@sarimarcus/content-sites-ui 0.17.0 → 0.19.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.
@@ -0,0 +1,54 @@
1
+ ---
2
+ /**
3
+ * ArticleKeyFacts — the above-the-fold "3 quick facts" strip: figure-first tiles in a semantic <dl>, rendered only
4
+ * with 3+ items. Not composed/QuickFacts: that one is label-above-value, needs a title and a per-item icon.
5
+ * Palette classes are props so a site keeps its own figure and surface roles.
6
+ */
7
+ import type { KeyFact } from './types';
8
+
9
+ interface Props {
10
+ items: KeyFact[];
11
+ ariaLabel: string;
12
+ borderClass?: string;
13
+ surfaceClass?: string;
14
+ labelClass?: string;
15
+ figureClass?: string;
16
+ class?: string;
17
+ }
18
+
19
+ const {
20
+ items,
21
+ ariaLabel,
22
+ borderClass = 'border-accent/25',
23
+ surfaceClass = 'bg-card',
24
+ labelClass = 'tracking-[0.14em] text-foreground/70',
25
+ figureClass = 'text-primary',
26
+ class: className = '',
27
+ } = Astro.props;
28
+ ---
29
+
30
+ {items.length >= 3 && (
31
+ <dl
32
+ class:list={[
33
+ // gap-px over a bg-border grid draws the dividers as cell edges, never a side-stripe border.
34
+ `grid grid-cols-1 gap-px overflow-hidden rounded-xl border ${borderClass} bg-border sm:grid-cols-3`,
35
+ className,
36
+ ]}
37
+ aria-label={ariaLabel}
38
+ >
39
+ {items.map((fact) => (
40
+ <div class={`flex min-w-0 flex-col ${surfaceClass} px-5 py-5 sm:px-6`}>
41
+ {/* dt precedes dd in the DOM; order-* puts the figure first visually. */}
42
+ <dt class={`order-2 mt-2 text-[0.8125rem] font-semibold uppercase leading-none ${labelClass}`}>
43
+ {fact.label}
44
+ </dt>
45
+ <dd class={`order-1 m-0 font-display text-[2rem] leading-none tracking-[-0.02em] tabular-nums ${figureClass} sm:text-[2.25rem]`}>
46
+ {fact.figure}
47
+ </dd>
48
+ {fact.detailHtml && (
49
+ <dd class="order-3 m-0 mt-2 text-sm leading-snug text-muted-foreground" set:html={fact.detailHtml} />
50
+ )}
51
+ </div>
52
+ ))}
53
+ </dl>
54
+ )}
@@ -0,0 +1,40 @@
1
+ ---
2
+ /**
3
+ * ArticlesListingPage — the shell of an article listing (index, topic hub, their paginated pages): breadcrumb band,
4
+ * a header (eyebrow, heading, accent rule, lede) and <main>. The route keeps Layout, schema, paging and image maps,
5
+ * passes the breadcrumb in `breadcrumb`, an optional line under the lede in `header-extra`, and the main content
6
+ * (its own Containers) as default-slot children.
7
+ */
8
+ import BreadcrumbBand from '../layout/BreadcrumbBand.astro';
9
+ import Container from '../layout/Container.astro';
10
+
11
+ interface Props {
12
+ eyebrow: string;
13
+ heading: string;
14
+ lede: string;
15
+ /** Mark <main> as the Pagefind body. */
16
+ pagefindBody?: boolean;
17
+ }
18
+
19
+ const { eyebrow, heading, lede, pagefindBody = false } = Astro.props;
20
+ ---
21
+
22
+ <BreadcrumbBand>
23
+ <slot name="breadcrumb" />
24
+ </BreadcrumbBand>
25
+
26
+ <header class="bg-muted/30 border-b border-border pt-12 pb-10">
27
+ <Container size="measure">
28
+ <p class="text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">{eyebrow}</p>
29
+ <h1 class="text-3xl sm:text-4xl font-normal font-display text-foreground leading-tight text-balance">
30
+ {heading}
31
+ </h1>
32
+ <div class="mt-4 flex"><span class="block h-0.5 w-16 rounded-full bg-accent"></span></div>
33
+ <p class="mt-5 text-lg text-muted-foreground leading-relaxed">{lede}</p>
34
+ <slot name="header-extra" />
35
+ </Container>
36
+ </header>
37
+
38
+ <main class="py-14" data-pagefind-body={pagefindBody ? '' : undefined}>
39
+ <slot />
40
+ </main>
@@ -0,0 +1,174 @@
1
+ ---
2
+ /**
3
+ * AuthorCard — an author's portrait, role line, name, bio and expertise badges. Three shapes: a whole-card link
4
+ * (`href`), a centred polaroid portrait (`polaroid`), or a plain card with a profile link. `roleLabel` is free text
5
+ * whose meaning is the caller's: a job title on one page, "Curated by" on another.
6
+ */
7
+ import Badge from '../primitives/Badge.astro';
8
+ import LinkArrow from '../primitives/LinkArrow.astro';
9
+ import ArrowLabel from '../primitives/ArrowLabel.astro';
10
+
11
+ interface Props {
12
+ name: string;
13
+ /** Portrait URL, base path already applied. */
14
+ photoSrc: string;
15
+ portraitAlt: string;
16
+ bio: string;
17
+ credentials?: string;
18
+ expertise: string[];
19
+ /** Profile URL used by the plain and polaroid shapes. */
20
+ profileHref: string;
21
+ /** When set, the whole card is this link. */
22
+ href?: string;
23
+ roleLabel: string;
24
+ viewLabel: string;
25
+ /** Link-card footer text, e.g. "12 articles". */
26
+ countLabel?: string;
27
+ /** Clamp the bio to two lines (link-card shape). */
28
+ compact?: boolean;
29
+ headingLevel?: 2 | 3;
30
+ polaroid?: boolean;
31
+ /** Caption under the polaroid portrait. */
32
+ caption?: string;
33
+ class?: string;
34
+ }
35
+
36
+ const {
37
+ name,
38
+ photoSrc,
39
+ portraitAlt,
40
+ bio,
41
+ credentials,
42
+ expertise,
43
+ profileHref,
44
+ href,
45
+ roleLabel,
46
+ viewLabel,
47
+ countLabel,
48
+ compact = false,
49
+ headingLevel = 2,
50
+ polaroid = false,
51
+ caption,
52
+ class: className,
53
+ } = Astro.props;
54
+
55
+ const cardBase = 'rounded-2xl border border-border bg-card p-6 shadow-sm';
56
+ const linkExtra = 'group transition-all duration-200 hover:shadow-md hover:border-primary/40 block';
57
+ const Heading = `h${headingLevel}` as 'h2' | 'h3';
58
+ ---
59
+
60
+ {href ? (
61
+ <a href={href} class:list={[cardBase, linkExtra, className]}>
62
+ <article>
63
+ <div class="flex items-start gap-4">
64
+ <img
65
+ src={photoSrc}
66
+ alt={portraitAlt}
67
+ width="96"
68
+ height="96"
69
+ class="h-24 w-24 rounded-xl object-cover bg-muted shrink-0"
70
+ loading="lazy"
71
+ decoding="async"
72
+ />
73
+ <div class="min-w-0">
74
+ <p class="text-xs font-semibold uppercase tracking-[0.16em] text-primary">{roleLabel}</p>
75
+ <Heading class="mt-2 text-xl font-display font-semibold text-foreground">{name}</Heading>
76
+ <p class:list={["mt-3 text-sm text-muted-foreground leading-relaxed", compact && "line-clamp-2"]}>{bio}</p>
77
+ </div>
78
+ </div>
79
+
80
+ <div class="mt-5 flex flex-wrap gap-2">
81
+ {expertise.map((topic) => (
82
+ <Badge tone="custom" toneClass="border bg-muted/50 text-foreground" size="md" lines={0} class="border-border">
83
+ {topic}
84
+ </Badge>
85
+ ))}
86
+ </div>
87
+
88
+ <div class="mt-5 flex items-center justify-between gap-4">
89
+ {countLabel !== undefined && (
90
+ <p class="text-sm text-muted-foreground">
91
+ {countLabel}
92
+ </p>
93
+ )}
94
+ <ArrowLabel class="text-sm font-medium text-primary ml-auto">
95
+ {viewLabel}
96
+ </ArrowLabel>
97
+ </div>
98
+ </article>
99
+ </a>
100
+ ) : polaroid ? (
101
+ <article class:list={[cardBase, 'sm:p-8', className]}>
102
+ <div class="flex flex-col items-center gap-6 text-center lg:flex-row lg:items-start lg:gap-10 lg:text-left">
103
+ <div class="shrink-0 rotate-[-2deg] rounded-sm bg-white p-3 pb-8 shadow-xl ring-1 ring-black/5">
104
+ <img
105
+ src={photoSrc}
106
+ alt={portraitAlt}
107
+ width="256"
108
+ height="256"
109
+ class="h-48 w-48 sm:h-64 sm:w-64 rounded-sm object-cover bg-muted"
110
+ loading="lazy"
111
+ decoding="async"
112
+ />
113
+ {caption && (
114
+ <p class="mt-3 font-display italic text-sm text-neutral-600">{caption}</p>
115
+ )}
116
+ </div>
117
+
118
+ <div class="flex w-full flex-col items-center lg:items-start">
119
+ <p class="text-xs font-semibold uppercase tracking-[0.16em] text-primary">{roleLabel}</p>
120
+ <Heading class="mt-1 text-xl font-display font-semibold text-foreground">{name}</Heading>
121
+ <p class="mt-4 max-w-xl text-sm text-muted-foreground leading-relaxed text-pretty">{bio}</p>
122
+ {credentials && <p class="mt-2 max-w-xl text-sm text-foreground leading-relaxed text-pretty">{credentials}</p>}
123
+
124
+ <div class="mt-5 flex flex-wrap justify-center gap-2 lg:justify-start">
125
+ {expertise.map((topic) => (
126
+ <Badge tone="custom" toneClass="border bg-muted/50 text-foreground" size="md" lines={0} class="border-border">
127
+ {topic}
128
+ </Badge>
129
+ ))}
130
+ </div>
131
+
132
+ <div class="mt-5">
133
+ <LinkArrow href={profileHref} size="sm" color="primary">
134
+ {viewLabel}
135
+ </LinkArrow>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </article>
140
+ ) : (
141
+ <article class:list={[cardBase, className]}>
142
+ <div class="flex items-start gap-4">
143
+ <img
144
+ src={photoSrc}
145
+ alt={portraitAlt}
146
+ width="96"
147
+ height="96"
148
+ class="h-24 w-24 rounded-xl object-cover bg-muted shrink-0"
149
+ loading="lazy"
150
+ decoding="async"
151
+ />
152
+ <div class="min-w-0">
153
+ <p class="text-xs font-semibold uppercase tracking-[0.16em] text-primary">{roleLabel}</p>
154
+ <Heading class="mt-2 text-xl font-display font-semibold text-foreground">{name}</Heading>
155
+ <p class="mt-3 text-sm text-muted-foreground leading-relaxed">{bio}</p>
156
+ {credentials && <p class="mt-3 text-sm text-foreground leading-relaxed">{credentials}</p>}
157
+ </div>
158
+ </div>
159
+
160
+ <div class="mt-5 flex flex-wrap gap-2">
161
+ {expertise.map((topic) => (
162
+ <Badge tone="custom" toneClass="border bg-muted/50 text-foreground" size="md" lines={0} class="border-border">
163
+ {topic}
164
+ </Badge>
165
+ ))}
166
+ </div>
167
+
168
+ <div class="mt-5">
169
+ <LinkArrow href={profileHref} size="sm" color="primary">
170
+ {viewLabel}
171
+ </LinkArrow>
172
+ </div>
173
+ </article>
174
+ )}
@@ -0,0 +1,52 @@
1
+ ---
2
+ /** AuthorStrip — a centred row of author portraits linking to their profiles, with a link to the team page. */
3
+ import { Image } from 'astro:assets';
4
+ import type { ImageMetadata } from 'astro';
5
+ import type { AuthorSummary } from './types';
6
+
7
+ interface StripAuthor extends AuthorSummary {
8
+ displayName: string;
9
+ specialty: string;
10
+ image: ImageMetadata | string;
11
+ }
12
+
13
+ interface Props {
14
+ authors: readonly StripAuthor[];
15
+ ariaLabel: string;
16
+ heading: string;
17
+ teamHref: string;
18
+ teamLabel: string;
19
+ /** Portrait ring colour, e.g. "ring-background". */
20
+ ringClass?: string;
21
+ class?: string;
22
+ }
23
+
24
+ const { authors, ariaLabel, heading, teamHref, teamLabel, ringClass = 'ring-background', class: className = '' } = Astro.props;
25
+ const imgClass = `w-16 h-16 rounded-full object-cover ring-2 ${ringClass} group-hover:ring-primary/60 transition-shadow duration-200`;
26
+ ---
27
+ <section id="authors" aria-label={ariaLabel} class:list={["bg-muted/50 py-10", className]}>
28
+ <div class="mx-auto max-w-shell px-4 sm:px-6 lg:px-8">
29
+ <p class="text-center text-sm font-medium text-muted-foreground mb-6">{heading}</p>
30
+ <div class="flex flex-wrap justify-center gap-6 sm:gap-8">
31
+ {authors.map((author) => (
32
+ <a
33
+ href={author.href}
34
+ class="group flex flex-col items-center gap-2 transition-opacity hover:opacity-80"
35
+ >
36
+ {typeof author.image === 'string' ? (
37
+ <img src={author.image} alt={author.name} width="64" height="64" class={imgClass} loading="lazy" decoding="async" />
38
+ ) : (
39
+ <Image src={author.image} alt={author.name} width={64} height={64} class={imgClass} loading="lazy" />
40
+ )}
41
+ <span class="text-sm font-medium text-foreground">{author.displayName}</span>
42
+ <span class="text-xs text-muted-foreground">{author.specialty}</span>
43
+ </a>
44
+ ))}
45
+ </div>
46
+ <div class="mt-6 text-center">
47
+ <a href={teamHref} class="text-sm font-medium text-primary hover:underline">
48
+ {teamLabel}
49
+ </a>
50
+ </div>
51
+ </div>
52
+ </section>
@@ -0,0 +1,102 @@
1
+ ---
2
+ /**
3
+ * InsiderTips — the "reporter notebook" block: a header with an optional author byline, then one card per tip with a
4
+ * kind badge. Kinds are site-defined keys mapped to a label and a tone; tip bodies arrive as site-rendered HTML.
5
+ */
6
+ import Badge from '../primitives/Badge.astro';
7
+ import type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
8
+
9
+ interface Props {
10
+ items: InsiderTip[];
11
+ kinds: InsiderTipKinds;
12
+ title: string;
13
+ eyebrow: string;
14
+ /** Label above the author's name. */
15
+ filedByLabel: string;
16
+ author?: InsiderTipAuthor;
17
+ lede?: string;
18
+ /** Typography colour preset for the tip bodies, e.g. "prose-stone". */
19
+ proseClass?: string;
20
+ class?: string;
21
+ }
22
+
23
+ const { items, kinds, title, eyebrow, filedByLabel, author, lede, proseClass = 'prose-stone', class: className = '' } = Astro.props;
24
+
25
+ const toneClasses: Record<InsiderTipTone, { card: string; badge: string }> = {
26
+ accent: { card: 'border-accent/40 bg-accent/5', badge: 'bg-accent/20 text-accent-strong font-bold' },
27
+ primary: { card: 'border-primary/40 bg-primary/5', badge: 'bg-primary/15 text-primary' },
28
+ secondary: { card: 'border-secondary/40 bg-secondary/5', badge: 'bg-secondary/15 text-secondary' },
29
+ muted: { card: 'border-border bg-muted/35', badge: 'bg-muted text-foreground' },
30
+ };
31
+ const kindOf = (kind: string) => {
32
+ const k = kinds[kind];
33
+ if (!k) throw new Error(`InsiderTips: no label/tone for kind "${kind}"`);
34
+ return { label: k.label, ...toneClasses[k.tone] };
35
+ };
36
+ ---
37
+
38
+ {items.length > 0 && (
39
+ <section class:list={['rounded-2xl border border-primary/20 bg-muted/40 p-6 shadow-sm lg:p-8', className]}>
40
+ <div class="border-b border-primary/15 pb-5">
41
+ <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
42
+ <div class="max-w-deck">
43
+ <p class="text-xs font-semibold uppercase tracking-[0.22em] text-primary">{eyebrow}</p>
44
+ <h2 class="mt-2 text-xl font-semibold font-display">{title}</h2>
45
+ {lede && <p class="mt-2 text-sm leading-relaxed text-muted-foreground">{lede}</p>}
46
+ </div>
47
+
48
+ {author && (
49
+ <aside class="flex items-center gap-2 md:gap-3 lg:max-w-sm lg:justify-end">
50
+ {author.photoSrc ? (
51
+ <img
52
+ src={author.photoSrc}
53
+ alt={author.portraitAlt}
54
+ width="44"
55
+ height="44"
56
+ class="h-11 w-11 rounded-full object-cover bg-muted shrink-0"
57
+ loading="lazy"
58
+ decoding="async"
59
+ />
60
+ ) : (
61
+ <div class="h-11 w-11 rounded-full bg-primary/15 shrink-0" aria-hidden="true" />
62
+ )}
63
+ <div class="min-w-0">
64
+ <p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{filedByLabel}</p>
65
+ {author.href ? (
66
+ <a href={author.href} class="font-semibold text-foreground hover:text-primary transition-colors">{author.name}</a>
67
+ ) : (
68
+ <p class="font-semibold text-foreground">{author.name}</p>
69
+ )}
70
+ {author.expertise && (
71
+ <p class="mt-0.5 text-xs uppercase tracking-wide text-muted-foreground">{author.expertise}</p>
72
+ )}
73
+ {author.credentials && (
74
+ <p class="mt-1 text-sm leading-relaxed text-muted-foreground line-clamp-2">{author.credentials}</p>
75
+ )}
76
+ </div>
77
+ </aside>
78
+ )}
79
+ </div>
80
+ </div>
81
+
82
+ <div class:list={['mt-6 grid gap-4', items.length > 1 && 'md:grid-cols-2']}>
83
+ {items.map((item) => {
84
+ const k = kindOf(item.kind);
85
+ return (
86
+ // min-w-0 + break-words: a grid item defaults to min-width:auto, so one unbreakable token (a bare URL) would
87
+ // size the whole track and scroll the page sideways on a phone.
88
+ <article class:list={['min-w-0 break-words rounded-2xl border px-5 py-4', k.card]}>
89
+ <Badge tone="custom" toneClass={k.badge} size="md" lines={0} class="uppercase tracking-wide !font-semibold">
90
+ {k.label}
91
+ </Badge>
92
+ <h3 class="mt-3 text-lg font-semibold font-display text-foreground">{item.title}</h3>
93
+ <div
94
+ class={`prose ${proseClass} prose-sm mt-3 max-w-none text-foreground/80 prose-p:my-0 prose-strong:text-foreground`}
95
+ set:html={item.bodyHtml}
96
+ />
97
+ </article>
98
+ );
99
+ })}
100
+ </div>
101
+ </section>
102
+ )}
@@ -0,0 +1,98 @@
1
+ ---
2
+ /**
3
+ * NotFoundPage — the 404 body: a decorative arch-and-zellige backdrop, the large "404", a message, a home button, a
4
+ * search link and a short list of section links. The route keeps Layout (with `noindex`), passes every string and
5
+ * imports `@sarimarcus/content-sites-ui/not-found.css`.
6
+ */
7
+ import Button from '../primitives/Button.astro';
8
+ import LinkArrow from '../primitives/LinkArrow.astro';
9
+ import Icon from '../primitives/Icon.astro';
10
+
11
+ interface Props {
12
+ tagline: string;
13
+ lede: string;
14
+ homeHref: string;
15
+ homeLabel: string;
16
+ searchHref: string;
17
+ searchLabel: string;
18
+ searchAriaLabel: string;
19
+ exploreLabel: string;
20
+ /** Accessible name of the section links. */
21
+ exploreAriaLabel?: string;
22
+ links: { href: string; label: string }[];
23
+ }
24
+
25
+ const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaLabel, exploreLabel, exploreAriaLabel = 'Explore sections', links } = Astro.props;
26
+ ---
27
+
28
+ <main class="relative min-h-[80vh] flex items-center justify-center overflow-hidden bg-muted">
29
+ {/* Geometric zellige-inspired background pattern — pure CSS */}
30
+ <div class="absolute inset-0 opacity-[0.04] pointer-events-none" aria-hidden="true">
31
+ <div class="not-found-zellige-grid"></div>
32
+ </div>
33
+
34
+ {/* Decorative horseshoe arches — layered behind content */}
35
+ <div class="absolute inset-0 flex items-end justify-center pointer-events-none" aria-hidden="true">
36
+ <div class="not-found-arch-row">
37
+ <div class="not-found-arch not-found-arch--left"></div>
38
+ <div class="not-found-arch not-found-arch--center"></div>
39
+ <div class="not-found-arch not-found-arch--right"></div>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="relative z-10 max-w-deck mx-auto px-6 py-20 text-center">
44
+ {/* Decorative star motif */}
45
+ <div class="flex justify-center mb-6" aria-hidden="true">
46
+ <div class="not-found-islamic-star"></div>
47
+ </div>
48
+
49
+ {/* The 404 number — large and decorative */}
50
+ <h1 class="text-accent text-[8rem] sm:text-[10rem] md:text-[12rem] leading-none font-display tracking-tight select-none not-found-fade-in-up">
51
+ 404
52
+ </h1>
53
+
54
+ {/* Thin accent separator */}
55
+ <div class="mx-auto w-24 h-px bg-gradient-to-r from-transparent via-accent to-transparent my-6 not-found-fade-in-up not-found-delay-1" aria-hidden="true"></div>
56
+
57
+ {/* Message */}
58
+ <h2 class="font-display text-2xl sm:text-3xl text-foreground mb-4 not-found-fade-in-up not-found-delay-1">
59
+ {tagline}
60
+ </h2>
61
+ <p class="text-muted-foreground text-lg sm:text-xl leading-relaxed max-w-lg mx-auto mb-10 not-found-fade-in-up not-found-delay-2">
62
+ {lede}
63
+ </p>
64
+
65
+ {/* Primary CTA */}
66
+ <div class="mb-6 not-found-fade-in-up not-found-delay-2">
67
+ <Button href={homeHref} variant="primary">
68
+ {homeLabel}
69
+ </Button>
70
+ </div>
71
+
72
+ {/* Search prompt */}
73
+ <div class="mb-12 not-found-fade-in-up not-found-delay-3">
74
+ <a
75
+ href={searchHref}
76
+ data-search-trigger
77
+ class="group flex items-center gap-3 w-full max-w-sm mx-auto px-4 py-3 rounded-xl border border-foreground/20 bg-white/60 backdrop-blur-sm text-left hover:border-primary/40 hover:bg-white/80 transition-all no-underline"
78
+ aria-label={searchAriaLabel}
79
+ >
80
+ <Icon name="search" class="w-4 h-4 text-muted-foreground shrink-0" />
81
+ <span class="text-muted-foreground text-sm flex-1">{searchLabel}</span>
82
+ <kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border border-border text-muted-foreground/60 bg-muted/50">⌘K</kbd>
83
+ </a>
84
+ </div>
85
+
86
+ {/* Explore sections */}
87
+ <div class="not-found-fade-in-up not-found-delay-4">
88
+ <p class="text-sm uppercase tracking-widest text-muted-foreground/60 mb-5 font-medium">
89
+ {exploreLabel}
90
+ </p>
91
+ <nav aria-label={exploreAriaLabel} class="flex flex-wrap justify-center gap-x-6 gap-y-3">
92
+ {links.map((link, i) => (
93
+ <LinkArrow href={link.href} color={i % 2 === 0 ? 'secondary' : 'primary'} size="base">{link.label}</LinkArrow>
94
+ ))}
95
+ </nav>
96
+ </div>
97
+ </div>
98
+ </main>
@@ -0,0 +1,48 @@
1
+ ---
2
+ /**
3
+ * StaticProsePage — the shell of a legal or site-information page (privacy, terms, contact, editorial standards,
4
+ * agents). The route keeps Layout, schema and the prose itself (default slot), so copy and links stay in site source.
5
+ * Not Pagefind-indexed: it carries no body marker.
6
+ */
7
+ import Container from '../layout/Container.astro';
8
+ import Button from '../primitives/Button.astro';
9
+
10
+ interface CtaLink {
11
+ href: string;
12
+ label: string;
13
+ }
14
+
15
+ interface Props {
16
+ heading: string;
17
+ /** Small uppercase line above the heading ("Site policy"). */
18
+ eyebrow?: string;
19
+ /** Closing box under the prose: a short line and secondary buttons. */
20
+ cta?: { text: string; links: CtaLink[] };
21
+ }
22
+
23
+ const { heading, eyebrow, cta } = Astro.props;
24
+ ---
25
+
26
+ <main class="py-16 lg:py-24">
27
+ <Container size="measure">
28
+ {eyebrow && <p class="text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">{eyebrow}</p>}
29
+ <h1 class="text-3xl sm:text-4xl font-medium font-display mb-8">{heading}</h1>
30
+
31
+ <div class="prose prose-stone max-w-none">
32
+ <slot />
33
+ </div>
34
+
35
+ <slot name="after" />
36
+
37
+ {cta && (
38
+ <div class="not-prose mt-12 rounded-2xl border border-border bg-muted/40 p-6">
39
+ <p class="text-sm text-muted-foreground mb-4">
40
+ {cta.text}
41
+ </p>
42
+ <div class="flex flex-wrap gap-3">
43
+ {cta.links.map((link) => <Button href={link.href} variant="secondary">{link.label}</Button>)}
44
+ </div>
45
+ </div>
46
+ )}
47
+ </Container>
48
+ </main>
@@ -3,4 +3,17 @@ export { default as EditorialGuidePage } from './EditorialGuidePage.astro';
3
3
  export { default as EventPage } from './EventPage.astro';
