Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.86% covered (success)
99.86%
739 / 740
94.12% covered (success)
94.12%
16 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Styleguide
99.86% covered (success)
99.86%
739 / 740
94.12% covered (success)
94.12%
16 / 17
36
0.00% covered (danger)
0.00%
0 / 1
 index
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 tokenGroups
98.04% covered (success)
98.04%
50 / 51
0.00% covered (danger)
0.00%
0 / 1
17
 fields
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
1
 fieldset
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 richtextFields
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 mediaFields
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
1
 entriesFields
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
1 / 1
1
 entriesContent
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 blocksFields
100.00% covered (success)
100.00%
214 / 214
100.00% covered (success)
100.00%
1 / 1
1
 blocksContent
100.00% covered (success)
100.00%
103 / 103
100.00% covered (success)
100.00%
1 / 1
1
 mediaContent
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 galleryUid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mediaAssets
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
2
 richtextContent
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 inspector
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 rows
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
1
 content
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Cosray\Block as Builtin;
8use Cosray\Field\Control;
9use Cosray\Field\Control\Registry as Controls;
10use Cosray\Field\Iframe;
11use Cosray\Field\Image;
12use Cosray\Field\Option;
13use Cosray\Field\RichText;
14use Cosray\Field\Text;
15use Cosray\Field\Textarea;
16use Cosray\Field\Youtube;
17use Cosray\Locales;
18use Cosray\Panel\System;
19use Cosray\Richtext\Envelope;
20use Cosray\Schema\Tool;
21
22/**
23 * Renders every panel component against the current stylesheets, so rare
24 * states (empty, error, disabled, truncating, both themes) can be checked
25 * without hunting for content that happens to produce them.
26 *
27 * Registered only when `app.debug` is on.
28 */
29final class Styleguide extends Panel
30{
31    protected const string AREA = 'styleguide';
32    private const int GALLERY_SIZE = 14;
33
34    public function index(Controls $controls): array
35    {
36        $locales = $this->container->get(Locales::class);
37        assert($locales instanceof Locales, 'The locales service must be available');
38
39        return $this->context([
40            'tokenGroups' => $this->tokenGroups(),
41            'locales' => [
42                ['id' => 'en', 'title' => 'English'],
43                ['id' => 'de', 'title' => 'Deutsch', 'fallback' => 'en'],
44            ],
45            'defaultLocale' => 'en',
46            'fields' => $this->fields(),
47            'fieldset' => $this->fieldset(),
48            'content' => $this->content(),
49            'rows' => $this->rows(),
50            'inspector' => $this->inspector(),
51            'richtextFields' => $this->richtextFields($controls),
52            'richtextContent' => $this->richtextContent(),
53            'mediaFields' => $this->mediaFields($controls),
54            'mediaContent' => $this->mediaContent(),
55            'mediaAssets' => $this->mediaAssets(),
56            'entriesFields' => $this->entriesFields($controls),
57            'entriesContent' => $this->entriesContent(),
58            'blocksFields' => $this->blocksFields($controls),
59            'blocksContent' => $this->blocksContent(),
60            // Media controls need the editor bridge for uploads and the
61            // library picker; the payload is the one the editor embeds.
62            'system' => new System($this->config, $locales)->payload(),
63        ]);
64    }
65
66    /**
67     * Token groups read out of `tokens.css` rather than listed here, so the
68     * page cannot drift from the stylesheet it documents. Only the `:root`
69     * block is read — the rules after it force a theme, they declare no
70     * tokens.
71     *
72     * @return list<array{title: string, open: bool, tokens: list<array{name: string, value: string, swatch: bool}>}>
73     */
74    private function tokenGroups(): array
75    {
76        $path = $this->panelDir . '/styles/tokens.css';
77
78        if (!is_file($path)) {
79            return [];
80        }
81
82        $groups = [];
83        $title = 'Tokens';
84        $declaration = '';
85        $comment = [];
86        $inRoot = false;
87        $inComment = false;
88        $inNote = false;
89
90        foreach (preg_split('/\R/', (string) file_get_contents($path)) ?: [] as $line) {
91            $line = trim($line);
92
93            if (!$inRoot) {
94                $inRoot = $line === ':root {';
95
96                continue;
97            }
98
99            if ($line === '}') {
100                break;
101            }
102
103            // A `/**` block names a group; a plain `/*` block is a note about
104            // the token below it and carries no heading.
105            if ($inNote || str_starts_with($line, '/*') && !str_starts_with($line, '/**')) {
106                $inNote = !str_ends_with($line, '*/');
107
108                continue;
109            }
110
111            if ($inComment || str_starts_with($line, '/**')) {
112                $inComment = !str_ends_with($line, '*/');
113                $text = trim(trim($line, '/*'));
114
115                if ($text !== '' && $comment === []) {
116                    $comment[] = $text;
117                }
118
119                if (!$inComment) {
120                    $title = $comment[0] ?? $title;
121                    $comment = [];
122                }
123
124                continue;
125            }
126
127            // A declaration may span several lines (multi-line color-mix()).
128            $declaration = trim($declaration . ' ' . $line);
129
130            if (!str_ends_with($declaration, ';') || !str_starts_with($declaration, '--cms-')) {
131                if (str_ends_with($declaration, ';')) {
132                    $declaration = '';
133                }
134
135                continue;
136            }
137
138            [$name, $value] = explode(':', $declaration, 2);
139            $name = trim($name);
140            $declaration = '';
141
142            $groups[$title][] = [
143                'name' => $name,
144                'value' => trim(rtrim(trim($value), ';')),
145                'swatch' => str_contains($name, 'color'),
146            ];
147        }
148
149        // Primitives are the longest and least consulted group — 55 spacing
150        // steps ahead of everything worth looking up — so they start collapsed.
151        return array_map(
152            static fn(string $title, array $tokens): array => [
153                'title' => $title,
154                'open' => !str_starts_with($title, 'Primitives'),
155                'tokens' => $tokens,
156            ],
157            array_keys($groups),
158            array_values($groups),
159        );
160    }
161
162    /**
163     * Field descriptors in the shape the editor passes to `field/item`.
164     *
165     * @return list<array<string, mixed>>
166     */
167    private function fields(): array
168    {
169        return [
170            [
171                'name' => 'title',
172                'label' => 'Title',
173                'control' => ['name' => 'text', 'props' => ['placeholder' => 'Untitled']],
174                'required' => true,
175                'description' => 'Shown in listings, search results and when the page is shared.',
176            ],
177            [
178                'name' => 'teaser',
179                'label' => 'Teaser',
180                'control' => ['name' => 'textarea', 'props' => []],
181                'translate' => true,
182            ],
183            [
184                'name' => 'category',
185                'label' => 'Category',
186                'control' => ['name' => 'option', 'props' => []],
187                'options' => ['news', 'event', 'press'],
188            ],
189            [
190                'name' => 'weight',
191                'label' => 'Weight',
192                'control' => ['name' => 'number', 'props' => []],
193                'width' => 50,
194            ],
195            [
196                'name' => 'featured',
197                'label' => 'Featured',
198                'control' => ['name' => 'checkbox', 'props' => []],
199                'width' => 50,
200            ],
201            [
202                'name' => 'locked',
203                'label' => 'Locked',
204                'control' => ['name' => 'text', 'props' => []],
205                'immutable' => true,
206                'description' => 'Immutable fields render disabled.',
207            ],
208            [
209                'name' => 'overflow',
210                'label' => 'A label long enough to find out what happens when it does not fit',
211                'control' => ['name' => 'text', 'props' => []],
212            ],
213        ];
214    }
215
216    /**
217     * A fieldset descriptor in the shape the editor passes to `field/fieldset`;
218     * its members come out of `fields()`, the rest render as a loose run below
219     * it — the two section forms the editor sheet knows.
220     *
221     * @return array<string, mixed>
222     */
223    private function fieldset(): array
224    {
225        return [
226            'name' => 'basics',
227            'label' => 'Basics',
228            'description' => 'Title and teaser are shown in listings, search results and when the page is shared.',
229            'fields' => ['title', 'teaser'],
230        ];
231    }
232
233    /**
234     * Two richtext descriptors: the built-in default toolbar and a field
235     * trimmed the way `#[Tools]` would.
236     *
237     * @return list<array<string, mixed>>
238     */
239    private function richtextFields(Controls $controls): array
240    {
241        $control = Control::richtext()->resolve($controls)->array();
242
243        return [
244            [
245                'name' => 'rtDefault',
246                'label' => 'Richtext — default tools',
247                'control' => $control,
248                'tools' => array_map(static fn(Tool $tool): string => $tool->value, Tool::DEFAULT),
249                'richtextClasses' => (object) [],
250                'richtextStyles' => (object) [],
251            ],
252            [
253                'name' => 'rtTrimmed',
254                'label' => 'Richtext — #[Tools(Bold, Italic, Link, Source)]',
255                'control' => $control,
256                'tools' => ['bold', 'italic', 'link', 'source'],
257                'richtextClasses' => (object) [],
258                'richtextStyles' => (object) [],
259            ],
260        ];
261    }
262
263    /**
264     * Image descriptors in both shapes the control takes: a single image
265     * and a gallery, each once filled and once empty.
266     *
267     * @return list<array<string, mixed>>
268     */
269    private function mediaFields(Controls $controls): array
270    {
271        $control = Control::image()->resolve($controls)->array();
272        $single = ['min' => 0, 'max' => 1];
273        $many = ['min' => 0, 'max' => -1];
274
275        return [
276            [
277                'name' => 'cover',
278                'label' => 'Cover image',
279                'control' => $control,
280                'limit' => $single,
281                'translate' => true,
282                'description' => 'Alt text and title are edited in place; the thumbnail opens the preview.',
283            ],
284            [
285                'name' => 'coverEmpty',
286                'label' => 'Cover image — empty, required',
287                'control' => $control,
288                'limit' => $single,
289                'required' => true,
290            ],
291            [
292                'name' => 'gallery',
293                'label' => 'Gallery',
294                'control' => $control,
295                'limit' => $many,
296                'description' => 'Selecting a tile opens the drawer; tiles reorder by drag.',
297            ],
298            [
299                'name' => 'galleryEmpty',
300                'label' => 'Gallery — empty',
301                'control' => $control,
302                'limit' => $many,
303            ],
304        ];
305    }
306
307    /**
308     * Entries descriptors: a field allowing two entry types — one with an
309     * image, so rows carry a thumb — and a single-type field with no rows.
310     *
311     * @return list<array<string, mixed>>
312     */
313    private function entriesFields(Controls $controls): array
314    {
315        $image = Control::image()->resolve($controls)->array();
316        $text = static fn(string $name, string $label, int $width = 100): array => [
317            'name' => $name,
318            'label' => $label,
319            'type' => Text::class,
320            'control' => ['name' => 'text', 'props' => []],
321            'width' => $width,
322        ];
323        $person = [
324            'type' => 'App\\Styleguide\\Person',
325            'label' => 'Person',
326            'fields' => [
327                [
328                    'name' => 'photo',
329                    'label' => 'Photo',
330                    'type' => Image::class,
331                    'control' => $image,
332                    'limit' => ['min' => 0, 'max' => 1],
333                    'width' => 34,
334                ],
335                $text('name', 'Name', 66) + ['required' => true],
336                $text('role', 'Role', 66),
337            ],
338            'fieldsets' => [],
339        ];
340        $quote = [
341            'type' => 'App\\Styleguide\\Quote',
342            'label' => 'Quote',
343            'fields' => [
344                [
345                    'name' => 'text',
346                    'label' => 'Quote',
347                    'type' => Textarea::class,
348                    'control' => ['name' => 'textarea', 'props' => []],
349                ],
350                $text('author', 'Author'),
351            ],
352            'fieldsets' => [],
353        ];
354
355        return [
356            [
357                'name' => 'team',
358                'label' => 'Team',
359                'control' => [
360                    'name' => 'entries',
361                    'props' => ['entryTypes' => [$person, $quote], 'min' => 0],
362                ],
363                'description' => 'Rows collapse to a summary; the summary follows the form while typing.',
364            ],
365            [
366                'name' => 'teamEmpty',
367                'label' => 'Team — empty, one type',
368                'control' => [
369                    'name' => 'entries',
370                    'props' => ['entryTypes' => [$person], 'min' => 0, 'max' => 3],
371                ],
372            ],
373        ];
374    }
375
376    /**
377     * @return array<string, array<string, mixed>>
378     */
379    private function entriesContent(): array
380    {
381        $person = static fn(string $uid, ?string $photo, string $name, string $role): array => [
382            'uid' => $uid,
383            'type' => 'App\\Styleguide\\Person',
384            'fields' => [
385                'photo' => ['value' => ['zxx' => $photo === null ? [] : [['uid' => $photo]]]],
386                'name' => ['value' => ['zxx' => $name]],
387                'role' => ['value' => ['zxx' => $role]],
388            ],
389        ];
390
391        return [
392            'team' => [
393                'value' => [
394                    'zxx' => [
395                        $person('sg-person-1', 'sg-cover', 'Anja Reinhardt', 'Head brewer'),
396                        $person('sg-person-2', null, 'Sofia Mendes', ''),
397                        [
398                            'uid' => 'sg-quote-1',
399                            'type' => 'App\\Styleguide\\Quote',
400                            'fields' => [
401                                'text' => ['value' => ['zxx' => str_repeat(
402                                    'A quote long enough to be cut short in the summary line. ',
403                                    3,
404                                )]],
405                                'author' => ['value' => ['zxx' => 'Anonymous']],
406                            ],
407                        ],
408                    ],
409                ],
410            ],
411            'teamEmpty' => ['value' => ['zxx' => []]],
412        ];
413    }
414
415    /**
416     * Blocks descriptors: a one-column symmetric field (a quiet list whose
417     * richtext and heading translate inside the row) and a twelve-column
418     * asymmetric field (bordered cells, one list per locale) in the shape
419     * `Field\Blocks::control()` emits.
420     *
421     * @return list<array<string, mixed>>
422     */
423    private function blocksFields(Controls $controls): array
424    {
425        $richtext = Control::richtext()->resolve($controls)->array();
426        $image = Control::image()->resolve($controls)->array();
427        $video = Control::video()->resolve($controls)->array();
428        $meta = Control::group([
429            ['key' => 'class', 'label' => 'CSS class', 'control' => Control::text()],
430            ['key' => 'id', 'label' => 'Element ID', 'control' => Control::text()],
431        ])->array();
432        $aspect = Control::group([
433            [
434                'key' => 'aspectRatioX',
435                'label' => 'Aspect ratio width',
436                'control' => Control::number(step: 1, min: 1),
437                'width' => 50,
438            ],
439            [
440                'key' => 'aspectRatioY',
441                'label' => 'Aspect ratio height',
442                'control' => Control::number(step: 1, min: 1),
443                'width' => 50,
444            ],
445        ])->array();
446        $types = static fn(bool $translate): array => [
447            [
448                'type' => Builtin\RichText::class,
449                'handle' => 'richtext',
450                'label' => 'Rich text',
451                // Mirrors Blocks::blockTypeProperties(): one visible field, no label.
452                'labels' => false,
453                'fields' => [
454                    [
455                        'name' => 'text',
456                        'label' => 'Rich text',
457                        'type' => RichText::class,
458                        'control' => $richtext,
459                        'translate' => $translate,
460                        'tools' => array_map(static fn(Tool $tool): string => $tool->value, Tool::DEFAULT),
461                        'richtextClasses' => (object) [],
462                        'richtextStyles' => (object) [],
463                    ],
464                ],
465                'fieldsets' => [],
466            ],
467            [
468                'type' => Builtin\Heading::class,
469                'handle' => 'heading',
470                'label' => 'Heading',
471                // Two visible fields, so the labels stay.
472                'labels' => true,
473                'fields' => [
474                    [
475                        'name' => 'text',
476                        'label' => 'Heading text',
477                        'type' => Text::class,
478                        'control' => ['name' => 'text', 'props' => []],
479                        'translate' => $translate,
480                        'width' => 75,
481                    ],
482                    [
483                        'name' => 'level',
484                        'label' => 'Level',
485                        'type' => Option::class,
486                        'control' => ['name' => 'option', 'props' => []],
487                        'options' => ['1', '2', '3', '4', '5', '6'],
488                        'width' => 25,
489                    ],
490                ],
491                'fieldsets' => [],
492            ],
493            [
494                'type' => Builtin\Text::class,
495                'handle' => 'text',
496                'label' => 'Plain text',
497                'labels' => false,
498                'fields' => [
499                    [
500                        'name' => 'text',
501                        'label' => 'Plain text',
502                        'type' => Textarea::class,
503                        'control' => ['name' => 'textarea', 'props' => []],
504                        'placeholder' => 'Write…',
505                        'translate' => $translate,
506                    ],
507                ],
508                'fieldsets' => [],
509            ],
510            [
511                'type' => Builtin\Youtube::class,
512                'handle' => 'youtube',
513                'label' => 'YouTube video',
514                'labels' => false,
515                'fields' => [
516                    [
517                        'name' => 'video',
518                        'label' => 'YouTube video',
519                        'type' => Youtube::class,
520                        'control' => ['name' => 'youtube', 'props' => []],
521                        'placeholder' => 'YouTube URL or video id',
522                        'metaControl' => $aspect,
523                    ],
524                ],
525                'fieldsets' => [],
526            ],
527            [
528                'type' => Builtin\Iframe::class,
529                'handle' => 'iframe',
530                'label' => 'Iframe',
531                'labels' => false,
532                'fields' => [
533                    [
534                        'name' => 'code',
535                        'label' => 'Iframe',
536                        'type' => Iframe::class,
537                        'control' => ['name' => 'iframe', 'props' => []],
538                        'placeholder' => 'Paste the embed code',
539                    ],
540                ],
541                'fieldsets' => [],
542            ],
543            [
544                'type' => Builtin\Images::class,
545                'handle' => 'images',
546                'label' => __('block:images'),
547                'labels' => false,
548                'fields' => [[
549                    'name' => 'images',
550                    'label' => __('block:images'),
551                    'control' => $image,
552                    'translate' => $translate,
553                ]],
554            ],
555            [
556                'type' => Builtin\Video::class,
557                'handle' => 'video',
558                'label' => __('block:video'),
559                'labels' => false,
560                'fields' => [[
561                    'name' => 'video',
562                    'label' => __('block:video'),
563                    'control' => $video,
564                    'translate' => $translate,
565                ]],
566            ],
567            [
568                'type' => Builtin\Image::class,
569                'handle' => 'image',
570                'label' => 'Single image',
571                // Mirrors Blocks::blockTypeProperties(): one visible field, no label.
572                'labels' => false,
573                'fields' => [
574                    [
575                        'name' => 'image',
576                        'label' => 'Image',
577                        'type' => Image::class,
578                        'control' => $image,
579                        'limit' => ['min' => 0, 'max' => 1],
580                        'translate' => $translate,
581                    ],
582                ],
583                'fieldsets' => [],
584            ],
585        ];
586
587        return [
588            [
589                'name' => 'story',
590                'label' => 'Story — one column, translated in the row',
591                'control' => [
592                    'name' => 'blocks',
593                    'props' => [
594                        'blockTypes' => $types(true),
595                        'commonTypes' => [Builtin\RichText::class, Builtin\Image::class],
596                        'columns' => 1,
597                        'min' => 1,
598                        'responsive' => 'stack',
599                        'meta' => $meta,
600                    ],
601                ],
602                'translate' => true,
603                'translateMode' => 'symmetric',
604                'description' => 'Explicit common choices: Rich text and Image. More blocks opens the complete searchable catalog.',
605            ],
606            [
607                'name' => 'grid',
608                'label' => 'Grid — twelve columns, one list per locale',
609                'control' => [
610                    'name' => 'blocks',
611                    'props' => [
612                        'blockTypes' => $types(false),
613                        'columns' => 12,
614                        'min' => 2,
615                        'responsive' => 'stack',
616                        'meta' => $meta,
617                    ],
618                ],
619                'translate' => true,
620                'translateMode' => 'asymmetric',
621                'description' => 'Default first-six menu and all eight types in the catalog. Drag an edge to resize; the gear holds layout settings.',
622            ],
623            [
624                'name' => 'singleBlock',
625                'label' => 'One type — direct insertion',
626                'control' => Control::blocks()
627                    ->prop('blockTypes', [$types(false)[2]])
628                    ->array(),
629            ],
630            [
631                'name' => 'fewBlocks',
632                'label' => 'Small catalog — no redundant More action',
633                'control' => Control::blocks()
634                    ->prop('blockTypes', array_slice($types(false), 0, 3))
635                    ->array(),
636            ],
637            [
638                'name' => 'noBlocks',
639                'label' => 'No allowed types — insertion unavailable',
640                'control' => Control::blocks()->prop('blockTypes', [])->array(),
641            ],
642        ];
643    }
644
645    /**
646     * @return array<string, array<string, mixed>>
647     */
648    private function blocksContent(): array
649    {
650        $doc = static fn(string $text): array => [
651            'type' => 'doc',
652            'content' => [['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => $text]]]],
653        ];
654        $layout = static fn(int $colspan, int $rowspan = 1, int $indent = 0): array => [
655            'colspan' => $colspan,
656            'rowspan' => $rowspan,
657            'indent' => $indent,
658        ];
659        $richtext = static fn(string $uid, array $layout, array $value): array => [
660            'uid' => $uid,
661            'type' => Builtin\RichText::class,
662            'layout' => $layout,
663            'fields' => [
664                'text' => [
665                    'type' => RichText::class,
666                    'format' => Envelope::FORMAT,
667                    'version' => Envelope::VERSION,
668                    'value' => $value,
669                ],
670            ],
671        ];
672        $heading = static fn(string $uid, array $layout, array $text, string $level): array => [
673            'uid' => $uid,
674            'type' => Builtin\Heading::class,
675            'layout' => $layout,
676            'fields' => [
677                'text' => ['type' => Text::class, 'value' => $text],
678                'level' => ['type' => Option::class, 'value' => ['zxx' => $level]],
679            ],
680        ];
681        $image = static fn(string $uid, array $layout, string $asset): array => [
682            'uid' => $uid,
683            'type' => Builtin\Image::class,
684            'layout' => $layout,
685            'fields' => ['image' => ['type' => Image::class, 'value' => ['zxx' => [['uid' => $asset]]]]],
686        ];
687        $text = static fn(string $uid, array $layout, array $value): array => [
688            'uid' => $uid,
689            'type' => Builtin\Text::class,
690            'layout' => $layout,
691            'fields' => ['text' => ['type' => Textarea::class, 'value' => $value]],
692        ];
693        $youtube = static fn(string $uid, array $layout, string $id): array => [
694            'uid' => $uid,
695            'type' => Builtin\Youtube::class,
696            'layout' => $layout,
697            'fields' => [
698                'video' => [
699                    'type' => Youtube::class,
700                    'value' => ['zxx' => $id],
701                    'meta' => ['aspectRatioX' => ['zxx' => 16], 'aspectRatioY' => ['zxx' => 9]],
702                ],
703            ],
704        ];
705        $iframe = static fn(string $uid, array $layout, string $code): array => [
706            'uid' => $uid,
707            'type' => Builtin\Iframe::class,
708            'layout' => $layout,
709            'fields' => ['code' => ['type' => Iframe::class, 'value' => ['zxx' => $code]]],
710        ];
711
712        return [
713            'story' => [
714                'value' => [
715                    'zxx' => [
716                        $heading('sg-story-1', $layout(1), ['en' => 'The brewhouse', 'de' => 'Das Sudhaus'], '2'),
717                        $richtext('sg-story-2', $layout(1), [
718                            'en' => $doc('The new mash tun arrives in autumn.'),
719                            'de' => $doc('Die neue Maischepfanne wird im Herbst eingebaut.'),
720                        ]),
721                        $text('sg-story-3', $layout(1), [
722                            'en' => "Opening hours\nTuesday to Saturday, 10 to 18.",
723                            'de' => "Öffnungszeiten\nDienstag bis Samstag, 10 bis 18 Uhr.",
724                        ]),
725                        $image('sg-story-4', $layout(1), 'sg-cover'),
726                    ],
727                ],
728            ],
729            'grid' => [
730                'value' => [
731                    'en' => [
732                        $image('sg-grid-1', $layout(4, 2), self::galleryUid(1)),
733                        $richtext('sg-grid-2', $layout(8), ['zxx' => $doc('Eight columns beside a two-row image.')]),
734                        $richtext('sg-grid-3', $layout(8), ['zxx' => $doc('The second row of the same pair.')]),
735                        $heading('sg-grid-4', $layout(6, 1, 3), ['zxx' => 'Centered by an indent of three'], '3'),
736                        $richtext('sg-grid-5', $layout(4), ['zxx' => $doc('A third.')]),
737                        $richtext('sg-grid-6', $layout(4), ['zxx' => $doc('Another third.')]),
738                        $richtext('sg-grid-7', $layout(4), ['zxx' => $doc('And the last third.')]),
739                        $youtube('sg-grid-8', $layout(6), 'dQw4w9WgXcQ'),
740                        $iframe(
741                            'sg-grid-9',
742                            $layout(6),
743                            '<iframe src="https://example.org/embed" title="Map"></iframe>',
744                        ),
745                    ],
746                    'de' => [
747                        $heading('sg-grid-8', $layout(12), ['zxx' => 'Die deutsche Liste'], '2'),
748                        $richtext('sg-grid-9', $layout(6), ['zxx' => $doc('Eine eigene Liste je Sprache.')]),
749                    ],
750                ],
751                'meta' => [],
752            ],
753        ];
754    }
755
756    /**
757     * @return array<string, array<string, mixed>>
758     */
759    private function mediaContent(): array
760    {
761        $gallery = [];
762
763        for ($i = 1; $i <= self::GALLERY_SIZE; $i++) {
764            $gallery[] = ['uid' => self::galleryUid($i)];
765        }
766
767        $gallery[2]['meta'] = ['alt' => ['zxx' => 'Bottling line at full speed']];
768
769        return [
770            'cover' => [
771                'value' => [
772                    'zxx' => [[
773                        'uid' => 'sg-cover',
774                        'meta' => ['alt' => [
775                            'en' => 'Copper kettles in the brewhouse',
776                            'de' => 'Kupferkessel im Sudhaus',
777                        ]],
778                    ]],
779                ],
780            ],
781            'coverEmpty' => ['value' => ['zxx' => []]],
782            'gallery' => ['value' => ['zxx' => $gallery]],
783            'galleryEmpty' => ['value' => ['zxx' => []]],
784        ];
785    }
786
787    private static function galleryUid(int $i): string
788    {
789        return sprintf('sg-gallery-%02d', $i);
790    }
791
792    /**
793     * Catalog rows for the fixture uids. The thumbnails are inline SVG
794     * plates in shifting hues, so the samples need no files on disk.
795     *
796     * @return array<string, array<string, mixed>>
797     */
798    private function mediaAssets(): array
799    {
800        $plate = static function (int $hue, string $filename, int $width, int $height, int $bytes): array {
801            $svg = sprintf(
802                '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3">'
803                    . '<rect width="4" height="3" fill="hsl(%d, 35%%, 62%%)"/></svg>',
804                $hue,
805            );
806            $url = 'data:image/svg+xml,' . rawurlencode($svg);
807
808            return [
809                'filename' => $filename,
810                'url' => $url,
811                'thumbUrl' => $url,
812                'kind' => 'image',
813                'mime' => 'image/jpeg',
814                'width' => $width,
815                'height' => $height,
816                'bytes' => $bytes,
817            ];
818        };
819
820        $assets = ['sg-cover' => $plate(28, 'sudhaus-kupferkessel.jpg', 2400, 1600, 862208)];
821
822        for ($i = 1; $i <= self::GALLERY_SIZE; $i++) {
823            $assets[self::galleryUid($i)] = $plate(
824                ($i * 47) % 360,
825                sprintf('brauerei-rundgang-%02d.jpg', $i),
826                1800,
827                1200,
828                300000 + ($i * 41213),
829            );
830        }
831
832        return $assets;
833    }
834
835    /**
836     * @return array<string, array<string, mixed>>
837     */
838    private function richtextContent(): array
839    {
840        $doc = static fn(string $heading, string $text): array => [
841            'type' => 'doc',
842            'content' => [
843                [
844                    'type' => 'heading',
845                    'attrs' => ['level' => 2],
846                    'content' => [['type' => 'text', 'text' => $heading]],
847                ],
848                ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => $text]]],
849            ],
850        ];
851
852        return [
853            'rtDefault' => [
854                'value' => ['zxx' => $doc('Sudhaus', 'Die neue Maischepfanne wird im Herbst eingebaut.')],
855                'format' => Envelope::FORMAT,
856                'version' => Envelope::VERSION,
857            ],
858            'rtTrimmed' => [
859                'value' => ['zxx' => $doc('Presse', 'Nur Fett, Kursiv, Link und die Quelltextansicht.')],
860                'format' => Envelope::FORMAT,
861                'version' => Envelope::VERSION,
862            ],
863        ];
864    }
865
866    /**
867     * Everything `node/inspector` needs, in the shape the editor passes it:
868     * toggles, route paths per locale, the handle and the fact rows of an
869     * existing node.
870     *
871     * @return array<string, mixed>
872     */
873    private function inspector(): array
874    {
875        return [
876            'node' => [
877                'uid' => 'node-4f21c8',
878                'handle' => 'sudhaus',
879                'published' => true,
880                'hidden' => false,
881                'paths' => ['en' => '/en/brewery/brewhouse', 'de' => '/brauerei/sudhaus'],
882                'type' => ['label' => 'Page'],
883            ],
884            'locales' => [
885                ['id' => 'en', 'title' => 'English'],
886                ['id' => 'de', 'title' => 'Deutsch'],
887            ],
888            'defaultLocale' => 'en',
889            'routable' => true,
890            'renderable' => true,
891            'pathsUrl' => null,
892            'generatedPaths' => [],
893            'meta' => ['created' => 'Aug 11, 2026', 'editor' => 'M. Keller'],
894        ];
895    }
896
897    /**
898     * Listing rows in the shape `collection/row` expects, covering the states a
899     * real collection rarely shows all at once: tree depth, a collapsed branch,
900     * the last child of a branch, and each status.
901     *
902     * @return list<array<string, mixed>>
903     */
904    private function rows(): array
905    {
906        $row = static fn(array $overrides): array => array_merge([
907            'uid' => 'styleguide',
908            'depth' => 0,
909            'last' => false,
910            'expanded' => false,
911            'published' => true,
912            'hasChildren' => false,
913            'childrenUrl' => null,
914            'focusedChildrenUrl' => null,
915            'childCreateLinks' => [],
916            'status' => [['kind' => 'published', 'label' => 'Published']],
917            'cells' => [],
918        ], $overrides);
919
920        $cells = static fn(string $title, string $type, string $changed): array => [
921            ['class' => 'is-bold', 'label' => 'Title', 'value' => $title, 'editUrl' => '#'],
922            ['class' => '', 'label' => 'Type', 'value' => $type, 'editUrl' => null],
923            ['class' => '', 'label' => 'Modified', 'value' => $changed, 'editUrl' => null],
924        ];
925
926        return [
927            $row([
928                'expanded' => true,
929                'hasChildren' => true,
930                'childrenUrl' => '#',
931                'focusedChildrenUrl' => '#',
932                'childCreateLinks' => [['url' => '#', 'name' => 'Page']],
933                'cells' => $cells('Brauerei', 'Page', 'Aug 11, 2026, 10:25 PM'),
934            ]),
935            $row([
936                'depth' => 1,
937                'childrenUrl' => '#',
938                'status' => [['kind' => 'draft', 'label' => 'Draft']],
939                'published' => false,
940                'cells' => $cells('Sudhaus', 'Page', 'Aug 11, 2026, 10:25 PM'),
941            ]),
942            $row([
943                'depth' => 2,
944                'last' => true,
945                'status' => [['kind' => 'hidden', 'label' => 'Hidden']],
946                'published' => false,
947                'cells' => $cells(
948                    'A title long enough that it has to be cut off somewhere',
949                    'Page',
950                    'Aug 11, 2026, 10:25 PM',
951                ),
952            ]),
953            $row([
954                'depth' => 1,
955                'last' => true,
956                'status' => [['kind' => 'locked', 'label' => 'Locked']],
957                'cells' => $cells('Presse', 'Page', 'Aug 11, 2026, 10:25 PM'),
958            ]),
959        ];
960    }
961
962    /**
963     * @return array<string, array{value: array<string, mixed>}>
964     */
965    private function content(): array
966    {
967        return [
968            'title' => ['value' => ['zxx' => 'Sudhaus wird modernisiert']],
969            'teaser' => ['value' => [
970                'en' => 'The brewhouse gets a new mash tun.',
971                'de' => 'Das Sudhaus bekommt eine neue Maischepfanne.',
972            ]],
973            'category' => ['value' => ['zxx' => 'news']],
974            'weight' => ['value' => ['zxx' => 20]],
975            'featured' => ['value' => ['zxx' => true]],
976            'locked' => ['value' => ['zxx' => 'node-4f21c8']],
977            'overflow' => ['value' => ['zxx' => '']],
978        ];
979    }
980}