@getvitops/emdash 1.0.0 → 2.0.0-alpha.0

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 (37) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/index.d.mts +33 -1
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1308 -184
  5. package/dist/index.mjs.map +1 -1
  6. package/package.json +10 -7
  7. package/src/admin/DesignSystemEditorPage.tsx +15 -42
  8. package/src/astro/blocks/Callout.astro +73 -0
  9. package/src/astro/blocks/Cards.astro +53 -9
  10. package/src/astro/blocks/Embed.astro +67 -0
  11. package/src/astro/blocks/Entries.astro +30 -0
  12. package/src/astro/blocks/Form.astro +194 -0
  13. package/src/astro/blocks/Hero.astro +77 -23
  14. package/src/astro/blocks/Logos.astro +48 -0
  15. package/src/astro/blocks/Nav.astro +85 -0
  16. package/src/astro/blocks/NavTree.astro +48 -0
  17. package/src/astro/blocks/Pricing.astro +66 -0
  18. package/src/astro/blocks/Reviews.astro +122 -0
  19. package/src/astro/blocks/RichText.astro +14 -0
  20. package/src/astro/blocks/Section.astro +84 -0
  21. package/src/astro/blocks/Stats.astro +59 -0
  22. package/src/astro/blocks/Steps.astro +48 -0
  23. package/src/astro/blocks/embed.test.ts +43 -0
  24. package/src/astro/blocks/embed.ts +84 -0
  25. package/src/astro/blocks/form.sample.ts +33 -0
  26. package/src/astro/blocks/form.test.ts +55 -0
  27. package/src/astro/blocks/form.ts +158 -0
  28. package/src/astro/blocks/head.stub.ts +2 -0
  29. package/src/astro/blocks/nav.test.ts +51 -0
  30. package/src/astro/blocks/nav.ts +65 -0
  31. package/src/astro/blocks/render.container.test.ts +570 -0
  32. package/src/astro/blocks/tone.test.ts +53 -0
  33. package/src/astro/blocks/tone.ts +122 -0
  34. package/src/astro/blocks/value.test.ts +36 -1
  35. package/src/astro/blocks/value.ts +78 -1
  36. package/src/astro/index.ts +30 -0
  37. package/src/admin/core-editor.d.ts +0 -7
@@ -5,15 +5,30 @@
5
5
  * as a whole while its text stays selectable; without JS the stretched link does the
6
6
  * same job. The card CSS is the framework's `.card` pattern.
7
7
  *
8
- * The title is a styled paragraph rather than a fixed heading element: the block can
9
- * sit at any depth in a document, and a hard-coded `<h3>` would skip or repeat levels.
8
+ * **The title is a real heading** at the editor's `level` (default `h3`), in the `heading`
9
+ * type role. It used to be `<p class="font-heading">`: the wrong size for a card in most
10
+ * type scales AND no heading in the outline (UPSTREAM 19). The level is a field because the
11
+ * block can sit at any depth, and a hard-coded one would skip or repeat levels.
12
+ *
13
+ * `variant: panel` is the tinted service-panel card — a tone, a checklist and a button.
14
+ * A panel's whole-card link is dropped (it holds its own button), so it renders as
15
+ * `<Subgrid>` markup rather than a `<Cards>` that would try to forward clicks.
16
+ *
17
+ * The body may carry `[text](url)` inline links; see `linkRuns()`.
10
18
  */
11
19
  import Cards from '@getvitops/astro/components/Cards.astro';
12
- import { resolveBlockValue, rows, safeHref, text } from './value.ts';
20
+ import Subgrid from '@getvitops/astro/components/Subgrid.astro';
21
+ import RichText from './RichText.astro';
22
+ import { joinStyle, resolveTone } from './tone.ts';
23
+ import { headingTag, lines, resolveBlockValue, rows, safeHref, text } from './value.ts';
13
24
 
