@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/emdash",
3
- "version": "2.0.0-alpha.0",
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.0",
35
- "@getvitops/utils": "8.3.0-alpha.0"
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.0",
42
- "@getvitops/core": "8.3.0-alpha.0",
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",
@@ -0,0 +1,3 @@
1
+ ---
2
+ /** Renders nothing. Stands in for a block that must not render in a given place. */
3
+ ---
@@ -0,0 +1,113 @@
1
+ ---
2
+ /**
3
+ * A widget area rendered in the framework's vocabulary, in place of `emdash/ui`'s
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
6
+ * works inside it. Renders nothing when the area is missing or empty.
7
+ *
8
+ * Two limits, both deliberate:
9
+ * - **No widget area inside a widget area.** A content widget that held a `vitops_widgetArea`
10
+ * block could name its own area and recurse, and a request-scoped depth counter cannot be kept
11
+ * honest while Astro streams children, so the block is simply not rendered in here.
12
+ * - **`component` widgets** (`core:recent-posts`, …) are EmDash's closed map and its renderer is
13
+ * not exported, so an area that holds one is handed whole to the stock `<WidgetArea>`.
14
+ *
15
+ * Prerendered pages bake the area at build time: an edit in the admin shows after the next deploy.
16
+ */
17
+ import { getMenu, getWidgetArea } from 'emdash';
18
+ import Nothing from './Nothing.astro';
19
+ import { unrenderedTypes } from './blocks/unrendered.ts';
20
+ import { joinStyle, resolveTheme } from './blocks/tone.ts';
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');
25
+
26
+ interface Props {
27
+ /** The area's `name` as set under Widgets. */
28
+ name: string;
29
+ /** Level of each widget's title; default `3`. */
30
+ level?: number | string;
31
+ class?: string;
32
+ id?: string;
33
+ style?: string;
34
+ /** A backdrop for the area, from the site's colour roles: `surface`, `brand-primary:solid`, … */
35
+ theme?: string;
36
+ }
37
+
38
+ const { name, level, class: className, id, style, theme } = Astro.props;
39
+ const themed = resolveTheme(theme);
40
+ const area = name ? await getWidgetArea(name) : null;
41
+ const widgets = area?.widgets ?? [];
42
+ const delegated = widgets.some((w) => w.type === 'component');
43
+ const Heading = headingTag(level, 3);
44
+ const menus = Object.fromEntries(
45
+ await Promise.all(
46
+ widgets
47
+ .filter((w) => w.type === 'menu' && w.menuName)
48
+ .map(async (w) => [w.id, await getMenu(w.menuName!)] as const),
49
+ ),
50
+ );
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 } };
66
+ ---
67
+
68
+ {
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}>
71
+ {delegated ? (
72
+ <StockWidgetArea name={name} />
73
+ ) : (
74
+ <>
75
+ {shown.map((widget) => (
76
+ <section class="widget" data-widget-type={widget.type}>
77
+ {text(widget.title) && widget.type !== 'menu' && (
78
+ <Heading class="widget__title font-heading">{widget.title}</Heading>
79
+ )}
80
+ {widget.type === 'content' && (
81
+ <div class="widget__content rhythm">
82
+ <PortableText value={widget.content} components={inner} />
83
+ </div>
84
+ )}
85
+ {widget.type === 'menu' && menus[widget.id] && (
86
+ <>
87
+ {text(widget.title) && <Heading class="widget__title font-heading">{widget.title}</Heading>}
88
+ <nav class="widget__menu" aria-label={text(widget.title) || area?.label}>
89
+ <ul role="list">
90
+ {menus[widget.id]!.items.map((item) => {
91
+ const href = safeHref(item.url);
92
+ return (
93
+ href && (
94
+ <li>
95
+ <a class="link" href={href} title={item.titleAttr || undefined}>
96
+ {item.label}
97
+ </a>
98
+ </li>
99
+ )
100
+ );
101
+ })}
102
+ </ul>
103
+ </nav>
104
+ </>
105
+ )}
106
+ </section>
107
+ ))}
108
+ {missing.map((type) => <template data-vitops-unrendered={type} />)}
109
+ </>
110
+ )}
111
+ </div>
112
+ )
113
+ }
@@ -21,11 +21,12 @@ const items = rows<Record<string, unknown>>(value.items)
21
21
  }))
