ferst-core 0.2.8 → 0.3.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/CalendarEmbed.astro +27 -32
- package/components/CookieBanner.astro +126 -0
- package/components/CookiePreferences.astro +13 -13
- package/components/recipes/Banner.astro +13 -1
- package/components/recipes/Faq.astro +7 -1
- package/components/recipes/LatestPosts.astro +7 -5
- package/components/recipes/MapEmbed.astro +27 -33
- package/content/blocks.ts +24 -2
- package/layouts/Base.astro +2 -0
- package/lib/consent-client.ts +46 -0
- package/lib/inline-markdown.ts +52 -0
- package/package.json +1 -1
|
@@ -76,42 +76,37 @@ 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
|
+
function load(el: HTMLElement) {
|
|
88
|
+
if (el.dataset.loaded === 'true') return;
|
|
83
89
|
const url = el.dataset.embedUrl;
|
|
84
|
-
if (!url) return;
|
|
85
|
-
const height = el.dataset.height || '600';
|
|
86
|
-
const title = el.dataset.title || 'Calendar';
|
|
87
90
|
const frameSlot = el.querySelector<HTMLElement>('.b-calendar__frame');
|
|
91
|
+
if (!url || !frameSlot) return;
|
|
92
|
+
const frame = document.createElement('iframe');
|
|
93
|
+
frame.src = url;
|
|
94
|
+
frame.title = el.dataset.title || 'Calendar';
|
|
95
|
+
frame.loading = 'lazy';
|
|
96
|
+
frame.style.width = '100%';
|
|
97
|
+
frame.style.height = (el.dataset.height || '600') + 'px';
|
|
98
|
+
frame.style.border = '0';
|
|
99
|
+
frameSlot.replaceChildren(frame);
|
|
100
|
+
el.dataset.loaded = 'true';
|
|
88
101
|
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 (_) {}
|
|
102
|
+
if (consent) consent.hidden = true;
|
|
103
|
+
}
|
|
104
|
+
const loadAll = () => embeds.forEach(load);
|
|
108
105
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
load();
|
|
114
|
-
});
|
|
106
|
+
if (hasConsent()) loadAll();
|
|
107
|
+
onConsentChange((allowed) => { if (allowed) loadAll(); });
|
|
108
|
+
embeds.forEach((el) => {
|
|
109
|
+
el.querySelector<HTMLButtonElement>('.b-calendar__load')?.addEventListener('click', () => setConsent(true));
|
|
115
110
|
});
|
|
116
111
|
</script>
|
|
117
112
|
|
|
@@ -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();
|
|
@@ -22,8 +22,9 @@ interface Props {
|
|
|
22
22
|
overlay?: number;
|
|
23
23
|
align?: 'start' | 'center' | 'end';
|
|
24
24
|
parallax?: boolean;
|
|
25
|
+
fullBleed?: boolean;
|
|
25
26
|
}
|
|
26
|
-
const { image, eyebrow, title, text, cta, overlay = 50, align = 'start', parallax = true } = Astro.props;
|
|
27
|
+
const { image, eyebrow, title, text, cta, overlay = 50, align = 'start', parallax = true, fullBleed = false } = Astro.props;
|
|
27
28
|
const scrim = overlay / 100;
|
|
28
29
|
---
|
|
29
30
|
|
|
@@ -31,6 +32,7 @@ const scrim = overlay / 100;
|
|
|
31
32
|
class="b-banner"
|
|
32
33
|
data-align={align}
|
|
33
34
|
data-parallax={parallax ? 'true' : 'false'}
|
|
35
|
+
data-full-bleed={fullBleed ? 'true' : 'false'}
|
|
34
36
|
style={`--banner-image: url('${image}'); --banner-scrim: ${scrim};`}
|
|
35
37
|
>
|
|
36
38
|
<div class="b-banner__scrim" aria-hidden="true"></div>
|
|
@@ -60,6 +62,16 @@ const scrim = overlay / 100;
|
|
|
60
62
|
background-position: center;
|
|
61
63
|
background-repeat: no-repeat;
|
|
62
64
|
}
|
|
65
|
+
/* Full-bleed: break out of the content column to the full viewport width, edges
|
|
66
|
+
squared off. Safe from a horizontal scrollbar because Base sets overflow-x:
|
|
67
|
+
clip on html/body. The card keeps a comfortable inset that tracks the content
|
|
68
|
+
column on wide screens, so it still lines up with the page below. */
|
|
69
|
+
.b-banner[data-full-bleed='true'] {
|
|
70
|
+
width: 100vw;
|
|
71
|
+
margin-inline: calc(50% - 50vw);
|
|
72
|
+
border-radius: 0;
|
|
73
|
+
padding-inline: max(clamp(1.25rem, 5vw, 4rem), calc((100vw - 72rem) / 2 + 2rem));
|
|
74
|
+
}
|
|
63
75
|
/* Parallax: pin the image to the viewport so it slides under the content.
|
|
64
76
|
Only on fine pointers with motion allowed — `fixed` is ignored/janky on
|
|
65
77
|
touch (iOS) and unwelcome under reduced-motion. */
|
|
@@ -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>
|
|
@@ -45,9 +45,11 @@ const fmt = (date: Date | string) => {
|
|
|
45
45
|
: { iso: d.toISOString().slice(0, 10), label: d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) };
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
-
// "New"
|
|
49
|
-
//
|
|
50
|
-
//
|
|
48
|
+
// The "New" badge marks only the single most-recent post, and only if it was
|
|
49
|
+
// published within `newWithinDays` of build time — so a site that posts often
|
|
50
|
+
// doesn't end up flagging every row (which would make the badge meaningless).
|
|
51
|
+
// Recomputed each build (a site rebuilds on publish, so it stays fresh); isRecent
|
|
52
|
+
// lives in the pure posts module so it's unit-tested independently.
|
|
51
53
|
const nowMs = Date.now();
|
|
52
54
|
const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
|
|
53
55
|
---
|
|
@@ -64,7 +66,7 @@ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
|
|
|
64
66
|
<p class="b-latest-posts__empty">No posts yet.</p>
|
|
65
67
|
) : layout === 'list' ? (
|
|
66
68
|
<ul class="b-latest-posts__list">
|
|
67
|
-
{posts.map((p) => {
|
|
69
|
+
{posts.map((p, i) => {
|
|
68
70
|
const { iso, label } = fmt(p.data.date);
|
|
69
71
|
const href = `/posts/${p.slug}`;
|
|
70
72
|
const cats = (p.data.tags ?? []).slice(0, 2);
|
|
@@ -82,7 +84,7 @@ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
|
|
|
82
84
|
<h3 class="b-post-row__title"><a href={href}>{p.data.title}</a></h3>
|
|
83
85
|
</div>
|
|
84
86
|
<div class="b-post-row__aside">
|
|
85
|
-
{isNew(p.data.date) && <span class="b-post-row__new">New</span>}
|
|
87
|
+
{i === 0 && isNew(p.data.date) && <span class="b-post-row__new">New</span>}
|
|
86
88
|
{label && <time class="b-post-row__date" datetime={iso}>{label}</time>}
|
|
87
89
|
<span class="b-post-row__chevron" aria-hidden="true">›</span>
|
|
88
90
|
</div>
|
|
@@ -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
|
@@ -268,6 +268,10 @@ export const bannerBlock = z.object({
|
|
|
268
268
|
overlay: z.number().min(0).max(100).default(50),
|
|
269
269
|
align: z.enum(['start', 'center', 'end']).default('start'),
|
|
270
270
|
parallax: z.boolean().default(true),
|
|
271
|
+
/** Break the band out to the full viewport width (edge-to-edge, no rounded
|
|
272
|
+
* corners); the card stays aligned to the page's content column. Great for a
|
|
273
|
+
* homepage hero. Off by default (the band sits inside the content column). */
|
|
274
|
+
fullBleed: z.boolean().default(false),
|
|
271
275
|
});
|
|
272
276
|
|
|
273
277
|
/** Stats — a band of headline numbers (value + label + optional note), over an
|
|
@@ -396,8 +400,9 @@ export const latestPostsBlock = z.object({
|
|
|
396
400
|
/** `grid` = full image cards; `list` = a compact miniature index (thumbnail +
|
|
397
401
|
* category + date per row) that stays short even with many posts. */
|
|
398
402
|
layout: z.enum(['grid', 'list']).default('grid'),
|
|
399
|
-
/** In the `list` layout,
|
|
400
|
-
*
|
|
403
|
+
/** In the `list` layout, badge the most-recent post as "New" when it was
|
|
404
|
+
* published within this many days (0 disables it). Only the newest row is ever
|
|
405
|
+
* flagged, so a frequently-posting site doesn't mark every row. Grid ignores it. */
|
|
401
406
|
newWithinDays: z.number().int().min(0).max(365).default(14),
|
|
402
407
|
/** "View all" target + label (the posts index by default). */
|
|
403
408
|
viewAllHref: z.string().default('/posts'),
|
|
@@ -639,6 +644,23 @@ export const pageSchema = z.object({
|
|
|
639
644
|
* bespoke page may have none.
|
|
640
645
|
*/
|
|
641
646
|
templateId: z.string().optional(),
|
|
647
|
+
/**
|
|
648
|
+
* Editor change-requests captured in the CMS — the seed of AI-driven editing
|
|
649
|
+
* ([[ferst_editing_ai_first_pdlc]]): rather than let editors edit block JSON
|
|
650
|
+
* directly (easy to break), they describe a desired change against the page and
|
|
651
|
+
* it's stored here for an agent to action later → PR → human verify. NEVER
|
|
652
|
+
* rendered; carried through the schema so a CMS save preserves it.
|
|
653
|
+
*/
|
|
654
|
+
requests: z
|
|
655
|
+
.array(
|
|
656
|
+
z.object({
|
|
657
|
+
text: z.string(),
|
|
658
|
+
at: z.string().optional(),
|
|
659
|
+
by: z.string().optional(),
|
|
660
|
+
status: z.enum(['open', 'done']).default('open'),
|
|
661
|
+
}),
|
|
662
|
+
)
|
|
663
|
+
.default([]),
|
|
642
664
|
/** Keep the JSON in the repo but exclude it from the build. */
|
|
643
665
|
draft: z.boolean().default(false),
|
|
644
666
|
blocks: z.array(blockSchema).default([]),
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|