@sarimarcus/content-sites-ui 0.12.1 → 0.14.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.14.0 (2026-10-01)
6
+
7
+ - New `./seo`: `JsonLd` (safe JSON-LD renderer: escapes `</script`, `<!--` and U+2028/9), `OgImageMeta`, `Breadcrumb` (chevron or star separator, tap targets, copy as props), `ArticleMetadata` (resolved author in, no byline without one) and `AuthoritativeSources` (domain labels and outbound classification as props; competitor sources unlinked, affiliates `rel="noopener sponsored"`; optional disambiguation of domain-derived labels shared by different URLs). `scrollbar-hide` utility in `tailwind.css` (SLA-2737).
8
+
9
+ ## 0.13.0 (2026-10-01)
10
+
11
+ - New `./editorial`: `EditorialGuidePage` (breadcrumb band, hero and body slots; owns the Pagefind markers, `pagefindType`, `breadcrumbIgnore`) and `EventPage` (back link, rendered body as `TrustedHtml`, highlights, practical information, price, official website and tags as props; `breadcrumb`, `hero`, `after-body`, `after-details` and `tail` slots), plus `BeforeYouGo` and `NumberedStep`. `./layout`: `BreadcrumbBand` (SLA-2819).
12
+
5
13
  ## 0.12.1 (2026-10-01)
6
14
 
7
15
  - No changes; lockstep release with tourism (SLA-2739).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.12.1",
3
+ "version": "0.14.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.12.1"
45
+ "@sarimarcus/content-sites-core": "0.14.0"
46
46
  }
47
47
  }
