ferst-core 0.7.4 → 0.7.6
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/bin/ferst-update.mjs +33 -2
- package/cms/config.template.yml +227 -206
- package/components/BlockRenderer.astro +4 -0
- package/components/SiteFooter.astro +325 -270
- package/components/SiteHeader.astro +82 -19
- package/components/blocks/Heading.astro +5 -1
- package/components/recipes/Contacts.astro +139 -0
- package/components/recipes/Document.astro +157 -152
- package/components/recipes/Documents.astro +2 -2
- package/components/recipes/MediaText.astro +92 -0
- package/components/recipes/Steps.astro +121 -120
- package/components/recipes/Table.astro +7 -3
- package/components/recipes/Video.astro +1 -1
- package/content/blocks.ts +64 -8
- package/content/schemas.ts +172 -161
- package/examples/recipes.ts +2 -0
- package/fonts/OFL.txt +287 -0
- package/fonts/inter-latin-400-normal.woff2 +0 -0
- package/fonts/inter-latin-500-normal.woff2 +0 -0
- package/fonts/inter-latin-600-normal.woff2 +0 -0
- package/fonts/playfair-display-latin-700-normal.woff2 +0 -0
- package/fonts/poppins-latin-300-normal.woff2 +0 -0
- package/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/integration.mjs +41 -1
- package/layouts/Base.astro +3 -0
- package/lib/icons.ts +10 -0
- package/lib/links.ts +24 -12
- package/lib/nav.ts +12 -0
- package/lib/siteSettings.ts +62 -60
- package/lib/slug.ts +32 -0
- package/lib/table.ts +10 -0
- package/package.json +69 -68
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import Button from './Button.astro';
|
|
13
13
|
import type { NavLink, NavGroup } from '../content/schemas';
|
|
14
14
|
|
|
15
|
+
import { navWithoutCta } from '../lib/nav';
|
|
15
16
|
interface Props {
|
|
16
17
|
path: string;
|
|
17
18
|
headerClass?: string;
|
|
@@ -21,9 +22,14 @@ interface Props {
|
|
|
21
22
|
primaryNav: NavLink[];
|
|
22
23
|
/** Optional dropdown / mega-menu group. Client-configured — the core ships none. */
|
|
23
24
|
dropdown?: NavGroup;
|
|
25
|
+
/** The call-to-action button (navbarSettings.cta); null hides it. */
|
|
26
|
+
cta?: { label: string; href: string } | null;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
const { path, headerClass, primaryLogoLight, primaryLogoDark, brandAlt, primaryNav, dropdown } = Astro.props;
|
|
29
|
+
const { path, headerClass, primaryLogoLight, primaryLogoDark, brandAlt, primaryNav, dropdown, cta = { label: 'Contact', href: '/contact/' } } = Astro.props;
|
|
30
|
+
// A menu link to the same place as the button would show it twice: the button wins.
|
|
31
|
+
const navLinks = navWithoutCta(primaryNav, cta);
|
|
32
|
+
const ctaActive = !!cta && cta.href.startsWith('/') && path.startsWith(cta.href.replace(/\/$/, '') || '/#');
|
|
27
33
|
|
|
28
34
|
/**
|
|
29
35
|
* Longest-prefix-wins active-nav matching. A plain independent `startsWith`
|
|
@@ -34,7 +40,7 @@ const { path, headerClass, primaryLogoLight, primaryLogoDark, brandAlt, primaryN
|
|
|
34
40
|
* longest means only the most specific matching link is ever active,
|
|
35
41
|
* however nav hrefs happen to nest.
|
|
36
42
|
*/
|
|
37
|
-
const primaryNavMatchLengths =
|
|
43
|
+
const primaryNavMatchLengths = navLinks.map((link) => {
|
|
38
44
|
const trimmed = link.href.replace(/\/$/, '');
|
|
39
45
|
return trimmed.length > 0 && path.startsWith(trimmed) ? trimmed.length : -1;
|
|
40
46
|
});
|
|
@@ -121,7 +127,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
|
|
|
121
127
|
the longest/most-specific match wins — see primaryNavLongestMatch
|
|
122
128
|
above — so a link whose href is a subpath of another's (Newsletters
|
|
123
129
|
under Posts) doesn't light up both at once. */}
|
|
124
|
-
{
|
|
130
|
+
{navLinks.map((link, i) => {
|
|
125
131
|
const isActive =
|
|
126
132
|
primaryNavMatchLengths[i] >= 0 && primaryNavMatchLengths[i] === primaryNavLongestMatch;
|
|
127
133
|
return (
|
|
@@ -131,13 +137,15 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
|
|
|
131
137
|
);
|
|
132
138
|
})}
|
|
133
139
|
<div class="nav-mobile-theme">
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
{cta && (
|
|
141
|
+
<Button
|
|
142
|
+
href={cta.href}
|
|
143
|
+
variant="cta"
|
|
144
|
+
class:list={['nav-cta', 'nav-cta--drawer', { active: ctaActive }]}
|
|
145
|
+
>
|
|
146
|
+
{cta.label}
|
|
147
|
+
</Button>
|
|
148
|
+
)}
|
|
141
149
|
<button type="button" class="theme-toggle-mobile" id="theme-toggle-mobile" role="switch" aria-checked="false" aria-label="Toggle dark mode">
|
|
142
150
|
<span class="theme-toggle__highlight" aria-hidden="true"></span>
|
|
143
151
|
<svg class="theme-icon theme-icon--sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
|
@@ -152,15 +160,17 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
|
|
|
152
160
|
<svg class="theme-icon theme-icon--sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
|
153
161
|
<svg class="theme-icon theme-icon--moon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
154
162
|
</button>
|
|
155
|
-
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
{cta && (
|
|
164
|
+
<span class="nav-cta-header-wrap">
|
|
165
|
+
<Button
|
|
166
|
+
href={cta.href}
|
|
167
|
+
variant="cta"
|
|
168
|
+
class:list={['nav-cta', 'nav-cta--header', { active: ctaActive }]}
|
|
169
|
+
>
|
|
170
|
+
{cta.label}
|
|
171
|
+
</Button>
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
164
174
|
<button class="nav-toggle" aria-label="Toggle navigation" aria-expanded="false" aria-controls="site-nav">
|
|
165
175
|
<span></span><span></span><span></span>
|
|
166
176
|
</button>
|
|
@@ -357,6 +367,32 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
|
|
|
357
367
|
z-index: 101; /* always above the mobile full-screen nav (z-index: 99) */
|
|
358
368
|
}
|
|
359
369
|
|
|
370
|
+
/* Crowded (#11, set by measuring): the centred menu would collide with the site name or
|
|
371
|
+
the buttons, so it takes its own row under them and wraps if it must. Desktop only;
|
|
372
|
+
below 64em the drawer applies as usual. */
|
|
373
|
+
@media (min-width: 64em) {
|
|
374
|
+
.site-header.is-crowded,
|
|
375
|
+
.site-header.is-crowded.is-compact {
|
|
376
|
+
height: auto;
|
|
377
|
+
flex-wrap: wrap;
|
|
378
|
+
row-gap: 0;
|
|
379
|
+
padding-block: 6px;
|
|
380
|
+
}
|
|
381
|
+
.site-header.is-crowded .site-nav {
|
|
382
|
+
position: static;
|
|
383
|
+
transform: none;
|
|
384
|
+
order: 3;
|
|
385
|
+
flex-basis: 100%;
|
|
386
|
+
justify-content: center;
|
|
387
|
+
flex-wrap: wrap;
|
|
388
|
+
column-gap: 24px;
|
|
389
|
+
row-gap: 0;
|
|
390
|
+
}
|
|
391
|
+
.site-header.is-crowded .site-nav .nav-link {
|
|
392
|
+
padding-block: 8px;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
360
396
|
/* ── Contact CTA ── */
|
|
361
397
|
.nav-cta {
|
|
362
398
|
white-space: nowrap;
|
|
@@ -990,6 +1026,33 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
|
|
|
990
1026
|
window.addEventListener('scroll', onScrollCompact, { passive: true });
|
|
991
1027
|
}
|
|
992
1028
|
|
|
1029
|
+
// Crowding (#11): on desktop the menu is centred on the header. When the site name (or
|
|
1030
|
+
// the right-hand buttons) would run into it, give the menu its own row instead. Decided
|
|
1031
|
+
// by measuring the real layout, so it adapts to any name and any number of links.
|
|
1032
|
+
if (siteHeader) {
|
|
1033
|
+
const desktop = window.matchMedia('(min-width: 64em)');
|
|
1034
|
+
const brand = siteHeader.querySelector<HTMLElement>('.brand');
|
|
1035
|
+
const nav = siteHeader.querySelector<HTMLElement>('.site-nav');
|
|
1036
|
+
const right = siteHeader.querySelector<HTMLElement>('.nav-right');
|
|
1037
|
+
const GAP = 16;
|
|
1038
|
+
const fitCheck = () => {
|
|
1039
|
+
if (!brand || !nav || !right) return;
|
|
1040
|
+
siteHeader.classList.remove('is-crowded');
|
|
1041
|
+
if (!desktop.matches) return;
|
|
1042
|
+
const b = brand.getBoundingClientRect();
|
|
1043
|
+
const n = nav.getBoundingClientRect();
|
|
1044
|
+
const r = right.getBoundingClientRect();
|
|
1045
|
+
if (b.right + GAP > n.left || n.right + GAP > r.left) siteHeader.classList.add('is-crowded');
|
|
1046
|
+
};
|
|
1047
|
+
let queued = false;
|
|
1048
|
+
const queue = () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; fitCheck(); }); } };
|
|
1049
|
+
if (brand) new ResizeObserver(queue).observe(brand);
|
|
1050
|
+
new ResizeObserver(queue).observe(siteHeader);
|
|
1051
|
+
desktop.addEventListener('change', queue);
|
|
1052
|
+
document.fonts?.ready.then(queue);
|
|
1053
|
+
fitCheck();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
993
1056
|
function openMega() {
|
|
994
1057
|
if (!megaNav || !aboutTrigger) return;
|
|
995
1058
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { pageSlugs, uniqueSlug } from '../../lib/slug';
|
|
2
3
|
interface Props {
|
|
3
4
|
text: string;
|
|
4
5
|
level?: number;
|
|
@@ -7,9 +8,11 @@ interface Props {
|
|
|
7
8
|
}
|
|
8
9
|
const { text, level = 2, align, gradient = false } = Astro.props;
|
|
9
10
|
const Tag = `h${level}` as unknown as keyof HTMLElementTagNameMap;
|
|
11
|
+
// A readable, page-unique anchor so any heading can be linked to (#mass-times).
|
|
12
|
+
const id = uniqueSlug(text, pageSlugs(Astro.locals));
|
|
10
13
|
---
|
|
11
14
|
|
|
12
|
-
<Tag class="b-heading" data-align={align} data-gradient={gradient ? 'true' : 'false'}>{text}</Tag>
|
|
15
|
+
<Tag class="b-heading" id={id} data-align={align} data-gradient={gradient ? 'true' : 'false'}>{text}</Tag>
|
|
13
16
|
|
|
14
17
|
<style>
|
|
15
18
|
/* By default a heading INHERITS the page/section content alignment (the
|
|
@@ -22,6 +25,7 @@ const Tag = `h${level}` as unknown as keyof HTMLElementTagNameMap;
|
|
|
22
25
|
margin: 0;
|
|
23
26
|
text-wrap: balance;
|
|
24
27
|
white-space: pre-line; /* a title typed over several lines keeps its breaks */
|
|
28
|
+
scroll-margin-top: 6rem; /* a linked heading stops clear of the sticky header */
|
|
25
29
|
line-height: 1.2;
|
|
26
30
|
text-align: inherit;
|
|
27
31
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Contacts — a compact directory of people or offices. Each card: the role as its title,
|
|
4
|
+
* the name, phone and email as tap targets (email with an optional subject), address and
|
|
5
|
+
* hours. A group can carry one shared note (e.g. office hours) instead of repeating it on
|
|
6
|
+
* every card. For one contact as a branded band use `contactCard`; for one contact's
|
|
7
|
+
* links in a row, `linkRow`.
|
|
8
|
+
*/
|
|
9
|
+
import Heading from '../blocks/Heading.astro';
|
|
10
|
+
import Icon from '../Icon.astro';
|
|
11
|
+
import { inlineMarkdown, textWithBreaks } from '../../lib/inline-markdown';
|
|
12
|
+
import { mailtoHref, telHref } from '../../lib/links';
|
|
13
|
+
|
|
14
|
+
interface Contact { role?: string; name?: string; phone?: string; email?: string; emailSubject?: string; address?: string; hours?: string }
|
|
15
|
+
interface Props {
|
|
16
|
+
title?: string;
|
|
17
|
+
intro?: string;
|
|
18
|
+
groups: Array<{ heading?: string; note?: string; contacts: Contact[] }>;
|
|
19
|
+
}
|
|
20
|
+
const { title, intro, groups = [] } = Astro.props;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<section class="b-contacts">
|
|
24
|
+
{(title || intro) && (
|
|
25
|
+
<div class="b-contacts__head">
|
|
26
|
+
{title && <Heading text={title} level={2} />}
|
|
27
|
+
{intro && <p class="b-contacts__intro" set:html={inlineMarkdown(intro)} />}
|
|
28
|
+
</div>
|
|
29
|
+
)}
|
|
30
|
+
{groups.map((g) => (
|
|
31
|
+
<div class="b-contacts__group">
|
|
32
|
+
{(g.heading || g.note) && (
|
|
33
|
+
<div class="b-contacts__group-head">
|
|
34
|
+
{g.heading && <h3 class="b-contacts__group-title">{g.heading}</h3>}
|
|
35
|
+
{g.note && <p class="b-contacts__note" set:html={inlineMarkdown(g.note)} />}
|
|
36
|
+
</div>
|
|
37
|
+
)}
|
|
38
|
+
<ul class="b-contacts__grid">
|
|
39
|
+
{g.contacts.map((c) => (
|
|
40
|
+
<li class="b-contacts__card">
|
|
41
|
+
<h3 class="b-contacts__role">{c.role ?? c.name}</h3>
|
|
42
|
+
{c.role && c.name && <p class="b-contacts__name">{c.name}</p>}
|
|
43
|
+
<div class="b-contacts__lines">
|
|
44
|
+
{c.phone && <a class="b-contacts__line" href={telHref(c.phone)}><Icon name="phone" size={16} /><span>{c.phone}</span></a>}
|
|
45
|
+
{c.email && <a class="b-contacts__line" href={mailtoHref(c.email, c.emailSubject)}><Icon name="email" size={16} /><span>{c.email}</span></a>}
|
|
46
|
+
{c.address && <p class="b-contacts__line"><Icon name="map-pin" size={16} /><span set:html={textWithBreaks(c.address)} /></p>}
|
|
47
|
+
{c.hours && <p class="b-contacts__line"><Icon name="calendar" size={16} /><span set:html={textWithBreaks(c.hours)} /></p>}
|
|
48
|
+
</div>
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ul>
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</section>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
.b-contacts {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
gap: 2rem;
|
|
61
|
+
width: 100%;
|
|
62
|
+
max-width: 72rem;
|
|
63
|
+
}
|
|
64
|
+
.b-contacts__head,
|
|
65
|
+
.b-contacts__group-head {
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-direction: column;
|
|
68
|
+
gap: 0.4rem;
|
|
69
|
+
max-width: var(--max);
|
|
70
|
+
}
|
|
71
|
+
.b-contacts__intro,
|
|
72
|
+
.b-contacts__note {
|
|
73
|
+
margin: 0;
|
|
74
|
+
color: var(--muted);
|
|
75
|
+
line-height: 1.6;
|
|
76
|
+
}
|
|
77
|
+
.b-contacts__group {
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
gap: 1rem;
|
|
81
|
+
}
|
|
82
|
+
.b-contacts__group-title {
|
|
83
|
+
margin: 0;
|
|
84
|
+
font-size: 1.15rem;
|
|
85
|
+
}
|
|
86
|
+
.b-contacts__grid {
|
|
87
|
+
list-style: none;
|
|
88
|
+
margin: 0;
|
|
89
|
+
padding: 0;
|
|
90
|
+
display: grid;
|
|
91
|
+
gap: 1rem;
|
|
92
|
+
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
|
|
93
|
+
}
|
|
94
|
+
.b-contacts__card {
|
|
95
|
+
display: flex;
|
|
96
|
+
flex-direction: column;
|
|
97
|
+
gap: 0.4rem;
|
|
98
|
+
padding: 1.1rem 1.25rem;
|
|
99
|
+
border: var(--card-border);
|
|
100
|
+
border-radius: var(--radius);
|
|
101
|
+
background: var(--bg-surface);
|
|
102
|
+
}
|
|
103
|
+
.b-contacts__role {
|
|
104
|
+
margin: 0;
|
|
105
|
+
font-size: 1.05rem;
|
|
106
|
+
}
|
|
107
|
+
.b-contacts__name {
|
|
108
|
+
margin: 0;
|
|
109
|
+
font-size: 0.9375rem;
|
|
110
|
+
color: var(--muted);
|
|
111
|
+
}
|
|
112
|
+
.b-contacts__lines {
|
|
113
|
+
display: grid;
|
|
114
|
+
gap: 0.35rem;
|
|
115
|
+
margin-top: 0.35rem;
|
|
116
|
+
}
|
|
117
|
+
.b-contacts__line {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: flex-start;
|
|
120
|
+
gap: 0.5rem;
|
|
121
|
+
margin: 0;
|
|
122
|
+
color: var(--fg);
|
|
123
|
+
text-decoration: none;
|
|
124
|
+
font-size: 0.9375rem;
|
|
125
|
+
overflow-wrap: anywhere;
|
|
126
|
+
}
|
|
127
|
+
.b-contacts__line :global(svg) {
|
|
128
|
+
flex: none;
|
|
129
|
+
margin-top: 0.2em;
|
|
130
|
+
color: var(--gold);
|
|
131
|
+
}
|
|
132
|
+
a.b-contacts__line:hover span {
|
|
133
|
+
text-decoration: underline;
|
|
134
|
+
}
|
|
135
|
+
a.b-contacts__line:focus-visible {
|
|
136
|
+
outline: 2px solid var(--gold);
|
|
137
|
+
outline-offset: 2px;
|
|
138
|
+
}
|
|
139
|
+
</style>
|
|
@@ -1,152 +1,157 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* Document — a policy or legal text as ONE block. Sections are numbered automatically,
|
|
4
|
-
* a contents list links to each (beside the text, sticky, on wide screens), and an
|
|
5
|
-
* optional "last updated" line sits at the top. Section bodies take prose formatting
|
|
6
|
-
* (links, bold, lists); `items` add a list. Print-friendly: the contents list is
|
|
7
|
-
* dropped on paper.
|
|
8
|
-
*/
|
|
9
|
-
import { proseParts } from '../../lib/prose';
|
|
10
|
-
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
11
|
-
import { longDate } from '../../lib/documents';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
interface
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
</
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
.b-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
.b-doc__toc a
|
|
98
|
-
color: var(--
|
|
99
|
-
text-decoration:
|
|
100
|
-
}
|
|
101
|
-
.b-doc__toc
|
|
102
|
-
color: var(--
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
.b-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
.b-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
.b-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
.b-doc__clause ul,
|
|
140
|
-
.b-doc__clause ol {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Document — a policy or legal text as ONE block. Sections are numbered automatically,
|
|
4
|
+
* a contents list links to each (beside the text, sticky, on wide screens), and an
|
|
5
|
+
* optional "last updated" line sits at the top. Section bodies take prose formatting
|
|
6
|
+
* (links, bold, lists); `items` add a list. Print-friendly: the contents list is
|
|
7
|
+
* dropped on paper.
|
|
8
|
+
*/
|
|
9
|
+
import { proseParts } from '../../lib/prose';
|
|
10
|
+
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
11
|
+
import { longDate } from '../../lib/documents';
|
|
12
|
+
import { pageSlugs, uniqueSlug } from '../../lib/slug';
|
|
13
|
+
|
|
14
|
+
interface Section { title: string; body?: string; items?: string[] }
|
|
15
|
+
interface Props {
|
|
16
|
+
intro?: string;
|
|
17
|
+
updated?: string;
|
|
18
|
+
numbered?: boolean;
|
|
19
|
+
toc?: boolean;
|
|
20
|
+
sections: Section[];
|
|
21
|
+
}
|
|
22
|
+
const { intro, updated, numbered = true, toc = true, sections = [] } = Astro.props;
|
|
23
|
+
const updatedLabel = longDate(updated);
|
|
24
|
+
const showToc = toc && sections.length > 1;
|
|
25
|
+
// Readable anchors that survive renumbering (#payment); #clause-N stays on the heading.
|
|
26
|
+
const used = pageSlugs(Astro.locals);
|
|
27
|
+
const ids = sections.map((s) => uniqueSlug(s.title, used));
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<section class="b-doc" data-toc={showToc ? 'true' : undefined}>
|
|
31
|
+
{showToc && (
|
|
32
|
+
<nav class="b-doc__toc" aria-label="Contents">
|
|
33
|
+
<p class="b-doc__toc-title">Contents</p>
|
|
34
|
+
<ol>
|
|
35
|
+
{sections.map((s, i) => (
|
|
36
|
+
<li><a href={`#${ids[i]}`}>{numbered && <span class="b-doc__toc-num">{i + 1}.</span>} {s.title}</a></li>
|
|
37
|
+
))}
|
|
38
|
+
</ol>
|
|
39
|
+
</nav>
|
|
40
|
+
)}
|
|
41
|
+
<div class="b-doc__body">
|
|
42
|
+
{updatedLabel && <p class="b-doc__updated">Last updated {updatedLabel}</p>}
|
|
43
|
+
{intro && <p class="b-doc__intro" set:html={inlineMarkdown(intro)} />}
|
|
44
|
+
{sections.map((s, i) => (
|
|
45
|
+
<section class="b-doc__clause" id={ids[i]}>
|
|
46
|
+
<h2 id={`clause-${i + 1}`}>{numbered && <span class="b-doc__num">{i + 1}.</span>} {s.title}</h2>
|
|
47
|
+
{proseParts(s.body).map((part) =>
|
|
48
|
+
part.kind === 'p' ? <p set:html={part.html} />
|
|
49
|
+
: part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
|
|
50
|
+
: <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
|
|
51
|
+
)}
|
|
52
|
+
{s.items && s.items.length > 0 && <ul>{s.items.map((x) => <li set:html={inlineMarkdown(x)} />)}</ul>}
|
|
53
|
+
</section>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
|
|
58
|
+
<style>
|
|
59
|
+
.b-doc {
|
|
60
|
+
display: grid;
|
|
61
|
+
gap: 2rem;
|
|
62
|
+
width: 100%;
|
|
63
|
+
max-width: 64rem;
|
|
64
|
+
}
|
|
65
|
+
@media (min-width: 64em) {
|
|
66
|
+
.b-doc[data-toc] {
|
|
67
|
+
grid-template-columns: 15rem minmax(0, 1fr);
|
|
68
|
+
align-items: start;
|
|
69
|
+
}
|
|
70
|
+
.b-doc__toc {
|
|
71
|
+
position: sticky;
|
|
72
|
+
top: 6rem;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
.b-doc__toc {
|
|
76
|
+
border: var(--card-border);
|
|
77
|
+
border-radius: var(--radius);
|
|
78
|
+
background: var(--bg-surface);
|
|
79
|
+
padding: 1rem 1.1rem;
|
|
80
|
+
}
|
|
81
|
+
.b-doc__toc-title {
|
|
82
|
+
margin: 0 0 0.5rem;
|
|
83
|
+
font-size: 0.75rem;
|
|
84
|
+
font-weight: 600;
|
|
85
|
+
letter-spacing: 0.08em;
|
|
86
|
+
text-transform: uppercase;
|
|
87
|
+
color: var(--muted);
|
|
88
|
+
}
|
|
89
|
+
.b-doc__toc ol {
|
|
90
|
+
list-style: none;
|
|
91
|
+
margin: 0;
|
|
92
|
+
padding: 0;
|
|
93
|
+
display: grid;
|
|
94
|
+
gap: 0.35rem;
|
|
95
|
+
font-size: 0.9375rem;
|
|
96
|
+
}
|
|
97
|
+
.b-doc__toc a {
|
|
98
|
+
color: var(--fg);
|
|
99
|
+
text-decoration: none;
|
|
100
|
+
}
|
|
101
|
+
.b-doc__toc a:hover {
|
|
102
|
+
color: var(--gold);
|
|
103
|
+
text-decoration: underline;
|
|
104
|
+
}
|
|
105
|
+
.b-doc__toc-num {
|
|
106
|
+
color: var(--muted);
|
|
107
|
+
font-variant-numeric: tabular-nums;
|
|
108
|
+
}
|
|
109
|
+
.b-doc__body {
|
|
110
|
+
max-width: var(--max);
|
|
111
|
+
display: flex;
|
|
112
|
+
flex-direction: column;
|
|
113
|
+
gap: 1.75rem;
|
|
114
|
+
}
|
|
115
|
+
.b-doc__updated {
|
|
116
|
+
margin: 0;
|
|
117
|
+
font-size: 0.875rem;
|
|
118
|
+
color: var(--muted);
|
|
119
|
+
}
|
|
120
|
+
.b-doc__intro {
|
|
121
|
+
margin: 0;
|
|
122
|
+
font-size: 1.125rem;
|
|
123
|
+
line-height: 1.6;
|
|
124
|
+
color: var(--muted);
|
|
125
|
+
}
|
|
126
|
+
.b-doc__clause {
|
|
127
|
+
scroll-margin-top: 6rem;
|
|
128
|
+
}
|
|
129
|
+
.b-doc__clause h2 {
|
|
130
|
+
scroll-margin-top: 6rem;
|
|
131
|
+
margin: 0 0 0.6rem;
|
|
132
|
+
font-size: 1.25rem;
|
|
133
|
+
}
|
|
134
|
+
.b-doc__num {
|
|
135
|
+
color: var(--gold);
|
|
136
|
+
font-variant-numeric: tabular-nums;
|
|
137
|
+
}
|
|
138
|
+
.b-doc__clause p,
|
|
139
|
+
.b-doc__clause ul,
|
|
140
|
+
.b-doc__clause ol {
|
|
141
|
+
margin: 0 0 0.75rem;
|
|
142
|
+
line-height: 1.7;
|
|
143
|
+
}
|
|
144
|
+
.b-doc__clause ul,
|
|
145
|
+
.b-doc__clause ol {
|
|
146
|
+
padding-inline-start: 1.4em;
|
|
147
|
+
}
|
|
148
|
+
.b-doc__clause :global(a) {
|
|
149
|
+
color: var(--accent);
|
|
150
|
+
text-decoration: underline;
|
|
151
|
+
text-underline-offset: 0.15em;
|
|
152
|
+
}
|
|
153
|
+
@media print {
|
|
154
|
+
.b-doc__toc { display: none; }
|
|
155
|
+
.b-doc[data-toc] { grid-template-columns: 1fr; }
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import Heading from '../blocks/Heading.astro';
|
|
9
9
|
import Icon from '../Icon.astro';
|
|
10
10
|
import { documentGroups, type DocumentItem } from '../../lib/documents';
|
|
11
|
-
|
|
11
|
+
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
12
12
|
interface Props {
|
|
13
13
|
title?: string;
|
|
14
14
|
intro?: string;
|
|
@@ -23,7 +23,7 @@ const groups = documentGroups(items, groupByYear);
|
|
|
23
23
|
{(title || intro) && (
|
|
24
24
|
<div class="b-docs__head">
|
|
25
25
|
{title && <Heading text={title} level={2} />}
|
|
26
|
-
{intro && <p class="b-docs__intro"
|
|
26
|
+
{intro && <p class="b-docs__intro" set:html={inlineMarkdown(intro)} />}
|
|
27
27
|
</div>
|
|
28
28
|
)}
|
|
29
29
|
{items.length === 0 ? (
|