@getvitops/emdash 1.0.0 → 2.0.0-alpha.1

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 (52) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/index.d.mts +34 -1
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1450 -177
  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/Nothing.astro +3 -0
  9. package/src/astro/WidgetArea.astro +91 -0
  10. package/src/astro/blocks/Accordion.astro +2 -1
  11. package/src/astro/blocks/ActionLink.astro +2 -1
  12. package/src/astro/blocks/Badges.astro +67 -0
  13. package/src/astro/blocks/Banner.astro +3 -2
  14. package/src/astro/blocks/Callout.astro +68 -0
  15. package/src/astro/blocks/Cards.astro +54 -9
  16. package/src/astro/blocks/Carousel.astro +2 -0
  17. package/src/astro/blocks/CopyButton.astro +3 -2
  18. package/src/astro/blocks/Details.astro +2 -1
  19. package/src/astro/blocks/Embed.astro +68 -0
  20. package/src/astro/blocks/Entries.astro +31 -0
  21. package/src/astro/blocks/Form.astro +195 -0
  22. package/src/astro/blocks/Gallery.astro +2 -0
  23. package/src/astro/blocks/Hero.astro +78 -23
  24. package/src/astro/blocks/ImageCompare.astro +2 -1
  25. package/src/astro/blocks/Logos.astro +48 -0
  26. package/src/astro/blocks/Marquee.astro +2 -1
  27. package/src/astro/blocks/Nav.astro +85 -0
  28. package/src/astro/blocks/NavTree.astro +48 -0
  29. package/src/astro/blocks/Pricing.astro +66 -0
  30. package/src/astro/blocks/Reviews.astro +122 -0
  31. package/src/astro/blocks/RichText.astro +14 -0
  32. package/src/astro/blocks/Section.astro +82 -0
  33. package/src/astro/blocks/Stats.astro +59 -0
  34. package/src/astro/blocks/Steps.astro +48 -0
  35. package/src/astro/blocks/WidgetArea.astro +21 -0
  36. package/src/astro/blocks/attrs.test.ts +56 -0
  37. package/src/astro/blocks/attrs.ts +104 -0
  38. package/src/astro/blocks/embed.test.ts +43 -0
  39. package/src/astro/blocks/embed.ts +84 -0
  40. package/src/astro/blocks/form.sample.ts +33 -0
  41. package/src/astro/blocks/form.test.ts +55 -0
  42. package/src/astro/blocks/form.ts +158 -0
  43. package/src/astro/blocks/head.stub.ts +2 -0
  44. package/src/astro/blocks/nav.test.ts +51 -0
  45. package/src/astro/blocks/nav.ts +65 -0
  46. package/src/astro/blocks/render.container.test.ts +610 -0
  47. package/src/astro/blocks/tone.test.ts +53 -0
  48. package/src/astro/blocks/tone.ts +122 -0
  49. package/src/astro/blocks/value.test.ts +36 -1
  50. package/src/astro/blocks/value.ts +78 -1
  51. package/src/astro/index.ts +37 -0
  52. package/src/admin/core-editor.d.ts +0 -7
