@getvitops/emdash 2.0.0-alpha.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/emdash",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.2",
4
4
  "description": "EmDash CMS native plugin for the Vitops design system — Portable Text block types editors can insert from the admin, rendered with the design-system web components and CSS framework.",
5
5
  "homepage": "https://docs.vitops.ca",
6
6
  "bugs": "https://github.com/getvitops/design-system/issues",
@@ -31,15 +31,15 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@getvitops/generator": "8.3.0-alpha.1",
35
- "@getvitops/utils": "8.3.0-alpha.1"
34
+ "@getvitops/generator": "8.3.0-alpha.2",
35
+ "@getvitops/utils": "8.3.0-alpha.2"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@astrojs/cloudflare": "^14.0.0",
39
39
  "@astrojs/react": "^7.0.0",
40
40
  "@emdash-cms/cloudflare": "^1.4.0",
41
- "@getvitops/astro": "8.3.0-alpha.1",
42
- "@getvitops/core": "8.3.0-alpha.1",
41
+ "@getvitops/astro": "8.3.0-alpha.2",
42
+ "@getvitops/core": "8.3.0-alpha.2",
43
43
  "@types/react": "^19.3.0",
44
44
  "@types/react-dom": "^19.3.0",
45
45
  "astro": "^7.4.4",
@@ -2,11 +2,11 @@
2
2
  /**
3
3
  * A widget area rendered in the framework's vocabulary, in place of `emdash/ui`'s
4
4
  * `<WidgetArea>`: titles at a chosen heading level (the stock one is always `h3`), a menu as a
5
- * labelled `<nav>` of `.link`s, content through `<PortableText>` so every `vitops.*` block
5
+ * labelled `<nav>` of `.link`s, content through `<PortableText>` so every `vitops_*` block
6
6
  * works inside it. Renders nothing when the area is missing or empty.
7
7
  *
8
8
  * Two limits, both deliberate:
9
- * - **No widget area inside a widget area.** A content widget that held a `vitops.widgetArea`
9
+ * - **No widget area inside a widget area.** A content widget that held a `vitops_widgetArea`
10
10
  * block could name its own area and recurse, and a request-scoped depth counter cannot be kept
11
11
  * honest while Astro streams children, so the block is simply not rendered in here.
12
12
  * - **`component` widgets** (`core:recent-posts`, …) are EmDash's closed map and its renderer is
@@ -15,9 +15,13 @@
15
15
  * Prerendered pages bake the area at build time: an edit in the admin shows after the next deploy.
16
16
  */
17
17
  import { getMenu, getWidgetArea } from 'emdash';
18
- import { PortableText, WidgetArea as StockWidgetArea } from 'emdash/ui';
19
18
  import Nothing from './Nothing.astro';
19
+ import { unrenderedTypes } from './blocks/unrendered.ts';
20
+ import { joinStyle, resolveTheme } from './blocks/tone.ts';
20
21
  import { headingTag, safeHref, text } from './blocks/value.ts';
22
+ // Lazy for the same reason as `blocks/Section.astro`: a static `emdash/ui` import makes a cycle
23
+ // through `virtual:emdash/block-components` and empties the plugin block map in a static build.
24
+ const { PortableText, WidgetArea: StockWidgetArea } = await import('emdash/ui');
21
25
 
22
26
  interface Props {
23
27
  /** The area's `name` as set under Widgets. */
@@ -27,9 +31,12 @@ interface Props {
27
31
  class?: string;
28
32
  id?: string;
29
33
  style?: string;
34
+ /** A backdrop for the area, from the site's colour roles: `surface`, `brand-primary:solid`, … */
35
+ theme?: string;
30
36
  }
31
37
 
32
- const { name, level, class: className, id, style } = Astro.props;
38
+ const { name, level, class: className, id, style, theme } = Astro.props;
39
+ const themed = resolveTheme(theme);
33
40
  const area = name ? await getWidgetArea(name) : null;
34
41
  const widgets = area?.widgets ?? [];
35
42
  const delegated = widgets.some((w) => w.type === 'component');
@@ -41,22 +48,36 @@ const menus = Object.fromEntries(
41
48
  .map(async (w) => [w.id, await getMenu(w.menuName!)] as const),
42
49
  ),
43
50
  );
