@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,91 @@
1
+ ---
2
+ /**
3
+ * A widget area rendered in the framework's vocabulary, in place of `emdash/ui`'s
4
+ * `<WidgetArea>`: titles at a chosen heading level (the stock one is always `h3`), a menu as a
5
+ * labelled `<nav>` of `.link`s, content through `<PortableText>` so every `vitops.*` block
6
+ * works inside it. Renders nothing when the area is missing or empty.
7
+ *
8
+ * Two limits, both deliberate:
9
+ * - **No widget area inside a widget area.** A content widget that held a `vitops.widgetArea`
10
+ * block could name its own area and recurse, and a request-scoped depth counter cannot be kept
11
+ * honest while Astro streams children, so the block is simply not rendered in here.
12
+ * - **`component` widgets** (`core:recent-posts`, …) are EmDash's closed map and its renderer is
13
+ * not exported, so an area that holds one is handed whole to the stock `<WidgetArea>`.
14
+ *
15
+ * Prerendered pages bake the area at build time: an edit in the admin shows after the next deploy.
16
+ */
17
+ import { getMenu, getWidgetArea } from 'emdash';
18
+ import { PortableText, WidgetArea as StockWidgetArea } from 'emdash/ui';
19
+ import Nothing from './Nothing.astro';
20
+ import { headingTag, safeHref, text } from './blocks/value.ts';
21
+
22
+ interface Props {
23
+ /** The area's `name` as set under Widgets. */
24
+ name: string;
25
+ /** Level of each widget's title; default `3`. */
26
+ level?: number | string;
27
+ class?: string;
28
+ id?: string;
29
+ style?: string;
30
+ }
31
+
32
+ const { name, level, class: className, id, style } = Astro.props;
33
+ const area = name ? await getWidgetArea(name) : null;
34
+ const widgets = area?.widgets ?? [];
35
+ const delegated = widgets.some((w) => w.type === 'component');
36
+ const Heading = headingTag(level, 3);
37
+ const menus = Object.fromEntries(
38
+ await Promise.all(
39
+ widgets
40
+ .filter((w) => w.type === 'menu' && w.menuName)
41
+ .map(async (w) => [w.id, await getMenu(w.menuName!)] as const),
42
+ ),
43
+ );
44
+ const inner = { type: { 'vitops.widgetArea': Nothing } };
45
+ ---
46
+
47
+ {
48
+ widgets.length > 0 && (
49
+ <div id={id} class:list={['widget-area', className]} style={style} data-widget-area={area?.name}>
50
+ {delegated ? (
51
+ <StockWidgetArea name={name} />
52
+ ) : (
53
+ <>
54
+ {widgets.map((widget) => (
55
+ <section class="widget" data-widget-type={widget.type}>
56
+ {text(widget.title) && widget.type !== 'menu' && (
57
+ <Heading class="widget__title font-heading">{widget.title}</Heading>
58
+ )}
59
+ {widget.type === 'content' && widget.content && (
60
+ <div class="widget__content rhythm">
61
+ <PortableText value={widget.content} components={inner} />
62
+ </div>
63
+ )}
64
+ {widget.type === 'menu' && menus[widget.id] && (
65
+ <>
66
+ {text(widget.title) && <Heading class="widget__title font-heading">{widget.title}</Heading>}
67
+ <nav class="widget__menu" aria-label={text(widget.title) || area?.label}>
68
+ <ul role="list">
69
+ {menus[widget.id]!.items.map((item) => {
70
+ const href = safeHref(item.url);
71
+ return (
72
+ href && (
73
+ <li>
74
+ <a class="link" href={href} title={item.titleAttr || undefined}>
75
+ {item.label}
76
+ </a>
77
+ </li>
78
+ )
79
+ );
80
+ })}
81
+ </ul>
82
+ </nav>
83
+ </>
84
+ )}
85
+ </section>
86
+ ))}
87
+ </>
88
+ )}
89
+ </div>
90
+ )
91
+ }
@@ -21,11 +21,12 @@ const items = rows<Record<string, unknown>>(value.items)
21
21
  }))
22
22
  .filter((item) => item.summary);
