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.
Files changed (37) hide show
  1. package/components/BlockRenderer.astro +6 -0
  2. package/components/Pagination.astro +51 -0
  3. package/components/blocks/Heading.astro +47 -46
  4. package/components/blocks/Prose.astro +10 -2
  5. package/components/blocks/Quote.astro +40 -38
  6. package/components/recipes/Accordion.astro +91 -84
  7. package/components/recipes/Document.astro +152 -0
  8. package/components/recipes/FeatureCards.astro +112 -111
  9. package/components/recipes/Hero.astro +52 -4
  10. package/components/recipes/LinkRow.astro +91 -0
  11. package/components/recipes/MediaCards.astro +213 -212
  12. package/components/recipes/Notice.astro +90 -89
  13. package/components/recipes/Stats.astro +152 -145
  14. package/components/recipes/Table.astro +98 -97
  15. package/components/recipes/Tabs.astro +134 -133
  16. package/components/recipes/Tiles.astro +4 -2
  17. package/components/recipes/Timetable.astro +189 -0
  18. package/components/recipes/Video.astro +177 -157
  19. package/content/blocks.ts +65 -1
  20. package/content/cookies.ts +10 -0
  21. package/examples/primitives.ts +2 -0
  22. package/examples/recipes.ts +5 -0
  23. package/integration.mjs +83 -64
  24. package/layouts/Base.astro +481 -475
  25. package/lib/documents.ts +68 -62
  26. package/lib/embed-consent.ts +78 -73
  27. package/lib/inline-markdown.ts +6 -0
  28. package/lib/links.ts +12 -0
  29. package/lib/paginate.ts +23 -0
  30. package/lib/posts.ts +20 -2
  31. package/lib/timetable.ts +27 -0
  32. package/lib/video.ts +101 -75
  33. package/package.json +1 -1
  34. package/routes/cookie-policy.astro +27 -0
  35. package/routes/posts/index.astro +36 -31
  36. package/routes/posts/page/[page].astro +36 -0
  37. package/routes/posts/tag/[...tag].astro +41 -37