14
25
  interface Value {
15
26
  cards?: unknown;
16
27
  columns?: string | number;
28
+ variant?: string;
29
+ level?: string | number;
30
+ tone?: string;
31
+ color?: string;
17
32
  }
18
33
 
19
34
  const value = resolveBlockValue<Value>(Astro.props);
@@ -25,20 +40,49 @@ const cards = rows<Record<string, unknown>>(value.cards)
25
40
  imageAlt: text(row.imageAlt),
26
41
  href: safeHref(row.href),
27
42
  linkLabel: text(row.linkLabel) || 'Read more',
43
+ checklist: lines(row.checklist),
44
+ ctaLabel: text(row.ctaLabel),
45
+ ctaHref: safeHref(row.ctaHref),
28
46
  }))
29
47
  .filter((card) => card.title || card.body || card.image);
30
48
  const columns = Math.min(4, Math.max(1, Math.round(Number(value.columns) || 3)));
49
+ const panel = value.variant === 'panel';
50
+ const Heading = headingTag(value.level, 3);
51
+ const tone = resolveTone(value.tone, value.color);
52
+ const Grid = panel ? Subgrid : Cards;
31
53
  ---
32
54
 
33
55
  {
34
56
  cards.length > 0 && (
35
- <Cards style={`--subgrid-cols: ${columns}`}>
57
+ <Grid style={`--subgrid-cols: ${columns}`}>
36
58
  {cards.map((card) => (
37
- <li class="card subgrid-card">
59
+ <li
60
+ class:list={['card', 'subgrid-card', panel && tone.className]}
61
+ style={panel ? joinStyle(tone.style) : undefined}
62
+ >
38
63
  {card.image && <img src={card.image} alt={card.imageAlt} loading="lazy" />}
39
- {card.title && <p class="font-heading">{card.title}</p>}
40
- {card.body && <p>{card.body}</p>}
41
- {card.href && (
64
+ {card.title && <Heading class="font-heading">{card.title}</Heading>}
65
+ {card.body && (
66
+ <p>
67
+ <RichText text={card.body} />
68
+ </p>
69
+ )}
70
+ {panel && card.checklist.length > 0 && (
71
+ <ul class="rhythm" role="list">
72
+ {card.checklist.map((item) => (
73
+ <li>{item}</li>
74
+ ))}
75
+ </ul>
76
+ )}
77
+ {panel && card.ctaLabel && card.ctaHref && (
78
+ <p class="subgrid-card__footer">
79
+ <a class="cta" href={card.ctaHref}>
80
+ {card.ctaLabel}
81
+ <span class="sr-only">: {card.title}</span>
82
+ </a>
83
+ </p>
84
+ )}
85
+ {!panel && card.href && (
42
86
  <a class="link stretched-link" href={card.href}>
43
87
  {card.linkLabel}
44
88
  {/* "Read more" alone is meaningless in a list of links; name the card for AT. */}
@@ -47,6 +91,6 @@ const columns = Math.min(4, Math.max(1, Math.round(Number(value.columns) || 3)))
47
91
  )}
48
92
  </li>
49
93
  ))}
50
- </Cards>
94
+ </Grid>
51
95
  )
52
96
  }
@@ -0,0 +1,67 @@
1
+ ---
2
+ /**
3
+ * A video or map that loads only after the visitor allows it.
4
+ *
5
+ * The consent gate's rule (AGENTS.md, "The consent gate"): never give a gated element a
6
+ * live address. The iframe is emitted with NO `src`; the address rides on
7
+ * `data-consent-src`, which the consent runtime (`@getvitops/core/consent`) assigns once the
8
+ * category is granted — and, because the element reaches its loading strategy, raises the
9
+ * banner for that category rather than waiting for something else to ask. Until then the
10
+ * page issues no request to the provider. With no JS the gate never runs, so the
11
+ * placeholder's plain link to the same content is what the visitor gets.
12
+ *
13
+ * A `title` is required (WCAG 4.1.2 names the frame); without one, or with an address the
14
+ * provider rules in `embed.ts` refuse, the block renders nothing rather than an unnamed or
15
+ * unsafe frame. Only categories the consent store defines are accepted.
16
+ */
17
+ import { resolveEmbed } from './embed.ts';
18
+ import { resolveBlockValue, text } from './value.ts';
19
+
20
+ interface Value {
21
+ provider?: string;
22
+ url?: string;
23
+ title?: string;
24
+ category?: string;
25
+ ratio?: string;
26
+ }
27
+
28
+ const CATEGORIES = new Set(['marketing', 'preferences', 'analytics']);
29
+
30
+ const value = resolveBlockValue<Value>(Astro.props);
31
+ const title = text(value.title);
32
+ const embed = resolveEmbed(value.provider, value.url);
33
+ const category = CATEGORIES.has(text(value.category)) ? text(value.category) : 'marketing';
34
+ // A ratio lands in a style attribute: digits, slash and spaces only.
35
+ const ratio = /^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(text(value.ratio)) ? text(value.ratio) : null;
36
+ ---
37
+
38
+ {
39
+ title && embed && (
40
+ <div class="embed" style={ratio ? `--embed-ratio: ${ratio}` : undefined}>
41
+ <iframe
42
+ class="embed__frame"
43
+ title={title}
44
+ data-consent={category}
45
+ data-consent-src={embed.src}
46
+ loading="lazy"
47
+ referrerpolicy="strict-origin-when-cross-origin"
48
+ allow="fullscreen; picture-in-picture"
49
+ />
50
+ <div class="embed__placeholder">
51
+ <p class="font-heading">{title}</p>
52
+ <p>
53
+ This content is provided by {embed.provider}. It loads once you allow {category} cookies.
54
+ </p>
55
+ <p class="cluster">
56
+ <button type="button" class="btn" data-consent-open>
57
+ Cookie settings
58
+ </button>
59
+ <a class="link" href={embed.openUrl} rel="noopener noreferrer">
60
+ Open on {embed.provider}
61
+ <span class="sr-only">: {title}</span>
62
+ </a>
63
+ </p>
64
+ </div>
65
+ </div>
66
+ )
67
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ /**
3
+ * Thin adapter over `@getvitops/astro`'s `<Entries />`, which emits `<wc-entries>` itself —
4
+ * so this must not add its own wrapper. Stacked heading + `<dl>` pairs with no JS, a table
5
+ * with it. The editor writes each row's details as `Term | Value` lines.
6
+ */
7
+ import Entries from '@getvitops/astro/components/Entries.astro';
8
+ import { headingTag, pairs, resolveBlockValue, rows, text } from './value.ts';
9
+
10
+ interface Value {
11
+ entries?: unknown;
12
+ level?: string | number;
13
+ breakpoint?: string;
14
+ }
15
+
16
+ const value = resolveBlockValue<Value>(Astro.props);
17
+ const entries = rows<Record<string, unknown>>(value.entries)
18
+ .map((row) => ({
19
+ heading: text(row.heading),
20
+ pairs: pairs(row.pairs).filter((pair) => pair.term),
21
+ }))
22
+ .filter((entry) => entry.heading && entry.pairs.length > 0);
23
+ const level = Number(headingTag(value.level, 3).slice(1)) as 2 | 3 | 4 | 5 | 6;
24
+ // A breakpoint lands in an attribute the element parses as a CSS length; keep it to one.
25
+ const breakpoint = /^\d+(\.\d+)?(rem|em|px|ch)$/.test(text(value.breakpoint))
26
+ ? text(value.breakpoint)
27
+ : undefined;
28
+ ---
29
+
30
+ {entries.length > 0 && <Entries entries={entries} level={level} breakpoint={breakpoint} />}
@@ -0,0 +1,194 @@
1
+ ---
2
+ /**
3
+ * A contact / enquiry form that posts to the site's conversion endpoint.
4
+ *
5
+ * Tier 1 first: a real `<form method="post">` of real `<label>`s, styled by the framework's
6
+ * `forms.css` vocabulary, that works with no JavaScript. It posts to `createConversionRoute()`
7
+ * (default `/api/track`, which the emdash template ships), which applies the honeypot, origin,
8
+ * size and rate checks, verifies Turnstile, then writes the CRM and emails the notification.
9
+ * The block itself decides none of that: it only emits markup the route understands.
10
+ *
11
+ * The no-JS round trip. The route answers a form post with a 303 back to the `_next` hidden
12
+ * field (this page's path, a same-site path only) plus `?sent=1#form-sent` or
13
+ * `?error=1#form-error`. The page is prerendered and cannot know which happened, so both
14
+ * messages are always in the markup and `.form-notice--target:not(:target)` hides whichever the
15
+ * URL fragment does not name. They carry `tabindex="-1"` so following the fragment moves focus
16
+ * to the message. One form per page: the fragment ids are fixed.
17
+ *
18
+ * Spam protection, in layers: a honeypot field named per the route's default, the route's own
19
+ * origin/rate/size checks, and Cloudflare Turnstile when `site.security.turnstile.siteKey`
20
+ * exists (the siteKey comes from the build's `virtual:getvitops/head`). Turnstile is emitted as
21
+ * `<wc-turnstile>`, which loads Cloudflare's script only on first interaction with the form.
22
+ * Its slotted paragraph is the no-JS explanation. With no site key there is no widget and no
23
+ * third-party reference at all.
24
+ *
25
+ * Field names go through `form.ts`, which keeps them clear of the names the route reserves
26
+ * (`phone`, `event`, the honeypots), and a phone field submits as `tel`.
27
+ */
28
+ import head from 'virtual:getvitops/head';
29
+ import { resolveTone, joinStyle } from './tone.ts';
30
+ import { HONEYPOT_NAME, resolveFields, safeAction, safeNext } from './form.ts';
31
+ import { headingTag, resolveBlockValue, rows, text } from './value.ts';
32
+
33
+ interface Value {
34
+ heading?: string;
35
+ level?: string | number;
36
+ intro?: string;
37
+ fields?: unknown;
38
+ submitLabel?: string;
39
+ successMessage?: string;
40
+ errorMessage?: string;
41
+ noJsMessage?: string;
42
+ action?: string;
43
+ tone?: string;
44
+ color?: string;
45
+ }
46
+
47
+ const value = resolveBlockValue<Value>(Astro.props);
48
+ const fields = resolveFields(rows(value.fields));
49
+ const heading = text(value.heading);
50
+ const intro = text(value.intro);
51
+ const Heading = headingTag(value.level, 2);
52
+ const submitLabel = text(value.submitLabel) || 'Send';
53
+ const successMessage = text(value.successMessage) || 'Thanks. Your message has been sent.';
54
+ const errorMessage =
55
+ text(value.errorMessage) ||
56
+ 'Sorry, your message could not be sent. Please try again, or contact us another way.';
57
+ const noJsMessage =
58
+ text(value.noJsMessage) ||
59
+ 'The spam check on this form needs JavaScript. If it is turned off, please contact us by email or phone instead.';
60
+ const action = safeAction(value.action);
61
+ const next = safeNext(Astro.url.pathname) ?? '/';
62
+ const siteKey = typeof head?.turnstileSiteKey === 'string' ? head.turnstileSiteKey : null;
63
+ const tone = resolveTone(value.tone, value.color);
64
+ const successTone = resolveTone('success', '');
65
+ const errorTone = resolveTone('danger', '');
66
+ const hasForm = fields.length > 0;
67
+ ---
68
+
69
+ {
70
+ hasForm && (
71
+ <div class:list={['form-block', 'rhythm', tone.className]} style={joinStyle(tone.style)}>
72
+ {heading && <Heading class="font-heading">{heading}</Heading>}
73
+ {intro && <p>{intro}</p>}
74
+
75
+ <div
76
+ id="form-sent"
77
+ class:list={['callout', 'form-notice--target', successTone.className]}
78
+ style={joinStyle(successTone.style)}
79
+ role="status"
80
+ tabindex="-1"
81
+ >
82
+ <div class="callout__body">
83
+ <p class="callout__text">{successMessage}</p>
84
+ </div>
85
+ </div>
86
+ <div
87
+ id="form-error"
88
+ class:list={['callout', 'form-notice--target', errorTone.className]}
89
+ style={joinStyle(errorTone.style)}
90
+ role="alert"
91
+ tabindex="-1"
92
+ >
93
+ <div class="callout__body">
94
+ <p class="callout__text">{errorMessage}</p>
95
+ </div>
96
+ </div>
97
+
98
+ <form method="post" action={action} class="rhythm">
99
+ <input type="hidden" name="_next" value={next} />
100
+ {fields.map((f) => {
101
+ const describedby = f.help ? `${f.id}-help` : undefined;
102
+ const requiredMark = f.required ? { required: true, 'aria-required': 'true' } : {};
103
+ return f.type === 'checkbox' ? (
104
+ <div class="form-group">
105
+ <label class="custom-check">
106
+ <input
107
+ type="checkbox"
108
+ id={f.id}
109
+ name={f.name}
110
+ value="yes"
111
+ aria-describedby={describedby}
112
+ {...requiredMark}
113
+ />
114
+ <span class="custom-check__box">
115
+ <span class="custom-check__indicator custom-check__indicator--check" />
116
+ </span>
117
+ <span>{f.label}</span>
118
+ </label>
119
+ {f.help && (
120
+ <p id={`${f.id}-help`} class="form-group__helper">
121
+ {f.help}
122
+ </p>
123
+ )}
124
+ </div>
125
+ ) : (
126
+ <div class="form-group">
127
+ <label class="form-group__label" for={f.id}>
128
+ {f.label}
129
+ {!f.required && <span class="form-group__optional">(optional)</span>}
130
+ </label>
131
+ {f.type === 'textarea' ? (
132
+ <textarea
133
+ id={f.id}
134
+ name={f.name}
135
+ rows="5"
136
+ placeholder={f.placeholder || undefined}
137
+ aria-describedby={describedby}
138
+ {...requiredMark}
139
+ />
140
+ ) : f.type === 'select' ? (
141
+ <div class="custom-select">
142
+ <select id={f.id} name={f.name} aria-describedby={describedby} {...requiredMark}>
143
+ <option value="">Choose one</option>
144
+ {f.options.map((o) => (
145
+ <option value={o}>{o}</option>
146
+ ))}
147
+ </select>
148
+ </div>
149
+ ) : (
150
+ <input
151
+ type={f.type}
152
+ id={f.id}
153
+ name={f.name}
154
+ placeholder={f.placeholder || undefined}
155
+ autocomplete={f.autocomplete}
156
+ aria-describedby={describedby}
157
+ {...requiredMark}
158
+ />
159
+ )}
160
+ {f.help && (
161
+ <p id={`${f.id}-help`} class="form-group__helper">
162
+ {f.help}
163
+ </p>
164
+ )}
165
+ </div>
166
+ );
167
+ })}
168
+
169
+ {/* Honeypot: named per createConversionRoute's default list. Positioned off-screen rather than
170
+ display:none (which simple bots skip), and hidden from assistive tech and the tab order. */}
171
+ <div class="sr-only" aria-hidden="true">
172
+ <label>
173
+ Leave this field empty
174
+ <input type="text" name={HONEYPOT_NAME} tabindex="-1" autocomplete="off" />
175
+ </label>
176
+ </div>
177
+
178
+ {siteKey && (
179
+ <wc-turnstile sitekey={siteKey}>
180
+ <p class="form-group__helper" data-turnstile-fallback>
181
+ {noJsMessage}
182
+ </p>
183
+ </wc-turnstile>
184
+ )}
185
+
186
+ <p>
187
+ <button type="submit" class="cta">
188
+ {submitLabel}
189
+ </button>
190
+ </p>
191
+ </form>
192
+ </div>
193
+ )
194
+ }
@@ -1,15 +1,27 @@
1
1
  ---
2
2
  /**
3
3
  * A page opener built from the layout primitives: `.centered` for a single column, `.split`
4
- * for text beside an image. The heading is a real `<h1>` only when the editor chooses nothing
5
- * else — a page has one, and EmDash renders the page title elsewhere, so this uses `<h2>`.
4
+ * for text beside an image. The heading defaults to `<h2>` — a page has one `<h1>`, and
5
+ * EmDash renders the page title elsewhere — and an editor can change the level. Words wrapped
6
+ * in `*stars*` render as `<em class="accent">` (accent marks, no raw HTML in the field).
7
+ * An optional row of option cards sits under the buttons, as the framework's `.card` list.
6
8
  * Flat fields, because a block cannot contain other blocks.
7
9
  */
8
- import { resolveBlockValue, safeHref, text } from './value.ts';
10
+ import RichText from './RichText.astro';
11
+ import { joinStyle, resolveTone } from './tone.ts';
12
+ import {
13
+ accentRuns,
14
+ headingTag,
15
+ resolveBlockValue,
16
+ rows,
17
+ safeHref,
18
+ text,
19
+ } from './value.ts';
9
20
 
10
21
  interface Value {
11
22
  eyebrow?: string;
12
23
  heading?: string;
24
+ level?: string | number;
13
25
  body?: string;
14
26
  primaryLabel?: string;
15
27
  primaryHref?: string;
@@ -18,11 +30,16 @@ interface Value {
18
30
  image?: string;
19
31
  imageAlt?: string;
20
32
  layout?: string;
33
+ tone?: string;
34
+ color?: string;
35
+ cards?: unknown;
21
36
  }
22
37
 
23
38
  const value = resolveBlockValue<Value>(Astro.props);
24
39
  const eyebrow = text(value.eyebrow);
25
40
  const heading = text(value.heading);
41
+ const headingRuns = accentRuns(heading);
42
+ const Heading = headingTag(value.level, 2);
26
43
  const body = text(value.body);
27
44
  const primaryLabel = text(value.primaryLabel);
28
45
  const primaryHref = safeHref(value.primaryHref);
@@ -31,31 +48,68 @@ const secondaryHref = safeHref(value.secondaryHref);
31
48
  const image = safeHref(value.image);
32
49
  const imageAlt = text(value.imageAlt);
33
50
  const split = value.layout === 'split' && Boolean(image);
51
+ const tone = resolveTone(value.tone, value.color);
52
+ const cards = rows<Record<string, unknown>>(value.cards)
53
+ .map((row) => ({
54
+ title: text(row.title),
55
+ body: text(row.body),
56
+ href: safeHref(row.href),
57
+ linkLabel: text(row.linkLabel) || 'Learn more',
58
+ }))
59
+ .filter((card) => card.title || card.body);
34
60
  ---
35
61
 
36
62
  {
37
63
  (heading || body) && (
38
- <section class={split ? 'split split-1-2' : 'centered'}>
39
- <div class="rhythm">
40
- {eyebrow && <p class="font-eyebrow">{eyebrow}</p>}
41
- {heading && <h2 class="font-display">{heading}</h2>}
42
- {body && <p class="font-lead">{body}</p>}
43
- {(primaryLabel && primaryHref) || (secondaryLabel && secondaryHref) ? (
44
- <p class="cluster">
45
- {primaryLabel && primaryHref && (
46
- <a class="cta" href={primaryHref}>
47
- {primaryLabel}
48
- </a>
49
- )}
50
- {secondaryLabel && secondaryHref && (
51
- <a class="link" href={secondaryHref}>
52
- {secondaryLabel}
53
- </a>
54
- )}
55
- </p>
56
- ) : null}
64
+ <section class={tone.style ? tone.className : undefined} style={joinStyle(tone.style)}>
65
+ <div class={split ? 'split split-1-2' : 'centered'}>
66
+ <div class="rhythm">
67
+ {eyebrow && <p class="font-eyebrow">{eyebrow}</p>}
68
+ {heading && (
69
+ <Heading class="font-display">
70
+ {headingRuns.map((run) =>
71
+ run.accent ? <em class="accent">{run.text}</em> : run.text,
72
+ )}
73
+ </Heading>
74
+ )}
75
+ {body && <p class="font-lead">{body}</p>}
76
+ {(primaryLabel && primaryHref) || (secondaryLabel && secondaryHref) ? (
77
+ <p class="cluster">
78
+ {primaryLabel && primaryHref && (
79
+ <a class="cta" href={primaryHref}>
80
+ {primaryLabel}
81
+ </a>
82
+ )}
83
+ {secondaryLabel && secondaryHref && (
84
+ <a class="link" href={secondaryHref}>
85
+ {secondaryLabel}
86
+ </a>
87
+ )}
88
+ </p>
89
+ ) : null}
90
+ </div>
91
+ {split && <img src={image} alt={imageAlt} loading="eager" />}
57
92
  </div>
58
- {split && <img src={image} alt={imageAlt} loading="eager" />}
93
+ {cards.length > 0 && (
94
+ <ul class="subgrid centered" role="list" style={`--subgrid-cols: ${Math.min(4, cards.length)}`}>
95
+ {cards.map((card) => (
96
+ <li class="card subgrid-card">
97
+ {card.title && <h3 class="font-heading">{card.title}</h3>}
98
+ {card.body && (
99
+ <p>
100
+ <RichText text={card.body} />
101
+ </p>
102
+ )}
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>
108
+ )}
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ )}
59
113
  </section>
60
114
  )
61
115
  }
@@ -0,0 +1,48 @@
1
+ ---
2
+ /**
3
+ * A still row of partner/client marks on the framework's `.logos` pattern. `vitops.marquee`
4
+ * is the scrolling version; this one holds still. A list, so the count is announced, and
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.
7
+ */
8
+ import { resolveBlockValue, rows, safeHref, text } from './value.ts';
9
+
10
+ interface Value {
11
+ label?: string;
12
+ logos?: unknown;
13
+ grayscale?: boolean;
14
+ size?: string;
15
+ }
16
+
17
+ const HEIGHTS: Record<string, string> = { sm: '1.75rem', md: '2.5rem', lg: '3.5rem' };
18
+
19
+ const value = resolveBlockValue<Value>(Astro.props);
20
+ const label = text(value.label) || 'Partners';
21
+ const logos = rows<Record<string, unknown>>(value.logos)
22
+ .map((row) => ({ image: safeHref(row.image), alt: text(row.alt), href: safeHref(row.href) }))
23
+ .filter((logo) => logo.image);
24
+ const height = HEIGHTS[text(value.size)];
25
+ ---
26
+
27
+ {
28
+ logos.length > 0 && (
29
+ <ul
30
+ class:list={['logos', value.grayscale && 'logos--grayscale']}
31
+ role="list"
32
+ aria-label={label}
33
+ style={height ? `--logos-height: ${height}` : undefined}
34
+ >
35
+ {logos.map((logo) => (
36
+ <li class="logos__item">
37
+ {logo.href ? (
38
+ <a href={logo.href}>
39
+ <img src={logo.image} alt={logo.alt} loading="lazy" />
40
+ </a>
41
+ ) : (
42
+ <img src={logo.image} alt={logo.alt} loading="lazy" />
43
+ )}
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ )
48
+ }
@@ -0,0 +1,85 @@
1
+ ---
2
+ /**
3
+ * An EmDash menu inside page content — a site map, a section's link list, a set of footer-style
4
+ * columns. **Not the site header:** that is `<SiteNav>` in the layout, which owns the drawer,
5
+ * the dropdowns and the breakpoints. The `sitenav` variant exists for the rare page that wants
6
+ * that navbar in the body, and renders `<SiteNav>` itself rather than a copy of it.
7
+ *
8
+ * Tier-3, no runtime JS. The menu is fetched with EmDash's `getMenu()` at render time (so at
9
+ * build time on a prerendered page). **A missing menu, an empty one, or a name that is not a
10
+ * slug renders nothing** — never an error; a deleted menu must not take the page down.
11
+ *
12
+ * Accessibility: a `<nav>` landmark with a name that is unique on the page — the header is
13
+ * "Primary" and the footer "Site map" / "Legal", so `navLabel()` never hands this block one of
14
+ * those — and `aria-current="page"` on the current item. An optional visible heading is a real
15
+ * heading at the editor's `level` (default `h2`), kept outside the `<nav>` so the landmark's
16
+ * name is the `aria-label`, not a duplicate of the heading text.
17
+ *
18
+ * The menu-tree helpers (`trimDepth`, `isCurrent`, `linkRel`) are `@getvitops/astro`'s
19
+ * `sitenav.ts`; only the selection rules live in `nav.ts`.
20
+ */
21
+ import { getMenu } from 'emdash';
22
+ import SiteNav from '@getvitops/astro/components/SiteNav.astro';
23
+ import { menuItems, trimDepth } from '@getvitops/astro/components/sitenav.ts';
24
+ import NavTree from './NavTree.astro';
25
+ import { menuName, navDepth, navId, navLabel, navVariant } from './nav.ts';
26
+ import { joinStyle, resolveTone } from './tone.ts';
27
+ import { headingTag, resolveBlockValue, text } from './value.ts';
28
+
29
+ interface Value {
30
+ _key?: string;
31
+ menu?: string;
32
+ label?: string;
33
+ heading?: string;
34
+ level?: string | number;
35
+ variant?: string;
36
+ depth?: string | number;
37
+ tone?: string;
38
+ color?: string;
39
+ }
40
+
41
+ const value = resolveBlockValue<Value>(Astro.props);
42
+ const name = menuName(value.menu);
43
+ let menu: Awaited<ReturnType<typeof getMenu>> = null;
44
+ if (name) {
45
+ try {
46
+ menu = await getMenu(name);
47
+ } catch {
48
+ // No database or request context: the same quiet nothing as a missing menu.
49
+ menu = null;
50
+ }
51
+ }
52
+
53
+ const variant = navVariant(value.variant);
54
+ const depth = navDepth(value.depth);
55
+ const items = menuItems(menu);
56
+ const label = navLabel(value.label, value.heading, menu?.label, name ?? '');
57
+ const heading = text(value.heading);
58
+ const Heading = headingTag(value.level, 2);
59
+ const tone = resolveTone(value.tone, value.color);
60
+ const toned = Boolean(value.tone || value.color);
61
+ const trimmed = trimDepth(items, depth);
62
+ const current = Astro.url.pathname;
63
+ ---
64
+
65
+ {
66
+ items.length > 0 && (
67
+ <div
68
+ class:list={['navblock', toned && tone.className]}
69
+ style={toned ? joinStyle(tone.style) : undefined}
70
+ >
71
+ {heading && <Heading class="font-heading">{heading}</Heading>}
72
+ {variant === 'sitenav' ? (
73
+ <SiteNav primary={trimmed} label={label} depth={depth} id={navId(value._key, name ?? '')} />
74
+ ) : (
75
+ <nav aria-label={label}>
76
+ <NavTree
77
+ items={trimmed}
78
+ current={current}
79
+ modifiers={variant === 'list' ? [] : [`navlist--${variant}`]}
80
+ />
81
+ </nav>
82
+ )}
83
+ </div>
84
+ )
85
+ }