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.
- package/components/BlockRenderer.astro +6 -0
- package/components/Pagination.astro +51 -0
- package/components/blocks/Heading.astro +47 -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/ContactCard.astro +172 -170
- package/components/recipes/Document.astro +152 -0
- package/components/recipes/FeatureCards.astro +112 -111
- package/components/recipes/Hero.astro +120 -8
- package/components/recipes/LinkRow.astro +91 -0
- package/components/recipes/MediaCards.astro +213 -212
- package/components/recipes/Notice.astro +90 -89
- package/components/recipes/Stats.astro +152 -145
- package/components/recipes/Table.astro +98 -97
- package/components/recipes/Tabs.astro +134 -133
- package/components/recipes/Tiles.astro +287 -271
- package/components/recipes/Timetable.astro +189 -0
- package/components/recipes/Video.astro +177 -157
- package/content/blocks.ts +68 -2
- package/content/cookies.ts +10 -0
- package/examples/primitives.ts +2 -0
- package/examples/recipes.ts +42 -36
- package/integration.mjs +83 -64
- package/layouts/Base.astro +481 -475
- package/lib/documents.ts +68 -62
- package/lib/embed-consent.ts +78 -73
- package/lib/inline-markdown.ts +12 -0
- package/lib/links.ts +12 -0
- package/lib/paginate.ts +23 -0
- package/lib/posts.ts +20 -2
- package/lib/timetable.ts +27 -0
- package/lib/video.ts +101 -56
- 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({
|
|
@@ -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
|
|
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,
|
package/content/cookies.ts
CHANGED
|
@@ -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
|
+
}
|