@getvitops/emdash 1.0.0 → 2.0.0-alpha.1

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 (52) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/index.d.mts +34 -1
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1450 -177
  5. package/dist/index.mjs.map +1 -1
  6. package/package.json +10 -7
  7. package/src/admin/DesignSystemEditorPage.tsx +15 -42
  8. package/src/astro/Nothing.astro +3 -0
  9. package/src/astro/WidgetArea.astro +91 -0
  10. package/src/astro/blocks/Accordion.astro +2 -1
  11. package/src/astro/blocks/ActionLink.astro +2 -1
  12. package/src/astro/blocks/Badges.astro +67 -0
  13. package/src/astro/blocks/Banner.astro +3 -2
  14. package/src/astro/blocks/Callout.astro +68 -0
  15. package/src/astro/blocks/Cards.astro +54 -9
  16. package/src/astro/blocks/Carousel.astro +2 -0
  17. package/src/astro/blocks/CopyButton.astro +3 -2
  18. package/src/astro/blocks/Details.astro +2 -1
  19. package/src/astro/blocks/Embed.astro +68 -0
  20. package/src/astro/blocks/Entries.astro +31 -0
  21. package/src/astro/blocks/Form.astro +195 -0
  22. package/src/astro/blocks/Gallery.astro +2 -0
  23. package/src/astro/blocks/Hero.astro +78 -23
  24. package/src/astro/blocks/ImageCompare.astro +2 -1
  25. package/src/astro/blocks/Logos.astro +48 -0
  26. package/src/astro/blocks/Marquee.astro +2 -1
  27. package/src/astro/blocks/Nav.astro +85 -0
  28. package/src/astro/blocks/NavTree.astro +48 -0
  29. package/src/astro/blocks/Pricing.astro +66 -0
  30. package/src/astro/blocks/Reviews.astro +122 -0
  31. package/src/astro/blocks/RichText.astro +14 -0
  32. package/src/astro/blocks/Section.astro +82 -0
  33. package/src/astro/blocks/Stats.astro +59 -0
  34. package/src/astro/blocks/Steps.astro +48 -0
  35. package/src/astro/blocks/WidgetArea.astro +21 -0
  36. package/src/astro/blocks/attrs.test.ts +56 -0
  37. package/src/astro/blocks/attrs.ts +104 -0
  38. package/src/astro/blocks/embed.test.ts +43 -0
  39. package/src/astro/blocks/embed.ts +84 -0
  40. package/src/astro/blocks/form.sample.ts +33 -0
  41. package/src/astro/blocks/form.test.ts +55 -0
  42. package/src/astro/blocks/form.ts +158 -0
  43. package/src/astro/blocks/head.stub.ts +2 -0
  44. package/src/astro/blocks/nav.test.ts +51 -0
  45. package/src/astro/blocks/nav.ts +65 -0
  46. package/src/astro/blocks/render.container.test.ts +610 -0
  47. package/src/astro/blocks/tone.test.ts +53 -0
  48. package/src/astro/blocks/tone.ts +122 -0
  49. package/src/astro/blocks/value.test.ts +36 -1
  50. package/src/astro/blocks/value.ts +78 -1
  51. package/src/astro/index.ts +37 -0
  52. package/src/admin/core-editor.d.ts +0 -7
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { definePlugin, pluginResponse } from "emdash";
1
+ import { definePlugin, getMenus, getWidgetAreas, 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";
@@ -25,8 +25,191 @@ const SEMANTIC_ICON_OPTIONS = Object.keys(iconMap.fa7).sort().map((name) => ({
25
25
  value: name
26
26
  }));
27
27
 
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
+
28
134
  // src/blocks.ts
29
- const BLOCKS = [
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
+ /** Route that lists the site's widget areas for `vitops.widgetArea`'s `area` select. */
171
+ const WIDGET_AREAS_ROUTE = "widget-areas/list";
172
+ /**
173
+ * The escape hatches every block carries, appended last by `BLOCKS` (see `astro/blocks/attrs.ts`
174
+ * for how each is validated and why `style` takes custom properties only).
175
+ */
176
+ function commonFields() {
177
+ return [
178
+ {
179
+ type: "text_input",
180
+ action_id: "anchor",
181
+ label: "Anchor id (so a link can point here with #id)"
182
+ },
183
+ {
184
+ type: "text_input",
185
+ action_id: "className",
186
+ label: "Extra CSS classes (with the tailwind format, only classes the site already uses)"
187
+ },
188
+ {
189
+ type: "text_input",
190
+ action_id: "style",
191
+ label: "CSS custom properties, e.g. --p-card: 2rem; --tone-bg: #fee"
192
+ }
193
+ ];
194
+ }
195
+ /** Heading level for a block's own heading; `h1` is reserved for the page title. */
196
+ const levelField = (initial) => ({
197
+ type: "select",
198
+ action_id: "level",
199
+ label: "Heading level",
200
+ initial_value: initial,
201
+ options: [
202
+ "2",
203
+ "3",
204
+ "4",
205
+ "5",
206
+ "6"
207
+ ].map((n) => ({
208
+ label: `h${n}`,
209
+ value: n
210
+ }))
211
+ });
212
+ const BASE_BLOCKS = [
30
213
  {
31
214
  /**
32
215
  * A link or button, optionally with an icon at either end.
@@ -380,70 +563,104 @@ const BLOCKS = [
380
563
  label: "Cards",
381
564
  placeholder: "A grid of cards",
382
565
  category: "Sections",
383
- fields: [{
384
- type: "repeater",
385
- action_id: "cards",
386
- label: "Cards",
387
- item_label: "card",
388
- min_items: 1,
389
- fields: [
390
- {
391
- type: "text_input",
392
- action_id: "title",
393
- label: "Title"
394
- },
395
- {
396
- type: "text_input",
397
- action_id: "body",
398
- label: "Text",
399
- multiline: true
400
- },
401
- {
402
- type: "text_input",
403
- action_id: "image",
404
- label: "Image URL"
405
- },
406
- {
407
- type: "text_input",
408
- action_id: "imageAlt",
409
- label: "Image alt text"
410
- },
411
- {
412
- type: "text_input",
413
- action_id: "href",
414
- label: "Link URL (makes the whole card clickable)"
415
- },
416
- {
417
- type: "text_input",
418
- action_id: "linkLabel",
419
- label: "Link text",
420
- initial_value: "Read more"
421
- }
422
- ]
423
- }, {
424
- type: "select",
425
- action_id: "columns",
426
- label: "Columns",
427
- initial_value: "3",
428
- options: [
429
- {
430
- label: "1",
431
- value: "1"
432
- },
433
- {
434
- label: "2",
435
- value: "2"
436
- },
437
- {
438
- label: "3",
439
- value: "3"
440
- },
441
- {
442
- label: "4",
443
- value: "4"
444
- }
445
- ]
446
- }]
566
+ fields: [
567
+ {
568
+ type: "repeater",
569
+ action_id: "cards",
570
+ label: "Cards",
571
+ item_label: "card",
572
+ min_items: 1,
573
+ fields: [
574
+ {
575
+ type: "text_input",
576
+ action_id: "title",
577
+ label: "Title"
578
+ },
579
+ {
580
+ type: "text_input",
581
+ action_id: "body",
582
+ label: "Text (write [link text](/url) for an inline link)",
583
+ multiline: true
584
+ },
585
+ {
586
+ type: "text_input",
587
+ action_id: "image",
588
+ label: "Image URL"
589
+ },
590
+ {
591
+ type: "text_input",
592
+ action_id: "imageAlt",
593
+ label: "Image alt text"
594
+ },
595
+ {
596
+ type: "text_input",
597
+ action_id: "href",
598
+ label: "Link URL (makes the whole card clickable)"
599
+ },
600
+ {
601
+ type: "text_input",
602
+ action_id: "linkLabel",
603
+ label: "Link text",
604
+ initial_value: "Read more"
605
+ },
606
+ {
607
+ type: "text_input",
608
+ action_id: "checklist",
609
+ label: "Checklist (panel style; one item per line)",
610
+ multiline: true
611
+ },
612
+ {
613
+ type: "text_input",
614
+ action_id: "ctaLabel",
615
+ label: "Button text (panel style)"
616
+ },
617
+ {
618
+ type: "text_input",
619
+ action_id: "ctaHref",
620
+ label: "Button URL (panel style)"
621
+ }
622
+ ]
623
+ },
624
+ {
625
+ type: "select",
626
+ action_id: "variant",
627
+ label: "Style",
628
+ initial_value: "default",
629
+ options: [{
630
+ label: "Cards",
631
+ value: "default"
632
+ }, {
633
+ label: "Panels (tinted, with checklist and button)",
634
+ value: "panel"
635
+ }]
636
+ },
637
+ levelField("3"),
638
+ ...toneFields(),
639
+ {
640
+ type: "select",
641
+ action_id: "columns",
642
+ label: "Columns",
643
+ initial_value: "3",
644
+ options: [
645
+ {
646
+ label: "1",
647
+ value: "1"
648
+ },
649
+ {
650
+ label: "2",
651
+ value: "2"
652
+ },
653
+ {
654
+ label: "3",
655
+ value: "3"
656
+ },
657
+ {
658
+ label: "4",
659
+ value: "4"
660
+ }
661
+ ]
662
+ }
663
+ ]
447
664
  },
448
665
  {
449
666
  type: "vitops.accordion",
@@ -496,8 +713,9 @@ const BLOCKS = [
496
713
  {
497
714
  type: "text_input",
498
715
  action_id: "heading",
499
- label: "Heading"
716
+ label: "Heading (wrap words in *stars* to accent them)"
500
717
  },
718
+ levelField("2"),
501
719
  {
502
720
  type: "text_input",
503
721
  action_id: "body",
@@ -546,6 +764,37 @@ const BLOCKS = [
546
764
  label: "Text and image side by side",
547
765
  value: "split"
548
766
  }]
767
+ },
768
+ ...toneFields(),
769
+ {
770
+ type: "repeater",
771
+ action_id: "cards",
772
+ label: "Option cards under the buttons (optional)",
773
+ item_label: "card",
774
+ fields: [
775
+ {
776
+ type: "text_input",
777
+ action_id: "title",
778
+ label: "Title"
779
+ },
780
+ {
781
+ type: "text_input",
782
+ action_id: "body",
783
+ label: "Text (write [link text](/url) for an inline link)",
784
+ multiline: true
785
+ },
786
+ {
787
+ type: "text_input",
788
+ action_id: "href",
789
+ label: "Link URL (makes the card clickable)"
790
+ },
791
+ {
792
+ type: "text_input",
793
+ action_id: "linkLabel",
794
+ label: "Link text",
795
+ initial_value: "Learn more"
796
+ }
797
+ ]
549
798
  }
550
799
  ]
551
800
  },
@@ -592,112 +841,983 @@ const BLOCKS = [
592
841
  initial_value: false
593
842
  }
594
843
  ]
595
- }
596
- ];
597
- /**
598
- * Resolve {@link BlockSelection} against {@link BLOCKS}.
599
- *
600
- * Pure so a misconfiguration fails at plugin construction, with the offending name in the
601
- * message, rather than as an editor menu that is quietly missing something.
602
- */
603
- function selectBlocks(selection = {}) {
604
- const known = new Set(BLOCKS.map((b) => b.type));
605
- const check = (names, option) => {
606
- for (const name of names ?? []) if (!known.has(name)) throw new Error(`[vitops] blocks.${option}: unknown block '${name}'. Known: ${[...known].join(", ")}.`);
607
- };
608
- check(selection.include, "include");
609
- check(selection.exclude, "exclude");
610
- const include = selection.include ? new Set(selection.include) : null;
611
- const exclude = new Set(selection.exclude);
612
- const base = BLOCKS.filter((b) => (!include || include.has(b.type)) && !exclude.has(b.type));
613
- const extra = selection.extra ?? [];
614
- const taken = new Set(BLOCKS.map((b) => b.type));
615
- for (const block of extra) {
616
- if (taken.has(block.type)) throw new Error(`[vitops] blocks.extra: type '${block.type}' is already defined.`);
617
- taken.add(block.type);
618
- }
619
- return [...base, ...extra];
620
- }
621
-
622
- // src/design-system-route.ts
623
- /**
624
- * The `vitops.design-system` plugin route — what `<wc-theme-editor>` probes
625
- * and POSTs its patch to when mounted inside the EmDash admin (see this
626
- * package's README and AGENTS.md's "EmDash plugin as the design-system
627
- * editing surface" section). Mounted at
628
- * `/_emdash/api/plugins/vitops/design-system`.
629
- *
630
- * Two responsibilities, matching the dev-only `@getvitops/vite` endpoint's
631
- * contract byte-for-byte (`GET → { writable }`, `POST { patch } → { ok } |
632
- * { errors }`) so `<wc-theme-editor>` needs no branch for which one it's
633
- * talking to:
634
- *
635
- * 1. **Immediate**: persist the patch to `ctx.settings` — read back by the
636
- * `page:fragments` hook in `index.ts`, which turns it into a site-wide
637
- * `:root` override `<style>` on every public page. This is what makes an
638
- * edit visible without a rebuild.
639
- * 2. **Canonical**: auto-commit the merged file to `site.deployment.branch`
640
- * (default `dev`) via `@getvitops/utils/git`'s `commitFile()`, so the edit
641
- * reaches the git-tracked `site.json` through this repo's own normal
642
- * `dev → promote → main` review path — never a silent second source of
643
- * truth. A commit failure (missing token, misconfigured repo, a transient
644
- * GitHub error) is logged and surfaces as a warning, but never fails the
645
- * save: the immediate override already works regardless.
646
- *
647
- * **Assumes `config` is a full site `Config`** (has `organization`/`site`),
648
- * not a bare `design-system.json` — true for any real EmDash site (it always
649
- * has a `site.json`), and the only case this plugin already handles anywhere
650
- * else (`vitopsHosting()`, `BLOCKS`).
651
- *
652
- * The GitHub token is read as `process.env.VITOPS_GITHUB_TOKEN` — matching
653
- * `hosting.ts`'s own `process.env.HOSTING` read in this same package, rather
654
- * than a `cloudflare:workers`-specific import: a native EmDash plugin runs
655
- * in-process with no isolation boundary (EmDash's own docs say plugins "can
656
- * access environment variables" directly), and this package already targets
657
- * both the Cloudflare and Node hosting targets through one `process.env`
658
- * convention (`nodejs_compat` on Workers maps bindings into `process.env`).
659
- */
660
- const SETTINGS_KEY = "designSystemPatch";
661
- function json(status, body) {
662
- return pluginResponse({
663
- status,
664
- headers: { "content-type": "application/json" },
665
- body: {
666
- kind: "text",
667
- value: JSON.stringify(body)
668
- }
669
- });
670
- }
671
- /** Fetches the currently-stored patch, clearing it if the bundled `config`
672
- * (the last real build) already reflects every value in it — see the
673
- * module docblock's "self-clearing" note in AGENTS.md. */
674
- async function currentPatch(ctx, ds) {
675
- const stored = await ctx.settings.get(SETTINGS_KEY);
676
- if (!stored) return null;
677
- const patchedColors = stored.patch["colors"];
678
- if (patchedColors != null && JSON.stringify(mergePatch(ds.colors, patchedColors)) === JSON.stringify(ds.colors)) {
679
- await ctx.settings.delete(SETTINGS_KEY);
680
- return null;
681
- }
682
- return stored;
683
- }
684
- function createDesignSystemRoute(opts) {
685
- const theme = opts.theme ?? "default";
686
- return {
687
- methods: ["GET", "POST"],
688
- response: "raw",
689
- permission: "plugins:manage",
690
- handler: async (ctx) => {
691
- const { designSystem: ds, config } = resolveInput(opts.config, {
692
- theme,
693
- siteEnv: "production"
694
- });
695
- if (ctx.request.method === "GET") {
696
- await currentPatch(ctx, ds);
697
- return json(200, { writable: true });
698
- }
699
- const patch = ctx.input?.patch;
700
- if (patch == null || typeof patch !== "object" || Array.isArray(patch)) return json(400, { errors: ["body must be { patch: object }"] });
844
+ },
845
+ {
846
+ type: "vitops.logos",
847
+ label: "Logos",
848
+ placeholder: "A still row of partner or client logos",
849
+ category: "Media",
850
+ fields: [
851
+ {
852
+ type: "text_input",
853
+ action_id: "label",
854
+ label: "Accessible name",
855
+ initial_value: "Partners"
856
+ },
857
+ {
858
+ type: "repeater",
859
+ action_id: "logos",
860
+ label: "Logos",
861
+ item_label: "logo",
862
+ min_items: 1,
863
+ fields: [
864
+ {
865
+ type: "text_input",
866
+ action_id: "image",
867
+ label: "Image URL"
868
+ },
869
+ {
870
+ type: "text_input",
871
+ action_id: "alt",
872
+ label: "Name (alt text)"
873
+ },
874
+ {
875
+ type: "text_input",
876
+ action_id: "href",
877
+ label: "Link URL (optional)"
878
+ }
879
+ ]
880
+ },
881
+ {
882
+ type: "toggle",
883
+ action_id: "grayscale",
884
+ label: "Grey until hovered",
885
+ initial_value: false
886
+ },
887
+ {
888
+ type: "select",
889
+ action_id: "size",
890
+ label: "Logo height",
891
+ initial_value: "md",
892
+ options: [
893
+ {
894
+ label: "Small",
895
+ value: "sm"
896
+ },
897
+ {
898
+ label: "Medium",
899
+ value: "md"
900
+ },
901
+ {
902
+ label: "Large",
903
+ value: "lg"
904
+ }
905
+ ]
906
+ }
907
+ ]
908
+ },
909
+ {
910
+ type: "vitops.steps",
911
+ label: "Steps",
912
+ placeholder: "A numbered sequence joined by a connector line",
913
+ category: "Sections",
914
+ fields: [
915
+ {
916
+ type: "repeater",
917
+ action_id: "steps",
918
+ label: "Steps",
919
+ item_label: "step",
920
+ min_items: 1,
921
+ fields: [{
922
+ type: "text_input",
923
+ action_id: "title",
924
+ label: "Title"
925
+ }, {
926
+ type: "text_input",
927
+ action_id: "text",
928
+ label: "Text (write [link text](/url) for an inline link)",
929
+ multiline: true
930
+ }]
931
+ },
932
+ levelField("3"),
933
+ {
934
+ type: "select",
935
+ action_id: "layout",
936
+ label: "Layout",
937
+ initial_value: "column",
938
+ options: [{
939
+ label: "Stacked",
940
+ value: "column"
941
+ }, {
942
+ label: "Side by side when wide",
943
+ value: "row"
944
+ }]
945
+ },
946
+ ...toneFields()
947
+ ]
948
+ },
949
+ {
950
+ type: "vitops.stats",
951
+ label: "Stats",
952
+ placeholder: "Headline figures that count up, or labelled tiles",
953
+ category: "Sections",
954
+ fields: [
955
+ {
956
+ type: "repeater",
957
+ action_id: "stats",
958
+ label: "Figures",
959
+ item_label: "figure",
960
+ min_items: 1,
961
+ fields: [
962
+ {
963
+ type: "text_input",
964
+ action_id: "value",
965
+ label: "Figure (e.g. 82% or 11 hrs)"
966
+ },
967
+ {
968
+ type: "text_input",
969
+ action_id: "label",
970
+ label: "Label"
971
+ },
972
+ {
973
+ type: "text_input",
974
+ action_id: "group",
975
+ label: "Group (tiles style; small line above)"
976
+ },
977
+ {
978
+ type: "text_input",
979
+ action_id: "delta",
980
+ label: "Change (tiles style; e.g. +3 this week)"
981
+ },
982
+ {
983
+ type: "select",
984
+ action_id: "state",
985
+ label: "Change is",
986
+ initial_value: "",
987
+ options: [
988
+ {
989
+ label: "Neutral",
990
+ value: ""
991
+ },
992
+ {
993
+ label: "Good (up)",
994
+ value: "up"
995
+ },
996
+ {
997
+ label: "Bad (down)",
998
+ value: "down"
999
+ }
1000
+ ]
1001
+ }
1002
+ ]
1003
+ },
1004
+ {
1005
+ type: "select",
1006
+ action_id: "variant",
1007
+ label: "Style",
1008
+ initial_value: "figures",
1009
+ options: [{
1010
+ label: "Figures",
1011
+ value: "figures"
1012
+ }, {
1013
+ label: "Tiles",
1014
+ value: "tiles"
1015
+ }]
1016
+ },
1017
+ {
1018
+ type: "toggle",
1019
+ action_id: "animate",
1020
+ label: "Count up when scrolled into view",
1021
+ initial_value: true
1022
+ },
1023
+ ...toneFields()
1024
+ ]
1025
+ },
1026
+ {
1027
+ type: "vitops.callout",
1028
+ label: "Callout",
1029
+ placeholder: "A call-to-action band (not a dismissible banner)",
1030
+ category: "Sections",
1031
+ fields: [
1032
+ {
1033
+ type: "text_input",
1034
+ action_id: "heading",
1035
+ label: "Heading"
1036
+ },
1037
+ levelField("2"),
1038
+ {
1039
+ type: "text_input",
1040
+ action_id: "body",
1041
+ label: "Text (write [link text](/url) for an inline link)",
1042
+ multiline: true
1043
+ },
1044
+ {
1045
+ type: "text_input",
1046
+ action_id: "primaryLabel",
1047
+ label: "Main button text"
1048
+ },
1049
+ {
1050
+ type: "text_input",
1051
+ action_id: "primaryHref",
1052
+ label: "Main button URL"
1053
+ },
1054
+ {
1055
+ type: "text_input",
1056
+ action_id: "secondaryLabel",
1057
+ label: "Second link text"
1058
+ },
1059
+ {
1060
+ type: "text_input",
1061
+ action_id: "secondaryHref",
1062
+ label: "Second link URL"
1063
+ },
1064
+ {
1065
+ type: "select",
1066
+ action_id: "align",
1067
+ label: "Layout",
1068
+ initial_value: "start",
1069
+ options: [{
1070
+ label: "Message left, buttons right",
1071
+ value: "start"
1072
+ }, {
1073
+ label: "Centered",
1074
+ value: "center"
1075
+ }]
1076
+ },
1077
+ {
1078
+ type: "toggle",
1079
+ action_id: "solid",
1080
+ label: "Fill with the solid colour",
1081
+ initial_value: false
1082
+ },
1083
+ ...toneFields()
1084
+ ]
1085
+ },
1086
+ {
1087
+ type: "vitops.entries",
1088
+ label: "Entries table",
1089
+ placeholder: "Rows of term/value pairs: a stacked list that becomes a table when wide",
1090
+ category: "Sections",
1091
+ fields: [
1092
+ {
1093
+ type: "repeater",
1094
+ action_id: "entries",
1095
+ label: "Entries",
1096
+ item_label: "entry",
1097
+ min_items: 1,
1098
+ fields: [{
1099
+ type: "text_input",
1100
+ action_id: "heading",
1101
+ label: "Heading (row name)"
1102
+ }, {
1103
+ type: "text_input",
1104
+ action_id: "pairs",
1105
+ label: "Details (one per line: Term | Value)",
1106
+ multiline: true
1107
+ }]
1108
+ },
1109
+ levelField("3"),
1110
+ {
1111
+ type: "text_input",
1112
+ action_id: "breakpoint",
1113
+ label: "Becomes a table at this width",
1114
+ initial_value: "40rem"
1115
+ }
1116
+ ]
1117
+ },
1118
+ {
1119
+ type: "vitops.pricing",
1120
+ label: "Pricing",
1121
+ placeholder: "Plan cards with aligned feature rows",
1122
+ category: "Sections",
1123
+ fields: [{
1124
+ type: "repeater",
1125
+ action_id: "plans",
1126
+ label: "Plans",
1127
+ item_label: "plan",
1128
+ min_items: 1,
1129
+ fields: [
1130
+ {
1131
+ type: "text_input",
1132
+ action_id: "name",
1133
+ label: "Plan name"
1134
+ },
1135
+ {
1136
+ type: "text_input",
1137
+ action_id: "price",
1138
+ label: "Price (e.g. $49)"
1139
+ },
1140
+ {
1141
+ type: "text_input",
1142
+ action_id: "period",
1143
+ label: "Period (e.g. per month)"
1144
+ },
1145
+ {
1146
+ type: "text_input",
1147
+ action_id: "features",
1148
+ label: "Features (one per line)",
1149
+ multiline: true
1150
+ },
1151
+ {
1152
+ type: "text_input",
1153
+ action_id: "ctaLabel",
1154
+ label: "Button text"
1155
+ },
1156
+ {
1157
+ type: "text_input",
1158
+ action_id: "ctaHref",
1159
+ label: "Button URL"
1160
+ },
1161
+ {
1162
+ type: "toggle",
1163
+ action_id: "featured",
1164
+ label: "Highlight this plan",
1165
+ initial_value: false
1166
+ }
1167
+ ]
1168
+ }, levelField("3")]
1169
+ },
1170
+ {
1171
+ type: "vitops.embed",
1172
+ label: "Embed (video or map)",
1173
+ placeholder: "A video or map that loads only after the visitor allows it",
1174
+ category: "Embeds",
1175
+ fields: [
1176
+ {
1177
+ type: "select",
1178
+ action_id: "provider",
1179
+ label: "Provider",
1180
+ initial_value: "youtube",
1181
+ options: [
1182
+ {
1183
+ label: "YouTube",
1184
+ value: "youtube"
1185
+ },
1186
+ {
1187
+ label: "Vimeo",
1188
+ value: "vimeo"
1189
+ },
1190
+ {
1191
+ label: "Map (Google or OpenStreetMap embed address)",
1192
+ value: "map"
1193
+ },
1194
+ {
1195
+ label: "Other (https address)",
1196
+ value: "custom"
1197
+ }
1198
+ ]
1199
+ },
1200
+ {
1201
+ type: "text_input",
1202
+ action_id: "url",
1203
+ label: "Address"
1204
+ },
1205
+ {
1206
+ type: "text_input",
1207
+ action_id: "title",
1208
+ label: "Title (required: it names the frame for screen readers)"
1209
+ },
1210
+ {
1211
+ type: "select",
1212
+ action_id: "category",
1213
+ label: "Consent category it waits on",
1214
+ initial_value: "marketing",
1215
+ options: [
1216
+ {
1217
+ label: "Marketing",
1218
+ value: "marketing"
1219
+ },
1220
+ {
1221
+ label: "Preferences",
1222
+ value: "preferences"
1223
+ },
1224
+ {
1225
+ label: "Analytics",
1226
+ value: "analytics"
1227
+ }
1228
+ ]
1229
+ },
1230
+ {
1231
+ type: "text_input",
1232
+ action_id: "ratio",
1233
+ label: "Aspect ratio",
1234
+ initial_value: "16 / 9"
1235
+ }
1236
+ ]
1237
+ },
1238
+ {
1239
+ type: "vitops.form",
1240
+ label: "Contact form",
1241
+ placeholder: "An enquiry form that emails you (and your CRM) and works without JavaScript",
1242
+ category: "Forms",
1243
+ fields: [
1244
+ {
1245
+ type: "text_input",
1246
+ action_id: "heading",
1247
+ label: "Heading"
1248
+ },
1249
+ levelField("2"),
1250
+ {
1251
+ type: "text_input",
1252
+ action_id: "intro",
1253
+ label: "Text above the form",
1254
+ multiline: true
1255
+ },
1256
+ {
1257
+ type: "repeater",
1258
+ action_id: "fields",
1259
+ label: "Fields",
1260
+ item_label: "field",
1261
+ min_items: 1,
1262
+ fields: [
1263
+ {
1264
+ type: "text_input",
1265
+ action_id: "label",
1266
+ label: "Label (what the visitor sees)"
1267
+ },
1268
+ {
1269
+ type: "text_input",
1270
+ action_id: "name",
1271
+ label: "Name in the message (optional: letters, digits, underscores)"
1272
+ },
1273
+ {
1274
+ type: "select",
1275
+ action_id: "type",
1276
+ label: "Kind",
1277
+ initial_value: "text",
1278
+ options: [
1279
+ {
1280
+ label: "Short text",
1281
+ value: "text"
1282
+ },
1283
+ {
1284
+ label: "Email address",
1285
+ value: "email"
1286
+ },
1287
+ {
1288
+ label: "Phone number",
1289
+ value: "tel"
1290
+ },
1291
+ {
1292
+ label: "Long text",
1293
+ value: "textarea"
1294
+ },
1295
+ {
1296
+ label: "Choose one",
1297
+ value: "select"
1298
+ },
1299
+ {
1300
+ label: "Tick box",
1301
+ value: "checkbox"
1302
+ }
1303
+ ]
1304
+ },
1305
+ {
1306
+ type: "toggle",
1307
+ action_id: "required",
1308
+ label: "Required",
1309
+ initial_value: false
1310
+ },
1311
+ {
1312
+ type: "text_input",
1313
+ action_id: "placeholder",
1314
+ label: "Example text"
1315
+ },
1316
+ {
1317
+ type: "text_input",
1318
+ action_id: "help",
1319
+ label: "Help text under the field"
1320
+ },
1321
+ {
1322
+ type: "text_input",
1323
+ action_id: "options",
1324
+ label: "Choices (one per line; for \"Choose one\" only)",
1325
+ multiline: true
1326
+ }
1327
+ ]
1328
+ },
1329
+ {
1330
+ type: "text_input",
1331
+ action_id: "submitLabel",
1332
+ label: "Button text",
1333
+ initial_value: "Send"
1334
+ },
1335
+ {
1336
+ type: "text_input",
1337
+ action_id: "successMessage",
1338
+ label: "Message after sending",
1339
+ initial_value: "Thanks. Your message has been sent."
1340
+ },
1341
+ {
1342
+ type: "text_input",
1343
+ action_id: "errorMessage",
1344
+ label: "Message if sending fails"
1345
+ },
1346
+ {
1347
+ type: "text_input",
1348
+ action_id: "noJsMessage",
1349
+ label: "Message when JavaScript is off (shown only if the site has a spam check)"
1350
+ },
1351
+ {
1352
+ type: "text_input",
1353
+ action_id: "action",
1354
+ label: "Where the form posts (leave empty for /api/track)"
1355
+ },
1356
+ ...toneFields()
1357
+ ]
1358
+ },
1359
+ {
1360
+ /**
1361
+ * Google reviews from the committed `.vitops/reviews.json` cache (`vitops reviews`).
1362
+ *
1363
+ * The data is a BUILD-TIME import (`virtual:getvitops/reviews`, from the `vitops()` Astro
1364
+ * integration), never a filesystem read: the renderer runs in a Worker. So a new review
1365
+ * appears after the next `vitops reviews` run is committed and the site rebuilt, not when
1366
+ * an editor saves. Which reviews show is `selectReviews()` from `@getvitops/utils`, the
1367
+ * one place that is decided — these fields are exactly its `ReviewLocationConfig`.
1368
+ */
1369
+ type: "vitops.reviews",
1370
+ label: "Google reviews",
1371
+ icon: "star",
1372
+ placeholder: "Customer reviews from the Google Business Profile cache",
1373
+ category: "Sections",
1374
+ fields: [
1375
+ {
1376
+ type: "text_input",
1377
+ action_id: "label",
1378
+ label: "Name for the group (read by screen readers)",
1379
+ initial_value: "Customer reviews"
1380
+ },
1381
+ {
1382
+ type: "text_input",
1383
+ action_id: "location",
1384
+ label: "Location key (organization.locations key; blank = every location in the cache)"
1385
+ },
1386
+ {
1387
+ type: "select",
1388
+ action_id: "layout",
1389
+ label: "Layout",
1390
+ initial_value: "grid",
1391
+ options: [
1392
+ {
1393
+ label: "Grid",
1394
+ value: "grid"
1395
+ },
1396
+ {
1397
+ label: "Carousel",
1398
+ value: "carousel"
1399
+ },
1400
+ {
1401
+ label: "Marquee (scrolling row)",
1402
+ value: "marquee"
1403
+ }
1404
+ ]
1405
+ },
1406
+ {
1407
+ type: "select",
1408
+ action_id: "columns",
1409
+ label: "Columns (grid only)",
1410
+ initial_value: "3",
1411
+ options: [
1412
+ "1",
1413
+ "2",
1414
+ "3",
1415
+ "4"
1416
+ ].map((n) => ({
1417
+ label: n,
1418
+ value: n
1419
+ }))
1420
+ },
1421
+ {
1422
+ type: "number_input",
1423
+ action_id: "limit",
1424
+ label: "Most reviews to show (0 = all)",
1425
+ initial_value: 6
1426
+ },
1427
+ {
1428
+ type: "select",
1429
+ action_id: "minRating",
1430
+ label: "Lowest rating to show",
1431
+ initial_value: "4",
1432
+ options: [
1433
+ "1",
1434
+ "2",
1435
+ "3",
1436
+ "4",
1437
+ "5"
1438
+ ].map((n) => ({
1439
+ label: n === "1" ? "Any" : `${n} stars and up`,
1440
+ value: n
1441
+ }))
1442
+ },
1443
+ {
1444
+ type: "select",
1445
+ action_id: "sort",
1446
+ label: "Order",
1447
+ initial_value: "recent",
1448
+ options: [{
1449
+ label: "Newest first",
1450
+ value: "recent"
1451
+ }, {
1452
+ label: "Highest rated first",
1453
+ value: "rating"
1454
+ }]
1455
+ },
1456
+ {
1457
+ type: "text_input",
1458
+ action_id: "featured",
1459
+ label: "Review ids to show first (comma-separated; they ignore the rating filter)"
1460
+ },
1461
+ {
1462
+ type: "toggle",
1463
+ action_id: "avatars",
1464
+ label: "Show reviewers' Google photos (loads images from Google for every visitor)",
1465
+ initial_value: false
1466
+ },
1467
+ ...toneFields()
1468
+ ]
1469
+ },
1470
+ {
1471
+ /**
1472
+ * A named EmDash menu inside page content: a site map, a section's link list, a footer-style
1473
+ * set of columns. NOT the header — that is `<SiteNav>` in the layout, which owns the drawer,
1474
+ * dropdowns and breakpoints. `variant: sitenav` renders the same markup and pattern for the
1475
+ * rare page that wants a real navbar in the body.
1476
+ *
1477
+ * The menu is chosen from `menus/list` (the site's own menus) with the conventional names as
1478
+ * the static fallback, because a select needs a non-empty `options` list.
1479
+ */
1480
+ type: "vitops.nav",
1481
+ label: "Menu",
1482
+ icon: "list",
1483
+ placeholder: "An EmDash menu as a list of links",
1484
+ category: "Navigation",
1485
+ fields: [
1486
+ {
1487
+ type: "select",
1488
+ action_id: "menu",
1489
+ label: "Menu",
1490
+ initial_value: "footer-sitemap",
1491
+ options: [
1492
+ {
1493
+ label: "Footer site map",
1494
+ value: "footer-sitemap"
1495
+ },
1496
+ {
1497
+ label: "Footer legal",
1498
+ value: "footer-legal"
1499
+ },
1500
+ {
1501
+ label: "Primary",
1502
+ value: "primary"
1503
+ }
1504
+ ],
1505
+ optionsRoute: MENUS_ROUTE
1506
+ },
1507
+ {
1508
+ type: "text_input",
1509
+ action_id: "label",
1510
+ label: "Name for the navigation (read by screen readers; must differ from other menus)"
1511
+ },
1512
+ {
1513
+ type: "text_input",
1514
+ action_id: "heading",
1515
+ label: "Visible heading (optional)"
1516
+ },
1517
+ levelField("2"),
1518
+ {
1519
+ type: "select",
1520
+ action_id: "variant",
1521
+ label: "Layout",
1522
+ initial_value: "list",
1523
+ options: [
1524
+ {
1525
+ label: "Vertical list",
1526
+ value: "list"
1527
+ },
1528
+ {
1529
+ label: "Inline row",
1530
+ value: "inline"
1531
+ },
1532
+ {
1533
+ label: "Columns (each top-level item with its sub-links)",
1534
+ value: "columns"
1535
+ },
1536
+ {
1537
+ label: "Navbar (the header pattern, in the page)",
1538
+ value: "sitenav"
1539
+ }
1540
+ ]
1541
+ },
1542
+ {
1543
+ type: "select",
1544
+ action_id: "depth",
1545
+ label: "Levels to show",
1546
+ initial_value: "1",
1547
+ options: [
1548
+ "1",
1549
+ "2",
1550
+ "3"
1551
+ ].map((n) => ({
1552
+ label: n,
1553
+ value: n
1554
+ }))
1555
+ },
1556
+ ...toneFields()
1557
+ ]
1558
+ },
1559
+ {
1560
+ /** A row of static labels (`badge`) or editable/dismissable ones (`tag`). */
1561
+ type: "vitops.badges",
1562
+ label: "Badges",
1563
+ icon: "tag",
1564
+ placeholder: "A row of short labels",
1565
+ fields: [
1566
+ {
1567
+ type: "repeater",
1568
+ action_id: "items",
1569
+ label: "Labels",
1570
+ fields: [
1571
+ {
1572
+ type: "text_input",
1573
+ action_id: "label",
1574
+ label: "Label"
1575
+ },
1576
+ {
1577
+ type: "select",
1578
+ action_id: "tone",
1579
+ label: "Tone (blank uses the block tone)",
1580
+ initial_value: "",
1581
+ options: STATIC_TONE_OPTIONS,
1582
+ optionsRoute: TONES_ROUTE
1583
+ },
1584
+ {
1585
+ type: "text_input",
1586
+ action_id: "color",
1587
+ label: "Palette colour instead (e.g. amber)"
1588
+ },
1589
+ {
1590
+ type: "combobox",
1591
+ action_id: "icon",
1592
+ label: "Icon before the label",
1593
+ placeholder: "None",
1594
+ options: SEMANTIC_ICON_OPTIONS
1595
+ },
1596
+ {
1597
+ type: "text_input",
1598
+ action_id: "href",
1599
+ label: "Link URL (optional)"
1600
+ }
1601
+ ]
1602
+ },
1603
+ {
1604
+ type: "select",
1605
+ action_id: "variant",
1606
+ label: "Style",
1607
+ initial_value: "badge",
1608
+ options: [{
1609
+ label: "Badge (static label)",
1610
+ value: "badge"
1611
+ }, {
1612
+ label: "Tag",
1613
+ value: "tag"
1614
+ }]
1615
+ },
1616
+ ...toneFields()
1617
+ ]
1618
+ },
1619
+ {
1620
+ /**
1621
+ * A widget area: content defined once under Widgets in the admin and shown wherever this
1622
+ * block is placed. Edits reach every page it sits on (after the next build, on a
1623
+ * prerendered site) — unlike a Section, which is copied.
1624
+ */
1625
+ type: "vitops.widgetArea",
1626
+ label: "Widget area",
1627
+ icon: "layout",
1628
+ placeholder: "Shared content managed under Widgets",
1629
+ fields: [{
1630
+ type: "select",
1631
+ action_id: "area",
1632
+ label: "Widget area",
1633
+ initial_value: "",
1634
+ options: [{
1635
+ label: "Choose…",
1636
+ value: ""
1637
+ }],
1638
+ optionsRoute: WIDGET_AREAS_ROUTE
1639
+ }, levelField("3")]
1640
+ }
1641
+ ];
1642
+ /** Every block gets the escape hatches last, so the editor reads content first. */
1643
+ const BLOCKS = BASE_BLOCKS.map((b) => ({
1644
+ ...b,
1645
+ fields: [...b.fields ?? [], ...commonFields()]
1646
+ }));
1647
+ /**
1648
+ * Resolve {@link BlockSelection} against {@link BLOCKS}.
1649
+ *
1650
+ * Pure so a misconfiguration fails at plugin construction, with the offending name in the
1651
+ * message, rather than as an editor menu that is quietly missing something.
1652
+ */
1653
+ function selectBlocks(selection = {}) {
1654
+ const known = new Set(BLOCKS.map((b) => b.type));
1655
+ const check = (names, option) => {
1656
+ for (const name of names ?? []) if (!known.has(name)) throw new Error(`[vitops] blocks.${option}: unknown block '${name}'. Known: ${[...known].join(", ")}.`);
1657
+ };
1658
+ check(selection.include, "include");
1659
+ check(selection.exclude, "exclude");
1660
+ const include = selection.include ? new Set(selection.include) : null;
1661
+ const exclude = new Set(selection.exclude);
1662
+ const base = BLOCKS.filter((b) => (!include || include.has(b.type)) && !exclude.has(b.type));
1663
+ const extra = selection.extra ?? [];
1664
+ const taken = new Set(BLOCKS.map((b) => b.type));
1665
+ for (const block of extra) {
1666
+ if (taken.has(block.type)) throw new Error(`[vitops] blocks.extra: type '${block.type}' is already defined.`);
1667
+ taken.add(block.type);
1668
+ }
1669
+ return [...base, ...extra];
1670
+ }
1671
+
1672
+ // src/menus-route.ts
1673
+ /** The items the select shows: one per menu, valued by its `name` (what `getMenu()` takes). */
1674
+ function menuItems(menus) {
1675
+ const seen = /* @__PURE__ */ new Set();
1676
+ const out = [];
1677
+ for (const { name, label } of menus) {
1678
+ if (!name || seen.has(name)) continue;
1679
+ seen.add(name);
1680
+ out.push({
1681
+ id: name,
1682
+ name: label && label !== name ? `${label} (${name})` : name
1683
+ });
1684
+ }
1685
+ return out;
1686
+ }
1687
+ function createMenusRoute(list) {
1688
+ return {
1689
+ permission: "content:read",
1690
+ handler: async () => {
1691
+ try {
1692
+ return { items: menuItems(await list()) };
1693
+ } catch {
1694
+ return { items: [] };
1695
+ }
1696
+ }
1697
+ };
1698
+ }
1699
+
1700
+ // src/tones-route.ts
1701
+ /**
1702
+ * The `tones/list` plugin route: what fills every block's `tone` select with the site's own
1703
+ * colour roles (UPSTREAM 21 — "role names the admin never offered").
1704
+ *
1705
+ * EmDash's Block Kit `select` takes an `optionsRoute` for exactly this, and falls back to the
1706
+ * field's static options if the route fails or answers with no `items`, so a site that
1707
+ * configures nothing still gets the semantic names from `STATIC_TONE_OPTIONS`.
1708
+ *
1709
+ * **Where the roles come from.** Plugin code has no site config at runtime, so the site hands
1710
+ * it over: `tones: [...]` verbatim, or — if the design-system editor is already wired with the
1711
+ * site's `Config` — the `colors.roles` keys of that config, so there is nothing to keep in
1712
+ * step. A role is listed under its own name (`brand-primary`), because that is what
1713
+ * `resolveTone()` and the token names (`--color-bg-brand-primary-muted`) both use.
1714
+ */
1715
+ /** Role names of a design system or full config, or `[]` if it cannot be resolved. */
1716
+ function rolesOf(config) {
1717
+ if (!config || typeof config !== "object") return [];
1718
+ try {
1719
+ const { designSystem } = resolveInput(config, { siteEnv: "production" });
1720
+ return Object.keys(designSystem.colors?.roles ?? {});
1721
+ } catch {
1722
+ return [];
1723
+ }
1724
+ }
1725
+ /** The items the select shows: the site's roles first, then any semantic name it lacks. */
1726
+ function toneItems(roles) {
1727
+ return [{
1728
+ id: "",
1729
+ name: "None"
1730
+ }, ...[.../* @__PURE__ */ new Set([...roles, ...SEMANTIC_TONES])].map((id) => ({
1731
+ id,
1732
+ name: id.replace(/-/g, " ")
1733
+ }))];
1734
+ }
1735
+ function createTonesRoute(roles) {
1736
+ return {
1737
+ permission: "content:read",
1738
+ handler: async () => ({ items: toneItems(roles()) })
1739
+ };
1740
+ }
1741
+
1742
+ // src/design-system-route.ts
1743
+ /**
1744
+ * The `vitops.design-system` plugin route — what `<wc-theme-editor>` probes
1745
+ * and POSTs its patch to when mounted inside the EmDash admin (see this
1746
+ * package's README and AGENTS.md's "EmDash plugin as the design-system
1747
+ * editing surface" section). Mounted at
1748
+ * `/_emdash/api/plugins/vitops/design-system`.
1749
+ *
1750
+ * Two responsibilities, matching the dev-only `@getvitops/vite` endpoint's
1751
+ * contract byte-for-byte (`GET → { writable }`, `POST { patch } → { ok } |
1752
+ * { errors }`) so `<wc-theme-editor>` needs no branch for which one it's
1753
+ * talking to:
1754
+ *
1755
+ * 1. **Immediate**: persist the patch to `ctx.settings` — read back by the
1756
+ * `page:fragments` hook in `index.ts`, which turns it into a site-wide
1757
+ * `:root` override `<style>` on every public page. This is what makes an
1758
+ * edit visible without a rebuild.
1759
+ * 2. **Canonical**: auto-commit the merged file to `site.deployment.branch`
1760
+ * (default `dev`) via `@getvitops/utils/git`'s `commitFile()`, so the edit
1761
+ * reaches the git-tracked `site.json` through this repo's own normal
1762
+ * `dev → promote → main` review path — never a silent second source of
1763
+ * truth. A commit failure (missing token, misconfigured repo, a transient
1764
+ * GitHub error) is logged and surfaces as a warning, but never fails the
1765
+ * save: the immediate override already works regardless.
1766
+ *
1767
+ * **Assumes `config` is a full site `Config`** (has `organization`/`site`),
1768
+ * not a bare `design-system.json` — true for any real EmDash site (it always
1769
+ * has a `site.json`), and the only case this plugin already handles anywhere
1770
+ * else (`vitopsHosting()`, `BLOCKS`).
1771
+ *
1772
+ * The GitHub token is read as `process.env.VITOPS_GITHUB_TOKEN` — matching
1773
+ * `hosting.ts`'s own `process.env.HOSTING` read in this same package, rather
1774
+ * than a `cloudflare:workers`-specific import: a native EmDash plugin runs
1775
+ * in-process with no isolation boundary (EmDash's own docs say plugins "can
1776
+ * access environment variables" directly), and this package already targets
1777
+ * both the Cloudflare and Node hosting targets through one `process.env`
1778
+ * convention (`nodejs_compat` on Workers maps bindings into `process.env`).
1779
+ */
1780
+ const SETTINGS_KEY = "designSystemPatch";
1781
+ function json(status, body) {
1782
+ return pluginResponse({
1783
+ status,
1784
+ headers: { "content-type": "application/json" },
1785
+ body: {
1786
+ kind: "text",
1787
+ value: JSON.stringify(body)
1788
+ }
1789
+ });
1790
+ }
1791
+ /** Fetches the currently-stored patch, clearing it if the bundled `config`
1792
+ * (the last real build) already reflects every value in it — see the
1793
+ * module docblock's "self-clearing" note in AGENTS.md. */
1794
+ async function currentPatch(ctx, ds) {
1795
+ const stored = await ctx.settings.get(SETTINGS_KEY);
1796
+ if (!stored) return null;
1797
+ const patchedColors = stored.patch["colors"];
1798
+ if (patchedColors != null && JSON.stringify(mergePatch(ds.colors, patchedColors)) === JSON.stringify(ds.colors)) {
1799
+ await ctx.settings.delete(SETTINGS_KEY);
1800
+ return null;
1801
+ }
1802
+ return stored;
1803
+ }
1804
+ function createDesignSystemRoute(opts) {
1805
+ const theme = opts.theme ?? "default";
1806
+ return {
1807
+ methods: ["GET", "POST"],
1808
+ response: "raw",
1809
+ permission: "plugins:manage",
1810
+ handler: async (ctx) => {
1811
+ const { designSystem: ds, config } = resolveInput(opts.config, {
1812
+ theme,
1813
+ siteEnv: "production"
1814
+ });
1815
+ if (ctx.request.method === "GET") {
1816
+ await currentPatch(ctx, ds);
1817
+ return json(200, { writable: true });
1818
+ }
1819
+ const patch = ctx.input?.patch;
1820
+ if (patch == null || typeof patch !== "object" || Array.isArray(patch)) return json(400, { errors: ["body must be { patch: object }"] });
701
1821
  const mergedColors = mergePatch(ds.colors, patch["colors"] ?? {});
702
1822
  const contrastErrors = validateColorContract(mergedColors);
703
1823
  if (contrastErrors.length) return json(400, { errors: contrastErrors });
@@ -760,6 +1880,130 @@ async function designSystemOverrideFragment(ctx, opts) {
760
1880
  };
761
1881
  }
762
1882
 
1883
+ // src/block-types.ts
1884
+ /** Block-type slugs may not contain a dot, so `vitops.section` is stored as `vitops_section`. */
1885
+ const SECTION_SLUG = "vitops_section";
1886
+ const select = (slug, label, options, defaultValue) => ({
1887
+ slug,
1888
+ label,
1889
+ type: "select",
1890
+ defaultValue,
1891
+ validation: { options }
1892
+ });
1893
+ /**
1894
+ * The `theme` select's values: `none`, a role, or `<role>:solid`. Block-type selects are
1895
+ * static, so this lists the roles every site defines; any other site role still works through
1896
+ * the `tone` field. `<role>:solid` is the filled backdrop with the contrast-computed text.
1897
+ */
1898
+ const THEME_ROLES = [
1899
+ "neutral",
1900
+ "surface",
1901
+ "brand-primary",
1902
+ "brand-secondary",
1903
+ "info",
1904
+ "success",
1905
+ "warning",
1906
+ "danger"
1907
+ ];
1908
+ const THEMES = [
1909
+ "none",
1910
+ ...THEME_ROLES,
1911
+ ...THEME_ROLES.map((r) => `${r}:solid`)
1912
+ ];
1913
+ const SECTION_FIELDS = [
1914
+ {
1915
+ slug: "kicker",
1916
+ label: "Kicker (small line above the heading)",
1917
+ type: "string"
1918
+ },
1919
+ {
1920
+ slug: "heading",
1921
+ label: "Heading",
1922
+ type: "string"
1923
+ },
1924
+ select("level", "Heading level", [
1925
+ "2",
1926
+ "3",
1927
+ "4",
1928
+ "5",
1929
+ "6"
1930
+ ], "2"),
1931
+ {
1932
+ slug: "sub",
1933
+ label: "Lead-in text under the heading",
1934
+ type: "text"
1935
+ },
1936
+ {
1937
+ slug: "body",
1938
+ label: "Content (any blocks)",
1939
+ type: "portableText"
1940
+ },
1941
+ select("layout", "Layout", [
1942
+ "stack",
1943
+ "center",
1944
+ "split"
1945
+ ], "stack"),
1946
+ select("width", "Width", [
1947
+ "measure",
1948
+ "breakout",
1949
+ "spotlight",
1950
+ "fullbleed"
1951
+ ], "measure"),
1952
+ {
1953
+ slug: "tone",
1954
+ label: "Backdrop tone (a colour role from the site, e.g. surface or brand-primary)",
1955
+ type: "string"
1956
+ },
1957
+ {
1958
+ slug: "color",
1959
+ label: "Palette colour instead of a tone (e.g. amber or amber-300)",
1960
+ type: "string"
1961
+ },
1962
+ {
1963
+ slug: "solid",
1964
+ label: "Fill with the solid colour",
1965
+ type: "boolean",
1966
+ defaultValue: false
1967
+ },
1968
+ select("theme", "Theme (backdrop and readable text in one pick; overrides the tone fields)", THEMES, "none"),
1969
+ {
1970
+ slug: "anchor",
1971
+ label: "Anchor id (so a link can point here with #id)",
1972
+ type: "string"
1973
+ },
1974
+ {
1975
+ slug: "class_name",
1976
+ label: "Extra CSS classes",
1977
+ type: "string"
1978
+ },
1979
+ {
1980
+ slug: "style",
1981
+ label: "CSS custom properties, e.g. --p-card: 2rem",
1982
+ type: "string"
1983
+ }
1984
+ ];
1985
+ /** `vitops.section`: kicker, heading, lead-in, tone/backdrop, optional heading-left layout, and a body of blocks. */
1986
+ const SECTION_BLOCK_TYPE = {
1987
+ slug: SECTION_SLUG,
1988
+ label: "Section",
1989
+ description: "A titled page section whose body can hold any other blocks.",
1990
+ category: "Sections",
1991
+ icon: "layout",
1992
+ currentVersion: 1,
1993
+ versions: [{
1994
+ version: 1,
1995
+ fields: SECTION_FIELDS
1996
+ }]
1997
+ };
1998
+ /**
1999
+ * Every block type this plugin ships, in the shape `seed.json`'s `blockTypes` takes. Spread
2000
+ * it into the seed (and allow the slugs on the collection's `blocks` field), e.g.
2001
+ * `blockTypes: [...vitopsBlockTypes()]`.
2002
+ */
2003
+ function vitopsBlockTypes() {
2004
+ return [SECTION_BLOCK_TYPE];
2005
+ }
2006
+
763
2007
  // src/blocks-doc.ts
764
2008
  const cell = (s) => (s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
765
2009
  function describe(f) {
@@ -794,8 +2038,18 @@ function renderBlocksDoc(blocks = BLOCKS) {
794
2038
  "# EmDash blocks",
795
2039
  "",
796
2040
  "Each block is a Portable Text object: `{ \"_type\": \"vitops.cards\", \"_key\": \"<unique>\", ...fields }`.",
797
- "Blocks are flat — a block cannot contain other blocks, and a repeater row holds scalar fields only.",
2041
+ "Portable Text blocks are flat — they cannot contain other blocks, and a repeater row holds scalar fields only.",
2042
+ "To put blocks inside something, use the section block TYPE (end of this page), whose body is Portable Text.",
798
2043
  "Give every block a unique `_key`; gallery and carousel derive their element id from it.",
2044
+ "",
2045
+ "## Colour: `tone` and `color`",
2046
+ "",
2047
+ "Every block that colours something takes the same two fields. `tone` is a colour role from the",
2048
+ "site's `colors.roles` (or a semantic name); an unknown role falls back through its semantic",
2049
+ "relatives to `neutral`. `color` is an escape hatch naming a palette hue (`amber`) or hue and step",
2050
+ "(`amber-300`) and wins over `tone`; its contrast is not checked. Renderers emit `--tone-*` custom",
2051
+ "properties, never a class built from the value. Multiline fields that accept links take",
2052
+ "`[text](/url)`; hero headings accent words wrapped in `*stars*`.",
799
2053
  ""
800
2054
  ];
801
2055
  for (const b of blocks) {
@@ -803,6 +2057,19 @@ function renderBlocksDoc(blocks = BLOCKS) {
803
2057
  if (b.placeholder) out.push(`${b.placeholder}.`, "");
804
2058
  if (b.fields?.length) out.push("| Field | Type | Label | Notes |", "| --- | --- | --- | --- |", ...fieldRows(b.fields), "");
805
2059
  }
2060
+ 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`.", "");
2061
+ for (const t of vitopsBlockTypes()) {
2062
+ out.push(`### \`${t.slug}\` — ${t.label}`, "", `${t.description ?? ""}`, "");
2063
+ const fields = t.versions.find((v) => v.version === t.currentVersion)?.fields ?? [];
2064
+ out.push("| Field | Type | Label | Notes |", "| --- | --- | --- | --- |", ...fields.map((f) => {
2065
+ const notes = [
2066
+ f.validation?.options ? `one of ${f.validation.options.join(", ")}` : "",
2067
+ f.defaultValue !== void 0 ? `default \`${String(f.defaultValue)}\`` : "",
2068
+ f.type === "portableText" ? "accepts every `vitops.*` block above" : ""
2069
+ ].filter(Boolean);
2070
+ return `| \`${f.slug}\` | ${f.type} | ${cell(f.label)} | ${cell(notes.join("; "))} |`;
2071
+ }), "");
2072
+ }
806
2073
  return out.join("\n");
807
2074
  }
808
2075
 
@@ -867,7 +2134,7 @@ async function vitopsHosting(options = {}) {
867
2134
  }
868
2135
 
869
2136
  // package.json
870
- var version = "1.0.0";
2137
+ var version = "2.0.0-alpha.1";
871
2138
 
872
2139
  // src/index.ts
873
2140
  /**
@@ -929,6 +2196,7 @@ function createPlugin(options = {}) {
929
2196
  const wcBase = options.wcBase ?? "/vitops";
930
2197
  const editor = options.designSystemEditor || void 0;
931
2198
  const fragmentsEnabled = scripts === "fragments" || !!editor;
2199
+ const roles = options.tones ?? rolesOf(editor?.config);
932
2200
  const pageFragments = async (_event, ctx) => {
933
2201
  const contributions = [];
934
2202
  if (scripts === "fragments") contributions.push({
@@ -966,9 +2234,14 @@ function createPlugin(options = {}) {
966
2234
  }]
967
2235
  } : {}
968
2236
  },
969
- ...editor ? { routes: { "design-system": createDesignSystemRoute(editor) } } : {}
2237
+ routes: {
2238
+ [TONES_ROUTE]: createTonesRoute(() => roles),
2239
+ [MENUS_ROUTE]: createMenusRoute(() => getMenus()),
2240
+ [WIDGET_AREAS_ROUTE]: createMenusRoute(() => getWidgetAreas()),
2241
+ ...editor ? { "design-system": createDesignSystemRoute(editor) } : {}
2242
+ }
970
2243
  });
971
2244
  }
972
2245
 
973
- export { BLOCKS, createPlugin, createPlugin as default, renderBlocksDoc, selectBlocks, vitopsEmdash, vitopsHosting };
2246
+ export { BLOCKS, SECTION_BLOCK_TYPE, SECTION_SLUG, createPlugin, createPlugin as default, renderBlocksDoc, resolveTone, selectBlocks, toneChain, vitopsBlockTypes, vitopsEmdash, vitopsHosting };
974
2247
  //# sourceMappingURL=index.mjs.map