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
|
@@ -1,97 +1,101 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* Table — a Level-2 recipe: a simple data table (header row + body rows) under an
|
|
4
|
-
* optional heading/intro. Token-driven, striped, and horizontally scrollable on
|
|
5
|
-
* narrow screens so it never breaks the page width. For structured data our other
|
|
6
|
-
* blocks can't carry cleanly — fees, opening hours, a cookies list, comparisons.
|
|
7
|
-
*/
|
|
8
|
-
import Heading from '../blocks/Heading.astro';
|
|
9
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
border-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
.b-table__table tbody
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Table — a Level-2 recipe: a simple data table (header row + body rows) under an
|
|
4
|
+
* optional heading/intro. Token-driven, striped, and horizontally scrollable on
|
|
5
|
+
* narrow screens so it never breaks the page width. For structured data our other
|
|
6
|
+
* blocks can't carry cleanly — fees, opening hours, a cookies list, comparisons.
|
|
7
|
+
*/
|
|
8
|
+
import Heading from '../blocks/Heading.astro';
|
|
9
|
+
|
|
10
|
+
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
11
|
+
import { filledColumns } from '../../lib/table';
|
|
12
|
+
interface Props {
|
|
13
|
+
title?: string;
|
|
14
|
+
intro?: string;
|
|
15
|
+
columns?: string[];
|
|
16
|
+
rows?: string[][];
|
|
17
|
+
}
|
|
18
|
+
const { title, intro, columns = [], rows = [] } = Astro.props;
|
|
19
|
+
// Columns empty in every row (an unused "Evening") are dropped, not shown blank.
|
|
20
|
+
const keep = filledColumns(columns, rows);
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<section class="b-table">
|
|
24
|
+
{(title || intro) && (
|
|
25
|
+
<div class="b-table__head">
|
|
26
|
+
{title && <Heading text={title} level={2} />}
|
|
27
|
+
{intro && <p class="b-table__intro" set:html={inlineMarkdown(intro)} />}
|
|
28
|
+
</div>
|
|
29
|
+
)}
|
|
30
|
+
<div class="b-table__scroll">
|
|
31
|
+
<table class="b-table__table">
|
|
32
|
+
{columns.length > 0 && (
|
|
33
|
+
<thead>
|
|
34
|
+
<tr>{keep.map((i) => <th scope="col">{columns[i]}</th>)}</tr>
|
|
35
|
+
</thead>
|
|
36
|
+
)}
|
|
37
|
+
<tbody>
|
|
38
|
+
{rows.map((row) => (
|
|
39
|
+
<tr>{(columns.length ? keep : row.map((_, i) => i)).map((i) => <td set:html={inlineMarkdown(row[i] ?? '')} />)}</tr>
|
|
40
|
+
))}
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<style>
|
|
47
|
+
.b-table {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
gap: 1.25rem;
|
|
51
|
+
max-width: 72rem;
|
|
52
|
+
margin-inline: auto;
|
|
53
|
+
padding-inline: 1rem;
|
|
54
|
+
}
|
|
55
|
+
.b-table__head {
|
|
56
|
+
display: flex;
|
|
57
|
+
flex-direction: column;
|
|
58
|
+
gap: 0.6rem;
|
|
59
|
+
}
|
|
60
|
+
.b-table__intro {
|
|
61
|
+
margin: 0;
|
|
62
|
+
color: var(--muted);
|
|
63
|
+
max-width: var(--max);
|
|
64
|
+
}
|
|
65
|
+
/* Its own scroll container so a wide table scrolls sideways instead of forcing
|
|
66
|
+
the whole page to (the body never scrolls horizontally). */
|
|
67
|
+
.b-table__scroll {
|
|
68
|
+
overflow-x: auto;
|
|
69
|
+
border: var(--card-border);
|
|
70
|
+
border-radius: var(--radius);
|
|
71
|
+
}
|
|
72
|
+
.b-table__table {
|
|
73
|
+
width: 100%;
|
|
74
|
+
border-collapse: collapse;
|
|
75
|
+
font-size: 0.95rem;
|
|
76
|
+
}
|
|
77
|
+
.b-table__table th,
|
|
78
|
+
.b-table__table td {
|
|
79
|
+
padding: 0.7rem 0.9rem;
|
|
80
|
+
text-align: start;
|
|
81
|
+
vertical-align: top;
|
|
82
|
+
border-bottom: 1px solid var(--border);
|
|
83
|
+
}
|
|
84
|
+
.b-table__table thead th {
|
|
85
|
+
font-family: var(--font-heading);
|
|
86
|
+
font-weight: 600;
|
|
87
|
+
color: var(--fg);
|
|
88
|
+
background: var(--bg-section);
|
|
89
|
+
white-space: nowrap;
|
|
90
|
+
}
|
|
91
|
+
.b-table__table tbody td {
|
|
92
|
+
color: var(--muted);
|
|
93
|
+
line-height: 1.55;
|
|
94
|
+
}
|
|
95
|
+
.b-table__table tbody tr:nth-child(even) td {
|
|
96
|
+
background: var(--bg-section);
|
|
97
|
+
}
|
|
98
|
+
.b-table__table tbody tr:last-child td {
|
|
99
|
+
border-bottom: 0;
|
|
100
|
+
}
|
|
101
|
+
</style>
|
|
@@ -1,133 +1,134 @@
|
|
|
1
|
-
---
|
|
2
|
-
/** Tabs — a Level-2 recipe: labelled panels, one open at a time. Progressive:
|
|
3
|
-
* the first panel is visible with no JavaScript; a small script adds selection,
|
|
4
|
-
* ARIA state and arrow-key navigation. Composes the Heading primitive. */
|
|
5
|
-
import Heading from '../blocks/Heading.astro';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
interface
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
aria-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
{item.
|
|
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
|
-
border
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
font-
|
|
79
|
-
font-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
.b-tabs__tab
|
|
86
|
-
.b-tabs__tab
|
|
87
|
-
.b-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.b-
|
|
102
|
-
.b-
|
|
103
|
-
.b-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
tab.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
tab.addEventListener('
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
else if (e.key === '
|
|
129
|
-
else if (e.key === '
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
1
|
+
---
|
|
2
|
+
/** Tabs — a Level-2 recipe: labelled panels, one open at a time. Progressive:
|
|
3
|
+
* the first panel is visible with no JavaScript; a small script adds selection,
|
|
4
|
+
* ARIA state and arrow-key navigation. Composes the Heading primitive. */
|
|
5
|
+
import Heading from '../blocks/Heading.astro';
|
|
6
|
+
|
|
7
|
+
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
8
|
+
interface Item { label: string; heading?: string; text?: string; image?: { src: string; alt?: string } }
|
|
9
|
+
interface Props {
|
|
10
|
+
title?: string;
|
|
11
|
+
items?: Item[];
|
|
12
|
+
}
|
|
13
|
+
const { title, items = [] } = Astro.props;
|
|
14
|
+
const uid = 'tabs-' + Math.random().toString(36).slice(2, 9);
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<section class="b-tabs">
|
|
18
|
+
{title && <Heading text={title} level={2} align="center" />}
|
|
19
|
+
<div class="b-tabs__inner">
|
|
20
|
+
<div class="b-tabs__list" role="tablist" aria-label={title ?? 'Tabs'}>
|
|
21
|
+
{items.map((item, i) => (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
role="tab"
|
|
25
|
+
id={`${uid}-tab-${i}`}
|
|
26
|
+
aria-controls={`${uid}-panel-${i}`}
|
|
27
|
+
aria-selected={i === 0 ? 'true' : 'false'}
|
|
28
|
+
tabindex={i === 0 ? 0 : -1}
|
|
29
|
+
class="b-tabs__tab"
|
|
30
|
+
>
|
|
31
|
+
{item.label}
|
|
32
|
+
</button>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
{items.map((item, i) => (
|
|
36
|
+
<div
|
|
37
|
+
role="tabpanel"
|
|
38
|
+
id={`${uid}-panel-${i}`}
|
|
39
|
+
aria-labelledby={`${uid}-tab-${i}`}
|
|
40
|
+
class="b-tabs__panel"
|
|
41
|
+
hidden={i !== 0}
|
|
42
|
+
tabindex="0"
|
|
43
|
+
>
|
|
44
|
+
{item.image && (
|
|
45
|
+
<img class="b-tabs__img" src={item.image.src} alt={item.image.alt ?? ''} loading="lazy" decoding="async" />
|
|
46
|
+
)}
|
|
47
|
+
<div class="b-tabs__copy">
|
|
48
|
+
{item.heading && <h3 class="b-tabs__heading">{item.heading}</h3>}
|
|
49
|
+
{item.text && <p class="b-tabs__text" set:html={inlineMarkdown(item.text)} />}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
</section>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
.b-tabs {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
gap: 1.5rem;
|
|
61
|
+
max-width: 60rem;
|
|
62
|
+
margin-inline: auto;
|
|
63
|
+
padding-inline: 1rem;
|
|
64
|
+
}
|
|
65
|
+
.b-tabs__list {
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-wrap: wrap;
|
|
68
|
+
gap: 0.25rem;
|
|
69
|
+
border-bottom: 1px solid var(--border);
|
|
70
|
+
}
|
|
71
|
+
.b-tabs__tab {
|
|
72
|
+
appearance: none;
|
|
73
|
+
background: none;
|
|
74
|
+
border: none;
|
|
75
|
+
border-bottom: 2px solid transparent;
|
|
76
|
+
margin-bottom: -1px;
|
|
77
|
+
padding: 0.7rem 1rem;
|
|
78
|
+
font-family: var(--font-heading);
|
|
79
|
+
font-size: 0.95rem;
|
|
80
|
+
font-weight: 600;
|
|
81
|
+
color: var(--muted);
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
transition: color 0.15s, border-color 0.15s;
|
|
84
|
+
}
|
|
85
|
+
.b-tabs__tab:hover { color: var(--fg); }
|
|
86
|
+
.b-tabs__tab[aria-selected='true'] { color: var(--gold); border-bottom-color: var(--gold); }
|
|
87
|
+
.b-tabs__tab:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; border-radius: 4px; }
|
|
88
|
+
.b-tabs__panel {
|
|
89
|
+
display: grid;
|
|
90
|
+
gap: 1.25rem;
|
|
91
|
+
}
|
|
92
|
+
/* A class `display` beats the UA `[hidden]{display:none}` (equal specificity,
|
|
93
|
+
author origin wins), so make the hidden state explicit — for the first paint
|
|
94
|
+
and every JS toggle. */
|
|
95
|
+
.b-tabs__panel[hidden] {
|
|
96
|
+
display: none;
|
|
97
|
+
}
|
|
98
|
+
@media (min-width: 48em) {
|
|
99
|
+
.b-tabs__panel:has(.b-tabs__img) { grid-template-columns: 1fr 1fr; align-items: center; }
|
|
100
|
+
}
|
|
101
|
+
.b-tabs__panel:focus-visible { outline: 2px solid var(--gold); outline-offset: 4px; border-radius: var(--radius-sm); }
|
|
102
|
+
.b-tabs__img { width: 100%; height: auto; border-radius: var(--radius); }
|
|
103
|
+
.b-tabs__heading { margin: 0 0 0.5rem; color: var(--fg); }
|
|
104
|
+
.b-tabs__text { margin: 0; color: var(--muted); line-height: 1.65; }
|
|
105
|
+
</style>
|
|
106
|
+
|
|
107
|
+
<script>
|
|
108
|
+
// Wire every tabs group on the page: selection, ARIA state, arrow-key nav.
|
|
109
|
+
document.querySelectorAll<HTMLElement>('.b-tabs').forEach((root) => {
|
|
110
|
+
const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
|
|
111
|
+
const panels = Array.from(root.querySelectorAll<HTMLElement>('[role="tabpanel"]'));
|
|
112
|
+
if (!tabs.length) return;
|
|
113
|
+
|
|
114
|
+
function select(index: number, focus = true) {
|
|
115
|
+
tabs.forEach((tab, i) => {
|
|
116
|
+
const on = i === index;
|
|
117
|
+
tab.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
118
|
+
tab.tabIndex = on ? 0 : -1;
|
|
119
|
+
panels[i]?.toggleAttribute('hidden', !on);
|
|
120
|
+
});
|
|
121
|
+
if (focus) tabs[index]?.focus();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
tabs.forEach((tab, i) => {
|
|
125
|
+
tab.addEventListener('click', () => select(i, false));
|
|
126
|
+
tab.addEventListener('keydown', (e) => {
|
|
127
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); select((i + 1) % tabs.length); }
|
|
128
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); select((i - 1 + tabs.length) % tabs.length); }
|
|
129
|
+
else if (e.key === 'Home') { e.preventDefault(); select(0); }
|
|
130
|
+
else if (e.key === 'End') { e.preventDefault(); select(tabs.length - 1); }
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
</script>
|
|
@@ -17,6 +17,7 @@ import Heading from '../blocks/Heading.astro';
|
|
|
17
17
|
import Icon from '../Icon.astro';
|
|
18
18
|
import { isIconKey } from '../../lib/icons';
|
|
19
19
|
|
|
20
|
+
import { inlineMarkdown, inlineMarkdownNoLinks } from '../../lib/inline-markdown';
|
|
20
21
|
interface Tile {
|
|
21
22
|
icon?: string;
|
|
22
23
|
eyebrow?: string;
|
|
@@ -43,7 +44,7 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
|
|
|
43
44
|
{(title || intro) && (
|
|
44
45
|
<div class="b-tiles__head">
|
|
45
46
|
{title && <Heading text={title} level={2} align="center" />}
|
|
46
|
-
{intro && <p class="b-tiles__intro"
|
|
47
|
+
{intro && <p class="b-tiles__intro" set:html={inlineMarkdown(intro)} />}
|
|
47
48
|
</div>
|
|
48
49
|
)}
|
|
49
50
|
<div class="b-tiles__grid" data-cols={cols} data-single={tiles.length === 1 ? 'true' : undefined} data-variant={variant} data-spotlight={spotlight ? 'true' : 'false'}>
|
|
@@ -69,7 +70,8 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
|
|
|
69
70
|
)}
|
|
70
71
|
{tile.eyebrow && <span class="b-tile__eyebrow">{tile.eyebrow}</span>}
|
|
71
72
|
{tile.title && <h3 class="b-tile__title">{tile.title}</h3>}
|
|
72
|
-
{tile
|
|
73
|
+
{/* A linked tile is one big link: its text keeps breaks and emphasis, no inner links. */}
|
|
74
|
+
{tile.text && <p class="b-tile__text" set:html={tile.href ? inlineMarkdownNoLinks(tile.text) : inlineMarkdown(tile.text)} />}
|
|
73
75
|
{tile.href && !plain && <span class="b-tile__more" aria-hidden="true">{tile.linkLabel ?? 'Learn more'} →</span>}
|
|
74
76
|
</Tag>
|
|
75
77
|
);
|