@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,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
  )}
@@ -0,0 +1,68 @@
1
+ ---
2
+ /**
3
+ * A call-to-action band on the framework's `.callout` pattern. Not `vitops.banner`: that is
4
+ * a dismissible announcement, this is content that stays. The heading is a real heading at
5
+ * the editor's `level`; colour is the shared tone convention, tinted by default and filled
6
+ * with the role's solid when `solid` is on.
7
+ */
8
+ import RichText from './RichText.astro';
9
+ import { resolveTone, joinStyle } from './tone.ts';
10
+ import { headingTag, resolveBlockValue, safeHref, text } from './value.ts';
11
+
12
+ interface Value {
13
+ heading?: string;
14
+ level?: string | number;
15
+ body?: string;
16
+ primaryLabel?: string;
17
+ primaryHref?: string;
18
+ secondaryLabel?: string;
19
+ secondaryHref?: string;
20
+ align?: string;
21
+ solid?: boolean;
22
+ tone?: string;
23
+ color?: string;
24
+ }
25
+
26
+ const value = resolveBlockValue<Value>(Astro.props);
27
+ const heading = text(value.heading);
28
+ const body = text(value.body);
29
+ const primaryLabel = text(value.primaryLabel);
30
+ const primaryHref = safeHref(value.primaryHref);
31
+ const secondaryLabel = text(value.secondaryLabel);
32
+ const secondaryHref = safeHref(value.secondaryHref);
33
+ const hasActions = Boolean((primaryLabel && primaryHref) || (secondaryLabel && secondaryHref));
34
+ const Heading = headingTag(value.level, 2);
35
+ const tone = resolveTone(value.tone, value.color);
36
+ import { rootAttrs } from './attrs.ts';
37
+ ---
38
+
39
+ {
40
+ (heading || body) && (
41
+ <div
42
+ {...rootAttrs(value, ['callout', tone.className, value.solid && 'tone--solid', value.align === 'center' && 'callout--center'], joinStyle(tone.style))}
43
+ >
44
+ <div class="callout__body">
45
+ {heading && <Heading class="callout__title font-title">{heading}</Heading>}
46
+ {body && (
47
+ <p class="callout__text">
48
+ <RichText text={body} />
49
+ </p>
50
+ )}
51
+ </div>
52
+ {hasActions && (
53
+ <p class="callout__actions cluster">
54
+ {primaryLabel && primaryHref && (
55
+ <a class="cta" href={primaryHref}>
56
+ {primaryLabel}
57
+ </a>
58
+ )}
59
+ {secondaryLabel && secondaryHref && (
60
+ <a class="link" href={secondaryHref}>
61
+ {secondaryLabel}
62
+ </a>
63
+ )}
64
+ </p>
65
+ )}
66
+ </div>
67
+ )
68
+ }
@@ -5,15 +5,30 @@
5
5
  * as a whole while its text stays selectable; without JS the stretched link does the
6
6
  * same job. The card CSS is the framework's `.card` pattern.
7
7
  *
8
- * The title is a styled paragraph rather than a fixed heading element: the block can
9
- * sit at any depth in a document, and a hard-coded `<h3>` would skip or repeat levels.
8
+ * **The title is a real heading** at the editor's `level` (default `h3`), in the `heading`
9
+ * type role. It used to be `<p class="font-heading">`: the wrong size for a card in most
10
+ * type scales AND no heading in the outline (UPSTREAM 19). The level is a field because the
11
+ * block can sit at any depth, and a hard-coded one would skip or repeat levels.
12
+ *
13
+ * `variant: panel` is the tinted service-panel card — a tone, a checklist and a button.
14
+ * A panel's whole-card link is dropped (it holds its own button), so it renders as
15
+ * `<Subgrid>` markup rather than a `<Cards>` that would try to forward clicks.
16
+ *
17
+ * The body may carry `[text](url)` inline links; see `linkRuns()`.
10
18
  */
11
19
  import Cards from '@getvitops/astro/components/Cards.astro';
