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.
- package/bin/ferst-update.mjs +33 -2
- package/cms/config.template.yml +214 -206
- package/components/BlockRenderer.astro +10 -0
- package/components/Pagination.astro +51 -0
- package/components/SiteFooter.astro +274 -270
- package/components/SiteHeader.astro +1243 -1183
- package/components/blocks/Heading.astro +51 -46
- package/components/blocks/Prose.astro +10 -2
- package/components/blocks/Quote.astro +40 -38
- package/components/recipes/Accordion.astro +91 -84
- package/components/recipes/Contacts.astro +139 -0
- package/components/recipes/Document.astro +157 -0
- package/components/recipes/FeatureCards.astro +112 -111
- package/components/recipes/Hero.astro +52 -4
- package/components/recipes/LinkRow.astro +91 -0
- package/components/recipes/MediaCards.astro +213 -212
- package/components/recipes/MediaText.astro +92 -0
- package/components/recipes/Notice.astro +90 -89
- package/components/recipes/Stats.astro +152 -145
- package/components/recipes/Table.astro +101 -97
- package/components/recipes/Tabs.astro +134 -133
- package/components/recipes/Tiles.astro +4 -2
- package/components/recipes/Timetable.astro +189 -0
- package/components/recipes/Video.astro +177 -157
- package/content/blocks.ts +113 -1
- package/content/cookies.ts +10 -0
- package/content/schemas.ts +167 -161
- package/examples/primitives.ts +2 -0
- package/examples/recipes.ts +7 -0
- package/integration.mjs +83 -64
- package/layouts/Base.astro +483 -475
- package/lib/documents.ts +68 -62
- package/lib/embed-consent.ts +78 -73
- package/lib/inline-markdown.ts +6 -0
- package/lib/links.ts +22 -0
- package/lib/paginate.ts +23 -0
- package/lib/posts.ts +20 -2
- package/lib/siteSettings.ts +61 -60
- package/lib/slug.ts +32 -0
- package/lib/table.ts +10 -0
- package/lib/timetable.ts +27 -0
- package/lib/video.ts +101 -75
- package/package.json +1 -1
- package/routes/cookie-policy.astro +27 -0
- package/routes/posts/index.astro +36 -31
- package/routes/posts/page/[page].astro +36 -0
- package/routes/posts/tag/[...tag].astro +41 -37
package/content/cookies.ts
CHANGED
|
@@ -110,3 +110,13 @@ export function scanCookieUses(input: CookieScanInput): CookiePolicyModel {
|
|
|
110
110
|
).map(({ blocks, calendar, ...use }) => use); // drop detection fields from the view model
|
|
111
111
|
return { necessary: NECESSARY.map((u) => ({ ...u })), additional };
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/** Every block `type` used in a list of blocks, walking container children
|
|
115
|
+
* (`section` / `grid` / `stack`), so the policy sees an embed nested in a grid. */
|
|
116
|
+
export function blockTypesIn(blocks: ReadonlyArray<{ type: string; blocks?: unknown }>, acc = new Set<string>()): Set<string> {
|
|
117
|
+
for (const b of blocks) {
|
|
118
|
+
acc.add(b.type);
|
|
119
|
+
if (Array.isArray(b.blocks)) blockTypesIn(b.blocks as Array<{ type: string; blocks?: unknown }>, acc);
|
|
120
|
+
}
|
|
121
|
+
return acc;
|
|
122
|
+
}
|
package/content/schemas.ts
CHANGED
|
@@ -1,161 +1,167 @@
|
|
|
1
|
-
// Pure Zod schemas for the site-settings collections the chrome reads (identity +
|
|
2
|
-
// contact, logos, navigation, footer, theme tokens). Separated from config.ts so
|
|
3
|
-
// they can be unit-tested without `astro:content`. The page CONTENT model is the
|
|
4
|
-
// block palette in `content/blocks.ts` — a page is an ordered list of typed
|
|
5
|
-
// blocks, not a bespoke per-page-type schema.
|
|
6
|
-
import { z } from 'zod';
|
|
7
|
-
|
|
8
|
-
const navLinkSchema = z.object({
|
|
9
|
-
label: z.string(),
|
|
10
|
-
href: z.string(),
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
/** One item inside a nav dropdown / mega panel (client-configured, generic). */
|
|
14
|
-
const navGroupItemSchema = z.object({
|
|
15
|
-
title: z.string(),
|
|
16
|
-
desc: z.string().optional(),
|
|
17
|
-
href: z.string(),
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
/** An optional dropdown group in the primary nav (e.g. an "About" mega-menu
|
|
21
|
-
* or a "Services" menu). Entirely client-configured — the core ships none. */
|
|
22
|
-
const navGroupSchema = z.object({
|
|
23
|
-
label: z.string(),
|
|
24
|
-
href: z.string(),
|
|
25
|
-
items: z.array(navGroupItemSchema).default([]),
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const siteIdentitySchema = z
|
|
29
|
-
.object({
|
|
30
|
-
/** Used in <title> as " — {siteTitle}" and the description fallback. */
|
|
31
|
-
siteTitle: z.string().default('Your Site'),
|
|
32
|
-
/** Short brand label — used in alt text and the footer. */
|
|
33
|
-
shortName: z.string().default('Your Site'),
|
|
34
|
-
/** Default <meta name="description"> when a page doesn't supply its own. */
|
|
35
|
-
description: z.string().default('A website built with Ferst.'),
|
|
36
|
-
/** Town / city — appended to the brand in alt text. */
|
|
37
|
-
location: z.string().default(''),
|
|
38
|
-
})
|
|
39
|
-
.default({});
|
|
40
|
-
|
|
41
|
-
const siteContactSchema = z
|
|
42
|
-
.object({
|
|
43
|
-
/** Human-readable phone number. Blank by default — client provides. */
|
|
44
|
-
phoneDisplay: z.string().default(''),
|
|
45
|
-
/** Digits-only form used in a `tel:` href. */
|
|
46
|
-
phoneTel: z.string().default(''),
|
|
47
|
-
email: z.string().default(''),
|
|
48
|
-
facebookUrl: z.string().default(''),
|
|
49
|
-
})
|
|
50
|
-
.default({});
|
|
51
|
-
|
|
52
|
-
/** Site identity + contact details. Logos, navigation, footer and theme tokens
|
|
53
|
-
* each have their own collection (below) so each is a focused CMS pane. */
|
|
54
|
-
export const siteSettingsSchema = z.object({
|
|
55
|
-
identity: siteIdentitySchema,
|
|
56
|
-
contact: siteContactSchema,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
export const logoSettingsSchema = z.object({
|
|
60
|
-
/** Primary/header logo — light theme variant. Optional (SiteHeader falls back to a wordmark). */
|
|
61
|
-
primaryLogoLight: z.string().optional(),
|
|
62
|
-
/** Primary/header logo — dark theme variant. Optional. */
|
|
63
|
-
primaryLogoDark: z.string().optional(),
|
|
64
|
-
/** Secondary/footer logo — light theme variant (optional). */
|
|
65
|
-
secondaryLogoLight: z.string().optional(),
|
|
66
|
-
/** Secondary/footer logo — dark theme variant (optional). */
|
|
67
|
-
secondaryLogoDark: z.string().optional(),
|
|
68
|
-
/** Display name + target URL for the secondary/footer logo's link (optional). */
|
|
69
|
-
secondaryLogoLink: z
|
|
70
|
-
.object({
|
|
71
|
-
name: z.string().default(''),
|
|
72
|
-
url: z.string().default(''),
|
|
73
|
-
})
|
|
74
|
-
.default({}),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
export const navbarSettingsSchema = z.object({
|
|
78
|
-
/** Simple top-level links. Empty by default — every client defines its own nav. */
|
|
79
|
-
primary: z.array(navLinkSchema).default([]),
|
|
80
|
-
/** Optional dropdown / mega-menu group. Rendered by SiteHeader only when present. */
|
|
81
|
-
dropdown: navGroupSchema.optional(),
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* the
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
export type
|
|
161
|
-
export type
|
|
1
|
+
// Pure Zod schemas for the site-settings collections the chrome reads (identity +
|
|
2
|
+
// contact, logos, navigation, footer, theme tokens). Separated from config.ts so
|
|
3
|
+
// they can be unit-tested without `astro:content`. The page CONTENT model is the
|
|
4
|
+
// block palette in `content/blocks.ts` — a page is an ordered list of typed
|
|
5
|
+
// blocks, not a bespoke per-page-type schema.
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
const navLinkSchema = z.object({
|
|
9
|
+
label: z.string(),
|
|
10
|
+
href: z.string(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** One item inside a nav dropdown / mega panel (client-configured, generic). */
|
|
14
|
+
const navGroupItemSchema = z.object({
|
|
15
|
+
title: z.string(),
|
|
16
|
+
desc: z.string().optional(),
|
|
17
|
+
href: z.string(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** An optional dropdown group in the primary nav (e.g. an "About" mega-menu
|
|
21
|
+
* or a "Services" menu). Entirely client-configured — the core ships none. */
|
|
22
|
+
const navGroupSchema = z.object({
|
|
23
|
+
label: z.string(),
|
|
24
|
+
href: z.string(),
|
|
25
|
+
items: z.array(navGroupItemSchema).default([]),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const siteIdentitySchema = z
|
|
29
|
+
.object({
|
|
30
|
+
/** Used in <title> as " — {siteTitle}" and the description fallback. */
|
|
31
|
+
siteTitle: z.string().default('Your Site'),
|
|
32
|
+
/** Short brand label — used in alt text and the footer. */
|
|
33
|
+
shortName: z.string().default('Your Site'),
|
|
34
|
+
/** Default <meta name="description"> when a page doesn't supply its own. */
|
|
35
|
+
description: z.string().default('A website built with Ferst.'),
|
|
36
|
+
/** Town / city — appended to the brand in alt text. */
|
|
37
|
+
location: z.string().default(''),
|
|
38
|
+
})
|
|
39
|
+
.default({});
|
|
40
|
+
|
|
41
|
+
const siteContactSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
/** Human-readable phone number. Blank by default — client provides. */
|
|
44
|
+
phoneDisplay: z.string().default(''),
|
|
45
|
+
/** Digits-only form used in a `tel:` href. */
|
|
46
|
+
phoneTel: z.string().default(''),
|
|
47
|
+
email: z.string().default(''),
|
|
48
|
+
facebookUrl: z.string().default(''),
|
|
49
|
+
})
|
|
50
|
+
.default({});
|
|
51
|
+
|
|
52
|
+
/** Site identity + contact details. Logos, navigation, footer and theme tokens
|
|
53
|
+
* each have their own collection (below) so each is a focused CMS pane. */
|
|
54
|
+
export const siteSettingsSchema = z.object({
|
|
55
|
+
identity: siteIdentitySchema,
|
|
56
|
+
contact: siteContactSchema,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const logoSettingsSchema = z.object({
|
|
60
|
+
/** Primary/header logo — light theme variant. Optional (SiteHeader falls back to a wordmark). */
|
|
61
|
+
primaryLogoLight: z.string().optional(),
|
|
62
|
+
/** Primary/header logo — dark theme variant. Optional. */
|
|
63
|
+
primaryLogoDark: z.string().optional(),
|
|
64
|
+
/** Secondary/footer logo — light theme variant (optional). */
|
|
65
|
+
secondaryLogoLight: z.string().optional(),
|
|
66
|
+
/** Secondary/footer logo — dark theme variant (optional). */
|
|
67
|
+
secondaryLogoDark: z.string().optional(),
|
|
68
|
+
/** Display name + target URL for the secondary/footer logo's link (optional). */
|
|
69
|
+
secondaryLogoLink: z
|
|
70
|
+
.object({
|
|
71
|
+
name: z.string().default(''),
|
|
72
|
+
url: z.string().default(''),
|
|
73
|
+
})
|
|
74
|
+
.default({}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const navbarSettingsSchema = z.object({
|
|
78
|
+
/** Simple top-level links. Empty by default — every client defines its own nav. */
|
|
79
|
+
primary: z.array(navLinkSchema).default([]),
|
|
80
|
+
/** Optional dropdown / mega-menu group. Rendered by SiteHeader only when present. */
|
|
81
|
+
dropdown: navGroupSchema.optional(),
|
|
82
|
+
/** The call-to-action button in the header (and footer). Contact → /contact/ unless set;
|
|
83
|
+
* `null` hides it. */
|
|
84
|
+
cta: z
|
|
85
|
+
.object({ label: z.string(), href: z.string() })
|
|
86
|
+
.nullable()
|
|
87
|
+
.default({ label: 'Contact', href: '/contact/' }),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const footerSettingsSchema = z.object({
|
|
91
|
+
/** Footer legal/info nav. Empty by default — every client defines its own. */
|
|
92
|
+
footer: z.array(navLinkSchema).default([]),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The brand — a client's design tokens, set in the CMS. The whole system is
|
|
97
|
+
* built to derive from just THREE source colours: `brand` (interactive accent),
|
|
98
|
+
* `ink` (text) and `surface` (background). `lib/themeTokens.ts` expands those
|
|
99
|
+
* into ~40 working tokens (light via `color-mix`, dark via an OKLCH translation
|
|
100
|
+
* — lightness flipped, hue kept, chroma damped so nothing turns neon), and
|
|
101
|
+
* `Base.astro` emits the result as a `<style>` that layers over the core's
|
|
102
|
+
* NEUTRAL defaults in `styles/theme.css`. Fonts and the four surface "knobs"
|
|
103
|
+
* (corners / elevation / stroke / fields) are the rest of the site-wide look —
|
|
104
|
+
* each set once, applied everywhere. `advanced.light/.dark` are a raw
|
|
105
|
+
* per-token escape hatch (power users / AI composition / back-compat) layered
|
|
106
|
+
* LAST. The core ships none of this — a brand lives in the client's repo (or
|
|
107
|
+
* the CMS), never baked into the shared core.
|
|
108
|
+
*
|
|
109
|
+
* This shape is what the Sveltia token pane edits (apps/showcase/public/admin):
|
|
110
|
+
* a client picks three colours + a font pairing + four knobs, not 40 tokens.
|
|
111
|
+
*/
|
|
112
|
+
export const themeSettingsSchema = z.object({
|
|
113
|
+
/** Interactive accent — buttons, links, icons, highlights. Blank ⇒ neutral. */
|
|
114
|
+
brand: z.string().default(''),
|
|
115
|
+
/** Text colour — headings & body. Blank ⇒ neutral. */
|
|
116
|
+
ink: z.string().default(''),
|
|
117
|
+
/** Background — page, cards, sections (tints derived from it). Blank ⇒ neutral. */
|
|
118
|
+
surface: z.string().default(''),
|
|
119
|
+
/** Site-wide font pairing (role → CSS font-family stack). Blank ⇒ core default. */
|
|
120
|
+
fonts: z
|
|
121
|
+
.object({
|
|
122
|
+
heading: z.string().default(''),
|
|
123
|
+
body: z.string().default(''),
|
|
124
|
+
display: z.string().default(''),
|
|
125
|
+
})
|
|
126
|
+
.default({}),
|
|
127
|
+
/** Corner radius applied to every card / tile / input / button. */
|
|
128
|
+
corners: z.enum(['sharp', 'rounded', 'soft']).default('rounded'),
|
|
129
|
+
/** Card/tile elevation — `flat` removes the soft shadow site-wide. */
|
|
130
|
+
elevation: z.enum(['raised', 'flat']).default('raised'),
|
|
131
|
+
/** Card/tile stroke — `off` removes the 1px border site-wide. */
|
|
132
|
+
stroke: z.enum(['on', 'off']).default('on'),
|
|
133
|
+
/** Input treatment — a single site-wide choice (a `data-field` attr on <html>). */
|
|
134
|
+
fields: z.enum(['boxed', 'filled', 'underline']).default('boxed'),
|
|
135
|
+
/** Dark-mode formula knobs (root-derivation model). `darkDepth` = how light the
|
|
136
|
+
* dark page background sits (OKLCH L); `darkChroma` = the cap on how much colour
|
|
137
|
+
* the dark ground may carry. Both feed the AUTOMATIC dark roots. */
|
|
138
|
+
darkDepth: z.number().min(0.12).max(0.4).default(0.21),
|
|
139
|
+
darkChroma: z.number().min(0).max(0.15).default(0.05),
|
|
140
|
+
/** Explicit overrides for the three dark ROOTS. Each blank ⇒ the root is derived
|
|
141
|
+
* from its light source by the formula; set ⇒ every dark token in that role
|
|
142
|
+
* re-derives from this colour. Roots only (ground / text / accent) — never a
|
|
143
|
+
* lone downstream token — so the palette can never fall out of sync. */
|
|
144
|
+
darkOverrides: z
|
|
145
|
+
.object({
|
|
146
|
+
ground: z.string().default(''),
|
|
147
|
+
text: z.string().default(''),
|
|
148
|
+
accent: z.string().default(''),
|
|
149
|
+
})
|
|
150
|
+
.default({}),
|
|
151
|
+
/** Raw per-token overrides, layered last. Rarely needed. */
|
|
152
|
+
advanced: z
|
|
153
|
+
.object({
|
|
154
|
+
light: z.record(z.string()).default({}),
|
|
155
|
+
dark: z.record(z.string()).default({}),
|
|
156
|
+
})
|
|
157
|
+
.default({}),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export type SiteSettings = z.infer<typeof siteSettingsSchema>;
|
|
161
|
+
export type LogoSettings = z.infer<typeof logoSettingsSchema>;
|
|
162
|
+
export type NavbarSettings = z.infer<typeof navbarSettingsSchema>;
|
|
163
|
+
export type FooterSettings = z.infer<typeof footerSettingsSchema>;
|
|
164
|
+
export type ThemeSettings = z.infer<typeof themeSettingsSchema>;
|
|
165
|
+
export type NavLink = z.infer<typeof navLinkSchema>;
|
|
166
|
+
export type NavGroup = z.infer<typeof navGroupSchema>;
|
|
167
|
+
export type NavGroupItem = z.infer<typeof navGroupItemSchema>;
|
package/examples/primitives.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const primitives: GalleryEntry[] = [
|
|
|
9
9
|
{ type: 'heading', description: 'A section title, levels 1–6, optionally aligned.', block: { type: 'heading', text: 'A heading', level: 2 } },
|
|
10
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
11
|
{ type: 'prose', description: 'Body text: blank lines start new paragraphs, `- ` / `1. ` lines make lists, and inline links (incl. phone and email), bold and italic render safely.', block: { type: 'prose', text: 'A paragraph of prose with **bold**, *italic* and a [link to the recipes](/recipes).\n\nFor a quick answer, [phone the office](tel:01234567890).\n\n- Lists from simple lines\n- With a [link](/primitives) inside' } },
|
|
12
|
+
{ type: 'prose', description: 'The `lead` variant: the paragraph straight under the hero that introduces the page, larger and quieter than body text.', block: { type: 'prose', variant: 'lead', text: 'A lead paragraph introduces the page and invites the reader in.' } },
|
|
12
13
|
{ 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
14
|
{ 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
15
|
{ 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' } },
|
|
@@ -17,6 +18,7 @@ export const primitives: GalleryEntry[] = [
|
|
|
17
18
|
{ type: 'button', description: 'Filled CTA — the brand-accent call to action.', block: { type: 'button', label: 'Get started', href: '#', variant: 'cta' } },
|
|
18
19
|
{ type: 'button', description: 'Ghost — a quiet, borderless text button for tertiary actions.', block: { type: 'button', label: 'Learn more', href: '#', variant: 'ghost' } },
|
|
19
20
|
{ type: 'quote', description: 'A pull quote with an optional citation.', block: { type: 'quote', text: 'A memorable quotation, set apart.', cite: 'Someone' } },
|
|
21
|
+
{ type: 'quote', description: 'A prayer, verse or poem: line breaks are kept as written, and inline formatting works.', block: { type: 'quote', text: 'Lord, make me an instrument of your peace:\nwhere there is hatred, let me sow love;\nwhere there is injury, *pardon*.', cite: 'Prayer of St Francis' } },
|
|
20
22
|
{ type: 'divider', description: 'A horizontal rule between sections.', block: { type: 'divider' } },
|
|
21
23
|
{ type: 'spacer', description: 'Vertical space (sm / md / lg) — invisible by design.', block: { type: 'spacer', size: 'lg' } },
|
|
22
24
|
{ 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.' }] } },
|
package/examples/recipes.ts
CHANGED
|
@@ -33,5 +33,12 @@ export const recipes: GalleryEntry[] = [
|
|
|
33
33
|
{ 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. See our [Safeguarding policy](/safeguarding).', items: [{ icon: 'map-pin', value: '1 Example Street\nPlaceholdertown EX4 3PL' }, { 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/' }] } },
|
|
34
34
|
{ 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/' }] } },
|
|
35
35
|
{ type: 'video', description: 'A consent-gated video — paste an ordinary YouTube (incl. a channel’s live page) or Vimeo link; it plays through the privacy-friendly embed only once the visitor agrees, with a “watch on YouTube” alternative. Fixed ratio, never overflows.', block: { type: 'video', title: 'Watch', caption: 'Sample video link.', url: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ', ratio: '16/9' } },
|
|
36
|
+
{ type: 'video', description: 'A channel’s live stream: it doesn’t auto-load (an off-air channel shows “unavailable”), so visitors see the schedule note, a link to the channel’s streams, and “Show the live stream”.', block: { type: 'video', title: 'Watch Mass live', url: 'https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv/live', note: 'Live: **Sundays 10.15am** and holy days.' } },
|
|
36
37
|
{ type: 'documents', description: 'A list of files — newsletters, minutes, policies, forms — newest first, optionally grouped by year: type badge, title, date and note per row. Opens in the browser’s own viewer rather than an in-page one (better on phones).', block: { type: 'documents', title: 'Newsletters', intro: 'Every issue, newest first.', groupByYear: true, items: [{ title: 'Newsletter — Advent', file: '/files/sample-advent.pdf', date: '2026-11-30' }, { title: 'Newsletter — Harvest', file: '/files/sample-harvest.pdf', date: '2026-10-04', note: 'Includes the harvest appeal' }, { title: 'Newsletter — Christmas', file: '/files/sample-christmas.pdf', date: '2025-12-21' }, { title: 'Hall hire form', file: '/files/sample-hire-form.docx' }] } },
|
|
38
|
+
{ type: 'linkRow', description: 'A titled row of contact links as pills: phone, email and web links each get the right icon from the link itself. With an optional intro and footnote. For a contact who doesn’t need a whole contact card.', block: { type: 'linkRow', title: 'Vocations', intro: 'Thinking about a vocation? Talk to the vocations director.', links: [{ label: '01234 567890', href: 'tel:01234567890' }, { label: 'vocations@example.org', href: 'mailto:vocations@example.org' }, { label: 'Vocations website', href: 'https://example.com/vocations' }], note: 'All enquiries are treated in confidence.' } },
|
|
39
|
+
{ type: 'document', description: 'A policy or legal text as one block: sections numbered automatically, a contents list linking to each (sticky beside the text on wide screens), a “last updated” date, prose formatting in each section, print-friendly.', block: { type: 'document', intro: 'These conditions apply to every booking.', updated: '2026-10-04', sections: [{ title: 'Use of the hall', body: 'The hall may be used only for the purpose stated on the booking form.' }, { title: 'Bookings', body: 'Bookings are confirmed once the deposit is received.', items: ['Book through the office.', 'Bookings can’t be transferred.'] }, { title: 'Cancellation', body: 'Cancellations made less than **seven days** before the event forfeit the deposit.' }] } },
|
|
40
|
+
{ type: 'timetable', description: 'Weekly times for one or more places (service times, opening hours, sessions): a card per place, days with no times left out, notes with links under each. Set `layout: "table"` to compare places side by side.', block: { type: 'timetable', title: 'Service times', places: [{ name: "St Placeholder's", address: '1 Example Street', schedule: [{ day: 'Saturday', times: ['6:00pm (vigil)'] }, { day: 'Sunday', times: ['8:30am', '10:30am'] }, { day: 'Weekdays', times: ['9:30am'] }], notes: 'Confessions on Saturdays from 5pm. See [how to find us](#find-us).' }, { name: "St Sample's", schedule: [{ day: 'Saturday', times: [] }, { day: 'Sunday', times: ['9:00am'] }], notes: 'No Saturday service.' }] } },
|
|
41
|
+
{ type: 'timetable', description: 'The `table` layout: days down the side, one column per place; a day nobody uses is dropped.', block: { type: 'timetable', title: 'Opening hours', layout: 'table', places: [{ name: 'Library', schedule: [{ day: 'Monday', times: ['9am–5pm'] }, { day: 'Saturday', times: ['10am–1pm'] }] }, { name: 'Café', schedule: [{ day: 'Monday', times: ['8am–4pm'] }, { day: 'Sunday', times: [] }] }] } },
|
|
42
|
+
{ type: 'contacts', description: 'A compact directory of people or offices: role as the card title, phone and email as tap targets (email with an optional subject), a shared note per group (e.g. office hours) instead of repeating it on every card.', block: { type: 'contacts', title: 'Who to contact', groups: [{ heading: 'Parish office', note: 'Office hours: **Mon–Fri 9am–1pm**.', contacts: [{ role: 'Parish Secretary', name: 'Anne Example', phone: '01234 567890', email: 'office@example.org', emailSubject: 'Parish enquiry' }, { role: 'Safeguarding Representative', email: 'safeguarding@example.org' }] }, { heading: 'Clergy', contacts: [{ role: 'Parish Priest', name: 'Fr John Example', phone: '01234 567891' }] }] } },
|
|
43
|
+
{ type: 'mediaText', description: 'An image beside its text (start or end), stacking on a phone, the image keeping its shape. Text takes prose formatting; an optional button.', block: { type: 'mediaText', title: 'Getting married here', text: 'We are delighted that you are thinking of marrying in our church.\n\nPlease contact the parish office **at least six months** before the date.', image: { src: '/placeholder.svg', alt: 'The church interior decorated for a wedding', caption: 'The church, ready for a wedding' }, imageSide: 'end', cta: { label: 'Contact the office', href: '#' } } },
|
|
37
44
|
];
|
package/integration.mjs
CHANGED
|
@@ -1,64 +1,83 @@
|
|
|
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), the
|
|
6
|
-
* calendar route and the blog routes are injected from ferst-core. A routing fix
|
|
7
|
-
* then ships as a package bump + rebuild — the same channel component fixes travel
|
|
8
|
-
* through — instead 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
|
-
* The blog routes (posts index / individual post / tag pages) inject as a set:
|
|
16
|
-
* the listing components (NewsList, PostsFilter) now live in core, so `/posts`,
|
|
17
|
-
* `/posts/[...slug]` and `/posts/tag/[...tag]` all ship from the package. Turn
|
|
18
|
-
* the whole capability off with `blog: false` (then a client wanting posts hand-
|
|
19
|
-
* builds its own routes over `ferst-core/lib/posts`).
|
|
20
|
-
*
|
|
21
|
-
* @param {object} [options]
|
|
22
|
-
* @param {boolean} [options.calendar=true] Inject the `/calendar` route.
|
|
23
|
-
* @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
|
|
24
|
-
* `/posts/[...slug]`, `/posts/tag/[...tag]`).
|
|
25
|
-
* @
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
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), the
|
|
6
|
+
* calendar route and the blog routes are injected from ferst-core. A routing fix
|
|
7
|
+
* then ships as a package bump + rebuild — the same channel component fixes travel
|
|
8
|
+
* through — instead 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
|
+
* The blog routes (posts index / individual post / tag pages) inject as a set:
|
|
16
|
+
* the listing components (NewsList, PostsFilter) now live in core, so `/posts`,
|
|
17
|
+
* `/posts/[...slug]` and `/posts/tag/[...tag]` all ship from the package. Turn
|
|
18
|
+
* the whole capability off with `blog: false` (then a client wanting posts hand-
|
|
19
|
+
* builds its own routes over `ferst-core/lib/posts`).
|
|
20
|
+
*
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {boolean} [options.calendar=true] Inject the `/calendar` route.
|
|
23
|
+
* @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
|
|
24
|
+
* `/posts/[...slug]`, `/posts/tag/[...tag]`).
|
|
25
|
+
* @param {boolean} [options.cookiePolicy=true] Inject `/cookie-policy` (the page the cookie
|
|
26
|
+
* banner links to), generated from the blocks the site uses. Skipped automatically when
|
|
27
|
+
* the site has its own `src/pages/cookie-policy.astro`.
|
|
28
|
+
* @returns {import('astro').AstroIntegration}
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
|
|
32
|
+
export default function ferst(options = {}) {
|
|
33
|
+
const { calendar = true, blog = true, cookiePolicy = true } = options;
|
|
34
|
+
return {
|
|
35
|
+
name: 'ferst',
|
|
36
|
+
hooks: {
|
|
37
|
+
'astro:config:setup': ({ injectRoute, config }) => {
|
|
38
|
+
// Pages + home: the reserved `index` entry renders at `/`, every other
|
|
39
|
+
// `pages` entry at its own slug (see lib/pages.ts `getSiteRoutes`).
|
|
40
|
+
injectRoute({
|
|
41
|
+
pattern: '/[...slug]',
|
|
42
|
+
entrypoint: 'ferst-core/routes/[...slug].astro',
|
|
43
|
+
});
|
|
44
|
+
if (calendar) {
|
|
45
|
+
injectRoute({
|
|
46
|
+
pattern: '/calendar',
|
|
47
|
+
entrypoint: 'ferst-core/routes/calendar/index.astro',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (blog) {
|
|
51
|
+
// The posts capability: index, individual post, and tag pages. Reads
|
|
52
|
+
// the `posts`/`tags`/`postsSettings` collections (content/collections.ts).
|
|
53
|
+
injectRoute({
|
|
54
|
+
pattern: '/posts',
|
|
55
|
+
entrypoint: 'ferst-core/routes/posts/index.astro',
|
|
56
|
+
});
|
|
57
|
+
injectRoute({
|
|
58
|
+
pattern: '/posts/[...slug]',
|
|
59
|
+
entrypoint: 'ferst-core/routes/posts/[...slug].astro',
|
|
60
|
+
});
|
|
61
|
+
injectRoute({
|
|
62
|
+
pattern: '/posts/tag/[...tag]',
|
|
63
|
+
entrypoint: 'ferst-core/routes/posts/tag/[...tag].astro',
|
|
64
|
+
});
|
|
65
|
+
// Index pages 2+ (page 1 is /posts; tag pages paginate inside their own route).
|
|
66
|
+
injectRoute({
|
|
67
|
+
pattern: '/posts/page/[page]',
|
|
68
|
+
entrypoint: 'ferst-core/routes/posts/page/[page].astro',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// The cookie banner links here on every page, so it must always resolve. A site's
|
|
72
|
+
// own hand-built page wins (Astro would otherwise see two routes for one path).
|
|
73
|
+
const bespoke = config?.root && existsSync(new URL('src/pages/cookie-policy.astro', config.root));
|
|
74
|
+
if (cookiePolicy && !bespoke) {
|
|
75
|
+
injectRoute({
|
|
76
|
+
pattern: '/cookie-policy',
|
|
77
|
+
entrypoint: 'ferst-core/routes/cookie-policy.astro',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|