@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.
- package/CHANGELOG.md +69 -0
- package/dist/index.d.mts +33 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1308 -184
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -7
- package/src/admin/DesignSystemEditorPage.tsx +15 -42
- package/src/astro/blocks/Callout.astro +73 -0
- package/src/astro/blocks/Cards.astro +53 -9
- 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/Hero.astro +77 -23
- package/src/astro/blocks/Logos.astro +48 -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 +36 -1
- package/src/astro/blocks/value.ts +78 -1
- package/src/astro/index.ts +30 -0
- package/src/admin/core-editor.d.ts +0 -7
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One `<ul class="navlist">` level of `vitops.nav`, recursing for sub-links. Internal — import
|
|
4
|
+
* `Nav.astro`. Plain nested lists, no disclosure widgets: a page-body menu is read, not
|
|
5
|
+
* collapsed, so there is no state to manage and no runtime JS. The `.navlist` pattern is
|
|
6
|
+
* `patterns/sitenav.css`'s (all formats).
|
|
7
|
+
*/
|
|
8
|
+
import { isCurrent, linkRel, type NavItem } from '@getvitops/astro/components/sitenav.ts';
|
|
9
|
+
import { safeHref } from './value.ts';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
items: NavItem[];
|
|
13
|
+
current: string;
|
|
14
|
+
/** Modifier classes on the top-level list (`navlist--inline`, …); empty for sub-levels. */
|
|
15
|
+
modifiers?: string[];
|
|
16
|
+
top?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { items, current, modifiers = [], top = true } = Astro.props;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<ul class:list={[top ? 'navlist' : 'navlist__sub', ...modifiers]} role="list">
|
|
23
|
+
{
|
|
24
|
+
items.map((item) => {
|
|
25
|
+
const href = safeHref(item.url);
|
|
26
|
+
const kids = item.children ?? [];
|
|
27
|
+
return (
|
|
28
|
+
<li class="navlist__item">
|
|
29
|
+
{href ? (
|
|
30
|
+
<a
|
|
31
|
+
class:list={['navlist__link', item.cssClasses]}
|
|
32
|
+
href={href}
|
|
33
|
+
target={item.target || undefined}
|
|
34
|
+
rel={linkRel(item)}
|
|
35
|
+
aria-current={isCurrent(item.url, current) ? 'page' : undefined}
|
|
36
|
+
>
|
|
37
|
+
{item.label}
|
|
38
|
+
</a>
|
|
39
|
+
) : (
|
|
40
|
+
// A heading-only item (no or an unsafe URL) is a label, not a dead link.
|
|
41
|
+
<span class="navlist__label">{item.label}</span>
|
|
42
|
+
)}
|
|
43
|
+
{kids.length > 0 && <Astro.self items={kids} current={current} top={false} />}
|
|
44
|
+
</li>
|
|
45
|
+
);
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
</ul>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Plan cards on the framework's `.pricing-grid` / `.pricing-card` pattern (patterns/
|
|
4
|
+
* subgrid.css): each card is a subgrid over four shared rows — header, price, features,
|
|
5
|
+
* button — so features and buttons line up across plans whatever each holds.
|
|
6
|
+
*
|
|
7
|
+
* `--pricing-card-rows` must name those four rows or the cards do not span them; it is set
|
|
8
|
+
* here rather than left to the pattern's `auto`, which is not a valid `span`.
|
|
9
|
+
*/
|
|
10
|
+
import { headingTag, lines, resolveBlockValue, rows, safeHref, text } from './value.ts';
|
|
11
|
+
|
|
12
|
+
interface Value {
|
|
13
|
+
plans?: unknown;
|
|
14
|
+
level?: string | number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const value = resolveBlockValue<Value>(Astro.props);
|
|
18
|
+
const plans = rows<Record<string, unknown>>(value.plans)
|
|
19
|
+
.map((row) => ({
|
|
20
|
+
name: text(row.name),
|
|
21
|
+
price: text(row.price),
|
|
22
|
+
period: text(row.period),
|
|
23
|
+
features: lines(row.features),
|
|
24
|
+
ctaLabel: text(row.ctaLabel),
|
|
25
|
+
ctaHref: safeHref(row.ctaHref),
|
|
26
|
+
featured: row.featured === true,
|
|
27
|
+
}))
|
|
28
|
+
.filter((plan) => plan.name || plan.price);
|
|
29
|
+
const Heading = headingTag(value.level, 3);
|
|
30
|
+
const columns = Math.min(4, Math.max(1, plans.length));
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
plans.length > 0 && (
|
|
35
|
+
<ul
|
|
36
|
+
class="pricing-grid"
|
|
37
|
+
role="list"
|
|
38
|
+
style={`--pricing-columns: ${columns}; --pricing-card-rows: 4`}
|
|
39
|
+
>
|
|
40
|
+
{plans.map((plan) => (
|
|
41
|
+
<li class:list={['pricing-card', plan.featured && 'pricing-card--featured']}>
|
|
42
|
+
<div class="pricing-card__header">
|
|
43
|
+
{plan.name && <Heading class="font-title">{plan.name}</Heading>}
|
|
44
|
+
</div>
|
|
45
|
+
<p class="pricing-card__price">
|
|
46
|
+
{plan.price}
|
|
47
|
+
{plan.period && <small class="font-caption"> {plan.period}</small>}
|
|
48
|
+
</p>
|
|
49
|
+
<ul class="pricing-card__features" role="list">
|
|
50
|
+
{plan.features.map((feature) => (
|
|
51
|
+
<li class="pricing-card__feature">{feature}</li>
|
|
52
|
+
))}
|
|
53
|
+
</ul>
|
|
54
|
+
<div class="pricing-card__cta">
|
|
55
|
+
{plan.ctaLabel && plan.ctaHref && (
|
|
56
|
+
<a class:list={[plan.featured ? 'cta' : 'btn']} href={plan.ctaHref}>
|
|
57
|
+
{plan.ctaLabel}
|
|
58
|
+
<span class="sr-only"> — {plan.name}</span>
|
|
59
|
+
</a>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
</li>
|
|
63
|
+
))}
|
|
64
|
+
</ul>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Google reviews on the framework's `.review` pattern, from the committed
|
|
4
|
+
* `.vitops/reviews.json` cache (`vitops reviews`).
|
|
5
|
+
*
|
|
6
|
+
* **The data is a build-time import, never a filesystem read.** This renders in a Worker (and,
|
|
7
|
+
* with pages prerendered, inside the Cloudflare adapter's local workerd), where there is no
|
|
8
|
+
* `fs`. `virtual:getvitops/reviews` is served by the `vitops()` Astro integration from the
|
|
9
|
+
* project's `.vitops/reviews.json`; with no file it is an empty cache, so a fresh scaffold
|
|
10
|
+
* builds and this block renders nothing. A new review therefore reaches the page on the next
|
|
11
|
+
* rebuild after `vitops reviews` is re-run and committed — not when an editor saves.
|
|
12
|
+
*
|
|
13
|
+
* **Which reviews show is `selectReviews()`**, `@getvitops/utils`' one decision (featured ids
|
|
14
|
+
* first, then `minRating` / `sort` / `limit`). The block's fields are its
|
|
15
|
+
* `ReviewLocationConfig`; nothing is re-implemented here.
|
|
16
|
+
*
|
|
17
|
+
* The cards are the Astro `<ReviewCard>`, in a grid (`<Subgrid>`), a `<Carousel>` or a
|
|
18
|
+
* `<Marquee>`. Both of those emit their own `<wc-*>`, so there is no wrapper here, and each
|
|
19
|
+
* works with no JS. Reviewer photos are Google-hosted and default OFF (a third-party request
|
|
20
|
+
* for every visitor); the source link carries `rel="nofollow"`; review JSON-LD is not offered
|
|
21
|
+
* — Google treats a business marking up reviews of itself as ineligible for rich results.
|
|
22
|
+
* `tone` colours the surrounding band, never the cards, so `.tone` and the card's own
|
|
23
|
+
* background cannot fight over one element.
|
|
24
|
+
*
|
|
25
|
+
* With nothing to show the public page gets nothing; `astro dev` gets a note saying why, since
|
|
26
|
+
* "the block is blank" is otherwise indistinguishable from "the block is broken".
|
|
27
|
+
*/
|
|
28
|
+
import reviewCache from 'virtual:getvitops/reviews';
|
|
29
|
+
import { selectReviews, type Review, type ReviewCache } from '@getvitops/utils/reviews/select';
|
|
30
|
+
import Carousel from '@getvitops/astro/components/Carousel.astro';
|
|
31
|
+
import Marquee from '@getvitops/astro/components/Marquee.astro';
|
|
32
|
+
import ReviewCard from '@getvitops/astro/components/ReviewCard.astro';
|
|
33
|
+
import Subgrid from '@getvitops/astro/components/Subgrid.astro';
|
|
34
|
+
import { joinStyle, resolveTone } from './tone.ts';
|
|
35
|
+
import { resolveBlockValue, splitLines, text } from './value.ts';
|
|
36
|
+
|
|
37
|
+
interface Value {
|
|
38
|
+
label?: string;
|
|
39
|
+
location?: string;
|
|
40
|
+
layout?: string;
|
|
41
|
+
columns?: string | number;
|
|
42
|
+
limit?: string | number;
|
|
43
|
+
minRating?: string | number;
|
|
44
|
+
sort?: string;
|
|
45
|
+
featured?: string;
|
|
46
|
+
avatars?: boolean;
|
|
47
|
+
tone?: string;
|
|
48
|
+
color?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const value = resolveBlockValue<Value>(Astro.props);
|
|
52
|
+
const cache = reviewCache as unknown as ReviewCache;
|
|
53
|
+
|
|
54
|
+
// A blank location means "every location in the cache" — the common case is one, and a
|
|
55
|
+
// multi-location site that wants one location names it.
|
|
56
|
+
const key = text(value.location);
|
|
57
|
+
const pool: Review[] = (key ? [cache.locations[key]] : Object.values(cache.locations)).flatMap(
|
|
58
|
+
(l) => l?.reviews ?? [],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const limit = Math.round(Number(value.limit));
|
|
62
|
+
const minRating = Math.round(Number(value.minRating));
|
|
63
|
+
const reviews = selectReviews(pool, {
|
|
64
|
+
featured: splitLines(value.featured),
|
|
65
|
+
...(minRating >= 1 && minRating <= 5 ? { minRating } : {}),
|
|
66
|
+
sort: value.sort === 'rating' ? 'rating' : 'recent',
|
|
67
|
+
// 0 / blank = no cap, the same as leaving `limit` unset in `site.reviews`.
|
|
68
|
+
...(limit > 0 ? { limit } : {}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const label = text(value.label) || 'Customer reviews';
|
|
72
|
+
const layout = ['carousel', 'marquee'].includes(text(value.layout)) ? text(value.layout) : 'grid';
|
|
73
|
+
const columns = Math.min(4, Math.max(1, Math.round(Number(value.columns)) || 3));
|
|
74
|
+
const avatar = Boolean(value.avatars);
|
|
75
|
+
const tone = resolveTone(value.tone, value.color);
|
|
76
|
+
const toned = Boolean(value.tone || value.color);
|
|
77
|
+
const dev = import.meta.env.DEV;
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
reviews.length > 0 ? (
|
|
82
|
+
<div
|
|
83
|
+
class:list={['reviews', toned && tone.className]}
|
|
84
|
+
style={toned ? joinStyle(tone.style) : undefined}
|
|
85
|
+
>
|
|
86
|
+
{layout === 'carousel' ? (
|
|
87
|
+
<Carousel label={label}>
|
|
88
|
+
{reviews.map((review) => (
|
|
89
|
+
<li class="carousel__slide">
|
|
90
|
+
<ReviewCard review={review} avatar={avatar} />
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</Carousel>
|
|
94
|
+
) : layout === 'marquee' ? (
|
|
95
|
+
<Marquee pauseOnHover aria-label={label} role="group">
|
|
96
|
+
{reviews.map((review) => (
|
|
97
|
+
<ReviewCard review={review} avatar={avatar} />
|
|
98
|
+
))}
|
|
99
|
+
</Marquee>
|
|
100
|
+
) : (
|
|
101
|
+
<Subgrid
|
|
102
|
+
aria-label={label}
|
|
103
|
+
style={`--subgrid-cols: ${columns}; --subgrid-row-span: 1`}
|
|
104
|
+
>
|
|
105
|
+
{reviews.map((review) => (
|
|
106
|
+
<li>
|
|
107
|
+
<ReviewCard review={review} avatar={avatar} />
|
|
108
|
+
</li>
|
|
109
|
+
))}
|
|
110
|
+
</Subgrid>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
dev && (
|
|
115
|
+
<p class="reviews__empty" role="note">
|
|
116
|
+
vitops.reviews: no reviews to show
|
|
117
|
+
{key ? ` for location "${key}"` : ''}. Run <code>vitops reviews</code> and commit
|
|
118
|
+
<code>.vitops/reviews.json</code>, or lower the rating filter. (Shown in dev only.)
|
|
119
|
+
</p>
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Inline text with `[label](url)` links, rendered from `linkRuns()` runs — no `set:html`,
|
|
4
|
+
* so editor text can never inject markup. Internal to the block adapters.
|
|
5
|
+
*/
|
|
6
|
+
import { linkRuns } from './value.ts';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
text: unknown;
|
|
10
|
+
}
|
|
11
|
+
const runs = linkRuns(Astro.props.text);
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{runs.map((run) => (run.href ? <a class="link" href={run.href}>{run.text}</a> : run.text))}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* `vitops_section` — the editorial wrapper: kicker, heading, lead-in, then a body of other
|
|
4
|
+
* blocks. It renders a block TYPE (a `blocks` field entry), not a Portable Text block — see
|
|
5
|
+
* `block-types.ts` for why — so it is mapped through `blockTypeComponents`, not
|
|
6
|
+
* `blockComponents`.
|
|
7
|
+
*
|
|
8
|
+
* Composed from the framework's layout primitives: `.region` for vertical rhythm, `.centered`
|
|
9
|
+
* for the tracks (the `width` field picks `breakout`/`spotlight`/`fullbleed`), `.section` for
|
|
10
|
+
* the head and the heading-left `split` layout, `.tone` for the backdrop. The heading is a
|
|
11
|
+
* real heading at the editor's `level`.
|
|
12
|
+
*
|
|
13
|
+
* The body is rendered by EmDash's `<PortableText>`, which merges this plugin's
|
|
14
|
+
* `blockComponents`, so a `vitops.cards` or `vitops.actionLink` placed in it renders with its
|
|
15
|
+
* web component. Class names never come from field values (Tailwind could not see them): the
|
|
16
|
+
* width and layout are looked up in closed tables, colour is `--tone-*` custom properties.
|
|
17
|
+
*/
|
|
18
|
+
import { PortableText } from 'emdash/ui';
|
|
19
|
+
import { joinStyle, resolveTone } from './tone.ts';
|
|
20
|
+
import { headingTag, resolveBlockValue, text } from './value.ts';
|
|
21
|
+
|
|
22
|
+
interface Value {
|
|
23
|
+
kicker?: string;
|
|
24
|
+
heading?: string;
|
|
25
|
+
level?: string | number;
|
|
26
|
+
sub?: string;
|
|
27
|
+
body?: unknown;
|
|
28
|
+
layout?: string;
|
|
29
|
+
width?: string;
|
|
30
|
+
tone?: string;
|
|
31
|
+
color?: string;
|
|
32
|
+
solid?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const WIDTHS: Record<string, string> = {
|
|
36
|
+
breakout: 'breakout',
|
|
37
|
+
spotlight: 'spotlight',
|
|
38
|
+
fullbleed: 'fullbleed',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const value = resolveBlockValue<Value>(Astro.props);
|
|
42
|
+
const kicker = text(value.kicker);
|
|
43
|
+
const heading = text(value.heading);
|
|
44
|
+
const sub = text(value.sub);
|
|
45
|
+
const body = Array.isArray(value.body) ? value.body : [];
|
|
46
|
+
const Heading = headingTag(value.level, 2);
|
|
47
|
+
const tone = resolveTone(value.tone, value.color);
|
|
48
|
+
const layout = text(value.layout);
|
|
49
|
+
const width = WIDTHS[text(value.width)];
|
|
50
|
+
const hasHead = Boolean(kicker || heading || sub);
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
(hasHead || body.length > 0) && (
|
|
55
|
+
<section
|
|
56
|
+
class:list={[
|
|
57
|
+
'section',
|
|
58
|
+
'region',
|
|
59
|
+
tone.style && tone.className,
|
|
60
|
+
tone.style && value.solid && 'tone--solid',
|
|
61
|
+
layout === 'split' && 'section--split',
|
|
62
|
+
layout === 'center' && 'section--center',
|
|
63
|
+
]}
|
|
64
|
+
style={joinStyle(tone.style)}
|
|
65
|
+
>
|
|
66
|
+
<div class="centered">
|
|
67
|
+
<div class:list={['section__inner', width]}>
|
|
68
|
+
{hasHead && (
|
|
69
|
+
<header class="section__head">
|
|
70
|
+
{kicker && <p class="section__kicker font-eyebrow">{kicker}</p>}
|
|
71
|
+
{heading && <Heading class="section__title font-title">{heading}</Heading>}
|
|
72
|
+
{sub && <p class="section__sub font-lead">{sub}</p>}
|
|
73
|
+
</header>
|
|
74
|
+
)}
|
|
75
|
+
{body.length > 0 && (
|
|
76
|
+
<div class="section__body">
|
|
77
|
+
<PortableText value={body} />
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</section>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Headline figures on the framework's `.stats` pattern, each figure the existing
|
|
4
|
+
* `<Counter>` (which emits its own `<wc-counter>` — no wrapper here). `variant: tiles` is
|
|
5
|
+
* the generic kernel of a dashboard mock: a group line, the figure, its label and a
|
|
6
|
+
* delta whose `state` (`up`/`down`) colours it through the site's success/danger roles.
|
|
7
|
+
* The delta's words stay in the text, so colour is never the only carrier.
|
|
8
|
+
*
|
|
9
|
+
* `animate: false` renders the plain fragment (`enhance={false}`) — no count-up.
|
|
10
|
+
*/
|
|
11
|
+
import Counter from '@getvitops/astro/components/Counter.astro';
|
|
12
|
+
import { resolveTone, joinStyle } from './tone.ts';
|
|
13
|
+
import { resolveBlockValue, rows, text } from './value.ts';
|
|
14
|
+
|
|
15
|
+
interface Value {
|
|
16
|
+
stats?: unknown;
|
|
17
|
+
variant?: string;
|
|
18
|
+
animate?: boolean;
|
|
19
|
+
tone?: string;
|
|
20
|
+
color?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const STATES = new Set(['up', 'down']);
|
|
24
|
+
|
|
25
|
+
const value = resolveBlockValue<Value>(Astro.props);
|
|
26
|
+
const stats = rows<Record<string, unknown>>(value.stats)
|
|
27
|
+
.map((row) => ({
|
|
28
|
+
value: text(row.value),
|
|
29
|
+
label: text(row.label),
|
|
30
|
+
group: text(row.group),
|
|
31
|
+
delta: text(row.delta),
|
|
32
|
+
state: STATES.has(text(row.state)) ? text(row.state) : undefined,
|
|
33
|
+
}))
|
|
34
|
+
.filter((stat) => stat.value);
|
|
35
|
+
const tiles = value.variant === 'tiles';
|
|
36
|
+
const animate = value.animate !== false;
|
|
37
|
+
const tone = resolveTone(value.tone, value.color);
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
stats.length > 0 && (
|
|
42
|
+
<ul
|
|
43
|
+
class:list={['stats', tone.className, tiles && 'stats--tiles']}
|
|
44
|
+
role="list"
|
|
45
|
+
style={joinStyle(tone.style)}
|
|
46
|
+
>
|
|
47
|
+
{stats.map((stat) => (
|
|
48
|
+
<li class="stats__item" data-state={stat.state}>
|
|
49
|
+
{tiles && stat.group && <p class="stats__group font-eyebrow">{stat.group}</p>}
|
|
50
|
+
<p class="stats__figure font-display">
|
|
51
|
+
<Counter value={stat.value} enhance={animate} />
|
|
52
|
+
</p>
|
|
53
|
+
{stat.label && <p class="stats__label">{stat.label}</p>}
|
|
54
|
+
{tiles && stat.delta && <p class="stats__delta">{stat.delta}</p>}
|
|
55
|
+
</li>
|
|
56
|
+
))}
|
|
57
|
+
</ul>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A numbered sequence on the framework's `.steps` pattern: an `<ol>` (so the order is
|
|
4
|
+
* semantic with no CSS), a CSS-counter marker and a connector line. Each step's title is a
|
|
5
|
+
* real heading at the editor's `level`, so the outline survives. Colour is the shared tone
|
|
6
|
+
* convention — see `tone.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import RichText from './RichText.astro';
|
|
9
|
+
import { resolveTone, joinStyle } from './tone.ts';
|
|
10
|
+
import { headingTag, resolveBlockValue, rows, text } from './value.ts';
|
|
11
|
+
|
|
12
|
+
interface Value {
|
|
13
|
+
steps?: unknown;
|
|
14
|
+
level?: string | number;
|
|
15
|
+
layout?: string;
|
|
16
|
+
tone?: string;
|
|
17
|
+
color?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const value = resolveBlockValue<Value>(Astro.props);
|
|
21
|
+
const steps = rows<Record<string, unknown>>(value.steps)
|
|
22
|
+
.map((row) => ({ title: text(row.title), text: text(row.text) }))
|
|
23
|
+
.filter((step) => step.title || step.text);
|
|
24
|
+
const Heading = headingTag(value.level, 3);
|
|
25
|
+
const tone = resolveTone(value.tone, value.color);
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
steps.length > 0 && (
|
|
30
|
+
<ol
|
|
31
|
+
class:list={['steps', tone.className, value.layout === 'row' && 'steps--row']}
|
|
32
|
+
style={joinStyle(tone.style)}
|
|
33
|
+
>
|
|
34
|
+
{steps.map((step) => (
|
|
35
|
+
<li class="steps__item">
|
|
36
|
+
<div class="steps__body">
|
|
37
|
+
{step.title && <Heading class="steps__title font-heading">{step.title}</Heading>}
|
|
38
|
+
{step.text && (
|
|
39
|
+
<p class="steps__text">
|
|
40
|
+
<RichText text={step.text} />
|
|
41
|
+
</p>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
</li>
|
|
45
|
+
))}
|
|
46
|
+
</ol>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vite-plus/test';
|
|
2
|
+
import { resolveEmbed } from './embed.ts';
|
|
3
|
+
|
|
4
|
+
describe('resolveEmbed', () => {
|
|
5
|
+
it('rewrites YouTube addresses to the no-cookie embed', () => {
|
|
6
|
+
for (const url of [
|
|
7
|
+
'https://www.youtube.com/watch?v=abc_123-Z',
|
|
8
|
+
'https://youtu.be/abc_123-Z',
|
|
9
|
+
'https://www.youtube.com/shorts/abc_123-Z',
|
|
10
|
+
])
|
|
11
|
+
expect(resolveEmbed('youtube', url)?.src).toBe(
|
|
12
|
+
'https://www.youtube-nocookie.com/embed/abc_123-Z',
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('rewrites Vimeo addresses and asks for do-not-track', () => {
|
|
17
|
+
expect(resolveEmbed('vimeo', 'https://vimeo.com/123456')?.src).toBe(
|
|
18
|
+
'https://player.vimeo.com/video/123456?dnt=1',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('accepts only the framing endpoints of a map provider', () => {
|
|
23
|
+
expect(resolveEmbed('map', 'https://www.google.com/maps/embed?pb=!1m1')?.provider).toBe(
|
|
24
|
+
'Google Maps',
|
|
25
|
+
);
|
|
26
|
+
expect(resolveEmbed('map', 'https://www.google.com/maps/place/Ottawa')).toBeNull();
|
|
27
|
+
expect(
|
|
28
|
+
resolveEmbed('map', 'https://www.openstreetmap.org/export/embed.html?bbox=1')?.src,
|
|
29
|
+
).toContain('openstreetmap.org/export/embed');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('refuses anything that is not https, and a named provider’s foreign hosts', () => {
|
|
33
|
+
expect(resolveEmbed('custom', 'javascript:alert(1)')).toBeNull();
|
|
34
|
+
expect(resolveEmbed('custom', 'http://example.com/x')).toBeNull();
|
|
35
|
+
expect(resolveEmbed('youtube', 'https://evil.example/watch?v=abc')).toBeNull();
|
|
36
|
+
expect(resolveEmbed('vimeo', 'https://evil.example/123')).toBeNull();
|
|
37
|
+
expect(resolveEmbed('custom', undefined)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('treats an unknown provider as custom https', () => {
|
|
41
|
+
expect(resolveEmbed('nope', 'https://example.com/w')?.src).toBe('https://example.com/w');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn an editor-pasted address into the iframe `src` for a provider, and say which
|
|
3
|
+
* consent category loading it needs.
|
|
4
|
+
*
|
|
5
|
+
* Pure so the consequential cases — what is allowed to become a frame, and which
|
|
6
|
+
* category it waits on — are asserted without rendering. The rule is **closed by
|
|
7
|
+
* default**: a `custom` embed must still be `https:`, and each named provider only
|
|
8
|
+
* accepts its own hosts, so a pasted `javascript:` or an unrelated site never reaches an
|
|
9
|
+
* iframe `src`.
|
|
10
|
+
*/
|
|
11
|
+
export type EmbedProvider = 'youtube' | 'vimeo' | 'map' | 'custom';
|
|
12
|
+
|
|
13
|
+
export interface ResolvedEmbed {
|
|
14
|
+
/** Address the consent runtime assigns to the iframe after the visitor allows it. */
|
|
15
|
+
src: string;
|
|
16
|
+
/** What the placeholder calls the third party ("YouTube"). */
|
|
17
|
+
provider: string;
|
|
18
|
+
/** Where the visitor can open the same content without embedding it. */
|
|
19
|
+
openUrl: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NAMES: Record<EmbedProvider, string> = {
|
|
23
|
+
youtube: 'YouTube',
|
|
24
|
+
vimeo: 'Vimeo',
|
|
25
|
+
map: 'Google Maps',
|
|
26
|
+
custom: 'a third party',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function parse(input: unknown): URL | null {
|
|
30
|
+
if (typeof input !== 'string') return null;
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(input.trim());
|
|
33
|
+
return url.protocol === 'https:' ? url : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const host = (url: URL, ...names: string[]) =>
|
|
40
|
+
names.some((n) => url.hostname === n || url.hostname.endsWith(`.${n}`));
|
|
41
|
+
|
|
42
|
+
/** The id of a YouTube watch / short / embed address, or null. */
|
|
43
|
+
function youtubeId(url: URL): string | null {
|
|
44
|
+
if (url.hostname === 'youtu.be') return url.pathname.slice(1) || null;
|
|
45
|
+
if (!host(url, 'youtube.com', 'youtube-nocookie.com')) return null;
|
|
46
|
+
if (url.pathname === '/watch') return url.searchParams.get('v');
|
|
47
|
+
const m = /^\/(?:embed|shorts|live)\/([\w-]+)/.exec(url.pathname);
|
|
48
|
+
return m ? m[1]! : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveEmbed(provider: unknown, input: unknown): ResolvedEmbed | null {
|
|
52
|
+
const url = parse(input);
|
|
53
|
+
if (!url) return null;
|
|
54
|
+
const kind = (['youtube', 'vimeo', 'map', 'custom'] as const).includes(provider as EmbedProvider)
|
|
55
|
+
? (provider as EmbedProvider)
|
|
56
|
+
: 'custom';
|
|
57
|
+
const provider_ = NAMES[kind];
|
|
58
|
+
const done = (src: string): ResolvedEmbed => ({ src, provider: provider_, openUrl: url.href });
|
|
59
|
+
|
|
60
|
+
switch (kind) {
|
|
61
|
+
case 'youtube': {
|
|
62
|
+
const id = youtubeId(url);
|
|
63
|
+
// The no-cookie host: the same player, and it sets nothing until the visitor plays.
|
|
64
|
+
return id && /^[\w-]+$/.test(id)
|
|
65
|
+
? done(`https://www.youtube-nocookie.com/embed/${id}`)
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
case 'vimeo': {
|
|
69
|
+
const m = /^\/(?:video\/)?(\d+)/.exec(url.pathname);
|
|
70
|
+
return host(url, 'vimeo.com') && m
|
|
71
|
+
? done(`https://player.vimeo.com/video/${m[1]}?dnt=1`)
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
case 'map': {
|
|
75
|
+
// Only the embed endpoints: a plain maps.google.com share link refuses framing.
|
|
76
|
+
const google = host(url, 'google.com') && url.pathname.startsWith('/maps/embed');
|
|
77
|
+
const osm =
|
|
78
|
+
url.hostname === 'www.openstreetmap.org' && url.pathname.startsWith('/export/embed');
|
|
79
|
+
return google || osm ? done(url.href) : null;
|
|
80
|
+
}
|
|
81
|
+
default:
|
|
82
|
+
return done(url.href);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A realistic saved `vitops.form` block, for tests and the e2e fixtures: the fields a small
|
|
3
|
+
* service business asks for, including the two names the conversion route treats specially.
|
|
4
|
+
*/
|
|
5
|
+
export const SAMPLE_FORM_BLOCK = {
|
|
6
|
+
_type: 'vitops.form',
|
|
7
|
+
_key: 'contact',
|
|
8
|
+
heading: 'Get in touch',
|
|
9
|
+
level: '2',
|
|
10
|
+
intro: 'Tell us about your project and we will reply within one business day.',
|
|
11
|
+
fields: [
|
|
12
|
+
{ label: 'Your name', name: 'name', type: 'text', required: true },
|
|
13
|
+
{
|
|
14
|
+
label: 'Email',
|
|
15
|
+
name: 'email',
|
|
16
|
+
type: 'email',
|
|
17
|
+
required: true,
|
|
18
|
+
help: 'We only use it to reply.',
|
|
19
|
+
},
|
|
20
|
+
{ label: 'Phone', name: 'phone', type: 'tel', required: false },
|
|
21
|
+
{
|
|
22
|
+
label: 'Service',
|
|
23
|
+
name: 'service',
|
|
24
|
+
type: 'select',
|
|
25
|
+
required: false,
|
|
26
|
+
options: 'Maintenance\nDesign\nInstallation',
|
|
27
|
+
},
|
|
28
|
+
{ label: 'Message', name: 'message', type: 'textarea', required: true },
|
|
29
|
+
{ label: 'Add me to your newsletter', name: 'newsletter', type: 'checkbox' },
|
|
30
|
+
],
|
|
31
|
+
submitLabel: 'Send message',
|
|
32
|
+
successMessage: 'Thanks, we will be in touch.',
|
|
33
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vite-plus/test';
|
|
2
|
+
import { fieldName, resolveFields, safeAction, safeNext, slugName } from './form.ts';
|
|
3
|
+
|
|
4
|
+
describe('field names', () => {
|
|
5
|
+
it('derives a safe name from the label', () => {
|
|
6
|
+
expect(slugName('Your full name!')).toBe('your_full_name');
|
|
7
|
+
expect(slugName('Café ☕')).toBe('cafe');
|
|
8
|
+
});
|
|
9
|
+
it('keeps clear of every name the conversion route treats specially', () => {
|
|
10
|
+
const taken = new Set<string>();
|
|
11
|
+
expect(fieldName('phone', 'Phone', 'tel', taken)).toBe('tel');
|
|
12
|
+
expect(fieldName('event', 'Event', 'text', taken)).toBe('event_field');
|
|
13
|
+
expect(fieldName('website', 'Website', 'text', taken)).toBe('website_field');
|
|
14
|
+
expect(fieldName('cf-turnstile-response', 'x', 'text', taken)).toBe('cf_turnstile_response');
|
|
15
|
+
});
|
|
16
|
+
it('de-duplicates and never starts with a digit', () => {
|
|
17
|
+
const taken = new Set<string>();
|
|
18
|
+
expect(fieldName('', 'Name', 'text', taken)).toBe('name');
|
|
19
|
+
expect(fieldName('', 'Name', 'text', taken)).toBe('name_2');
|
|
20
|
+
expect(fieldName('', '3 guests', 'text', new Set())).toBe('f_3_guests');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('resolveFields', () => {
|
|
25
|
+
it('drops unlabeled rows, coerces unknown kinds, and turns an empty select into text', () => {
|
|
26
|
+
const f = resolveFields([
|
|
27
|
+
{ label: '' },
|
|
28
|
+
{ label: 'A', type: 'bogus' },
|
|
29
|
+
{ label: 'B', type: 'select', options: '' },
|
|
30
|
+
{ label: 'C', type: 'select', options: 'x\n y \n' },
|
|
31
|
+
]);
|
|
32
|
+
expect(f.map((x) => [x.name, x.type])).toEqual([
|
|
33
|
+
['a', 'text'],
|
|
34
|
+
['b', 'text'],
|
|
35
|
+
['c', 'select'],
|
|
36
|
+
]);
|
|
37
|
+
expect(f[2]!.options).toEqual(['x', 'y']);
|
|
38
|
+
});
|
|
39
|
+
it('caps the number of fields', () => {
|
|
40
|
+
const many = Array.from({ length: 40 }, (_, i) => ({ label: `F${i}` }));
|
|
41
|
+
expect(resolveFields(many)).toHaveLength(20);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('same-site paths only', () => {
|
|
46
|
+
it.each(['//evil.example', 'https://evil.example', '/a\\b', '/a?x=1', '/a#f', 'contact', ''])(
|
|
47
|
+
'refuses %j',
|
|
48
|
+
(v) => expect(safeNext(v)).toBeNull(),
|
|
49
|
+
);
|
|
50
|
+
it('accepts a plain path and defaults the action', () => {
|
|
51
|
+
expect(safeNext('/contact')).toBe('/contact');
|
|
52
|
+
expect(safeAction(undefined)).toBe('/api/track');
|
|
53
|
+
expect(safeAction('/api/enquiry')).toBe('/api/enquiry');
|
|
54
|
+
});
|
|
55
|
+
});
|