@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
@@ -28,14 +28,14 @@ const plans = rows<Record<string, unknown>>(value.plans)
28
28
  .filter((plan) => plan.name || plan.price);
29
29
  const Heading = headingTag(value.level, 3);
30
30
  const columns = Math.min(4, Math.max(1, plans.length));
31
+ import { rootAttrs } from './attrs.ts';
31
32
  ---
32
33
 
33
34
  {
34
35
  plans.length > 0 && (
35
36
  <ul
36
- class="pricing-grid"
37
+ {...rootAttrs(value, ['pricing-grid'], `--pricing-columns: ${columns}; --pricing-card-rows: 4`)}
37
38
  role="list"
38
- style={`--pricing-columns: ${columns}; --pricing-card-rows: 4`}
39
39
  >
40
40
  {plans.map((plan) => (
41
41
  <li class:list={['pricing-card', plan.featured && 'pricing-card--featured']}>
@@ -75,13 +75,13 @@ const avatar = Boolean(value.avatars);
75
75
  const tone = resolveTone(value.tone, value.color);
76
76
  const toned = Boolean(value.tone || value.color);
77
77
  const dev = import.meta.env.DEV;
78
+ import { rootAttrs } from './attrs.ts';
78
79
  ---
79
80
 
80
81
  {
81
82
  reviews.length > 0 ? (
82
83
  <div
83
- class:list={['reviews', toned && tone.className]}
84
- style={toned ? joinStyle(tone.style) : undefined}
84
+ {...rootAttrs(value, ['reviews', toned && tone.className], toned ? joinStyle(tone.style) : undefined)}
85
85
  >
86
86
  {layout === 'carousel' ? (
87
87
  <Carousel label={label}>
@@ -113,7 +113,7 @@ const dev = import.meta.env.DEV;
113
113
  ) : (
114
114
  dev && (
115
115
  <p class="reviews__empty" role="note">
116
- vitops.reviews: no reviews to show
116
+ vitops_reviews: no reviews to show
117
117
  {key ? ` for location "${key}"` : ''}. Run <code>vitops reviews</code> and commit
118
118
  <code>.vitops/reviews.json</code>, or lower the rating filter. (Shown in dev only.)
119
119
  </p>
@@ -11,13 +11,18 @@
11
11
  * real heading at the editor's `level`.
12
12
  *
13
13
  * The body is rendered by EmDash's `<PortableText>`, which merges this plugin's
14
- * `blockComponents`, so a `vitops.cards` or `vitops.actionLink` placed in it renders with its
14
+ * `blockComponents`, so a `vitops_cards` or `vitops_actionLink` placed in it renders with its
15
15
  * web component. Class names never come from field values (Tailwind could not see them): the
16
16
  * width and layout are looked up in closed tables, colour is `--tone-*` custom properties.
17
17
  */
18
- import { PortableText } from 'emdash/ui';
18
+ // Imported lazily on purpose. `emdash/ui` statically imports `virtual:emdash/block-components`,
19
+ // which is this package's `index.ts`, which imports this file: a static import here is a cycle
20
+ // that evaluates the virtual module before `blockComponents` exists, so the static build ships
21
+ // an empty plugin block map and drops every `vitops_*` block in a section body.
22
+ const { PortableText } = await import('emdash/ui');
23
+ import { unrenderedTypes } from './unrendered.ts';
19
24
  import { joinStyle, resolveTone } from './tone.ts';
20
- import { headingTag, resolveBlockValue, text } from './value.ts';
25
+ import { outlineTag, resolveBlockValue, text } from './value.ts';
21
26
 
22
27
  interface Value {
23
28
  kicker?: string;
@@ -30,6 +35,7 @@ interface Value {
30
35
  tone?: string;
31
36
  color?: string;
32
37
  solid?: boolean;
38
+ theme?: string;
33
39
  }
34
40
 
35
41
  const WIDTHS: Record<string, string> = {
@@ -43,25 +49,22 @@ const kicker = text(value.kicker);
43
49
  const heading = text(value.heading);
44
50
  const sub = text(value.sub);
45
51
  const body = Array.isArray(value.body) ? value.body : [];
46
- const Heading = headingTag(value.level, 2);
52
+ const Heading = outlineTag(value.level, 2);
53
+ const missing = await unrenderedTypes(body);
54
+ // `theme` (every block's backdrop) is applied by `rootAttrs`, and wins over the older
55
+ // `tone`/`color`/`solid` fields, which stay for content that already uses them.
47
56
  const tone = resolveTone(value.tone, value.color);
57
+ const solid = Boolean(value.solid);
48
58
  const layout = text(value.layout);
49
59
  const width = WIDTHS[text(value.width)];
50
60
  const hasHead = Boolean(kicker || heading || sub);
61
+ import { rootAttrs } from './attrs.ts';
51
62
  ---
52
63
 
53
64
  {
54
65
  (hasHead || body.length > 0) && (
55
66
  <section
56
- class:list={[
57
- 'section',
58
- 'region',
59
- tone.style && tone.className,
60
- tone.style && value.solid && 'tone--solid',
61
- layout === 'split' && 'section--split',
62
- layout === 'center' && 'section--center',
63
- ]}
64
- style={joinStyle(tone.style)}
67
+ {...rootAttrs(value, ['section', 'region', tone.style && tone.className, tone.style && solid && 'tone--solid', layout === 'split' && 'section--split', layout === 'center' && 'section--center'], joinStyle(tone.style))}
65
68
  >
66
69
  <div class="centered">
67
70
  <div class:list={['section__inner', width]}>
@@ -75,6 +78,7 @@ const hasHead = Boolean(kicker || heading || sub);
75
78
  {body.length > 0 && (
76
79
  <div class="section__body">
77
80
  <PortableText value={body} />
81
+ {missing.map((type) => <template data-vitops-unrendered={type} />)}
78
82
  </div>
79
83
  )}
80
84
  </div>
@@ -9,7 +9,7 @@
9
9
  * `animate: false` renders the plain fragment (`enhance={false}`) — no count-up.
10
10
  */
11
11
  import Counter from '@getvitops/astro/components/Counter.astro';
12
- import { resolveTone, joinStyle } from './tone.ts';
12
+ import { resolveTone, joinStyle, rowTone } from './tone.ts';
13
13
  import { resolveBlockValue, rows, text } from './value.ts';
14
14
 
15
15
  interface Value {
@@ -30,22 +30,23 @@ const stats = rows<Record<string, unknown>>(value.stats)
30
30
  group: text(row.group),
31
31
  delta: text(row.delta),
32
32
  state: STATES.has(text(row.state)) ? text(row.state) : undefined,
33
+ style: rowTone(row),
33
34
  }))
34
35
  .filter((stat) => stat.value);
35
36
  const tiles = value.variant === 'tiles';
36
37
  const animate = value.animate !== false;
37
38
  const tone = resolveTone(value.tone, value.color);
39
+ import { rootAttrs } from './attrs.ts';
38
40
  ---
39
41
 
40
42
  {
41
43
  stats.length > 0 && (
42
44
  <ul
43
- class:list={['stats', tone.className, tiles && 'stats--tiles']}
45
+ {...rootAttrs(value, ['stats', tone.className, tiles && 'stats--tiles'], joinStyle(tone.style))}
44
46
  role="list"
45
- style={joinStyle(tone.style)}
46
47
  >
47
48
  {stats.map((stat) => (
48
- <li class="stats__item" data-state={stat.state}>
49
+ <li class="stats__item" data-state={stat.state} style={stat.style}>
49
50
  {tiles && stat.group && <p class="stats__group font-eyebrow">{stat.group}</p>}
50
51
  <p class="stats__figure font-display">
51
52
  <Counter value={stat.value} enhance={animate} />
@@ -6,7 +6,7 @@
6
6
  * convention — see `tone.ts`.
7
7
  */
8
8
  import RichText from './RichText.astro';
9
- import { resolveTone, joinStyle } from './tone.ts';
9
+ import { resolveTone, joinStyle, rowTone } from './tone.ts';
10
10
  import { headingTag, resolveBlockValue, rows, text } from './value.ts';
11
11
 
12
12
  interface Value {
@@ -19,20 +19,20 @@ interface Value {
19
19
 
20
20
  const value = resolveBlockValue<Value>(Astro.props);
21
21
  const steps = rows<Record<string, unknown>>(value.steps)
22
- .map((row) => ({ title: text(row.title), text: text(row.text) }))
22
+ .map((row) => ({ title: text(row.title), text: text(row.text), style: rowTone(row) }))
23
23
  .filter((step) => step.title || step.text);
24
24
  const Heading = headingTag(value.level, 3);
25
25
  const tone = resolveTone(value.tone, value.color);
26
+ import { rootAttrs } from './attrs.ts';
26
27
  ---
27
28
 
28
29
  {
29
30
  steps.length > 0 && (
30
31
  <ol
31
- class:list={['steps', tone.className, value.layout === 'row' && 'steps--row']}
32
- style={joinStyle(tone.style)}
32
+ {...rootAttrs(value, ['steps', tone.className, value.layout === 'row' && 'steps--row'], joinStyle(tone.style))}
33
33
  >
34
34
  {steps.map((step) => (
35
- <li class="steps__item">
35
+ <li class="steps__item" style={step.style}>
36
36
  <div class="steps__body">
37
37
  {step.title && <Heading class="steps__title font-heading">{step.title}</Heading>}
38
38
  {step.text && (
@@ -0,0 +1,21 @@
1
+ ---
2
+ /**
3
+ * `vitops_widgetArea` — a synced region inside page content: the area is defined once under
4
+ * Widgets in the admin and every page that places this block shows the same thing. (An EmDash
5
+ * Section, by contrast, is copied in.) See `../WidgetArea.astro` for what it renders and why a
6
+ * widget area cannot hold another. The markup (`.widget-area`) lives there.
7
+ */
8
+ import WidgetArea from '../WidgetArea.astro';
9
+ import { resolveBlockValue, text } from './value.ts';
10
+ import { rootAttrs } from './attrs.ts';
11
+
12
+ interface Value {
13
+ area?: string;
14
+ level?: string | number;
15
+ }
16
+
17
+ const value = resolveBlockValue<Value>(Astro.props);
18
+ const name = text(value.area);
19
+ ---
20
+
21
+ {name && <WidgetArea name={name} level={value.level} {...rootAttrs(value, ['widget-area--in-page'])} />}
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vite-plus/test';
2
+ import { anchorId, blockAttrs, classTokens, customProperties, rootAttrs } from './attrs.ts';
3
+
4
+ describe('anchorId', () => {
5
+ it('slugifies and accepts a clean id', () => {
6
+ expect(anchorId('Contact Us')).toBe('contact-us');
7
+ expect(anchorId('contact')).toBe('contact');
8
+ });
9
+ it('rejects what cannot be an id', () => {
10
+ expect(anchorId('')).toBeUndefined();
11
+ expect(anchorId('9lives')).toBeUndefined();
12
+ expect(anchorId(42)).toBeUndefined();
13
+ });
14
+ });
15
+
16
+ describe('classTokens', () => {
17
+ it('keeps valid tokens, drops the rest', () => {
18
+ expect(classTokens('mt-4 md:flex "x" <b>')).toBe('mt-4 md:flex');
19
+ });
20
+ it('is undefined when nothing survives', () => {
21
+ expect(classTokens('"><script>')).toBeUndefined();
22
+ });
23
+ });
24
+
25
+ describe('customProperties', () => {
26
+ it('keeps custom properties', () => {
27
+ expect(customProperties('--p-card: 3rem; --gap: 1rem')).toBe('--p-card: 3rem; --gap: 1rem');
28
+ });
29
+ it('drops ordinary properties and fetching values', () => {
30
+ expect(
31
+ customProperties('background: url(x); --a: url(https://x/y); display: none'),
32
+ ).toBeUndefined();
33
+ expect(customProperties('--a: 1px; position: fixed')).toBe('--a: 1px');
34
+ });
35
+ it('rejects values that try to leave the declaration', () => {
36
+ expect(customProperties('--a: red}</style><script>')).toBeUndefined();
37
+ expect(customProperties('--a: \\75rl(x)')).toBeUndefined();
38
+ });
39
+ });
40
+
41
+ describe('blockAttrs', () => {
42
+ it('is empty for a block that sets nothing', () => {
43
+ expect(blockAttrs({})).toEqual({});
44
+ expect(blockAttrs(undefined)).toEqual({});
45
+ });
46
+ it('reads the block-type spelling too', () => {
47
+ expect(blockAttrs({ class_name: 'mt-4' })).toEqual({ class: 'mt-4' });
48
+ });
49
+ it('collects the three fields', () => {
50
+ expect(blockAttrs({ anchor: 'services', className: 'mt-4', style: '--p-card: 2rem' })).toEqual({
51
+ id: 'services',
52
+ class: 'mt-4',
53
+ style: '--p-card: 2rem',
54
+ });
55
+ });
56
+ });
57
+
58
+ describe('theme on every block', () => {
59
+ it('adds the tinted classes and --tone-* properties to the root', () => {
60
+ const a = rootAttrs({ theme: 'surface' }, ['callout']);
61
+ expect(a.class).toBe('callout tone');
62
+ expect(a.style).toContain('--tone-bg: var(--color-bg-surface-muted');
63
+ });
64
+ it('fills with the role solid and its computed text for `<role>:solid`', () => {
65
+ const a = rootAttrs({ theme: 'brand-primary:solid' }, ['hero']);
66
+ expect(a.class).toBe('hero tone tone--solid');
67
+ expect(a.style).toContain('--tone-on-solid: var(--color-text-on-brand-primary');
68
+ });
69
+ it("replaces a block's own tone classes rather than doubling them, and is inert for none", () => {
70
+ expect(rootAttrs({ theme: 'info' }, ['x', 'tone', 'tone--solid']).class).toBe('x tone');
71
+ expect(rootAttrs({ theme: 'none' }, ['x', 'tone']).class).toBe('x tone');
72
+ expect(rootAttrs({ theme: 'bad role!' }, ['x']).class).toBe('x');
73
+ });
74
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The escape hatches every block carries: `anchor` (an `id`), `className` (extra classes)
3
+ * and `style` (CSS custom properties).
4
+ *
5
+ * All three are editor-supplied strings that land in attributes, so each is validated against
6
+ * a closed grammar and anything outside it is dropped, never escaped into place.
7
+ *
8
+ * **`style` is custom properties only.** `--p-card: 2rem` tunes a framework hook, which is how
9
+ * the design system is meant to be overridden. Free-form declarations (`display`, `position`,
10
+ * `background: url(…)`) would let one page quietly diverge from the tokens, and `url(` is a
11
+ * request the visitor's browser makes to wherever an editor points it.
12
+ *
13
+ * **`className` and the `tailwind` format.** Tailwind scans source for class names, not CMS
14
+ * content, so a utility typed here exists only if something else already used it. Under the
15
+ * `css` and `bricks` formats every framework class ships, so the caveat does not apply.
16
+ */
17
+
18
+ import { resolveTheme } from './tone.ts';
19
+
20
+ export interface BlockAttrs {
21
+ id?: string;
22
+ class?: string;
23
+ style?: string;
24
+ }
25
+
26
+ const ANCHOR = /^[a-z][a-z0-9-]{0,63}$/;
27
+ const CLASS_TOKEN = /^[A-Za-z0-9_:@/.[\]-]{1,80}$/;
28
+ const MAX_CLASSES = 20;
29
+ const PROPERTY = /^--[a-zA-Z][a-zA-Z0-9-]{0,60}$/;
30
+ /** Characters that could end the declaration, open a function that fetches, or leave the attribute. */
31
+ const FORBIDDEN_VALUE = /[\\<>@{}"'`]|url\(|expression\(|image-set\(|;/i;
32
+
33
+ /** Lowercase an anchor and replace anything else with `-`; `undefined` when nothing valid remains. */
34
+ export function anchorId(input: unknown): string | undefined {
35
+ if (typeof input !== 'string') return undefined;
36
+ const slug = input
37
+ .trim()
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9-]+/g, '-')
40
+ .replace(/^-+|-+$/g, '');
41
+ return ANCHOR.test(slug) ? slug : undefined;
42
+ }
43
+
44
+ export function classTokens(input: unknown): string | undefined {
45
+ if (typeof input !== 'string') return undefined;
46
+ const tokens = input
47
+ .split(/\s+/)
48
+ .filter((t) => CLASS_TOKEN.test(t))
49
+ .slice(0, MAX_CLASSES);
50
+ return tokens.length ? tokens.join(' ') : undefined;
51
+ }
52
+
53
+ /** Keep each `--name: value` pair that passes; drop the rest. */
54
+ export function customProperties(input: unknown): string | undefined {
55
+ if (typeof input !== 'string') return undefined;
56
+ const kept: string[] = [];
57
+ for (const decl of input.split(';')) {
58
+ const at = decl.indexOf(':');
59
+ if (at < 0) continue;
60
+ const name = decl.slice(0, at).trim();
61
+ const value = decl.slice(at + 1).trim();
62
+ if (!PROPERTY.test(name) || !value || value.length > 200 || FORBIDDEN_VALUE.test(value))
63
+ continue;
64
+ kept.push(`${name}: ${value}`);
65
+ }
66
+ return kept.length ? kept.join('; ') : undefined;
67
+ }
68
+
69
+ /** The validated `id` / `class` / `style` for a block's saved value. Empty when none is set. */
70
+ export function blockAttrs(input: object | undefined): BlockAttrs {
71
+ const value = input as Record<string, unknown> | undefined;
72
+ const out: BlockAttrs = {};
73
+ const id = anchorId(value?.anchor);
74
+ // Portable Text blocks name it `className`; a block TYPE's slugs must be lowercase, so `class_name`.
75
+ const cls = classTokens(value?.className ?? value?.class_name);
76
+ const style = customProperties(value?.style);
77
+ if (id) out.id = id;
78
+ if (cls) out.class = cls;
79
+ if (style) out.style = style;
80
+ return out;
81
+ }
82
+
83
+ type ClassPart = string | false | null | undefined;
84
+
85
+ /**
86
+ * The attributes for a block's outermost element: its own classes and style with the editor's
87
+ * `anchor` / `className` / `style` merged in. Spread it on that element (`<div {...root}>`) or pass
88
+ * it to the pattern component that renders it — there is never an extra wrapper node, so a block's
89
+ * DOM shape does not depend on which fields an editor filled in.
90
+ *
91
+ * An anchored element also gets `.block-anchor`, which offsets `scroll-margin-top` for a sticky header.
92
+ */
93
+ export function rootAttrs(
94
+ value: object | undefined,
95
+ own: ClassPart[] = [],
96
+ ownStyle?: string,
97
+ ): BlockAttrs {
98
+ const user = blockAttrs(value);
99
+ // `theme` is the block's backdrop: classes and `--tone-*` properties on its own root, after the
100
+ // block's own tone so an explicit theme wins, and before the editor's `style` so that wins over it.
101
+ const theme = resolveTheme((value as Record<string, unknown> | undefined)?.theme);
102
+ const out: BlockAttrs = {};
103
+ if (user.id) out.id = user.id;
104
+ const cls = [
105
+ ...own.filter((c) => !theme || (c !== 'tone' && c !== 'tone--solid')),
106
+ theme?.className,
107
+ user.id && 'block-anchor',
108
+ user.class,
109
+ ]
110
+ .filter(Boolean)
111
+ .join(' ');
112
+ if (cls) out.class = cls;
113
+ const style = [ownStyle, theme?.style, user.style].filter(Boolean).join('; ');
114
+ if (style) out.style = style;
115
+ return out;
116
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Import-order regression: `@getvitops/emdash/astro` before `emdash/ui`.
3
+ *
4
+ * `emdash/ui` reads `virtual:emdash/block-components`, which in a real build re-exports this
5
+ * package's `blockComponents`. The entry used to import `Section.astro`, which imported
6
+ * `emdash/ui` at module load, closing a cycle: with the entry imported first, the virtual module
7
+ * evaluated while `blockComponents` was still undefined and every `vitops.*` block inside a
8
+ * `vitops_section` body was dropped from the prerendered HTML. The build still passed.
9
+ *
10
+ * This mock has the real shape (it reads the entry's export), and the test imports the entry
11
+ * first, which is the order that used to break.
12
+ */
13
+ import { experimental_AstroContainer as AstroContainer } from 'astro/container';
14
+ import { describe, expect, it, vi } from 'vite-plus/test';
15
+
16
+ vi.mock('virtual:emdash/block-components', async () => {
17
+ const entry = await import('../index.ts');
18
+ return { pluginBlockComponents: { ...entry.blockComponents } };
19
+ });
20
+ vi.mock('virtual:emdash/config', () => ({ default: {} }));
21
+ vi.mock('virtual:getvitops/reviews', () => ({ default: { locations: {} } }));
22
+ vi.mock('virtual:getvitops/icons', () => ({
23
+ default: {
24
+ engine: 'sprite',
25
+ brand: 'simple-icons',
26
+ ui: 'lucide',
27
+ overrides: {},
28
+ weight: null,
29
+ root: '',
30
+ },
31
+ }));
32
+
33
+ type Component = Parameters<AstroContainer['renderToString']>[0];
34
+
35
+ describe('import order', () => {
36
+ it('a vitops_section body keeps its vitops.* blocks when the entry is imported first', async () => {
37
+ const entry = await import('../index.ts');
38
+ const container = await AstroContainer.create();
39
+ const html = await container.renderToString(
40
+ entry.blockTypeComponents.vitops_section as unknown as Component,
41
+ {
42
+ props: {
43
+ value: {
44
+ heading: 'Services',
45
+ body: [
46
+ {
47
+ _type: 'vitops_cards',
48
+ _key: 'c',
49
+ cards: [{ title: 'Managed IT', body: 'We run it.' }],
50
+ },
51
+ ],
52
+ },
53
+ },
54
+ },
55
+ );
56
+ expect(html).toContain('Managed IT');
57
+ expect(html).not.toContain('data-vitops-unrendered');
58
+ }, 60_000);
59
+
60
+ it('marks a body block with no renderer so check-prerender can fail the build', async () => {
61
+ const entry = await import('../index.ts');
62
+ const container = await AstroContainer.create();
63
+ const html = await container.renderToString(
64
+ entry.blockTypeComponents.vitops_section as unknown as Component,
65
+ {
66
+ props: {
67
+ value: { heading: 'Services', body: [{ _type: 'vitops_nope', _key: 'x' }] },
68
+ },
69
+ },
70
+ );
71
+ expect(html).toContain('data-vitops-unrendered="vitops_nope"');
72
+ }, 60_000);
73
+ });
@@ -1,9 +1,9 @@
1
1
  /**
2
- * A realistic saved `vitops.form` block, for tests and the e2e fixtures: the fields a small
2
+ * A realistic saved `vitops_form` block, for tests and the e2e fixtures: the fields a small
3
3
  * service business asks for, including the two names the conversion route treats specially.
4
4
  */
5
5
  export const SAMPLE_FORM_BLOCK = {
6
- _type: 'vitops.form',
6
+ _type: 'vitops_form',
7
7
  _key: 'contact',
8
8
  heading: 'Get in touch',
9
9
  level: '2',
@@ -25,6 +25,12 @@ export const SAMPLE_FORM_BLOCK = {
25
25
  required: false,
26
26
  options: 'Maintenance\nDesign\nInstallation',
27
27
  },
28
+ {
29
+ label: 'Interested in',
30
+ name: 'interests',
31
+ type: 'checkboxes',
32
+ options: 'Managed IT\nWeb design\nSEO',
33
+ },
28
34
  { label: 'Message', name: 'message', type: 'textarea', required: true },
29
35
  { label: 'Add me to your newsletter', name: 'newsletter', type: 'checkbox' },
30
36
  ],
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure decisions for the `vitops.form` block: which of an editor's field rows become real
2
+ * Pure decisions for the `vitops_form` block: which of an editor's field rows become real
3
3
  * controls, and under what submitted names.
4
4
  *
5
5
  * Names matter more than they look. `createConversionRoute` (`@getvitops/astro/routes`) is
@@ -12,7 +12,15 @@
12
12
  * that is where it goes.
13
13
  */
14
14
 
15
- export const FIELD_TYPES = ['text', 'email', 'tel', 'textarea', 'select', 'checkbox'] as const;
15
+ export const FIELD_TYPES = [
16
+ 'text',
17
+ 'email',
18
+ 'tel',
19
+ 'textarea',
20
+ 'select',
21
+ 'checkbox',
22
+ 'checkboxes',
23
+ ] as const;
16
24
  export type FieldType = (typeof FIELD_TYPES)[number];
17
25
 
18
26
  /** Names the route treats specially. A field must never submit under one of these. */
@@ -114,14 +122,15 @@ export function resolveFields(rows: Record<string, unknown>[]): FormField[] {
114
122
  : 'text';
115
123
  const name = fieldName(row['name'], label, type, taken);
116
124
  const options =
117
- type === 'select'
125
+ type === 'select' || type === 'checkboxes'
118
126
  ? str(row['options'])
119
127
  .split('\n')
120
128
  .map((o) => o.trim())
121
129
  .filter(Boolean)
122
130
  : [];
123
131
  // A select with nothing to choose is unusable; render it as free text instead.
124
- const effective: FieldType = type === 'select' && options.length === 0 ? 'text' : type;
132
+ const effective: FieldType =
133
+ (type === 'select' || type === 'checkboxes') && options.length === 0 ? 'text' : type;
125
134
  out.push({
126
135
  name,
127
136
  id: `form-${out.length + 1}-${name}`,
@@ -129,7 +138,9 @@ export function resolveFields(rows: Record<string, unknown>[]): FormField[] {
129
138
  type: effective,
130
139
  required: row['required'] === true,
131
140
  placeholder:
132
- effective === 'checkbox' || effective === 'select' ? '' : str(row['placeholder']),
141
+ effective === 'checkbox' || effective === 'select' || effective === 'checkboxes'
142
+ ? ''
143
+ : str(row['placeholder']),
133
144
  help: str(row['help']),
134
145
  options,
135
146
  autocomplete: autocompleteFor(name, effective),
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure decisions behind the `vitops.nav` block, kept out of `Nav.astro` so they are asserted in
2
+ * Pure decisions behind the `vitops_nav` block, kept out of `Nav.astro` so they are asserted in
3
3
  * `nav.test.ts` without rendering (the split `sitenav.ts` makes for `<SiteNav>`). The menu-tree
4
4
  * helpers themselves — depth trimming, `aria-current` — are `@getvitops/astro`'s, not copied.
5
5
  */