ferst-core 0.7.2 → 0.7.5

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.
Files changed (47) hide show
  1. package/bin/ferst-update.mjs +33 -2
  2. package/cms/config.template.yml +214 -206
  3. package/components/BlockRenderer.astro +10 -0
  4. package/components/Pagination.astro +51 -0
  5. package/components/SiteFooter.astro +274 -270
  6. package/components/SiteHeader.astro +1243 -1183
  7. package/components/blocks/Heading.astro +51 -46
  8. package/components/blocks/Prose.astro +10 -2
  9. package/components/blocks/Quote.astro +40 -38
  10. package/components/recipes/Accordion.astro +91 -84
  11. package/components/recipes/Contacts.astro +139 -0
  12. package/components/recipes/Document.astro +157 -0
  13. package/components/recipes/FeatureCards.astro +112 -111
  14. package/components/recipes/Hero.astro +52 -4
  15. package/components/recipes/LinkRow.astro +91 -0
  16. package/components/recipes/MediaCards.astro +213 -212
  17. package/components/recipes/MediaText.astro +92 -0
  18. package/components/recipes/Notice.astro +90 -89
  19. package/components/recipes/Stats.astro +152 -145
  20. package/components/recipes/Table.astro +101 -97
  21. package/components/recipes/Tabs.astro +134 -133
  22. package/components/recipes/Tiles.astro +4 -2
  23. package/components/recipes/Timetable.astro +189 -0
  24. package/components/recipes/Video.astro +177 -157
  25. package/content/blocks.ts +113 -1
  26. package/content/cookies.ts +10 -0
  27. package/content/schemas.ts +167 -161
  28. package/examples/primitives.ts +2 -0
  29. package/examples/recipes.ts +7 -0
  30. package/integration.mjs +83 -64
  31. package/layouts/Base.astro +483 -475
  32. package/lib/documents.ts +68 -62
  33. package/lib/embed-consent.ts +78 -73
  34. package/lib/inline-markdown.ts +6 -0
  35. package/lib/links.ts +22 -0
  36. package/lib/paginate.ts +23 -0
  37. package/lib/posts.ts +20 -2
  38. package/lib/siteSettings.ts +61 -60
  39. package/lib/slug.ts +32 -0
  40. package/lib/table.ts +10 -0
  41. package/lib/timetable.ts +27 -0
  42. package/lib/video.ts +101 -75
  43. package/package.json +1 -1
  44. package/routes/cookie-policy.astro +27 -0
  45. package/routes/posts/index.astro +36 -31
  46. package/routes/posts/page/[page].astro +36 -0
  47. package/routes/posts/tag/[...tag].astro +41 -37
