@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.
- package/CHANGELOG.md +13 -0
- package/README.md +54 -2
- package/package.json +5 -3
- package/src/composed/ArrowLinkGrid.astro +36 -0
- package/src/composed/RelatedContent.astro +135 -0
- package/src/composed/RelatedContentCard.astro +99 -0
- package/src/composed/index.ts +5 -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 +71 -0
- package/src/editorial/ArticleGrid.astro +23 -0
- package/src/editorial/ArticleKeyFacts.astro +54 -0
- package/src/editorial/ArticlesListingPage.astro +40 -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/NotFoundPage.astro +98 -0
- package/src/editorial/StaticProsePage.astro +48 -0
- package/src/editorial/index.ts +13 -0
- package/src/editorial/types.ts +68 -0
- package/src/layout/ListingShell.astro +32 -0
- package/src/layout/index.ts +1 -0
- package/src/search/SearchPage.astro +222 -0
- package/src/search/index.ts +2 -0
- package/src/search/searchPage.ts +591 -0
- package/src/styles/not-found.css +151 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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
|
+
|
|
11
|
+
## 0.18.0 (2026-10-02)
|
|
12
|
+
|
|
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).
|
|
14
|
+
- `./search`: `SearchPage`, the search page markup (hero, type chips, optional audience row, result states, type-icon template), with its client controller `initSearchPage` on the new `./search-page` entry point (SLA-2744).
|
|
15
|
+
- `./layout`: `ListingShell`, the listing hub frame inside one Pagefind-ignore wrapper; StructuredData stays a direct Layout child so its JSON-LD renders in `<head>` (SLA-2744).
|
|
16
|
+
- `./composed`: `ArrowLinkGrid`, a grid of bordered link tiles (SLA-2744).
|
|
17
|
+
|
|
5
18
|
## 0.17.0 (2026-10-01)
|
|
6
19
|
|
|
7
20
|
- New `DocumentShell` (`./layout`): the whole HTML document a site page renders into: description, robots (`noindex, follow` when noindex), canonical from the page path, RSS and favicons, Open Graph / article / Twitter (handle optional), title, non-critical stylesheets, the Emerald loader, the skip link, navigation and footer wrapped out of the Pagefind index, `#main-content`, and consent-gated analytics. Slots `head-start`, `fonts`, `head`, `body-start`, `nav`, `footer`. It imports no stylesheet and emits no bundled script, so a site's thin Layout keeps its bundle names (SLA-2741).
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Universal Astro UI shared by every content site. Named entry points, one per folder: `primitives`,
|
|
4
4
|
`composed`, `editorial`, `affiliate`, `seo`, `layout`, `navigation`, `search` and `types`, plus the script
|
|
5
|
-
entry points `analytics` and `
|
|
5
|
+
entry points `analytics`, `travelpayouts-styler` and `search-page`. For example,
|
|
6
6
|
`import { Container } from '@sarimarcus/content-sites-ui/layout'`. `src/internal` is never exported,
|
|
7
7
|
and `src/styles` holds the Tailwind entry.
|
|
8
8
|
|
|
@@ -74,7 +74,59 @@ state and one bundled controller (idempotent on `astro:page-load`, any number of
|
|
|
74
74
|
`<nav aria-label="Legal">` ending with the cookie-preferences button, which calls `window.openCookiePreferences()`
|
|
75
75
|
(the site's consent loader defines it). Defaults paint the `inverse` roles; class props restyle each part.
|
|
76
76
|
- `appendResults` renders Pagefind result cards from the site's type taxonomy; `SearchSuggestions` renders the
|
|
77
|
-
empty-state chips.
|
|
77
|
+
empty-state chips. `SearchPage` is the whole page body (hero, type chips, an audience row when `audienceFacets` is
|
|
78
|
+
passed, result states); the route keeps Layout (`noindex`), imports `search.css` and calls `initSearchPage` from
|
|
79
|
+
`@sarimarcus/content-sites-ui/search-page` in its own `<script>` with the same taxonomy and copy. The copy, the
|
|
80
|
+
taxonomy and the index stay in the site.
|
|
81
|
+
|
|
82
|
+
## Page templates
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { StaticProsePage, NotFoundPage, ArticlesListingPage, ArticleFeatureCard } from '@sarimarcus/content-sites-ui/editorial';
|
|
86
|
+
import { ListingShell } from '@sarimarcus/content-sites-ui/layout';
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The route file keeps `getStaticPaths`, queries, URLs, `<Layout title description>` (the SEO length gate reads it) and
|
|
90
|
+
`<StructuredData slot="head">` as a direct Layout child; the template receives normalised props and slots. Hrefs and
|
|
91
|
+
prose stay in the route so the site's link and outbound-domain scanners still see them.
|
|
92
|
+
|
|
93
|
+
- `StaticProsePage`: privacy, terms, contact, editorial standards. Prose in the default slot, `after` slot, optional
|
|
94
|
+
`cta` box. Carries no Pagefind body.
|
|
95
|
+
- `NotFoundPage`: the 404 body; the route passes `noindex` to Layout and imports `@sarimarcus/content-sites-ui/not-found.css`.
|
|
96
|
+
- `ArticlesListingPage` / `ArticleFeatureCard`: article index, topic hubs and their paginated pages.
|
|
97
|
+
- `ListingShell`: listing hubs (breadcrumb, `hero`, `before-main`, Container'd `<main>`, `after-main`), one outer
|
|
98
|
+
`data-pagefind-ignore`.
|
|
99
|
+
|
|
100
|
+
A component exported from a barrel must not carry a `<style>` block: Astro ships it to every page that imports the
|
|
101
|
+
barrel. Put such rules in a stylesheet entry the one route imports.
|
|
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.
|
|
78
130
|
|
|
79
131
|
## Document shell
|
|
80
132
|
|
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",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"./seo": "./src/seo/index.ts",
|
|
35
35
|
"./layout": "./src/layout/index.ts",
|
|
36
36
|
"./analytics": "./src/layout/analytics.ts",
|
|
37
|
+
"./search-page": "./src/search/searchPage.ts",
|
|
37
38
|
"./navigation": "./src/navigation/index.ts",
|
|
38
39
|
"./search": "./src/search/index.ts",
|
|
39
40
|
"./types": "./src/types/index.ts",
|
|
@@ -41,12 +42,13 @@
|
|
|
41
42
|
"./widths.css": "./src/styles/widths.css",
|
|
42
43
|
"./theme.css": "./src/styles/theme.css",
|
|
43
44
|
"./consent.css": "./src/styles/consent.css",
|
|
44
|
-
"./search.css": "./src/styles/search.css"
|
|
45
|
+
"./search.css": "./src/styles/search.css",
|
|
46
|
+
"./not-found.css": "./src/styles/not-found.css"
|
|
45
47
|
},
|
|
46
48
|
"peerDependencies": {
|
|
47
49
|
"@lucide/astro": "^1.23.0",
|
|
48
50
|
"astro": "^7.0.6",
|
|
49
51
|
"tailwindcss": "^4.1.18",
|
|
50
|
-
"@sarimarcus/content-sites-core": "0.
|
|
52
|
+
"@sarimarcus/content-sites-core": "0.19.0"
|
|
51
53
|
}
|
|
52
54
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Icon from '../primitives/Icon.astro';
|
|
3
|
+
|
|
4
|
+
/** ArrowLinkGrid — a grid of bordered link tiles (title, short subtitle, trailing arrow) tinted on hover. */
|
|
5
|
+
interface Item {
|
|
6
|
+
href: string;
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
items: Item[];
|
|
13
|
+
tone?: 'primary' | 'secondary';
|
|
14
|
+
columns?: 3 | 4;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { items, tone = 'primary', columns = 4 } = Astro.props;
|
|
18
|
+
const TONES = {
|
|
19
|
+
primary: { border: 'hover:border-primary/50', text: 'group-hover:text-primary' },
|
|
20
|
+
secondary: { border: 'hover:border-secondary/50', text: 'group-hover:text-secondary' },
|
|
21
|
+
} as const;
|
|
22
|
+
const GRID = { 3: 'grid sm:grid-cols-2 lg:grid-cols-3 gap-3', 4: 'grid sm:grid-cols-2 lg:grid-cols-4 gap-3' } as const;
|
|
23
|
+
const t = TONES[tone];
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<div class={GRID[columns]}>
|
|
27
|
+
{items.map((item) => (
|
|
28
|
+
<a href={item.href} class={`group flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-border ${t.border} bg-card transition-all`}>
|
|
29
|
+
<div>
|
|
30
|
+
<p class={`text-sm font-medium text-foreground ${t.text} transition-colors`}>{item.title}</p>
|
|
31
|
+
<p class="text-xs text-muted-foreground mt-0.5">{item.subtitle}</p>
|
|
32
|
+
</div>
|
|
33
|
+
<Icon name="arrow-right" class={`w-4 h-4 text-muted-foreground shrink-0 ${t.text} group-hover:translate-x-0.5 transition-all`} />
|
|
34
|
+
</a>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
@@ -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
|
@@ -6,3 +6,8 @@ export { default as FAQ } from './FAQ.astro';
|
|
|
6
6
|
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
|
+
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
|
+
)}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ArticleFeatureCard — the wide lead card above an article grid: image left, label, title, byline with the
|
|
4
|
+
* published or "Updated" date, description and a read link. Dates arrive formatted; the image is the route's import.
|
|
5
|
+
*/
|
|
6
|
+
import { Picture } from 'astro:assets';
|
|
7
|
+
import type { ImageMetadata } from 'astro';
|
|
8
|
+
import ArticleByline from './ArticleByline.astro';
|
|
9
|
+
import Icon from '../primitives/Icon.astro';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
href: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
author: string;
|
|
16
|
+
/** "Featured" or "Latest". */
|
|
17
|
+
label?: string;
|
|
18
|
+
image?: ImageMetadata;
|
|
19
|
+
imageAlt?: string;
|
|
20
|
+
/** The date shown: the modified one when `updated`, else the published one. */
|
|
21
|
+
dateTime: string;
|
|
22
|
+
dateLabel: string;
|
|
23
|
+
updated?: boolean;
|
|
24
|
+
readLabel?: string;
|
|
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;
|
|
33
|
+
}
|
|
34
|
+
|
|
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';
|
|
37
|
+
---
|
|
38
|
+
|
|
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']}>
|
|
40
|
+
<div class="grid sm:grid-cols-[1.2fr_1fr] gap-0">
|
|
41
|
+
{image ? (
|
|
42
|
+
<div class="aspect-[4/3] sm:aspect-auto overflow-hidden">
|
|
43
|
+
<Picture
|
|
44
|
+
src={image}
|
|
45
|
+
alt={imageAlt}
|
|
46
|
+
widths={[480, 768, 1024]}
|
|
47
|
+
sizes="(max-width: 640px) 100vw, 50vw"
|
|
48
|
+
formats={['avif', 'webp']}
|
|
49
|
+
fallbackFormat="webp"
|
|
50
|
+
class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
|
|
51
|
+
loading={loading}
|
|
52
|
+
fetchpriority={loading === 'eager' ? 'high' : undefined}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
) : (
|
|
56
|
+
<div class="aspect-[4/3] sm:aspect-auto bg-muted/40"></div>
|
|
57
|
+
)}
|
|
58
|
+
<div class="p-6 sm:p-8 flex flex-col justify-center">
|
|
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">
|
|
61
|
+
{title}
|
|
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} />
|
|
64
|
+
<p class="mt-3 text-sm text-muted-foreground leading-relaxed line-clamp-3">{description}</p>
|
|
65
|
+
<span class="inline-flex items-center gap-1 mt-5 text-sm font-medium text-primary">
|
|
66
|
+
{readLabel}
|
|
67
|
+
<Icon name="arrow-right" class="w-3.5 h-3.5 motion-safe:transition-transform motion-safe:group-hover:translate-x-0.5" />
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</a>
|
|
@@ -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
|
+
)}
|