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.
Files changed (47) hide show
  1. package/bin/ferst-update.mjs +33 -2
  2. package/cms/config.template.yml +214 -206
  3. package/components/BlockRenderer.astro +10 -0
  4. package/components/Pagination.astro +51 -0
  5. package/components/SiteFooter.astro +274 -270
  6. package/components/SiteHeader.astro +1243 -1183
  7. package/components/blocks/Heading.astro +51 -46
  8. package/components/blocks/Prose.astro +10 -2
  9. package/components/blocks/Quote.astro +40 -38
  10. package/components/recipes/Accordion.astro +91 -84
  11. package/components/recipes/Contacts.astro +139 -0
  12. package/components/recipes/Document.astro +157 -0
  13. package/components/recipes/FeatureCards.astro +112 -111
  14. package/components/recipes/Hero.astro +52 -4
  15. package/components/recipes/LinkRow.astro +91 -0
  16. package/components/recipes/MediaCards.astro +213 -212
  17. package/components/recipes/MediaText.astro +92 -0
  18. package/components/recipes/Notice.astro +90 -89
  19. package/components/recipes/Stats.astro +152 -145
  20. package/components/recipes/Table.astro +101 -97
  21. package/components/recipes/Tabs.astro +134 -133
  22. package/components/recipes/Tiles.astro +4 -2
  23. package/components/recipes/Timetable.astro +189 -0
  24. package/components/recipes/Video.astro +177 -157
  25. package/content/blocks.ts +113 -1
  26. package/content/cookies.ts +10 -0
  27. package/content/schemas.ts +167 -161
  28. package/examples/primitives.ts +2 -0
  29. package/examples/recipes.ts +7 -0
  30. package/integration.mjs +83 -64
  31. package/layouts/Base.astro +483 -475
  32. package/lib/documents.ts +68 -62
  33. package/lib/embed-consent.ts +78 -73
  34. package/lib/inline-markdown.ts +6 -0
  35. package/lib/links.ts +22 -0
  36. package/lib/paginate.ts +23 -0
  37. package/lib/posts.ts +20 -2
  38. package/lib/siteSettings.ts +61 -60
  39. package/lib/slug.ts +32 -0
  40. package/lib/table.ts +10 -0
  41. package/lib/timetable.ts +27 -0
  42. package/lib/video.ts +101 -75
  43. package/package.json +1 -1
  44. package/routes/cookie-policy.astro +27 -0
  45. package/routes/posts/index.astro +36 -31
  46. package/routes/posts/page/[page].astro +36 -0
  47. 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
