Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.76% covered (success)
95.76%
158 / 165
61.54% covered (warning)
61.54%
8 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Serializer
95.76% covered (success)
95.76%
158 / 165
61.54% covered (warning)
61.54%
8 / 13
41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 content
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 data
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 blueprint
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
5
 fields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 fieldsets
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 read
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 pathMap
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 assetMap
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
7
 resolveTitle
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 orderedNames
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
9.08
 resolveType
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 resolveDeletable
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Cosray\Assets\Repository;
8use Cosray\Exception\RuntimeException;
9use Cosray\Field\Definitions;
10use Cosray\Field\Fieldsets;
11use Cosray\Locales;
12use Cosray\Richtext\Scanner;
13use Cosray\Uid;
14use ReflectionMethod;
15
16class Serializer
17{
18    public function __construct(
19        private readonly Types $types,
20        private readonly Uid $uid,
21        private readonly ?Repository $assets = null,
22        private readonly ?UrlPaths $paths = null,
23    ) {}
24
25    /**
26     * @param array<mixed> $rawData
27     * @param list<string> $fieldNames
28     * @return array<string, array<mixed>>
29     */
30    public function content(object $node, array $rawData, array $fieldNames): array
31    {
32        $content = [];
33
34        foreach ($fieldNames as $fieldName) {
35            $field = Factory::fieldFor($node, $fieldName);
36            $structure = $field->structure();
37            $content[$fieldName] = array_merge($structure, $rawData['content'][$fieldName] ?? []);
38            $content[$fieldName]['type'] = $structure['type'];
39        }
40
41        return $content;
42    }
43
44    /**
45     * @param array<mixed> $rawData
46     * @param list<string> $fieldNames
47     * @return array<string, mixed>
48     */
49    public function data(object $node, array $rawData, array $fieldNames): array
50    {
51        $class = $node::class;
52
53        return [
54            'uid' => $rawData['uid'],
55            'handle' => $rawData['handle'] ?? null,
56            'published' => $rawData['published'],
57            'hidden' => $rawData['hidden'],
58            'locked' => $rawData['locked'],
59            'created' => $rawData['created'],
60            'changed' => $rawData['changed'],
61            'deleted' => $rawData['deleted'],
62            'paths' => $rawData['paths'],
63            'parent' => $rawData['parent'] ?? null,
64            'type' => $this->resolveType($class, $rawData['type_handle'] ?? null),
65            'editor' => [
66                'uid' => $rawData['editor_uid'],
67                'email' => $rawData['editor_email'],
68                'username' => $rawData['editor_username'],
69                'data' => $rawData['editor_data'],
70            ],
71            'creator' => [
72                'uid' => $rawData['creator_uid'],
73                'email' => $rawData['creator_email'],
74                'username' => $rawData['creator_username'],
75                'data' => $rawData['creator_data'],
76            ],
77            'content' => $this->content($node, $rawData, $fieldNames),
78            'deletable' => $this->resolveDeletable($node),
79            'draft' => $rawData['draft'] ?? null,
80        ];
81    }
82
83    /**
84     * @param list<string> $fieldNames
85     * @param array<string, mixed> $values
86     * @return array<string, mixed>
87     */
88    public function blueprint(
89        object $node,
90        array $fieldNames,
91        Locales $locales,
92        array $values = [],
93    ): array {
94        $content = [];
95        $paths = [];
96
97        foreach ($fieldNames as $fieldName) {
98            $field = Factory::fieldFor($node, $fieldName);
99            $content[$fieldName] = array_key_exists($fieldName, $values)
100                ? $field->structure($values[$fieldName])
101                : $field->structure();
102        }
103
104        $class = $node::class;
105        $schema = $this->types->schemaOf($class);
106
107        foreach ($locales as $locale) {
108            $paths[$locale->id] = '';
109        }
110
111        $result = [
112            'title' => __('node:new-entry') . ' ' . __((string) $schema->label),
113            'fields' => $this->fields($node, $fieldNames),
114            'fieldsets' => $this->fieldsets($node, $fieldNames),
115            'uid' => $this->uid->generate(),
116            'handle' => null,
117            'published' => false,
118            'hidden' => false,
119            'locked' => false,
120            'deletable' => $this->resolveDeletable($node),
121            'content' => $content,
122            'type' => $this->resolveType($class),
123            'paths' => $paths,
124            'generatedPaths' => [],
125        ];
126
127        if ($schema->routable) {
128            $result['route'] = $schema->route;
129        }
130
131        return $result;
132    }
133
134    /**
135     * @param list<string> $fieldNames
136     * @return list<array<mixed>>
137     */
138    public function fields(object $node, array $fieldNames): array
139    {
140        $fields = [];
141        $ownerType = (string) $this->types->get($node::class, 'handle');
142
143        foreach ($this->orderedNames($node, $fieldNames) as $fieldName) {
144            $properties = Factory::fieldFor($node, $fieldName)->properties();
145            // The owning node type, so controls that query other nodes (the
146            // reference picker) can scope to this field's schema.
147            $properties['ownerType'] = $ownerType;
148            $fields[] = $properties;
149        }
150
151        return $fields;
152    }
153
154    /**
155     * @param list<string> $fieldNames
156     * @return list<array{name: string, label: ?string, description: ?string, width: int, fields: list<string>}>
157     */
158    public function fieldsets(object $node, array $fieldNames): array
159    {
160        return Fieldsets::serialize(
161            Factory::fieldsetsFor($node),
162            $this->orderedNames($node, $fieldNames),
163            Factory::fieldsFor($node),
164            $node::class,
165        );
166    }
167
168    /**
169     * @param array<mixed> $rawData
170     * @param list<string> $fieldNames
171     * @return array<string, mixed>
172     */
173    public function read(object $node, array $rawData, array $fieldNames): array
174    {
175        $data = $this->data($node, $rawData, $fieldNames);
176
177        return array_merge([
178            'title' => $this->resolveTitle($node),
179            'uid' => $rawData['uid'],
180            'fields' => $this->fields($node, $fieldNames),
181            'fieldsets' => $this->fieldsets($node, $fieldNames),
182            'assets' => $this->assetMap($rawData['content'] ?? null),
183            'nodePaths' => $this->pathMap($rawData['content'] ?? null),
184        ], $data);
185    }
186
187    /**
188     * Locale-to-path maps for every node uid the content references
189     * (richtext `link.node`); consumers resolve internal links through
190     * this map, mirroring the assets map.
191     *
192     * @return array<string, array<string, string>>
193     */
194    public function pathMap(mixed $content): array
195    {
196        if ($this->paths === null || !is_array($content)) {
197            return [];
198        }
199
200        $map = [];
201
202        foreach (Scanner::scanContent($content)['nodes'] as $uid) {
203            $paths = $this->paths->map($uid);
204
205            if ($paths !== []) {
206                $map[$uid] = $paths;
207            }
208        }
209
210        return $map;
211    }
212
213    /**
214     * Resolved catalog data for every asset uid the content references.
215     * Content items stay canonical `{uid, meta?}`; consumers (headless
216     * JSON readers, the panel editor) resolve uids through this map.
217     *
218     * @return array<string, array<string, mixed>>
219     */
220    public function assetMap(mixed $content): array
221    {
222        if ($this->assets === null || !is_array($content)) {
223            return [];
224        }
225
226        $uids = array_values(array_unique(array_merge(
227            Repository::collectUids($content),
228            Scanner::scanContent($content)['assets'],
229        )));
230        $this->assets->preload($uids);
231        $map = [];
232
233        foreach ($uids as $uid) {
234            $asset = $this->assets->get($uid);
235
236            if (!$asset) {
237                continue;
238            }
239
240            $map[$uid] = [
241                'filename' => $asset->filename,
242                'url' => $asset->path(),
243                'thumbUrl' => $asset->resizable() ? $asset->sizePath('thumb') : $asset->path(),
244                'previewUrl' => $asset->resizable() ? $asset->sizePath('preview') : $asset->path(),
245                'kind' => $asset->kind,
246                'mime' => $asset->mime,
247                'bytes' => $asset->bytes,
248                'width' => $asset->width,
249                'height' => $asset->height,
250                'meta' => $asset->meta,
251            ];
252        }
253
254        return $map;
255    }
256
257    public function resolveTitle(object $node): string
258    {
259        $proxy = $node instanceof Wrapper
260            ? $node
261            : new Wrapper(
262                Wrapper::unwrap($node),
263                Factory::fieldNamesFor($node),
264                $this->types,
265            );
266
267        return $proxy->title();
268    }
269
270    /**
271     * @param list<string> $fieldNames
272     * @return list<string>
273     */
274    private function orderedNames(object $node, array $fieldNames): array
275    {
276        $order = $this->types->get($node::class, 'fieldOrder');
277
278        if ($order === null && method_exists($node, 'order')) {
279            $order = $node->order();
280        }
281
282        if (!is_array($order)) {
283            return array_values($fieldNames);
284        }
285
286        $class = $node::class;
287        $ordered = [];
288
289        foreach ($order as $name) {
290            if (!is_string($name)) {
291                throw new RuntimeException("Field order for '{$class}' must contain field names only.");
292            }
293
294            if (!in_array($name, $fieldNames, true)) {
295                // A defined field outside this serialization subset is
296                // tolerated; a name unknown to the class is a typo.
297                if (Definitions::for($class)->field($name) === null) {
298                    throw new RuntimeException(
299                        "Field order for '{$class}' references unknown field '{$name}'.",
300                    );
301                }
302
303                continue;
304            }
305
306            if (in_array($name, $ordered, true)) {
307                throw new RuntimeException("Field order for '{$class}' repeats field '{$name}'.");
308            }
309
310            $ordered[] = $name;
311        }
312
313        return [...$ordered, ...array_values(array_diff($fieldNames, $ordered))];
314    }
315
316    /**
317     * @param class-string $class
318     * @return array<string, mixed>
319     */
320    private function resolveType(string $class, ?string $handle = null): array
321    {
322        $schema = $this->types->schemaOf($class);
323
324        $type = array_merge([
325            'handle' => $handle ?? $schema->handle,
326            'routable' => $schema->routable,
327            'renderable' => $schema->renderable,
328            'class' => $class,
329        ], $schema->properties());
330
331        // The schema is cached per class, so its display strings are raw ids;
332        // translate them for the active locale as they are serialized.
333        foreach (['label', 'badge', 'description'] as $key) {
334            $value = $type[$key] ?? null;
335
336            if (is_string($value)) {
337                $type[$key] = __($value);
338            }
339        }
340
341        return $type;
342    }
343
344    private function resolveDeletable(object $node): bool
345    {
346        if (method_exists($node, 'deletable')) {
347            $method = new ReflectionMethod($node, 'deletable');
348
349            return $method->invoke($node);
350        }
351
352        return (bool) $this->types->get($node::class, 'deletable', true);
353    }
354}