ferst-core 0.2.6 → 0.2.8
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/components/CalendarEmbed.astro +9 -4
- package/components/recipes/LatestPosts.astro +129 -26
- package/components/recipes/Table.astro +1 -1
- package/content/blocks.ts +5 -0
- package/content/calendar.ts +27 -0
- package/content/posts.ts +14 -0
- package/layouts/Base.astro +15 -7
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* The embed URL is required to be https (isSafeEmbedUrl) before it can reach the
|
|
10
10
|
* iframe; an unset or unsafe URL degrades to a friendly note + optional link.
|
|
11
11
|
*/
|
|
12
|
-
import { isSafeEmbedUrl } from '../content/calendar';
|
|
12
|
+
import { isSafeEmbedUrl, withWeekStart } from '../content/calendar';
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
15
15
|
title?: string;
|
|
@@ -18,6 +18,8 @@ interface Props {
|
|
|
18
18
|
publicUrl?: string;
|
|
19
19
|
height?: number;
|
|
20
20
|
provider?: string;
|
|
21
|
+
/** First day of the weekly view (Google Calendar embeds only). Default Monday. */
|
|
22
|
+
weekStart?: 'monday' | 'sunday';
|
|
21
23
|
}
|
|
22
24
|
const {
|
|
23
25
|
title = 'Calendar',
|
|
@@ -26,16 +28,19 @@ const {
|
|
|
26
28
|
publicUrl = '',
|
|
27
29
|
height = 600,
|
|
28
30
|
provider = 'Google Calendar',
|
|
31
|
+
weekStart = 'monday',
|
|
29
32
|
} = Astro.props;
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
// Apply the week-start preference to the embed (no-op for non-Google URLs).
|
|
35
|
+
const finalEmbedUrl = withWeekStart(embedUrl, weekStart);
|
|
36
|
+
const safe = isSafeEmbedUrl(finalEmbedUrl);
|
|
32
37
|
---
|
|
33
38
|
|
|
34
39
|
<section class="b-calendar">
|
|
35
40
|
{title && <h2 class="b-calendar__title">{title}</h2>}
|
|
36
41
|
{intro && <p class="b-calendar__intro">{intro}</p>}
|
|
37
42
|
|
|
38
|
-
{!
|
|
43
|
+
{!finalEmbedUrl ? (
|
|
39
44
|
<p class="b-calendar__note">The calendar isn't set up yet.</p>
|
|
40
45
|
) : !safe ? (
|
|
41
46
|
<p class="b-calendar__note">
|
|
@@ -47,7 +52,7 @@ const safe = isSafeEmbedUrl(embedUrl);
|
|
|
47
52
|
) : (
|
|
48
53
|
<div
|
|
49
54
|
class="b-calendar__embed"
|
|
50
|
-
data-embed-url={
|
|
55
|
+
data-embed-url={finalEmbedUrl}
|
|
51
56
|
data-height={String(height)}
|
|
52
57
|
data-title={title}
|
|
53
58
|
>
|
|
@@ -11,6 +11,7 @@ import PostCard from '../PostCard.astro';
|
|
|
11
11
|
import Heading from '../blocks/Heading.astro';
|
|
12
12
|
import Button from '../Button.astro';
|
|
13
13
|
import { getIndexPosts, getPostsByTag } from '../../lib/posts';
|
|
14
|
+
import { isRecent } from '../../content/posts';
|
|
14
15
|
|
|
15
16
|
interface Props {
|
|
16
17
|
title?: string;
|
|
@@ -18,6 +19,8 @@ interface Props {
|
|
|
18
19
|
limit?: number;
|
|
19
20
|
tag?: string;
|
|
20
21
|
layout?: 'grid' | 'list';
|
|
22
|
+
/** In the list layout, badge posts newer than this many days as "New" (0 = off). */
|
|
23
|
+
newWithinDays?: number;
|
|
21
24
|
viewAllHref?: string;
|
|
22
25
|
viewAllLabel?: string;
|
|
23
26
|
}
|
|
@@ -27,6 +30,7 @@ const {
|
|
|
27
30
|
limit = 3,
|
|
28
31
|
tag,
|
|
29
32
|
layout = 'grid',
|
|
33
|
+
newWithinDays = 14,
|
|
30
34
|
viewAllHref = '/posts',
|
|
31
35
|
viewAllLabel = 'All posts',
|
|
32
36
|
} = Astro.props;
|
|
@@ -40,6 +44,12 @@ const fmt = (date: Date | string) => {
|
|
|
40
44
|
? { iso: undefined as string | undefined, label: '' }
|
|
41
45
|
: { iso: d.toISOString().slice(0, 10), label: d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) };
|
|
42
46
|
};
|
|
47
|
+
|
|
48
|
+
// "New" = published within `newWithinDays` of build time (recomputed each build;
|
|
49
|
+
// a site rebuilds on publish, so this stays fresh). isRecent lives in the pure
|
|
50
|
+
// posts module so it's unit-tested independently.
|
|
51
|
+
const nowMs = Date.now();
|
|
52
|
+
const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
|
|
43
53
|
---
|
|
44
54
|
|
|
45
55
|
<section class="b-latest-posts">
|
|
@@ -56,13 +66,26 @@ const fmt = (date: Date | string) => {
|
|
|
56
66
|
<ul class="b-latest-posts__list">
|
|
57
67
|
{posts.map((p) => {
|
|
58
68
|
const { iso, label } = fmt(p.data.date);
|
|
69
|
+
const href = `/posts/${p.slug}`;
|
|
70
|
+
const cats = (p.data.tags ?? []).slice(0, 2);
|
|
59
71
|
return (
|
|
60
|
-
<li class="b-
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
<li class="b-post-row">
|
|
73
|
+
{p.data.heroImage && (
|
|
74
|
+
<a class="b-post-row__media" href={href} tabindex="-1" aria-hidden="true">
|
|
75
|
+
<img src={p.data.heroImage} alt="" loading="lazy" />
|
|
76
|
+
</a>
|
|
77
|
+
)}
|
|
78
|
+
<div class="b-post-row__main">
|
|
79
|
+
{cats.length > 0 && (
|
|
80
|
+
<span class="b-post-row__cat">{cats.join(' · ')}</span>
|
|
81
|
+
)}
|
|
82
|
+
<h3 class="b-post-row__title"><a href={href}>{p.data.title}</a></h3>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="b-post-row__aside">
|
|
85
|
+
{isNew(p.data.date) && <span class="b-post-row__new">New</span>}
|
|
86
|
+
{label && <time class="b-post-row__date" datetime={iso}>{label}</time>}
|
|
87
|
+
<span class="b-post-row__chevron" aria-hidden="true">›</span>
|
|
88
|
+
</div>
|
|
66
89
|
</li>
|
|
67
90
|
);
|
|
68
91
|
})}
|
|
@@ -122,7 +145,9 @@ const fmt = (date: Date | string) => {
|
|
|
122
145
|
@media (min-width: 60em) {
|
|
123
146
|
.b-latest-posts__grid { grid-template-columns: repeat(3, 1fr); }
|
|
124
147
|
}
|
|
125
|
-
/* Compact list —
|
|
148
|
+
/* Compact miniature list — a thumbnail + category + title per row with the date
|
|
149
|
+
(and an optional "New" badge) on the right, split by hairlines. Stays short
|
|
150
|
+
however many posts it shows; the whole row is one click target. */
|
|
126
151
|
.b-latest-posts__list {
|
|
127
152
|
list-style: none;
|
|
128
153
|
margin: 0;
|
|
@@ -130,40 +155,118 @@ const fmt = (date: Date | string) => {
|
|
|
130
155
|
display: flex;
|
|
131
156
|
flex-direction: column;
|
|
132
157
|
}
|
|
133
|
-
.b-
|
|
134
|
-
|
|
135
|
-
|
|
158
|
+
.b-post-row {
|
|
159
|
+
position: relative;
|
|
160
|
+
display: flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
gap: 1rem;
|
|
163
|
+
padding: 0.85rem 0;
|
|
164
|
+
border-top: 1px solid var(--border);
|
|
136
165
|
}
|
|
137
|
-
.b-
|
|
166
|
+
.b-post-row:first-child {
|
|
138
167
|
border-top: 0;
|
|
139
168
|
}
|
|
140
|
-
|
|
169
|
+
/* Thumbnail — a small square, cover-cropped (never stretched). */
|
|
170
|
+
.b-post-row__media {
|
|
171
|
+
flex: 0 0 auto;
|
|
172
|
+
display: block;
|
|
173
|
+
width: 3.25rem;
|
|
174
|
+
height: 3.25rem;
|
|
175
|
+
border-radius: var(--radius-sm);
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
background: var(--bg-section);
|
|
178
|
+
}
|
|
179
|
+
.b-post-row .b-post-row__media img {
|
|
180
|
+
width: 100%;
|
|
181
|
+
height: 100%;
|
|
182
|
+
object-fit: cover;
|
|
183
|
+
display: block;
|
|
184
|
+
}
|
|
185
|
+
.b-post-row__main {
|
|
186
|
+
flex: 1 1 auto;
|
|
187
|
+
min-width: 0;
|
|
141
188
|
display: flex;
|
|
142
|
-
flex-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
189
|
+
flex-direction: column;
|
|
190
|
+
gap: 0.25rem;
|
|
191
|
+
}
|
|
192
|
+
/* Category chip — the post's tags, joined, as a small brand-tinted pill. */
|
|
193
|
+
.b-post-row__cat {
|
|
194
|
+
align-self: flex-start;
|
|
195
|
+
max-width: 100%;
|
|
196
|
+
padding: 0.1rem 0.5rem;
|
|
197
|
+
border-radius: var(--radius-pill);
|
|
198
|
+
background: var(--bg-tag);
|
|
199
|
+
color: var(--muted);
|
|
200
|
+
font-family: var(--font-heading);
|
|
201
|
+
font-size: 0.66rem;
|
|
202
|
+
font-weight: 700;
|
|
203
|
+
letter-spacing: 0.06em;
|
|
204
|
+
text-transform: uppercase;
|
|
205
|
+
white-space: nowrap;
|
|
206
|
+
overflow: hidden;
|
|
207
|
+
text-overflow: ellipsis;
|
|
208
|
+
}
|
|
209
|
+
.b-post-row__title {
|
|
210
|
+
margin: 0;
|
|
211
|
+
font-size: 1.05rem;
|
|
212
|
+
line-height: 1.35;
|
|
213
|
+
}
|
|
214
|
+
.b-post-row__title a {
|
|
146
215
|
color: var(--fg);
|
|
147
|
-
|
|
216
|
+
font-family: var(--font-heading);
|
|
217
|
+
font-weight: 600;
|
|
148
218
|
}
|
|
149
|
-
.b-
|
|
219
|
+
.b-post-row:hover .b-post-row__title a {
|
|
150
220
|
color: var(--gold);
|
|
151
221
|
}
|
|
152
|
-
|
|
222
|
+
/* Stretch the title link across the whole row so any part of it is clickable. */
|
|
223
|
+
.b-post-row__title a::after {
|
|
224
|
+
content: '';
|
|
225
|
+
position: absolute;
|
|
226
|
+
inset: 0;
|
|
227
|
+
}
|
|
228
|
+
.b-post-row__aside {
|
|
229
|
+
flex: 0 0 auto;
|
|
230
|
+
display: flex;
|
|
231
|
+
align-items: center;
|
|
232
|
+
gap: 0.6rem 0.8rem;
|
|
233
|
+
}
|
|
234
|
+
.b-post-row__new {
|
|
235
|
+
padding: 0.12rem 0.45rem;
|
|
236
|
+
border-radius: var(--radius-pill);
|
|
237
|
+
background: var(--gold);
|
|
238
|
+
color: var(--text-light);
|
|
153
239
|
font-family: var(--font-heading);
|
|
154
|
-
font-
|
|
155
|
-
font-
|
|
240
|
+
font-size: 0.62rem;
|
|
241
|
+
font-weight: 700;
|
|
242
|
+
letter-spacing: 0.06em;
|
|
243
|
+
text-transform: uppercase;
|
|
156
244
|
}
|
|
157
|
-
.b-
|
|
245
|
+
.b-post-row__date {
|
|
158
246
|
color: var(--muted);
|
|
159
247
|
font-size: 0.85rem;
|
|
160
248
|
white-space: nowrap;
|
|
249
|
+
font-variant-numeric: tabular-nums;
|
|
161
250
|
}
|
|
162
|
-
.b-
|
|
163
|
-
margin: 0.35rem 0 0;
|
|
251
|
+
.b-post-row__chevron {
|
|
164
252
|
color: var(--muted);
|
|
165
|
-
font-size:
|
|
166
|
-
line-height: 1
|
|
253
|
+
font-size: 1.25rem;
|
|
254
|
+
line-height: 1;
|
|
255
|
+
}
|
|
256
|
+
.b-post-row:hover .b-post-row__chevron {
|
|
257
|
+
color: var(--gold);
|
|
258
|
+
}
|
|
259
|
+
/* On narrow screens keep the title on the left and let the date tuck under the
|
|
260
|
+
"New" badge if needed; the chevron drops (touch targets are the whole row). */
|
|
261
|
+
@media (max-width: 30rem) {
|
|
262
|
+
.b-post-row__chevron {
|
|
263
|
+
display: none;
|
|
264
|
+
}
|
|
265
|
+
.b-post-row__aside {
|
|
266
|
+
flex-direction: column;
|
|
267
|
+
align-items: flex-end;
|
|
268
|
+
gap: 0.25rem;
|
|
269
|
+
}
|
|
167
270
|
}
|
|
168
271
|
.b-latest-posts__empty {
|
|
169
272
|
margin: 0;
|
|
@@ -75,7 +75,7 @@ const { title, intro, columns = [], rows = [] } = Astro.props;
|
|
|
75
75
|
padding: 0.7rem 0.9rem;
|
|
76
76
|
text-align: start;
|
|
77
77
|
vertical-align: top;
|
|
78
|
-
border-bottom: 1px solid var(--
|
|
78
|
+
border-bottom: 1px solid var(--border);
|
|
79
79
|
}
|
|
80
80
|
.b-table__table thead th {
|
|
81
81
|
font-family: var(--font-heading);
|
package/content/blocks.ts
CHANGED
|
@@ -393,7 +393,12 @@ export const latestPostsBlock = z.object({
|
|
|
393
393
|
limit: z.number().int().min(1).max(12).default(3),
|
|
394
394
|
/** Filter to a single tag slug; omit for the main index (minus excluded tags). */
|
|
395
395
|
tag: z.string().optional(),
|
|
396
|
+
/** `grid` = full image cards; `list` = a compact miniature index (thumbnail +
|
|
397
|
+
* category + date per row) that stays short even with many posts. */
|
|
396
398
|
layout: z.enum(['grid', 'list']).default('grid'),
|
|
399
|
+
/** In the `list` layout, flag posts newer than this many days with a "New"
|
|
400
|
+
* badge (0 disables it). Ignored by the grid. */
|
|
401
|
+
newWithinDays: z.number().int().min(0).max(365).default(14),
|
|
397
402
|
/** "View all" target + label (the posts index by default). */
|
|
398
403
|
viewAllHref: z.string().default('/posts'),
|
|
399
404
|
viewAllLabel: z.string().default('All posts'),
|
package/content/calendar.ts
CHANGED
|
@@ -21,10 +21,37 @@ export const calendarSettingsSchema = z.object({
|
|
|
21
21
|
height: z.number().int().min(200).max(2000).default(600),
|
|
22
22
|
/** Provider name, shown in the consent notice ("shared with …"). */
|
|
23
23
|
provider: z.string().default('Google Calendar'),
|
|
24
|
+
/** First day of the week in the weekly view. UK convention is Monday, so that is
|
|
25
|
+
* the default; sites with a US/Sunday-first audience can switch it. Applied to
|
|
26
|
+
* Google Calendar embeds via `wkst` (other providers ignore it). */
|
|
27
|
+
weekStart: z.enum(['monday', 'sunday']).default('monday'),
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
export type CalendarSettings = z.infer<typeof calendarSettingsSchema>;
|
|
27
31
|
|
|
32
|
+
/** Google Calendar's `wkst` numbering: 1 = Sunday, 2 = Monday, … 7 = Saturday. */
|
|
33
|
+
const WKST: Record<'monday' | 'sunday', string> = { monday: '2', sunday: '1' };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Return `embedUrl` with the week-start applied. Only Google Calendar embed URLs
|
|
37
|
+
* carry a `wkst` param, so we touch nothing else — a non-Google URL (or an
|
|
38
|
+
* unparseable one) is returned unchanged. Any existing `wkst` is overridden so the
|
|
39
|
+
* CMS setting always wins. Pure + testable.
|
|
40
|
+
*/
|
|
41
|
+
export function withWeekStart(embedUrl: string, weekStart: 'monday' | 'sunday' = 'monday'): string {
|
|
42
|
+
if (!embedUrl) return embedUrl;
|
|
43
|
+
try {
|
|
44
|
+
const u = new URL(embedUrl);
|
|
45
|
+
const isGoogleCalendar =
|
|
46
|
+
u.hostname.endsWith('google.com') && u.pathname.includes('/calendar/embed');
|
|
47
|
+
if (!isGoogleCalendar) return embedUrl;
|
|
48
|
+
u.searchParams.set('wkst', WKST[weekStart]);
|
|
49
|
+
return u.toString();
|
|
50
|
+
} catch {
|
|
51
|
+
return embedUrl;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
/**
|
|
29
56
|
* Whether an embed URL is safe to load into an iframe. The value is trusted repo /
|
|
30
57
|
* CMS data, but we still require `https:` so a stray `javascript:` / `http:` value
|
package/content/posts.ts
CHANGED
|
@@ -87,3 +87,17 @@ export function tagCounts(posts: Array<{ data: { tags?: string[] } }>): Record<s
|
|
|
87
87
|
for (const p of posts) for (const t of p.data.tags ?? []) counts[t] = (counts[t] ?? 0) + 1;
|
|
88
88
|
return counts;
|
|
89
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether a post date is within the last `days` of `now` — used to flag a "New"
|
|
93
|
+
* badge in the compact latest-posts list. `days <= 0` disables it; a future or
|
|
94
|
+
* unparseable date is never "new". Pure; `now` is injectable for tests.
|
|
95
|
+
*/
|
|
96
|
+
export function isRecent(date: Date | string, days: number, now: number = Date.now()): boolean {
|
|
97
|
+
if (!days || days <= 0) return false;
|
|
98
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
99
|
+
const t = d.valueOf();
|
|
100
|
+
if (Number.isNaN(t)) return false;
|
|
101
|
+
const ageMs = now - t;
|
|
102
|
+
return ageMs >= 0 && ageMs <= days * 86_400_000;
|
|
103
|
+
}
|
package/layouts/Base.astro
CHANGED
|
@@ -313,14 +313,23 @@ const metaDescription = description ?? identity.description;
|
|
|
313
313
|
article :where(img, picture, video, table, figure, iframe) {
|
|
314
314
|
max-width: 100%;
|
|
315
315
|
}
|
|
316
|
-
|
|
316
|
+
/* Prose images — editor/markdown images dropped into long-form body content
|
|
317
|
+
get a centred block treatment (rounded, soft shadow). Scoped to the real
|
|
318
|
+
prose containers (post body, Prose block) so it never leaks onto STRUCTURAL
|
|
319
|
+
component images — cards, banners, media headers — which own their own
|
|
320
|
+
framing and crop absolutely. (A leaked `margin` pushes an inset image down
|
|
321
|
+
inside its overflow-hidden box, exposing a strip above it — the "empty
|
|
322
|
+
space above the image" a bare `article img` rule caused.) */
|
|
323
|
+
.b-post__body img,
|
|
324
|
+
.b-prose img {
|
|
317
325
|
display: block;
|
|
318
326
|
margin: 1.5rem auto;
|
|
319
327
|
border-radius: 8px;
|
|
320
328
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
321
329
|
}
|
|
322
330
|
@media (min-width: 44em) {
|
|
323
|
-
|
|
331
|
+
.b-post__body img,
|
|
332
|
+
.b-prose img {
|
|
324
333
|
border-radius: 12px;
|
|
325
334
|
}
|
|
326
335
|
}
|
|
@@ -340,11 +349,10 @@ const metaDescription = description ?? identity.description;
|
|
|
340
349
|
}
|
|
341
350
|
|
|
342
351
|
/* ── Fluid media: never overflow container ── */
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
.group-card__img {
|
|
352
|
+
/* The logo is exempt: it carries a fixed height in SiteHeader that `height:
|
|
353
|
+
auto` would collapse. Card/media images set their own size at higher
|
|
354
|
+
specificity, so this only ever backstops loose images. */
|
|
355
|
+
img:not(.primary-logo), picture, video {
|
|
348
356
|
max-width: 100%;
|
|
349
357
|
height: auto;
|
|
350
358
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"type": "module",
|
|
5
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
6
|
"license": "BUSL-1.1",
|