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