@@ -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
+ import { rootAttrs } from './attrs.ts';
79
+ ---
80
+
81
+ {
82
+ reviews.length > 0 ? (
83
+ <div
84
+ {...rootAttrs(value, ['reviews', toned && tone.className], 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,82 @@
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
+ theme?: string;
34
+ }
35
+
36
+ const WIDTHS: Record<string, string> = {
37
+ breakout: 'breakout',
38
+ spotlight: 'spotlight',
39
+ fullbleed: 'fullbleed',
40
+ };
41
+
42
+ const value = resolveBlockValue<Value>(Astro.props);
43
+ const kicker = text(value.kicker);
44
+ const heading = text(value.heading);
45
+ const sub = text(value.sub);
46
+ const body = Array.isArray(value.body) ? value.body : [];
47
+ const Heading = headingTag(value.level, 2);
48
+ // `theme` is one pick for the common case: `<role>` tints, `<role>:solid` fills. It wins over the
49
+ // older `tone`/`color`/`solid` fields, which stay for content that already uses them.
50
+ const [themeRole, themeFill] = text(value.theme).replace(/^none$/, '').split(':');
51
+ const tone = themeRole ? resolveTone(themeRole) : resolveTone(value.tone, value.color);
52
+ const solid = themeRole ? themeFill === 'solid' : Boolean(value.solid);
53
+ const layout = text(value.layout);
54
+ const width = WIDTHS[text(value.width)];
55
+ const hasHead = Boolean(kicker || heading || sub);
56
+ import { rootAttrs } from './attrs.ts';
57
+ ---
58
+
59
+ {
60
+ (hasHead || body.length > 0) && (
61
+ <section
62
+ {...rootAttrs(value, ['section', 'region', tone.style && tone.className, tone.style && solid && 'tone--solid', layout === 'split' && 'section--split', layout === 'center' && 'section--center'], joinStyle(tone.style))}
63
+ >
64
+ <div class="centered">
65
+ <div class:list={['section__inner', width]}>
66
+ {hasHead && (
67
+ <header class="section__head">
68
+ {kicker && <p class="section__kicker font-eyebrow">{kicker}</p>}
69
+ {heading && <Heading class="section__title font-title">{heading}</Heading>}
70
+ {sub && <p class="section__sub font-lead">{sub}</p>}
71
+ </header>
72
+ )}
73
+ {body.length > 0 && (
74
+ <div class="section__body">
75
+ <PortableText value={body} />
76
+ </div>
77
+ )}
78
+ </div>
79
+ </div>
80
+ </section>
81
+ )
82
+ }
@@ -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
+ import { rootAttrs } from './attrs.ts';
39
+ ---
40
+
41
+ {
42
+ stats.length > 0 && (
43
+ <ul
44
+ {...rootAttrs(value, ['stats', tone.className, tiles && 'stats--tiles'], joinStyle(tone.style))}
45
+ role="list"
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
+ import { rootAttrs } from './attrs.ts';
27
+ ---
28
+
29
+ {
30
+ steps.length > 0 && (
31
+ <ol
32
+ {...rootAttrs(value, ['steps', tone.className, value.layout === 'row' && 'steps--row'], 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,21 @@
1
+ ---
2
+ /**
3
+ * `vitops.widgetArea` — a synced region inside page content: the area is defined once under
4
+ * Widgets in the admin and every page that places this block shows the same thing. (An EmDash
5
+ * Section, by contrast, is copied in.) See `../WidgetArea.astro` for what it renders and why a
6
+ * widget area cannot hold another. The markup (`.widget-area`) lives there.
7
+ */
8
+ import WidgetArea from '../WidgetArea.astro';
9
+ import { resolveBlockValue, text } from './value.ts';
10
+ import { rootAttrs } from './attrs.ts';
11
+
12
+ interface Value {
13
+ area?: string;
14
+ level?: string | number;
15
+ }
16
+
17
+ const value = resolveBlockValue<Value>(Astro.props);
18
+ const name = text(value.area);
19
+ ---
20
+
21
+ {name && <WidgetArea name={name} level={value.level} {...rootAttrs(value, ['widget-area--in-page'])} />}
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vite-plus/test';
2
+ import { anchorId, blockAttrs, classTokens, customProperties } from './attrs.ts';
3
+
4
+ describe('anchorId', () => {
5
+ it('slugifies and accepts a clean id', () => {
6
+ expect(anchorId('Contact Us')).toBe('contact-us');
7
+ expect(anchorId('contact')).toBe('contact');
8
+ });
9
+ it('rejects what cannot be an id', () => {
10
+ expect(anchorId('')).toBeUndefined();
11
+ expect(anchorId('9lives')).toBeUndefined();
12
+ expect(anchorId(42)).toBeUndefined();
13
+ });
14
+ });
15
+
16
+ describe('classTokens', () => {
17
+ it('keeps valid tokens, drops the rest', () => {
18
+ expect(classTokens('mt-4 md:flex "x" <b>')).toBe('mt-4 md:flex');
19
+ });
20
+ it('is undefined when nothing survives', () => {
21
+ expect(classTokens('"><script>')).toBeUndefined();
22
+ });
23
+ });
24
+
25
+ describe('customProperties', () => {
26
+ it('keeps custom properties', () => {
27
+ expect(customProperties('--p-card: 3rem; --gap: 1rem')).toBe('--p-card: 3rem; --gap: 1rem');
28
+ });
29
+ it('drops ordinary properties and fetching values', () => {
30
+ expect(
31
+ customProperties('background: url(x); --a: url(https://x/y); display: none'),
32
+ ).toBeUndefined();
33
+ expect(customProperties('--a: 1px; position: fixed')).toBe('--a: 1px');
34
+ });
35
+ it('rejects values that try to leave the declaration', () => {
36
+ expect(customProperties('--a: red}</style><script>')).toBeUndefined();
37
+ expect(customProperties('--a: \\75rl(x)')).toBeUndefined();
38
+ });
39
+ });
40
+
41
+ describe('blockAttrs', () => {
42
+ it('is empty for a block that sets nothing', () => {
43
+ expect(blockAttrs({})).toEqual({});
44
+ expect(blockAttrs(undefined)).toEqual({});
45
+ });
46
+ it('reads the block-type spelling too', () => {
47
+ expect(blockAttrs({ class_name: 'mt-4' })).toEqual({ class: 'mt-4' });
48
+ });
49
+ it('collects the three fields', () => {
50
+ expect(blockAttrs({ anchor: 'services', className: 'mt-4', style: '--p-card: 2rem' })).toEqual({
51
+ id: 'services',
52
+ class: 'mt-4',
53
+ style: '--p-card: 2rem',
54
+ });
55
+ });
56
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The escape hatches every block carries: `anchor` (an `id`), `className` (extra classes)
3
+ * and `style` (CSS custom properties).
4
+ *
5
+ * All three are editor-supplied strings that land in attributes, so each is validated against
6
+ * a closed grammar and anything outside it is dropped, never escaped into place.
7
+ *
8
+ * **`style` is custom properties only.** `--p-card: 2rem` tunes a framework hook, which is how
9
+ * the design system is meant to be overridden. Free-form declarations (`display`, `position`,
10
+ * `background: url(…)`) would let one page quietly diverge from the tokens, and `url(` is a
11
+ * request the visitor's browser makes to wherever an editor points it.
12
+ *
13
+ * **`className` and the `tailwind` format.** Tailwind scans source for class names, not CMS
14
+ * content, so a utility typed here exists only if something else already used it. Under the
15
+ * `css` and `bricks` formats every framework class ships, so the caveat does not apply.
16
+ */
17
+
18
+ export interface BlockAttrs {
19
+ id?: string;
20
+ class?: string;
21
+ style?: string;
22
+ }
23
+
24
+ const ANCHOR = /^[a-z][a-z0-9-]{0,63}$/;
25
+ const CLASS_TOKEN = /^[A-Za-z0-9_:@/.[\]-]{1,80}$/;
26
+ const MAX_CLASSES = 20;
27
+ const PROPERTY = /^--[a-zA-Z][a-zA-Z0-9-]{0,60}$/;
28
+ /** Characters that could end the declaration, open a function that fetches, or leave the attribute. */
29
+ const FORBIDDEN_VALUE = /[\\<>@{}"'`]|url\(|expression\(|image-set\(|;/i;
30
+
31
+ /** Lowercase an anchor and replace anything else with `-`; `undefined` when nothing valid remains. */
32
+ export function anchorId(input: unknown): string | undefined {
33
+ if (typeof input !== 'string') return undefined;
34
+ const slug = input
35
+ .trim()
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9-]+/g, '-')
38
+ .replace(/^-+|-+$/g, '');
39
+ return ANCHOR.test(slug) ? slug : undefined;
40
+ }
41
+
42
+ export function classTokens(input: unknown): string | undefined {
43
+ if (typeof input !== 'string') return undefined;
44
+ const tokens = input
45
+ .split(/\s+/)
46
+ .filter((t) => CLASS_TOKEN.test(t))
47
+ .slice(0, MAX_CLASSES);
48
+ return tokens.length ? tokens.join(' ') : undefined;
49
+ }
50
+
51
+ /** Keep each `--name: value` pair that passes; drop the rest. */
52
+ export function customProperties(input: unknown): string | undefined {
53
+ if (typeof input !== 'string') return undefined;
54
+ const kept: string[] = [];
55
+ for (const decl of input.split(';')) {
56
+ const at = decl.indexOf(':');
57
+ if (at < 0) continue;
58
+ const name = decl.slice(0, at).trim();
59
+ const value = decl.slice(at + 1).trim();
60
+ if (!PROPERTY.test(name) || !value || value.length > 200 || FORBIDDEN_VALUE.test(value))
61
+ continue;
62
+ kept.push(`${name}: ${value}`);
63
+ }
64
+ return kept.length ? kept.join('; ') : undefined;
65
+ }
66
+
67
+ /** The validated `id` / `class` / `style` for a block's saved value. Empty when none is set. */
68
+ export function blockAttrs(input: object | undefined): BlockAttrs {
69
+ const value = input as Record<string, unknown> | undefined;
70
+ const out: BlockAttrs = {};
71
+ const id = anchorId(value?.anchor);
72
+ // Portable Text blocks name it `className`; a block TYPE's slugs must be lowercase, so `class_name`.
73
+ const cls = classTokens(value?.className ?? value?.class_name);
74
+ const style = customProperties(value?.style);
75
+ if (id) out.id = id;
76
+ if (cls) out.class = cls;
77
+ if (style) out.style = style;
78
+ return out;
79
+ }
80
+
81
+ type ClassPart = string | false | null | undefined;
82
+
83
+ /**
84
+ * The attributes for a block's outermost element: its own classes and style with the editor's
85
+ * `anchor` / `className` / `style` merged in. Spread it on that element (`<div {...root}>`) or pass
86
+ * it to the pattern component that renders it — there is never an extra wrapper node, so a block's
87
+ * DOM shape does not depend on which fields an editor filled in.
88
+ *
89
+ * An anchored element also gets `.block-anchor`, which offsets `scroll-margin-top` for a sticky header.
90
+ */
91
+ export function rootAttrs(
92
+ value: object | undefined,
93
+ own: ClassPart[] = [],
94
+ ownStyle?: string,
95
+ ): BlockAttrs {
96
+ const user = blockAttrs(value);
97
+ const out: BlockAttrs = {};
98
+ if (user.id) out.id = user.id;
99
+ const cls = [...own, user.id && 'block-anchor', user.class].filter(Boolean).join(' ');
100
+ if (cls) out.class = cls;
101
+ const style = [ownStyle, user.style].filter(Boolean).join('; ');
102
+ if (style) out.style = style;
103
+ return out;
104
+ }
@@ -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
+ };