@getvitops/emdash 1.0.0-alpha.0 → 2.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +279 -3
  2. package/README.md +66 -21
  3. package/dist/index.d.mts +82 -18
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +1626 -152
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +16 -13
  8. package/src/admin/DesignSystemEditorPage.tsx +15 -42
  9. package/src/astro/blocks/Accordion.astro +39 -0
  10. package/src/astro/blocks/ActionLink.astro +4 -2
  11. package/src/astro/blocks/Callout.astro +73 -0
  12. package/src/astro/blocks/Cards.astro +96 -0
  13. package/src/astro/blocks/Carousel.astro +25 -8
  14. package/src/astro/blocks/Embed.astro +67 -0
  15. package/src/astro/blocks/Entries.astro +30 -0
  16. package/src/astro/blocks/Form.astro +194 -0
  17. package/src/astro/blocks/Gallery.astro +40 -0
  18. package/src/astro/blocks/Hero.astro +115 -0
  19. package/src/astro/blocks/ImageCompare.astro +5 -2
  20. package/src/astro/blocks/Logos.astro +48 -0
  21. package/src/astro/blocks/Marquee.astro +38 -0
  22. package/src/astro/blocks/Nav.astro +85 -0
  23. package/src/astro/blocks/NavTree.astro +48 -0
  24. package/src/astro/blocks/Pricing.astro +66 -0
  25. package/src/astro/blocks/Reviews.astro +122 -0
  26. package/src/astro/blocks/RichText.astro +14 -0
  27. package/src/astro/blocks/Section.astro +84 -0
  28. package/src/astro/blocks/Stats.astro +59 -0
  29. package/src/astro/blocks/Steps.astro +48 -0
  30. package/src/astro/blocks/embed.test.ts +43 -0
  31. package/src/astro/blocks/embed.ts +84 -0
  32. package/src/astro/blocks/form.sample.ts +33 -0
  33. package/src/astro/blocks/form.test.ts +55 -0
  34. package/src/astro/blocks/form.ts +158 -0
  35. package/src/astro/blocks/head.stub.ts +2 -0
  36. package/src/astro/blocks/nav.test.ts +51 -0
  37. package/src/astro/blocks/nav.ts +65 -0
  38. package/src/astro/blocks/render.container.test.ts +570 -0
  39. package/src/astro/blocks/tone.test.ts +53 -0
  40. package/src/astro/blocks/tone.ts +122 -0
  41. package/src/astro/blocks/value.test.ts +56 -0
  42. package/src/astro/blocks/value.ts +103 -1
  43. package/src/astro/index.ts +40 -0
  44. package/src/admin/core-editor.d.ts +0 -7
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
- import { definePlugin, pluginResponse } from "emdash";
1
+ import { definePlugin, getMenus, pluginResponse } from "emdash";
2
2
  import { iconMap } from "@getvitops/utils";
3
3
  import { designSystemPath, formatConfig, getAt, isConfig, mergePatch, resolveInput, setAt } from "@getvitops/generator/config";
4
4
  import { buildReverseIndex, patchToOverrideCss, validateColorContract } from "@getvitops/generator/tokens";
5
5
  import { commitFile } from "@getvitops/utils/git";
6
6
 
