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