ferst-core 0.2.3 → 0.2.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/check-thin.mjs +1 -1
- package/components/BlockRenderer.astro +8 -0
- package/components/StickyCallout.astro +202 -0
- package/components/recipes/LatestPosts.astro +182 -0
- package/components/recipes/MapEmbed.astro +188 -0
- package/components/recipes/Notice.astro +89 -0
- package/components/recipes/Table.astro +97 -0
- package/content/blocks.ts +71 -1
- package/content/callout.ts +32 -0
- package/content/collections.ts +5 -0
- package/layouts/Base.astro +7 -0
- package/lib/callout.ts +13 -0
- package/package.json +62 -62
package/bin/check-thin.mjs
CHANGED
|
@@ -15,7 +15,7 @@ const CORE_OWNED_DIRS = ['components', 'layouts', 'lib', 'utils', 'styles'];
|
|
|
15
15
|
// Files under src/content/ that are the core's contract, not client data.
|
|
16
16
|
// (Directories like content/posts/ and content/pages/ are client DATA — allowed;
|
|
17
17
|
// these are the single-source contract FILES a client must import, never copy.)
|
|
18
|
-
const CORE_OWNED_CONTENT_FILES = ['schemas.ts', 'collections.ts', 'blocks.ts', 'posts.ts', 'calendar.ts'];
|
|
18
|
+
const CORE_OWNED_CONTENT_FILES = ['schemas.ts', 'collections.ts', 'blocks.ts', 'posts.ts', 'calendar.ts', 'callout.ts'];
|
|
19
19
|
|
|
20
20
|
const toPosix = (s) => s.replace(/\\/g, '/');
|
|
21
21
|
|
|
@@ -37,6 +37,10 @@ import Tabs from './recipes/Tabs.astro';
|
|
|
37
37
|
import Announcement from './recipes/Announcement.astro';
|
|
38
38
|
import Bento from './recipes/Bento.astro';
|
|
39
39
|
import Marquee from './recipes/Marquee.astro';
|
|
40
|
+
import LatestPosts from './recipes/LatestPosts.astro';
|
|
41
|
+
import MapEmbed from './recipes/MapEmbed.astro';
|
|
42
|
+
import Table from './recipes/Table.astro';
|
|
43
|
+
import Notice from './recipes/Notice.astro';
|
|
40
44
|
|
|
41
45
|
interface Props {
|
|
42
46
|
blocks: Block[];
|
|
@@ -76,6 +80,10 @@ const registry: Record<BlockType, any> = {
|
|
|
76
80
|
announcement: Announcement,
|
|
77
81
|
bento: Bento,
|
|
78
82
|
marquee: Marquee,
|
|
83
|
+
latestPosts: LatestPosts,
|
|
84
|
+
mapEmbed: MapEmbed,
|
|
85
|
+
table: Table,
|
|
86
|
+
notice: Notice,
|
|
79
87
|
};
|
|
80
88
|
---
|
|
81
89
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* StickyCallout — site-level chrome: a fixed corner launcher that expands a small
|
|
4
|
+
* panel with one key piece of info + an optional link. The reusable, industry-
|
|
5
|
+
* standard form of a "mass times / opening hours / book now / contact" widget
|
|
6
|
+
* (like a support-chat launcher): pinned to the corner, on every page, staying put
|
|
7
|
+
* as you scroll. Settings-driven (calloutSettings), rendered by Base. Renders
|
|
8
|
+
* nothing unless enabled AND given a title or text. Progressive: it's a native
|
|
9
|
+
* <details>, so open/close works with no JS; close + "don't show again" are JS
|
|
10
|
+
* enhancements. White-room, token-driven.
|
|
11
|
+
*/
|
|
12
|
+
import Icon from './Icon.astro';
|
|
13
|
+
import { isIconKey } from '../lib/icons';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
label?: string;
|
|
18
|
+
icon?: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
text?: string;
|
|
21
|
+
link?: { label: string; href: string };
|
|
22
|
+
position?: 'bottom-right' | 'bottom-left';
|
|
23
|
+
dismissible?: boolean;
|
|
24
|
+
id?: string;
|
|
25
|
+
}
|
|
26
|
+
const {
|
|
27
|
+
enabled = false,
|
|
28
|
+
label = 'Quick info',
|
|
29
|
+
icon = 'calendar',
|
|
30
|
+
title = '',
|
|
31
|
+
text = '',
|
|
32
|
+
link,
|
|
33
|
+
position = 'bottom-right',
|
|
34
|
+
dismissible = true,
|
|
35
|
+
id = 'default',
|
|
36
|
+
} = Astro.props;
|
|
37
|
+
|
|
38
|
+
const hasContent = enabled && (title.trim() !== '' || text.trim() !== '');
|
|
39
|
+
// Blank lines separate paragraphs (same convention as the prose block).
|
|
40
|
+
const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
{hasContent && (
|
|
44
|
+
<details class="b-callout" data-position={position} data-id={id}>
|
|
45
|
+
<summary class="b-callout__trigger">
|
|
46
|
+
{isIconKey(icon) && (
|
|
47
|
+
<span class="b-callout__trigger-icon" aria-hidden="true"><Icon name={icon} size={20} /></span>
|
|
48
|
+
)}
|
|
49
|
+
<span class="b-callout__trigger-label">{label}</span>
|
|
50
|
+
</summary>
|
|
51
|
+
<div class="b-callout__panel" role="group" aria-label={title || label}>
|
|
52
|
+
<div class="b-callout__head">
|
|
53
|
+
{title && <p class="b-callout__title">{title}</p>}
|
|
54
|
+
<button type="button" class="b-callout__close" aria-label="Close">
|
|
55
|
+
<span aria-hidden="true">×</span>
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
{paras.map((p) => <p class="b-callout__text">{p}</p>)}
|
|
59
|
+
{link && (
|
|
60
|
+
<a class="b-callout__link" href={link.href}>{link.label}</a>
|
|
61
|
+
)}
|
|
62
|
+
{dismissible && (
|
|
63
|
+
<button type="button" class="b-callout__dismiss">Don’t show this again</button>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
</details>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
<script>
|
|
70
|
+
// Bundled (runs once). Each callout: honour a prior dismissal, wire close (sets
|
|
71
|
+
// the <details> shut) and "don't show again" (hide + remember per id).
|
|
72
|
+
document.querySelectorAll<HTMLDetailsElement>('.b-callout').forEach((el) => {
|
|
73
|
+
const id = el.dataset.id || 'default';
|
|
74
|
+
const KEY = 'ferst-callout-dismissed:' + id;
|
|
75
|
+
try {
|
|
76
|
+
if (localStorage.getItem(KEY) === '1') {
|
|
77
|
+
el.remove();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
} catch (_) {}
|
|
81
|
+
|
|
82
|
+
el.querySelector<HTMLButtonElement>('.b-callout__close')?.addEventListener('click', () => {
|
|
83
|
+
el.open = false;
|
|
84
|
+
});
|
|
85
|
+
el.querySelector<HTMLButtonElement>('.b-callout__dismiss')?.addEventListener('click', () => {
|
|
86
|
+
try {
|
|
87
|
+
localStorage.setItem(KEY, '1');
|
|
88
|
+
} catch (_) {}
|
|
89
|
+
el.remove();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<style>
|
|
95
|
+
.b-callout {
|
|
96
|
+
position: fixed;
|
|
97
|
+
bottom: clamp(1rem, 3vw, 1.5rem);
|
|
98
|
+
z-index: 60;
|
|
99
|
+
max-width: calc(100vw - 2rem);
|
|
100
|
+
}
|
|
101
|
+
.b-callout[data-position='bottom-right'] {
|
|
102
|
+
right: clamp(1rem, 3vw, 1.5rem);
|
|
103
|
+
}
|
|
104
|
+
.b-callout[data-position='bottom-left'] {
|
|
105
|
+
left: clamp(1rem, 3vw, 1.5rem);
|
|
106
|
+
}
|
|
107
|
+
/* Trigger — a rounded pill launcher (icon + label). */
|
|
108
|
+
.b-callout__trigger {
|
|
109
|
+
display: inline-flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
gap: 0.5rem;
|
|
112
|
+
padding: 0.6rem 1.05rem;
|
|
113
|
+
background: var(--gold);
|
|
114
|
+
color: var(--text-light);
|
|
115
|
+
border-radius: 999px;
|
|
116
|
+
box-shadow: var(--shadow-md);
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
font-family: var(--font-heading);
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
font-size: 0.95rem;
|
|
121
|
+
list-style: none;
|
|
122
|
+
user-select: none;
|
|
123
|
+
transition: filter 0.15s ease, transform 0.15s ease;
|
|
124
|
+
}
|
|
125
|
+
.b-callout__trigger::-webkit-details-marker { display: none; }
|
|
126
|
+
.b-callout__trigger:hover { filter: brightness(0.95); transform: translateY(-1px); }
|
|
127
|
+
.b-callout__trigger:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
|
128
|
+
.b-callout__trigger-icon { display: inline-flex; }
|
|
129
|
+
|
|
130
|
+
/* Panel — a card floating above the pill; opens upward from the corner. */
|
|
131
|
+
.b-callout__panel {
|
|
132
|
+
position: absolute;
|
|
133
|
+
bottom: calc(100% + 0.6rem);
|
|
134
|
+
width: min(20rem, calc(100vw - 2rem));
|
|
135
|
+
display: flex;
|
|
136
|
+
flex-direction: column;
|
|
137
|
+
gap: 0.5rem;
|
|
138
|
+
padding: 1rem 1.1rem 1.15rem;
|
|
139
|
+
background: var(--bg-surface);
|
|
140
|
+
color: var(--fg);
|
|
141
|
+
border: var(--card-border);
|
|
142
|
+
border-radius: var(--radius);
|
|
143
|
+
box-shadow: var(--shadow-md);
|
|
144
|
+
text-align: start;
|
|
145
|
+
}
|
|
146
|
+
.b-callout[data-position='bottom-right'] .b-callout__panel { right: 0; }
|
|
147
|
+
.b-callout[data-position='bottom-left'] .b-callout__panel { left: 0; }
|
|
148
|
+
|
|
149
|
+
.b-callout__head {
|
|
150
|
+
display: flex;
|
|
151
|
+
align-items: flex-start;
|
|
152
|
+
justify-content: space-between;
|
|
153
|
+
gap: 0.75rem;
|
|
154
|
+
}
|
|
155
|
+
.b-callout__title {
|
|
156
|
+
margin: 0;
|
|
157
|
+
font-family: var(--font-heading);
|
|
158
|
+
font-weight: 600;
|
|
159
|
+
font-size: 1.05rem;
|
|
160
|
+
color: var(--fg);
|
|
161
|
+
}
|
|
162
|
+
.b-callout__close {
|
|
163
|
+
flex-shrink: 0;
|
|
164
|
+
border: none;
|
|
165
|
+
background: transparent;
|
|
166
|
+
color: var(--muted);
|
|
167
|
+
font-size: 1.4rem;
|
|
168
|
+
line-height: 1;
|
|
169
|
+
padding: 0 0.15rem;
|
|
170
|
+
cursor: pointer;
|
|
171
|
+
border-radius: var(--radius-sm);
|
|
172
|
+
}
|
|
173
|
+
.b-callout__close:hover { color: var(--fg); }
|
|
174
|
+
.b-callout__close:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
|
175
|
+
.b-callout__text {
|
|
176
|
+
margin: 0;
|
|
177
|
+
color: var(--muted);
|
|
178
|
+
font-size: 0.95rem;
|
|
179
|
+
line-height: 1.55;
|
|
180
|
+
}
|
|
181
|
+
.b-callout__link {
|
|
182
|
+
margin-top: 0.15rem;
|
|
183
|
+
color: var(--gold);
|
|
184
|
+
font-weight: 600;
|
|
185
|
+
}
|
|
186
|
+
.b-callout__dismiss {
|
|
187
|
+
margin-top: 0.35rem;
|
|
188
|
+
align-self: flex-start;
|
|
189
|
+
border: none;
|
|
190
|
+
background: transparent;
|
|
191
|
+
color: var(--muted);
|
|
192
|
+
font-size: 0.8rem;
|
|
193
|
+
padding: 0;
|
|
194
|
+
cursor: pointer;
|
|
195
|
+
text-decoration: underline;
|
|
196
|
+
}
|
|
197
|
+
.b-callout__dismiss:hover { color: var(--fg); }
|
|
198
|
+
|
|
199
|
+
@media (prefers-reduced-motion: reduce) {
|
|
200
|
+
.b-callout__trigger { transition: none; }
|
|
201
|
+
}
|
|
202
|
+
</style>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* LatestPosts — a Level-2 recipe that surfaces the posts capability inside a page:
|
|
4
|
+
* the N most-recent published posts (optionally filtered to a tag), as a card grid
|
|
5
|
+
* or a compact list, with a "view all" link. This is the block that bridges the
|
|
6
|
+
* posts *capability* into the block *system*, so a homepage "Latest news" needs no
|
|
7
|
+
* per-client custom code. The posts are queried at build time from the client's
|
|
8
|
+
* own posts collection (empty state renders when there are none).
|
|
9
|
+
*/
|
|
10
|
+
import PostCard from '../PostCard.astro';
|
|
11
|
+
import Heading from '../blocks/Heading.astro';
|
|
12
|
+
import Button from '../Button.astro';
|
|
13
|
+
import { getIndexPosts, getPostsByTag } from '../../lib/posts';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
title?: string;
|
|
17
|
+
intro?: string;
|
|
18
|
+
limit?: number;
|
|
19
|
+
tag?: string;
|
|
20
|
+
layout?: 'grid' | 'list';
|
|
21
|
+
viewAllHref?: string;
|
|
22
|
+
viewAllLabel?: string;
|
|
23
|
+
}
|
|
24
|
+
const {
|
|
25
|
+
title,
|
|
26
|
+
intro,
|
|
27
|
+
limit = 3,
|
|
28
|
+
tag,
|
|
29
|
+
layout = 'grid',
|
|
30
|
+
viewAllHref = '/posts',
|
|
31
|
+
viewAllLabel = 'All posts',
|
|
32
|
+
} = Astro.props;
|
|
33
|
+
|
|
34
|
+
const all = tag ? await getPostsByTag(tag) : await getIndexPosts();
|
|
35
|
+
const posts = all.slice(0, Math.max(1, limit));
|
|
36
|
+
|
|
37
|
+
const fmt = (date: Date | string) => {
|
|
38
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
39
|
+
return Number.isNaN(d.valueOf())
|
|
40
|
+
? { iso: undefined as string | undefined, label: '' }
|
|
41
|
+
: { iso: d.toISOString().slice(0, 10), label: d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) };
|
|
42
|
+
};
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
<section class="b-latest-posts">
|
|
46
|
+
{(title || intro) && (
|
|
47
|
+
<div class="b-latest-posts__head">
|
|
48
|
+
{title && <Heading text={title} level={2} />}
|
|
49
|
+
{intro && <p class="b-latest-posts__intro">{intro}</p>}
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
{posts.length === 0 ? (
|
|
54
|
+
<p class="b-latest-posts__empty">No posts yet.</p>
|
|
55
|
+
) : layout === 'list' ? (
|
|
56
|
+
<ul class="b-latest-posts__list">
|
|
57
|
+
{posts.map((p) => {
|
|
58
|
+
const { iso, label } = fmt(p.data.date);
|
|
59
|
+
return (
|
|
60
|
+
<li class="b-latest-posts__row">
|
|
61
|
+
<a class="b-latest-posts__row-link" href={`/posts/${p.slug}`}>
|
|
62
|
+
<span class="b-latest-posts__row-title">{p.data.title}</span>
|
|
63
|
+
{label && <time class="b-latest-posts__row-date" datetime={iso}>{label}</time>}
|
|
64
|
+
</a>
|
|
65
|
+
{p.data.summary && <p class="b-latest-posts__row-summary">{p.data.summary}</p>}
|
|
66
|
+
</li>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</ul>
|
|
70
|
+
) : (
|
|
71
|
+
<div class="b-latest-posts__grid">
|
|
72
|
+
{posts.map((p) => (
|
|
73
|
+
<PostCard
|
|
74
|
+
href={`/posts/${p.slug}`}
|
|
75
|
+
title={p.data.title}
|
|
76
|
+
date={p.data.date}
|
|
77
|
+
summary={p.data.summary}
|
|
78
|
+
heroImage={p.data.heroImage}
|
|
79
|
+
tagLabels={p.data.tags}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{posts.length > 0 && viewAllHref && (
|
|
86
|
+
<div class="b-latest-posts__more">
|
|
87
|
+
<Button href={viewAllHref} variant="secondary" size="sm">{viewAllLabel}</Button>
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
</section>
|
|
91
|
+
|
|
92
|
+
<style>
|
|
93
|
+
.b-latest-posts {
|
|
94
|
+
display: flex;
|
|
95
|
+
flex-direction: column;
|
|
96
|
+
gap: 1.75rem;
|
|
97
|
+
max-width: 72rem;
|
|
98
|
+
margin-inline: auto;
|
|
99
|
+
padding-inline: 1rem;
|
|
100
|
+
}
|
|
101
|
+
.b-latest-posts__head {
|
|
102
|
+
display: flex;
|
|
103
|
+
flex-direction: column;
|
|
104
|
+
gap: 0.6rem;
|
|
105
|
+
}
|
|
106
|
+
.b-latest-posts__intro {
|
|
107
|
+
margin: 0;
|
|
108
|
+
color: var(--muted);
|
|
109
|
+
font-size: 1.0625rem;
|
|
110
|
+
line-height: 1.6;
|
|
111
|
+
max-width: var(--max);
|
|
112
|
+
}
|
|
113
|
+
/* Grid — mirrors the posts index/tag layout (any image ratio cropped by PostCard). */
|
|
114
|
+
.b-latest-posts__grid {
|
|
115
|
+
display: grid;
|
|
116
|
+
grid-template-columns: 1fr;
|
|
117
|
+
gap: 1.5rem;
|
|
118
|
+
}
|
|
119
|
+
@media (min-width: 40em) {
|
|
120
|
+
.b-latest-posts__grid { grid-template-columns: repeat(2, 1fr); }
|
|
121
|
+
}
|
|
122
|
+
@media (min-width: 60em) {
|
|
123
|
+
.b-latest-posts__grid { grid-template-columns: repeat(3, 1fr); }
|
|
124
|
+
}
|
|
125
|
+
/* Compact list — no images; a tidy dated index for sidebars or short homepages. */
|
|
126
|
+
.b-latest-posts__list {
|
|
127
|
+
list-style: none;
|
|
128
|
+
margin: 0;
|
|
129
|
+
padding: 0;
|
|
130
|
+
display: flex;
|
|
131
|
+
flex-direction: column;
|
|
132
|
+
}
|
|
133
|
+
.b-latest-posts__row {
|
|
134
|
+
padding: 0.9rem 0;
|
|
135
|
+
border-top: var(--card-border);
|
|
136
|
+
}
|
|
137
|
+
.b-latest-posts__row:first-child {
|
|
138
|
+
border-top: 0;
|
|
139
|
+
}
|
|
140
|
+
.b-latest-posts__row-link {
|
|
141
|
+
display: flex;
|
|
142
|
+
flex-wrap: wrap;
|
|
143
|
+
align-items: baseline;
|
|
144
|
+
justify-content: space-between;
|
|
145
|
+
gap: 0.25rem 1rem;
|
|
146
|
+
color: var(--fg);
|
|
147
|
+
text-decoration: none;
|
|
148
|
+
}
|
|
149
|
+
.b-latest-posts__row-link:hover .b-latest-posts__row-title {
|
|
150
|
+
color: var(--gold);
|
|
151
|
+
}
|
|
152
|
+
.b-latest-posts__row-title {
|
|
153
|
+
font-family: var(--font-heading);
|
|
154
|
+
font-weight: 600;
|
|
155
|
+
font-size: 1.05rem;
|
|
156
|
+
}
|
|
157
|
+
.b-latest-posts__row-date {
|
|
158
|
+
color: var(--muted);
|
|
159
|
+
font-size: 0.85rem;
|
|
160
|
+
white-space: nowrap;
|
|
161
|
+
}
|
|
162
|
+
.b-latest-posts__row-summary {
|
|
163
|
+
margin: 0.35rem 0 0;
|
|
164
|
+
color: var(--muted);
|
|
165
|
+
font-size: 0.95rem;
|
|
166
|
+
line-height: 1.55;
|
|
167
|
+
}
|
|
168
|
+
.b-latest-posts__empty {
|
|
169
|
+
margin: 0;
|
|
170
|
+
color: var(--muted);
|
|
171
|
+
}
|
|
172
|
+
.b-latest-posts__more {
|
|
173
|
+
display: flex;
|
|
174
|
+
}
|
|
175
|
+
/* Follow the page's content-alignment for the "view all" action. */
|
|
176
|
+
:global([data-content-align='center']) .b-latest-posts__more {
|
|
177
|
+
justify-content: center;
|
|
178
|
+
}
|
|
179
|
+
:global([data-content-align='end']) .b-latest-posts__more {
|
|
180
|
+
justify-content: flex-end;
|
|
181
|
+
}
|
|
182
|
+
</style>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* MapEmbed — a consent-gated external map embed (e.g. Google Maps), the sibling of
|
|
4
|
+
* CalendarEmbed. Nothing loads from the provider until the visitor clicks "Load
|
|
5
|
+
* map", so no third-party request fires on page load (privacy-first, matching the
|
|
6
|
+
* site's cookie stance). Consent is remembered per visitor (localStorage). A page
|
|
7
|
+
* block (drop it on a contact/find-us page), white-room, token-driven.
|
|
8
|
+
*
|
|
9
|
+
* The embed URL must be https (isSafeEmbedUrl) before it can reach the iframe; an
|
|
10
|
+
* unset or unsafe URL degrades to a friendly note + optional "open in Maps" link.
|
|
11
|
+
*/
|
|
12
|
+
import { isSafeEmbedUrl } from '../../content/calendar';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
title?: string;
|
|
16
|
+
intro?: string;
|
|
17
|
+
embedUrl?: string;
|
|
18
|
+
publicUrl?: string;
|
|
19
|
+
height?: number;
|
|
20
|
+
provider?: string;
|
|
21
|
+
}
|
|
22
|
+
const {
|
|
23
|
+
title = '',
|
|
24
|
+
intro = '',
|
|
25
|
+
embedUrl = '',
|
|
26
|
+
publicUrl = '',
|
|
27
|
+
height = 450,
|
|
28
|
+
provider = 'Google Maps',
|
|
29
|
+
} = Astro.props;
|
|
30
|
+
|
|
31
|
+
const safe = isSafeEmbedUrl(embedUrl);
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<section class="b-map">
|
|
35
|
+
{title && <h2 class="b-map__title">{title}</h2>}
|
|
36
|
+
{intro && <p class="b-map__intro">{intro}</p>}
|
|
37
|
+
|
|
38
|
+
{!embedUrl ? (
|
|
39
|
+
<p class="b-map__note">The map isn't set up yet.</p>
|
|
40
|
+
) : !safe ? (
|
|
41
|
+
<p class="b-map__note">
|
|
42
|
+
This map can't be displayed here.
|
|
43
|
+
{publicUrl && (
|
|
44
|
+
<> <a href={publicUrl} target="_blank" rel="noopener">Open it in a new tab</a>.</>
|
|
45
|
+
)}
|
|
46
|
+
</p>
|
|
47
|
+
) : (
|
|
48
|
+
<div
|
|
49
|
+
class="b-map__embed"
|
|
50
|
+
data-embed-url={embedUrl}
|
|
51
|
+
data-height={String(height)}
|
|
52
|
+
data-title={title || 'Map'}
|
|
53
|
+
>
|
|
54
|
+
<div class="b-map__consent">
|
|
55
|
+
<p class="b-map__consent-text">
|
|
56
|
+
This map is provided by <strong>{provider}</strong>. Loading it shares your
|
|
57
|
+
device's data (such as your IP address) with {provider}.
|
|
58
|
+
</p>
|
|
59
|
+
<div class="b-map__actions">
|
|
60
|
+
<button type="button" class="b-map__load">Load map</button>
|
|
61
|
+
{publicUrl && (
|
|
62
|
+
<a class="b-map__link" href={publicUrl} target="_blank" rel="noopener">
|
|
63
|
+
Open in a new tab instead
|
|
64
|
+
</a>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="b-map__frame"></div>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</section>
|
|
72
|
+
|
|
73
|
+
<script>
|
|
74
|
+
// Bundled (not is:inline), so it runs once even with multiple maps on a page.
|
|
75
|
+
// Wires each embed's consent gate; auto-loads where consent was given.
|
|
76
|
+
const CONSENT_KEY = 'ferst-map-consent';
|
|
77
|
+
document.querySelectorAll<HTMLElement>('.b-map__embed').forEach((el) => {
|
|
78
|
+
const url = el.dataset.embedUrl;
|
|
79
|
+
if (!url) return;
|
|
80
|
+
const height = el.dataset.height || '450';
|
|
81
|
+
const title = el.dataset.title || 'Map';
|
|
82
|
+
const frameSlot = el.querySelector<HTMLElement>('.b-map__frame');
|
|
83
|
+
const consent = el.querySelector<HTMLElement>('.b-map__consent');
|
|
84
|
+
const btn = el.querySelector<HTMLButtonElement>('.b-map__load');
|
|
85
|
+
|
|
86
|
+
function load() {
|
|
87
|
+
if (el.dataset.loaded === 'true' || !frameSlot) return;
|
|
88
|
+
const frame = document.createElement('iframe');
|
|
89
|
+
frame.src = url as string;
|
|
90
|
+
frame.title = title;
|
|
91
|
+
frame.loading = 'lazy';
|
|
92
|
+
frame.referrerPolicy = 'no-referrer-when-downgrade';
|
|
93
|
+
frame.style.width = '100%';
|
|
94
|
+
frame.style.height = height + 'px';
|
|
95
|
+
frame.style.border = '0';
|
|
96
|
+
frameSlot.replaceChildren(frame);
|
|
97
|
+
el.dataset.loaded = 'true';
|
|
98
|
+
if (consent) consent.hidden = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
if (localStorage.getItem(CONSENT_KEY) === '1') load();
|
|
103
|
+
} catch (_) {}
|
|
104
|
+
|
|
105
|
+
btn?.addEventListener('click', () => {
|
|
106
|
+
try {
|
|
107
|
+
localStorage.setItem(CONSENT_KEY, '1');
|
|
108
|
+
} catch (_) {}
|
|
109
|
+
load();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
.b-map {
|
|
116
|
+
display: flex;
|
|
117
|
+
flex-direction: column;
|
|
118
|
+
gap: 1rem;
|
|
119
|
+
max-width: 72rem;
|
|
120
|
+
margin-inline: auto;
|
|
121
|
+
padding-inline: 1rem;
|
|
122
|
+
}
|
|
123
|
+
.b-map__title {
|
|
124
|
+
margin: 0;
|
|
125
|
+
}
|
|
126
|
+
.b-map__intro,
|
|
127
|
+
.b-map__note {
|
|
128
|
+
margin: 0;
|
|
129
|
+
color: var(--muted);
|
|
130
|
+
}
|
|
131
|
+
.b-map__note a,
|
|
132
|
+
.b-map__link {
|
|
133
|
+
color: var(--gold);
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
}
|
|
136
|
+
.b-map__frame :global(iframe) {
|
|
137
|
+
display: block;
|
|
138
|
+
border: var(--card-border);
|
|
139
|
+
border-radius: var(--radius);
|
|
140
|
+
background: var(--bg-surface);
|
|
141
|
+
}
|
|
142
|
+
.b-map__consent {
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-direction: column;
|
|
145
|
+
gap: 1rem;
|
|
146
|
+
padding: 2.5rem 1.5rem;
|
|
147
|
+
border: var(--card-border);
|
|
148
|
+
border-radius: var(--radius);
|
|
149
|
+
background: var(--bg-section);
|
|
150
|
+
text-align: center;
|
|
151
|
+
align-items: center;
|
|
152
|
+
}
|
|
153
|
+
.b-map__consent-text {
|
|
154
|
+
margin: 0;
|
|
155
|
+
max-width: 46ch;
|
|
156
|
+
color: var(--muted);
|
|
157
|
+
line-height: 1.6;
|
|
158
|
+
}
|
|
159
|
+
.b-map__actions {
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-wrap: wrap;
|
|
162
|
+
gap: 0.75rem 1.25rem;
|
|
163
|
+
align-items: center;
|
|
164
|
+
justify-content: center;
|
|
165
|
+
}
|
|
166
|
+
.b-map__load {
|
|
167
|
+
font: inherit;
|
|
168
|
+
font-family: var(--font-heading);
|
|
169
|
+
font-weight: 600;
|
|
170
|
+
padding: 0.6rem 1.4rem;
|
|
171
|
+
border: none;
|
|
172
|
+
border-radius: var(--radius-sm);
|
|
173
|
+
background: var(--gold);
|
|
174
|
+
color: var(--text-light);
|
|
175
|
+
cursor: pointer;
|
|
176
|
+
transition: filter 0.15s ease;
|
|
177
|
+
}
|
|
178
|
+
.b-map__load:hover {
|
|
179
|
+
filter: brightness(0.94);
|
|
180
|
+
}
|
|
181
|
+
.b-map__load:focus-visible {
|
|
182
|
+
outline: 2px solid var(--gold);
|
|
183
|
+
outline-offset: 2px;
|
|
184
|
+
}
|
|
185
|
+
.b-map__consent[hidden] {
|
|
186
|
+
display: none;
|
|
187
|
+
}
|
|
188
|
+
</style>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Notice — a Level-2 recipe: an inline highlighted info box (optional icon +
|
|
4
|
+
* title + body + optional link) that sets a passage apart from the surrounding
|
|
5
|
+
* prose — a "reporting a concern", "please note", "how to book" callout. Two
|
|
6
|
+
* tones (accent / neutral) in keeping with badge/announcement; token-driven, so
|
|
7
|
+
* it follows the brand and both themes. The page-level sibling of the corner
|
|
8
|
+
* StickyCallout (this one sits in the content flow).
|
|
9
|
+
*/
|
|
10
|
+
import Icon from '../Icon.astro';
|
|
11
|
+
import { isIconKey } from '../../lib/icons';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
tone?: 'accent' | 'neutral';
|
|
15
|
+
icon?: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
text: string;
|
|
18
|
+
link?: { label: string; href: string };
|
|
19
|
+
}
|
|
20
|
+
const { tone = 'accent', icon, title, text, link } = Astro.props;
|
|
21
|
+
const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<aside class="b-notice" data-tone={tone}>
|
|
25
|
+
{icon && isIconKey(icon) && (
|
|
26
|
+
<span class="b-notice__icon" aria-hidden="true"><Icon name={icon} size={22} /></span>
|
|
27
|
+
)}
|
|
28
|
+
<div class="b-notice__body">
|
|
29
|
+
{title && <p class="b-notice__title">{title}</p>}
|
|
30
|
+
{paras.map((p) => <p class="b-notice__text">{p}</p>)}
|
|
31
|
+
{link && <a class="b-notice__link" href={link.href}>{link.label}</a>}
|
|
32
|
+
</div>
|
|
33
|
+
</aside>
|
|
34
|
+
|
|
35
|
+
<style>
|
|
36
|
+
.b-notice {
|
|
37
|
+
display: flex;
|
|
38
|
+
gap: 0.85rem;
|
|
39
|
+
align-items: flex-start;
|
|
40
|
+
max-width: var(--max);
|
|
41
|
+
margin-inline: auto;
|
|
42
|
+
padding: 1.1rem 1.25rem;
|
|
43
|
+
border-radius: var(--radius);
|
|
44
|
+
border-inline-start: 4px solid var(--gold);
|
|
45
|
+
}
|
|
46
|
+
.b-notice[data-tone='accent'] {
|
|
47
|
+
background: var(--gold-light);
|
|
48
|
+
border-inline-start-color: var(--gold);
|
|
49
|
+
}
|
|
50
|
+
.b-notice[data-tone='neutral'] {
|
|
51
|
+
background: var(--bg-section);
|
|
52
|
+
border-inline-start-color: var(--accent);
|
|
53
|
+
}
|
|
54
|
+
/* On a centred page, keep the box a readable width but centre it as a block;
|
|
55
|
+
text inside stays left (a centred info box reads badly). */
|
|
56
|
+
:global([data-content-align='center']) .b-notice {
|
|
57
|
+
text-align: start;
|
|
58
|
+
}
|
|
59
|
+
.b-notice__icon {
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
color: var(--gold);
|
|
62
|
+
margin-top: 0.1rem;
|
|
63
|
+
flex-shrink: 0;
|
|
64
|
+
}
|
|
65
|
+
.b-notice__body {
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-direction: column;
|
|
68
|
+
gap: 0.4rem;
|
|
69
|
+
min-width: 0;
|
|
70
|
+
}
|
|
71
|
+
.b-notice__title {
|
|
72
|
+
margin: 0;
|
|
73
|
+
font-family: var(--font-heading);
|
|
74
|
+
font-weight: 600;
|
|
75
|
+
font-size: 1.05rem;
|
|
76
|
+
color: var(--fg);
|
|
77
|
+
}
|
|
78
|
+
.b-notice__text {
|
|
79
|
+
margin: 0;
|
|
80
|
+
color: var(--muted);
|
|
81
|
+
line-height: 1.6;
|
|
82
|
+
}
|
|
83
|
+
.b-notice__link {
|
|
84
|
+
margin-top: 0.1rem;
|
|
85
|
+
align-self: flex-start;
|
|
86
|
+
color: var(--gold);
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
}
|
|
89
|
+
</style>
|
|
@@ -0,0 +1,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
|
+
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(--line, var(--card-border-color, rgba(0,0,0,0.08)));
|
|
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>
|
package/content/blocks.ts
CHANGED
|
@@ -380,6 +380,68 @@ export const marqueeBlock = z.object({
|
|
|
380
380
|
speed: z.enum(['slow', 'normal', 'fast']).default('normal'),
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
+
/** LatestPosts — a capability-backed recipe: the N most-recent published posts
|
|
384
|
+
* (optionally filtered to a `tag`) as a card grid or a compact list, with a
|
|
385
|
+
* "view all" link. Unlike other recipes it carries no content of its own — it
|
|
386
|
+
* queries the client's posts collection at build time — so it's the bridge from
|
|
387
|
+
* the posts capability into the page/block system (no per-client custom code). */
|
|
388
|
+
export const latestPostsBlock = z.object({
|
|
389
|
+
type: z.literal('latestPosts'),
|
|
390
|
+
enabled: z.boolean().default(true),
|
|
391
|
+
title: z.string().optional(),
|
|
392
|
+
intro: z.string().optional(),
|
|
393
|
+
limit: z.number().int().min(1).max(12).default(3),
|
|
394
|
+
/** Filter to a single tag slug; omit for the main index (minus excluded tags). */
|
|
395
|
+
tag: z.string().optional(),
|
|
396
|
+
layout: z.enum(['grid', 'list']).default('grid'),
|
|
397
|
+
/** "View all" target + label (the posts index by default). */
|
|
398
|
+
viewAllHref: z.string().default('/posts'),
|
|
399
|
+
viewAllLabel: z.string().default('All posts'),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
/** MapEmbed — a consent-gated external map (e.g. Google Maps), the sibling of the
|
|
403
|
+
* calendar embed: nothing loads from the provider until the visitor opts in, so
|
|
404
|
+
* no third-party request fires on page load. `embedUrl` must be https; an unset
|
|
405
|
+
* or unsafe URL degrades to a friendly note + optional "open in Maps" link. */
|
|
406
|
+
export const mapEmbedBlock = z.object({
|
|
407
|
+
type: z.literal('mapEmbed'),
|
|
408
|
+
enabled: z.boolean().default(true),
|
|
409
|
+
title: z.string().optional(),
|
|
410
|
+
intro: z.string().optional(),
|
|
411
|
+
/** The map iframe src (an https embed URL). Blank → a "not set up yet" note. */
|
|
412
|
+
embedUrl: z.string().default(''),
|
|
413
|
+
/** Link to open the location in the provider's app/site (a no-embed fallback). */
|
|
414
|
+
publicUrl: z.string().default(''),
|
|
415
|
+
height: z.number().int().min(150).max(1200).default(450),
|
|
416
|
+
/** Provider name shown in the consent notice ("shared with …"). */
|
|
417
|
+
provider: z.string().default('Google Maps'),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
/** Table — a simple data table (header row + body rows) for structured data our
|
|
421
|
+
* other blocks can't carry cleanly (fees, opening hours, a cookies list). Scrolls
|
|
422
|
+
* horizontally on narrow screens so it never breaks the page width. */
|
|
423
|
+
export const tableBlock = z.object({
|
|
424
|
+
type: z.literal('table'),
|
|
425
|
+
enabled: z.boolean().default(true),
|
|
426
|
+
title: z.string().optional(),
|
|
427
|
+
intro: z.string().optional(),
|
|
428
|
+
columns: z.array(z.string()).default([]),
|
|
429
|
+
rows: z.array(z.array(z.string())).default([]),
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
/** Notice — an inline highlighted info box (icon? + title? + body + link?) that
|
|
433
|
+
* sets a passage apart from prose ("reporting a concern", "please note"). Two
|
|
434
|
+
* tones (accent / neutral); the in-flow sibling of the corner StickyCallout. */
|
|
435
|
+
export const noticeBlock = z.object({
|
|
436
|
+
type: z.literal('notice'),
|
|
437
|
+
enabled: z.boolean().default(true),
|
|
438
|
+
tone: z.enum(['accent', 'neutral']).default('accent'),
|
|
439
|
+
icon: z.string().optional(),
|
|
440
|
+
title: z.string().optional(),
|
|
441
|
+
text: z.string(),
|
|
442
|
+
link: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
443
|
+
});
|
|
444
|
+
|
|
383
445
|
export type RecipeBlock =
|
|
384
446
|
| z.infer<typeof heroBlock>
|
|
385
447
|
| z.infer<typeof featureCardsBlock>
|
|
@@ -398,7 +460,11 @@ export type RecipeBlock =
|
|
|
398
460
|
| z.infer<typeof tabsBlock>
|
|
399
461
|
| z.infer<typeof announcementBlock>
|
|
400
462
|
| z.infer<typeof bentoBlock>
|
|
401
|
-
| z.infer<typeof marqueeBlock
|
|
463
|
+
| z.infer<typeof marqueeBlock>
|
|
464
|
+
| z.infer<typeof latestPostsBlock>
|
|
465
|
+
| z.infer<typeof mapEmbedBlock>
|
|
466
|
+
| z.infer<typeof tableBlock>
|
|
467
|
+
| z.infer<typeof noticeBlock>;
|
|
402
468
|
|
|
403
469
|
/* ── Level-1 layout / container primitives ─────────────────────────────────
|
|
404
470
|
* These hold child blocks, so the schema is **recursive** (a container's
|
|
@@ -502,6 +568,10 @@ export const blockSchema = z.discriminatedUnion('type', [
|
|
|
502
568
|
announcementBlock,
|
|
503
569
|
bentoBlock,
|
|
504
570
|
marqueeBlock,
|
|
571
|
+
latestPostsBlock,
|
|
572
|
+
mapEmbedBlock,
|
|
573
|
+
tableBlock,
|
|
574
|
+
noticeBlock,
|
|
505
575
|
sectionBlock,
|
|
506
576
|
gridBlock,
|
|
507
577
|
stackBlock,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The sticky-callout capability contract — white-room, block-system-era core (no
|
|
2
|
+
// CTK code). A site-level launcher pinned to a screen corner, expanding a small
|
|
3
|
+
// panel with one key piece of info + an optional link (mass times, opening hours,
|
|
4
|
+
// "book now", contact). Reusable/replicable → public (Tier 1a). Site-wide chrome
|
|
5
|
+
// (rendered by Base from calloutSettings), NOT a page block, so it stays put on
|
|
6
|
+
// every page as you scroll. Disabled by default — a client opts in via the CMS.
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
/** Sticky-callout settings — one focused CMS pane (a singleton per site). */
|
|
10
|
+
export const calloutSettingsSchema = z.object({
|
|
11
|
+
/** Off by default; a client turns it on when they have something to surface. */
|
|
12
|
+
enabled: z.boolean().default(false),
|
|
13
|
+
/** The launcher pill's text. */
|
|
14
|
+
label: z.string().default('Quick info'),
|
|
15
|
+
/** Launcher icon (an icon-registry key; unknown keys just render no icon). */
|
|
16
|
+
icon: z.string().default('calendar'),
|
|
17
|
+
/** Panel heading. */
|
|
18
|
+
title: z.string().default(''),
|
|
19
|
+
/** Panel body; blank lines separate paragraphs. Blank (with no title) ⇒ the
|
|
20
|
+
* callout renders nothing even when enabled. */
|
|
21
|
+
text: z.string().default(''),
|
|
22
|
+
/** Optional call-to-action link in the panel (e.g. "See the full calendar"). */
|
|
23
|
+
link: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
24
|
+
/** Which corner it pins to. */
|
|
25
|
+
position: z.enum(['bottom-right', 'bottom-left']).default('bottom-right'),
|
|
26
|
+
/** Offer a "don't show again" action (remembered per `id` in localStorage). */
|
|
27
|
+
dismissible: z.boolean().default(true),
|
|
28
|
+
/** localStorage key suffix so a dismissed callout stays dismissed. */
|
|
29
|
+
id: z.string().default('default'),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export type CalloutSettings = z.infer<typeof calloutSettingsSchema>;
|
package/content/collections.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { pageSchema } from './blocks';
|
|
17
17
|
import { postSchema, tagSchema, postsSettingsSchema } from './posts';
|
|
18
18
|
import { calendarSettingsSchema } from './calendar';
|
|
19
|
+
import { calloutSettingsSchema } from './callout';
|
|
19
20
|
|
|
20
21
|
const siteSettings = defineCollection({ type: 'data', schema: siteSettingsSchema });
|
|
21
22
|
const logoSettings = defineCollection({ type: 'data', schema: logoSettingsSchema });
|
|
@@ -37,6 +38,9 @@ const postsSettings = defineCollection({ type: 'data', schema: postsSettingsSche
|
|
|
37
38
|
// The calendar capability (see content/calendar.ts): a consent-gated embed.
|
|
38
39
|
const calendarSettings = defineCollection({ type: 'data', schema: calendarSettingsSchema });
|
|
39
40
|
|
|
41
|
+
// The sticky-callout capability (see content/callout.ts): a corner launcher (chrome).
|
|
42
|
+
const calloutSettings = defineCollection({ type: 'data', schema: calloutSettingsSchema });
|
|
43
|
+
|
|
40
44
|
export const collections = {
|
|
41
45
|
siteSettings,
|
|
42
46
|
logoSettings,
|
|
@@ -48,4 +52,5 @@ export const collections = {
|
|
|
48
52
|
tags,
|
|
49
53
|
postsSettings,
|
|
50
54
|
calendarSettings,
|
|
55
|
+
calloutSettings,
|
|
51
56
|
};
|
package/layouts/Base.astro
CHANGED
|
@@ -24,8 +24,10 @@ import '../styles/fonts.css';
|
|
|
24
24
|
import SiteHeader from '../components/SiteHeader.astro';
|
|
25
25
|
import SiteFooter from '../components/SiteFooter.astro';
|
|
26
26
|
import CookiePreferences from '../components/CookiePreferences.astro';
|
|
27
|
+
import StickyCallout from '../components/StickyCallout.astro';
|
|
27
28
|
import SecondaryLogo from '../components/SecondaryLogo.astro';
|
|
28
29
|
import { loadSiteSettings } from '../lib/siteSettings';
|
|
30
|
+
import { getCalloutSettings } from '../lib/callout';
|
|
29
31
|
import { buildThemeStyle } from '../lib/themeTokens';
|
|
30
32
|
|
|
31
33
|
interface Props {
|
|
@@ -56,6 +58,9 @@ const {
|
|
|
56
58
|
const settings = await loadSiteSettings();
|
|
57
59
|
const { identity, contact, navigation, theme } = settings;
|
|
58
60
|
|
|
61
|
+
// Site-level corner launcher (chrome) — off unless a client configures it.
|
|
62
|
+
const callout = await getCalloutSettings();
|
|
63
|
+
|
|
59
64
|
const path = Astro.url.pathname;
|
|
60
65
|
|
|
61
66
|
// The brand → an inline <style> that layers over the core's neutral defaults
|
|
@@ -166,6 +171,8 @@ const metaDescription = description ?? identity.description;
|
|
|
166
171
|
|
|
167
172
|
<CookiePreferences />
|
|
168
173
|
|
|
174
|
+
<StickyCallout {...callout} />
|
|
175
|
+
|
|
169
176
|
<style is:global>
|
|
170
177
|
/* ── Global resets & typography ─────────────────────────────────────
|
|
171
178
|
Anything that affects every page's body / typography / article
|
package/lib/callout.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sticky-callout capability's data side — a tiny loader so Base can render the
|
|
3
|
+
* corner launcher from settings. The launcher + panel live in
|
|
4
|
+
* components/StickyCallout.astro.
|
|
5
|
+
*/
|
|
6
|
+
import { getEntry } from 'astro:content';
|
|
7
|
+
import { calloutSettingsSchema, type CalloutSettings } from '../content/callout';
|
|
8
|
+
|
|
9
|
+
/** Callout settings (defaults applied, so a missing file is fine → disabled). */
|
|
10
|
+
export async function getCalloutSettings(): Promise<CalloutSettings> {
|
|
11
|
+
const entry = await getEntry('calloutSettings', 'index');
|
|
12
|
+
return calloutSettingsSchema.parse(entry?.data ?? {});
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "ferst-core",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
|
|
6
|
-
"license": "BUSL-1.1",
|
|
7
|
-
"author": "MyAI4 Ltd (https://ferst.co.uk)",
|
|
8
|
-
"homepage": "https://ferst.co.uk",
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/MyAI4WebDev/myai4-ferst-core.git",
|
|
12
|
-
"directory": "packages/core"
|
|
13
|
-
},
|
|
14
|
-
"bugs": {
|
|
15
|
-
"url": "https://github.com/MyAI4WebDev/myai4-ferst-core/issues"
|
|
16
|
-
},
|
|
17
|
-
"keywords": [
|
|
18
|
-
"astro",
|
|
19
|
-
"astro-component",
|
|
20
|
-
"cms",
|
|
21
|
-
"sveltia",
|
|
22
|
-
"website",
|
|
23
|
-
"design-tokens",
|
|
24
|
-
"ferst"
|
|
25
|
-
],
|
|
26
|
-
"scripts": {
|
|
27
|
-
"test": "vitest run",
|
|
28
|
-
"test:watch": "vitest"
|
|
29
|
-
},
|
|
30
|
-
"sideEffects": [
|
|
31
|
-
"**/*.css"
|
|
32
|
-
],
|
|
33
|
-
"files": [
|
|
34
|
-
"bin",
|
|
35
|
-
"components",
|
|
36
|
-
"layouts",
|
|
37
|
-
"content",
|
|
38
|
-
"lib",
|
|
39
|
-
"utils",
|
|
40
|
-
"styles",
|
|
41
|
-
"README.md",
|
|
42
|
-
"NOTICE",
|
|
43
|
-
"LICENSE",
|
|
44
|
-
"LICENSE-Apache-2.0"
|
|
45
|
-
],
|
|
46
|
-
"bin": {
|
|
47
|
-
"ferst-check-thin": "bin/check-thin.mjs",
|
|
48
|
-
"ferst-set-cms-branch": "bin/set-cms-branch.mjs"
|
|
49
|
-
},
|
|
50
|
-
"dependencies": {
|
|
51
|
-
"zod": "^3.25.0"
|
|
52
|
-
},
|
|
53
|
-
"devDependencies": {
|
|
54
|
-
"vitest": "^3.0.0"
|
|
55
|
-
},
|
|
56
|
-
"peerDependencies": {
|
|
57
|
-
"astro": "^5.0.0"
|
|
58
|
-
},
|
|
59
|
-
"publishConfig": {
|
|
60
|
-
"access": "public"
|
|
61
|
-
}
|
|
62
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "ferst-core",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
|
|
6
|
+
"license": "BUSL-1.1",
|
|
7
|
+
"author": "MyAI4 Ltd (https://ferst.co.uk)",
|
|
8
|
+
"homepage": "https://ferst.co.uk",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MyAI4WebDev/myai4-ferst-core.git",
|
|
12
|
+
"directory": "packages/core"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/MyAI4WebDev/myai4-ferst-core/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"astro",
|
|
19
|
+
"astro-component",
|
|
20
|
+
"cms",
|
|
21
|
+
"sveltia",
|
|
22
|
+
"website",
|
|
23
|
+
"design-tokens",
|
|
24
|
+
"ferst"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest"
|
|
29
|
+
},
|
|
30
|
+
"sideEffects": [
|
|
31
|
+
"**/*.css"
|
|
32
|
+
],
|
|
33
|
+
"files": [
|
|
34
|
+
"bin",
|
|
35
|
+
"components",
|
|
36
|
+
"layouts",
|
|
37
|
+
"content",
|
|
38
|
+
"lib",
|
|
39
|
+
"utils",
|
|
40
|
+
"styles",
|
|
41
|
+
"README.md",
|
|
42
|
+
"NOTICE",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"LICENSE-Apache-2.0"
|
|
45
|
+
],
|
|
46
|
+
"bin": {
|
|
47
|
+
"ferst-check-thin": "bin/check-thin.mjs",
|
|
48
|
+
"ferst-set-cms-branch": "bin/set-cms-branch.mjs"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"zod": "^3.25.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"vitest": "^3.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"astro": "^5.0.0"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|