@getvitops/emdash 2.0.0-alpha.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.
- package/CHANGELOG.md +33 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +152 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/astro/Nothing.astro +3 -0
- package/src/astro/WidgetArea.astro +91 -0
- package/src/astro/blocks/Accordion.astro +2 -1
- package/src/astro/blocks/ActionLink.astro +2 -1
- package/src/astro/blocks/Badges.astro +67 -0
- package/src/astro/blocks/Banner.astro +3 -2
- package/src/astro/blocks/Callout.astro +2 -7
- package/src/astro/blocks/Cards.astro +2 -1
- package/src/astro/blocks/Carousel.astro +2 -0
- package/src/astro/blocks/CopyButton.astro +3 -2
- package/src/astro/blocks/Details.astro +2 -1
- package/src/astro/blocks/Embed.astro +2 -1
- package/src/astro/blocks/Entries.astro +2 -1
- package/src/astro/blocks/Form.astro +2 -1
- package/src/astro/blocks/Gallery.astro +2 -0
- package/src/astro/blocks/Hero.astro +2 -1
- package/src/astro/blocks/ImageCompare.astro +2 -1
- package/src/astro/blocks/Logos.astro +2 -2
- package/src/astro/blocks/Marquee.astro +2 -1
- package/src/astro/blocks/Nav.astro +2 -2
- package/src/astro/blocks/Pricing.astro +2 -2
- package/src/astro/blocks/Reviews.astro +2 -2
- package/src/astro/blocks/Section.astro +8 -10
- package/src/astro/blocks/Stats.astro +2 -2
- package/src/astro/blocks/Steps.astro +2 -2
- package/src/astro/blocks/WidgetArea.astro +21 -0
- package/src/astro/blocks/attrs.test.ts +56 -0
- package/src/astro/blocks/attrs.ts +104 -0
- package/src/astro/blocks/render.container.test.ts +40 -0
- package/src/astro/index.ts +7 -0
|
@@ -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
|
+
}
|
|
@@ -568,3 +568,43 @@ describe('vitops.form', () => {
|
|
|
568
568
|
expect(html).not.toContain('<script>alert(1)');
|
|
569
569
|
});
|
|
570
570
|
});
|
|
571
|
+
|
|
572
|
+
describe('escape hatches (anchor, className, style)', () => {
|
|
573
|
+
const hatches = { anchor: 'x', className: 'mt-4', style: '--a: 1px; background: url(evil)' };
|
|
574
|
+
const samples: [string, unknown, object][] = [
|
|
575
|
+
['callout', Callout, { heading: 'H', body: 'b' }],
|
|
576
|
+
['stats', Stats, { stats: [{ value: '1', label: 'a' }] }],
|
|
577
|
+
['steps', Steps, { steps: [{ title: 'a', text: 'b' }] }],
|
|
578
|
+
['logos', Logos, { logos: [{ image: '/a.png', alt: 'A' }] }],
|
|
579
|
+
['pricing', Pricing, { plans: [{ name: 'P', price: '$1' }] }],
|
|
580
|
+
['hero', Hero, { heading: 'H', body: 'b' }],
|
|
581
|
+
['cards (default)', Cards, { cards: [{ title: 'T' }] }],
|
|
582
|
+
['cards (panel)', Cards, { variant: 'panel', cards: [{ title: 'T' }] }],
|
|
583
|
+
];
|
|
584
|
+
|
|
585
|
+
it.each(samples)(
|
|
586
|
+
'%s: lands on its own element, with no wrapper node',
|
|
587
|
+
async (name, component, fields) => {
|
|
588
|
+
const html = (await render(component, { ...fields, ...hatches })).trim();
|
|
589
|
+
// One id, one added class, the custom property kept and the fetching declaration dropped.
|
|
590
|
+
expect(html.match(/id="x"/g)).toHaveLength(1);
|
|
591
|
+
expect(html).toContain('mt-4');
|
|
592
|
+
expect(html).toContain('block-anchor');
|
|
593
|
+
expect(html).toContain('--a: 1px');
|
|
594
|
+
expect(html).not.toContain('url(');
|
|
595
|
+
expect(html).not.toContain('block-shell');
|
|
596
|
+
// The element carrying it is the first one rendered, except `cards`, whose default variant is
|
|
597
|
+
// a <wc-cards> host around the grid the attributes go on.
|
|
598
|
+
if (!name.startsWith('cards')) expect(html).toMatch(/^<[a-z-]+[^>]*\sid="x"/);
|
|
599
|
+
},
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
it.each(samples)(
|
|
603
|
+
'%s: renders the same markup with none set',
|
|
604
|
+
async (_name, component, fields) => {
|
|
605
|
+
const html = await render(component, fields);
|
|
606
|
+
expect(html).not.toContain('block-anchor');
|
|
607
|
+
expect(html).not.toContain(' id="x"');
|
|
608
|
+
},
|
|
609
|
+
);
|
|
610
|
+
});
|
package/src/astro/index.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* `../block-types.ts` (a `blocks` field entry, keyed by slug), for a site to pass to
|
|
8
8
|
* `defineBlockComponents` / `<Blocks components={…}>`. EmDash does not merge it.
|
|
9
9
|
*/
|
|
10
|
+
import Badges from './blocks/Badges.astro';
|
|
11
|
+
import WidgetArea from './blocks/WidgetArea.astro';
|
|
10
12
|
import Accordion from './blocks/Accordion.astro';
|
|
11
13
|
import ActionLink from './blocks/ActionLink.astro';
|
|
12
14
|
import Banner from './blocks/Banner.astro';
|
|
@@ -52,9 +54,14 @@ export const blockComponents = {
|
|
|
52
54
|
'vitops.form': Form,
|
|
53
55
|
'vitops.reviews': Reviews,
|
|
54
56
|
'vitops.nav': Nav,
|
|
57
|
+
'vitops.badges': Badges,
|
|
58
|
+
'vitops.widgetArea': WidgetArea,
|
|
55
59
|
};
|
|
56
60
|
|
|
57
61
|
/** Keyed by block-type slug (see `SECTION_SLUG`), not by Portable Text `_type`. */
|
|
58
62
|
export const blockTypeComponents = {
|
|
59
63
|
vitops_section: Section,
|
|
60
64
|
};
|
|
65
|
+
|
|
66
|
+
/** The framework-styled widget area, for a site layout (the block form is `vitops.widgetArea`). */
|
|
67
|
+
export { default as VitopsWidgetArea } from './WidgetArea.astro';
|