@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,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Thin adapter over `@getvitops/astro`'s `<Marquee />`, which emits `<wc-marquee>` itself
|
|
4
|
+
* and duplicates the slot (the copies are `aria-hidden`) for the seamless loop. Without
|
|
5
|
+
* JS the row still scrolls from CSS alone; `prefers-reduced-motion` is handled there.
|
|
6
|
+
*/
|
|
7
|
+
import Marquee from '@getvitops/astro/components/Marquee.astro';
|
|
8
|
+
import { resolveBlockValue, rows, safeHref, text } from './value.ts';
|
|
9
|
+
|
|
10
|
+
interface Value {
|
|
11
|
+
items?: unknown;
|
|
12
|
+
pauseOnHover?: boolean;
|
|
13
|
+
reverse?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const value = resolveBlockValue<Value>(Astro.props);
|
|
17
|
+
const items = rows<Record<string, unknown>>(value.items)
|
|
18
|
+
.map((row) => ({
|
|
19
|
+
text: text(row.text),
|
|
20
|
+
image: safeHref(row.image),
|
|
21
|
+
alt: text(row.alt),
|
|
22
|
+
}))
|
|
23
|
+
.filter((item) => item.text || item.image);
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
items.length > 0 && (
|
|
28
|
+
<Marquee pauseOnHover={Boolean(value.pauseOnHover)} reverse={Boolean(value.reverse)}>
|
|
29
|
+
{items.map((item) =>
|
|
30
|
+
item.image ? (
|
|
31
|
+
<img src={item.image} alt={item.alt} loading="lazy" />
|
|
32
|
+
) : (
|
|
33
|
+
<span>{item.text}</span>
|
|
34
|
+
),
|
|
35
|
+
)}
|
|
36
|
+
</Marquee>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|