Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.58% covered (success)
96.58%
113 / 117
93.33% covered (success)
93.33%
14 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Menu
96.58% covered (success)
96.58%
113 / 117
93.33% covered (success)
93.33%
14 / 15
43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 rewind
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 current
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 next
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 valid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 html
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 compileHtml
91.49% covered (success)
91.49%
43 / 47
0.00% covered (danger)
0.00%
0 / 1
12.09
 anchorAttributes
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 escape
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeTree
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 branch
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 expand
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 childRows
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 nodeRows
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Finder;
6
7use Cosray\Cms;
8use Cosray\Context;
9use Cosray\Exception\RuntimeException;
10use Iterator;
11
12class Menu implements Iterator
13{
14    /** The `order` values a `children` item may configure. */
15    public const array CHILD_ORDERS = ['title', 'created', 'created desc', 'changed desc'];
16
17    protected array $items;
18    protected int $pointer = 0;
19
20    /**
21     * `$expand` resolves dynamic `children` items into node entries at
22     * read time; the panel editor turns it off to show the items as
23     * stored. Expansion needs the `$cms` finder entry point — without
24     * one, `children` items render as nothing.
25     *
26     * `$hidden` includes hidden items; the panel editor asks for them so
27     * it can show what the site does not.
28     */
29    public function __construct(
30        protected readonly Context $context,
31        string $menu,
32        protected readonly ?Cms $cms = null,
33        bool $expand = true,
34        bool $hidden = false,
35    ) {
36        $rows = $context->db->menus->get(['menu' => $menu])->all();
37
38        if (!$hidden) {
39            // Dropping the row is enough to drop its subtree: `makeTree` nests
40            // by parent, so children of a hidden item find no group to join.
41            $rows = array_values(array_filter(
42                $rows,
43                static fn(array $row): bool => !$row['hidden'],
44            ));
45        }
46
47        $this->items = $this->makeTree($rows);
48
49        // An existing menu without items iterates nothing and renders as
50        // an empty string; only an unknown menu is an error.
51        if (
52            count($this->items) === 0
53            && !$context->db->menus->exists(['menu' => $menu])->first()
54        ) {
55            throw new RuntimeException("Menu '{$menu}' not found");
56        }
57
58        if ($expand) {
59            $this->items = $this->expand($this->items);
60        }
61    }
62
63    public function rewind(): void
64    {
65        reset($this->items);
66    }
67
68    public function current(): MenuItem
69    {
70        return new MenuItem($this->context, current($this->items));
71    }
72
73    public function key(): string
74    {
75        return key($this->items);
76    }
77
78    public function next(): void
79    {
80        next($this->items);
81    }
82
83    public function valid(): bool
84    {
85        return key($this->items) !== null;
86    }
87
88    public function html(string $class = '', string $tag = 'nav'): string
89    {
90        return $this->compileHtml($this, $class, $tag);
91    }
92
93    protected function compileHtml(
94        Iterator $items,
95        string $class = '',
96        string $tag = 'nav',
97    ): string {
98        $out = '';
99        $level = 1;
100
101        foreach ($items as $item) {
102            $level = $item->level();
103            $itemClass = $item->class();
104            $image = $item->image() ?: '';
105
106            if ($image) {
107                $image = sprintf(
108                    '<div class="nav-image"><img src="%s" alt="Navigation Icon"/></div>',
109                    $this->escape($image),
110                );
111            }
112
113            $submenu = $this->compileHtml($item->children(), tag: '');
114
115            if ($submenu) {
116                $submenu = sprintf('<div class="nav-submenu">%s</div>', $submenu);
117            }
118
119            $content = sprintf(
120                '%s<div class="nav-label"><span>%s</span></div>%s',
121                $image,
122                $this->escape($item->title()),
123                $submenu,
124            );
125            $href = $item->href();
126
127            if ($href !== null) {
128                $content = sprintf('<a%s>%s</a>', $this->anchorAttributes($item, $href), $content);
129            }
130
131            $out .= sprintf(
132                '<li class="nav-level-%s%s%s">%s</li>',
133                (string) $level,
134                $item->hasChildren() ? ' nav-has-children' : '',
135                $itemClass ? ' ' . $this->escape($itemClass) : '',
136                $content,
137            );
138        }
139
140        if ($out === '') {
141            return '';
142        }
143
144        if ($tag) {
145            return sprintf(
146                '<%s%s><ul class="nav-level-%s">%s</ul></%s>',
147                $tag,
148                $class ? sprintf(' class="%s"', $this->escape($class)) : '',
149                $level,
150                $out,
151                $tag,
152            );
153        }
154
155        return sprintf(
156            '<ul class="%snav-level-%s">%s</ul>',
157            $class ? $this->escape($class) . ' ' : '',
158            $level,
159            $out,
160        );
161    }
162
163    protected function anchorAttributes(MenuItem $item, string $href): string
164    {
165        $attributes = sprintf(' href="%s"', $this->escape($href));
166        $target = $item->target();
167
168        if ($target !== null) {
169            $attributes .= sprintf(' target="%s"', $this->escape($target));
170
171            if ($target === '_blank') {
172                $attributes .= ' rel="noopener"';
173            }
174        }
175
176        return $attributes;
177    }
178
179    protected function escape(string $value): string
180    {
181        return htmlspecialchars($value, ENT_QUOTES);
182    }
183
184    /**
185     * Nests the sorted rows by their parent column. Splitting the CTE's
186     * dotted path would duplicate items whose ids contain a dot.
187     */
188    protected function makeTree(array $items): array
189    {
190        $grouped = [];
191
192        foreach ($items as $item) {
193            $grouped[$item['parent'] ?? ''][$item['item']] = $item;
194        }
195
196        return $this->branch($grouped, '');
197    }
198
199    private function branch(array $grouped, string $parent): array
200    {
201        $tree = [];
202
203        foreach ($grouped[$parent] ?? [] as $id => $item) {
204            $item['children'] = $this->branch($grouped, $id);
205            $tree[$id] = $item;
206        }
207
208        return $tree;
209    }
210
211    /**
212     * Replaces every `children` item in place with the linked node's
213     * published, visible children, `levels` deep and in the configured
214     * order. The entries are synthesized as `node` rows carrying their
215     * resolved title and path for the current locale, so rendering
216     * treats them exactly like hand-placed node items.
217     */
218    protected function expand(array $items): array
219    {
220        $result = [];
221
222        foreach ($items as $id => $item) {
223            $data = json_decode((string) $item['data'], true);
224            $data = is_array($data) ? $data : [];
225
226            if (($data['type'] ?? '') === 'children') {
227                $result += $this->childRows($data, (int) $item['level']);
228
229                continue;
230            }
231
232            $item['children'] = $this->expand($item['children']);
233            $result[$id] = $item;
234        }
235
236        return $result;
237    }
238
239    /** @return array<string, array> */
240    private function childRows(array $data, int $level): array
241    {
242        $uid = $data['node'] ?? null;
243
244        if ($this->cms === null || !is_string($uid) || $uid === '') {
245            return [];
246        }
247
248        $order = $data['order'] ?? '';
249        $order = in_array($order, self::CHILD_ORDERS, true) ? $order : 'title';
250
251        return $this->nodeRows($uid, $level, max(1, (int) ($data['levels'] ?? 1)), $order);
252    }
253
254    /** @return array<string, array> */
255    private function nodeRows(string $uid, int $level, int $depth, string $order): array
256    {
257        assert($this->cms !== null, 'childRows guards the finder entry point');
258        $locale = $this->context->locale()->id;
259        $rows = [];
260
261        // The uid tie-break keeps equal sort keys deterministic.
262        foreach ($this->cms->nodes()->childrenOf($uid)->order($order, 'id') as $node) {
263            $childUid = (string) $node->meta->uid;
264            $key = 'children:' . $childUid;
265
266            $rows[$key] = [
267                'item' => $key,
268                'parent' => null,
269                'level' => $level,
270                'data' => json_encode([
271                    'type' => 'node',
272                    'node' => $childUid,
273                    'title' => [$locale => $node->title()],
274                    'path' => [$locale => $node->path()],
275                ]),
276                'children' => $depth > 1
277                    ? $this->nodeRows($childUid, $level + 1, $depth - 1, $order)
278                    : [],
279            ];
280        }
281
282        return $rows;
283    }
284}