ferst-core 0.7.2 → 0.7.5

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 (47) hide show
  1. package/bin/ferst-update.mjs +33 -2
  2. package/cms/config.template.yml +214 -206
  3. package/components/BlockRenderer.astro +10 -0
  4. package/components/Pagination.astro +51 -0
  5. package/components/SiteFooter.astro +274 -270
  6. package/components/SiteHeader.astro +1243 -1183
  7. package/components/blocks/Heading.astro +51 -46
  8. package/components/blocks/Prose.astro +10 -2
  9. package/components/blocks/Quote.astro +40 -38
  10. package/components/recipes/Accordion.astro +91 -84
  11. package/components/recipes/Contacts.astro +139 -0
  12. package/components/recipes/Document.astro +157 -0
  13. package/components/recipes/FeatureCards.astro +112 -111
  14. package/components/recipes/Hero.astro +52 -4
  15. package/components/recipes/LinkRow.astro +91 -0
  16. package/components/recipes/MediaCards.astro +213 -212
  17. package/components/recipes/MediaText.astro +92 -0
  18. package/components/recipes/Notice.astro +90 -89
  19. package/components/recipes/Stats.astro +152 -145
  20. package/components/recipes/Table.astro +101 -97
  21. package/components/recipes/Tabs.astro +134 -133
  22. package/components/recipes/Tiles.astro +4 -2
  23. package/components/recipes/Timetable.astro +189 -0
  24. package/components/recipes/Video.astro +177 -157
  25. package/content/blocks.ts +113 -1
  26. package/content/cookies.ts +10 -0
  27. package/content/schemas.ts +167 -161
  28. package/examples/primitives.ts +2 -0
  29. package/examples/recipes.ts +7 -0
  30. package/integration.mjs +83 -64
  31. package/layouts/Base.astro +483 -475
  32. package/lib/documents.ts +68 -62
  33. package/lib/embed-consent.ts +78 -73
  34. package/lib/inline-markdown.ts +6 -0
  35. package/lib/links.ts +22 -0
  36. package/lib/paginate.ts +23 -0
  37. package/lib/posts.ts +20 -2
  38. package/lib/siteSettings.ts +61 -60
  39. package/lib/slug.ts +32 -0
  40. package/lib/table.ts +10 -0
  41. package/lib/timetable.ts +27 -0
  42. package/lib/video.ts +101 -75
  43. package/package.json +1 -1
  44. package/routes/cookie-policy.astro +27 -0
  45. package/routes/posts/index.astro +36 -31
  46. package/routes/posts/page/[page].astro +36 -0
  47. package/routes/posts/tag/[...tag].astro +41 -37
