ferst-core 0.7.0 → 0.7.4

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.
Files changed (38) hide show
  1. package/components/BlockRenderer.astro +6 -0
  2. package/components/Pagination.astro +51 -0
  3. package/components/blocks/Heading.astro +47 -46
  4. package/components/blocks/Prose.astro +10 -2
  5. package/components/blocks/Quote.astro +40 -38
  6. package/components/recipes/Accordion.astro +91 -84
  7. package/components/recipes/ContactCard.astro +172 -170
  8. package/components/recipes/Document.astro +152 -0
  9. package/components/recipes/FeatureCards.astro +112 -111
  10. package/components/recipes/Hero.astro +120 -8
  11. package/components/recipes/LinkRow.astro +91 -0
  12. package/components/recipes/MediaCards.astro +213 -212
  13. package/components/recipes/Notice.astro +90 -89
  14. package/components/recipes/Stats.astro +152 -145
  15. package/components/recipes/Table.astro +98 -97
  16. package/components/recipes/Tabs.astro +134 -133
  17. package/components/recipes/Tiles.astro +287 -271
  18. package/components/recipes/Timetable.astro +189 -0
  19. package/components/recipes/Video.astro +177 -157
  20. package/content/blocks.ts +68 -2
  21. package/content/cookies.ts +10 -0
  22. package/examples/primitives.ts +2 -0
  23. package/examples/recipes.ts +42 -36
  24. package/integration.mjs +83 -64
  25. package/layouts/Base.astro +481 -475
  26. package/lib/documents.ts +68 -62
  27. package/lib/embed-consent.ts +78 -73
  28. package/lib/inline-markdown.ts +12 -0
  29. package/lib/links.ts +12 -0
  30. package/lib/paginate.ts +23 -0
  31. package/lib/posts.ts +20 -2
  32. package/lib/timetable.ts +27 -0
  33. package/lib/video.ts +101 -56
  34. package/package.json +1 -1
  35. package/routes/cookie-policy.astro +27 -0
  36. package/routes/posts/index.astro +36 -31
  37. package/routes/posts/page/[page].astro +36 -0
  38. package/routes/posts/tag/[...tag].astro +41 -37
