@sarimarcus/content-sites-ui 0.13.0 → 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 +4 -0
- package/package.json +2 -2
- package/src/seo/ArticleMetadata.astro +144 -0
- package/src/seo/AuthoritativeSources.astro +190 -0
- package/src/seo/Breadcrumb.astro +80 -0
- package/src/seo/JsonLd.astro +20 -0
- package/src/seo/OgImageMeta.astro +29 -0
- package/src/seo/index.ts +8 -2
- package/src/seo/jsonLd.ts +11 -0
- package/src/seo/types.ts +20 -0
- package/src/styles/tailwind.css +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
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
|
+
|
|
5
9
|
## 0.13.0 (2026-10-01)
|
|
6
10
|
|
|
7
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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-ui",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
45
|
+
"@sarimarcus/content-sites-core": "0.14.0"
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -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
|
+
“{source.quote}”
|
|
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.
|
|
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
|
+
}
|
package/src/seo/types.ts
ADDED
|
@@ -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);
|
package/src/styles/tailwind.css
CHANGED
|
@@ -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
|
+
}
|