ferst-core 0.7.0 → 0.7.4

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 (38) hide show
  1. package/components/BlockRenderer.astro +6 -0
  2. package/components/Pagination.astro +51 -0
  3. package/components/blocks/Heading.astro +47 -46
  4. package/components/blocks/Prose.astro +10 -2
  5. package/components/blocks/Quote.astro +40 -38
  6. package/components/recipes/Accordion.astro +91 -84
  7. package/components/recipes/ContactCard.astro +172 -170
  8. package/components/recipes/Document.astro +152 -0
  9. package/components/recipes/FeatureCards.astro +112 -111
  10. package/components/recipes/Hero.astro +120 -8
  11. package/components/recipes/LinkRow.astro +91 -0
  12. package/components/recipes/MediaCards.astro +213 -212
  13. package/components/recipes/Notice.astro +90 -89
  14. package/components/recipes/Stats.astro +152 -145
  15. package/components/recipes/Table.astro +98 -97
  16. package/components/recipes/Tabs.astro +134 -133
  17. package/components/recipes/Tiles.astro +287 -271
  18. package/components/recipes/Timetable.astro +189 -0
  19. package/components/recipes/Video.astro +177 -157
  20. package/content/blocks.ts +68 -2
  21. package/content/cookies.ts +10 -0
  22. package/examples/primitives.ts +2 -0
  23. package/examples/recipes.ts +42 -36
  24. package/integration.mjs +83 -64
  25. package/layouts/Base.astro +481 -475
  26. package/lib/documents.ts +68 -62
  27. package/lib/embed-consent.ts +78 -73
  28. package/lib/inline-markdown.ts +12 -0
  29. package/lib/links.ts +12 -0
  30. package/lib/paginate.ts +23 -0
  31. package/lib/posts.ts +20 -2
  32. package/lib/timetable.ts +27 -0
  33. package/lib/video.ts +101 -56
  34. package/package.json +1 -1
  35. package/routes/cookie-policy.astro +27 -0
  36. package/routes/posts/index.astro +36 -31
  37. package/routes/posts/page/[page].astro +36 -0
  38. 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({
@@ -115,7 +118,9 @@ export const heroBlock = z.object({
115
118
  cta: z.object({ label: z.string(), href: z.string() }).optional(),
116
119
  image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
117
120
  /** Background media for the `media` layout, in order of precedence: a looping
118
- * muted `video`, a slow crossfade through `images` (2+), or one `image`.
121
+ * muted `video` (a hosted file, or a YouTube / Vimeo link — embedded only after
122
+ * cookie consent, showing `poster` until then), a slow crossfade through `images`
123
+ * (2+), or one `image`.
119
124
  * `overlay` (0–100) is the darkening scrim so text stays legible; `poster` is
120
125
  * the video's still (and what reduced-motion visitors see). Moving backgrounds
121
126
  * get a pause button and stay still under prefers-reduced-motion. */
@@ -137,6 +142,9 @@ export const heroBlock = z.object({
137
142
  /** How much of the screen a `media` hero takes: `standard` (a band), `tall`,
138
143
  * or `screen` — the full first screen, edge to edge (e.g. a video opener). */
139
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),
140
148
  /** Ambient background for centered/split heroes: `mesh` = a soft, slowly
141
149
  * drifting brand gradient behind the text (static under reduced-motion). */
142
150
  background: z.enum(['none', 'mesh']).default('none'),
@@ -546,6 +554,8 @@ export const videoBlock = z.object({
546
554
  enabled: z.boolean().default(true),
547
555
  title: z.string().optional(),
548
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(),
549
559
  url: z.string(),
550
560
  ratio: z.enum(['16/9', '4/3', '1/1', '9/16']).default('16/9'),
551
561
  });
@@ -571,6 +581,56 @@ export const documentsBlock = z.object({
571
581
  .default([]),
572
582
  });
573
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
+
574
634
  export type RecipeBlock =
575
635
  | z.infer<typeof heroBlock>
576
636
  | z.infer<typeof featureCardsBlock>
@@ -598,7 +658,10 @@ export type RecipeBlock =
598
658
  | z.infer<typeof contactCardBlock>
599
659
  | z.infer<typeof locationsBlock>
600
660
  | z.infer<typeof videoBlock>
601
- | z.infer<typeof documentsBlock>;
661
+ | z.infer<typeof documentsBlock>
662
+ | z.infer<typeof linkRowBlock>
663
+ | z.infer<typeof documentBlock>
664
+ | z.infer<typeof timetableBlock>;
602
665
 
603
666
  /* ── Level-1 layout / container primitives ─────────────────────────────────
604
667
  * These hold child blocks, so the schema is **recursive** (a container's
@@ -711,6 +774,9 @@ export const blockSchema = z.discriminatedUnion('type', [
711
774
  locationsBlock,
712
775
  videoBlock,
713
776
  documentsBlock,
777
+ linkRowBlock,
778
+ documentBlock,
779
+ timetableBlock,
714
780
  sectionBlock,
715
781
  gridBlock,
716
782
  stackBlock,
@@ -110,3 +110,13 @@ export function scanCookieUses(input: CookieScanInput): CookiePolicyModel {
110
110
  ).map(({ blocks, calendar, ...use }) => use); // drop detection fields from the view model
111
111
  return { necessary: NECESSARY.map((u) => ({ ...u })), additional };
112
112
  }
113
+
114
+ /** Every block `type` used in a list of blocks, walking container children
115
+ * (`section` / `grid` / `stack`), so the policy sees an embed nested in a grid. */
116
+ export function blockTypesIn(blocks: ReadonlyArray<{ type: string; blocks?: unknown }>, acc = new Set<string>()): Set<string> {
117
+ for (const b of blocks) {
118
+ acc.add(b.type);
119
+ if (Array.isArray(b.blocks)) blockTypesIn(b.blocks as Array<{ type: string; blocks?: unknown }>, acc);
120
+ }
121
+ return acc;
122
+ }