ferst-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +73 -0
- package/components/Button.astro +208 -0
- package/components/Card.astro +59 -0
- package/components/CookiePreferences.astro +232 -0
- package/components/DocLayout.astro +292 -0
- package/components/GroupCard.astro +164 -0
- package/components/LatestPostList.astro +64 -0
- package/components/PaginationNav.astro +81 -0
- package/components/PostCard.astro +262 -0
- package/components/PostsToolbar.astro +83 -0
- package/components/SecondaryLogo.astro +63 -0
- package/components/SiteFooter.astro +245 -0
- package/components/SiteHeader.astro +1130 -0
- package/content/collections.ts +40 -0
- package/content/filters.ts +107 -0
- package/content/schemas.ts +210 -0
- package/layouts/Base.astro +414 -0
- package/lib/mailLinks.ts +13 -0
- package/lib/mapsLink.ts +7 -0
- package/lib/siteSettings.ts +78 -0
- package/package.json +48 -0
- package/styles/fonts.css +76 -0
- package/styles/theme.css +90 -0
- package/utils/excerpt.ts +56 -0
- package/utils/formatDate.ts +22 -0
- package/utils/freshness.ts +5 -0
- package/utils/scrollReveal.ts +40 -0
|
@@ -0,0 +1,414 @@
|
|
|
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 SecondaryLogo from '../components/SecondaryLogo.astro';
|
|
28
|
+
import { loadSiteSettings } from '../lib/siteSettings';
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
title: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
heroImage?: string;
|
|
34
|
+
headerClass?: string;
|
|
35
|
+
mainClass?: string;
|
|
36
|
+
footerClass?: string;
|
|
37
|
+
/** Render the secondary logo centred at the bottom of the page content. */
|
|
38
|
+
showSecondaryLogo?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
title,
|
|
43
|
+
description,
|
|
44
|
+
heroImage,
|
|
45
|
+
headerClass,
|
|
46
|
+
mainClass,
|
|
47
|
+
footerClass,
|
|
48
|
+
showSecondaryLogo = false,
|
|
49
|
+
} = Astro.props;
|
|
50
|
+
|
|
51
|
+
const settings = await loadSiteSettings();
|
|
52
|
+
const { identity, contact, navigation, theme } = settings;
|
|
53
|
+
|
|
54
|
+
const path = Astro.url.pathname;
|
|
55
|
+
|
|
56
|
+
// Client design-token overrides → an inline <style> that layers over the
|
|
57
|
+
// core's neutral defaults (styles/theme.css). Selectors are doubled
|
|
58
|
+
// (`:root:root`, `:root[data-theme="dark"]`) so they win by specificity
|
|
59
|
+
// regardless of stylesheet order. Keys are sanitised to `--custom-prop`
|
|
60
|
+
// form and values that could break out of the declaration are dropped —
|
|
61
|
+
// the source is trusted repo JSON, but this keeps a stray value honest.
|
|
62
|
+
function tokenBlock(selector: string, tokens: Record<string, string>): string {
|
|
63
|
+
const decls = Object.entries(tokens ?? {})
|
|
64
|
+
.filter(([k, v]) => /^--?[a-zA-Z0-9-]+$/.test(k) && !/[<>{};]/.test(String(v)))
|
|
65
|
+
.map(([k, v]) => `${k.startsWith('--') ? k : `--${k}`}:${String(v).trim()}`)
|
|
66
|
+
.join(';');
|
|
67
|
+
return decls ? `${selector}{${decls}}` : '';
|
|
68
|
+
}
|
|
69
|
+
const themeCss = [
|
|
70
|
+
tokenBlock(':root:root', theme.light),
|
|
71
|
+
tokenBlock(':root[data-theme="dark"]', theme.dark),
|
|
72
|
+
]
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.join('');
|
|
75
|
+
|
|
76
|
+
// Logos: schema marks them optional so legacy data files keep validating.
|
|
77
|
+
// Fall back to paths that exist under `public/uploads/` in this repo (there is
|
|
78
|
+
// no `uploads/branding/` tree — using it here caused broken images whenever
|
|
79
|
+
// JSON omitted the keys or site settings fell back to schema defaults).
|
|
80
|
+
const primaryLogoLight = settings.primaryLogoLight ?? '/uploads/logo-light.png';
|
|
81
|
+
const primaryLogoDark = settings.primaryLogoDark ?? '/uploads/logo-dark.png';
|
|
82
|
+
const secondaryLogoLight = settings.secondaryLogoLight;
|
|
83
|
+
const secondaryLogoDark = settings.secondaryLogoDark;
|
|
84
|
+
const secondaryLogoLink = settings.secondaryLogoLink;
|
|
85
|
+
const brandAlt = [identity.shortName, identity.location].filter(Boolean).join(', ');
|
|
86
|
+
const metaDescription = description ?? identity.description;
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
<!doctype html>
|
|
90
|
+
<html lang="en-GB">
|
|
91
|
+
<head>
|
|
92
|
+
<meta charset="utf-8" />
|
|
93
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
94
|
+
<slot name="head" />
|
|
95
|
+
<meta name="description" content={metaDescription} />
|
|
96
|
+
<title>{title} — {identity.siteTitle}</title>
|
|
97
|
+
{themeCss && <style is:inline set:html={themeCss} />}
|
|
98
|
+
<!-- Self-hosted (see src/styles/fonts.css) — h1 (Poppins 700) and body copy
|
|
99
|
+
(Inter 400) are what virtually every page needs at first paint. -->
|
|
100
|
+
<link rel="preload" as="font" type="font/woff2" href="/fonts/poppins-latin-700-normal.woff2" crossorigin />
|
|
101
|
+
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter-latin-400-normal.woff2" crossorigin />
|
|
102
|
+
{heroImage && <link rel="preload" as="image" href={heroImage} />}
|
|
103
|
+
<!--
|
|
104
|
+
Early-paint theme detection: localStorage override → system preference.
|
|
105
|
+
Must be inline + run before <body> paints, otherwise dark-mode users see
|
|
106
|
+
a light-mode flash. Also preloads the correct brand logo for that theme.
|
|
107
|
+
-->
|
|
108
|
+
<script is:inline define:vars={{ primaryLogoDark, primaryLogoLight }}>
|
|
109
|
+
(function () {
|
|
110
|
+
var source = localStorage.getItem('theme-source');
|
|
111
|
+
var saved = localStorage.getItem('theme');
|
|
112
|
+
var t;
|
|
113
|
+
if (source === 'user' && (saved === 'light' || saved === 'dark')) {
|
|
114
|
+
t = saved;
|
|
115
|
+
} else {
|
|
116
|
+
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
117
|
+
}
|
|
118
|
+
document.documentElement.dataset.theme = t;
|
|
119
|
+
var logoHref = t === 'dark' ? primaryLogoDark : primaryLogoLight;
|
|
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
|
+
</script>
|
|
127
|
+
<script>
|
|
128
|
+
// Pages that opt into Astro's <ClientRouter /> (currently /posts/)
|
|
129
|
+
// swap <html>'s attributes to match the newly-fetched document by
|
|
130
|
+
// default. Since dark/light mode is only ever set client-side above
|
|
131
|
+
// (never baked into the server-rendered HTML, which doesn't know the
|
|
132
|
+
// visitor's localStorage), that wipe resets data-theme on every
|
|
133
|
+
// transition — the whole page flips back to light mode after every
|
|
134
|
+
// filter click. Carry the current theme over before the swap applies.
|
|
135
|
+
import type { TransitionBeforeSwapEvent } from 'astro:transitions/client';
|
|
136
|
+
|
|
137
|
+
document.addEventListener('astro:before-swap', ((event: TransitionBeforeSwapEvent) => {
|
|
138
|
+
const theme = document.documentElement.dataset.theme;
|
|
139
|
+
if (theme) {
|
|
140
|
+
event.newDocument.documentElement.dataset.theme = theme;
|
|
141
|
+
}
|
|
142
|
+
}) as EventListener);
|
|
143
|
+
</script>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
<SiteHeader
|
|
147
|
+
path={path}
|
|
148
|
+
headerClass={headerClass}
|
|
149
|
+
primaryLogoLight={primaryLogoLight}
|
|
150
|
+
primaryLogoDark={primaryLogoDark}
|
|
151
|
+
brandAlt={brandAlt}
|
|
152
|
+
primaryNav={navigation.primary}
|
|
153
|
+
dropdown={navigation.dropdown}
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
<main class:list={['site-main', mainClass]}>
|
|
157
|
+
<slot name="hero" />
|
|
158
|
+
<slot />
|
|
159
|
+
{showSecondaryLogo && (secondaryLogoLight || secondaryLogoDark) && (
|
|
160
|
+
<SecondaryLogo logoLight={secondaryLogoLight} logoDark={secondaryLogoDark} link={secondaryLogoLink} />
|
|
161
|
+
)}
|
|
162
|
+
</main>
|
|
163
|
+
|
|
164
|
+
<SiteFooter
|
|
165
|
+
facebookUrl={contact.facebookUrl}
|
|
166
|
+
footerLinks={navigation.footer}
|
|
167
|
+
footerClass={footerClass}
|
|
168
|
+
shortName={identity.shortName}
|
|
169
|
+
tagline={identity.description}
|
|
170
|
+
/>
|
|
171
|
+
|
|
172
|
+
<CookiePreferences />
|
|
173
|
+
|
|
174
|
+
<style is:global>
|
|
175
|
+
/* ── Global resets & typography ─────────────────────────────────────
|
|
176
|
+
Anything that affects every page's body / typography / article
|
|
177
|
+
content lives here. Header, footer, and cookie banner each own
|
|
178
|
+
their own <style is:global> in their component file. Theme tokens
|
|
179
|
+
(`:root`, `[data-theme=dark]`) come from styles/theme.css and are
|
|
180
|
+
overridable per-client via the themeSettings collection. Every
|
|
181
|
+
colour below reads a token, so light/dark and any client palette
|
|
182
|
+
all flow from the same variables — no per-mode hex overrides here.
|
|
183
|
+
*/
|
|
184
|
+
* { box-sizing: border-box; }
|
|
185
|
+
html, body { margin: 0; padding: 0; }
|
|
186
|
+
html {
|
|
187
|
+
height: 100%;
|
|
188
|
+
overflow-x: clip;
|
|
189
|
+
overscroll-behavior-x: none;
|
|
190
|
+
}
|
|
191
|
+
body {
|
|
192
|
+
font-family: 'Inter', 'Lato', Georgia, serif;
|
|
193
|
+
color: var(--fg);
|
|
194
|
+
background: var(--bg);
|
|
195
|
+
line-height: 1.6;
|
|
196
|
+
min-height: 100%;
|
|
197
|
+
display: flex;
|
|
198
|
+
flex-direction: column;
|
|
199
|
+
overflow-x: clip;
|
|
200
|
+
overscroll-behavior-x: none;
|
|
201
|
+
}
|
|
202
|
+
h1, h2, h3, h4 {
|
|
203
|
+
font-family: 'Playfair Display', Georgia, serif;
|
|
204
|
+
}
|
|
205
|
+
h1 {
|
|
206
|
+
font-family: 'Poppins', sans-serif;
|
|
207
|
+
color: var(--fg);
|
|
208
|
+
font-size: clamp(2rem, 0.95rem + 4.5vw, 3.35rem);
|
|
209
|
+
font-weight: 700;
|
|
210
|
+
line-height: 1.34;
|
|
211
|
+
letter-spacing: -0.01em;
|
|
212
|
+
margin: 0;
|
|
213
|
+
}
|
|
214
|
+
.site-main h1 {
|
|
215
|
+
text-align: center;
|
|
216
|
+
}
|
|
217
|
+
h2 {
|
|
218
|
+
font-family: 'Playfair Display', Georgia, serif;
|
|
219
|
+
color: var(--fg);
|
|
220
|
+
font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
|
|
221
|
+
line-height: 1.3;
|
|
222
|
+
margin: 0;
|
|
223
|
+
text-align: center;
|
|
224
|
+
}
|
|
225
|
+
h3 {
|
|
226
|
+
font-family: 'Poppins', sans-serif;
|
|
227
|
+
color: var(--fg);
|
|
228
|
+
font-size: clamp(1.375rem, 1.2rem + 0.55vw, 1.625rem);
|
|
229
|
+
font-weight: 600;
|
|
230
|
+
line-height: 1.35;
|
|
231
|
+
margin: 0;
|
|
232
|
+
}
|
|
233
|
+
h4 {
|
|
234
|
+
font-family: 'Poppins', sans-serif;
|
|
235
|
+
color: var(--accent);
|
|
236
|
+
font-size: clamp(0.6875rem, 0.9vw, 0.75rem);
|
|
237
|
+
font-weight: 500;
|
|
238
|
+
letter-spacing: 0.2em;
|
|
239
|
+
text-transform: uppercase;
|
|
240
|
+
margin: 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
p, li {
|
|
244
|
+
font-family: 'Inter', sans-serif;
|
|
245
|
+
color: var(--muted);
|
|
246
|
+
font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
|
|
247
|
+
font-weight: 400;
|
|
248
|
+
line-height: 1.65;
|
|
249
|
+
margin: 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
time {
|
|
253
|
+
color: var(--muted);
|
|
254
|
+
font-size: clamp(0.7rem, 0.62rem + 0.25vw, 0.8rem);
|
|
255
|
+
letter-spacing: 0.04em;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ── Typography utility classes (mobile-first, fluid via clamp).
|
|
259
|
+
Apply explicitly to elements: .t-h1/.t-h2/.t-h3 for headings,
|
|
260
|
+
.t-body for body copy, .t-lead for intro paragraphs, .t-meta for small/meta text.
|
|
261
|
+
Mobile baseline matches iOS Human Interface Guidelines (Dynamic Type, "Large").
|
|
262
|
+
Large Title 34 / Title1 28 / Title2 22 / Title3 ~22px mobile → max 26px / Body 17 / Footnote 13. */
|
|
263
|
+
.t-h1 {
|
|
264
|
+
font-size: clamp(2.125rem, 1.6rem + 1.75vw, 3rem);
|
|
265
|
+
line-height: 1.2;
|
|
266
|
+
}
|
|
267
|
+
.t-h2 {
|
|
268
|
+
font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
|
|
269
|
+
line-height: 1.3;
|
|
270
|
+
margin: 0;
|
|
271
|
+
}
|
|
272
|
+
.t-h3 {
|
|
273
|
+
font-size: var(--type-h3-size);
|
|
274
|
+
line-height: 1.35;
|
|
275
|
+
margin: 0;
|
|
276
|
+
}
|
|
277
|
+
.t-body {
|
|
278
|
+
font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
|
|
279
|
+
line-height: 1.65;
|
|
280
|
+
}
|
|
281
|
+
.t-lead {
|
|
282
|
+
font-size: clamp(1.25rem, 1.1rem + 0.5vw, 1.5rem);
|
|
283
|
+
line-height: 1.7;
|
|
284
|
+
}
|
|
285
|
+
.t-meta {
|
|
286
|
+
font-size: clamp(0.8125rem, 0.76rem + 0.18vw, 0.9375rem);
|
|
287
|
+
line-height: 1.5;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* Reveal-on-scroll utility — paired with the `armReveal()` helper in
|
|
291
|
+
src/utils/scrollReveal.ts. An element only gets hidden once JS adds
|
|
292
|
+
.js-reveal, so it stays fully visible with no JS or under
|
|
293
|
+
prefers-reduced-motion. Global (not page-scoped) since it's shared
|
|
294
|
+
by the homepage and the /posts/ listing. */
|
|
295
|
+
.js-reveal {
|
|
296
|
+
opacity: 0;
|
|
297
|
+
transform: translateY(16px);
|
|
298
|
+
transition: opacity 0.6s ease, transform 0.6s ease;
|
|
299
|
+
}
|
|
300
|
+
.js-reveal.is-visible {
|
|
301
|
+
opacity: 1;
|
|
302
|
+
transform: none;
|
|
303
|
+
}
|
|
304
|
+
/* Optimal reading measure for long-form article content. Below this
|
|
305
|
+
breakpoint the viewport itself is already narrower than 65ch, so
|
|
306
|
+
the cap only needs to do work on wider (tablet/desktop) screens. */
|
|
307
|
+
article :where(p, ul, ol, blockquote, h2, h3, h4) {
|
|
308
|
+
max-width: 65ch;
|
|
309
|
+
}
|
|
310
|
+
@media (max-width: 47.9375em) {
|
|
311
|
+
article :where(p, ul, ol, blockquote, h2, h3, h4) {
|
|
312
|
+
max-width: none;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
article :where(img, picture, video, table, figure, iframe) {
|
|
316
|
+
max-width: 100%;
|
|
317
|
+
}
|
|
318
|
+
article img:not(.group-card__img) {
|
|
319
|
+
display: block;
|
|
320
|
+
margin: 1.5rem auto;
|
|
321
|
+
border-radius: 8px;
|
|
322
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
323
|
+
}
|
|
324
|
+
@media (min-width: 44em) {
|
|
325
|
+
article img:not(.group-card__img) {
|
|
326
|
+
border-radius: 12px;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
a {
|
|
330
|
+
font-family: 'Inter', sans-serif;
|
|
331
|
+
font-size: clamp(0.875rem, 0.82rem + 0.25vw, 1rem);
|
|
332
|
+
font-weight: 600;
|
|
333
|
+
color: var(--accent);
|
|
334
|
+
text-decoration: none;
|
|
335
|
+
transition: color 0.2s ease;
|
|
336
|
+
}
|
|
337
|
+
h1 a, h2 a, h3 a, h4 a {
|
|
338
|
+
font-size: inherit;
|
|
339
|
+
font-family: inherit;
|
|
340
|
+
font-weight: inherit;
|
|
341
|
+
line-height: inherit;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* ── Fluid media: never overflow container ── */
|
|
345
|
+
img:not(.group-card__img):not(.primary-logo), picture, video {
|
|
346
|
+
max-width: 100%;
|
|
347
|
+
height: auto;
|
|
348
|
+
}
|
|
349
|
+
.group-card__img {
|
|
350
|
+
max-width: 100%;
|
|
351
|
+
height: auto;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.site-main {
|
|
355
|
+
flex: 1;
|
|
356
|
+
min-width: 0;
|
|
357
|
+
width: min(100%, 72rem);
|
|
358
|
+
max-width: 72rem;
|
|
359
|
+
margin-inline: auto;
|
|
360
|
+
padding-inline: clamp(1rem, 3vw, 2.5rem);
|
|
361
|
+
}
|
|
362
|
+
.site-main {
|
|
363
|
+
display: flex;
|
|
364
|
+
flex-direction: column;
|
|
365
|
+
gap: clamp(2rem, 4vw, 3rem);
|
|
366
|
+
padding-block: clamp(3rem, 6vw, 5rem);
|
|
367
|
+
}
|
|
368
|
+
.site-main > * {
|
|
369
|
+
margin-block: 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* ── Secondary logo block: centered ── */
|
|
373
|
+
.secondary-logo {
|
|
374
|
+
display: flex;
|
|
375
|
+
flex-direction: column;
|
|
376
|
+
align-items: center;
|
|
377
|
+
justify-content: center;
|
|
378
|
+
width: 100%;
|
|
379
|
+
box-sizing: border-box;
|
|
380
|
+
}
|
|
381
|
+
.secondary-logo__img {
|
|
382
|
+
display: block;
|
|
383
|
+
max-width: min(150px, 100%);
|
|
384
|
+
height: auto;
|
|
385
|
+
}
|
|
386
|
+
html:not([data-theme='dark']) .secondary-logo__img {
|
|
387
|
+
opacity: 0.85;
|
|
388
|
+
mix-blend-mode: multiply;
|
|
389
|
+
}
|
|
390
|
+
html[data-theme='dark'] .secondary-logo__img {
|
|
391
|
+
mix-blend-mode: normal;
|
|
392
|
+
opacity: 1;
|
|
393
|
+
filter: var(--secondary-logo-filter);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
@media (max-width: 22.5em) {
|
|
397
|
+
.site-main { padding-inline: 1rem; }
|
|
398
|
+
}
|
|
399
|
+
</style>
|
|
400
|
+
|
|
401
|
+
<script is:inline>
|
|
402
|
+
// Remove the "New" flag from posts older than 3 days. Inline so it
|
|
403
|
+
// runs as soon as it's parsed (before LCP), without an extra request.
|
|
404
|
+
(function () {
|
|
405
|
+
var WINDOW = 3 * 24 * 60 * 60 * 1000;
|
|
406
|
+
var now = Date.now();
|
|
407
|
+
document.querySelectorAll('.new-flag[data-fresh-since]').forEach(function (el) {
|
|
408
|
+
var ts = Date.parse(el.getAttribute('data-fresh-since') || '');
|
|
409
|
+
if (!isNaN(ts) && now - ts >= WINDOW) el.remove();
|
|
410
|
+
});
|
|
411
|
+
})();
|
|
412
|
+
</script>
|
|
413
|
+
</body>
|
|
414
|
+
</html>
|
package/lib/mailLinks.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a single well-formed mailto URI for use in `<a href>`.
|
|
3
|
+
* Strips duplicated `mailto:` prefixes (CMS / paste errors) without
|
|
4
|
+
* rewriting valid `?subject=` / `&body=` query pairs on the remainder.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeMailtoHref(raw: string): string {
|
|
7
|
+
let s = raw.trim();
|
|
8
|
+
while (s.toLowerCase().startsWith('mailto:')) {
|
|
9
|
+
s = s.slice(7).trimStart();
|
|
10
|
+
}
|
|
11
|
+
if (!s) return '';
|
|
12
|
+
return `mailto:${s}`;
|
|
13
|
+
}
|
package/lib/mapsLink.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free-form maps query → real URL. encodeURIComponent for safety; the live
|
|
3
|
+
* site has historically used `+` for spaces — both are equivalent to Google.
|
|
4
|
+
*/
|
|
5
|
+
export function mapsLink(query: string): string {
|
|
6
|
+
return `https://maps.google.com/?q=${encodeURIComponent(query)}`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single accessor merging all the site-settings content entries into one
|
|
3
|
+
* combined object.
|
|
4
|
+
*
|
|
5
|
+
* Storage is split across several small collections (identity+contact,
|
|
6
|
+
* logos, posts, calendar, navbar, footer) so each is also its own focused
|
|
7
|
+
* Sveltia CMS pane instead of one long mixed-purpose form — see
|
|
8
|
+
* `src/content/schemas.ts`. Every existing consumer (`Base.astro`,
|
|
9
|
+
* `/calendar/`, `/contact/`, `/`) reads a single combined shape though, so
|
|
10
|
+
* this loader re-assembles that shape (including the `navigation.primary`
|
|
11
|
+
* / `navigation.footer` grouping, even though navbar and footer links now
|
|
12
|
+
* live in two separate collections) at read time — the only place that
|
|
13
|
+
* needs to know the storage is split.
|
|
14
|
+
*
|
|
15
|
+
* `getEntry(...)` yields `data` typed per-collection when the entry exists,
|
|
16
|
+
* but the content runtime does not always apply nested Zod defaults the same
|
|
17
|
+
* way a plain `schema.parse()` does. We always re-parse each entry's data
|
|
18
|
+
* through its own schema so nested defaults (`identity`, `contact`,
|
|
19
|
+
* `secondaryLogoLink`) are fully populated, including when a file is missing.
|
|
20
|
+
*/
|
|
21
|
+
import { getEntry } from 'astro:content';
|
|
22
|
+
import {
|
|
23
|
+
calendarSettingsSchema,
|
|
24
|
+
footerSettingsSchema,
|
|
25
|
+
logoSettingsSchema,
|
|
26
|
+
navbarSettingsSchema,
|
|
27
|
+
postsSettingsSchema,
|
|
28
|
+
siteSettingsSchema,
|
|
29
|
+
themeSettingsSchema,
|
|
30
|
+
type CalendarSettings,
|
|
31
|
+
type LogoSettings,
|
|
32
|
+
type NavLink,
|
|
33
|
+
type NavGroup,
|
|
34
|
+
type PostsSettings,
|
|
35
|
+
type SiteSettings,
|
|
36
|
+
type ThemeSettings,
|
|
37
|
+
} from '../content/schemas';
|
|
38
|
+
|
|
39
|
+
export type LoadedSiteSettings = SiteSettings &
|
|
40
|
+
LogoSettings &
|
|
41
|
+
PostsSettings &
|
|
42
|
+
CalendarSettings & {
|
|
43
|
+
navigation: { primary: NavLink[]; footer: NavLink[]; dropdown?: NavGroup };
|
|
44
|
+
/** Client-provided design-token overrides (empty objects if none). */
|
|
45
|
+
theme: ThemeSettings;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let cached: LoadedSiteSettings | null = null;
|
|
49
|
+
|
|
50
|
+
export async function loadSiteSettings(): Promise<LoadedSiteSettings> {
|
|
51
|
+
if (cached) return cached;
|
|
52
|
+
|
|
53
|
+
const [siteSettingsEntry, logoEntry, postsEntry, calendarEntry, navbarEntry, footerEntry, themeEntry] =
|
|
54
|
+
await Promise.all([
|
|
55
|
+
getEntry('siteSettings', 'index'),
|
|
56
|
+
getEntry('logoSettings', 'index'),
|
|
57
|
+
getEntry('postsSettings', 'index'),
|
|
58
|
+
getEntry('calendarSettings', 'index'),
|
|
59
|
+
getEntry('navbarSettings', 'index'),
|
|
60
|
+
getEntry('footerSettings', 'index'),
|
|
61
|
+
getEntry('themeSettings', 'index'),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
cached = {
|
|
65
|
+
...siteSettingsSchema.parse(siteSettingsEntry?.data ?? {}),
|
|
66
|
+
...logoSettingsSchema.parse(logoEntry?.data ?? {}),
|
|
67
|
+
...postsSettingsSchema.parse(postsEntry?.data ?? {}),
|
|
68
|
+
...calendarSettingsSchema.parse(calendarEntry?.data ?? {}),
|
|
69
|
+
navigation: {
|
|
70
|
+
primary: navbarSettingsSchema.parse(navbarEntry?.data ?? {}).primary,
|
|
71
|
+
footer: footerSettingsSchema.parse(footerEntry?.data ?? {}).footer,
|
|
72
|
+
dropdown: navbarSettingsSchema.parse(navbarEntry?.data ?? {}).dropdown,
|
|
73
|
+
},
|
|
74
|
+
theme: themeSettingsSchema.parse(themeEntry?.data ?? {}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return cached;
|
|
78
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ferst-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": "MyAI4 Ltd (https://ferst.co.uk)",
|
|
8
|
+
"homepage": "https://ferst.co.uk",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MyAI4WebDev/myai4-ferst-core.git",
|
|
12
|
+
"directory": "packages/core"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/MyAI4WebDev/myai4-ferst-core/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"astro",
|
|
19
|
+
"astro-component",
|
|
20
|
+
"cms",
|
|
21
|
+
"sveltia",
|
|
22
|
+
"website",
|
|
23
|
+
"design-tokens",
|
|
24
|
+
"ferst"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": [
|
|
27
|
+
"**/*.css"
|
|
28
|
+
],
|
|
29
|
+
"files": [
|
|
30
|
+
"components",
|
|
31
|
+
"layouts",
|
|
32
|
+
"content",
|
|
33
|
+
"lib",
|
|
34
|
+
"utils",
|
|
35
|
+
"styles",
|
|
36
|
+
"README.md",
|
|
37
|
+
"NOTICE"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"zod": "^3.25.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"astro": "^5.0.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/styles/fonts.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/* Self-hosted fonts — see scripts/setup-fonts.mjs for how public/fonts/ gets
|
|
2
|
+
populated (from @fontsource, regenerated on `npm install`, gitignored).
|
|
3
|
+
Loaded same-origin instead of from fonts.googleapis.com/fonts.gstatic.com
|
|
4
|
+
so visiting this site no longer sends every visitor's IP to Google on
|
|
5
|
+
every page (see /cookie-policy/). Latin subset, normal style only —
|
|
6
|
+
every weight below is one actually referenced somewhere in src/; no
|
|
7
|
+
unicode-range needed since each file ships exactly one subset.
|
|
8
|
+
|
|
9
|
+
Inter, Playfair Display, and Poppins are all SIL Open Font License 1.1 —
|
|
10
|
+
self-hosting/redistribution is explicitly permitted. */
|
|
11
|
+
|
|
12
|
+
@font-face {
|
|
13
|
+
font-family: 'Inter';
|
|
14
|
+
font-style: normal;
|
|
15
|
+
font-weight: 400;
|
|
16
|
+
font-display: swap;
|
|
17
|
+
src: url('/fonts/inter-latin-400-normal.woff2') format('woff2');
|
|
18
|
+
}
|
|
19
|
+
@font-face {
|
|
20
|
+
font-family: 'Inter';
|
|
21
|
+
font-style: normal;
|
|
22
|
+
font-weight: 500;
|
|
23
|
+
font-display: swap;
|
|
24
|
+
src: url('/fonts/inter-latin-500-normal.woff2') format('woff2');
|
|
25
|
+
}
|
|
26
|
+
@font-face {
|
|
27
|
+
font-family: 'Inter';
|
|
28
|
+
font-style: normal;
|
|
29
|
+
font-weight: 600;
|
|
30
|
+
font-display: swap;
|
|
31
|
+
src: url('/fonts/inter-latin-600-normal.woff2') format('woff2');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@font-face {
|
|
35
|
+
font-family: 'Playfair Display';
|
|
36
|
+
font-style: normal;
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
font-display: swap;
|
|
39
|
+
src: url('/fonts/playfair-display-latin-700-normal.woff2') format('woff2');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@font-face {
|
|
43
|
+
font-family: 'Poppins';
|
|
44
|
+
font-style: normal;
|
|
45
|
+
font-weight: 300;
|
|
46
|
+
font-display: swap;
|
|
47
|
+
src: url('/fonts/poppins-latin-300-normal.woff2') format('woff2');
|
|
48
|
+
}
|
|
49
|
+
@font-face {
|
|
50
|
+
font-family: 'Poppins';
|
|
51
|
+
font-style: normal;
|
|
52
|
+
font-weight: 400;
|
|
53
|
+
font-display: swap;
|
|
54
|
+
src: url('/fonts/poppins-latin-400-normal.woff2') format('woff2');
|
|
55
|
+
}
|
|
56
|
+
@font-face {
|
|
57
|
+
font-family: 'Poppins';
|
|
58
|
+
font-style: normal;
|
|
59
|
+
font-weight: 500;
|
|
60
|
+
font-display: swap;
|
|
61
|
+
src: url('/fonts/poppins-latin-500-normal.woff2') format('woff2');
|
|
62
|
+
}
|
|
63
|
+
@font-face {
|
|
64
|
+
font-family: 'Poppins';
|
|
65
|
+
font-style: normal;
|
|
66
|
+
font-weight: 600;
|
|
67
|
+
font-display: swap;
|
|
68
|
+
src: url('/fonts/poppins-latin-600-normal.woff2') format('woff2');
|
|
69
|
+
}
|
|
70
|
+
@font-face {
|
|
71
|
+
font-family: 'Poppins';
|
|
72
|
+
font-style: normal;
|
|
73
|
+
font-weight: 700;
|
|
74
|
+
font-display: swap;
|
|
75
|
+
src: url('/fonts/poppins-latin-700-normal.woff2') format('woff2');
|
|
76
|
+
}
|