@@ -1,475 +1,483 @@
1
- ---
2
- /**
3
- * Shared page chrome. After the refactor this file is a thin wrapper:
4
- * <head>, the early-paint theme script, and three composed components —
5
- * <SiteHeader />, <SiteFooter />, <CookiePreferences />. Everything brand-specific
6
- * (brand name, navbar links, footer links, the Facebook URL, the
7
- * contact email) is read from the `siteSettings` collection so a single
8
- * JSON edit propagates to every page that uses this layout.
9
- *
10
- * No general "essential cookies" notice: this site sets no cookies of its
11
- * own (see /cookie-policy/), so there's nothing to ask consent for — a
12
- * one-button "Got it" banner would be pure FYI, not a real decision, and
13
- * it duplicated the one place there IS a real decision (the Google
14
- * Calendar embed's own consent gate, /calendar/). <CookiePreferences />
15
- * — opened from the footer — is that one real choice.
16
- *
17
- * Pages opt in to the secondary-logo footer block with `<Base showSecondaryLogo>`
18
- * instead of importing `<SecondaryLogo />` themselves — fewer imports,
19
- * fewer placement decisions, same visual outcome.
20
- */
21
- import '../styles/theme.css';
22
- import '../styles/fonts.css';
23
-
24
- import SiteHeader from '../components/SiteHeader.astro';
25
- import SiteFooter from '../components/SiteFooter.astro';
26
- import CookiePreferences from '../components/CookiePreferences.astro';
27
- import CookieBanner from '../components/CookieBanner.astro';
28
- import StickyCallout from '../components/StickyCallout.astro';
29
- import SecondaryLogo from '../components/SecondaryLogo.astro';
30
- import { loadSiteSettings } from '../lib/siteSettings';
31
- import { getCalloutSettings } from '../lib/callout';
32
- import { buildThemeStyle } from '../lib/themeTokens';
33
-
34
- interface Props {
35
- title: string;
36
- description?: string;
37
- heroImage?: string;
38
- headerClass?: string;
39
- mainClass?: string;
40
- footerClass?: string;
41
- /** Render the secondary logo centred at the bottom of the page content. */
42
- showSecondaryLogo?: boolean;
43
- /** Template-level content alignment — cascades to text-bearing blocks via
44
- * `data-content-align` on <main>. `start` (default) keeps long-form readable. */
45
- align?: 'start' | 'center' | 'end';
46
- }
47
-
48
- const {
49
- title,
50
- description,
51
- heroImage,
52
- headerClass,
53
- mainClass,
54
- footerClass,
55
- showSecondaryLogo = false,
56
- align = 'start',
57
- } = Astro.props;
58
-
59
- const settings = await loadSiteSettings();
60
- const { identity, contact, navigation, theme } = settings;
61
-
62
- // Site-level corner launcher (chrome) — off unless a client configures it.
63
- const callout = await getCalloutSettings();
64
-
65
- const path = Astro.url.pathname;
66
-
67
- // The brand → an inline <style> that layers over the core's neutral defaults
68
- // (styles/theme.css). `buildThemeStyle` derives ~40 working tokens from the
69
- // three source colours (light color-mix + dark OKLCH) and emits the font/knob
70
- // overrides too; selectors are doubled (`:root:root`) so they win by
71
- // specificity regardless of stylesheet order. `themeField` is the site-wide
72
- // input treatment, applied as a `data-field` attribute on <html>.
73
- const { css: themeCss, field: themeField } = buildThemeStyle(theme);
74
-
75
- // Logos are optional. When a client hasn't configured one, SiteHeader renders a
76
- // text wordmark (the site name) rather than pointing <img> at a baked-in path
77
- // that may not exist — a broken logo by default was the old bug. Dark falls back
78
- // to the light variant when only one is supplied.
79
- const primaryLogoLight = settings.primaryLogoLight;
80
- const primaryLogoDark = settings.primaryLogoDark ?? settings.primaryLogoLight;
81
- const secondaryLogoLight = settings.secondaryLogoLight;
82
- const secondaryLogoDark = settings.secondaryLogoDark;
83
- const secondaryLogoLink = settings.secondaryLogoLink;
84
- const brandAlt = [identity.shortName, identity.location].filter(Boolean).join(', ');
85
- const metaDescription = description ?? identity.description;
86
- ---
87
-
88
- <!doctype html>
89
- <html lang="en-GB" data-field={themeField}>
90
- <head>
91
- <meta charset="utf-8" />
92
- <meta name="viewport" content="width=device-width, initial-scale=1" />
93
- <slot name="head" />
94
- <meta name="description" content={metaDescription} />
95
- <title>{title} — {identity.siteTitle}</title>
96
- {themeCss && <style is:inline set:html={themeCss} />}
97
- <!-- Self-hosted (see src/styles/fonts.css) — h1 (Poppins 700) and body copy
98
- (Inter 400) are what virtually every page needs at first paint. -->
99
- <link rel="preload" as="font" type="font/woff2" href="/fonts/poppins-latin-700-normal.woff2" crossorigin />
100
- <link rel="preload" as="font" type="font/woff2" href="/fonts/inter-latin-400-normal.woff2" crossorigin />
101
- {heroImage && <link rel="preload" as="image" href={heroImage} />}
102
- <!--
103
- Early-paint theme detection: localStorage override → system preference.
104
- Must be inline + run before <body> paints, otherwise dark-mode users see
105
- a light-mode flash. Also preloads the correct brand logo for that theme.
106
- -->
107
- <script is:inline define:vars={{ primaryLogoDark, primaryLogoLight }}>
108
- (function () {
109
- var source = localStorage.getItem('theme-source');
110
- var saved = localStorage.getItem('theme');
111
- var t;
112
- if (source === 'user' && (saved === 'light' || saved === 'dark')) {
113
- t = saved;
114
- } else {
115
- t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
116
- }
117
- document.documentElement.dataset.theme = t;
118
- var logoHref = t === 'dark' ? primaryLogoDark : primaryLogoLight;
119
- if (logoHref) {
120
- var link = document.createElement('link');
121
- link.rel = 'preload';
122
- link.as = 'image';
123
- link.href = logoHref;
124
- document.head.appendChild(link);
125
- }
126
- })();
127
- </script>
128
- <script>
129
- // Pages that opt into Astro's <ClientRouter /> (currently /posts/)
130
- // swap <html>'s attributes to match the newly-fetched document by
131
- // default. Since dark/light mode is only ever set client-side above
132
- // (never baked into the server-rendered HTML, which doesn't know the
133
- // visitor's localStorage), that wipe resets data-theme on every
134
- // transition — the whole page flips back to light mode after every
135
- // filter click. Carry the current theme over before the swap applies.
136
- import type { TransitionBeforeSwapEvent } from 'astro:transitions/client';
137
-
138
- document.addEventListener('astro:before-swap', ((event: TransitionBeforeSwapEvent) => {
139
- const theme = document.documentElement.dataset.theme;
140
- if (theme) {
141
- event.newDocument.documentElement.dataset.theme = theme;
142
- }
143
- }) as EventListener);
144
- </script>
145
- <script>
146
- // Open external links in a new tab — site-wide and future-proof. Any <a>
147
- // pointing to a DIFFERENT origin over http(s) gets target=_blank +
148
- // rel="noopener noreferrer"; mailto:/tel:/anchors and same-site links are
149
- // left untouched. Runs on load and after every view-transition navigation,
150
- // so it covers every component AND markdown-authored links (e.g. FAQ
151
- // answers, prose) without each one having to opt in. Idempotent, so
152
- // components that already set target/rel are unaffected.
153
- function ferstMarkExternalLinks() {
154
- const host = location.host;
155
- document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
156
- let url: URL;
157
- try {
158
- url = new URL(a.href, location.href);
159
- } catch {
160
- return;
161
- }
162
- if (url.protocol !== 'http:' && url.protocol !== 'https:') return; // skip mailto/tel/etc.
163
- if (url.host === host) return; // same-site
164
- if (!a.target) a.target = '_blank';
165
- const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
166
- rel.add('noopener');
167
- rel.add('noreferrer');
168
- a.setAttribute('rel', Array.from(rel).join(' '));
169
- });
170
- }
171
- ferstMarkExternalLinks();
172
- document.addEventListener('astro:page-load', ferstMarkExternalLinks);
173
- </script>
174
- <script>
175
- // Live theme preview: only when this page is FRAMED and either loaded with
176
- // ?preview=1 OR already in a preview session (so the draft survives clicking
177
- // links inside the frame, which drop the query). Listens for draft theme
178
- // settings over postMessage and re-themes live. Dynamically imported so the
179
- // code never ships to normal visitors; never runs unframed.
180
- try {
181
- var inPreview =
182
- new URLSearchParams(location.search).has('preview') ||
183
- sessionStorage.getItem('ferst-preview-on') === '1';
184
- if (window.parent !== window && inPreview) {
185
- import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
186
- }
187
- } catch (_) {
188
- /* sessionStorage blocked — fall back to the ?preview query only */
189
- if (window.parent !== window && new URLSearchParams(location.search).has('preview')) {
190
- import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
191
- }
192
- }
193
- </script>
194
- </head>
195
- <body>
196
- <SiteHeader
197
- path={path}
198
- headerClass={headerClass}
199
- primaryLogoLight={primaryLogoLight}
200
- primaryLogoDark={primaryLogoDark}
201
- brandAlt={brandAlt}
202
- primaryNav={navigation.primary}
203
- dropdown={navigation.dropdown}
204
- />
205
-
206
- <main class:list={['site-main', mainClass]} data-content-align={align}>
207
- <slot name="hero" />
208
- <slot />
209
- {showSecondaryLogo && (secondaryLogoLight || secondaryLogoDark) && (
210
- <SecondaryLogo logoLight={secondaryLogoLight} logoDark={secondaryLogoDark} link={secondaryLogoLink} />
211
- )}
212
- </main>
213
-
214
- <SiteFooter
215
- facebookUrl={contact.facebookUrl}
216
- footerLinks={navigation.footer}
217
- footerClass={footerClass}
218
- shortName={identity.shortName}
219
- tagline={identity.description}
220
- />
221
-
222
- <CookiePreferences />
223
- <CookieBanner />
224
-
225
- <StickyCallout {...callout} />
226
-
227
- <style is:global>
228
- /* ── Global resets & typography ─────────────────────────────────────
229
- Anything that affects every page's body / typography / article
230
- content lives here. Header, footer, and cookie banner each own
231
- their own <style is:global> in their component file. Theme tokens
232
- (`:root`, `[data-theme=dark]`) come from styles/theme.css and are
233
- overridable per-client via the themeSettings collection. Every
234
- colour below reads a token, so light/dark and any client palette
235
- all flow from the same variables — no per-mode hex overrides here.
236
- */
237
- * { box-sizing: border-box; }
238
- html, body { margin: 0; padding: 0; }
239
- html {
240
- height: 100%;
241
- overflow-x: clip;
242
- overscroll-behavior-x: none;
243
- }
244
- body {
245
- font-family: var(--font-body);
246
- color: var(--fg);
247
- background: var(--bg);
248
- line-height: 1.6;
249
- min-height: 100%;
250
- display: flex;
251
- flex-direction: column;
252
- overflow-x: clip;
253
- overscroll-behavior-x: none;
254
- }
255
- h1, h2, h3, h4 {
256
- font-family: var(--font-display);
257
- }
258
- h1 {
259
- font-family: var(--font-heading);
260
- color: var(--fg);
261
- font-size: clamp(2rem, 0.95rem + 4.5vw, 3.35rem);
262
- font-weight: 700;
263
- line-height: 1.34;
264
- letter-spacing: -0.01em;
265
- margin: 0;
266
- }
267
- h2 {
268
- font-family: var(--font-display);
269
- color: var(--fg);
270
- font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
271
- line-height: 1.3;
272
- margin: 0;
273
- }
274
- h3 {
275
- font-family: var(--font-heading);
276
- color: var(--fg);
277
- font-size: clamp(1.375rem, 1.2rem + 0.55vw, 1.625rem);
278
- font-weight: 600;
279
- line-height: 1.35;
280
- margin: 0;
281
- }
282
- h4 {
283
- font-family: var(--font-heading);
284
- color: var(--accent);
285
- font-size: clamp(0.6875rem, 0.9vw, 0.75rem);
286
- font-weight: 500;
287
- letter-spacing: 0.2em;
288
- text-transform: uppercase;
289
- margin: 0;
290
- }
291
-
292
- p, li {
293
- font-family: var(--font-body);
294
- color: var(--muted);
295
- font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
296
- font-weight: 400;
297
- line-height: 1.65;
298
- margin: 0;
299
- }
300
-
301
- time {
302
- color: var(--muted);
303
- font-size: clamp(0.7rem, 0.62rem + 0.25vw, 0.8rem);
304
- letter-spacing: 0.04em;
305
- }
306
-
307
- /* ── Typography utility classes (mobile-first, fluid via clamp).
308
- Apply explicitly to elements: .t-h1/.t-h2/.t-h3 for headings,
309
- .t-body for body copy, .t-lead for intro paragraphs, .t-meta for small/meta text.
310
- Mobile baseline matches iOS Human Interface Guidelines (Dynamic Type, "Large").
311
- Large Title 34 / Title1 28 / Title2 22 / Title3 ~22px mobile → max 26px / Body 17 / Footnote 13. */
312
- .t-h1 {
313
- font-size: clamp(2.125rem, 1.6rem + 1.75vw, 3rem);
314
- line-height: 1.2;
315
- }
316
- .t-h2 {
317
- font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
318
- line-height: 1.3;
319
- margin: 0;
320
- }
321
- .t-h3 {
322
- font-size: var(--type-h3-size);
323
- line-height: 1.35;
324
- margin: 0;
325
- }
326
- .t-body {
327
- font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
328
- line-height: 1.65;
329
- }
330
- .t-lead {
331
- font-size: clamp(1.25rem, 1.1rem + 0.5vw, 1.5rem);
332
- line-height: 1.7;
333
- }
334
- .t-meta {
335
- font-size: clamp(0.8125rem, 0.76rem + 0.18vw, 0.9375rem);
336
- line-height: 1.5;
337
- }
338
-
339
- /* Reveal-on-scroll utility — paired with the `armReveal()` helper in
340
- src/utils/scrollReveal.ts. An element only gets hidden once JS adds
341
- .js-reveal, so it stays fully visible with no JS or under
342
- prefers-reduced-motion. Global (not page-scoped) since it's shared
343
- by the homepage and the /posts/ listing. */
344
- .js-reveal {
345
- opacity: 0;
346
- transform: translateY(16px);
347
- transition: opacity 0.6s ease, transform 0.6s ease;
348
- }
349
- .js-reveal.is-visible {
350
- opacity: 1;
351
- transform: none;
352
- }
353
- /* Optimal reading measure for long-form article content. Below this
354
- breakpoint the viewport itself is already narrower than 65ch, so
355
- the cap only needs to do work on wider (tablet/desktop) screens. */
356
- article :where(p, ul, ol, blockquote, h2, h3, h4) {
357
- max-width: 65ch;
358
- }
359
- @media (max-width: 47.9375em) {
360
- article :where(p, ul, ol, blockquote, h2, h3, h4) {
361
- max-width: none;
362
- }
363
- }
364
- article :where(img, picture, video, table, figure, iframe) {
365
- max-width: 100%;
366
- }
367
- /* Prose images — editor/markdown images dropped into long-form body content
368
- get a centred block treatment (rounded, soft shadow). Scoped to the real
369
- prose containers (post body, Prose block) so it never leaks onto STRUCTURAL
370
- component images — cards, banners, media headers — which own their own
371
- framing and crop absolutely. (A leaked `margin` pushes an inset image down
372
- inside its overflow-hidden box, exposing a strip above it — the "empty
373
- space above the image" a bare `article img` rule caused.) */
374
- .b-post__body img,
375
- .b-prose img {
376
- display: block;
377
- margin: 1.5rem auto;
378
- border-radius: 8px;
379
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
380
- }
381
- @media (min-width: 44em) {
382
- .b-post__body img,
383
- .b-prose img {
384
- border-radius: 12px;
385
- }
386
- }
387
- a {
388
- font-family: var(--font-body);
389
- font-size: clamp(0.875rem, 0.82rem + 0.25vw, 1rem);
390
- font-weight: 600;
391
- color: var(--accent);
392
- text-decoration: none;
393
- transition: color 0.2s ease;
394
- }
395
- h1 a, h2 a, h3 a, h4 a {
396
- font-size: inherit;
397
- font-family: inherit;
398
- font-weight: inherit;
399
- line-height: inherit;
400
- }
401
-
402
- /* ── Fluid media: never overflow container ── */
403
- /* The logo is exempt: it carries a fixed height in SiteHeader that `height:
404
- auto` would collapse. Card/media images set their own size at higher
405
- specificity, so this only ever backstops loose images. */
406
- img:not(.primary-logo), picture, video {
407
- max-width: 100%;
408
- height: auto;
409
- }
410
-
411
- .site-main {
412
- flex: 1;
413
- min-width: 0;
414
- width: min(100%, 72rem);
415
- max-width: 72rem;
416
- margin-inline: auto;
417
- padding-inline: clamp(1rem, 3vw, 2.5rem);
418
- }
419
- .site-main {
420
- display: flex;
421
- flex-direction: column;
422
- gap: clamp(2rem, 4vw, 3rem);
423
- padding-block: clamp(3rem, 6vw, 5rem);
424
- }
425
- .site-main > * {
426
- margin-block: 0;
427
- }
428
-
429
- /* ── Content alignment cascade ──────────────────────────────────────
430
- A page sets its default alignment via the `align` prop (→
431
- data-content-align on <main>); a `section` block can override it for
432
- its own subtree. Text-bearing blocks inherit `text-align` from here, so
433
- a whole template stays on one alignment axis instead of each recipe
434
- hard-coding its own. A few blocks adjust when centred so the result
435
- reads as deliberately centred, not centred-inside-a-left-column.
436
- `start` (left) is the readable default for long-form; a heading can
437
- still override per-block via its own `align`. */
438
- [data-content-align='start'] { text-align: start; }
439
- [data-content-align='center'] { text-align: center; }
440
- [data-content-align='end'] { text-align: end; }
441
- /* Each content block owns how it ADJUSTS when centred/ended (re-centring
442
- its reading column, dropping a left rule, etc.) in its own component,
443
- so the rule out-specifies the block's own scoped styles. Here we only
444
- set the inherited text-align axis. */
445
-
446
- /* ── Secondary logo block: centered ── */
447
- .secondary-logo {
448
- display: flex;
449
- flex-direction: column;
450
- align-items: center;
451
- justify-content: center;
452
- width: 100%;
453
- box-sizing: border-box;
454
- }
455
- .secondary-logo__img {
456
- display: block;
457
- max-width: min(150px, 100%);
458
- height: auto;
459
- }
460
- html:not([data-theme='dark']) .secondary-logo__img {
461
- opacity: 0.85;
462
- mix-blend-mode: multiply;
463
- }
464
- html[data-theme='dark'] .secondary-logo__img {
465
- mix-blend-mode: normal;
466
- opacity: 1;
467
- filter: var(--secondary-logo-filter);
468
- }
469
-
470
- @media (max-width: 22.5em) {
471
- .site-main { padding-inline: 1rem; }
472
- }
473
- </style>
474
- </body>
475
- </html>
1
+ ---
2
+ /**
3
+ * Shared page chrome. After the refactor this file is a thin wrapper:
4
+ * <head>, the early-paint theme script, and three composed components —
5
+ * <SiteHeader />, <SiteFooter />, <CookiePreferences />. Everything brand-specific
6
+ * (brand name, navbar links, footer links, the Facebook URL, the
7
+ * contact email) is read from the `siteSettings` collection so a single
8
+ * JSON edit propagates to every page that uses this layout.
9
+ *
10
+ * No general "essential cookies" notice: this site sets no cookies of its
11
+ * own (see /cookie-policy/), so there's nothing to ask consent for — a
12
+ * one-button "Got it" banner would be pure FYI, not a real decision, and
13
+ * it duplicated the one place there IS a real decision (the Google
14
+ * Calendar embed's own consent gate, /calendar/). <CookiePreferences />
15
+ * — opened from the footer — is that one real choice.
16
+ *
17
+ * Pages opt in to the secondary-logo footer block with `<Base showSecondaryLogo>`
18
+ * instead of importing `<SecondaryLogo />` themselves — fewer imports,
19
+ * fewer placement decisions, same visual outcome.
20
+ */
21
+ import '../styles/theme.css';
22
+ import '../styles/fonts.css';
23
+
24
+ import SiteHeader from '../components/SiteHeader.astro';
25
+ import SiteFooter from '../components/SiteFooter.astro';
26
+ import CookiePreferences from '../components/CookiePreferences.astro';
27
+ import CookieBanner from '../components/CookieBanner.astro';
28
+ import StickyCallout from '../components/StickyCallout.astro';
29
+ import SecondaryLogo from '../components/SecondaryLogo.astro';
30
+ import { loadSiteSettings } from '../lib/siteSettings';
31
+ import { getCalloutSettings } from '../lib/callout';
32
+ import { buildThemeStyle } from '../lib/themeTokens';
33
+
34
+ interface Props {
35
+ title: string;
36
+ description?: string;
37
+ heroImage?: string;
38
+ headerClass?: string;
39
+ mainClass?: string;
40
+ footerClass?: string;
41
+ /** Render the secondary logo centred at the bottom of the page content. */
42
+ showSecondaryLogo?: boolean;
43
+ /** Template-level content alignment — cascades to text-bearing blocks via
44
+ * `data-content-align` on <main>. `start` (default) keeps long-form readable. */
45
+ align?: 'start' | 'center' | 'end';
46
+ }
47
+
48
+ const {
49
+ title,
50
+ description,
51
+ heroImage,
52
+ headerClass,
53
+ mainClass,
54
+ footerClass,
55
+ showSecondaryLogo = false,
56
+ align = 'start',
57
+ } = Astro.props;
58
+
59
+ const settings = await loadSiteSettings();
60
+ const { identity, contact, navigation, theme } = settings;
61
+
62
+ // Site-level corner launcher (chrome) — off unless a client configures it.
63
+ const callout = await getCalloutSettings();
64
+
65
+ const path = Astro.url.pathname;
66
+
67
+ // The brand → an inline <style> that layers over the core's neutral defaults
68
+ // (styles/theme.css). `buildThemeStyle` derives ~40 working tokens from the
69
+ // three source colours (light color-mix + dark OKLCH) and emits the font/knob
70
+ // overrides too; selectors are doubled (`:root:root`) so they win by
71
+ // specificity regardless of stylesheet order. `themeField` is the site-wide
72
+ // input treatment, applied as a `data-field` attribute on <html>.
73
+ const { css: themeCss, field: themeField } = buildThemeStyle(theme);
74
+
75
+ // Logos are optional. When a client hasn't configured one, SiteHeader renders a
76
+ // text wordmark (the site name) rather than pointing <img> at a baked-in path
77
+ // that may not exist — a broken logo by default was the old bug. Dark falls back
78
+ // to the light variant when only one is supplied.
79
+ const primaryLogoLight = settings.primaryLogoLight;
80
+ const primaryLogoDark = settings.primaryLogoDark ?? settings.primaryLogoLight;
81
+ const secondaryLogoLight = settings.secondaryLogoLight;
82
+ const secondaryLogoDark = settings.secondaryLogoDark;
83
+ const secondaryLogoLink = settings.secondaryLogoLink;
84
+ const brandAlt = [identity.shortName, identity.location].filter(Boolean).join(', ');
85
+ const metaDescription = description ?? identity.description;
86
+ ---
87
+
88
+ <!doctype html>
89
+ <html lang="en-GB" data-field={themeField}>
90
+ <head>
91
+ <meta charset="utf-8" />
92
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
93
+ <slot name="head" />
94
+ <meta name="description" content={metaDescription} />
95
+ <title>{title} — {identity.siteTitle}</title>
96
+ {themeCss && <style is:inline set:html={themeCss} />}
97
+ <!-- Self-hosted (see src/styles/fonts.css) — h1 (Poppins 700) and body copy
98
+ (Inter 400) are what virtually every page needs at first paint. -->
99
+ <link rel="preload" as="font" type="font/woff2" href="/fonts/poppins-latin-700-normal.woff2" crossorigin />
100
+ <link rel="preload" as="font" type="font/woff2" href="/fonts/inter-latin-400-normal.woff2" crossorigin />
101
+ {heroImage && <link rel="preload" as="image" href={heroImage} />}
102
+ <!--
103
+ Early-paint theme detection: localStorage override → system preference.
104
+ Must be inline + run before <body> paints, otherwise dark-mode users see
105
+ a light-mode flash. Also preloads the correct brand logo for that theme.
106
+ -->
107
+ <script is:inline define:vars={{ primaryLogoDark, primaryLogoLight }}>
108
+ (function () {
109
+ var source = localStorage.getItem('theme-source');
110
+ var saved = localStorage.getItem('theme');
111
+ var t;
112
+ if (source === 'user' && (saved === 'light' || saved === 'dark')) {
113
+ t = saved;
114
+ } else {
115
+ t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
116
+ }
117
+ document.documentElement.dataset.theme = t;
118
+ var logoHref = t === 'dark' ? primaryLogoDark : primaryLogoLight;
119
+ if (logoHref) {
120
+ var link = document.createElement('link');
121
+ link.rel = 'preload';
122
+ link.as = 'image';
123
+ link.href = logoHref;
124
+ document.head.appendChild(link);
125
+ }
126
+ })();
127
+ </script>
128
+ <script>
129
+ // Pages that opt into Astro's <ClientRouter /> (currently /posts/)
130
+ // swap <html>'s attributes to match the newly-fetched document by
131
+ // default. Since dark/light mode is only ever set client-side above
132
+ // (never baked into the server-rendered HTML, which doesn't know the
133
+ // visitor's localStorage), that wipe resets data-theme on every
134
+ // transition — the whole page flips back to light mode after every
135
+ // filter click. Carry the current theme over before the swap applies.
136
+ import type { TransitionBeforeSwapEvent } from 'astro:transitions/client';
137
+
138
+ document.addEventListener('astro:before-swap', ((event: TransitionBeforeSwapEvent) => {
139
+ const theme = document.documentElement.dataset.theme;
140
+ if (theme) {
141
+ event.newDocument.documentElement.dataset.theme = theme;
142
+ }
143
+ }) as EventListener);
144
+ </script>
145
+ <script>
146
+ // Open external links in a new tab — site-wide and future-proof. Any <a>
147
+ // pointing to a DIFFERENT origin over http(s) gets target=_blank +
148
+ // rel="noopener noreferrer"; mailto:/tel:/anchors and same-site links are
149
+ // left untouched. Runs on load and after every view-transition navigation,
150
+ // so it covers every component AND markdown-authored links (e.g. FAQ
151
+ // answers, prose) without each one having to opt in. Idempotent, so
152
+ // components that already set target/rel are unaffected.
153
+ function ferstMarkExternalLinks() {
154
+ const host = location.host;
155
+ document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
156
+ let url: URL;
157
+ try {
158
+ url = new URL(a.href, location.href);
159
+ } catch {
160
+ return;
161
+ }
162
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return; // skip mailto/tel/etc.
163
+ if (url.host === host) return; // same-site
164
+ if (!a.target) a.target = '_blank';
165
+ const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
166
+ rel.add('noopener');
167
+ rel.add('noreferrer');
168
+ a.setAttribute('rel', Array.from(rel).join(' '));
169
+ });
170
+ }
171
+ ferstMarkExternalLinks();
172
+ document.addEventListener('astro:page-load', ferstMarkExternalLinks);
173
+ </script>
174
+ <script>
175
+ // Live theme preview: only when this page is FRAMED and either loaded with
176
+ // ?preview=1 OR already in a preview session (so the draft survives clicking
177
+ // links inside the frame, which drop the query). Listens for draft theme
178
+ // settings over postMessage and re-themes live. Dynamically imported so the
179
+ // code never ships to normal visitors; never runs unframed.
180
+ try {
181
+ var inPreview =
182
+ new URLSearchParams(location.search).has('preview') ||
183
+ sessionStorage.getItem('ferst-preview-on') === '1';
184
+ if (window.parent !== window && inPreview) {
185
+ import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
186
+ }
187
+ } catch (_) {
188
+ /* sessionStorage blocked — fall back to the ?preview query only */
189
+ if (window.parent !== window && new URLSearchParams(location.search).has('preview')) {
190
+ import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
191
+ }
192
+ }
193
+ </script>
194
+ </head>
195
+ <body>
196
+ <SiteHeader
197
+ path={path}
198
+ headerClass={headerClass}
199
+ primaryLogoLight={primaryLogoLight}
200
+ primaryLogoDark={primaryLogoDark}
201
+ brandAlt={brandAlt}
202
+ primaryNav={navigation.primary}
203
+ dropdown={navigation.dropdown}
204
+ cta={navigation.cta}
205
+ />
206
+
207
+ <main class:list={['site-main', mainClass]} data-content-align={align}>
208
+ <slot name="hero" />
209
+ <slot />
210
+ {showSecondaryLogo && (secondaryLogoLight || secondaryLogoDark) && (
211
+ <SecondaryLogo logoLight={secondaryLogoLight} logoDark={secondaryLogoDark} link={secondaryLogoLink} />
212
+ )}
213
+ </main>
214
+
215
+ <SiteFooter
216
+ facebookUrl={contact.facebookUrl}
217
+ footerLinks={navigation.footer}
218
+ cta={navigation.cta}
219
+ footerClass={footerClass}
220
+ shortName={identity.shortName}
221
+ tagline={identity.description}
222
+ />
223
+
224
+ <CookiePreferences />
225
+ <CookieBanner />
226
+
227
+ <StickyCallout {...callout} />
228
+
229
+ <style is:global>
230
+ /* ── Global resets & typography ─────────────────────────────────────
231
+ Anything that affects every page's body / typography / article
232
+ content lives here. Header, footer, and cookie banner each own
233
+ their own <style is:global> in their component file. Theme tokens
234
+ (`:root`, `[data-theme=dark]`) come from styles/theme.css and are
235
+ overridable per-client via the themeSettings collection. Every
236
+ colour below reads a token, so light/dark and any client palette
237
+ all flow from the same variables — no per-mode hex overrides here.
238
+ */
239
+ * { box-sizing: border-box; }
240
+ html, body { margin: 0; padding: 0; }
241
+ html {
242
+ height: 100%;
243
+ overflow-x: clip;
244
+ overscroll-behavior-x: none;
245
+ }
246
+ body {
247
+ font-family: var(--font-body);
248
+ color: var(--fg);
249
+ background: var(--bg);
250
+ line-height: 1.6;
251
+ min-height: 100%;
252
+ display: flex;
253
+ flex-direction: column;
254
+ overflow-x: clip;
255
+ overscroll-behavior-x: none;
256
+ }
257
+ h1, h2, h3, h4 {
258
+ font-family: var(--font-display);
259
+ }
260
+ h1 {
261
+ font-family: var(--font-heading);
262
+ color: var(--fg);
263
+ font-size: clamp(2rem, 0.95rem + 4.5vw, 3.35rem);
264
+ font-weight: 700;
265
+ line-height: 1.34;
266
+ letter-spacing: -0.01em;
267
+ margin: 0;
268
+ }
269
+ h2 {
270
+ font-family: var(--font-display);
271
+ color: var(--fg);
272
+ font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
273
+ line-height: 1.3;
274
+ margin: 0;
275
+ }
276
+ h3 {
277
+ font-family: var(--font-heading);
278
+ color: var(--fg);
279
+ font-size: clamp(1.375rem, 1.2rem + 0.55vw, 1.625rem);
280
+ font-weight: 600;
281
+ line-height: 1.35;
282
+ margin: 0;
283
+ }
284
+ h4 {
285
+ font-family: var(--font-heading);
286
+ color: var(--accent);
287
+ font-size: clamp(0.6875rem, 0.9vw, 0.75rem);
288
+ font-weight: 500;
289
+ letter-spacing: 0.2em;
290
+ text-transform: uppercase;
291
+ margin: 0;
292
+ }
293
+
294
+ p, li {
295
+ font-family: var(--font-body);
296
+ color: var(--muted);
297
+ font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
298
+ font-weight: 400;
299
+ line-height: 1.65;
300
+ margin: 0;
301
+ }
302
+
303
+ time {
304
+ color: var(--muted);
305
+ font-size: clamp(0.7rem, 0.62rem + 0.25vw, 0.8rem);
306
+ letter-spacing: 0.04em;
307
+ }
308
+
309
+ /* ── Typography utility classes (mobile-first, fluid via clamp).
310
+ Apply explicitly to elements: .t-h1/.t-h2/.t-h3 for headings,
311
+ .t-body for body copy, .t-lead for intro paragraphs, .t-meta for small/meta text.
312
+ Mobile baseline matches iOS Human Interface Guidelines (Dynamic Type, "Large").
313
+ Large Title 34 / Title1 28 / Title2 22 / Title3 ~22px mobile → max 26px / Body 17 / Footnote 13. */
314
+ .t-h1 {
315
+ font-size: clamp(2.125rem, 1.6rem + 1.75vw, 3rem);
316
+ line-height: 1.2;
317
+ }
318
+ .t-h2 {
319
+ font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
320
+ line-height: 1.3;
321
+ margin: 0;
322
+ }
323
+ .t-h3 {
324
+ font-size: var(--type-h3-size);
325
+ line-height: 1.35;
326
+ margin: 0;
327
+ }
328
+ .t-body {
329
+ font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
330
+ line-height: 1.65;
331
+ }
332
+ .t-lead {
333
+ font-size: clamp(1.25rem, 1.1rem + 0.5vw, 1.5rem);
334
+ line-height: 1.7;
335
+ }
336
+ .t-meta {
337
+ font-size: clamp(0.8125rem, 0.76rem + 0.18vw, 0.9375rem);
338
+ line-height: 1.5;
339
+ }
340
+
341
+ /* Reveal-on-scroll utility — paired with the `armReveal()` helper in
342
+ src/utils/scrollReveal.ts. An element only gets hidden once JS adds
343
+ .js-reveal, so it stays fully visible with no JS or under
344
+ prefers-reduced-motion. Global (not page-scoped) since it's shared
345
+ by the homepage and the /posts/ listing. */
346
+ .js-reveal {
347
+ opacity: 0;
348
+ transform: translateY(16px);
349
+ transition: opacity 0.6s ease, transform 0.6s ease;
350
+ }
351
+ .js-reveal.is-visible {
352
+ opacity: 1;
353
+ transform: none;
354
+ }
355
+ /* Optimal reading measure for long-form article content. Below this
356
+ breakpoint the viewport itself is already narrower than 65ch, so
357
+ the cap only needs to do work on wider (tablet/desktop) screens. */
358
+ article :where(p, ul, ol, blockquote, h2, h3, h4) {
359
+ max-width: 65ch;
360
+ }
361
+ @media (max-width: 47.9375em) {
362
+ article :where(p, ul, ol, blockquote, h2, h3, h4) {
363
+ max-width: none;
364
+ }
365
+ }
366
+ article :where(img, picture, video, table, figure, iframe) {
367
+ max-width: 100%;
368
+ }
369
+ /* Prose images — editor/markdown images dropped into long-form body content
370
+ get a centred block treatment (rounded, soft shadow). Scoped to the real
371
+ prose containers (post body, Prose block) so it never leaks onto STRUCTURAL
372
+ component images — cards, banners, media headers — which own their own
373
+ framing and crop absolutely. (A leaked `margin` pushes an inset image down
374
+ inside its overflow-hidden box, exposing a strip above it — the "empty
375
+ space above the image" a bare `article img` rule caused.) */
376
+ .b-post__body img,
377
+ .b-prose img {
378
+ display: block;
379
+ margin: 1.5rem auto;
380
+ border-radius: 8px;
381
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
382
+ }
383
+ @media (min-width: 44em) {
384
+ .b-post__body img,
385
+ .b-prose img {
386
+ border-radius: 12px;
387
+ }
388
+ }
389
+ a {
390
+ font-family: var(--font-body);
391
+ font-size: clamp(0.875rem, 0.82rem + 0.25vw, 1rem);
392
+ font-weight: 600;
393
+ color: var(--accent);
394
+ text-decoration: none;
395
+ transition: color 0.2s ease;
396
+ }
397
+ h1 a, h2 a, h3 a, h4 a {
398
+ font-size: inherit;
399
+ font-family: inherit;
400
+ font-weight: inherit;
401
+ line-height: inherit;
402
+ }
403
+
404
+ /* ── Fluid media: never overflow container ── */
405
+ /* The logo is exempt: it carries a fixed height in SiteHeader that `height:
406
+ auto` would collapse. Card/media images set their own size at higher
407
+ specificity, so this only ever backstops loose images. */
408
+ img:not(.primary-logo), picture, video {
409
+ max-width: 100%;
410
+ height: auto;
411
+ }
412
+
413
+ .site-main {
414
+ flex: 1;
415
+ min-width: 0;
416
+ width: min(100%, 72rem);
417
+ max-width: 72rem;
418
+ margin-inline: auto;
419
+ padding-inline: clamp(1rem, 3vw, 2.5rem);
420
+ }
421
+ .site-main {
422
+ display: flex;
423
+ flex-direction: column;
424
+ gap: clamp(2rem, 4vw, 3rem);
425
+ padding-block: clamp(3rem, 6vw, 5rem);
426
+ }
427
+ .site-main > * {
428
+ margin-block: 0;
429
+ }
430
+ /* A full-bleed opener (a full-screen hero, a full-bleed banner) as the page's first
431
+ block sits flush under the header, not below the main's top padding. */
432
+ .site-main:has(> .b-hero[data-size='screen']:first-child),
433
+ .site-main:has(> .b-banner[data-full-bleed='true']:first-child) {
434
+ padding-block-start: 0;
435
+ }
436
+
437
+ /* ── Content alignment cascade ──────────────────────────────────────
438
+ A page sets its default alignment via the `align` prop (→
439
+ data-content-align on <main>); a `section` block can override it for
440
+ its own subtree. Text-bearing blocks inherit `text-align` from here, so
441
+ a whole template stays on one alignment axis instead of each recipe
442
+ hard-coding its own. A few blocks adjust when centred so the result
443
+ reads as deliberately centred, not centred-inside-a-left-column.
444
+ `start` (left) is the readable default for long-form; a heading can
445
+ still override per-block via its own `align`. */
446
+ [data-content-align='start'] { text-align: start; }
447
+ [data-content-align='center'] { text-align: center; }
448
+ [data-content-align='end'] { text-align: end; }
449
+ /* Each content block owns how it ADJUSTS when centred/ended (re-centring
450
+ its reading column, dropping a left rule, etc.) in its own component,
451
+ so the rule out-specifies the block's own scoped styles. Here we only
452
+ set the inherited text-align axis. */
453
+
454
+ /* ── Secondary logo block: centered ── */
455
+ .secondary-logo {
456
+ display: flex;
457
+ flex-direction: column;
458
+ align-items: center;
459
+ justify-content: center;
460
+ width: 100%;
461
+ box-sizing: border-box;
462
+ }
463
+ .secondary-logo__img {
464
+ display: block;
465
+ max-width: min(150px, 100%);
466
+ height: auto;
467
+ }
468
+ html:not([data-theme='dark']) .secondary-logo__img {
469
+ opacity: 0.85;
470
+ mix-blend-mode: multiply;
471
+ }
472
+ html[data-theme='dark'] .secondary-logo__img {
473
+ mix-blend-mode: normal;
474
+ opacity: 1;
475
+ filter: var(--secondary-logo-filter);
476
+ }
477
+
478
+ @media (max-width: 22.5em) {
479
+ .site-main { padding-inline: 1rem; }
480
+ }
481
+ </style>
482
+ </body>
483
+ </html>