@sarimarcus/content-sites-ui 0.18.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.
- package/CHANGELOG.md +6 -0
- package/README.md +28 -0
- package/package.json +2 -2
- package/src/composed/RelatedContent.astro +135 -0
- package/src/composed/RelatedContentCard.astro +99 -0
- package/src/composed/index.ts +4 -0
- package/src/composed/related.ts +16 -0
- package/src/editorial/ArticleByline.astro +34 -0
- package/src/editorial/ArticleCard.astro +56 -0
- package/src/editorial/ArticleCategoryNav.astro +50 -0
- package/src/editorial/ArticleFeatureCard.astro +18 -24
- package/src/editorial/ArticleGrid.astro +23 -0
- package/src/editorial/ArticleKeyFacts.astro +54 -0
- package/src/editorial/AuthorCard.astro +174 -0
- package/src/editorial/AuthorStrip.astro +52 -0
- package/src/editorial/InsiderTips.astro +102 -0
- package/src/editorial/index.ts +9 -0
- package/src/editorial/types.ts +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.19.0 (2026-10-02)
|
|
6
|
+
|
|
7
|
+
- `./editorial` editorial presentation: `ArticleKeyFacts`, `AuthorCard` (link, polaroid and plain shapes), `AuthorStrip`, `ArticleCard` and `ArticleGrid`, `ArticleCategoryNav`, `InsiderTips` (site-defined tip kinds). Every visible and aria string is a required prop. New view-model types `ArticleSummary`, `AuthorSummary`, `KeyFact`, `SourceItem`, `ArticleCategoryLink`, `InsiderTip`, `InsiderTipKinds`, `InsiderTipAuthor` (SLA-2740).
|
|
8
|
+
- `./composed`: `RelatedContent` and `RelatedContentCard` (grid or carousel of `RelatedItem` cards, `tone` primary/accent/secondary; types `RelatedItem`, `RelatedTone`). In `./composed` because they render CardCarousel, whose `<style>` would otherwise reach every `./editorial` importer (SLA-2740).
|
|
9
|
+
- `ArticleFeatureCard`: `label` optional; new `readingLabel`, `loading` (lazy drops the high fetch priority), `headingLevel`, `pagefindIgnore` and `class` (replaces the default `mb-12`) (SLA-2740).
|
|
10
|
+
|
|
5
11
|
## 0.18.0 (2026-10-02)
|
|
6
12
|
|
|
7
13
|
- New page templates in `./editorial`: `StaticProsePage` (legal and site-information pages: heading, optional eyebrow, prose slot, `after` slot, closing CTA box; not Pagefind-indexed), `NotFoundPage` (the 404 body, every string a prop; its decoration ships as `./not-found.css` so a barrel import never carries it to other pages), `ArticlesListingPage` (article index and topic hub shell: breadcrumb band, header, `<main>`, Pagefind body optional) and `ArticleFeatureCard` (SLA-2744).
|
package/README.md
CHANGED
|
@@ -100,6 +100,34 @@ prose stay in the route so the site's link and outbound-domain scanners still se
|
|
|
100
100
|
A component exported from a barrel must not carry a `<style>` block: Astro ships it to every page that imports the
|
|
101
101
|
barrel. Put such rules in a stylesheet entry the one route imports.
|
|
102
102
|
|
|
103
|
+
## Editorial presentation
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { ArticleKeyFacts, AuthorCard, AuthorStrip, ArticleCard, ArticleGrid, ArticleCategoryNav, InsiderTips } from '@sarimarcus/content-sites-ui/editorial';
|
|
107
|
+
import { RelatedContent, RelatedContentCard } from '@sarimarcus/content-sites-ui/composed';
|
|
108
|
+
import type { ArticleSummary, AuthorSummary, KeyFact, SourceItem } from '@sarimarcus/content-sites-ui/editorial';
|
|
109
|
+
import type { RelatedItem } from '@sarimarcus/content-sites-ui/composed';
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Every visible and aria string is a required prop with no default, so the copy stays in the site. The site keeps a thin
|
|
113
|
+
adapter of the same name that reads its collections, globs, routes, markdown renderer and author profiles, then passes
|
|
114
|
+
view models: dates already formatted (the package never picks a time zone), hrefs with the base path applied, HTML that
|
|
115
|
+
crosses the boundary only through `trustedHtml()` (key-fact details and tip bodies keep their `#fn-N` anchors because
|
|
116
|
+
the site parses them with its source count). Palette props (`figureClass`, `surfaceClass`, `ringClass`, `proseClass`)
|
|
117
|
+
let a site keep its own roles.
|
|
118
|
+
|
|
119
|
+
- `ArticleKeyFacts`: figure-first `<dl>`, rendered only with three or more facts.
|
|
120
|
+
- `AuthorCard`: whole-card link, polaroid, or plain card with a profile link. `AuthorStrip`: portraits row.
|
|
121
|
+
- `ArticleCard` / `ArticleGrid`: listing cards; `pagefindIgnore` for a listing that is itself indexed.
|
|
122
|
+
`ArticleFeatureCard` is the wide lead card.
|
|
123
|
+
- `ArticleCategoryNav`: "All" plus one chip per topic; the site supplies the links and counts.
|
|
124
|
+
- `RelatedContent` / `RelatedContentCard` (in `./composed`, beside the CardCarousel they render): grid or carousel of `RelatedItem` cards, `tone` primary/accent/secondary.
|
|
125
|
+
Resolving which items to show (collections, scoring, backfill) stays a site adapter.
|
|
126
|
+
- `InsiderTips`: tip kinds are site keys mapped to a label and a tone.
|
|
127
|
+
|
|
128
|
+
These render inside the site's article styles (`.article-body` link rules, typography plugin presets); outside them
|
|
129
|
+
they look different.
|
|
130
|
+
|
|
103
131
|
## Document shell
|
|
104
132
|
|
|
105
133
|
```astro
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
"@lucide/astro": "^1.23.0",
|
|
50
50
|
"astro": "^7.0.6",
|
|
51
51
|
"tailwindcss": "^4.1.18",
|
|
52
|
-
"@sarimarcus/content-sites-core": "0.
|
|
52
|
+
"@sarimarcus/content-sites-core": "0.19.0"
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* RelatedContent — related link cards as a grid (default) or a swipable carousel. Under `carousel`, `columns` sets
|
|
4
|
+
* the card width (how many fit a desktop viewport) rather than grid columns.
|
|
5
|
+
*/
|
|
6
|
+
import ArrowLabel from '../primitives/ArrowLabel.astro';
|
|
7
|
+
import SectionDivider from '../primitives/SectionDivider.astro';
|
|
8
|
+
import CardCarousel from './CardCarousel.astro';
|
|
9
|
+
import RelatedContentCard from './RelatedContentCard.astro';
|
|
10
|
+
import type { RelatedItem } from './related';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Omit only when the caller renders its own heading (see `plain`). */
|
|
14
|
+
title?: string;
|
|
15
|
+
items: RelatedItem[];
|
|
16
|
+
/**
|
|
17
|
+
* `bordered`: hairline divider above, no surface. `card`: tinted, bordered panel. `plain`: no surface and no
|
|
18
|
+
* divider, for a caller that owns its heading and separation.
|
|
19
|
+
*/
|
|
20
|
+
variant?: 'bordered' | 'card' | 'plain';
|
|
21
|
+
layout?: 'grid' | 'carousel';
|
|
22
|
+
columns?: 2 | 3;
|
|
23
|
+
headingLevel?: 2 | 3;
|
|
24
|
+
viewAllHref?: string;
|
|
25
|
+
/** Required with `viewAllHref`. */
|
|
26
|
+
viewAllLabel?: string;
|
|
27
|
+
trackingBlock?: string;
|
|
28
|
+
/** Carousel track label; defaults to the title. */
|
|
29
|
+
carouselAriaLabel?: string;
|
|
30
|
+
/** Image `sizes` override; the defaults assume a ~700px prose column. */
|
|
31
|
+
pictureSizes?: string;
|
|
32
|
+
class?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const {
|
|
36
|
+
title,
|
|
37
|
+
items,
|
|
38
|
+
variant = 'bordered',
|
|
39
|
+
layout = 'grid',
|
|
40
|
+
columns = 2,
|
|
41
|
+
headingLevel = 2,
|
|
42
|
+
viewAllHref,
|
|
43
|
+
viewAllLabel,
|
|
44
|
+
trackingBlock,
|
|
45
|
+
carouselAriaLabel,
|
|
46
|
+
pictureSizes: pictureSizesProp,
|
|
47
|
+
class: className = '',
|
|
48
|
+
} = Astro.props;
|
|
49
|
+
|
|
50
|
+
if (viewAllHref && !viewAllLabel) throw new Error('RelatedContent: viewAllHref needs a viewAllLabel');
|
|
51
|
+
|
|
52
|
+
const HeadingTag = `h${headingLevel}` as 'h2' | 'h3';
|
|
53
|
+
|
|
54
|
+
// A titled block is tangential content (aside); an untitled one is the caller's own payload, and an unnamed aside
|
|
55
|
+
// would add a second, unlabelled landmark inside it.
|
|
56
|
+
const Wrapper = title ? 'aside' : 'div';
|
|
57
|
+
|
|
58
|
+
const wrapperClass = variant === 'bordered'
|
|
59
|
+
? 'mt-8'
|
|
60
|
+
: variant === 'plain'
|
|
61
|
+
? ''
|
|
62
|
+
: 'p-6 bg-muted/50 rounded-xl border border-border';
|
|
63
|
+
|
|
64
|
+
const gridClass = columns === 3
|
|
65
|
+
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
|
|
66
|
+
: 'grid gap-4 sm:grid-cols-2';
|
|
67
|
+
|
|
68
|
+
// At `lg` a card fits `columns` per view, so the track stops overflowing and the controls hide. Percentages, not
|
|
69
|
+
// fixed widths: the block usually sits inside a ~700px prose column. Below `lg` cards are wider than a column share
|
|
70
|
+
// so the next one peeks in.
|
|
71
|
+
const cardWidthClass = columns === 3
|
|
72
|
+
? 'w-[80vw] sm:w-64 lg:w-[calc((100%-2rem)/3)]'
|
|
73
|
+
: 'w-[85vw] sm:w-80 lg:w-[calc((100%-1rem)/2)]';
|
|
74
|
+
|
|
75
|
+
const pictureSizes = pictureSizesProp ?? (layout === 'carousel'
|
|
76
|
+
? (columns === 3
|
|
77
|
+
? '(min-width: 1024px) 240px, (min-width: 640px) 256px, 80vw'
|
|
78
|
+
: '(min-width: 640px) 320px, 85vw')
|
|
79
|
+
: (columns === 3
|
|
80
|
+
? '(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw'
|
|
81
|
+
: '(min-width: 640px) 50vw, 100vw'));
|
|
82
|
+
|
|
83
|
+
// CardCarousel's contract: every direct child of the track is one card with `snap-start shrink-0` and a width.
|
|
84
|
+
const carouselShell = `shrink-0 snap-start ${cardWidthClass}`;
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
{items.length > 0 && (
|
|
88
|
+
<Wrapper class:list={[wrapperClass, className]} aria-label={title || undefined}>
|
|
89
|
+
{variant === 'bordered' && <SectionDivider />}
|
|
90
|
+
|
|
91
|
+
{layout === 'carousel' ? (
|
|
92
|
+
<CardCarousel
|
|
93
|
+
heading={title}
|
|
94
|
+
headingTag={HeadingTag}
|
|
95
|
+
headingClass="text-2xl font-display font-semibold text-foreground"
|
|
96
|
+
viewAllHref={viewAllHref}
|
|
97
|
+
viewAllLabel={viewAllLabel}
|
|
98
|
+
ariaLabel={carouselAriaLabel ?? title}
|
|
99
|
+
>
|
|
100
|
+
{items.map((item) => (
|
|
101
|
+
<RelatedContentCard
|
|
102
|
+
item={item}
|
|
103
|
+
shellClass={carouselShell}
|
|
104
|
+
pictureSizes={pictureSizes}
|
|
105
|
+
trackingBlock={trackingBlock}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
108
|
+
</CardCarousel>
|
|
109
|
+
) : null}
|
|
110
|
+
|
|
111
|
+
{/* Gated on `title || viewAllHref`: this row is the only consumer of viewAllHref in grid layout. */}
|
|
112
|
+
{layout !== 'carousel' && (title || viewAllHref) && (
|
|
113
|
+
<div class:list={['mb-6', viewAllHref && 'flex flex-wrap items-baseline justify-between gap-x-6 gap-y-2 mb-8']}>
|
|
114
|
+
{title && <HeadingTag class="text-2xl font-display font-semibold text-foreground">{title}</HeadingTag>}
|
|
115
|
+
{viewAllHref && (
|
|
116
|
+
<a href={viewAllHref} class="group text-sm font-medium text-primary hover:text-primary-strong transition-colors">
|
|
117
|
+
<ArrowLabel>{viewAllLabel}</ArrowLabel>
|
|
118
|
+
</a>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{layout !== 'carousel' && (
|
|
124
|
+
<div class={gridClass}>
|
|
125
|
+
{items.map((item) => (
|
|
126
|
+
<RelatedContentCard
|
|
127
|
+
item={item}
|
|
128
|
+
pictureSizes={pictureSizes}
|
|
129
|
+
trackingBlock={trackingBlock}
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</Wrapper>
|
|
135
|
+
)}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* RelatedContentCard — one card inside RelatedContent. The grid and the carousel render this same card; only the
|
|
4
|
+
* shell classes and the responsive image hints differ. Three shapes, by precedence: image-led, icon-led, plain text.
|
|
5
|
+
*/
|
|
6
|
+
import { Picture } from 'astro:assets';
|
|
7
|
+
import Icon from '../primitives/Icon.astro';
|
|
8
|
+
import type { RelatedItem, RelatedTone } from './related';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
item: RelatedItem;
|
|
12
|
+
/** Layout-specific classes appended to the shared shell. */
|
|
13
|
+
shellClass?: string;
|
|
14
|
+
pictureSizes: string;
|
|
15
|
+
trackingBlock?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { item, shellClass = '', pictureSizes, trackingBlock } = Astro.props;
|
|
19
|
+
|
|
20
|
+
const toneMap: Record<RelatedTone, { border: string; text: string; iconBg: string; iconText: string }> = {
|
|
21
|
+
primary: {
|
|
22
|
+
border: 'hover:border-primary/40',
|
|
23
|
+
text: 'group-hover:text-primary',
|
|
24
|
+
iconBg: 'bg-primary/20 group-hover:bg-primary/30',
|
|
25
|
+
iconText: 'text-primary',
|
|
26
|
+
},
|
|
27
|
+
accent: {
|
|
28
|
+
border: 'hover:border-accent',
|
|
29
|
+
text: 'group-hover:text-accent-strong',
|
|
30
|
+
iconBg: 'bg-accent/20 group-hover:bg-accent/30',
|
|
31
|
+
iconText: 'text-accent-strong',
|
|
32
|
+
},
|
|
33
|
+
secondary: {
|
|
34
|
+
border: 'hover:border-secondary',
|
|
35
|
+
text: 'group-hover:text-secondary',
|
|
36
|
+
iconBg: 'bg-secondary/20 group-hover:bg-secondary/30',
|
|
37
|
+
iconText: 'text-secondary',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const c = toneMap[item.tone ?? 'primary'];
|
|
42
|
+
const hasIcon = !!item.icon;
|
|
43
|
+
const hasImage = !!item.image;
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
<a
|
|
47
|
+
href={item.href}
|
|
48
|
+
aria-label={item.title}
|
|
49
|
+
{...(trackingBlock ? {
|
|
50
|
+
'data-track-event': 'internal_link_click',
|
|
51
|
+
'data-track-link-block': trackingBlock,
|
|
52
|
+
} : {})}
|
|
53
|
+
class:list={[
|
|
54
|
+
'group rounded-2xl border border-border bg-card transition-colors hover:bg-muted/50 overflow-hidden',
|
|
55
|
+
c.border,
|
|
56
|
+
hasImage ? 'flex flex-col' : (hasIcon ? 'flex flex-col gap-3 p-5' : 'flex flex-col gap-2 p-5'),
|
|
57
|
+
shellClass,
|
|
58
|
+
]}
|
|
59
|
+
>
|
|
60
|
+
{hasImage && (
|
|
61
|
+
<div class="relative aspect-video overflow-hidden">
|
|
62
|
+
<Picture
|
|
63
|
+
src={item.image!}
|
|
64
|
+
alt={item.imageAlt || item.title}
|
|
65
|
+
widths={[320, 480, 640]}
|
|
66
|
+
formats={['avif', 'webp']}
|
|
67
|
+
fallbackFormat="webp"
|
|
68
|
+
sizes={pictureSizes}
|
|
69
|
+
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
{!hasImage && hasIcon && (
|
|
74
|
+
<div class:list={['w-10 h-10 rounded-lg flex items-center justify-center shrink-0 transition-colors', c.iconBg]}>
|
|
75
|
+
<Icon name={item.icon!} class:list={['w-5 h-5', c.iconText]} />
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
<div class:list={[
|
|
79
|
+
hasImage ? 'flex flex-col gap-2 p-5' : 'flex flex-col gap-1.5',
|
|
80
|
+
]}>
|
|
81
|
+
{item.eyebrow && (
|
|
82
|
+
<span class="text-xs font-medium uppercase tracking-wider text-muted-foreground" aria-hidden="true">
|
|
83
|
+
{item.eyebrow}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
<span class:list={[
|
|
87
|
+
'font-display font-semibold text-foreground transition-colors flex items-center gap-1.5',
|
|
88
|
+
c.text,
|
|
89
|
+
]}>
|
|
90
|
+
{item.title}
|
|
91
|
+
<Icon name="arrow-right" class="w-3.5 h-3.5 shrink-0 transition-transform group-hover:translate-x-0.5" />
|
|
92
|
+
</span>
|
|
93
|
+
{item.description && (
|
|
94
|
+
<span class="text-sm leading-relaxed text-muted-foreground" aria-hidden="true">
|
|
95
|
+
{item.description}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
</a>
|
package/src/composed/index.ts
CHANGED
|
@@ -7,3 +7,7 @@ export { default as InlineImage } from './InlineImage.astro';
|
|
|
7
7
|
export { default as Pagination } from './Pagination.astro';
|
|
8
8
|
export { default as EntityCard } from './EntityCard.astro';
|
|
9
9
|
export { default as ArrowLinkGrid } from './ArrowLinkGrid.astro';
|
|
10
|
+
// RelatedContent sits here, not in ./editorial: it renders CardCarousel, whose <style> ships to every importer of the barrel.
|
|
11
|
+
export { default as RelatedContent } from './RelatedContent.astro';
|
|
12
|
+
export { default as RelatedContentCard } from './RelatedContentCard.astro';
|
|
13
|
+
export type { RelatedItem, RelatedTone } from './related';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ImageMetadata } from 'astro';
|
|
2
|
+
|
|
3
|
+
export type RelatedTone = 'primary' | 'accent' | 'secondary';
|
|
4
|
+
|
|
5
|
+
/** A related-content card. Images are optimised imports, never public path strings. */
|
|
6
|
+
export interface RelatedItem {
|
|
7
|
+
href: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
eyebrow?: string;
|
|
11
|
+
/** A ui Icon name. */
|
|
12
|
+
icon?: string;
|
|
13
|
+
tone?: RelatedTone;
|
|
14
|
+
image?: ImageMetadata;
|
|
15
|
+
imageAlt?: string;
|
|
16
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Internal: an article card's byline — author, the published date or an "Updated" Badge, then the reading time. */
|
|
3
|
+
import Badge from '../primitives/Badge.astro';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
author?: string;
|
|
7
|
+
dateTime?: string;
|
|
8
|
+
dateLabel?: string;
|
|
9
|
+
updated?: boolean;
|
|
10
|
+
updatedLabel: string;
|
|
11
|
+
readingLabel?: string;
|
|
12
|
+
class: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { author, dateTime, dateLabel, updated = false, updatedLabel, readingLabel, class: className } = Astro.props;
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<p class={className}>
|
|
19
|
+
{author && <span>{author}</span>}
|
|
20
|
+
{author && dateTime && <span aria-hidden="true">·</span>}
|
|
21
|
+
{dateTime && (updated ? (
|
|
22
|
+
<Badge tone="custom" toneClass="bg-muted" size="sm" lines={0} class="![font-size:inherit] ![line-height:inherit] ![font-weight:inherit] !gap-1">
|
|
23
|
+
<svg class="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
24
|
+
<polyline points="23 4 23 10 17 10" />
|
|
25
|
+
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
|
26
|
+
</svg>
|
|
27
|
+
{updatedLabel}{' '}<time datetime={dateTime}>{dateLabel}</time>
|
|
28
|
+
</Badge>
|
|
29
|
+
) : (
|
|
30
|
+
<time datetime={dateTime}>{dateLabel}</time>
|
|
31
|
+
))}
|
|
32
|
+
{readingLabel && <span aria-hidden="true">·</span>}
|
|
33
|
+
{readingLabel && <span>{readingLabel}</span>}
|
|
34
|
+
</p>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ArticleCard — one article in a grid: image, title, byline with the published or "Updated" date, description and
|
|
4
|
+
* a read link. Dates arrive formatted; the image is the route's import.
|
|
5
|
+
*/
|
|
6
|
+
import { Picture } from 'astro:assets';
|
|
7
|
+
import ArticleByline from './ArticleByline.astro';
|
|
8
|
+
import Icon from '../primitives/Icon.astro';
|
|
9
|
+
import type { ArticleSummary } from './types';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
article: ArticleSummary;
|
|
13
|
+
readLabel: string;
|
|
14
|
+
updatedLabel: string;
|
|
15
|
+
loading?: 'eager' | 'lazy';
|
|
16
|
+
/** Keep the card out of the search index (listings that are themselves indexed). */
|
|
17
|
+
pagefindIgnore?: boolean;
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { article: a, readLabel, updatedLabel, loading = 'lazy', pagefindIgnore = false, class: className } = Astro.props;
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<a
|
|
25
|
+
href={a.href}
|
|
26
|
+
data-pagefind-ignore={pagefindIgnore ? '' : undefined}
|
|
27
|
+
class:list={['group flex flex-col rounded-xl border border-border bg-card overflow-hidden hover:border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50', className]}
|
|
28
|
+
>
|
|
29
|
+
{a.image ? (
|
|
30
|
+
<div class="aspect-[4/3] overflow-hidden">
|
|
31
|
+
<Picture
|
|
32
|
+
src={a.image}
|
|
33
|
+
alt={a.imageAlt ?? ''}
|
|
34
|
+
widths={[320, 480, 640]}
|
|
35
|
+
sizes="(max-width: 640px) 100vw, 33vw"
|
|
36
|
+
formats={['avif', 'webp']}
|
|
37
|
+
fallbackFormat="webp"
|
|
38
|
+
class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
|
|
39
|
+
loading={loading}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
) : (
|
|
43
|
+
<div class="aspect-[4/3] bg-muted/40"></div>
|
|
44
|
+
)}
|
|
45
|
+
<div class="p-5 flex-1 flex flex-col">
|
|
46
|
+
<h3 class="text-base sm:text-lg font-semibold font-display leading-snug group-hover:text-primary transition-colors text-balance">
|
|
47
|
+
{a.title}
|
|
48
|
+
</h3>
|
|
49
|
+
<ArticleByline class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground" author={a.author} dateTime={a.dateTime} dateLabel={a.dateLabel} updated={a.updated} updatedLabel={updatedLabel} readingLabel={a.readingLabel} />
|
|
50
|
+
<p class="mt-2 text-sm text-muted-foreground leading-relaxed line-clamp-2">{a.description}</p>
|
|
51
|
+
<span class="inline-flex items-center gap-1 mt-auto pt-3 text-sm font-medium text-primary">
|
|
52
|
+
{readLabel}
|
|
53
|
+
<Icon name="arrow-right" class="w-3.5 h-3.5 motion-safe:transition-transform motion-safe:group-hover:translate-x-0.5" />
|
|
54
|
+
</span>
|
|
55
|
+
</div>
|
|
56
|
+
</a>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** ArticleCategoryNav — "All" plus one chip per populated topic, each with a count badge. The site resolves the links. */
|
|
3
|
+
import Chip from '../primitives/Chip.astro';
|
|
4
|
+
import Badge from '../primitives/Badge.astro';
|
|
5
|
+
import type { ArticleCategoryLink } from './types';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
categories: ArticleCategoryLink[];
|
|
9
|
+
all: Omit<ArticleCategoryLink, 'icon'>;
|
|
10
|
+
ariaLabel: string;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { categories, all, ariaLabel, class: className } = Astro.props;
|
|
15
|
+
const countClass = (active: boolean) => (active ? 'bg-overlay-foreground/20' : 'bg-muted');
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
{categories.length > 0 && (
|
|
19
|
+
<nav aria-label={ariaLabel} class={className}>
|
|
20
|
+
<div class="flex gap-2.5 overflow-x-auto flex-nowrap pb-2 scrollbar-hide md:flex-wrap md:overflow-visible md:pb-0">
|
|
21
|
+
<Chip
|
|
22
|
+
href={all.href}
|
|
23
|
+
size="md"
|
|
24
|
+
tone="outline"
|
|
25
|
+
accent="primary"
|
|
26
|
+
active={all.active}
|
|
27
|
+
aria-current={all.active ? 'page' : undefined}
|
|
28
|
+
class="shrink-0"
|
|
29
|
+
>
|
|
30
|
+
{all.label}
|
|
31
|
+
<Badge tone="custom" toneClass={countClass(all.active)} size="sm" lines={0} class="!px-1.5 ![font-weight:inherit]">{all.count}</Badge>
|
|
32
|
+
</Chip>
|
|
33
|
+
{categories.map((c) => (
|
|
34
|
+
<Chip
|
|
35
|
+
href={c.href}
|
|
36
|
+
size="md"
|
|
37
|
+
tone="outline"
|
|
38
|
+
accent="primary"
|
|
39
|
+
active={c.active}
|
|
40
|
+
aria-current={c.active ? 'page' : undefined}
|
|
41
|
+
icon={c.icon}
|
|
42
|
+
class="shrink-0"
|
|
43
|
+
>
|
|
44
|
+
{c.label}
|
|
45
|
+
<Badge tone="custom" toneClass={countClass(c.active)} size="sm" lines={0} class="!px-1.5 ![font-weight:inherit]">{c.count}</Badge>
|
|
46
|
+
</Chip>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</nav>
|
|
50
|
+
)}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { Picture } from 'astro:assets';
|
|
7
7
|
import type { ImageMetadata } from 'astro';
|
|
8
|
-
import
|
|
8
|
+
import ArticleByline from './ArticleByline.astro';
|
|
9
9
|
import Icon from '../primitives/Icon.astro';
|
|
10
10
|
|
|
11
11
|
interface Props {
|
|
@@ -14,7 +14,7 @@ interface Props {
|
|
|
14
14
|
description: string;
|
|
15
15
|
author: string;
|
|
16
16
|
/** "Featured" or "Latest". */
|
|
17
|
-
label
|
|
17
|
+
label?: string;
|
|
18
18
|
image?: ImageMetadata;
|
|
19
19
|
imageAlt?: string;
|
|
20
20
|
/** The date shown: the modified one when `updated`, else the published one. */
|
|
@@ -23,12 +23,20 @@ interface Props {
|
|
|
23
23
|
updated?: boolean;
|
|
24
24
|
readLabel?: string;
|
|
25
25
|
updatedLabel?: string;
|
|
26
|
+
/** Pre-composed reading-time text, e.g. "6 min read". */
|
|
27
|
+
readingLabel?: string;
|
|
28
|
+
/** Lazy for a card below the fold; eager (with high fetch priority) by default. */
|
|
29
|
+
loading?: 'eager' | 'lazy';
|
|
30
|
+
headingLevel?: 2 | 3;
|
|
31
|
+
pagefindIgnore?: boolean;
|
|
32
|
+
class?: string;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
const { href, title, description, author, label, image, imageAlt = '', dateTime, dateLabel, updated = false, readLabel = 'Read article', updatedLabel = 'Updated' } = Astro.props;
|
|
35
|
+
const { href, title, description, author, label, image, imageAlt = '', dateTime, dateLabel, updated = false, readLabel = 'Read article', updatedLabel = 'Updated', readingLabel, loading = 'eager', headingLevel = 2, pagefindIgnore = false, class: className = 'mb-12' } = Astro.props;
|
|
36
|
+
const Heading = `h${headingLevel}` as 'h2' | 'h3';
|
|
29
37
|
---
|
|
30
38
|
|
|
31
|
-
<a href={href} class=
|
|
39
|
+
<a href={href} data-pagefind-ignore={pagefindIgnore ? '' : undefined} class:list={['group block', className, 'rounded-xl border border-border bg-card overflow-hidden hover:border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50']}>
|
|
32
40
|
<div class="grid sm:grid-cols-[1.2fr_1fr] gap-0">
|
|
33
41
|
{image ? (
|
|
34
42
|
<div class="aspect-[4/3] sm:aspect-auto overflow-hidden">
|
|
@@ -40,33 +48,19 @@ const { href, title, description, author, label, image, imageAlt = '', dateTime,
|
|
|
40
48
|
formats={['avif', 'webp']}
|
|
41
49
|
fallbackFormat="webp"
|
|
42
50
|
class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
|
|
43
|
-
loading=
|
|
44
|
-
fetchpriority=
|
|
51
|
+
loading={loading}
|
|
52
|
+
fetchpriority={loading === 'eager' ? 'high' : undefined}
|
|
45
53
|
/>
|
|
46
54
|
</div>
|
|
47
55
|
) : (
|
|
48
56
|
<div class="aspect-[4/3] sm:aspect-auto bg-muted/40"></div>
|
|
49
57
|
)}
|
|
50
58
|
<div class="p-6 sm:p-8 flex flex-col justify-center">
|
|
51
|
-
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary mb-2">{label}</p>
|
|
52
|
-
<
|
|
59
|
+
{label && <p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary mb-2">{label}</p>}
|
|
60
|
+
<Heading class="text-xl sm:text-2xl font-semibold font-display text-foreground leading-snug group-hover:text-primary transition-colors text-balance">
|
|
53
61
|
{title}
|
|
54
|
-
</
|
|
55
|
-
<
|
|
56
|
-
<span>{author}</span>
|
|
57
|
-
<span aria-hidden="true">·</span>
|
|
58
|
-
{updated ? (
|
|
59
|
-
<Badge tone="custom" toneClass="bg-muted" size="sm" lines={0} class="![font-size:inherit] ![line-height:inherit] ![font-weight:inherit] !gap-1">
|
|
60
|
-
<svg class="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
61
|
-
<polyline points="23 4 23 10 17 10" />
|
|
62
|
-
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
|
63
|
-
</svg>
|
|
64
|
-
{updatedLabel}{' '}<time datetime={dateTime}>{dateLabel}</time>
|
|
65
|
-
</Badge>
|
|
66
|
-
) : (
|
|
67
|
-
<time datetime={dateTime}>{dateLabel}</time>
|
|
68
|
-
)}
|
|
69
|
-
</p>
|
|
62
|
+
</Heading>
|
|
63
|
+
<ArticleByline class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground" author={author} dateTime={dateTime} dateLabel={dateLabel} updated={updated} updatedLabel={updatedLabel} readingLabel={readingLabel} />
|
|
70
64
|
<p class="mt-3 text-sm text-muted-foreground leading-relaxed line-clamp-3">{description}</p>
|
|
71
65
|
<span class="inline-flex items-center gap-1 mt-5 text-sm font-medium text-primary">
|
|
72
66
|
{readLabel}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** ArticleGrid — a grid of ArticleCards; renders nothing for an empty list. */
|
|
3
|
+
import ArticleCard from './ArticleCard.astro';
|
|
4
|
+
import type { ArticleSummary } from './types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
articles: ArticleSummary[];
|
|
8
|
+
readLabel: string;
|
|
9
|
+
updatedLabel: string;
|
|
10
|
+
pagefindIgnore?: boolean;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { articles, readLabel, updatedLabel, pagefindIgnore = false, class: className = 'grid gap-6 sm:grid-cols-2' } = Astro.props;
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
{articles.length > 0 && (
|
|
18
|
+
<div class={className}>
|
|
19
|
+
{articles.map((article) => (
|
|
20
|
+
<ArticleCard article={article} readLabel={readLabel} updatedLabel={updatedLabel} pagefindIgnore={pagefindIgnore} />
|
|
21
|
+
))}
|
|
22
|
+
</div>
|
|
23
|
+
)}
|
|
@@ -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,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
|
+
)}
|
package/src/editorial/index.ts
CHANGED
|
@@ -8,3 +8,12 @@ export { default as NotFoundPage } from './NotFoundPage.astro';
|
|
|
8
8
|
export { default as ArticlesListingPage } from './ArticlesListingPage.astro';
|
|
9
9
|
export { default as ArticleFeatureCard } from './ArticleFeatureCard.astro';
|
|
10
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
|
+
}
|