22
22
  .filter((item) => item.summary);
23
23
  const group = value.exclusive ? `accordion-${value._key ?? 'group'}` : undefined;
24
+ import { rootAttrs } from './attrs.ts';
24
25
  ---
25
26
 
26
27
  {
27
28
  items.length > 0 && (
28
- <div class="accordion">
29
+ <div {...rootAttrs(value, ['accordion'])}>
29
30
  {items.map((item) => (
30
31
  <details name={group} open={item.open}>
31
32
  <summary>{item.summary}</summary>
@@ -51,12 +51,13 @@ const classes = [variant, role ? `${variant}-${role}` : null, newTab ? 'link--ex
51
51
  // Only render when there is somewhere to go and something to read — a bare icon
52
52
  // with no label would be an unlabelled link.
53
53
  const renderable = Boolean(label && href);
54
+ import { rootAttrs } from './attrs.ts';
54
55
  ---
55
56
 
56
57
  {
57
58
  renderable && (
58
59
  <a
59
- class={classes}
60
+ {...rootAttrs(value, [classes])}
60
61
  href={href}
61
62
  {...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}>
62
63
  {startIcon && (
@@ -0,0 +1,67 @@
1
+ ---
2
+ /**
3
+ * `vitops_badges` — a row of short labels on the framework's `.badge` (static) or `.tag`
4
+ * pattern. Each row may carry its own tone, falling back to the block's, which is what lets
5
+ * one block show a pine label beside an amber one. An icon is child order, not a modifier, and
6
+ * a row with a `href` becomes a link.
7
+ */
8
+ import { resolveTone } from './tone.ts';
9
+ import { resolveBlockValue, rows, safeHref, text } from './value.ts';
10
+ import { rootAttrs } from './attrs.ts';
11
+
12
+ interface Props {
13
+ /** Base URL of the sprite. Matches VitopsEmdashOptions.wcBase. */
14
+ spriteBase?: string;
15
+ }
16
+
17
+ interface Value {
18
+ items?: unknown;
19
+ variant?: string;
20
+ tone?: string;
21
+ color?: string;
22
+ }
23
+
24
+ const value = resolveBlockValue<Value>(Astro.props);
25
+ const { spriteBase = '/vitops' } = Astro.props as Props;
26
+ const kind = text(value.variant) === 'tag' ? 'tag' : 'badge';
27
+ const items = rows<Record<string, unknown>>(value.items)
28
+ .map((row) => {
29
+ const own = text(row.tone) || text(row.color);
30
+ const tone = own ? resolveTone(row.tone, row.color) : resolveTone(value.tone, value.color);
31
+ return {
32
+ label: text(row.label),
33
+ icon: /^[a-z0-9-]+$/.test(text(row.icon)) ? text(row.icon) : '',
34
+ href: safeHref(row.href),
35
+ tone,
36
+ };
37
+ })
38
+ .filter((item) => item.label);
39
+ ---
40
+
41
+ {
42
+ items.length > 0 && (
43
+ <ul {...rootAttrs(value, ['cluster'])} role="list">
44
+ {items.map((item) => {
45
+ const Tag = item.href ? 'a' : 'span';
46
+ return (
47
+ <li>
48
+ <Tag
49
+ class:list={[kind, item.tone.style && item.tone.className, item.href && 'link']}
50
+ href={item.href || undefined}
51
+ style={item.tone.style || undefined}
52
+ >
53
+ {item.icon && (
54
+ <span class="icon" aria-hidden="true">
55
+ <svg>
56
+ <use href={`${spriteBase}/icons.svg#icon-${item.icon}`} />
57
+ </svg>
58
+ </span>
59
+ )}
60
+ {item.label}
61
+ </Tag>
62
+ </li>
63
+ );
64
+ })}
65
+ </ul>
66
+ )
67
+ }
@@ -10,10 +10,11 @@ interface Value {
10
10
  const value = resolveBlockValue<Value>(Astro.props);
11
11
  const { message, tone = 'info', dismissible = true } = value;
12
12
  const bannerClass = `banner bg-${tone}${dismissible ? ' banner--dismissible' : ''}`;
13
+ import { rootAttrs } from './attrs.ts';
13
14
  ---
14
15
 
15
16
  {message && dismissible && (
16
- <wc-dismissable>
17
+ <wc-dismissable {...rootAttrs(value)}>
17
18
  <div class={bannerClass}>
18
19
  <div class="banner__content">{message}</div>
19
20
  <button class="banner__close" data-dismiss aria-label="Dismiss">✕</button>
@@ -21,7 +22,7 @@ const bannerClass = `banner bg-${tone}${dismissible ? ' banner--dismissible' : '
21
22
  </wc-dismissable>
22
23
  )}
23
24
  {message && !dismissible && (
24
- <div class={bannerClass}>
25
+ <div {...rootAttrs(value, [bannerClass])}>
25
26
  <div class="banner__content">{message}</div>
26
27
  </div>
27
28
  )}
@@ -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.
@@ -33,18 +33,13 @@ const secondaryHref = safeHref(value.secondaryHref);
33
33
  const hasActions = Boolean((primaryLabel && primaryHref) || (secondaryLabel && secondaryHref));
34
34
  const Heading = headingTag(value.level, 2);
35
35
  const tone = resolveTone(value.tone, value.color);
36
+ import { rootAttrs } from './attrs.ts';
36
37
  ---
37
38
 
38
39
  {
39
40
  (heading || body) && (
40
41
  <div
41
- class:list={[
42
- 'callout',
43
- tone.className,
44
- value.solid && 'tone--solid',
45
- value.align === 'center' && 'callout--center',
46
- ]}
47
- style={joinStyle(tone.style)}
42
+ {...rootAttrs(value, ['callout', tone.className, value.solid && 'tone--solid', value.align === 'center' && 'callout--center'], joinStyle(tone.style))}
48
43
  >
49
44
  <div class="callout__body">
50
45
  {heading && <Heading class="callout__title font-title">{heading}</Heading>}
@@ -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)));
@@ -50,18 +51,19 @@ const panel = value.variant === 'panel';
50
51
  const Heading = headingTag(value.level, 3);
51
52
  const tone = resolveTone(value.tone, value.color);
52
53
  const Grid = panel ? Subgrid : Cards;
54
+ import { rootAttrs } from './attrs.ts';
53
55
  ---
54
56
 
55
57
  {
56
58
  cards.length > 0 && (
57
- <Grid style={`--subgrid-cols: ${columns}`}>
59
+ <Grid {...rootAttrs(value, [], `--subgrid-cols: ${columns}`)}>
58
60
  {cards.map((card) => (
59
61
  <li
60
62
  class:list={['card', 'subgrid-card', panel && tone.className]}
61
- style={panel ? joinStyle(tone.style) : undefined}
63
+ style={panel ? joinStyle(tone.style, card.style) : card.style}
62
64
  >
63
65
  {card.image && <img src={card.image} alt={card.imageAlt} loading="lazy" />}
64
- {card.title && <Heading class="font-heading">{card.title}</Heading>}
66
+ {card.title && <Heading class="card__title font-heading">{card.title}</Heading>}
65
67
  {card.body && (
66
68
  <p>
67
69
  <RichText text={card.body} />
@@ -39,6 +39,7 @@ const slides = fromRepeater.length > 0 ? fromRepeater : fromLegacy;
39
39
  // Two blocks with the same label would derive the same id; `_key` is unique per block.
40
40
  const id = value._key ? `carousel-${value._key.replace(/[^a-z0-9_-]/gi, '')}` : undefined;
41
41
  const autoplay = Number(value.autoplay) || 0;
42
+ import { rootAttrs } from './attrs.ts';
42
43
  ---
43
44
 
44
45
  {
@@ -50,6 +51,7 @@ const autoplay = Number(value.autoplay) || 0;
50
51
  aspect={value.aspect || '16 / 9'}
51
52
  slides={slides}
52
53
  id={id}
54
+ {...rootAttrs(value)}
53
55
  />
54
56
  )
55
57
  }
@@ -9,14 +9,15 @@ interface Value {
9
9
 
10
10
  const value = resolveBlockValue<Value>(Astro.props);
11
11
  const { text, label = 'Copy', display = 'code-block' } = value;
12
+ import { rootAttrs } from './attrs.ts';
12
13
  ---
13
14
 
14
15
  {text && display === 'code-block' && (
15
- <div class="copy">
16
+ <div {...rootAttrs(value, ['copy'])}>
16
17
  <pre><code>{text}</code></pre>
17
18
  <wc-copy value={text} label={label}>{label}</wc-copy>
18
19
  </div>
19
20
  )}
20
21
  {text && display === 'inline' && (
21
- <wc-copy value={text} label={label}><code>{text}</code></wc-copy>
22
+ <wc-copy {...rootAttrs(value)} value={text} label={label}><code>{text}</code></wc-copy>
22
23
  )}
@@ -9,10 +9,11 @@ interface Value {
9
9
 
10
10
  const value = resolveBlockValue<Value>(Astro.props);
11
11
  const { summary, body, open = false } = value;
12
+ import { rootAttrs } from './attrs.ts';
12
13
  ---
13
14
 
14
15
  {summary && (
15
- <details open={open}>
16
+ <details open={open} {...rootAttrs(value)}>
16
17
  <summary>{summary}</summary>
17
18
  <p>{body}</p>
18
19
  </details>
@@ -33,11 +33,12 @@ const embed = resolveEmbed(value.provider, value.url);
33
33
  const category = CATEGORIES.has(text(value.category)) ? text(value.category) : 'marketing';
34
34
  // A ratio lands in a style attribute: digits, slash and spaces only.
35
35
  const ratio = /^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(text(value.ratio)) ? text(value.ratio) : null;
36
+ import { rootAttrs } from './attrs.ts';
36
37
  ---
37
38
 
38
39
  {
39
40
  title && embed && (
40
- <div class="embed" style={ratio ? `--embed-ratio: ${ratio}` : undefined}>
41
+ <div {...rootAttrs(value, ['embed'], ratio ? `--embed-ratio: ${ratio}` : undefined)}>
41
42
  <iframe
42
43
  class="embed__frame"
43
44
  title={title}
@@ -25,6 +25,7 @@ const level = Number(headingTag(value.level, 3).slice(1)) as 2 | 3 | 4 | 5 | 6;
25
25
  const breakpoint = /^\d+(\.\d+)?(rem|em|px|ch)$/.test(text(value.breakpoint))
26
26
  ? text(value.breakpoint)
27
27
  : undefined;
28
+ import { rootAttrs } from './attrs.ts';
28
29
  ---
29
30
 
30
- {entries.length > 0 && <Entries entries={entries} level={level} breakpoint={breakpoint} />}
31
+ {entries.length > 0 && <Entries entries={entries} level={level} breakpoint={breakpoint} {...rootAttrs(value)} />}
@@ -64,11 +64,12 @@ const tone = resolveTone(value.tone, value.color);
64
64
  const successTone = resolveTone('success', '');
65
65
  const errorTone = resolveTone('danger', '');
66
66
  const hasForm = fields.length > 0;
67
+ import { rootAttrs } from './attrs.ts';
67
68
  ---
68
69
 
69
70
  {
70
71
  hasForm && (
71
- <div class:list={['form-block', 'rhythm', tone.className]} style={joinStyle(tone.style)}>
72
+ <div {...rootAttrs(value, ['form-block', 'rhythm', tone.className], joinStyle(tone.style))}>
72
73
  {heading && <Heading class="font-heading">{heading}</Heading>}
73
74
  {intro && <p>{intro}</p>}
74
75
 
@@ -122,6 +123,24 @@ const hasForm = fields.length > 0;
122
123
  </p>
123
124
  )}
124
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>
125
144
  ) : (
126
145
  <div class="form-group">
127
146
  <label class="form-group__label" for={f.id}>
@@ -25,6 +25,7 @@ const images = rows<{ src?: unknown; alt?: unknown; caption?: unknown }>(value.i
25
25
  return src ? [{ src, alt: text(row.alt), ...(caption ? { caption } : {}) }] : [];
26
26
  },
27
27
  );
28
+ import { rootAttrs } from './attrs.ts';
28
29
  ---
29
30
 
30
31
  {
@@ -33,6 +34,7 @@ const images = rows<{ src?: unknown; alt?: unknown; caption?: unknown }>(value.i
33
34
  label={text(value.label) || 'Gallery'}
34
35
  images={images}
35
36
  id={id}
37
+ {...rootAttrs(value)}
36
38
  aspect={text(value.aspect) || undefined}
37
39
  loop={Boolean(value.loop)}
38
40
  />
@@ -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,13 +57,18 @@ 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);
66
+ import { rootAttrs } from './attrs.ts';
60
67
  ---
61
68
 
62
69
  {
63
70
  (heading || body) && (
64
- <section class={tone.style ? tone.className : undefined} style={joinStyle(tone.style)}>
71
+ <section {...rootAttrs(value, [tone.style && tone.className], joinStyle(tone.style))}>
65
72
  <div class={split ? 'split split-1-2' : 'centered'}>
66
73
  <div class="rhythm">
67
74
  {eyebrow && <p class="font-eyebrow">{eyebrow}</p>}
@@ -93,18 +100,27 @@ const cards = rows<Record<string, unknown>>(value.cards)
93
100
  {cards.length > 0 && (
94
101
  <ul class="subgrid centered" role="list" style={`--subgrid-cols: ${Math.min(4, cards.length)}`}>
95
102
  {cards.map((card) => (
96
- <li class="card subgrid-card">
97
- {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>}
98
106
  {card.body && (
99
107
  <p>
100
108
  <RichText text={card.body} />
101
109
  </p>
102
110
  )}
103
- {card.href && (
104
- <a class="link stretched-link" href={card.href}>
105
- {card.linkLabel}
106
- {card.title && <span class="sr-only">: {card.title}</span>}
107
- </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
+ )
108
124
  )}
109
125
  </li>
110
126
  ))}
@@ -15,10 +15,11 @@ const { beforeAlt = '', beforeLabel = 'Before', afterAlt = '', afterLabel = 'Aft
15
15
  // A `javascript:`/`data:` src is dropped, so the block renders nothing rather than that image.
16
16
  const before = safeHref(value.before);
17
17
  const after = safeHref(value.after);
18
+ import { rootAttrs } from './attrs.ts';
18
19
  ---
19
20
 
20
21
  {before && after && (
21
- <wc-image-compare before-label={beforeLabel} after-label={afterLabel}>
22
+ <wc-image-compare {...rootAttrs(value)} before-label={beforeLabel} after-label={afterLabel}>
22
23
  <img slot="before" src={before} alt={beforeAlt} loading="lazy" />
23
24
  <img slot="after" src={after} alt={afterAlt} loading="lazy" />
24
25
  </wc-image-compare>
@@ -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,26 +21,28 @@ 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)];
26
+ import { rootAttrs } from './attrs.ts';
25
27
  ---
26
28
 
27
29
  {
28
30
  logos.length > 0 && (
29
31
  <ul
30
- class:list={['logos', value.grayscale && 'logos--grayscale']}
32
+ {...rootAttrs(value, ['logos', value.grayscale && 'logos--grayscale'], height ? `--logos-height: ${height}` : undefined)}
31
33
  role="list"
32
34
  aria-label={label}
33
- style={height ? `--logos-height: ${height}` : undefined}
34
35
  >
35
36
  {logos.map((logo) => (
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
  ))}
@@ -21,11 +21,12 @@ const items = rows<Record<string, unknown>>(value.items)
21
21
  alt: text(row.alt),
22
22
  }))
23
23
  .filter((item) => item.text || item.image);
24
+ import { rootAttrs } from './attrs.ts';
24
25
  ---
25
26
 
26
27
  {
27
28
  items.length > 0 && (
28
- <Marquee pauseOnHover={Boolean(value.pauseOnHover)} reverse={Boolean(value.reverse)}>
29
+ <Marquee pauseOnHover={Boolean(value.pauseOnHover)} reverse={Boolean(value.reverse)} {...rootAttrs(value)}>
29
30
  {items.map((item) =>
30
31
  item.image ? (
31
32
  <img src={item.image} alt={item.alt} loading="lazy" />
@@ -60,13 +60,13 @@ const tone = resolveTone(value.tone, value.color);
60
60
  const toned = Boolean(value.tone || value.color);
61
61
  const trimmed = trimDepth(items, depth);
62
62
  const current = Astro.url.pathname;
63
+ import { rootAttrs } from './attrs.ts';
63
64
  ---
64
65
 
65
66
  {
66
67
  items.length > 0 && (
67
68
  <div
68
- class:list={['navblock', toned && tone.className]}
69
- style={toned ? joinStyle(tone.style) : undefined}
69
+ {...rootAttrs(value, ['navblock', toned && tone.className], toned ? joinStyle(tone.style) : undefined)}
70
70
  >
71
71
  {heading && <Heading class="font-heading">{heading}</Heading>}
72
72
  {variant === 'sitenav' ? (
@@ -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).