- interface Props {
11
- title?: string;
12
- intro?: string;
13
- columns?: string[];
14
- rows?: string[][];
15
- }
16
- const { title, intro, columns = [], rows = [] } = Astro.props;
17
- ---
18
-
19
- <section class="b-table">
20
- {(title || intro) && (
21
- <div class="b-table__head">
22
- {title && <Heading text={title} level={2} />}
23
- {intro && <p class="b-table__intro">{intro}</p>}
24
- </div>
25
- )}
26
- <div class="b-table__scroll">
27
- <table class="b-table__table">
28
- {columns.length > 0 && (
29
- <thead>
30
- <tr>{columns.map((c) => <th scope="col">{c}</th>)}</tr>
31
- </thead>
32
- )}
33
- <tbody>
34
- {rows.map((row) => (
35
- <tr>{row.map((cell) => <td>{cell}</td>)}</tr>
36
- ))}
37
- </tbody>
38
- </table>
39
- </div>
40
- </section>
41
-
42
- <style>
43
- .b-table {
44
- display: flex;
45
- flex-direction: column;
46
- gap: 1.25rem;
47
- max-width: 72rem;
48
- margin-inline: auto;
49
- padding-inline: 1rem;
50
- }
51
- .b-table__head {
52
- display: flex;
53
- flex-direction: column;
54
- gap: 0.6rem;
55
- }
56
- .b-table__intro {
57
- margin: 0;
58
- color: var(--muted);
59
- max-width: var(--max);
60
- }
61
- /* Its own scroll container so a wide table scrolls sideways instead of forcing
62
- the whole page to (the body never scrolls horizontally). */
63
- .b-table__scroll {
64
- overflow-x: auto;
65
- border: var(--card-border);
66
- border-radius: var(--radius);
67
- }
68
- .b-table__table {
69
- width: 100%;
70
- border-collapse: collapse;
71
- font-size: 0.95rem;
72
- }
73
- .b-table__table th,
74
- .b-table__table td {
75
- padding: 0.7rem 0.9rem;
76
- text-align: start;
77
- vertical-align: top;
78
- border-bottom: 1px solid var(--border);
79
- }
80
- .b-table__table thead th {
81
- font-family: var(--font-heading);
82
- font-weight: 600;
83
- color: var(--fg);
84
- background: var(--bg-section);
85
- white-space: nowrap;
86
- }
87
- .b-table__table tbody td {
88
- color: var(--muted);
89
- line-height: 1.55;
90
- }
91
- .b-table__table tbody tr:nth-child(even) td {
92
- background: var(--bg-section);
93
- }
94
- .b-table__table tbody tr:last-child td {
95
- border-bottom: 0;
96
- }
97
- </style>
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
- interface Item { label: string; heading?: string; text?: string; image?: { src: string; alt?: string } }
8
- interface Props {
9
- title?: string;
10
- items?: Item[];
11
- }
12
- const { title, items = [] } = Astro.props;
13
- const uid = 'tabs-' + Math.random().toString(36).slice(2, 9);
14
- ---
15
-
16
- <section class="b-tabs">
17
- {title && <Heading text={title} level={2} align="center" />}
18
- <div class="b-tabs__inner">
19
- <div class="b-tabs__list" role="tablist" aria-label={title ?? 'Tabs'}>
20
- {items.map((item, i) => (
21
- <button
22
- type="button"
23
- role="tab"
24
- id={`${uid}-tab-${i}`}
25
- aria-controls={`${uid}-panel-${i}`}
26
- aria-selected={i === 0 ? 'true' : 'false'}
27
- tabindex={i === 0 ? 0 : -1}
28
- class="b-tabs__tab"
29
- >
30
- {item.label}
31
- </button>
32
- ))}
33
- </div>
34
- {items.map((item, i) => (
35
- <div
36
- role="tabpanel"
37
- id={`${uid}-panel-${i}`}
38
- aria-labelledby={`${uid}-tab-${i}`}
39
- class="b-tabs__panel"
40
- hidden={i !== 0}
41
- tabindex="0"
42
- >
43
- {item.image && (
44
- <img class="b-tabs__img" src={item.image.src} alt={item.image.alt ?? ''} loading="lazy" decoding="async" />
45
- )}
46
- <div class="b-tabs__copy">
47
- {item.heading && <h3 class="b-tabs__heading">{item.heading}</h3>}
48
- {item.text && <p class="b-tabs__text">{item.text}</p>}
49
- </div>
50
- </div>
51
- ))}
52
- </div>
53
- </section>
54
-
55
- <style>
56
- .b-tabs {
57
- display: flex;
58
- flex-direction: column;
59
- gap: 1.5rem;
60
- max-width: 60rem;
61
- margin-inline: auto;
62
- padding-inline: 1rem;
63
- }
64
- .b-tabs__list {
65
- display: flex;
66
- flex-wrap: wrap;
67
- gap: 0.25rem;
68
- border-bottom: 1px solid var(--border);
69
- }
70
- .b-tabs__tab {
71
- appearance: none;
72
- background: none;
73
- border: none;
74
- border-bottom: 2px solid transparent;
75
- margin-bottom: -1px;
76
- padding: 0.7rem 1rem;
77
- font-family: var(--font-heading);
78
- font-size: 0.95rem;
79
- font-weight: 600;
80
- color: var(--muted);
81
- cursor: pointer;
82
- transition: color 0.15s, border-color 0.15s;
83
- }
84
- .b-tabs__tab:hover { color: var(--fg); }
85
- .b-tabs__tab[aria-selected='true'] { color: var(--gold); border-bottom-color: var(--gold); }
86
- .b-tabs__tab:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; border-radius: 4px; }
87
- .b-tabs__panel {
88
- display: grid;
89
- gap: 1.25rem;
90
- }
91
- /* A class `display` beats the UA `[hidden]{display:none}` (equal specificity,
92
- author origin wins), so make the hidden state explicit — for the first paint
93
- and every JS toggle. */
94
- .b-tabs__panel[hidden] {
95
- display: none;
96
- }
97
- @media (min-width: 48em) {
98
- .b-tabs__panel:has(.b-tabs__img) { grid-template-columns: 1fr 1fr; align-items: center; }
99
- }
100
- .b-tabs__panel:focus-visible { outline: 2px solid var(--gold); outline-offset: 4px; border-radius: var(--radius-sm); }
101
- .b-tabs__img { width: 100%; height: auto; border-radius: var(--radius); }
102
- .b-tabs__heading { margin: 0 0 0.5rem; color: var(--fg); }
103
- .b-tabs__text { margin: 0; color: var(--muted); line-height: 1.65; }
104
- </style>
105
-
106
- <script>
107
- // Wire every tabs group on the page: selection, ARIA state, arrow-key nav.
108
- document.querySelectorAll<HTMLElement>('.b-tabs').forEach((root) => {
109
- const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
110
- const panels = Array.from(root.querySelectorAll<HTMLElement>('[role="tabpanel"]'));
111
- if (!tabs.length) return;
112
-
113
- function select(index: number, focus = true) {
114
- tabs.forEach((tab, i) => {
115
- const on = i === index;
116
- tab.setAttribute('aria-selected', on ? 'true' : 'false');
117
- tab.tabIndex = on ? 0 : -1;
118
- panels[i]?.toggleAttribute('hidden', !on);
119
- });
120
- if (focus) tabs[index]?.focus();
121
- }
122
-
123
- tabs.forEach((tab, i) => {
124
- tab.addEventListener('click', () => select(i, false));
125
- tab.addEventListener('keydown', (e) => {
126
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); select((i + 1) % tabs.length); }
127
- else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); select((i - 1 + tabs.length) % tabs.length); }
128
- else if (e.key === 'Home') { e.preventDefault(); select(0); }
129
- else if (e.key === 'End') { e.preventDefault(); select(tabs.length - 1); }
130
- });
131
- });
132
- });
133
- </script>
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">{intro}</p>}
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.text && <p class="b-tile__text">{tile.text}</p>}
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'} &rarr;</span>}
74
76
  </Tag>
75
77
  );