@@ -0,0 +1,27 @@
1
+ ---
2
+ /**
3
+ * BeforeYouGo — the boxed checklist that opens a planning guide: a heading with an info glyph and a list of
4
+ * `label` + `text` pairs. Labels carry their own punctuation.
5
+ */
6
+ import Icon from '../primitives/Icon.astro';
7
+
8
+ interface Props {
9
+ heading: string;
10
+ items: { label: string; text: string }[];
11
+ class?: string;
12
+ }
13
+
14
+ const { heading, items, class: className = 'mb-16' } = Astro.props;
15
+ ---
16
+
17
+ <div class:list={['max-w-measure mx-auto', className]}>
18
+ <div class="p-6 bg-accent/10 border border-accent/20 rounded-xl">
19
+ <h2 class="font-semibold mb-3 flex items-center gap-2 font-display">
20
+ <Icon name="info" class="w-5 h-5 text-accent-strong" />
21
+ {heading}
22
+ </h2>
23
+ <ul class="text-sm text-muted-foreground space-y-2">
24
+ {items.map((item) => <li><strong>{item.label}</strong> {item.text}</li>)}
25
+ </ul>
26
+ </div>
27
+ </div>
@@ -0,0 +1,32 @@
1
+ ---
2
+ /**
3
+ * EditorialGuidePage — the shell of a long-form planning guide. The route keeps data, SEO and schema, and renders
4
+ * the site Layout around this template. It passes the site Breadcrumb in `breadcrumb`, its hero in `hero`, and the
5
+ * page body (byline, intro, sections, FAQ, sources, related blocks, each in its own Container) as default-slot
6
+ * children. The template owns the Pagefind markers: the hero and the body are indexed, the breadcrumb band is not.
7
+ */
8
+ import BreadcrumbBand from '../layout/BreadcrumbBand.astro';
9
+
10
+ interface Props {
11
+ /** Pagefind `type` meta on the hero. */
12
+ pagefindType?: string;
13
+ /** Keep the breadcrumb band out of the Pagefind body. */
14
+ breadcrumbIgnore?: boolean;
15
+ }
16
+
17
+ const { pagefindType = 'guide', breadcrumbIgnore = true } = Astro.props;
18
+ ---
19
+
20
+ <BreadcrumbBand ignore={breadcrumbIgnore}>
21
+ <slot name="breadcrumb" />
22
+ </BreadcrumbBand>
23
+
24
+ <div data-pagefind-body data-pagefind-meta={`type:${pagefindType}`}>
25
+ <slot name="hero" />
26
+ </div>
27
+
28
+ <main class="py-12 lg:py-20">
29
+ <div data-pagefind-body>
30
+ <slot />
31
+ </div>
32
+ </main>
@@ -0,0 +1,178 @@
1
+ ---
2
+ /**
3
+ * EventPage — the shell and shared body of an event page. The route keeps paths, data, SEO and schema, and renders
4
+ * the site Layout around this template. Slots:
5
+ * breadcrumb — the site Breadcrumb, in the band under the header.
6
+ * hero — the site hero, inside the Pagefind body with `type:<pagefindType>` meta.
7
+ * after-body — site blocks between the description and the shared boxes.
8
+ * after-details — site blocks after the tags, inside the measure column (affiliate, FAQ, onward links).
9
+ * tail — full-width blocks after the column (related events, related content).
10
+ * Every string arrives as a prop; the route renders markdown and passes it as TrustedHtml.
11
+ */
12
+ import Container from '../layout/Container.astro';
13
+ import Badge from '../primitives/Badge.astro';
14
+ import BreadcrumbBand from '../layout/BreadcrumbBand.astro';
15
+ import { newTab } from '../primitives/newTab';
16
+ import type { TrustedHtml } from '../types';
17
+ import type { EventInfoRow } from './event';
18
+
19
+ interface Props {
20
+ bodyHtml: TrustedHtml;
21
+ /** Prose modifiers after `prose`, e.g. the site's prose theme and size. */
22
+ proseClass?: string;
23
+ backLink: { href: string; label: string };
24
+ highlights?: { heading: string; items: string[] };
25
+ practicalInfo?: { heading: string; rows: EventInfoRow[] };
26
+ price?: { label: string; value: string };
27
+ officialWebsite?: { href: string; label: string };
28
+ tags?: { heading: string; items: string[] };
29
+ pagefindType?: string;
30
+ }
31
+
32
+ const {
33
+ bodyHtml,
34
+ proseClass = 'prose-lg',
35
+ backLink,
36
+ highlights,
37
+ practicalInfo,
38
+ price,
39
+ officialWebsite,
40
+ tags,
41
+ pagefindType = 'event',
42
+ } = Astro.props;
43
+
44
+ const toneText = { primary: 'text-primary', secondary: 'text-secondary', accent: 'text-accent' };
45
+ ---
46
+
47
+ <BreadcrumbBand>
48
+ <slot name="breadcrumb" />
49
+ </BreadcrumbBand>
50
+
51
+ <div data-pagefind-body data-pagefind-meta={`type:${pagefindType}`}>
52
+ <slot name="hero" />
53
+ </div>
54
+
55
+ <main class="pt-6 pb-12 lg:pt-8 lg:pb-20">
56
+ <Container size="measure">
57
+ <a
58
+ href={backLink.href}
59
+ class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors mb-8"
60
+ >
61
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
62
+ <path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
63
+ </svg>
64
+ {backLink.label}
65
+ </a>
66
+
67
+ <article class={`prose ${proseClass} max-w-none`} data-pagefind-body>
68
+ <div set:html={bodyHtml} />
69
+ </article>
70
+
71
+ <slot name="after-body" />
72
+
73
+ {highlights && highlights.items.length > 0 && (
74
+ <div class="mt-12 p-6 bg-primary/5 rounded-xl border border-primary/20">
75
+ <h2
76
+ class="text-xl font-medium mb-6 flex items-center gap-2 font-display"
77
+ >
78
+ <svg class="w-5 h-5 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
79
+ <path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" />
80
+ </svg>
81
+ {highlights.heading}
82
+ </h2>
83
+ <ul class="space-y-3">
84
+ {highlights.items.map((highlight) => (
85
+ <li class="flex items-start gap-3">
86
+ <svg class="w-5 h-5 text-primary shrink-0 mt-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
87
+ <polyline points="20 6 9 17 4 12" />
88
+ </svg>
89
+ <span class="text-foreground">{highlight}</span>
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ </div>
94
+ )}
95
+
96
+ {practicalInfo && (
97
+ <div class="mt-12 p-6 bg-muted/50 rounded-xl border border-border">
98
+ <h2
99
+ class="text-xl font-medium mb-6 flex items-center gap-2 font-display"
100
+ >
101
+ <svg class="w-5 h-5 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
102
+ <circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" />
103
+ </svg>
104
+ {practicalInfo.heading}
105
+ </h2>
106
+
107
+ <div class="space-y-4">
108
+ {practicalInfo.rows.map((row) => (
109
+ <div class="flex items-start gap-3">
110
+ <div class="w-10 h-10 rounded-lg bg-background flex items-center justify-center shrink-0">
111
+ <svg class={`w-5 h-5 shrink-0 ${toneText[row.tone]}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
112
+ {row.icon === 'clock' && <Fragment><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></Fragment>}
113
+ {row.icon === 'sun' && <Fragment><circle cx="12" cy="12" r="4" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" /></Fragment>}
114
+ {row.icon === 'pen' && <Fragment><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" /></Fragment>}
115
+ {row.icon === 'bus' && <Fragment><path d="M8 6v6" /><path d="M15 6v6" /><path d="M2 12h19.6" /><path d="M18 18h3s.5-1.7.8-2.8c.1-.4.2-.8.2-1.2 0-.4-.1-.8-.2-1.2l-1.4-5C20.1 6.8 19.1 6 18 6H4a2 2 0 0 0-2 2v10h3" /><circle cx="7" cy="18" r="2" /><path d="M9 18h5" /><circle cx="16" cy="18" r="2" /></Fragment>}
116
+ </svg>
117
+ </div>
118
+ <div>
119
+ <span class="text-sm font-medium text-foreground">{row.label}</span>
120
+ {row.html !== undefined
121
+ ? <div class="text-sm text-muted-foreground mt-0.5 whitespace-pre-line" set:html={row.html} />
122
+ : <p class="text-sm text-muted-foreground mt-0.5">{row.text}</p>}
123
+ </div>
124
+ </div>
125
+ ))}
126
+ </div>
127
+ </div>
128
+ )}
129
+
130
+ {price && (
131
+ <div class="mt-8 p-4 bg-accent/10 rounded-lg border border-accent/20">
132
+ <div class="flex items-center gap-3">
133
+ <svg class="w-6 h-6 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
134
+ <path d="M4 10h12"/><path d="M4 14h9"/><path d="M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2"/>
135
+ </svg>
136
+ <div>
137
+ <span class="text-sm font-medium">{price.label}</span>
138
+ <p class="text-lg text-accent font-medium">{price.value}</p>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ )}
143
+
144
+ {officialWebsite && (
145
+ <div class="mt-8">
146
+ <a
147
+ href={officialWebsite.href}
148
+ {...newTab(officialWebsite.href, '_blank')}
149
+ class="inline-flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary-strong transition-colors"
150
+ >
151
+ <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
152
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" />
153
+ </svg>
154
+ {officialWebsite.label}
155
+ </a>
156
+ </div>
157
+ )}
158
+
159
+ {tags && tags.items.length > 0 && (
160
+ <div class="mt-8">
161
+ <h3 class="text-sm font-medium text-muted-foreground mb-3">
162
+ {tags.heading}
163
+ </h3>
164
+ <div class="flex flex-wrap gap-2">
165
+ {tags.items.map((tag) => (
166
+ <Badge tone="custom" toneClass="bg-muted text-muted-foreground" size="lg" lines={0} class="capitalize !py-1 ![font-weight:inherit]">
167
+ {tag}
168
+ </Badge>
169
+ ))}
170
+ </div>
171
+ </div>
172
+ )}
173
+
174
+ <slot name="after-details" />
175
+ </Container>
176
+
177
+ <slot name="tail" />
178
+ </main>
@@ -0,0 +1,34 @@
1
+ ---
2
+ /**
3
+ * NumberedStep — one stage of a multi-stage plan: a round marker, a title and a subtitle, then the stage's prose in
4
+ * a card (default slot). `tone` colours the marker and the card; the accent tone tints the card too.
5
+ */
6
+ interface Props {
7
+ marker: string;
8
+ title: string;
9
+ subtitle: string;
10
+ tone?: 'primary' | 'accent' | 'secondary';
11
+ }
12
+
13
+ const { marker, title, subtitle, tone = 'primary' } = Astro.props;
14
+
15
+ const [markerBg, markerText] = {
16
+ primary: ['bg-primary', 'text-primary-foreground'],
17
+ accent: ['bg-accent', 'text-accent-foreground'],
18
+ secondary: ['bg-secondary', 'text-secondary-foreground'],
19
+ }[tone];
20
+ const cardClass = tone === 'accent' ? 'bg-accent/10 rounded-xl border border-accent/20' : 'bg-card rounded-xl border border-border';
21
+ ---
22
+
23
+ <div>
24
+ <div class="flex items-center gap-4 mb-4">
25
+ <div class={`w-14 h-14 rounded-full ${markerBg} flex items-center justify-center ${markerText} font-bold text-xl shrink-0`}>{marker}</div>
26
+ <div>
27
+ <h3 class="text-xl font-semibold font-display">{title}</h3>
28
+ <p class="text-muted-foreground text-sm">{subtitle}</p>
29
+ </div>
30
+ </div>
31
+ <div class={`p-6 ${cardClass}`}>
32
+ <slot />
33
+ </div>
34
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { TrustedHtml } from '../types';
2
+
3
+ export type EventInfoIcon = 'clock' | 'sun' | 'pen' | 'bus';
4
+
5
+ /** One row of an EventPage practical-information box. */
6
+ export interface EventInfoRow {
7
+ icon: EventInfoIcon;
8
+ tone: 'primary' | 'secondary' | 'accent';
9
+ label: string;
10
+ /** Plain text, or `html` for rendered markdown. */
11
+ text?: string;
12
+ html?: TrustedHtml;
13
+ }
@@ -1,2 +1,6 @@
1
- // Public surface of @sarimarcus/content-sites-ui/editorial. Components land here from SLA-2742 on.
2
- export {};
1
+ // Public surface of @sarimarcus/content-sites-ui/editorial.
2
+ export { default as EditorialGuidePage } from './EditorialGuidePage.astro';
3
+ export { default as EventPage } from './EventPage.astro';
4
+ export { default as BeforeYouGo } from './BeforeYouGo.astro';
5
+ export { default as NumberedStep } from './NumberedStep.astro';
6
+ export type { EventInfoRow, EventInfoIcon } from './event';
@@ -0,0 +1,19 @@
1
+ ---
2
+ /**
3
+ * BreadcrumbBand — the muted strip under the fixed header that holds a page's breadcrumb trail (default slot).
4
+ * `ignore` keeps it out of the Pagefind body.
5
+ */
6
+ import Container from './Container.astro';
7
+
8
+ interface Props {
9
+ ignore?: boolean;
10
+ }
11
+
12
+ const { ignore = true } = Astro.props;
13
+ ---
14
+
15
+ <div class="pt-16 bg-muted/50 border-b border-border" data-pagefind-ignore={ignore ? '' : undefined}>
16
+ <Container class="py-3">
17
+ <slot />
18
+ </Container>
19
+ </div>
@@ -1,2 +1,3 @@
1
1
  // Public surface of @sarimarcus/content-sites-ui/layout.
2
2
  export { default as Container } from './Container.astro';
3
+ export { default as BreadcrumbBand } from './BreadcrumbBand.astro';
@@ -0,0 +1,144 @@
1
+ ---
2
+ /**
3
+ * ArticleMetadata — byline and freshness dates (E-E-A-T).
4
+ *
5
+ * The author arrives resolved: the site looks the name up in its authors collection and passes the profile URL,
6
+ * the optimised portrait (or a photo URL) and the credentials. No author renders no byline and no avatar; a site
7
+ * that wants a brand byline passes it as an author.
8
+ *
9
+ * Dates: most callers pass a date-only value (only articles carry a real HH:MM), so a time is printed only when the
10
+ * input has one, in `timeZone`; a date-only value always prints its UTC calendar day. "Updated" leads when the two
11
+ * dates fall on different calendar days, since recency is what a reader and a crawler look for.
12
+ *
13
+ * The `\n`-prefixed labels and `bylineLead` reproduce the text nodes the sites' templates compile to, so adoption
14
+ * changes no byte.
15
+ */
16
+ import { Image } from 'astro:assets';
17
+ import type { ArticleAuthor } from './types';
18
+
19
+ interface Props {
20
+ author?: ArticleAuthor | null;
21
+ datePublished?: string;
22
+ dateModified?: string;
23
+ /** Time zone for a value that carries a time. */
24
+ timeZone?: string;
25
+ locale?: string;
26
+ writtenByLabel?: string;
27
+ /** Portrait alt text, followed by the author's name. */
28
+ portraitLabel?: string;
29
+ updatedLabel?: string;
30
+ publishedLabel?: string;
31
+ ariaLabel?: string;
32
+ /** Classes of the round placeholder shown when the author has no portrait. */
33
+ placeholderClass?: string;
34
+ updatedIconClass?: string;
35
+ /** Whitespace compatibility only: 'grouped' compiles to the markup of a template that wraps the byline in a Fragment. */
36
+ bylineWhitespace?: 'flat' | 'grouped';
37
+ }
38
+
39
+ const {
40
+ author,
41
+ datePublished,
42
+ dateModified,
43
+ timeZone = 'UTC',
44
+ locale = 'en-GB',
45
+ writtenByLabel = 'Written by',
46
+ portraitLabel = 'Portrait of',
47
+ updatedLabel = 'Updated',
48
+ publishedLabel = 'Published',
49
+ ariaLabel = 'Article information',
50
+ placeholderClass = 'bg-gradient-to-br from-primary/20 to-accent/20',
51
+ updatedIconClass = 'text-primary',
52
+ bylineWhitespace = 'flat',
53
+ } = Astro.props;
54
+
55
+ function formatDate(dateStr: string | undefined): string {
56
+ if (!dateStr) return '';
57
+
58
+ const date = new Date(dateStr);
59
+ return dateStr.includes('T')
60
+ ? date.toLocaleString(locale, {
61
+ year: 'numeric',
62
+ month: 'long',
63
+ day: 'numeric',
64
+ hour: '2-digit',
65
+ minute: '2-digit',
66
+ timeZone,
67
+ })
68
+ : date.toLocaleDateString(locale, {
69
+ year: 'numeric',
70
+ month: 'long',
71
+ day: 'numeric',
72
+ timeZone: 'UTC',
73
+ });
74
+ }
75
+
76
+ const publishedFormatted = formatDate(datePublished);
77
+ const modifiedFormatted = formatDate(dateModified);
78
+ const showModified = !!dateModified && (!datePublished || dateModified.slice(0, 10) !== datePublished.slice(0, 10));
79
+
80
+ const name = author?.name;
81
+ const portraitAlt = `${portraitLabel} ${name}`;
82
+ const credentials = author?.credentials ?? null;
83
+ const grouped = bylineWhitespace === 'grouped';
84
+ const bylineLead = author ? (grouped ? ' ' : ' ') : (grouped ? '' : ' ');
85
+ ---
86
+
87
+ <address class="not-italic mb-5" role="contentinfo" aria-label={ariaLabel}>
88
+ <div class="flex items-start gap-3">
89
+ {author && (author.image ? (
90
+ <Image
91
+ src={author.image}
92
+ alt={portraitAlt}
93
+ width={96}
94
+ height={96}
95
+ class="w-12 h-12 rounded-full object-cover bg-muted shrink-0 mt-0.5"
96
+ loading="lazy"
97
+ decoding="async"
98
+ />
99
+ ) : author.photoUrl ? (
100
+ <img
101
+ src={author.photoUrl}
102
+ alt={portraitAlt}
103
+ width="48"
104
+ height="48"
105
+ class="w-12 h-12 rounded-full object-cover bg-muted shrink-0 mt-0.5"
106
+ loading="lazy"
107
+ decoding="async"
108
+ />
109
+ ) : (
110
+ <div class:list={['w-12 h-12 rounded-full', placeholderClass, 'shrink-0 mt-0.5']} aria-hidden="true" />
111
+ ))}
112
+
113
+ {/* name, credentials, dates */}
114
+ <div class="min-w-0">{bylineLead}{author && <div class="text-xs text-muted-foreground mb-0.5">{writtenByLabel}</div>}
115
+ {author && (author.href ? (
116
+ <a href={author.href} rel="author" class="font-semibold text-foreground hover:text-primary transition-colors hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
117
+ {name}
118
+ </a>
119
+ ) : (
120
+ <span class="font-semibold text-foreground">{name}</span>
121
+ ))}
122
+ {author && credentials && (
123
+ <p class="mt-1 text-sm text-muted-foreground leading-snug line-clamp-2">{credentials}</p>
124
+ )}
125
+ {/* freshness first */}
126
+ <div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
127
+ {showModified && modifiedFormatted ? (
128
+ <span class="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
129
+ <svg class:list={['w-3.5 h-3.5 shrink-0', updatedIconClass]} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
130
+ <polyline points="23 4 23 10 17 10" />
131
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
132
+ </svg>{`\n${updatedLabel}`} <time datetime={dateModified}>{modifiedFormatted}</time>
133
+ </span>
134
+ ) : publishedFormatted && (
135
+ <span class="text-sm font-medium text-foreground">{`\n${publishedLabel}`} <time datetime={datePublished}>{publishedFormatted}</time>
136
+ </span>
137
+ )}
138
+ {showModified && publishedFormatted && (
139
+ <span>{publishedLabel} <time datetime={datePublished}>{publishedFormatted}</time></span>
140
+ )}
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </address>
@@ -0,0 +1,190 @@
1
+ ---
2
+ /**
3
+ * AuthoritativeSources — numbered, anchored references (E-E-A-T). Each item carries id="fn-{n}" so an inline `[^n]`
4
+ * footnote link jumps to it (validate-footnote-render relies on that), and the inline script opens the list when the
5
+ * page loads on, or navigates to, a `#fn-n` hash.
6
+ *
7
+ * A source is a URL string, labelled from `domainLabels` (by host, without `www.`) or else by its host, or an object
8
+ * { url, name?, description?, quote? }: `description` is a curated blurb, `quote` the verbatim cited sentence.
9
+ *
10
+ * Link policy (Rule 38), from `domainClass` (host → class, or a function of the URL): a competitor renders its label
11
+ * unlinked (the item stays, so footnote numbers hold), an affiliate link carries rel="noopener sponsored", any other
12
+ * rel="noopener".
13
+ *
14
+ * `disambiguate` suffixes domain-derived labels shared by different URLs with a fragment of their URL path
15
+ * ("Host — Prices"), so two pages of one domain stay distinguishable. Explicit names, repeat citations of one URL and
16
+ * domain-root URLs keep their label.
17
+ */
18
+ import type { SourceInput, DomainClassifier } from './types';
19
+
20
+ interface Props {
21
+ sources: SourceInput[];
22
+ title?: string;
23
+ class?: string;
24
+ eyebrow?: string;
25
+ eyebrowClass?: string;
26
+ domainLabels?: Record<string, string>;
27
+ domainClass?: DomainClassifier;
28
+ disambiguate?: boolean;
29
+ disambiguationSeparator?: string;
30
+ /** Follows the count: "12 references". */
31
+ countLabel?: string;
32
+ showLabel?: string;
33
+ hideLabel?: string;
34
+ newTabLabel?: string;
35
+ /** Size class of the Show/Hide toggle labels. */
36
+ toggleSizeClass?: string;
37
+ }
38
+
39
+ const {
40
+ sources,
41
+ title = 'Sources',
42
+ class: className = '',
43
+ eyebrow = 'Further reading',
44
+ eyebrowClass = 'text-xs font-semibold uppercase tracking-[0.18em] text-primary',
45
+ domainLabels = {},
46
+ domainClass = {},
47
+ disambiguate = false,
48
+ disambiguationSeparator = ' — ',
49
+ countLabel = 'references',
50
+ showLabel = 'Show',
51
+ hideLabel = 'Hide',
52
+ newTabLabel = '(opens in a new tab)',
53
+ toggleSizeClass = 'text-sm',
54
+ } = Astro.props;
55
+
56
+ if (!sources || sources.length === 0) return null;
57
+
58
+ const hostKey = (host: string) => host.toLowerCase().replace(/^www\./, '');
59
+ const classMap = typeof domainClass === 'function'
60
+ ? null
61
+ : new Map(Object.entries(domainClass).map(([d, c]) => [hostKey(d), c]));
62
+
63
+ function classify(url: string): string {
64
+ if (typeof domainClass === 'function') return domainClass(url);
65
+ try {
66
+ return classMap?.get(hostKey(new URL(url).hostname)) ?? 'other';
67
+ } catch {
68
+ return 'other';
69
+ }
70
+ }
71
+
72
+ function domainLabel(url: string): string {
73
+ try {
74
+ const h = new URL(url).hostname.replace(/^www\./, '');
75
+ return domainLabels[h] ?? h;
76
+ } catch {
77
+ return url;
78
+ }
79
+ }
80
+
81
+ function humanizeSlug(slug: string): string {
82
+ const cleaned = slug
83
+ .replace(/\.(html?|php|aspx?)$/i, '')
84
+ .replace(/[-_]+/g, ' ')
85
+ .trim();
86
+ if (!cleaned) return '';
87
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
88
+ }
89
+
90
+ function pathFragment(url: string): string | null {
91
+ try {
92
+ const segments = new URL(url).pathname.split('/').filter(Boolean);
93
+ if (segments.length === 0) return null;
94
+ const humanized = humanizeSlug(decodeURIComponent(segments[segments.length - 1]));
95
+ return humanized || null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ const normalized: { name: string; url: string; description?: string; quote?: string; linkRel: string | null; fromDomain: boolean }[] = sources.map((s) => {
102
+ const entry =
103
+ typeof s === 'string'
104
+ ? { name: domainLabel(s), url: s, fromDomain: true }
105
+ : { url: s.url, name: s.name ?? domainLabel(s.url), description: s.description, quote: s.quote, fromDomain: !s.name };
106
+ const cls = classify(entry.url);
107
+ return {
108
+ ...entry,
109
+ linkRel: cls === 'competitor' ? null : cls === 'affiliate' ? 'noopener sponsored' : 'noopener',
110
+ };
111
+ });
112
+
113
+ if (disambiguate) {
114
+ const urlsByName = new Map<string, Set<string>>();
115
+ for (const n of normalized) if (n.fromDomain) urlsByName.set(n.name, (urlsByName.get(n.name) ?? new Set()).add(n.url));
116
+ for (const n of normalized) {
117
+ if (n.fromDomain && (urlsByName.get(n.name)?.size ?? 0) > 1) {
118
+ const frag = pathFragment(n.url);
119
+ if (frag) n.name = `${n.name}${disambiguationSeparator}${frag}`;
120
+ }
121
+ }
122
+ }
123
+ ---
124
+
125
+ <section class:list={["mt-12 pt-8 border-t border-border", className]} aria-labelledby="sources-heading">
126
+ <p class:list={[eyebrowClass, 'mb-2']}>{eyebrow}</p>
127
+ <h2 id="sources-heading" class="text-xl sm:text-2xl font-normal font-display text-foreground mb-3 tracking-tight">{title}</h2>
128
+ <details data-sources class="group">
129
+ <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 px-4 py-2.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary marker:content-none">
130
+ <span class="text-muted-foreground">{normalized.length} {countLabel}</span>
131
+ <span class:list={[toggleSizeClass, 'font-medium text-primary group-open:hidden']}>{showLabel}</span>
132
+ <span class:list={['hidden', toggleSizeClass, 'font-medium text-primary group-open:inline']}>{hideLabel}</span>
133
+ </summary>
134
+
135
+ <ol class="mt-5 space-y-2 list-none" role="list">
136
+ {normalized.map((source, i) => (
137
+ <li
138
+ id={`fn-${i + 1}`}
139
+ class="flex items-baseline gap-3 p-3 bg-muted/30 rounded-lg border border-border hover:border-primary/50 transition-colors scroll-mt-28"
140
+ >
141
+ <span class="font-mono text-sm text-primary min-w-[2rem] shrink-0 select-none" aria-hidden="true">
142
+ [{i + 1}]
143
+ </span>
144
+ <div class="flex-1 min-w-0">
145
+ {source.linkRel === null ? (
146
+ <span class="inline-flex items-baseline gap-1.5 font-medium text-sm text-foreground break-words">
147
+ <span>{source.name}</span>
148
+ </span>
149
+ ) : (
150
+ <a
151
+ href={source.url}
152
+ target="_blank"
153
+ rel={source.linkRel}
154
+ class="inline-flex items-baseline gap-1.5 font-medium text-sm text-foreground hover:text-primary transition-colors break-words"
155
+ >
156
+ <span>{source.name}</span>
157
+ <svg class="w-3.5 h-3.5 self-center text-muted-foreground shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
158
+ <path d="M7 7h10v10" />
159
+ <path d="M7 17 17 7" />
160
+ </svg>
161
+ <span class="sr-only">{newTabLabel}</span>
162
+ </a>
163
+ )}
164
+ {source.description && (
165
+ <p class="text-sm text-muted-foreground mt-1 leading-relaxed">{source.description}</p>
166
+ )}
167
+ {source.quote && (
168
+ <p class="text-sm text-muted-foreground mt-1.5 leading-relaxed italic border-l-2 border-primary/30 pl-3">
169
+ &ldquo;{source.quote}&rdquo;
170
+ </p>
171
+ )}
172
+ </div>
173
+ </li>
174
+ ))}
175
+ </ol>
176
+ </details>
177
+ </section>
178
+
179
+ <script is:inline>
180
+ (() => {
181
+ const revealSource = () => {
182
+ if (!/^#fn-\d+$/.test(location.hash)) return;
183
+ const target = document.querySelector(location.hash);
184
+ const details = target?.closest('[data-sources]');
185
+ if (details) details.open = true;
186
+ };
187
+ revealSource();
188
+ addEventListener('hashchange', revealSource);
189
+ })();
190
+ </script>
@@ -0,0 +1,80 @@
1
+ ---
2
+ /**
3
+ * Breadcrumb — the visual trail, Home first. Renders no JSON-LD: the BreadcrumbList is the structured-data adapter's.
4
+ *
5
+ * Single line, always: the trail scrolls horizontally instead of wrapping, so a deep trail never pushes the page
6
+ * content down on mobile. `tapTargets`: py-1/-my-1 extends each link's hit area 4px beyond its row without moving
7
+ * layout; the nav's own py-1/-my-1 mirrors it so the overflow container never clips that hit area (28px meets
8
+ * WCAG 2.5.8 AA). `separator="star"` draws a filled star that fades with depth.
9
+ *
10
+ * `navLead` reproduces the text node the sites' templates emit before the list (two spaces with tap targets, one
11
+ * without), so adopting the package changes no byte.
12
+ */
13
+ import { url } from '@sarimarcus/content-sites-core/url';
14
+ import Icon from '../primitives/Icon.astro';
15
+ import type { BreadcrumbItem } from './types';
16
+
17
+ interface Props {
18
+ items: BreadcrumbItem[];
19
+ compactMobile?: boolean;
20
+ textColorClass?: string;
21
+ linkColorClass?: string;
22
+ separatorColorClass?: string;
23
+ separator?: 'chevron' | 'star';
24
+ tapTargets?: boolean;
25
+ homeLabel?: string;
26
+ homeHref?: string;
27
+ ariaLabel?: string;
28
+ basePath?: string;
29
+ }
30
+
31
+ const {
32
+ items,
33
+ textColorClass = 'text-muted-foreground',
34
+ linkColorClass = 'hover:text-primary',
35
+ separatorColorClass = 'text-border',
36
+ compactMobile = false,
37
+ separator = 'chevron',
38
+ tapTargets = true,
39
+ homeLabel = 'Home',
40
+ homeHref = '/',
41
+ ariaLabel = 'Breadcrumb navigation',
42
+ basePath = '/',
43
+ } = Astro.props;
44
+
45
+ const breadcrumbs = [
46
+ { name: homeLabel, url: homeHref },
47
+ ...items,
48
+ ];
49
+ const navLead = tapTargets ? ' ' : ' ';
50
+ ---
51
+
52
+ <nav aria-label={ariaLabel} class:list={[tapTargets ? 'text-sm py-1 -my-1 px-1 -mx-1' : 'text-sm', compactMobile ? 'overflow-hidden sm:overflow-x-auto' : 'overflow-x-auto scrollbar-hide', textColorClass]}>{navLead}<ol class:list={[tapTargets ? 'flex flex-nowrap items-center gap-x-1.5 whitespace-nowrap' : 'flex flex-nowrap items-center gap-1.5 whitespace-nowrap']}>
53
+ {breadcrumbs.map((item, index) => (
54
+ <li class:list={['flex items-center gap-1.5', compactMobile && index === breadcrumbs.length - 1 ? 'min-w-0 sm:shrink-0' : 'shrink-0']}>
55
+ {index > 0 && (separator === 'star' ? (
56
+ <Icon
57
+ name="star"
58
+ filled
59
+ class:list={['w-2.5 h-2.5 shrink-0', separatorColorClass]}
60
+ style={`opacity: ${Math.max(0.4, 1 - (index - 1) * 0.15)}`}
61
+ />
62
+ ) : (
63
+ <svg class:list={['w-3.5 h-3.5 shrink-0', separatorColorClass]} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
64
+ <path d="m9 18 6-6-6-6"/>
65
+ </svg>
66
+ ))}
67
+ {item.url && index < breadcrumbs.length - 1 ? (
68
+ <a
69
+ href={url(item.url, basePath)}
70
+ class:list={[tapTargets ? 'inline-flex items-center py-1 -my-1 px-1 -mx-1 transition-colors' : 'transition-colors', linkColorClass]}
71
+ >
72
+ {item.name}
73
+ </a>
74
+ ) : (
75
+ <span class:list={['font-medium', compactMobile && index === breadcrumbs.length - 1 ? 'truncate sm:overflow-visible' : '']}>{item.name}</span>
76
+ )}
77
+ </li>
78
+ ))}
79
+ </ol>
80
+ </nav>
@@ -0,0 +1,20 @@
1
+ ---
2
+ /**
3
+ * JsonLd — renders structured data as one <script type="application/ld+json">.
4
+ *
5
+ * Output is JSON.stringify(data) with the minimal escaping that keeps the block inside its script element while
6
+ * staying valid JSON: `</script` (any case) → `<\/script`, `<!--` → `<\u0021--`, U+2028/U+2029 → `\u2028`/`\u2029`.
7
+ * Any other input renders byte-identical to `set:html={JSON.stringify(data)}`. Values may carry markup (an FAQ answer
8
+ * with a link), so `<` is deliberately left as is.
9
+ */
10
+ import { escapeJsonLd } from './jsonLd';
11
+
12
+ interface Props {
13
+ data: object | object[];
14
+ }
15
+
16
+ const { data } = Astro.props;
17
+ const json = escapeJsonLd(JSON.stringify(data));
18
+ ---
19
+
20
+ <script type="application/ld+json" set:html={json} />
@@ -0,0 +1,29 @@
1
+ ---
2
+ /**
3
+ * OgImageMeta — Open Graph and Twitter image tags. A relative `image` (or the default) is made absolute against
4
+ * `siteUrl` + `baseUrl`; an `http…` image passes through. The 1200×675 default matches the OG generator
5
+ * (scripts/og-images/generate.mjs).
6
+ */
7
+ interface Props {
8
+ image?: string;
9
+ alt: string;
10
+ siteUrl: string;
11
+ baseUrl: string;
12
+ defaultImage?: string;
13
+ width?: number;
14
+ height?: number;
15
+ }
16
+
17
+ const { image, alt, siteUrl, baseUrl, defaultImage = '/og-default.jpg', width = 1200, height = 675 } = Astro.props;
18
+ const ogImagePath = image ?? defaultImage;
19
+ const absoluteOgImage = ogImagePath.startsWith('http')
20
+ ? ogImagePath
21
+ : `${siteUrl}${baseUrl}${ogImagePath}`;
22
+ ---
23
+
24
+ <meta property="og:image" content={absoluteOgImage} />
25
+ <meta property="og:image:alt" content={alt} />
26
+ <meta property="og:image:width" content={String(width)} />
27
+ <meta property="og:image:height" content={String(height)} />
28
+ <meta name="twitter:image" content={absoluteOgImage} />
29
+ <meta name="twitter:image:alt" content={alt} />
package/src/seo/index.ts CHANGED
@@ -1,2 +1,8 @@
1
- // Public surface of @sarimarcus/content-sites-ui/seo. Components land here from SLA-2742 on.
2
- export {};
1
+ // Public surface of @sarimarcus/content-sites-ui/seo.
2
+ export { default as JsonLd } from './JsonLd.astro';
3
+ export { default as OgImageMeta } from './OgImageMeta.astro';
4
+ export { default as Breadcrumb } from './Breadcrumb.astro';
5
+ export { default as ArticleMetadata } from './ArticleMetadata.astro';
6
+ export { default as AuthoritativeSources } from './AuthoritativeSources.astro';
7
+ export { escapeJsonLd } from './jsonLd';
8
+ export type { BreadcrumbItem, ArticleAuthor, SourceInput, DomainClassifier } from './types';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Keep serialised JSON inside a <script> element without changing what it parses to. Every match sits inside a JSON
3
+ * string (none of these sequences can occur outside one), where `\/`, `\u0021` and `\u2028` are valid escapes.
4
+ */
5
+ export function escapeJsonLd(json: string): string {
6
+ return json
7
+ .replace(/<\/(script)/gi, '<\\/$1')
8
+ .replace(/<!--/g, '<\\u0021--')
9
+ .replace(/\u2028/g, '\\u2028')
10
+ .replace(/\u2029/g, '\\u2029');
11
+ }
@@ -0,0 +1,20 @@
1
+ import type { ImageMetadata } from 'astro';
2
+
3
+ export interface BreadcrumbItem {
4
+ name: string;
5
+ url?: string;
6
+ }
7
+
8
+ /** An author already resolved by the site: profile URL, optimised portrait or photo URL, credentials. */
9
+ export interface ArticleAuthor {
10
+ name: string;
11
+ href?: string | null;
12
+ image?: ImageMetadata | null;
13
+ photoUrl?: string | null;
14
+ credentials?: string | null;
15
+ }
16
+
17
+ export type SourceInput = string | { name?: string; url: string; description?: string; quote?: string };
18
+
19
+ /** Host → outbound class (`competitor`, `affiliate`, …), or a function of the URL. */
20
+ export type DomainClassifier = Record<string, string> | ((url: string) => string);
@@ -1,3 +1,12 @@
1
1
  /* Tailwind v4 skips node_modules during class detection. Consumers @import this file so the
2
2
  package's own templates are scanned: @import "@sarimarcus/content-sites-ui/tailwind.css"; */
3
3
  @source "..";
4
+
5
+ /* Horizontal scrollers that keep scrolling but draw no scrollbar (Breadcrumb, TableOfContents). */
6
+ @utility scrollbar-hide {
7
+ -ms-overflow-style: none;
8
+ scrollbar-width: none;
9
+ &::-webkit-scrollbar {
10
+ display: none;
11
+ }
12
+ }