ferst-core 0.4.9 → 0.5.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.
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // ferst-cms-config — generate this build's Sveltia CMS config from the SINGLE-SOURCE
3
+ // template shipped in ferst-core (cms/config.template.yml), so every client's
4
+ // collections are identical and always match the engine — no bespoke CMS drift. Run
5
+ // it AFTER `astro build`:
6
+ //
7
+ // "build": "astro build && ferst-cms-config"
8
+ //
9
+ // It resolves the per-site backend (repo + git-proxy origin) and the deployment
10
+ // branch from the environment, fills the template's placeholders and writes
11
+ // dist/admin/config.yml. The client repo commits NO config.yml — it is a build
12
+ // product, so it can never be hand-edited into drift. Supersedes ferst-set-cms-branch
13
+ // for clients on this path (it also stamps the branch).
14
+ //
15
+ // Output path: dist/admin/config.yml under the CWD (the client repo root at build
16
+ // time). Override with a first CLI arg.
17
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
+ import { execSync } from 'node:child_process';
19
+ import { dirname, resolve } from 'node:path';
20
+ import { fileURLToPath, pathToFileURL } from 'node:url';
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url));
23
+ export const TEMPLATE_PATH = resolve(HERE, '../cms/config.template.yml');
24
+
25
+ function env(name) {
26
+ const v = process.env[name];
27
+ return v && v.trim() ? v.trim() : '';
28
+ }
29
+
30
+ /** Resolve the per-site backend repo (owner/name) from the environment / git remote. */
31
+ export function repoSlug() {
32
+ // GitHub Actions sets GITHUB_REPOSITORY=owner/name.
33
+ const gh = env('GITHUB_REPOSITORY');
34
+ if (gh) return gh;
35
+ try {
36
+ const url = execSync('git config --get remote.origin.url', {
37
+ stdio: ['ignore', 'pipe', 'ignore'],
38
+ }).toString().trim();
39
+ const m = url.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
40
+ if (m) return m[1];
41
+ } catch {
42
+ /* not a git checkout — fall through */
43
+ }
44
+ return '';
45
+ }
46
+
47
+ /** The deployment branch: CF Pages env → current git branch → main. */
48
+ export function deploymentBranch() {
49
+ const cf = env('CF_PAGES_BRANCH');
50
+ if (cf) return cf;
51
+ try {
52
+ const git = execSync('git rev-parse --abbrev-ref HEAD', {
53
+ stdio: ['ignore', 'pipe', 'ignore'],
54
+ }).toString().trim();
55
+ if (git && git !== 'HEAD') return git;
56
+ } catch {
57
+ /* not a git checkout — fall through */
58
+ }
59
+ return 'main';
60
+ }
61
+
62
+ /**
63
+ * Fill the config template's placeholders. Pure — unit-testable.
64
+ * @param {string} tpl the template contents
65
+ * @param {{ repo: string, base: string, branch: string }} vars
66
+ * @returns {string}
67
+ */
68
+ export function fillConfig(tpl, { repo, base, branch }) {
69
+ const out = tpl
70
+ .split('__REPO__').join(repo)
71
+ .split('__API_ROOT__').join(`${base}/git`)
72
+ .split('__BASE_URL__').join(base)
73
+ .split('__BRANCH__').join(branch);
74
+ const leftover = out.match(/__[A-Z][A-Z_]*__/);
75
+ if (leftover) {
76
+ throw new Error(`ferst-cms-config: unfilled placeholder ${leftover[0]} — add it to the filler.`);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function main() {
82
+ const outPath = resolve(process.cwd(), process.argv[2] ?? 'dist/admin/config.yml');
83
+
84
+ const repo = repoSlug();
85
+ if (!repo) {
86
+ console.error('ferst-cms-config: could not determine the repo — set GITHUB_REPOSITORY=owner/name.');
87
+ process.exit(1);
88
+ }
89
+ // The platform git-proxy origin. Identical for every managed client; overridable for
90
+ // dev/self-hosted via FERST_GIT_BASE.
91
+ const base = env('FERST_GIT_BASE') || 'https://signin.ferst.co.uk';
92
+ const branch = deploymentBranch();
93
+
94
+ let tpl;
95
+ try {
96
+ tpl = readFileSync(TEMPLATE_PATH, 'utf8');
97
+ } catch {
98
+ console.error(`ferst-cms-config: template not found at ${TEMPLATE_PATH} — is ferst-core installed?`);
99
+ process.exit(1);
100
+ }
101
+
102
+ let out;
103
+ try {
104
+ out = fillConfig(tpl, { repo, base, branch });
105
+ } catch (e) {
106
+ console.error(String(e.message ?? e));
107
+ process.exit(1);
108
+ }
109
+
110
+ mkdirSync(dirname(outPath), { recursive: true });
111
+ writeFileSync(outPath, out);
112
+ console.log(`ferst-cms-config: wrote ${outPath} (repo=${repo}, branch="${branch}", base=${base})`);
113
+ }
114
+
115
+ // Run only when invoked directly as a bin (not when imported by a test).
116
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
117
+ main();
118
+ }
@@ -0,0 +1,206 @@
1
+ # Sveltia CMS — the content editor for a Ferst site. SINGLE SOURCE: this file lives
2
+ # in ferst-core and is written to dist/admin/config.yml at build by `ferst-cms-config`,
3
+ # so every client's collections are identical and always match the engine. The client
4
+ # repo commits NO config.yml — it is a build product, so it can never drift by hand.
5
+ #
6
+ # The backend repo (owner/name), git-proxy origin, its /git api_root and this
7
+ # deployment's branch are injected at build by ferst-cms-config.
8
+ backend:
9
+ name: github
10
+ repo: __REPO__
11
+ branch: __BRANCH__
12
+ base_url: __BASE_URL__
13
+ auth_endpoint: auth
14
+ api_root: __API_ROOT__
15
+
16
+ media_folder: public/uploads
17
+ public_folder: /uploads
18
+
19
+ collections:
20
+ - name: settings
21
+ label: Site settings
22
+ files:
23
+ - name: theme
24
+ label: Design tokens
25
+ file: src/content/themeSettings/index.json
26
+ icon: palette
27
+ description: "Edit your brand in the Theme Studio, where you preview changes live before publishing — open portal.ferst.co.uk, choose this site, then the Design tab. The fields here still work as a fallback."
28
+ fields:
29
+ - { name: brand, label: Brand colour, widget: color, default: '#0E4D4A', hint: Buttons, links, icons, highlights. }
30
+ - { name: ink, label: Ink colour, widget: color, default: '#0C2123', hint: Headings and body text. }
31
+ - { name: surface, label: Surface colour, widget: color, default: '#FFFFFF', hint: Page background. }
32
+ - name: fonts
33
+ label: Typography
34
+ widget: object
35
+ collapsed: false
36
+ fields:
37
+ - name: heading
38
+ label: Heading font
39
+ widget: select
40
+ required: false
41
+ default: ''
42
+ options: &fonts
43
+ - { label: 'Default (theme)', value: '' }
44
+ - { label: 'Poppins', value: 'Poppins, system-ui, sans-serif' }
45
+ - { label: 'Inter', value: 'Inter, system-ui, sans-serif' }
46
+ - { label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" }
47
+ - { label: 'Georgia', value: "Georgia, 'Times New Roman', serif" }
48
+ - { label: 'System', value: 'system-ui, sans-serif' }
49
+ - { name: body, label: Body font, widget: select, required: false, default: '', options: *fonts }
50
+ - { name: display, label: Display font, widget: select, required: false, default: '', options: *fonts }
51
+ - { name: corners, label: Corners, widget: select, default: rounded, options: [{ label: Sharp, value: sharp }, { label: Rounded, value: rounded }, { label: Soft, value: soft }] }
52
+ - { name: elevation, label: Elevation, widget: select, default: raised, options: [{ label: Raised, value: raised }, { label: Flat, value: flat }] }
53
+ - { name: stroke, label: Card stroke, widget: select, default: 'on', options: [{ label: 'On', value: 'on' }, { label: 'Off', value: 'off' }] }
54
+ - { name: fields, label: Input fields, widget: select, default: boxed, options: [{ label: Boxed, value: boxed }, { label: Filled, value: filled }, { label: Underline, value: underline }] }
55
+ - { name: darkDepth, label: Dark depth, widget: number, value_type: float, min: 0.14, max: 0.34, step: 0.01, default: 0.21 }
56
+ - { name: darkChroma, label: Dark colour intensity, widget: number, value_type: float, min: 0, max: 0.15, step: 0.01, default: 0.05 }
57
+ - name: darkOverrides
58
+ label: Dark colour overrides (optional)
59
+ widget: object
60
+ collapsed: true
61
+ fields:
62
+ - { name: ground, label: Ground, widget: string, required: false }
63
+ - { name: text, label: Text, widget: string, required: false }
64
+ - { name: accent, label: Accent, widget: string, required: false }
65
+
66
+ - name: identity
67
+ label: Identity & contact
68
+ file: src/content/siteSettings/index.json
69
+ icon: info
70
+ fields:
71
+ - name: identity
72
+ label: Identity
73
+ widget: object
74
+ fields:
75
+ - { name: siteTitle, label: Site title, widget: string }
76
+ - { name: shortName, label: Short name, widget: string }
77
+ - { name: description, label: Description, widget: text }
78
+ - { name: location, label: Location, widget: string, required: false }
79
+ - name: contact
80
+ label: Contact
81
+ widget: object
82
+ fields:
83
+ - { name: phoneDisplay, label: Phone (display), widget: string, required: false }
84
+ - { name: phoneTel, label: 'Phone (tel: digits)', widget: string, required: false }
85
+ - { name: email, label: Email, widget: string, required: false }
86
+ - { name: facebookUrl, label: Facebook URL, widget: string, required: false }
87
+
88
+ - name: logos
89
+ label: Logos
90
+ file: src/content/logoSettings/index.json
91
+ icon: image
92
+ fields:
93
+ - { name: primaryLogoLight, label: Header logo (light), widget: image, required: false }
94
+ - { name: primaryLogoDark, label: Header logo (dark), widget: image, required: false }
95
+ - { name: secondaryLogoLight, label: Footer logo (light), widget: image, required: false }
96
+ - { name: secondaryLogoDark, label: Footer logo (dark), widget: image, required: false }
97
+
98
+ - name: navbar
99
+ label: Navigation
100
+ file: src/content/navbarSettings/index.json
101
+ icon: menu
102
+ fields:
103
+ - name: primary
104
+ label: Primary links
105
+ widget: list
106
+ fields:
107
+ - { name: label, label: Label, widget: string }
108
+ - { name: href, label: Link, widget: string }
109
+
110
+ - name: footer
111
+ label: Footer
112
+ file: src/content/footerSettings/index.json
113
+ icon: table_rows
114
+ fields:
115
+ - name: footer
116
+ label: Footer links
117
+ widget: list
118
+ fields:
119
+ - { name: label, label: Label, widget: string }
120
+ - { name: href, label: Link, widget: string }
121
+
122
+ - name: calendar-settings
123
+ label: Calendar
124
+ file: src/content/calendarSettings/index.json
125
+ icon: calendar_today
126
+ fields:
127
+ - { name: title, label: Heading, widget: string, default: Calendar }
128
+ - { name: intro, label: Intro, widget: text, required: false }
129
+ - { name: embedUrl, label: Embed URL, widget: string, required: false }
130
+ - { name: publicUrl, label: Public link, widget: string, required: false }
131
+ - { name: height, label: Height (px), widget: number, default: 700, value_type: int, min: 200, max: 2000 }
132
+ - { name: provider, label: Provider name, widget: string, default: Google Calendar }
133
+ - { name: weekStart, label: Week starts on, widget: select, default: monday, options: [{ label: "Monday", value: monday }, { label: "Sunday", value: sunday }] }
134
+
135
+ - name: posts
136
+ label: Posts
137
+ label_singular: Post
138
+ folder: src/content/posts
139
+ create: true
140
+ slug: "{{slug}}"
141
+ extension: md
142
+ format: frontmatter
143
+ icon: article
144
+ summary: "{{title}} — {{date}}"
145
+ sortable_fields: [date, title]
146
+ fields:
147
+ - { name: title, label: Title, widget: string }
148
+ - { name: date, label: Date, widget: datetime, picker_utc: true }
149
+ - { name: summary, label: Summary, widget: text, required: false }
150
+ - name: tags
151
+ label: Tags
152
+ widget: relation
153
+ required: false
154
+ collection: tags
155
+ value_field: "{{slug}}"
156
+ display_fields: [label]
157
+ search_fields: [label]
158
+ multiple: true
159
+ - { name: heroImage, label: Hero image, widget: image, required: false }
160
+ - { name: attachment, label: Attachment (e.g. PDF), widget: file, required: false }
161
+ - { name: draft, label: Draft, widget: boolean, default: false, required: false }
162
+ - { name: body, label: Body, widget: markdown, required: false }
163
+
164
+ - name: tags
165
+ label: Tags
166
+ label_singular: Tag
167
+ folder: src/content/tags
168
+ create: true
169
+ slug: "{{slug}}"
170
+ extension: json
171
+ format: json
172
+ icon: label
173
+ identifier_field: label
174
+ fields:
175
+ - { name: label, label: Label, widget: string }
176
+ - { name: description, label: Description, widget: text, required: false }
177
+ - { name: hidden, label: Hidden, widget: boolean, default: false, required: false }
178
+
179
+ - name: pages
180
+ label: Pages
181
+ label_singular: Page
182
+ description: "See each page and request a change — our team makes it and sends it back for you to check. You don't edit the page layout here."
183
+ folder: src/content/pages
184
+ extension: json
185
+ format: json
186
+ icon: description
187
+ nested: { depth: 3, summary: "{{title}}" }
188
+ summary: "{{title}}"
189
+ identifier_field: title
190
+ fields:
191
+ - { name: title, label: Page title, widget: string }
192
+ - name: requests
193
+ label: Requested changes
194
+ label_singular: Request
195
+ widget: list
196
+ required: false
197
+ summary: "{{fields.text}}"
198
+ fields:
199
+ - { name: text, label: "What would you like changed?", widget: text }
200
+ - { name: status, label: Status, widget: select, default: open, options: [{ label: Open, value: open }, { label: Done, value: done }] }
201
+ - { name: description, label: SEO description, widget: hidden, required: false }
202
+ - { name: heroImage, widget: hidden, required: false }
203
+ - { name: align, widget: hidden, required: false }
204
+ - { name: templateId, widget: hidden, required: false }
205
+ - { name: draft, widget: hidden, required: false }
206
+ - { name: blocks, widget: hidden }
@@ -118,7 +118,7 @@ const {
118
118
  }
119
119
 
120
120
  /* CTA / compact variants — a filled button in the brand accent. Token-driven
121
- (was a hardcoded CTK green) so it recolours with the site palette; hover
121
+ (was a hardcoded brand green) so it recolours with the site palette; hover
122
122
  darkens the accent via color-mix, which works for any brand colour. */
123
123
  .btn--cta {
124
124
  background: var(--gold);
@@ -4,7 +4,7 @@
4
4
  * Nothing loads from the provider until the visitor clicks "Load calendar", so no
5
5
  * third-party request fires on page load (privacy-first, matching the site's
6
6
  * cookie stance). Consent is remembered per visitor (localStorage), so it
7
- * auto-loads on later visits. White-room, token-driven.
7
+ * auto-loads on later visits. Token-driven.
8
8
  *
9
9
  * The embed URL is required to be https (isSafeEmbedUrl) before it can reach the
10
10
  * iframe; an unset or unsafe URL degrades to a friendly note + optional link.
@@ -4,7 +4,7 @@
4
4
  * hero image, then a <slot /> for the rendered markdown body. The route calls
5
5
  * `entry.render()` and drops `<Content />` into the slot. Body typography (measure,
6
6
  * images, headings) is handled by Base's global `article` styles; this owns only
7
- * the post header. White-room (no CTK code).
7
+ * the post header.
8
8
  */
9
9
  interface Props {
10
10
  title: string;
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * PostCard — a post preview for the news index and tag pages. Token-driven (radius,
4
4
  * border, shadow, colours all from the theme), equal-height in a grid, with the
5
- * whole card clickable via a stretched title link. White-room (no CTK code).
5
+ * whole card clickable via a stretched title link.
6
6
  */
7
7
  interface Props {
8
8
  href: string;
@@ -7,7 +7,7 @@
7
7
  * as you scroll. Settings-driven (calloutSettings), rendered by Base. Renders
8
8
  * nothing unless enabled AND given a title or text. Progressive: it's a native
9
9
  * <details>, so open/close works with no JS; close + "don't show again" are JS
10
- * enhancements. White-room, token-driven.
10
+ * enhancements. Token-driven.
11
11
  */
12
12
  import Icon from './Icon.astro';
13
13
  import { isIconKey } from '../lib/icons';
@@ -4,7 +4,7 @@
4
4
  * CalendarEmbed. Nothing loads from the provider until the visitor clicks "Load
5
5
  * map", so no third-party request fires on page load (privacy-first, matching the
6
6
  * site's cookie stance). Consent is remembered per visitor (localStorage). A page
7
- * block (drop it on a contact/find-us page), white-room, token-driven.
7
+ * block (drop it on a contact/find-us page), token-driven.
8
8
  *
9
9
  * The embed URL must be https (isSafeEmbedUrl) before it can reach the iframe; an
10
10
  * unset or unsafe URL degrades to a friendly note + optional "open in Maps" link.
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * MediaCards — a Level-2 recipe: a grid of rich content cards with the IMAGE as a
4
4
  * header on top (cover-cropped to a uniform ratio, like PostCard), then an optional
5
- * icon badge, a title, a meta line (e.g. "Sundays 10:45 · CTK"), a description, and
5
+ * icon badge, a title, a meta line (e.g. "Sundays 10:45 · Main hall"), a description, and
6
6
  * an optional CTA link. The image-HEADER pattern (distinct from `tiles`, which puts
7
7
  * the image BEHIND the text): use it for groups, team members, services or events —
8
8
  * anywhere the photo and the copy both need to be fully legible. Whole card links
package/content/blocks.ts CHANGED
@@ -6,8 +6,8 @@ import { z } from 'zod';
6
6
  * as JSON; a Zod **discriminated union** on `type` is the single contract the AI
7
7
  * composes against, the CMS validates, and the renderer's registry keys on.
8
8
  *
9
- * This is a white-room design of the *mechanism* (not a copy of CTK's block
10
- * builder). It starts with **Level-1 primitives** only; recipes (Level 2) and
9
+ * This is an original design of the *mechanism*, built from scratch. It starts
10
+ * with **Level-1 primitives** only; recipes (Level 2) and
11
11
  * page templates (Level 3) layer on top later, and layout/container primitives
12
12
  * (Section/Grid/Stack, which nest child blocks) are a deliberate next slice.
13
13
  */
@@ -469,7 +469,7 @@ export const mediaCardsBlock = z.object({
469
469
  image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
470
470
  icon: z.string().optional(),
471
471
  title: z.string(),
472
- /** A short meta line shown as a pill (e.g. "Sundays 10:45 · CTK"). */
472
+ /** A short meta line shown as a pill (e.g. "Sundays 10:45 · Main hall"). */
473
473
  meta: z.string().optional(),
474
474
  text: z.string().optional(),
475
475
  href: z.string().optional(),
@@ -1,5 +1,5 @@
1
- // The calendar capability contract — white-room, block-system-era core (no CTK
2
- // code). A parish/club/venue embeds an external calendar (typically Google
1
+ // The calendar capability contract — built from scratch on the block-system-era
2
+ // core. A parish/club/venue embeds an external calendar (typically Google
3
3
  // Calendar) for its schedule (Mass times, events). Embedding an external calendar
4
4
  // sends the visitor's device data (e.g. IP) to the provider, so it is ALWAYS
5
5
  // consent-gated (CalendarEmbed.astro) — consistent with the site's no-silent-
@@ -1,5 +1,5 @@
1
- // The sticky-callout capability contract — white-room, block-system-era core (no
2
- // CTK code). A site-level launcher pinned to a screen corner, expanding a small
1
+ // The sticky-callout capability contract — built from scratch on the block-system-era
2
+ // core. A site-level launcher pinned to a screen corner, expanding a small
3
3
  // panel with one key piece of info + an optional link (mass times, opening hours,
4
4
  // "book now", contact). Reusable/replicable → public (Tier 1a). Site-wide chrome
5
5
  // (rendered by Base from calloutSettings), NOT a page block, so it stays put on
package/content/posts.ts CHANGED
@@ -1,5 +1,5 @@
1
- // The posts/blog capability contract — white-room, rebuilt on the block-system-era
2
- // core (no CTK code). A capability owns its own content schema (this file), its
1
+ // The posts/blog capability contract — rebuilt from scratch on the block-system-era
2
+ // core. A capability owns its own content schema (this file), its
3
3
  // collections (collections.ts), its rendering (components/Post*.astro) and its
4
4
  // data helpers (lib/posts.ts); a client repo carries only the post/tag DATA + a
5
5
  // couple of thin routes. Posts are ubiquitous/replicable → public (Tier 1a).
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The block catalogue — a live example of every primitive and recipe, as block JSON.
3
+ *
4
+ * This is the **canonical, single-source** example data for the core's block palette:
5
+ * the reference showcase renders it, the platform renders it ("preview studio" gallery),
6
+ * and `tests/gallery-coverage.test.ts` fails the build if any block `type` lacks an
7
+ * example here. Kept as pure data (validated against `pageSchema`) so any consumer — Astro
8
+ * or not — can read it.
9
+ */
10
+ export type { GalleryEntry } from './primitives';
11
+ export { primitives } from './primitives';
12
+ export { recipes } from './recipes';
@@ -0,0 +1,28 @@
1
+ /** One catalogue entry: a block type, a short description, and a live example. */
2
+ export interface GalleryEntry {
3
+ type: string;
4
+ description?: string;
5
+ block: Record<string, unknown>;
6
+ }
7
+
8
+ export const primitives: GalleryEntry[] = [
9
+ { type: 'heading', description: 'A section title, levels 1–6, optionally aligned.', block: { type: 'heading', text: 'A heading', level: 2 } },
10
+ { type: 'heading', description: 'Optional gradient text — brand → derived accent-2 (falls back to solid ink where unsupported).', block: { type: 'heading', text: 'A gradient heading', level: 2, gradient: true } },
11
+ { type: 'prose', description: 'Body text; blank lines start new paragraphs.', block: { type: 'prose', text: 'A paragraph of prose.\n\nA second paragraph after a blank line.' } },
12
+ { type: 'image', description: 'A responsive image with an optional caption. Corners follow the site by default.', block: { type: 'image', src: '/placeholder.svg', alt: 'Placeholder', caption: 'An image with a caption.' } },
13
+ { type: 'image', description: 'Canonical framing options: soft corners + drop shadow + a fixed 16/9 ratio (image is cover-fit).', block: { type: 'image', src: '/showcase-hero.svg', alt: 'Abstract', radius: 'soft', shadow: true, ratio: '16/9', caption: 'radius: soft · shadow · ratio 16/9' } },
14
+ { type: 'image', description: 'Square ratio with an outline stroke and sharp corners — same block, different canonical options.', block: { type: 'image', src: '/showcase-hero.svg', alt: 'Abstract', radius: 'none', border: true, ratio: 'square', caption: 'radius: none · stroke · square' } },
15
+ { type: 'button', description: 'Primary action — the default. Variant is a role, not decoration; corner shape follows the global radius knob.', block: { type: 'button', label: 'Primary', href: '#' } },
16
+ { type: 'button', description: 'Secondary action — a quieter outline button.', block: { type: 'button', label: 'Secondary', href: '#', variant: 'secondary' } },
17
+ { type: 'button', description: 'Filled CTA — the brand-accent call to action.', block: { type: 'button', label: 'Get started', href: '#', variant: 'cta' } },
18
+ { type: 'button', description: 'Ghost — a quiet, borderless text button for tertiary actions.', block: { type: 'button', label: 'Learn more', href: '#', variant: 'ghost' } },
19
+ { type: 'quote', description: 'A pull quote with an optional citation.', block: { type: 'quote', text: 'A memorable quotation, set apart.', cite: 'Someone' } },
20
+ { type: 'divider', description: 'A horizontal rule between sections.', block: { type: 'divider' } },
21
+ { type: 'spacer', description: 'Vertical space (sm / md / lg) — invisible by design.', block: { type: 'spacer', size: 'lg' } },
22
+ { type: 'section', description: 'A full-width band (background + width) holding child blocks.', block: { type: 'section', background: 'muted', width: 'normal', blocks: [{ type: 'heading', text: 'Inside a section', level: 3 }, { type: 'prose', text: 'Sections hold child blocks.' }] } },
23
+ { type: 'grid', description: 'A responsive N-column grid of child blocks.', block: { type: 'grid', columns: 3, blocks: [{ type: 'prose', text: 'Column one' }, { type: 'prose', text: 'Column two' }, { type: 'prose', text: 'Column three' }] } },
24
+ { type: 'stack', description: 'Stacks child blocks vertically with a consistent gap.', block: { type: 'stack', gap: 'sm', blocks: [{ type: 'prose', text: 'Stacked one' }, { type: 'prose', text: 'Stacked two' }] } },
25
+ { type: 'badge', description: 'A small pill label (accent or neutral tone).', block: { type: 'badge', text: 'New', tone: 'accent' } },
26
+ { type: 'stat', description: 'A headline number with a label.', block: { type: 'stat', value: '250+', label: 'families served' } },
27
+ { type: 'list', description: 'A bulleted, numbered, or checked list of lines.', block: { type: 'list', style: 'check', items: ['Everything shares one design language', 'Your brand, applied consistently', 'You own the output'] } },
28
+ ];
@@ -0,0 +1,32 @@
1
+ import type { GalleryEntry } from './primitives';
2
+
3
+ export const recipes: GalleryEntry[] = [
4
+ { type: 'hero', description: 'Page-opening band — centred preset.', block: { type: 'hero', eyebrow: 'Recipe', title: 'Hero (centered)', subtitle: 'A curated opening band composing Heading + Button.', cta: { label: 'Primary action', href: '#' }, layout: 'centered' } },
5
+ { type: 'hero', description: 'Page-opening band — split preset (text beside image).', block: { type: 'hero', title: 'Hero (split)', subtitle: 'Text beside an image.', cta: { label: 'Primary action', href: '#' }, image: { src: '/placeholder.svg', alt: 'Placeholder' }, layout: 'split' } },
6
+ { type: 'featureCards', description: 'An optional header over a responsive grid of icon + title + text cards.', block: { type: 'featureCards', title: 'Feature cards', columns: 3, cards: [{ icon: 'book-open', title: 'First feature', text: 'A short description.' }, { icon: 'shield', title: 'Second feature', text: 'A short description.' }, { icon: 'sparkle', title: 'Third feature', text: 'A short description.' }] } },
7
+ { type: 'cta', description: 'A centred call-to-action band (heading + text + button).', block: { type: 'cta', title: 'Ready to start?', text: 'A closing nudge with a single, clear action.', cta: { label: 'Get in touch', href: '#' }, background: 'accent' } },
8
+ { type: 'faq', description: 'Expandable question / answer list — native, accessible, no JavaScript.', block: { type: 'faq', title: 'Frequently asked', items: [{ question: 'How are pages built?', answer: 'From a curated set of blocks, composed into your pages and verified before they go live — see the [architecture notes](https://example.com/architecture).' }, { question: 'Can I use my own brand?', answer: 'Yes — colours and fonts are your config; the core is brand-agnostic.' }] } },
9
+ { type: 'gallery', description: 'A responsive grid of images with optional captions.', block: { type: 'gallery', columns: 3, images: [{ src: '/placeholder.svg', alt: 'One', caption: 'One' }, { src: '/placeholder.svg', alt: 'Two', caption: 'Two' }, { src: '/placeholder.svg', alt: 'Three', caption: 'Three' }] } },
10
+ { type: 'testimonial', description: 'A centred quotation with author, role and an optional avatar.', block: { type: 'testimonial', quote: 'The showcase made the whole model click in a minute.', author: 'A. Client', role: 'Parish administrator' } },
11
+ { type: 'contactForm', description: 'An accessible contact form in a centred column — labelled fields, a submit and an optional note. Fields are generic, so it also serves enquiry / sign-up forms.', block: { type: 'contactForm', title: 'Send us a message', intro: 'We usually reply within a day.', note: 'Demo form — the showcase has no backend; a real client points its action at their own endpoint.' } },
12
+ { type: 'accordion', description: 'Generic expand/collapse rows (title + body) — native, accessible, no JavaScript. FAQ’s domain-free sibling.', block: { type: 'accordion', title: 'How it works', items: [{ heading: 'Composable', body: 'Pages are ordered blocks stored as JSON.' }, { heading: 'Consistent', body: 'Recipes lock a good layout so quality can’t drift.' }] } },
13
+ { type: 'tiles', description: 'A card grid with five looks — here “elevated” (soft shadow, lifts on hover) with the optional pointer-following spotlight. Radius / stroke / elevation come from the global surface tokens.', block: { type: 'tiles', variant: 'elevated', columns: 3, spotlight: true, tiles: [{ icon: 'sparkle', eyebrow: 'Level 1', title: 'Primitives', text: 'The raw ingredients.', href: '#' }, { icon: 'book-open', eyebrow: 'Level 2', title: 'Recipes', text: 'Curated compositions.', href: '#' }, { icon: 'shield', eyebrow: 'Level 3', title: 'Templates', text: 'Whole pages.', href: '#' }] } },
14
+ { type: 'tiles', description: 'The “gradient” variant — brand → a derived accent-2 (an OKLCH hue-shift of your one brand colour), light text.', block: { type: 'tiles', variant: 'gradient', columns: 3, tiles: [{ icon: 'flame', title: 'Vibrant', text: 'One brand colour, a harmonious gradient.' }, { icon: 'sparkle', title: 'Derived', text: 'Accent-2 is computed, not configured.' }, { icon: 'heart', title: 'On-brand', text: 'Always harmonises with your hue.' }] } },
15
+ { type: 'tiles', description: 'The “image” variant — a full-bleed photo with a legibility scrim and light text; the whole tile links.', block: { type: 'tiles', variant: 'image', columns: 3, tiles: [{ eyebrow: 'Feature', title: 'Photo tile', text: 'Text stays legible over any image.', href: '#', image: { src: '/placeholder.svg', alt: 'Placeholder' } }, { title: 'Second', text: 'Cover-fit background.', href: '#', image: { src: '/placeholder.svg', alt: 'Placeholder' } }, { title: 'Third', text: 'With a gradient scrim.', href: '#', image: { src: '/placeholder.svg', alt: 'Placeholder' } }] } },
16
+ { type: 'banner', description: 'A full-bleed feature band — a fixed/parallax background image with a scrim and a floating “shaded glass” card. Pure-CSS parallax; disabled on touch and under reduced-motion.', block: { type: 'banner', image: '/showcase-hero.svg', eyebrow: 'Featured', title: 'A banner that moves', text: 'The image is pinned to the viewport, so it drifts under this card as you scroll.', cta: { label: 'Take a look', href: '#' }, align: 'start' } },
17
+ { type: 'stats', description: 'A band of headline numbers (value + label) over an optional heading — social proof at a glance.', block: { type: 'stats', title: 'By the numbers', background: 'muted', columns: 3, items: [{ value: '250+', label: 'projects shipped' }, { value: '99.9%', label: 'uptime', description: 'rolling 12 months' }, { value: '<1 day', label: 'average reply' }] } },
18
+ { type: 'steps', description: 'A numbered process — connected cards (horizontal) or a stacked timeline (vertical).', block: { type: 'steps', title: 'How it works', layout: 'horizontal', items: [{ icon: 'chat', title: 'Tell us the problem', text: 'A straight conversation about the work costing you time.' }, { icon: 'sparkle', title: 'We fit it to you', text: 'The platform, shaped around how you actually work.' }, { icon: 'shield', title: 'You run it', text: 'Yours to use; we keep it running.' }] } },
19
+ { type: 'pricing', description: 'Plan cards with price, feature list and CTA; one plan can be “featured”. Prices shown are placeholder sample data, not real offers.', block: { type: 'pricing', title: 'Pricing (layout example)', intro: 'Sample plans to show the layout — placeholder figures, not real prices.', plans: [{ name: 'Tier one', price: '$XX', period: '/mo', description: 'Example plan.', features: ['Example feature', 'Example feature', 'Example feature'], cta: { label: 'Example CTA', href: '#' } }, { name: 'Tier two', price: '$XX', period: '/mo', description: 'Example plan (featured).', features: ['Everything in tier one', 'Example feature', 'Example feature'], cta: { label: 'Example CTA', href: '#' }, featured: true }, { name: 'Tier three', price: 'Custom', description: 'Example plan.', features: ['Everything in tier two', 'Example feature', 'Example feature'], cta: { label: 'Example CTA', href: '#' } }] } },
20
+ { type: 'logos', description: 'A “trusted by” wall — greyscale and dimmed, coming to colour on hover. (Sample logos — fictional companies.)', block: { type: 'logos', title: 'Trusted by teams like yours', items: [{ src: '/logos/acme.svg', alt: 'Acme' }, { src: '/logos/nimbus.svg', alt: 'Nimbus' }, { src: '/logos/vertex.svg', alt: 'Vertex' }, { src: '/logos/harbor.svg', alt: 'Harbor' }, { src: '/logos/quanta.svg', alt: 'Quanta' }] } },
21
+ { type: 'tabs', description: 'Labelled panels, one open at a time — progressive (first panel shows with no JS), with ARIA + arrow-key nav.', block: { type: 'tabs', title: 'One system, many angles', items: [{ label: 'Design', heading: 'Design, once', text: 'Set colour, type and style once; every page follows.' }, { label: 'Build', heading: 'Built to last', text: 'A curated system, not a pile of one-offs.' }, { label: 'Own', heading: 'Yours to keep', text: 'Static output and plain content in your own repo.' }] } },
22
+ { type: 'announcement', description: 'A thin full-width bar (message + optional link), dismissible — the dismissal is remembered per id.', block: { type: 'announcement', text: 'New: five section recipes just landed.', link: { label: 'See the recipes', href: '#' }, tone: 'accent', id: 'gallery-demo' } },
23
+ { type: 'bento', description: 'An asymmetric grid — cards pick a size (sm / wide / tall / lg) and a look (surface / filled / gradient / image). Dense packing, the “living moodboard” layout.', block: { type: 'bento', items: [{ size: 'lg', variant: 'gradient', icon: 'sparkle', eyebrow: 'Flagship', title: 'The big idea', text: 'A 2×2 tile anchors the grid.' }, { size: 'sm', variant: 'surface', icon: 'shield', title: 'Trust' }, { size: 'sm', variant: 'filled', icon: 'heart', title: 'Care' }, { size: 'wide', variant: 'image', eyebrow: 'In use', title: 'A wide photo tile', image: { src: '/showcase-hero.svg', alt: 'Abstract' } }, { size: 'sm', variant: 'surface', icon: 'checklist', title: 'Simple' }, { size: 'sm', variant: 'surface', icon: 'book-open', title: 'Clear' }] } },
24
+ { type: 'marquee', description: 'An infinite feature strip — a pure-CSS scrolling loop that pauses on hover and wraps statically under reduced-motion. Hover it to pause.', block: { type: 'marquee', speed: 'normal', items: ['Design once', 'Apply everywhere', 'Unmistakably yours', 'You own it', 'One system'] } },
25
+ { type: 'latestPosts', description: 'Surfaces the posts capability inside a page — the most-recent posts as full image cards (`grid`) or a compact miniature index (`list`: thumbnail + category + date per row, staying short however many posts), with a “view all” link. Queried from the site’s own posts at build time, so a homepage “Latest news” needs no custom code.', block: { type: 'latestPosts', title: 'Latest from the showcase', limit: 4, layout: 'list' } },
26
+ { type: 'mapEmbed', description: 'A consent-gated map (e.g. Google Maps) — the sibling of the calendar embed. Nothing loads from the provider until the visitor clicks “Load map”, so no third-party request fires on page load; consent is remembered per visitor.', block: { type: 'mapEmbed', title: 'Find us', intro: 'Where to find the showcase.', embedUrl: 'https://www.google.com/maps/embed?pb=!demo', publicUrl: 'https://maps.google.com/', provider: 'Google Maps' } },
27
+ { type: 'table', description: 'A simple data table (header row + body rows) under an optional heading — for structured data the other blocks can’t carry cleanly (fees, opening hours, a cookies list). Striped, and scrolls horizontally on narrow screens.', block: { type: 'table', title: 'Hall hire', intro: 'Sample rates.', columns: ['Item', 'Rate', 'Notes'], rows: [['Hall (per hour)', '£20', 'Deposit confirms the booking'], ['Insurance', '£1.50/hr', 'Waived with your own public liability cover'], ['Cleaning deposit', '£50', 'Refundable']] } },
28
+ { type: 'notice', description: 'An inline highlighted info box (icon + title + body + optional link) that sets a passage apart from the surrounding prose. Two tones (accent / neutral); the in-flow sibling of the corner sticky callout.', block: { type: 'notice', tone: 'accent', icon: 'shield', title: 'Reporting a concern', text: 'The parish has a Safeguarding Representative you can contact through the Parish Office.', link: { label: 'Safeguarding policy', href: '#' } } },
29
+ { type: 'mediaCards', description: 'Rich content cards with the image as a HEADER on top (cover-cropped) + an icon badge, title, meta pill, description and CTA — the image and copy both stay fully legible (unlike the tiles image variant, which puts the image behind the text). For groups, team, services or events.', block: { type: 'mediaCards', title: 'Get involved', intro: 'Cards whose photo and copy both need to read clearly.', columns: 3, cards: [{ image: { src: '/placeholder.svg', alt: '' }, icon: 'heart', title: 'Prayer Group', meta: 'Sundays 10:45 · Parish hall', text: 'Praying together before the Sunday service — newcomers always welcome.', href: '#', linkLabel: 'Find out more' }, { image: { src: '/placeholder.svg', alt: '' }, icon: 'people', title: 'Community League', meta: 'First Wednesday · 7pm', text: 'Faith, friendship, service and mission.', href: '#' }, { image: { src: '/placeholder.svg', alt: '' }, icon: 'globe', title: 'Partner Community', meta: 'Your area', text: 'A neighbouring community we work closely with.', href: '#', linkLabel: 'Visit their site' }] } },
30
+ { type: 'contactCard', description: 'A branded "get in touch" band — a theme-adaptive panel (a section surface with a soft brand-gold corner wash) carrying a display-type title + subtitle on the left, contact channels as tappable tiles on the right, and a closing note across the bottom. Composes in both light and dark. The polished alternative to a plain notice box.', block: { type: 'contactCard', eyebrow: 'Parish office', title: 'Get in touch', subtitle: 'Parish Priest: Fr John Doe', note: 'For a quick reply, phone the office directly — email is best for non-urgent matters.', items: [{ icon: 'phone', value: '01234 567890', href: 'tel:01234567890' }, { icon: 'email', value: 'office@st-placeholder.example', href: 'mailto:office@st-placeholder.example' }, { icon: 'link', value: 'Follow us on Facebook', href: 'https://facebook.com/' }] } },
31
+ { type: 'locations', description: 'A "where to find us" grid — one or more places, each a neat tile with a consent-gated map on top and its name / address / directions below. Maps load once the visitor accepts the site cookie banner (shared consent, no per-tile gate). Replaces stacking bare map embeds.', block: { type: 'locations', title: 'Where to find us', columns: 2, items: [{ name: "St Placeholder's", address: '1 Example Street\nPlaceholdertown EX4 3PL', embedUrl: 'https://www.google.com/maps/embed?pb=!demo1', directionsUrl: 'https://maps.google.com/' }, { name: "St Sample's", address: '2 Sample Road\nPlaceholdertown', embedUrl: 'https://www.google.com/maps/embed?pb=!demo2', directionsUrl: 'https://maps.google.com/' }] } },
32
+ ];
@@ -0,0 +1,44 @@
1
+ /**
2
+ * ferst() — the Astro integration that makes a client site's ROUTING package-owned.
3
+ *
4
+ * A thin client adds `integrations: [ferst()]` to astro.config.mjs and carries
5
+ * ZERO page files: the page catch-all (which also owns the home page) and the
6
+ * calendar route are injected from ferst-core. A routing fix then ships as a
7
+ * package bump + rebuild — the same channel component fixes travel through — instead
8
+ * of being hand-copied into every client repo.
9
+ *
10
+ * Irreducible caveat (Astro 5): content collections must be defined in the CONSUMER,
11
+ * so the one-line `src/content/config.ts` re-export stays. "Zero page files" is
12
+ * reachable; "zero files" is not. Explicit client `.astro` routes still win over
13
+ * these injected ones, so a one-off hand-built page is always possible.
14
+ *
15
+ * Blog routes (news index / posts / tags) are deliberately NOT injected yet — the
16
+ * listing component (NewsList) is not in the package. They join once it is promoted
17
+ * to core; individual routes would otherwise exist with no listing to reach them.
18
+ *
19
+ * @param {object} [options]
20
+ * @param {boolean} [options.calendar=true] Inject the `/calendar` route.
21
+ * @returns {import('astro').AstroIntegration}
22
+ */
23
+ export default function ferst(options = {}) {
24
+ const { calendar = true } = options;
25
+ return {
26
+ name: 'ferst',
27
+ hooks: {
28
+ 'astro:config:setup': ({ injectRoute }) => {
29
+ // Pages + home: the reserved `index` entry renders at `/`, every other
30
+ // `pages` entry at its own slug (see lib/pages.ts `getSiteRoutes`).
31
+ injectRoute({
32
+ pattern: '/[...slug]',
33
+ entrypoint: 'ferst-core/routes/[...slug].astro',
34
+ });
35
+ if (calendar) {
36
+ injectRoute({
37
+ pattern: '/calendar',
38
+ entrypoint: 'ferst-core/routes/calendar/index.astro',
39
+ });
40
+ }
41
+ },
42
+ },
43
+ };
44
+ }
@@ -172,12 +172,23 @@ const metaDescription = description ?? identity.description;
172
172
  document.addEventListener('astro:page-load', ferstMarkExternalLinks);
173
173
  </script>
174
174
  <script>
175
- // Live theme preview: only when this page is FRAMED with ?preview=1 (the
176
- // portal Theme Studio or the CMS design-tokens pane), listen for draft theme
177
- // settings over postMessage and re-theme live. Dynamically imported so the
178
- // code never ships to normal visitors. Inert otherwise.
179
- if (window.parent !== window && new URLSearchParams(location.search).has('preview')) {
180
- import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
175
+ // Live theme preview: only when this page is FRAMED and either loaded with
176
+ // ?preview=1 OR already in a preview session (so the draft survives clicking
177
+ // links inside the frame, which drop the query). Listens for draft theme
178
+ // settings over postMessage and re-themes live. Dynamically imported so the
179
+ // code never ships to normal visitors; never runs unframed.
180
+ try {
181
+ var inPreview =
182
+ new URLSearchParams(location.search).has('preview') ||
183
+ sessionStorage.getItem('ferst-preview-on') === '1';
184
+ if (window.parent !== window && inPreview) {
185
+ import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
186
+ }
187
+ } catch (_) {
188
+ /* sessionStorage blocked — fall back to the ?preview query only */
189
+ if (window.parent !== window && new URLSearchParams(location.search).has('preview')) {
190
+ import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
191
+ }
181
192
  }
182
193
  </script>
183
194
  </head>
package/lib/icons.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * Keys are deliberately **business-agnostic** (`landmark`, not `church`;
10
10
  * `map-pin`, not `parish-office`) — this set is part of the reusable substrate.
11
11
  *
12
- * History: this merges CTK's two divergent registries (`lib/icons.ts` raw
12
+ * History: this merges two divergent icon registries (`lib/icons.ts` raw
13
13
  * markup + `lib/tagIcons` structured objects) into one — the seven shapes below
14
14
  * the divider were ported from the tag set (Lucide-derived), converted to the
15
15
  * same markup shape, with duplicates (calendar/heart/tag/people/email/book)
package/lib/pages.ts CHANGED
@@ -40,3 +40,27 @@ export async function getPageRoutes() {
40
40
  props: { entry },
41
41
  }));
42
42
  }
43
+
44
+ /**
45
+ * Map a `pages` entry id to its route params, treating the reserved `index`
46
+ * entry as the site root (`/`). Pure — the routing convention in one place, so
47
+ * it's unit-testable without `astro:content`.
48
+ */
49
+ export function pageRouteParams(id: string): { slug: string | undefined } {
50
+ return { slug: id === 'index' ? undefined : id };
51
+ }
52
+
53
+ /**
54
+ * `getStaticPaths` for the site catch-all that ALSO owns the home page — used by
55
+ * the `ferst()` integration's injected `/[...slug]` route. The reserved `index`
56
+ * entry becomes `/`; every other page becomes `/<id>`. Home is therefore just
57
+ * another `pages` entry (content, not code), so a client carries zero page files
58
+ * AND can pick its home layout in the builder like any other page.
59
+ */
60
+ export async function getSiteRoutes() {
61
+ const entries = await getPageEntries();
62
+ return entries.map((entry) => ({
63
+ params: pageRouteParams(entry.id),
64
+ props: { entry },
65
+ }));
66
+ }
@@ -28,6 +28,27 @@ export const PREVIEW_MESSAGES = {
28
28
  } as const;
29
29
 
30
30
  const STYLE_ID = 'ferst-preview';
31
+ /** Marks a live preview session so the draft survives navigation WITHIN the frame
32
+ * (internal links drop ?preview=1). Base.astro's gate also checks this flag. */
33
+ export const PREVIEW_SESSION_FLAG = 'ferst-preview-on';
34
+ const PREVIEW_DRAFT_KEY = 'ferst-preview-draft';
35
+
36
+ function rememberDraft(theme: ThemeSettings): void {
37
+ try {
38
+ sessionStorage.setItem(PREVIEW_DRAFT_KEY, JSON.stringify(theme));
39
+ } catch {
40
+ /* storage blocked — the page still themes from the live postMessage */
41
+ }
42
+ }
43
+
44
+ function recallDraft(): ThemeSettings | null {
45
+ try {
46
+ const raw = sessionStorage.getItem(PREVIEW_DRAFT_KEY);
47
+ return raw ? (JSON.parse(raw) as ThemeSettings) : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
31
52
 
32
53
  /** Origins allowed to drive the preview (besides same-origin, i.e. the CMS pane).
33
54
  * The Ferst portal — both its custom domains and the bare Cloudflare Pages URLs
@@ -66,8 +87,30 @@ export function applyPreviewTheme(theme: ThemeSettings): void {
66
87
  else delete document.documentElement.dataset.field;
67
88
  }
68
89
 
69
- /** Wire the page to receive drafts from an allowed surface, and announce readiness. */
90
+ /** Wire the page to receive drafts from an allowed surface, and announce readiness.
91
+ * Persists the draft for the session so it survives navigation inside the frame. */
70
92
  export function initThemePreviewListener(): void {
93
+ try {
94
+ sessionStorage.setItem(PREVIEW_SESSION_FLAG, '1'); // keep previewing across in-frame nav
95
+ } catch {
96
+ /* storage blocked — the portal re-push on ready still re-themes each new page */
97
+ }
98
+
99
+ const reapply = () => {
100
+ const draft = recallDraft();
101
+ if (draft) {
102
+ try {
103
+ applyPreviewTheme(draft);
104
+ } catch {
105
+ /* ignore a bad stored draft */
106
+ }
107
+ }
108
+ };
109
+
110
+ // A freshly-navigated page in the session: re-theme immediately from the stored
111
+ // draft (before the portal re-pushes), so tokens don't drop on link clicks.
112
+ reapply();
113
+
71
114
  window.addEventListener('message', (event: MessageEvent) => {
72
115
  if (!isAllowedPreviewOrigin(event.origin)) return;
73
116
  const data = event.data as { type?: string; theme?: ThemeSettings; mode?: string } | null;
@@ -75,6 +118,7 @@ export function initThemePreviewListener(): void {
75
118
  if (data.type === PREVIEW_MESSAGES.theme && data.theme) {
76
119
  try {
77
120
  applyPreviewTheme(data.theme);
121
+ rememberDraft(data.theme);
78
122
  } catch {
79
123
  /* a malformed draft must never break the preview page */
80
124
  }
@@ -82,10 +126,15 @@ export function initThemePreviewListener(): void {
82
126
  document.documentElement.dataset.theme = data.mode === 'dark' ? 'dark' : 'light';
83
127
  }
84
128
  });
85
- // Tell whoever framed us that we're listening, so it can push the current draft.
129
+
130
+ // View-transition (ClientRouter) pages swap the DOM without a full load, which
131
+ // drops the injected <style> — re-apply after each swap.
132
+ document.addEventListener('astro:page-load', reapply);
133
+
134
+ // Tell whoever framed us that we're listening, so it can push the latest draft.
86
135
  try {
87
136
  window.parent?.postMessage({ type: PREVIEW_MESSAGES.ready }, '*');
88
137
  } catch {
89
- /* no parent / blocked — the surface can still push on its own timer */
138
+ /* no parent / blocked — the stored draft already re-themed this page */
90
139
  }
91
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.4.9",
3
+ "version": "0.5.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",
@@ -34,9 +34,12 @@
34
34
  "bin",
35
35
  "cms",
36
36
  "components",
37
+ "integration.mjs",
37
38
  "layouts",
38
39
  "content",
40
+ "examples",
39
41
  "lib",
42
+ "routes",
40
43
  "utils",
41
44
  "styles",
42
45
  "README.md",
@@ -46,7 +49,8 @@
46
49
  ],
47
50
  "bin": {
48
51
  "ferst-check-thin": "bin/check-thin.mjs",
49
- "ferst-set-cms-branch": "bin/set-cms-branch.mjs"
52
+ "ferst-set-cms-branch": "bin/set-cms-branch.mjs",
53
+ "ferst-cms-config": "bin/cms-config.mjs"
50
54
  },
51
55
  "dependencies": {
52
56
  "zod": "^3.25.0"
@@ -0,0 +1,24 @@
1
+ ---
2
+ /**
3
+ * The client site's page routes — INJECTED by the ferst() integration, so a
4
+ * client repo carries NO page files. Every entry in the `pages` collection
5
+ * becomes a route: the reserved `index` entry is the home page (`/`), every
6
+ * other JSON file its own slug. Content, not code.
7
+ *
8
+ * A client can still hand-build a one-off `.astro` page beside these — explicit
9
+ * routes always win over an injected catch-all.
10
+ */
11
+ import Base from '../layouts/Base.astro';
12
+ import BlockRenderer from '../components/BlockRenderer.astro';
13
+ import { pageSchema } from '../content/blocks';
14
+ import { getSiteRoutes } from '../lib/pages';
15
+
16
+ export const getStaticPaths = getSiteRoutes;
17
+
18
+ const { entry } = Astro.props;
19
+ const page = pageSchema.parse(entry.data);
20
+ ---
21
+
22
+ <Base title={page.title} description={page.description} heroImage={page.heroImage} align={page.align}>
23
+ <BlockRenderer blocks={page.blocks} />
24
+ </Base>
@@ -0,0 +1,25 @@
1
+ ---
2
+ /**
3
+ * The calendar page (`/calendar`) — INJECTED by the ferst() integration. Consent-
4
+ * gated by CalendarEmbed (nothing loads from Google until the visitor opts in).
5
+ * Configure it in the CMS (calendarSettings). Off by default until an embed URL is
6
+ * set, but the route always exists so nav links to it never 404.
7
+ */
8
+ import Base from '../../layouts/Base.astro';
9
+ import CalendarEmbed from '../../components/CalendarEmbed.astro';
10
+ import { getCalendarSettings } from '../../lib/calendar';
11
+
12
+ const cal = await getCalendarSettings();
13
+ ---
14
+
15
+ <Base title={cal.title} description={cal.intro || undefined}>
16
+ <CalendarEmbed
17
+ title={cal.title}
18
+ intro={cal.intro}
19
+ embedUrl={cal.embedUrl}
20
+ publicUrl={cal.publicUrl}
21
+ height={cal.height}
22
+ provider={cal.provider}
23
+ weekStart={cal.weekStart}
24
+ />
25
+ </Base>