@sarimarcus/content-sites-ui 0.41.3 → 0.42.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 +8 -0
- package/package.json +2 -2
- package/src/composed/QuickFacts.astro +24 -11
- package/src/editorial/CardPlaceholder.astro +7 -0
- package/src/editorial/EraChip.astro +4 -1
- package/src/editorial/PersonCard.astro +30 -5
- package/src/editorial/PersonPage.astro +185 -72
- package/src/editorial/PersonSection.astro +21 -0
- package/src/editorial/RoleBadge.astro +7 -1
- package/src/editorial/index.ts +1 -0
- package/src/editorial/monogram.ts +21 -0
- package/src/internal/labels.ts +7 -0
- package/src/layout/ContentHero.astro +9 -0
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.42.0 (2026-10-21)
|
|
6
|
+
|
|
7
|
+
- `editorial`: `PersonPage` is rebuilt biography first: a `ContentHero` (warm, editorial overlay, short) with the role badges and `EraChip` in its badges slot, a lifespan line ("Lived c. 1138–1204 (about 66 years)", or "Born"/"Died" with one known year) and a monogram panel when there is no image; a key-facts strip (aliases, optional `contextFact`); the bio with a lead first paragraph at a 65ch measure; a `TableOfContents` only past four bio paragraphs; a featured full-story card (`fullStory.title`, else the old link line); places to see; the era timeline; then read more, capped at `readMoreLimit` (default 4). New optional props: `birthYear`, `deathYear`, `circa`, `lifespan`, `aliases`, `aliasesLabel`, `contextFact`, `monogram`, `fullStoryHeading`, `readMoreLimit`, `tocLabels`; `fullStory` gains `title`, `summary`, `image`, `fallbackSrc`, `imageAlt`. `PersonCard` gains `knownFor` (replaces the description line), `eraLabel` and a monogram tile when there is no image. New `monogram()` helper, internal `PersonSection` and `CardPlaceholder`; `RoleBadge` and `EraChip` gain `onImage`. `composed`: `QuickFacts` gains `variant="strip"`, an optional `icon` and `pagefindIgnore`; its other variants render identically. `layout`: `ContentHero` gains `monogram`, a dark panel with the initials when no image is set; existing heroes render identically. Copy defaults in `LABELS.personPage` (`lifespan`, `born`, `died`, `alsoKnownAs`, `fullStoryHeading`, `tocLabels`) (SLA-3290).
|
|
8
|
+
|
|
9
|
+
### Breaking
|
|
10
|
+
|
|
11
|
+
- `PersonPage`'s markup and section order change: after-body now renders before the full-story block, the timeline renders before read more, the bio is always wrapped in `<section data-person-section="biography">`, and the section ids are `person-<section>` with `person-<section>-heading` headings. `readMore` renders only its first four entries unless `readMoreLimit` says otherwise, so a route passing many cards should rank them first. No site selects on the old `data-person-*` contract and no site edit is needed, so there is no codemod.
|
|
12
|
+
|
|
5
13
|
## 0.41.3 (2026-10-21)
|
|
6
14
|
|
|
7
15
|
- `editorial`: new `PersonChip` (a Chip link to a historical figure's page, name and life dates), `PersonCard` (an `EntityCard` poster teaser with portrait, dates, role badges and a clamped description) and `PersonPage` (the person page shell: portrait, roles, name, dates with the `EraChip`, short bio, "Read the full story" link to the primary article, places to see and related reading as `RelatedContentGrid`s, a `fallbackSrc` prop for a string `image`, and the era on a `ChronologyTimeline`; slots `breadcrumb`, `before-body`, `after-body`, `tail`). Copy defaults live in `LABELS.personChip`, `personCard` and `personPage`. No change to existing components' markup (SLA-3260).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.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.42.0"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -25,7 +25,7 @@ import Icon from '../primitives/Icon.astro';
|
|
|
25
25
|
import type { BrandTone } from '../types';
|
|
26
26
|
|
|
27
27
|
interface QuickFactItem {
|
|
28
|
-
icon
|
|
28
|
+
icon?: string;
|
|
29
29
|
label: string;
|
|
30
30
|
value: string;
|
|
31
31
|
href?: string;
|
|
@@ -44,12 +44,15 @@ interface ActionLink {
|
|
|
44
44
|
interface Props {
|
|
45
45
|
title?: string;
|
|
46
46
|
items: QuickFactItem[];
|
|
47
|
-
|
|
47
|
+
/** `strip`: borderless rows divided by hairlines, label beside value on `sm`, for a quiet facts line. */
|
|
48
|
+
variant?: 'card' | 'muted' | 'strip';
|
|
48
49
|
columns?: 2 | 3 | 4;
|
|
49
50
|
links?: ActionLink[];
|
|
50
51
|
headingLevel?: 'h2' | 'h3' | 'h4';
|
|
51
52
|
/** Replaces the fact labels' size class. */
|
|
52
53
|
labelSizeClass?: string;
|
|
54
|
+
/** `false` lets Pagefind index the facts (default: ignored). */
|
|
55
|
+
pagefindIgnore?: boolean;
|
|
53
56
|
class?: string;
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -61,16 +64,22 @@ const {
|
|
|
61
64
|
links,
|
|
62
65
|
headingLevel = 'h2',
|
|
63
66
|
labelSizeClass = 'text-[0.8125rem]',
|
|
67
|
+
pagefindIgnore = true,
|
|
64
68
|
class: className = '',
|
|
65
69
|
} = Astro.props;
|
|
66
70
|
|
|
67
71
|
const Heading = headingLevel;
|
|
68
72
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
const strip = variant === 'strip';
|
|
74
|
+
const containerClass = strip
|
|
75
|
+
? ''
|
|
76
|
+
: variant === 'card'
|
|
77
|
+
? 'p-6 lg:p-8 bg-card rounded-2xl border border-accent/20'
|
|
78
|
+
: 'p-6 bg-primary/5 rounded-xl border border-primary/15';
|
|
72
79
|
|
|
73
|
-
const gridClass =
|
|
80
|
+
const gridClass = strip
|
|
81
|
+
? 'divide-y divide-border border-y border-border'
|
|
82
|
+
: columns === 4
|
|
74
83
|
? 'grid sm:grid-cols-2 lg:grid-cols-4 gap-6'
|
|
75
84
|
: columns === 3
|
|
76
85
|
? 'grid sm:grid-cols-2 lg:grid-cols-3 gap-6'
|
|
@@ -78,9 +87,13 @@ const gridClass = columns === 4
|
|
|
78
87
|
|
|
79
88
|
const titleClass = 'text-2xl font-display font-semibold text-foreground mb-6 leading-tight';
|
|
80
89
|
|
|
81
|
-
const labelClass =
|
|
90
|
+
const labelClass = strip
|
|
91
|
+
? 'text-sm font-semibold text-muted-foreground'
|
|
92
|
+
: `${labelSizeClass} uppercase tracking-wider text-muted-foreground mb-1.5 leading-none`;
|
|
82
93
|
|
|
83
|
-
const valueClass =
|
|
94
|
+
const valueClass = strip
|
|
95
|
+
? 'text-foreground'
|
|
96
|
+
: variant === 'card'
|
|
84
97
|
? 'text-lg font-medium text-foreground leading-snug'
|
|
85
98
|
: 'text-base font-medium text-foreground leading-snug';
|
|
86
99
|
|
|
@@ -92,14 +105,14 @@ const linkColorMap: Record<string, { bg: string; hover: string; text: string }>
|
|
|
92
105
|
---
|
|
93
106
|
|
|
94
107
|
{items.length > 0 && (
|
|
95
|
-
<div class:list={[containerClass, className]} data-pagefind-ignore>
|
|
108
|
+
<div class:list={[containerClass, className]} data-pagefind-ignore={pagefindIgnore ? '' : undefined}>
|
|
96
109
|
{title && <Heading class={titleClass}>{title}</Heading>}
|
|
97
110
|
|
|
98
111
|
<dl class={gridClass}>
|
|
99
112
|
{items.map((item) => (
|
|
100
|
-
<div class:list={['min-w-0', item.span === 2 && 'sm:col-span-2']}>
|
|
113
|
+
<div class:list={['min-w-0', item.span === 2 && 'sm:col-span-2', strip && 'grid gap-1 py-3 sm:grid-cols-[10rem_minmax(0,1fr)] sm:gap-6']}>
|
|
101
114
|
<dt class={`${labelClass} flex items-center gap-1.5`}>
|
|
102
|
-
<Icon name={item.icon} class="w-3.5 h-3.5 text-primary/60" />
|
|
115
|
+
{item.icon && <Icon name={item.icon} class="w-3.5 h-3.5 text-primary/60" />}
|
|
103
116
|
{item.label}
|
|
104
117
|
</dt>
|
|
105
118
|
<dd class={`${valueClass} tabular-nums break-words`}>
|
|
@@ -15,6 +15,8 @@ interface Props {
|
|
|
15
15
|
/** Accessible name; keep the visible label and range in it. */
|
|
16
16
|
ariaLabel?: (era: EraDefinition) => string;
|
|
17
17
|
icon?: string;
|
|
18
|
+
/** Opaque scrim tone for a chip laid over a photo or the hero overlay. */
|
|
19
|
+
onImage?: boolean;
|
|
18
20
|
class?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -23,6 +25,7 @@ const {
|
|
|
23
25
|
basePath,
|
|
24
26
|
ariaLabel = (e: EraDefinition) => LABELS.eraChip.ariaLabel(e.label, e.rangeLabel),
|
|
25
27
|
icon = 'history',
|
|
28
|
+
onImage = false,
|
|
26
29
|
class: className,
|
|
27
30
|
}: Props = Astro.props;
|
|
28
31
|
---
|
|
@@ -32,7 +35,7 @@ const {
|
|
|
32
35
|
as="a"
|
|
33
36
|
href={eraHref(era, basePath)}
|
|
34
37
|
size="md"
|
|
35
|
-
tone=
|
|
38
|
+
tone={onImage ? 'onImage' : 'tint'}
|
|
36
39
|
accent="primary"
|
|
37
40
|
icon={icon}
|
|
38
41
|
data-era={era.id}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* PersonCard — a historical figure's teaser card on EntityCard's poster frame: portrait, name, life dates,
|
|
4
|
-
* labels and a
|
|
5
|
-
* falls back to LABELS. Without `image` or `fallbackSrc` the image block
|
|
3
|
+
* PersonCard — a historical figure's teaser card on EntityCard's poster frame: portrait, name, life dates, era label,
|
|
4
|
+
* role labels and a one-line "known for" hook. Every string arrives as a prop (roles are the site's display labels);
|
|
5
|
+
* `ctaLabel` falls back to LABELS. Without `image` or `fallbackSrc` the image block is a monogram tile (initials of
|
|
6
|
+
* the name, or `monogram`). `eraLabel` is plain text: the card is one link, so it cannot hold the linked EraChip.
|
|
6
7
|
*/
|
|
7
8
|
import type { ImageMetadata } from 'astro';
|
|
8
9
|
import EntityCard from '../composed/EntityCard.astro';
|
|
9
10
|
import RoleBadge from './RoleBadge.astro';
|
|
10
11
|
import ArrowLabel from '../primitives/ArrowLabel.astro';
|
|
12
|
+
import Badge from '../primitives/Badge.astro';
|
|
13
|
+
import { monogram as initialsOf } from './monogram';
|
|
14
|
+
import CardPlaceholder from './CardPlaceholder.astro';
|
|
11
15
|
import { LABELS } from '../internal/labels';
|
|
12
16
|
|
|
13
17
|
interface Props {
|
|
@@ -19,6 +23,13 @@ interface Props {
|
|
|
19
23
|
/** Life dates as displayed, e.g. core `formatLifeDates`. */
|
|
20
24
|
dates?: string;
|
|
21
25
|
roles?: readonly string[];
|
|
26
|
+
/** One-line hook, e.g. "Physician and philosopher of Averroism". Wins over `description`. */
|
|
27
|
+
knownFor?: string;
|
|
28
|
+
/** Era as displayed, e.g. "Almohad Caliphate · 1121–1269". */
|
|
29
|
+
eraLabel?: string;
|
|
30
|
+
/** Tile initials when there is no image; defaults to the initials of `name`. */
|
|
31
|
+
monogram?: string;
|
|
32
|
+
/** Deprecated: clamped fallback when `knownFor` is absent. */
|
|
22
33
|
description?: string;
|
|
23
34
|
ctaLabel?: string;
|
|
24
35
|
headingLevel?: 2 | 3;
|
|
@@ -36,6 +47,9 @@ const {
|
|
|
36
47
|
imageAlt,
|
|
37
48
|
dates,
|
|
38
49
|
roles = [],
|
|
50
|
+
knownFor,
|
|
51
|
+
eraLabel,
|
|
52
|
+
monogram,
|
|
39
53
|
description,
|
|
40
54
|
ctaLabel = LABELS.personCard.cta,
|
|
41
55
|
headingLevel = 3,
|
|
@@ -46,6 +60,7 @@ const {
|
|
|
46
60
|
} = Astro.props;
|
|
47
61
|
|
|
48
62
|
const Heading = `h${headingLevel}` as 'h2' | 'h3';
|
|
63
|
+
const initials = monogram ?? initialsOf(name);
|
|
49
64
|
---
|
|
50
65
|
|
|
51
66
|
<EntityCard
|
|
@@ -60,15 +75,25 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3';
|
|
|
60
75
|
footerJustify="end"
|
|
61
76
|
class={className}
|
|
62
77
|
>
|
|
78
|
+
{initials && (
|
|
79
|
+
<CardPlaceholder slot="fallback">
|
|
80
|
+
<span class="font-display text-6xl font-semibold text-primary/70 select-none">{initials}</span>
|
|
81
|
+
</CardPlaceholder>
|
|
82
|
+
)}
|
|
63
83
|
<Heading class="text-xl font-display font-semibold text-foreground">{name}</Heading>
|
|
64
84
|
{dates && <p class="mt-1 text-sm tabular-nums text-muted-foreground">{dates}</p>}
|
|
65
|
-
{roles.length > 0 && (
|
|
85
|
+
{(eraLabel || roles.length > 0) && (
|
|
66
86
|
<div class="mt-3 flex flex-wrap gap-2">
|
|
87
|
+
{eraLabel && <Badge tone="primary" size="md" lines={0}><span class="tabular-nums">{eraLabel}</span></Badge>}
|
|
67
88
|
{roles.map((role) => (
|
|
68
89
|
<RoleBadge>{role}</RoleBadge>
|
|
69
90
|
))}
|
|
70
91
|
</div>
|
|
71
92
|
)}
|
|
72
|
-
{
|
|
93
|
+
{knownFor ? (
|
|
94
|
+
<p class="mt-3 text-sm leading-relaxed text-foreground">{knownFor}</p>
|
|
95
|
+
) : description ? (
|
|
96
|
+
<p class="mt-3 text-sm leading-relaxed text-muted-foreground line-clamp-3">{description}</p>
|
|
97
|
+
) : null}
|
|
73
98
|
<ArrowLabel slot="footer-cta" class="text-sm font-medium text-primary">{ctaLabel}</ArrowLabel>
|
|
74
99
|
</EntityCard>
|
|
@@ -1,29 +1,43 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* PersonPage — the shell and shared body of a historical figure's page. The route keeps paths, data,
|
|
4
|
-
* (core `historicalPersonSchema`), renders markdown to `bioHtml`, and wraps this template in the site
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
3
|
+
* PersonPage — the shell and shared body of a historical figure's page, biography first. The route keeps paths, data,
|
|
4
|
+
* SEO and schema (core `historicalPersonSchema`), renders markdown to `bioHtml`, and wraps this template in the site
|
|
5
|
+
* Layout. Order: the `ContentHero` (warm, editorial overlay, short: name as title, role badges and the era chip,
|
|
6
|
+
* the lifespan as typography; portrait as the backdrop, else a monogram panel), a quiet key-facts strip (aliases,
|
|
7
|
+
* optional `contextFact`), a short sticky ToC only when the bio runs past four paragraphs, the bio with its first
|
|
8
|
+
* paragraph as the lead, the featured full-story card, places to see, the era on a ChronologyTimeline, then up to
|
|
9
|
+
* four related reads (`RelatedContentGrid`, not `RelatedContent`: the person page owns its headings and
|
|
10
|
+
* `RelatedContent` carries the carousel CSS). Every string is a prop or a LABELS default; a block with no data
|
|
11
|
+
* renders nothing. The lifespan reads "Lived c. 1138–1204 (about 66 years)" from `birthYear`/`deathYear`/`circa`,
|
|
12
|
+
* or from a pre-formatted `lifespan` or `dates` (both shown as is). `readMore` is ranked by the caller: the first
|
|
13
|
+
* `readMoreLimit` (four) render, so the route passes the best reads first. Slots:
|
|
14
|
+
* breadcrumb — the site Breadcrumb, in the band above the hero.
|
|
15
|
+
* before-body — site blocks between the key facts and the bio.
|
|
16
|
+
* after-body — site blocks after the bio, before the full-story card.
|
|
17
|
+
* tail — full-width blocks after the last section.
|
|
13
18
|
*/
|
|
14
19
|
import type { ImageMetadata } from 'astro';
|
|
15
20
|
import type { EraDefinition } from '@sarimarcus/content-sites-core/eras';
|
|
21
|
+
import { lifeSpan } from '@sarimarcus/content-sites-core/people';
|
|
16
22
|
import Container from '../layout/Container.astro';
|
|
17
23
|
import BreadcrumbBand from '../layout/BreadcrumbBand.astro';
|
|
24
|
+
import ContentHero from '../layout/ContentHero.astro';
|
|
18
25
|
import RoleBadge from './RoleBadge.astro';
|
|
19
26
|
import LinkArrow from '../primitives/LinkArrow.astro';
|
|
20
|
-
import
|
|
27
|
+
import ArrowLabel from '../primitives/ArrowLabel.astro';
|
|
28
|
+
import Icon from '../primitives/Icon.astro';
|
|
29
|
+
import EntityCard from '../composed/EntityCard.astro';
|
|
30
|
+
import TableOfContents from '../composed/TableOfContents.astro';
|
|
21
31
|
import RelatedContentGrid from '../composed/RelatedContentGrid.astro';
|
|
22
32
|
import type { RelatedItem } from '../composed/related';
|
|
23
33
|
import type { TrustedHtml } from '../types';
|
|
24
34
|
import EraChip from './EraChip.astro';
|
|
25
35
|
import ChronologyTimeline from './ChronologyTimeline.astro';
|
|
26
36
|
import type { TimelineItem } from './chronology';
|
|
37
|
+
import { monogram as initialsOf } from './monogram';
|
|
38
|
+
import PersonSection from './PersonSection.astro';
|
|
39
|
+
import CardPlaceholder from './CardPlaceholder.astro';
|
|
40
|
+
import QuickFacts from '../composed/QuickFacts.astro';
|
|
27
41
|
import { LABELS } from '../internal/labels';
|
|
28
42
|
|
|
29
43
|
type PersonImage =
|
|
@@ -38,8 +52,21 @@ type PersonImage =
|
|
|
38
52
|
|
|
39
53
|
type Props = PersonImage & {
|
|
40
54
|
name: string;
|
|
41
|
-
/** Life
|
|
55
|
+
/** Life years as displayed, e.g. core `formatLifeDates`; used when no `birthYear`/`deathYear` is given. */
|
|
42
56
|
dates?: string;
|
|
57
|
+
birthYear?: number;
|
|
58
|
+
deathYear?: number;
|
|
59
|
+
/** Marks the years as approximate ("c."). */
|
|
60
|
+
circa?: boolean;
|
|
61
|
+
/** The whole lifespan line, shown as is (wins over the computed one). */
|
|
62
|
+
lifespan?: string;
|
|
63
|
+
/** Other names, as displayed. */
|
|
64
|
+
aliases?: readonly string[];
|
|
65
|
+
aliasesLabel?: string;
|
|
66
|
+
/** One extra key-facts row, e.g. the person's place in the city's history. */
|
|
67
|
+
contextFact?: { label: string; value: string };
|
|
68
|
+
/** Tile initials when there is no portrait; defaults to the initials of `name`. */
|
|
69
|
+
monogram?: string;
|
|
43
70
|
/** Role labels as displayed. */
|
|
44
71
|
roles?: readonly string[];
|
|
45
72
|
era?: EraDefinition;
|
|
@@ -47,20 +74,45 @@ type Props = PersonImage & {
|
|
|
47
74
|
bioHtml: TrustedHtml;
|
|
48
75
|
/** Prose modifiers after `prose`. */
|
|
49
76
|
proseClass?: string;
|
|
50
|
-
/**
|
|
51
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The person's primary article. With a `title` it renders as a featured card under `fullStoryHeading`; without,
|
|
79
|
+
* as a link line (`label` falls back to LABELS).
|
|
80
|
+
*/
|
|
81
|
+
fullStory?: {
|
|
82
|
+
href: string;
|
|
83
|
+
label?: string;
|
|
84
|
+
title?: string;
|
|
85
|
+
summary?: string;
|
|
86
|
+
image?: ImageMetadata;
|
|
87
|
+
fallbackSrc?: string;
|
|
88
|
+
imageAlt?: string;
|
|
89
|
+
};
|
|
90
|
+
fullStoryHeading?: string;
|
|
52
91
|
places?: readonly RelatedItem[];
|
|
53
92
|
placesHeading?: string;
|
|
93
|
+
/** Caller-ranked: the first `readMoreLimit` render. */
|
|
54
94
|
readMore?: readonly RelatedItem[];
|
|
95
|
+
/** Cards shown under read more. */
|
|
96
|
+
readMoreLimit?: number;
|
|
55
97
|
readMoreHeading?: string;
|
|
56
98
|
/** The era in context: pass the person's era only, or the whole chronology. */
|
|
57
99
|
timeline?: { eras: readonly EraDefinition[]; items: Record<string, readonly TimelineItem[]>; heading?: string };
|
|
100
|
+
/** Overrides of the ToC pill labels. */
|
|
101
|
+
tocLabels?: Partial<Record<'biography' | 'places' | 'timeline' | 'readMore', string>>;
|
|
58
102
|
pagefindType?: string;
|
|
59
103
|
};
|
|
60
104
|
|
|
61
105
|
const {
|
|
62
106
|
name,
|
|
63
107
|
dates,
|
|
108
|
+
birthYear,
|
|
109
|
+
deathYear,
|
|
110
|
+
circa = false,
|
|
111
|
+
lifespan,
|
|
112
|
+
aliases = [],
|
|
113
|
+
aliasesLabel = LABELS.personPage.alsoKnownAs,
|
|
114
|
+
contextFact,
|
|
115
|
+
monogram,
|
|
64
116
|
roles = [],
|
|
65
117
|
image,
|
|
66
118
|
fallbackSrc,
|
|
@@ -70,98 +122,159 @@ const {
|
|
|
70
122
|
bioHtml,
|
|
71
123
|
proseClass = 'prose-lg',
|
|
72
124
|
fullStory,
|
|
125
|
+
fullStoryHeading = LABELS.personPage.fullStoryHeading,
|
|
73
126
|
places = [],
|
|
74
127
|
placesHeading = LABELS.personPage.placesHeading,
|
|
75
128
|
readMore = [],
|
|
129
|
+
readMoreLimit = 4,
|
|
76
130
|
readMoreHeading = LABELS.personPage.readMoreHeading,
|
|
77
131
|
timeline,
|
|
132
|
+
tocLabels,
|
|
78
133
|
pagefindType = 'person',
|
|
79
134
|
} = Astro.props;
|
|
80
135
|
|
|
81
136
|
const timelineHeading = timeline?.heading ?? LABELS.personPage.timelineHeading;
|
|
82
|
-
const hasTimeline = timeline && Object.values(timeline.items).some((list) => list.length > 0);
|
|
137
|
+
const hasTimeline = Boolean(timeline && Object.values(timeline.items).some((list) => list.length > 0));
|
|
138
|
+
const readMoreItems = readMore.slice(0, readMoreLimit);
|
|
139
|
+
|
|
140
|
+
const span = lifeSpan({ birthYear, deathYear });
|
|
141
|
+
const lifespanByKind = {
|
|
142
|
+
range: () => LABELS.personPage.lifespan(span.years ?? '', circa, span.age),
|
|
143
|
+
born: () => LABELS.personPage.born(span.years ?? '', circa),
|
|
144
|
+
died: () => LABELS.personPage.died(span.years ?? '', circa),
|
|
145
|
+
none: () => undefined,
|
|
146
|
+
};
|
|
147
|
+
const derivedLifespan = lifespanByKind[span.kind]();
|
|
148
|
+
const lifespanText = lifespan ?? derivedLifespan ?? dates;
|
|
149
|
+
|
|
150
|
+
const facts = [
|
|
151
|
+
...(aliases.length > 0 ? [{ label: aliasesLabel, value: aliases.join(', ') }] : []),
|
|
152
|
+
...(contextFact ? [contextFact] : []),
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const hasFullStoryCard = Boolean(fullStory?.title);
|
|
156
|
+
const bioParagraphs = (String(bioHtml).match(/<p[\s>]/g) ?? []).length;
|
|
157
|
+
const toc = {
|
|
158
|
+
biography: tocLabels?.biography ?? LABELS.personPage.tocLabels.biography,
|
|
159
|
+
places: tocLabels?.places ?? LABELS.personPage.tocLabels.places,
|
|
160
|
+
timeline: tocLabels?.timeline ?? LABELS.personPage.tocLabels.timeline,
|
|
161
|
+
readMore: tocLabels?.readMore ?? LABELS.personPage.tocLabels.readMore,
|
|
162
|
+
};
|
|
163
|
+
const showToc = bioParagraphs > 4;
|
|
164
|
+
const tocItems = [
|
|
165
|
+
{ label: toc.biography, href: '#person-biography' },
|
|
166
|
+
...(places.length > 0 ? [{ label: toc.places, href: '#person-places' }] : []),
|
|
167
|
+
...(hasTimeline ? [{ label: toc.timeline, href: '#person-timeline' }] : []),
|
|
168
|
+
...(readMoreItems.length > 0 ? [{ label: toc.readMore, href: '#person-read-more' }] : []),
|
|
169
|
+
];
|
|
170
|
+
const heroMonogram = image || fallbackSrc ? undefined : (monogram ?? initialsOf(name));
|
|
83
171
|
---
|
|
84
172
|
|
|
85
173
|
<BreadcrumbBand>
|
|
86
174
|
<slot name="breadcrumb" />
|
|
87
175
|
</BreadcrumbBand>
|
|
88
176
|
|
|
89
|
-
<main class="
|
|
177
|
+
<main class="pb-12 lg:pb-20" data-person-page>
|
|
90
178
|
<div data-pagefind-body data-pagefind-meta={`type:${pagefindType}`}>
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
fetchpriority="high"
|
|
111
|
-
decoding="async"
|
|
112
|
-
class="size-full object-cover"
|
|
113
|
-
/>
|
|
114
|
-
)}
|
|
115
|
-
</div>
|
|
179
|
+
<ContentHero
|
|
180
|
+
title={name}
|
|
181
|
+
theme="warm"
|
|
182
|
+
overlay="editorial"
|
|
183
|
+
minHeight="short"
|
|
184
|
+
image={image}
|
|
185
|
+
imageFallback={fallbackSrc}
|
|
186
|
+
imageAlt={imageAlt ?? name}
|
|
187
|
+
monogram={heroMonogram}
|
|
188
|
+
>
|
|
189
|
+
<Fragment slot="badges">
|
|
190
|
+
{roles.map((role) => (
|
|
191
|
+
<RoleBadge onImage>{role}</RoleBadge>
|
|
192
|
+
))}
|
|
193
|
+
<EraChip era={era} basePath={eraBasePath} onImage />
|
|
194
|
+
</Fragment>
|
|
195
|
+
<Fragment slot="subtitle">
|
|
196
|
+
{lifespanText && (
|
|
197
|
+
<p class="font-display text-lg tabular-nums text-overlay-foreground/90 sm:text-xl" data-person-dates>{lifespanText}</p>
|
|
116
198
|
)}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
))}
|
|
123
|
-
</div>
|
|
124
|
-
)}
|
|
125
|
-
<h1 class="font-display text-4xl leading-tight text-balance text-foreground">{name}</h1>
|
|
126
|
-
{(dates || era) && (
|
|
127
|
-
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
128
|
-
{dates && <p class="font-display text-xl tabular-nums text-secondary" data-person-dates>{dates}</p>}
|
|
129
|
-
<EraChip era={era} basePath={eraBasePath} />
|
|
130
|
-
</div>
|
|
131
|
-
)}
|
|
132
|
-
</div>
|
|
133
|
-
</header>
|
|
199
|
+
</Fragment>
|
|
200
|
+
</ContentHero>
|
|
201
|
+
|
|
202
|
+
<Container size="measure" class="pt-8 lg:pt-10">
|
|
203
|
+
<QuickFacts variant="strip" items={facts} class="mb-8" pagefindIgnore={false} />
|
|
134
204
|
|
|
135
205
|
<slot name="before-body" />
|
|
136
206
|
|
|
137
|
-
|
|
207
|
+
{showToc && tocItems.length > 1 && (
|
|
208
|
+
<TableOfContents
|
|
209
|
+
class="mb-8"
|
|
210
|
+
showHeader={false}
|
|
211
|
+
items={tocItems}
|
|
212
|
+
sectionSelector="[data-person-section]"
|
|
213
|
+
hideMiniPastLastSection
|
|
214
|
+
/>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
<section id="person-biography" class="scroll-mt-28" aria-label={toc.biography} data-person-section="biography">
|
|
218
|
+
<div
|
|
219
|
+
class={`prose ${proseClass} max-w-[65ch] [&>p:first-child]:text-[1.2em] [&>p:first-child]:leading-relaxed`}
|
|
220
|
+
data-person-bio
|
|
221
|
+
set:html={bioHtml}
|
|
222
|
+
/>
|
|
223
|
+
</section>
|
|
138
224
|
|
|
139
|
-
|
|
225
|
+
<slot name="after-body" />
|
|
226
|
+
|
|
227
|
+
{fullStory && !hasFullStoryCard && (
|
|
140
228
|
<p class="mt-6">
|
|
141
229
|
<LinkArrow href={fullStory.href} size="base">{fullStory.label ?? LABELS.personPage.readFullStory}</LinkArrow>
|
|
142
230
|
</p>
|
|
143
231
|
)}
|
|
144
232
|
|
|
145
|
-
|
|
233
|
+
{fullStory && hasFullStoryCard && (
|
|
234
|
+
<PersonSection section="full-story" heading={fullStoryHeading} class="mt-12">
|
|
235
|
+
<div class="max-w-md">
|
|
236
|
+
<EntityCard
|
|
237
|
+
href={fullStory.href}
|
|
238
|
+
variant="poster"
|
|
239
|
+
image={fullStory.image}
|
|
240
|
+
fallbackSrc={fullStory.fallbackSrc}
|
|
241
|
+
imageAlt={fullStory.imageAlt ?? fullStory.title ?? ''}
|
|
242
|
+
footerJustify="end"
|
|
243
|
+
>
|
|
244
|
+
<CardPlaceholder slot="fallback"><Icon name="book-open" class="size-10 text-primary/70" /></CardPlaceholder>
|
|
245
|
+
<h3 class="text-xl font-display font-semibold text-foreground">{fullStory.title}</h3>
|
|
246
|
+
{fullStory.summary && <p class="mt-2 text-sm leading-relaxed text-muted-foreground">{fullStory.summary}</p>}
|
|
247
|
+
<ArrowLabel slot="footer-cta" class="text-sm font-medium text-primary">{fullStory.label ?? LABELS.personPage.readFullStory}</ArrowLabel>
|
|
248
|
+
</EntityCard>
|
|
249
|
+
</div>
|
|
250
|
+
</PersonSection>
|
|
251
|
+
)}
|
|
146
252
|
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<h2 id={`person-${group.id}`} class="mb-6 text-2xl font-display font-semibold text-foreground">{group.heading}</h2>
|
|
153
|
-
<RelatedContentGrid items={group.items} />
|
|
154
|
-
</section>
|
|
155
|
-
))}
|
|
253
|
+
{places.length > 0 && (
|
|
254
|
+
<PersonSection section="places" heading={placesHeading} class="mt-12">
|
|
255
|
+
<RelatedContentGrid items={places} />
|
|
256
|
+
</PersonSection>
|
|
257
|
+
)}
|
|
156
258
|
</Container>
|
|
157
259
|
</div>
|
|
158
260
|
|
|
159
261
|
{timeline && hasTimeline && (
|
|
160
262
|
<Container size="content" class="mt-16">
|
|
161
|
-
<
|
|
162
|
-
|
|
263
|
+
<PersonSection section="timeline" heading={timelineHeading} headingClass="mb-8">
|
|
264
|
+
<ChronologyTimeline eras={timeline.eras} items={timeline.items} index={false} />
|
|
265
|
+
</PersonSection>
|
|
163
266
|
</Container>
|
|
164
267
|
)}
|
|
165
268
|
|
|
269
|
+
{readMoreItems.length > 0 && (
|
|
270
|
+
<div data-pagefind-body data-pagefind-meta={`type:${pagefindType}`}>
|
|
271
|
+
<Container size="measure" class="mt-16">
|
|
272
|
+
<PersonSection section="read-more" heading={readMoreHeading}>
|
|
273
|
+
<RelatedContentGrid items={readMoreItems} />
|
|
274
|
+
</PersonSection>
|
|
275
|
+
</Container>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
|
|
166
279
|
<slot name="tail" />
|
|
167
280
|
</main>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A titled section of the person page: one shell for the full story, places, timeline and read-more blocks.
|
|
3
|
+
interface Props {
|
|
4
|
+
section: string;
|
|
5
|
+
heading: string;
|
|
6
|
+
headingClass?: string;
|
|
7
|
+
class?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { section, heading, headingClass = 'mb-6', class: className } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<section
|
|
14
|
+
id={`person-${section}`}
|
|
15
|
+
class:list={['scroll-mt-28', className]}
|
|
16
|
+
aria-labelledby={`person-${section}-heading`}
|
|
17
|
+
data-person-section={section}
|
|
18
|
+
>
|
|
19
|
+
<h2 id={`person-${section}-heading`} class:list={['font-display text-2xl font-semibold text-foreground', headingClass]}>{heading}</h2>
|
|
20
|
+
<slot />
|
|
21
|
+
</section>
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
// The outlined label for a role or an area of expertise (person and author cards, person page).
|
|
3
3
|
import Badge from '../primitives/Badge.astro';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Opaque scrim tone for a badge laid over a photo or the hero overlay. */
|
|
7
|
+
onImage?: boolean;
|
|
8
|
+
}
|
|
9
|
+
const { onImage = false } = Astro.props;
|
|
4
10
|
---
|
|
5
11
|
|
|
6
|
-
<Badge tone=
|
|
12
|
+
<Badge tone={onImage ? 'onImage' : 'outline'} size="md" lines={0}><slot /></Badge>
|
package/src/editorial/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export { default as AlsoExplore } from './AlsoExplore.astro';
|
|
|
29
29
|
export { default as VisitTiming } from './VisitTiming.astro';
|
|
30
30
|
export { default as ChronologyTimeline } from './ChronologyTimeline.astro';
|
|
31
31
|
export { groupByEra, type TimelineItem } from './chronology';
|
|
32
|
+
export { monogram } from './monogram';
|
|
32
33
|
export type { AlsoExploreItem } from './AlsoExplore.astro';
|
|
33
34
|
export type { VisitTimingData } from './VisitTiming.astro';
|
|
34
35
|
export { default as AnswerBox } from './interludes/AnswerBox.astro';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const PARTICLES = new Set([
|
|
2
|
+
'al', 'el', 'ibn', 'ben', 'bin', 'bint', 'abu', 'd', 'de', 'del', 'della', 'di', 'da', 'do', 'dos', 'das', 'du', 'des',
|
|
3
|
+
'van', 'von', 'der', 'den', 'la', 'le', 'los', 'las', 'y', 'e', 'of', 'the',
|
|
4
|
+
]);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Initials for a name tile: first and last significant word, at most two letters. Particles (al, ibn, de, van...),
|
|
8
|
+
* diacritics, apostrophes and Roman numeral suffixes are ignored; one significant word gives one letter.
|
|
9
|
+
*/
|
|
10
|
+
export function monogram(name: string): string {
|
|
11
|
+
const words = name
|
|
12
|
+
.normalize('NFD')
|
|
13
|
+
.replace(/[̀-ͯ]/g, '')
|
|
14
|
+
.split(/[\s\-'’‘ʻʿʾ]+/)
|
|
15
|
+
.map((w) => w.replace(/[^\p{L}]/gu, ''))
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
const kept = words.filter((w, i) => !PARTICLES.has(w.toLowerCase()) && !(i > 0 && /^[IVXLC]+$/.test(w)));
|
|
18
|
+
const pick = kept.length ? kept : words.slice(0, 1);
|
|
19
|
+
const letters = pick.length > 1 ? [pick[0], pick[pick.length - 1]] : pick;
|
|
20
|
+
return letters.map((w) => w[0].toUpperCase()).join('');
|
|
21
|
+
}
|
package/src/internal/labels.ts
CHANGED
|
@@ -77,9 +77,16 @@ export const LABELS = {
|
|
|
77
77
|
personCard: { cta: 'Read the profile' },
|
|
78
78
|
personPage: {
|
|
79
79
|
readFullStory: 'Read the full story',
|
|
80
|
+
fullStoryHeading: 'The full story',
|
|
80
81
|
placesHeading: 'Places to see',
|
|
81
82
|
readMoreHeading: 'Read more',
|
|
82
83
|
timelineHeading: 'In their time',
|
|
84
|
+
alsoKnownAs: 'Also known as',
|
|
85
|
+
lifespan: (years: string, circa?: boolean, age?: number) =>
|
|
86
|
+
`Lived ${circa ? 'c. ' : ''}${years}${age ? ` (about ${age} years)` : ''}`,
|
|
87
|
+
born: (year: string, circa?: boolean) => `Born ${circa ? 'c. ' : ''}${year}`,
|
|
88
|
+
died: (year: string, circa?: boolean) => `Died ${circa ? 'c. ' : ''}${year}`,
|
|
89
|
+
tocLabels: { biography: 'Biography', places: 'Places', timeline: 'Timeline', readMore: 'Read more' },
|
|
83
90
|
},
|
|
84
91
|
articleTopicPager: {
|
|
85
92
|
heading: (topic: string) => `Next in ${topic}`,
|
|
@@ -18,6 +18,8 @@ interface Props {
|
|
|
18
18
|
imageAlt?: string;
|
|
19
19
|
/** HTML title attribute of the hero image (tooltip / image SEO). */
|
|
20
20
|
imageTitle?: string;
|
|
21
|
+
/** Initials drawn on a dark panel when the hero has no image of any kind. */
|
|
22
|
+
monogram?: string;
|
|
21
23
|
/** Unknown names fall back to `dark` (`stone` and `nature` are such names on Granada). */
|
|
22
24
|
theme?: string;
|
|
23
25
|
/** Per-theme gradient classes layered over the built-in map, for a site whose theme looks differ. */
|
|
@@ -35,6 +37,7 @@ const {
|
|
|
35
37
|
imagePlaceholder,
|
|
36
38
|
imageAlt = title,
|
|
37
39
|
imageTitle,
|
|
40
|
+
monogram,
|
|
38
41
|
theme = 'dark',
|
|
39
42
|
themeGradients: themeOverrides,
|
|
40
43
|
overlay = 'themed',
|
|
@@ -109,6 +112,12 @@ const [badges, subtitle, info, actions, decoration] = await Promise.all(
|
|
|
109
112
|
loading="eager"
|
|
110
113
|
fetchpriority="high"
|
|
111
114
|
/>
|
|
115
|
+
) : monogram ? (
|
|
116
|
+
<div class="absolute inset-0 bg-overlay" aria-hidden="true">
|
|
117
|
+
<span class="absolute right-6 bottom-0 font-display text-[11rem] leading-none text-overlay-foreground/10 select-none sm:right-12 sm:text-[16rem] lg:text-[22rem]">
|
|
118
|
+
{monogram}
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
112
121
|
) : null}
|
|
113
122
|
{overlay === 'editorial' ? (
|
|
114
123
|
<>
|