ferst-core 0.7.2 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ferst-update.mjs +33 -2
- package/cms/config.template.yml +214 -206
- package/components/BlockRenderer.astro +10 -0
- package/components/Pagination.astro +51 -0
- package/components/SiteFooter.astro +274 -270
- package/components/SiteHeader.astro +1243 -1183
- package/components/blocks/Heading.astro +51 -46
- package/components/blocks/Prose.astro +10 -2
- package/components/blocks/Quote.astro +40 -38
- package/components/recipes/Accordion.astro +91 -84
- package/components/recipes/Contacts.astro +139 -0
- package/components/recipes/Document.astro +157 -0
- package/components/recipes/FeatureCards.astro +112 -111
- package/components/recipes/Hero.astro +52 -4
- package/components/recipes/LinkRow.astro +91 -0
- package/components/recipes/MediaCards.astro +213 -212
- package/components/recipes/MediaText.astro +92 -0
- package/components/recipes/Notice.astro +90 -89
- package/components/recipes/Stats.astro +152 -145
- package/components/recipes/Table.astro +101 -97
- package/components/recipes/Tabs.astro +134 -133
- package/components/recipes/Tiles.astro +4 -2
- package/components/recipes/Timetable.astro +189 -0
- package/components/recipes/Video.astro +177 -157
- package/content/blocks.ts +113 -1
- package/content/cookies.ts +10 -0
- package/content/schemas.ts +167 -161
- package/examples/primitives.ts +2 -0
- package/examples/recipes.ts +7 -0
- package/integration.mjs +83 -64
- package/layouts/Base.astro +483 -475
- package/lib/documents.ts +68 -62
- package/lib/embed-consent.ts +78 -73
- package/lib/inline-markdown.ts +6 -0
- package/lib/links.ts +22 -0
- package/lib/paginate.ts +23 -0
- package/lib/posts.ts +20 -2
- package/lib/siteSettings.ts +61 -60
- package/lib/slug.ts +32 -0
- package/lib/table.ts +10 -0
- package/lib/timetable.ts +27 -0
- package/lib/video.ts +101 -75
- package/package.json +1 -1
- package/routes/cookie-policy.astro +27 -0
- package/routes/posts/index.astro +36 -31
- package/routes/posts/page/[page].astro +36 -0
- package/routes/posts/tag/[...tag].astro +41 -37
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
margin:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
.b-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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} ↗</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,
|