Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.81% covered (warning)
85.81%
266 / 310
55.88% covered (warning)
55.88%
19 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
RoutePathGenerator
85.81% covered (warning)
85.81%
266 / 310
55.88% covered (warning)
55.88%
19 / 34
214.34
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%
24 / 24
100.00% covered (success)
100.00%
1 / 1
10
 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
7.05
 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
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 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
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 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        return [
320            'depth' => $depth,
321            'field' => $matches[2] ?? null,
322            'optional' => false,
323        ];
324    }
325
326    private function friendlyPlaceholder(string $placeholder): string
327    {
328        $selector = trim(explode('|', $placeholder)[0] ?? '');
329        $parent = $this->friendlyParentPlaceholder($selector);
330
331        if ($parent !== null) {
332            return $parent;
333        }
334
335        return '[' . $this->friendlyLabel($selector) . ']';
336    }
337
338    private function friendlyParentPlaceholder(string $selector): ?string
339    {
340        if ($selector === 'parent?') {
341            return '[parent path]';
342        }
343
344        if (!preg_match('/^parent(?:\((\d+)\))?(?:\.(.+))?$/', $selector, $matches)) {
345            return null;
346        }
347
348        $depth = isset($matches[1]) && $matches[1] !== '' ? (int) $matches[1] : 1;
349        $prefix = $depth > 1 ? 'ancestor' : 'parent';
350        $field = $matches[2] ?? null;
351
352        if ($field === null) {
353            return "[{$prefix} path]";
354        }
355
356        return "[{$prefix} {$this->friendlyLabel($field)}]";
357    }
358
359    private function friendlyLabel(string $selector): string
360    {
361        $label = str_replace(['.', '_', '-', '(', ')'], ' ', $selector);
362        $label = preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1 $2', $label) ?? $label;
363        $label = preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $label) ?? $label;
364        $label = preg_replace('/\s+/', ' ', $label) ?? $label;
365        $label = strtolower(trim($label));
366
367        return $label === '' ? 'value' : $label;
368    }
369
370    /**
371     * @param array<string, mixed> $data
372     * @param array<int, array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}> $parents
373     * @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}
374     */
375    private function ancestor(array $data, ?int $parentId, array &$parents, int $depth): array
376    {
377        $parents[1] ??= $this->parent($data, $parentId);
378
379        for ($level = 2; $level <= $depth; $level++) {
380            if (isset($parents[$level])) {
381                continue;
382            }
383
384            $node = $parents[$level - 1]['parent'];
385
386            if ($node === null) {
387                throw new RoutePathError('Ancestor node not found for route path');
388            }
389
390            $parents[$level] = $this->parentByNode($node);
391        }
392
393        return $parents[$depth];
394    }
395
396    /**
397     * @param array<string, mixed> $data
398     * @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}
399     */
400    private function parent(array $data, ?int $parentId): array
401    {
402        if ($parentId !== null) {
403            return $this->parentByNode($parentId);
404        }
405
406        $parentUid = $data['parent'] ?? null;
407
408        if (!is_string($parentUid) || trim($parentUid) === '') {
409            throw new RoutePathError('A parent is required for this node route');
410        }
411
412        return $this->parentByUid(trim($parentUid));
413    }
414
415    /** @param array<string, mixed> $data */
416    private function hasParent(array $data, ?int $parentId): bool
417    {
418        if ($parentId !== null) {
419            return true;
420        }
421
422        $parentUid = $data['parent'] ?? null;
423
424        return is_string($parentUid) && trim($parentUid) !== '';
425    }
426
427    /** @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>} */
428    private function parentByNode(int $node): array
429    {
430        $parent = $this->db->nodes->routeParentByNode(['node' => $node])->first();
431
432        if (!$parent) {
433            throw new RoutePathError('Parent node not found for route path');
434        }
435
436        return $this->parentRow($parent);
437    }
438
439    /** @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>} */
440    private function parentByUid(string $uid): array
441    {
442        $parent = $this->db->nodes->routeParentByUid(['uid' => $uid])->first();
443
444        if (!$parent) {
445            throw new RoutePathError('Parent node not found for route path');
446        }
447
448        return $this->parentRow($parent);
449    }
450
451    /**
452     * @param array<string, mixed> $parent
453     * @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}
454     */
455    private function parentRow(array $parent): array
456    {
457        $content = $this->decodeContent($parent['content'] ?? '{}');
458        $handle = $parent['handle'] ?? null;
459        $node = (int) $parent['node'];
460
461        return [
462            'node' => $node,
463            'parent' => $this->nodeId($parent['parent'] ?? null),
464            'uid' => (string) $parent['uid'],
465            'handle' => is_string($handle) && $handle !== '' ? $handle : null,
466            'content' => $content,
467            'paths' => $this->parentPaths($node),
468        ];
469    }
470
471    private function nodeId(mixed $node): ?int
472    {
473        if (is_int($node)) {
474            return $node;
475        }
476
477        if (is_string($node) && ctype_digit($node)) {
478            return (int) $node;
479        }
480
481        return null;
482    }
483
484    /**
485     * @return array<string, string>
486     */
487    private function parentPaths(int $node): array
488    {
489        $paths = [];
490
491        foreach ($this->db->paths->activeByNode(['node' => $node])->all() as $path) {
492            $locale = $path['locale'] ?? null;
493            $value = $path['path'] ?? null;
494
495            if (is_string($locale) && is_string($value) && trim($value) !== '') {
496                $paths[$locale] = $value;
497            }
498        }
499
500        return $paths;
501    }
502
503    /**
504     * @param array{uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>} $parent
505     * @param list<string> $transformers
506     */
507    private function resolveParent(
508        string $placeholder,
509        array $parent,
510        Locale $locale,
511        string $fullPlaceholder,
512        array $transformers,
513    ): string {
514        return match ($placeholder) {
515            'uid' => $this->requiredSlug($parent['uid'], $fullPlaceholder, $transformers),
516            'handle' => $this->requiredSlug($parent['handle'], $fullPlaceholder, $transformers),
517            default => $this->field(
518                $parent['content'],
519                $placeholder,
520                $locale,
521                $fullPlaceholder,
522                $transformers,
523            ),
524        };
525    }
526
527    /** @param array{paths: array<string, string>} $parent */
528    private function parentPath(array $parent, Locale $locale, string $placeholder): string
529    {
530        $current = $locale;
531
532        while ($current !== null) {
533            $path = $this->pathValue($parent['paths'][$current->id] ?? null);
534
535            if ($path !== null) {
536                return $path;
537            }
538
539            $current = $current->fallback();
540        }
541
542        throw new RoutePathError(sprintf('Could not resolve route placeholder: {%s}', $placeholder));
543    }
544
545    private function pathValue(mixed $path): ?string
546    {
547        if (!is_string($path)) {
548            return null;
549        }
550
551        $path = trim($path);
552
553        return $path === '' ? null : $path;
554    }
555
556    private function normalizePath(string $path): string
557    {
558        $path = preg_replace('#/+#', '/', $path) ?? '';
559
560        if ($path === '') {
561            return '/';
562        }
563
564        return str_starts_with($path, '/') ? $path : '/' . $path;
565    }
566
567    /**
568     * @param array<string, mixed> $content
569     * @param list<string> $transformers
570     */
571    private function field(
572        array $content,
573        string $field,
574        Locale $locale,
575        string $placeholder,
576        array $transformers,
577    ): string {
578        $value = $content[$field]['value'] ?? null;
579
580        if (!is_array($value)) {
581            throw new RoutePathError(sprintf('Could not resolve route placeholder: {%s}', $placeholder));
582        }
583
584        $current = $locale;
585
586        while ($current !== null) {
587            // The fallback chain can hand back another locale's text; the
588            // transform follows the text, not the path being generated.
589            $resolved = $this->slugValue($value[$current->id] ?? null, $transformers, $current->id);
590
591            if ($resolved !== null) {
592                return $resolved;
593            }
594
595            $current = $current->fallback();
596        }
597
598        $resolved = $this->slugValue($value[Field::NEUTRAL_LOCALE] ?? null, $transformers, null);
599
600        if ($resolved !== null) {
601            return $resolved;
602        }
603
604        throw new RoutePathError(sprintf('Could not resolve route placeholder: {%s}', $placeholder));
605    }
606
607    /** @param list<string> $transformers */
608    private function requiredSlug(mixed $value, string $placeholder, array $transformers): string
609    {
610        if (!is_string($value) || trim($value) === '') {
611            throw new RoutePathError(sprintf('Could not resolve route placeholder: {%s}', $placeholder));
612        }
613
614        $slug = $this->slugify(
615            $this->transformCase($this->toAscii($value, null), $transformers),
616            $this->separator($transformers),
617        );
618
619        if ($slug === '') {
620            throw new RoutePathError(sprintf('Could not resolve route placeholder: {%s}', $placeholder));
621        }
622
623        return $slug;
624    }
625
626    /** @param list<string> $transformers */
627    private function slugValue(mixed $value, array $transformers, ?string $locale): ?string
628    {
629        if (!is_string($value) && !is_int($value) && !is_float($value)) {
630            return null;
631        }
632
633        $slug = $this->slugify(
634            $this->transformCase($this->toAscii((string) $value, $locale), $transformers),
635            $this->separator($transformers),
636        );
637
638        return $slug === '' ? null : $slug;
639    }
640
641    /** @param list<string> $transformers */
642    private function transformCase(string $value, array $transformers): string
643    {
644        $case = 'lowercase';
645
646        foreach ($transformers as $transformer) {
647            if (!in_array($transformer, ['lowercase', 'uppercase', 'titlecase', 'keepcase'], true)) {
648                continue;
649            }
650
651            $case = $transformer;
652        }
653
654        return match ($case) {
655            'uppercase' => strtoupper($value),
656            'titlecase' => ucwords(strtolower($value)),
657            'keepcase' => $value,
658            default => strtolower($value),
659        };
660    }
661
662    /** @param list<string> $transformers */
663    private function separator(array $transformers): string
664    {
665        $separator = '-';
666
667        foreach ($transformers as $transformer) {
668            $separator = match ($transformer) {
669                'dashes' => '-',
670                'underscore' => '_',
671                default => $separator,
672            };
673        }
674
675        return $separator;
676    }
677
678    /** Fold with the value's locale before byte-oriented case conversion and truncation. */
679    private function toAscii(string $value, ?string $locale): string
680    {
681        $value = Normalizer::normalize($value, Normalizer::FORM_C) ?: $value;
682        // Latin-ASCII can spell symbols such as Â® and Â© out as "(R)" and "(C)",
683        // whose letters would survive the filter below and land in the path.
684        $value = preg_replace('/[\p{So}\p{Sk}]/u', '', $value) ?? $value;
685        $folded = $this->transliterator($locale)?->transliterate($value);
686        $folded = is_string($folded) ? $folded : $value;
687
688        return preg_replace('/[^\x00-\x7F]+/', '', $folded) ?? $folded;
689    }
690
691    private function transliterator(?string $locale): ?Transliterator
692    {
693        // An empty id would resolve to ICU's default locale rather than to no
694        // language at all, so it has to short-circuit alongside null.
695        $language =
696            $locale === null || $locale === ''
697                ? ''
698                : (string) IcuLocale::getPrimaryLanguage($locale);
699
700        if (!array_key_exists($language, $this->transliterators)) {
701            // Valid language tags do not necessarily have a locale-specific ASCII transform.
702            $this->transliterators[$language] = (
703                $language === ''
704                    ? null
705                    : Transliterator::create($language . '-ASCII; ' . self::LATIN_FOLD)
706            )
707                ?? Transliterator::create(self::LATIN_FOLD);
708        }
709
710        return $this->transliterators[$language];
711    }
712
713    private function slugify(string $value, string $separator): string
714    {
715        $value = trim(preg_replace('/\s+/', $separator, $value) ?? '');
716        $value = substr($value, 0, 255);
717        $value = preg_replace('/[^A-Za-z0-9_-]+/', '', $value) ?? '';
718
719        $quoted = preg_quote($separator, '/');
720        $value = preg_replace("/{$quoted}{$quoted}+/", $separator, $value) ?? '';
721
722        return trim($value, $separator);
723    }
724
725    /**
726     * @return array<string, mixed>
727     */
728    private function decodeContent(mixed $content): array
729    {
730        if (is_array($content)) {
731            return $content;
732        }
733
734        if (!is_string($content) || $content === '') {
735            return [];
736        }
737
738        try {
739            $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
740        } catch (JsonException $e) {
741            throw new RoutePathError('Could not decode parent content for route path', previous: $e);
742        }
743
744        return is_array($decoded) ? $decoded : [];
745    }
746}