ferst-core 0.7.6 → 0.7.8
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-modules.mjs +107 -0
- package/components/BlockRenderer.astro +125 -117
- package/components/Button.astro +1 -1
- package/components/Card.astro +2 -0
- package/components/ProseParts.astro +26 -0
- package/components/SiteHeader.astro +24 -0
- package/components/blocks/Prose.astro +12 -17
- package/components/recipes/Accordion.astro +91 -91
- package/components/recipes/Bento.astro +7 -3
- package/components/recipes/ContactForm.astro +6 -3
- package/components/recipes/Cta.astro +5 -1
- package/components/recipes/Document.astro +7 -11
- package/components/recipes/Faq.astro +5 -1
- package/components/recipes/FeatureCards.astro +115 -112
- package/components/recipes/Form.astro +30 -0
- package/components/recipes/Hero.astro +502 -495
- package/components/recipes/LatestPosts.astro +1 -1
- package/components/recipes/MediaCards.astro +214 -213
- package/components/recipes/MediaText.astro +88 -92
- package/components/recipes/Notice.astro +90 -90
- package/components/recipes/Pricing.astro +7 -4
- package/components/recipes/Stats.astro +155 -152
- package/components/recipes/Steps.astro +124 -121
- package/components/recipes/Table.astro +3 -3
- package/components/recipes/Tabs.astro +138 -134
- package/components/recipes/Tiles.astro +291 -287
- package/content/blocks.ts +943 -895
- package/layouts/Base.astro +495 -484
- package/lib/forms.ts +57 -0
- package/lib/slug.ts +1 -1
- package/package.json +70 -69
package/content/blocks.ts
CHANGED
|
@@ -1,895 +1,943 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import { iconKeys, isIconKey } from '../lib/icons';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The block palette — the heart of the Ferst page model (see
|
|
6
|
-
* docs/BLOCK-ARCHITECTURE.md). A page is an ordered list of typed blocks stored
|
|
7
|
-
* as JSON; a Zod **discriminated union** on `type` is the single contract the AI
|
|
8
|
-
* composes against, the CMS validates, and the renderer's registry keys on.
|
|
9
|
-
*
|
|
10
|
-
* This is an original design of the *mechanism*, built from scratch. It starts
|
|
11
|
-
* with **Level-1 primitives** only; recipes (Level 2) and
|
|
12
|
-
* page templates (Level 3) layer on top later, and layout/container primitives
|
|
13
|
-
* (Section/Grid/Stack, which nest child blocks) are a deliberate next slice.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** Every block shares a `type` discriminant and an `enabled` flag (keep a block
|
|
17
|
-
* in the page data without rendering it). */
|
|
18
|
-
/** An icon name from the engine's registry (lib/icons). An unknown name fails validation,
|
|
19
|
-
* and so the build, with the valid names listed, instead of rendering nothing silently. */
|
|
20
|
-
const iconName = z.string().refine(isIconKey, (v) => ({
|
|
21
|
-
message: `Unknown icon "${v}". Use one of: ${iconKeys.join(', ')}.`,
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
export const headingBlock = z.object({
|
|
25
|
-
type: z.literal('heading'),
|
|
26
|
-
enabled: z.boolean().default(true),
|
|
27
|
-
text: z.string(),
|
|
28
|
-
level: z.number().int().min(1).max(6).default(2),
|
|
29
|
-
/** Explicit per-heading alignment. OMITTED ⇒ inherit the page/section
|
|
30
|
-
* `--content-align` cascade; set it only to override for this one heading. */
|
|
31
|
-
align: z.enum(['start', 'center', 'end']).optional(),
|
|
32
|
-
/** Paint the text with a brand → accent-2 gradient (falls back to solid ink
|
|
33
|
-
* where background-clip:text is unsupported). A per-block opt-in effect. */
|
|
34
|
-
gradient: z.boolean().default(false),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
export const proseBlock = z.object({
|
|
38
|
-
type: z.literal('prose'),
|
|
39
|
-
enabled: z.boolean().default(true),
|
|
40
|
-
/** Blank lines separate paragraphs; `- ` / `1. ` lines make lists; inline
|
|
41
|
-
* `[links](href)` (incl. tel:/mailto:), `**bold**` and `*italic*` (lib/prose.ts). */
|
|
42
|
-
text: z.string(),
|
|
43
|
-
/** `lead` = the page's introduction: larger and quieter than body text, for the
|
|
44
|
-
* paragraph straight under the hero. */
|
|
45
|
-
variant: z.enum(['body', 'lead']).default('body'),
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
export const imageBlock = z.object({
|
|
49
|
-
type: z.literal('image'),
|
|
50
|
-
enabled: z.boolean().default(true),
|
|
51
|
-
src: z.string(),
|
|
52
|
-
alt: z.string().default(''),
|
|
53
|
-
caption: z.string().optional(),
|
|
54
|
-
/** Canonical framing options — a small, clear set the AI or a client picks
|
|
55
|
-
* from, rather than free-form CSS. Corners follow the site by default. */
|
|
56
|
-
radius: z.enum(['none', 'soft', 'round']).default('soft'),
|
|
57
|
-
shadow: z.boolean().default(false),
|
|
58
|
-
border: z.boolean().default(false),
|
|
59
|
-
ratio: z.enum(['auto', 'square', '4/3', '3/2', '16/9', '21/9']).default('auto'),
|
|
60
|
-
/** Opt in to a soft clip-path reveal as the image scrolls into view
|
|
61
|
-
* (progressive: no motion under reduced-motion or unsupported browsers). */
|
|
62
|
-
reveal: z.boolean().default(false),
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
export const buttonBlock = z.object({
|
|
66
|
-
type: z.literal('button'),
|
|
67
|
-
enabled: z.boolean().default(true),
|
|
68
|
-
label: z.string(),
|
|
69
|
-
href: z.string(),
|
|
70
|
-
/** Role, not decoration: primary action, secondary action, filled CTA, or a
|
|
71
|
-
* quiet ghost. Shape (radius) is a site-wide token, not a per-button choice. */
|
|
72
|
-
variant: z.enum(['primary', 'secondary', 'cta', 'ghost']).default('primary'),
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
export const quoteBlock = z.object({
|
|
76
|
-
type: z.literal('quote'),
|
|
77
|
-
enabled: z.boolean().default(true),
|
|
78
|
-
text: z.string(),
|
|
79
|
-
cite: z.string().optional(),
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
export const dividerBlock = z.object({
|
|
83
|
-
type: z.literal('divider'),
|
|
84
|
-
enabled: z.boolean().default(true),
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
export const spacerBlock = z.object({
|
|
88
|
-
type: z.literal('spacer'),
|
|
89
|
-
enabled: z.boolean().default(true),
|
|
90
|
-
size: z.enum(['sm', 'md', 'lg']).default('md'),
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
export const badgeBlock = z.object({
|
|
94
|
-
type: z.literal('badge'),
|
|
95
|
-
enabled: z.boolean().default(true),
|
|
96
|
-
text: z.string(),
|
|
97
|
-
tone: z.enum(['neutral', 'accent']).default('accent'),
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
export const statBlock = z.object({
|
|
101
|
-
type: z.literal('stat'),
|
|
102
|
-
enabled: z.boolean().default(true),
|
|
103
|
-
value: z.string(),
|
|
104
|
-
label: z.string(),
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
export const listBlock = z.object({
|
|
108
|
-
type: z.literal('list'),
|
|
109
|
-
enabled: z.boolean().default(true),
|
|
110
|
-
/** bullet (disc) · number (ordered) · check (ticked, uses the check icon). */
|
|
111
|
-
style: z.enum(['bullet', 'number', 'check']).default('bullet'),
|
|
112
|
-
items: z.array(z.string()).default([]),
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
/* ── Level 2 — Recipes (curated, opinionated compositions of primitives) ─────
|
|
116
|
-
* A recipe is a first-class block in the same union, but it renders a *designed*
|
|
117
|
-
* layout (composing primitives internally) rather than a bare element. Locked
|
|
118
|
-
* components so quality can't drift (BLOCK-ARCHITECTURE §6). */
|
|
119
|
-
export const heroBlock = z.object({
|
|
120
|
-
type: z.literal('hero'),
|
|
121
|
-
enabled: z.boolean().default(true),
|
|
122
|
-
eyebrow: z.string().optional(),
|
|
123
|
-
title: z.string(),
|
|
124
|
-
subtitle: z.string().optional(),
|
|
125
|
-
cta: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
126
|
-
image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
|
|
127
|
-
/** Background media for the `media` layout, in order of precedence: a looping
|
|
128
|
-
* muted `video` (a hosted file, or a YouTube / Vimeo link — embedded only after
|
|
129
|
-
* cookie consent, showing `poster` until then), a slow crossfade through `images`
|
|
130
|
-
* (2+), or one `image`.
|
|
131
|
-
* `overlay` (0–100) is the darkening scrim so text stays legible; `poster` is
|
|
132
|
-
* the video's still (and what reduced-motion visitors see). Moving backgrounds
|
|
133
|
-
* get a pause button and stay still under prefers-reduced-motion. */
|
|
134
|
-
media: z
|
|
135
|
-
.object({
|
|
136
|
-
image: z.string().optional(),
|
|
137
|
-
images: z.array(z.string()).max(8).optional(),
|
|
138
|
-
video: z.string().optional(),
|
|
139
|
-
poster: z.string().optional(),
|
|
140
|
-
overlay: z.number().min(0).max(100).default(45),
|
|
141
|
-
/** Where the background leads: e.g. the full video on YouTube. The whole
|
|
142
|
-
* background becomes a link (opens in a new tab — the app on phones), with
|
|
143
|
-
* a visible "Watch the full video" cue; the text and CTA stay on top. */
|
|
144
|
-
link: z.string().optional(),
|
|
145
|
-
linkLabel: z.string().optional(),
|
|
146
|
-
})
|
|
147
|
-
.optional(),
|
|
148
|
-
layout: z.enum(['centered', 'split', 'media']).default('centered'),
|
|
149
|
-
/** How much of the screen a `media` hero takes: `standard` (a band), `tall`,
|
|
150
|
-
* or `screen` — the full first screen, edge to edge (e.g. a video opener). */
|
|
151
|
-
size: z.enum(['standard', 'tall', 'screen']).default('standard'),
|
|
152
|
-
/** A `media` hero's background drifts more slowly than the page as it scrolls (any
|
|
153
|
-
* background: image, slides or video). Off on touch devices and under reduced motion. */
|
|
154
|
-
parallax: z.boolean().default(false),
|
|
155
|
-
/** Ambient background for centered/split heroes: `mesh` = a soft, slowly
|
|
156
|
-
* drifting brand gradient behind the text (static under reduced-motion). */
|
|
157
|
-
background: z.enum(['none', 'mesh']).default('none'),
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
export const featureCardsBlock = z.object({
|
|
161
|
-
type: z.literal('featureCards'),
|
|
162
|
-
enabled: z.boolean().default(true),
|
|
163
|
-
title: z.string().optional(),
|
|
164
|
-
intro: z.string().optional(),
|
|
165
|
-
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
|
|
166
|
-
cards: z
|
|
167
|
-
.array(
|
|
168
|
-
z.object({
|
|
169
|
-
icon: iconName.optional(),
|
|
170
|
-
title: z.string(),
|
|
171
|
-
text: z.string().optional(),
|
|
172
|
-
href: z.string().optional(),
|
|
173
|
-
})
|
|
174
|
-
)
|
|
175
|
-
.default([]),
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
export const ctaBlock = z.object({
|
|
179
|
-
type: z.literal('cta'),
|
|
180
|
-
enabled: z.boolean().default(true),
|
|
181
|
-
title: z.string(),
|
|
182
|
-
text: z.string().optional(),
|
|
183
|
-
cta: z.object({ label: z.string(), href: z.string() }),
|
|
184
|
-
background: z.enum(['muted', 'accent', 'mesh']).default('accent'),
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
export const faqBlock = z.object({
|
|
188
|
-
type: z.literal('faq'),
|
|
189
|
-
enabled: z.boolean().default(true),
|
|
190
|
-
title: z.string().optional(),
|
|
191
|
-
items: z.array(z.object({ question: z.string(), answer: z.string() })).default([]),
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
export const galleryBlock = z.object({
|
|
195
|
-
type: z.literal('gallery'),
|
|
196
|
-
enabled: z.boolean().default(true),
|
|
197
|
-
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
|
|
198
|
-
images: z
|
|
199
|
-
.array(z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional() }))
|
|
200
|
-
.default([]),
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
export const testimonialBlock = z.object({
|
|
204
|
-
type: z.literal('testimonial'),
|
|
205
|
-
enabled: z.boolean().default(true),
|
|
206
|
-
quote: z.string(),
|
|
207
|
-
author: z.string(),
|
|
208
|
-
role: z.string().optional(),
|
|
209
|
-
avatar: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
export const
|
|
217
|
-
type: z.literal('
|
|
218
|
-
enabled: z.boolean().default(true),
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
})
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
export const
|
|
493
|
-
type: z.literal('
|
|
494
|
-
enabled: z.boolean().default(true),
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
* is
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
})
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
export const
|
|
623
|
-
type: z.literal('
|
|
624
|
-
enabled: z.boolean().default(true),
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
| z.infer<typeof
|
|
703
|
-
| z.infer<typeof
|
|
704
|
-
| z.infer<typeof
|
|
705
|
-
| z.infer<typeof
|
|
706
|
-
| z.infer<typeof
|
|
707
|
-
| z.infer<typeof
|
|
708
|
-
| z.infer<typeof
|
|
709
|
-
| z.infer<typeof
|
|
710
|
-
| z.infer<typeof
|
|
711
|
-
| z.infer<typeof
|
|
712
|
-
| z.infer<typeof
|
|
713
|
-
| z.infer<typeof
|
|
714
|
-
| z.infer<typeof
|
|
715
|
-
| z.infer<typeof
|
|
716
|
-
| z.infer<typeof
|
|
717
|
-
| z.infer<typeof
|
|
718
|
-
| z.infer<typeof
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
|
753
|
-
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
export
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
).
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { iconKeys, isIconKey } from '../lib/icons';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The block palette — the heart of the Ferst page model (see
|
|
6
|
+
* docs/BLOCK-ARCHITECTURE.md). A page is an ordered list of typed blocks stored
|
|
7
|
+
* as JSON; a Zod **discriminated union** on `type` is the single contract the AI
|
|
8
|
+
* composes against, the CMS validates, and the renderer's registry keys on.
|
|
9
|
+
*
|
|
10
|
+
* This is an original design of the *mechanism*, built from scratch. It starts
|
|
11
|
+
* with **Level-1 primitives** only; recipes (Level 2) and
|
|
12
|
+
* page templates (Level 3) layer on top later, and layout/container primitives
|
|
13
|
+
* (Section/Grid/Stack, which nest child blocks) are a deliberate next slice.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Every block shares a `type` discriminant and an `enabled` flag (keep a block
|
|
17
|
+
* in the page data without rendering it). */
|
|
18
|
+
/** An icon name from the engine's registry (lib/icons). An unknown name fails validation,
|
|
19
|
+
* and so the build, with the valid names listed, instead of rendering nothing silently. */
|
|
20
|
+
const iconName = z.string().refine(isIconKey, (v) => ({
|
|
21
|
+
message: `Unknown icon "${v}". Use one of: ${iconKeys.join(', ')}.`,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
export const headingBlock = z.object({
|
|
25
|
+
type: z.literal('heading'),
|
|
26
|
+
enabled: z.boolean().default(true),
|
|
27
|
+
text: z.string(),
|
|
28
|
+
level: z.number().int().min(1).max(6).default(2),
|
|
29
|
+
/** Explicit per-heading alignment. OMITTED ⇒ inherit the page/section
|
|
30
|
+
* `--content-align` cascade; set it only to override for this one heading. */
|
|
31
|
+
align: z.enum(['start', 'center', 'end']).optional(),
|
|
32
|
+
/** Paint the text with a brand → accent-2 gradient (falls back to solid ink
|
|
33
|
+
* where background-clip:text is unsupported). A per-block opt-in effect. */
|
|
34
|
+
gradient: z.boolean().default(false),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const proseBlock = z.object({
|
|
38
|
+
type: z.literal('prose'),
|
|
39
|
+
enabled: z.boolean().default(true),
|
|
40
|
+
/** Blank lines separate paragraphs; `- ` / `1. ` lines make lists; inline
|
|
41
|
+
* `[links](href)` (incl. tel:/mailto:), `**bold**` and `*italic*` (lib/prose.ts). */
|
|
42
|
+
text: z.string(),
|
|
43
|
+
/** `lead` = the page's introduction: larger and quieter than body text, for the
|
|
44
|
+
* paragraph straight under the hero. */
|
|
45
|
+
variant: z.enum(['body', 'lead']).default('body'),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const imageBlock = z.object({
|
|
49
|
+
type: z.literal('image'),
|
|
50
|
+
enabled: z.boolean().default(true),
|
|
51
|
+
src: z.string(),
|
|
52
|
+
alt: z.string().default(''),
|
|
53
|
+
caption: z.string().optional(),
|
|
54
|
+
/** Canonical framing options — a small, clear set the AI or a client picks
|
|
55
|
+
* from, rather than free-form CSS. Corners follow the site by default. */
|
|
56
|
+
radius: z.enum(['none', 'soft', 'round']).default('soft'),
|
|
57
|
+
shadow: z.boolean().default(false),
|
|
58
|
+
border: z.boolean().default(false),
|
|
59
|
+
ratio: z.enum(['auto', 'square', '4/3', '3/2', '16/9', '21/9']).default('auto'),
|
|
60
|
+
/** Opt in to a soft clip-path reveal as the image scrolls into view
|
|
61
|
+
* (progressive: no motion under reduced-motion or unsupported browsers). */
|
|
62
|
+
reveal: z.boolean().default(false),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const buttonBlock = z.object({
|
|
66
|
+
type: z.literal('button'),
|
|
67
|
+
enabled: z.boolean().default(true),
|
|
68
|
+
label: z.string(),
|
|
69
|
+
href: z.string(),
|
|
70
|
+
/** Role, not decoration: primary action, secondary action, filled CTA, or a
|
|
71
|
+
* quiet ghost. Shape (radius) is a site-wide token, not a per-button choice. */
|
|
72
|
+
variant: z.enum(['primary', 'secondary', 'cta', 'ghost']).default('primary'),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const quoteBlock = z.object({
|
|
76
|
+
type: z.literal('quote'),
|
|
77
|
+
enabled: z.boolean().default(true),
|
|
78
|
+
text: z.string(),
|
|
79
|
+
cite: z.string().optional(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const dividerBlock = z.object({
|
|
83
|
+
type: z.literal('divider'),
|
|
84
|
+
enabled: z.boolean().default(true),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const spacerBlock = z.object({
|
|
88
|
+
type: z.literal('spacer'),
|
|
89
|
+
enabled: z.boolean().default(true),
|
|
90
|
+
size: z.enum(['sm', 'md', 'lg']).default('md'),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const badgeBlock = z.object({
|
|
94
|
+
type: z.literal('badge'),
|
|
95
|
+
enabled: z.boolean().default(true),
|
|
96
|
+
text: z.string(),
|
|
97
|
+
tone: z.enum(['neutral', 'accent']).default('accent'),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const statBlock = z.object({
|
|
101
|
+
type: z.literal('stat'),
|
|
102
|
+
enabled: z.boolean().default(true),
|
|
103
|
+
value: z.string(),
|
|
104
|
+
label: z.string(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const listBlock = z.object({
|
|
108
|
+
type: z.literal('list'),
|
|
109
|
+
enabled: z.boolean().default(true),
|
|
110
|
+
/** bullet (disc) · number (ordered) · check (ticked, uses the check icon). */
|
|
111
|
+
style: z.enum(['bullet', 'number', 'check']).default('bullet'),
|
|
112
|
+
items: z.array(z.string()).default([]),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/* ── Level 2 — Recipes (curated, opinionated compositions of primitives) ─────
|
|
116
|
+
* A recipe is a first-class block in the same union, but it renders a *designed*
|
|
117
|
+
* layout (composing primitives internally) rather than a bare element. Locked
|
|
118
|
+
* components so quality can't drift (BLOCK-ARCHITECTURE §6). */
|
|
119
|
+
export const heroBlock = z.object({
|
|
120
|
+
type: z.literal('hero'),
|
|
121
|
+
enabled: z.boolean().default(true),
|
|
122
|
+
eyebrow: z.string().optional(),
|
|
123
|
+
title: z.string(),
|
|
124
|
+
subtitle: z.string().optional(),
|
|
125
|
+
cta: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
126
|
+
image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
|
|
127
|
+
/** Background media for the `media` layout, in order of precedence: a looping
|
|
128
|
+
* muted `video` (a hosted file, or a YouTube / Vimeo link — embedded only after
|
|
129
|
+
* cookie consent, showing `poster` until then), a slow crossfade through `images`
|
|
130
|
+
* (2+), or one `image`.
|
|
131
|
+
* `overlay` (0–100) is the darkening scrim so text stays legible; `poster` is
|
|
132
|
+
* the video's still (and what reduced-motion visitors see). Moving backgrounds
|
|
133
|
+
* get a pause button and stay still under prefers-reduced-motion. */
|
|
134
|
+
media: z
|
|
135
|
+
.object({
|
|
136
|
+
image: z.string().optional(),
|
|
137
|
+
images: z.array(z.string()).max(8).optional(),
|
|
138
|
+
video: z.string().optional(),
|
|
139
|
+
poster: z.string().optional(),
|
|
140
|
+
overlay: z.number().min(0).max(100).default(45),
|
|
141
|
+
/** Where the background leads: e.g. the full video on YouTube. The whole
|
|
142
|
+
* background becomes a link (opens in a new tab — the app on phones), with
|
|
143
|
+
* a visible "Watch the full video" cue; the text and CTA stay on top. */
|
|
144
|
+
link: z.string().optional(),
|
|
145
|
+
linkLabel: z.string().optional(),
|
|
146
|
+
})
|
|
147
|
+
.optional(),
|
|
148
|
+
layout: z.enum(['centered', 'split', 'media']).default('centered'),
|
|
149
|
+
/** How much of the screen a `media` hero takes: `standard` (a band), `tall`,
|
|
150
|
+
* or `screen` — the full first screen, edge to edge (e.g. a video opener). */
|
|
151
|
+
size: z.enum(['standard', 'tall', 'screen']).default('standard'),
|
|
152
|
+
/** A `media` hero's background drifts more slowly than the page as it scrolls (any
|
|
153
|
+
* background: image, slides or video). Off on touch devices and under reduced motion. */
|
|
154
|
+
parallax: z.boolean().default(false),
|
|
155
|
+
/** Ambient background for centered/split heroes: `mesh` = a soft, slowly
|
|
156
|
+
* drifting brand gradient behind the text (static under reduced-motion). */
|
|
157
|
+
background: z.enum(['none', 'mesh']).default('none'),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export const featureCardsBlock = z.object({
|
|
161
|
+
type: z.literal('featureCards'),
|
|
162
|
+
enabled: z.boolean().default(true),
|
|
163
|
+
title: z.string().optional(),
|
|
164
|
+
intro: z.string().optional(),
|
|
165
|
+
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
|
|
166
|
+
cards: z
|
|
167
|
+
.array(
|
|
168
|
+
z.object({
|
|
169
|
+
icon: iconName.optional(),
|
|
170
|
+
title: z.string(),
|
|
171
|
+
text: z.string().optional(),
|
|
172
|
+
href: z.string().optional(),
|
|
173
|
+
})
|
|
174
|
+
)
|
|
175
|
+
.default([]),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
export const ctaBlock = z.object({
|
|
179
|
+
type: z.literal('cta'),
|
|
180
|
+
enabled: z.boolean().default(true),
|
|
181
|
+
title: z.string(),
|
|
182
|
+
text: z.string().optional(),
|
|
183
|
+
cta: z.object({ label: z.string(), href: z.string() }),
|
|
184
|
+
background: z.enum(['muted', 'accent', 'mesh']).default('accent'),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export const faqBlock = z.object({
|
|
188
|
+
type: z.literal('faq'),
|
|
189
|
+
enabled: z.boolean().default(true),
|
|
190
|
+
title: z.string().optional(),
|
|
191
|
+
items: z.array(z.object({ question: z.string(), answer: z.string() })).default([]),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
export const galleryBlock = z.object({
|
|
195
|
+
type: z.literal('gallery'),
|
|
196
|
+
enabled: z.boolean().default(true),
|
|
197
|
+
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
|
|
198
|
+
images: z
|
|
199
|
+
.array(z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional() }))
|
|
200
|
+
.default([]),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
export const testimonialBlock = z.object({
|
|
204
|
+
type: z.literal('testimonial'),
|
|
205
|
+
enabled: z.boolean().default(true),
|
|
206
|
+
quote: z.string(),
|
|
207
|
+
author: z.string(),
|
|
208
|
+
role: z.string().optional(),
|
|
209
|
+
avatar: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
/** One of the organisation's forms from the Ferst portal, placed by id. Its fields, button
|
|
213
|
+
* and submit address are read from the platform at build time (lib/forms), so a change
|
|
214
|
+
* in the portal reaches the site on its next build. List a site's forms with
|
|
215
|
+
* `npx ferst-modules`. Title defaults to the form's name. */
|
|
216
|
+
export const formBlock = z.object({
|
|
217
|
+
type: z.literal('form'),
|
|
218
|
+
enabled: z.boolean().default(true),
|
|
219
|
+
/** The form's id in the portal (e.g. `form_8c1d…`). */
|
|
220
|
+
form: z.string().min(1),
|
|
221
|
+
title: z.string().optional(),
|
|
222
|
+
intro: z.string().optional(),
|
|
223
|
+
/** Small print under the form (e.g. a privacy line). */
|
|
224
|
+
note: z.string().optional(),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/** A contact form recipe. `fields` is generic (any labelled input list) so the
|
|
228
|
+
* same recipe serves a contact page, an enquiry form, a sign-up, etc. `action`
|
|
229
|
+
* is optional: the core ships no backend, so with none it renders a standard
|
|
230
|
+
* semantic form a client later points at their own endpoint or a form service. */
|
|
231
|
+
export const contactFormBlock = z.object({
|
|
232
|
+
type: z.literal('contactForm'),
|
|
233
|
+
enabled: z.boolean().default(true),
|
|
234
|
+
title: z.string().optional(),
|
|
235
|
+
intro: z.string().optional(),
|
|
236
|
+
/** Form target. Omit for a non-wired form (e.g. the showcase demo). */
|
|
237
|
+
action: z.string().optional(),
|
|
238
|
+
method: z.enum(['post', 'get']).default('post'),
|
|
239
|
+
submitLabel: z.string().default('Send message'),
|
|
240
|
+
/** Small print under the form (a privacy line, a demo notice, …). */
|
|
241
|
+
note: z.string().optional(),
|
|
242
|
+
fields: z
|
|
243
|
+
.array(
|
|
244
|
+
z.object({
|
|
245
|
+
name: z.string(),
|
|
246
|
+
label: z.string(),
|
|
247
|
+
type: z.enum(['text', 'email', 'tel', 'textarea']).default('text'),
|
|
248
|
+
required: z.boolean().default(false),
|
|
249
|
+
placeholder: z.string().optional(),
|
|
250
|
+
})
|
|
251
|
+
)
|
|
252
|
+
.default([
|
|
253
|
+
{ name: 'name', label: 'Your name', type: 'text', required: true },
|
|
254
|
+
{ name: 'email', label: 'Email', type: 'email', required: true },
|
|
255
|
+
{ name: 'message', label: 'Message', type: 'textarea', required: true },
|
|
256
|
+
]),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/** Accordion — a generic expand/collapse list (title + body). Domain-free
|
|
260
|
+
* sibling of `faq` (which carries question/answer semantics). */
|
|
261
|
+
export const accordionBlock = z.object({
|
|
262
|
+
type: z.literal('accordion'),
|
|
263
|
+
enabled: z.boolean().default(true),
|
|
264
|
+
title: z.string().optional(),
|
|
265
|
+
items: z.array(z.object({ heading: z.string(), body: z.string() })).default([]),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/** Tiles — a curated card grid with five looks (outline / elevated / filled /
|
|
269
|
+
* gradient / image). The look is chosen per-block via `variant`; radius, stroke
|
|
270
|
+
* and elevation come from the global surface tokens, so tiles stay consistent
|
|
271
|
+
* with every other card on the page. Each tile can link (the whole tile is the
|
|
272
|
+
* target) and carry an icon, eyebrow, title, text and (for `image`) a photo. */
|
|
273
|
+
export const tilesBlock = z.object({
|
|
274
|
+
type: z.literal('tiles'),
|
|
275
|
+
enabled: z.boolean().default(true),
|
|
276
|
+
title: z.string().optional(),
|
|
277
|
+
intro: z.string().optional(),
|
|
278
|
+
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
|
|
279
|
+
variant: z.enum(['outline', 'elevated', 'filled', 'gradient', 'image']).default('elevated'),
|
|
280
|
+
/** Opt in to a pointer-following spotlight glow on hover (a small JS enhancement). */
|
|
281
|
+
spotlight: z.boolean().default(false),
|
|
282
|
+
tiles: z
|
|
283
|
+
.array(
|
|
284
|
+
z
|
|
285
|
+
.object({
|
|
286
|
+
icon: iconName.optional(),
|
|
287
|
+
eyebrow: z.string().optional(),
|
|
288
|
+
/** Optional only for a picture tile: with an image and no text, the tile is
|
|
289
|
+
* the picture alone (no scrim) — a linked poster/logo — named by its alt. */
|
|
290
|
+
title: z.string().optional(),
|
|
291
|
+
text: z.string().optional(),
|
|
292
|
+
href: z.string().optional(),
|
|
293
|
+
/** The link cue on a linking tile ("Learn more" by default). */
|
|
294
|
+
linkLabel: z.string().optional(),
|
|
295
|
+
image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
|
|
296
|
+
})
|
|
297
|
+
.refine((t) => !!t.title || !!t.image, { message: 'A tile needs a title or an image', path: ['title'] })
|
|
298
|
+
)
|
|
299
|
+
.default([]),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/** Banner — a full-bleed feature band: a fixed/parallax background image with a
|
|
303
|
+
* legibility scrim and a floating "shaded" content card (eyebrow + heading +
|
|
304
|
+
* text + optional CTA) drifting over it. `align` places the card; `parallax`
|
|
305
|
+
* pins the image so it slides under the content as you scroll (auto-disabled on
|
|
306
|
+
* touch and under reduced-motion). The showpiece "not-dull" effect. */
|
|
307
|
+
export const bannerBlock = z.object({
|
|
308
|
+
type: z.literal('banner'),
|
|
309
|
+
enabled: z.boolean().default(true),
|
|
310
|
+
image: z.string(),
|
|
311
|
+
eyebrow: z.string().optional(),
|
|
312
|
+
title: z.string(),
|
|
313
|
+
text: z.string().optional(),
|
|
314
|
+
cta: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
315
|
+
overlay: z.number().min(0).max(100).default(50),
|
|
316
|
+
align: z.enum(['start', 'center', 'end']).default('start'),
|
|
317
|
+
parallax: z.boolean().default(true),
|
|
318
|
+
/** Break the band out to the full viewport width (edge-to-edge, no rounded
|
|
319
|
+
* corners); the card stays aligned to the page's content column. Great for a
|
|
320
|
+
* homepage hero. Off by default (the band sits inside the content column). */
|
|
321
|
+
fullBleed: z.boolean().default(false),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
/** Stats — a band of headline numbers (value + label + optional note), over an
|
|
325
|
+
* optional heading/intro and a chosen background. Composes the Stat primitive. */
|
|
326
|
+
export const statsBlock = z.object({
|
|
327
|
+
type: z.literal('stats'),
|
|
328
|
+
enabled: z.boolean().default(true),
|
|
329
|
+
title: z.string().optional(),
|
|
330
|
+
intro: z.string().optional(),
|
|
331
|
+
background: z.enum(['none', 'muted', 'accent']).default('muted'),
|
|
332
|
+
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
|
|
333
|
+
items: z.array(z.object({ value: z.string(), label: z.string(), description: z.string().optional() })).default([]),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
/** Steps — a numbered process (how-it-works). `horizontal` shows connected cards
|
|
337
|
+
* in a row; `vertical` a stacked timeline. Each step: title + optional text/icon. */
|
|
338
|
+
export const stepsBlock = z.object({
|
|
339
|
+
type: z.literal('steps'),
|
|
340
|
+
enabled: z.boolean().default(true),
|
|
341
|
+
title: z.string().optional(),
|
|
342
|
+
intro: z.string().optional(),
|
|
343
|
+
layout: z.enum(['horizontal', 'vertical']).default('horizontal'),
|
|
344
|
+
items: z.array(z.object({ title: z.string(), text: z.string().optional(), icon: iconName.optional() })).default([]),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
/** Pricing — a row of plan cards (name, price, period, feature list, CTA). One
|
|
348
|
+
* plan may be `featured` (highlighted). */
|
|
349
|
+
export const pricingBlock = z.object({
|
|
350
|
+
type: z.literal('pricing'),
|
|
351
|
+
enabled: z.boolean().default(true),
|
|
352
|
+
title: z.string().optional(),
|
|
353
|
+
intro: z.string().optional(),
|
|
354
|
+
plans: z
|
|
355
|
+
.array(
|
|
356
|
+
z.object({
|
|
357
|
+
name: z.string(),
|
|
358
|
+
price: z.string(),
|
|
359
|
+
period: z.string().optional(),
|
|
360
|
+
description: z.string().optional(),
|
|
361
|
+
features: z.array(z.string()).default([]),
|
|
362
|
+
cta: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
363
|
+
featured: z.boolean().default(false),
|
|
364
|
+
})
|
|
365
|
+
)
|
|
366
|
+
.default([]),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
/** Logos — a "trusted by" wall of images; muted by default, colour on hover. */
|
|
370
|
+
export const logosBlock = z.object({
|
|
371
|
+
type: z.literal('logos'),
|
|
372
|
+
enabled: z.boolean().default(true),
|
|
373
|
+
title: z.string().optional(),
|
|
374
|
+
items: z.array(z.object({ src: z.string(), alt: z.string().default(''), href: z.string().optional() })).default([]),
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
/** Tabs — labelled panels; one open at a time. Progressive: the first panel is
|
|
378
|
+
* visible with no JS; a small script wires selection + ARIA. */
|
|
379
|
+
export const tabsBlock = z.object({
|
|
380
|
+
type: z.literal('tabs'),
|
|
381
|
+
enabled: z.boolean().default(true),
|
|
382
|
+
title: z.string().optional(),
|
|
383
|
+
items: z
|
|
384
|
+
.array(z.object({ label: z.string(), heading: z.string().optional(), text: z.string().optional(), image: z.object({ src: z.string(), alt: z.string().default('') }).optional() }))
|
|
385
|
+
.default([]),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
/** Announcement — a thin full-width bar (message + optional link), optionally
|
|
389
|
+
* dismissible. Dismissal is remembered per `id` in localStorage. */
|
|
390
|
+
export const announcementBlock = z.object({
|
|
391
|
+
type: z.literal('announcement'),
|
|
392
|
+
enabled: z.boolean().default(true),
|
|
393
|
+
text: z.string(),
|
|
394
|
+
link: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
395
|
+
tone: z.enum(['accent', 'neutral']).default('accent'),
|
|
396
|
+
dismissible: z.boolean().default(true),
|
|
397
|
+
/** localStorage key suffix so a dismissed bar stays dismissed. */
|
|
398
|
+
id: z.string().default('default'),
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
/** Bento — an asymmetric (mixed-span) grid of cards. Each item picks a `size`
|
|
402
|
+
* (sm / wide / tall / lg) and a `variant` look; the "living moodboard" layout. */
|
|
403
|
+
export const bentoBlock = z.object({
|
|
404
|
+
type: z.literal('bento'),
|
|
405
|
+
enabled: z.boolean().default(true),
|
|
406
|
+
title: z.string().optional(),
|
|
407
|
+
intro: z.string().optional(),
|
|
408
|
+
items: z
|
|
409
|
+
.array(
|
|
410
|
+
z.object({
|
|
411
|
+
size: z.enum(['sm', 'wide', 'tall', 'lg']).default('sm'),
|
|
412
|
+
variant: z.enum(['surface', 'filled', 'gradient', 'image']).default('surface'),
|
|
413
|
+
icon: iconName.optional(),
|
|
414
|
+
eyebrow: z.string().optional(),
|
|
415
|
+
title: z.string(),
|
|
416
|
+
text: z.string().optional(),
|
|
417
|
+
href: z.string().optional(),
|
|
418
|
+
image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
|
|
419
|
+
})
|
|
420
|
+
)
|
|
421
|
+
.default([]),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
/** Marquee — an infinite horizontal scroll of short phrases (a feature strip).
|
|
425
|
+
* Pure-CSS transform loop (GPU); pauses on hover; static wrap under
|
|
426
|
+
* reduced-motion. Decorative, so the moving track is aria-hidden. */
|
|
427
|
+
export const marqueeBlock = z.object({
|
|
428
|
+
type: z.literal('marquee'),
|
|
429
|
+
enabled: z.boolean().default(true),
|
|
430
|
+
items: z.array(z.string()).default([]),
|
|
431
|
+
speed: z.enum(['slow', 'normal', 'fast']).default('normal'),
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
/** LatestPosts — a capability-backed recipe: the N most-recent published posts
|
|
435
|
+
* (optionally filtered to a `tag`) as a card grid or a compact list, with a
|
|
436
|
+
* "view all" link. Unlike other recipes it carries no content of its own — it
|
|
437
|
+
* queries the client's posts collection at build time — so it's the bridge from
|
|
438
|
+
* the posts capability into the page/block system (no per-client custom code). */
|
|
439
|
+
export const latestPostsBlock = z.object({
|
|
440
|
+
type: z.literal('latestPosts'),
|
|
441
|
+
enabled: z.boolean().default(true),
|
|
442
|
+
title: z.string().optional(),
|
|
443
|
+
intro: z.string().optional(),
|
|
444
|
+
limit: z.number().int().min(1).max(12).default(3),
|
|
445
|
+
/** Filter to a single tag slug; omit for the main index (minus excluded tags). */
|
|
446
|
+
tag: z.string().optional(),
|
|
447
|
+
/** `grid` = full image cards; `list` = a compact miniature index (thumbnail +
|
|
448
|
+
* category + date per row) that stays short even with many posts. */
|
|
449
|
+
layout: z.enum(['grid', 'list']).default('grid'),
|
|
450
|
+
/** In the `list` layout, badge the most-recent post as "New" when it was
|
|
451
|
+
* published within this many days (0 disables it). Only the newest row is ever
|
|
452
|
+
* flagged, so a frequently-posting site doesn't mark every row. Grid ignores it. */
|
|
453
|
+
newWithinDays: z.number().int().min(0).max(365).default(14),
|
|
454
|
+
/** "View all" target + label (the posts index by default). */
|
|
455
|
+
viewAllHref: z.string().default('/posts'),
|
|
456
|
+
viewAllLabel: z.string().default('All posts'),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
/** MapEmbed — a consent-gated external map (e.g. Google Maps), the sibling of the
|
|
460
|
+
* calendar embed: nothing loads from the provider until the visitor opts in, so
|
|
461
|
+
* no third-party request fires on page load. `embedUrl` must be https; an unset
|
|
462
|
+
* or unsafe URL degrades to a friendly note + optional "open in Maps" link. */
|
|
463
|
+
export const mapEmbedBlock = z.object({
|
|
464
|
+
type: z.literal('mapEmbed'),
|
|
465
|
+
enabled: z.boolean().default(true),
|
|
466
|
+
title: z.string().optional(),
|
|
467
|
+
intro: z.string().optional(),
|
|
468
|
+
/** The map iframe src (an https embed URL). Blank → a "not set up yet" note. */
|
|
469
|
+
embedUrl: z.string().default(''),
|
|
470
|
+
/** Link to open the location in the provider's app/site (a no-embed fallback). */
|
|
471
|
+
publicUrl: z.string().default(''),
|
|
472
|
+
height: z.number().int().min(150).max(1200).default(450),
|
|
473
|
+
/** Provider name shown in the consent notice ("shared with …"). */
|
|
474
|
+
provider: z.string().default('Google Maps'),
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
/** Table — a simple data table (header row + body rows) for structured data our
|
|
478
|
+
* other blocks can't carry cleanly (fees, opening hours, a cookies list). Scrolls
|
|
479
|
+
* horizontally on narrow screens so it never breaks the page width. */
|
|
480
|
+
export const tableBlock = z.object({
|
|
481
|
+
type: z.literal('table'),
|
|
482
|
+
enabled: z.boolean().default(true),
|
|
483
|
+
title: z.string().optional(),
|
|
484
|
+
intro: z.string().optional(),
|
|
485
|
+
columns: z.array(z.string()).default([]),
|
|
486
|
+
rows: z.array(z.array(z.string())).default([]),
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
/** Notice — an inline highlighted info box (icon? + title? + body + link?) that
|
|
490
|
+
* sets a passage apart from prose ("reporting a concern", "please note"). Two
|
|
491
|
+
* tones (accent / neutral); the in-flow sibling of the corner StickyCallout. */
|
|
492
|
+
export const noticeBlock = z.object({
|
|
493
|
+
type: z.literal('notice'),
|
|
494
|
+
enabled: z.boolean().default(true),
|
|
495
|
+
tone: z.enum(['accent', 'neutral']).default('accent'),
|
|
496
|
+
icon: iconName.optional(),
|
|
497
|
+
title: z.string().optional(),
|
|
498
|
+
text: z.string(),
|
|
499
|
+
link: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
/** MediaCards — a grid of rich content cards with the IMAGE as a header on top
|
|
503
|
+
* (cover-cropped), then an optional icon badge, title, meta line, description and
|
|
504
|
+
* CTA. The image-HEADER pattern (distinct from `tiles`, which puts the image
|
|
505
|
+
* BEHIND the text) — for groups, team, services, events where the photo AND copy
|
|
506
|
+
* must both stay legible. Whole card links when a card has `href`. */
|
|
507
|
+
export const mediaCardsBlock = z.object({
|
|
508
|
+
type: z.literal('mediaCards'),
|
|
509
|
+
enabled: z.boolean().default(true),
|
|
510
|
+
title: z.string().optional(),
|
|
511
|
+
intro: z.string().optional(),
|
|
512
|
+
columns: z.union([z.literal(2), z.literal(3)]).default(3),
|
|
513
|
+
cards: z
|
|
514
|
+
.array(
|
|
515
|
+
z.object({
|
|
516
|
+
image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
|
|
517
|
+
icon: iconName.optional(),
|
|
518
|
+
title: z.string(),
|
|
519
|
+
/** A short meta line shown as a pill (e.g. "Sundays 10:45 · Main hall"). */
|
|
520
|
+
meta: z.string().optional(),
|
|
521
|
+
text: z.string().optional(),
|
|
522
|
+
href: z.string().optional(),
|
|
523
|
+
/** CTA label when the card links (defaults to "Learn more"). */
|
|
524
|
+
linkLabel: z.string().optional(),
|
|
525
|
+
})
|
|
526
|
+
)
|
|
527
|
+
.default([]),
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
/** ContactCard — a branded "get in touch" band: display title + subtitle on the
|
|
531
|
+
* left, contact channels as tappable pills on the right, a note across the bottom. */
|
|
532
|
+
export const contactCardBlock = z.object({
|
|
533
|
+
type: z.literal('contactCard'),
|
|
534
|
+
enabled: z.boolean().default(true),
|
|
535
|
+
eyebrow: z.string().optional(),
|
|
536
|
+
title: z.string(),
|
|
537
|
+
subtitle: z.string().optional(),
|
|
538
|
+
note: z.string().optional(),
|
|
539
|
+
items: z
|
|
540
|
+
.array(
|
|
541
|
+
z.object({
|
|
542
|
+
icon: iconName.optional(),
|
|
543
|
+
value: z.string(),
|
|
544
|
+
href: z.string().optional(),
|
|
545
|
+
})
|
|
546
|
+
)
|
|
547
|
+
.default([]),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
/** Locations ("Where to find us") — location tiles, each a consent-gated map over
|
|
551
|
+
* a name / address / directions. https embed URLs only (isSafeEmbedUrl). */
|
|
552
|
+
export const locationsBlock = z.object({
|
|
553
|
+
type: z.literal('locations'),
|
|
554
|
+
enabled: z.boolean().default(true),
|
|
555
|
+
title: z.string().optional(),
|
|
556
|
+
intro: z.string().optional(),
|
|
557
|
+
columns: z.union([z.literal(1), z.literal(2)]).default(2),
|
|
558
|
+
provider: z.string().default('Google Maps'),
|
|
559
|
+
items: z
|
|
560
|
+
.array(
|
|
561
|
+
z.object({
|
|
562
|
+
name: z.string(),
|
|
563
|
+
address: z.string().optional(),
|
|
564
|
+
embedUrl: z.string(),
|
|
565
|
+
directionsUrl: z.string().optional(),
|
|
566
|
+
})
|
|
567
|
+
)
|
|
568
|
+
.default([]),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
/** Video — a consent-gated YouTube / Vimeo player (incl. a channel's live stream).
|
|
572
|
+
* `url` is the ordinary link an editor would paste; it becomes a privacy-friendly
|
|
573
|
+
* embed (lib/video). An unrecognised link shows as a plain "watch" link. */
|
|
574
|
+
export const videoBlock = z.object({
|
|
575
|
+
type: z.literal('video'),
|
|
576
|
+
enabled: z.boolean().default(true),
|
|
577
|
+
title: z.string().optional(),
|
|
578
|
+
caption: z.string().optional(),
|
|
579
|
+
/** A short note under the player (inline markdown), e.g. a live stream's schedule. */
|
|
580
|
+
note: z.string().optional(),
|
|
581
|
+
url: z.string(),
|
|
582
|
+
ratio: z.enum(['16/9', '4/3', '1/1', '9/16']).default('16/9'),
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
/** Documents — a list of files (newsletters, minutes, policies, forms): type badge,
|
|
586
|
+
* title, date and note per row, newest first, optionally grouped by year. `date`
|
|
587
|
+
* is YYYY-MM-DD. Files open in the browser's own viewer, not an in-page one. */
|
|
588
|
+
export const documentsBlock = z.object({
|
|
589
|
+
type: z.literal('documents'),
|
|
590
|
+
enabled: z.boolean().default(true),
|
|
591
|
+
title: z.string().optional(),
|
|
592
|
+
intro: z.string().optional(),
|
|
593
|
+
groupByYear: z.boolean().default(false),
|
|
594
|
+
items: z
|
|
595
|
+
.array(
|
|
596
|
+
z.object({
|
|
597
|
+
title: z.string(),
|
|
598
|
+
file: z.string(),
|
|
599
|
+
date: z.string().optional(),
|
|
600
|
+
note: z.string().optional(),
|
|
601
|
+
})
|
|
602
|
+
)
|
|
603
|
+
.default([]),
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
/** LinkRow — a titled row of contact or reference links as tappable pills (phone, email,
|
|
607
|
+
* web), each with the icon its link implies, plus an optional intro and footnote. For a
|
|
608
|
+
* contact who isn't worth a whole contact card (e.g. "Vocations: phone · email · site"). */
|
|
609
|
+
export const linkRowBlock = z.object({
|
|
610
|
+
type: z.literal('linkRow'),
|
|
611
|
+
enabled: z.boolean().default(true),
|
|
612
|
+
title: z.string().optional(),
|
|
613
|
+
intro: z.string().optional(),
|
|
614
|
+
links: z.array(z.object({ label: z.string(), href: z.string() })).min(1),
|
|
615
|
+
note: z.string().optional(),
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
/** Document — a policy or legal text as ONE block: sections numbered automatically, a
|
|
619
|
+
* contents list linking to each (sticky beside the text on wide screens), an optional
|
|
620
|
+
* "last updated" date (YYYY-MM-DD), and print-friendly. Section bodies take prose
|
|
621
|
+
* formatting; `items` add a list. Replaces dozens of hand-numbered headings + paragraphs. */
|
|
622
|
+
export const documentBlock = z.object({
|
|
623
|
+
type: z.literal('document'),
|
|
624
|
+
enabled: z.boolean().default(true),
|
|
625
|
+
intro: z.string().optional(),
|
|
626
|
+
updated: z.string().optional(),
|
|
627
|
+
numbered: z.boolean().default(true),
|
|
628
|
+
toc: z.boolean().default(true),
|
|
629
|
+
sections: z
|
|
630
|
+
.array(z.object({ title: z.string(), body: z.string().optional(), items: z.array(z.string()).optional() }))
|
|
631
|
+
.min(1),
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
/** Timetable — weekly times for one or more places (worship times, opening hours,
|
|
635
|
+
* sessions, classes). `cards`: a card per place, days with no times left out, notes
|
|
636
|
+
* (inline markdown) under each. `table`: places side by side, days down the side. */
|
|
637
|
+
export const timetableBlock = z.object({
|
|
638
|
+
type: z.literal('timetable'),
|
|
639
|
+
enabled: z.boolean().default(true),
|
|
640
|
+
title: z.string().optional(),
|
|
641
|
+
intro: z.string().optional(),
|
|
642
|
+
layout: z.enum(['cards', 'table']).default('cards'),
|
|
643
|
+
places: z
|
|
644
|
+
.array(
|
|
645
|
+
z.object({
|
|
646
|
+
name: z.string(),
|
|
647
|
+
address: z.string().optional(),
|
|
648
|
+
href: z.string().optional(),
|
|
649
|
+
schedule: z.array(z.object({ day: z.string(), times: z.array(z.string()).default([]) })).default([]),
|
|
650
|
+
notes: z.string().optional(),
|
|
651
|
+
})
|
|
652
|
+
)
|
|
653
|
+
.min(1),
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
/** Contacts — a compact directory of people or offices: role as the card title, phone and
|
|
657
|
+
* email as tap targets (email with an optional subject), address and hours, and a shared
|
|
658
|
+
* note per group (e.g. office hours) instead of repeating it on every card. */
|
|
659
|
+
export const contactsBlock = z.object({
|
|
660
|
+
type: z.literal('contacts'),
|
|
661
|
+
enabled: z.boolean().default(true),
|
|
662
|
+
title: z.string().optional(),
|
|
663
|
+
intro: z.string().optional(),
|
|
664
|
+
groups: z
|
|
665
|
+
.array(
|
|
666
|
+
z.object({
|
|
667
|
+
heading: z.string().optional(),
|
|
668
|
+
note: z.string().optional(),
|
|
669
|
+
contacts: z
|
|
670
|
+
.array(
|
|
671
|
+
z.object({
|
|
672
|
+
role: z.string().optional(),
|
|
673
|
+
name: z.string().optional(),
|
|
674
|
+
phone: z.string().optional(),
|
|
675
|
+
email: z.string().optional(),
|
|
676
|
+
emailSubject: z.string().optional(),
|
|
677
|
+
address: z.string().optional(),
|
|
678
|
+
hours: z.string().optional(),
|
|
679
|
+
})
|
|
680
|
+
.refine((c) => !!c.role || !!c.name, { message: 'A contact needs a role or a name', path: ['role'] })
|
|
681
|
+
)
|
|
682
|
+
.min(1),
|
|
683
|
+
})
|
|
684
|
+
)
|
|
685
|
+
.min(1),
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
/** MediaText — an image beside its text (start or end), stacking on a phone. The text takes
|
|
689
|
+
* prose formatting; the image keeps its shape (`ratio` crops to a set shape); optional CTA. */
|
|
690
|
+
export const mediaTextBlock = z.object({
|
|
691
|
+
type: z.literal('mediaText'),
|
|
692
|
+
enabled: z.boolean().default(true),
|
|
693
|
+
title: z.string().optional(),
|
|
694
|
+
text: z.string(),
|
|
695
|
+
image: z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional() }),
|
|
696
|
+
imageSide: z.enum(['start', 'end']).default('end'),
|
|
697
|
+
ratio: z.enum(['auto', '1/1', '4/3', '3/2', '16/9']).default('auto'),
|
|
698
|
+
cta: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
export type RecipeBlock =
|
|
702
|
+
| z.infer<typeof heroBlock>
|
|
703
|
+
| z.infer<typeof featureCardsBlock>
|
|
704
|
+
| z.infer<typeof ctaBlock>
|
|
705
|
+
| z.infer<typeof faqBlock>
|
|
706
|
+
| z.infer<typeof galleryBlock>
|
|
707
|
+
| z.infer<typeof testimonialBlock>
|
|
708
|
+
| z.infer<typeof contactFormBlock>
|
|
709
|
+
| z.infer<typeof formBlock>
|
|
710
|
+
| z.infer<typeof accordionBlock>
|
|
711
|
+
| z.infer<typeof tilesBlock>
|
|
712
|
+
| z.infer<typeof bannerBlock>
|
|
713
|
+
| z.infer<typeof statsBlock>
|
|
714
|
+
| z.infer<typeof stepsBlock>
|
|
715
|
+
| z.infer<typeof pricingBlock>
|
|
716
|
+
| z.infer<typeof logosBlock>
|
|
717
|
+
| z.infer<typeof tabsBlock>
|
|
718
|
+
| z.infer<typeof announcementBlock>
|
|
719
|
+
| z.infer<typeof bentoBlock>
|
|
720
|
+
| z.infer<typeof marqueeBlock>
|
|
721
|
+
| z.infer<typeof latestPostsBlock>
|
|
722
|
+
| z.infer<typeof mapEmbedBlock>
|
|
723
|
+
| z.infer<typeof tableBlock>
|
|
724
|
+
| z.infer<typeof noticeBlock>
|
|
725
|
+
| z.infer<typeof mediaCardsBlock>
|
|
726
|
+
| z.infer<typeof contactCardBlock>
|
|
727
|
+
| z.infer<typeof locationsBlock>
|
|
728
|
+
| z.infer<typeof videoBlock>
|
|
729
|
+
| z.infer<typeof documentsBlock>
|
|
730
|
+
| z.infer<typeof linkRowBlock>
|
|
731
|
+
| z.infer<typeof documentBlock>
|
|
732
|
+
| z.infer<typeof timetableBlock>
|
|
733
|
+
| z.infer<typeof contactsBlock>
|
|
734
|
+
| z.infer<typeof mediaTextBlock>;
|
|
735
|
+
|
|
736
|
+
/* ── Level-1 layout / container primitives ─────────────────────────────────
|
|
737
|
+
* These hold child blocks, so the schema is **recursive** (a container's
|
|
738
|
+
* `blocks` field is the same block union). TypeScript can't infer a type through
|
|
739
|
+
* `z.lazy`, so the recursive `Block` type is declared explicitly and the union
|
|
740
|
+
* is cast to it. Containers are how primitives compose into layouts (and, later,
|
|
741
|
+
* recipes). */
|
|
742
|
+
export interface SectionBlock {
|
|
743
|
+
type: 'section';
|
|
744
|
+
enabled: boolean;
|
|
745
|
+
background: 'none' | 'muted' | 'accent';
|
|
746
|
+
width: 'normal' | 'wide' | 'full';
|
|
747
|
+
blocks: Block[];
|
|
748
|
+
}
|
|
749
|
+
export interface GridBlock {
|
|
750
|
+
type: 'grid';
|
|
751
|
+
enabled: boolean;
|
|
752
|
+
columns: 2 | 3 | 4;
|
|
753
|
+
gap: 'sm' | 'md' | 'lg';
|
|
754
|
+
blocks: Block[];
|
|
755
|
+
}
|
|
756
|
+
export interface StackBlock {
|
|
757
|
+
type: 'stack';
|
|
758
|
+
enabled: boolean;
|
|
759
|
+
gap: 'sm' | 'md' | 'lg';
|
|
760
|
+
blocks: Block[];
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
export type LeafBlock =
|
|
764
|
+
| z.infer<typeof headingBlock>
|
|
765
|
+
| z.infer<typeof proseBlock>
|
|
766
|
+
| z.infer<typeof imageBlock>
|
|
767
|
+
| z.infer<typeof buttonBlock>
|
|
768
|
+
| z.infer<typeof quoteBlock>
|
|
769
|
+
| z.infer<typeof dividerBlock>
|
|
770
|
+
| z.infer<typeof spacerBlock>
|
|
771
|
+
| z.infer<typeof badgeBlock>
|
|
772
|
+
| z.infer<typeof statBlock>
|
|
773
|
+
| z.infer<typeof listBlock>;
|
|
774
|
+
|
|
775
|
+
/** The full recursive block type: leaf primitives + layout containers. */
|
|
776
|
+
/**
|
|
777
|
+
* Alignment overrides every block takes, so a page's look can follow a client's preference
|
|
778
|
+
* from the page data alone. Defaults are the sensible visual choice (a block follows its
|
|
779
|
+
* page / section; a recipe's section title is centred).
|
|
780
|
+
* - `align`: re-aligns the whole block — title, intro, text, and the icons, pills and
|
|
781
|
+
* buttons in its cards — overriding the page / section for this one block.
|
|
782
|
+
* - `headingAlign`: just a recipe's title + intro (e.g. a start-aligned title over centred
|
|
783
|
+
* cards). Recipes without a section title ignore it.
|
|
784
|
+
* `heading`, `banner` and `section` already had `align` with this meaning and keep it.
|
|
785
|
+
*/
|
|
786
|
+
const alignOption = z.enum(['start', 'center', 'end']);
|
|
787
|
+
export interface BlockLayout {
|
|
788
|
+
align?: 'start' | 'center' | 'end';
|
|
789
|
+
headingAlign?: 'start' | 'center' | 'end';
|
|
790
|
+
}
|
|
791
|
+
function withLayout<T extends z.AnyZodObject>(block: T): z.AnyZodObject {
|
|
792
|
+
const shape = block.shape as Record<string, unknown>;
|
|
793
|
+
return block.extend({
|
|
794
|
+
...('align' in shape ? {} : { align: alignOption.optional() }),
|
|
795
|
+
headingAlign: alignOption.optional(),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export type Block = (LeafBlock | RecipeBlock | SectionBlock | GridBlock | StackBlock) & BlockLayout;
|
|
800
|
+
export type BlockType = Block['type'];
|
|
801
|
+
|
|
802
|
+
export const sectionBlock = z.object({
|
|
803
|
+
type: z.literal('section'),
|
|
804
|
+
enabled: z.boolean().default(true),
|
|
805
|
+
background: z.enum(['none', 'muted', 'accent']).default('none'),
|
|
806
|
+
width: z.enum(['normal', 'wide', 'full']).default('normal'),
|
|
807
|
+
/** Override the page's content alignment for this section's subtree. */
|
|
808
|
+
align: z.enum(['start', 'center', 'end']).optional(),
|
|
809
|
+
blocks: z.lazy(() => z.array(blockSchema)).default([]),
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
export const gridBlock = z.object({
|
|
813
|
+
type: z.literal('grid'),
|
|
814
|
+
enabled: z.boolean().default(true),
|
|
815
|
+
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(2),
|
|
816
|
+
gap: z.enum(['sm', 'md', 'lg']).default('md'),
|
|
817
|
+
blocks: z.lazy(() => z.array(blockSchema)).default([]),
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
export const stackBlock = z.object({
|
|
821
|
+
type: z.literal('stack'),
|
|
822
|
+
enabled: z.boolean().default(true),
|
|
823
|
+
gap: z.enum(['sm', 'md', 'lg']).default('md'),
|
|
824
|
+
blocks: z.lazy(() => z.array(blockSchema)).default([]),
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
/** The block palette: leaf primitives + layout containers. A discriminated union
|
|
828
|
+
* on `type` — the AI's + CMS's contract and the renderer's registry key. Adding
|
|
829
|
+
* a member without a matching BlockRenderer registry entry is a TypeScript error
|
|
830
|
+
* (the exhaustiveness guard against a silent-undefined render crash). Cast to
|
|
831
|
+
* the recursive `Block` type because `z.lazy` fields can't be inferred. */
|
|
832
|
+
export const blockSchema = z.discriminatedUnion(
|
|
833
|
+
'type',
|
|
834
|
+
[
|
|
835
|
+
headingBlock,
|
|
836
|
+
proseBlock,
|
|
837
|
+
imageBlock,
|
|
838
|
+
buttonBlock,
|
|
839
|
+
quoteBlock,
|
|
840
|
+
dividerBlock,
|
|
841
|
+
spacerBlock,
|
|
842
|
+
badgeBlock,
|
|
843
|
+
statBlock,
|
|
844
|
+
listBlock,
|
|
845
|
+
heroBlock,
|
|
846
|
+
featureCardsBlock,
|
|
847
|
+
ctaBlock,
|
|
848
|
+
faqBlock,
|
|
849
|
+
galleryBlock,
|
|
850
|
+
testimonialBlock,
|
|
851
|
+
contactFormBlock,
|
|
852
|
+
formBlock,
|
|
853
|
+
accordionBlock,
|
|
854
|
+
tilesBlock,
|
|
855
|
+
bannerBlock,
|
|
856
|
+
statsBlock,
|
|
857
|
+
stepsBlock,
|
|
858
|
+
pricingBlock,
|
|
859
|
+
logosBlock,
|
|
860
|
+
tabsBlock,
|
|
861
|
+
announcementBlock,
|
|
862
|
+
bentoBlock,
|
|
863
|
+
marqueeBlock,
|
|
864
|
+
latestPostsBlock,
|
|
865
|
+
mapEmbedBlock,
|
|
866
|
+
tableBlock,
|
|
867
|
+
noticeBlock,
|
|
868
|
+
mediaCardsBlock,
|
|
869
|
+
contactCardBlock,
|
|
870
|
+
locationsBlock,
|
|
871
|
+
videoBlock,
|
|
872
|
+
documentsBlock,
|
|
873
|
+
linkRowBlock,
|
|
874
|
+
documentBlock,
|
|
875
|
+
timetableBlock,
|
|
876
|
+
contactsBlock,
|
|
877
|
+
mediaTextBlock,
|
|
878
|
+
sectionBlock,
|
|
879
|
+
gridBlock,
|
|
880
|
+
stackBlock,
|
|
881
|
+
].map(withLayout) as unknown as [z.AnyZodObject, z.AnyZodObject, ...z.AnyZodObject[]],
|
|
882
|
+
) as unknown as z.ZodType<Block>;
|
|
883
|
+
|
|
884
|
+
/** A page = metadata + an ordered list of blocks. Stored as JSON in the client
|
|
885
|
+
* repo; the AI composes it, a human verifies. This is also the schema of the
|
|
886
|
+
* `pages` collection that the client's `[...slug].astro` catch-all routes over,
|
|
887
|
+
* so one page = one JSON file (its filename is the URL slug). */
|
|
888
|
+
export const pageSchema = z.object({
|
|
889
|
+
title: z.string(),
|
|
890
|
+
description: z.string().optional(),
|
|
891
|
+
/** Preloaded as the LCP image (passed to the layout) when set. */
|
|
892
|
+
heroImage: z.string().optional(),
|
|
893
|
+
/**
|
|
894
|
+
* Template-level content alignment — the default for text-bearing blocks on the
|
|
895
|
+
* page (headings, prose, quote). Cascades as `--content-align` so every recipe
|
|
896
|
+
* inherits one coherent alignment instead of each hard-coding its own. A
|
|
897
|
+
* `section` block can override it for its subtree; a `heading` can override per
|
|
898
|
+
* block. `start` (left) is the readable default for long-form; `center` suits
|
|
899
|
+
* short, hero-led pages.
|
|
900
|
+
*/
|
|
901
|
+
align: z.enum(['start', 'center', 'end']).default('start'),
|
|
902
|
+
/**
|
|
903
|
+
* Provenance — the ferst-library template this page was instantiated from.
|
|
904
|
+
* The triangular model (docs/BLOCK-ARCHITECTURE.md §5): lego box → a template
|
|
905
|
+
* stored in the library → each client page links a `templateId`. The client
|
|
906
|
+
* repo never depends on the library; this is just a design-time back-reference
|
|
907
|
+
* so a template's lineage/variants stay traceable platform-side. Optional — a
|
|
908
|
+
* bespoke page may have none.
|
|
909
|
+
*/
|
|
910
|
+
templateId: z.string().optional(),
|
|
911
|
+
/**
|
|
912
|
+
* Editor change-requests captured in the CMS — the seed of AI-driven editing
|
|
913
|
+
* ([[ferst_editing_ai_first_pdlc]]): rather than let editors edit block JSON
|
|
914
|
+
* directly (easy to break), they describe a desired change against the page and
|
|
915
|
+
* it's stored here for an agent to action later → PR → human verify. NEVER
|
|
916
|
+
* rendered; carried through the schema so a CMS save preserves it.
|
|
917
|
+
*/
|
|
918
|
+
requests: z
|
|
919
|
+
.array(
|
|
920
|
+
z.object({
|
|
921
|
+
text: z.string(),
|
|
922
|
+
at: z.string().optional(),
|
|
923
|
+
by: z.string().optional(),
|
|
924
|
+
status: z.enum(['open', 'done']).default('open'),
|
|
925
|
+
}),
|
|
926
|
+
)
|
|
927
|
+
.default([]),
|
|
928
|
+
/** Keep the JSON in the repo but exclude it from the build. */
|
|
929
|
+
draft: z.boolean().default(false),
|
|
930
|
+
blocks: z.array(blockSchema).default([]),
|
|
931
|
+
});
|
|
932
|
+
export type Page = z.infer<typeof pageSchema>;
|
|
933
|
+
|
|
934
|
+
/** Every block type id, derived from the union so it stays in sync — used for
|
|
935
|
+
* gallery coverage, CMS config generation, docs, etc. */
|
|
936
|
+
export const blockTypes = (
|
|
937
|
+
blockSchema as unknown as { options: ReadonlyArray<{ shape: { type: { value: BlockType } } }> }
|
|
938
|
+
).options.map((o) => o.shape.type.value);
|
|
939
|
+
|
|
940
|
+
/** Blocks whose content comes from the Ferst platform at build time (a form placed by id).
|
|
941
|
+
* The public showcase can't render them without the platform, so its galleries show the
|
|
942
|
+
* look through their local twin instead (`form` renders with the contactForm recipe). */
|
|
943
|
+
export const platformBlockTypes: readonly BlockType[] = ['form'];
|