7
- //#region src/icon-options.ts
7
+ // src/icon-options.ts
8
8
  /**
9
9
  * Semantic icon names, as Block Kit combobox options.
10
10
  *
@@ -25,8 +25,165 @@ const SEMANTIC_ICON_OPTIONS = Object.keys(iconMap.fa7).sort().map((name) => ({
25
25
  value: name
26
26
  }));
27
27
 
28
- //#endregion
29
- //#region src/blocks.ts
28
+ // src/astro/blocks/tone.ts
29
+ /**
30
+ * The shared `tone` convention (UPSTREAM finding 21): every block that colours anything
31
+ * takes one `tone` and, optionally, one `color`.
32
+ *
33
+ * - **`tone`** — a role name from the site's `colors.roles` (`brand-primary`, `danger`, a
34
+ * consumer-named surface role…), or a semantic name. Role first, then its semantic
35
+ * fallbacks, then `neutral`.
36
+ * - **`color`** — the escape hatch: a palette hue (`amber`) or hue and step (`amber-300`).
37
+ * Wins over `tone` when set. Contrast is NOT checked for it — the generator's contrast
38
+ * contract covers roles, not arbitrary hue/step pairs — so it is for decoration.
39
+ *
40
+ * **Why CSS custom properties, not classes.** A class name built from a field value
41
+ * (`bg-${tone}`) is invisible to Tailwind's scanner, so it generates nothing in that
42
+ * format. The resolver instead returns an inline `style` of `--tone-*` properties that
43
+ * the framework's `.tone` pattern (patterns/tone.css) reads. Each property is a `var()`
44
+ * chain, so the fallback happens in CSS at render time and this module needs NO copy of
45
+ * the site's config — a tone the site never defined degrades to `neutral`, not to
46
+ * nothing.
47
+ *
48
+ * The inputs are editor-supplied strings that land inside a `style` attribute, so both are
49
+ * validated against a closed identifier grammar before use; anything else is dropped.
50
+ */
51
+ /** Semantic names every site is expected to define (`REQUIRED_ROLES`), plus common aliases. */
52
+ const SEMANTIC_TONES = [
53
+ "neutral",
54
+ "info",
55
+ "success",
56
+ "warning",
57
+ "danger"
58
+ ];
59
+ /**
60
+ * When a role is absent, which others to try before `neutral`. Covers the framework's own
61
+ * vocabulary (`ui-*`, `brand-*`) so a site that names only some of them still gets the
62
+ * nearest relative rather than a grey block.
63
+ */
64
+ const FALLBACKS = {
65
+ "brand-primary": ["ui-primary", "info"],
66
+ "brand-secondary": ["ui-secondary", "info"],
67
+ "ui-primary": ["info"],
68
+ "ui-secondary": ["info"],
69
+ "ui-accent": ["warning"],
70
+ surface: ["neutral"],
71
+ error: ["danger"],
72
+ notice: ["warning"],
73
+ note: ["info"],
74
+ it: ["ui-primary", "info"],
75
+ mk: ["ui-accent", "warning"]
76
+ };
77
+ const ROLE = /^[a-z][a-z0-9-]{0,40}$/;
78
+ const HUE = /^([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*?)(?:-(\d{2,3}))?$/;
79
+ /** The role chain for a tone: itself, its relatives, then `neutral`. Pure and exported for tests. */
80
+ function toneChain(tone) {
81
+ const name = typeof tone === "string" ? tone.trim().toLowerCase() : "";
82
+ if (!ROLE.test(name)) return ["neutral"];
83
+ return [.../* @__PURE__ */ new Set([
84
+ name,
85
+ ...FALLBACKS[name] ?? [],
86
+ "neutral"
87
+ ])];
88
+ }
89
+ /** `var(--a, var(--b, …))` over a chain of token names; the last link has no fallback. */
90
+ function chain(tokens) {
91
+ return tokens.reduceRight((inner, token) => inner ? `var(${token}, ${inner})` : `var(${token})`, "");
92
+ }
93
+ /**
94
+ * Resolve a block's `tone` / `color` fields.
95
+ *
96
+ * Returns an empty style when neither is set, so an unconfigured block carries no tone at
97
+ * all (rather than a neutral one) and stays transparent.
98
+ */
99
+ function resolveTone(tone, color) {
100
+ const hasTone = typeof tone === "string" && tone.trim() !== "";
101
+ const hue = typeof color === "string" ? HUE.exec(color.trim().toLowerCase()) : null;
102
+ if (hue) {
103
+ const [, name, step] = hue;
104
+ const at = (s) => `var(--color-${name}-${s})`;
105
+ return {
106
+ className: "tone",
107
+ style: [
108
+ `--tone-bg: ${at("100")}`,
109
+ `--tone-fg: ${at("900")}`,
110
+ `--tone-border: ${at("300")}`,
111
+ `--tone-solid: ${at(step ?? "600")}`,
112
+ `--tone-on-solid: #fff`
113
+ ].join("; ")
114
+ };
115
+ }
116
+ if (!hasTone) return {
117
+ className: "tone",
118
+ style: ""
119
+ };
120
+ const roles = toneChain(tone);
121
+ const of = (prefix, suffix = "") => chain(roles.map((r) => `--color-${prefix}-${r}${suffix}`));
122
+ return {
123
+ className: "tone",
124
+ style: [
125
+ `--tone-bg: ${of("bg", "-muted")}`,
126
+ `--tone-fg: ${of("text")}`,
127
+ `--tone-border: ${of("border")}`,
128
+ `--tone-solid: ${chain([...roles.map((r) => `--color-bg-${r}-solid`), ...roles.map((r) => `--color-bg-${r}`)])}`,
129
+ `--tone-on-solid: ${chain(roles.map((r) => `--color-text-on-${r}`))}`
130
+ ].join("; ")
131
+ };
132
+ }
133
+
134
+ // src/blocks.ts
135
+ /** Route that fills every tone select with the site's own roles — see `index.ts`. */
136
+ const TONES_ROUTE = "tones/list";
137
+ /** Route that lists the site's EmDash menus for `vitops.nav`'s `menu` select — see `menus-route.ts`. */
138
+ const MENUS_ROUTE = "menus/list";
139
+ /**
140
+ * The tone options an editor can always pick, before the site adds its own roles.
141
+ * Static because Block Kit needs a non-empty `options` list even when `optionsRoute` is set
142
+ * (it is the fallback if the route fails, and what non-block-editor renderers read).
143
+ */
144
+ const STATIC_TONE_OPTIONS = [{
145
+ label: "None",
146
+ value: ""
147
+ }, ...SEMANTIC_TONES.map((t) => ({
148
+ label: t[0].toUpperCase() + t.slice(1),
149
+ value: t
150
+ }))];
151
+ /**
152
+ * The shared tone pair (UPSTREAM 21; resolution lives in `astro/blocks/tone.ts`): a `tone`
153
+ * select that offers the site's roles, and a `color` escape hatch naming a palette hue.
154
+ * Spread into any block that colours something.
155
+ */
156
+ function toneFields() {
157
+ return [{
158
+ type: "select",
159
+ action_id: "tone",
160
+ label: "Tone (a colour role from the site)",
161
+ initial_value: "",
162
+ options: STATIC_TONE_OPTIONS,
163
+ optionsRoute: TONES_ROUTE
164
+ }, {
165
+ type: "text_input",
166
+ action_id: "color",
167
+ label: "Palette colour instead of a tone (e.g. amber or amber-300)"
168
+ }];
169
+ }
170
+ /** Heading level for a block's own heading; `h1` is reserved for the page title. */
171
+ const levelField = (initial) => ({
172
+ type: "select",
173
+ action_id: "level",
174
+ label: "Heading level",
175
+ initial_value: initial,
176
+ options: [
177
+ "2",
178
+ "3",
179
+ "4",
180
+ "5",
181
+ "6"
182
+ ].map((n) => ({
183
+ label: `h${n}`,
184
+ value: n
185
+ }))
186
+ });
30
187
  const BLOCKS = [
31
188
  {
32
189
  /**
@@ -93,226 +250,1384 @@ const BLOCKS = [
93
250
  },
94
251
  {
95
252
  type: "select",
96
- action_id: "role",
97
- label: "Colour role",
98
- initial_value: "",
253
+ action_id: "role",
254
+ label: "Colour role",
255
+ initial_value: "",
256
+ options: [
257
+ {
258
+ label: "Default",
259
+ value: ""
260
+ },
261
+ {
262
+ label: "Success",
263
+ value: "success"
264
+ },
265
+ {
266
+ label: "Danger",
267
+ value: "danger"
268
+ },
269
+ {
270
+ label: "Warning",
271
+ value: "warning"
272
+ },
273
+ {
274
+ label: "Info",
275
+ value: "info"
276
+ }
277
+ ]
278
+ },
279
+ {
280
+ type: "toggle",
281
+ action_id: "newTab",
282
+ label: "Open in a new tab",
283
+ initial_value: false
284
+ }
285
+ ]
286
+ },
287
+ {
288
+ type: "vitops.imageCompare",
289
+ label: "Image compare",
290
+ placeholder: "Before/after image slider",
291
+ fields: [
292
+ {
293
+ type: "text_input",
294
+ action_id: "before",
295
+ label: "Before image URL"
296
+ },
297
+ {
298
+ type: "text_input",
299
+ action_id: "beforeAlt",
300
+ label: "Before alt text"
301
+ },
302
+ {
303
+ type: "text_input",
304
+ action_id: "beforeLabel",
305
+ label: "Before label",
306
+ initial_value: "Before"
307
+ },
308
+ {
309
+ type: "text_input",
310
+ action_id: "after",
311
+ label: "After image URL"
312
+ },
313
+ {
314
+ type: "text_input",
315
+ action_id: "afterAlt",
316
+ label: "After alt text"
317
+ },
318
+ {
319
+ type: "text_input",
320
+ action_id: "afterLabel",
321
+ label: "After label",
322
+ initial_value: "After"
323
+ }
324
+ ]
325
+ },
326
+ {
327
+ type: "vitops.copyButton",
328
+ label: "Copy snippet",
329
+ icon: "code",
330
+ placeholder: "Text with a copy-to-clipboard button",
331
+ fields: [
332
+ {
333
+ type: "text_input",
334
+ action_id: "text",
335
+ label: "Text to copy",
336
+ multiline: true
337
+ },
338
+ {
339
+ type: "text_input",
340
+ action_id: "label",
341
+ label: "Button label",
342
+ initial_value: "Copy"
343
+ },
344
+ {
345
+ type: "select",
346
+ action_id: "display",
347
+ label: "Display",
348
+ initial_value: "code-block",
349
+ options: [{
350
+ label: "Code block",
351
+ value: "code-block"
352
+ }, {
353
+ label: "Inline",
354
+ value: "inline"
355
+ }]
356
+ }
357
+ ]
358
+ },
359
+ {
360
+ type: "vitops.banner",
361
+ label: "Banner",
362
+ placeholder: "Announcement or notice",
363
+ fields: [
364
+ {
365
+ type: "text_input",
366
+ action_id: "message",
367
+ label: "Message",
368
+ multiline: true
369
+ },
370
+ {
371
+ type: "select",
372
+ action_id: "tone",
373
+ label: "Tone",
374
+ initial_value: "info",
375
+ options: [
376
+ {
377
+ label: "Info",
378
+ value: "info"
379
+ },
380
+ {
381
+ label: "Success",
382
+ value: "success"
383
+ },
384
+ {
385
+ label: "Warning",
386
+ value: "warning"
387
+ },
388
+ {
389
+ label: "Danger",
390
+ value: "danger"
391
+ }
392
+ ]
393
+ },
394
+ {
395
+ type: "toggle",
396
+ action_id: "dismissible",
397
+ label: "Dismissible",
398
+ initial_value: true
399
+ }
400
+ ]
401
+ },
402
+ {
403
+ type: "vitops.details",
404
+ label: "Disclosure",
405
+ placeholder: "Collapsible details/summary",
406
+ fields: [
407
+ {
408
+ type: "text_input",
409
+ action_id: "summary",
410
+ label: "Summary (always visible)"
411
+ },
412
+ {
413
+ type: "text_input",
414
+ action_id: "body",
415
+ label: "Body (revealed)",
416
+ multiline: true
417
+ },
418
+ {
419
+ type: "toggle",
420
+ action_id: "open",
421
+ label: "Open by default",
422
+ initial_value: false
423
+ }
424
+ ]
425
+ },
426
+ {
427
+ type: "vitops.carousel",
428
+ label: "Carousel",
429
+ placeholder: "Image carousel",
430
+ category: "Media",
431
+ fields: [
432
+ {
433
+ type: "repeater",
434
+ action_id: "slides",
435
+ label: "Slides",
436
+ item_label: "slide",
437
+ min_items: 1,
438
+ fields: [{
439
+ type: "text_input",
440
+ action_id: "src",
441
+ label: "Image URL"
442
+ }, {
443
+ type: "text_input",
444
+ action_id: "alt",
445
+ label: "Alt text"
446
+ }]
447
+ },
448
+ {
449
+ type: "text_input",
450
+ action_id: "images",
451
+ label: "Image URLs, one per line (legacy — use Slides)",
452
+ multiline: true
453
+ },
454
+ {
455
+ type: "text_input",
456
+ action_id: "alts",
457
+ label: "Alt texts, one per line (legacy — use Slides)",
458
+ multiline: true
459
+ },
460
+ {
461
+ type: "text_input",
462
+ action_id: "label",
463
+ label: "Accessible name",
464
+ initial_value: "Carousel"
465
+ },
466
+ {
467
+ type: "number_input",
468
+ action_id: "autoplay",
469
+ label: "Autoplay interval (ms, 0 = off)",
470
+ initial_value: 0
471
+ },
472
+ {
473
+ type: "toggle",
474
+ action_id: "loop",
475
+ label: "Loop (clones the slides — autoplay implies it)",
476
+ initial_value: false
477
+ },
478
+ {
479
+ type: "text_input",
480
+ action_id: "aspect",
481
+ label: "Slide aspect ratio",
482
+ initial_value: "16 / 9"
483
+ }
484
+ ]
485
+ },
486
+ {
487
+ type: "vitops.gallery",
488
+ label: "Gallery",
489
+ placeholder: "Thumbnail grid that opens a lightbox carousel",
490
+ category: "Media",
491
+ fields: [
492
+ {
493
+ type: "text_input",
494
+ action_id: "label",
495
+ label: "Accessible name",
496
+ initial_value: "Gallery"
497
+ },
498
+ {
499
+ type: "repeater",
500
+ action_id: "images",
501
+ label: "Images",
502
+ item_label: "image",
503
+ min_items: 1,
504
+ fields: [
505
+ {
506
+ type: "text_input",
507
+ action_id: "src",
508
+ label: "Image URL"
509
+ },
510
+ {
511
+ type: "text_input",
512
+ action_id: "alt",
513
+ label: "Alt text"
514
+ },
515
+ {
516
+ type: "text_input",
517
+ action_id: "caption",
518
+ label: "Caption"
519
+ }
520
+ ]
521
+ },
522
+ {
523
+ type: "text_input",
524
+ action_id: "aspect",
525
+ label: "Thumbnail aspect ratio",
526
+ initial_value: "1"
527
+ },
528
+ {
529
+ type: "toggle",
530
+ action_id: "loop",
531
+ label: "Wrap around at the ends",
532
+ initial_value: false
533
+ }
534
+ ]
535
+ },
536
+ {
537
+ type: "vitops.cards",
538
+ label: "Cards",
539
+ placeholder: "A grid of cards",
540
+ category: "Sections",
541
+ fields: [
542
+ {
543
+ type: "repeater",
544
+ action_id: "cards",
545
+ label: "Cards",
546
+ item_label: "card",
547
+ min_items: 1,
548
+ fields: [
549
+ {
550
+ type: "text_input",
551
+ action_id: "title",
552
+ label: "Title"
553
+ },
554
+ {
555
+ type: "text_input",
556
+ action_id: "body",
557
+ label: "Text (write [link text](/url) for an inline link)",
558
+ multiline: true
559
+ },
560
+ {
561
+ type: "text_input",
562
+ action_id: "image",
563
+ label: "Image URL"
564
+ },
565
+ {
566
+ type: "text_input",
567
+ action_id: "imageAlt",
568
+ label: "Image alt text"
569
+ },
570
+ {
571
+ type: "text_input",
572
+ action_id: "href",
573
+ label: "Link URL (makes the whole card clickable)"
574
+ },
575
+ {
576
+ type: "text_input",
577
+ action_id: "linkLabel",
578
+ label: "Link text",
579
+ initial_value: "Read more"
580
+ },
581
+ {
582
+ type: "text_input",
583
+ action_id: "checklist",
584
+ label: "Checklist (panel style; one item per line)",
585
+ multiline: true
586
+ },
587
+ {
588
+ type: "text_input",
589
+ action_id: "ctaLabel",
590
+ label: "Button text (panel style)"
591
+ },
592
+ {
593
+ type: "text_input",
594
+ action_id: "ctaHref",
595
+ label: "Button URL (panel style)"
596
+ }
597
+ ]
598
+ },
599
+ {
600
+ type: "select",
601
+ action_id: "variant",
602
+ label: "Style",
603
+ initial_value: "default",
604
+ options: [{
605
+ label: "Cards",
606
+ value: "default"
607
+ }, {
608
+ label: "Panels (tinted, with checklist and button)",
609
+ value: "panel"
610
+ }]
611
+ },
612
+ levelField("3"),
613
+ ...toneFields(),
614
+ {
615
+ type: "select",
616
+ action_id: "columns",
617
+ label: "Columns",
618
+ initial_value: "3",
619
+ options: [
620
+ {
621
+ label: "1",
622
+ value: "1"
623
+ },
624
+ {
625
+ label: "2",
626
+ value: "2"
627
+ },
628
+ {
629
+ label: "3",
630
+ value: "3"
631
+ },
632
+ {
633
+ label: "4",
634
+ value: "4"
635
+ }
636
+ ]
637
+ }
638
+ ]
639
+ },
640
+ {
641
+ type: "vitops.accordion",
642
+ label: "Accordion",
643
+ placeholder: "A group of collapsible sections",
644
+ category: "Sections",
645
+ fields: [{
646
+ type: "repeater",
647
+ action_id: "items",
648
+ label: "Items",
649
+ item_label: "item",
650
+ min_items: 1,
651
+ fields: [
652
+ {
653
+ type: "text_input",
654
+ action_id: "summary",
655
+ label: "Summary (always visible)"
656
+ },
657
+ {
658
+ type: "text_input",
659
+ action_id: "body",
660
+ label: "Body (revealed)",
661
+ multiline: true
662
+ },
663
+ {
664
+ type: "toggle",
665
+ action_id: "open",
666
+ label: "Open by default",
667
+ initial_value: false
668
+ }
669
+ ]
670
+ }, {
671
+ type: "toggle",
672
+ action_id: "exclusive",
673
+ label: "Only one open at a time",
674
+ initial_value: false
675
+ }]
676
+ },
677
+ {
678
+ type: "vitops.hero",
679
+ label: "Hero",
680
+ placeholder: "Page opener: heading, text, buttons and an optional image",
681
+ category: "Sections",
682
+ fields: [
683
+ {
684
+ type: "text_input",
685
+ action_id: "eyebrow",
686
+ label: "Eyebrow (small line above)"
687
+ },
688
+ {
689
+ type: "text_input",
690
+ action_id: "heading",
691
+ label: "Heading (wrap words in *stars* to accent them)"
692
+ },
693
+ levelField("2"),
694
+ {
695
+ type: "text_input",
696
+ action_id: "body",
697
+ label: "Text",
698
+ multiline: true
699
+ },
700
+ {
701
+ type: "text_input",
702
+ action_id: "primaryLabel",
703
+ label: "Main button text"
704
+ },
705
+ {
706
+ type: "text_input",
707
+ action_id: "primaryHref",
708
+ label: "Main button URL"
709
+ },
710
+ {
711
+ type: "text_input",
712
+ action_id: "secondaryLabel",
713
+ label: "Second link text"
714
+ },
715
+ {
716
+ type: "text_input",
717
+ action_id: "secondaryHref",
718
+ label: "Second link URL"
719
+ },
720
+ {
721
+ type: "text_input",
722
+ action_id: "image",
723
+ label: "Image URL (split layout)"
724
+ },
725
+ {
726
+ type: "text_input",
727
+ action_id: "imageAlt",
728
+ label: "Image alt text"
729
+ },
730
+ {
731
+ type: "select",
732
+ action_id: "layout",
733
+ label: "Layout",
734
+ initial_value: "centered",
735
+ options: [{
736
+ label: "Centered",
737
+ value: "centered"
738
+ }, {
739
+ label: "Text and image side by side",
740
+ value: "split"
741
+ }]
742
+ },
743
+ ...toneFields(),
744
+ {
745
+ type: "repeater",
746
+ action_id: "cards",
747
+ label: "Option cards under the buttons (optional)",
748
+ item_label: "card",
749
+ fields: [
750
+ {
751
+ type: "text_input",
752
+ action_id: "title",
753
+ label: "Title"
754
+ },
755
+ {
756
+ type: "text_input",
757
+ action_id: "body",
758
+ label: "Text (write [link text](/url) for an inline link)",
759
+ multiline: true
760
+ },
761
+ {
762
+ type: "text_input",
763
+ action_id: "href",
764
+ label: "Link URL (makes the card clickable)"
765
+ },
766
+ {
767
+ type: "text_input",
768
+ action_id: "linkLabel",
769
+ label: "Link text",
770
+ initial_value: "Learn more"
771
+ }
772
+ ]
773
+ }
774
+ ]
775
+ },
776
+ {
777
+ type: "vitops.marquee",
778
+ label: "Marquee",
779
+ placeholder: "A continuously scrolling row (logos, quotes, badges)",
780
+ category: "Media",
781
+ fields: [
782
+ {
783
+ type: "repeater",
784
+ action_id: "items",
785
+ label: "Items",
786
+ item_label: "item",
787
+ min_items: 1,
788
+ fields: [
789
+ {
790
+ type: "text_input",
791
+ action_id: "text",
792
+ label: "Text"
793
+ },
794
+ {
795
+ type: "text_input",
796
+ action_id: "image",
797
+ label: "Image URL (shown instead of text)"
798
+ },
799
+ {
800
+ type: "text_input",
801
+ action_id: "alt",
802
+ label: "Image alt text"
803
+ }
804
+ ]
805
+ },
806
+ {
807
+ type: "toggle",
808
+ action_id: "pauseOnHover",
809
+ label: "Pause on hover",
810
+ initial_value: true
811
+ },
812
+ {
813
+ type: "toggle",
814
+ action_id: "reverse",
815
+ label: "Scroll the other way",
816
+ initial_value: false
817
+ }
818
+ ]
819
+ },
820
+ {
821
+ type: "vitops.logos",
822
+ label: "Logos",
823
+ placeholder: "A still row of partner or client logos",
824
+ category: "Media",
825
+ fields: [
826
+ {
827
+ type: "text_input",
828
+ action_id: "label",
829
+ label: "Accessible name",
830
+ initial_value: "Partners"
831
+ },
832
+ {
833
+ type: "repeater",
834
+ action_id: "logos",
835
+ label: "Logos",
836
+ item_label: "logo",
837
+ min_items: 1,
838
+ fields: [
839
+ {
840
+ type: "text_input",
841
+ action_id: "image",
842
+ label: "Image URL"
843
+ },
844
+ {
845
+ type: "text_input",
846
+ action_id: "alt",
847
+ label: "Name (alt text)"
848
+ },
849
+ {
850
+ type: "text_input",
851
+ action_id: "href",
852
+ label: "Link URL (optional)"
853
+ }
854
+ ]
855
+ },
856
+ {
857
+ type: "toggle",
858
+ action_id: "grayscale",
859
+ label: "Grey until hovered",
860
+ initial_value: false
861
+ },
862
+ {
863
+ type: "select",
864
+ action_id: "size",
865
+ label: "Logo height",
866
+ initial_value: "md",
867
+ options: [
868
+ {
869
+ label: "Small",
870
+ value: "sm"
871
+ },
872
+ {
873
+ label: "Medium",
874
+ value: "md"
875
+ },
876
+ {
877
+ label: "Large",
878
+ value: "lg"
879
+ }
880
+ ]
881
+ }
882
+ ]
883
+ },
884
+ {
885
+ type: "vitops.steps",
886
+ label: "Steps",
887
+ placeholder: "A numbered sequence joined by a connector line",
888
+ category: "Sections",
889
+ fields: [
890
+ {
891
+ type: "repeater",
892
+ action_id: "steps",
893
+ label: "Steps",
894
+ item_label: "step",
895
+ min_items: 1,
896
+ fields: [{
897
+ type: "text_input",
898
+ action_id: "title",
899
+ label: "Title"
900
+ }, {
901
+ type: "text_input",
902
+ action_id: "text",
903
+ label: "Text (write [link text](/url) for an inline link)",
904
+ multiline: true
905
+ }]
906
+ },
907
+ levelField("3"),
908
+ {
909
+ type: "select",
910
+ action_id: "layout",
911
+ label: "Layout",
912
+ initial_value: "column",
913
+ options: [{
914
+ label: "Stacked",
915
+ value: "column"
916
+ }, {
917
+ label: "Side by side when wide",
918
+ value: "row"
919
+ }]
920
+ },
921
+ ...toneFields()
922
+ ]
923
+ },
924
+ {
925
+ type: "vitops.stats",
926
+ label: "Stats",
927
+ placeholder: "Headline figures that count up, or labelled tiles",
928
+ category: "Sections",
929
+ fields: [
930
+ {
931
+ type: "repeater",
932
+ action_id: "stats",
933
+ label: "Figures",
934
+ item_label: "figure",
935
+ min_items: 1,
936
+ fields: [
937
+ {
938
+ type: "text_input",
939
+ action_id: "value",
940
+ label: "Figure (e.g. 82% or 11 hrs)"
941
+ },
942
+ {
943
+ type: "text_input",
944
+ action_id: "label",
945
+ label: "Label"
946
+ },
947
+ {
948
+ type: "text_input",
949
+ action_id: "group",
950
+ label: "Group (tiles style; small line above)"
951
+ },
952
+ {
953
+ type: "text_input",
954
+ action_id: "delta",
955
+ label: "Change (tiles style; e.g. +3 this week)"
956
+ },
957
+ {
958
+ type: "select",
959
+ action_id: "state",
960
+ label: "Change is",
961
+ initial_value: "",
962
+ options: [
963
+ {
964
+ label: "Neutral",
965
+ value: ""
966
+ },
967
+ {
968
+ label: "Good (up)",
969
+ value: "up"
970
+ },
971
+ {
972
+ label: "Bad (down)",
973
+ value: "down"
974
+ }
975
+ ]
976
+ }
977
+ ]
978
+ },
979
+ {
980
+ type: "select",
981
+ action_id: "variant",
982
+ label: "Style",
983
+ initial_value: "figures",
984
+ options: [{
985
+ label: "Figures",
986
+ value: "figures"
987
+ }, {
988
+ label: "Tiles",
989
+ value: "tiles"
990
+ }]
991
+ },
992
+ {
993
+ type: "toggle",
994
+ action_id: "animate",
995
+ label: "Count up when scrolled into view",
996
+ initial_value: true
997
+ },
998
+ ...toneFields()
999
+ ]
1000
+ },
1001
+ {
1002
+ type: "vitops.callout",
1003
+ label: "Callout",
1004
+ placeholder: "A call-to-action band (not a dismissible banner)",
1005
+ category: "Sections",
1006
+ fields: [
1007
+ {
1008
+ type: "text_input",
1009
+ action_id: "heading",
1010
+ label: "Heading"
1011
+ },
1012
+ levelField("2"),
1013
+ {
1014
+ type: "text_input",
1015
+ action_id: "body",
1016
+ label: "Text (write [link text](/url) for an inline link)",
1017
+ multiline: true
1018
+ },
1019
+ {
1020
+ type: "text_input",
1021
+ action_id: "primaryLabel",
1022
+ label: "Main button text"
1023
+ },
1024
+ {
1025
+ type: "text_input",
1026
+ action_id: "primaryHref",
1027
+ label: "Main button URL"
1028
+ },
1029
+ {
1030
+ type: "text_input",
1031
+ action_id: "secondaryLabel",
1032
+ label: "Second link text"
1033
+ },
1034
+ {
1035
+ type: "text_input",
1036
+ action_id: "secondaryHref",
1037
+ label: "Second link URL"
1038
+ },
1039
+ {
1040
+ type: "select",
1041
+ action_id: "align",
1042
+ label: "Layout",
1043
+ initial_value: "start",
1044
+ options: [{
1045
+ label: "Message left, buttons right",
1046
+ value: "start"
1047
+ }, {
1048
+ label: "Centered",
1049
+ value: "center"
1050
+ }]
1051
+ },
1052
+ {
1053
+ type: "toggle",
1054
+ action_id: "solid",
1055
+ label: "Fill with the solid colour",
1056
+ initial_value: false
1057
+ },
1058
+ ...toneFields()
1059
+ ]
1060
+ },
1061
+ {
1062
+ type: "vitops.entries",
1063
+ label: "Entries table",
1064
+ placeholder: "Rows of term/value pairs: a stacked list that becomes a table when wide",
1065
+ category: "Sections",
1066
+ fields: [
1067
+ {
1068
+ type: "repeater",
1069
+ action_id: "entries",
1070
+ label: "Entries",
1071
+ item_label: "entry",
1072
+ min_items: 1,
1073
+ fields: [{
1074
+ type: "text_input",
1075
+ action_id: "heading",
1076
+ label: "Heading (row name)"
1077
+ }, {
1078
+ type: "text_input",
1079
+ action_id: "pairs",
1080
+ label: "Details (one per line: Term | Value)",
1081
+ multiline: true
1082
+ }]
1083
+ },
1084
+ levelField("3"),
1085
+ {
1086
+ type: "text_input",
1087
+ action_id: "breakpoint",
1088
+ label: "Becomes a table at this width",
1089
+ initial_value: "40rem"
1090
+ }
1091
+ ]
1092
+ },
1093
+ {
1094
+ type: "vitops.pricing",
1095
+ label: "Pricing",
1096
+ placeholder: "Plan cards with aligned feature rows",
1097
+ category: "Sections",
1098
+ fields: [{
1099
+ type: "repeater",
1100
+ action_id: "plans",
1101
+ label: "Plans",
1102
+ item_label: "plan",
1103
+ min_items: 1,
1104
+ fields: [
1105
+ {
1106
+ type: "text_input",
1107
+ action_id: "name",
1108
+ label: "Plan name"
1109
+ },
1110
+ {
1111
+ type: "text_input",
1112
+ action_id: "price",
1113
+ label: "Price (e.g. $49)"
1114
+ },
1115
+ {
1116
+ type: "text_input",
1117
+ action_id: "period",
1118
+ label: "Period (e.g. per month)"
1119
+ },
1120
+ {
1121
+ type: "text_input",
1122
+ action_id: "features",
1123
+ label: "Features (one per line)",
1124
+ multiline: true
1125
+ },
1126
+ {
1127
+ type: "text_input",
1128
+ action_id: "ctaLabel",
1129
+ label: "Button text"
1130
+ },
1131
+ {
1132
+ type: "text_input",
1133
+ action_id: "ctaHref",
1134
+ label: "Button URL"
1135
+ },
1136
+ {
1137
+ type: "toggle",
1138
+ action_id: "featured",
1139
+ label: "Highlight this plan",
1140
+ initial_value: false
1141
+ }
1142
+ ]
1143
+ }, levelField("3")]
1144
+ },
1145
+ {
1146
+ type: "vitops.embed",
1147
+ label: "Embed (video or map)",
1148
+ placeholder: "A video or map that loads only after the visitor allows it",
1149
+ category: "Embeds",
1150
+ fields: [
1151
+ {
1152
+ type: "select",
1153
+ action_id: "provider",
1154
+ label: "Provider",
1155
+ initial_value: "youtube",
1156
+ options: [
1157
+ {
1158
+ label: "YouTube",
1159
+ value: "youtube"
1160
+ },
1161
+ {
1162
+ label: "Vimeo",
1163
+ value: "vimeo"
1164
+ },
1165
+ {
1166
+ label: "Map (Google or OpenStreetMap embed address)",
1167
+ value: "map"
1168
+ },
1169
+ {
1170
+ label: "Other (https address)",
1171
+ value: "custom"
1172
+ }
1173
+ ]
1174
+ },
1175
+ {
1176
+ type: "text_input",
1177
+ action_id: "url",
1178
+ label: "Address"
1179
+ },
1180
+ {
1181
+ type: "text_input",
1182
+ action_id: "title",
1183
+ label: "Title (required: it names the frame for screen readers)"
1184
+ },
1185
+ {
1186
+ type: "select",
1187
+ action_id: "category",
1188
+ label: "Consent category it waits on",
1189
+ initial_value: "marketing",
99
1190
  options: [
100
1191
  {
101
- label: "Default",
102
- value: ""
103
- },
104
- {
105
- label: "Success",
106
- value: "success"
107
- },
108
- {
109
- label: "Danger",
110
- value: "danger"
1192
+ label: "Marketing",
1193
+ value: "marketing"
111
1194
  },
112
1195
  {
113
- label: "Warning",
114
- value: "warning"
1196
+ label: "Preferences",
1197
+ value: "preferences"
115
1198
  },
116
1199
  {
117
- label: "Info",
118
- value: "info"
1200
+ label: "Analytics",
1201
+ value: "analytics"
119
1202
  }
120
1203
  ]
121
1204
  },
122
1205
  {
123
- type: "toggle",
124
- action_id: "newTab",
125
- label: "Open in a new tab",
126
- initial_value: false
1206
+ type: "text_input",
1207
+ action_id: "ratio",
1208
+ label: "Aspect ratio",
1209
+ initial_value: "16 / 9"
127
1210
  }
128
1211
  ]
129
1212
  },
130
1213
  {
131
- type: "vitops.imageCompare",
132
- label: "Image compare",
133
- placeholder: "Before/after image slider",
1214
+ type: "vitops.form",
1215
+ label: "Contact form",
1216
+ placeholder: "An enquiry form that emails you (and your CRM) and works without JavaScript",
1217
+ category: "Forms",
134
1218
  fields: [
135
1219
  {
136
1220
  type: "text_input",
137
- action_id: "before",
138
- label: "Before image URL"
1221
+ action_id: "heading",
1222
+ label: "Heading"
139
1223
  },
1224
+ levelField("2"),
140
1225
  {
141
1226
  type: "text_input",
142
- action_id: "beforeAlt",
143
- label: "Before alt text"
1227
+ action_id: "intro",
1228
+ label: "Text above the form",
1229
+ multiline: true
144
1230
  },
145
1231
  {
146
- type: "text_input",
147
- action_id: "beforeLabel",
148
- label: "Before label",
149
- initial_value: "Before"
1232
+ type: "repeater",
1233
+ action_id: "fields",
1234
+ label: "Fields",
1235
+ item_label: "field",
1236
+ min_items: 1,
1237
+ fields: [
1238
+ {
1239
+ type: "text_input",
1240
+ action_id: "label",
1241
+ label: "Label (what the visitor sees)"
1242
+ },
1243
+ {
1244
+ type: "text_input",
1245
+ action_id: "name",
1246
+ label: "Name in the message (optional: letters, digits, underscores)"
1247
+ },
1248
+ {
1249
+ type: "select",
1250
+ action_id: "type",
1251
+ label: "Kind",
1252
+ initial_value: "text",
1253
+ options: [
1254
+ {
1255
+ label: "Short text",
1256
+ value: "text"
1257
+ },
1258
+ {
1259
+ label: "Email address",
1260
+ value: "email"
1261
+ },
1262
+ {
1263
+ label: "Phone number",
1264
+ value: "tel"
1265
+ },
1266
+ {
1267
+ label: "Long text",
1268
+ value: "textarea"
1269
+ },
1270
+ {
1271
+ label: "Choose one",
1272
+ value: "select"
1273
+ },
1274
+ {
1275
+ label: "Tick box",
1276
+ value: "checkbox"
1277
+ }
1278
+ ]
1279
+ },
1280
+ {
1281
+ type: "toggle",
1282
+ action_id: "required",
1283
+ label: "Required",
1284
+ initial_value: false
1285
+ },
1286
+ {
1287
+ type: "text_input",
1288
+ action_id: "placeholder",
1289
+ label: "Example text"
1290
+ },
1291
+ {
1292
+ type: "text_input",
1293
+ action_id: "help",
1294
+ label: "Help text under the field"
1295
+ },
1296
+ {
1297
+ type: "text_input",
1298
+ action_id: "options",
1299
+ label: "Choices (one per line; for \"Choose one\" only)",
1300
+ multiline: true
1301
+ }
1302
+ ]
150
1303
  },
151
1304
  {
152
1305
  type: "text_input",
153
- action_id: "after",
154
- label: "After image URL"
1306
+ action_id: "submitLabel",
1307
+ label: "Button text",
1308
+ initial_value: "Send"
155
1309
  },
156
1310
  {
157
1311
  type: "text_input",
158
- action_id: "afterAlt",
159
- label: "After alt text"
1312
+ action_id: "successMessage",
1313
+ label: "Message after sending",
1314
+ initial_value: "Thanks. Your message has been sent."
160
1315
  },
161
1316
  {
162
1317
  type: "text_input",
163
- action_id: "afterLabel",
164
- label: "After label",
165
- initial_value: "After"
166
- }
167
- ]
168
- },
169
- {
170
- type: "vitops.copyButton",
171
- label: "Copy snippet",
172
- icon: "code",
173
- placeholder: "Text with a copy-to-clipboard button",
174
- fields: [
1318
+ action_id: "errorMessage",
1319
+ label: "Message if sending fails"
1320
+ },
175
1321
  {
176
1322
  type: "text_input",
177
- action_id: "text",
178
- label: "Text to copy",
179
- multiline: true
1323
+ action_id: "noJsMessage",
1324
+ label: "Message when JavaScript is off (shown only if the site has a spam check)"
180
1325
  },
181
1326
  {
182
1327
  type: "text_input",
183
- action_id: "label",
184
- label: "Button label",
185
- initial_value: "Copy"
1328
+ action_id: "action",
1329
+ label: "Where the form posts (leave empty for /api/track)"
186
1330
  },
187
- {
188
- type: "select",
189
- action_id: "display",
190
- label: "Display",
191
- initial_value: "code-block",
192
- options: [{
193
- label: "Code block",
194
- value: "code-block"
195
- }, {
196
- label: "Inline",
197
- value: "inline"
198
- }]
199
- }
1331
+ ...toneFields()
200
1332
  ]
201
1333
  },
202
1334
  {
203
- type: "vitops.banner",
204
- label: "Banner",
205
- placeholder: "Announcement or notice",
1335
+ /**
1336
+ * Google reviews from the committed `.vitops/reviews.json` cache (`vitops reviews`).
1337
+ *
1338
+ * The data is a BUILD-TIME import (`virtual:getvitops/reviews`, from the `vitops()` Astro
1339
+ * integration), never a filesystem read: the renderer runs in a Worker. So a new review
1340
+ * appears after the next `vitops reviews` run is committed and the site rebuilt, not when
1341
+ * an editor saves. Which reviews show is `selectReviews()` from `@getvitops/utils`, the
1342
+ * one place that is decided — these fields are exactly its `ReviewLocationConfig`.
1343
+ */
1344
+ type: "vitops.reviews",
1345
+ label: "Google reviews",
1346
+ icon: "star",
1347
+ placeholder: "Customer reviews from the Google Business Profile cache",
1348
+ category: "Sections",
206
1349
  fields: [
207
1350
  {
208
1351
  type: "text_input",
209
- action_id: "message",
210
- label: "Message",
211
- multiline: true
1352
+ action_id: "label",
1353
+ label: "Name for the group (read by screen readers)",
1354
+ initial_value: "Customer reviews"
1355
+ },
1356
+ {
1357
+ type: "text_input",
1358
+ action_id: "location",
1359
+ label: "Location key (organization.locations key; blank = every location in the cache)"
212
1360
  },
213
1361
  {
214
1362
  type: "select",
215
- action_id: "tone",
216
- label: "Tone",
217
- initial_value: "info",
1363
+ action_id: "layout",
1364
+ label: "Layout",
1365
+ initial_value: "grid",
218
1366
  options: [
219
1367
  {
220
- label: "Info",
221
- value: "info"
222
- },
223
- {
224
- label: "Success",
225
- value: "success"
1368
+ label: "Grid",
1369
+ value: "grid"
226
1370
  },
227
1371
  {
228
- label: "Warning",
229
- value: "warning"
1372
+ label: "Carousel",
1373
+ value: "carousel"
230
1374
  },
231
1375
  {
232
- label: "Danger",
233
- value: "danger"
1376
+ label: "Marquee (scrolling row)",
1377
+ value: "marquee"
234
1378
  }
235
1379
  ]
236
1380
  },
237
1381
  {
238
- type: "toggle",
239
- action_id: "dismissible",
240
- label: "Dismissible",
241
- initial_value: true
242
- }
243
- ]
244
- },
245
- {
246
- type: "vitops.details",
247
- label: "Disclosure",
248
- placeholder: "Collapsible details/summary",
249
- fields: [
1382
+ type: "select",
1383
+ action_id: "columns",
1384
+ label: "Columns (grid only)",
1385
+ initial_value: "3",
1386
+ options: [
1387
+ "1",
1388
+ "2",
1389
+ "3",
1390
+ "4"
1391
+ ].map((n) => ({
1392
+ label: n,
1393
+ value: n
1394
+ }))
1395
+ },
250
1396
  {
251
- type: "text_input",
252
- action_id: "summary",
253
- label: "Summary (always visible)"
1397
+ type: "number_input",
1398
+ action_id: "limit",
1399
+ label: "Most reviews to show (0 = all)",
1400
+ initial_value: 6
1401
+ },
1402
+ {
1403
+ type: "select",
1404
+ action_id: "minRating",
1405
+ label: "Lowest rating to show",
1406
+ initial_value: "4",
1407
+ options: [
1408
+ "1",
1409
+ "2",
1410
+ "3",
1411
+ "4",
1412
+ "5"
1413
+ ].map((n) => ({
1414
+ label: n === "1" ? "Any" : `${n} stars and up`,
1415
+ value: n
1416
+ }))
1417
+ },
1418
+ {
1419
+ type: "select",
1420
+ action_id: "sort",
1421
+ label: "Order",
1422
+ initial_value: "recent",
1423
+ options: [{
1424
+ label: "Newest first",
1425
+ value: "recent"
1426
+ }, {
1427
+ label: "Highest rated first",
1428
+ value: "rating"
1429
+ }]
254
1430
  },
255
1431
  {
256
1432
  type: "text_input",
257
- action_id: "body",
258
- label: "Body (revealed)",
259
- multiline: true
1433
+ action_id: "featured",
1434
+ label: "Review ids to show first (comma-separated; they ignore the rating filter)"
260
1435
  },
261
1436
  {
262
1437
  type: "toggle",
263
- action_id: "open",
264
- label: "Open by default",
1438
+ action_id: "avatars",
1439
+ label: "Show reviewers' Google photos (loads images from Google for every visitor)",
265
1440
  initial_value: false
266
- }
1441
+ },
1442
+ ...toneFields()
267
1443
  ]
268
1444
  },
