@sarimarcus/content-sites-ui 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.41.0 (2026-10-20)
|
|
6
|
+
|
|
7
|
+
- `editorial`: new `EraChip`, a Chip link from an article or monument to its era's section on the chronology page (`eraHref`), rendering nothing without an era; new `ChronologyTimeline`, a vertical rail of a site's eras (heading anchor, range, intro, dated entry rows, related dossiers, compact one-line markers for empty eras), with `groupByEra` and the `TimelineItem` type. No change to existing components' markup (SLA-3251).
|
|
8
|
+
|
|
9
|
+
## 0.40.0 (2026-10-18)
|
|
10
|
+
|
|
11
|
+
- No changes; lockstep release with core (SLA-3046).
|
|
12
|
+
|
|
5
13
|
## 0.39.0 (2026-10-16)
|
|
6
14
|
|
|
7
15
|
- `layout`: new `AudienceFilters`, the sr-only `audience:<a>` Pagefind filter spans that `EntityDetailShell` and `EventPage` each wrote inline; both now render it, with identical HTML (SLA-3045).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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",
|
|
@@ -50,6 +50,6 @@
|
|
|
50
50
|
"@lucide/astro": "^1.23.0",
|
|
51
51
|
"astro": "^7.0.6",
|
|
52
52
|
"tailwindcss": "^4.1.18",
|
|
53
|
-
"@sarimarcus/content-sites-core": "0.
|
|
53
|
+
"@sarimarcus/content-sites-core": "0.41.0"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* emptyEra 'hide' (default) collapses an era with no entries to a one-line rail marker that keeps `id={era.anchor}`,
|
|
4
|
+
* so eraHref() links never dead-end; 'show' renders every era in full.
|
|
5
|
+
*/
|
|
6
|
+
import type { EraDefinition } from '@sarimarcus/content-sites-core/eras';
|
|
7
|
+
import Chip from '../primitives/Chip.astro';
|
|
8
|
+
import LinkArrow from '../primitives/LinkArrow.astro';
|
|
9
|
+
import ResponsivePicture from '../primitives/ResponsivePicture.astro';
|
|
10
|
+
import { LABELS } from '../internal/labels';
|
|
11
|
+
import type { TimelineItem } from './chronology';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
eras: readonly EraDefinition[];
|
|
15
|
+
/** Entries keyed by era id, already ordered (see groupByEra). */
|
|
16
|
+
items: Record<string, readonly TimelineItem[]>;
|
|
17
|
+
emptyEra?: 'hide' | 'show';
|
|
18
|
+
/** The jump links above the rail, shown when two or more eras render in full. */
|
|
19
|
+
index?: boolean;
|
|
20
|
+
indexLabel?: string;
|
|
21
|
+
dossiersLabel?: string;
|
|
22
|
+
emptyLabel?: string;
|
|
23
|
+
class?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
eras,
|
|
28
|
+
items,
|
|
29
|
+
emptyEra = 'hide',
|
|
30
|
+
index = true,
|
|
31
|
+
indexLabel = LABELS.chronologyTimeline.indexLabel,
|
|
32
|
+
dossiersLabel = LABELS.chronologyTimeline.dossiersLabel,
|
|
33
|
+
emptyLabel = LABELS.chronologyTimeline.emptyLabel,
|
|
34
|
+
class: className,
|
|
35
|
+
} = Astro.props;
|
|
36
|
+
|
|
37
|
+
const ordered = [...eras].sort((a, b) => a.startYear - b.startYear);
|
|
38
|
+
const rows = ordered.map((era) => {
|
|
39
|
+
const entries = items[era.id] ?? [];
|
|
40
|
+
return { era, entries, full: entries.length > 0 || emptyEra === 'show' };
|
|
41
|
+
});
|
|
42
|
+
const fullEras = rows.filter((r) => r.full).map((r) => r.era);
|
|
43
|
+
|
|
44
|
+
if (import.meta.env.DEV) {
|
|
45
|
+
const known = new Set(eras.map((e) => e.id));
|
|
46
|
+
const orphans = Object.keys(items).filter((id) => !known.has(id) && items[id].length);
|
|
47
|
+
if (orphans.length) console.warn(`ChronologyTimeline: entries for unknown era id(s) ${orphans.join(', ')} are not rendered.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const thumbSizes = '(min-width: 640px) 128px, 64px';
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
<div class:list={['w-full', className]} data-chronology>
|
|
54
|
+
{index && fullEras.length > 1 && (
|
|
55
|
+
<nav aria-label={indexLabel} class="mb-12 md:mb-16">
|
|
56
|
+
<ol class="flex flex-wrap gap-2">
|
|
57
|
+
{fullEras.map((era) => (
|
|
58
|
+
<li><Chip href={`#${era.anchor}`} tone="outlineQuiet" accent="primary">{era.label}</Chip></li>
|
|
59
|
+
))}
|
|
60
|
+
</ol>
|
|
61
|
+
</nav>
|
|
62
|
+
)}
|
|
63
|
+
<ol class="grid">
|
|
64
|
+
{rows.map(({ era, entries, full }) => full ? (
|
|
65
|
+
<li class="group/era grid grid-cols-[1.25rem_minmax(0,1fr)] grid-rows-[auto_auto_1fr] gap-x-4 md:grid-cols-[10rem_1.25rem_minmax(0,1fr)] md:grid-rows-[auto_1fr] md:gap-x-6" data-chronology-era={era.id}>
|
|
66
|
+
<div class="relative col-start-1 row-span-3 flex justify-center md:col-start-2 md:row-span-2 before:absolute before:inset-y-0 before:left-1/2 before:w-0.5 before:-translate-x-1/2 before:bg-foreground/15 group-first/era:before:top-3 md:group-first/era:before:top-4" aria-hidden="true">
|
|
67
|
+
<span class="relative mt-3 size-3 rotate-45 bg-primary ring-4 ring-background md:mt-4 md:size-3.5"></span>
|
|
68
|
+
</div>
|
|
69
|
+
<h2 id={era.anchor} class="col-start-2 row-start-1 scroll-mt-28 font-display text-3xl leading-tight text-balance text-foreground md:col-start-3 md:text-4xl">{era.label}</h2>
|
|
70
|
+
<p class="col-start-2 row-start-2 mt-1 font-display text-lg tabular-nums text-secondary md:col-start-1 md:row-start-1 md:row-span-2 md:mt-4 md:self-start md:sticky md:top-28 md:text-right md:text-xl md:leading-snug">{era.rangeLabel}</p>
|
|
71
|
+
<div class="col-start-2 row-start-3 pb-16 md:col-start-3 md:row-start-2 md:pb-24 group-last/era:pb-0">
|
|
72
|
+
<p class="mt-4 max-w-measure text-lg leading-relaxed text-foreground">{era.intro}</p>
|
|
73
|
+
{entries.length > 0 && (
|
|
74
|
+
<ol class="mt-8 divide-y divide-border border-y border-border">
|
|
75
|
+
{entries.map((item) => (
|
|
76
|
+
<li class="group/entry relative flex items-start gap-3 py-5 sm:gap-6">
|
|
77
|
+
<div class="min-w-0 flex-1">
|
|
78
|
+
<h3 class="text-lg font-semibold leading-snug text-foreground text-pretty">
|
|
79
|
+
<a href={item.href} class="rounded-sm transition-colors group-hover/entry:text-primary focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring after:absolute after:inset-0">{item.title}</a>
|
|
80
|
+
</h3>
|
|
81
|
+
<p class="mt-1 flex flex-wrap gap-x-2 gap-y-0.5 text-sm text-muted-foreground">
|
|
82
|
+
{item.yearLabel && <span class="tabular-nums font-medium text-foreground">{item.yearLabel}</span>}
|
|
83
|
+
{item.yearLabel && <span aria-hidden="true">·</span>}
|
|
84
|
+
<span>{item.kind}</span>
|
|
85
|
+
</p>
|
|
86
|
+
{item.description && (
|
|
87
|
+
<p class="mt-2 line-clamp-3 max-w-measure text-base leading-relaxed text-muted-foreground sm:line-clamp-none">{item.description}</p>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
{item.image && (
|
|
91
|
+
<div class="w-16 shrink-0 overflow-hidden rounded-md bg-muted aspect-[4/3] sm:w-32">
|
|
92
|
+
{typeof item.image === 'string' ? (
|
|
93
|
+
<img src={item.image} alt="" width="128" height="96" loading="lazy" decoding="async" class="size-full object-cover" />
|
|
94
|
+
) : (
|
|
95
|
+
<ResponsivePicture src={item.image} alt="" sizes={thumbSizes} loading="lazy" decoding="async" class="size-full object-cover" />
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</li>
|
|
100
|
+
))}
|
|
101
|
+
</ol>
|
|
102
|
+
)}
|
|
103
|
+
{era.dossiers && era.dossiers.length > 0 && (
|
|
104
|
+
<div class="mt-8">
|
|
105
|
+
<p id={`${era.anchor}-dossiers`} class="text-sm font-semibold text-foreground">{dossiersLabel}</p>
|
|
106
|
+
<ul aria-labelledby={`${era.anchor}-dossiers`} class="mt-2 flex flex-wrap gap-x-6">
|
|
107
|
+
{era.dossiers.map((d) => (
|
|
108
|
+
<li><LinkArrow href={d.href} color="secondary" class="min-h-11">{d.label}</LinkArrow></li>
|
|
109
|
+
))}
|
|
110
|
+
</ul>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</li>
|
|
115
|
+
) : (
|
|
116
|
+
<li class="group/era grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-4 md:grid-cols-[10rem_1.25rem_minmax(0,1fr)] md:gap-x-6" data-chronology-era={era.id} data-chronology-empty>
|
|
117
|
+
<div class="relative col-start-1 flex justify-center md:col-start-2 before:absolute before:inset-y-0 before:left-1/2 before:w-0.5 before:-translate-x-1/2 before:bg-foreground/15 group-first/era:before:top-2" aria-hidden="true">
|
|
118
|
+
<span class="relative mt-2 size-2 rotate-45 border border-muted-foreground bg-background"></span>
|
|
119
|
+
</div>
|
|
120
|
+
<p id={era.anchor} class="col-start-2 flex scroll-mt-28 flex-wrap items-baseline gap-x-2 pb-10 text-sm text-muted-foreground md:col-start-3 group-last/era:pb-0">
|
|
121
|
+
<span class="font-display text-base text-foreground">{era.label}</span>
|
|
122
|
+
<span aria-hidden="true">·</span>
|
|
123
|
+
<span class="tabular-nums">{era.rangeLabel}</span>
|
|
124
|
+
{emptyLabel && (<><span aria-hidden="true">·</span><span>{emptyLabel}</span></>)}
|
|
125
|
+
</p>
|
|
126
|
+
</li>
|
|
127
|
+
))}
|
|
128
|
+
</ol>
|
|
129
|
+
</div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* EraChip — links an item to its historical era on the site's chronology hub. Placed beside the byline by the site;
|
|
4
|
+
* renders nothing when the item has no era. Visible text is `label · rangeLabel`, which must stay within the Chip
|
|
5
|
+
* 80-char ceiling (validate-badge-overflow.mjs). `basePath` falls back to core `eraHref`'s default.
|
|
6
|
+
*/
|
|
7
|
+
import type { EraDefinition } from '@sarimarcus/content-sites-core/eras';
|
|
8
|
+
import { eraHref } from '@sarimarcus/content-sites-core/eras';
|
|
9
|
+
import Chip from '../primitives/Chip.astro';
|
|
10
|
+
import { LABELS } from '../internal/labels';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
era?: EraDefinition;
|
|
14
|
+
basePath?: string;
|
|
15
|
+
/** Accessible name; keep the visible label and range in it. */
|
|
16
|
+
ariaLabel?: (era: EraDefinition) => string;
|
|
17
|
+
icon?: string;
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
era,
|
|
23
|
+
basePath,
|
|
24
|
+
ariaLabel = (e: EraDefinition) => LABELS.eraChip.ariaLabel(e.label, e.rangeLabel),
|
|
25
|
+
icon = 'history',
|
|
26
|
+
class: className,
|
|
27
|
+
}: Props = Astro.props;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
{era && (
|
|
31
|
+
<Chip
|
|
32
|
+
as="a"
|
|
33
|
+
href={eraHref(era, basePath)}
|
|
34
|
+
size="md"
|
|
35
|
+
tone="tint"
|
|
36
|
+
accent="primary"
|
|
37
|
+
icon={icon}
|
|
38
|
+
data-era={era.id}
|
|
39
|
+
aria-label={ariaLabel(era)}
|
|
40
|
+
class={className}
|
|
41
|
+
>
|
|
42
|
+
{era.label}<span aria-hidden="true">·</span><span class="whitespace-nowrap tabular-nums">{era.rangeLabel}</span>
|
|
43
|
+
</Chip>
|
|
44
|
+
)}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ImageMetadata } from 'astro';
|
|
2
|
+
|
|
3
|
+
/** One entry on a ChronologyTimeline era. `kind` and `yearLabel` are site copy, rendered verbatim. */
|
|
4
|
+
export interface TimelineItem {
|
|
5
|
+
title: string;
|
|
6
|
+
href: string;
|
|
7
|
+
kind: string;
|
|
8
|
+
yearLabel?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
/** Decorative (alt=""): the row's title link names the entry. */
|
|
11
|
+
image?: ImageMetadata | string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Groups entries by era id and orders each group by year (entries without a year last), then by title. Entries whose
|
|
16
|
+
* era is undefined are dropped. The input is not mutated.
|
|
17
|
+
*/
|
|
18
|
+
export function groupByEra<T>(
|
|
19
|
+
entries: readonly T[],
|
|
20
|
+
getEra: (entry: T) => string | undefined,
|
|
21
|
+
getYear: (entry: T) => number | undefined,
|
|
22
|
+
getTitle: (entry: T) => string = (entry) => {
|
|
23
|
+
const e = entry as { title?: unknown; data?: { title?: unknown; name?: unknown } };
|
|
24
|
+
return String(e?.title ?? e?.data?.title ?? e?.data?.name ?? '');
|
|
25
|
+
},
|
|
26
|
+
): Record<string, T[]> {
|
|
27
|
+
const groups: Record<string, T[]> = {};
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const era = getEra(entry);
|
|
30
|
+
if (!era) continue;
|
|
31
|
+
(groups[era] ??= []).push(entry);
|
|
32
|
+
}
|
|
33
|
+
for (const list of Object.values(groups)) {
|
|
34
|
+
list.sort((a, b) => {
|
|
35
|
+
const ya = getYear(a), yb = getYear(b);
|
|
36
|
+
if (ya !== yb) {
|
|
37
|
+
if (ya === undefined) return 1;
|
|
38
|
+
if (yb === undefined) return -1;
|
|
39
|
+
return ya - yb;
|
|
40
|
+
}
|
|
41
|
+
return getTitle(a).localeCompare(getTitle(b), 'en');
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return groups;
|
|
45
|
+
}
|
package/src/editorial/index.ts
CHANGED
|
@@ -21,8 +21,11 @@ export type { AuthorSummary, ArticleSummary, KeyFact, ArticleCategoryLink, Artic
|
|
|
21
21
|
export { default as InsiderTips } from './InsiderTips.astro';
|
|
22
22
|
export type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
|
|
23
23
|
export { default as AuthorStrip } from './AuthorStrip.astro';
|
|
24
|
+
export { default as EraChip } from './EraChip.astro';
|
|
24
25
|
export { default as AlsoExplore } from './AlsoExplore.astro';
|
|
25
26
|
export { default as VisitTiming } from './VisitTiming.astro';
|
|
27
|
+
export { default as ChronologyTimeline } from './ChronologyTimeline.astro';
|
|
28
|
+
export { groupByEra, type TimelineItem } from './chronology';
|
|
26
29
|
export type { AlsoExploreItem } from './AlsoExplore.astro';
|
|
27
30
|
export type { VisitTimingData } from './VisitTiming.astro';
|
|
28
31
|
export { default as AnswerBox } from './interludes/AnswerBox.astro';
|
package/src/internal/labels.ts
CHANGED
|
@@ -72,6 +72,7 @@ export const LABELS = {
|
|
|
72
72
|
alsoExplore: { eyebrow: 'Also explore' },
|
|
73
73
|
articleCategoryNav: { ariaLabel: 'Browse articles by topic', allLabel: 'All' },
|
|
74
74
|
articleFeatureCard: { readLabel: 'Read article', updatedLabel: 'Updated' },
|
|
75
|
+
eraChip: { ariaLabel: (label: string, range: string) => `${label}, ${range}: see this era on the chronology` },
|
|
75
76
|
articleTopicPager: {
|
|
76
77
|
heading: (topic: string) => `Next in ${topic}`,
|
|
77
78
|
newerLabel: 'Newer',
|
|
@@ -99,4 +100,9 @@ export const LABELS = {
|
|
|
99
100
|
newTabLabel: '(opens in a new tab)',
|
|
100
101
|
},
|
|
101
102
|
breadcrumb: { homeLabel: 'Home', ariaLabel: 'Breadcrumb navigation' },
|
|
103
|
+
chronologyTimeline: {
|
|
104
|
+
indexLabel: 'Eras',
|
|
105
|
+
dossiersLabel: 'Go deeper',
|
|
106
|
+
emptyLabel: '',
|
|
107
|
+
},
|
|
102
108
|
};
|