ferst-core 0.1.0 → 0.2.0
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/LICENSE +102 -201
- package/LICENSE-Apache-2.0 +201 -0
- package/NOTICE +24 -5
- package/README.md +89 -73
- package/bin/check-thin.mjs +74 -0
- package/components/BlockRenderer.astro +89 -0
- package/components/Button.astro +85 -33
- package/components/CalendarEmbed.astro +188 -0
- package/components/Card.astro +7 -17
- package/components/Icon.astro +35 -0
- package/components/PostArticle.astro +133 -0
- package/components/PostCard.astro +85 -225
- package/components/SiteFooter.astro +2 -2
- package/components/SiteHeader.astro +104 -51
- package/components/blocks/Badge.astro +29 -0
- package/components/blocks/ButtonBlock.astro +13 -0
- package/components/blocks/Divider.astro +12 -0
- package/components/blocks/Grid.astro +48 -0
- package/components/blocks/Heading.astro +42 -0
- package/components/blocks/ImageBlock.astro +80 -0
- package/components/blocks/List.astro +78 -0
- package/components/blocks/Prose.astro +29 -0
- package/components/blocks/Quote.astro +32 -0
- package/components/blocks/Section.astro +52 -0
- package/components/blocks/Spacer.astro +20 -0
- package/components/blocks/Stack.astro +30 -0
- package/components/blocks/Stat.astro +30 -0
- package/components/recipes/Accordion.astro +84 -0
- package/components/recipes/Announcement.astro +74 -0
- package/components/recipes/Banner.astro +134 -0
- package/components/recipes/Bento.astro +138 -0
- package/components/recipes/ContactForm.astro +172 -0
- package/components/recipes/Cta.astro +83 -0
- package/components/recipes/Faq.astro +80 -0
- package/components/recipes/FeatureCards.astro +105 -0
- package/components/recipes/Gallery.astro +55 -0
- package/components/recipes/Hero.astro +191 -0
- package/components/recipes/Logos.astro +77 -0
- package/components/recipes/Marquee.astro +75 -0
- package/components/recipes/Pricing.astro +149 -0
- package/components/recipes/Stats.astro +124 -0
- package/components/recipes/Steps.astro +120 -0
- package/components/recipes/Tabs.astro +133 -0
- package/components/recipes/Testimonial.astro +66 -0
- package/components/recipes/Tiles.astro +248 -0
- package/content/blocks.ts +534 -0
- package/content/calendar.ts +40 -0
- package/content/collections.ts +25 -14
- package/content/posts.ts +89 -0
- package/content/schemas.ts +58 -123
- package/layouts/Base.astro +30 -56
- package/lib/calendar.ts +12 -0
- package/lib/icons.ts +64 -0
- package/lib/pages.ts +42 -0
- package/lib/posts.ts +93 -0
- package/lib/siteSettings.ts +18 -36
- package/lib/themeTokens.ts +163 -0
- package/package.json +61 -48
- package/styles/theme.css +42 -0
- package/components/DocLayout.astro +0 -292
- package/components/GroupCard.astro +0 -164
- package/components/LatestPostList.astro +0 -64
- package/components/PaginationNav.astro +0 -81
- package/components/PostsToolbar.astro +0 -83
- package/content/filters.ts +0 -107
- package/lib/mailLinks.ts +0 -13
- package/lib/mapsLink.ts +0 -7
- package/utils/excerpt.ts +0 -56
- package/utils/formatDate.ts +0 -22
- package/utils/freshness.ts +0 -5
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CalendarEmbed — a consent-gated external calendar embed (e.g. Google Calendar).
|
|
4
|
+
* Nothing loads from the provider until the visitor clicks "Load calendar", so no
|
|
5
|
+
* third-party request fires on page load (privacy-first, matching the site's
|
|
6
|
+
* cookie stance). Consent is remembered per visitor (localStorage), so it
|
|
7
|
+
* auto-loads on later visits. White-room, token-driven.
|
|
8
|
+
*
|
|
9
|
+
* The embed URL is required to be https (isSafeEmbedUrl) before it can reach the
|
|
10
|
+
* iframe; an unset or unsafe URL degrades to a friendly note + optional 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 = 'Calendar',
|
|
24
|
+
intro = '',
|
|
25
|
+
embedUrl = '',
|
|
26
|
+
publicUrl = '',
|
|
27
|
+
height = 600,
|
|
28
|
+
provider = 'Google Calendar',
|
|
29
|
+
} = Astro.props;
|
|
30
|
+
|
|
31
|
+
const safe = isSafeEmbedUrl(embedUrl);
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<section class="b-calendar">
|
|
35
|
+
{title && <h2 class="b-calendar__title">{title}</h2>}
|
|
36
|
+
{intro && <p class="b-calendar__intro">{intro}</p>}
|
|
37
|
+
|
|
38
|
+
{!embedUrl ? (
|
|
39
|
+
<p class="b-calendar__note">The calendar isn't set up yet.</p>
|
|
40
|
+
) : !safe ? (
|
|
41
|
+
<p class="b-calendar__note">
|
|
42
|
+
This calendar 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-calendar__embed"
|
|
50
|
+
data-embed-url={embedUrl}
|
|
51
|
+
data-height={String(height)}
|
|
52
|
+
data-title={title}
|
|
53
|
+
>
|
|
54
|
+
<div class="b-calendar__consent">
|
|
55
|
+
<p class="b-calendar__consent-text">
|
|
56
|
+
This calendar 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-calendar__actions">
|
|
60
|
+
<button type="button" class="b-calendar__load">Load calendar</button>
|
|
61
|
+
{publicUrl && (
|
|
62
|
+
<a class="b-calendar__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-calendar__frame"></div>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</section>
|
|
72
|
+
|
|
73
|
+
<script>
|
|
74
|
+
// Bundled (not is:inline), so it runs once even with multiple calendars on a
|
|
75
|
+
// page. Wires each embed's consent gate; auto-loads where consent was given.
|
|
76
|
+
const CONSENT_KEY = 'ferst-calendar-consent';
|
|
77
|
+
document.querySelectorAll<HTMLElement>('.b-calendar__embed').forEach((el) => {
|
|
78
|
+
const url = el.dataset.embedUrl;
|
|
79
|
+
if (!url) return;
|
|
80
|
+
const height = el.dataset.height || '600';
|
|
81
|
+
const title = el.dataset.title || 'Calendar';
|
|
82
|
+
const frameSlot = el.querySelector<HTMLElement>('.b-calendar__frame');
|
|
83
|
+
const consent = el.querySelector<HTMLElement>('.b-calendar__consent');
|
|
84
|
+
const btn = el.querySelector<HTMLButtonElement>('.b-calendar__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.style.width = '100%';
|
|
93
|
+
frame.style.height = height + 'px';
|
|
94
|
+
frame.style.border = '0';
|
|
95
|
+
frameSlot.replaceChildren(frame);
|
|
96
|
+
el.dataset.loaded = 'true';
|
|
97
|
+
if (consent) consent.hidden = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
if (localStorage.getItem(CONSENT_KEY) === '1') load();
|
|
102
|
+
} catch (_) {}
|
|
103
|
+
|
|
104
|
+
btn?.addEventListener('click', () => {
|
|
105
|
+
try {
|
|
106
|
+
localStorage.setItem(CONSENT_KEY, '1');
|
|
107
|
+
} catch (_) {}
|
|
108
|
+
load();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
</script>
|
|
112
|
+
|
|
113
|
+
<style>
|
|
114
|
+
.b-calendar {
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-direction: column;
|
|
117
|
+
gap: 1rem;
|
|
118
|
+
}
|
|
119
|
+
.b-calendar__title {
|
|
120
|
+
margin: 0;
|
|
121
|
+
}
|
|
122
|
+
.b-calendar__intro,
|
|
123
|
+
.b-calendar__note {
|
|
124
|
+
margin: 0;
|
|
125
|
+
color: var(--muted);
|
|
126
|
+
}
|
|
127
|
+
.b-calendar__note a,
|
|
128
|
+
.b-calendar__link {
|
|
129
|
+
color: var(--gold);
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
}
|
|
132
|
+
/* The frame slot is empty (0 height) until the iframe is injected on consent, so
|
|
133
|
+
the consent card below defines the height until then — no empty reserved box. */
|
|
134
|
+
.b-calendar__frame :global(iframe) {
|
|
135
|
+
display: block;
|
|
136
|
+
border: var(--card-border);
|
|
137
|
+
border-radius: var(--radius);
|
|
138
|
+
background: var(--bg-surface);
|
|
139
|
+
}
|
|
140
|
+
/* Consent card stands in for the calendar until the visitor opts in. */
|
|
141
|
+
.b-calendar__consent {
|
|
142
|
+
display: flex;
|
|
143
|
+
flex-direction: column;
|
|
144
|
+
gap: 1rem;
|
|
145
|
+
padding: 2.5rem 1.5rem;
|
|
146
|
+
border: var(--card-border);
|
|
147
|
+
border-radius: var(--radius);
|
|
148
|
+
background: var(--bg-section);
|
|
149
|
+
text-align: center;
|
|
150
|
+
align-items: center;
|
|
151
|
+
}
|
|
152
|
+
.b-calendar__consent-text {
|
|
153
|
+
margin: 0;
|
|
154
|
+
max-width: 46ch;
|
|
155
|
+
color: var(--muted);
|
|
156
|
+
line-height: 1.6;
|
|
157
|
+
}
|
|
158
|
+
.b-calendar__actions {
|
|
159
|
+
display: flex;
|
|
160
|
+
flex-wrap: wrap;
|
|
161
|
+
gap: 0.75rem 1.25rem;
|
|
162
|
+
align-items: center;
|
|
163
|
+
justify-content: center;
|
|
164
|
+
}
|
|
165
|
+
.b-calendar__load {
|
|
166
|
+
font: inherit;
|
|
167
|
+
font-family: var(--font-heading);
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
padding: 0.6rem 1.4rem;
|
|
170
|
+
border: none;
|
|
171
|
+
border-radius: var(--radius-sm);
|
|
172
|
+
background: var(--gold);
|
|
173
|
+
color: var(--text-light);
|
|
174
|
+
cursor: pointer;
|
|
175
|
+
transition: filter 0.15s ease;
|
|
176
|
+
}
|
|
177
|
+
.b-calendar__load:hover {
|
|
178
|
+
filter: brightness(0.94);
|
|
179
|
+
}
|
|
180
|
+
.b-calendar__load:focus-visible {
|
|
181
|
+
outline: 2px solid var(--gold);
|
|
182
|
+
outline-offset: 2px;
|
|
183
|
+
}
|
|
184
|
+
/* When JS has loaded the frame, the consent card is hidden via [hidden]. */
|
|
185
|
+
.b-calendar__consent[hidden] {
|
|
186
|
+
display: none;
|
|
187
|
+
}
|
|
188
|
+
</style>
|
package/components/Card.astro
CHANGED
|
@@ -15,13 +15,14 @@ const { as: Tag = 'div', hover = true, padding = '1.25rem 1.5rem', overflow = 'h
|
|
|
15
15
|
</Tag>
|
|
16
16
|
|
|
17
17
|
<style>
|
|
18
|
+
/* Token-driven so the global surface "knobs" (--radius / --card-border /
|
|
19
|
+
--shadow-*) restyle every card at once, in both themes. */
|
|
18
20
|
.card {
|
|
19
21
|
position: relative;
|
|
20
|
-
border-radius:
|
|
21
|
-
background:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
0 2px 8px rgba(160, 120, 50, 0.06);
|
|
22
|
+
border-radius: var(--radius);
|
|
23
|
+
background: var(--bg-surface);
|
|
24
|
+
border: var(--card-border);
|
|
25
|
+
box-shadow: var(--shadow-md);
|
|
25
26
|
overflow: var(--card-overflow, hidden);
|
|
26
27
|
padding: var(--card-padding);
|
|
27
28
|
display: flex;
|
|
@@ -34,9 +35,7 @@ const { as: Tag = 'div', hover = true, padding = '1.25rem 1.5rem', overflow = 'h
|
|
|
34
35
|
|
|
35
36
|
.card--hover:hover {
|
|
36
37
|
transform: translateY(-4px);
|
|
37
|
-
box-shadow:
|
|
38
|
-
0 12px 32px rgba(44, 59, 42, 0.15),
|
|
39
|
-
0 4px 12px rgba(160, 120, 50, 0.1);
|
|
38
|
+
box-shadow: var(--shadow-lg);
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -47,13 +46,4 @@ const { as: Tag = 'div', hover = true, padding = '1.25rem 1.5rem', overflow = 'h
|
|
|
47
46
|
transform: none;
|
|
48
47
|
}
|
|
49
48
|
}
|
|
50
|
-
|
|
51
|
-
:global([data-theme='dark']) .card {
|
|
52
|
-
background: rgba(255, 255, 255, 0.04);
|
|
53
|
-
box-shadow: none;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
:global([data-theme='dark']) .card--hover:hover {
|
|
57
|
-
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
58
|
-
}
|
|
59
49
|
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Renders one shape from `lib/icons.ts` as an inline SVG. `stroke` defaults to
|
|
4
|
+
* `currentColor` so the icon's colour follows the caller's CSS `color` (and so
|
|
5
|
+
* switches with the site's light/dark theme automatically). The single intended
|
|
6
|
+
* consumer of the icon registry.
|
|
7
|
+
*/
|
|
8
|
+
import { ICONS, type IconKey } from '../lib/icons';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
name: IconKey;
|
|
12
|
+
size?: number;
|
|
13
|
+
strokeWidth?: number;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { name, size = 24, strokeWidth = 1.6, class: className } = Astro.props;
|
|
18
|
+
const shape = ICONS[name];
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
{shape && (
|
|
22
|
+
<svg
|
|
23
|
+
class={className}
|
|
24
|
+
width={size}
|
|
25
|
+
height={size}
|
|
26
|
+
viewBox="0 0 24 24"
|
|
27
|
+
fill="none"
|
|
28
|
+
stroke="currentColor"
|
|
29
|
+
stroke-width={strokeWidth}
|
|
30
|
+
stroke-linecap="round"
|
|
31
|
+
stroke-linejoin="round"
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
set:html={shape}
|
|
34
|
+
/>
|
|
35
|
+
)}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PostArticle — the individual post layout: a header (title, date, tags) + optional
|
|
4
|
+
* hero image, then a <slot /> for the rendered markdown body. The route calls
|
|
5
|
+
* `entry.render()` and drops `<Content />` into the slot. Body typography (measure,
|
|
6
|
+
* images, headings) is handled by Base's global `article` styles; this owns only
|
|
7
|
+
* the post header. White-room (no CTK code).
|
|
8
|
+
*/
|
|
9
|
+
interface Props {
|
|
10
|
+
title: string;
|
|
11
|
+
date: Date | string;
|
|
12
|
+
heroImage?: string;
|
|
13
|
+
/** Optional downloadable file (e.g. a PDF newsletter) shown as a download link. */
|
|
14
|
+
attachment?: string;
|
|
15
|
+
/** Resolved tags for display + linking. */
|
|
16
|
+
tags?: Array<{ slug: string; label: string }>;
|
|
17
|
+
/** Base path for tag links (default `/tags`). */
|
|
18
|
+
tagBase?: string;
|
|
19
|
+
/** Optional "back to the index" link. */
|
|
20
|
+
backHref?: string;
|
|
21
|
+
backLabel?: string;
|
|
22
|
+
}
|
|
23
|
+
const { title, date, heroImage, attachment, tags = [], tagBase = '/tags', backHref, backLabel = 'Back to news' } = Astro.props;
|
|
24
|
+
// A human label for the download link, hinting the file type from its extension.
|
|
25
|
+
const attachmentExt = attachment ? attachment.split('.').pop()?.toUpperCase() : undefined;
|
|
26
|
+
const attachmentLabel = attachmentExt && attachmentExt.length <= 4 ? `Download (${attachmentExt})` : 'Download attachment';
|
|
27
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
28
|
+
const iso = Number.isNaN(d.valueOf()) ? undefined : d.toISOString().slice(0, 10);
|
|
29
|
+
const display = Number.isNaN(d.valueOf())
|
|
30
|
+
? ''
|
|
31
|
+
: d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<article class="b-post">
|
|
35
|
+
<header class="b-post__header">
|
|
36
|
+
{backHref && (
|
|
37
|
+
<a class="b-post__back" href={backHref}>← {backLabel}</a>
|
|
38
|
+
)}
|
|
39
|
+
<h1 class="b-post__title">{title}</h1>
|
|
40
|
+
<div class="b-post__meta">
|
|
41
|
+
{display && <time datetime={iso}>{display}</time>}
|
|
42
|
+
{tags.length > 0 && (
|
|
43
|
+
<ul class="b-post__tags">
|
|
44
|
+
{tags.map((t) => (
|
|
45
|
+
<li><a href={`${tagBase}/${t.slug}`}>{t.label}</a></li>
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
{heroImage && <img class="b-post__hero" src={heroImage} alt="" />}
|
|
51
|
+
</header>
|
|
52
|
+
<div class="b-post__body">
|
|
53
|
+
<slot />
|
|
54
|
+
</div>
|
|
55
|
+
{attachment && (
|
|
56
|
+
<p class="b-post__attachment">
|
|
57
|
+
<a href={attachment} target="_blank" rel="noopener">{attachmentLabel}</a>
|
|
58
|
+
</p>
|
|
59
|
+
)}
|
|
60
|
+
</article>
|
|
61
|
+
|
|
62
|
+
<style>
|
|
63
|
+
.b-post {
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
gap: 1.5rem;
|
|
67
|
+
width: 100%;
|
|
68
|
+
}
|
|
69
|
+
.b-post__header {
|
|
70
|
+
display: flex;
|
|
71
|
+
flex-direction: column;
|
|
72
|
+
gap: 0.75rem;
|
|
73
|
+
}
|
|
74
|
+
.b-post__back {
|
|
75
|
+
font-family: var(--font-heading);
|
|
76
|
+
font-size: 0.85rem;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
color: var(--gold);
|
|
79
|
+
}
|
|
80
|
+
.b-post__title {
|
|
81
|
+
max-width: 22ch;
|
|
82
|
+
}
|
|
83
|
+
.b-post__meta {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-wrap: wrap;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 0.5rem 1rem;
|
|
88
|
+
}
|
|
89
|
+
.b-post__tags {
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-wrap: wrap;
|
|
92
|
+
gap: 0.4rem;
|
|
93
|
+
margin: 0;
|
|
94
|
+
padding: 0;
|
|
95
|
+
list-style: none;
|
|
96
|
+
}
|
|
97
|
+
.b-post__tags a {
|
|
98
|
+
display: inline-block;
|
|
99
|
+
padding: 0.15rem 0.6rem;
|
|
100
|
+
border: var(--card-border);
|
|
101
|
+
border-radius: var(--radius-pill);
|
|
102
|
+
background: var(--bg-tag);
|
|
103
|
+
color: var(--accent);
|
|
104
|
+
font-size: 0.78rem;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
}
|
|
107
|
+
.b-post__tags a:hover {
|
|
108
|
+
border-color: var(--gold);
|
|
109
|
+
color: var(--gold);
|
|
110
|
+
}
|
|
111
|
+
.b-post__hero {
|
|
112
|
+
width: 100%;
|
|
113
|
+
border-radius: var(--radius);
|
|
114
|
+
margin-top: 0.5rem;
|
|
115
|
+
}
|
|
116
|
+
.b-post__attachment {
|
|
117
|
+
margin: 0;
|
|
118
|
+
}
|
|
119
|
+
.b-post__attachment a {
|
|
120
|
+
display: inline-block;
|
|
121
|
+
padding: 0.55rem 1.1rem;
|
|
122
|
+
border: var(--card-border);
|
|
123
|
+
border-radius: var(--radius-sm);
|
|
124
|
+
background: var(--bg-section);
|
|
125
|
+
color: var(--accent);
|
|
126
|
+
font-family: var(--font-heading);
|
|
127
|
+
font-weight: 600;
|
|
128
|
+
}
|
|
129
|
+
.b-post__attachment a:hover {
|
|
130
|
+
border-color: var(--gold);
|
|
131
|
+
color: var(--gold);
|
|
132
|
+
}
|
|
133
|
+
</style>
|