269
1445
  {
270
- type: "vitops.carousel",
271
- label: "Carousel",
272
- placeholder: "Image carousel",
1446
+ /**
1447
+ * A named EmDash menu inside page content: a site map, a section's link list, a footer-style
1448
+ * set of columns. NOT the header — that is `<SiteNav>` in the layout, which owns the drawer,
1449
+ * dropdowns and breakpoints. `variant: sitenav` renders the same markup and pattern for the
1450
+ * rare page that wants a real navbar in the body.
1451
+ *
1452
+ * The menu is chosen from `menus/list` (the site's own menus) with the conventional names as
1453
+ * the static fallback, because a select needs a non-empty `options` list.
1454
+ */
1455
+ type: "vitops.nav",
1456
+ label: "Menu",
1457
+ icon: "list",
1458
+ placeholder: "An EmDash menu as a list of links",
1459
+ category: "Navigation",
273
1460
  fields: [
274
1461
  {
275
- type: "text_input",
276
- action_id: "images",
277
- label: "Image URLs (one per line)",
278
- multiline: true
1462
+ type: "select",
1463
+ action_id: "menu",
1464
+ label: "Menu",
1465
+ initial_value: "footer-sitemap",
1466
+ options: [
1467
+ {
1468
+ label: "Footer site map",
1469
+ value: "footer-sitemap"
1470
+ },
1471
+ {
1472
+ label: "Footer legal",
1473
+ value: "footer-legal"
1474
+ },
1475
+ {
1476
+ label: "Primary",
1477
+ value: "primary"
1478
+ }
1479
+ ],
1480
+ optionsRoute: MENUS_ROUTE
279
1481
  },
280
1482
  {
281
1483
  type: "text_input",
282
- action_id: "alts",
283
- label: "Alt texts (one per line, same order)",
284
- multiline: true
1484
+ action_id: "label",
1485
+ label: "Name for the navigation (read by screen readers; must differ from other menus)"
285
1486
  },
286
1487
  {
287
1488
  type: "text_input",
288
- action_id: "label",
289
- label: "Accessible name",
290
- initial_value: "Carousel"
1489
+ action_id: "heading",
1490
+ label: "Visible heading (optional)"
291
1491
  },
1492
+ levelField("2"),
292
1493
  {
293
- type: "number_input",
294
- action_id: "autoplay",
295
- label: "Autoplay interval (ms, 0 = off)",
296
- initial_value: 0
1494
+ type: "select",
1495
+ action_id: "variant",
1496
+ label: "Layout",
1497
+ initial_value: "list",
1498
+ options: [
1499
+ {
1500
+ label: "Vertical list",
1501
+ value: "list"
1502
+ },
1503
+ {
1504
+ label: "Inline row",
1505
+ value: "inline"
1506
+ },
1507
+ {
1508
+ label: "Columns (each top-level item with its sub-links)",
1509
+ value: "columns"
1510
+ },
1511
+ {
1512
+ label: "Navbar (the header pattern, in the page)",
1513
+ value: "sitenav"
1514
+ }
1515
+ ]
297
1516
  },
298
1517
  {
299
- type: "toggle",
300
- action_id: "loop",
301
- label: "Loop (clones the slides — autoplay implies it)",
302
- initial_value: false
1518
+ type: "select",
1519
+ action_id: "depth",
1520
+ label: "Levels to show",
1521
+ initial_value: "1",
1522
+ options: [
1523
+ "1",
1524
+ "2",
1525
+ "3"
1526
+ ].map((n) => ({
1527
+ label: n,
1528
+ value: n
1529
+ }))
303
1530
  },
304
- {
305
- type: "text_input",
306
- action_id: "aspect",
307
- label: "Slide aspect ratio",
308
- initial_value: "16 / 9"
309
- }
1531
+ ...toneFields()
310
1532
  ]
311
1533
  }
