Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
99.86% |
739 / 740 |
|
94.12% |
16 / 17 |
CRAP | |
0.00% |
0 / 1 |
| Styleguide | |
99.86% |
739 / 740 |
|
94.12% |
16 / 17 |
36 | |
0.00% |
0 / 1 |
| index | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
1 | |||
| tokenGroups | |
98.04% |
50 / 51 |
|
0.00% |
0 / 1 |
17 | |||
| fields | |
100.00% |
45 / 45 |
|
100.00% |
1 / 1 |
1 | |||
| fieldset | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| richtextFields | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
1 | |||
| mediaFields | |
100.00% |
33 / 33 |
|
100.00% |
1 / 1 |
1 | |||
| entriesFields | |
100.00% |
58 / 58 |
|
100.00% |
1 / 1 |
1 | |||
| entriesContent | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
2 | |||
| blocksFields | |
100.00% |
214 / 214 |
|
100.00% |
1 / 1 |
1 | |||
| blocksContent | |
100.00% |
103 / 103 |
|
100.00% |
1 / 1 |
1 | |||
| mediaContent | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
2 | |||
| galleryUid | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| mediaAssets | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
2 | |||
| richtextContent | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
1 | |||
| inspector | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
1 | |||
| rows | |
100.00% |
52 / 52 |
|
100.00% |
1 / 1 |
1 | |||
| content | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Controller\Panel; |
| 6 | |
| 7 | use Cosray\Block as Builtin; |
| 8 | use Cosray\Field\Control; |
| 9 | use Cosray\Field\Control\Registry as Controls; |
| 10 | use Cosray\Field\Iframe; |
| 11 | use Cosray\Field\Image; |
| 12 | use Cosray\Field\Option; |
| 13 | use Cosray\Field\RichText; |
| 14 | use Cosray\Field\Text; |
| 15 | use Cosray\Field\Textarea; |
| 16 | use Cosray\Field\Youtube; |
| 17 | use Cosray\Locales; |
| 18 | use Cosray\Panel\System; |
| 19 | use Cosray\Richtext\Envelope; |
| 20 | use 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 | */ |
| 29 | final 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 | } |