4
4
  export { default as BeforeYouGo } from './BeforeYouGo.astro';
5
5
  export { default as NumberedStep } from './NumberedStep.astro';
6
+ export { default as StaticProsePage } from './StaticProsePage.astro';
7
+ export { default as NotFoundPage } from './NotFoundPage.astro';
8
+ export { default as ArticlesListingPage } from './ArticlesListingPage.astro';
9
+ export { default as ArticleFeatureCard } from './ArticleFeatureCard.astro';
6
10
  export type { EventInfoRow, EventInfoIcon } from './event';
11
+ export { default as ArticleKeyFacts } from './ArticleKeyFacts.astro';
12
+ export { default as AuthorCard } from './AuthorCard.astro';
13
+ export { default as ArticleCard } from './ArticleCard.astro';
14
+ export { default as ArticleGrid } from './ArticleGrid.astro';
15
+ export { default as ArticleCategoryNav } from './ArticleCategoryNav.astro';
16
+ export type { AuthorSummary, ArticleSummary, KeyFact, SourceItem, ArticleCategoryLink } from './types';
17
+ export { default as InsiderTips } from './InsiderTips.astro';
18
+ export type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
19
+ export { default as AuthorStrip } from './AuthorStrip.astro';
@@ -0,0 +1,68 @@
1
+ import type { ImageMetadata } from 'astro';
2
+ import type { TrustedHtml } from '../types';
3
+ import type { SourceInput } from '../seo/types';
4
+
5
+ /** A person as listed on author strips and cards. The byline shape is ui/seo `ArticleAuthor`. */
6
+ export interface AuthorSummary {
7
+ name: string;
8
+ /** Profile URL, base path already applied. */
9
+ href: string;
10
+ /** Optimised portrait, or a public URL with the base path applied. */
11
+ image?: ImageMetadata | string;
12
+ }
13
+
14
+ /** One article as a card or grid entry. Dates arrive formatted, so the package never picks a time zone. */
15
+ export interface ArticleSummary {
16
+ href: string;
17
+ title: string;
18
+ description: string;
19
+ author?: string;
20
+ image?: ImageMetadata;
21
+ imageAlt?: string;
22
+ /** ISO value for `<time datetime>`: dateModified when `updated`, else datePublished. */
23
+ dateTime?: string;
24
+ dateLabel?: string;
25
+ updated?: boolean;
26
+ /** Pre-composed reading-time text, e.g. "6 min read". */
27
+ readingLabel?: string;
28
+ }
29
+
30
+ /** A key-fact tile. `detailHtml` is parsed by the site, so footnote markers resolve. */
31
+ export interface KeyFact {
32
+ figure: string;
33
+ label: string;
34
+ detailHtml?: TrustedHtml;
35
+ }
36
+
37
+ export type SourceItem = SourceInput;
38
+
39
+ /** One category chip in an article topic nav. */
40
+ export interface ArticleCategoryLink {
41
+ href: string;
42
+ label: string;
43
+ icon?: string;
44
+ count: number;
45
+ active: boolean;
46
+ }
47
+
48
+ export type InsiderTipTone = 'primary' | 'accent' | 'secondary' | 'muted';
49
+
50
+ /** Site-defined tip kinds: the key stored in content, its badge label and its tone. */
51
+ export type InsiderTipKinds = Record<string, { label: string; tone: InsiderTipTone }>;
52
+
53
+ export interface InsiderTip {
54
+ kind: string;
55
+ title: string;
56
+ bodyHtml: TrustedHtml;
57
+ }
58
+
59
+ export interface InsiderTipAuthor {
60
+ name: string;
61
+ href?: string;
62
+ /** Portrait URL, base path already applied. */
63
+ photoSrc?: string;
64
+ portraitAlt: string;
65
+ /** Pre-joined expertise line. */
66
+ expertise?: string;
67
+ credentials?: string;
68
+ }