12
- import { resolveBlockValue, rows, safeHref, text } from './value.ts';
20
+ import Subgrid from '@getvitops/astro/components/Subgrid.astro';
21
+ import RichText from './RichText.astro';
22
+ import { joinStyle, resolveTone } from './tone.ts';
23
+ import { headingTag, lines, resolveBlockValue, rows, safeHref, text } from './value.ts';
13
24
 
14
25
  interface Value {
15
26
  cards?: unknown;
16
27
  columns?: string | number;
28
+ variant?: string;
29
+ level?: string | number;
30
+ tone?: string;
31
+ color?: string;
17
32
  }
18
33
 
19
34
  const value = resolveBlockValue<Value>(Astro.props);
@@ -25,20 +40,50 @@ const cards = rows<Record<string, unknown>>(value.cards)
25
40
  imageAlt: text(row.imageAlt),
26
41
  href: safeHref(row.href),
27
42
  linkLabel: text(row.linkLabel) || 'Read more',
43
+ checklist: lines(row.checklist),
44
+ ctaLabel: text(row.ctaLabel),
45
+ ctaHref: safeHref(row.ctaHref),
28
46
  }))
29
47
  .filter((card) => card.title || card.body || card.image);
30
48
  const columns = Math.min(4, Math.max(1, Math.round(Number(value.columns) || 3)));
49
+ const panel = value.variant === 'panel';
50
+ const Heading = headingTag(value.level, 3);
51
+ const tone = resolveTone(value.tone, value.color);
52
+ const Grid = panel ? Subgrid : Cards;
53
+ import { rootAttrs } from './attrs.ts';
31
54
  ---
32
55
 
