ferst-core 0.7.2 → 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/Document.astro +152 -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/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 +4 -2
- package/components/recipes/Timetable.astro +189 -0
- package/components/recipes/Video.astro +177 -157
- package/content/blocks.ts +65 -1
- package/content/cookies.ts +10 -0
- package/examples/primitives.ts +2 -0
- package/examples/recipes.ts +5 -0
- 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 +6 -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 -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
|
@@ -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,56 @@ 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
|
+
|
|
576
634
|
export type RecipeBlock =
|
|
577
635
|
| z.infer<typeof heroBlock>
|
|
578
636
|
| z.infer<typeof featureCardsBlock>
|
|
@@ -600,7 +658,10 @@ export type RecipeBlock =
|
|
|
600
658
|
| z.infer<typeof contactCardBlock>
|
|
601
659
|
| z.infer<typeof locationsBlock>
|
|
602
660
|
| z.infer<typeof videoBlock>
|
|
603
|
-
| z.infer<typeof documentsBlock
|
|
661
|
+
| z.infer<typeof documentsBlock>
|
|
662
|
+
| z.infer<typeof linkRowBlock>
|
|
663
|
+
| z.infer<typeof documentBlock>
|
|
664
|
+
| z.infer<typeof timetableBlock>;
|
|
604
665
|
|
|
605
666
|
/* ── Level-1 layout / container primitives ─────────────────────────────────
|
|
606
667
|
* These hold child blocks, so the schema is **recursive** (a container's
|
|
@@ -713,6 +774,9 @@ export const blockSchema = z.discriminatedUnion('type', [
|
|
|
713
774
|
locationsBlock,
|
|
714
775
|
videoBlock,
|
|
715
776
|
documentsBlock,
|
|
777
|
+
linkRowBlock,
|
|
778
|
+
documentBlock,
|
|
779
|
+
timetableBlock,
|
|
716
780
|
sectionBlock,
|
|
717
781
|
gridBlock,
|
|
718
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
|
+
}
|
package/examples/primitives.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const primitives: GalleryEntry[] = [
|
|
|
9
9
|
{ type: 'heading', description: 'A section title, levels 1–6, optionally aligned.', block: { type: 'heading', text: 'A heading', level: 2 } },
|
|
10
10
|
{ type: 'heading', description: 'Optional gradient text — brand → derived accent-2 (falls back to solid ink where unsupported).', block: { type: 'heading', text: 'A gradient heading', level: 2, gradient: true } },
|
|
11
11
|
{ type: 'prose', description: 'Body text: blank lines start new paragraphs, `- ` / `1. ` lines make lists, and inline links (incl. phone and email), bold and italic render safely.', block: { type: 'prose', text: 'A paragraph of prose with **bold**, *italic* and a [link to the recipes](/recipes).\n\nFor a quick answer, [phone the office](tel:01234567890).\n\n- Lists from simple lines\n- With a [link](/primitives) inside' } },
|
|
12
|
+
{ type: 'prose', description: 'The `lead` variant: the paragraph straight under the hero that introduces the page, larger and quieter than body text.', block: { type: 'prose', variant: 'lead', text: 'A lead paragraph introduces the page and invites the reader in.' } },
|
|
12
13
|
{ type: 'image', description: 'A responsive image with an optional caption. Corners follow the site by default.', block: { type: 'image', src: '/placeholder.svg', alt: 'Placeholder', caption: 'An image with a caption.' } },
|
|
13
14
|
{ type: 'image', description: 'Canonical framing options: soft corners + drop shadow + a fixed 16/9 ratio (image is cover-fit).', block: { type: 'image', src: '/showcase-hero.svg', alt: 'Abstract', radius: 'soft', shadow: true, ratio: '16/9', caption: 'radius: soft · shadow · ratio 16/9' } },
|
|
14
15
|
{ type: 'image', description: 'Square ratio with an outline stroke and sharp corners — same block, different canonical options.', block: { type: 'image', src: '/showcase-hero.svg', alt: 'Abstract', radius: 'none', border: true, ratio: 'square', caption: 'radius: none · stroke · square' } },
|
|
@@ -17,6 +18,7 @@ export const primitives: GalleryEntry[] = [
|
|
|
17
18
|
{ type: 'button', description: 'Filled CTA — the brand-accent call to action.', block: { type: 'button', label: 'Get started', href: '#', variant: 'cta' } },
|
|
18
19
|
{ type: 'button', description: 'Ghost — a quiet, borderless text button for tertiary actions.', block: { type: 'button', label: 'Learn more', href: '#', variant: 'ghost' } },
|
|
19
20
|
{ type: 'quote', description: 'A pull quote with an optional citation.', block: { type: 'quote', text: 'A memorable quotation, set apart.', cite: 'Someone' } },
|
|
21
|
+
{ type: 'quote', description: 'A prayer, verse or poem: line breaks are kept as written, and inline formatting works.', block: { type: 'quote', text: 'Lord, make me an instrument of your peace:\nwhere there is hatred, let me sow love;\nwhere there is injury, *pardon*.', cite: 'Prayer of St Francis' } },
|
|
20
22
|
{ type: 'divider', description: 'A horizontal rule between sections.', block: { type: 'divider' } },
|
|
21
23
|
{ type: 'spacer', description: 'Vertical space (sm / md / lg) — invisible by design.', block: { type: 'spacer', size: 'lg' } },
|
|
22
24
|
{ type: 'section', description: 'A full-width band (background + width) holding child blocks.', block: { type: 'section', background: 'muted', width: 'normal', blocks: [{ type: 'heading', text: 'Inside a section', level: 3 }, { type: 'prose', text: 'Sections hold child blocks.' }] } },
|
package/examples/recipes.ts
CHANGED
|
@@ -33,5 +33,10 @@ export const recipes: GalleryEntry[] = [
|
|
|
33
33
|
{ type: 'contactCard', description: 'A branded "get in touch" band — a theme-adaptive panel (a section surface with a soft brand-gold corner wash) carrying a display-type title + subtitle on the left, contact channels as tappable tiles on the right, and a closing note across the bottom. Composes in both light and dark. The polished alternative to a plain notice box.', block: { type: 'contactCard', eyebrow: 'Parish office', title: 'Get in touch', subtitle: 'Parish Priest: Fr John Doe', note: 'For a quick reply, phone the office directly — email is best for non-urgent matters. See our [Safeguarding policy](/safeguarding).', items: [{ icon: 'map-pin', value: '1 Example Street\nPlaceholdertown EX4 3PL' }, { icon: 'phone', value: '01234 567890', href: 'tel:01234567890' }, { icon: 'email', value: 'office@st-placeholder.example', href: 'mailto:office@st-placeholder.example' }, { icon: 'link', value: 'Follow us on Facebook', href: 'https://facebook.com/' }] } },
|
|
34
34
|
{ type: 'locations', description: 'A "where to find us" grid — one or more places, each a neat tile with a consent-gated map on top and its name / address / directions below. Maps load once the visitor accepts the site cookie banner (shared consent, no per-tile gate). Replaces stacking bare map embeds.', block: { type: 'locations', title: 'Where to find us', columns: 2, items: [{ name: "St Placeholder's", address: '1 Example Street\nPlaceholdertown EX4 3PL', embedUrl: 'https://www.google.com/maps/embed?pb=!demo1', directionsUrl: 'https://maps.google.com/' }, { name: "St Sample's", address: '2 Sample Road\nPlaceholdertown', embedUrl: 'https://www.google.com/maps/embed?pb=!demo2', directionsUrl: 'https://maps.google.com/' }] } },
|
|
35
35
|
{ type: 'video', description: 'A consent-gated video — paste an ordinary YouTube (incl. a channel’s live page) or Vimeo link; it plays through the privacy-friendly embed only once the visitor agrees, with a “watch on YouTube” alternative. Fixed ratio, never overflows.', block: { type: 'video', title: 'Watch', caption: 'Sample video link.', url: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ', ratio: '16/9' } },
|
|
36
|
+
{ type: 'video', description: 'A channel’s live stream: it doesn’t auto-load (an off-air channel shows “unavailable”), so visitors see the schedule note, a link to the channel’s streams, and “Show the live stream”.', block: { type: 'video', title: 'Watch Mass live', url: 'https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv/live', note: 'Live: **Sundays 10.15am** and holy days.' } },
|
|
36
37
|
{ type: 'documents', description: 'A list of files — newsletters, minutes, policies, forms — newest first, optionally grouped by year: type badge, title, date and note per row. Opens in the browser’s own viewer rather than an in-page one (better on phones).', block: { type: 'documents', title: 'Newsletters', intro: 'Every issue, newest first.', groupByYear: true, items: [{ title: 'Newsletter — Advent', file: '/files/sample-advent.pdf', date: '2026-11-30' }, { title: 'Newsletter — Harvest', file: '/files/sample-harvest.pdf', date: '2026-10-04', note: 'Includes the harvest appeal' }, { title: 'Newsletter — Christmas', file: '/files/sample-christmas.pdf', date: '2025-12-21' }, { title: 'Hall hire form', file: '/files/sample-hire-form.docx' }] } },
|
|
38
|
+
{ type: 'linkRow', description: 'A titled row of contact links as pills: phone, email and web links each get the right icon from the link itself. With an optional intro and footnote. For a contact who doesn’t need a whole contact card.', block: { type: 'linkRow', title: 'Vocations', intro: 'Thinking about a vocation? Talk to the vocations director.', links: [{ label: '01234 567890', href: 'tel:01234567890' }, { label: 'vocations@example.org', href: 'mailto:vocations@example.org' }, { label: 'Vocations website', href: 'https://example.com/vocations' }], note: 'All enquiries are treated in confidence.' } },
|
|
39
|
+
{ type: 'document', description: 'A policy or legal text as one block: sections numbered automatically, a contents list linking to each (sticky beside the text on wide screens), a “last updated” date, prose formatting in each section, print-friendly.', block: { type: 'document', intro: 'These conditions apply to every booking.', updated: '2026-10-04', sections: [{ title: 'Use of the hall', body: 'The hall may be used only for the purpose stated on the booking form.' }, { title: 'Bookings', body: 'Bookings are confirmed once the deposit is received.', items: ['Book through the office.', 'Bookings can’t be transferred.'] }, { title: 'Cancellation', body: 'Cancellations made less than **seven days** before the event forfeit the deposit.' }] } },
|
|
40
|
+
{ type: 'timetable', description: 'Weekly times for one or more places (service times, opening hours, sessions): a card per place, days with no times left out, notes with links under each. Set `layout: "table"` to compare places side by side.', block: { type: 'timetable', title: 'Service times', places: [{ name: "St Placeholder's", address: '1 Example Street', schedule: [{ day: 'Saturday', times: ['6:00pm (vigil)'] }, { day: 'Sunday', times: ['8:30am', '10:30am'] }, { day: 'Weekdays', times: ['9:30am'] }], notes: 'Confessions on Saturdays from 5pm. See [how to find us](#find-us).' }, { name: "St Sample's", schedule: [{ day: 'Saturday', times: [] }, { day: 'Sunday', times: ['9:00am'] }], notes: 'No Saturday service.' }] } },
|
|
41
|
+
{ type: 'timetable', description: 'The `table` layout: days down the side, one column per place; a day nobody uses is dropped.', block: { type: 'timetable', title: 'Opening hours', layout: 'table', places: [{ name: 'Library', schedule: [{ day: 'Monday', times: ['9am–5pm'] }, { day: 'Saturday', times: ['10am–1pm'] }] }, { name: 'Café', schedule: [{ day: 'Monday', times: ['8am–4pm'] }, { day: 'Sunday', times: [] }] }] } },
|
|
37
42
|
];
|