ferst-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +102 -201
  2. package/LICENSE-Apache-2.0 +201 -0
  3. package/NOTICE +24 -5
  4. package/README.md +89 -73
  5. package/bin/check-thin.mjs +74 -0
  6. package/components/BlockRenderer.astro +89 -0
  7. package/components/Button.astro +85 -33
  8. package/components/CalendarEmbed.astro +188 -0
  9. package/components/Card.astro +7 -17
  10. package/components/Icon.astro +35 -0
  11. package/components/PostArticle.astro +133 -0
  12. package/components/PostCard.astro +85 -225
  13. package/components/SiteFooter.astro +2 -2
  14. package/components/SiteHeader.astro +104 -51
  15. package/components/blocks/Badge.astro +29 -0
  16. package/components/blocks/ButtonBlock.astro +13 -0
  17. package/components/blocks/Divider.astro +12 -0
  18. package/components/blocks/Grid.astro +48 -0
  19. package/components/blocks/Heading.astro +42 -0
  20. package/components/blocks/ImageBlock.astro +80 -0
  21. package/components/blocks/List.astro +78 -0
  22. package/components/blocks/Prose.astro +29 -0
  23. package/components/blocks/Quote.astro +32 -0
  24. package/components/blocks/Section.astro +52 -0
  25. package/components/blocks/Spacer.astro +20 -0
  26. package/components/blocks/Stack.astro +30 -0
  27. package/components/blocks/Stat.astro +30 -0
  28. package/components/recipes/Accordion.astro +84 -0
  29. package/components/recipes/Announcement.astro +74 -0
  30. package/components/recipes/Banner.astro +134 -0
  31. package/components/recipes/Bento.astro +138 -0
  32. package/components/recipes/ContactForm.astro +172 -0
  33. package/components/recipes/Cta.astro +83 -0
  34. package/components/recipes/Faq.astro +80 -0
  35. package/components/recipes/FeatureCards.astro +105 -0
  36. package/components/recipes/Gallery.astro +55 -0
  37. package/components/recipes/Hero.astro +191 -0
  38. package/components/recipes/Logos.astro +77 -0
  39. package/components/recipes/Marquee.astro +75 -0
  40. package/components/recipes/Pricing.astro +149 -0
  41. package/components/recipes/Stats.astro +124 -0
  42. package/components/recipes/Steps.astro +120 -0
  43. package/components/recipes/Tabs.astro +133 -0
  44. package/components/recipes/Testimonial.astro +66 -0
  45. package/components/recipes/Tiles.astro +248 -0
  46. package/content/blocks.ts +534 -0
  47. package/content/calendar.ts +40 -0
  48. package/content/collections.ts +25 -14
  49. package/content/posts.ts +89 -0
  50. package/content/schemas.ts +58 -123
  51. package/layouts/Base.astro +30 -56
  52. package/lib/calendar.ts +12 -0
  53. package/lib/icons.ts +64 -0
  54. package/lib/pages.ts +42 -0
  55. package/lib/posts.ts +93 -0
  56. package/lib/siteSettings.ts +18 -36
  57. package/lib/themeTokens.ts +163 -0
  58. package/package.json +61 -48
  59. package/styles/theme.css +42 -0
  60. package/components/DocLayout.astro +0 -292
  61. package/components/GroupCard.astro +0 -164
  62. package/components/LatestPostList.astro +0 -64
  63. package/components/PaginationNav.astro +0 -81
  64. package/components/PostsToolbar.astro +0 -83
  65. package/content/filters.ts +0 -107
  66. package/lib/mailLinks.ts +0 -13
  67. package/lib/mapsLink.ts +0 -7
  68. package/utils/excerpt.ts +0 -56
  69. package/utils/formatDate.ts +0 -22
  70. package/utils/freshness.ts +0 -5