23
23
  const group = value.exclusive ? `accordion-${value._key ?? 'group'}` : undefined;
24
+ import { rootAttrs } from './attrs.ts';
24
25
  ---
25
26
 
26
27
  {
27
28
  items.length > 0 && (
28
- <div class="accordion">
29
+ <div {...rootAttrs(value, ['accordion'])}>
29
30
  {items.map((item) => (
30
31
  <details name={group} open={item.open}>
31
32
  <summary>{item.summary}</summary>
@@ -51,12 +51,13 @@ const classes = [variant, role ? `${variant}-${role}` : null, newTab ? 'link--ex
51
51
  // Only render when there is somewhere to go and something to read — a bare icon
52
52
  // with no label would be an unlabelled link.
53
53
  const renderable = Boolean(label && href);
54
+ import { rootAttrs } from './attrs.ts';
54
55
  ---
55
56
 
56
57
  {
57
58
  renderable && (
58
59
  <a
59
- class={classes}
60
+ {...rootAttrs(value, [classes])}
60
61
  href={href}
61
62
  {...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}>
62
63
  {startIcon && (
@@ -0,0 +1,67 @@
1
+ ---
2
+ /**
3
+ * `vitops.badges` — a row of short labels on the framework's `.badge` (static) or `.tag`
4
+ * pattern. Each row may carry its own tone, falling back to the block's, which is what lets
5
+ * one block show a pine label beside an amber one. An icon is child order, not a modifier, and
6
+ * a row with a `href` becomes a link.
7
+ */
8
+ import { resolveTone } from './tone.ts';
9
+ import { resolveBlockValue, rows, safeHref, text } from './value.ts';
10
+ import { rootAttrs } from './attrs.ts';
11
+
12
+ interface Props {
13
+ /** Base URL of the sprite. Matches VitopsEmdashOptions.wcBase. */
14
+ spriteBase?: string;
15
+ }
16
+
17
+ interface Value {
18
+ items?: unknown;
19
+ variant?: string;
20
+ tone?: string;
21
+ color?: string;
22
+ }
23
+
24
+ const value = resolveBlockValue<Value>(Astro.props);
25
+ const { spriteBase = '/vitops' } = Astro.props as Props;
26
+ const kind = text(value.variant) === 'tag' ? 'tag' : 'badge';
27
+ const items = rows<Record<string, unknown>>(value.items)
28
+ .map((row) => {
29
+ const own = text(row.tone) || text(row.color);
30
+ const tone = own ? resolveTone(row.tone, row.color) : resolveTone(value.tone, value.color);
31
+ return {
32
+ label: text(row.label),
33
+ icon: /^[a-z0-9-]+$/.test(text(row.icon)) ? text(row.icon) : '',
34
+ href: safeHref(row.href),
35
+ tone,
36
+ };
37
+ })
38
+ .filter((item) => item.label);
39
+ ---
40
+
41
+ {
42
+ items.length > 0 && (
43
+ <ul {...rootAttrs(value, ['cluster'])} role="list">
44
+ {items.map((item) => {
45
+ const Tag = item.href ? 'a' : 'span';
46
+ return (
47
+ <li>
48
+ <Tag
49
+ class:list={[kind, item.tone.style && item.tone.className, item.href && 'link']}
50
+ href={item.href || undefined}
51
+ style={item.tone.style || undefined}
52
+ >
53
+ {item.icon && (
54
+ <span class="icon" aria-hidden="true">
55
+ <svg>
56
+ <use href={`${spriteBase}/icons.svg#icon-${item.icon}`} />
57
+ </svg>
58
+ </span>
59
+ )}
60
+ {item.label}
61
+ </Tag>
62
+ </li>
63
+ );
64
+ })}
65
+ </ul>
66
+ )
67
+ }
@@ -10,10 +10,11 @@ interface Value {
10
10
  const value = resolveBlockValue<Value>(Astro.props);
11
11
  const { message, tone = 'info', dismissible = true } = value;
12
12
  const bannerClass = `banner bg-${tone}${dismissible ? ' banner--dismissible' : ''}`;
13
+ import { rootAttrs } from './attrs.ts';
13
14
  ---
14
15
 
15
16
  {message && dismissible && (
16
- <wc-dismissable>
17
+ <wc-dismissable {...rootAttrs(value)}>
17
18
  <div class={bannerClass}>
18
19
  <div class="banner__content">{message}</div>
19
20
  <button class="banner__close" data-dismiss aria-label="Dismiss">✕</button>
@@ -21,7 +22,7 @@ const bannerClass = `banner bg-${tone}${dismissible ? ' banner--dismissible' : '
21
22
  </wc-dismissable>
22
23
  )}
23
24
  {message && !dismissible && (
24
- <div class={bannerClass}>
25
+ <div {...rootAttrs(value, [bannerClass])}>
25
26
  <div class="banner__content">{message}</div>
26
27
  </div>
27
28
  )}
@@ -33,18 +33,13 @@ const secondaryHref = safeHref(value.secondaryHref);
33
33
  const hasActions = Boolean((primaryLabel && primaryHref) || (secondaryLabel && secondaryHref));
34
34
  const Heading = headingTag(value.level, 2);
35
35
  const tone = resolveTone(value.tone, value.color);
36
+ import { rootAttrs } from './attrs.ts';
36
37
  ---
37
38
 
38
39
  {
39
40
  (heading || body) && (
40
41
  <div
41
- class:list={[
42
- 'callout',
43
- tone.className,
44
- value.solid && 'tone--solid',
45
- value.align === 'center' && 'callout--center',
46
- ]}
47
- style={joinStyle(tone.style)}
42
+ {...rootAttrs(value, ['callout', tone.className, value.solid && 'tone--solid', value.align === 'center' && 'callout--center'], joinStyle(tone.style))}
48
43
  >
49
44
  <div class="callout__body">
50
45
  {heading && <Heading class="callout__title font-title">{heading}</Heading>}
@@ -50,11 +50,12 @@ const panel = value.variant === 'panel';
50
50
  const Heading = headingTag(value.level, 3);
51
51
  const tone = resolveTone(value.tone, value.color);
52
52
  const Grid = panel ? Subgrid : Cards;
53
+ import { rootAttrs } from './attrs.ts';
53
54
  ---
54
55
 
55
56
  {
56
57
  cards.length > 0 && (
57
- <Grid style={`--subgrid-cols: ${columns}`}>
58
+ <Grid {...rootAttrs(value, [], `--subgrid-cols: ${columns}`)}>
58
59
  {cards.map((card) => (
59
60
  <li
60
61
  class:list={['card', 'subgrid-card', panel && tone.className]}
@@ -39,6 +39,7 @@ const slides = fromRepeater.length > 0 ? fromRepeater : fromLegacy;
39
39
  // Two blocks with the same label would derive the same id; `_key` is unique per block.
40
40
  const id = value._key ? `carousel-${value._key.replace(/[^a-z0-9_-]/gi, '')}` : undefined;
41
41
  const autoplay = Number(value.autoplay) || 0;
42
+ import { rootAttrs } from './attrs.ts';
42
43
  ---
43
44
 
44
45
  {
@@ -50,6 +51,7 @@ const autoplay = Number(value.autoplay) || 0;
50
51
  aspect={value.aspect || '16 / 9'}
51
52
  slides={slides}
52
53
  id={id}
54
+ {...rootAttrs(value)}
53
55
  />
54
56
  )
55
57
  }
@@ -9,14 +9,15 @@ interface Value {
9
9
 
10
10
  const value = resolveBlockValue<Value>(Astro.props);
11
11
  const { text, label = 'Copy', display = 'code-block' } = value;
12
+ import { rootAttrs } from './attrs.ts';
12
13
  ---
13
14
 
14
15
  {text && display === 'code-block' && (
15
- <div class="copy">
16
+ <div {...rootAttrs(value, ['copy'])}>
16
17
  <pre><code>{text}</code></pre>
17
18
  <wc-copy value={text} label={label}>{label}</wc-copy>
18
19
  </div>
19
20
  )}
20
21
  {text && display === 'inline' && (
21
- <wc-copy value={text} label={label}><code>{text}</code></wc-copy>
22
+ <wc-copy {...rootAttrs(value)} value={text} label={label}><code>{text}</code></wc-copy>
22
23
  )}
@@ -9,10 +9,11 @@ interface Value {
9
9
 
10
10
  const value = resolveBlockValue<Value>(Astro.props);
11
11
  const { summary, body, open = false } = value;
12
+ import { rootAttrs } from './attrs.ts';
12
13
  ---
13
14
 
14
15
  {summary && (
15
- <details open={open}>
16
+ <details open={open} {...rootAttrs(value)}>
16
17
  <summary>{summary}</summary>
17
18
  <p>{body}</p>
18
19
  </details>
@@ -33,11 +33,12 @@ const embed = resolveEmbed(value.provider, value.url);
33
33
  const category = CATEGORIES.has(text(value.category)) ? text(value.category) : 'marketing';
34
34
  // A ratio lands in a style attribute: digits, slash and spaces only.
35
35
  const ratio = /^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(text(value.ratio)) ? text(value.ratio) : null;
36
+ import { rootAttrs } from './attrs.ts';
36
37
  ---
37
38
 
38
39
  {
39
40
  title && embed && (
40
- <div class="embed" style={ratio ? `--embed-ratio: ${ratio}` : undefined}>
41
+ <div {...rootAttrs(value, ['embed'], ratio ? `--embed-ratio: ${ratio}` : undefined)}>
41
42
  <iframe
42
43
  class="embed__frame"
43
44
  title={title}
@@ -25,6 +25,7 @@ const level = Number(headingTag(value.level, 3).slice(1)) as 2 | 3 | 4 | 5 | 6;
25
25
  const breakpoint = /^\d+(\.\d+)?(rem|em|px|ch)$/.test(text(value.breakpoint))
26
26
  ? text(value.breakpoint)
27
27
  : undefined;
28
+ import { rootAttrs } from './attrs.ts';
28
29
  ---
29
30
 
30
- {entries.length > 0 && <Entries entries={entries} level={level} breakpoint={breakpoint} />}
31
+ {entries.length > 0 && <Entries entries={entries} level={level} breakpoint={breakpoint} {...rootAttrs(value)} />}
@@ -64,11 +64,12 @@ const tone = resolveTone(value.tone, value.color);
64
64
  const successTone = resolveTone('success', '');
65
65
  const errorTone = resolveTone('danger', '');
66
66
  const hasForm = fields.length > 0;
67
+ import { rootAttrs } from './attrs.ts';
67
68
  ---
68
69
 
69
70
  {
70
71
  hasForm && (
71
- <div class:list={['form-block', 'rhythm', tone.className]} style={joinStyle(tone.style)}>
72
+ <div {...rootAttrs(value, ['form-block', 'rhythm', tone.className], joinStyle(tone.style))}>
72
73
  {heading && <Heading class="font-heading">{heading}</Heading>}
73
74
  {intro && <p>{intro}</p>}
74
75
 
@@ -25,6 +25,7 @@ const images = rows<{ src?: unknown; alt?: unknown; caption?: unknown }>(value.i
25
25
  return src ? [{ src, alt: text(row.alt), ...(caption ? { caption } : {}) }] : [];
26
26
  },
27
27
  );
28
+ import { rootAttrs } from './attrs.ts';
28
29
  ---
29
30
 
30
31
  {
@@ -33,6 +34,7 @@ const images = rows<{ src?: unknown; alt?: unknown; caption?: unknown }>(value.i
33
34
  label={text(value.label) || 'Gallery'}
34
35
  images={images}
35
36
  id={id}
37
+ {...rootAttrs(value)}
36
38
  aspect={text(value.aspect) || undefined}
37
39
  loop={Boolean(value.loop)}
38
40
  />
@@ -57,11 +57,12 @@ const cards = rows<Record<string, unknown>>(value.cards)
57
57
  linkLabel: text(row.linkLabel) || 'Learn more',
58
58
  }))
59
59
  .filter((card) => card.title || card.body);
60
+ import { rootAttrs } from './attrs.ts';
60
61
  ---
61
62
 
62
63
  {
63
64
  (heading || body) && (
64
- <section class={tone.style ? tone.className : undefined} style={joinStyle(tone.style)}>
65
+ <section {...rootAttrs(value, [tone.style && tone.className], joinStyle(tone.style))}>
65
66
  <div class={split ? 'split split-1-2' : 'centered'}>
66
67
  <div class="rhythm">
67
68
  {eyebrow && <p class="font-eyebrow">{eyebrow}</p>}
@@ -15,10 +15,11 @@ const { beforeAlt = '', beforeLabel = 'Before', afterAlt = '', afterLabel = 'Aft
15
15
  // A `javascript:`/`data:` src is dropped, so the block renders nothing rather than that image.
16
16
  const before = safeHref(value.before);
17
17
  const after = safeHref(value.after);
18
+ import { rootAttrs } from './attrs.ts';
18
19
  ---
19
20
 
20
21
  {before && after && (
21
- <wc-image-compare before-label={beforeLabel} after-label={afterLabel}>
22
+ <wc-image-compare {...rootAttrs(value)} before-label={beforeLabel} after-label={afterLabel}>
22
23
  <img slot="before" src={before} alt={beforeAlt} loading="lazy" />
23
24
  <img slot="after" src={after} alt={afterAlt} loading="lazy" />
24
25
  </wc-image-compare>
@@ -22,15 +22,15 @@ const logos = rows<Record<string, unknown>>(value.logos)
22
22
  .map((row) => ({ image: safeHref(row.image), alt: text(row.alt), href: safeHref(row.href) }))
23
23
  .filter((logo) => logo.image);
24
24
  const height = HEIGHTS[text(value.size)];
25
+ import { rootAttrs } from './attrs.ts';
25
26
  ---
26
27
 
27
28
  {
28
29
  logos.length > 0 && (
29
30
  <ul
30
- class:list={['logos', value.grayscale && 'logos--grayscale']}
31
+ {...rootAttrs(value, ['logos', value.grayscale && 'logos--grayscale'], height ? `--logos-height: ${height}` : undefined)}
31
32
  role="list"
32
33
  aria-label={label}
33
- style={height ? `--logos-height: ${height}` : undefined}
34
34
  >
35
35
  {logos.map((logo) => (
36
36
  <li class="logos__item">
@@ -21,11 +21,12 @@ const items = rows<Record<string, unknown>>(value.items)
21
21
  alt: text(row.alt),
22
22
  }))
23
23
  .filter((item) => item.text || item.image);
24
+ import { rootAttrs } from './attrs.ts';
24
25
  ---
25
26
 
26
27
  {
27
28
  items.length > 0 && (
28
- <Marquee pauseOnHover={Boolean(value.pauseOnHover)} reverse={Boolean(value.reverse)}>
29
+ <Marquee pauseOnHover={Boolean(value.pauseOnHover)} reverse={Boolean(value.reverse)} {...rootAttrs(value)}>
29
30
  {items.map((item) =>
30
31
  item.image ? (
31
32
  <img src={item.image} alt={item.alt} loading="lazy" />
@@ -60,13 +60,13 @@ const tone = resolveTone(value.tone, value.color);
60
60
  const toned = Boolean(value.tone || value.color);
61
61
  const trimmed = trimDepth(items, depth);
62
62
  const current = Astro.url.pathname;
63
+ import { rootAttrs } from './attrs.ts';
63
64
  ---
64
65
 
65
66
  {
66
67
  items.length > 0 && (
67
68
  <div
68
- class:list={['navblock', toned && tone.className]}
69
- style={toned ? joinStyle(tone.style) : undefined}
69
+ {...rootAttrs(value, ['navblock', toned && tone.className], toned ? joinStyle(tone.style) : undefined)}
70
70
  >
71
71
  {heading && <Heading class="font-heading">{heading}</Heading>}
72
72
  {variant === 'sitenav' ? (
@@ -28,14 +28,14 @@ const plans = rows<Record<string, unknown>>(value.plans)
28
28
  .filter((plan) => plan.name || plan.price);
29
29
  const Heading = headingTag(value.level, 3);
30
30
  const columns = Math.min(4, Math.max(1, plans.length));
31
+ import { rootAttrs } from './attrs.ts';
31
32
  ---
32
33
 
33
34
  {
34
35
  plans.length > 0 && (
35
36
  <ul
36
- class="pricing-grid"
37
+ {...rootAttrs(value, ['pricing-grid'], `--pricing-columns: ${columns}; --pricing-card-rows: 4`)}
37
38
  role="list"
38
- style={`--pricing-columns: ${columns}; --pricing-card-rows: 4`}
39
39
  >
40
40
  {plans.map((plan) => (
41
41
  <li class:list={['pricing-card', plan.featured && 'pricing-card--featured']}>
@@ -75,13 +75,13 @@ const avatar = Boolean(value.avatars);
75
75
  const tone = resolveTone(value.tone, value.color);
76
76
  const toned = Boolean(value.tone || value.color);
77
77
  const dev = import.meta.env.DEV;
78
+ import { rootAttrs } from './attrs.ts';
78
79
  ---
79
80
 
80
81
  {
81
82
  reviews.length > 0 ? (
82
83
  <div
83
- class:list={['reviews', toned && tone.className]}
84
- style={toned ? joinStyle(tone.style) : undefined}
84
+ {...rootAttrs(value, ['reviews', toned && tone.className], toned ? joinStyle(tone.style) : undefined)}
85
85
  >
86
86
  {layout === 'carousel' ? (
87
87
  <Carousel label={label}>
@@ -30,6 +30,7 @@ interface Value {
30
30
  tone?: string;
31
31
  color?: string;
32
32
  solid?: boolean;
33
+ theme?: string;
33
34
  }
34
35
 
35
36
  const WIDTHS: Record<string, string> = {
@@ -44,24 +45,21 @@ const heading = text(value.heading);
44
45
  const sub = text(value.sub);
45
46
  const body = Array.isArray(value.body) ? value.body : [];
46
47
  const Heading = headingTag(value.level, 2);
47
- const tone = resolveTone(value.tone, value.color);
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);
48
53
  const layout = text(value.layout);
49
54
  const width = WIDTHS[text(value.width)];
50
55
  const hasHead = Boolean(kicker || heading || sub);
56
+ import { rootAttrs } from './attrs.ts';
51
57
  ---
52
58
 
53
59
  {
54
60
  (hasHead || body.length > 0) && (
55
61
  <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)}
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))}
65
63
  >
66
64
  <div class="centered">
67
65
  <div class:list={['section__inner', width]}>
@@ -35,14 +35,14 @@ const stats = rows<Record<string, unknown>>(value.stats)
35
35
  const tiles = value.variant === 'tiles';
36
36
  const animate = value.animate !== false;
37
37
  const tone = resolveTone(value.tone, value.color);
38
+ import { rootAttrs } from './attrs.ts';
38
39
  ---
39
40
 
40
41
  {
41
42
  stats.length > 0 && (
42
43
  <ul
43
- class:list={['stats', tone.className, tiles && 'stats--tiles']}
44
+ {...rootAttrs(value, ['stats', tone.className, tiles && 'stats--tiles'], joinStyle(tone.style))}
44
45
  role="list"
45
- style={joinStyle(tone.style)}
46
46
  >
47
47
  {stats.map((stat) => (
48
48
  <li class="stats__item" data-state={stat.state}>
@@ -23,13 +23,13 @@ const steps = rows<Record<string, unknown>>(value.steps)
23
23
  .filter((step) => step.title || step.text);
24
24
  const Heading = headingTag(value.level, 3);
25
25
  const tone = resolveTone(value.tone, value.color);
26
+ import { rootAttrs } from './attrs.ts';
26
27
  ---
27
28
 
28
29
  {
29
30
  steps.length > 0 && (
30
31
  <ol
31
- class:list={['steps', tone.className, value.layout === 'row' && 'steps--row']}
32
- style={joinStyle(tone.style)}
32
+ {...rootAttrs(value, ['steps', tone.className, value.layout === 'row' && 'steps--row'], joinStyle(tone.style))}
33
33
  >
34
34
  {steps.map((step) => (
35
35
  <li class="steps__item">
@@ -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
+ });