@sarimarcus/content-sites-ui 0.9.0 → 0.10.0

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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.10.0 (2026-09-30)
6
+
7
+ - New `./composed`: `TableOfContents` (+ `miniLinkClass`, opt-in `hideMiniPastLastSection`), `CardCarousel` (active dot from `--carousel-dot`), `QuickFacts` (link colours as roles, `labelSizeClass`), `FAQ` and `InlineImage` (rendered HTML in, via `TrustedHtml`; markdown stays in the site), `Pagination` (`href(page)` builder). Controllers are bundled modules, initialised once per instance. `./types`: `TrustedHtml`, `trustedHtml()`, `textAsHtml()` (SLA-2732).
8
+
9
+ ## 0.9.1 (2026-09-30)
10
+
11
+ - No changes; lockstep release with core (SLA-2747).
12
+
5
13
  ## 0.9.0 (2026-09-30)
6
14
 
7
15
  - No changes; lockstep release with core (SLA-2747).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Universal Astro UI components, layout shell and design tokens shared by the content sites.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,6 @@
42
42
  "@lucide/astro": "^1.23.0",
43
43
  "astro": "^7.0.6",
44
44
  "tailwindcss": "^4.1.18",
45
- "@sarimarcus/content-sites-core": "0.9.0"
45
+ "@sarimarcus/content-sites-core": "0.10.0"
46
46
  }
47
47
  }