package/lib/documents.ts CHANGED
@@ -1,62 +1,68 @@
1
- /**
2
- * The `documents` block's list logic: newest first (dated items by date, undated ones
3
- * after, in the order given), an optional grouping by year, a file-type label from the
4
- * extension, and a readable date. Pure — the component only renders the result.
5
- */
6
- export interface DocumentItem {
7
- title: string;
8
- file: string;
9
- date?: string;
10
- note?: string;
11
- }
12
- export interface DocumentRow extends DocumentItem {
13
- kind: string;
14
- dateLabel?: string;
15
- }
16
- export interface DocumentGroup {
17
- year: string | null;
18
- items: DocumentRow[];
19
- }
20
-
21
- const KINDS: Record<string, string> = {
22
- pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel', csv: 'CSV',
23
- ppt: 'PowerPoint', pptx: 'PowerPoint', odt: 'Document', rtf: 'Document', txt: 'Text',
24
- jpg: 'Image', jpeg: 'Image', png: 'Image', zip: 'ZIP',
25
- };
26
-
27
- export function fileKind(file: string): string {
28
- const ext = file.split(/[?#]/)[0].split('.').pop()?.toLowerCase() ?? '';
29
- return KINDS[ext] ?? 'File';
30
- }
31
-
32
- /** A valid ISO-ish date (YYYY-MM-DD…) → a Date, else null. */
33
- function parseDate(d: string | undefined): Date | null {
34
- if (!d || !/^\d{4}-\d{2}-\d{2}/.test(d)) return null;
35
- const t = new Date(d.slice(0, 10) + 'T00:00:00Z');
36
- return Number.isNaN(t.getTime()) ? null : t;
37
- }
38
-
39
- export function documentGroups(items: DocumentItem[], groupByYear = false): DocumentGroup[] {
40
- const rows = items.map((item, i) => {
41
- const d = parseDate(item.date);
42
- return {
43
- row: {
44
- ...item,
45
- kind: fileKind(item.file),
46
- dateLabel: d ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }) : undefined,
47
- } as DocumentRow,
48
- time: d?.getTime() ?? null,
49
- year: d ? String(d.getUTCFullYear()) : null,
50
- i,
51
- };
52
- });
53
- rows.sort((a, b) => (a.time !== null && b.time !== null ? b.time - a.time : a.time !== null ? -1 : b.time !== null ? 1 : a.i - b.i));
54
- if (!groupByYear) return [{ year: null, items: rows.map((r) => r.row) }];
55
- const groups: DocumentGroup[] = [];
56
- for (const r of rows) {
57
- const last = groups[groups.length - 1];
58
- if (last && last.year === r.year) last.items.push(r.row);
59
- else groups.push({ year: r.year, items: [r.row] });
60
- }
61
- return groups;
62
- }
1
+ /**
2
+ * The `documents` block's list logic: newest first (dated items by date, undated ones
3
+ * after, in the order given), an optional grouping by year, a file-type label from the
4
+ * extension, and a readable date. Pure — the component only renders the result.
5
+ */
6
+ export interface DocumentItem {
7
+ title: string;
8
+ file: string;
9
+ date?: string;
10
+ note?: string;
11
+ }
12
+ export interface DocumentRow extends DocumentItem {
13
+ kind: string;
14
+ dateLabel?: string;
15
+ }
16
+ export interface DocumentGroup {
17
+ year: string | null;
18
+ items: DocumentRow[];
19
+ }
20
+
21
+ const KINDS: Record<string, string> = {
22
+ pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel', csv: 'CSV',
23
+ ppt: 'PowerPoint', pptx: 'PowerPoint', odt: 'Document', rtf: 'Document', txt: 'Text',
24
+ jpg: 'Image', jpeg: 'Image', png: 'Image', zip: 'ZIP',
25
+ };
26
+
27
+ export function fileKind(file: string): string {
28
+ const ext = file.split(/[?#]/)[0].split('.').pop()?.toLowerCase() ?? '';
29
+ return KINDS[ext] ?? 'File';
30
+ }
31
+
32
+ /** A YYYY-MM-DD date as "4 October 2026" (UTC, so it never shifts a day); else undefined. */
33
+ export function longDate(d: string | undefined): string | undefined {
34
+ const t = parseDate(d);
35
+ return t ? t.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }) : undefined;
36
+ }
37
+
38
+ /** A valid ISO-ish date (YYYY-MM-DD…) → a Date, else null. */
39
+ function parseDate(d: string | undefined): Date | null {
40
+ if (!d || !/^\d{4}-\d{2}-\d{2}/.test(d)) return null;
41
+ const t = new Date(d.slice(0, 10) + 'T00:00:00Z');
42
+ return Number.isNaN(t.getTime()) ? null : t;
43
+ }
44
+
45
+ export function documentGroups(items: DocumentItem[], groupByYear = false): DocumentGroup[] {
46
+ const rows = items.map((item, i) => {
47
+ const d = parseDate(item.date);
48
+ return {
49
+ row: {
50
+ ...item,
51
+ kind: fileKind(item.file),
52
+ dateLabel: longDate(item.date),
53
+ } as DocumentRow,
54
+ time: d?.getTime() ?? null,
55
+ year: d ? String(d.getUTCFullYear()) : null,
56
+ i,
57
+ };
58
+ });
59
+ rows.sort((a, b) => (a.time !== null && b.time !== null ? b.time - a.time : a.time !== null ? -1 : b.time !== null ? 1 : a.i - b.i));
60
+ if (!groupByYear) return [{ year: null, items: rows.map((r) => r.row) }];
61
+ const groups: DocumentGroup[] = [];
62
+ for (const r of rows) {
63
+ const last = groups[groups.length - 1];
64
+ if (last && last.year === r.year) last.items.push(r.row);
65
+ else groups.push({ year: r.year, items: [r.row] });
66
+ }
67
+ return groups;
68
+ }
@@ -1,73 +1,78 @@
1
- /**
2
- * Shared client-side loader for consent-gated third-party embeds (Google Calendar,
3
- * Google Maps, the Locations tiles). One implementation, so every embed gates,
4
- * loads and reacts to consent identically — instead of the same ~25-line script
5
- * copied into each component (where a fix, or a bug, lands in only one copy).
6
- *
7
- * Behaviour: nothing loads until the visitor consents. Accepting the site cookie
8
- * banner (or the footer preferences panel) grants the shared consent and
9
- * auto-loads EVERY registered embed on the page; the per-embed button is a
10
- * fallback that grants that same shared consent. Consent is remembered per
11
- * visitor. Import from a component `<script>` (it touches the DOM).
12
- *
13
- * Each embed root element carries the data the loader needs:
14
- * data-embed-url the https URL to load (required)
15
- * data-title the iframe title (optional)
16
- * data-height pixel height, for fixed-height embeds (optional)
17
- * and contains a `frame` slot (filled with the iframe), a `consent` card (hidden
18
- * once loaded) and a `button` that grants consent.
19
- */
20
- import { hasConsent, setConsent, onConsentChange } from './consent-client';
21
-
22
- export interface EmbedConsentConfig {
23
- /** Selector for each embed root (one per embed on the page). */
24
- root: string;
25
- /** Selector, within a root, of the empty slot the iframe is injected into. */
26
- frame: string;
27
- /** Selector, within a root, of the consent card to hide once loaded. */
28
- consent: string;
29
- /** Selector, within a root, of the button that grants consent. */
30
- button: string;
31
- /** Build the iframe for one root — lets each embed set its own src / size / attrs. */
32
- buildFrame: (el: HTMLElement, url: string) => HTMLIFrameElement;
33
- }
34
-
35
- /** Wire up every consent-gated embed matching `cfg.root`. Call once per component. */
36
- export function initEmbedConsent(cfg: EmbedConsentConfig): void {
37
- const roots = document.querySelectorAll<HTMLElement>(cfg.root);
38
- if (!roots.length) return;
39
-
40
- function load(el: HTMLElement): void {
41
- if (el.dataset.loaded === 'true') return;
42
- const url = el.dataset.embedUrl;
43
- const slot = el.querySelector<HTMLElement>(cfg.frame);
44
- if (!url || !slot) return;
45
- slot.replaceChildren(cfg.buildFrame(el, url));
46
- el.dataset.loaded = 'true';
47
- const card = el.querySelector<HTMLElement>(cfg.consent);
48
- if (card) card.hidden = true;
49
- }
50
- const loadAll = (): void => roots.forEach(load);
51
-
52
- if (hasConsent()) loadAll();
53
- onConsentChange((allowed) => { if (allowed) loadAll(); });
54
- roots.forEach((el) => {
55
- el.querySelector<HTMLButtonElement>(cfg.button)?.addEventListener('click', () => setConsent(true));
56
- });
57
- }
58
-
59
- /** Build a standard lazy embed iframe (100% width, 0 border). */
60
- export function makeEmbedFrame(
61
- src: string,
62
- opts: { title?: string; height?: string; referrer?: boolean } = {},
63
- ): HTMLIFrameElement {
64
- const f = document.createElement('iframe');
65
- f.src = src;
66
- if (opts.title) f.title = opts.title;
67
- f.loading = 'lazy';
68
- if (opts.referrer) f.referrerPolicy = 'no-referrer-when-downgrade';
69
- f.style.width = '100%';
70
- f.style.height = opts.height ?? '100%';
71
- f.style.border = '0';
72
- return f;
73
- }
1
+ /**
2
+ * Shared client-side loader for consent-gated third-party embeds (Google Calendar,
3
+ * Google Maps, the Locations tiles). One implementation, so every embed gates,
4
+ * loads and reacts to consent identically — instead of the same ~25-line script
5
+ * copied into each component (where a fix, or a bug, lands in only one copy).
6
+ *
7
+ * Behaviour: nothing loads until the visitor consents. Accepting the site cookie
8
+ * banner (or the footer preferences panel) grants the shared consent and
9
+ * auto-loads EVERY registered embed on the page; the per-embed button is a
10
+ * fallback that grants that same shared consent. Consent is remembered per
11
+ * visitor. Import from a component `<script>` (it touches the DOM).
12
+ *
13
+ * Each embed root element carries the data the loader needs:
14
+ * data-embed-url the https URL to load (required)
15
+ * data-title the iframe title (optional)
16
+ * data-height pixel height, for fixed-height embeds (optional)
17
+ * and contains a `frame` slot (filled with the iframe), a `consent` card (hidden
18
+ * once loaded) and a `button` that grants consent.
19
+ */
20
+ import { hasConsent, setConsent, onConsentChange } from './consent-client';
21
+
22
+ export interface EmbedConsentConfig {
23
+ /** Selector for each embed root (one per embed on the page). */
24
+ root: string;
25
+ /** Selector, within a root, of the empty slot the iframe is injected into. */
26
+ frame: string;
27
+ /** Selector, within a root, of the consent card to hide once loaded. */
28
+ consent: string;
29
+ /** Selector, within a root, of the button that grants consent. */
30
+ button: string;
31
+ /** Build the iframe for one root — lets each embed set its own src / size / attrs. */
32
+ buildFrame: (el: HTMLElement, url: string) => HTMLIFrameElement;
33
+ }
34
+
35
+ /** Wire up every consent-gated embed matching `cfg.root`. Call once per component. */
36
+ export function initEmbedConsent(cfg: EmbedConsentConfig): void {
37
+ const roots = document.querySelectorAll<HTMLElement>(cfg.root);
38
+ if (!roots.length) return;
39
+
40
+ function load(el: HTMLElement): void {
41
+ if (el.dataset.loaded === 'true') return;
42
+ const url = el.dataset.embedUrl;
43
+ const slot = el.querySelector<HTMLElement>(cfg.frame);
44
+ if (!url || !slot) return;
45
+ slot.replaceChildren(cfg.buildFrame(el, url));
46
+ el.dataset.loaded = 'true';
47
+ const card = el.querySelector<HTMLElement>(cfg.consent);
48
+ if (card) card.hidden = true;
49
+ }
50
+ // An embed marked `data-on-demand="true"` (e.g. a live stream that's often off air)
51
+ // never loads just because consent exists: only its own button loads it.
52
+ const loadAll = (): void => roots.forEach((el) => { if (el.dataset.onDemand !== 'true') load(el); });
53
+
54
+ if (hasConsent()) loadAll();
55
+ onConsentChange((allowed) => { if (allowed) loadAll(); });
56
+ roots.forEach((el) => {
57
+ el.querySelector<HTMLButtonElement>(cfg.button)?.addEventListener('click', () => {
58
+ setConsent(true);
59
+ if (el.dataset.onDemand === 'true') load(el);
60
+ });
61
+ });
62
+ }
63
+
64
+ /** Build a standard lazy embed iframe (100% width, 0 border). */
65
+ export function makeEmbedFrame(
66
+ src: string,
67
+ opts: { title?: string; height?: string; referrer?: boolean } = {},
68
+ ): HTMLIFrameElement {
69
+ const f = document.createElement('iframe');
70
+ f.src = src;
71
+ if (opts.title) f.title = opts.title;
72
+ f.loading = 'lazy';
73
+ if (opts.referrer) f.referrerPolicy = 'no-referrer-when-downgrade';
74
+ f.style.width = '100%';
75
+ f.style.height = opts.height ?? '100%';
76
+ f.style.border = '0';
77
+ return f;
78
+ }
@@ -37,6 +37,18 @@ function inlineFormat(escaped: string): string {
37
37
  .replace(/\n/g, '<br>');
38
38
  }
