Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.57% covered (warning)
84.57%
263 / 311
52.94% covered (warning)
52.94%
18 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
RoutePathGenerator
84.57% covered (warning)
84.57%
263 / 311
52.94% covered (warning)
52.94%
18 / 34
239.06
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
 generate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 preview
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 referencedFields
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
15
 generateFromRoute
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 template
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 expand
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
8.10
 resolve
75.76% covered (warning)
75.76%
25 / 33
0.00% covered (danger)
0.00%
0 / 1
12.72
 parsePlaceholder
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 parentSelector
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
12
 friendlyPlaceholder
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 friendlyParentPlaceholder
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
 friendlyLabel
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 ancestor
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
8.74
 parent
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 hasParent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 parentByNode
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 parentByUid
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 parentRow
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 nodeId
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 parentPaths
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 resolveParent
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 parentPath
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 pathValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 normalizePath
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 field
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 requiredSlug
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 slugValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 transformCase
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 separator
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 toAscii
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 transliterator
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 slugify
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 decodeContent
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
10.50
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Celema\Quma\Database;
8use Cosray\Exception\RoutePathError;
9use Cosray\Field\Field;
10use Cosray\Locale;
11use Cosray\Locales;
12use JsonException;
13use Locale as IcuLocale;
14use Normalizer;
15use Transliterator;
16
17final class RoutePathGenerator
18{
19    private const MAX_PARENT_DEPTH = 5;
20
21    private const LATIN_FOLD = 'Any-Latin; Latin-ASCII';
22
23    /** @var array<string, ?Transliterator> Keyed by language subtag. */
24    private array $transliterators = [];
25
26    public function __construct(
27        private readonly Database $db,
28        private readonly Types $types,
29    ) {}
30
31    /**
32     * @param class-string $nodeClass
33     * @param array<string, mixed> $data
34     * @return array<string, string>
35     */
36    public function generate(
37        string $nodeClass,
38        array $data,
39        Locales $locales,
40        ?int $parentId = null,
41    ): array {
42        return $this->generateFromRoute(
43            $this->types->get($nodeClass, 'route'),
44            $data,
45            $locales,
46            $parentId,
47        );
48    }
49
50    /**
51     * @param class-string $nodeClass
52     * @param array<string, mixed> $data
53     * @return array<string, string>
54     */
55    public function preview(
56        string $nodeClass,
57        array $data,
58        Locales $locales,
59        ?int $parentId = null,
60    ): array {
61        return $this->generateFromRoute(
62            $this->types->get($nodeClass, 'route'),
63            $data,
64            $locales,
65            $parentId,
66            strict: false,
67        );
68    }
69
70    /**
71     * The node's own content-field names a route template references, so the
72     * editor can live-preview generated paths when exactly those inputs
73     * change. Excludes uid, handle and the parent family â€” none of which are
74     * editable content fields of this node.
75     *
76     * @return list<string>
77     */
78    public function referencedFields(mixed $route): array
79    {
80        $templates = match (true) {
81            is_array($route) => array_values($route),
82            is_string($route) => [$route],
83            default => [],
84        };
85        $fields = [];
86
87        foreach ($templates as $template) {
88            if (!is_string($template) || !preg_match_all('/\{([^{}]+)\}/', $template, $matches)) {
89                continue;
90            }
91
92            foreach ($matches[1] as $inner) {
93                $selector = trim(explode('|', $inner)[0] ?? '');
94
95                if (
96                    $selector === ''
97                    || $selector === 'uid'
98                    || $selector === 'handle'
99                    || $selector === 'parent'
100                    || str_starts_with($selector, 'parent.')
101                    || str_starts_with($selector, 'parent(')
102                    || str_starts_with($selector, 'parent?')
103                ) {
104                    continue;
105                }
106
107                $fields[$selector] = true;
108            }
109        }
110
111        return array_keys($fields);
112    }
113
114    /**
115     * @param array<string, string>|string|mixed $route
116     * @param array<string, mixed> $data
117     * @return array<string, string>
118     */
119    public function generateFromRoute(
120        mixed $route,
121        array $data,
122        Locales $locales,
123        ?int $parentId = null,
124        bool $strict = true,
125    ): array {
126        if (!is_string($route) && !is_array($route)) {
127            return [];
128        }
129
130        $parents = [];
131        $paths = [];
132
133        foreach ($locales as $locale) {
134            $template = $this->template($route, $locale);
135
136            if ($template === '') {
137                continue;
138            }
139
140            $paths[$locale->id] = $this->expand($template, $data, $locale, $parents, $parentId, $strict);
141        }
142
143        return $paths;
144    }
145
146    /**
147     * @param array<string, string>|string $route
148     */
149    private function template(array|string $route, Locale $locale): string
150    {
151        if (is_string($route)) {
152            return $route;
153        }
154
155        return $route[$locale->id] ?? '';
156    }
157
158    /**
159     * @param array<string, mixed> $data
160     * @param array<int, array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}> $parents
161     */
162    private function expand(
163        string $template,
164        array $data,
165        Locale $locale,
166        array &$parents,
167        ?int $parentId,
168        bool $strict,
169    ): string {
170        $usesParentPath = false;
171        $path = preg_replace_callback(
172            '/\{([^{}]+)\}/',
173            function (array $matches) use ($data, $locale, &$parents, $parentId, $strict, &$usesParentPath): string {
174                try {
175                    return $this->resolve($matches[1], $data, $locale, $parents, $parentId, $usesParentPath);
176                } catch (RoutePathError $e) {
177                    if ($strict) {
178                        throw $e;
179                    }
180
181                    return $this->friendlyPlaceholder($matches[1]);
182                }
183            },
184            $template,
185        );
186
187        if (!is_string($path)) {
188            throw new RoutePathError('Could not generate route path');
189        }
190
191        if ($strict && (str_contains($path, '{') || str_contains($path, '}'))) {
192            throw new RoutePathError('Invalid route path placeholder syntax');
193        }
194
195        return $usesParentPath ? $this->normalizePath($path) : $path;
196    }
197
198    /**
199     * @param array<string, mixed> $data
200     * @param array<int, array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}> $parents
201     */
202    private function resolve(
203        string $placeholder,
204        array $data,
205        Locale $locale,
206        array &$parents,
207        ?int $parentId,
208        bool &$usesParentPath,
209    ): string {
210        [$selector, $transformers] = $this->parsePlaceholder($placeholder);
211
212        if ($selector === 'uid') {
213            return $this->requiredSlug($data['uid'] ?? null, $placeholder, $transformers);
214        }
215
216        if ($selector === 'handle') {
217            return $this->requiredSlug($data['handle'] ?? null, $placeholder, $transformers);
218        }
219
220        $parentSelector = $this->parentSelector($selector);
221
222        if ($parentSelector !== null) {
223            if ($parentSelector['field'] === null && $transformers !== []) {
224                throw new RoutePathError(
225                    'Route path transformers are not supported for parent path placeholders',
226                );
227            }
228
229            if ($parentSelector['field'] === null) {
230                $usesParentPath = true;
231
232                if ($parentSelector['optional'] && !$this->hasParent($data, $parentId)) {
233                    return '';
234                }
235            }
236
237            $parent = $this->ancestor($data, $parentId, $parents, $parentSelector['depth']);
238
239            if ($parentSelector['field'] === null) {
240                return trim($this->parentPath($parent, $locale, $placeholder), '/');
241            }
242
243            return $this->resolveParent(
244                $parentSelector['field'],
245                $parent,
246                $locale,
247                $placeholder,
248                $transformers,
249            );
250        }
251
252        $content = $data['content'] ?? [];
253
254        return $this->field(
255            is_array($content) ? $content : [],
256            $selector,
257            $locale,
258            $placeholder,
259            $transformers,
260        );
261    }
262
263    /** @return array{0: string, 1: list<string>} */
264    private function parsePlaceholder(string $placeholder): array
265    {
266        $parts = array_map(trim(...), explode('|', $placeholder));
267        $selector = array_shift($parts) ?? '';
268
269        if ($selector === '') {
270            throw new RoutePathError('Invalid route path placeholder syntax');
271        }
272
273        foreach ($parts as $transformer) {
274            if (!in_array(
275                $transformer,
276                ['lowercase', 'uppercase', 'titlecase', 'keepcase', 'dashes', 'underscore'],
277                true,
278            )) {
279                throw new RoutePathError(sprintf('Unknown route path transformer: %s', $transformer));
280            }
281        }
282
283        return [$selector, $parts];
284    }
285
286    /** @return ?array{depth: int, field: ?string, optional: bool} */
287    private function parentSelector(string $selector): ?array
288    {
289        if (
290            $selector !== 'parent'
291            && !str_starts_with($selector, 'parent.')
292            && !str_starts_with($selector, 'parent(')
293            && !str_starts_with($selector, 'parent?')
294        ) {
295            return null;
296        }
297
298        if ($selector === 'parent?') {
299            return [
300                'depth' => 1,
301                'field' => null,
302                'optional' => true,
303            ];
304        }
305
306        if (!preg_match('/^parent(?:\(([1-9]\d*)\))?(?:\.(.+))?$/', $selector, $matches)) {
307            throw new RoutePathError('Invalid route path parent syntax');
308        }
309
310        $depth = isset($matches[1]) && $matches[1] !== '' ? (int) $matches[1] : 1;
311
312        if ($depth > self::MAX_PARENT_DEPTH) {
313            throw new RoutePathError(sprintf(
314                'Route path parent depth cannot exceed %d',
315                self::MAX_PARENT_DEPTH,
316            ));
317        }
318
319        $field = $matches[2] ?? null;
320
321        return [
322            'depth' => $depth,
323            'field' => is_string($field) && $field !== '' ? $field : null,
324            'optional' => false,
325        ];
326    }
327
328    private function friendlyPlaceholder(string $placeholder): string
329    {
330        $selector = trim(explode('|', $placeholder)[0] ?? '');
331        $parent = $this->friendlyParentPlaceholder($selector);
332
333        if ($parent !== null) {
334            return $parent;
335        }
336
337        return '[' . $this->friendlyLabel($selector) . ']';
338    }
339
340    private function friendlyParentPlaceholder(string $selector): ?string
341    {
342        if ($selector === 'parent?') {
343            return '[parent path]';
344        }
345
346        if (!preg_match('/^parent(?:\((\d+)\))?(?:\.(.+))?$/', $selector, $matches)) {
347            return null;
348        }
349
350        $depth = isset($matches[1]) && $matches[1] !== '' ? (int) $matches[1] : 1;
351        $prefix = $depth > 1 ? 'ancestor' : 'parent';
352        $field = $matches[2] ?? null;
353
354        if (!is_string($field) || $field === '') {
355            return "[{$prefix} path]";
356        }
357
358        return "[{$prefix} {$this->friendlyLabel($field)}]";
359    }
360
361    private function friendlyLabel(string $selector): string
362    {
363        $label = str_replace(['.', '_', '-', '(', ')'], ' ', $selector);
364        $label = preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1 $2', $label) ?? $label;
365        $label = preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $label) ?? $label;
366        $label = preg_replace('/\s+/', ' ', $label) ?? $label;
367        $label = strtolower(trim($label));
368
369        return $label === '' ? 'value' : $label;
370    }
371
372    /**
373     * @param array<string, mixed> $data
374     * @param array<int, array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}> $parents
375     * @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}
376     */
377    private function ancestor(array $data, ?int $parentId, array &$parents, int $depth): array
378    {
379        $parents[1] ??= $this->parent($data, $parentId);
380
381        for ($level = 2; $level <= $depth; $level++) {
382            if (isset($parents[$level])) {
383                continue;
384            }
385
386            $node = $parents[$level - 1]['parent'];
387
388            if ($node === null) {
389                throw new RoutePathError('Ancestor node not found for route path');
390            }
391
392            $parents[$level] = $this->parentByNode($node);
393        }
394
395        return $parents[$depth];
396    }
397
398    /**
399     * @param array<string, mixed> $data
400     * @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}
401     */
402    private function parent(array $data, ?int $parentId): array
403    {
404        if ($parentId !== null) {
405            return $this->parentByNode($parentId);
406        }
407
408        $parentUid = $data['parent'] ?? null;
409
410        if (!is_string($parentUid) || trim($parentUid) === '') {
411            throw new RoutePathError('A parent is required for this node route');
412        }
413
414        return $this->parentByUid(trim($parentUid));
415    }
416
417    /** @param array<string, mixed> $data */
418    private function hasParent(array $data, ?int $parentId): bool
419    {
420        if ($parentId !== null) {
421            return true;
422        }
423
424        $parentUid = $data['parent'] ?? null;
425
426        return is_string($parentUid) && trim($parentUid) !== '';
427    }
428
429    /** @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>} */
430    private function parentByNode(int $node): array
431    {
432        $parent = $this->db->nodes->routeParentByNode(['node' => $node])->first();
433
434        if (!$parent) {
435            throw new RoutePathError('Parent node not found for route path');
436        }
437
438        return $this->parentRow($parent);
439    }
440
441    /** @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>} */
442    private function parentByUid(string $uid): array
443    {
444        $parent = $this->db->nodes->routeParentByUid(['uid' => $uid])->first();
445
446        if (!$parent) {
447            throw new RoutePathError('Parent node not found for route path');
448        }
449
450        return $this->parentRow($parent);
451    }
452
453    /**
454     * @param array<string, mixed> $parent
455     * @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}
456     */
457    private function parentRow(array $parent): array
458    {
459        $content = $this->decodeContent($parent['content'] ?? '{}');
460        $handle = $parent['handle'] ?? null;
461        $node = (int) $parent['node'];
462
463        return [
464            'node' => $node,
465            'parent' => $this->nodeId($parent['parent'] ?? null),
466            'uid' => (string) $parent['uid'],
467            'handle' => is_string($handle) && $handle !== '' ? $handle : null,
468            'content' => $content,
469            'paths' => $this->parentPaths($node),
470        ];
471    }
472
473    private function nodeId(mixed $node): ?int
474    {
475        if (is_int($node)) {
476            return $node;
477        }
478
479        if (is_string($node) && ctype_digit($node)) {
480            return (int) $node;
481        }
482
483        return null;
484    }
485
486    /**
487     * @return array<string, string>
488     */
489    private function parentPaths(int $node): array
490    {
491        $paths = [];
492
493        foreach ($this->db->paths->activeByNode(['node' => $node])->all() as $path) {
494            $locale = $path['locale'] ?? null;
495            $value = $path['path'] ?? null;
496
497            if (is_string($locale) && is_string($value) && trim($value) !== '') {
498                $paths[$locale] = $value;
499            }
500        }
501
502        return $paths;
503    }
504
505    /**
506     * @param array{uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>} $parent
507     * @param list<string> $transformers
508     */
509    private function resolveParent(
510        string $placeholder,
511        array $parent,
512        Locale $locale,
513        string $fullPlaceholder,
514        array $transformers,
515    ): string {
516        return match ($placeholder) {
517            'uid' => $this->requiredSlug($parent['uid'], $fullPlaceholder, $transformers),
518            'handle' => $this->requiredSlug($parent['handle'], $fullPlaceholder, $transformers),
519            default => $this->field(
520                $parent['content'],
521                $placeholder,
522                $locale,
523                $fullPlaceholder,
524                $transformers,
525            ),
526        };
527    }
528
529    /** @param array{paths: array<string, string>} $parent */
530    private function parentPath(array $parent, Locale $locale, string $placeholder): string
531    {
532        $current = $locale;
533
534        while ($current !== null) {
535            $path = $this->pathValue($parent['paths'][$current->id] ?? null);
536
537            if ($path !== null) {
538                return $path;
539            }
540
541            $current = $current->fallback();
542        }
543
544        throw new RoutePathError(sprintf('Could not resolve route placeholder: {%s}', $placeholder));
545    }
546
547    private function pathValue(mixed $path): ?string
548    {
549        if (!is_string($path)) {
550            return null;
551        }
552
553        $path = trim($path);
554
555        return $path === '' ? null : $path;
556    }
557
558    private function normalizePath(string $path): string
559    {
560        $path = preg_replace('#/+#', '/', $path) ?? '';
561
562        if ($path === '') {
563            return '/';
564        }
565
566        return str_starts_with($path, '/') ? $path : '/' . $path;
567    }
568
569    /**
570     * @param array<string, mixed> $content
571     * @param list<string> $transformers
572     */
573    private function field(
574        array $content,
575        string $field,
576        Locale $locale,
577        string $placeholder,
578        array $transformers,
579    ): string {
580        $value = $content[$field]['value'] ?? null;
581
582        if (!is_array($value)) {
583            throw new RoutePathError(sprintf('Could not resolve route placeholder: {%s}', $placeholder));
584        }
585
586        $current = $locale;
587
588        while ($current !== null) {
589            // The fallback chain can hand back another locale's text; the
590            // transform follows the text, not the path being generated.
591            $resolved = $this->slugValue($value[$current->id] ?? null, $transformers, $current->id);
592
593            if ($resolved !== null) {
594                return $resolved;
595            }
596
597            $current = $current->fallback();
598        }
599
600        $resolved = $this->slugValue($value[Field::NEUTRAL_LOCALE] ?? null, $transformers, null);
601
602        if ($resolved !== null) {
603            return $resolved;
604        }
605
606        throw new RoutePathError(sprintf('Could not resolve route placeholder: {%s}', $placeholder));
607    }
608
609    /** @param list<string> $transformers */
610    private function requiredSlug(mixed $value, string $placeholder, array $transformers): string
611    {
612        if (!is_string($value) || trim($value) === '') {
613            throw new RoutePathError(sprintf('Could not resolve route placeholder: {%s}', $placeholder));
614        }
615
616        $slug = $this->slugify(
617            $this->transformCase($this->toAscii($value, null), $transformers),
618            $this->separator($transformers),
619        );
620
621        if ($slug === '') {
622            throw new RoutePathError(sprintf('Could not resolve route placeholder: {%s}', $placeholder));
623        }
624
625        return $slug;
626    }
627
628    /** @param list<string> $transformers */
629    private function slugValue(mixed $value, array $transformers, ?string $locale): ?string
630    {
631        if (!is_string($value) && !is_int($value) && !is_float($value)) {
632            return null;
633        }
634
635        $slug = $this->slugify(
636            $this->transformCase($this->toAscii((string) $value, $locale), $transformers),
637            $this->separator($transformers),
638        );
639
640        return $slug === '' ? null : $slug;
641    }
642
643    /** @param list<string> $transformers */
644    private function transformCase(string $value, array $transformers): string
645    {
646        $case = 'lowercase';
647
648        foreach ($transformers as $transformer) {
649            if (!in_array($transformer, ['lowercase', 'uppercase', 'titlecase', 'keepcase'], true)) {
650                continue;
651            }
652
653            $case = $transformer;
654        }
655
656        return match ($case) {
657            'uppercase' => strtoupper($value),
658            'titlecase' => ucwords(strtolower($value)),
659            'keepcase' => $value,
660            default => strtolower($value),
661        };
662    }
663
664    /** @param list<string> $transformers */
665    private function separator(array $transformers): string
666    {
667        $separator = '-';
668
669        foreach ($transformers as $transformer) {
670            $separator = match ($transformer) {
671                'dashes' => '-',
672                'underscore' => '_',
673                default => $separator,
674            };
675        }
676
677        return $separator;
678    }
679
680    /** Fold with the value's locale before byte-oriented case conversion and truncation. */
681    private function toAscii(string $value, ?string $locale): string
682    {
683        $value = Normalizer::normalize($value, Normalizer::FORM_C) ?: $value;
684        // Latin-ASCII can spell symbols such as Â® and Â© out as "(R)" and "(C)",
685        // whose letters would survive the filter below and land in the path.
686        $value = preg_replace('/[\p{So}\p{Sk}]/u', '', $value) ?? $value;
687        $folded = $this->transliterator($locale)?->transliterate($value);
688        $folded = is_string($folded) ? $folded : $value;
689
690        return preg_replace('/[^\x00-\x7F]+/', '', $folded) ?? $folded;
691    }
692
693    private function transliterator(?string $locale): ?Transliterator
694    {
695        // An empty id would resolve to ICU's default locale rather than to no
696        // language at all, so it has to short-circuit alongside null.
697        $language =
698            $locale === null || $locale === ''
699                ? ''
700                : (string) IcuLocale::getPrimaryLanguage($locale);
701
702        if (!array_key_exists($language, $this->transliterators)) {
703            // Valid language tags do not necessarily have a locale-specific ASCII transform.
704            $this->transliterators[$language] = (
705                $language === ''
706                    ? null
707                    : Transliterator::create($language . '-ASCII; ' . self::LATIN_FOLD)
708            )
709                ?? Transliterator::create(self::LATIN_FOLD);
710        }
711
712        return $this->transliterators[$language];
713    }
714
715    private function slugify(string $value, string $separator): string
716    {
717        $value = trim(preg_replace('/\s+/', $separator, $value) ?? '');
718        $value = substr($value, 0, 255);
719        $value = preg_replace('/[^A-Za-z0-9_-]+/', '', $value) ?? '';
720
721        $quoted = preg_quote($separator, '/');
722        $value = preg_replace("/{$quoted}{$quoted}+/", $separator, $value) ?? '';
723
724        return trim($value, $separator);
725    }
726
727    /**
728     * @return array<string, mixed>
729     */
730    private function decodeContent(mixed $content): array
731    {
732        if (is_array($content)) {
733            return $content;
734        }
735
736        if (!is_string($content) || $content === '') {
737            return [];
738        }
739
740        try {
741            $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
742        } catch (JsonException $e) {
743            throw new RoutePathError('Could not decode parent content for route path', previous: $e);
744        }
745
746        return is_array($decoded) ? $decoded : [];
747    }
748}