ferst-core 0.7.4 → 0.7.6
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 +227 -206
- package/components/BlockRenderer.astro +4 -0
- package/components/SiteFooter.astro +325 -270
- package/components/SiteHeader.astro +82 -19
- package/components/blocks/Heading.astro +5 -1
- package/components/recipes/Contacts.astro +139 -0
- package/components/recipes/Document.astro +157 -152
- package/components/recipes/Documents.astro +2 -2
- package/components/recipes/MediaText.astro +92 -0
- package/components/recipes/Steps.astro +121 -120
- package/components/recipes/Table.astro +7 -3
- package/components/recipes/Video.astro +1 -1
- package/content/blocks.ts +64 -8
- package/content/schemas.ts +172 -161
- package/examples/recipes.ts +2 -0
- package/fonts/OFL.txt +287 -0
- package/fonts/inter-latin-400-normal.woff2 +0 -0
- package/fonts/inter-latin-500-normal.woff2 +0 -0
- package/fonts/inter-latin-600-normal.woff2 +0 -0
- package/fonts/playfair-display-latin-700-normal.woff2 +0 -0
- package/fonts/poppins-latin-300-normal.woff2 +0 -0
- package/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/integration.mjs +41 -1
- package/layouts/Base.astro +3 -0
- package/lib/icons.ts +10 -0
- package/lib/links.ts +24 -12
- package/lib/nav.ts +12 -0
- package/lib/siteSettings.ts +62 -60
- package/lib/slug.ts +32 -0
- package/lib/table.ts +10 -0
- package/package.json +69 -68
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* MediaText — an image beside its text, on the start or end side; stacks (text, then image)
|
|
4
|
+
* on a phone. The text takes prose formatting (paragraphs, lists, links); the image keeps
|
|
5
|
+
* its own shape unless `ratio` sets one (cropped to fill). An optional button follows the
|
|
6
|
+
* text.
|
|
7
|
+
*/
|
|
8
|
+
import Heading from '../blocks/Heading.astro';
|
|
9
|
+
import Button from '../Button.astro';
|
|
10
|
+
import { proseParts } from '../../lib/prose';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
title?: string;
|
|
14
|
+
text: string;
|
|
15
|
+
image: { src: string; alt?: string; caption?: string };
|
|
16
|
+
imageSide?: 'start' | 'end';
|
|
17
|
+
ratio?: 'auto' | '1/1' | '4/3' | '3/2' | '16/9';
|
|
18
|
+
cta?: { label: string; href: string };
|
|
19
|
+
}
|
|
20
|
+
const { title, text, image, imageSide = 'end', ratio = 'auto', cta } = Astro.props;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<section class="b-mediatext" data-side={imageSide}>
|
|
24
|
+
<div class="b-mediatext__text">
|
|
25
|
+
{title && <Heading text={title} level={2} />}
|
|
26
|
+
{proseParts(text).map((part) =>
|
|
27
|
+
part.kind === 'p' ? <p set:html={part.html} />
|
|
28
|
+
: part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
|
|
29
|
+
: <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
|
|
30
|
+
)}
|
|
31
|
+
{cta && <div class="b-mediatext__cta"><Button href={cta.href} variant="primary">{cta.label}</Button></div>}
|
|
32
|
+
</div>
|
|
33
|
+
<figure class="b-mediatext__media">
|
|
34
|
+
<img src={image.src} alt={image.alt ?? ''} loading="lazy" decoding="async" style={ratio !== 'auto' ? `aspect-ratio: ${ratio}; object-fit: cover` : undefined} />
|
|
35
|
+
{image.caption && <figcaption>{image.caption}</figcaption>}
|
|
36
|
+
</figure>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
<style>
|
|
40
|
+
.b-mediatext {
|
|
41
|
+
display: grid;
|
|
42
|
+
gap: clamp(1.5rem, 4vw, 3rem);
|
|
43
|
+
align-items: center;
|
|
44
|
+
width: 100%;
|
|
45
|
+
max-width: 72rem;
|
|
46
|
+
}
|
|
47
|
+
@media (min-width: 48em) {
|
|
48
|
+
.b-mediatext {
|
|
49
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
50
|
+
}
|
|
51
|
+
.b-mediatext[data-side='start'] .b-mediatext__media {
|
|
52
|
+
order: -1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
.b-mediatext__text {
|
|
56
|
+
display: flex;
|
|
57
|
+
flex-direction: column;
|
|
58
|
+
gap: 0.9rem;
|
|
59
|
+
}
|
|
60
|
+
.b-mediatext__text p,
|
|
61
|
+
.b-mediatext__text ul,
|
|
62
|
+
.b-mediatext__text ol {
|
|
63
|
+
margin: 0;
|
|
64
|
+
line-height: 1.7;
|
|
65
|
+
}
|
|
66
|
+
.b-mediatext__text ul,
|
|
67
|
+
.b-mediatext__text ol {
|
|
68
|
+
padding-inline-start: 1.4em;
|
|
69
|
+
}
|
|
70
|
+
.b-mediatext__text :global(a:not(.btn)) {
|
|
71
|
+
color: var(--accent);
|
|
72
|
+
text-decoration: underline;
|
|
73
|
+
text-underline-offset: 0.15em;
|
|
74
|
+
}
|
|
75
|
+
.b-mediatext__cta {
|
|
76
|
+
margin-top: 0.4rem;
|
|
77
|
+
}
|
|
78
|
+
.b-mediatext__media {
|
|
79
|
+
margin: 0;
|
|
80
|
+
}
|
|
81
|
+
.b-mediatext__media img {
|
|
82
|
+
display: block;
|
|
83
|
+
width: 100%;
|
|
84
|
+
height: auto;
|
|
85
|
+
border-radius: var(--radius);
|
|
86
|
+
}
|
|
87
|
+
.b-mediatext__media figcaption {
|
|
88
|
+
margin-top: 0.5rem;
|
|
89
|
+
font-size: 0.875rem;
|
|
90
|
+
color: var(--muted);
|
|
91
|
+
}
|
|
92
|
+
</style>
|
|
@@ -1,120 +1,121 @@
|
|
|
1
|
-
---
|
|
2
|
-
/** Steps — a Level-2 recipe: a numbered process. `horizontal` lays connected
|
|
3
|
-
* cards in a row; `vertical` a stacked timeline. Composes Heading + Icon. */
|
|
4
|
-
import Heading from '../blocks/Heading.astro';
|
|
5
|
-
import Icon from '../Icon.astro';
|
|
6
|
-
import { isIconKey } from '../../lib/icons';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
interface
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
.b-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
font-
|
|
83
|
-
font-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
.b-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
grid-auto-
|
|
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
|
-
|
|
1
|
+
---
|
|
2
|
+
/** Steps — a Level-2 recipe: a numbered process. `horizontal` lays connected
|
|
3
|
+
* cards in a row; `vertical` a stacked timeline. Composes Heading + Icon. */
|
|
4
|
+
import Heading from '../blocks/Heading.astro';
|
|
5
|
+
import Icon from '../Icon.astro';
|
|
6
|
+
import { isIconKey } from '../../lib/icons';
|
|
7
|
+
|
|
8
|
+
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
9
|
+
interface Item { title: string; text?: string; icon?: string }
|
|
10
|
+
interface Props {
|
|
11
|
+
title?: string;
|
|
12
|
+
intro?: string;
|
|
13
|
+
layout?: 'horizontal' | 'vertical';
|
|
14
|
+
items?: Item[];
|
|
15
|
+
}
|
|
16
|
+
const { title, intro, layout = 'horizontal', items = [] } = Astro.props;
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<section class="b-steps" data-layout={layout}>
|
|
20
|
+
{(title || intro) && (
|
|
21
|
+
<div class="b-steps__head">
|
|
22
|
+
{title && <Heading text={title} level={2} align="center" />}
|
|
23
|
+
{intro && <p class="b-steps__intro" set:html={inlineMarkdown(intro)} />}
|
|
24
|
+
</div>
|
|
25
|
+
)}
|
|
26
|
+
<ol class="b-steps__list">
|
|
27
|
+
{items.map((item, i) => (
|
|
28
|
+
<li class="b-steps__item">
|
|
29
|
+
<span class="b-steps__marker" aria-hidden="true">
|
|
30
|
+
{item.icon && isIconKey(item.icon) ? <Icon name={item.icon} size={20} /> : i + 1}
|
|
31
|
+
</span>
|
|
32
|
+
<div class="b-steps__body">
|
|
33
|
+
<h3 class="b-steps__title">{item.title}</h3>
|
|
34
|
+
{item.text && <p class="b-steps__text">{item.text}</p>}
|
|
35
|
+
</div>
|
|
36
|
+
</li>
|
|
37
|
+
))}
|
|
38
|
+
</ol>
|
|
39
|
+
</section>
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
.b-steps {
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-direction: column;
|
|
45
|
+
gap: 2rem;
|
|
46
|
+
max-width: 72rem;
|
|
47
|
+
margin-inline: auto;
|
|
48
|
+
padding-inline: 1rem;
|
|
49
|
+
}
|
|
50
|
+
.b-steps__head {
|
|
51
|
+
max-width: var(--max);
|
|
52
|
+
margin-inline: auto;
|
|
53
|
+
text-align: center;
|
|
54
|
+
display: flex;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
gap: 0.75rem;
|
|
57
|
+
}
|
|
58
|
+
.b-steps__intro { margin: 0; color: var(--muted); line-height: 1.6; }
|
|
59
|
+
.b-steps__list {
|
|
60
|
+
list-style: none;
|
|
61
|
+
margin: 0;
|
|
62
|
+
padding: 0;
|
|
63
|
+
display: grid;
|
|
64
|
+
gap: 1.5rem;
|
|
65
|
+
}
|
|
66
|
+
/* Base (and mobile): marker left, body right. */
|
|
67
|
+
.b-steps__item {
|
|
68
|
+
display: flex;
|
|
69
|
+
gap: 1rem;
|
|
70
|
+
align-items: flex-start;
|
|
71
|
+
}
|
|
72
|
+
.b-steps__marker {
|
|
73
|
+
flex-shrink: 0;
|
|
74
|
+
width: 2.5rem;
|
|
75
|
+
height: 2.5rem;
|
|
76
|
+
display: inline-flex;
|
|
77
|
+
align-items: center;
|
|
78
|
+
justify-content: center;
|
|
79
|
+
border-radius: var(--radius-pill);
|
|
80
|
+
background: var(--gold);
|
|
81
|
+
color: var(--text-light);
|
|
82
|
+
font-family: var(--font-heading);
|
|
83
|
+
font-weight: 700;
|
|
84
|
+
font-size: 1.05rem;
|
|
85
|
+
}
|
|
86
|
+
.b-steps__title { margin: 0 0 0.35rem; font-size: 1.1rem; color: var(--fg); }
|
|
87
|
+
.b-steps__text { margin: 0; color: var(--muted); line-height: 1.6; }
|
|
88
|
+
|
|
89
|
+
/* Vertical: a centred, stacked timeline. */
|
|
90
|
+
.b-steps[data-layout='vertical'] .b-steps__list { max-width: 40rem; margin-inline: auto; }
|
|
91
|
+
|
|
92
|
+
/* Horizontal (≥48em): connected cards in a row, marker over centred body. */
|
|
93
|
+
@media (min-width: 48em) {
|
|
94
|
+
.b-steps[data-layout='horizontal'] .b-steps__list {
|
|
95
|
+
grid-auto-flow: column;
|
|
96
|
+
grid-auto-columns: 1fr;
|
|
97
|
+
gap: 1rem;
|
|
98
|
+
}
|
|
99
|
+
.b-steps[data-layout='horizontal'] .b-steps__item {
|
|
100
|
+
flex-direction: column;
|
|
101
|
+
align-items: center;
|
|
102
|
+
text-align: center;
|
|
103
|
+
gap: 0.85rem;
|
|
104
|
+
position: relative;
|
|
105
|
+
}
|
|
106
|
+
/* Connector from this marker's right edge to the next marker's left edge.
|
|
107
|
+
The +1.5rem / -2rem account for the 2.5rem marker AND the 1rem grid gap,
|
|
108
|
+
so the line sits centred between markers (not falling short). */
|
|
109
|
+
.b-steps[data-layout='horizontal'] .b-steps__item:not(:last-child)::after {
|
|
110
|
+
content: '';
|
|
111
|
+
position: absolute;
|
|
112
|
+
top: calc(1.25rem - 1px);
|
|
113
|
+
left: calc(50% + 1.5rem);
|
|
114
|
+
width: calc(100% - 2rem);
|
|
115
|
+
height: 2px;
|
|
116
|
+
background: var(--border);
|
|
117
|
+
z-index: 0;
|
|
118
|
+
}
|
|
119
|
+
.b-steps[data-layout='horizontal'] .b-steps__marker { position: relative; z-index: 1; }
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import Heading from '../blocks/Heading.astro';
|
|
9
9
|
|
|
10
10
|
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
11
|
+
import { filledColumns } from '../../lib/table';
|
|
12
|
+
import { proseParts } from '../../lib/prose';
|
|
11
13
|
interface Props {
|
|
12
14
|
title?: string;
|
|
13
15
|
intro?: string;
|
|
@@ -15,25 +17,27 @@ interface Props {
|
|
|
15
17
|
rows?: string[][];
|
|
16
18
|
}
|
|
17
19
|
const { title, intro, columns = [], rows = [] } = Astro.props;
|
|
20
|
+
// Columns empty in every row (an unused "Evening") are dropped, not shown blank.
|
|
21
|
+
const keep = filledColumns(columns, rows);
|
|
18
22
|
---
|
|
19
23
|
|
|
20
24
|
<section class="b-table">
|
|
21
25
|
{(title || intro) && (
|
|
22
26
|
<div class="b-table__head">
|
|
23
27
|
{title && <Heading text={title} level={2} />}
|
|
24
|
-
{intro && <p class="b-table__intro" set:html={
|
|
28
|
+
{intro && proseParts(intro).map((part) => part.kind === 'p' ? <p class="b-table__intro" set:html={part.html} /> : <ul class="b-table__intro">{part.items.map((x) => <li set:html={x} />)}</ul>)}
|
|
25
29
|
</div>
|
|
26
30
|
)}
|
|
27
31
|
<div class="b-table__scroll">
|
|
28
32
|
<table class="b-table__table">
|
|
29
33
|
{columns.length > 0 && (
|
|
30
34
|
<thead>
|
|
31
|
-
<tr>{
|
|
35
|
+
<tr>{keep.map((i) => <th scope="col">{columns[i]}</th>)}</tr>
|
|
32
36
|
</thead>
|
|
33
37
|
)}
|
|
34
38
|
<tbody>
|
|
35
39
|
{rows.map((row) => (
|
|
36
|
-
<tr>{row.map((
|
|
40
|
+
<tr>{(columns.length ? keep : row.map((_, i) => i)).map((i) => <td set:html={inlineMarkdown(row[i] ?? '')} />)}</tr>
|
|
37
41
|
))}
|
|
38
42
|
</tbody>
|
|
39
43
|
</table>
|
|
@@ -38,7 +38,7 @@ const safeLink = /^https:\/\//i.test(url) ? url : undefined;
|
|
|
38
38
|
</div>
|
|
39
39
|
<div class="b-video__frame"></div>
|
|
40
40
|
</div>
|
|
41
|
-
{caption && <figcaption class="b-video__caption"
|
|
41
|
+
{caption && <figcaption class="b-video__caption" set:html={inlineMarkdown(caption)} />}
|
|
42
42
|
</figure>
|
|
43
43
|
{/* Always visible, before AND after the player loads: the way to the provider. */}
|
|
44
44
|
<div class="b-video__footer">
|
package/content/blocks.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { iconKeys, isIconKey } from '../lib/icons';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The block palette — the heart of the Ferst page model (see
|
|
@@ -14,6 +15,12 @@ import { z } from 'zod';
|
|
|
14
15
|
|
|
15
16
|
/** Every block shares a `type` discriminant and an `enabled` flag (keep a block
|
|
16
17
|
* in the page data without rendering it). */
|
|
18
|
+
/** An icon name from the engine's registry (lib/icons). An unknown name fails validation,
|
|
19
|
+
* and so the build, with the valid names listed, instead of rendering nothing silently. */
|
|
20
|
+
const iconName = z.string().refine(isIconKey, (v) => ({
|
|
21
|
+
message: `Unknown icon "${v}". Use one of: ${iconKeys.join(', ')}.`,
|
|
22
|
+
}));
|
|
23
|
+
|
|
17
24
|
export const headingBlock = z.object({
|
|
18
25
|
type: z.literal('heading'),
|
|
19
26
|
enabled: z.boolean().default(true),
|
|
@@ -159,7 +166,7 @@ export const featureCardsBlock = z.object({
|
|
|
159
166
|
cards: z
|
|
160
167
|
.array(
|
|
161
168
|
z.object({
|
|
162
|
-
icon:
|
|
169
|
+
icon: iconName.optional(),
|
|
163
170
|
title: z.string(),
|
|
164
171
|
text: z.string().optional(),
|
|
165
172
|
href: z.string().optional(),
|
|
@@ -261,7 +268,7 @@ export const tilesBlock = z.object({
|
|
|
261
268
|
.array(
|
|
262
269
|
z
|
|
263
270
|
.object({
|
|
264
|
-
icon:
|
|
271
|
+
icon: iconName.optional(),
|
|
265
272
|
eyebrow: z.string().optional(),
|
|
266
273
|
/** Optional only for a picture tile: with an image and no text, the tile is
|
|
267
274
|
* the picture alone (no scrim) — a linked poster/logo — named by its alt. */
|
|
@@ -319,7 +326,7 @@ export const stepsBlock = z.object({
|
|
|
319
326
|
title: z.string().optional(),
|
|
320
327
|
intro: z.string().optional(),
|
|
321
328
|
layout: z.enum(['horizontal', 'vertical']).default('horizontal'),
|
|
322
|
-
items: z.array(z.object({ title: z.string(), text: z.string().optional(), icon:
|
|
329
|
+
items: z.array(z.object({ title: z.string(), text: z.string().optional(), icon: iconName.optional() })).default([]),
|
|
323
330
|
});
|
|
324
331
|
|
|
325
332
|
/** Pricing — a row of plan cards (name, price, period, feature list, CTA). One
|
|
@@ -388,7 +395,7 @@ export const bentoBlock = z.object({
|
|
|
388
395
|
z.object({
|
|
389
396
|
size: z.enum(['sm', 'wide', 'tall', 'lg']).default('sm'),
|
|
390
397
|
variant: z.enum(['surface', 'filled', 'gradient', 'image']).default('surface'),
|
|
391
|
-
icon:
|
|
398
|
+
icon: iconName.optional(),
|
|
392
399
|
eyebrow: z.string().optional(),
|
|
393
400
|
title: z.string(),
|
|
394
401
|
text: z.string().optional(),
|
|
@@ -471,7 +478,7 @@ export const noticeBlock = z.object({
|
|
|
471
478
|
type: z.literal('notice'),
|
|
472
479
|
enabled: z.boolean().default(true),
|
|
473
480
|
tone: z.enum(['accent', 'neutral']).default('accent'),
|
|
474
|
-
icon:
|
|
481
|
+
icon: iconName.optional(),
|
|
475
482
|
title: z.string().optional(),
|
|
476
483
|
text: z.string(),
|
|
477
484
|
link: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
@@ -492,7 +499,7 @@ export const mediaCardsBlock = z.object({
|
|
|
492
499
|
.array(
|
|
493
500
|
z.object({
|
|
494
501
|
image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
|
|
495
|
-
icon:
|
|
502
|
+
icon: iconName.optional(),
|
|
496
503
|
title: z.string(),
|
|
497
504
|
/** A short meta line shown as a pill (e.g. "Sundays 10:45 · Main hall"). */
|
|
498
505
|
meta: z.string().optional(),
|
|
@@ -517,7 +524,7 @@ export const contactCardBlock = z.object({
|
|
|
517
524
|
items: z
|
|
518
525
|
.array(
|
|
519
526
|
z.object({
|
|
520
|
-
icon:
|
|
527
|
+
icon: iconName.optional(),
|
|
521
528
|
value: z.string(),
|
|
522
529
|
href: z.string().optional(),
|
|
523
530
|
})
|
|
@@ -631,6 +638,51 @@ export const timetableBlock = z.object({
|
|
|
631
638
|
.min(1),
|
|
632
639
|
});
|
|
633
640
|
|
|
641
|
+
/** Contacts — a compact directory of people or offices: role as the card title, phone and
|
|
642
|
+
* email as tap targets (email with an optional subject), address and hours, and a shared
|
|
643
|
+
* note per group (e.g. office hours) instead of repeating it on every card. */
|
|
644
|
+
export const contactsBlock = z.object({
|
|
645
|
+
type: z.literal('contacts'),
|
|
646
|
+
enabled: z.boolean().default(true),
|
|
647
|
+
title: z.string().optional(),
|
|
648
|
+
intro: z.string().optional(),
|
|
649
|
+
groups: z
|
|
650
|
+
.array(
|
|
651
|
+
z.object({
|
|
652
|
+
heading: z.string().optional(),
|
|
653
|
+
note: z.string().optional(),
|
|
654
|
+
contacts: z
|
|
655
|
+
.array(
|
|
656
|
+
z.object({
|
|
657
|
+
role: z.string().optional(),
|
|
658
|
+
name: z.string().optional(),
|
|
659
|
+
phone: z.string().optional(),
|
|
660
|
+
email: z.string().optional(),
|
|
661
|
+
emailSubject: z.string().optional(),
|
|
662
|
+
address: z.string().optional(),
|
|
663
|
+
hours: z.string().optional(),
|
|
664
|
+
})
|
|
665
|
+
.refine((c) => !!c.role || !!c.name, { message: 'A contact needs a role or a name', path: ['role'] })
|
|
666
|
+
)
|
|
667
|
+
.min(1),
|
|
668
|
+
})
|
|
669
|
+
)
|
|
670
|
+
.min(1),
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
/** MediaText — an image beside its text (start or end), stacking on a phone. The text takes
|
|
674
|
+
* prose formatting; the image keeps its shape (`ratio` crops to a set shape); optional CTA. */
|
|
675
|
+
export const mediaTextBlock = z.object({
|
|
676
|
+
type: z.literal('mediaText'),
|
|
677
|
+
enabled: z.boolean().default(true),
|
|
678
|
+
title: z.string().optional(),
|
|
679
|
+
text: z.string(),
|
|
680
|
+
image: z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional() }),
|
|
681
|
+
imageSide: z.enum(['start', 'end']).default('end'),
|
|
682
|
+
ratio: z.enum(['auto', '1/1', '4/3', '3/2', '16/9']).default('auto'),
|
|
683
|
+
cta: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
684
|
+
});
|
|
685
|
+
|
|
634
686
|
export type RecipeBlock =
|
|
635
687
|
| z.infer<typeof heroBlock>
|
|
636
688
|
| z.infer<typeof featureCardsBlock>
|
|
@@ -661,7 +713,9 @@ export type RecipeBlock =
|
|
|
661
713
|
| z.infer<typeof documentsBlock>
|
|
662
714
|
| z.infer<typeof linkRowBlock>
|
|
663
715
|
| z.infer<typeof documentBlock>
|
|
664
|
-
| z.infer<typeof timetableBlock
|
|
716
|
+
| z.infer<typeof timetableBlock>
|
|
717
|
+
| z.infer<typeof contactsBlock>
|
|
718
|
+
| z.infer<typeof mediaTextBlock>;
|
|
665
719
|
|
|
666
720
|
/* ── Level-1 layout / container primitives ─────────────────────────────────
|
|
667
721
|
* These hold child blocks, so the schema is **recursive** (a container's
|
|
@@ -777,6 +831,8 @@ export const blockSchema = z.discriminatedUnion('type', [
|
|
|
777
831
|
linkRowBlock,
|
|
778
832
|
documentBlock,
|
|
779
833
|
timetableBlock,
|
|
834
|
+
contactsBlock,
|
|
835
|
+
mediaTextBlock,
|
|
780
836
|
sectionBlock,
|
|
781
837
|
gridBlock,
|
|
782
838
|
stackBlock,
|