39
39
 
40
+ /** Escaped text with single newlines → `<br>`, and no links: for text that already sits
41
+ * inside a link (a nested <a> is invalid). */
42
+ export function textWithBreaks(text: string | undefined | null): string {
43
+ return text ? escapeHtml(text).replace(/\n/g, '<br>') : '';
44
+ }
45
+
46
+ /** Inline formatting (bold, italic, line breaks) WITHOUT links: `[label](href)` becomes
47
+ * its label. For text inside something that is already a link (a linked tile or card). */
48
+ export function inlineMarkdownNoLinks(text: string | undefined | null): string {
49
+ return text ? inlineFormat(escapeHtml(text.replace(LINK_RE, '$1'))) : '';
50
+ }
51
+
40
52
  export function inlineMarkdown(text: string | undefined | null): string {
41
53
  if (!text) return '';
42
54
  let out = '';
package/lib/links.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The icon a link deserves, read from the link itself — so an editor writes a phone
3
+ * number, an email or a web address and the row shows the right symbol without choosing.
4
+ */
5
+ import type { IconKey } from './icons';
6
+
7
+ export function linkIcon(href: string): IconKey {
8
+ if (/^tel:/i.test(href)) return 'phone';
9
+ if (/^mailto:/i.test(href)) return 'email';
10
+ if (/^https?:\/\//i.test(href)) return 'globe';
11
+ return 'arrow';
12
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Pagination for post listings (the index and tag pages). Page 1 lives at the listing's
3
+ * own URL; later pages at `<base>/page/<n>`. Pure, unit-tested.
4
+ */
5
+ export function pageCount(total: number, perPage: number): number {
6
+ return Math.max(1, Math.ceil(total / Math.max(1, perPage)));
7
+ }
8
+
9
+ export function pageSlice<T>(items: T[], page: number, perPage: number): T[] {
10
+ return items.slice((page - 1) * perPage, page * perPage);
11
+ }
12
+
13
+ export function pageHref(base: string, page: number): string {
14
+ return page <= 1 ? base : `${base}/page/${page}`;
15
+ }
16
+
17
+ /** Newer (prev) and older (next) page links, where they exist. */
18
+ export function pagerLinks(base: string, page: number, pages: number): { prev?: string; next?: string } {
19
+ return {
20
+ prev: page > 1 ? pageHref(base, page - 1) : undefined,
21
+ next: page < pages ? pageHref(base, page + 1) : undefined,
22
+ };
23
+ }
package/lib/posts.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * // <PostArticle ...><Content /></PostArticle>
14
14
  */
15
15
  import { getCollection, getEntry, type CollectionEntry } from 'astro:content';
16
+ import { pageCount } from './paginate';
16
17
  import {
17
18
  postsSettingsSchema,
18
19
  tagSchema,
@@ -92,6 +93,23 @@ export async function getPostRoutes() {
92
93
 
93
94
  /** `getStaticPaths` for a tag route (`tags/[...tag].astro`). */
94
95
  export async function getTagRoutes() {
95
- const tags = await getUsedTags();
96
- return tags.map((tag) => ({ params: { tag: tag.slug }, props: { tag } }));
96
+ const [tags, settings] = await Promise.all([getUsedTags(), getPostsSettings()]);
97
+ // Page 1 at /posts/tag/<slug>; later pages at /posts/tag/<slug>/page/<n>.
98
+ return tags.flatMap((tag) => {
99
+ const pages = pageCount(tag.count, settings.perPage);
100
+ return Array.from({ length: pages }, (_, i) => ({
101
+ params: { tag: i === 0 ? tag.slug : `${tag.slug}/page/${i + 1}` },
102
+ props: { tag, page: i + 1, pages },
103
+ }));
104
+ });
105
+ }
106
+
107
+ /** `/posts/page/<n>` for n ≥ 2 (page 1 is `/posts` itself). */
108
+ export async function getIndexPageRoutes() {
109
+ const [posts, settings] = await Promise.all([getIndexPosts(), getPostsSettings()]);
110
+ const pages = pageCount(posts.length, settings.perPage);
111
+ return Array.from({ length: Math.max(0, pages - 1) }, (_, i) => ({
112
+ params: { page: String(i + 2) },
113
+ props: { page: i + 2, pages },
114
+ }));
97
115
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The `timetable` recipe's logic: which days a place actually has times on, and the
3
+ * rows of the side-by-side table (days down the side, a cell per place). Pure.
4
+ */
5
+ export interface DaySchedule { day: string; times: string[] }
6
+ export interface Place { name: string; schedule: DaySchedule[] }
7
+
8
+ /** Only the days with at least one real time (blank strings don't count). */
9
+ export function daysWithTimes(schedule: DaySchedule[]): DaySchedule[] {
10
+ return schedule
11
+ .map((d) => ({ day: d.day, times: d.times.map((t) => t.trim()).filter(Boolean) }))
12
+ .filter((d) => d.times.length > 0);
13
+ }
14
+
15
+ /** Table rows: every day some place uses, in first-seen order, with that day's times per place. */
16
+ export function timetableRows(places: Place[]): Array<{ day: string; cells: string[][] }> {
17
+ const days: string[] = [];
18
+ const byPlace = places.map((p) => {
19
+ const map = new Map<string, string[]>();
20
+ for (const d of daysWithTimes(p.schedule)) {
21
+ map.set(d.day, d.times);
22
+ if (!days.includes(d.day)) days.push(d.day);
23
+ }
24
+ return map;
25
+ });
26
+ return days.map((day) => ({ day, cells: byPlace.map((m) => m.get(day) ?? []) }));
27
+ }
package/lib/video.ts CHANGED
@@ -1,56 +1,101 @@
1
- /**
2
- * Video links → a privacy-friendly embed. Accepts what an editor would paste — a
3
- * YouTube watch / share / shorts / live link, a channel's live page, or a Vimeo link —
4
- * and returns the embed URL (youtube-nocookie / Vimeo `dnt=1`) plus a link to watch it
5
- * on the provider. Anything else → null (the block shows a plain link instead).
6
- */
7
- export interface VideoEmbed {
8
- provider: 'YouTube' | 'Vimeo';
9
- embedUrl: string;
10
- watchUrl: string;
11
- }
12
-
13
- const YT_ID = /^[A-Za-z0-9_-]{11}$/;
14
- const YT_CHANNEL = /^UC[A-Za-z0-9_-]{22}$/;
15
-
16
- export function videoEmbed(input: string | undefined | null): VideoEmbed | null {
17
- if (!input) return null;
18
- let u: URL;
19
- try {
20
- u = new URL(input.trim());
21
- } catch {
22
- return null;
23
- }
24
- if (u.protocol !== 'https:') return null;
25
- const host = u.hostname.replace(/^(www\.|m\.)/, '');
26
- const parts = u.pathname.split('/').filter(Boolean);
27
- const watchUrl = u.toString();
28
-
29
- if (host === 'youtu.be' && YT_ID.test(parts[0] ?? '')) return yt(parts[0], watchUrl);
30
- if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
31
- const v = u.searchParams.get('v');
32
- if (parts[0] === 'watch' && v && YT_ID.test(v)) return yt(v, watchUrl);
33
- if (['embed', 'shorts', 'live', 'v'].includes(parts[0]) && YT_ID.test(parts[1] ?? '')) return yt(parts[1], watchUrl);
34
- // A channel's live stream: /channel/<UC…>/live, or an embed of live_stream?channel=…
35
- const channel = parts[0] === 'channel' && parts[2] === 'live' ? parts[1] : u.searchParams.get('channel');
36
- if (channel && YT_CHANNEL.test(channel)) {
37
- return {
38
- provider: 'YouTube',
39
- embedUrl: `https://www.youtube.com/embed/live_stream?channel=${channel}`,
40
- watchUrl: `https://www.youtube.com/channel/${channel}/live`,
41
- };
42
- }
43
- return null;
44
- }
45
- if (host === 'vimeo.com' && /^\d+$/.test(parts[0] ?? '')) {
46
- return { provider: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${parts[0]}?dnt=1`, watchUrl };
47
- }
48
- if (host === 'player.vimeo.com' && parts[0] === 'video' && /^\d+$/.test(parts[1] ?? '')) {
49
- return { provider: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${parts[1]}?dnt=1`, watchUrl: `https://vimeo.com/${parts[1]}` };
50
- }
51
- return null;
52
- }
53
-
54
- function yt(id: string, watchUrl: string): VideoEmbed {
55
- return { provider: 'YouTube', embedUrl: `https://www.youtube-nocookie.com/embed/${id}?rel=0`, watchUrl };
56
- }
1
+ /**
2
+ * Video links → a privacy-friendly embed. Accepts what an editor would paste — a
3
+ * YouTube watch / share / shorts / live link, a channel's live page, or a Vimeo link —
4
+ * and returns the embed URL (youtube-nocookie / Vimeo `dnt=1`) plus a link to watch it
5
+ * on the provider. Anything else → null (the block shows a plain link instead).
6
+ */
7
+ export interface VideoEmbed {
8
+ provider: 'YouTube' | 'Vimeo';
9
+ embedUrl: string;
10
+ watchUrl: string;
11
+ /** A channel's live stream: often off air, so it loads only when the visitor asks. */
12
+ live?: boolean;
13
+ }
14
+
15
+ const YT_ID = /^[A-Za-z0-9_-]{11}$/;
16
+ const YT_CHANNEL = /^UC[A-Za-z0-9_-]{22}$/;
17
+
18
+ export function videoEmbed(input: string | undefined | null): VideoEmbed | null {
19
+ if (!input) return null;
20
+ let u: URL;
21
+ try {
22
+ u = new URL(input.trim());
23
+ } catch {
24
+ return null;
25
+ }
26
+ if (u.protocol !== 'https:') return null;
27
+ const host = u.hostname.replace(/^(www\.|m\.)/, '');
28
+ const parts = u.pathname.split('/').filter(Boolean);
29
+ const watchUrl = u.toString();
30
+
31
+ if (host === 'youtu.be' && YT_ID.test(parts[0] ?? '')) return yt(parts[0], watchUrl);
32
+ if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
33
+ const v = u.searchParams.get('v');
34
+ if (parts[0] === 'watch' && v && YT_ID.test(v)) return yt(v, watchUrl);
35
+ if (['embed', 'shorts', 'live', 'v'].includes(parts[0]) && YT_ID.test(parts[1] ?? '')) return yt(parts[1], watchUrl);
36
+ // A channel's live stream: /channel/<UC…>/live, or an embed of live_stream?channel=…
37
+ const channel = parts[0] === 'channel' && parts[2] === 'live' ? parts[1] : u.searchParams.get('channel');
38
+ if (channel && YT_CHANNEL.test(channel)) {
39
+ return {
40
+ provider: 'YouTube',
41
+ embedUrl: `https://www.youtube.com/embed/live_stream?channel=${channel}`,
42
+ // The streams tab always has something (upcoming and past streams); /live is
43
+ // empty whenever the channel is off air.
44
+ watchUrl: `https://www.youtube.com/channel/${channel}/streams`,
45
+ live: true,
46
+ };
47
+ }
48
+ return null;
49
+ }
50
+ if (host === 'vimeo.com' && /^\d+$/.test(parts[0] ?? '')) {
51
+ return { provider: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${parts[0]}?dnt=1`, watchUrl };
52
+ }
53
+ if (host === 'player.vimeo.com' && parts[0] === 'video' && /^\d+$/.test(parts[1] ?? '')) {
54
+ return { provider: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${parts[1]}?dnt=1`, watchUrl: `https://vimeo.com/${parts[1]}` };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * The same links as a header BACKGROUND: muted, autoplaying, looping and chromeless.
61
+ * YouTube loops a single video only with `playlist=<id>`; `enablejsapi=1` lets the
62
+ * hero's pause button send play/pause commands (postMessage, no API script). Vimeo
63
+ * has a dedicated `background=1` mode. Live streams and hosted files → null (a file
64
+ * plays in a native <video>).
65
+ */
66
+ export function backgroundEmbed(input: string | undefined | null): VideoEmbed | null {
67
+ const v = videoEmbed(input);
68
+ if (!v || v.embedUrl.includes('live_stream')) return null;
69
+ if (v.provider === 'Vimeo') return { ...v, embedUrl: v.embedUrl.replace('?dnt=1', '?background=1&dnt=1') };
70
+ const id = new URL(v.embedUrl).pathname.split('/').pop()!;
71
+ const q = new URLSearchParams({
72
+ autoplay: '1', mute: '1', loop: '1', playlist: id, controls: '0', playsinline: '1',
73
+ rel: '0', disablekb: '1', iv_load_policy: '3', enablejsapi: '1',
74
+ });
75
+ return { ...v, embedUrl: `https://www.youtube-nocookie.com/embed/${id}?${q}` };
76
+ }
77
+
78
+ /**
79
+ * Has an embedded background player actually started playing? Read from the messages the
80
+ * player posts back (YouTube with `enablejsapi=1` after a `listening` handshake; Vimeo
81
+ * after `addEventListener: play`). Used to keep the poster up until the video is moving,
82
+ * then cross-fade, rather than flashing a black, loading player.
83
+ */
84
+ export function playerStarted(provider: string, data: unknown): boolean {
85
+ let msg: Record<string, unknown> | null = null;
86
+ try {
87
+ msg = typeof data === 'string' ? JSON.parse(data) : (data as Record<string, unknown> | null);
88
+ } catch {
89
+ return false;
90
+ }
91
+ if (!msg || typeof msg !== 'object') return false;
92
+ if (provider === 'Vimeo') return msg.event === 'play' || msg.event === 'playProgress';
93
+ const info = msg.info as unknown;
94
+ if (msg.event === 'onStateChange') return info === 1;
95
+ if (msg.event === 'infoDelivery' && info && typeof info === 'object') return (info as { playerState?: number }).playerState === 1;
96
+ return false;
97
+ }
98
+
99
+ function yt(id: string, watchUrl: string): VideoEmbed {
100
+ return { provider: 'YouTube', embedUrl: `https://www.youtube-nocookie.com/embed/${id}?rel=0`, watchUrl };
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.7.0",
3
+ "version": "0.7.4",
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",
@@ -0,0 +1,27 @@
1
+ ---
2
+ /**
3
+ * The cookie policy (`/cookie-policy`) — INJECTED by the ferst() integration, so the
4
+ * cookie banner's link always resolves. Generated from what this site actually uses:
5
+ * every block type on its pages (third-party embeds such as maps or video) and whether a
6
+ * calendar is configured. A site with its own `src/pages/cookie-policy.astro` keeps it
7
+ * (the integration skips this route then).
8
+ */
9
+ import Base from '../layouts/Base.astro';
10
+ import CookiePolicy from '../components/CookiePolicy.astro';
11
+ import { getPageEntries } from '../lib/pages';
12
+ import { getCalendarSettings } from '../lib/calendar';
13
+ import { blockTypesIn, scanCookieUses } from '../content/cookies';
14
+ import { pageSchema } from '../content/blocks';
15
+
16
+ const blockTypes = new Set<string>();
17
+ for (const entry of await getPageEntries()) {
18
+ const page = pageSchema.safeParse(entry.data);
19
+ if (page.success) blockTypesIn(page.data.blocks as Array<{ type: string; blocks?: unknown }>, blockTypes);
20
+ }
21
+ const cal = await getCalendarSettings();
22
+ const model = scanCookieUses({ blockTypes, calendarConfigured: Boolean(cal.embedUrl) });
23
+ ---
24
+
25
+ <Base title="Cookie Policy" description="What this site stores on your device, and the third-party content it can show.">
26
+ <CookiePolicy model={model} />
27
+ </Base>