@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
@@ -30,7 +30,7 @@ async function render(component: unknown, node: object) {
30
30
  return container.renderToString(component as Component, { props: { node } });
31
31
  }
32
32
 
33
- describe('vitops.cards', () => {
33
+ describe('vitops_cards', () => {
34
34
  const cards = [
35
35
  {
36
36
  title: 'Cut costs',
@@ -42,7 +42,7 @@ describe('vitops.cards', () => {
42
42
 
43
43
  it('renders the title as a heading at the chosen level (U19), not a paragraph', async () => {
44
44
  const html = await render(Cards, { cards, level: '4' });
45
- expect(html).toContain('<h4 class="font-heading">Cut costs</h4>');
45
+ expect(html).toContain('<h4 class="card__title font-heading">Cut costs</h4>');
46
46
  expect(html).not.toContain('<p class="font-heading"');
47
47
  });
48
48
 
@@ -73,7 +73,7 @@ describe('vitops.cards', () => {
73
73
  });
74
74
  });
75
75
 
76
- describe('vitops.hero', () => {
76
+ describe('vitops_hero', () => {
77
77
  it('accents starred words and honours the heading level', async () => {
78
78
  const html = await render(Hero, { heading: 'Two services. *One team.*', level: '3' });
79
79
  expect(html).toContain(
@@ -83,11 +83,14 @@ describe('vitops.hero', () => {
83
83
 
84
84
  it('renders the optional cards slot', async () => {
85
85
  const html = await render(Hero, { heading: 'Hi', cards: [{ title: 'A', body: 'b' }] });
86
- expect(html).toContain('<h3 class="font-heading">A</h3>');
86
+ // the hero defaults to h1, so its option cards sit one level down
87
+ expect(html).toContain('<h2 class="font-heading">A</h2>');
88
+ const under2 = await render(Hero, { heading: 'Hi', level: '2', cards: [{ title: 'A' }] });
89
+ expect(under2).toContain('<h3 class="font-heading">A</h3>');
87
90
  });
88
91
  });
89
92
 
90
- describe('vitops.logos', () => {
93
+ describe('vitops_logos', () => {
91
94
  it('is a labelled list of images with alt text', async () => {
92
95
  const html = await render(Logos, {
93
96
  label: 'Key partners',
@@ -103,12 +106,28 @@ describe('vitops.logos', () => {
103
106
  expect(html).toContain('<a href="/acme">');
104
107
  });
105
108
 
109
+ it('shows the name as text when an entry has no image, linked or not', async () => {
110
+ const html = await render(Logos, {
111
+ logos: [
112
+ { alt: 'Acme Partners', href: '/acme' },
113
+ { alt: 'Beta Group' },
114
+ { image: '/c.svg', alt: 'Gamma' },
115
+ { image: '', alt: '' },
116
+ ],
117
+ });
118
+ expect(html).toContain('<span class="logos__name font-heading">Acme Partners</span>');
119
+ expect(html).toContain('<span class="logos__name font-heading">Beta Group</span>');
120
+ expect(html).toContain('<a href="/acme"><span');
121
+ expect(html).toContain('alt="Gamma"');
122
+ expect(html.match(/logos__item/g)).toHaveLength(3);
123
+ });
124
+
106
125
  it('renders nothing with no logos', async () => {
107
126
  expect((await render(Logos, { logos: [] })).trim()).toBe('');
108
127
  });
109
128
  });
110
129
 
111
- describe('vitops.steps', () => {
130
+ describe('vitops_steps', () => {
112
131
  it('is an ordered list with real headings and a tone', async () => {
113
132
  const html = await render(Steps, {
114
133
  steps: [{ title: 'Call', text: 'We talk' }, { title: 'Build' }],
@@ -123,7 +142,7 @@ describe('vitops.steps', () => {
123
142
  });
124
143
  });
125
144
 
126
- describe('vitops.stats', () => {
145
+ describe('vitops_stats', () => {
127
146
  const stats = [
128
147
  { value: '82%', label: 'Faster', group: 'Ops', delta: '+3 this week', state: 'up' },
129
148
  ];
@@ -150,7 +169,7 @@ describe('vitops.stats', () => {
150
169
  });
151
170
  });
152
171
 
153
- describe('vitops.callout', () => {
172
+ describe('vitops_callout', () => {
154
173
  it('is a heading, text and actions in a tinted or solid band — not a banner', async () => {
155
174
  const html = await render(Callout, {
156
175
  heading: 'Ready?',
@@ -168,7 +187,7 @@ describe('vitops.callout', () => {
168
187
  });
169
188
  });
170
189
 
171
- describe('vitops.entries', () => {
190
+ describe('vitops_entries', () => {
172
191
  it('emits <wc-entries> with a semantic heading + dl per entry', async () => {
173
192
  const html = await render(Entries, {
174
193
  level: '4',
@@ -191,7 +210,7 @@ describe('vitops.entries', () => {
191
210
  });
192
211
  });
193
212
 
194
- describe('vitops.pricing', () => {
213
+ describe('vitops_pricing', () => {
195
214
  it('aligns plans through the pricing-card subgrid rows', async () => {
196
215
  const html = await render(Pricing, {
197
216
  plans: [
@@ -221,7 +240,7 @@ describe('vitops.pricing', () => {
221
240
  });
222
241
  });
223
242
 
224
- describe('vitops.embed', () => {
243
+ describe('vitops_embed', () => {
225
244
  it('ships the iframe with NO src and the address on data-consent-src', async () => {
226
245
  const html = await render(Embed, {
227
246
  provider: 'youtube',
@@ -289,12 +308,12 @@ const review = (id: string, rating: number, extra: object = {}) => ({
289
308
  ...extra,
290
309
  });
291
310
 
292
- describe('vitops.reviews', () => {
311
+ describe('vitops_reviews', () => {
293
312
  it('renders nothing when the cache is empty (a fresh scaffold has no reviews.json)', async () => {
294
313
  fixtures.cache = { version: 1, locations: {} };
295
314
  const { default: Reviews } = await import('./Reviews.astro');
296
315
  // The dev-only note is allowed (vitest runs with DEV on); no card or wrapper is.
297
- const html = await render(Reviews, { _type: 'vitops.reviews' });
316
+ const html = await render(Reviews, { _type: 'vitops_reviews' });
298
317
  expect(html).not.toContain('<article');
299
318
  expect(html).not.toMatch(/class="reviews[" ]/);
300
319
  });
@@ -371,7 +390,7 @@ describe('vitops.reviews', () => {
371
390
  });
372
391
  });
373
392
 
374
- describe('vitops.nav', () => {
393
+ describe('vitops_nav', () => {
375
394
  const menu = {
376
395
  label: 'Footer site map',
377
396
  items: [
@@ -441,7 +460,7 @@ describe('vitops.nav', () => {
441
460
  // block the test nests, so it resolves as in production. (Not the whole `blockComponents` map:
442
461
  // that imports Section.astro, which imports `emdash/ui`, which awaits this very factory.)
443
462
  vi.mock('virtual:emdash/block-components', async () => ({
444
- pluginBlockComponents: { 'vitops.logos': (await import('./Logos.astro')).default },
463
+ pluginBlockComponents: { vitops_logos: (await import('./Logos.astro')).default },
445
464
  }));
446
465
 
447
466
  vi.mock('virtual:emdash/config', () => ({ default: {} }));
@@ -466,8 +485,8 @@ describe('vitops_section', () => {
466
485
  style: 'normal',
467
486
  children: [{ _type: 'span', _key: 'b', text: 'Body copy' }],
468
487
  },
469
- // The point of the block: another vitops.* block resolves INSIDE the body.
470
- { _type: 'vitops.logos', _key: 'c', logos: [{ image: '/x.svg', alt: 'Acme' }] },
488
+ // The point of the block: another vitops_* block resolves INSIDE the body.
489
+ { _type: 'vitops_logos', _key: 'c', logos: [{ image: '/x.svg', alt: 'Acme' }] },
471
490
  ],
472
491
  },
473
492
  },
@@ -481,7 +500,7 @@ describe('vitops_section', () => {
481
500
  }, 60_000);
482
501
  });
483
502
 
484
- describe('vitops.form', () => {
503
+ describe('vitops_form', () => {
485
504
  async function renderForm(node: object, path = '/contact') {
486
505
  const container = await AstroContainer.create();
487
506
  return container.renderToString(Form as unknown as Component, {
@@ -506,6 +525,19 @@ describe('vitops.form', () => {
506
525
  expect(html).toContain('Send message');
507
526
  });
508
527
 
528
+ it('renders "tick any number" as a fieldset of checkboxes sharing one name', async () => {
529
+ const html = await renderForm({
530
+ ...SAMPLE_FORM_BLOCK,
531
+ fields: [
532
+ { label: 'Interests', name: 'interests', type: 'checkboxes', options: 'IT\nMarketing' },
533
+ ],
534
+ });
535
+ expect(html).toContain('<fieldset');
536
+ expect(html).toContain('<legend class="form-group__label">Interests</legend>');
537
+ expect(html.match(/name="interests"/g)).toHaveLength(2);
538
+ expect(html).toContain('value="Marketing"');
539
+ });
540
+
509
541
  it('never submits a phone field as `phone`: the route drops that name', async () => {
510
542
  const html = await renderForm(SAMPLE_FORM_BLOCK);
511
543
  expect(html).toContain('name="tel"');
@@ -560,7 +592,7 @@ describe('vitops.form', () => {
560
592
  const html = await renderForm({ ...SAMPLE_FORM_BLOCK, action: 'https://evil.example/steal' });
561
593
  expect(html).toContain('action="/api/track"');
562
594
  expect(html).not.toContain('evil.example');
563
- expect(await renderForm({ _type: 'vitops.form', fields: [] })).not.toContain('<form');
595
+ expect(await renderForm({ _type: 'vitops_form', fields: [] })).not.toContain('<form');
564
596
  });
565
597
 
566
598
  it('escapes editor text', async () => {
@@ -568,3 +600,83 @@ describe('vitops.form', () => {
568
600
  expect(html).not.toContain('<script>alert(1)');
569
601
  });
570
602
  });
603
+
604
+ describe('escape hatches (anchor, className, style)', () => {
605
+ const hatches = { anchor: 'x', className: 'mt-4', style: '--a: 1px; background: url(evil)' };
606
+ const samples: [string, unknown, object][] = [
607
+ ['callout', Callout, { heading: 'H', body: 'b' }],
608
+ ['stats', Stats, { stats: [{ value: '1', label: 'a' }] }],
609
+ ['steps', Steps, { steps: [{ title: 'a', text: 'b' }] }],
610
+ ['logos', Logos, { logos: [{ image: '/a.png', alt: 'A' }] }],
611
+ ['pricing', Pricing, { plans: [{ name: 'P', price: '$1' }] }],
612
+ ['hero', Hero, { heading: 'H', body: 'b' }],
613
+ ['cards (default)', Cards, { cards: [{ title: 'T' }] }],
614
+ ['cards (panel)', Cards, { variant: 'panel', cards: [{ title: 'T' }] }],
615
+ ];
616
+
617
+ it.each(samples)(
618
+ '%s: lands on its own element, with no wrapper node',
619
+ async (name, component, fields) => {
620
+ const html = (await render(component, { ...fields, ...hatches })).trim();
621
+ // One id, one added class, the custom property kept and the fetching declaration dropped.
622
+ expect(html.match(/id="x"/g)).toHaveLength(1);
623
+ expect(html).toContain('mt-4');
624
+ expect(html).toContain('block-anchor');
625
+ expect(html).toContain('--a: 1px');
626
+ expect(html).not.toContain('url(');
627
+ expect(html).not.toContain('block-shell');
628
+ // The element carrying it is the first one rendered, except `cards`, whose default variant is
629
+ // a <wc-cards> host around the grid the attributes go on.
630
+ if (!name.startsWith('cards')) expect(html).toMatch(/^<[a-z-]+[^>]*\sid="x"/);
631
+ },
632
+ );
633
+
634
+ it.each(samples)(
635
+ '%s: renders the same markup with none set',
636
+ async (_name, component, fields) => {
637
+ const html = await render(component, fields);
638
+ expect(html).not.toContain('block-anchor');
639
+ expect(html).not.toContain(' id="x"');
640
+ },
641
+ );
642
+ });
643
+
644
+ describe('per-item colour and hero card extras', () => {
645
+ const rows = [
646
+ { title: 'Pine', body: 'a', tone: 'success' },
647
+ { title: 'Amber', body: 'b', color: 'amber' },
648
+ { title: 'Plain', body: 'c' },
649
+ ];
650
+
651
+ it('a row sets its own --tone-* properties; a row with none inherits', async () => {
652
+ const html = await render(Cards, { variant: 'panel', tone: 'info', cards: rows });
653
+ const items = html.match(/<li[^>]*class="card[^"]*"[^>]*>/g)!;
654
+ expect(items).toHaveLength(3);
655
+ expect(items[0]).toContain('--color-bg-success-muted');
656
+ expect(items[1]).toContain('--color-amber-100');
657
+ expect(items[2]).toContain('--color-bg-info-muted'); // the block's own
658
+ });
659
+
660
+ it('applies to stats and steps rows', async () => {
661
+ const stats = await render(Stats, {
662
+ stats: [{ value: '1', tone: 'danger' }, { value: '2' }],
663
+ });
664
+ expect(stats).toContain('stats__item" style="--tone-bg');
665
+ const steps = await render(Steps, {
666
+ steps: [{ title: 'A', color: 'amber' }, { title: 'B' }],
667
+ });
668
+ expect(steps).toContain('steps__item" style="--tone-bg');
669
+ });
670
+
671
+ it('hero cards take an eyebrow, a button and a tone', async () => {
672
+ const html = await render(Hero, {
673
+ heading: 'H',
674
+ level: '1',
675
+ cards: [{ title: 'Door', eyebrow: 'For IT', ctaLabel: 'Go', ctaHref: '/go', color: 'amber' }],
676
+ });
677
+ expect(html).toContain('class="font-eyebrow">For IT');
678
+ expect(html).toContain('<a class="cta" href="/go">');
679
+ expect(html).toContain('card subgrid-card tone');
680
+ expect(html).not.toContain('stretched-link');
681
+ });
682
+ });
@@ -51,3 +51,13 @@ describe('joinStyle', () => {
51
51
  expect(joinStyle('--a: 1', '', '--b: 2')).toBe('--a: 1; --b: 2');
52
52
  });
53
53
  });
54
+
55
+ describe('solid fill from a hue and step', () => {
56
+ it("takes its text from the site's -on token for that step", () => {
57
+ expect(resolveTone('', 'amber-500').style).toContain(
58
+ '--tone-on-solid: var(--color-amber-500-on)',
59
+ );
60
+ expect(resolveTone('', 'navy').style).toContain('--tone-on-solid: var(--color-navy-600-on)');
61
+ expect(resolveTone('', 'navy').className).toBe('tone');
62
+ });
63
+ });
@@ -7,7 +7,10 @@
7
7
  * fallbacks, then `neutral`.
8
8
  * - **`color`** — the escape hatch: a palette hue (`amber`) or hue and step (`amber-300`).
9
9
  * Wins over `tone` when set. Contrast is NOT checked for it — the generator's contrast
10
- * contract covers roles, not arbitrary hue/step pairs — so it is for decoration.
10
+ * contract covers roles, not arbitrary hue/step pairs — so it is for decoration. A solid fill
11
+ * reads from the site's `--color-<hue>-<step>-on` and the tint flips with the colour scheme
12
+ * (`light-dark()`); mid steps (300–500) cannot reach APCA Lc 75 with either text colour, so use
13
+ * a role where contrast matters.
11
14
  *
12
15
  * **Why CSS custom properties, not classes.** A class name built from a field value
13
16
  * (`bg-${tone}`) is invisible to Tailwind's scanner, so it generates nothing in that
@@ -82,14 +85,21 @@ export function resolveTone(tone?: unknown, color?: unknown): ResolvedTone {
82
85
  if (hue) {
83
86
  const [, name, step] = hue;
84
87
  const at = (s: string) => `var(--color-${name}-${s})`;
88
+ // The foreground is the site's own: the generator emits `--color-<hue>-<step>-on` for every
89
+ // step, picked by the same APCA rule as `text-on-<role>`, so a fill by hue and step reads
90
+ // without a fixed white and without asking the browser to guess.
91
+ const fill = step ?? '600';
92
+ // Raw hue steps never re-point in dark, so the tint flips here: `light-dark()` follows the
93
+ // `color-scheme` the generator sets under the dark selector.
94
+ const flip = (light: string, dark: string) => `light-dark(${at(light)}, ${at(dark)})`;
85
95
  return {
86
96
  className: 'tone',
87
97
  style: [
88
- `--tone-bg: ${at('100')}`,
89
- `--tone-fg: ${at('900')}`,
90
- `--tone-border: ${at('300')}`,
91
- `--tone-solid: ${at(step ?? '600')}`,
92
- `--tone-on-solid: #fff`,
98
+ `--tone-bg: ${flip('100', '900')}`,
99
+ `--tone-fg: ${flip('900', '100')}`,
100
+ `--tone-border: ${flip('300', '700')}`,
101
+ `--tone-solid: ${at(fill)}`,
102
+ `--tone-on-solid: ${at(`${fill}-on`)}`,
93
103
  ].join('; '),
94
104
  };
95
105
  }
@@ -120,3 +130,35 @@ export function joinStyle(...parts: (string | undefined | false)[]): string | un
120
130
  const out = parts.filter(Boolean).join('; ');
121
131
  return out || undefined;
122
132
  }
133
+
134
+ /**
135
+ * A repeater row's own colour: the `style` that overrides the block's `--tone-*` properties for
136
+ * that item, or `undefined` when the row sets none (it then inherits the block's). Only the custom
137
+ * properties are written, so the row picks up the block's own `.tone` surface and just recolours it.
138
+ */
139
+ export function rowTone(row: Record<string, unknown>): string | undefined {
140
+ return resolveTone(row.tone, row.color).style || undefined;
141
+ }
142
+
143
+ /**
144
+ * The `theme` every block can carry: a backdrop for the block's own root element, chosen from the
145
+ * site's colour roles. `<role>` is the tinted surface (`bg-<role>-muted`, with the role's text);
146
+ * `<role>:solid` is the filled backdrop with the contrast-computed `text-on-<role>`; `none` or
147
+ * nothing is no theme. Contrast is therefore the generator's guarantee, not the editor's judgement,
148
+ * and a site that styles its roles as raised, glass or flat gets that look, because the tokens are
149
+ * the site's own.
150
+ *
151
+ * Returns the classes and `--tone-*` style to put on the root element, or `undefined` for no theme.
152
+ */
153
+ export function resolveTheme(theme: unknown): { className: string; style: string } | undefined {
154
+ if (typeof theme !== 'string') return undefined;
155
+ const [role, fill] = theme
156
+ .trim()
157
+ .toLowerCase()
158
+ .replace(/^none$/, '')
159
+ .split(':');
160
+ if (!role || !ROLE.test(role)) return undefined;
161
+ const tone = resolveTone(role);
162
+ if (!tone.style) return undefined;
163
+ return { className: fill === 'solid' ? 'tone tone--solid' : 'tone', style: tone.style };
164
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Which `vitops_*` blocks in a Portable Text body have no renderer in the merged plugin map.
3
+ *
4
+ * `<PortableText>` logs `[components.type] is missing "vitops.…"` and drops the block, and the
5
+ * build still succeeds. Renderers that nest a body (`vitops_section`, widget areas) emit a
6
+ * `data-vitops-unrendered` marker for each such type so `vitops emdash check-prerender` can fail
7
+ * the build instead of publishing a page with holes in it.
8
+ *
9
+ * The virtual module is imported lazily, for the same reason `emdash/ui` is: it imports this
10
+ * package's `./astro` entry, so a static import closes the cycle.
11
+ */
12
+ export async function unrenderedTypes(body: unknown): Promise<string[]> {
13
+ if (!Array.isArray(body)) return [];
14
+ const wanted = new Set<string>();
15
+ for (const node of body) {
16
+ const type = node && typeof node === 'object' ? (node as { _type?: unknown })._type : undefined;
17
+ if (typeof type === 'string' && type.startsWith('vitops_')) wanted.add(type);
18
+ }
19
+ if (wanted.size === 0) return [];
20
+ const { pluginBlockComponents } = await import('virtual:emdash/block-components');
21
+ const map = (pluginBlockComponents ?? {}) as Record<string, unknown>;
22
+ return [...wanted].filter((type) => !map[type]).sort();
23
+ }
@@ -1,5 +1,15 @@
1
1
  import { describe, expect, it } from 'vite-plus/test';
2
- import { accentRuns, headingTag, lines, linkRuns, pairs, paragraphs, rows, text } from './value.ts';
2
+ import {
3
+ accentRuns,
4
+ headingTag,
5
+ outlineTag,
6
+ lines,
7
+ linkRuns,
8
+ pairs,
9
+ paragraphs,
10
+ rows,
11
+ text,
12
+ } from './value.ts';
3
13
 
4
14
  describe('editor-text helpers', () => {
5
15
  it('headingTag clamps to h2-h6 and falls back', () => {
@@ -54,3 +64,19 @@ describe('repeater helpers', () => {
54
64
  expect(paragraphs('')).toEqual([]);
55
65
  });
56
66
  });
67
+
68
+ describe('outlineTag', () => {
69
+ it('allows h1 through h6', () => {
70
+ expect(outlineTag('1')).toBe('h1');
71
+ expect(outlineTag(6)).toBe('h6');
72
+ });
73
+ it('returns p for a heading that is not in the outline', () => {
74
+ expect(outlineTag('p')).toBe('p');
75
+ expect(outlineTag(' P ')).toBe('p');
76
+ });
77
+ it('falls back for anything else', () => {
78
+ expect(outlineTag(undefined, 1)).toBe('h1');
79
+ expect(outlineTag(9)).toBe('h2');
80
+ expect(outlineTag('x', 3)).toBe('h3');
81
+ });
82
+ });
@@ -59,6 +59,22 @@ export function headingTag(
59
59
  return `h${clamped}` as 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
60
60
  }
61
61
 
62
+ /**
63
+ * The element for a block that may own the page's title: `h1`–`h6`, or `p` when the heading is
64
+ * visual only and the outline should not carry it (the role class still styles it). Hero and
65
+ * section use this; every other block keeps `headingTag`'s `h2`–`h6`, so a page's `h1` only
66
+ * comes from a block an editor chose it for.
67
+ */
68
+ export function outlineTag(
69
+ level: unknown,
70
+ fallback: 1 | 2 | 3 | 4 | 5 | 6 = 2,
71
+ ): 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' {
72
+ if (String(level).trim().toLowerCase() === 'p') return 'p';
73
+ const n = Math.round(Number(level));
74
+ const clamped = Number.isFinite(n) && n >= 1 && n <= 6 ? n : fallback;
75
+ return `h${clamped}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
76
+ }
77
+
62
78
  /** One run of inline text, optionally a link. */
63
79
  export interface Run {
64
80
  text: string;
@@ -7,6 +7,9 @@
7
7
  * `../block-types.ts` (a `blocks` field entry, keyed by slug), for a site to pass to
8
8
  * `defineBlockComponents` / `<Blocks components={…}>`. EmDash does not merge it.
9
9
  */
10
+ import Badges from './blocks/Badges.astro';
11
+ import { resolveTheme } from './blocks/tone.ts';
12
+ import WidgetArea from './blocks/WidgetArea.astro';
10
13
  import Accordion from './blocks/Accordion.astro';
11
14
  import ActionLink from './blocks/ActionLink.astro';
12
15
  import Banner from './blocks/Banner.astro';
@@ -31,30 +34,44 @@ import Stats from './blocks/Stats.astro';
31
34
  import Steps from './blocks/Steps.astro';
32
35
 
33
36
  export const blockComponents = {
34
- 'vitops.actionLink': ActionLink,
35
- 'vitops.imageCompare': ImageCompare,
36
- 'vitops.copyButton': CopyButton,
37
- 'vitops.banner': Banner,
38
- 'vitops.details': Details,
39
- 'vitops.carousel': Carousel,
40
- 'vitops.gallery': Gallery,
41
- 'vitops.cards': Cards,
42
- 'vitops.accordion': Accordion,
43
- 'vitops.hero': Hero,
44
- 'vitops.marquee': Marquee,
45
- 'vitops.logos': Logos,
46
- 'vitops.steps': Steps,
47
- 'vitops.stats': Stats,
48
- 'vitops.callout': Callout,
49
- 'vitops.entries': Entries,
50
- 'vitops.pricing': Pricing,
51
- 'vitops.embed': Embed,
52
- 'vitops.form': Form,
53
- 'vitops.reviews': Reviews,
54
- 'vitops.nav': Nav,
37
+ vitops_actionLink: ActionLink,
38
+ vitops_imageCompare: ImageCompare,
39
+ vitops_copyButton: CopyButton,
40
+ vitops_banner: Banner,
41
+ vitops_details: Details,
42
+ vitops_carousel: Carousel,
43
+ vitops_gallery: Gallery,
44
+ vitops_cards: Cards,
45
+ vitops_accordion: Accordion,
46
+ vitops_hero: Hero,
47
+ vitops_marquee: Marquee,
48
+ vitops_logos: Logos,
49
+ vitops_steps: Steps,
50
+ vitops_stats: Stats,
51
+ vitops_callout: Callout,
52
+ vitops_entries: Entries,
53
+ vitops_pricing: Pricing,
54
+ vitops_embed: Embed,
55
+ vitops_form: Form,
56
+ vitops_reviews: Reviews,
57
+ vitops_nav: Nav,
58
+ vitops_badges: Badges,
59
+ vitops_widgetArea: WidgetArea,
55
60
  };
56
61
 
57
62
  /** Keyed by block-type slug (see `SECTION_SLUG`), not by Portable Text `_type`. */
58
63
  export const blockTypeComponents = {
59
64
  vitops_section: Section,
60
65
  };
66
+
67
+ /** The framework-styled widget area, for a site layout (the block form is `vitops_widgetArea`). */
68
+ export { default as VitopsWidgetArea } from './WidgetArea.astro';
69
+
70
+ /**
71
+ * `themeAttrs('surface')` → `{ class, style }` for the layout's `<body>` (or any element a site owns):
72
+ * the same backdrop-and-readable-text every block's `theme` field gives, from the site's own roles.
73
+ */
74
+ export function themeAttrs(theme: string | undefined): { class?: string; style?: string } {
75
+ const t = resolveTheme(theme);
76
+ return t ? { class: t.className, style: t.style } : {};
77
+ }