@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/index.d.mts +1 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +152 -3
  5. package/dist/index.mjs.map +1 -1
  6. package/package.json +5 -5
  7. package/src/astro/Nothing.astro +3 -0
  8. package/src/astro/WidgetArea.astro +91 -0
  9. package/src/astro/blocks/Accordion.astro +2 -1
  10. package/src/astro/blocks/ActionLink.astro +2 -1
  11. package/src/astro/blocks/Badges.astro +67 -0
  12. package/src/astro/blocks/Banner.astro +3 -2
  13. package/src/astro/blocks/Callout.astro +2 -7
  14. package/src/astro/blocks/Cards.astro +2 -1
  15. package/src/astro/blocks/Carousel.astro +2 -0
  16. package/src/astro/blocks/CopyButton.astro +3 -2
  17. package/src/astro/blocks/Details.astro +2 -1
  18. package/src/astro/blocks/Embed.astro +2 -1
  19. package/src/astro/blocks/Entries.astro +2 -1
  20. package/src/astro/blocks/Form.astro +2 -1
  21. package/src/astro/blocks/Gallery.astro +2 -0
  22. package/src/astro/blocks/Hero.astro +2 -1
  23. package/src/astro/blocks/ImageCompare.astro +2 -1
  24. package/src/astro/blocks/Logos.astro +2 -2
  25. package/src/astro/blocks/Marquee.astro +2 -1
  26. package/src/astro/blocks/Nav.astro +2 -2
  27. package/src/astro/blocks/Pricing.astro +2 -2
  28. package/src/astro/blocks/Reviews.astro +2 -2
  29. package/src/astro/blocks/Section.astro +8 -10
  30. package/src/astro/blocks/Stats.astro +2 -2
  31. package/src/astro/blocks/Steps.astro +2 -2
  32. package/src/astro/blocks/WidgetArea.astro +21 -0
  33. package/src/astro/blocks/attrs.test.ts +56 -0
  34. package/src/astro/blocks/attrs.ts +104 -0
  35. package/src/astro/blocks/render.container.test.ts +40 -0
  36. 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
+ });
@@ -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';