@getvitops/emdash 2.0.0-alpha.1 → 2.0.0-alpha.3

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.
package/dist/index.mjs CHANGED
@@ -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,7 +300,7 @@ function toneFields() {
167
300
  label: "Palette colour instead of a tone (e.g. amber or amber-300)"
168
301
  }];
169
302
  }
170
- /** Route that lists the site's widget areas for `vitops.widgetArea`'s `area` select. */
303
+ /** Route that lists the site's widget areas for `vitops_widgetArea`'s `area` select. */
171
304
  const WIDGET_AREAS_ROUTE = "widget-areas/list";
172
305
  /**
173
306
  * The escape hatches every block carries, appended last by `BLOCKS` (see `astro/blocks/attrs.ts`
@@ -175,6 +308,23 @@ const WIDGET_AREAS_ROUTE = "widget-areas/list";
175
308
  */
176
309
  function commonFields() {
177
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
+ },
178
328
  {
179
329
  type: "text_input",
180
330
  action_id: "anchor",
@@ -209,6 +359,30 @@ const levelField = (initial) => ({
209
359
  value: n
210
360
  }))
211
361
  });
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
+ });
212
386
  const BASE_BLOCKS = [
213
387
  {
214
388
  /**
@@ -224,7 +398,7 @@ const BASE_BLOCKS = [
224
398
  * raw HTML), so `<wc-icon-picker>` cannot be mounted inside a block modal
225
399
  * however much nicer it would be. Revisit if EmDash opens that union.
226
400
  */
227
- type: "vitops.actionLink",
401
+ type: "vitops_actionLink",
228
402
  label: "Link or button",
229
403
  icon: "link",
230
404
  placeholder: "A link or button, with optional icons",
@@ -310,7 +484,7 @@ const BASE_BLOCKS = [
310
484
  ]
311
485
  },
312
486
  {
313
- type: "vitops.imageCompare",
487
+ type: "vitops_imageCompare",
314
488
  label: "Image compare",
315
489
  placeholder: "Before/after image slider",
316
490
  fields: [
@@ -349,7 +523,7 @@ const BASE_BLOCKS = [
349
523
  ]
350
524
  },
351
525
  {
352
- type: "vitops.copyButton",
526
+ type: "vitops_copyButton",
353
527
  label: "Copy snippet",
354
528
  icon: "code",
355
529
  placeholder: "Text with a copy-to-clipboard button",
@@ -382,7 +556,7 @@ const BASE_BLOCKS = [
382
556
  ]
383
557
  },
384
558
  {
385
- type: "vitops.banner",
559
+ type: "vitops_banner",
386
560
  label: "Banner",
387
561
  placeholder: "Announcement or notice",
388
562
  fields: [
@@ -425,7 +599,7 @@ const BASE_BLOCKS = [
425
599
  ]
426
600
  },
427
601
  {
428
- type: "vitops.details",
602
+ type: "vitops_details",
429
603
  label: "Disclosure",
430
604
  placeholder: "Collapsible details/summary",
431
605
  fields: [
@@ -449,7 +623,7 @@ const BASE_BLOCKS = [
449
623
  ]
450
624
  },
451
625
  {
452
- type: "vitops.carousel",
626
+ type: "vitops_carousel",
453
627
  label: "Carousel",
454
628
  placeholder: "Image carousel",
455
629
  category: "Media",
@@ -509,7 +683,7 @@ const BASE_BLOCKS = [
509
683
  ]
510
684
  },
511
685
  {
512
- type: "vitops.gallery",
686
+ type: "vitops_gallery",
513
687
  label: "Gallery",
514
688
  placeholder: "Thumbnail grid that opens a lightbox carousel",
515
689
  category: "Media",
@@ -559,7 +733,7 @@ const BASE_BLOCKS = [
559
733
  ]
560
734
  },
561
735
  {
562
- type: "vitops.cards",
736
+ type: "vitops_cards",
563
737
  label: "Cards",
564
738
  placeholder: "A grid of cards",
565
739
  category: "Sections",
@@ -663,7 +837,7 @@ const BASE_BLOCKS = [
663
837
  ]
664
838
  },
665
839
  {
666
- type: "vitops.accordion",
840
+ type: "vitops_accordion",
667
841
  label: "Accordion",
668
842
  placeholder: "A group of collapsible sections",
669
843
  category: "Sections",
@@ -700,7 +874,7 @@ const BASE_BLOCKS = [
700
874
  }]
701
875
  },
702
876
  {
703
- type: "vitops.hero",
877
+ type: "vitops_hero",
704
878
  label: "Hero",
705
879
  placeholder: "Page opener: heading, text, buttons and an optional image",
706
880
  category: "Sections",
@@ -715,7 +889,7 @@ const BASE_BLOCKS = [
715
889
  action_id: "heading",
716
890
  label: "Heading (wrap words in *stars* to accent them)"
717
891
  },
718
- levelField("2"),
892
+ titleLevelField("1"),
719
893
  {
720
894
  type: "text_input",
721
895
  action_id: "body",
@@ -799,7 +973,7 @@ const BASE_BLOCKS = [
799
973
  ]
800
974
  },
801
975
  {
802
- type: "vitops.marquee",
976
+ type: "vitops_marquee",
803
977
  label: "Marquee",
804
978
  placeholder: "A continuously scrolling row (logos, quotes, badges)",
805
979
  category: "Media",
@@ -843,7 +1017,7 @@ const BASE_BLOCKS = [
843
1017
  ]
844
1018
  },
845
1019
  {
846
- type: "vitops.logos",
1020
+ type: "vitops_logos",
847
1021
  label: "Logos",
848
1022
  placeholder: "A still row of partner or client logos",
849
1023
  category: "Media",
@@ -864,12 +1038,12 @@ const BASE_BLOCKS = [
864
1038
  {
865
1039
  type: "text_input",
866
1040
  action_id: "image",
867
- label: "Image URL"
1041
+ label: "Image URL (leave empty to show the name as text)"
868
1042
  },
869
1043
  {
870
1044
  type: "text_input",
871
1045
  action_id: "alt",
872
- label: "Name (alt text)"
1046
+ label: "Name (alt text, or the text shown with no image)"
873
1047
  },
874
1048
  {
875
1049
  type: "text_input",
@@ -907,7 +1081,7 @@ const BASE_BLOCKS = [
907
1081
  ]
908
1082
  },
909
1083
  {
910
- type: "vitops.steps",
1084
+ type: "vitops_steps",
911
1085
  label: "Steps",
912
1086
  placeholder: "A numbered sequence joined by a connector line",
913
1087
  category: "Sections",
@@ -947,7 +1121,7 @@ const BASE_BLOCKS = [
947
1121
  ]
948
1122
  },
949
1123
  {
950
- type: "vitops.stats",
1124
+ type: "vitops_stats",
951
1125
  label: "Stats",
952
1126
  placeholder: "Headline figures that count up, or labelled tiles",
953
1127
  category: "Sections",
@@ -1024,7 +1198,7 @@ const BASE_BLOCKS = [
1024
1198
  ]
1025
1199
  },
1026
1200
  {
1027
- type: "vitops.callout",
1201
+ type: "vitops_callout",
1028
1202
  label: "Callout",
1029
1203
  placeholder: "A call-to-action band (not a dismissible banner)",
1030
1204
  category: "Sections",
@@ -1084,7 +1258,7 @@ const BASE_BLOCKS = [
1084
1258
  ]
1085
1259
  },
1086
1260
  {
1087
- type: "vitops.entries",
1261
+ type: "vitops_entries",
1088
1262
  label: "Entries table",
1089
1263
  placeholder: "Rows of term/value pairs: a stacked list that becomes a table when wide",
1090
1264
  category: "Sections",
@@ -1116,7 +1290,7 @@ const BASE_BLOCKS = [
1116
1290
  ]
1117
1291
  },
1118
1292
  {
1119
- type: "vitops.pricing",
1293
+ type: "vitops_pricing",
1120
1294
  label: "Pricing",
1121
1295
  placeholder: "Plan cards with aligned feature rows",
1122
1296
  category: "Sections",
@@ -1168,7 +1342,7 @@ const BASE_BLOCKS = [
1168
1342
  }, levelField("3")]
1169
1343
  },
1170
1344
  {
1171
- type: "vitops.embed",
1345
+ type: "vitops_embed",
1172
1346
  label: "Embed (video or map)",
1173
1347
  placeholder: "A video or map that loads only after the visitor allows it",
1174
1348
  category: "Embeds",
@@ -1236,7 +1410,7 @@ const BASE_BLOCKS = [
1236
1410
  ]
1237
1411
  },
1238
1412
  {
1239
- type: "vitops.form",
1413
+ type: "vitops_form",
1240
1414
  label: "Contact form",
1241
1415
  placeholder: "An enquiry form that emails you (and your CRM) and works without JavaScript",
1242
1416
  category: "Forms",
@@ -1299,6 +1473,10 @@ const BASE_BLOCKS = [
1299
1473
  {
1300
1474
  label: "Tick box",
1301
1475
  value: "checkbox"
1476
+ },
1477
+ {
1478
+ label: "Tick any number",
1479
+ value: "checkboxes"
1302
1480
  }
1303
1481
  ]
1304
1482
  },
@@ -1321,7 +1499,7 @@ const BASE_BLOCKS = [
1321
1499
  {
1322
1500
  type: "text_input",
1323
1501
  action_id: "options",
1324
- label: "Choices (one per line; for \"Choose one\" only)",
1502
+ label: "Choices (one per line; for \"Choose one\" and \"Tick any number\")",
1325
1503
  multiline: true
1326
1504
  }
1327
1505
  ]
@@ -1366,7 +1544,7 @@ const BASE_BLOCKS = [
1366
1544
  * an editor saves. Which reviews show is `selectReviews()` from `@getvitops/utils`, the
1367
1545
  * one place that is decided — these fields are exactly its `ReviewLocationConfig`.
1368
1546
  */
1369
- type: "vitops.reviews",
1547
+ type: "vitops_reviews",
1370
1548
  label: "Google reviews",
1371
1549
  icon: "star",
1372
1550
  placeholder: "Customer reviews from the Google Business Profile cache",
@@ -1477,7 +1655,7 @@ const BASE_BLOCKS = [
1477
1655
  * The menu is chosen from `menus/list` (the site's own menus) with the conventional names as
1478
1656
  * the static fallback, because a select needs a non-empty `options` list.
1479
1657
  */
1480
- type: "vitops.nav",
1658
+ type: "vitops_nav",
1481
1659
  label: "Menu",
1482
1660
  icon: "list",
1483
1661
  placeholder: "An EmDash menu as a list of links",
@@ -1558,7 +1736,7 @@ const BASE_BLOCKS = [
1558
1736
  },
1559
1737
  {
1560
1738
  /** A row of static labels (`badge`) or editable/dismissable ones (`tag`). */
1561
- type: "vitops.badges",
1739
+ type: "vitops_badges",
1562
1740
  label: "Badges",
1563
1741
  icon: "tag",
1564
1742
  placeholder: "A row of short labels",
@@ -1622,7 +1800,7 @@ const BASE_BLOCKS = [
1622
1800
  * block is placed. Edits reach every page it sits on (after the next build, on a
1623
1801
  * prerendered site) — unlike a Section, which is copied.
1624
1802
  */
1625
- type: "vitops.widgetArea",
1803
+ type: "vitops_widgetArea",
1626
1804
  label: "Widget area",
1627
1805
  icon: "layout",
1628
1806
  placeholder: "Shared content managed under Widgets",
@@ -1640,9 +1818,61 @@ const BASE_BLOCKS = [
1640
1818
  }
1641
1819
  ];
1642
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
+ }
1643
1873
  const BLOCKS = BASE_BLOCKS.map((b) => ({
1644
1874
  ...b,
1645
- fields: [...b.fields ?? [], ...commonFields()]
1875
+ fields: [...withRowFields(b), ...commonFields()]
1646
1876
  }));
1647
1877
  /**
1648
1878
  * Resolve {@link BlockSelection} against {@link BLOCKS}.
@@ -1738,6 +1968,27 @@ function createTonesRoute(roles) {
1738
1968
  handler: async () => ({ items: toneItems(roles()) })
1739
1969
  };
1740
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
+ }
1741
1992
 
1742
1993
  // src/design-system-route.ts
1743
1994
  /**
@@ -1880,130 +2131,6 @@ async function designSystemOverrideFragment(ctx, opts) {
1880
2131
  };
1881
2132
  }
1882
2133
 
1883
- // src/block-types.ts
1884
- /** Block-type slugs may not contain a dot, so `vitops.section` is stored as `vitops_section`. */
1885
- const SECTION_SLUG = "vitops_section";
1886
- const select = (slug, label, options, defaultValue) => ({
1887
- slug,
1888
- label,
1889
- type: "select",
1890
- defaultValue,
1891
- validation: { options }
1892
- });
1893
- /**
1894
- * The `theme` select's values: `none`, a role, or `<role>:solid`. Block-type selects are
1895
- * static, so this lists the roles every site defines; any other site role still works through
1896
- * the `tone` field. `<role>:solid` is the filled backdrop with the contrast-computed text.
1897
- */
1898
- const THEME_ROLES = [
1899
- "neutral",
1900
- "surface",
1901
- "brand-primary",
1902
- "brand-secondary",
1903
- "info",
1904
- "success",
1905
- "warning",
1906
- "danger"
1907
- ];
1908
- const THEMES = [
1909
- "none",
1910
- ...THEME_ROLES,
1911
- ...THEME_ROLES.map((r) => `${r}:solid`)
1912
- ];
1913
- const SECTION_FIELDS = [
1914
- {
1915
- slug: "kicker",
1916
- label: "Kicker (small line above the heading)",
1917
- type: "string"
1918
- },
1919
- {
1920
- slug: "heading",
1921
- label: "Heading",
1922
- type: "string"
1923
- },
1924
- select("level", "Heading level", [
1925
- "2",
1926
- "3",
1927
- "4",
1928
- "5",
1929
- "6"
1930
- ], "2"),
1931
- {
1932
- slug: "sub",
1933
- label: "Lead-in text under the heading",
1934
- type: "text"
1935
- },
1936
- {
1937
- slug: "body",
1938
- label: "Content (any blocks)",
1939
- type: "portableText"
1940
- },
1941
- select("layout", "Layout", [
1942
- "stack",
1943
- "center",
1944
- "split"
1945
- ], "stack"),
1946
- select("width", "Width", [
1947
- "measure",
1948
- "breakout",
1949
- "spotlight",
1950
- "fullbleed"
1951
- ], "measure"),
1952
- {
1953
- slug: "tone",
1954
- label: "Backdrop tone (a colour role from the site, e.g. surface or brand-primary)",
1955
- type: "string"
1956
- },
1957
- {
1958
- slug: "color",
1959
- label: "Palette colour instead of a tone (e.g. amber or amber-300)",
1960
- type: "string"
1961
- },
1962
- {
1963
- slug: "solid",
1964
- label: "Fill with the solid colour",
1965
- type: "boolean",
1966
- defaultValue: false
1967
- },
1968
- select("theme", "Theme (backdrop and readable text in one pick; overrides the tone fields)", THEMES, "none"),
1969
- {
1970
- slug: "anchor",
1971
- label: "Anchor id (so a link can point here with #id)",
1972
- type: "string"
1973
- },
1974
- {
1975
- slug: "class_name",
1976
- label: "Extra CSS classes",
1977
- type: "string"
1978
- },
1979
- {
1980
- slug: "style",
1981
- label: "CSS custom properties, e.g. --p-card: 2rem",
1982
- type: "string"
1983
- }
1984
- ];
1985
- /** `vitops.section`: kicker, heading, lead-in, tone/backdrop, optional heading-left layout, and a body of blocks. */
1986
- const SECTION_BLOCK_TYPE = {
1987
- slug: SECTION_SLUG,
1988
- label: "Section",
1989
- description: "A titled page section whose body can hold any other blocks.",
1990
- category: "Sections",
1991
- icon: "layout",
1992
- currentVersion: 1,
1993
- versions: [{
1994
- version: 1,
1995
- fields: SECTION_FIELDS
1996
- }]
1997
- };
1998
- /**
1999
- * Every block type this plugin ships, in the shape `seed.json`'s `blockTypes` takes. Spread
2000
- * it into the seed (and allow the slugs on the collection's `blocks` field), e.g.
2001
- * `blockTypes: [...vitopsBlockTypes()]`.
2002
- */
2003
- function vitopsBlockTypes() {
2004
- return [SECTION_BLOCK_TYPE];
2005
- }
2006
-
2007
2134
  // src/blocks-doc.ts
2008
2135
  const cell = (s) => (s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
2009
2136
  function describe(f) {
@@ -2037,7 +2164,7 @@ function renderBlocksDoc(blocks = BLOCKS) {
2037
2164
  "",
2038
2165
  "# EmDash blocks",
2039
2166
  "",
2040
- "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 }`.",
2041
2168
  "Portable Text blocks are flat — they cannot contain other blocks, and a repeater row holds scalar fields only.",
2042
2169
  "To put blocks inside something, use the section block TYPE (end of this page), whose body is Portable Text.",
2043
2170
  "Give every block a unique `_key`; gallery and carousel derive their element id from it.",
@@ -2047,9 +2174,27 @@ function renderBlocksDoc(blocks = BLOCKS) {
2047
2174
  "Every block that colours something takes the same two fields. `tone` is a colour role from the",
2048
2175
  "site's `colors.roles` (or a semantic name); an unknown role falls back through its semantic",
2049
2176
  "relatives to `neutral`. `color` is an escape hatch naming a palette hue (`amber`) or hue and step",
2050
- "(`amber-300`) and wins over `tone`; its contrast is not checked. Renderers emit `--tone-*` custom",
2051
- "properties, never a class built from the value. Multiline fields that accept links take",
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",
2052
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`.",
2053
2198
  ""
2054
2199
  ];
2055
2200
  for (const b of blocks) {
@@ -2057,7 +2202,7 @@ function renderBlocksDoc(blocks = BLOCKS) {
2057
2202
  if (b.placeholder) out.push(`${b.placeholder}.`, "");
2058
2203
  if (b.fields?.length) out.push("| Field | Type | Label | Notes |", "| --- | --- | --- | --- |", ...fieldRows(b.fields), "");
2059
2204
  }
2060
- out.push("## `vitops.form` needs an endpoint", "", "The form posts to `/api/track` (the `action` field overrides it), which must be an on-demand route", "built with `createConversionRoute()` from `@getvitops/astro/routes`; the emdash template ships one.", "Field names are submitted as written, except that `phone` becomes `tel` and a few names the route", "reserves (`event`, `website`, `honeypot`, `_honeypot`, `cf-turnstile-response`) are renamed.", "It emails `site.notifications.email` and writes `site.crm` when set; with neither, the route refuses", "the post rather than accept a message nobody receives. Add the spam check with", "`vitops setup turnstile`: once `site.security.turnstile.siteKey` is set the block renders", "`<wc-turnstile>`, which loads Cloudflare Turnstile only when the visitor first uses the form", "(category `necessary`, so it is not behind the consent banner). The secret lives in the", "`VITOPS_TURNSTILE_SECRET` Worker secret, never in `site.json`. Use one form per page.", "", "## Block types (the `blocks` field)", "", "Schema-level block types, not Portable Text blocks: add them to the seed (`blockTypes: [...vitopsBlockTypes()]`),", "allow the slug on the collection's `blocks` field, and render with `blockTypeComponents` from `@getvitops/emdash/astro`.", "");
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`.", "");
2061
2206
  for (const t of vitopsBlockTypes()) {
2062
2207
  out.push(`### \`${t.slug}\` — ${t.label}`, "", `${t.description ?? ""}`, "");
2063
2208
  const fields = t.versions.find((v) => v.version === t.currentVersion)?.fields ?? [];
@@ -2065,7 +2210,7 @@ function renderBlocksDoc(blocks = BLOCKS) {
2065
2210
  const notes = [
2066
2211
  f.validation?.options ? `one of ${f.validation.options.join(", ")}` : "",
2067
2212
  f.defaultValue !== void 0 ? `default \`${String(f.defaultValue)}\`` : "",
2068
- f.type === "portableText" ? "accepts every `vitops.*` block above" : ""
2213
+ f.type === "portableText" ? "accepts every `vitops_*` block above" : ""
2069
2214
  ].filter(Boolean);
2070
2215
  return `| \`${f.slug}\` | ${f.type} | ${cell(f.label)} | ${cell(notes.join("; "))} |`;
2071
2216
  }), "");
@@ -2073,6 +2218,160 @@ function renderBlocksDoc(blocks = BLOCKS) {
2073
2218
  return out.join("\n");
2074
2219
  }
2075
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
+
2076
2375
  // src/hosting.ts
2077
2376
  const TARGETS = ["cloudflare", "node"];
2078
2377
  /**
@@ -2134,7 +2433,7 @@ async function vitopsHosting(options = {}) {
2134
2433
  }
2135
2434
 
2136
2435
  // package.json
2137
- var version = "2.0.0-alpha.1";
2436
+ var version = "2.0.0-alpha.3";
2138
2437
 
2139
2438
  // src/index.ts
2140
2439
  /**
@@ -2236,6 +2535,7 @@ function createPlugin(options = {}) {
2236
2535
  },
2237
2536
  routes: {
2238
2537
  [TONES_ROUTE]: createTonesRoute(() => roles),
2538
+ [THEMES_ROUTE]: createThemesRoute(() => roles),
2239
2539
  [MENUS_ROUTE]: createMenusRoute(() => getMenus()),
2240
2540
  [WIDGET_AREAS_ROUTE]: createMenusRoute(() => getWidgetAreas()),
2241
2541
  ...editor ? { "design-system": createDesignSystemRoute(editor) } : {}
@@ -2243,5 +2543,5 @@ function createPlugin(options = {}) {
2243
2543
  });
2244
2544
  }
2245
2545
 
2246
- 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 };
2247
2547
  //# sourceMappingURL=index.mjs.map