Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.26% covered (warning)
82.26%
51 / 62
68.00% covered (warning)
68.00%
17 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
MenuItem
82.26% covered (warning)
82.26%
51 / 62
68.00% covered (warning)
68.00%
17 / 25
65.53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rewind
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 current
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 next
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 valid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hidden
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 node
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 type
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 title
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 path
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 href
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 target
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 image
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 class
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 level
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 children
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 setChildren
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasChildren
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 titles
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 translated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 localized
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 assetPath
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 joined
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Finder;
6
7use Cosray\Context;
8use Cosray\Exception\RuntimeException;
9use Cosray\Field\Field;
10use Generator;
11use Iterator;
12use Override;
13
14/** @implements Iterator<string, MenuItem> */
15class MenuItem implements Iterator
16{
17    /** @var array<string, mixed> */
18    protected readonly array $data;
19
20    /** @var array<string, array<string, mixed>> */
21    protected array $children;
22
23    /** @param array<string, mixed> $item */
24    public function __construct(
25        protected readonly Context $context,
26        protected readonly array $item,
27    ) {
28        $this->data = json_decode($item['data'], true);
29        $this->children = $item['children'];
30    }
31
32    #[Override]
33    public function rewind(): void
34    {
35        reset($this->children);
36    }
37
38    #[Override]
39    public function current(): MenuItem
40    {
41        $item = current($this->children);
42
43        return $item !== false
44            ? new MenuItem($this->context, $item)
45            : throw new RuntimeException('The menu item iterator is past its end');
46    }
47
48    #[Override]
49    public function key(): string
50    {
51        return key($this->children) ?? throw new RuntimeException('The menu item iterator is past its end');
52    }
53
54    #[Override]
55    public function next(): void
56    {
57        next($this->children);
58    }
59
60    #[Override]
61    public function valid(): bool
62    {
63        return key($this->children) !== null;
64    }
65
66    public function id(): string
67    {
68        return (string) $this->item['item'];
69    }
70
71    /** Whether the item is kept out of the site; only the panel sees these. */
72    public function hidden(): bool
73    {
74        return (bool) ($this->item['hidden'] ?? false);
75    }
76
77    /** The linked node uid of `node` and `children` items, null otherwise. */
78    public function node(): ?string
79    {
80        $uid = $this->data['node'] ?? null;
81
82        return is_string($uid) && $uid !== '' ? $uid : null;
83    }
84
85    public function type(): string
86    {
87        return $this->data['type'];
88    }
89
90    /**
91     * Node items linked by uid inherit their node's current title when
92     * no title is stored; a stored title always overrides.
93     */
94    public function title(): string
95    {
96        $title = $this->translated('title');
97
98        return $title !== '' ? $title : $this->localized($this->joined('node_title'));
99    }
100
101    /**
102     * Node items linked by uid follow their node's current path; the
103     * snapshot in `data` covers legacy rows and vanished nodes.
104     */
105    public function path(): string
106    {
107        $resolved = $this->localized($this->joined('node_paths'));
108
109        return $resolved !== '' ? $resolved : $this->translated('path');
110    }
111
112    /**
113     * The link target for anchored item types, null for plain labels:
114     * `node` and `url` items link their path, `asset` items their file.
115     */
116    public function href(): ?string
117    {
118        return match ($this->type()) {
119            'node', 'url' => $this->path() ?: null,
120            'asset' => $this->assetPath(),
121            default => null,
122        };
123    }
124
125    public function target(): ?string
126    {
127        $target = $this->data['target'] ?? null;
128
129        return is_string($target) && $target !== '' ? $target : null;
130    }
131
132    public function image(): ?string
133    {
134        $uid = $this->data['image'] ?? null;
135
136        if (!$uid || !is_string($uid)) {
137            return null;
138        }
139
140        return $this->context->assets()->get($uid)?->path();
141    }
142
143    public function class(): ?string
144    {
145        return $this->data['class'] ?? null;
146    }
147
148    public function level(): int
149    {
150        return $this->item['level'];
151    }
152
153    /** @return Generator<int, MenuItem> */
154    public function children(): Generator
155    {
156        foreach ($this->children as $child) {
157            yield new MenuItem($this->context, $child);
158        }
159    }
160
161    /** @param array<string, array<string, mixed>> $children */
162    public function setChildren(array $children): void
163    {
164        $this->children = $children;
165    }
166
167    public function hasChildren(): bool
168    {
169        return count($this->children) > 0;
170    }
171
172    /**
173     * The stored title per locale, each exactly as stored and never through a
174     * fallback: a locale without its own title is absent, which is what lets
175     * the panel show the gap.
176     *
177     * @return array<string, string>
178     */
179    public function titles(): array
180    {
181        $stored = is_array($this->data['title'] ?? null) ? $this->data['title'] : [];
182        $inherited = $this->joined('node_title') ?? [];
183        $titles = [];
184
185        foreach ($stored + $inherited as $locale => $title) {
186            if (is_string($locale) && is_string($title) && $title !== '') {
187                $titles[$locale] = $title;
188            }
189        }
190
191        return $titles;
192    }
193
194    protected function translated(string $key): string
195    {
196        return $this->localized($this->data[$key] ?? null);
197    }
198
199    protected function localized(mixed $map): string
200    {
201        if (!is_array($map)) {
202            return '';
203        }
204
205        $locale = $this->context->locale();
206
207        while ($locale) {
208            $value = $map[$locale->id] ?? null;
209
210            if ($value) {
211                return $value;
212            }
213
214            $locale = $locale->fallback();
215        }
216
217        // A language-neutral value applies when no locale in the chain
218        // matches, mirroring the finder's field compilation.
219        return $map[Field::NEUTRAL_LOCALE] ?? '';
220    }
221
222    private function assetPath(): ?string
223    {
224        $uid = $this->data['asset'] ?? null;
225
226        if (!$uid || !is_string($uid)) {
227            return null;
228        }
229
230        return $this->context->assets()->get($uid)?->path();
231    }
232
233    /**
234     * A locale map the read query joined onto the row as jsonb, decoded.
235     *
236     * @return ?array<string, string>
237     */
238    /** @return array<string, string>|null */
239    private function joined(string $key): ?array
240    {
241        $value = $this->item[$key] ?? null;
242
243        if (!is_string($value)) {
244            return null;
245        }
246
247        $decoded = json_decode($value, true);
248
249        return is_array($decoded) ? $decoded : null;
250    }
251}