package/lib/video.ts CHANGED
@@ -1,75 +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
- /**
55
- * The same links as a header BACKGROUND: muted, autoplaying, looping and chromeless.
56
- * YouTube loops a single video only with `playlist=<id>`; `enablejsapi=1` lets the
57
- * hero's pause button send play/pause commands (postMessage, no API script). Vimeo
58
- * has a dedicated `background=1` mode. Live streams and hosted files → null (a file
59
- * plays in a native <video>).
60
- */
61
- export function backgroundEmbed(input: string | undefined | null): VideoEmbed | null {
62
- const v = videoEmbed(input);
63
- if (!v || v.embedUrl.includes('live_stream')) return null;
64
- if (v.provider === 'Vimeo') return { ...v, embedUrl: v.embedUrl.replace('?dnt=1', '?background=1&dnt=1') };
65
- const id = new URL(v.embedUrl).pathname.split('/').pop()!;
66
- const q = new URLSearchParams({
67
- autoplay: '1', mute: '1', loop: '1', playlist: id, controls: '0', playsinline: '1',
68
- rel: '0', disablekb: '1', iv_load_policy: '3', enablejsapi: '1',
69
- });
70
- return { ...v, embedUrl: `https://www.youtube-nocookie.com/embed/${id}?${q}` };
71
- }
72
-
73
- function yt(id: string, watchUrl: string): VideoEmbed {
74
- return { provider: 'YouTube', embedUrl: `https://www.youtube-nocookie.com/embed/${id}?rel=0`, watchUrl };
75
- }
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.2",
3
+ "version": "0.7.5",
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>
@@ -1,31 +1,36 @@
1
- ---
2
- /**
3
- * Posts index (`/posts`) — INJECTED by the ferst() integration (blog option).
4
- * The news / notices listing. Excludes any tag in postsSettings.excludeFromIndex
5
- * (e.g. a newsletter series that has its own tag page). Content, not code — a
6
- * client carries the post markdown + tag data; this route ships from core.
7
- */
8
- import Base from '../../layouts/Base.astro';
9
- import NewsList from '../../components/NewsList.astro';
10
- import PostsFilter from '../../components/PostsFilter.astro';
11
- import { getIndexPosts, getPostsSettings, getUsedTags } from '../../lib/posts';
12
-
13
- const [posts, settings, tags] = await Promise.all([getIndexPosts(), getPostsSettings(), getUsedTags()]);
14
- ---
15
-
16
- <Base title={settings.title} description={settings.intro || undefined}>
17
- <section class="posts">
18
- <header class="posts__head">
19
- <h1>{settings.title}</h1>
20
- {settings.intro && <p class="t-lead">{settings.intro}</p>}
21
- </header>
22
- <PostsFilter tags={tags} />
23
- <NewsList posts={posts} empty="No posts yet." />
24
- </section>
25
- </Base>
26
-
27
- <style>
28
- .posts { display: flex; flex-direction: column; gap: 2rem; }
29
- .posts__head { display: flex; flex-direction: column; gap: 0.5rem; }
30
- .posts__head .t-lead { color: var(--muted); }
31
- </style>
1
+ ---
2
+ /**
3
+ * Posts index (`/posts`) — INJECTED by the ferst() integration (blog option).
4
+ * The news / notices listing. Excludes any tag in postsSettings.excludeFromIndex
5
+ * (e.g. a newsletter series that has its own tag page). Content, not code — a
6
+ * client carries the post markdown + tag data; this route ships from core.
7
+ */
8
+ import Base from '../../layouts/Base.astro';
9
+ import NewsList from '../../components/NewsList.astro';
10
+ import PostsFilter from '../../components/PostsFilter.astro';
11
+ import Pagination from '../../components/Pagination.astro';
12
+ import { getIndexPosts, getPostsSettings, getUsedTags } from '../../lib/posts';
13
+ import { pageCount, pageSlice } from '../../lib/paginate';
14
+
15
+ const [all, settings, tags] = await Promise.all([getIndexPosts(), getPostsSettings(), getUsedTags()]);
16
+ const pages = pageCount(all.length, settings.perPage);
17
+ const posts = pageSlice(all, 1, settings.perPage);
18
+ ---
19
+
20
+ <Base title={settings.title} description={settings.intro || undefined}>
21
+ <section class="posts">
22
+ <header class="posts__head">
23
+ <h1>{settings.title}</h1>
24
+ {settings.intro && <p class="t-lead">{settings.intro}</p>}
25
+ </header>
26
+ <PostsFilter tags={tags} />
27
+ <NewsList posts={posts} empty="No posts yet." />
28
+ <Pagination base="/posts" page={1} pages={pages} />
29
+ </section>
30
+ </Base>
31
+
32
+ <style>
33
+ .posts { display: flex; flex-direction: column; gap: 2rem; }
34
+ .posts__head { display: flex; flex-direction: column; gap: 0.5rem; }
35
+ .posts__head .t-lead { color: var(--muted); }
36
+ </style>
@@ -0,0 +1,36 @@
1
+ ---
2
+ /**
3
+ * Posts index, page 2 onwards (`/posts/page/<n>`) — INJECTED by the ferst() integration
4
+ * (blog option). Page 1 is `/posts`. Pages are `postsSettings.perPage` posts long.
5
+ */
6
+ import Base from '../../../layouts/Base.astro';
7
+ import NewsList from '../../../components/NewsList.astro';
8
+ import PostsFilter from '../../../components/PostsFilter.astro';
9
+ import Pagination from '../../../components/Pagination.astro';
10
+ import { getIndexPageRoutes, getIndexPosts, getPostsSettings, getUsedTags } from '../../../lib/posts';
11
+ import { pageSlice } from '../../../lib/paginate';
12
+
13
+ export const getStaticPaths = getIndexPageRoutes;
14
+
15
+ const { page, pages } = Astro.props as { page: number; pages: number };
16
+ const [all, settings, tags] = await Promise.all([getIndexPosts(), getPostsSettings(), getUsedTags()]);
17
+ const posts = pageSlice(all, page, settings.perPage);
18
+ ---
19
+
20
+ <Base title={`${settings.title} — page ${page}`} description={settings.intro || undefined}>
21
+ <section class="posts">
22
+ <header class="posts__head">
23
+ <h1>{settings.title}</h1>
24
+ <p class="posts__page">Page {page} of {pages}</p>
25
+ </header>
26
+ <PostsFilter tags={tags} />
27
+ <NewsList posts={posts} />
28
+ <Pagination base="/posts" page={page} pages={pages} />
29
+ </section>
30
+ </Base>
31
+
32
+ <style>
33
+ .posts { display: flex; flex-direction: column; gap: 2rem; }
34
+ .posts__head { display: flex; flex-direction: column; gap: 0.5rem; }
35
+ .posts__page { margin: 0; color: var(--muted); }
36
+ </style>
@@ -1,37 +1,41 @@
1
- ---
2
- /**
3
- * A tag page (`/posts/tag/<slug>`) — INJECTED by the ferst() integration (blog
4
- * option). All posts carrying one tag. This is also how an excluded-from-index
5
- * series (e.g. a newsletter) gets its own page.
6
- */
7
- import Base from '../../../layouts/Base.astro';
8
- import NewsList from '../../../components/NewsList.astro';
9
- import PostsFilter from '../../../components/PostsFilter.astro';
10
- import { getTagRoutes, getPostsByTag, getUsedTags } from '../../../lib/posts';
11
-
12
- export const getStaticPaths = getTagRoutes;
13
-
14
- const { tag } = Astro.props;
15
- const [posts, tags] = await Promise.all([getPostsByTag(tag.slug), getUsedTags()]);
16
- ---
17
-
18
- <Base title={tag.label} description={tag.description || undefined}>
19
- <section class="posts">
20
- <header class="posts__head">
21
- <p class="posts__eyebrow">Tag</p>
22
- <h1>{tag.label}</h1>
23
- {tag.description && <p class="t-lead">{tag.description}</p>}
24
- </header>
25
- <PostsFilter tags={tags} active={tag.slug} />
26
- <NewsList posts={posts} />
27
- <a class="posts__back" href="/posts">&larr; All posts</a>
28
- </section>
29
- </Base>
30
-
31
- <style>
32
- .posts { display: flex; flex-direction: column; gap: 2rem; }
33
- .posts__head { display: flex; flex-direction: column; gap: 0.35rem; }
34
- .posts__eyebrow { margin: 0; font-family: var(--font-heading); font-size: 0.75rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: var(--gold); }
35
- .posts__head .t-lead { color: var(--muted); }
36
- .posts__back { font-weight: 600; color: var(--gold); }
37
- </style>
1
+ ---
2
+ /**
3
+ * A tag page (`/posts/tag/<slug>`) — INJECTED by the ferst() integration (blog
4
+ * option). All posts carrying one tag. This is also how an excluded-from-index
5
+ * series (e.g. a newsletter) gets its own page.
6
+ */
7
+ import Base from '../../../layouts/Base.astro';
8
+ import NewsList from '../../../components/NewsList.astro';
9
+ import PostsFilter from '../../../components/PostsFilter.astro';
10
+ import Pagination from '../../../components/Pagination.astro';
11
+ import { getTagRoutes, getPostsByTag, getPostsSettings, getUsedTags } from '../../../lib/posts';
12
+ import { pageSlice } from '../../../lib/paginate';
13
+
14
+ export const getStaticPaths = getTagRoutes;
15
+
16
+ const { tag, page = 1, pages = 1 } = Astro.props;
17
+ const [all, tags, settings] = await Promise.all([getPostsByTag(tag.slug), getUsedTags(), getPostsSettings()]);
18
+ const posts = pageSlice(all, page, settings.perPage);
19
+ ---
20
+
21
+ <Base title={page > 1 ? `${tag.label} — page ${page}` : tag.label} description={tag.description || undefined}>
22
+ <section class="posts">
23
+ <header class="posts__head">
24
+ <p class="posts__eyebrow">Tag</p>
25
+ <h1>{tag.label}</h1>
26
+ {tag.description && <p class="t-lead">{tag.description}</p>}
27
+ </header>
28
+ <PostsFilter tags={tags} active={tag.slug} />
29
+ <NewsList posts={posts} />
30
+ <Pagination base={`/posts/tag/${tag.slug}`} page={page} pages={pages} />
31
+ <a class="posts__back" href="/posts">&larr; All posts</a>
32
+ </section>
33
+ </Base>
34
+
35
+ <style>
36
+ .posts { display: flex; flex-direction: column; gap: 2rem; }
37
+ .posts__head { display: flex; flex-direction: column; gap: 0.35rem; }
38
+ .posts__eyebrow { margin: 0; font-family: var(--font-heading); font-size: 0.75rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: var(--gold); }
39
+ .posts__head .t-lead { color: var(--muted); }
40
+ .posts__back { font-weight: 600; color: var(--gold); }
41
+ </style>