@@ -1,145 +1,152 @@
1
- ---
2
- /** Stats — a Level-2 recipe: a band of headline numbers over an optional
3
- * heading/intro. Composes the Heading primitive; numbers use the brand accent.
4
- *
5
- * Self-composing: the grid never reserves more columns than there are stats, so
6
- * a single item centres as a feature instead of stranding an empty half-band
7
- * (the "£10 pinned to one side" bug); each stat is a token-driven tile (radius/
8
- * surface/stroke/shadow knobs, like every Card), so it reads as a designed unit
9
- * at any count and in either theme. Labels are width-capped so a long caption
10
- * wraps to a tidy block rather than a full-width line. */
11
- import Heading from '../blocks/Heading.astro';
12
-
13
- interface Item { value: string; label: string; description?: string }
14
- interface Props {
15
- title?: string;
16
- intro?: string;
17
- background?: 'none' | 'muted' | 'accent';
18
- columns?: 2 | 3 | 4;
19
- items?: Item[];
20
- }
21
- const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.props;
22
- // Cap the column count at the item count — one stat should never sit in a
23
- // two-column grid (half empty). Falls back to 1 for an empty list.
24
- const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
25
- ---
26
-
27
- <section class="b-stats" data-bg={background}>
28
- <div class="b-stats__inner">
29
- {(title || intro) && (
30
- <div class="b-stats__head">
31
- {title && <Heading text={title} level={2} align="center" />}
32
- {intro && <p class="b-stats__intro">{intro}</p>}
33
- </div>
34
- )}
35
- <div class="b-stats__grid" data-cols={cols}>
36
- {items.map((item) => (
37
- <div class="b-stats__item ferst-tile">
38
- <span class="b-stats__value">{item.value}</span>
39
- {item.label && <span class="b-stats__label">{item.label}</span>}
40
- {item.description && <span class="b-stats__desc">{item.description}</span>}
41
- </div>
42
- ))}
43
- </div>
44
- </div>
45
- </section>
46
-
47
- <style>
48
- .b-stats { padding-block: clamp(2.5rem, 6vw, 4rem); }
49
- .b-stats[data-bg='muted'] { background: var(--bg-section); }
50
- .b-stats[data-bg='accent'] { background: var(--gold-light); }
51
- .b-stats__inner {
52
- max-width: 72rem;
53
- margin-inline: auto;
54
- padding-inline: 1rem;
55
- display: flex;
56
- flex-direction: column;
57
- gap: 2rem;
58
- }
59
- .b-stats__head {
60
- max-width: var(--max);
61
- margin-inline: auto;
62
- text-align: center;
63
- display: flex;
64
- flex-direction: column;
65
- gap: 0.75rem;
66
- }
67
- .b-stats__intro { margin: 0; color: var(--muted); line-height: 1.6; }
68
- .b-stats__grid {
69
- display: grid;
70
- grid-template-columns: 1fr;
71
- gap: 1.25rem;
72
- width: 100%;
73
- margin-inline: auto;
74
- }
75
- @media (min-width: 40em) {
76
- .b-stats__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
77
- .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
78
- .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
79
- }
80
- @media (min-width: 60em) {
81
- .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
82
- .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
83
- }
84
- /* A lone stat is a centred feature, not a full-width slab. */
85
- .b-stats__grid[data-cols='1'] { max-width: 30rem; }
86
- /* Token-driven tile: the same radius / surface / stroke / shadow knobs as every
87
- Card, so it stays consistent and theme-correct. On the muted band the tile's
88
- --bg-surface sits a step above --bg-section, giving depth in both themes. */
89
- .b-stats__item {
90
- display: flex;
91
- flex-direction: column;
92
- align-items: center;
93
- text-align: center;
94
- gap: 0.5rem;
95
- padding: clamp(1.5rem, 3vw, 2.25rem) clamp(1.25rem, 3vw, 1.75rem);
96
- /* surface (bg/border/radius/shadow) comes from .ferst-tile */
97
- }
98
- .b-stats__value {
99
- font-family: var(--font-heading);
100
- font-weight: 700;
101
- font-size: clamp(2.25rem, 1.6rem + 2.6vw, 3.25rem);
102
- line-height: 1;
103
- color: var(--gold);
104
- }
105
- .b-stats__label { font-weight: 600; color: var(--fg); line-height: 1.4; max-width: 30ch; }
106
- .b-stats__desc { font-size: 0.875rem; color: var(--muted); line-height: 1.5; max-width: 32ch; }
107
- </style>
108
-
109
- <script>
110
- // Count-up: numbers tick from 0 to their value when they scroll into view.
111
- // Progressive — the final value is server-rendered, so with no JS (or under
112
- // reduced-motion) it just shows the number. Non-numeric values (One, Any…) are
113
- // left untouched; a leading sign / trailing suffix (+, %, " day") is preserved.
114
- const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
115
- const values = document.querySelectorAll<HTMLElement>('.b-stats__value');
116
- if (!reduce && 'IntersectionObserver' in window && values.length) {
117
- const parse = (t: string) => {
118
- const m = t.match(/^(\D*)(\d[\d,]*(?:\.\d+)?)(.*)$/);
119
- if (!m) return null;
120
- const decimals = (m[2].split('.')[1] || '').length;
121
- return { pre: m[1], num: parseFloat(m[2].replace(/,/g, '')), suf: m[3], decimals };
122
- };
123
- const fmt = (n: number, d: number) => (d > 0 ? n.toFixed(d) : Math.round(n).toLocaleString());
124
- const run = (el: HTMLElement) => {
125
- const final = (el.textContent || '').trim();
126
- const p = parse(final);
127
- if (!p) return;
128
- const dur = 1100;
129
- const t0 = performance.now();
130
- const tick = (now: number) => {
131
- const t = Math.min(1, (now - t0) / dur);
132
- const eased = 1 - Math.pow(1 - t, 3);
133
- el.textContent = p.pre + fmt(p.num * eased, p.decimals) + p.suf;
134
- if (t < 1) requestAnimationFrame(tick);
135
- else el.textContent = final;
136
- };
137
- requestAnimationFrame(tick);
138
- };
139
- const io = new IntersectionObserver(
140
- (entries) => entries.forEach((e) => { if (e.isIntersecting) { run(e.target as HTMLElement); io.unobserve(e.target); } }),
141
- { threshold: 0.4 }
142
- );
143
- values.forEach((v) => io.observe(v));
144
- }
145
- </script>
1
+ ---
2
+ /** Stats — a Level-2 recipe: a band of headline numbers over an optional
3
+ * heading/intro. Composes the Heading primitive; numbers use the brand accent.
4
+ *
5
+ * Self-composing: the grid never reserves more columns than there are stats, so
6
+ * a single item centres as a feature instead of stranding an empty half-band
7
+ * (the "£10 pinned to one side" bug); each stat is a token-driven tile (radius/
8
+ * surface/stroke/shadow knobs, like every Card), so it reads as a designed unit
9
+ * at any count and in either theme. Labels are width-capped so a long caption
10
+ * wraps to a tidy block rather than a full-width line. */
11
+ import Heading from '../blocks/Heading.astro';
12
+
13
+ interface Item { value: string; label: string; description?: string }
14
+ interface Props {
15
+ title?: string;
16
+ intro?: string;
17
+ background?: 'none' | 'muted' | 'accent';
18
+ columns?: 2 | 3 | 4;
19
+ items?: Item[];
20
+ }
21
+ const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.props;
22
+ // Cap the column count at the item count — one stat should never sit in a
23
+ // two-column grid (half empty). Falls back to 1 for an empty list.
24
+ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
25
+ ---
26
+
27
+ <section class="b-stats" data-bg={background}>
28
+ <div class="b-stats__inner">
29
+ {(title || intro) && (
30
+ <div class="b-stats__head">
31
+ {title && <Heading text={title} level={2} align="center" />}
32
+ {intro && <p class="b-stats__intro">{intro}</p>}
33
+ </div>
34
+ )}
35
+ <div class="b-stats__grid" data-cols={cols}>
36
+ {items.map((item) => (
37
+ <div class="b-stats__item ferst-tile">
38
+ <span class="b-stats__value">{item.value}</span>
39
+ {item.label && <span class="b-stats__label">{item.label}</span>}
40
+ {item.description && <span class="b-stats__desc">{item.description}</span>}
41
+ </div>
42
+ ))}
43
+ </div>
44
+ </div>
45
+ </section>
46
+
47
+ <style>
48
+ .b-stats { padding-block: clamp(2.5rem, 6vw, 4rem); }
49
+ .b-stats[data-bg='muted'] { background: var(--bg-section); }
50
+ .b-stats[data-bg='accent'] { background: var(--gold-light); }
51
+ .b-stats__inner {
52
+ max-width: 72rem;
53
+ margin-inline: auto;
54
+ padding-inline: 1rem;
55
+ display: flex;
56
+ flex-direction: column;
57
+ gap: 2rem;
58
+ }
59
+ .b-stats__head {
60
+ max-width: var(--max);
61
+ margin-inline: auto;
62
+ text-align: center;
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: 0.75rem;
66
+ }
67
+ .b-stats__intro { margin: 0; color: var(--muted); line-height: 1.6; }
68
+ .b-stats__grid {
69
+ display: grid;
70
+ grid-template-columns: 1fr;
71
+ gap: 1.25rem;
72
+ width: 100%;
73
+ margin-inline: auto;
74
+ }
75
+ @media (min-width: 40em) {
76
+ .b-stats__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
77
+ .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
78
+ .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
79
+ }
80
+ @media (min-width: 60em) {
81
+ .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
82
+ .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
83
+ }
84
+ /* A lone stat is a centred feature, not a full-width slab. */
85
+ .b-stats__grid[data-cols='1'] { max-width: 30rem; }
86
+ /* Token-driven tile: the same radius / surface / stroke / shadow knobs as every
87
+ Card, so it stays consistent and theme-correct. On the muted band the tile's
88
+ --bg-surface sits a step above --bg-section, giving depth in both themes. */
89
+ .b-stats__item {
90
+ display: flex;
91
+ flex-direction: column;
92
+ align-items: center;
93
+ text-align: center;
94
+ gap: 0.5rem;
95
+ padding: clamp(1.5rem, 3vw, 2.25rem) clamp(1.25rem, 3vw, 1.75rem);
96
+ /* surface (bg/border/radius/shadow) comes from .ferst-tile */
97
+ }
98
+ .b-stats__value {
99
+ font-family: var(--font-heading);
100
+ font-weight: 700;
101
+ font-size: clamp(2.25rem, 1.6rem + 2.6vw, 3.25rem);
102
+ line-height: 1;
103
+ color: var(--gold);
104
+ }
105
+ .b-stats__label { font-weight: 600; color: var(--fg); line-height: 1.4; max-width: 30ch; }
106
+ .b-stats__desc { font-size: 0.875rem; color: var(--muted); line-height: 1.5; max-width: 32ch; }
107
+ /* One key figure: centred, not stranded at the start of a wide row. */
108
+ .b-stats__grid[data-cols='1'] {
109
+ max-width: 28rem;
110
+ margin-inline: auto;
111
+ text-align: center;
112
+ justify-items: center;
113
+ }
114
+ </style>
115
+
116
+ <script>
117
+ // Count-up: numbers tick from 0 to their value when they scroll into view.
118
+ // Progressive — the final value is server-rendered, so with no JS (or under
119
+ // reduced-motion) it just shows the number. Non-numeric values (One, Any…) are
120
+ // left untouched; a leading sign / trailing suffix (+, %, " day") is preserved.
121
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
122
+ const values = document.querySelectorAll<HTMLElement>('.b-stats__value');
123
+ if (!reduce && 'IntersectionObserver' in window && values.length) {
124
+ const parse = (t: string) => {
125
+ const m = t.match(/^(\D*)(\d[\d,]*(?:\.\d+)?)(.*)$/);
126
+ if (!m) return null;
127
+ const decimals = (m[2].split('.')[1] || '').length;
128
+ return { pre: m[1], num: parseFloat(m[2].replace(/,/g, '')), suf: m[3], decimals };
129
+ };
130
+ const fmt = (n: number, d: number) => (d > 0 ? n.toFixed(d) : Math.round(n).toLocaleString());
131
+ const run = (el: HTMLElement) => {
132
+ const final = (el.textContent || '').trim();
133
+ const p = parse(final);
134
+ if (!p) return;
135
+ const dur = 1100;
136
+ const t0 = performance.now();
137
+ const tick = (now: number) => {
138
+ const t = Math.min(1, (now - t0) / dur);
139
+ const eased = 1 - Math.pow(1 - t, 3);
140
+ el.textContent = p.pre + fmt(p.num * eased, p.decimals) + p.suf;
141
+ if (t < 1) requestAnimationFrame(tick);
142
+ else el.textContent = final;
143
+ };
144
+ requestAnimationFrame(tick);
145
+ };
146
+ const io = new IntersectionObserver(
147
+ (entries) => entries.forEach((e) => { if (e.isIntersecting) { run(e.target as HTMLElement); io.unobserve(e.target); } }),
148
+ { threshold: 0.4 }
149
+ );
150
+ values.forEach((v) => io.observe(v));
151
+ }
152
+ </script>
@@ -1,97 +1,98 @@
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
+ interface Props {
12
+ title?: string;
13
+ intro?: string;
14
+ columns?: string[];
15
+ rows?: string[][];
16
+ }
17
+ const { title, intro, columns = [], rows = [] } = Astro.props;
18
+ ---
19
+
20
+ <section class="b-table">
21
+ {(title || intro) && (
22
+ <div class="b-table__head">
23
+ {title && <Heading text={title} level={2} />}
24
+ {intro && <p class="b-table__intro" set:html={inlineMarkdown(intro)} />}
25
+ </div>
26
+ )}
27
+ <div class="b-table__scroll">
28
+ <table class="b-table__table">
29
+ {columns.length > 0 && (
30
+ <thead>
31
+ <tr>{columns.map((c) => <th scope="col">{c}</th>)}</tr>
32
+ </thead>
33
+ )}
34
+ <tbody>
35
+ {rows.map((row) => (
36
+ <tr>{row.map((cell) => <td set:html={inlineMarkdown(cell)} />)}</tr>
37
+ ))}
38
+ </tbody>
39
+ </table>
40
+ </div>
41
+ </section>
42
+
43
+ <style>
44
+ .b-table {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 1.25rem;
48
+ max-width: 72rem;
49
+ margin-inline: auto;
50
+ padding-inline: 1rem;
51
+ }
52
+ .b-table__head {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 0.6rem;
56
+ }
57
+ .b-table__intro {
58
+ margin: 0;
59
+ color: var(--muted);
60
+ max-width: var(--max);
61
+ }
62
+ /* Its own scroll container so a wide table scrolls sideways instead of forcing
63
+ the whole page to (the body never scrolls horizontally). */
64
+ .b-table__scroll {
65
+ overflow-x: auto;
66
+ border: var(--card-border);
67
+ border-radius: var(--radius);
68
+ }
69
+ .b-table__table {
70
+ width: 100%;
71
+ border-collapse: collapse;
72
+ font-size: 0.95rem;
73
+ }
74
+ .b-table__table th,
75
+ .b-table__table td {
76
+ padding: 0.7rem 0.9rem;
77
+ text-align: start;
78
+ vertical-align: top;
79
+ border-bottom: 1px solid var(--border);
80
+ }
81
+ .b-table__table thead th {
82
+ font-family: var(--font-heading);
83
+ font-weight: 600;
84
+ color: var(--fg);
85
+ background: var(--bg-section);
86
+ white-space: nowrap;
87
+ }
88
+ .b-table__table tbody td {
89
+ color: var(--muted);
90
+ line-height: 1.55;
91
+ }
92
+ .b-table__table tbody tr:nth-child(even) td {
93
+ background: var(--bg-section);
94
+ }
95
+ .b-table__table tbody tr:last-child td {
96
+ border-bottom: 0;
97
+ }
98
+ </style>