@getvitops/emdash 1.0.0-alpha.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.
- package/CHANGELOG.md +279 -3
- package/README.md +66 -21
- package/dist/index.d.mts +82 -18
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1626 -152
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -13
- package/src/admin/DesignSystemEditorPage.tsx +15 -42
- package/src/astro/blocks/Accordion.astro +39 -0
- package/src/astro/blocks/ActionLink.astro +4 -2
- package/src/astro/blocks/Callout.astro +73 -0
- package/src/astro/blocks/Cards.astro +96 -0
- package/src/astro/blocks/Carousel.astro +25 -8
- package/src/astro/blocks/Embed.astro +67 -0
- package/src/astro/blocks/Entries.astro +30 -0
- package/src/astro/blocks/Form.astro +194 -0
- package/src/astro/blocks/Gallery.astro +40 -0
- package/src/astro/blocks/Hero.astro +115 -0
- package/src/astro/blocks/ImageCompare.astro +5 -2
- package/src/astro/blocks/Logos.astro +48 -0
- package/src/astro/blocks/Marquee.astro +38 -0
- package/src/astro/blocks/Nav.astro +85 -0
- package/src/astro/blocks/NavTree.astro +48 -0
- package/src/astro/blocks/Pricing.astro +66 -0
- package/src/astro/blocks/Reviews.astro +122 -0
- package/src/astro/blocks/RichText.astro +14 -0
- package/src/astro/blocks/Section.astro +84 -0
- package/src/astro/blocks/Stats.astro +59 -0
- package/src/astro/blocks/Steps.astro +48 -0
- package/src/astro/blocks/embed.test.ts +43 -0
- package/src/astro/blocks/embed.ts +84 -0
- package/src/astro/blocks/form.sample.ts +33 -0
- package/src/astro/blocks/form.test.ts +55 -0
- package/src/astro/blocks/form.ts +158 -0
- package/src/astro/blocks/head.stub.ts +2 -0
- package/src/astro/blocks/nav.test.ts +51 -0
- package/src/astro/blocks/nav.ts +65 -0
- package/src/astro/blocks/render.container.test.ts +570 -0
- package/src/astro/blocks/tone.test.ts +53 -0
- package/src/astro/blocks/tone.ts +122 -0
- package/src/astro/blocks/value.test.ts +56 -0
- package/src/astro/blocks/value.ts +103 -1
- package/src/astro/index.ts +40 -0
- package/src/admin/core-editor.d.ts +0 -7
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Thin adapter over `@getvitops/astro`'s `<Cards />`, which emits `<wc-cards>` itself —
|
|
4
|
+
* so this must not add its own wrapper. With JS a card holding a link becomes clickable
|
|
5
|
+
* as a whole while its text stays selectable; without JS the stretched link does the
|
|
6
|
+
* same job. The card CSS is the framework's `.card` pattern.
|
|
7
|
+
*
|
|
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()`.
|
|
18
|
+
*/
|
|
19
|
+
import Cards from '@getvitops/astro/components/Cards.astro';
|
|
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';
|
|
24
|
+
|
|
25
|
+
interface Value {
|
|
26
|
+
cards?: unknown;
|
|
27
|
+
columns?: string | number;
|
|
28
|
+
variant?: string;
|
|
29
|
+
level?: string | number;
|
|
30
|
+
tone?: string;
|
|
31
|
+
color?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const value = resolveBlockValue<Value>(Astro.props);
|
|
35
|
+
const cards = rows<Record<string, unknown>>(value.cards)
|
|
36
|
+
.map((row) => ({
|
|
37
|
+
title: text(row.title),
|
|
38
|
+
body: text(row.body),
|
|
39
|
+
image: safeHref(row.image),
|
|
40
|
+
imageAlt: text(row.imageAlt),
|
|
41
|
+
href: safeHref(row.href),
|
|
42
|
+
linkLabel: text(row.linkLabel) || 'Read more',
|
|
43
|
+
checklist: lines(row.checklist),
|
|
44
|
+
ctaLabel: text(row.ctaLabel),
|
|
45
|
+
ctaHref: safeHref(row.ctaHref),
|
|
46
|
+
}))
|
|
47
|
+
.filter((card) => card.title || card.body || card.image);
|
|
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;
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
cards.length > 0 && (
|
|
57
|
+
<Grid style={`--subgrid-cols: ${columns}`}>
|
|
58
|
+
{cards.map((card) => (
|
|
59
|
+
<li
|
|
60
|
+
class:list={['card', 'subgrid-card', panel && tone.className]}
|
|
61
|
+
style={panel ? joinStyle(tone.style) : undefined}
|
|
62
|
+
>
|
|
63
|
+
{card.image && <img src={card.image} alt={card.imageAlt} loading="lazy" />}
|
|
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 && (
|
|
86
|
+
<a class="link stretched-link" href={card.href}>
|
|
87
|
+
{card.linkLabel}
|
|
88
|
+
{/* "Read more" alone is meaningless in a list of links; name the card for AT. */}
|
|
89
|
+
{card.title && <span class="sr-only">: {card.title}</span>}
|
|
90
|
+
</a>
|
|
91
|
+
)}
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</Grid>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
* Thin adapter over `@getvitops/astro`'s `<Carousel />`.
|
|
4
4
|
*
|
|
5
5
|
* The markup, the image priority matrix and the accessibility wiring all live
|
|
6
|
-
* there — this only maps the block's
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* there — this only maps the block's fields onto its `slides` prop. Emitting the
|
|
7
|
+
* markup here too would be a second copy to keep in step, and the one that drifts
|
|
8
|
+
* is always the one nobody is looking at.
|
|
9
|
+
*
|
|
10
|
+
* `slides` (a repeater) is the editing surface. `images`/`alts` are the newline-
|
|
11
|
+
* separated fields every block saved before the repeater existed carries; they are
|
|
12
|
+
* still read when there are no `slides`, so existing content keeps rendering.
|
|
9
13
|
*/
|
|
10
14
|
import Carousel from '@getvitops/astro/components/Carousel.astro';
|
|
11
|
-
import { resolveBlockValue, splitLines } from './value.ts';
|
|
15
|
+
import { resolveBlockValue, rows, safeHref, splitLines, text } from './value.ts';
|
|
12
16
|
|
|
13
17
|
interface Value {
|
|
18
|
+
_key?: string;
|
|
19
|
+
slides?: unknown;
|
|
14
20
|
images?: string;
|
|
15
21
|
alts?: string;
|
|
16
22
|
label?: string;
|
|
@@ -20,19 +26,30 @@ interface Value {
|
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
const value = resolveBlockValue<Value>(Astro.props);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
29
|
+
const fromRepeater = rows<{ src?: unknown; alt?: unknown }>(value.slides).flatMap((row) => {
|
|
30
|
+
const src = safeHref(row.src);
|
|
31
|
+
return src ? [{ src, alt: text(row.alt) }] : [];
|
|
32
|
+
});
|
|
33
|
+
const legacyAlts = splitLines(value.alts);
|
|
34
|
+
const fromLegacy = splitLines(value.images).flatMap((raw, i) => {
|
|
35
|
+
const src = safeHref(raw);
|
|
36
|
+
return src ? [{ src, alt: legacyAlts[i] ?? '' }] : [];
|
|
37
|
+
});
|
|
38
|
+
const slides = fromRepeater.length > 0 ? fromRepeater : fromLegacy;
|
|
39
|
+
// Two blocks with the same label would derive the same id; `_key` is unique per block.
|
|
40
|
+
const id = value._key ? `carousel-${value._key.replace(/[^a-z0-9_-]/gi, '')}` : undefined;
|
|
25
41
|
const autoplay = Number(value.autoplay) || 0;
|
|
26
42
|
---
|
|
27
43
|
|
|
28
44
|
{
|
|
29
|
-
|
|
45
|
+
slides.length > 0 && (
|
|
30
46
|
<Carousel
|
|
31
47
|
label={value.label || 'Carousel'}
|
|
32
48
|
autoplay={autoplay > 0 ? autoplay : undefined}
|
|
33
49
|
loop={Boolean(value.loop)}
|
|
34
50
|
aspect={value.aspect || '16 / 9'}
|
|
35
|
-
slides={
|
|
51
|
+
slides={slides}
|
|
52
|
+
id={id}
|
|
36
53
|
/>
|
|
37
54
|
)
|
|
38
55
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Thin adapter over `@getvitops/astro`'s `<Gallery />` — a thumbnail grid whose dialog
|
|
4
|
+
* is a carousel. The `<wc-gallery>` tag, the no-JS `:target` dialog and the image
|
|
5
|
+
* pipeline all live there; this only maps the repeater rows onto `images`.
|
|
6
|
+
*/
|
|
7
|
+
import Gallery from '@getvitops/astro/components/Gallery.astro';
|
|
8
|
+
import { resolveBlockValue, rows, safeHref, text } from './value.ts';
|
|
9
|
+
|
|
10
|
+
interface Value {
|
|
11
|
+
_key?: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
images?: unknown;
|
|
14
|
+
aspect?: string;
|
|
15
|
+
loop?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const value = resolveBlockValue<Value>(Astro.props);
|
|
19
|
+
// Two blocks with the same label would derive the same id; `_key` is unique per block.
|
|
20
|
+
const id = value._key ? `gallery-${value._key.replace(/[^a-z0-9_-]/gi, '')}` : undefined;
|
|
21
|
+
const images = rows<{ src?: unknown; alt?: unknown; caption?: unknown }>(value.images).flatMap(
|
|
22
|
+
(row) => {
|
|
23
|
+
const src = safeHref(row.src);
|
|
24
|
+
const caption = text(row.caption);
|
|
25
|
+
return src ? [{ src, alt: text(row.alt), ...(caption ? { caption } : {}) }] : [];
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
images.length > 0 && (
|
|
32
|
+
<Gallery
|
|
33
|
+
label={text(value.label) || 'Gallery'}
|
|
34
|
+
images={images}
|
|
35
|
+
id={id}
|
|
36
|
+
aspect={text(value.aspect) || undefined}
|
|
37
|
+
loop={Boolean(value.loop)}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A page opener built from the layout primitives: `.centered` for a single column, `.split`
|
|
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.
|
|
8
|
+
* Flat fields, because a block cannot contain other blocks.
|
|
9
|
+
*/
|
|
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';
|
|
20
|
+
|
|
21
|
+
interface Value {
|
|
22
|
+
eyebrow?: string;
|
|
23
|
+
heading?: string;
|
|
24
|
+
level?: string | number;
|
|
25
|
+
body?: string;
|
|
26
|
+
primaryLabel?: string;
|
|
27
|
+
primaryHref?: string;
|
|
28
|
+
secondaryLabel?: string;
|
|
29
|
+
secondaryHref?: string;
|
|
30
|
+
image?: string;
|
|
31
|
+
imageAlt?: string;
|
|
32
|
+
layout?: string;
|
|
33
|
+
tone?: string;
|
|
34
|
+
color?: string;
|
|
35
|
+
cards?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const value = resolveBlockValue<Value>(Astro.props);
|
|
39
|
+
const eyebrow = text(value.eyebrow);
|
|
40
|
+
const heading = text(value.heading);
|
|
41
|
+
const headingRuns = accentRuns(heading);
|
|
42
|
+
const Heading = headingTag(value.level, 2);
|
|
43
|
+
const body = text(value.body);
|
|
44
|
+
const primaryLabel = text(value.primaryLabel);
|
|
45
|
+
const primaryHref = safeHref(value.primaryHref);
|
|
46
|
+
const secondaryLabel = text(value.secondaryLabel);
|
|
47
|
+
const secondaryHref = safeHref(value.secondaryHref);
|
|
48
|
+
const image = safeHref(value.image);
|
|
49
|
+
const imageAlt = text(value.imageAlt);
|
|
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);
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
(heading || body) && (
|
|
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" />}
|
|
92
|
+
</div>
|
|
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
|
+
)}
|
|
113
|
+
</section>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { resolveBlockValue } from './value.ts';
|
|
2
|
+
import { resolveBlockValue, safeHref } from './value.ts';
|
|
3
3
|
|
|
4
4
|
interface Value {
|
|
5
5
|
before?: string;
|
|
@@ -11,7 +11,10 @@ interface Value {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
const value = resolveBlockValue<Value>(Astro.props);
|
|
14
|
-
const {
|
|
14
|
+
const { beforeAlt = '', beforeLabel = 'Before', afterAlt = '', afterLabel = 'After' } = value;
|
|
15
|
+
// A `javascript:`/`data:` src is dropped, so the block renders nothing rather than that image.
|
|
16
|
+
const before = safeHref(value.before);
|
|
17
|
+
const after = safeHref(value.after);
|
|
15
18
|
---
|
|
16
19
|
|
|
17
20
|
{before && after && (
|
|
@@ -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
|
+
}
|