@@ -0,0 +1,534 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * The block palette — the heart of the Ferst page model (see
5
+ * docs/BLOCK-ARCHITECTURE.md). A page is an ordered list of typed blocks stored
6
+ * as JSON; a Zod **discriminated union** on `type` is the single contract the AI
7
+ * composes against, the CMS validates, and the renderer's registry keys on.
8
+ *
9
+ * This is a white-room design of the *mechanism* (not a copy of CTK's block
10
+ * builder). It starts with **Level-1 primitives** only; recipes (Level 2) and
11
+ * page templates (Level 3) layer on top later, and layout/container primitives
12
+ * (Section/Grid/Stack, which nest child blocks) are a deliberate next slice.
13
+ */
14
+
15
+ /** Every block shares a `type` discriminant and an `enabled` flag (keep a block
16
+ * in the page data without rendering it). */
17
+ export const headingBlock = z.object({
18
+ type: z.literal('heading'),
19
+ enabled: z.boolean().default(true),
20
+ text: z.string(),
21
+ level: z.number().int().min(1).max(6).default(2),
22
+ align: z.enum(['start', 'center', 'end']).default('start'),
23
+ /** Paint the text with a brand → accent-2 gradient (falls back to solid ink
24
+ * where background-clip:text is unsupported). A per-block opt-in effect. */
25
+ gradient: z.boolean().default(false),
26
+ });
27
+
28
+ export const proseBlock = z.object({
29
+ type: z.literal('prose'),
30
+ enabled: z.boolean().default(true),
31
+ /** Plain text; blank lines separate paragraphs. Markdown rendering is a later
32
+ * upgrade to this same block — the field stays `text`. */
33
+ text: z.string(),
34
+ });
35
+
36
+ export const imageBlock = z.object({
37
+ type: z.literal('image'),
38
+ enabled: z.boolean().default(true),
39
+ src: z.string(),
40
+ alt: z.string().default(''),
41
+ caption: z.string().optional(),
42
+ /** Canonical framing options — a small, clear set the AI or a client picks
43
+ * from, rather than free-form CSS. Corners follow the site by default. */
44
+ radius: z.enum(['none', 'soft', 'round']).default('soft'),
45
+ shadow: z.boolean().default(false),
46
+ border: z.boolean().default(false),
47
+ ratio: z.enum(['auto', 'square', '4/3', '3/2', '16/9', '21/9']).default('auto'),
48
+ /** Opt in to a soft clip-path reveal as the image scrolls into view
49
+ * (progressive: no motion under reduced-motion or unsupported browsers). */
50
+ reveal: z.boolean().default(false),
51
+ });
52
+
53
+ export const buttonBlock = z.object({
54
+ type: z.literal('button'),
55
+ enabled: z.boolean().default(true),
56
+ label: z.string(),
57
+ href: z.string(),
58
+ /** Role, not decoration: primary action, secondary action, filled CTA, or a
59
+ * quiet ghost. Shape (radius) is a site-wide token, not a per-button choice. */
60
+ variant: z.enum(['primary', 'secondary', 'cta', 'ghost']).default('primary'),
61
+ });
62
+
63
+ export const quoteBlock = z.object({
64
+ type: z.literal('quote'),
65
+ enabled: z.boolean().default(true),
66
+ text: z.string(),
67
+ cite: z.string().optional(),
68
+ });
69
+
70
+ export const dividerBlock = z.object({
71
+ type: z.literal('divider'),
72
+ enabled: z.boolean().default(true),
73
+ });
74
+
75
+ export const spacerBlock = z.object({
76
+ type: z.literal('spacer'),
77
+ enabled: z.boolean().default(true),
78
+ size: z.enum(['sm', 'md', 'lg']).default('md'),
79
+ });
80
+
81
+ export const badgeBlock = z.object({
82
+ type: z.literal('badge'),
83
+ enabled: z.boolean().default(true),
84
+ text: z.string(),
85
+ tone: z.enum(['neutral', 'accent']).default('accent'),
86
+ });
87
+
88
+ export const statBlock = z.object({
89
+ type: z.literal('stat'),
90
+ enabled: z.boolean().default(true),
91
+ value: z.string(),
92
+ label: z.string(),
93
+ });
94
+
95
+ export const listBlock = z.object({
96
+ type: z.literal('list'),
97
+ enabled: z.boolean().default(true),
98
+ /** bullet (disc) · number (ordered) · check (ticked, uses the check icon). */
99
+ style: z.enum(['bullet', 'number', 'check']).default('bullet'),
100
+ items: z.array(z.string()).default([]),
101
+ });
102
+
103
+ /* ── Level 2 — Recipes (curated, opinionated compositions of primitives) ─────
104
+ * A recipe is a first-class block in the same union, but it renders a *designed*
105
+ * layout (composing primitives internally) rather than a bare element. Locked
106
+ * components so quality can't drift (BLOCK-ARCHITECTURE §6). */
107
+ export const heroBlock = z.object({
108
+ type: z.literal('hero'),
109
+ enabled: z.boolean().default(true),
110
+ eyebrow: z.string().optional(),
111
+ title: z.string(),
112
+ subtitle: z.string().optional(),
113
+ cta: z.object({ label: z.string(), href: z.string() }).optional(),
114
+ image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
115
+ /** Full-bleed background media for the `media` layout: an image and/or a
116
+ * looping muted video (video wins when both are set). `overlay` (0–100) is
117
+ * the darkening scrim so text stays legible; `poster` is the video's still. */
118
+ media: z
119
+ .object({
120
+ image: z.string().optional(),
121
+ video: z.string().optional(),
122
+ poster: z.string().optional(),
123
+ overlay: z.number().min(0).max(100).default(45),
124
+ })
125
+ .optional(),
126
+ layout: z.enum(['centered', 'split', 'media']).default('centered'),
127
+ /** Ambient background for centered/split heroes: `mesh` = a soft, slowly
128
+ * drifting brand gradient behind the text (static under reduced-motion). */
129
+ background: z.enum(['none', 'mesh']).default('none'),
130
+ });
131
+
132
+ export const featureCardsBlock = z.object({
133
+ type: z.literal('featureCards'),
134
+ enabled: z.boolean().default(true),
135
+ title: z.string().optional(),
136
+ intro: z.string().optional(),
137
+ columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
138
+ cards: z
139
+ .array(
140
+ z.object({
141
+ icon: z.string().optional(),
142
+ title: z.string(),
143
+ text: z.string().optional(),
144
+ href: z.string().optional(),
145
+ })
146
+ )
147
+ .default([]),
148
+ });
149
+
150
+ export const ctaBlock = z.object({
151
+ type: z.literal('cta'),
152
+ enabled: z.boolean().default(true),
153
+ title: z.string(),
154
+ text: z.string().optional(),
155
+ cta: z.object({ label: z.string(), href: z.string() }),
156
+ background: z.enum(['muted', 'accent', 'mesh']).default('accent'),
157
+ });
158
+
159
+ export const faqBlock = z.object({
160
+ type: z.literal('faq'),
161
+ enabled: z.boolean().default(true),
162
+ title: z.string().optional(),
163
+ items: z.array(z.object({ question: z.string(), answer: z.string() })).default([]),
164
+ });
165
+
166
+ export const galleryBlock = z.object({
167
+ type: z.literal('gallery'),
168
+ enabled: z.boolean().default(true),
169
+ columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
170
+ images: z
171
+ .array(z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional() }))
172
+ .default([]),
173
+ });
174
+
175
+ export const testimonialBlock = z.object({
176
+ type: z.literal('testimonial'),
177
+ enabled: z.boolean().default(true),
178
+ quote: z.string(),
179
+ author: z.string(),
180
+ role: z.string().optional(),
181
+ avatar: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
182
+ });
183
+
184
+ /** A contact form recipe. `fields` is generic (any labelled input list) so the
185
+ * same recipe serves a contact page, an enquiry form, a sign-up, etc. `action`
186
+ * is optional: the core ships no backend, so with none it renders a standard
187
+ * semantic form a client later points at their own endpoint or a form service. */
188
+ export const contactFormBlock = z.object({
189
+ type: z.literal('contactForm'),
190
+ enabled: z.boolean().default(true),
191
+ title: z.string().optional(),
192
+ intro: z.string().optional(),
193
+ /** Form target. Omit for a non-wired form (e.g. the showcase demo). */
194
+ action: z.string().optional(),
195
+ method: z.enum(['post', 'get']).default('post'),
196
+ submitLabel: z.string().default('Send message'),
197
+ /** Small print under the form (a privacy line, a demo notice, …). */
198
+ note: z.string().optional(),
199
+ fields: z
200
+ .array(
201
+ z.object({
202
+ name: z.string(),
203
+ label: z.string(),
204
+ type: z.enum(['text', 'email', 'tel', 'textarea']).default('text'),
205
+ required: z.boolean().default(false),
206
+ placeholder: z.string().optional(),
207
+ })
208
+ )
209
+ .default([
210
+ { name: 'name', label: 'Your name', type: 'text', required: true },
211
+ { name: 'email', label: 'Email', type: 'email', required: true },
212
+ { name: 'message', label: 'Message', type: 'textarea', required: true },
213
+ ]),
214
+ });
215
+
216
+ /** Accordion — a generic expand/collapse list (title + body). Domain-free
217
+ * sibling of `faq` (which carries question/answer semantics). */
218
+ export const accordionBlock = z.object({
219
+ type: z.literal('accordion'),
220
+ enabled: z.boolean().default(true),
221
+ title: z.string().optional(),
222
+ items: z.array(z.object({ heading: z.string(), body: z.string() })).default([]),
223
+ });
224
+
225
+ /** Tiles — a curated card grid with five looks (outline / elevated / filled /
226
+ * gradient / image). The look is chosen per-block via `variant`; radius, stroke
227
+ * and elevation come from the global surface tokens, so tiles stay consistent
228
+ * with every other card on the page. Each tile can link (the whole tile is the
229
+ * target) and carry an icon, eyebrow, title, text and (for `image`) a photo. */
230
+ export const tilesBlock = z.object({
231
+ type: z.literal('tiles'),
232
+ enabled: z.boolean().default(true),
233
+ title: z.string().optional(),
234
+ intro: z.string().optional(),
235
+ columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
236
+ variant: z.enum(['outline', 'elevated', 'filled', 'gradient', 'image']).default('elevated'),
237
+ /** Opt in to a pointer-following spotlight glow on hover (a small JS enhancement). */
238
+ spotlight: z.boolean().default(false),
239
+ tiles: z
240
+ .array(
241
+ z.object({
242
+ icon: z.string().optional(),
243
+ eyebrow: z.string().optional(),
244
+ title: z.string(),
245
+ text: z.string().optional(),
246
+ href: z.string().optional(),
247
+ image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
248
+ })
249
+ )
250
+ .default([]),
251
+ });
252
+
253
+ /** Banner — a full-bleed feature band: a fixed/parallax background image with a
254
+ * legibility scrim and a floating "shaded" content card (eyebrow + heading +
255
+ * text + optional CTA) drifting over it. `align` places the card; `parallax`
256
+ * pins the image so it slides under the content as you scroll (auto-disabled on
257
+ * touch and under reduced-motion). The showpiece "not-dull" effect. */
258
+ export const bannerBlock = z.object({
259
+ type: z.literal('banner'),
260
+ enabled: z.boolean().default(true),
261
+ image: z.string(),
262
+ eyebrow: z.string().optional(),
263
+ title: z.string(),
264
+ text: z.string().optional(),
265
+ cta: z.object({ label: z.string(), href: z.string() }).optional(),
266
+ overlay: z.number().min(0).max(100).default(50),
267
+ align: z.enum(['start', 'center', 'end']).default('start'),
268
+ parallax: z.boolean().default(true),
269
+ });
270
+
271
+ /** Stats — a band of headline numbers (value + label + optional note), over an
272
+ * optional heading/intro and a chosen background. Composes the Stat primitive. */
273
+ export const statsBlock = z.object({
274
+ type: z.literal('stats'),
275
+ enabled: z.boolean().default(true),
276
+ title: z.string().optional(),
277
+ intro: z.string().optional(),
278
+ background: z.enum(['none', 'muted', 'accent']).default('muted'),
279
+ columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
280
+ items: z.array(z.object({ value: z.string(), label: z.string(), description: z.string().optional() })).default([]),
281
+ });
282
+
283
+ /** Steps — a numbered process (how-it-works). `horizontal` shows connected cards
284
+ * in a row; `vertical` a stacked timeline. Each step: title + optional text/icon. */
285
+ export const stepsBlock = z.object({
286
+ type: z.literal('steps'),
287
+ enabled: z.boolean().default(true),
288
+ title: z.string().optional(),
289
+ intro: z.string().optional(),
290
+ layout: z.enum(['horizontal', 'vertical']).default('horizontal'),
291
+ items: z.array(z.object({ title: z.string(), text: z.string().optional(), icon: z.string().optional() })).default([]),
292
+ });
293
+
294
+ /** Pricing — a row of plan cards (name, price, period, feature list, CTA). One
295
+ * plan may be `featured` (highlighted). */
296
+ export const pricingBlock = z.object({
297
+ type: z.literal('pricing'),
298
+ enabled: z.boolean().default(true),
299
+ title: z.string().optional(),
300
+ intro: z.string().optional(),
301
+ plans: z
302
+ .array(
303
+ z.object({
304
+ name: z.string(),
305
+ price: z.string(),
306
+ period: z.string().optional(),
307
+ description: z.string().optional(),
308
+ features: z.array(z.string()).default([]),
309
+ cta: z.object({ label: z.string(), href: z.string() }).optional(),
310
+ featured: z.boolean().default(false),
311
+ })
312
+ )
313
+ .default([]),
314
+ });
315
+
316
+ /** Logos — a "trusted by" wall of images; muted by default, colour on hover. */
317
+ export const logosBlock = z.object({
318
+ type: z.literal('logos'),
319
+ enabled: z.boolean().default(true),
320
+ title: z.string().optional(),
321
+ items: z.array(z.object({ src: z.string(), alt: z.string().default(''), href: z.string().optional() })).default([]),
322
+ });
323
+
324
+ /** Tabs — labelled panels; one open at a time. Progressive: the first panel is
325
+ * visible with no JS; a small script wires selection + ARIA. */
326
+ export const tabsBlock = z.object({
327
+ type: z.literal('tabs'),
328
+ enabled: z.boolean().default(true),
329
+ title: z.string().optional(),
330
+ items: z
331
+ .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() }))
332
+ .default([]),
333
+ });
334
+
335
+ /** Announcement — a thin full-width bar (message + optional link), optionally
336
+ * dismissible. Dismissal is remembered per `id` in localStorage. */
337
+ export const announcementBlock = z.object({
338
+ type: z.literal('announcement'),
339
+ enabled: z.boolean().default(true),
340
+ text: z.string(),
341
+ link: z.object({ label: z.string(), href: z.string() }).optional(),
342
+ tone: z.enum(['accent', 'neutral']).default('accent'),
343
+ dismissible: z.boolean().default(true),
344
+ /** localStorage key suffix so a dismissed bar stays dismissed. */
345
+ id: z.string().default('default'),
346
+ });
347
+
348
+ /** Bento — an asymmetric (mixed-span) grid of cards. Each item picks a `size`
349
+ * (sm / wide / tall / lg) and a `variant` look; the "living moodboard" layout. */
350
+ export const bentoBlock = z.object({
351
+ type: z.literal('bento'),
352
+ enabled: z.boolean().default(true),
353
+ title: z.string().optional(),
354
+ intro: z.string().optional(),
355
+ items: z
356
+ .array(
357
+ z.object({
358
+ size: z.enum(['sm', 'wide', 'tall', 'lg']).default('sm'),
359
+ variant: z.enum(['surface', 'filled', 'gradient', 'image']).default('surface'),
360
+ icon: z.string().optional(),
361
+ eyebrow: z.string().optional(),
362
+ title: z.string(),
363
+ text: z.string().optional(),
364
+ href: z.string().optional(),
365
+ image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
366
+ })
367
+ )
368
+ .default([]),
369
+ });
370
+
371
+ /** Marquee — an infinite horizontal scroll of short phrases (a feature strip).
372
+ * Pure-CSS transform loop (GPU); pauses on hover; static wrap under
373
+ * reduced-motion. Decorative, so the moving track is aria-hidden. */
374
+ export const marqueeBlock = z.object({
375
+ type: z.literal('marquee'),
376
+ enabled: z.boolean().default(true),
377
+ items: z.array(z.string()).default([]),
378
+ speed: z.enum(['slow', 'normal', 'fast']).default('normal'),
379
+ });
380
+
381
+ export type RecipeBlock =
382
+ | z.infer<typeof heroBlock>
383
+ | z.infer<typeof featureCardsBlock>
384
+ | z.infer<typeof ctaBlock>
385
+ | z.infer<typeof faqBlock>
386
+ | z.infer<typeof galleryBlock>
387
+ | z.infer<typeof testimonialBlock>
388
+ | z.infer<typeof contactFormBlock>
389
+ | z.infer<typeof accordionBlock>
390
+ | z.infer<typeof tilesBlock>
391
+ | z.infer<typeof bannerBlock>
392
+ | z.infer<typeof statsBlock>
393
+ | z.infer<typeof stepsBlock>
394
+ | z.infer<typeof pricingBlock>
395
+ | z.infer<typeof logosBlock>
396
+ | z.infer<typeof tabsBlock>
397
+ | z.infer<typeof announcementBlock>
398
+ | z.infer<typeof bentoBlock>
399
+ | z.infer<typeof marqueeBlock>;
400
+
401
+ /* ── Level-1 layout / container primitives ─────────────────────────────────
402
+ * These hold child blocks, so the schema is **recursive** (a container's
403
+ * `blocks` field is the same block union). TypeScript can't infer a type through
404
+ * `z.lazy`, so the recursive `Block` type is declared explicitly and the union
405
+ * is cast to it. Containers are how primitives compose into layouts (and, later,
406
+ * recipes). */
407
+ export interface SectionBlock {
408
+ type: 'section';
409
+ enabled: boolean;
410
+ background: 'none' | 'muted' | 'accent';
411
+ width: 'normal' | 'wide' | 'full';
412
+ blocks: Block[];
413
+ }
414
+ export interface GridBlock {
415
+ type: 'grid';
416
+ enabled: boolean;
417
+ columns: 2 | 3 | 4;
418
+ gap: 'sm' | 'md' | 'lg';
419
+ blocks: Block[];
420
+ }
421
+ export interface StackBlock {
422
+ type: 'stack';
423
+ enabled: boolean;
424
+ gap: 'sm' | 'md' | 'lg';
425
+ blocks: Block[];
426
+ }
427
+
428
+ export type LeafBlock =
429
+ | z.infer<typeof headingBlock>
430
+ | z.infer<typeof proseBlock>
431
+ | z.infer<typeof imageBlock>
432
+ | z.infer<typeof buttonBlock>
433
+ | z.infer<typeof quoteBlock>
434
+ | z.infer<typeof dividerBlock>
435
+ | z.infer<typeof spacerBlock>
436
+ | z.infer<typeof badgeBlock>
437
+ | z.infer<typeof statBlock>
438
+ | z.infer<typeof listBlock>;
439
+
440
+ /** The full recursive block type: leaf primitives + layout containers. */
441
+ export type Block = LeafBlock | RecipeBlock | SectionBlock | GridBlock | StackBlock;
442
+ export type BlockType = Block['type'];
443
+
444
+ export const sectionBlock = z.object({
445
+ type: z.literal('section'),
446
+ enabled: z.boolean().default(true),
447
+ background: z.enum(['none', 'muted', 'accent']).default('none'),
448
+ width: z.enum(['normal', 'wide', 'full']).default('normal'),
449
+ blocks: z.lazy(() => z.array(blockSchema)).default([]),
450
+ });
451
+
452
+ export const gridBlock = z.object({
453
+ type: z.literal('grid'),
454
+ enabled: z.boolean().default(true),
455
+ columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(2),
456
+ gap: z.enum(['sm', 'md', 'lg']).default('md'),
457
+ blocks: z.lazy(() => z.array(blockSchema)).default([]),
458
+ });
459
+
460
+ export const stackBlock = z.object({
461
+ type: z.literal('stack'),
462
+ enabled: z.boolean().default(true),
463
+ gap: z.enum(['sm', 'md', 'lg']).default('md'),
464
+ blocks: z.lazy(() => z.array(blockSchema)).default([]),
465
+ });
466
+
467
+ /** The block palette: leaf primitives + layout containers. A discriminated union
468
+ * on `type` — the AI's + CMS's contract and the renderer's registry key. Adding
469
+ * a member without a matching BlockRenderer registry entry is a TypeScript error
470
+ * (the exhaustiveness guard against a silent-undefined render crash). Cast to
471
+ * the recursive `Block` type because `z.lazy` fields can't be inferred. */
472
+ export const blockSchema = z.discriminatedUnion('type', [
473
+ headingBlock,
474
+ proseBlock,
475
+ imageBlock,
476
+ buttonBlock,
477
+ quoteBlock,
478
+ dividerBlock,
479
+ spacerBlock,
480
+ badgeBlock,
481
+ statBlock,
482
+ listBlock,
483
+ heroBlock,
484
+ featureCardsBlock,
485
+ ctaBlock,
486
+ faqBlock,
487
+ galleryBlock,
488
+ testimonialBlock,
489
+ contactFormBlock,
490
+ accordionBlock,
491
+ tilesBlock,
492
+ bannerBlock,
493
+ statsBlock,
494
+ stepsBlock,
495
+ pricingBlock,
496
+ logosBlock,
497
+ tabsBlock,
498
+ announcementBlock,
499
+ bentoBlock,
500
+ marqueeBlock,
501
+ sectionBlock,
502
+ gridBlock,
503
+ stackBlock,
504
+ ]) as unknown as z.ZodType<Block>;
505
+
506
+ /** A page = metadata + an ordered list of blocks. Stored as JSON in the client
507
+ * repo; the AI composes it, a human verifies. This is also the schema of the
508
+ * `pages` collection that the client's `[...slug].astro` catch-all routes over,
509
+ * so one page = one JSON file (its filename is the URL slug). */
510
+ export const pageSchema = z.object({
511
+ title: z.string(),
512
+ description: z.string().optional(),
513
+ /** Preloaded as the LCP image (passed to the layout) when set. */
514
+ heroImage: z.string().optional(),
515
+ /**
516
+ * Provenance — the ferst-library template this page was instantiated from.
517
+ * The triangular model (docs/BLOCK-ARCHITECTURE.md §5): lego box → a template
518
+ * stored in the library → each client page links a `templateId`. The client
519
+ * repo never depends on the library; this is just a design-time back-reference
520
+ * so a template's lineage/variants stay traceable platform-side. Optional — a
521
+ * bespoke page may have none.
522
+ */
523
+ templateId: z.string().optional(),
524
+ /** Keep the JSON in the repo but exclude it from the build. */
525
+ draft: z.boolean().default(false),
526
+ blocks: z.array(blockSchema).default([]),
527
+ });
528
+ export type Page = z.infer<typeof pageSchema>;
529
+
530
+ /** Every block type id, derived from the union so it stays in sync — used for
531
+ * gallery coverage, CMS config generation, docs, etc. */
532
+ export const blockTypes = (
533
+ blockSchema as unknown as { options: ReadonlyArray<{ shape: { type: { value: BlockType } } }> }
534
+ ).options.map((o) => o.shape.type.value);
@@ -0,0 +1,40 @@
1
+ // The calendar capability contract — white-room, block-system-era core (no CTK
2
+ // code). A parish/club/venue embeds an external calendar (typically Google
3
+ // Calendar) for its schedule (Mass times, events). Embedding an external calendar
4
+ // sends the visitor's device data (e.g. IP) to the provider, so it is ALWAYS
5
+ // consent-gated (CalendarEmbed.astro) — consistent with the site's no-silent-
6
+ // third-party-calls stance. Ubiquitous/replicable → public (Tier 1a).
7
+ import { z } from 'zod';
8
+
9
+ /** Calendar settings — one focused CMS pane (a singleton per site). */
10
+ export const calendarSettingsSchema = z.object({
11
+ /** Page/section heading. */
12
+ title: z.string().default('Calendar'),
13
+ /** Optional intro copy shown above the calendar. */
14
+ intro: z.string().default(''),
15
+ /** The embed iframe src (e.g. a Google Calendar embed URL). Blank → the page
16
+ * shows a friendly "not set up yet" note instead of an empty frame. */
17
+ embedUrl: z.string().default(''),
18
+ /** Link to open the full calendar in a new tab (a no-embed fallback). */
19
+ publicUrl: z.string().default(''),
20
+ /** iframe height in px. */
21
+ height: z.number().int().min(200).max(2000).default(600),
22
+ /** Provider name, shown in the consent notice ("shared with …"). */
23
+ provider: z.string().default('Google Calendar'),
24
+ });
25
+
26
+ export type CalendarSettings = z.infer<typeof calendarSettingsSchema>;
27
+
28
+ /**
29
+ * Whether an embed URL is safe to load into an iframe. The value is trusted repo /
30
+ * CMS data, but we still require `https:` so a stray `javascript:` / `http:` value
31
+ * can never reach the frame — defence in depth. Pure + testable.
32
+ */
33
+ export function isSafeEmbedUrl(url: string): boolean {
34
+ if (!url) return false;
35
+ try {
36
+ return new URL(url).protocol === 'https:';
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
@@ -1,40 +1,51 @@
1
1
  // Collection definitions — the content *contract*. Core owns this; each consumer
2
2
  // re-exports it from its own `src/content/config.ts` (Astro requires the config
3
3
  // to live in the consumer). The content files themselves stay in the consumer.
4
+ //
5
+ // Page CONTENT is block JSON validated by `content/blocks.ts` (pageSchema) at the
6
+ // point of use, not a per-page-type collection. These collections are just the
7
+ // site-settings panes the chrome reads.
4
8
  import { defineCollection } from 'astro:content';
5
9
  import {
6
- homepageSchema,
7
- postSchema,
8
10
  siteSettingsSchema,
9
11
  logoSettingsSchema,
10
- postsSettingsSchema,
11
- calendarSettingsSchema,
12
12
  navbarSettingsSchema,
13
13
  footerSettingsSchema,
14
14
  themeSettingsSchema,
15
- tagSchema,
16
15
  } from './schemas';
16
+ import { pageSchema } from './blocks';
17
+ import { postSchema, tagSchema, postsSettingsSchema } from './posts';
18
+ import { calendarSettingsSchema } from './calendar';
17
19
 
18
- const homepage = defineCollection({ type: 'data', schema: homepageSchema });
19
- const posts = defineCollection({ type: 'content', schema: postSchema });
20
20
  const siteSettings = defineCollection({ type: 'data', schema: siteSettingsSchema });
21
21
  const logoSettings = defineCollection({ type: 'data', schema: logoSettingsSchema });
22
- const postsSettings = defineCollection({ type: 'data', schema: postsSettingsSchema });
23
- const calendarSettings = defineCollection({ type: 'data', schema: calendarSettingsSchema });
24
22
  const navbarSettings = defineCollection({ type: 'data', schema: navbarSettingsSchema });
25
23
  const footerSettings = defineCollection({ type: 'data', schema: footerSettingsSchema });
26
24
  const themeSettings = defineCollection({ type: 'data', schema: themeSettingsSchema });
27
- const tags = defineCollection({ type: 'content', schema: tagSchema });
25
+
26
+ // Client pages as DATA: one JSON file of block content per page, its filename the
27
+ // URL slug. The client's `src/pages/[...slug].astro` catch-all routes over this
28
+ // (see lib/pages.ts) — so a client adds a page by adding a JSON file, no code.
29
+ const pages = defineCollection({ type: 'data', schema: pageSchema });
30
+
31
+ // The posts/blog capability (see content/posts.ts): markdown posts, a tags data
32
+ // collection (canonical label + description per tag), and a blog-settings pane.
33
+ const posts = defineCollection({ type: 'content', schema: postSchema });
34
+ const tags = defineCollection({ type: 'data', schema: tagSchema });
35
+ const postsSettings = defineCollection({ type: 'data', schema: postsSettingsSchema });
36
+
37
+ // The calendar capability (see content/calendar.ts): a consent-gated embed.
38
+ const calendarSettings = defineCollection({ type: 'data', schema: calendarSettingsSchema });
28
39
 
29
40
  export const collections = {
30
- posts,
31
- homepage,
32
41
  siteSettings,
33
42
  logoSettings,
34
- postsSettings,
35
- calendarSettings,
36
43
  navbarSettings,
37
44
  footerSettings,
38
45
  themeSettings,
46
+ pages,
47
+ posts,
39
48
  tags,
49
+ postsSettings,
50
+ calendarSettings,
40
51
  };