ferst-core 0.2.9 → 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.
@@ -76,42 +76,37 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
76
76
  </section>
77
77
 
78
78
  <script>
79
- // Bundled (not is:inline), so it runs once even with multiple calendars on a
80
- // page. Wires each embed's consent gate; auto-loads where consent was given.
81
- const CONSENT_KEY = 'ferst-calendar-consent';
82
- document.querySelectorAll<HTMLElement>('.b-calendar__embed').forEach((el) => {
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
- const btn = el.querySelector<HTMLButtonElement>('.b-calendar__load');
90
-
91
- function load() {
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
- btn?.addEventListener('click', () => {
110
- try {
111
- localStorage.setItem(CONSENT_KEY, '1');
112
- } catch (_) {}
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">Google Calendar</p>
33
- <p class="cookie-prefs__row-desc">Loads the embedded calendar on the <a href="/calendar/">Calendar</a> page. Google may set its own cookies once it's loaded.</p>
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-calendar-toggle"
38
+ id="cookie-prefs-embed-toggle"
39
39
  role="switch"
40
40
  aria-checked="false"
41
- aria-label="Allow Google Calendar embed"
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
- const CALENDAR_KEY = 'calendar-consent';
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 calendarToggle = document.getElementById('cookie-prefs-calendar-toggle') as HTMLButtonElement;
204
+ const embedToggle = document.getElementById('cookie-prefs-embed-toggle') as HTMLButtonElement;
205
205
 
206
206
  function syncToggle() {
207
- const allowed = localStorage.getItem(CALENDAR_KEY) === '1';
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
- calendarToggle.addEventListener('click', () => {
221
- const next = calendarToggle.getAttribute('aria-checked') !== 'true';
222
- calendarToggle.setAttribute('aria-checked', String(next));
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();
@@ -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"><p>{item.answer}</p></div>
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>
@@ -71,43 +71,37 @@ const safe = isSafeEmbedUrl(embedUrl);
71
71
  </section>
72
72
 
73
73
  <script>
74
- // Bundled (not is:inline), so it runs once even with multiple maps on a page.
75
- // Wires each embed's consent gate; auto-loads where consent was given.
76
- const CONSENT_KEY = 'ferst-map-consent';
77
- document.querySelectorAll<HTMLElement>('.b-map__embed').forEach((el) => {
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
- const btn = el.querySelector<HTMLButtonElement>('.b-map__load');
85
-
86
- function load() {
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
- btn?.addEventListener('click', () => {
106
- try {
107
- localStorage.setItem(CONSENT_KEY, '1');
108
- } catch (_) {}
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
@@ -644,6 +644,23 @@ export const pageSchema = z.object({
644
644
  * bespoke page may have none.
645
645
  */
646
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([]),
647
664
  /** Keep the JSON in the repo but exclude it from the build. */
648
665
  draft: z.boolean().default(false),
649
666
  blocks: z.array(blockSchema).default([]),
@@ -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
+ '&': '&amp;',
14
+ '<': '&lt;',
15
+ '>': '&gt;',
16
+ '"': '&quot;',
17
+ "'": '&#39;',
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.2.9",
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",