33
56
  {
34
57
  cards.length > 0 && (
35
- <Cards style={`--subgrid-cols: ${columns}`}>
58
+ <Grid {...rootAttrs(value, [], `--subgrid-cols: ${columns}`)}>
36
59
  {cards.map((card) => (
37
- <li class="card subgrid-card">
60
+ <li
61
+ class:list={['card', 'subgrid-card', panel && tone.className]}
62
+ style={panel ? joinStyle(tone.style) : undefined}
63
+ >
38
64
  {card.image && <img src={card.image} alt={card.imageAlt} loading="lazy" />}
39
- {card.title && <p class="font-heading">{card.title}</p>}
40
- {card.body && <p>{card.body}</p>}
41
- {card.href && (
65
+ {card.title && <Heading class="font-heading">{card.title}</Heading>}
66
+ {card.body && (
67
+ <p>
68
+ <RichText text={card.body} />
69
+ </p>
70
+ )}
71
+ {panel && card.checklist.length > 0 && (
72
+ <ul class="rhythm" role="list">
73
+ {card.checklist.map((item) => (
74
+ <li>{item}</li>
75
+ ))}
76
+ </ul>
77
+ )}
78
+ {panel && card.ctaLabel && card.ctaHref && (
79
+ <p class="subgrid-card__footer">
80
+ <a class="cta" href={card.ctaHref}>
81
+ {card.ctaLabel}
82
+ <span class="sr-only">: {card.title}</span>
83
+ </a>
84
+ </p>
85
+ )}
86
+ {!panel && card.href && (
42
87
  <a class="link stretched-link" href={card.href}>
43
88
  {card.linkLabel}
44
89
  {/* "Read more" alone is meaningless in a list of links; name the card for AT. */}
@@ -47,6 +92,6 @@ const columns = Math.min(4, Math.max(1, Math.round(Number(value.columns) || 3)))
47
92
  )}
48
93
  </li>
49
94
  ))}
50
- </Cards>
95
+ </Grid>
51
96
  )
52
97
  }
@@ -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>
@@ -0,0 +1,68 @@
1
+ ---
2
+ /**
3
+ * A video or map that loads only after the visitor allows it.
4
+ *
5
+ * The consent gate's rule (AGENTS.md, "The consent gate"): never give a gated element a
6
+ * live address. The iframe is emitted with NO `src`; the address rides on
7
+ * `data-consent-src`, which the consent runtime (`@getvitops/core/consent`) assigns once the
8
+ * category is granted — and, because the element reaches its loading strategy, raises the
9
+ * banner for that category rather than waiting for something else to ask. Until then the
10
+ * page issues no request to the provider. With no JS the gate never runs, so the
11
+ * placeholder's plain link to the same content is what the visitor gets.
12
+ *
13
+ * A `title` is required (WCAG 4.1.2 names the frame); without one, or with an address the
14
+ * provider rules in `embed.ts` refuse, the block renders nothing rather than an unnamed or
15
+ * unsafe frame. Only categories the consent store defines are accepted.
16
+ */
17
+ import { resolveEmbed } from './embed.ts';
18
+ import { resolveBlockValue, text } from './value.ts';
19
+
20
+ interface Value {
21
+ provider?: string;
22
+ url?: string;
23
+ title?: string;
24
+ category?: string;
25
+ ratio?: string;
26
+ }
27
+
28
+ const CATEGORIES = new Set(['marketing', 'preferences', 'analytics']);
29
+
30
+ const value = resolveBlockValue<Value>(Astro.props);
31
+ const title = text(value.title);
32
+ const embed = resolveEmbed(value.provider, value.url);
33
+ const category = CATEGORIES.has(text(value.category)) ? text(value.category) : 'marketing';
34
+ // A ratio lands in a style attribute: digits, slash and spaces only.
35
+ const ratio = /^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(text(value.ratio)) ? text(value.ratio) : null;
36
+ import { rootAttrs } from './attrs.ts';
37
+ ---
38
+
39
+ {
40
+ title && embed && (
41
+ <div {...rootAttrs(value, ['embed'], ratio ? `--embed-ratio: ${ratio}` : undefined)}>
42
+ <iframe
43
+ class="embed__frame"
44
+ title={title}
45
+ data-consent={category}
46
+ data-consent-src={embed.src}
47
+ loading="lazy"
48
+ referrerpolicy="strict-origin-when-cross-origin"
49
+ allow="fullscreen; picture-in-picture"
50
+ />
51
+ <div class="embed__placeholder">
52
+ <p class="font-heading">{title}</p>
53
+ <p>
54
+ This content is provided by {embed.provider}. It loads once you allow {category} cookies.
55
+ </p>
56
+ <p class="cluster">
57
+ <button type="button" class="btn" data-consent-open>
58
+ Cookie settings
59
+ </button>
60
+ <a class="link" href={embed.openUrl} rel="noopener noreferrer">
61
+ Open on {embed.provider}
62
+ <span class="sr-only">: {title}</span>
63
+ </a>
64
+ </p>
65
+ </div>
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,31 @@
1
+ ---
2
+ /**
3
+ * Thin adapter over `@getvitops/astro`'s `<Entries />`, which emits `<wc-entries>` itself —
4
+ * so this must not add its own wrapper. Stacked heading + `<dl>` pairs with no JS, a table
5
+ * with it. The editor writes each row's details as `Term | Value` lines.
6
+ */
7
+ import Entries from '@getvitops/astro/components/Entries.astro';
8
+ import { headingTag, pairs, resolveBlockValue, rows, text } from './value.ts';
9
+
10
+ interface Value {
11
+ entries?: unknown;
12
+ level?: string | number;
13
+ breakpoint?: string;
14
+ }
15
+
16
+ const value = resolveBlockValue<Value>(Astro.props);
17
+ const entries = rows<Record<string, unknown>>(value.entries)
18
+ .map((row) => ({
19
+ heading: text(row.heading),
20
+ pairs: pairs(row.pairs).filter((pair) => pair.term),
21
+ }))
22
+ .filter((entry) => entry.heading && entry.pairs.length > 0);
23
+ const level = Number(headingTag(value.level, 3).slice(1)) as 2 | 3 | 4 | 5 | 6;
24
+ // A breakpoint lands in an attribute the element parses as a CSS length; keep it to one.
25
+ const breakpoint = /^\d+(\.\d+)?(rem|em|px|ch)$/.test(text(value.breakpoint))
26
+ ? text(value.breakpoint)
27
+ : undefined;
28
+ import { rootAttrs } from './attrs.ts';
29
+ ---
30
+
31
+ {entries.length > 0 && <Entries entries={entries} level={level} breakpoint={breakpoint} {...rootAttrs(value)} />}