44
- const inner = { type: { 'vitops.widgetArea': Nothing } };
51
+ /** A content widget with no text and no blocks is what the admin creates by default; showing its title alone is noise. */
52
+ const hasBody = (content: unknown) =>
53
+ Array.isArray(content) &&
54
+ content.some(
55
+ (b: any) =>
56
+ b?._type !== 'block' ||
57
+ (Array.isArray(b.children) && b.children.some((c: any) => text(c?.text).trim())),
58
+ );
59
+ const shown = widgets.filter(
60
+ (w) => w.type !== 'content' || hasBody(w.content),
61
+ );
62
+ const missing = [
63
+ ...new Set((await Promise.all(shown.map((w) => unrenderedTypes(w.content)))).flat()),
64
+ ];
65
+ const inner = { type: { 'vitops_widgetArea': Nothing } };
45
66
  ---
46
67
 
47
68
  {
48
- widgets.length > 0 && (
49
- <div id={id} class:list={['widget-area', className]} style={style} data-widget-area={area?.name}>
69
+ shown.length > 0 && (
70
+ <div id={id} class:list={['widget-area', themed?.className, className]} style={joinStyle(themed?.style, style)} data-widget-area={area?.name}>
50
71
  {delegated ? (
51
72
  <StockWidgetArea name={name} />
52
73
  ) : (
53
74
  <>
54
- {widgets.map((widget) => (
75
+ {shown.map((widget) => (
55
76
  <section class="widget" data-widget-type={widget.type}>
56
77
  {text(widget.title) && widget.type !== 'menu' && (
57
78
  <Heading class="widget__title font-heading">{widget.title}</Heading>
58
79
  )}
59
- {widget.type === 'content' && widget.content && (
80
+ {widget.type === 'content' && (
60
81
  <div class="widget__content rhythm">
61
82
  <PortableText value={widget.content} components={inner} />
62
83
  </div>
@@ -84,6 +105,7 @@ const inner = { type: { 'vitops.widgetArea': Nothing } };
84
105
  )}
85
106
  </section>
86
107
  ))}
108
+ {missing.map((type) => <template data-vitops-unrendered={type} />)}
87
109
  </>
88
110
  )}
89
111
  </div>
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  /**
3
- * `vitops.badges` — a row of short labels on the framework's `.badge` (static) or `.tag`
3
+ * `vitops_badges` — a row of short labels on the framework's `.badge` (static) or `.tag`
4
4
  * pattern. Each row may carry its own tone, falling back to the block's, which is what lets
5
5
  * one block show a pine label beside an amber one. An icon is child order, not a modifier, and
6
6
  * a row with a `href` becomes a link.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  /**
3
- * A call-to-action band on the framework's `.callout` pattern. Not `vitops.banner`: that is
3
+ * A call-to-action band on the framework's `.callout` pattern. Not `vitops_banner`: that is
4
4
  * a dismissible announcement, this is content that stays. The heading is a real heading at
5
5
  * the editor's `level`; colour is the shared tone convention, tinted by default and filled
6
6
  * with the role's solid when `solid` is on.
@@ -19,7 +19,7 @@
19
19
  import Cards from '@getvitops/astro/components/Cards.astro';
20
20
  import Subgrid from '@getvitops/astro/components/Subgrid.astro';
21
21
  import RichText from './RichText.astro';
22
- import { joinStyle, resolveTone } from './tone.ts';
22
+ import { joinStyle, resolveTone, rowTone } from './tone.ts';
23
23
  import { headingTag, lines, resolveBlockValue, rows, safeHref, text } from './value.ts';
24
24
 
25
25
  interface Value {
@@ -43,6 +43,7 @@ const cards = rows<Record<string, unknown>>(value.cards)
43
43
  checklist: lines(row.checklist),
44
44
  ctaLabel: text(row.ctaLabel),
45
45
  ctaHref: safeHref(row.ctaHref),
46
+ style: rowTone(row),
46
47
  }))
47
48
  .filter((card) => card.title || card.body || card.image);
48
49
  const columns = Math.min(4, Math.max(1, Math.round(Number(value.columns) || 3)));
@@ -59,10 +60,10 @@ import { rootAttrs } from './attrs.ts';
59
60
  {cards.map((card) => (
60
61
  <li
61
62
  class:list={['card', 'subgrid-card', panel && tone.className]}
62
- style={panel ? joinStyle(tone.style) : undefined}
63
+ style={panel ? joinStyle(tone.style, card.style) : card.style}
63
64
  >
64
65
  {card.image && <img src={card.image} alt={card.imageAlt} loading="lazy" />}
65
- {card.title && <Heading class="font-heading">{card.title}</Heading>}
66
+ {card.title && <Heading class="card__title font-heading">{card.title}</Heading>}
66
67
  {card.body && (
67
68
  <p>
68
69
  <RichText text={card.body} />
@@ -123,6 +123,24 @@ import { rootAttrs } from './attrs.ts';
123
123
  </p>
124
124
  )}
125
125
  </div>
126
+ ) : f.type === 'checkboxes' ? (
127
+ <fieldset class="form-group" aria-describedby={describedby}>
128
+ <legend class="form-group__label">{f.label}</legend>
129
+ {f.options.map((o) => (
130
+ <label class="custom-check">
131
+ <input type="checkbox" name={f.name} value={o} />
132
+ <span class="custom-check__box">
133
+ <span class="custom-check__indicator custom-check__indicator--check" />
134
+ </span>
135
+ <span>{o}</span>
136
+ </label>
137
+ ))}
138
+ {f.help && (
139
+ <p id={`${f.id}-help`} class="form-group__helper">
140
+ {f.help}
141
+ </p>
142
+ )}
143
+ </fieldset>
126
144
  ) : (
127
145
  <div class="form-group">
128
146
  <label class="form-group__label" for={f.id}>
@@ -8,10 +8,10 @@
8
8
  * Flat fields, because a block cannot contain other blocks.
9
9
  */
10
10
  import RichText from './RichText.astro';
11
- import { joinStyle, resolveTone } from './tone.ts';
11
+ import { joinStyle, resolveTone, rowTone } from './tone.ts';
12
12
  import {
13
13
  accentRuns,
14
- headingTag,
14
+ outlineTag,
15
15
  resolveBlockValue,
16
16
  rows,
17
17
  safeHref,
@@ -39,7 +39,9 @@ const value = resolveBlockValue<Value>(Astro.props);
39
39
  const eyebrow = text(value.eyebrow);
40
40
  const heading = text(value.heading);
41
41
  const headingRuns = accentRuns(heading);
42
- const Heading = headingTag(value.level, 2);
42
+ const Heading = outlineTag(value.level, 1);
43
+ // A hero titled `h1` puts its option cards one level down, at `h2`.
44
+ const CardHeading = Heading === 'h1' ? 'h2' : 'h3';
43
45
  const body = text(value.body);
44
46
  const primaryLabel = text(value.primaryLabel);
45
47
  const primaryHref = safeHref(value.primaryHref);
@@ -55,6 +57,10 @@ const cards = rows<Record<string, unknown>>(value.cards)
55
57
  body: text(row.body),
56
58
  href: safeHref(row.href),
57
59
  linkLabel: text(row.linkLabel) || 'Learn more',
60
+ eyebrow: text(row.eyebrow),
61
+ ctaLabel: text(row.ctaLabel),
62
+ ctaHref: safeHref(row.ctaHref),
63
+ style: rowTone(row),
58
64
  }))
59
65
  .filter((card) => card.title || card.body);
60
66
  import { rootAttrs } from './attrs.ts';
@@ -94,18 +100,27 @@ import { rootAttrs } from './attrs.ts';
94
100
  {cards.length > 0 && (
95
101
  <ul class="subgrid centered" role="list" style={`--subgrid-cols: ${Math.min(4, cards.length)}`}>
96
102
  {cards.map((card) => (
97
- <li class="card subgrid-card">
98
- {card.title && <h3 class="font-heading">{card.title}</h3>}
103
+ <li class:list={['card', 'subgrid-card', card.style && 'tone']} style={card.style}>
104
+ {card.eyebrow && <p class="font-eyebrow">{card.eyebrow}</p>}
105
+ {card.title && <CardHeading class="font-heading">{card.title}</CardHeading>}
99
106
  {card.body && (
100
107
  <p>
101
108
  <RichText text={card.body} />
102
109
  </p>
103
110
  )}
104
- {card.href && (
105
- <a class="link stretched-link" href={card.href}>
106
- {card.linkLabel}
107
- {card.title && <span class="sr-only">: {card.title}</span>}
108
- </a>
111
+ {card.ctaLabel && card.ctaHref ? (
112
+ <p class="subgrid-card__footer">
113
+ <a class="cta" href={card.ctaHref}>
114
+ {card.ctaLabel}
115
+ </a>
116
+ </p>
117
+ ) : (
118
+ card.href && (
119
+ <a class="link stretched-link" href={card.href}>
120
+ {card.linkLabel}
121
+ {card.title && <span class="sr-only">: {card.title}</span>}
122
+ </a>
123
+ )
109
124
  )}
110
125
  </li>
111
126
  ))}
@@ -1,9 +1,10 @@
1
1
  ---
2
2
  /**
3
- * A still row of partner/client marks on the framework's `.logos` pattern. `vitops.marquee`
3
+ * A still row of partner/client marks on the framework's `.logos` pattern. `vitops_marquee`
4
4
  * is the scrolling version; this one holds still. A list, so the count is announced, and
5
5
  * each mark is an image with the editor's name as its alt text — a logo with no alt is a
6
- * link or a fact the page silently drops.
6
+ * link or a fact the page silently drops. An entry with a name and no image renders the name as
7
+ * text in the same list (`.logos__name`), for partners who have no wordmark.
7
8
  */
8
9
  import { resolveBlockValue, rows, safeHref, text } from './value.ts';
9
10
 
@@ -20,7 +21,7 @@ const value = resolveBlockValue<Value>(Astro.props);
20
21
  const label = text(value.label) || 'Partners';
21
22
  const logos = rows<Record<string, unknown>>(value.logos)
22
23
  .map((row) => ({ image: safeHref(row.image), alt: text(row.alt), href: safeHref(row.href) }))
23
- .filter((logo) => logo.image);
24
+ .filter((logo) => logo.image || logo.alt);
24
25
  const height = HEIGHTS[text(value.size)];
25
26
  import { rootAttrs } from './attrs.ts';
26
27
  ---
@@ -36,10 +37,12 @@ import { rootAttrs } from './attrs.ts';
36
37
  <li class="logos__item">
37
38
  {logo.href ? (
38
39
  <a href={logo.href}>
39
- <img src={logo.image} alt={logo.alt} loading="lazy" />
40
+ {logo.image ? <img src={logo.image} alt={logo.alt} loading="lazy" /> : <span class="logos__name font-heading">{logo.alt}</span>}
40
41
  </a>
41
- ) : (
42
+ ) : logo.image ? (
42
43
  <img src={logo.image} alt={logo.alt} loading="lazy" />
44
+ ) : (
45
+ <span class="logos__name font-heading">{logo.alt}</span>
43
46
  )}
44
47
  </li>
45
48
  ))}
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  /**
3
- * One `<ul class="navlist">` level of `vitops.nav`, recursing for sub-links. Internal — import
3
+ * One `<ul class="navlist">` level of `vitops_nav`, recursing for sub-links. Internal — import
4
4
  * `Nav.astro`. Plain nested lists, no disclosure widgets: a page-body menu is read, not
5
5
  * collapsed, so there is no state to manage and no runtime JS. The `.navlist` pattern is
6
6
  * `patterns/sitenav.css`'s (all formats).
@@ -113,7 +113,7 @@ import { rootAttrs } from './attrs.ts';
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;
@@ -44,12 +49,12 @@ const kicker = text(value.kicker);
44
49
  const heading = text(value.heading);
45
50
  const sub = text(value.sub);
46
51
  const body = Array.isArray(value.body) ? value.body : [];
47
- const Heading = headingTag(value.level, 2);
48
- // `theme` is one pick for the common case: `<role>` tints, `<role>:solid` fills. It wins over the
49
- // older `tone`/`color`/`solid` fields, which stay for content that already uses them.
50
- const [themeRole, themeFill] = text(value.theme).replace(/^none$/, '').split(':');
51
- const tone = themeRole ? resolveTone(themeRole) : resolveTone(value.tone, value.color);
52
- const solid = themeRole ? themeFill === 'solid' : Boolean(value.solid);
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.
56
+ const tone = resolveTone(value.tone, value.color);
57
+ const solid = Boolean(value.solid);
53
58
  const layout = text(value.layout);
54
59
  const width = WIDTHS[text(value.width)];
55
60
  const hasHead = Boolean(kicker || heading || sub);
@@ -73,6 +78,7 @@ import { rootAttrs } from './attrs.ts';
73
78
  {body.length > 0 && (
74
79
  <div class="section__body">
75
80
  <PortableText value={body} />
81
+ {missing.map((type) => <template data-vitops-unrendered={type} />)}
76
82
  </div>
77
83
  )}
78
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,6 +30,7 @@ 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';
@@ -45,7 +46,7 @@ import { rootAttrs } from './attrs.ts';
45
46
  role="list"
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,7 +19,7 @@ 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);
@@ -32,7 +32,7 @@ import { rootAttrs } from './attrs.ts';
32
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 && (
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  /**
3
- * `vitops.widgetArea` — a synced region inside page content: the area is defined once under
3
+ * `vitops_widgetArea` — a synced region inside page content: the area is defined once under
4
4
  * Widgets in the admin and every page that places this block shows the same thing. (An EmDash
5
5
  * Section, by contrast, is copied in.) See `../WidgetArea.astro` for what it renders and why a
6
6
  * widget area cannot hold another. The markup (`.widget-area`) lives there.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vite-plus/test';
2
- import { anchorId, blockAttrs, classTokens, customProperties } from './attrs.ts';
2
+ import { anchorId, blockAttrs, classTokens, customProperties, rootAttrs } from './attrs.ts';
3
3
 
4
4
  describe('anchorId', () => {
5
5
  it('slugifies and accepts a clean id', () => {
@@ -54,3 +54,21 @@ describe('blockAttrs', () => {
54
54
  });
55
55
  });
56
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
+ });
@@ -15,6 +15,8 @@
15
15
  * `css` and `bricks` formats every framework class ships, so the caveat does not apply.
16
16
  */
17
17
 
18
+ import { resolveTheme } from './tone.ts';
19
+
18
20
  export interface BlockAttrs {
19
21
  id?: string;
20
22
  class?: string;
@@ -94,11 +96,21 @@ export function rootAttrs(
94
96
  ownStyle?: string,
95
97
  ): BlockAttrs {
96
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);
97
102
  const out: BlockAttrs = {};
98
103
  if (user.id) out.id = user.id;
99
- const cls = [...own, user.id && 'block-anchor', user.class].filter(Boolean).join(' ');
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(' ');
100
112
  if (cls) out.class = cls;
101
- const style = [ownStyle, user.style].filter(Boolean).join('; ');
113
+ const style = [ownStyle, theme?.style, user.style].filter(Boolean).join('; ');
102
114
  if (style) out.style = style;
103
115
  return out;
104
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),