ferst-core 0.2.9 → 0.4.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/components/BlockRenderer.astro +4 -0
- package/components/CalendarEmbed.astro +43 -32
- package/components/CookieBanner.astro +126 -0
- package/components/CookiePreferences.astro +13 -13
- package/components/PostCard.astro +10 -4
- package/components/recipes/ContactCard.astro +145 -0
- package/components/recipes/Faq.astro +7 -1
- package/components/recipes/LatestPosts.astro +19 -14
- package/components/recipes/Locations.astro +150 -0
- package/components/recipes/MapEmbed.astro +27 -33
- package/content/blocks.ts +63 -1
- package/content/posts.ts +17 -0
- package/content/schemas.ts +16 -0
- package/layouts/Base.astro +2 -0
- package/lib/consent-client.ts +46 -0
- package/lib/inline-markdown.ts +52 -0
- package/lib/posts.ts +4 -0
- package/lib/themeTokens.ts +77 -22
- package/package.json +1 -1
|
@@ -42,6 +42,8 @@ import MapEmbed from './recipes/MapEmbed.astro';
|
|
|
42
42
|
import Table from './recipes/Table.astro';
|
|
43
43
|
import Notice from './recipes/Notice.astro';
|
|
44
44
|
import MediaCards from './recipes/MediaCards.astro';
|
|
45
|
+
import ContactCard from './recipes/ContactCard.astro';
|
|
46
|
+
import Locations from './recipes/Locations.astro';
|
|
45
47
|
|
|
46
48
|
interface Props {
|
|
47
49
|
blocks: Block[];
|
|
@@ -86,6 +88,8 @@ const registry: Record<BlockType, any> = {
|
|
|
86
88
|
table: Table,
|
|
87
89
|
notice: Notice,
|
|
88
90
|
mediaCards: MediaCards,
|
|
91
|
+
contactCard: ContactCard,
|
|
92
|
+
locations: Locations,
|
|
89
93
|
};
|
|
90
94
|
---
|
|
91
95
|
|
|
@@ -76,42 +76,53 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
|
|
|
76
76
|
</section>
|
|
77
77
|
|
|
78
78
|
<script>
|
|
79
|
-
// Bundled (
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
// Bundled (runs once even with multiple calendars on a page). Consent is the
|
|
80
|
+
// shared site-wide embed consent: accepting the cookie banner (or the footer
|
|
81
|
+
// panel) auto-loads every embed. The per-embed button is a fallback that grants
|
|
82
|
+
// the same consent, so one click still loads everything and is remembered.
|
|
83
|
+
import { hasConsent, setConsent, onConsentChange } from '../lib/consent-client';
|
|
84
|
+
|
|
85
|
+
const embeds = document.querySelectorAll<HTMLElement>('.b-calendar__embed');
|
|
86
|
+
|
|
87
|
+
// Best-effort theme blend: Google Calendar embeds expose only `bgcolor` (the
|
|
88
|
+
// surround), not a real dark mode — the grid stays Google's own light. We match
|
|
89
|
+
// the surround to the current theme surface so the frame doesn't sit as a hard
|
|
90
|
+
// block. Resolved from --bg-surface at load (a later theme toggle keeps it).
|
|
91
|
+
function themedCalendarUrl(url: string): string {
|
|
92
|
+
if (!/google\.com\/calendar|calendar\.google\.com/.test(url)) return url;
|
|
93
|
+
const probe = document.createElement('span');
|
|
94
|
+
probe.style.cssText = 'display:none;background:var(--bg-surface)';
|
|
95
|
+
document.body.appendChild(probe);
|
|
96
|
+
const m = getComputedStyle(probe).backgroundColor.match(/\d+/g);
|
|
97
|
+
probe.remove();
|
|
98
|
+
if (!m) return url;
|
|
99
|
+
const hex = m.slice(0, 3).map((n) => (+n).toString(16).padStart(2, '0')).join('');
|
|
100
|
+
return url + (url.includes('?') ? '&' : '?') + 'bgcolor=%23' + hex;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function load(el: HTMLElement) {
|
|
104
|
+
if (el.dataset.loaded === 'true') return;
|
|
83
105
|
const url = el.dataset.embedUrl;
|
|
84
|
-
if (!url) return;
|
|
85
|
-
const height = el.dataset.height || '600';
|
|
86
|
-
const title = el.dataset.title || 'Calendar';
|
|
87
106
|
const frameSlot = el.querySelector<HTMLElement>('.b-calendar__frame');
|
|
107
|
+
if (!url || !frameSlot) return;
|
|
108
|
+
const frame = document.createElement('iframe');
|
|
109
|
+
frame.src = themedCalendarUrl(url);
|
|
110
|
+
frame.title = el.dataset.title || 'Calendar';
|
|
111
|
+
frame.loading = 'lazy';
|
|
112
|
+
frame.style.width = '100%';
|
|
113
|
+
frame.style.height = (el.dataset.height || '600') + 'px';
|
|
114
|
+
frame.style.border = '0';
|
|
115
|
+
frameSlot.replaceChildren(frame);
|
|
116
|
+
el.dataset.loaded = 'true';
|
|
88
117
|
const consent = el.querySelector<HTMLElement>('.b-calendar__consent');
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (el.dataset.loaded === 'true' || !frameSlot) return;
|
|
93
|
-
const frame = document.createElement('iframe');
|
|
94
|
-
frame.src = url as string;
|
|
95
|
-
frame.title = title;
|
|
96
|
-
frame.loading = 'lazy';
|
|
97
|
-
frame.style.width = '100%';
|
|
98
|
-
frame.style.height = height + 'px';
|
|
99
|
-
frame.style.border = '0';
|
|
100
|
-
frameSlot.replaceChildren(frame);
|
|
101
|
-
el.dataset.loaded = 'true';
|
|
102
|
-
if (consent) consent.hidden = true;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
if (localStorage.getItem(CONSENT_KEY) === '1') load();
|
|
107
|
-
} catch (_) {}
|
|
118
|
+
if (consent) consent.hidden = true;
|
|
119
|
+
}
|
|
120
|
+
const loadAll = () => embeds.forEach(load);
|
|
108
121
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
load();
|
|
114
|
-
});
|
|
122
|
+
if (hasConsent()) loadAll();
|
|
123
|
+
onConsentChange((allowed) => { if (allowed) loadAll(); });
|
|
124
|
+
embeds.forEach((el) => {
|
|
125
|
+
el.querySelector<HTMLButtonElement>('.b-calendar__load')?.addEventListener('click', () => setConsent(true));
|
|
115
126
|
});
|
|
116
127
|
</script>
|
|
117
128
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CookieBanner — the site's single cookie consent prompt, shown once on the first
|
|
4
|
+
* visit until the visitor chooses. Accepting lets embedded third-party content
|
|
5
|
+
* (Google Calendar, Google Maps) load automatically wherever it appears — no
|
|
6
|
+
* separate per-embed gate. The footer's "Cookie preferences" panel lets them
|
|
7
|
+
* change their mind later. Site chrome, rendered by Base after the page.
|
|
8
|
+
*
|
|
9
|
+
* Hidden by default; the client script reveals it only when no choice is stored,
|
|
10
|
+
* so it never flashes for returning visitors.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Link to the cookie policy page (omit to hide the link). */
|
|
14
|
+
policyHref?: string;
|
|
15
|
+
}
|
|
16
|
+
const { policyHref = '/cookie-policy/' } = Astro.props;
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<div class="cookie-banner" id="cookie-banner" role="dialog" aria-label="Cookie notice" aria-live="polite" hidden>
|
|
20
|
+
<p class="cookie-banner__text">
|
|
21
|
+
We use a few cookies to run the site and to show embedded content like our
|
|
22
|
+
calendar and maps.{policyHref && (<> See our <a href={policyHref}>cookie policy</a>.</>)}
|
|
23
|
+
</p>
|
|
24
|
+
<div class="cookie-banner__actions">
|
|
25
|
+
<button type="button" class="cookie-banner__btn cookie-banner__btn--ghost" id="cookie-banner-decline">Decline</button>
|
|
26
|
+
<button type="button" class="cookie-banner__btn cookie-banner__btn--primary" id="cookie-banner-accept">Accept</button>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<script>
|
|
31
|
+
import { isDecided, setConsent } from '../lib/consent-client';
|
|
32
|
+
|
|
33
|
+
const banner = document.getElementById('cookie-banner');
|
|
34
|
+
if (banner && !isDecided()) {
|
|
35
|
+
banner.hidden = false;
|
|
36
|
+
// Next frame so the entry transition runs.
|
|
37
|
+
requestAnimationFrame(() => banner.classList.add('visible'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dismiss(granted: boolean) {
|
|
41
|
+
setConsent(granted);
|
|
42
|
+
banner?.classList.remove('visible');
|
|
43
|
+
if (banner) setTimeout(() => { banner.hidden = true; }, 400);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
document.getElementById('cookie-banner-accept')?.addEventListener('click', () => dismiss(true));
|
|
47
|
+
document.getElementById('cookie-banner-decline')?.addEventListener('click', () => dismiss(false));
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<style is:global>
|
|
51
|
+
.cookie-banner {
|
|
52
|
+
position: fixed;
|
|
53
|
+
left: 20px;
|
|
54
|
+
right: 20px;
|
|
55
|
+
bottom: 20px;
|
|
56
|
+
z-index: 210;
|
|
57
|
+
max-width: 40rem;
|
|
58
|
+
margin-inline: auto;
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-wrap: wrap;
|
|
61
|
+
align-items: center;
|
|
62
|
+
justify-content: space-between;
|
|
63
|
+
gap: 0.75rem 1.25rem;
|
|
64
|
+
padding: 1rem 1.2rem;
|
|
65
|
+
background: var(--bg-surface);
|
|
66
|
+
border: 1px solid var(--border);
|
|
67
|
+
border-radius: 14px;
|
|
68
|
+
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.18);
|
|
69
|
+
transform: translateY(12px);
|
|
70
|
+
opacity: 0;
|
|
71
|
+
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s ease;
|
|
72
|
+
}
|
|
73
|
+
.cookie-banner.visible {
|
|
74
|
+
transform: translateY(0);
|
|
75
|
+
opacity: 1;
|
|
76
|
+
}
|
|
77
|
+
@media (prefers-reduced-motion: reduce) {
|
|
78
|
+
.cookie-banner { transition: opacity 0.2s ease; transform: none; }
|
|
79
|
+
}
|
|
80
|
+
.cookie-banner__text {
|
|
81
|
+
margin: 0;
|
|
82
|
+
flex: 1 1 18rem;
|
|
83
|
+
min-width: 0;
|
|
84
|
+
font-family: var(--font-body);
|
|
85
|
+
font-size: 0.85rem;
|
|
86
|
+
line-height: 1.55;
|
|
87
|
+
color: var(--muted);
|
|
88
|
+
}
|
|
89
|
+
.cookie-banner__text a {
|
|
90
|
+
color: var(--muted);
|
|
91
|
+
text-decoration: underline;
|
|
92
|
+
text-underline-offset: 2px;
|
|
93
|
+
}
|
|
94
|
+
.cookie-banner__text a:hover { color: var(--fg); }
|
|
95
|
+
.cookie-banner__actions {
|
|
96
|
+
display: flex;
|
|
97
|
+
gap: 0.6rem;
|
|
98
|
+
flex-shrink: 0;
|
|
99
|
+
}
|
|
100
|
+
.cookie-banner__btn {
|
|
101
|
+
font: inherit;
|
|
102
|
+
font-family: var(--font-heading);
|
|
103
|
+
font-size: 0.85rem;
|
|
104
|
+
font-weight: 600;
|
|
105
|
+
padding: 0.5rem 1.1rem;
|
|
106
|
+
border-radius: var(--radius-sm);
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
transition: filter 0.15s ease, background 0.15s ease, color 0.15s ease;
|
|
109
|
+
}
|
|
110
|
+
.cookie-banner__btn--primary {
|
|
111
|
+
border: none;
|
|
112
|
+
background: var(--gold);
|
|
113
|
+
color: var(--text-light);
|
|
114
|
+
}
|
|
115
|
+
.cookie-banner__btn--primary:hover { filter: brightness(0.94); }
|
|
116
|
+
.cookie-banner__btn--ghost {
|
|
117
|
+
border: 1px solid var(--border);
|
|
118
|
+
background: transparent;
|
|
119
|
+
color: var(--fg);
|
|
120
|
+
}
|
|
121
|
+
.cookie-banner__btn--ghost:hover { background: var(--bg-section); }
|
|
122
|
+
.cookie-banner__btn:focus-visible {
|
|
123
|
+
outline: 2px solid var(--gold);
|
|
124
|
+
outline-offset: 2px;
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
@@ -29,16 +29,16 @@
|
|
|
29
29
|
|
|
30
30
|
<div class="cookie-prefs__row">
|
|
31
31
|
<div class="cookie-prefs__row-text">
|
|
32
|
-
<p class="cookie-prefs__row-title">
|
|
33
|
-
<p class="cookie-prefs__row-desc">
|
|
32
|
+
<p class="cookie-prefs__row-title">Embedded content</p>
|
|
33
|
+
<p class="cookie-prefs__row-desc">Lets our <a href="/calendar/">calendar</a> and maps load from Google. Google may set its own cookies once loaded.</p>
|
|
34
34
|
</div>
|
|
35
35
|
<button
|
|
36
36
|
type="button"
|
|
37
37
|
class="cookie-prefs__switch"
|
|
38
|
-
id="cookie-prefs-
|
|
38
|
+
id="cookie-prefs-embed-toggle"
|
|
39
39
|
role="switch"
|
|
40
40
|
aria-checked="false"
|
|
41
|
-
aria-label="Allow
|
|
41
|
+
aria-label="Allow embedded content (maps and calendar)"
|
|
42
42
|
>
|
|
43
43
|
<span class="cookie-prefs__switch-thumb"></span>
|
|
44
44
|
</button>
|
|
@@ -197,15 +197,14 @@
|
|
|
197
197
|
</style>
|
|
198
198
|
|
|
199
199
|
<script>
|
|
200
|
-
|
|
200
|
+
import { hasConsent, setConsent, onConsentChange } from '../lib/consent-client';
|
|
201
201
|
|
|
202
202
|
const panel = document.getElementById('cookie-prefs') as HTMLElement;
|
|
203
203
|
const closeBtn = document.getElementById('cookie-prefs-close') as HTMLButtonElement;
|
|
204
|
-
const
|
|
204
|
+
const embedToggle = document.getElementById('cookie-prefs-embed-toggle') as HTMLButtonElement;
|
|
205
205
|
|
|
206
206
|
function syncToggle() {
|
|
207
|
-
|
|
208
|
-
calendarToggle.setAttribute('aria-checked', String(allowed));
|
|
207
|
+
embedToggle.setAttribute('aria-checked', String(hasConsent()));
|
|
209
208
|
}
|
|
210
209
|
|
|
211
210
|
function open() {
|
|
@@ -217,13 +216,14 @@
|
|
|
217
216
|
panel.classList.remove('visible');
|
|
218
217
|
}
|
|
219
218
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
localStorage.setItem(CALENDAR_KEY, next ? '1' : '0');
|
|
224
|
-
document.dispatchEvent(new CustomEvent('calendar-consent-changed', { detail: { allowed: next } }));
|
|
219
|
+
embedToggle.addEventListener('click', () => {
|
|
220
|
+
setConsent(embedToggle.getAttribute('aria-checked') !== 'true');
|
|
221
|
+
syncToggle();
|
|
225
222
|
});
|
|
226
223
|
|
|
224
|
+
// Keep the switch in sync if consent changes elsewhere (e.g. the banner).
|
|
225
|
+
onConsentChange(syncToggle);
|
|
226
|
+
|
|
227
227
|
closeBtn.addEventListener('click', close);
|
|
228
228
|
document.addEventListener('keydown', (e) => {
|
|
229
229
|
if (e.key === 'Escape' && panel.classList.contains('visible')) close();
|
|
@@ -12,8 +12,14 @@ interface Props {
|
|
|
12
12
|
heroImage?: string;
|
|
13
13
|
/** Optional tag labels shown as a small eyebrow. */
|
|
14
14
|
tagLabels?: string[];
|
|
15
|
+
/** Open the link in a new tab (e.g. a post that's just an attached PDF). */
|
|
16
|
+
newTab?: boolean;
|
|
17
|
+
/** The call-to-action label (default "Read more"). */
|
|
18
|
+
moreLabel?: string;
|
|
15
19
|
}
|
|
16
|
-
const { href, title, date, summary, heroImage, tagLabels = [] } = Astro.props;
|
|
20
|
+
const { href, title, date, summary, heroImage, tagLabels = [], newTab = false, moreLabel = 'Read more' } = Astro.props;
|
|
21
|
+
const linkRel = newTab ? 'noopener' : undefined;
|
|
22
|
+
const linkTarget = newTab ? '_blank' : undefined;
|
|
17
23
|
const d = date instanceof Date ? date : new Date(date);
|
|
18
24
|
const iso = Number.isNaN(d.valueOf()) ? undefined : d.toISOString().slice(0, 10);
|
|
19
25
|
const display = Number.isNaN(d.valueOf())
|
|
@@ -23,7 +29,7 @@ const display = Number.isNaN(d.valueOf())
|
|
|
23
29
|
|
|
24
30
|
<article class="b-post-card">
|
|
25
31
|
{heroImage && (
|
|
26
|
-
<a class="b-post-card__media" href={href} tabindex="-1" aria-hidden="true">
|
|
32
|
+
<a class="b-post-card__media" href={href} target={linkTarget} rel={linkRel} tabindex="-1" aria-hidden="true">
|
|
27
33
|
<img src={heroImage} alt="" loading="lazy" />
|
|
28
34
|
</a>
|
|
29
35
|
)}
|
|
@@ -33,10 +39,10 @@ const display = Number.isNaN(d.valueOf())
|
|
|
33
39
|
{display && <time datetime={iso}>{display}</time>}
|
|
34
40
|
</div>
|
|
35
41
|
<h3 class="b-post-card__title">
|
|
36
|
-
<a href={href}>{title}</a>
|
|
42
|
+
<a href={href} target={linkTarget} rel={linkRel}>{title}</a>
|
|
37
43
|
</h3>
|
|
38
44
|
{summary && <p class="b-post-card__summary">{summary}</p>}
|
|
39
|
-
<span class="b-post-card__more" aria-hidden="true">
|
|
45
|
+
<span class="b-post-card__more" aria-hidden="true">{moreLabel}</span>
|
|
40
46
|
</div>
|
|
41
47
|
</article>
|
|
42
48
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ContactCard — a Level-2 recipe: a rich, branded "get in touch" band. A gradient
|
|
4
|
+
* panel (anchored on --ink so it stays a deep, warm brand tone in BOTH themes)
|
|
5
|
+
* carries a display-type title + a subtitle on the left, the contact channels as
|
|
6
|
+
* tappable pills on the right, and a closing note across the bottom. For a contact
|
|
7
|
+
* or find-us page — the polished alternative to a plain notice box.
|
|
8
|
+
*
|
|
9
|
+
* Token-driven: the gradient is --ink → brand-tinted, pills are translucent
|
|
10
|
+
* recesses on it, icons + hovers use --gold, so a CMS colour change recolours the
|
|
11
|
+
* whole band. Whole pill links when `href` is set.
|
|
12
|
+
*/
|
|
13
|
+
import Icon from '../Icon.astro';
|
|
14
|
+
import { isIconKey } from '../../lib/icons';
|
|
15
|
+
|
|
16
|
+
interface Item {
|
|
17
|
+
icon?: string;
|
|
18
|
+
value: string;
|
|
19
|
+
href?: string;
|
|
20
|
+
}
|
|
21
|
+
interface Props {
|
|
22
|
+
eyebrow?: string;
|
|
23
|
+
title: string;
|
|
24
|
+
subtitle?: string;
|
|
25
|
+
note?: string;
|
|
26
|
+
items?: Item[];
|
|
27
|
+
}
|
|
28
|
+
const { eyebrow, title, subtitle, note, items = [] } = Astro.props;
|
|
29
|
+
const rel = (href?: string) => (href && /^https?:\/\//.test(href) ? 'noopener' : undefined);
|
|
30
|
+
const target = (href?: string) => (href && /^https?:\/\//.test(href) ? '_blank' : undefined);
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<section class="b-contact">
|
|
34
|
+
<div class="b-contact__panel">
|
|
35
|
+
<div class="b-contact__head">
|
|
36
|
+
{eyebrow && <p class="b-contact__eyebrow">{eyebrow}</p>}
|
|
37
|
+
<h2 class="b-contact__title">{title}</h2>
|
|
38
|
+
{subtitle && <p class="b-contact__subtitle">{subtitle}</p>}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
{items.length > 0 && (
|
|
42
|
+
<ul class="b-contact__items">
|
|
43
|
+
{items.map((item) => {
|
|
44
|
+
const inner = (
|
|
45
|
+
<>
|
|
46
|
+
{item.icon && isIconKey(item.icon) && (
|
|
47
|
+
<span class="b-contact__icon" aria-hidden="true"><Icon name={item.icon} size={18} /></span>
|
|
48
|
+
)}
|
|
49
|
+
<span class="b-contact__value">{item.value}</span>
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
return (
|
|
53
|
+
<li>
|
|
54
|
+
{item.href ? (
|
|
55
|
+
<a class="b-contact__item" href={item.href} target={target(item.href)} rel={rel(item.href)}>{inner}</a>
|
|
56
|
+
) : (
|
|
57
|
+
<div class="b-contact__item">{inner}</div>
|
|
58
|
+
)}
|
|
59
|
+
</li>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</ul>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
{note && <p class="b-contact__note">{note}</p>}
|
|
66
|
+
</div>
|
|
67
|
+
</section>
|
|
68
|
+
|
|
69
|
+
<style>
|
|
70
|
+
.b-contact {
|
|
71
|
+
max-width: 72rem;
|
|
72
|
+
margin-inline: auto;
|
|
73
|
+
padding-inline: 1rem;
|
|
74
|
+
}
|
|
75
|
+
/* Anchored on --ink (a source colour that stays dark in both themes) so the band
|
|
76
|
+
reads as a deep, warm brand panel regardless of light/dark. */
|
|
77
|
+
.b-contact__panel {
|
|
78
|
+
display: grid;
|
|
79
|
+
grid-template-columns: 1fr;
|
|
80
|
+
gap: 1.5rem 2.5rem;
|
|
81
|
+
padding: clamp(1.75rem, 4vw, 3rem);
|
|
82
|
+
border-radius: var(--radius);
|
|
83
|
+
background: linear-gradient(135deg, var(--ink), color-mix(in srgb, var(--gold) 26%, var(--ink)));
|
|
84
|
+
color: var(--text-light);
|
|
85
|
+
box-shadow: var(--shadow-md);
|
|
86
|
+
}
|
|
87
|
+
@media (min-width: 48rem) {
|
|
88
|
+
.b-contact__panel { grid-template-columns: 1.25fr 1fr; align-items: center; }
|
|
89
|
+
}
|
|
90
|
+
.b-contact__head { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
91
|
+
.b-contact__eyebrow {
|
|
92
|
+
margin: 0;
|
|
93
|
+
font-family: var(--font-heading);
|
|
94
|
+
font-size: 0.75rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
|
|
95
|
+
color: color-mix(in srgb, var(--text-light) 70%, transparent);
|
|
96
|
+
}
|
|
97
|
+
.b-contact__title {
|
|
98
|
+
margin: 0;
|
|
99
|
+
font-family: var(--font-display, var(--font-heading));
|
|
100
|
+
font-size: clamp(1.9rem, 4vw, 2.8rem);
|
|
101
|
+
line-height: 1.05;
|
|
102
|
+
text-wrap: balance;
|
|
103
|
+
color: var(--text-light);
|
|
104
|
+
}
|
|
105
|
+
.b-contact__subtitle {
|
|
106
|
+
margin: 0.15rem 0 0;
|
|
107
|
+
font-family: var(--font-heading);
|
|
108
|
+
font-weight: 600;
|
|
109
|
+
color: var(--gold);
|
|
110
|
+
}
|
|
111
|
+
.b-contact__items {
|
|
112
|
+
list-style: none;
|
|
113
|
+
margin: 0; padding: 0;
|
|
114
|
+
display: flex; flex-direction: column; gap: 0.6rem;
|
|
115
|
+
}
|
|
116
|
+
.b-contact__item {
|
|
117
|
+
display: flex; align-items: center; gap: 0.75rem;
|
|
118
|
+
padding: 0.7rem 1rem;
|
|
119
|
+
border-radius: var(--radius-sm);
|
|
120
|
+
background: color-mix(in srgb, #000 22%, transparent);
|
|
121
|
+
border: 1px solid color-mix(in srgb, #fff 14%, transparent);
|
|
122
|
+
color: var(--text-light);
|
|
123
|
+
font-family: var(--font-heading);
|
|
124
|
+
font-weight: 600;
|
|
125
|
+
font-size: 0.95rem;
|
|
126
|
+
text-decoration: none;
|
|
127
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
128
|
+
}
|
|
129
|
+
a.b-contact__item:hover {
|
|
130
|
+
border-color: var(--gold);
|
|
131
|
+
background: color-mix(in srgb, #000 12%, transparent);
|
|
132
|
+
}
|
|
133
|
+
a.b-contact__item:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
|
134
|
+
.b-contact__icon { display: inline-flex; color: var(--gold); flex: 0 0 auto; }
|
|
135
|
+
.b-contact__value { min-width: 0; overflow-wrap: anywhere; }
|
|
136
|
+
.b-contact__note {
|
|
137
|
+
grid-column: 1 / -1;
|
|
138
|
+
margin: 0;
|
|
139
|
+
padding-top: 1.25rem;
|
|
140
|
+
border-top: 1px solid color-mix(in srgb, #fff 14%, transparent);
|
|
141
|
+
color: color-mix(in srgb, var(--text-light) 78%, transparent);
|
|
142
|
+
font-size: 0.9rem;
|
|
143
|
+
line-height: 1.6;
|
|
144
|
+
}
|
|
145
|
+
</style>
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
* question/answer items. Uses native <details>/<summary> — accessible and
|
|
4
4
|
* interactive with zero JavaScript. Composes the Heading primitive. */
|
|
5
5
|
import Heading from '../blocks/Heading.astro';
|
|
6
|
+
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
title?: string;
|
|
9
10
|
items?: { question: string; answer: string }[];
|
|
10
11
|
}
|
|
11
12
|
const { title, items = [] } = Astro.props;
|
|
13
|
+
// Answers may carry a link or bold via a tiny markdown subset (inlineMarkdown),
|
|
14
|
+
// split into paragraphs on blank lines. Trusted editor/CMS text, sanitised.
|
|
15
|
+
const paragraphs = (answer: string) => answer.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
|
|
12
16
|
---
|
|
13
17
|
|
|
14
18
|
<section class="b-faq">
|
|
@@ -17,7 +21,9 @@ const { title, items = [] } = Astro.props;
|
|
|
17
21
|
{items.map((item) => (
|
|
18
22
|
<details class="b-faq__item">
|
|
19
23
|
<summary class="b-faq__q">{item.question}</summary>
|
|
20
|
-
<div class="b-faq__a"
|
|
24
|
+
<div class="b-faq__a">
|
|
25
|
+
{paragraphs(item.answer).map((p) => <p set:html={inlineMarkdown(p)} />)}
|
|
26
|
+
</div>
|
|
21
27
|
</details>
|
|
22
28
|
))}
|
|
23
29
|
</div>
|
|
@@ -11,7 +11,7 @@ import PostCard from '../PostCard.astro';
|
|
|
11
11
|
import Heading from '../blocks/Heading.astro';
|
|
12
12
|
import Button from '../Button.astro';
|
|
13
13
|
import { getIndexPosts, getPostsByTag } from '../../lib/posts';
|
|
14
|
-
import { isRecent } from '../../content/posts';
|
|
14
|
+
import { isRecent, postLink } from '../../content/posts';
|
|
15
15
|
|
|
16
16
|
interface Props {
|
|
17
17
|
title?: string;
|
|
@@ -68,12 +68,12 @@ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
|
|
|
68
68
|
<ul class="b-latest-posts__list">
|
|
69
69
|
{posts.map((p, i) => {
|
|
70
70
|
const { iso, label } = fmt(p.data.date);
|
|
71
|
-
const href =
|
|
71
|
+
const { href, newTab } = postLink(p);
|
|
72
72
|
const cats = (p.data.tags ?? []).slice(0, 2);
|
|
73
73
|
return (
|
|
74
74
|
<li class="b-post-row">
|
|
75
75
|
{p.data.heroImage && (
|
|
76
|
-
<a class="b-post-row__media" href={href} tabindex="-1" aria-hidden="true">
|
|
76
|
+
<a class="b-post-row__media" href={href} target={newTab ? '_blank' : undefined} rel={newTab ? 'noopener' : undefined} tabindex="-1" aria-hidden="true">
|
|
77
77
|
<img src={p.data.heroImage} alt="" loading="lazy" />
|
|
78
78
|
</a>
|
|
79
79
|
)}
|
|
@@ -81,7 +81,7 @@ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
|
|
|
81
81
|
{cats.length > 0 && (
|
|
82
82
|
<span class="b-post-row__cat">{cats.join(' · ')}</span>
|
|
83
83
|
)}
|
|
84
|
-
<h3 class="b-post-row__title"><a href={href}>{p.data.title}</a></h3>
|
|
84
|
+
<h3 class="b-post-row__title"><a href={href} target={newTab ? '_blank' : undefined} rel={newTab ? 'noopener' : undefined}>{p.data.title}</a></h3>
|
|
85
85
|
</div>
|
|
86
86
|
<div class="b-post-row__aside">
|
|
87
87
|
{i === 0 && isNew(p.data.date) && <span class="b-post-row__new">New</span>}
|
|
@@ -94,16 +94,21 @@ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
|
|
|
94
94
|
</ul>
|
|
95
95
|
) : (
|
|
96
96
|
<div class="b-latest-posts__grid">
|
|
97
|
-
{posts.map((p) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
97
|
+
{posts.map((p) => {
|
|
98
|
+
const { href, newTab } = postLink(p);
|
|
99
|
+
return (
|
|
100
|
+
<PostCard
|
|
101
|
+
href={href}
|
|
102
|
+
newTab={newTab}
|
|
103
|
+
moreLabel={newTab ? 'Open PDF' : undefined}
|
|
104
|
+
title={p.data.title}
|
|
105
|
+
date={p.data.date}
|
|
106
|
+
summary={p.data.summary}
|
|
107
|
+
heroImage={p.data.heroImage}
|
|
108
|
+
tagLabels={p.data.tags}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
107
112
|
</div>
|
|
108
113
|
)}
|
|
109
114
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Locations ("Where to find us") — a Level-2 recipe: one or more places, each a
|
|
4
|
+
* neat tile with a consent-gated map on top and its name / address / directions
|
|
5
|
+
* below. Replaces stacking bare map embeds. The map loads once the visitor accepts
|
|
6
|
+
* the site cookie banner (shared consent — no per-tile gate for anyone who
|
|
7
|
+
* accepted); the per-tile "Show map" button is a fallback that grants the same
|
|
8
|
+
* consent. Token-driven, equal-height tiles. https embed URLs only.
|
|
9
|
+
*/
|
|
10
|
+
import Icon from '../Icon.astro';
|
|
11
|
+
import Heading from '../blocks/Heading.astro';
|
|
12
|
+
import { isSafeEmbedUrl } from '../../content/calendar';
|
|
13
|
+
|
|
14
|
+
interface Item {
|
|
15
|
+
name: string;
|
|
16
|
+
address?: string;
|
|
17
|
+
embedUrl: string;
|
|
18
|
+
directionsUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
interface Props {
|
|
21
|
+
title?: string;
|
|
22
|
+
intro?: string;
|
|
23
|
+
columns?: 1 | 2;
|
|
24
|
+
provider?: string;
|
|
25
|
+
items?: Item[];
|
|
26
|
+
}
|
|
27
|
+
const { title, intro, columns = 2, provider = 'Google Maps', items = [] } = Astro.props;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<section class="b-locations">
|
|
31
|
+
{(title || intro) && (
|
|
32
|
+
<div class="b-locations__head">
|
|
33
|
+
{title && <Heading text={title} level={2} />}
|
|
34
|
+
{intro && <p class="b-locations__intro">{intro}</p>}
|
|
35
|
+
</div>
|
|
36
|
+
)}
|
|
37
|
+
<div class="b-locations__grid" data-cols={columns}>
|
|
38
|
+
{items.map((item) => (
|
|
39
|
+
<article class="b-locations__card">
|
|
40
|
+
<div class="b-locations__map">
|
|
41
|
+
{isSafeEmbedUrl(item.embedUrl) ? (
|
|
42
|
+
<div class="b-loc-map" data-embed-url={item.embedUrl} data-title={`Map — ${item.name}`}>
|
|
43
|
+
<div class="b-loc-map__consent">
|
|
44
|
+
<p class="b-loc-map__text">This map is provided by {provider}.</p>
|
|
45
|
+
<button type="button" class="b-loc-map__load">Show map</button>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="b-loc-map__frame"></div>
|
|
48
|
+
</div>
|
|
49
|
+
) : (
|
|
50
|
+
<div class="b-loc-map__fallback" aria-hidden="true"><Icon name="map-pin" size={30} /></div>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
<div class="b-locations__body">
|
|
54
|
+
<h3 class="b-locations__name">{item.name}</h3>
|
|
55
|
+
{item.address && <p class="b-locations__address">{item.address}</p>}
|
|
56
|
+
{item.directionsUrl && (
|
|
57
|
+
<a class="b-locations__dir" href={item.directionsUrl} target="_blank" rel="noopener">
|
|
58
|
+
Get directions <span aria-hidden="true">→</span>
|
|
59
|
+
</a>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
</article>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
|
|
67
|
+
<script>
|
|
68
|
+
// Shared site consent: accepting the cookie banner auto-loads every map here.
|
|
69
|
+
import { hasConsent, setConsent, onConsentChange } from '../../lib/consent-client';
|
|
70
|
+
|
|
71
|
+
const maps = document.querySelectorAll<HTMLElement>('.b-loc-map');
|
|
72
|
+
function load(el: HTMLElement) {
|
|
73
|
+
if (el.dataset.loaded === 'true') return;
|
|
74
|
+
const url = el.dataset.embedUrl;
|
|
75
|
+
const slot = el.querySelector<HTMLElement>('.b-loc-map__frame');
|
|
76
|
+
if (!url || !slot) return;
|
|
77
|
+
const frame = document.createElement('iframe');
|
|
78
|
+
frame.src = url;
|
|
79
|
+
frame.title = el.dataset.title || 'Map';
|
|
80
|
+
frame.loading = 'lazy';
|
|
81
|
+
frame.referrerPolicy = 'no-referrer-when-downgrade';
|
|
82
|
+
frame.style.width = '100%';
|
|
83
|
+
frame.style.height = '100%';
|
|
84
|
+
frame.style.border = '0';
|
|
85
|
+
slot.replaceChildren(frame);
|
|
86
|
+
el.dataset.loaded = 'true';
|
|
87
|
+
const consent = el.querySelector<HTMLElement>('.b-loc-map__consent');
|
|
88
|
+
if (consent) consent.hidden = true;
|
|
89
|
+
}
|
|
90
|
+
const loadAll = () => maps.forEach(load);
|
|
91
|
+
|
|
92
|
+
if (hasConsent()) loadAll();
|
|
93
|
+
onConsentChange((allowed) => { if (allowed) loadAll(); });
|
|
94
|
+
maps.forEach((el) => {
|
|
95
|
+
el.querySelector<HTMLButtonElement>('.b-loc-map__load')?.addEventListener('click', () => setConsent(true));
|
|
96
|
+
});
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<style>
|
|
100
|
+
.b-locations {
|
|
101
|
+
display: flex;
|
|
102
|
+
flex-direction: column;
|
|
103
|
+
gap: 1.75rem;
|
|
104
|
+
max-width: 72rem;
|
|
105
|
+
margin-inline: auto;
|
|
106
|
+
padding-inline: 1rem;
|
|
107
|
+
}
|
|
108
|
+
.b-locations__head { display: flex; flex-direction: column; gap: 0.6rem; }
|
|
109
|
+
.b-locations__intro { margin: 0; color: var(--muted); max-width: var(--max); }
|
|
110
|
+
.b-locations__grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
|
|
111
|
+
@media (min-width: 44em) {
|
|
112
|
+
.b-locations__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
|
|
113
|
+
}
|
|
114
|
+
.b-locations__card {
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-direction: column;
|
|
117
|
+
background: var(--bg-surface);
|
|
118
|
+
border: var(--card-border);
|
|
119
|
+
border-radius: var(--radius);
|
|
120
|
+
box-shadow: var(--shadow-sm);
|
|
121
|
+
overflow: hidden;
|
|
122
|
+
}
|
|
123
|
+
/* Map region — uniform ratio; the consent card fills it until the iframe loads. */
|
|
124
|
+
.b-locations__map { position: relative; aspect-ratio: 4 / 3; background: var(--bg-section); }
|
|
125
|
+
.b-loc-map, .b-loc-map__frame, .b-loc-map__consent, .b-loc-map__fallback { position: absolute; inset: 0; }
|
|
126
|
+
.b-loc-map__frame :global(iframe) { display: block; }
|
|
127
|
+
.b-loc-map__consent {
|
|
128
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
129
|
+
gap: 0.75rem; padding: 1.5rem; text-align: center;
|
|
130
|
+
}
|
|
131
|
+
.b-loc-map__consent[hidden] { display: none; }
|
|
132
|
+
.b-loc-map__text { margin: 0; color: var(--muted); font-size: 0.9rem; max-width: 32ch; }
|
|
133
|
+
.b-loc-map__load {
|
|
134
|
+
font: inherit; font-family: var(--font-heading); font-weight: 600; font-size: 0.9rem;
|
|
135
|
+
padding: 0.5rem 1.1rem; border: none; border-radius: var(--radius-sm);
|
|
136
|
+
background: var(--gold); color: var(--text-light); cursor: pointer;
|
|
137
|
+
transition: filter 0.15s ease;
|
|
138
|
+
}
|
|
139
|
+
.b-loc-map__load:hover { filter: brightness(0.94); }
|
|
140
|
+
.b-loc-map__load:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
|
141
|
+
.b-loc-map__fallback { display: flex; align-items: center; justify-content: center; color: var(--muted); }
|
|
142
|
+
.b-locations__body { display: flex; flex-direction: column; gap: 0.4rem; padding: 1.1rem 1.2rem 1.3rem; }
|
|
143
|
+
.b-locations__name { margin: 0; font-size: 1.15rem; }
|
|
144
|
+
.b-locations__address { margin: 0; color: var(--muted); line-height: 1.55; white-space: pre-line; }
|
|
145
|
+
.b-locations__dir {
|
|
146
|
+
margin-top: 0.35rem;
|
|
147
|
+
font-family: var(--font-heading); font-weight: 600; font-size: 0.9rem;
|
|
148
|
+
color: var(--gold);
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
@@ -71,43 +71,37 @@ const safe = isSafeEmbedUrl(embedUrl);
|
|
|
71
71
|
</section>
|
|
72
72
|
|
|
73
73
|
<script>
|
|
74
|
-
// Bundled (
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
// Bundled (runs once even with multiple maps on a page). Uses the shared site
|
|
75
|
+
// embed consent: accepting the cookie banner (or the footer panel) auto-loads
|
|
76
|
+
// every embed. The per-embed button grants that same consent as a fallback.
|
|
77
|
+
import { hasConsent, setConsent, onConsentChange } from '../../lib/consent-client';
|
|
78
|
+
|
|
79
|
+
const embeds = document.querySelectorAll<HTMLElement>('.b-map__embed');
|
|
80
|
+
|
|
81
|
+
function load(el: HTMLElement) {
|
|
82
|
+
if (el.dataset.loaded === 'true') return;
|
|
78
83
|
const url = el.dataset.embedUrl;
|
|
79
|
-
if (!url) return;
|
|
80
|
-
const height = el.dataset.height || '450';
|
|
81
|
-
const title = el.dataset.title || 'Map';
|
|
82
84
|
const frameSlot = el.querySelector<HTMLElement>('.b-map__frame');
|
|
85
|
+
if (!url || !frameSlot) return;
|
|
86
|
+
const frame = document.createElement('iframe');
|
|
87
|
+
frame.src = url;
|
|
88
|
+
frame.title = el.dataset.title || 'Map';
|
|
89
|
+
frame.loading = 'lazy';
|
|
90
|
+
frame.referrerPolicy = 'no-referrer-when-downgrade';
|
|
91
|
+
frame.style.width = '100%';
|
|
92
|
+
frame.style.height = (el.dataset.height || '450') + 'px';
|
|
93
|
+
frame.style.border = '0';
|
|
94
|
+
frameSlot.replaceChildren(frame);
|
|
95
|
+
el.dataset.loaded = 'true';
|
|
83
96
|
const consent = el.querySelector<HTMLElement>('.b-map__consent');
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (el.dataset.loaded === 'true' || !frameSlot) return;
|
|
88
|
-
const frame = document.createElement('iframe');
|
|
89
|
-
frame.src = url as string;
|
|
90
|
-
frame.title = title;
|
|
91
|
-
frame.loading = 'lazy';
|
|
92
|
-
frame.referrerPolicy = 'no-referrer-when-downgrade';
|
|
93
|
-
frame.style.width = '100%';
|
|
94
|
-
frame.style.height = height + 'px';
|
|
95
|
-
frame.style.border = '0';
|
|
96
|
-
frameSlot.replaceChildren(frame);
|
|
97
|
-
el.dataset.loaded = 'true';
|
|
98
|
-
if (consent) consent.hidden = true;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
if (localStorage.getItem(CONSENT_KEY) === '1') load();
|
|
103
|
-
} catch (_) {}
|
|
97
|
+
if (consent) consent.hidden = true;
|
|
98
|
+
}
|
|
99
|
+
const loadAll = () => embeds.forEach(load);
|
|
104
100
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
load();
|
|
110
|
-
});
|
|
101
|
+
if (hasConsent()) loadAll();
|
|
102
|
+
onConsentChange((allowed) => { if (allowed) loadAll(); });
|
|
103
|
+
embeds.forEach((el) => {
|
|
104
|
+
el.querySelector<HTMLButtonElement>('.b-map__load')?.addEventListener('click', () => setConsent(true));
|
|
111
105
|
});
|
|
112
106
|
</script>
|
|
113
107
|
|
package/content/blocks.ts
CHANGED
|
@@ -480,6 +480,47 @@ export const mediaCardsBlock = z.object({
|
|
|
480
480
|
.default([]),
|
|
481
481
|
});
|
|
482
482
|
|
|
483
|
+
/** ContactCard — a branded "get in touch" band: display title + subtitle on the
|
|
484
|
+
* left, contact channels as tappable pills on the right, a note across the bottom. */
|
|
485
|
+
export const contactCardBlock = z.object({
|
|
486
|
+
type: z.literal('contactCard'),
|
|
487
|
+
enabled: z.boolean().default(true),
|
|
488
|
+
eyebrow: z.string().optional(),
|
|
489
|
+
title: z.string(),
|
|
490
|
+
subtitle: z.string().optional(),
|
|
491
|
+
note: z.string().optional(),
|
|
492
|
+
items: z
|
|
493
|
+
.array(
|
|
494
|
+
z.object({
|
|
495
|
+
icon: z.string().optional(),
|
|
496
|
+
value: z.string(),
|
|
497
|
+
href: z.string().optional(),
|
|
498
|
+
})
|
|
499
|
+
)
|
|
500
|
+
.default([]),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
/** Locations ("Where to find us") — location tiles, each a consent-gated map over
|
|
504
|
+
* a name / address / directions. https embed URLs only (isSafeEmbedUrl). */
|
|
505
|
+
export const locationsBlock = z.object({
|
|
506
|
+
type: z.literal('locations'),
|
|
507
|
+
enabled: z.boolean().default(true),
|
|
508
|
+
title: z.string().optional(),
|
|
509
|
+
intro: z.string().optional(),
|
|
510
|
+
columns: z.union([z.literal(1), z.literal(2)]).default(2),
|
|
511
|
+
provider: z.string().default('Google Maps'),
|
|
512
|
+
items: z
|
|
513
|
+
.array(
|
|
514
|
+
z.object({
|
|
515
|
+
name: z.string(),
|
|
516
|
+
address: z.string().optional(),
|
|
517
|
+
embedUrl: z.string(),
|
|
518
|
+
directionsUrl: z.string().optional(),
|
|
519
|
+
})
|
|
520
|
+
)
|
|
521
|
+
.default([]),
|
|
522
|
+
});
|
|
523
|
+
|
|
483
524
|
export type RecipeBlock =
|
|
484
525
|
| z.infer<typeof heroBlock>
|
|
485
526
|
| z.infer<typeof featureCardsBlock>
|
|
@@ -503,7 +544,9 @@ export type RecipeBlock =
|
|
|
503
544
|
| z.infer<typeof mapEmbedBlock>
|
|
504
545
|
| z.infer<typeof tableBlock>
|
|
505
546
|
| z.infer<typeof noticeBlock>
|
|
506
|
-
| z.infer<typeof mediaCardsBlock
|
|
547
|
+
| z.infer<typeof mediaCardsBlock>
|
|
548
|
+
| z.infer<typeof contactCardBlock>
|
|
549
|
+
| z.infer<typeof locationsBlock>;
|
|
507
550
|
|
|
508
551
|
/* ── Level-1 layout / container primitives ─────────────────────────────────
|
|
509
552
|
* These hold child blocks, so the schema is **recursive** (a container's
|
|
@@ -612,6 +655,8 @@ export const blockSchema = z.discriminatedUnion('type', [
|
|
|
612
655
|
tableBlock,
|
|
613
656
|
noticeBlock,
|
|
614
657
|
mediaCardsBlock,
|
|
658
|
+
contactCardBlock,
|
|
659
|
+
locationsBlock,
|
|
615
660
|
sectionBlock,
|
|
616
661
|
gridBlock,
|
|
617
662
|
stackBlock,
|
|
@@ -644,6 +689,23 @@ export const pageSchema = z.object({
|
|
|
644
689
|
* bespoke page may have none.
|
|
645
690
|
*/
|
|
646
691
|
templateId: z.string().optional(),
|
|
692
|
+
/**
|
|
693
|
+
* Editor change-requests captured in the CMS — the seed of AI-driven editing
|
|
694
|
+
* ([[ferst_editing_ai_first_pdlc]]): rather than let editors edit block JSON
|
|
695
|
+
* directly (easy to break), they describe a desired change against the page and
|
|
696
|
+
* it's stored here for an agent to action later → PR → human verify. NEVER
|
|
697
|
+
* rendered; carried through the schema so a CMS save preserves it.
|
|
698
|
+
*/
|
|
699
|
+
requests: z
|
|
700
|
+
.array(
|
|
701
|
+
z.object({
|
|
702
|
+
text: z.string(),
|
|
703
|
+
at: z.string().optional(),
|
|
704
|
+
by: z.string().optional(),
|
|
705
|
+
status: z.enum(['open', 'done']).default('open'),
|
|
706
|
+
}),
|
|
707
|
+
)
|
|
708
|
+
.default([]),
|
|
647
709
|
/** Keep the JSON in the repo but exclude it from the build. */
|
|
648
710
|
draft: z.boolean().default(false),
|
|
649
711
|
blocks: z.array(blockSchema).default([]),
|
package/content/posts.ts
CHANGED
|
@@ -88,6 +88,23 @@ export function tagCounts(posts: Array<{ data: { tags?: string[] } }>): Record<s
|
|
|
88
88
|
return counts;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Where a post card/row should link. Normally the post's own page — but a post
|
|
93
|
+
* that is just an attached file with no body (e.g. a newsletter that's only a PDF)
|
|
94
|
+
* links straight to the file, opened in a new tab, so there's no empty post page
|
|
95
|
+
* in between. Pure; the caller passes the entry's `body` (raw markdown) so
|
|
96
|
+
* "has text" is decided here.
|
|
97
|
+
*/
|
|
98
|
+
export function postLink(
|
|
99
|
+
post: { slug: string; body?: string | null; data: { attachment?: string } },
|
|
100
|
+
base = '/posts',
|
|
101
|
+
): { href: string; newTab: boolean } {
|
|
102
|
+
const attachment = post.data.attachment;
|
|
103
|
+
const hasBody = !!post.body && post.body.trim() !== '';
|
|
104
|
+
if (attachment && !hasBody) return { href: attachment, newTab: true };
|
|
105
|
+
return { href: `${base}/${post.slug}`, newTab: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
/**
|
|
92
109
|
* Whether a post date is within the last `days` of `now` — used to flag a "New"
|
|
93
110
|
* badge in the compact latest-posts list. `days <= 0` disables it; a future or
|
package/content/schemas.ts
CHANGED
|
@@ -126,6 +126,22 @@ export const themeSettingsSchema = z.object({
|
|
|
126
126
|
stroke: z.enum(['on', 'off']).default('on'),
|
|
127
127
|
/** Input treatment — a single site-wide choice (a `data-field` attr on <html>). */
|
|
128
128
|
fields: z.enum(['boxed', 'filled', 'underline']).default('boxed'),
|
|
129
|
+
/** Dark-mode formula knobs (root-derivation model). `darkDepth` = how light the
|
|
130
|
+
* dark page background sits (OKLCH L); `darkChroma` = the cap on how much colour
|
|
131
|
+
* the dark ground may carry. Both feed the AUTOMATIC dark roots. */
|
|
132
|
+
darkDepth: z.number().min(0.12).max(0.4).default(0.21),
|
|
133
|
+
darkChroma: z.number().min(0).max(0.15).default(0.05),
|
|
134
|
+
/** Explicit overrides for the three dark ROOTS. Each blank ⇒ the root is derived
|
|
135
|
+
* from its light source by the formula; set ⇒ every dark token in that role
|
|
136
|
+
* re-derives from this colour. Roots only (ground / text / accent) — never a
|
|
137
|
+
* lone downstream token — so the palette can never fall out of sync. */
|
|
138
|
+
darkOverrides: z
|
|
139
|
+
.object({
|
|
140
|
+
ground: z.string().default(''),
|
|
141
|
+
text: z.string().default(''),
|
|
142
|
+
accent: z.string().default(''),
|
|
143
|
+
})
|
|
144
|
+
.default({}),
|
|
129
145
|
/** Raw per-token overrides, layered last. Rarely needed. */
|
|
130
146
|
advanced: z
|
|
131
147
|
.object({
|
package/layouts/Base.astro
CHANGED
|
@@ -24,6 +24,7 @@ import '../styles/fonts.css';
|
|
|
24
24
|
import SiteHeader from '../components/SiteHeader.astro';
|
|
25
25
|
import SiteFooter from '../components/SiteFooter.astro';
|
|
26
26
|
import CookiePreferences from '../components/CookiePreferences.astro';
|
|
27
|
+
import CookieBanner from '../components/CookieBanner.astro';
|
|
27
28
|
import StickyCallout from '../components/StickyCallout.astro';
|
|
28
29
|
import SecondaryLogo from '../components/SecondaryLogo.astro';
|
|
29
30
|
import { loadSiteSettings } from '../lib/siteSettings';
|
|
@@ -170,6 +171,7 @@ const metaDescription = description ?? identity.description;
|
|
|
170
171
|
/>
|
|
171
172
|
|
|
172
173
|
<CookiePreferences />
|
|
174
|
+
<CookieBanner />
|
|
173
175
|
|
|
174
176
|
<StickyCallout {...callout} />
|
|
175
177
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-only consent state for embedded third-party content (Google Calendar,
|
|
3
|
+
* Google Maps). One shared key + event so the cookie banner, the footer's cookie
|
|
4
|
+
* preferences panel, and every embed on the page stay in sync: accept once (via
|
|
5
|
+
* the banner) and every embed loads — no separate per-embed gate.
|
|
6
|
+
*
|
|
7
|
+
* Import this ONLY from component `<script>` blocks (it touches localStorage /
|
|
8
|
+
* document). Every accessor is wrapped so a blocked/absent storage never throws.
|
|
9
|
+
*/
|
|
10
|
+
export const CONSENT_KEY = 'ferst-embed-consent';
|
|
11
|
+
export const CONSENT_EVENT = 'ferst-embed-consent-changed';
|
|
12
|
+
|
|
13
|
+
/** True once the visitor has explicitly accepted embedded content. */
|
|
14
|
+
export function hasConsent(): boolean {
|
|
15
|
+
try {
|
|
16
|
+
return localStorage.getItem(CONSENT_KEY) === '1';
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True once the visitor has made ANY choice (accept or decline) — hides the banner. */
|
|
23
|
+
export function isDecided(): boolean {
|
|
24
|
+
try {
|
|
25
|
+
return localStorage.getItem(CONSENT_KEY) !== null;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Record a choice and broadcast it so open embeds react immediately. */
|
|
32
|
+
export function setConsent(granted: boolean): void {
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem(CONSENT_KEY, granted ? '1' : '0');
|
|
35
|
+
} catch {
|
|
36
|
+
/* storage blocked — the event below still drives this page */
|
|
37
|
+
}
|
|
38
|
+
document.dispatchEvent(new CustomEvent(CONSENT_EVENT, { detail: { allowed: granted } }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Subscribe to consent changes (fires with the new allowed state). */
|
|
42
|
+
export function onConsentChange(cb: (allowed: boolean) => void): void {
|
|
43
|
+
document.addEventListener(CONSENT_EVENT, (e) => {
|
|
44
|
+
cb((e as CustomEvent).detail?.allowed === true);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inlineMarkdown — render a *tiny*, safe subset of markdown from trusted editor/CMS
|
|
3
|
+
* text: links `[label](href)`, bold `**text**`, and single newlines → `<br>`.
|
|
4
|
+
* Everything is HTML-escaped first, and only safe href schemes (http(s), root-
|
|
5
|
+
* relative, mailto, tel, in-page #anchors) become links — an unsafe href renders
|
|
6
|
+
* as literal text. The result is safe to drop into `set:html` for trusted content.
|
|
7
|
+
*
|
|
8
|
+
* This exists so recipes that show a line or two of editor copy (FAQ answers,
|
|
9
|
+
* notices) can carry a link without a full markdown pipeline — and without the
|
|
10
|
+
* `[text](url)` showing through as literal characters.
|
|
11
|
+
*/
|
|
12
|
+
const ESCAPE: Record<string, string> = {
|
|
13
|
+
'&': '&',
|
|
14
|
+
'<': '<',
|
|
15
|
+
'>': '>',
|
|
16
|
+
'"': '"',
|
|
17
|
+
"'": ''',
|
|
18
|
+
};
|
|
19
|
+
function escapeHtml(s: string): string {
|
|
20
|
+
return s.replace(/[&<>"']/g, (c) => ESCAPE[c]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Only these href schemes are allowed to become real links. */
|
|
24
|
+
const SAFE_HREF = /^(?:https?:\/\/|\/|mailto:|tel:|#)/i;
|
|
25
|
+
|
|
26
|
+
const LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
27
|
+
|
|
28
|
+
/** Bold + single-newline breaks, applied to already-escaped text. */
|
|
29
|
+
function inlineFormat(escaped: string): string {
|
|
30
|
+
return escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function inlineMarkdown(text: string | undefined | null): string {
|
|
34
|
+
if (!text) return '';
|
|
35
|
+
let out = '';
|
|
36
|
+
let last = 0;
|
|
37
|
+
let m: RegExpExecArray | null;
|
|
38
|
+
LINK_RE.lastIndex = 0;
|
|
39
|
+
while ((m = LINK_RE.exec(text))) {
|
|
40
|
+
out += inlineFormat(escapeHtml(text.slice(last, m.index)));
|
|
41
|
+
const label = inlineFormat(escapeHtml(m[1]));
|
|
42
|
+
const href = m[2];
|
|
43
|
+
if (SAFE_HREF.test(href)) {
|
|
44
|
+
out += `<a href="${escapeHtml(href)}">${label}</a>`;
|
|
45
|
+
} else {
|
|
46
|
+
out += inlineFormat(escapeHtml(m[0])); // unsafe scheme → keep literal
|
|
47
|
+
}
|
|
48
|
+
last = m.index + m[0].length;
|
|
49
|
+
}
|
|
50
|
+
out += inlineFormat(escapeHtml(text.slice(last)));
|
|
51
|
+
return out;
|
|
52
|
+
}
|
package/lib/posts.ts
CHANGED
|
@@ -24,6 +24,10 @@ import {
|
|
|
24
24
|
type PostsSettings,
|
|
25
25
|
} from '../content/posts';
|
|
26
26
|
|
|
27
|
+
// Re-export the pure link helper so clients can import it alongside the data
|
|
28
|
+
// helpers (e.g. a custom NewsList) from one place.
|
|
29
|
+
export { postLink } from '../content/posts';
|
|
30
|
+
|
|
27
31
|
export type PostEntry = CollectionEntry<'posts'>;
|
|
28
32
|
|
|
29
33
|
/** A tag resolved for display: its slug + label/description + how many posts use it. */
|
package/lib/themeTokens.ts
CHANGED
|
@@ -66,29 +66,84 @@ const LIGHT_DERIVATION = [
|
|
|
66
66
|
'--news-card-meta:color-mix(in srgb, var(--ink) 72%, var(--surface))',
|
|
67
67
|
].join(';');
|
|
68
68
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
'--
|
|
86
|
-
'--text
|
|
87
|
-
'--
|
|
88
|
-
'--divider-dark:oklch(from var(--brand) 0.32 0.02 h)',
|
|
89
|
-
'--news-card-meta:oklch(from var(--ink) 0.74 calc(c * 0.3) h)',
|
|
69
|
+
/**
|
|
70
|
+
* Dark mode uses a ROOT-DERIVATION model that mirrors light's three sources, so an
|
|
71
|
+
* editor can only ever change coherent roots — never a lone downstream token that
|
|
72
|
+
* would then clash with the rest.
|
|
73
|
+
*
|
|
74
|
+
* Three dark ROOTS, one per role:
|
|
75
|
+
* --dark-ground (surfaces) ← surface, chroma BOOSTED as it darkens so a pale
|
|
76
|
+
* tint survives instead of collapsing to grey/black;
|
|
77
|
+
* lightness = --dark-bg-l (the depth knob).
|
|
78
|
+
* --dark-text (text) ← ink, lightened for a dark ground.
|
|
79
|
+
* --dark-accent (accents) ← brand, brightened for a dark ground.
|
|
80
|
+
* Each defaults to this formula but can be OVERRIDDEN with an explicit colour (see
|
|
81
|
+
* buildThemeStyle); either way EVERY dark token below derives from the three roots,
|
|
82
|
+
* so the palette stays internally consistent whether automatic or hand-set.
|
|
83
|
+
*/
|
|
84
|
+
const DARK_ROOTS_AUTO = [
|
|
85
|
+
'--dark-ground:oklch(from var(--surface) var(--dark-bg-l) clamp(0, calc(c * 3.5), var(--dark-chroma-cap)) h)',
|
|
86
|
+
'--dark-text:oklch(from var(--ink) 0.94 calc(c * 0.3) h)',
|
|
87
|
+
'--dark-accent:oklch(from var(--brand) 0.78 calc(c * 0.9) h)',
|
|
90
88
|
].join(';');
|
|
91
89
|
|
|
90
|
+
/** Every working dark token, derived from the three roots (offsets in OKLCH L). */
|
|
91
|
+
const DARK_DOWNSTREAM = [
|
|
92
|
+
// Grounds ← --dark-ground
|
|
93
|
+
'--bg:var(--dark-ground)',
|
|
94
|
+
'--bg-surface:oklch(from var(--dark-ground) calc(l + 0.05) c h)',
|
|
95
|
+
'--bg-section:oklch(from var(--dark-ground) calc(l + 0.025) c h)',
|
|
96
|
+
'--bg-tag:oklch(from var(--dark-ground) calc(l - 0.03) c h)',
|
|
97
|
+
'--dark-bg:oklch(from var(--dark-ground) calc(l - 0.05) c h)',
|
|
98
|
+
'--border:oklch(from var(--dark-ground) calc(l + 0.19) c h)',
|
|
99
|
+
'--divider-dark:oklch(from var(--dark-ground) calc(l + 0.19) c h)',
|
|
100
|
+
// Text ← --dark-text
|
|
101
|
+
'--fg:var(--dark-text)',
|
|
102
|
+
'--muted:oklch(from var(--dark-text) calc(l - 0.20) c h)',
|
|
103
|
+
'--accent:oklch(from var(--dark-text) calc(l - 0.08) c h)',
|
|
104
|
+
'--brown:oklch(from var(--dark-text) calc(l - 0.20) c h)',
|
|
105
|
+
'--text-footer:oklch(from var(--dark-text) calc(l - 0.20) c h)',
|
|
106
|
+
'--text-copyright:oklch(from var(--dark-text) calc(l - 0.39) c h)',
|
|
107
|
+
'--text-subtle:oklch(from var(--dark-text) calc(l - 0.39) c h)',
|
|
108
|
+
'--news-card-meta:oklch(from var(--dark-text) calc(l - 0.20) c h)',
|
|
109
|
+
// Accents ← --dark-accent
|
|
110
|
+
'--gold:var(--dark-accent)',
|
|
111
|
+
'--gold-light:oklch(from var(--dark-accent) calc(l - 0.46) calc(c * 0.6) h)',
|
|
112
|
+
'--accent-2:oklch(from var(--dark-accent) calc(l - 0.08) c calc(h + 26))',
|
|
113
|
+
'--text-footer-link:var(--dark-accent)',
|
|
114
|
+
].join(';');
|
|
115
|
+
|
|
116
|
+
/** Defaults for the dark-formula knobs (also the CMS/schema defaults). */
|
|
117
|
+
export const DARK_DEFAULTS = { depth: 0.21, chroma: 0.05 } as const;
|
|
118
|
+
|
|
119
|
+
/** Clamp a numeric knob to a range, falling back to `fallback` when absent/NaN. */
|
|
120
|
+
function clampNum(v: unknown, min: number, max: number, fallback: number): number {
|
|
121
|
+
const n = typeof v === 'number' && Number.isFinite(v) ? v : fallback;
|
|
122
|
+
return Math.min(max, Math.max(min, n));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Compose the dark block: the depth/chroma knobs, the three auto roots, any
|
|
127
|
+
* explicit root overrides (which win over the auto value), then the downstream
|
|
128
|
+
* derivations that read whichever root value ended up winning.
|
|
129
|
+
*/
|
|
130
|
+
function darkDeclarations(theme: ThemeSettings): string {
|
|
131
|
+
const depth = clampNum(theme.darkDepth, 0.12, 0.4, DARK_DEFAULTS.depth);
|
|
132
|
+
const chroma = clampNum(theme.darkChroma, 0, 0.15, DARK_DEFAULTS.chroma);
|
|
133
|
+
const ov = theme.darkOverrides ?? {};
|
|
134
|
+
const overrides: string[] = [];
|
|
135
|
+
if (clean(ov.ground)) overrides.push(`--dark-ground:${clean(ov.ground)}`);
|
|
136
|
+
if (clean(ov.text)) overrides.push(`--dark-text:${clean(ov.text)}`);
|
|
137
|
+
if (clean(ov.accent)) overrides.push(`--dark-accent:${clean(ov.accent)}`);
|
|
138
|
+
return [
|
|
139
|
+
`--dark-bg-l:${depth}`,
|
|
140
|
+
`--dark-chroma-cap:${chroma}`,
|
|
141
|
+
DARK_ROOTS_AUTO,
|
|
142
|
+
...overrides,
|
|
143
|
+
DARK_DOWNSTREAM,
|
|
144
|
+
].join(';');
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
/** Drop a value that could break out of a CSS declaration. The source is trusted
|
|
93
148
|
* repo JSON, but this keeps a stray value honest (defence in depth). */
|
|
94
149
|
function clean(v: unknown): string {
|
|
@@ -151,7 +206,7 @@ export function buildThemeStyle(theme: ThemeSettings): ThemeStyle {
|
|
|
151
206
|
const css = [
|
|
152
207
|
rootDecls.length ? `:root:root{${rootDecls.join(';')}}` : '',
|
|
153
208
|
themed ? `:root:root:not([data-theme="dark"]){${LIGHT_DERIVATION}}` : '',
|
|
154
|
-
themed ? `:root:root[data-theme="dark"]{${
|
|
209
|
+
themed ? `:root:root[data-theme="dark"]{${darkDeclarations(theme)}}` : '',
|
|
155
210
|
rawBlock(':root:root', theme.advanced?.light),
|
|
156
211
|
rawBlock(':root:root[data-theme="dark"]', theme.advanced?.dark),
|
|
157
212
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
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
6
|
"license": "BUSL-1.1",
|