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

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 (47) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +18 -16
  3. package/dist/index.d.mts +26 -6
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +578 -129
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +5 -5
  8. package/src/astro/Nothing.astro +3 -0
  9. package/src/astro/WidgetArea.astro +113 -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 +3 -8
  15. package/src/astro/blocks/Cards.astro +6 -4
  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 +2 -1
  20. package/src/astro/blocks/Entries.astro +2 -1
  21. package/src/astro/blocks/Form.astro +20 -1
  22. package/src/astro/blocks/Gallery.astro +2 -0
  23. package/src/astro/blocks/Hero.astro +27 -11
  24. package/src/astro/blocks/ImageCompare.astro +2 -1
  25. package/src/astro/blocks/Logos.astro +10 -7
  26. package/src/astro/blocks/Marquee.astro +2 -1
  27. package/src/astro/blocks/Nav.astro +2 -2
  28. package/src/astro/blocks/NavTree.astro +1 -1
  29. package/src/astro/blocks/Pricing.astro +2 -2
  30. package/src/astro/blocks/Reviews.astro +3 -3
  31. package/src/astro/blocks/Section.astro +17 -13
  32. package/src/astro/blocks/Stats.astro +5 -4
  33. package/src/astro/blocks/Steps.astro +5 -5
  34. package/src/astro/blocks/WidgetArea.astro +21 -0
  35. package/src/astro/blocks/attrs.test.ts +74 -0
  36. package/src/astro/blocks/attrs.ts +116 -0
  37. package/src/astro/blocks/cycle.container.test.ts +73 -0
  38. package/src/astro/blocks/form.sample.ts +8 -2
  39. package/src/astro/blocks/form.ts +16 -5
  40. package/src/astro/blocks/nav.ts +1 -1
  41. package/src/astro/blocks/render.container.test.ts +131 -19
  42. package/src/astro/blocks/tone.test.ts +10 -0
  43. package/src/astro/blocks/tone.ts +48 -6
  44. package/src/astro/blocks/unrendered.ts +23 -0
  45. package/src/astro/blocks/value.test.ts +27 -1
  46. package/src/astro/blocks/value.ts +16 -0
  47. package/src/astro/index.ts +38 -21
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { definePlugin, getMenus, 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";
@@ -35,7 +35,10 @@ const SEMANTIC_ICON_OPTIONS = Object.keys(iconMap.fa7).sort().map((name) => ({
35
35
  * fallbacks, then `neutral`.
36
36
  * - **`color`** — the escape hatch: a palette hue (`amber`) or hue and step (`amber-300`).
37
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.
38
+ * contract covers roles, not arbitrary hue/step pairs — so it is for decoration. A solid fill
39
+ * reads from the site's `--color-<hue>-<step>-on` and the tint flips with the colour scheme
40
+ * (`light-dark()`); mid steps (300–500) cannot reach APCA Lc 75 with either text colour, so use
41
+ * a role where contrast matters.
39
42
  *
40
43
  * **Why CSS custom properties, not classes.** A class name built from a field value
41
44
  * (`bg-${tone}`) is invisible to Tailwind's scanner, so it generates nothing in that
@@ -102,14 +105,16 @@ function resolveTone(tone, color) {
102
105
  if (hue) {
103
106
  const [, name, step] = hue;
104
107
  const at = (s) => `var(--color-${name}-${s})`;
108
+ const fill = step ?? "600";
109
+ const flip = (light, dark) => `light-dark(${at(light)}, ${at(dark)})`;
105
110
  return {
106
111
  className: "tone",
107
112
  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
+ `--tone-bg: ${flip("100", "900")}`,
114
+ `--tone-fg: ${flip("900", "100")}`,
115
+ `--tone-border: ${flip("300", "700")}`,
116
+ `--tone-solid: ${at(fill)}`,
117
+ `--tone-on-solid: ${at(`${fill}-on`)}`
113
118
  ].join("; ")
114
119
  };
115
120
  }
@@ -131,10 +136,138 @@ function resolveTone(tone, color) {
131
136
  };
132
137
  }
133
138
 
139
+ // src/block-types.ts
140
+ /** Block-type slugs may not contain a dot, so `vitops_section` is stored as `vitops_section`. */
141
+ const SECTION_SLUG = "vitops_section";
142
+ const select = (slug, label, options, defaultValue) => ({
143
+ slug,
144
+ label,
145
+ type: "select",
146
+ defaultValue,
147
+ validation: { options }
148
+ });
149
+ /**
150
+ * The `theme` select's values: `none`, a role, or `<role>:solid`. Block-type selects are
151
+ * static, so this lists the roles every site defines; any other site role still works through
152
+ * the `tone` field. `<role>:solid` is the filled backdrop with the contrast-computed text.
153
+ */
154
+ const THEME_ROLES = [
155
+ "neutral",
156
+ "surface",
157
+ "brand-primary",
158
+ "brand-secondary",
159
+ "info",
160
+ "success",
161
+ "warning",
162
+ "danger"
163
+ ];
164
+ const THEMES = [
165
+ "none",
166
+ ...THEME_ROLES,
167
+ ...THEME_ROLES.map((r) => `${r}:solid`)
168
+ ];
169
+ const SECTION_FIELDS = [
170
+ {
171
+ slug: "kicker",
172
+ label: "Kicker (small line above the heading)",
173
+ type: "string"
174
+ },
175
+ {
176
+ slug: "heading",
177
+ label: "Heading",
178
+ type: "string"
179
+ },
180
+ select("level", "Heading level (p: styled like a heading, not in the outline)", [
181
+ "1",
182
+ "2",
183
+ "3",
184
+ "4",
185
+ "5",
186
+ "6",
187
+ "p"
188
+ ], "2"),
189
+ {
190
+ slug: "sub",
191
+ label: "Lead-in text under the heading",
192
+ type: "text"
193
+ },
194
+ {
195
+ slug: "body",
196
+ label: "Content (any blocks)",
197
+ type: "portableText"
198
+ },
199
+ select("layout", "Layout", [
200
+ "stack",
201
+ "center",
202
+ "split"
203
+ ], "stack"),
204
+ select("width", "Width", [
205
+ "measure",
206
+ "breakout",
207
+ "spotlight",
208
+ "fullbleed"
209
+ ], "measure"),
210
+ {
211
+ slug: "tone",
212
+ label: "Backdrop tone (a colour role from the site, e.g. surface or brand-primary)",
213
+ type: "string"
214
+ },
215
+ {
216
+ slug: "color",
217
+ label: "Palette colour instead of a tone (e.g. amber or amber-300)",
218
+ type: "string"
219
+ },
220
+ {
221
+ slug: "solid",
222
+ label: "Fill with the solid colour",
223
+ type: "boolean",
224
+ defaultValue: false
225
+ },
226
+ select("theme", "Theme (backdrop and readable text in one pick; overrides the tone fields)", THEMES, "none"),
227
+ {
228
+ slug: "anchor",
229
+ label: "Anchor id (so a link can point here with #id)",
230
+ type: "string"
231
+ },
232
+ {
233
+ slug: "class_name",
234
+ label: "Extra CSS classes",
235
+ type: "string"
236
+ },
237
+ {
238
+ slug: "style",
239
+ label: "CSS custom properties, e.g. --p-card: 2rem",
240
+ type: "string"
241
+ }
242
+ ];
243
+ /** `vitops_section`: kicker, heading, lead-in, tone/backdrop, optional heading-left layout, and a body of blocks. */
244
+ const SECTION_BLOCK_TYPE = {
245
+ slug: SECTION_SLUG,
246
+ label: "Section",
247
+ description: "A titled page section whose body can hold any other blocks.",
248
+ category: "Sections",
249
+ icon: "layout",
250
+ currentVersion: 1,
251
+ versions: [{
252
+ version: 1,
253
+ fields: SECTION_FIELDS
254
+ }]
255
+ };
256
+ /**
257
+ * Every block type this plugin ships, in the shape `seed.json`'s `blockTypes` takes. Spread
258
+ * it into the seed (and allow the slugs on the collection's `blocks` field), e.g.
259
+ * `blockTypes: [...vitopsBlockTypes()]`.
260
+ */
261
+ function vitopsBlockTypes() {
262
+ return [SECTION_BLOCK_TYPE];
263
+ }
264
+
134
265
  // src/blocks.ts
135
266
  /** Route that fills every tone select with the site's own roles — see `index.ts`. */
136
267
  const TONES_ROUTE = "tones/list";
137
- /** Route that lists the site's EmDash menus for `vitops.nav`'s `menu` select — see `menus-route.ts`. */
268
+ /** Lists each colour role twice: tinted (`role`) and filled (`role:solid`), for every block's `theme`. */
269
+ const THEMES_ROUTE = "themes/list";
270
+ /** Route that lists the site's EmDash menus for `vitops_nav`'s `menu` select — see `menus-route.ts`. */
138
271
  const MENUS_ROUTE = "menus/list";
139
272
  /**
140
273
  * The tone options an editor can always pick, before the site adds its own roles.
@@ -167,6 +300,48 @@ function toneFields() {
167
300
  label: "Palette colour instead of a tone (e.g. amber or amber-300)"
168
301
  }];
169
302
  }
303
+ /** Route that lists the site's widget areas for `vitops_widgetArea`'s `area` select. */
304
+ const WIDGET_AREAS_ROUTE = "widget-areas/list";
305
+ /**
306
+ * The escape hatches every block carries, appended last by `BLOCKS` (see `astro/blocks/attrs.ts`
307
+ * for how each is validated and why `style` takes custom properties only).
308
+ */
309
+ function commonFields() {
310
+ return [
311
+ {
312
+ type: "select",
313
+ action_id: "theme",
314
+ label: "Theme (a backdrop with readable text, from the site colours)",
315
+ initial_value: "",
316
+ options: [{
317
+ label: "None",
318
+ value: ""
319
+ }, ...THEME_ROLES.flatMap((r) => [{
320
+ label: r.replace(/-/g, " "),
321
+ value: r
322
+ }, {
323
+ label: `${r.replace(/-/g, " ")} (filled)`,
324
+ value: `${r}:solid`
325
+ }])],
326
+ optionsRoute: THEMES_ROUTE
327
+ },
328
+ {
329
+ type: "text_input",
330
+ action_id: "anchor",
331
+ label: "Anchor id (so a link can point here with #id)"
332
+ },
333
+ {
334
+ type: "text_input",
335
+ action_id: "className",
336
+ label: "Extra CSS classes (with the tailwind format, only classes the site already uses)"
337
+ },
338
+ {
339
+ type: "text_input",
340
+ action_id: "style",
341
+ label: "CSS custom properties, e.g. --p-card: 2rem; --tone-bg: #fee"
342
+ }
343
+ ];
344
+ }
170
345
  /** Heading level for a block's own heading; `h1` is reserved for the page title. */
171
346
  const levelField = (initial) => ({
172
347
  type: "select",
@@ -184,7 +359,31 @@ const levelField = (initial) => ({
184
359
  value: n
185
360
  }))
186
361
  });
187
- const BLOCKS = [
362
+ /**
363
+ * Heading level for a block that can own the page title (hero): `h1`, or `p` for a heading that is
364
+ * visual only and should not appear in the outline.
365
+ */
366
+ const titleLevelField = (initial) => ({
367
+ type: "select",
368
+ action_id: "level",
369
+ label: "Heading level (h1 is the page title; p is styled like a heading but not in the outline)",
370
+ initial_value: initial,
371
+ options: [...[
372
+ "1",
373
+ "2",
374
+ "3",
375
+ "4",
376
+ "5",
377
+ "6"
378
+ ].map((n) => ({
379
+ label: `h${n}`,
380
+ value: n
381
+ })), {
382
+ label: "p (not a heading)",
383
+ value: "p"
384
+ }]
385
+ });
386
+ const BASE_BLOCKS = [
188
387
  {
189
388
  /**
190
389
  * A link or button, optionally with an icon at either end.
@@ -199,7 +398,7 @@ const BLOCKS = [
199
398
  * raw HTML), so `<wc-icon-picker>` cannot be mounted inside a block modal
200
399
  * however much nicer it would be. Revisit if EmDash opens that union.
201
400
  */
202
- type: "vitops.actionLink",
401
+ type: "vitops_actionLink",
203
402
  label: "Link or button",
204
403
  icon: "link",
205
404
  placeholder: "A link or button, with optional icons",
@@ -285,7 +484,7 @@ const BLOCKS = [
285
484
  ]
286
485
  },
287
486
  {
288
- type: "vitops.imageCompare",
487
+ type: "vitops_imageCompare",
289
488
  label: "Image compare",
290
489
  placeholder: "Before/after image slider",
291
490
  fields: [
@@ -324,7 +523,7 @@ const BLOCKS = [
324
523
  ]
325
524
  },
326
525
  {
327
- type: "vitops.copyButton",
526
+ type: "vitops_copyButton",
328
527
  label: "Copy snippet",
329
528
  icon: "code",
330
529
  placeholder: "Text with a copy-to-clipboard button",
@@ -357,7 +556,7 @@ const BLOCKS = [
357
556
  ]
358
557
  },
359
558
  {
360
- type: "vitops.banner",
559
+ type: "vitops_banner",
361
560
  label: "Banner",
362
561
  placeholder: "Announcement or notice",
363
562
  fields: [
@@ -400,7 +599,7 @@ const BLOCKS = [
400
599
  ]
401
600
  },
402
601
  {
403
- type: "vitops.details",
602
+ type: "vitops_details",
404
603
  label: "Disclosure",
405
604
  placeholder: "Collapsible details/summary",
406
605
  fields: [
@@ -424,7 +623,7 @@ const BLOCKS = [
424
623
  ]
425
624
  },
426
625
  {
427
- type: "vitops.carousel",
626
+ type: "vitops_carousel",
428
627
  label: "Carousel",
429
628
  placeholder: "Image carousel",
430
629
  category: "Media",
@@ -484,7 +683,7 @@ const BLOCKS = [
484
683
  ]
485
684
  },
486
685
  {
487
- type: "vitops.gallery",
686
+ type: "vitops_gallery",
488
687
  label: "Gallery",
489
688
  placeholder: "Thumbnail grid that opens a lightbox carousel",
490
689
  category: "Media",
@@ -534,7 +733,7 @@ const BLOCKS = [
534
733
  ]
535
734
  },
536
735
  {
537
- type: "vitops.cards",
736
+ type: "vitops_cards",
538
737
  label: "Cards",
539
738
  placeholder: "A grid of cards",
540
739
  category: "Sections",
@@ -638,7 +837,7 @@ const BLOCKS = [
638
837
  ]
639
838
  },
640
839
  {
641
- type: "vitops.accordion",
840
+ type: "vitops_accordion",
642
841
  label: "Accordion",
643
842
  placeholder: "A group of collapsible sections",
644
843
  category: "Sections",
@@ -675,7 +874,7 @@ const BLOCKS = [
675
874
  }]
676
875
  },
677
876
  {
678
- type: "vitops.hero",
877
+ type: "vitops_hero",
679
878
  label: "Hero",
680
879
  placeholder: "Page opener: heading, text, buttons and an optional image",
681
880
  category: "Sections",
@@ -690,7 +889,7 @@ const BLOCKS = [
690
889
  action_id: "heading",
691
890
  label: "Heading (wrap words in *stars* to accent them)"
692
891
  },
693
- levelField("2"),
892
+ titleLevelField("1"),
694
893
  {
695
894
  type: "text_input",
696
895
  action_id: "body",
@@ -774,7 +973,7 @@ const BLOCKS = [
774
973
  ]
775
974
  },
776
975
  {
777
- type: "vitops.marquee",
976
+ type: "vitops_marquee",
778
977
  label: "Marquee",
779
978
  placeholder: "A continuously scrolling row (logos, quotes, badges)",
780
979
  category: "Media",
@@ -818,7 +1017,7 @@ const BLOCKS = [
818
1017
  ]
819
1018
  },
820
1019
  {
821
- type: "vitops.logos",
1020
+ type: "vitops_logos",
822
1021
  label: "Logos",
823
1022
  placeholder: "A still row of partner or client logos",
824
1023
  category: "Media",
@@ -839,12 +1038,12 @@ const BLOCKS = [
839
1038
  {
840
1039
  type: "text_input",
841
1040
  action_id: "image",
842
- label: "Image URL"
1041
+ label: "Image URL (leave empty to show the name as text)"
843
1042
  },
844
1043
  {
845
1044
  type: "text_input",
846
1045
  action_id: "alt",
847
- label: "Name (alt text)"
1046
+ label: "Name (alt text, or the text shown with no image)"
848
1047
  },
849
1048
  {
850
1049
  type: "text_input",
@@ -882,7 +1081,7 @@ const BLOCKS = [
882
1081
  ]
883
1082
  },
884
1083
  {
885
- type: "vitops.steps",
1084
+ type: "vitops_steps",
886
1085
  label: "Steps",
887
1086
  placeholder: "A numbered sequence joined by a connector line",
888
1087
  category: "Sections",
@@ -922,7 +1121,7 @@ const BLOCKS = [
922
1121
  ]
923
1122
  },
924
1123
  {
925
- type: "vitops.stats",
1124
+ type: "vitops_stats",
926
1125
  label: "Stats",
927
1126
  placeholder: "Headline figures that count up, or labelled tiles",
928
1127
  category: "Sections",
@@ -999,7 +1198,7 @@ const BLOCKS = [
999
1198
  ]
1000
1199
  },
1001
1200
  {
1002
- type: "vitops.callout",
1201
+ type: "vitops_callout",
1003
1202
  label: "Callout",
1004
1203
  placeholder: "A call-to-action band (not a dismissible banner)",
1005
1204
  category: "Sections",
@@ -1059,7 +1258,7 @@ const BLOCKS = [
1059
1258
  ]
1060
1259
  },
1061
1260
  {
1062
- type: "vitops.entries",
1261
+ type: "vitops_entries",
1063
1262
  label: "Entries table",
1064
1263
  placeholder: "Rows of term/value pairs: a stacked list that becomes a table when wide",
1065
1264
  category: "Sections",
@@ -1091,7 +1290,7 @@ const BLOCKS = [
1091
1290
  ]
1092
1291
  },
1093
1292
  {
1094
- type: "vitops.pricing",
1293
+ type: "vitops_pricing",
1095
1294
  label: "Pricing",
1096
1295
  placeholder: "Plan cards with aligned feature rows",
1097
1296
  category: "Sections",
@@ -1143,7 +1342,7 @@ const BLOCKS = [
1143
1342
  }, levelField("3")]
1144
1343
  },
1145
1344
  {
1146
- type: "vitops.embed",
1345
+ type: "vitops_embed",
1147
1346
  label: "Embed (video or map)",
1148
1347
  placeholder: "A video or map that loads only after the visitor allows it",
1149
1348
  category: "Embeds",
@@ -1211,7 +1410,7 @@ const BLOCKS = [
1211
1410
  ]
1212
1411
  },
1213
1412
  {
1214
- type: "vitops.form",
1413
+ type: "vitops_form",
1215
1414
  label: "Contact form",
1216
1415
  placeholder: "An enquiry form that emails you (and your CRM) and works without JavaScript",
1217
1416
  category: "Forms",
@@ -1274,6 +1473,10 @@ const BLOCKS = [
1274
1473
  {
1275
1474
  label: "Tick box",
1276
1475
  value: "checkbox"
1476
+ },
1477
+ {
1478
+ label: "Tick any number",
1479
+ value: "checkboxes"
1277
1480
  }
1278
1481
  ]
1279
1482
  },
@@ -1296,7 +1499,7 @@ const BLOCKS = [
1296
1499
  {
1297
1500
  type: "text_input",
1298
1501
  action_id: "options",
1299
- label: "Choices (one per line; for \"Choose one\" only)",
1502
+ label: "Choices (one per line; for \"Choose one\" and \"Tick any number\")",
1300
1503
  multiline: true
1301
1504
  }
1302
1505
  ]
@@ -1341,7 +1544,7 @@ const BLOCKS = [
1341
1544
  * an editor saves. Which reviews show is `selectReviews()` from `@getvitops/utils`, the
1342
1545
  * one place that is decided — these fields are exactly its `ReviewLocationConfig`.
1343
1546
  */
1344
- type: "vitops.reviews",
1547
+ type: "vitops_reviews",
1345
1548
  label: "Google reviews",
1346
1549
  icon: "star",
1347
1550
  placeholder: "Customer reviews from the Google Business Profile cache",
@@ -1452,7 +1655,7 @@ const BLOCKS = [
1452
1655
  * The menu is chosen from `menus/list` (the site's own menus) with the conventional names as
1453
1656
  * the static fallback, because a select needs a non-empty `options` list.
1454
1657
  */
1455
- type: "vitops.nav",
1658
+ type: "vitops_nav",
1456
1659
  label: "Menu",
1457
1660
  icon: "list",
1458
1661
  placeholder: "An EmDash menu as a list of links",
@@ -1530,8 +1733,147 @@ const BLOCKS = [
1530
1733
  },
1531
1734
  ...toneFields()
1532
1735
  ]
1736
+ },
1737
+ {
1738
+ /** A row of static labels (`badge`) or editable/dismissable ones (`tag`). */
1739
+ type: "vitops_badges",
1740
+ label: "Badges",
1741
+ icon: "tag",
1742
+ placeholder: "A row of short labels",
1743
+ fields: [
1744
+ {
1745
+ type: "repeater",
1746
+ action_id: "items",
1747
+ label: "Labels",
1748
+ fields: [
1749
+ {
1750
+ type: "text_input",
1751
+ action_id: "label",
1752
+ label: "Label"
1753
+ },
1754
+ {
1755
+ type: "select",
1756
+ action_id: "tone",
1757
+ label: "Tone (blank uses the block tone)",
1758
+ initial_value: "",
1759
+ options: STATIC_TONE_OPTIONS,
1760
+ optionsRoute: TONES_ROUTE
1761
+ },
1762
+ {
1763
+ type: "text_input",
1764
+ action_id: "color",
1765
+ label: "Palette colour instead (e.g. amber)"
1766
+ },
1767
+ {
1768
+ type: "combobox",
1769
+ action_id: "icon",
1770
+ label: "Icon before the label",
1771
+ placeholder: "None",
1772
+ options: SEMANTIC_ICON_OPTIONS
1773
+ },
1774
+ {
1775
+ type: "text_input",
1776
+ action_id: "href",
1777
+ label: "Link URL (optional)"
1778
+ }
1779
+ ]
1780
+ },
1781
+ {
1782
+ type: "select",
1783
+ action_id: "variant",
1784
+ label: "Style",
1785
+ initial_value: "badge",
1786
+ options: [{
1787
+ label: "Badge (static label)",
1788
+ value: "badge"
1789
+ }, {
1790
+ label: "Tag",
1791
+ value: "tag"
1792
+ }]
1793
+ },
1794
+ ...toneFields()
1795
+ ]
1796
+ },
1797
+ {
1798
+ /**
1799
+ * A widget area: content defined once under Widgets in the admin and shown wherever this
1800
+ * block is placed. Edits reach every page it sits on (after the next build, on a
1801
+ * prerendered site) — unlike a Section, which is copied.
1802
+ */
1803
+ type: "vitops_widgetArea",
1804
+ label: "Widget area",
1805
+ icon: "layout",
1806
+ placeholder: "Shared content managed under Widgets",
1807
+ fields: [{
1808
+ type: "select",
1809
+ action_id: "area",
1810
+ label: "Widget area",
1811
+ initial_value: "",
1812
+ options: [{
1813
+ label: "Choose…",
1814
+ value: ""
1815
+ }],
1816
+ optionsRoute: WIDGET_AREAS_ROUTE
1817
+ }, levelField("3")]
1533
1818
  }
1534
1819
  ];
1820
+ /** Every block gets the escape hatches last, so the editor reads content first. */
1821
+ /**
1822
+ * Repeaters whose rows can carry their own colour. A row's `tone` / `color` win over the block's,
1823
+ * and blank falls back to it, so one block can hold a pine card and an amber one without
1824
+ * becoming two blocks. (If you want a single colour for the lot, set it on the block.)
1825
+ */
1826
+ const ROW_TONE = {
1827
+ vitops_cards: "cards",
1828
+ vitops_stats: "stats",
1829
+ vitops_steps: "steps",
1830
+ vitops_hero: "cards"
1831
+ };
1832
+ /** Extra row fields for one repeater, beyond the shared tone pair. */
1833
+ const ROW_EXTRA = { vitops_hero: { cards: [
1834
+ {
1835
+ type: "text_input",
1836
+ action_id: "eyebrow",
1837
+ label: "Eyebrow (small line above the title)"
1838
+ },
1839
+ {
1840
+ type: "text_input",
1841
+ action_id: "ctaLabel",
1842
+ label: "Button text"
1843
+ },
1844
+ {
1845
+ type: "text_input",
1846
+ action_id: "ctaHref",
1847
+ label: "Button URL"
1848
+ }
1849
+ ] } };
1850
+ const rowToneFields = () => [{
1851
+ type: "select",
1852
+ action_id: "tone",
1853
+ label: "Tone for this item (blank uses the block tone)",
1854
+ initial_value: "",
1855
+ options: STATIC_TONE_OPTIONS,
1856
+ optionsRoute: TONES_ROUTE
1857
+ }, {
1858
+ type: "text_input",
1859
+ action_id: "color",
1860
+ label: "Palette colour instead (e.g. amber)"
1861
+ }];
1862
+ function withRowFields(block) {
1863
+ const repeater = ROW_TONE[block.type];
1864
+ return (block.fields ?? []).map((f) => f.type === "repeater" && f.action_id === repeater ? {
1865
+ ...f,
1866
+ fields: [
1867
+ ...f.fields,
1868
+ ...ROW_EXTRA[block.type]?.[repeater] ?? [],
1869
+ ...rowToneFields()
1870
+ ]
1871
+ } : f);
1872
+ }
1873
+ const BLOCKS = BASE_BLOCKS.map((b) => ({
1874
+ ...b,
1875
+ fields: [...withRowFields(b), ...commonFields()]
1876
+ }));
1535
1877
  /**
1536
1878
  * Resolve {@link BlockSelection} against {@link BLOCKS}.
1537
1879
  *
@@ -1626,6 +1968,27 @@ function createTonesRoute(roles) {
1626
1968
  handler: async () => ({ items: toneItems(roles()) })
1627
1969
  };
1628
1970
  }
1971
+ /** The `theme` select's items: `None`, then each role tinted and filled (`role`, `role:solid`). */
1972
+ function themeItems(roles) {
1973
+ const names = [.../* @__PURE__ */ new Set([...roles, ...SEMANTIC_TONES])];
1974
+ const label = (id) => id.replace(/-/g, " ");
1975
+ return [{
1976
+ id: "",
1977
+ name: "None"
1978
+ }, ...names.flatMap((id) => [{
1979
+ id,
1980
+ name: label(id)
1981
+ }, {
1982
+ id: `${id}:solid`,
1983
+ name: `${label(id)} (filled)`
1984
+ }])];
1985
+ }
1986
+ function createThemesRoute(roles) {
1987
+ return {
1988
+ permission: "content:read",
1989
+ handler: async () => ({ items: themeItems(roles()) })
1990
+ };
1991
+ }
1629
1992
 
1630
1993
  // src/design-system-route.ts
1631
1994
  /**
@@ -1768,94 +2131,6 @@ async function designSystemOverrideFragment(ctx, opts) {
1768
2131
  };
1769
2132
  }
1770
2133
 
1771
- // src/block-types.ts
1772
- /** Block-type slugs may not contain a dot, so `vitops.section` is stored as `vitops_section`. */
1773
- const SECTION_SLUG = "vitops_section";
1774
- const select = (slug, label, options, defaultValue) => ({
1775
- slug,
1776
- label,
1777
- type: "select",
1778
- defaultValue,
1779
- validation: { options }
1780
- });
1781
- const SECTION_FIELDS = [
1782
- {
1783
- slug: "kicker",
1784
- label: "Kicker (small line above the heading)",
1785
- type: "string"
1786
- },
1787
- {
1788
- slug: "heading",
1789
- label: "Heading",
1790
- type: "string"
1791
- },
1792
- select("level", "Heading level", [
1793
- "2",
1794
- "3",
1795
- "4",
1796
- "5",
1797
- "6"
1798
- ], "2"),
1799
- {
1800
- slug: "sub",
1801
- label: "Lead-in text under the heading",
1802
- type: "text"
1803
- },
1804
- {
1805
- slug: "body",
1806
- label: "Content (any blocks)",
1807
- type: "portableText"
1808
- },
1809
- select("layout", "Layout", [
1810
- "stack",
1811
- "center",
1812
- "split"
1813
- ], "stack"),
1814
- select("width", "Width", [
1815
- "measure",
1816
- "breakout",
1817
- "spotlight",
1818
- "fullbleed"
1819
- ], "measure"),
1820
- {
1821
- slug: "tone",
1822
- label: "Backdrop tone (a colour role from the site, e.g. surface or brand-primary)",
1823
- type: "string"
1824
- },
1825
- {
1826
- slug: "color",
1827
- label: "Palette colour instead of a tone (e.g. amber or amber-300)",
1828
- type: "string"
1829
- },
1830
- {
1831
- slug: "solid",
1832
- label: "Fill with the solid colour",
1833
- type: "boolean",
1834
- defaultValue: false
1835
- }
1836
- ];
1837
- /** `vitops.section`: kicker, heading, lead-in, tone/backdrop, optional heading-left layout, and a body of blocks. */
1838
- const SECTION_BLOCK_TYPE = {
1839
- slug: SECTION_SLUG,
1840
- label: "Section",
1841
- description: "A titled page section whose body can hold any other blocks.",
1842
- category: "Sections",
1843
- icon: "layout",
1844
- currentVersion: 1,
1845
- versions: [{
1846
- version: 1,
1847
- fields: SECTION_FIELDS
1848
- }]
1849
- };
1850
- /**
1851
- * Every block type this plugin ships, in the shape `seed.json`'s `blockTypes` takes. Spread
1852
- * it into the seed (and allow the slugs on the collection's `blocks` field), e.g.
1853
- * `blockTypes: [...vitopsBlockTypes()]`.
1854
- */
1855
- function vitopsBlockTypes() {
1856
- return [SECTION_BLOCK_TYPE];
1857
- }
1858
-
1859
2134
  // src/blocks-doc.ts
1860
2135
  const cell = (s) => (s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
1861
2136
  function describe(f) {
@@ -1889,7 +2164,7 @@ function renderBlocksDoc(blocks = BLOCKS) {
1889
2164
  "",
1890
2165
  "# EmDash blocks",
1891
2166
  "",
1892
- "Each block is a Portable Text object: `{ \"_type\": \"vitops.cards\", \"_key\": \"<unique>\", ...fields }`.",
2167
+ "Each block is a Portable Text object: `{ \"_type\": \"vitops_cards\", \"_key\": \"<unique>\", ...fields }`.",
1893
2168
  "Portable Text blocks are flat — they cannot contain other blocks, and a repeater row holds scalar fields only.",
1894
2169
  "To put blocks inside something, use the section block TYPE (end of this page), whose body is Portable Text.",
1895
2170
  "Give every block a unique `_key`; gallery and carousel derive their element id from it.",
@@ -1899,9 +2174,27 @@ function renderBlocksDoc(blocks = BLOCKS) {
1899
2174
  "Every block that colours something takes the same two fields. `tone` is a colour role from the",
1900
2175
  "site's `colors.roles` (or a semantic name); an unknown role falls back through its semantic",
1901
2176
  "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",
2177
+ "(`amber-300`) and wins over `tone`; text on a hue solid is the site's `--color-<hue>-<step>-on`,",
2178
+ "but contrast is only guaranteed for roles. Renderers emit `--tone-*` custom properties, never a",
2179
+ "class built from the value. Rows of `hero.cards`, `cards`, `stats` and `steps` (and `badges.items`)",
2180
+ "take their own `tone`/`color`; blank inherits the block's, and a row's colour never reaches its",
2181
+ "siblings, so mixed colours need no extra block. Multiline fields that accept links take",
1904
2182
  "`[text](/url)`; hero headings accent words wrapped in `*stars*`.",
2183
+ "",
2184
+ "## Theme",
2185
+ "",
2186
+ "Every block carries `theme`: a colour role, tinted (`surface`) or filled (`brand-primary:solid`), applied to",
2187
+ "the block's own root with the role's computed readable text. It wins over the block's `tone`.",
2188
+ "",
2189
+ "## Type size",
2190
+ "",
2191
+ "Titles use the type roles (`font-heading`, `font-display`, `font-eyebrow`). Every role reads its size",
2192
+ "from a custom property, `--<role>-fs` (`--heading-fs`, `--display-fs`), falling back to the role's",
2193
+ "configured size, so one block can resize its titles through its `style` field",
2194
+ "(`--heading-fs: 1.125rem`) with no site CSS; set it on an ancestor to resize a region. It is inherited,",
2195
+ "so it reaches every use of that role inside the block. `style` accepts custom properties only.",
2196
+ "Steps, stats and cards also have per-part hooks: `--steps-title-size` (default one step below the",
2197
+ "heading role), `--stats-figure-size` and `--card-title-size`.",
1905
2198
  ""
1906
2199
  ];
1907
2200
  for (const b of blocks) {
@@ -1909,7 +2202,7 @@ function renderBlocksDoc(blocks = BLOCKS) {
1909
2202
  if (b.placeholder) out.push(`${b.placeholder}.`, "");
1910
2203
  if (b.fields?.length) out.push("| Field | Type | Label | Notes |", "| --- | --- | --- | --- |", ...fieldRows(b.fields), "");
1911
2204
  }
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`.", "");
2205
+ 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.", "`vitops emdash check-prerender` warns (fails under `--strict`) when a built page has a form", "posting to `/api/track` and `site.json` configures nowhere to deliver it, or lists none of its fields", "under `site.templates`.", "", "A `checkboxes` field (\"tick any number\") posts one entry per ticked box under the same name; the route", "joins them with `\", \"`. HTML cannot require \"at least one\", so `required` is not applied to the group.", "", "The generated privacy policy reads `site.templates` form entries, not this block. Convert the saved", "block with `formTemplateFromBlock(block)` from `@getvitops/emdash` and paste the result under", "`site.templates` in `site.json`, so the policy lists what the form collects.", "", "## 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
2206
  for (const t of vitopsBlockTypes()) {
1914
2207
  out.push(`### \`${t.slug}\` — ${t.label}`, "", `${t.description ?? ""}`, "");
1915
2208
  const fields = t.versions.find((v) => v.version === t.currentVersion)?.fields ?? [];
@@ -1917,7 +2210,7 @@ function renderBlocksDoc(blocks = BLOCKS) {
1917
2210
  const notes = [
1918
2211
  f.validation?.options ? `one of ${f.validation.options.join(", ")}` : "",
1919
2212
  f.defaultValue !== void 0 ? `default \`${String(f.defaultValue)}\`` : "",
1920
- f.type === "portableText" ? "accepts every `vitops.*` block above" : ""
2213
+ f.type === "portableText" ? "accepts every `vitops_*` block above" : ""
1921
2214
  ].filter(Boolean);
1922
2215
  return `| \`${f.slug}\` | ${f.type} | ${cell(f.label)} | ${cell(notes.join("; "))} |`;
1923
2216
  }), "");
@@ -1925,6 +2218,160 @@ function renderBlocksDoc(blocks = BLOCKS) {
1925
2218
  return out.join("\n");
1926
2219
  }
1927
2220
 
2221
+ // src/astro/blocks/form.ts
2222
+ /**
2223
+ * Pure decisions for the `vitops_form` block: which of an editor's field rows become real
2224
+ * controls, and under what submitted names.
2225
+ *
2226
+ * Names matter more than they look. `createConversionRoute` (`@getvitops/astro/routes`) is
2227
+ * what receives the post, and it reserves a few: `phone` and `event` are the tel:/mailto:
2228
+ * click-beacon payload and are dropped from a form submission, `cf-turnstile-response` is
2229
+ * the Turnstile token, and `website`/`honeypot`/`_honeypot` are the honeypots (a filled one
2230
+ * makes the route answer success and deliver nothing). A visitor-facing field called
2231
+ * "Phone" must therefore not submit as `phone`, or the number silently vanishes from the
2232
+ * notification and the CRM record. `identifySubmitter` reads `tel` for a phone number, so
2233
+ * that is where it goes.
2234
+ */
2235
+ const FIELD_TYPES = [
2236
+ "text",
2237
+ "email",
2238
+ "tel",
2239
+ "textarea",
2240
+ "select",
2241
+ "checkbox",
2242
+ "checkboxes"
2243
+ ];
2244
+ /** Names the route treats specially. A field must never submit under one of these. */
2245
+ const RESERVED_NAMES = /* @__PURE__ */ new Set([
2246
+ "event",
2247
+ "phone",
2248
+ "cf-turnstile-response",
2249
+ "website",
2250
+ "honeypot",
2251
+ "_honeypot",
2252
+ "_next"
2253
+ ]);
2254
+ const MAX_FIELDS = 20;
2255
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2256
+ /** `Your full name` -> `your_full_name`. Empty when the text holds nothing usable. */
2257
+ function slugName(input) {
2258
+ return input.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40);
2259
+ }
2260
+ /** A submitted name for a row: the editor's `name` if usable, else one derived from the label. */
2261
+ function fieldName(raw, label, type, taken) {
2262
+ let base = slugName(str(raw)) || slugName(label) || type;
2263
+ if (!/^[a-z]/.test(base)) base = `f_${base}`;
2264
+ if (base === "phone") base = "tel";
2265
+ if (RESERVED_NAMES.has(base)) base = `${base}_field`;
2266
+ let name = base;
2267
+ for (let n = 2; taken.has(name); n++) name = `${base}_${n}`;
2268
+ taken.add(name);
2269
+ return name;
2270
+ }
2271
+ /** The HTML `autocomplete` token for a field, or undefined when there is no honest one. */
2272
+ function autocompleteFor(name, type) {
2273
+ if (type === "email") return "email";
2274
+ if (type === "tel") return "tel";
2275
+ if (type !== "text") return void 0;
2276
+ return {
2277
+ name: "name",
2278
+ full_name: "name",
2279
+ your_name: "name",
2280
+ first_name: "given-name",
2281
+ given_name: "given-name",
2282
+ last_name: "family-name",
2283
+ family_name: "family-name",
2284
+ company: "organization",
2285
+ organization: "organization",
2286
+ organisation: "organization",
2287
+ postal_code: "postal-code",
2288
+ postcode: "postal-code",
2289
+ zip: "postal-code",
2290
+ city: "address-level2",
2291
+ address: "street-address"
2292
+ }[name];
2293
+ }
2294
+ /** Editor rows to renderable fields. Rows with no label are dropped; at most {@link MAX_FIELDS}. */
2295
+ function resolveFields(rows) {
2296
+ const taken = /* @__PURE__ */ new Set();
2297
+ const out = [];
2298
+ for (const row of rows) {
2299
+ const label = str(row["label"]);
2300
+ if (!label) continue;
2301
+ if (out.length >= 20) break;
2302
+ const rawType = str(row["type"]);
2303
+ const type = FIELD_TYPES.includes(rawType) ? rawType : "text";
2304
+ const name = fieldName(row["name"], label, type, taken);
2305
+ const options = type === "select" || type === "checkboxes" ? str(row["options"]).split("\n").map((o) => o.trim()).filter(Boolean) : [];
2306
+ const effective = (type === "select" || type === "checkboxes") && options.length === 0 ? "text" : type;
2307
+ out.push({
2308
+ name,
2309
+ id: `form-${out.length + 1}-${name}`,
2310
+ label,
2311
+ type: effective,
2312
+ required: row["required"] === true,
2313
+ placeholder: effective === "checkbox" || effective === "select" || effective === "checkboxes" ? "" : str(row["placeholder"]),
2314
+ help: str(row["help"]),
2315
+ options,
2316
+ autocomplete: autocompleteFor(name, effective)
2317
+ });
2318
+ }
2319
+ return out;
2320
+ }
2321
+ /**
2322
+ * Where the endpoint may send a visitor back to: a same-site path, nothing else. The form
2323
+ * posts its own page path as `_next`; the route that reads it must apply this too, or the
2324
+ * field becomes an open redirect.
2325
+ */
2326
+ function safeNext(input) {
2327
+ const v = typeof input === "string" ? input : "";
2328
+ if (!v.startsWith("/") || v.startsWith("//") || v.includes("\\") || v.length > 200) return null;
2329
+ if (/[\u0000-\u001f\u007f#?]/.test(v)) return null;
2330
+ return v;
2331
+ }
2332
+ /** The endpoint a form posts to: a same-site path, defaulting to the conversion route. */
2333
+ function safeAction(input) {
2334
+ return safeNext(input) ?? "/api/track";
2335
+ }
2336
+
2337
+ // src/form-template.ts
2338
+ /**
2339
+ * A saved `vitops_form` block as a `site.templates` form entry.
2340
+ *
2341
+ * `vitops legal` derives "what personal information we collect" from `site.templates` entries
2342
+ * of type `form`, and a build cannot read the CMS database the editor's block lives in, so a
2343
+ * form added in the admin is invisible to the generated privacy policy. This turns a block's
2344
+ * saved value (copy it from the admin's JSON view, or from `emdash` content export) into the
2345
+ * entry to paste under `site.templates` in `site.json`.
2346
+ *
2347
+ * It resolves fields through the SAME `resolveFields` the renderer uses, so the names are the
2348
+ * ones the form actually submits (`phone` becomes `tel`, reserved names are renamed) and rows
2349
+ * the renderer drops (no label) are dropped here too. A `checkboxes` becomes a `checkbox`
2350
+ * with its choices as `options`: the policy's schema has no group type and both read as
2351
+ * "your answers to the questions we ask".
2352
+ */
2353
+ /** `block` is a saved `vitops_form` value (the object with `fields`, `action`, …). */
2354
+ function formTemplateFromBlock(block) {
2355
+ const value = block && typeof block === "object" ? block : {};
2356
+ const rows = (Array.isArray(value["fields"]) ? value["fields"] : []).filter((r) => !!r && typeof r === "object" && !Array.isArray(r));
2357
+ return {
2358
+ type: "form",
2359
+ action: safeAction(value["action"]),
2360
+ method: "post",
2361
+ honeypot: true,
2362
+ fields: resolveFields(rows).map((f) => ({
2363
+ name: f.name,
2364
+ type: f.type === "checkboxes" ? "checkbox" : f.type,
2365
+ label: f.label,
2366
+ ...f.required ? { required: true } : {},
2367
+ ...f.options.length ? { options: f.options.map((o) => ({
2368
+ label: o,
2369
+ value: o
2370
+ })) } : {}
2371
+ }))
2372
+ };
2373
+ }
2374
+
1928
2375
  // src/hosting.ts
1929
2376
  const TARGETS = ["cloudflare", "node"];
1930
2377
  /**
@@ -1986,7 +2433,7 @@ async function vitopsHosting(options = {}) {
1986
2433
  }
1987
2434
 
1988
2435
  // package.json
1989
- var version = "2.0.0-alpha.0";
2436
+ var version = "2.0.0-alpha.2";
1990
2437
 
1991
2438
  // src/index.ts
1992
2439
  /**
@@ -2088,11 +2535,13 @@ function createPlugin(options = {}) {
2088
2535
  },
2089
2536
  routes: {
2090
2537
  [TONES_ROUTE]: createTonesRoute(() => roles),
2538
+ [THEMES_ROUTE]: createThemesRoute(() => roles),
2091
2539
  [MENUS_ROUTE]: createMenusRoute(() => getMenus()),
2540
+ [WIDGET_AREAS_ROUTE]: createMenusRoute(() => getWidgetAreas()),
2092
2541
  ...editor ? { "design-system": createDesignSystemRoute(editor) } : {}
2093
2542
  }
2094
2543
  });
2095
2544
  }
2096
2545
 
2097
- export { BLOCKS, SECTION_BLOCK_TYPE, SECTION_SLUG, createPlugin, createPlugin as default, renderBlocksDoc, resolveTone, selectBlocks, toneChain, vitopsBlockTypes, vitopsEmdash, vitopsHosting };
2546
+ export { BLOCKS, SECTION_BLOCK_TYPE, SECTION_SLUG, createPlugin, createPlugin as default, formTemplateFromBlock, renderBlocksDoc, resolveTone, selectBlocks, toneChain, vitopsBlockTypes, vitopsEmdash, vitopsHosting };
2098
2547
  //# sourceMappingURL=index.mjs.map