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.
@@ -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();
@@ -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"><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>
@@ -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" = published within `newWithinDays` of build time (recomputed each build;
49
- // a site rebuilds on publish, so this stays fresh). isRecent lives in the pure
50
- // posts module so it's unit-tested independently.
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">&rsaquo;</span>
88
90
  </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
@@ -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, flag posts newer than this many days with a "New"
400
- * badge (0 disables it). Ignored by the grid. */
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([]),
@@ -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.8",
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",