312
1534
  ];
1535
+ /**
1536
+ * Resolve {@link BlockSelection} against {@link BLOCKS}.
1537
+ *
1538
+ * Pure so a misconfiguration fails at plugin construction, with the offending name in the
1539
+ * message, rather than as an editor menu that is quietly missing something.
1540
+ */
1541
+ function selectBlocks(selection = {}) {
1542
+ const known = new Set(BLOCKS.map((b) => b.type));
1543
+ const check = (names, option) => {
1544
+ for (const name of names ?? []) if (!known.has(name)) throw new Error(`[vitops] blocks.${option}: unknown block '${name}'. Known: ${[...known].join(", ")}.`);
1545
+ };
1546
+ check(selection.include, "include");
1547
+ check(selection.exclude, "exclude");
1548
+ const include = selection.include ? new Set(selection.include) : null;
1549
+ const exclude = new Set(selection.exclude);
1550
+ const base = BLOCKS.filter((b) => (!include || include.has(b.type)) && !exclude.has(b.type));
1551
+ const extra = selection.extra ?? [];
1552
+ const taken = new Set(BLOCKS.map((b) => b.type));
1553
+ for (const block of extra) {
1554
+ if (taken.has(block.type)) throw new Error(`[vitops] blocks.extra: type '${block.type}' is already defined.`);
1555
+ taken.add(block.type);
1556
+ }
1557
+ return [...base, ...extra];
1558
+ }
1559
+
1560
+ // src/menus-route.ts
1561
+ /** The items the select shows: one per menu, valued by its `name` (what `getMenu()` takes). */
1562
+ function menuItems(menus) {
1563
+ const seen = /* @__PURE__ */ new Set();
1564
+ const out = [];
1565
+ for (const { name, label } of menus) {
1566
+ if (!name || seen.has(name)) continue;
1567
+ seen.add(name);
1568
+ out.push({
1569
+ id: name,
1570
+ name: label && label !== name ? `${label} (${name})` : name
1571
+ });
1572
+ }
1573
+ return out;
1574
+ }
1575
+ function createMenusRoute(list) {
1576
+ return {
1577
+ permission: "content:read",
1578
+ handler: async () => {
1579
+ try {
1580
+ return { items: menuItems(await list()) };
1581
+ } catch {
1582
+ return { items: [] };
1583
+ }
1584
+ }
1585
+ };
1586
+ }
1587
+
1588
+ // src/tones-route.ts
1589
+ /**
1590
+ * The `tones/list` plugin route: what fills every block's `tone` select with the site's own
1591
+ * colour roles (UPSTREAM 21 — "role names the admin never offered").
1592
+ *
1593
+ * EmDash's Block Kit `select` takes an `optionsRoute` for exactly this, and falls back to the
1594
+ * field's static options if the route fails or answers with no `items`, so a site that
1595
+ * configures nothing still gets the semantic names from `STATIC_TONE_OPTIONS`.
1596
+ *
1597
+ * **Where the roles come from.** Plugin code has no site config at runtime, so the site hands
1598
+ * it over: `tones: [...]` verbatim, or — if the design-system editor is already wired with the
1599
+ * site's `Config` — the `colors.roles` keys of that config, so there is nothing to keep in
1600
+ * step. A role is listed under its own name (`brand-primary`), because that is what
1601
+ * `resolveTone()` and the token names (`--color-bg-brand-primary-muted`) both use.
1602
+ */
1603
+ /** Role names of a design system or full config, or `[]` if it cannot be resolved. */
1604
+ function rolesOf(config) {
1605
+ if (!config || typeof config !== "object") return [];
1606
+ try {
1607
+ const { designSystem } = resolveInput(config, { siteEnv: "production" });
1608
+ return Object.keys(designSystem.colors?.roles ?? {});
1609
+ } catch {
1610
+ return [];
1611
+ }
1612
+ }
1613
+ /** The items the select shows: the site's roles first, then any semantic name it lacks. */
1614
+ function toneItems(roles) {
1615
+ return [{
1616
+ id: "",
1617
+ name: "None"
1618
+ }, ...[.../* @__PURE__ */ new Set([...roles, ...SEMANTIC_TONES])].map((id) => ({
1619
+ id,
1620
+ name: id.replace(/-/g, " ")
1621
+ }))];
1622
+ }
1623
+ function createTonesRoute(roles) {
1624
+ return {
1625
+ permission: "content:read",
1626
+ handler: async () => ({ items: toneItems(roles()) })
1627
+ };
1628
+ }
313
1629
 
