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<mixed> $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    /**
41     * @param array<string, mixed> $content
42     * @return array<string, array<mixed>>
43     */
44    public function normalize(array $content): array
45    {
46        $result = [];
47
48        foreach ($content as $name => $field) {
49            if (!is_array($field)) {
50                continue;
51            }
52
53            $result[$name] = $this->field($field);
54        }
55
56        return $result;
57    }
58
59    /**
60     * @param array<string, mixed> $data
61     * @return array<mixed>
62     */
63    private function field(array $data): array
64    {
65        $type = $this->fieldType($data['type'] ?? null);
66
67        if ($type === Field\Blocks::class) {
68            return $this->blocksField($data, $type);
69        }
70
71        if ($type === Field\Entries::class) {
72            return $this->entriesField($data, $type);
73        }
74
75        if (isset(self::MEDIA_TYPES[$type])) {
76            return $this->mediaField($data, $type, ($data['type'] ?? null) === 'picture');
77        }
78
79        $result = ['type' => $type];
80
81        // Structured richtext carries the format envelope next to its
82        // value; the keys pass through untouched (see docs/richtext-format.md).
83        foreach (['format', 'version'] as $key) {
84            if (array_key_exists($key, $data)) {
85                $result[$key] = $data[$key];
86            }
87        }
88
89        $result['value'] = $this->valueMap($data['value'] ?? null);
90        $meta = $this->fieldMeta($data, ['type', 'format', 'version', 'value']);
91
92        if ($meta !== []) {
93            $result['meta'] = $meta;
94        }
95
96        return $result;
97    }
98
99    /**
100     * @param array<string, mixed> $data
101     * @return array<mixed>
102     */
103    private function blocksField(array $data, string $type): array
104    {
105        $value = $data['value'] ?? $data['items'] ?? [];
106        $result = [
107            'type' => $type,
108            'value' => $this->blockValueMap($value),
109        ];
110        $meta = $this->fieldMeta($data, ['type', 'value', 'items']);
111
112        if ($meta !== []) {
113            $result['meta'] = $meta;
114        }
115
116        return $result;
117    }
118
119    /**
120     * @param array<string, mixed> $data
121     * @return array<mixed>
122     */
123    private function entriesField(array $data, string $type): array
124    {
125        $value = $data['value'] ?? [];
126        $result = [
127            'type' => $type,
128            'value' => $this->entryValueMap($value),
129        ];
130        $meta = $this->fieldMeta($data, ['type', 'value']);
131
132        if ($meta !== []) {
133            $result['meta'] = $meta;
134        }
135
136        return $result;
137    }
138
139    /**
140     * @param array<string, mixed> $data
141     * @return array<mixed>
142     */
143    private function mediaField(array $data, string $type, bool $picture): array
144    {
145        $value = $data['value'] ?? $data['files'] ?? [];
146        $result = [
147            'type' => $type,
148            'value' => $this->mediaValueMap($value, $picture),
149        ];
150        $meta = $this->fieldMeta($data, ['type', 'value', 'files']);
151
152        if ($meta !== []) {
153            $result['meta'] = $meta;
154        }
155
156        return $result;
157    }
158
159    private function fieldType(mixed $type): string
160    {
161        if (is_string($type)) {
162            return $this->index->resolve($type) ?? Field\Text::class;
163        }
164
165        return Field\Text::class;
166    }
167
168    /** @return array<mixed> */
169    private function valueMap(mixed $value): array
170    {
171        if (is_array($value) && $this->isLocaleMap($value)) {
172            return $value;
173        }
174
175        return [self::ZXX => $value];
176    }
177
178    /** @return array<mixed> */
179    private function blockValueMap(mixed $value): array
180    {
181        if (is_array($value) && $this->isLocaleMap($value)) {
182            $result = [];
183
184            foreach ($value as $locale => $items) {
185                $result[$locale] = $this->blockList($items);
186            }
187
188            return $result;
189        }
190
191        return [self::ZXX => $this->blockList($value)];
192    }
193
194    /** @return list<array<mixed>> */
195    private function blockList(mixed $items): array
196    {
197        if (!is_array($items)) {
198            return [];
199        }
200
201        $result = [];
202
203        foreach ($items as $item) {
204            if (!is_array($item)) {
205                continue;
206            }
207
208            $result[] = $this->block($item);
209        }
210
211        return $result;
212    }
213
214    /**
215     * @param array<string, mixed> $data
216     * @return array<mixed>
217     */
218    private function block(array $data): array
219    {
220        $type = is_string($data['type'] ?? null) ? $data['type'] : 'text';
221        $result = ['type' => $type];
222
223        foreach (['uid', 'width', 'colspan', 'rowspan', 'colstart', 'format', 'version'] as $key) {
224            if (!array_key_exists($key, $data)) {
225                continue;
226            }
227
228            $result[$key] = $data[$key];
229        }
230
231        if (in_array($type, ['image', 'images', 'video'], true)) {
232            $value = $data['value'] ?? $data['files'] ?? [];
233            $list = $this->mediaList($value);
234            $result['value'] = $type === 'image' ? array_slice($list, 0, 1) : $list;
235        } elseif ($type === 'youtube') {
236            $result['value'] = $this->valueMap($data['value'] ?? $data['id'] ?? null);
237        } else {
238            $result['value'] = $this->valueMap($data['value'] ?? null);
239        }
240
241        $meta = $this->fieldMeta($data, [
242            'type',
243            'uid',
244            'width',
245            'colspan',
246            'rowspan',
247            'colstart',
248            'format',
249            'version',
250            'value',
251            'files',
252        ]);
253
254        if ($meta !== []) {
255            $result['meta'] = $meta;
256        }
257
258        return $result;
259    }
260
261    /** @return array<mixed> */
262    private function mediaValueMap(mixed $value, bool $picture): array
263    {
264        if (is_array($value) && $this->isLocaleMap($value)) {
265            $result = [];
266
267            foreach ($value as $locale => $items) {
268                $list = $this->mediaList($items);
269                $result[$locale] = $picture ? $this->selectPicture($list) : $list;
270            }
271
272            return $result;
273        }
274
275        $list = $this->mediaList($value);
276
277        return [self::ZXX => $picture ? $this->selectPicture($list) : $list];
278    }
279
280    /** @return list<array<mixed>> */
281    private function mediaList(mixed $items): array
282    {
283        if (!is_array($items)) {
284            return [];
285        }
286
287        $result = [];
288
289        foreach ($items as $item) {
290            if (!is_array($item)) {
291                continue;
292            }
293
294            $converted = $this->mediaItem($item);
295
296            if ($converted !== null) {
297                $result[] = $converted;
298            }
299        }
300
301        return $result;
302    }
303
304    /**
305     * @param array<string, mixed> $item
306     * @return ?array<mixed>
307     */
308    private function mediaItem(array $item): ?array
309    {
310        if ($this->mediaItem !== null) {
311            return ($this->mediaItem)($item);
312        }
313
314        $result = ['file' => $item['file'] ?? ''];
315        $meta = $this->fieldMeta($item, ['file']);
316
317        if ($meta !== []) {
318            $result['meta'] = $meta;
319        }
320
321        return $result;
322    }
323
324    /**
325     * @param list<array<mixed>> $items
326     * @return list<array<mixed>>
327     */
328    private function selectPicture(array $items): array
329    {
330        if ($items === []) {
331            return [];
332        }
333
334        foreach ($items as $item) {
335            $file = is_string($item['file'] ?? null) ? $item['file'] : '';
336
337            if (strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'webp') {
338                return [$item];
339            }
340        }
341
342        return [$items[0]];
343    }
344
345    /** @return array<mixed> */
346    private function entryValueMap(mixed $value): array
347    {
348        if (is_array($value) && $this->isLocaleMap($value)) {
349            $result = [];
350
351            foreach ($value as $locale => $entries) {
352                $result[$locale] = $this->entryList($entries);
353            }
354
355            return $result;
356        }
357
358        return [self::ZXX => $this->entryList($value)];
359    }
360
361    /** @return list<array<mixed>> */
362    private function entryList(mixed $entries): array
363    {
364        if (!is_array($entries)) {
365            return [];
366        }
367
368        $result = [];
369
370        foreach ($entries as $entry) {
371            if (!is_array($entry)) {
372                continue;
373            }
374
375            $result[] = $this->entry($entry);
376        }
377
378        return $result;
379    }
380
381    /**
382     * @param array<string, mixed> $entry
383     * @return array<mixed>
384     */
385    private function entry(array $entry): array
386    {
387        $fields = $entry['fields'] ?? $entry['value'] ?? [];
388
389        return [
390            'uid' => is_string($entry['uid'] ?? null) && $entry['uid'] !== ''
391                ? $entry['uid']
392                : $this->uid->generate(),
393            'type' => is_string($entry['type'] ?? null) ? $entry['type'] : '',
394            'fields' => is_array($fields) ? $this->normalize($fields) : [],
395        ];
396    }
397
398    /**
399     * @param list<string> $skip
400     * @param array<mixed> $data
401     * @return array<mixed>
402     */
403    private function fieldMeta(array $data, array $skip): array
404    {
405        $meta = [];
406
407        if (is_array($data['meta'] ?? null)) {
408            $meta = $this->normalizeMeta($data['meta']);
409        }
410
411        foreach ($data as $key => $value) {
412            if (in_array($key, $skip, true) || $key === 'meta') {
413                continue;
414            }
415
416            $meta[$key] = $this->normalizeMetaLeaf($value);
417        }
418
419        return $meta;
420    }
421
422    /**
423     * @param array<mixed> $meta
424     * @return array<mixed>
425     */
426    private function normalizeMeta(array $meta): array
427    {
428        $result = [];
429
430        foreach ($meta as $key => $value) {
431            $result[$key] = $this->normalizeMetaLeaf($value);
432        }
433
434        return $result;
435    }
436
437    /** @return array<mixed> */
438    private function normalizeMetaLeaf(mixed $value): array
439    {
440        if (is_array($value) && $this->isLocaleMap($value)) {
441            return $value;
442        }
443
444        return [self::ZXX => $value];
445    }
446
447    /** @param array<mixed> $value */
448    private function isLocaleMap(array $value): bool
449    {
450        if ($value === [] || array_is_list($value)) {
451            return false;
452        }
453
454        foreach (array_keys($value) as $key) {
455            if (!is_string($key) || !$this->isLocaleKey($key)) {
456                return false;
457            }
458        }
459
460        return true;
461    }
462
463    private function isLocaleKey(string $key): bool
464    {
465        return $key === self::ZXX || preg_match('/^[a-z]{2}(?:[-_][A-Za-z0-9]{2,8})?$/', $key) === 1;
466    }
467}