Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.38% covered (success)
91.38%
159 / 174
60.87% covered (warning)
60.87%
14 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
NodeContentNormalizer
91.38% covered (success)
91.38%
159 / 174
60.87% covered (warning)
60.87%
14 / 23
92.96
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
 normalize
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 field
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 blocksField
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 entriesField
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 mediaField
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 fieldType
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 valueMap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 blockValueMap
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 blockList
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 block
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
8.08
 mediaValueMap
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 mediaList
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 mediaItem
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 selectPicture
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 entryValueMap
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 entryList
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 entry
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 fieldMeta
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 normalizeMeta
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 normalizeMetaLeaf
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isLocaleMap
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
6.17
 isLocaleKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Migration;
6
7use Closure;
8use Cosray\Field;
9use Cosray\Uid;
10
11final class NodeContentNormalizer
12{
13    private const string ZXX = Field\Field::NEUTRAL_LOCALE;
14
15    /** @var array<class-string<Field\Field>, true> */
16    private const array MEDIA_TYPES = [
17        Field\File::class => true,
18        Field\Image::class => true,
19        Field\Video::class => true,
20    ];
21
22    private readonly Field\Index $index;
23
24    /**
25     * The optional $mediaItem callback takes over media item conversion
26     * (asset-catalog migration: `{file}` → `{uid}`). Without it the
27     * output stays byte-identical to migration 017's — downstreams that
28     * run 017 before the assets migrations depend on that.
29     *
30     * @param null|Closure(array<string, mixed>): ?array $mediaItem
31     */
32    public function __construct(
33        private readonly Uid $uid,
34        ?Field\Index $index = null,
35        private readonly ?Closure $mediaItem = null,
36    ) {
37        $this->index = $index ?? Field\Index::withDefaults();
38    }
39
40    /** @param array<string, mixed> $content */
41    public function normalize(array $content): array
42    {
43        $result = [];
44
45        foreach ($content as $name => $field) {
46            if (!is_array($field)) {
47                continue;
48            }
49
50            $result[$name] = $this->field($field);
51        }
52
53        return $result;
54    }
55
56    /** @param array<string, mixed> $data */
57    private function field(array $data): array
58    {
59        $type = $this->fieldType($data['type'] ?? null);
60
61        if ($type === Field\Blocks::class) {
62            return $this->blocksField($data, $type);
63        }
64
65        if ($type === Field\Entries::class) {
66            return $this->entriesField($data, $type);
67        }
68
69        if (isset(self::MEDIA_TYPES[$type])) {
70            return $this->mediaField($data, $type, ($data['type'] ?? null) === 'picture');
71        }
72
73        $result = ['type' => $type];
74
75        // Structured richtext carries the format envelope next to its
76        // value; the keys pass through untouched (see docs/richtext-format.md).
77        foreach (['format', 'version'] as $key) {
78            if (array_key_exists($key, $data)) {
79                $result[$key] = $data[$key];
80            }
81        }
82
83        $result['value'] = $this->valueMap($data['value'] ?? null);
84        $meta = $this->fieldMeta($data, ['type', 'format', 'version', 'value']);
85
86        if ($meta !== []) {
87            $result['meta'] = $meta;
88        }
89
90        return $result;
91    }
92
93    /** @param array<string, mixed> $data */
94    private function blocksField(array $data, string $type): array
95    {
96        $value = $data['value'] ?? $data['items'] ?? [];
97        $result = [
98            'type' => $type,
99            'value' => $this->blockValueMap($value),
100        ];
101        $meta = $this->fieldMeta($data, ['type', 'value', 'items']);
102
103        if ($meta !== []) {
104            $result['meta'] = $meta;
105        }
106
107        return $result;
108    }
109
110    /** @param array<string, mixed> $data */
111    private function entriesField(array $data, string $type): array
112    {
113        $value = $data['value'] ?? [];
114        $result = [
115            'type' => $type,
116            'value' => $this->entryValueMap($value),
117        ];
118        $meta = $this->fieldMeta($data, ['type', 'value']);
119
120        if ($meta !== []) {
121            $result['meta'] = $meta;
122        }
123
124        return $result;
125    }
126
127    /** @param array<string, mixed> $data */
128    private function mediaField(array $data, string $type, bool $picture): array
129    {
130        $value = $data['value'] ?? $data['files'] ?? [];
131        $result = [
132            'type' => $type,
133            'value' => $this->mediaValueMap($value, $picture),
134        ];
135        $meta = $this->fieldMeta($data, ['type', 'value', 'files']);
136
137        if ($meta !== []) {
138            $result['meta'] = $meta;
139        }
140
141        return $result;
142    }
143
144    private function fieldType(mixed $type): string
145    {
146        if (is_string($type)) {
147            return $this->index->resolve($type) ?? Field\Text::class;
148        }
149
150        return Field\Text::class;
151    }
152
153    private function valueMap(mixed $value): array
154    {
155        if (is_array($value) && $this->isLocaleMap($value)) {
156            return $value;
157        }
158
159        return [self::ZXX => $value];
160    }
161
162    private function blockValueMap(mixed $value): array
163    {
164        if (is_array($value) && $this->isLocaleMap($value)) {
165            $result = [];
166
167            foreach ($value as $locale => $items) {
168                $result[$locale] = $this->blockList($items);
169            }
170
171            return $result;
172        }
173
174        return [self::ZXX => $this->blockList($value)];
175    }
176
177    private function blockList(mixed $items): array
178    {
179        if (!is_array($items)) {
180            return [];
181        }
182
183        $result = [];
184
185        foreach ($items as $item) {
186            if (!is_array($item)) {
187                continue;
188            }
189
190            $result[] = $this->block($item);
191        }
192
193        return $result;
194    }
195
196    /** @param array<string, mixed> $data */
197    private function block(array $data): array
198    {
199        $type = is_string($data['type'] ?? null) ? $data['type'] : 'text';
200        $result = ['type' => $type];
201
202        foreach (['uid', 'width', 'colspan', 'rowspan', 'colstart', 'format', 'version'] as $key) {
203            if (!array_key_exists($key, $data)) {
204                continue;
205            }
206
207            $result[$key] = $data[$key];
208        }
209
210        if (in_array($type, ['image', 'images', 'video'], true)) {
211            $value = $data['value'] ?? $data['files'] ?? [];
212            $list = $this->mediaList($value);
213            $result['value'] = $type === 'image' ? array_slice($list, 0, 1) : $list;
214        } elseif ($type === 'youtube') {
215            $result['value'] = $this->valueMap($data['value'] ?? $data['id'] ?? null);
216        } else {
217            $result['value'] = $this->valueMap($data['value'] ?? null);
218        }
219
220        $meta = $this->fieldMeta($data, [
221            'type',
222            'uid',
223            'width',
224            'colspan',
225            'rowspan',
226            'colstart',
227            'format',
228            'version',
229            'value',
230            'files',
231        ]);
232
233        if ($meta !== []) {
234            $result['meta'] = $meta;
235        }
236
237        return $result;
238    }
239
240    private function mediaValueMap(mixed $value, bool $picture): array
241    {
242        if (is_array($value) && $this->isLocaleMap($value)) {
243            $result = [];
244
245            foreach ($value as $locale => $items) {
246                $list = $this->mediaList($items);
247                $result[$locale] = $picture ? $this->selectPicture($list) : $list;
248            }
249
250            return $result;
251        }
252
253        $list = $this->mediaList($value);
254
255        return [self::ZXX => $picture ? $this->selectPicture($list) : $list];
256    }
257
258    private function mediaList(mixed $items): array
259    {
260        if (!is_array($items)) {
261            return [];
262        }
263
264        $result = [];
265
266        foreach ($items as $item) {
267            if (!is_array($item)) {
268                continue;
269            }
270
271            $converted = $this->mediaItem($item);
272
273            if ($converted !== null) {
274                $result[] = $converted;
275            }
276        }
277
278        return $result;
279    }
280
281    /** @param array<string, mixed> $item */
282    private function mediaItem(array $item): ?array
283    {
284        if ($this->mediaItem !== null) {
285            return ($this->mediaItem)($item);
286        }
287
288        $result = ['file' => $item['file'] ?? ''];
289        $meta = $this->fieldMeta($item, ['file']);
290
291        if ($meta !== []) {
292            $result['meta'] = $meta;
293        }
294
295        return $result;
296    }
297
298    private function selectPicture(array $items): array
299    {
300        if ($items === []) {
301            return [];
302        }
303
304        foreach ($items as $item) {
305            $file = is_string($item['file'] ?? null) ? $item['file'] : '';
306
307            if (strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'webp') {
308                return [$item];
309            }
310        }
311
312        return [$items[0]];
313    }
314
315    private function entryValueMap(mixed $value): array
316    {
317        if (is_array($value) && $this->isLocaleMap($value)) {
318            $result = [];
319
320            foreach ($value as $locale => $entries) {
321                $result[$locale] = $this->entryList($entries);
322            }
323
324            return $result;
325        }
326
327        return [self::ZXX => $this->entryList($value)];
328    }
329
330    private function entryList(mixed $entries): array
331    {
332        if (!is_array($entries)) {
333            return [];
334        }
335
336        $result = [];
337
338        foreach ($entries as $entry) {
339            if (!is_array($entry)) {
340                continue;
341            }
342
343            $result[] = $this->entry($entry);
344        }
345
346        return $result;
347    }
348
349    /** @param array<string, mixed> $entry */
350    private function entry(array $entry): array
351    {
352        $fields = $entry['fields'] ?? $entry['value'] ?? [];
353
354        return [
355            'uid' => is_string($entry['uid'] ?? null) && $entry['uid'] !== ''
356                ? $entry['uid']
357                : $this->uid->generate(),
358            'type' => is_string($entry['type'] ?? null) ? $entry['type'] : '',
359            'fields' => is_array($fields) ? $this->normalize($fields) : [],
360        ];
361    }
362
363    /** @param list<string> $skip */
364    private function fieldMeta(array $data, array $skip): array
365    {
366        $meta = [];
367
368        if (is_array($data['meta'] ?? null)) {
369            $meta = $this->normalizeMeta($data['meta']);
370        }
371
372        foreach ($data as $key => $value) {
373            if (in_array($key, $skip, true) || $key === 'meta') {
374                continue;
375            }
376
377            $meta[$key] = $this->normalizeMetaLeaf($value);
378        }
379
380        return $meta;
381    }
382
383    private function normalizeMeta(array $meta): array
384    {
385        $result = [];
386
387        foreach ($meta as $key => $value) {
388            $result[$key] = $this->normalizeMetaLeaf($value);
389        }
390
391        return $result;
392    }
393
394    private function normalizeMetaLeaf(mixed $value): array
395    {
396        if (is_array($value) && $this->isLocaleMap($value)) {
397            return $value;
398        }
399
400        return [self::ZXX => $value];
401    }
402
403    private function isLocaleMap(array $value): bool
404    {
405        if ($value === [] || array_is_list($value)) {
406            return false;
407        }
408
409        foreach (array_keys($value) as $key) {
410            if (!is_string($key) || !$this->isLocaleKey($key)) {
411                return false;
412            }
413        }
414
415        return true;
416    }
417
418    private function isLocaleKey(string $key): bool
419    {
420        return $key === self::ZXX || preg_match('/^[a-z]{2}(?:[-_][A-Za-z0-9]{2,8})?$/', $key) === 1;
421    }
422}