@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.
- package/CHANGELOG.md +78 -0
- package/README.md +18 -16
- package/dist/index.d.mts +26 -6
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +578 -129
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/astro/Nothing.astro +3 -0
- package/src/astro/WidgetArea.astro +113 -0
- package/src/astro/blocks/Accordion.astro +2 -1
- package/src/astro/blocks/ActionLink.astro +2 -1
- package/src/astro/blocks/Badges.astro +67 -0
- package/src/astro/blocks/Banner.astro +3 -2
- package/src/astro/blocks/Callout.astro +3 -8
- package/src/astro/blocks/Cards.astro +6 -4
- package/src/astro/blocks/Carousel.astro +2 -0
- package/src/astro/blocks/CopyButton.astro +3 -2
- package/src/astro/blocks/Details.astro +2 -1
- package/src/astro/blocks/Embed.astro +2 -1
- package/src/astro/blocks/Entries.astro +2 -1
- package/src/astro/blocks/Form.astro +20 -1
- package/src/astro/blocks/Gallery.astro +2 -0
- package/src/astro/blocks/Hero.astro +27 -11
- package/src/astro/blocks/ImageCompare.astro +2 -1
- package/src/astro/blocks/Logos.astro +10 -7
- package/src/astro/blocks/Marquee.astro +2 -1
- package/src/astro/blocks/Nav.astro +2 -2
- package/src/astro/blocks/NavTree.astro +1 -1
- package/src/astro/blocks/Pricing.astro +2 -2
- package/src/astro/blocks/Reviews.astro +3 -3
- package/src/astro/blocks/Section.astro +17 -13
- package/src/astro/blocks/Stats.astro +5 -4
- package/src/astro/blocks/Steps.astro +5 -5
- package/src/astro/blocks/WidgetArea.astro +21 -0
- package/src/astro/blocks/attrs.test.ts +74 -0
- package/src/astro/blocks/attrs.ts +116 -0
- package/src/astro/blocks/cycle.container.test.ts +73 -0
- package/src/astro/blocks/form.sample.ts +8 -2
- package/src/astro/blocks/form.ts +16 -5
- package/src/astro/blocks/nav.ts +1 -1
- package/src/astro/blocks/render.container.test.ts +131 -19
- package/src/astro/blocks/tone.test.ts +10 -0
- package/src/astro/blocks/tone.ts +48 -6
- package/src/astro/blocks/unrendered.ts +23 -0
- package/src/astro/blocks/value.test.ts +27 -1
- package/src/astro/blocks/value.ts +16 -0
- 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: ${
|
|
109
|
-
`--tone-fg: ${
|
|
110
|
-
`--tone-border: ${
|
|
111
|
-
`--tone-solid: ${at(
|
|
112
|
-
`--tone-on-solid:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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\"
|
|
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: "
|
|
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: "
|
|
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\": \"
|
|
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`;
|
|
1903
|
-
"
|
|
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("## `
|
|
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 `
|
|
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.
|
|
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
|