@@ -0,0 +1,134 @@
1
+ ---
2
+ /**
3
+ * CardCarousel — scroll-snap card strip with prev/next arrows and dots, driven by ./carousel.ts.
4
+ *
5
+ * Callers place each card as a direct child of the default slot, carrying `snap-start shrink-0` and a fixed
6
+ * responsive width, e.g. `<a class="snap-start shrink-0 w-[80vw] sm:w-72">…</a>`.
7
+ *
8
+ * The header always renders so the arrows exist even under a caller's own heading; the view-all link shows at every
9
+ * width, so a capped carousel always offers an onward path (SLA-2368). The active dot takes `--carousel-dot` when the
10
+ * site defines it (default: the primary role).
11
+ */
12
+ import Icon from '../primitives/Icon.astro';
13
+ import { deterministicId } from '@sarimarcus/content-sites-core/dom';
14
+
15
+ interface Props {
16
+ /** Section heading rendered above the track. */
17
+ heading?: string;
18
+ headingTag?: 'h2' | 'h3';
19
+ /**
20
+ * Full replacement for the heading's classes (not appended — a merged list
21
+ * would keep the default `sm:text-3xl` winning at that breakpoint). Use when
22
+ * a caller needs a quieter heading than the default section scale, e.g. a
23
+ * subordinate related-content block sitting under a page `h2`.
24
+ */
25
+ headingClass?: string;
26
+ description?: string;
27
+ /** Anchor id for in-page jump links / scroll-spy. */
28
+ id?: string;
29
+ /** Optional "view all" link shown next to the arrows. */
30
+ viewAllHref?: string;
31
+ viewAllLabel?: string;
32
+ /** Accessible label for the scroll region (falls back to the heading). */
33
+ ariaLabel?: string;
34
+ class?: string;
35
+ }
36
+
37
+ const {
38
+ heading,
39
+ headingTag = 'h2',
40
+ headingClass,
41
+ description,
42
+ id,
43
+ viewAllHref,
44
+ viewAllLabel = 'View all',
45
+ ariaLabel,
46
+ class: className,
47
+ } = Astro.props;
48
+
49
+ const HeadingTag = headingTag;
50
+
51
+ // Ids must be unique per page AND stable across builds.
52
+ const containerId = deterministicId('cc', Astro.url.pathname, id, ariaLabel ?? heading);
53
+ const trackId = `${containerId}-t`;
54
+ ---
55
+
56
+ <div id={id} data-carousel={containerId} class:list={['scroll-mt-28', className]}>
57
+ <div class="mb-5 flex items-end justify-between gap-4">
58
+ <div class="min-w-0 flex-1">
59
+ {heading && (
60
+ <HeadingTag class={headingClass ?? 'font-display text-2xl font-medium sm:text-3xl'}>{heading}</HeadingTag>
61
+ )}
62
+ {description && (
63
+ <p class="mt-1 text-sm leading-relaxed text-muted-foreground sm:text-base">{description}</p>
64
+ )}
65
+ <slot name="header-note" />
66
+ </div>
67
+
68
+ <div class="flex shrink-0 items-center gap-2">
69
+ {viewAllHref && (
70
+ <a
71
+ href={viewAllHref}
72
+ class="mr-1 inline-flex items-center gap-1.5 text-sm font-medium text-primary underline decoration-primary/40 underline-offset-2 transition-colors hover:decoration-primary"
73
+ >
74
+ {viewAllLabel}
75
+ <Icon name="arrow-right" class="h-4 w-4" aria-hidden="true" />
76
+ </a>
77
+ )}
78
+ <div data-carousel-arrows class="hidden gap-2">
79
+ <button
80
+ data-carousel-prev
81
+ type="button"
82
+ aria-label={`Previous, ${ariaLabel ?? heading ?? 'carousel'}`}
83
+ disabled
84
+ class="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card transition-colors hover:border-primary/40 hover:bg-primary/10 disabled:pointer-events-none disabled:opacity-25"
85
+ >
86
+ <Icon name="chevron-left" class="h-4 w-4" aria-hidden="true" />
87
+ </button>
88
+ <button
89
+ data-carousel-next
90
+ type="button"
91
+ aria-label={`Next, ${ariaLabel ?? heading ?? 'carousel'}`}
92
+ class="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card transition-colors hover:border-primary/40 hover:bg-primary/10 disabled:pointer-events-none disabled:opacity-25"
93
+ >
94
+ <Icon name="chevron-right" class="h-4 w-4" aria-hidden="true" />
95
+ </button>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ <div
100
+ id={trackId}
101
+ data-carousel-track
102
+ class="cc-track -mx-4 flex gap-4 overflow-x-auto scroll-px-4 px-4 py-1.5 snap-x snap-mandatory sm:mx-0 sm:scroll-px-0 sm:px-0"
103
+ aria-label={ariaLabel ?? heading ?? 'Carousel'}
104
+ role="group"
105
+ tabindex="0"
106
+ >
107
+ <slot />
108
+ </div>
109
+ <div data-carousel-dots class="mt-4 hidden justify-center gap-2"></div>
110
+ </div>
111
+
112
+ <style is:global>
113
+ /* Hide the scrollbar while keeping the track scrollable/swipable. */
114
+ .cc-track {
115
+ -ms-overflow-style: none;
116
+ scrollbar-width: none;
117
+ }
118
+ .cc-track::-webkit-scrollbar {
119
+ display: none;
120
+ }
121
+ /* The track is a keyboard focus stop (tabindex="0", role="group"), so it needs its own visible focus ring. */
122
+ .cc-track:focus-visible {
123
+ outline: 2px solid var(--color-primary);
124
+ outline-offset: 2px;
125
+ border-radius: var(--radius-lg, 8px);
126
+ }
127
+ </style>
128
+
129
+ <script>
130
+ import { initCarousels } from './carousel.ts';
131
+
132
+ initCarousels();
133
+ document.addEventListener('astro:page-load', initCarousels);
134
+ </script>
@@ -0,0 +1,71 @@
1
+ ---
2
+ /**
3
+ * FAQ — accordion of questions and answers.
4
+ *
5
+ * Items arrive already rendered: a site converts its authored markdown (footnotes included) and passes the result
6
+ * through trustedHtml(). The package never converts markdown. Structured data is the page template's job:
7
+ * <StructuredData schemas={[{ type: 'faqPage', data: { questions: items } }]} />
8
+ */
9
+ import Icon from '../primitives/Icon.astro';
10
+ import type { TrustedHtml } from '../types/index.ts';
11
+
12
+ export interface FaqItem {
13
+ questionHtml: TrustedHtml;
14
+ answerHtml: TrustedHtml;
15
+ }
16
+
17
+ interface Props {
18
+ items: FaqItem[];
19
+ title?: string;
20
+ /** Show the info icon before the title (default: true). */
21
+ icon?: boolean;
22
+ /** Replaces the title's classes. */
23
+ headingClass?: string;
24
+ id?: string;
25
+ class?: string;
26
+ }
27
+
28
+ const {
29
+ items,
30
+ title = 'Frequently asked questions',
31
+ icon = true,
32
+ headingClass = 'text-2xl sm:text-3xl font-normal mb-6 flex items-center gap-3 font-display',
33
+ id,
34
+ class: className = '',
35
+ } = Astro.props;
36
+ ---
37
+
38
+ {items.length > 0 && (
39
+ <section id={id} class:list={[className]} data-pagefind-body>
40
+ {title && (
41
+ <h2 class={headingClass}>
42
+ {icon && <Icon name="info" class="w-7 h-7 text-primary shrink-0" />}
43
+ {title}
44
+ </h2>
45
+ )}
46
+ <div class="space-y-4">
47
+ {items.map((item) => (
48
+ <details class="group p-6 bg-card rounded-xl border border-border hover:border-primary/50 open:border-primary/50 transition-colors" data-faq-track>
49
+ <summary class="flex items-center justify-between cursor-pointer list-none">
50
+ <h3 class="text-base font-medium group-hover:text-primary transition-colors pr-4" set:html={item.questionHtml} />
51
+ <svg
52
+ class="w-5 h-5 text-muted-foreground group-open:rotate-180 transition-transform duration-200 shrink-0"
53
+ viewBox="0 0 24 24"
54
+ fill="none"
55
+ stroke="currentColor"
56
+ stroke-width="2"
57
+ stroke-linecap="round"
58
+ stroke-linejoin="round"
59
+ aria-hidden="true"
60
+ >
61
+ <path d="m6 9 6 6 6-6" />
62
+ </svg>
63
+ </summary>
64
+ <div class="mt-4 pt-4 border-t border-border text-muted-foreground leading-relaxed">
65
+ <div set:html={item.answerHtml} />
66
+ </div>
67
+ </details>
68
+ ))}
69
+ </div>
70
+ </section>
71
+ )}
@@ -0,0 +1,160 @@
1
+ ---
2
+ import { Picture } from 'astro:assets';
3
+ import type { ImageMetadata } from 'astro';
4
+ import type { TrustedHtml } from '../types/index.ts';
5
+
6
+ interface Props {
7
+ /** Imported image (ImageMetadata from Astro asset pipeline) */
8
+ src: ImageMetadata;
9
+ /** Accessible alt text describing the image content */
10
+ alt: string;
11
+ /**
12
+ * Caption below the image, already rendered by the site (inline markdown, footnote markers) — it sits inside a
13
+ * <p>, so it must be inline HTML.
14
+ */
15
+ captionHtml?: TrustedHtml;
16
+ /** The caption as plain text: the accessible name of a linked image never carries HTML. */
17
+ captionText?: string;
18
+ /** Secondary caption line below the main caption */
19
+ subtitle?: string;
20
+ /** Attribution / photographer credit (hidden by default, opt-in) */
21
+ attribution?: string;
22
+ /** Show the attribution line. Default: false */
23
+ showAttribution?: boolean;
24
+ /**
25
+ * Layout variant:
26
+ * - 'full' (default): full-width image breaking the prose flow
27
+ * - 'left': floats left, text wraps right
28
+ * - 'right': floats right, text wraps left
29
+ */
30
+ layout?: 'full' | 'left' | 'right';
31
+ /**
32
+ * Image height preset (only used with layout='full'):
33
+ * - 'tall': h-64 / sm:h-80 / lg:h-96
34
+ * - 'medium' (default): h-56 / sm:h-72 / lg:h-80
35
+ * - 'auto': uses aspectRatio instead of fixed height
36
+ */
37
+ size?: 'tall' | 'medium' | 'auto';
38
+ /**
39
+ * CSS aspect-ratio value. Only used when size='auto'.
40
+ * @default '16/9'
41
+ */
42
+ aspectRatio?: string;
43
+ /** Optional URL to wrap the image in a link */
44
+ href?: string;
45
+ /** Image formats for the <picture> sources (default: avif + webp). */
46
+ formats?: ('avif' | 'webp')[];
47
+ /** Additional CSS classes on the outer figure element */
48
+ class?: string;
49
+ }
50
+
51
+ const {
52
+ src,
53
+ alt,
54
+ captionHtml,
55
+ captionText,
56
+ subtitle,
57
+ attribution,
58
+ showAttribution = false,
59
+ layout = 'full',
60
+ size = 'medium',
61
+ aspectRatio = '16/9',
62
+ href,
63
+ formats = ['avif', 'webp'],
64
+ class: className,
65
+ } = Astro.props;
66
+
67
+ const hasCaption = captionHtml || subtitle;
68
+ const hasAttribution = showAttribution && attribution;
69
+
70
+ const sizeClasses: Record<string, string> = {
71
+ tall: 'h-64 sm:h-80 lg:h-96',
72
+ medium: 'h-56 sm:h-72 lg:h-80',
73
+ auto: '',
74
+ };
75
+
76
+ const imageHeightClass = sizeClasses[size] || sizeClasses.medium;
77
+ const useAspectRatio = size === 'auto';
78
+
79
+ // Float layout classes
80
+ const layoutClasses: Record<string, string> = {
81
+ full: 'w-full mb-8',
82
+ left: 'sm:float-left sm:w-1/2 sm:mr-6 mb-4 sm:mb-2',
83
+ right: 'sm:float-right sm:w-1/2 sm:ml-6 mb-4 sm:mb-2',
84
+ };
85
+
86
+ // Float images use auto size and a smaller aspect ratio
87
+ const isFloat = layout === 'left' || layout === 'right';
88
+ const effectiveAspectRatio = isFloat ? '4/3' : aspectRatio;
89
+ const effectiveHeightClass = isFloat ? '' : imageHeightClass;
90
+ const effectiveUseAspectRatio = isFloat ? true : useAspectRatio;
91
+
92
+ // Filter widths to not exceed the source image width
93
+ const defaultWidths = isFloat ? [400, 640] : [640, 960, 1280];
94
+ const fitting = src.width ? defaultWidths.filter((w) => w <= src.width) : defaultWidths;
95
+ const widths = fitting.length ? fitting : [src.width];
96
+
97
+ const sizes = isFloat
98
+ ? '(max-width: 640px) 100vw, 50vw'
99
+ : '(max-width: 1024px) 100vw, 1024px';
100
+ ---
101
+
102
+ <figure
103
+ class:list={[
104
+ 'relative overflow-hidden rounded-2xl border border-border/50',
105
+ layoutClasses[layout],
106
+ className,
107
+ ]}
108
+ >
109
+ {href ? (
110
+ <a href={href} class="block group" aria-label={captionText || alt}>
111
+ <Picture
112
+ src={src}
113
+ alt={alt}
114
+ widths={widths}
115
+ sizes={sizes}
116
+ formats={formats}
117
+ fallbackFormat="webp"
118
+ pictureAttributes={{ class: 'block w-full h-full' }}
119
+ class:list={[
120
+ 'w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]',
121
+ effectiveUseAspectRatio ? '' : effectiveHeightClass,
122
+ ]}
123
+ style={effectiveUseAspectRatio ? `aspect-ratio: ${effectiveAspectRatio}` : undefined}
124
+ loading="lazy"
125
+ decoding="async"
126
+ />
127
+ </a>
128
+ ) : (
129
+ <Picture
130
+ src={src}
131
+ alt={alt}
132
+ widths={widths}
133
+ sizes={sizes}
134
+ formats={formats}
135
+ fallbackFormat="webp"
136
+ pictureAttributes={{ class: 'block w-full h-full' }}
137
+ class:list={[
138
+ 'w-full object-cover',
139
+ effectiveUseAspectRatio ? '' : effectiveHeightClass,
140
+ ]}
141
+ style={effectiveUseAspectRatio ? `aspect-ratio: ${effectiveAspectRatio}` : undefined}
142
+ loading="lazy"
143
+ decoding="async"
144
+ />
145
+ )}
146
+
147
+ {(hasCaption || hasAttribution) && (
148
+ <figcaption class="px-4 py-3 bg-muted/30">
149
+ {captionHtml && (
150
+ <p class="text-sm font-medium text-foreground/80 font-display" set:html={captionHtml} />
151
+ )}
152
+ {subtitle && (
153
+ <p class="text-xs text-muted-foreground mt-0.5">{subtitle}</p>
154
+ )}
155
+ {hasAttribution && (
156
+ <p class="text-xs text-muted-foreground/60 mt-1">{attribution}</p>
157
+ )}
158
+ </figcaption>
159
+ )}
160
+ </figure>
@@ -0,0 +1,96 @@
1
+ ---
2
+ /**
3
+ * Pagination — accessible page navigation for paginated listings. Renders nothing when there is only one page.
4
+ * The site supplies the page → URL mapping, so the listing pages, their rel=prev/next head links and this component
5
+ * all agree on URLs.
6
+ */
7
+ import Icon from '../primitives/Icon.astro';
8
+
9
+ interface Props {
10
+ currentPage: number;
11
+ totalPages: number;
12
+ /** URL of a page number (base path included). */
13
+ href: (page: number) => string;
14
+ }
15
+
16
+ const { currentPage, totalPages, href } = Astro.props;
17
+
18
+ const prevPage = currentPage > 1 ? currentPage - 1 : null;
19
+ const nextPage = currentPage < totalPages ? currentPage + 1 : null;
20
+
21
+ // Windowed page list: always first + last, plus current and its neighbours,
22
+ // with gaps collapsed to an ellipsis. Keeps the control compact as articles grow.
23
+ type Item = number | 'ellipsis';
24
+ function buildItems(current: number, total: number): Item[] {
25
+ const wanted = new Set<number>([1, total, current, current - 1, current + 1]);
26
+ const visible = [...wanted].filter((p) => p >= 1 && p <= total).sort((a, b) => a - b);
27
+ const items: Item[] = [];
28
+ let prev = 0;
29
+ for (const p of visible) {
30
+ if (p - prev > 1) items.push('ellipsis');
31
+ items.push(p);
32
+ prev = p;
33
+ }
34
+ return items;
35
+ }
36
+ const items = buildItems(currentPage, totalPages);
37
+
38
+ const linkBase =
39
+ 'inline-flex items-center justify-center gap-1 h-11 rounded-lg border border-border text-sm font-medium text-foreground transition-colors hover:border-primary hover:text-primary focus:outline-none focus:ring-2 focus:ring-primary/50';
40
+ const numberBase = `${linkBase} w-11`;
41
+ const edgeBase = `${linkBase} px-3`;
42
+ ---
43
+
44
+ {totalPages > 1 && (
45
+ <nav aria-label="Pagination" class="mt-14 flex items-center justify-center gap-2 flex-wrap">
46
+ {prevPage ? (
47
+ <a href={href(prevPage)} rel="prev" aria-label={`Go to page ${prevPage}`} class={edgeBase}>
48
+ <Icon name="arrow-left" class="w-3.5 h-3.5" />
49
+ <span>Previous</span>
50
+ </a>
51
+ ) : (
52
+ <span aria-hidden="true" class={`${edgeBase} opacity-40 cursor-default pointer-events-none`}>
53
+ <Icon name="arrow-left" class="w-3.5 h-3.5" />
54
+ <span>Previous</span>
55
+ </span>
56
+ )}
57
+
58
+ <div class="overflow-x-auto min-w-0">
59
+ <ul class="flex items-center gap-1.5">
60
+ {items.map((item) =>
61
+ item === 'ellipsis' ? (
62
+ <li aria-hidden="true" class="w-11 text-center text-sm text-muted-foreground">…</li>
63
+ ) : item === currentPage ? (
64
+ <li>
65
+ <span
66
+ aria-current="page"
67
+ aria-label={`Page ${item}, current page`}
68
+ class={`${numberBase} border-primary bg-primary text-white hover:text-white`}
69
+ >
70
+ {item}
71
+ </span>
72
+ </li>
73
+ ) : (
74
+ <li>
75
+ <a href={href(item)} aria-label={`Go to page ${item}`} class={numberBase}>
76
+ {item}
77
+ </a>
78
+ </li>
79
+ )
80
+ )}
81
+ </ul>
82
+ </div>
83
+
84
+ {nextPage ? (
85
+ <a href={href(nextPage)} rel="next" aria-label={`Go to page ${nextPage}`} class={edgeBase}>
86
+ <span>Next</span>
87
+ <Icon name="arrow-right" class="w-3.5 h-3.5" />
88
+ </a>
89
+ ) : (
90
+ <span aria-hidden="true" class={`${edgeBase} opacity-40 cursor-default pointer-events-none`}>
91
+ <span>Next</span>
92
+ <Icon name="arrow-right" class="w-3.5 h-3.5" />
93
+ </span>
94
+ )}
95
+ </nav>
96
+ )}
@@ -0,0 +1,148 @@
1
+ ---
2
+ /**
3
+ * QuickFacts — reusable key-value facts block for content pages.
4
+ *
5
+ * Usage:
6
+ * <QuickFacts
7
+ * title="Practical information"
8
+ * items={[
9
+ * { icon: 'clock', label: 'Opening hours', value: '9am – 7pm' },
10
+ * { icon: 'ticket', label: 'Admission', value: '€10' },
11
+ * { icon: 'map-pin', label: 'Address', value: 'Calle Torrijos, 10', href: 'https://maps.google.com/...', hrefLabel: 'View on Google Maps', hrefExternal: true, span: 2 },
12
+ * ]}
13
+ * links={[
14
+ * { href: 'https://...', label: 'Google Maps', icon: 'map-pin', color: 'secondary' },
15
+ * ]}
16
+ * >
17
+ * <Map slot="after" ... />
18
+ * </QuickFacts>
19
+ *
20
+ * The optional `after` slot renders between the facts and the action links, so a site can place a map there without
21
+ * the package importing it. The card, slot included, renders only when `items` is non-empty.
22
+ */
23
+
24
+ import Icon from '../primitives/Icon.astro';
25
+
26
+ interface QuickFactItem {
27
+ icon: string;
28
+ label: string;
29
+ value: string;
30
+ href?: string;
31
+ hrefLabel?: string;
32
+ hrefExternal?: boolean;
33
+ span?: 2;
34
+ }
35
+
36
+ interface ActionLink {
37
+ href: string;
38
+ label: string;
39
+ icon: string;
40
+ color: 'primary' | 'secondary' | 'accent';
41
+ }
42
+
43
+ interface Props {
44
+ title?: string;
45
+ items: QuickFactItem[];
46
+ variant?: 'card' | 'muted';
47
+ columns?: 2 | 3 | 4;
48
+ links?: ActionLink[];
49
+ headingLevel?: 'h2' | 'h3' | 'h4';
50
+ /** Replaces the fact labels' size class. */
51
+ labelSizeClass?: string;
52
+ class?: string;
53
+ }
54
+
55
+ const {
56
+ title,
57
+ items,
58
+ variant = 'muted',
59
+ columns = 2,
60
+ links,
61
+ headingLevel = 'h2',
62
+ labelSizeClass = 'text-[0.8125rem]',
63
+ class: className = '',
64
+ } = Astro.props;
65
+
66
+ const Heading = headingLevel;
67
+
68
+ const containerClass = variant === 'card'
69
+ ? 'p-6 lg:p-8 bg-card rounded-2xl border border-accent/20'
70
+ : 'p-6 bg-primary/5 rounded-xl border border-primary/15';
71
+
72
+ const gridClass = columns === 4
73
+ ? 'grid sm:grid-cols-2 lg:grid-cols-4 gap-6'
74
+ : columns === 3
75
+ ? 'grid sm:grid-cols-2 lg:grid-cols-3 gap-6'
76
+ : 'grid gap-x-8 gap-y-6 sm:grid-cols-2';
77
+
78
+ const titleClass = 'text-2xl font-display font-semibold text-foreground mb-6 leading-tight';
79
+
80
+ const labelClass = `${labelSizeClass} uppercase tracking-wider text-muted-foreground mb-1.5 leading-none`;
81
+
82
+ const valueClass = variant === 'card'
83
+ ? 'text-lg font-medium text-foreground leading-snug'
84
+ : 'text-base font-medium text-foreground leading-snug';
85
+
86
+ const linkColorMap: Record<string, { bg: string; hover: string; text: string }> = {
87
+ secondary: { bg: 'bg-secondary/10', hover: 'hover:bg-secondary/20', text: 'text-secondary' },
88
+ primary: { bg: 'bg-primary/10', hover: 'hover:bg-primary/20', text: 'text-primary' },
89
+ accent: { bg: 'bg-accent/10', hover: 'hover:bg-accent/20', text: 'text-accent-strong' },
90
+ };
91
+ ---
92
+
93
+ {items.length > 0 && (
94
+ <div class:list={[containerClass, className]} data-pagefind-ignore>
95
+ {title && <Heading class={titleClass}>{title}</Heading>}
96
+
97
+ <dl class={gridClass}>
98
+ {items.map((item) => (
99
+ <div class:list={['min-w-0', item.span === 2 && 'sm:col-span-2']}>
100
+ <dt class={`${labelClass} flex items-center gap-1.5`}>
101
+ <Icon name={item.icon} class="w-3.5 h-3.5 text-primary/60" />
102
+ {item.label}
103
+ </dt>
104
+ <dd class={`${valueClass} tabular-nums break-words`}>
105
+ {item.value}
106
+ {item.href && (
107
+ <a
108
+ href={item.href}
109
+ target={item.hrefExternal ? '_blank' : undefined}
110
+ rel={item.hrefExternal ? 'noopener noreferrer' : undefined}
111
+ class="inline-flex items-center gap-1 text-primary underline decoration-primary/40 hover:text-primary-strong hover:decoration-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary rounded-sm ml-2 text-sm"
112
+ >
113
+ {item.hrefLabel || item.href}
114
+ {item.hrefExternal && <Icon name="external-link" class="w-3.5 h-3.5 shrink-0" />}
115
+ </a>
116
+ )}
117
+ </dd>
118
+ </div>
119
+ ))}
120
+ </dl>
121
+
122
+ {/* has('after') is true when the slot is passed, even if it renders nothing: empty:hidden collapses the gap, and
123
+ the slot stays on one line because :empty does not match a whitespace text node. */}
124
+ {Astro.slots.has('after') && (
125
+ <div class="mt-6 empty:hidden"><slot name="after" /></div>
126
+ )}
127
+
128
+ {links && links.length > 0 && (
129
+ <div class="mt-6 pt-6 border-t border-border flex flex-wrap gap-3">
130
+ {links.map((link) => {
131
+ const colors = linkColorMap[link.color] || linkColorMap.secondary;
132
+ return (
133
+ <a
134
+ href={link.href}
135
+ target="_blank"
136
+ rel="noopener noreferrer"
137
+ class={`inline-flex items-center gap-2 px-4 py-2.5 ${colors.bg} ${colors.hover} ${colors.text} rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary`}
138
+ >
139
+ <Icon name={link.icon} class="w-5 h-5" />
140
+ <span class="text-sm font-medium">{link.label}</span>
141
+ <Icon name="external-link" class="w-4 h-4" />
142
+ </a>
143
+ );
144
+ })}
145
+ </div>
146
+ )}
147
+ </div>
148
+ )}
@@ -0,0 +1,156 @@
1
+ ---
2
+ // Section jump pills in two rails (a mobile <details> list and a desktop pill row), plus, for the card variant, a
3
+ // sticky mini bar that appears once the main ToC has scrolled away. The controller marks the pill of the section in
4
+ // view (data-active + aria-current).
5
+ //
6
+ // The header renders only when something fills it: `title` defaults to '', and an unconditional <h2> shipped an
7
+ // empty heading on every caller that omits one (SLA-2340); `mb-2` sits behind `title` so an eyebrow-only header does
8
+ // not double the gap.
9
+ import Chip from '../primitives/Chip.astro';
10
+
11
+ interface TableOfContentsItem {
12
+ label: string;
13
+ href: string;
14
+ sectionId?: string;
15
+ }
16
+
17
+ interface Props {
18
+ title?: string;
19
+ items: TableOfContentsItem[];
20
+ /** Defaults to the title, else 'Table of contents'. */
21
+ ariaLabel?: string;
22
+ eyebrow?: string;
23
+ description?: string;
24
+ class?: string;
25
+ eyebrowClass?: string;
26
+ variant?: 'card' | 'plain';
27
+ showHeader?: boolean;
28
+ navClass?: string;
29
+ linkClass?: string;
30
+ sectionSelector?: string;
31
+ /** Extra classes on the sticky mini bar's pills. */
32
+ miniLinkClass?: string;
33
+ /** Hide the mini bar once the reader has scrolled past the last section it links to. */
34
+ hideMiniPastLastSection?: boolean;
35
+ }
36
+
37
+ const {
38
+ title = '',
39
+ items,
40
+ ariaLabel = title || 'Table of contents',
41
+ eyebrow,
42
+ description,
43
+ class: className = '',
44
+ eyebrowClass = 'text-primary',
45
+ variant = 'card',
46
+ showHeader = true,
47
+ navClass: navClassName = '',
48
+ linkClass: linkClassName = '',
49
+ sectionSelector = '[data-guide-section], section[id]',
50
+ miniLinkClass = '',
51
+ hideMiniPastLastSection = false,
52
+ } = Astro.props;
53
+
54
+ const resolveSectionId = (item: TableOfContentsItem) =>
55
+ item.sectionId ?? (item.href.startsWith('#') ? item.href.slice(1) : undefined);
56
+
57
+ const rootClass = variant === 'card' ? 'rounded-2xl border border-border bg-card p-6 lg:p-8' : '';
58
+ const defaultNavClass =
59
+ variant === 'card' ? 'flex gap-2 overflow-x-auto pb-2 md:flex-wrap md:pb-0' : 'flex gap-2 overflow-x-auto';
60
+ // Card variant sits on a bg-card ancestor, so its idle pill needs the muted fill to read as a
61
+ // control; plain variant already sits on bg-card, so it uses the plain outline tone instead.
62
+ const mainTone = variant === 'card' ? 'outlineMuted' : 'outline';
63
+
64
+ const showMini = variant === 'card';
65
+ ---
66
+
67
+ {showMini && (
68
+ <div
69
+ data-toc-mini
70
+ data-toc
71
+ data-toc-section-selector={sectionSelector}
72
+ class="fixed top-16 left-0 right-0 z-40 translate-y-[-100%] opacity-0 pointer-events-none transition-[transform,opacity] duration-300 ease-out bg-background/95 backdrop-blur-sm border-b border-border shadow-sm"
73
+ aria-hidden="true"
74
+ >
75
+ <div class="mx-auto max-w-shell px-4 sm:px-6 lg:px-8 py-2.5">
76
+ <nav class="flex gap-2 overflow-x-auto scrollbar-hide" aria-label={`${ariaLabel} (sticky)`}>
77
+ {items.map((item) => (
78
+ <Chip
79
+ href={item.href}
80
+ data-section-link={resolveSectionId(item)}
81
+ active={false}
82
+ size="sm"
83
+ tone="outlineMuted"
84
+ accent="primary"
85
+ hover={false}
86
+ class:list={['shrink-0', miniLinkClass]}
87
+ >
88
+ {item.label}
89
+ </Chip>
90
+ ))}
91
+ </nav>
92
+ </div>
93
+ </div>
94
+ )}
95
+
96
+ <section
97
+ data-toc
98
+ data-toc-section-selector={sectionSelector}
99
+ {...(showMini ? { 'data-toc-main': '' } : {})}
100
+ {...(showMini && hideMiniPastLastSection ? { 'data-toc-hide-past-last': '' } : {})}
101
+ class:list={[rootClass, className]}
102
+ >
103
+ {showHeader && (title || eyebrow || description) && (
104
+ <div class="mb-5 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
105
+ <div>
106
+ {eyebrow && (
107
+ <p class:list={['text-sm font-medium uppercase tracking-[0.2em]', title ? 'mb-2' : '', eyebrowClass]}>{eyebrow}</p>
108
+ )}
109
+ {title && <h2 class="text-2xl font-semibold font-display">{title}</h2>}
110
+ </div>
111
+ {description && <p class="max-w-deck text-sm text-muted-foreground">{description}</p>}
112
+ </div>
113
+ )}
114
+
115
+ <details class="group md:hidden">
116
+ <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between rounded-lg border border-border bg-muted/40 px-4 py-2.5 text-sm font-medium text-foreground marker:content-none">
117
+ Jump to a section
118
+ <span class="text-primary transition-transform group-open:rotate-180" aria-hidden="true">⌄</span>
119
+ </summary>
120
+ <nav class="mt-3 grid gap-2" aria-label={ariaLabel}>
121
+ {items.map((item) => (
122
+ <a
123
+ href={item.href}
124
+ data-section-link={resolveSectionId(item)}
125
+ class="rounded-lg border border-border px-3 py-2.5 text-sm text-foreground transition-colors hover:border-primary/40 hover:text-primary"
126
+ >
127
+ {item.label}
128
+ </a>
129
+ ))}
130
+ </nav>
131
+ </details>
132
+
133
+ <nav class:list={['hidden md:flex', defaultNavClass, navClassName]} aria-label={ariaLabel}>
134
+ {items.map((item) => (
135
+ <Chip
136
+ href={item.href}
137
+ data-section-link={resolveSectionId(item)}
138
+ active={false}
139
+ size="md"
140
+ tone={mainTone}
141
+ accent="primary"
142
+ hover={false}
143
+ class:list={['shrink-0', linkClassName]}
144
+ >
145
+ {item.label}
146
+ </Chip>
147
+ ))}
148
+ </nav>
149
+ </section>
150
+
151
+ <script>
152
+ import { initTableOfContents } from './toc.ts';
153
+
154
+ initTableOfContents();
155
+ document.addEventListener('astro:page-load', initTableOfContents);
156
+ </script>
@@ -0,0 +1,116 @@
1
+ // CardCarousel's controller. One module per page, however many carousels it holds: each instance is found by its
2
+ // data attributes and initialised once (data-carousel-ready), so a re-run after astro:page-load is a no-op.
3
+
4
+ const ACTIVE = 'var(--carousel-dot, var(--color-primary))';
5
+ const INACTIVE = 'rgba(120,120,130,0.35)';
6
+ const EDGE_SLOP = 4;
7
+
8
+ function initCarousel(section: HTMLElement): void {
9
+ const track = section.querySelector<HTMLElement>('[data-carousel-track]');
10
+ if (!track) return;
11
+ const cards = Array.from(track.children) as HTMLElement[];
12
+ if (cards.length === 0) return;
13
+
14
+ const arrowsWrap = section.querySelector<HTMLElement>('[data-carousel-arrows]');
15
+ const prevBtn = section.querySelector<HTMLButtonElement>('[data-carousel-prev]');
16
+ const nextBtn = section.querySelector<HTMLButtonElement>('[data-carousel-next]');
17
+ const dotsWrap = section.querySelector<HTMLElement>('[data-carousel-dots]');
18
+ let currentIndex = 0;
19
+
20
+ const dots: HTMLButtonElement[] = [];
21
+ if (dotsWrap) {
22
+ cards.forEach((_, i) => {
23
+ const dot = document.createElement('button');
24
+ dot.type = 'button';
25
+ dot.setAttribute('aria-label', `Go to item ${i + 1}`);
26
+ dot.style.cssText =
27
+ 'height:0.375rem;border-radius:9999px;transition:all 0.2s;border:0;cursor:pointer;padding:0;' +
28
+ (i === 0 ? `width:1rem;background:${ACTIVE};` : `width:0.375rem;background:${INACTIVE};`);
29
+ dot.addEventListener('click', () => scrollToIndex(i));
30
+ dotsWrap.appendChild(dot);
31
+ dots.push(dot);
32
+ });
33
+ }
34
+
35
+ const isScrollable = () => track.scrollWidth - track.clientWidth > EDGE_SLOP;
36
+
37
+ function updateUI(idx: number, atLeft?: number): void {
38
+ const left = typeof atLeft === 'number' ? atLeft : track!.scrollLeft;
39
+ const startEdge = left <= EDGE_SLOP;
40
+ const endEdge = left >= track!.scrollWidth - track!.clientWidth - EDGE_SLOP;
41
+ // currentIndex is the LEFTMOST VISIBLE card, which is what prev/next step from. Pinning it to the last card at
42
+ // max scroll made `prev` dead for the first N-1 clicks.
43
+ currentIndex = Math.max(0, Math.min(idx, cards.length - 1));
44
+ // Disabled state comes from the scroll position, not the card index: with several cards per view the track hits
45
+ // max scroll while currentIndex is still mid-list.
46
+ if (prevBtn) prevBtn.disabled = startEdge;
47
+ if (nextBtn) nextBtn.disabled = endEdge;
48
+ // At the extremes the dot shows the first or last card, otherwise the trailing dots could never light up.
49
+ const dotIndex = endEdge ? cards.length - 1 : startEdge ? 0 : currentIndex;
50
+ dots.forEach((dot, i) => {
51
+ const active = i === dotIndex;
52
+ dot.style.width = active ? '1rem' : '0.375rem';
53
+ dot.style.background = active ? ACTIVE : INACTIVE;
54
+ });
55
+ }
56
+
57
+ const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
58
+
59
+ // Scroll the TRACK, not via scrollIntoView, which also scrolls the document and jerks the page vertically.
60
+ function scrollToIndex(idx: number): void {
61
+ idx = Math.max(0, Math.min(idx, cards.length - 1));
62
+ const delta = cards[idx].getBoundingClientRect().left - track!.getBoundingClientRect().left;
63
+ const target = Math.max(0, Math.min(track!.scrollLeft + delta, track!.scrollWidth - track!.clientWidth));
64
+ track!.scrollTo({ left: target, behavior: reduceMotion ? 'auto' : 'smooth' });
65
+ // A smooth scroll has not moved scrollLeft yet, so the controls follow where the track is GOING.
66
+ updateUI(idx, target);
67
+ }
68
+
69
+ let raf = 0;
70
+ track.addEventListener('scroll', () => {
71
+ cancelAnimationFrame(raf);
72
+ raf = requestAnimationFrame(() => {
73
+ const trackLeft = track.getBoundingClientRect().left;
74
+ let closest = 0;
75
+ let min = Infinity;
76
+ cards.forEach((card, i) => {
77
+ const d = Math.abs(card.getBoundingClientRect().left - trackLeft);
78
+ if (d < min) { min = d; closest = i; }
79
+ });
80
+ updateUI(closest);
81
+ });
82
+ }, { passive: true });
83
+
84
+ prevBtn?.addEventListener('click', () => scrollToIndex(currentIndex - 1));
85
+ nextBtn?.addEventListener('click', () => scrollToIndex(currentIndex + 1));
86
+
87
+ // Controls show only when the track overflows; re-checked whenever the track resizes.
88
+ function syncControls(): void {
89
+ const show = isScrollable();
90
+ arrowsWrap?.classList.toggle('hidden', !show);
91
+ arrowsWrap?.classList.toggle('flex', show);
92
+ dotsWrap?.classList.toggle('hidden', !show);
93
+ dotsWrap?.classList.toggle('flex', show);
94
+ }
95
+
96
+ if ('ResizeObserver' in window) {
97
+ new ResizeObserver(syncControls).observe(track);
98
+ } else {
99
+ let resizeRaf = 0;
100
+ window.addEventListener('resize', () => {
101
+ cancelAnimationFrame(resizeRaf);
102
+ resizeRaf = requestAnimationFrame(syncControls);
103
+ }, { passive: true });
104
+ window.addEventListener('load', syncControls, { once: true });
105
+ }
106
+
107
+ syncControls();
108
+ updateUI(0);
109
+ }
110
+
111
+ export function initCarousels(): void {
112
+ document.querySelectorAll<HTMLElement>('[data-carousel]:not([data-carousel-ready])').forEach((section) => {
113
+ section.setAttribute('data-carousel-ready', '');
114
+ initCarousel(section);
115
+ });
116
+ }
@@ -1,2 +1,7 @@
1
- // Public surface of @sarimarcus/content-sites-ui/composed. Components land here from SLA-2742 on.
2
- export {};
1
+ // Public surface of @sarimarcus/content-sites-ui/composed.
2
+ export { default as TableOfContents } from './TableOfContents.astro';
3
+ export { default as CardCarousel } from './CardCarousel.astro';
4
+ export { default as QuickFacts } from './QuickFacts.astro';
5
+ export { default as FAQ } from './FAQ.astro';
6
+ export { default as InlineImage } from './InlineImage.astro';
7
+ export { default as Pagination } from './Pagination.astro';
@@ -0,0 +1,105 @@
1
+ // TableOfContents' controller: marks the pill of the section in view on every ToC of the page, and shows each card
2
+ // ToC's sticky mini bar once the main ToC has scrolled away. Re-running it (astro:page-load) disconnects the previous
3
+ // observers first, so it is idempotent however many ToCs the page holds.
4
+
5
+ let cleanup: (() => void) | null = null;
6
+
7
+ function setMiniVisible(mini: Element, visible: boolean): void {
8
+ mini.classList.toggle('translate-y-[-100%]', !visible);
9
+ mini.classList.toggle('opacity-0', !visible);
10
+ mini.classList.toggle('pointer-events-none', !visible);
11
+ mini.classList.toggle('translate-y-0', visible);
12
+ mini.classList.toggle('opacity-100', visible);
13
+ mini.classList.toggle('pointer-events-auto', visible);
14
+ mini.setAttribute('aria-hidden', visible ? 'false' : 'true');
15
+ document.dispatchEvent(new CustomEvent('toc:mini-visibility-change', { detail: { visible } }));
16
+ }
17
+
18
+ const linksOf = (root: Element) => Array.from(root.querySelectorAll<HTMLElement>('[data-section-link]'));
19
+
20
+ export function initTableOfContents(): void {
21
+ cleanup?.();
22
+ const observers: IntersectionObserver[] = [];
23
+
24
+ const groups = new Map<string, Element[]>();
25
+ for (const root of document.querySelectorAll('[data-toc]')) {
26
+ if (linksOf(root).length === 0) continue;
27
+ const selector = root.getAttribute('data-toc-section-selector') || '[data-guide-section]';
28
+ if (!groups.has(selector)) groups.set(selector, []);
29
+ groups.get(selector)!.push(root);
30
+ }
31
+
32
+ groups.forEach((roots, sectionSelector) => {
33
+ // Observe ONLY sections a pill points at: the default selector's `section[id]` half also matches unrelated page
34
+ // furniture (a runtime-injected cookie-consent section), which would otherwise blank every pill (SLA-2340).
35
+ const linkIds = new Set<string>();
36
+ roots.forEach((root) => linksOf(root).forEach((link) => link.dataset.sectionLink && linkIds.add(link.dataset.sectionLink)));
37
+ const sections = Array.from(document.querySelectorAll<HTMLElement>(sectionSelector))
38
+ .filter((s) => s.id && (linkIds.size === 0 || linkIds.has(s.id)));
39
+ if (sections.length === 0) return;
40
+
41
+ const firstSectionId = sections[0].id;
42
+ let activeSectionId = '';
43
+ const setActiveSection = (sectionId: string) => {
44
+ if (!sectionId || activeSectionId === sectionId) return;
45
+ activeSectionId = sectionId;
46
+ roots.forEach((root) => {
47
+ linksOf(root).forEach((link) => {
48
+ const isActive = link.dataset.sectionLink === sectionId;
49
+ link.dataset.active = isActive ? 'true' : 'false';
50
+ if (isActive) {
51
+ link.setAttribute('aria-current', 'location');
52
+ if (root.hasAttribute('data-toc-mini')) link.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
53
+ } else {
54
+ link.removeAttribute('aria-current');
55
+ }
56
+ });
57
+ });
58
+ document.dispatchEvent(new CustomEvent('toc:active-section-change', { detail: { firstSectionId, sectionId, sectionSelector } }));
59
+ };
60
+
61
+ const intersecting = new Set<Element>();
62
+ const observer = new IntersectionObserver((entries) => {
63
+ entries.forEach((e) => (e.isIntersecting ? intersecting.add(e.target) : intersecting.delete(e.target)));
64
+ if (intersecting.size === 0) return;
65
+ const top = sections.find((s) => intersecting.has(s));
66
+ if (top) setActiveSection(top.id);
67
+ }, { rootMargin: '-22% 0px -55% 0px', threshold: [0, 0.1, 0.25, 0.45, 0.7] });
68
+ sections.forEach((s) => observer.observe(s));
69
+ observers.push(observer);
70
+ });
71
+
72
+ for (const mainToc of document.querySelectorAll('[data-toc-main]')) {
73
+ const mini = mainToc.previousElementSibling;
74
+ if (!mini?.hasAttribute('data-toc-mini')) continue;
75
+ let mainAbove = false;
76
+ let pastLast = false;
77
+ const apply = () => setMiniVisible(mini, mainAbove && !pastLast);
78
+
79
+ // Visible only once the main ToC has scrolled FULLY above the viewport: !isIntersecting alone is also true
80
+ // before the reader has reached it, which fired the mini bar on page load.
81
+ const miniObserver = new IntersectionObserver((entries) => {
82
+ mainAbove = entries[0].boundingClientRect.bottom < 0;
83
+ apply();
84
+ }, { threshold: 0 });
85
+ miniObserver.observe(mainToc);
86
+ observers.push(miniObserver);
87
+
88
+ if (mainToc.hasAttribute('data-toc-hide-past-last')) {
89
+ const selector = mainToc.getAttribute('data-toc-section-selector') || '[data-guide-section]';
90
+ const linked = new Set(linksOf(mainToc).map((l) => l.dataset.sectionLink));
91
+ const sections = Array.from(document.querySelectorAll(selector)).filter((s) => s.id && (linked.size === 0 || linked.has(s.id)));
92
+ const last = sections[sections.length - 1];
93
+ if (last) {
94
+ const lastObserver = new IntersectionObserver((entries) => {
95
+ pastLast = entries[0].boundingClientRect.bottom < 0;
96
+ apply();
97
+ }, { threshold: 0 });
98
+ lastObserver.observe(last);
99
+ observers.push(lastObserver);
100
+ }
101
+ }
102
+ }
103
+
104
+ cleanup = () => observers.forEach((o) => o.disconnect());
105
+ }
@@ -1,8 +1,8 @@
1
1
  ---
