ferst-core 0.7.2 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/bin/ferst-update.mjs +33 -2
  2. package/cms/config.template.yml +214 -206
  3. package/components/BlockRenderer.astro +10 -0
  4. package/components/Pagination.astro +51 -0
  5. package/components/SiteFooter.astro +274 -270
  6. package/components/SiteHeader.astro +1243 -1183
  7. package/components/blocks/Heading.astro +51 -46
  8. package/components/blocks/Prose.astro +10 -2
  9. package/components/blocks/Quote.astro +40 -38
  10. package/components/recipes/Accordion.astro +91 -84
  11. package/components/recipes/Contacts.astro +139 -0
  12. package/components/recipes/Document.astro +157 -0
  13. package/components/recipes/FeatureCards.astro +112 -111
  14. package/components/recipes/Hero.astro +52 -4
  15. package/components/recipes/LinkRow.astro +91 -0
  16. package/components/recipes/MediaCards.astro +213 -212
  17. package/components/recipes/MediaText.astro +92 -0
  18. package/components/recipes/Notice.astro +90 -89
  19. package/components/recipes/Stats.astro +152 -145
  20. package/components/recipes/Table.astro +101 -97
  21. package/components/recipes/Tabs.astro +134 -133
  22. package/components/recipes/Tiles.astro +4 -2
  23. package/components/recipes/Timetable.astro +189 -0
  24. package/components/recipes/Video.astro +177 -157
  25. package/content/blocks.ts +113 -1
  26. package/content/cookies.ts +10 -0
  27. package/content/schemas.ts +167 -161
  28. package/examples/primitives.ts +2 -0
  29. package/examples/recipes.ts +7 -0
  30. package/integration.mjs +83 -64
  31. package/layouts/Base.astro +483 -475
  32. package/lib/documents.ts +68 -62
  33. package/lib/embed-consent.ts +78 -73
  34. package/lib/inline-markdown.ts +6 -0
  35. package/lib/links.ts +22 -0
  36. package/lib/paginate.ts +23 -0
  37. package/lib/posts.ts +20 -2
  38. package/lib/siteSettings.ts +61 -60
  39. package/lib/slug.ts +32 -0
  40. package/lib/table.ts +10 -0
  41. package/lib/timetable.ts +27 -0
  42. package/lib/video.ts +101 -75
  43. package/package.json +1 -1
  44. package/routes/cookie-policy.astro +27 -0
  45. package/routes/posts/index.astro +36 -31
  46. package/routes/posts/page/[page].astro +36 -0
  47. package/routes/posts/tag/[...tag].astro +41 -37
