Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.64% covered (warning)
89.64%
251 / 280
54.84% covered (warning)
54.84%
17 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
RoutePathGenerator
89.64% covered (warning)
89.64%
251 / 280
54.84% covered (warning)
54.84%
17 / 31
148.78
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
 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
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
8.04
 resolve
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
11.03
 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
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 parent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 hasParent
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 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
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 parentPaths
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 resolveParent
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 parentPath
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 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
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 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
 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 Celemas\Quma\Database;
8use Cosray\Exception\RoutePathError;
9use Cosray\Field\Field;
10use Cosray\Locale;
11use Cosray\Locales;
12use JsonException;
13
14final class RoutePathGenerator
15{
16    private const MAX_PARENT_DEPTH = 5;
17
18    public function __construct(
19        private readonly Database $db,
20        private readonly Types $types,
21    ) {}
22
23    /**
24     * @param class-string $nodeClass
25     * @param array<string, mixed> $data
26     * @return array<string, string>
27     */
28    public function generate(
29        string $nodeClass,
30        array $data,
31        Locales $locales,
32        ?int $parentId = null,
33    ): array {
34        return $this->generateFromRoute(
35            $this->types->get($nodeClass, 'route'),
36            $data,
37            $locales,
38            $parentId,
39        );
40    }
41
42    /**
43     * @param class-string $nodeClass
44     * @param array<string, mixed> $data
45     * @return array<string, string>
46     */
47    public function preview(
48        string $nodeClass,
49        array $data,
50        Locales $locales,
51        ?int $parentId = null,
52    ): array {
53        return $this->generateFromRoute(
54            $this->types->get($nodeClass, 'route'),
55            $data,
56            $locales,
57            $parentId,
58            strict: false,
59        );
60    }
61
62    /**
63     * @param array<string, string>|string|mixed $route
64     * @param array<string, mixed> $data
65     * @return array<string, string>
66     */
67    public function generateFromRoute(
68        mixed $route,
69        array $data,
70        Locales $locales,
71        ?int $parentId = null,
72        bool $strict = true,
73    ): array {
74        if (!is_string($route) && !is_array($route)) {
75            return [];
76        }
77
78        $parents = [];
79        $paths = [];
80
81        foreach ($locales as $locale) {
82            $template = $this->template($route, $locale);
83
84            if ($template === '') {
85                continue;
86            }
87
88            $paths[$locale->id] = $this->expand($template, $data, $locale, $parents, $parentId, $strict);
89        }
90
91        return $paths;
92    }
93
94    /**
95     * @param array<string, string>|string $route
96     */
97    private function template(array|string $route, Locale $locale): string
98    {
99        if (is_string($route)) {
100            return $route;
101        }
102
103        return $route[$locale->id] ?? '';
104    }
105
106    /**
107     * @param array<string, mixed> $data
108     * @param array<int, array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}> $parents
109     */
110    private function expand(
111        string $template,
112        array $data,
113        Locale $locale,
114        array &$parents,
115        ?int $parentId,
116        bool $strict,
117    ): string {
118        $usesParentPath = false;
119        $path = preg_replace_callback(
120            '/\{([^{}]+)\}/',
121            function (array $matches) use (
122                $data,
123                $locale,
124                &$parents,
125                $parentId,
126                $strict,
127                &$usesParentPath,
128            ): string {
129                try {
130                    return $this->resolve($matches[1], $data, $locale, $parents, $parentId, $usesParentPath);
131                } catch (RoutePathError $e) {
132                    if ($strict) {
133                        throw $e;
134                    }
135
136                    return $this->friendlyPlaceholder($matches[1]);
137                }
138            },
139            $template,
140        );
141
142        if (!is_string($path)) {
143            throw new RoutePathError(_('Could not generate route path'));
144        }
145
146        if ($strict && (str_contains($path, '{') || str_contains($path, '}'))) {
147            throw new RoutePathError(_('Invalid route path placeholder syntax'));
148        }
149
150        return $usesParentPath ? $this->normalizePath($path) : $path;
151    }
152
153    /**
154     * @param array<string, mixed> $data
155     * @param array<int, array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}> $parents
156     */
157    private function resolve(
158        string $placeholder,
159        array $data,
160        Locale $locale,
161        array &$parents,
162        ?int $parentId,
163        bool &$usesParentPath,
164    ): string {
165        [$selector, $transformers] = $this->parsePlaceholder($placeholder);
166
167        if ($selector === 'uid') {
168            return $this->requiredSlug($data['uid'] ?? null, $placeholder, $transformers);
169        }
170
171        if ($selector === 'handle') {
172            return $this->requiredSlug($data['handle'] ?? null, $placeholder, $transformers);
173        }
174
175        $parentSelector = $this->parentSelector($selector);
176
177        if ($parentSelector !== null) {
178            if ($parentSelector['field'] === null && $transformers !== []) {
179                throw new RoutePathError(_(
180                    'Route path transformers are not supported for parent path placeholders',
181                ));
182            }
183
184            if ($parentSelector['field'] === null) {
185                $usesParentPath = true;
186
187                if ($parentSelector['optional'] && !$this->hasParent($data, $parentId)) {
188                    return '';
189                }
190            }
191
192            $parent = $this->ancestor($data, $parentId, $parents, $parentSelector['depth']);
193
194            if ($parentSelector['field'] === null) {
195                return trim($this->parentPath($parent, $locale, $placeholder), '/');
196            }
197
198            return $this->resolveParent(
199                $parentSelector['field'],
200                $parent,
201                $locale,
202                $placeholder,
203                $transformers,
204            );
205        }
206
207        $content = $data['content'] ?? [];
208
209        return $this->field(
210            is_array($content) ? $content : [],
211            $selector,
212            $locale,
213            $placeholder,
214            $transformers,
215        );
216    }
217
218    /** @return array{0: string, 1: list<string>} */
219    private function parsePlaceholder(string $placeholder): array
220    {
221        $parts = array_map(trim(...), explode('|', $placeholder));
222        $selector = array_shift($parts) ?? '';
223
224        if ($selector === '') {
225            throw new RoutePathError(_('Invalid route path placeholder syntax'));
226        }
227
228        foreach ($parts as $transformer) {
229            if (!in_array(
230                $transformer,
231                ['lowercase', 'uppercase', 'titlecase', 'keepcase', 'dashes', 'underscore'],
232                true,
233            )) {
234                throw new RoutePathError(sprintf(_('Unknown route path transformer: %s'), $transformer));
235            }
236        }
237
238        return [$selector, $parts];
239    }
240
241    /** @return ?array{depth: int, field: ?string, optional: bool} */
242    private function parentSelector(string $selector): ?array
243    {
244        if (
245            $selector !== 'parent'
246            && !str_starts_with($selector, 'parent.')
247            && !str_starts_with($selector, 'parent(')
248            && !str_starts_with($selector, 'parent?')
249        ) {
250            return null;
251        }
252
253        if ($selector === 'parent?') {
254            return [
255                'depth' => 1,
256                'field' => null,
257                'optional' => true,
258            ];
259        }
260
261        if (!preg_match('/^parent(?:\(([1-9]\d*)\))?(?:\.(.+))?$/', $selector, $matches)) {
262            throw new RoutePathError(_('Invalid route path parent syntax'));
263        }
264
265        $depth = isset($matches[1]) && $matches[1] !== '' ? (int) $matches[1] : 1;
266
267        if ($depth > self::MAX_PARENT_DEPTH) {
268            throw new RoutePathError(sprintf(
269                _('Route path parent depth cannot exceed %d'),
270                self::MAX_PARENT_DEPTH,
271            ));
272        }
273
274        $field = $matches[2] ?? null;
275
276        return [
277            'depth' => $depth,
278            'field' => is_string($field) && $field !== '' ? $field : null,
279            'optional' => false,
280        ];
281    }
282
283    private function friendlyPlaceholder(string $placeholder): string
284    {
285        $selector = trim(explode('|', $placeholder)[0] ?? '');
286        $parent = $this->friendlyParentPlaceholder($selector);
287
288        if ($parent !== null) {
289            return $parent;
290        }
291
292        return '[' . $this->friendlyLabel($selector) . ']';
293    }
294
295    private function friendlyParentPlaceholder(string $selector): ?string
296    {
297        if ($selector === 'parent?') {
298            return '[parent path]';
299        }
300
301        if (!preg_match('/^parent(?:\((\d+)\))?(?:\.(.+))?$/', $selector, $matches)) {
302            return null;
303        }
304
305        $depth = isset($matches[1]) && $matches[1] !== '' ? (int) $matches[1] : 1;
306        $prefix = $depth > 1 ? 'ancestor' : 'parent';
307        $field = $matches[2] ?? null;
308
309        if (!is_string($field) || $field === '') {
310            return "[{$prefix} path]";
311        }
312
313        return "[{$prefix} {$this->friendlyLabel($field)}]";
314    }
315
316    private function friendlyLabel(string $selector): string
317    {
318        $label = str_replace(['.', '_', '-', '(', ')'], ' ', $selector);
319        $label = preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1 $2', $label) ?? $label;
320        $label = preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $label) ?? $label;
321        $label = preg_replace('/\s+/', ' ', $label) ?? $label;
322        $label = strtolower(trim($label));
323
324        return $label === '' ? 'value' : $label;
325    }
326
327    /**
328     * @param array<string, mixed> $data
329     * @param array<int, array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}> $parents
330     * @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}
331     */
332    private function ancestor(array $data, ?int $parentId, array &$parents, int $depth): array
333    {
334        $parents[1] ??= $this->parent($data, $parentId);
335
336        for ($level = 2; $level <= $depth; $level++) {
337            if (isset($parents[$level])) {
338                continue;
339            }
340
341            $node = $parents[$level - 1]['parent'];
342
343            if ($node === null) {
344                throw new RoutePathError(_('Ancestor node not found for route path'));
345            }
346
347            $parents[$level] = $this->parentByNode($node);
348        }
349
350        return $parents[$depth];
351    }
352
353    /**
354     * @param array<string, mixed> $data
355     * @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}
356     */
357    private function parent(array $data, ?int $parentId): array
358    {
359        if ($parentId !== null) {
360            return $this->parentByNode($parentId);
361        }
362
363        $parentUid = $data['parent'] ?? null;
364
365        if (!is_string($parentUid) || trim($parentUid) === '') {
366            throw new RoutePathError(_('A parent is required for this node route'));
367        }
368
369        return $this->parentByUid(trim($parentUid));
370    }
371
372    /** @param array<string, mixed> $data */
373    private function hasParent(array $data, ?int $parentId): bool
374    {
375        if ($parentId !== null) {
376            return true;
377        }
378
379        $parentUid = $data['parent'] ?? null;
380
381        return is_string($parentUid) && trim($parentUid) !== '';
382    }
383
384    /** @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>} */
385    private function parentByNode(int $node): array
386    {
387        $parent = $this->db->nodes->routeParentByNode(['node' => $node])->first();
388
389        if (!$parent) {
390            throw new RoutePathError(_('Parent node not found for route path'));
391        }
392
393        return $this->parentRow($parent);
394    }
395
396    /** @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>} */
397    private function parentByUid(string $uid): array
398    {
399        $parent = $this->db->nodes->routeParentByUid(['uid' => $uid])->first();
400
401        if (!$parent) {
402            throw new RoutePathError(_('Parent node not found for route path'));
403        }
404
405        return $this->parentRow($parent);
406    }
407
408    /**
409     * @param array<string, mixed> $parent
410     * @return array{node: int, parent: ?int, uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>}
411     */
412    private function parentRow(array $parent): array
413    {
414        $content = $this->decodeContent($parent['content'] ?? '{}');
415        $handle = $parent['handle'] ?? null;
416        $node = (int) $parent['node'];
417
418        return [
419            'node' => $node,
420            'parent' => $this->nodeId($parent['parent'] ?? null),
421            'uid' => (string) $parent['uid'],
422            'handle' => is_string($handle) && $handle !== '' ? $handle : null,
423            'content' => $content,
424            'paths' => $this->parentPaths($node),
425        ];
426    }
427
428    private function nodeId(mixed $node): ?int
429    {
430        if (is_int($node)) {
431            return $node;
432        }
433
434        if (is_string($node) && ctype_digit($node)) {
435            return (int) $node;
436        }
437
438        return null;
439    }
440
441    /**
442     * @return array<string, string>
443     */
444    private function parentPaths(int $node): array
445    {
446        $paths = [];
447
448        foreach ($this->db->paths->activeByNode(['node' => $node])->all() as $path) {
449            $locale = $path['locale'] ?? null;
450            $value = $path['path'] ?? null;
451
452            if (is_string($locale) && is_string($value) && trim($value) !== '') {
453                $paths[$locale] = $value;
454            }
455        }
456
457        return $paths;
458    }
459
460    /**
461     * @param array{uid: string, handle: ?string, content: array<string, mixed>, paths: array<string, string>} $parent
462     * @param list<string> $transformers
463     */
464    private function resolveParent(
465        string $placeholder,
466        array $parent,
467        Locale $locale,
468        string $fullPlaceholder,
469        array $transformers,
470    ): string {
471        return match ($placeholder) {
472            'uid' => $this->requiredSlug($parent['uid'], $fullPlaceholder, $transformers),
473            'handle' => $this->requiredSlug($parent['handle'], $fullPlaceholder, $transformers),
474            default => $this->field(
475                $parent['content'],
476                $placeholder,
477                $locale,
478                $fullPlaceholder,
479                $transformers,
480            ),
481        };
482    }
483
484    /** @param array{paths: array<string, string>} $parent */
485    private function parentPath(array $parent, Locale $locale, string $placeholder): string
486    {
487        $current = $locale;
488
489        while ($current !== null) {
490            $path = $this->pathValue($parent['paths'][$current->id] ?? null);
491
492            if ($path !== null) {
493                return $path;
494            }
495
496            $current = $current->fallback();
497        }
498
499        throw new RoutePathError(sprintf(_('Could not resolve route placeholder: {%s}'), $placeholder));
500    }
501
502    private function pathValue(mixed $path): ?string
503    {
504        if (!is_string($path)) {
505            return null;
506        }
507
508        $path = trim($path);
509
510        return $path === '' ? null : $path;
511    }
512
513    private function normalizePath(string $path): string
514    {
515        $path = preg_replace('#/+#', '/', $path) ?? '';
516
517        if ($path === '') {
518            return '/';
519        }
520
521        return str_starts_with($path, '/') ? $path : '/' . $path;
522    }
523
524    /**
525     * @param array<string, mixed> $content
526     * @param list<string> $transformers
527     */
528    private function field(
529        array $content,
530        string $field,
531        Locale $locale,
532        string $placeholder,
533        array $transformers,
534    ): string {
535        $value = $content[$field]['value'] ?? null;
536
537        if (!is_array($value)) {
538            throw new RoutePathError(sprintf(_('Could not resolve route placeholder: {%s}'), $placeholder));
539        }
540
541        $current = $locale;
542
543        while ($current !== null) {
544            $resolved = $this->slugValue($value[$current->id] ?? null, $transformers);
545
546            if ($resolved !== null) {
547                return $resolved;
548            }
549
550            $current = $current->fallback();
551        }
552
553        $resolved = $this->slugValue($value[Field::NEUTRAL_LOCALE] ?? null, $transformers);
554
555        if ($resolved !== null) {
556            return $resolved;
557        }
558
559        throw new RoutePathError(sprintf(_('Could not resolve route placeholder: {%s}'), $placeholder));
560    }
561
562    /** @param list<string> $transformers */
563    private function requiredSlug(mixed $value, string $placeholder, array $transformers): string
564    {
565        if (!is_string($value) || trim($value) === '') {
566            throw new RoutePathError(sprintf(_('Could not resolve route placeholder: {%s}'), $placeholder));
567        }
568
569        $slug = $this->slugify(
570            $this->transformCase($value, $transformers),
571            $this->separator($transformers),
572        );
573
574        if ($slug === '') {
575            throw new RoutePathError(sprintf(_('Could not resolve route placeholder: {%s}'), $placeholder));
576        }
577
578        return $slug;
579    }
580
581    /** @param list<string> $transformers */
582    private function slugValue(mixed $value, array $transformers): ?string
583    {
584        if (!is_string($value) && !is_int($value) && !is_float($value)) {
585            return null;
586        }
587
588        $slug = $this->slugify(
589            $this->transformCase((string) $value, $transformers),
590            $this->separator($transformers),
591        );
592
593        return $slug === '' ? null : $slug;
594    }
595
596    /** @param list<string> $transformers */
597    private function transformCase(string $value, array $transformers): string
598    {
599        $case = 'lowercase';
600
601        foreach ($transformers as $transformer) {
602            if (!in_array($transformer, ['lowercase', 'uppercase', 'titlecase', 'keepcase'], true)) {
603                continue;
604            }
605
606            $case = $transformer;
607        }
608
609        return match ($case) {
610            'uppercase' => strtoupper($value),
611            'titlecase' => ucwords(strtolower($value)),
612            'keepcase' => $value,
613            default => strtolower($value),
614        };
615    }
616
617    /** @param list<string> $transformers */
618    private function separator(array $transformers): string
619    {
620        $separator = '-';
621
622        foreach ($transformers as $transformer) {
623            $separator = match ($transformer) {
624                'dashes' => '-',
625                'underscore' => '_',
626                default => $separator,
627            };
628        }
629
630        return $separator;
631    }
632
633    private function slugify(string $value, string $separator): string
634    {
635        $value = trim(preg_replace('/\s+/', $separator, $value) ?? '');
636        $value = substr($value, 0, 255);
637        $value = preg_replace('/[^A-Za-z0-9_-]+/', '', $value) ?? '';
638
639        $quoted = preg_quote($separator, '/');
640        $value = preg_replace("/{$quoted}{$quoted}+/", $separator, $value) ?? '';
641
642        return trim($value, $separator);
643    }
644
645    /**
646     * @return array<string, mixed>
647     */
648    private function decodeContent(mixed $content): array
649    {
650        if (is_array($content)) {
651            return $content;
652        }
653
654        if (!is_string($content) || $content === '') {
655            return [];
656        }
657
658        try {
659            $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
660        } catch (JsonException $e) {
661            throw new RoutePathError(_('Could not decode parent content for route path'), previous: $e);
662        }
663
664        return is_array($decoded) ? $decoded : [];
665    }
666}