@getvitops/emdash 1.0.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 (37) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/index.d.mts +33 -1
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1308 -184
  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/blocks/Callout.astro +73 -0
  9. package/src/astro/blocks/Cards.astro +53 -9
  10. package/src/astro/blocks/Embed.astro +67 -0
  11. package/src/astro/blocks/Entries.astro +30 -0
  12. package/src/astro/blocks/Form.astro +194 -0
  13. package/src/astro/blocks/Hero.astro +77 -23
  14. package/src/astro/blocks/Logos.astro +48 -0
  15. package/src/astro/blocks/Nav.astro +85 -0
  16. package/src/astro/blocks/NavTree.astro +48 -0
  17. package/src/astro/blocks/Pricing.astro +66 -0
  18. package/src/astro/blocks/Reviews.astro +122 -0
  19. package/src/astro/blocks/RichText.astro +14 -0
  20. package/src/astro/blocks/Section.astro +84 -0
  21. package/src/astro/blocks/Stats.astro +59 -0
  22. package/src/astro/blocks/Steps.astro +48 -0
  23. package/src/astro/blocks/embed.test.ts +43 -0
  24. package/src/astro/blocks/embed.ts +84 -0
  25. package/src/astro/blocks/form.sample.ts +33 -0
  26. package/src/astro/blocks/form.test.ts +55 -0
  27. package/src/astro/blocks/form.ts +158 -0
  28. package/src/astro/blocks/head.stub.ts +2 -0
  29. package/src/astro/blocks/nav.test.ts +51 -0
  30. package/src/astro/blocks/nav.ts +65 -0
  31. package/src/astro/blocks/render.container.test.ts +570 -0
  32. package/src/astro/blocks/tone.test.ts +53 -0
  33. package/src/astro/blocks/tone.ts +122 -0
  34. package/src/astro/blocks/value.test.ts +36 -1
  35. package/src/astro/blocks/value.ts +78 -1
  36. package/src/astro/index.ts +30 -0
  37. 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, 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,7 +25,165 @@ 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
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
+ });
29
187
  const BLOCKS = [
30
188
  {
31
189
  /**
@@ -380,70 +538,104 @@ const BLOCKS = [
380
538
  label: "Cards",
381
539
  placeholder: "A grid of cards",
382
540
  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
- }]
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
+ ]
447
639
  },
448
640
  {
449
641
  type: "vitops.accordion",
@@ -496,8 +688,9 @@ const BLOCKS = [
496
688
  {
497
689
  type: "text_input",
498
690
  action_id: "heading",
499
- label: "Heading"
691
+ label: "Heading (wrap words in *stars* to accent them)"
500
692
  },
693
+ levelField("2"),
501
694
  {
502
695
  type: "text_input",
503
696
  action_id: "body",
@@ -546,6 +739,37 @@ const BLOCKS = [
546
739
  label: "Text and image side by side",
547
740
  value: "split"
548
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
+ ]
549
773
  }
550
774
  ]
551
775
  },
@@ -592,120 +816,904 @@ const BLOCKS = [
592
816
  initial_value: false
593
817
  }
594
818
  ]
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 }"] });
701
- const mergedColors = mergePatch(ds.colors, patch["colors"] ?? {});
702
- const contrastErrors = validateColorContract(mergedColors);
703
- if (contrastErrors.length) return json(400, { errors: contrastErrors });
704
- await ctx.settings.set(SETTINGS_KEY, {
705
- patch,
706
- at: Date.now()
707
- });
708
- const warnings = [];
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",
1190
+ options: [
1191
+ {
1192
+ label: "Marketing",
1193
+ value: "marketing"
1194
+ },
1195
+ {
1196
+ label: "Preferences",
1197
+ value: "preferences"
1198
+ },
1199
+ {
1200
+ label: "Analytics",
1201
+ value: "analytics"
1202
+ }
1203
+ ]
1204
+ },
1205
+ {
1206
+ type: "text_input",
1207
+ action_id: "ratio",
1208
+ label: "Aspect ratio",
1209
+ initial_value: "16 / 9"
1210
+ }
1211
+ ]
1212
+ },
1213
+ {
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",
1218
+ fields: [
1219
+ {
1220
+ type: "text_input",
1221
+ action_id: "heading",
1222
+ label: "Heading"
1223
+ },
1224
+ levelField("2"),
1225
+ {
1226
+ type: "text_input",
1227
+ action_id: "intro",
1228
+ label: "Text above the form",
1229
+ multiline: true
1230
+ },
1231
+ {
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
+ ]
1303
+ },
1304
+ {
1305
+ type: "text_input",
1306
+ action_id: "submitLabel",
1307
+ label: "Button text",
1308
+ initial_value: "Send"
1309
+ },
1310
+ {
1311
+ type: "text_input",
1312
+ action_id: "successMessage",
1313
+ label: "Message after sending",
1314
+ initial_value: "Thanks. Your message has been sent."
1315
+ },
1316
+ {
1317
+ type: "text_input",
1318
+ action_id: "errorMessage",
1319
+ label: "Message if sending fails"
1320
+ },
1321
+ {
1322
+ type: "text_input",
1323
+ action_id: "noJsMessage",
1324
+ label: "Message when JavaScript is off (shown only if the site has a spam check)"
1325
+ },
1326
+ {
1327
+ type: "text_input",
1328
+ action_id: "action",
1329
+ label: "Where the form posts (leave empty for /api/track)"
1330
+ },
1331
+ ...toneFields()
1332
+ ]
1333
+ },
1334
+ {
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",
1349
+ fields: [
1350
+ {
1351
+ type: "text_input",
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)"
1360
+ },
1361
+ {
1362
+ type: "select",
1363
+ action_id: "layout",
1364
+ label: "Layout",
1365
+ initial_value: "grid",
1366
+ options: [
1367
+ {
1368
+ label: "Grid",
1369
+ value: "grid"
1370
+ },
1371
+ {
1372
+ label: "Carousel",
1373
+ value: "carousel"
1374
+ },
1375
+ {
1376
+ label: "Marquee (scrolling row)",
1377
+ value: "marquee"
1378
+ }
1379
+ ]
1380
+ },
1381
+ {
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
+ },
1396
+ {
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
+ }]
1430
+ },
1431
+ {
1432
+ type: "text_input",
1433
+ action_id: "featured",
1434
+ label: "Review ids to show first (comma-separated; they ignore the rating filter)"
1435
+ },
1436
+ {
1437
+ type: "toggle",
1438
+ action_id: "avatars",
1439
+ label: "Show reviewers' Google photos (loads images from Google for every visitor)",
1440
+ initial_value: false
1441
+ },
1442
+ ...toneFields()
1443
+ ]
1444
+ },
1445
+ {
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",
1460
+ fields: [
1461
+ {
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
1481
+ },
1482
+ {
1483
+ type: "text_input",
1484
+ action_id: "label",
1485
+ label: "Name for the navigation (read by screen readers; must differ from other menus)"
1486
+ },
1487
+ {
1488
+ type: "text_input",
1489
+ action_id: "heading",
1490
+ label: "Visible heading (optional)"
1491
+ },
1492
+ levelField("2"),
1493
+ {
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
+ ]
1516
+ },
1517
+ {
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
+ }))
1530
+ },
1531
+ ...toneFields()
1532
+ ]
1533
+ }
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
+ }
1629
+
1630
+ // src/design-system-route.ts
1631
+ /**
1632
+ * The `vitops.design-system` plugin route — what `<wc-theme-editor>` probes
1633
+ * and POSTs its patch to when mounted inside the EmDash admin (see this
1634
+ * package's README and AGENTS.md's "EmDash plugin as the design-system
1635
+ * editing surface" section). Mounted at
1636
+ * `/_emdash/api/plugins/vitops/design-system`.
1637
+ *
1638
+ * Two responsibilities, matching the dev-only `@getvitops/vite` endpoint's
1639
+ * contract byte-for-byte (`GET → { writable }`, `POST { patch } → { ok } |
1640
+ * { errors }`) so `<wc-theme-editor>` needs no branch for which one it's
1641
+ * talking to:
1642
+ *
1643
+ * 1. **Immediate**: persist the patch to `ctx.settings` — read back by the
1644
+ * `page:fragments` hook in `index.ts`, which turns it into a site-wide
1645
+ * `:root` override `<style>` on every public page. This is what makes an
1646
+ * edit visible without a rebuild.
1647
+ * 2. **Canonical**: auto-commit the merged file to `site.deployment.branch`
1648
+ * (default `dev`) via `@getvitops/utils/git`'s `commitFile()`, so the edit
1649
+ * reaches the git-tracked `site.json` through this repo's own normal
1650
+ * `dev → promote → main` review path — never a silent second source of
1651
+ * truth. A commit failure (missing token, misconfigured repo, a transient
1652
+ * GitHub error) is logged and surfaces as a warning, but never fails the
1653
+ * save: the immediate override already works regardless.
1654
+ *
1655
+ * **Assumes `config` is a full site `Config`** (has `organization`/`site`),
1656
+ * not a bare `design-system.json` — true for any real EmDash site (it always
1657
+ * has a `site.json`), and the only case this plugin already handles anywhere
1658
+ * else (`vitopsHosting()`, `BLOCKS`).
1659
+ *
1660
+ * The GitHub token is read as `process.env.VITOPS_GITHUB_TOKEN` — matching
1661
+ * `hosting.ts`'s own `process.env.HOSTING` read in this same package, rather
1662
+ * than a `cloudflare:workers`-specific import: a native EmDash plugin runs
1663
+ * in-process with no isolation boundary (EmDash's own docs say plugins "can
1664
+ * access environment variables" directly), and this package already targets
1665
+ * both the Cloudflare and Node hosting targets through one `process.env`
1666
+ * convention (`nodejs_compat` on Workers maps bindings into `process.env`).
1667
+ */
1668
+ const SETTINGS_KEY = "designSystemPatch";
1669
+ function json(status, body) {
1670
+ return pluginResponse({
1671
+ status,
1672
+ headers: { "content-type": "application/json" },
1673
+ body: {
1674
+ kind: "text",
1675
+ value: JSON.stringify(body)
1676
+ }
1677
+ });
1678
+ }
1679
+ /** Fetches the currently-stored patch, clearing it if the bundled `config`
1680
+ * (the last real build) already reflects every value in it — see the
1681
+ * module docblock's "self-clearing" note in AGENTS.md. */
1682
+ async function currentPatch(ctx, ds) {
1683
+ const stored = await ctx.settings.get(SETTINGS_KEY);
1684
+ if (!stored) return null;
1685
+ const patchedColors = stored.patch["colors"];
1686
+ if (patchedColors != null && JSON.stringify(mergePatch(ds.colors, patchedColors)) === JSON.stringify(ds.colors)) {
1687
+ await ctx.settings.delete(SETTINGS_KEY);
1688
+ return null;
1689
+ }
1690
+ return stored;
1691
+ }
1692
+ function createDesignSystemRoute(opts) {
1693
+ const theme = opts.theme ?? "default";
1694
+ return {
1695
+ methods: ["GET", "POST"],
1696
+ response: "raw",
1697
+ permission: "plugins:manage",
1698
+ handler: async (ctx) => {
1699
+ const { designSystem: ds, config } = resolveInput(opts.config, {
1700
+ theme,
1701
+ siteEnv: "production"
1702
+ });
1703
+ if (ctx.request.method === "GET") {
1704
+ await currentPatch(ctx, ds);
1705
+ return json(200, { writable: true });
1706
+ }
1707
+ const patch = ctx.input?.patch;
1708
+ if (patch == null || typeof patch !== "object" || Array.isArray(patch)) return json(400, { errors: ["body must be { patch: object }"] });
1709
+ const mergedColors = mergePatch(ds.colors, patch["colors"] ?? {});
1710
+ const contrastErrors = validateColorContract(mergedColors);
1711
+ if (contrastErrors.length) return json(400, { errors: contrastErrors });
1712
+ await ctx.settings.set(SETTINGS_KEY, {
1713
+ patch,
1714
+ at: Date.now()
1715
+ });
1716
+ const warnings = [];
709
1717
  const repoSpec = config?.site.deployment?.repo;
710
1718
  const token = process.env["VITOPS_GITHUB_TOKEN"];
711
1719
  if (!repoSpec || !token) warnings.push(!repoSpec ? "design-system sync skipped: site.deployment.repo is not set — the change is live but not yet synced to source." : "design-system sync skipped: no GitHub token configured — the change is live but not yet synced to source.");
@@ -760,6 +1768,94 @@ async function designSystemOverrideFragment(ctx, opts) {
760
1768
  };
761
1769
  }
762
1770
 
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
+
763
1859
  // src/blocks-doc.ts
764
1860
  const cell = (s) => (s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
765
1861
  function describe(f) {
@@ -794,8 +1890,18 @@ function renderBlocksDoc(blocks = BLOCKS) {
794
1890
  "# EmDash blocks",
795
1891
  "",
796
1892
  "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.",
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.",
798
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*`.",
799
1905
  ""
800
1906
  ];
801
1907
  for (const b of blocks) {
@@ -803,6 +1909,19 @@ function renderBlocksDoc(blocks = BLOCKS) {
803
1909
  if (b.placeholder) out.push(`${b.placeholder}.`, "");
804
1910
  if (b.fields?.length) out.push("| Field | Type | Label | Notes |", "| --- | --- | --- | --- |", ...fieldRows(b.fields), "");
805
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
+ }
806
1925
  return out.join("\n");
807
1926
  }
808
1927
 
@@ -867,7 +1986,7 @@ async function vitopsHosting(options = {}) {
867
1986
  }
868
1987
 
869
1988
  // package.json
870
- var version = "1.0.0";
1989
+ var version = "2.0.0-alpha.0";
871
1990
 
872
1991
  // src/index.ts
873
1992
  /**
@@ -929,6 +2048,7 @@ function createPlugin(options = {}) {
929
2048
  const wcBase = options.wcBase ?? "/vitops";
930
2049
  const editor = options.designSystemEditor || void 0;
931
2050
  const fragmentsEnabled = scripts === "fragments" || !!editor;
2051
+ const roles = options.tones ?? rolesOf(editor?.config);
932
2052
  const pageFragments = async (_event, ctx) => {
933
2053
  const contributions = [];
934
2054
  if (scripts === "fragments") contributions.push({
@@ -966,9 +2086,13 @@ function createPlugin(options = {}) {
966
2086
  }]
967
2087
  } : {}
968
2088
  },
969
- ...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
+ }
970
2094
  });
971
2095
  }
972
2096
 
973
- export { BLOCKS, createPlugin, createPlugin as default, renderBlocksDoc, selectBlocks, vitopsEmdash, vitopsHosting };
2097
+ export { BLOCKS, SECTION_BLOCK_TYPE, SECTION_SLUG, createPlugin, createPlugin as default, renderBlocksDoc, resolveTone, selectBlocks, toneChain, vitopsBlockTypes, vitopsEmdash, vitopsHosting };
974
2098
  //# sourceMappingURL=index.mjs.map