314
- //#endregion
315
- //#region src/design-system-route.ts
1630
+ // src/design-system-route.ts
316
1631
  /**
317
1632
  * The `vitops.design-system` plugin route — what `<wc-theme-editor>` probes
318
1633
  * and POSTs its patch to when mounted inside the EmDash admin (see this
@@ -453,8 +1768,164 @@ async function designSystemOverrideFragment(ctx, opts) {
453
1768
  };
454
1769
  }
455
1770
 
456
- //#endregion
457
- //#region src/hosting.ts
1771
+ // src/block-types.ts
1772
+ /** Block-type slugs may not contain a dot, so `vitops.section` is stored as `vitops_section`. */
1773
+ const SECTION_SLUG = "vitops_section";
1774
+ const select = (slug, label, options, defaultValue) => ({
1775
+ slug,
1776
+ label,
1777
+ type: "select",
1778
+ defaultValue,
1779
+ validation: { options }
1780
+ });
1781
+ const SECTION_FIELDS = [
1782
+ {
1783
+ slug: "kicker",
1784
+ label: "Kicker (small line above the heading)",
1785
+ type: "string"
1786
+ },
1787
+ {
1788
+ slug: "heading",
1789
+ label: "Heading",
1790
+ type: "string"
1791
+ },
1792
+ select("level", "Heading level", [
1793
+ "2",
1794
+ "3",
1795
+ "4",
1796
+ "5",
1797
+ "6"
1798
+ ], "2"),
1799
+ {
1800
+ slug: "sub",
1801
+ label: "Lead-in text under the heading",
1802
+ type: "text"
1803
+ },
1804
+ {
1805
+ slug: "body",
1806
+ label: "Content (any blocks)",
1807
+ type: "portableText"
1808
+ },
1809
+ select("layout", "Layout", [
1810
+ "stack",
1811
+ "center",
1812
+ "split"
1813
+ ], "stack"),
1814
+ select("width", "Width", [
1815
+ "measure",
1816
+ "breakout",
1817
+ "spotlight",
1818
+ "fullbleed"
1819
+ ], "measure"),
1820
+ {
1821
+ slug: "tone",
1822
+ label: "Backdrop tone (a colour role from the site, e.g. surface or brand-primary)",
1823
+ type: "string"
1824
+ },
1825
+ {
1826
+ slug: "color",
1827
+ label: "Palette colour instead of a tone (e.g. amber or amber-300)",
1828
+ type: "string"
1829
+ },
1830
+ {
1831
+ slug: "solid",
1832
+ label: "Fill with the solid colour",
1833
+ type: "boolean",
1834
+ defaultValue: false
1835
+ }
1836
+ ];
1837
+ /** `vitops.section`: kicker, heading, lead-in, tone/backdrop, optional heading-left layout, and a body of blocks. */
1838
+ const SECTION_BLOCK_TYPE = {
1839
+ slug: SECTION_SLUG,
1840
+ label: "Section",
1841
+ description: "A titled page section whose body can hold any other blocks.",
1842
+ category: "Sections",
1843
+ icon: "layout",
1844
+ currentVersion: 1,
1845
+ versions: [{
1846
+ version: 1,
1847
+ fields: SECTION_FIELDS
1848
+ }]
1849
+ };
1850
+ /**
1851
+ * Every block type this plugin ships, in the shape `seed.json`'s `blockTypes` takes. Spread
1852
+ * it into the seed (and allow the slugs on the collection's `blocks` field), e.g.
1853
+ * `blockTypes: [...vitopsBlockTypes()]`.
1854
+ */
1855
+ function vitopsBlockTypes() {
1856
+ return [SECTION_BLOCK_TYPE];
1857
+ }
1858
+
1859
+ // src/blocks-doc.ts
1860
+ const cell = (s) => (s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
1861
+ function describe(f) {
1862
+ const bits = [];
1863
+ if ("options" in f && Array.isArray(f.options) && f.options.length) {
1864
+ const values = f.options.map((o) => String(o.value));
1865
+ bits.push(values.length > 8 ? `one of ${values.length} values (e.g. ${values.slice(0, 4).join(", ")})` : `one of ${values.join(", ")}`);
1866
+ }
1867
+ if ("initial_value" in f && f.initial_value !== void 0) bits.push(`default \`${String(f.initial_value)}\``);
1868
+ if (f.type === "repeater" && "min_items" in f && f.min_items) bits.push(`min ${f.min_items} rows`);
1869
+ return bits.join("; ");
1870
+ }
1871
+ function fieldRows(fields, prefix = "") {
1872
+ return fields.flatMap((f) => {
1873
+ const own = `| \`${prefix}${f.action_id}\` | ${f.type} | ${cell(f.label)} | ${cell(describe(f))} |`;
1874
+ return f.type === "repeater" ? [own, ...fieldRows(f.fields, `${f.action_id}[].`)] : [own];
1875
+ });
1876
+ }
1877
+ /**
1878
+ * The block reference an agent reads via `vitops docs blocks`: every Portable Text `_type`,
1879
+ * its fields and what each saves. Rendered from the same `BLOCKS` the editor menu uses, so
1880
+ * it cannot describe a field the editor doesn't offer.
1881
+ */
1882
+ function renderBlocksDoc(blocks = BLOCKS) {
1883
+ const out = [
1884
+ "---",
1885
+ "type: concept",
1886
+ "title: EmDash blocks",
1887
+ "description: Portable Text block types from @getvitops/emdash, with every field",
1888
+ "---",
1889
+ "",
1890
+ "# EmDash blocks",
1891
+ "",
1892
+ "Each block is a Portable Text object: `{ \"_type\": \"vitops.cards\", \"_key\": \"<unique>\", ...fields }`.",
1893
+ "Portable Text blocks are flat — they cannot contain other blocks, and a repeater row holds scalar fields only.",
1894
+ "To put blocks inside something, use the section block TYPE (end of this page), whose body is Portable Text.",
1895
+ "Give every block a unique `_key`; gallery and carousel derive their element id from it.",
1896
+ "",
1897
+ "## Colour: `tone` and `color`",
1898
+ "",
1899
+ "Every block that colours something takes the same two fields. `tone` is a colour role from the",
1900
+ "site's `colors.roles` (or a semantic name); an unknown role falls back through its semantic",
1901
+ "relatives to `neutral`. `color` is an escape hatch naming a palette hue (`amber`) or hue and step",
1902
+ "(`amber-300`) and wins over `tone`; its contrast is not checked. Renderers emit `--tone-*` custom",
1903
+ "properties, never a class built from the value. Multiline fields that accept links take",
1904
+ "`[text](/url)`; hero headings accent words wrapped in `*stars*`.",
1905
+ ""
1906
+ ];
1907
+ for (const b of blocks) {
1908
+ out.push(`## \`${b.type}\` — ${b.label}`, "");
1909
+ if (b.placeholder) out.push(`${b.placeholder}.`, "");
1910
+ if (b.fields?.length) out.push("| Field | Type | Label | Notes |", "| --- | --- | --- | --- |", ...fieldRows(b.fields), "");
1911
+ }
1912
+ out.push("## `vitops.form` needs an endpoint", "", "The form posts to `/api/track` (the `action` field overrides it), which must be an on-demand route", "built with `createConversionRoute()` from `@getvitops/astro/routes`; the emdash template ships one.", "Field names are submitted as written, except that `phone` becomes `tel` and a few names the route", "reserves (`event`, `website`, `honeypot`, `_honeypot`, `cf-turnstile-response`) are renamed.", "It emails `site.notifications.email` and writes `site.crm` when set; with neither, the route refuses", "the post rather than accept a message nobody receives. Add the spam check with", "`vitops setup turnstile`: once `site.security.turnstile.siteKey` is set the block renders", "`<wc-turnstile>`, which loads Cloudflare Turnstile only when the visitor first uses the form", "(category `necessary`, so it is not behind the consent banner). The secret lives in the", "`VITOPS_TURNSTILE_SECRET` Worker secret, never in `site.json`. Use one form per page.", "", "## Block types (the `blocks` field)", "", "Schema-level block types, not Portable Text blocks: add them to the seed (`blockTypes: [...vitopsBlockTypes()]`),", "allow the slug on the collection's `blocks` field, and render with `blockTypeComponents` from `@getvitops/emdash/astro`.", "");
1913
+ for (const t of vitopsBlockTypes()) {
1914
+ out.push(`### \`${t.slug}\` — ${t.label}`, "", `${t.description ?? ""}`, "");
1915
+ const fields = t.versions.find((v) => v.version === t.currentVersion)?.fields ?? [];
1916
+ out.push("| Field | Type | Label | Notes |", "| --- | --- | --- | --- |", ...fields.map((f) => {
1917
+ const notes = [
1918
+ f.validation?.options ? `one of ${f.validation.options.join(", ")}` : "",
1919
+ f.defaultValue !== void 0 ? `default \`${String(f.defaultValue)}\`` : "",
1920
+ f.type === "portableText" ? "accepts every `vitops.*` block above" : ""
1921
+ ].filter(Boolean);
1922
+ return `| \`${f.slug}\` | ${f.type} | ${cell(f.label)} | ${cell(notes.join("; "))} |`;
1923
+ }), "");
1924
+ }
1925
+ return out.join("\n");
1926
+ }
1927
+
1928
+ // src/hosting.ts
458
1929
  const TARGETS = ["cloudflare", "node"];
459
1930
  /**
460
1931
  * Import an optional hosting dependency. The specifier is passed as a
@@ -483,7 +1954,8 @@ async function vitopsHosting(options = {}) {
483
1954
  adapter: cloudflare(),
484
1955
  database: d1({
485
1956
  binding: cf.dbBinding ?? "DB",
486
- session: cf.session ?? "auto"
1957
+ session: cf.session ?? "auto",
1958
+ coalesce: cf.coalesce ?? true
487
1959
  }),
488
1960
  storage: r2({ binding: cf.mediaBinding ?? "MEDIA" })
489
1961
  };
@@ -513,12 +1985,10 @@ async function vitopsHosting(options = {}) {
513
1985
  throw new Error(`[vitops] unknown hosting target '${target}' (from ${process.env.HOSTING ? "HOSTING env var" : "options.target"}). Valid targets: ${TARGETS.join(", ")}.`);
514
1986
  }
515
1987
 
516
- //#endregion
517
- //#region package.json
518
- var version = "1.0.0-alpha.0";
1988
+ // package.json
1989
+ var version = "2.0.0-alpha.0";
519
1990
 
520
- //#endregion
521
- //#region src/index.ts
1991
+ // src/index.ts
522
1992
  /**
523
1993
  * @getvitops/emdash — EmDash CMS native plugin for the Vitops design system.
524
1994
  *
@@ -578,6 +2048,7 @@ function createPlugin(options = {}) {
578
2048
  const wcBase = options.wcBase ?? "/vitops";
579
2049
  const editor = options.designSystemEditor || void 0;
580
2050
  const fragmentsEnabled = scripts === "fragments" || !!editor;
2051
+ const roles = options.tones ?? rolesOf(editor?.config);
581
2052
  const pageFragments = async (_event, ctx) => {
582
2053
  const contributions = [];
583
2054
  if (scripts === "fragments") contributions.push({
@@ -605,7 +2076,7 @@ function createPlugin(options = {}) {
605
2076
  ...editor ? { allowedHosts: ["api.github.com"] } : {},
606
2077
  ...fragmentsEnabled ? { hooks: { "page:fragments": pageFragments } } : {},
607
2078
  admin: {
608
- portableTextBlocks: BLOCKS,
2079
+ portableTextBlocks: selectBlocks(options.blocks),
609
2080
  ...editor ? {
610
2081
  entry: "@getvitops/emdash/admin",
611
2082
  pages: [{
@@ -615,10 +2086,13 @@ function createPlugin(options = {}) {
615
2086
  }]
616
2087
  } : {}
617
2088
  },
618
- ...editor ? { routes: { "design-system": createDesignSystemRoute(editor) } } : {}
2089
+ routes: {
2090
+ [TONES_ROUTE]: createTonesRoute(() => roles),
2091
+ [MENUS_ROUTE]: createMenusRoute(() => getMenus()),
2092
+ ...editor ? { "design-system": createDesignSystemRoute(editor) } : {}
2093
+ }
619
2094
  });
620
2095
  }
621
2096
 
622
- //#endregion
623
- export { BLOCKS, createPlugin, createPlugin as default, vitopsEmdash, vitopsHosting };
2097
+ export { BLOCKS, SECTION_BLOCK_TYPE, SECTION_SLUG, createPlugin, createPlugin as default, renderBlocksDoc, resolveTone, selectBlocks, toneChain, vitopsBlockTypes, vitopsEmdash, vitopsHosting };
624
2098
  //# sourceMappingURL=index.mjs.map