@@ -0,0 +1,189 @@
1
+ ---
2
+ /**
3
+ * Timetable — weekly times for one or more places: worship times, opening hours,
4
+ * sessions, classes. `cards` (default): a card per place with its days and times, days
5
+ * with no times left out, notes (inline markdown) under each. `table`: places side by
6
+ * side, days down the side; a day nobody uses is dropped. Logic in lib/timetable.
7
+ */
8
+ import Heading from '../blocks/Heading.astro';
9
+ import { daysWithTimes, timetableRows } from '../../lib/timetable';
10
+ import { inlineMarkdown } from '../../lib/inline-markdown';
11
+
12
+ interface Place {
13
+ name: string;
14
+ address?: string;
15
+ href?: string;
16
+ schedule: Array<{ day: string; times: string[] }>;
17
+ notes?: string;
18
+ }
19
+ interface Props {
20
+ title?: string;
21
+ intro?: string;
22
+ layout?: 'cards' | 'table';
23
+ places: Place[];
24
+ }
25
+ const { title, intro, layout = 'cards', places = [] } = Astro.props;
26
+ const rows = layout === 'table' ? timetableRows(places) : [];
27
+ const cols = Math.min(places.length, 3);
28
+ ---
29
+
30
+ <section class="b-tt" data-layout={layout}>
31
+ {(title || intro) && (
32
+ <div class="b-tt__head">
33
+ {title && <Heading text={title} level={2} />}
34
+ {intro && <p class="b-tt__intro" set:html={inlineMarkdown(intro)} />}
35
+ </div>
36
+ )}
37
+
38
+ {layout === 'table' ? (
39
+ <div class="b-tt__scroll">
40
+ <table>
41
+ <thead>
42
+ <tr>
43
+ <td></td>
44
+ {places.map((p) => <th scope="col">{p.name}</th>)}
45
+ </tr>
46
+ </thead>
47
+ <tbody>
48
+ {rows.map((r) => (
49
+ <tr>
50
+ <th scope="row">{r.day}</th>
51
+ {r.cells.map((times) => <td>{times.length ? times.join(', ') : <span class="b-tt__none" aria-label="none">—</span>}</td>)}
52
+ </tr>
53
+ ))}
54
+ </tbody>
55
+ </table>
56
+ </div>
57
+ ) : (
58
+ <div class="b-tt__grid" data-cols={cols}>
59
+ {places.map((p) => (
60
+ <article class="b-tt__place">
61
+ <h3 class="b-tt__name">{p.href ? <a href={p.href}>{p.name}</a> : p.name}</h3>
62
+ {p.address && <p class="b-tt__address">{p.address}</p>}
63
+ <dl class="b-tt__days">
64
+ {daysWithTimes(p.schedule).map((d) => (
65
+ <div class="b-tt__day">
66
+ <dt>{d.day}</dt>
67
+ <dd>{d.times.join(' · ')}</dd>
68
+ </div>
69
+ ))}
70
+ </dl>
71
+ {p.notes && <p class="b-tt__notes" set:html={inlineMarkdown(p.notes)} />}
72
+ </article>
73
+ ))}
74
+ </div>
75
+ )}
76
+ </section>
77
+
78
+ <style>
79
+ .b-tt {
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: 1.5rem;
83
+ width: 100%;
84
+ max-width: 72rem;
85
+ }
86
+ .b-tt__head {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: 0.5rem;
90
+ max-width: var(--max);
91
+ }
92
+ .b-tt__intro {
93
+ margin: 0;
94
+ color: var(--muted);
95
+ line-height: 1.6;
96
+ }
97
+ .b-tt__grid {
98
+ display: grid;
99
+ gap: 1.25rem;
100
+ grid-template-columns: 1fr;
101
+ }
102
+ @media (min-width: 48em) {
103
+ .b-tt__grid[data-cols='2'],
104
+ .b-tt__grid[data-cols='3'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
105
+ }
106
+ @media (min-width: 64em) {
107
+ .b-tt__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
108
+ }
109
+ .b-tt__grid[data-cols='1'] {
110
+ max-width: 32rem;
111
+ }
112
+ .b-tt__place {
113
+ display: flex;
114
+ flex-direction: column;
115
+ gap: 0.75rem;
116
+ padding: 1.25rem 1.4rem;
117
+ border: var(--card-border);
118
+ border-radius: var(--radius);
119
+ background: var(--bg-surface);
120
+ }
121
+ .b-tt__name {
122
+ margin: 0;
123
+ font-size: 1.2rem;
124
+ }
125
+ .b-tt__name a {
126
+ color: inherit;
127
+ }
128
+ .b-tt__address {
129
+ margin: -0.4rem 0 0;
130
+ font-size: 0.9rem;
131
+ color: var(--muted);
132
+ }
133
+ .b-tt__days {
134
+ margin: 0;
135
+ display: grid;
136
+ }
137
+ .b-tt__day {
138
+ display: grid;
139
+ grid-template-columns: minmax(6.5rem, auto) 1fr;
140
+ gap: 0.75rem;
141
+ padding: 0.5rem 0;
142
+ border-top: var(--card-border);
143
+ }
144
+ .b-tt__day:first-child {
145
+ border-top: 0;
146
+ }
147
+ .b-tt__day dt {
148
+ font-weight: 600;
149
+ }
150
+ .b-tt__day dd {
151
+ margin: 0;
152
+ font-variant-numeric: tabular-nums;
153
+ }
154
+ .b-tt__notes {
155
+ margin: 0;
156
+ font-size: 0.9375rem;
157
+ color: var(--muted);
158
+ line-height: 1.6;
159
+ }
160
+ .b-tt__notes :global(a) {
161
+ color: var(--accent);
162
+ text-decoration: underline;
163
+ text-underline-offset: 0.15em;
164
+ }
165
+ .b-tt__scroll {
166
+ overflow-x: auto;
167
+ }
168
+ .b-tt table {
169
+ border-collapse: collapse;
170
+ min-width: 100%;
171
+ font-variant-numeric: tabular-nums;
172
+ }
173
+ .b-tt th,
174
+ .b-tt td {
175
+ padding: 0.7rem 1rem;
176
+ text-align: start;
177
+ border-bottom: var(--card-border);
178
+ }
179
+ .b-tt thead th {
180
+ font-size: 0.875rem;
181
+ color: var(--muted);
182
+ }
183
+ .b-tt tbody th {
184
+ font-weight: 600;
185
+ }
186
+ .b-tt__none {
187
+ color: var(--muted);
188
+ }
189
+ </style>
@@ -1,157 +1,177 @@
1
- ---
2
- /**
3
- * Video — a consent-gated YouTube / Vimeo player (including a channel's live stream),
4
- * the sibling of the map embed. The editor pastes the ordinary link; lib/video turns it
5
- * into a privacy-friendly embed (youtube-nocookie, Vimeo dnt). Nothing loads from the
6
- * provider until the visitor agrees, then the player fills a fixed-ratio frame. An
7
- * unrecognised link degrades to a plain "watch" link.
8
- */
9
- import { videoEmbed } from '../../lib/video';
10
-
11
- interface Props {
12
- title?: string;
13
- caption?: string;
14
- url: string;
15
- ratio?: '16/9' | '4/3' | '1/1' | '9/16';
16
- }
17
- const { title = '', caption = '', url, ratio = '16/9' } = Astro.props;
18
- const v = videoEmbed(url);
19
- const safeLink = /^https:\/\//i.test(url) ? url : undefined;
20
- ---
21
-
22
- <section class="b-video">
23
- {title && <h2 class="b-video__title">{title}</h2>}
24
- {v ? (
25
- <figure class="b-video__figure">
26
- <div class="b-video__embed" style={`aspect-ratio: ${ratio}`} data-embed-url={v.embedUrl} data-title={title || `Video on ${v.provider}`}>
27
- <div class="b-video__consent">
28
- <p class="b-video__consent-text">
29
- This video is provided by <strong>{v.provider}</strong>. Playing it here shares your
30
- device's data (such as your IP address) with {v.provider}.
31
- </p>
32
- <div class="b-video__actions">
33
- <button type="button" class="b-video__load">Show video</button>
34
- <a class="b-video__link" href={v.watchUrl} target="_blank" rel="noopener">Watch on {v.provider} instead</a>
35
- </div>
36
- </div>
37
- <div class="b-video__frame"></div>
38
- </div>
39
- {caption && <figcaption class="b-video__caption">{caption}</figcaption>}
40
- </figure>
41
- ) : (
42
- <p class="b-video__note">
43
- {safeLink ? <a href={safeLink} target="_blank" rel="noopener">Watch the video</a> : "The video isn't set up yet."}
44
- </p>
45
- )}
46
- </section>
47
-
48
- <script>
49
- import { initEmbedConsent, makeEmbedFrame } from '../../lib/embed-consent';
50
-
51
- initEmbedConsent({
52
- root: '.b-video__embed',
53
- frame: '.b-video__frame',
54
- consent: '.b-video__consent',
55
- button: '.b-video__load',
56
- buildFrame: (el, url) => {
57
- const f = makeEmbedFrame(url, { title: el.dataset.title || 'Video', height: '100%' });
58
- f.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen';
59
- f.allowFullscreen = true;
60
- return f;
61
- },
62
- });
63
- </script>
64
-
65
- <style>
66
- .b-video {
67
- display: flex;
68
- flex-direction: column;
69
- gap: 1rem;
70
- max-width: 60rem;
71
- margin-inline: auto;
72
- padding-inline: 1rem;
73
- width: 100%;
74
- }
75
- .b-video__title {
76
- margin: 0;
77
- }
78
- .b-video__figure {
79
- margin: 0;
80
- display: flex;
81
- flex-direction: column;
82
- gap: 0.6rem;
83
- }
84
- .b-video__embed {
85
- position: relative;
86
- width: 100%;
87
- max-width: 100%;
88
- border-radius: var(--radius);
89
- overflow: hidden;
90
- background: var(--bg-section);
91
- border: var(--card-border);
92
- }
93
- .b-video__frame {
94
- position: absolute;
95
- inset: 0;
96
- }
97
- .b-video__frame :global(iframe) {
98
- display: block;
99
- }
100
- .b-video__consent {
101
- position: absolute;
102
- inset: 0;
103
- z-index: 1;
104
- display: flex;
105
- flex-direction: column;
106
- gap: 1rem;
107
- align-items: center;
108
- justify-content: center;
109
- padding: 1.5rem;
110
- text-align: center;
111
- }
112
- .b-video__consent[hidden] {
113
- display: none;
114
- }
115
- .b-video__consent-text {
116
- margin: 0;
117
- max-width: 46ch;
118
- color: var(--muted);
119
- line-height: 1.6;
120
- }
121
- .b-video__actions {
122
- display: flex;
123
- flex-wrap: wrap;
124
- gap: 0.75rem 1.25rem;
125
- align-items: center;
126
- justify-content: center;
127
- }
128
- .b-video__load {
129
- font: inherit;
130
- font-family: var(--font-heading);
131
- font-weight: 600;
132
- padding: 0.6rem 1.4rem;
133
- border: none;
134
- border-radius: var(--radius-sm);
135
- background: var(--gold);
136
- color: var(--text-light);
137
- cursor: pointer;
138
- }
139
- .b-video__load:hover {
140
- filter: brightness(0.94);
141
- }
142
- .b-video__load:focus-visible {
143
- outline: 2px solid var(--gold);
144
- outline-offset: 2px;
145
- }
146
- .b-video__link,
147
- .b-video__note a {
148
- color: var(--gold);
149
- font-weight: 600;
150
- }
151
- .b-video__caption,
152
- .b-video__note {
153
- margin: 0;
154
- color: var(--muted);
155
- font-size: 0.9375rem;
156
- }
157
- </style>
1
+ ---
2
+ /**
3
+ * Video — a consent-gated YouTube / Vimeo player (including a channel's live stream),
4
+ * the sibling of the map embed. The editor pastes the ordinary link; lib/video turns it
5
+ * into a privacy-friendly embed (youtube-nocookie, Vimeo dnt). Nothing loads from the
6
+ * provider until the visitor agrees, then the player fills a fixed-ratio frame. An
7
+ * unrecognised link degrades to a plain "watch" link.
8
+ */
9
+ import { videoEmbed } from '../../lib/video';
10
+ import { inlineMarkdown } from '../../lib/inline-markdown';
11
+
12
+ interface Props {
13
+ title?: string;
14
+ caption?: string;
15
+ note?: string;
16
+ url: string;
17
+ ratio?: '16/9' | '4/3' | '1/1' | '9/16';
18
+ }
19
+ const { title = '', caption = '', note = '', url, ratio = '16/9' } = Astro.props;
20
+ const v = videoEmbed(url);
21
+ const safeLink = /^https:\/\//i.test(url) ? url : undefined;
22
+ ---
23
+
24
+ <section class="b-video" data-live={v?.live ? 'true' : undefined}>
25
+ {title && <h2 class="b-video__title">{title}</h2>}
26
+ {v ? (
27
+ <>
28
+ <figure class="b-video__figure">
29
+ <div class="b-video__embed" style={`aspect-ratio: ${ratio}`} data-embed-url={v.embedUrl} data-title={title || `Video on ${v.provider}`} data-on-demand={v.live ? 'true' : undefined}>
30
+ <div class="b-video__consent">
31
+ <p class="b-video__consent-text">
32
+ {v.live ? 'This live stream' : 'This video'} is provided by <strong>{v.provider}</strong>. Playing it here
33
+ shares your device's data (such as your IP address) with {v.provider}.
34
+ </p>
35
+ <div class="b-video__actions">
36
+ <button type="button" class="b-video__load">{v.live ? 'Show the live stream' : 'Show video'}</button>
37
+ </div>
38
+ </div>
39
+ <div class="b-video__frame"></div>
40
+ </div>
41
+ {caption && <figcaption class="b-video__caption">{caption}</figcaption>}
42
+ </figure>
43
+ {/* Always visible, before AND after the player loads: the way to the provider. */}
44
+ <div class="b-video__footer">
45
+ {note && <p class="b-video__note-text" set:html={inlineMarkdown(note)} />}
46
+ <a class="b-video__link" href={v.watchUrl} target="_blank" rel="noopener">Watch on {v.provider} &#8599;</a>
47
+ </div>
48
+ </>
49
+ ) : (
50
+ <p class="b-video__note">
51
+ {safeLink ? <a href={safeLink} target="_blank" rel="noopener">Watch the video</a> : "The video isn't set up yet."}
52
+ </p>
53
+ )}
54
+ </section>
55
+
56
+ <script>
57
+ import { initEmbedConsent, makeEmbedFrame } from '../../lib/embed-consent';
58
+
59
+ initEmbedConsent({
60
+ root: '.b-video__embed',
61
+ frame: '.b-video__frame',
62
+ consent: '.b-video__consent',
63
+ button: '.b-video__load',
64
+ buildFrame: (el, url) => {
65
+ const f = makeEmbedFrame(url, { title: el.dataset.title || 'Video', height: '100%' });
66
+ f.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen';
67
+ f.allowFullscreen = true;
68
+ return f;
69
+ },
70
+ });
71
+ </script>
72
+
73
+ <style>
74
+ .b-video {
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: 1rem;
78
+ max-width: 60rem;
79
+ margin-inline: auto;
80
+ padding-inline: 1rem;
81
+ width: 100%;
82
+ }
83
+ .b-video__title {
84
+ margin: 0;
85
+ }
86
+ .b-video__figure {
87
+ margin: 0;
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 0.6rem;
91
+ }
92
+ .b-video__embed {
93
+ position: relative;
94
+ width: 100%;
95
+ max-width: 100%;
96
+ border-radius: var(--radius);
97
+ overflow: hidden;
98
+ background: var(--bg-section);
99
+ border: var(--card-border);
100
+ }
101
+ .b-video__frame {
102
+ position: absolute;
103
+ inset: 0;
104
+ }
105
+ .b-video__frame :global(iframe) {
106
+ display: block;
107
+ }
108
+ .b-video__consent {
109
+ position: absolute;
110
+ inset: 0;
111
+ z-index: 1;
112
+ display: flex;
113
+ flex-direction: column;
114
+ gap: 1rem;
115
+ align-items: center;
116
+ justify-content: center;
117
+ padding: 1.5rem;
118
+ text-align: center;
119
+ }
120
+ .b-video__consent[hidden] {
121
+ display: none;
122
+ }
123
+ .b-video__consent-text {
124
+ margin: 0;
125
+ max-width: 46ch;
126
+ color: var(--muted);
127
+ line-height: 1.6;
128
+ }
129
+ .b-video__actions {
130
+ display: flex;
131
+ flex-wrap: wrap;
132
+ gap: 0.75rem 1.25rem;
133
+ align-items: center;
134
+ justify-content: center;
135
+ }
136
+ .b-video__load {
137
+ font: inherit;
138
+ font-family: var(--font-heading);
139
+ font-weight: 600;
140
+ padding: 0.6rem 1.4rem;
141
+ border: none;
142
+ border-radius: var(--radius-sm);
143
+ background: var(--gold);
144
+ color: var(--text-light);
145
+ cursor: pointer;
146
+ }
147
+ .b-video__load:hover {
148
+ filter: brightness(0.94);
149
+ }
150
+ .b-video__load:focus-visible {
151
+ outline: 2px solid var(--gold);
152
+ outline-offset: 2px;
153
+ }
154
+ .b-video__link,
155
+ .b-video__note a {
156
+ color: var(--gold);
157
+ font-weight: 600;
158
+ }
159
+ .b-video__footer {
160
+ display: flex;
161
+ flex-wrap: wrap;
162
+ align-items: baseline;
163
+ justify-content: space-between;
164
+ gap: 0.5rem 1.25rem;
165
+ }
166
+ .b-video__note-text {
167
+ margin: 0;
168
+ color: var(--fg);
169
+ line-height: 1.6;
170
+ }
171
+ .b-video__caption,
172
+ .b-video__note {
173
+ margin: 0;
174
+ color: var(--muted);
175
+ font-size: 0.9375rem;
176
+ }
177
+ </style>
package/content/blocks.ts CHANGED
@@ -33,6 +33,9 @@ export const proseBlock = z.object({
33
33
  /** Blank lines separate paragraphs; `- ` / `1. ` lines make lists; inline
34
34
  * `[links](href)` (incl. tel:/mailto:), `**bold**` and `*italic*` (lib/prose.ts). */
35
35
  text: z.string(),
36
+ /** `lead` = the page's introduction: larger and quieter than body text, for the
37
+ * paragraph straight under the hero. */
38
+ variant: z.enum(['body', 'lead']).default('body'),
36
39
  });
37
40
 
38
41
  export const imageBlock = z.object({
@@ -139,6 +142,9 @@ export const heroBlock = z.object({
139
142
  /** How much of the screen a `media` hero takes: `standard` (a band), `tall`,
140
143
  * or `screen` — the full first screen, edge to edge (e.g. a video opener). */
141
144
  size: z.enum(['standard', 'tall', 'screen']).default('standard'),
145
+ /** A `media` hero's background drifts more slowly than the page as it scrolls (any
146
+ * background: image, slides or video). Off on touch devices and under reduced motion. */
147
+ parallax: z.boolean().default(false),
142
148
  /** Ambient background for centered/split heroes: `mesh` = a soft, slowly
143
149
  * drifting brand gradient behind the text (static under reduced-motion). */
144
150
  background: z.enum(['none', 'mesh']).default('none'),
@@ -548,6 +554,8 @@ export const videoBlock = z.object({
548
554
  enabled: z.boolean().default(true),
549
555
  title: z.string().optional(),
550
556
  caption: z.string().optional(),
557
+ /** A short note under the player (inline markdown), e.g. a live stream's schedule. */
558
+ note: z.string().optional(),
551
559
  url: z.string(),
552
560
  ratio: z.enum(['16/9', '4/3', '1/1', '9/16']).default('16/9'),
553
561
  });
@@ -573,6 +581,100 @@ export const documentsBlock = z.object({
573
581
  .default([]),
574
582
  });
575
583
 
584
+ /** LinkRow — a titled row of contact or reference links as tappable pills (phone, email,
585
+ * web), each with the icon its link implies, plus an optional intro and footnote. For a
586
+ * contact who isn't worth a whole contact card (e.g. "Vocations: phone · email · site"). */
587
+ export const linkRowBlock = z.object({
588
+ type: z.literal('linkRow'),
589
+ enabled: z.boolean().default(true),
590
+ title: z.string().optional(),
591
+ intro: z.string().optional(),
592
+ links: z.array(z.object({ label: z.string(), href: z.string() })).min(1),
593
+ note: z.string().optional(),
594
+ });
595
+
596
+ /** Document — a policy or legal text as ONE block: sections numbered automatically, a
597
+ * contents list linking to each (sticky beside the text on wide screens), an optional
598
+ * "last updated" date (YYYY-MM-DD), and print-friendly. Section bodies take prose
599
+ * formatting; `items` add a list. Replaces dozens of hand-numbered headings + paragraphs. */
600
+ export const documentBlock = z.object({
601
+ type: z.literal('document'),
602
+ enabled: z.boolean().default(true),
603
+ intro: z.string().optional(),
604
+ updated: z.string().optional(),
605
+ numbered: z.boolean().default(true),
606
+ toc: z.boolean().default(true),
607
+ sections: z
608
+ .array(z.object({ title: z.string(), body: z.string().optional(), items: z.array(z.string()).optional() }))
609
+ .min(1),
610
+ });
611
+
612
+ /** Timetable — weekly times for one or more places (worship times, opening hours,
613
+ * sessions, classes). `cards`: a card per place, days with no times left out, notes
614
+ * (inline markdown) under each. `table`: places side by side, days down the side. */
615
+ export const timetableBlock = z.object({
616
+ type: z.literal('timetable'),
617
+ enabled: z.boolean().default(true),
618
+ title: z.string().optional(),
619
+ intro: z.string().optional(),
620
+ layout: z.enum(['cards', 'table']).default('cards'),
621
+ places: z
622
+ .array(
623
+ z.object({
624
+ name: z.string(),
625
+ address: z.string().optional(),
626
+ href: z.string().optional(),
627
+ schedule: z.array(z.object({ day: z.string(), times: z.array(z.string()).default([]) })).default([]),
628
+ notes: z.string().optional(),
629
+ })
630
+ )
631
+ .min(1),
632
+ });
633
+
634
+ /** Contacts — a compact directory of people or offices: role as the card title, phone and
635
+ * email as tap targets (email with an optional subject), address and hours, and a shared
636
+ * note per group (e.g. office hours) instead of repeating it on every card. */
637
+ export const contactsBlock = z.object({
638
+ type: z.literal('contacts'),
639
+ enabled: z.boolean().default(true),
640
+ title: z.string().optional(),
641
+ intro: z.string().optional(),
642
+ groups: z
643
+ .array(
644
+ z.object({
645
+ heading: z.string().optional(),
646
+ note: z.string().optional(),
647
+ contacts: z
648
+ .array(
649
+ z.object({
650
+ role: z.string(),
651
+ name: z.string().optional(),
652
+ phone: z.string().optional(),
653
+ email: z.string().optional(),
654
+ emailSubject: z.string().optional(),
655
+ address: z.string().optional(),
656
+ hours: z.string().optional(),
657
+ })
658
+ )
659
+ .min(1),
660
+ })
661
+ )
662
+ .min(1),
663
+ });
664
+
665
+ /** MediaText — an image beside its text (start or end), stacking on a phone. The text takes
666
+ * prose formatting; the image keeps its shape (`ratio` crops to a set shape); optional CTA. */
667
+ export const mediaTextBlock = z.object({
668
+ type: z.literal('mediaText'),
669
+ enabled: z.boolean().default(true),
670
+ title: z.string().optional(),
671
+ text: z.string(),
672
+ image: z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional() }),
673
+ imageSide: z.enum(['start', 'end']).default('end'),
674
+ ratio: z.enum(['auto', '1/1', '4/3', '3/2', '16/9']).default('auto'),
675
+ cta: z.object({ label: z.string(), href: z.string() }).optional(),
676
+ });
677
+
576
678
  export type RecipeBlock =
577
679
  | z.infer<typeof heroBlock>
578
680
  | z.infer<typeof featureCardsBlock>
@@ -600,7 +702,12 @@ export type RecipeBlock =
600
702
  | z.infer<typeof contactCardBlock>
601
703
  | z.infer<typeof locationsBlock>
602
704
  | z.infer<typeof videoBlock>
603
- | z.infer<typeof documentsBlock>;
705
+ | z.infer<typeof documentsBlock>
706
+ | z.infer<typeof linkRowBlock>
707
+ | z.infer<typeof documentBlock>
708
+ | z.infer<typeof timetableBlock>
709
+ | z.infer<typeof contactsBlock>
710
+ | z.infer<typeof mediaTextBlock>;
604
711
 
605
712
  /* ── Level-1 layout / container primitives ─────────────────────────────────
606
713
  * These hold child blocks, so the schema is **recursive** (a container's
@@ -713,6 +820,11 @@ export const blockSchema = z.discriminatedUnion('type', [
713
820
  locationsBlock,
714
821
  videoBlock,
715
822
  documentsBlock,
823
+ linkRowBlock,
824
+ documentBlock,
825
+ timetableBlock,
826
+ contactsBlock,
827
+ mediaTextBlock,
716
828
  sectionBlock,
717
829
  gridBlock,
718
830
  stackBlock,