2
- // GENERATED by scripts/width-tokens/sync.mjs (factory root) — DO NOT EDIT.
2
+ // GENERATED by scripts/platform/ui-widths.mjs (factory root) — DO NOT EDIT.
3
3
  //
4
- // Canonical widths live in scripts/width-tokens/tokens.json (SLA-2421); the tokens themselves ship
5
- // as @sarimarcus/content-sites-ui/widths.css. The sync --check gate rejects drift.
4
+ // Canonical widths live in packages/ui/width-tokens.json (SLA-2421); the tokens themselves ship
5
+ // as @sarimarcus/content-sites-ui/widths.css. The ui-widths --check gate rejects drift.
6
6
  //
7
7
  // `size` names a width token. Container is the sanctioned way to build a centred rail
8
8
  // with the standard gutter. Two token names are deliberately NOT sizes here:
@@ -1,4 +1,4 @@
1
- /* GENERATED by scripts/width-tokens/sync.mjs from scripts/width-tokens/tokens.json — DO NOT EDIT.
1
+ /* GENERATED by scripts/platform/ui-widths.mjs from packages/ui/width-tokens.json — DO NOT EDIT.
2
2
  A site imports it where its width region used to be, so the theme variables keep their order. */
3
3
  @theme {
4
4
  --container-shell: 80rem; /* 1280 — was max-w-7xl */
@@ -1,2 +1,17 @@
1
- // Public surface of @sarimarcus/content-sites-ui/types. Components land here from SLA-2742 on.
2
- export {};
1
+ // Public surface of @sarimarcus/content-sites-ui/types.
2
+
3
+ declare const trusted: unique symbol;
4
+
5
+ /**
6
+ * HTML a site has already rendered from its own authored content (markdown, footnotes). Package components never
7
+ * convert markdown themselves; a string crosses the package boundary as HTML only through trustedHtml().
8
+ */
9
+ export type TrustedHtml = string & { readonly [trusted]: true };
10
+
11
+ /** Mark site-rendered HTML as safe to inject with set:html. */
12
+ export const trustedHtml = (html: string): TrustedHtml => html as TrustedHtml;
13
+
14
+ const ESCAPES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
15
+
16
+ /** Plain text as HTML, escaped the way Astro escapes an `{expression}`. */
17
+ export const textAsHtml = (text: string): TrustedHtml => text.replace(/[&<>"']/g, (c) => ESCAPES[c]) as TrustedHtml;