ferst-core 0.2.5 → 0.2.7
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/BlockRenderer.astro +2 -0
- package/components/CalendarEmbed.astro +9 -4
- package/components/recipes/MediaCards.astro +192 -0
- package/components/recipes/Table.astro +1 -1
- package/content/blocks.ts +31 -1
- package/content/calendar.ts +27 -0
- package/layouts/Base.astro +15 -7
- package/package.json +1 -1
|
@@ -41,6 +41,7 @@ import LatestPosts from './recipes/LatestPosts.astro';
|
|
|
41
41
|
import MapEmbed from './recipes/MapEmbed.astro';
|
|
42
42
|
import Table from './recipes/Table.astro';
|
|
43
43
|
import Notice from './recipes/Notice.astro';
|
|
44
|
+
import MediaCards from './recipes/MediaCards.astro';
|
|
44
45
|
|
|
45
46
|
interface Props {
|
|
46
47
|
blocks: Block[];
|
|
@@ -84,6 +85,7 @@ const registry: Record<BlockType, any> = {
|
|
|
84
85
|
mapEmbed: MapEmbed,
|
|
85
86
|
table: Table,
|
|
86
87
|
notice: Notice,
|
|
88
|
+
mediaCards: MediaCards,
|
|
87
89
|
};
|
|
88
90
|
---
|
|
89
91
|
|
|
@@ -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
|
>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* MediaCards — a Level-2 recipe: a grid of rich content cards with the IMAGE as a
|
|
4
|
+
* header on top (cover-cropped to a uniform ratio, like PostCard), then an optional
|
|
5
|
+
* icon badge, a title, a meta line (e.g. "Sundays 10:45 · CTK"), a description, and
|
|
6
|
+
* an optional CTA link. The image-HEADER pattern (distinct from `tiles`, which puts
|
|
7
|
+
* the image BEHIND the text): use it for groups, team members, services or events —
|
|
8
|
+
* anywhere the photo and the copy both need to be fully legible. Whole card links
|
|
9
|
+
* when `href` is set. Token-driven, equal-height.
|
|
10
|
+
*/
|
|
11
|
+
import Icon from '../Icon.astro';
|
|
12
|
+
import { isIconKey } from '../../lib/icons';
|
|
13
|
+
import Heading from '../blocks/Heading.astro';
|
|
14
|
+
|
|
15
|
+
interface Card {
|
|
16
|
+
image?: { src: string; alt?: string };
|
|
17
|
+
icon?: string;
|
|
18
|
+
title: string;
|
|
19
|
+
meta?: string;
|
|
20
|
+
text?: string;
|
|
21
|
+
href?: string;
|
|
22
|
+
linkLabel?: string;
|
|
23
|
+
}
|
|
24
|
+
interface Props {
|
|
25
|
+
title?: string;
|
|
26
|
+
intro?: string;
|
|
27
|
+
columns?: 2 | 3;
|
|
28
|
+
cards?: Card[];
|
|
29
|
+
}
|
|
30
|
+
const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<section class="b-media-cards">
|
|
34
|
+
{(title || intro) && (
|
|
35
|
+
<div class="b-media-cards__head">
|
|
36
|
+
{title && <Heading text={title} level={2} />}
|
|
37
|
+
{intro && <p class="b-media-cards__intro">{intro}</p>}
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
<div class="b-media-cards__grid" data-cols={columns}>
|
|
41
|
+
{cards.map((card) => (
|
|
42
|
+
<article class:list={['b-media-cards__card', card.href && 'is-link']}>
|
|
43
|
+
{card.image && (
|
|
44
|
+
<div class="b-media-cards__media">
|
|
45
|
+
<img src={card.image.src} alt={card.image.alt ?? ''} loading="lazy" />
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
<div class="b-media-cards__body">
|
|
49
|
+
{card.icon && isIconKey(card.icon) && (
|
|
50
|
+
<span class="b-media-cards__badge" aria-hidden="true"><Icon name={card.icon} size={20} /></span>
|
|
51
|
+
)}
|
|
52
|
+
<h3 class="b-media-cards__title">
|
|
53
|
+
{card.href ? <a href={card.href}>{card.title}</a> : card.title}
|
|
54
|
+
</h3>
|
|
55
|
+
{card.meta && <p class="b-media-cards__meta">{card.meta}</p>}
|
|
56
|
+
{card.text && <p class="b-media-cards__text">{card.text}</p>}
|
|
57
|
+
{card.href && (
|
|
58
|
+
<span class="b-media-cards__cta" aria-hidden="true">{card.linkLabel ?? 'Learn more'} →</span>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
</article>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<style>
|
|
67
|
+
.b-media-cards {
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-direction: column;
|
|
70
|
+
gap: 2rem;
|
|
71
|
+
max-width: 72rem;
|
|
72
|
+
margin-inline: auto;
|
|
73
|
+
padding-inline: 1rem;
|
|
74
|
+
}
|
|
75
|
+
.b-media-cards__head {
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-direction: column;
|
|
78
|
+
gap: 0.75rem;
|
|
79
|
+
}
|
|
80
|
+
.b-media-cards__intro {
|
|
81
|
+
margin: 0;
|
|
82
|
+
color: var(--muted);
|
|
83
|
+
font-size: 1.0625rem;
|
|
84
|
+
line-height: 1.6;
|
|
85
|
+
max-width: var(--max);
|
|
86
|
+
}
|
|
87
|
+
.b-media-cards__grid {
|
|
88
|
+
display: grid;
|
|
89
|
+
grid-template-columns: 1fr;
|
|
90
|
+
gap: 1.5rem;
|
|
91
|
+
}
|
|
92
|
+
@media (min-width: 40em) {
|
|
93
|
+
.b-media-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
|
|
94
|
+
.b-media-cards__grid[data-cols='3'] { grid-template-columns: repeat(2, 1fr); }
|
|
95
|
+
}
|
|
96
|
+
@media (min-width: 60em) {
|
|
97
|
+
.b-media-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
|
|
98
|
+
}
|
|
99
|
+
.b-media-cards__card {
|
|
100
|
+
position: relative;
|
|
101
|
+
display: flex;
|
|
102
|
+
flex-direction: column;
|
|
103
|
+
background: var(--bg-surface);
|
|
104
|
+
border: var(--card-border);
|
|
105
|
+
border-radius: var(--radius);
|
|
106
|
+
box-shadow: var(--shadow-sm);
|
|
107
|
+
overflow: hidden;
|
|
108
|
+
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
|
109
|
+
}
|
|
110
|
+
.b-media-cards__card.is-link:hover {
|
|
111
|
+
transform: translateY(-2px);
|
|
112
|
+
box-shadow: var(--shadow-md);
|
|
113
|
+
border-color: var(--gold);
|
|
114
|
+
}
|
|
115
|
+
/* Image header — cover-cropped to a uniform 16:9 so any source ratio fits (the
|
|
116
|
+
PostCard technique; also out-specifies the global img{height:auto}). */
|
|
117
|
+
.b-media-cards__media {
|
|
118
|
+
position: relative;
|
|
119
|
+
aspect-ratio: 16 / 9;
|
|
120
|
+
overflow: hidden;
|
|
121
|
+
background: var(--bg-section);
|
|
122
|
+
}
|
|
123
|
+
.b-media-cards .b-media-cards__media img {
|
|
124
|
+
position: absolute;
|
|
125
|
+
inset: 0;
|
|
126
|
+
width: 100%;
|
|
127
|
+
height: 100%;
|
|
128
|
+
object-fit: cover;
|
|
129
|
+
display: block;
|
|
130
|
+
}
|
|
131
|
+
.b-media-cards__body {
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
gap: 0.5rem;
|
|
135
|
+
padding: 1.1rem 1.2rem 1.3rem;
|
|
136
|
+
flex: 1;
|
|
137
|
+
}
|
|
138
|
+
/* Icon badge — a small brand chip; slightly lifted over the image edge when an
|
|
139
|
+
image is present (the negative margin only bites inside a card with a header). */
|
|
140
|
+
.b-media-cards__badge {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
justify-content: center;
|
|
144
|
+
width: 2.25rem;
|
|
145
|
+
height: 2.25rem;
|
|
146
|
+
border-radius: 999px;
|
|
147
|
+
background: var(--gold-light);
|
|
148
|
+
color: var(--gold);
|
|
149
|
+
margin-top: -2.3rem;
|
|
150
|
+
margin-bottom: 0.2rem;
|
|
151
|
+
border: 3px solid var(--bg-surface);
|
|
152
|
+
}
|
|
153
|
+
/* No image → the badge sits normally at the top of the body. */
|
|
154
|
+
.b-media-cards__card:not(:has(.b-media-cards__media)) .b-media-cards__badge {
|
|
155
|
+
margin-top: 0;
|
|
156
|
+
border: none;
|
|
157
|
+
}
|
|
158
|
+
.b-media-cards__title {
|
|
159
|
+
margin: 0;
|
|
160
|
+
font-size: 1.2rem;
|
|
161
|
+
color: var(--fg);
|
|
162
|
+
}
|
|
163
|
+
.b-media-cards__card.is-link:hover .b-media-cards__title a { color: var(--gold); }
|
|
164
|
+
.b-media-cards__title a { color: var(--fg); }
|
|
165
|
+
/* Stretch the title link over the whole card so it's clickable. */
|
|
166
|
+
.b-media-cards__title a::after { content: ''; position: absolute; inset: 0; }
|
|
167
|
+
.b-media-cards__meta {
|
|
168
|
+
margin: 0;
|
|
169
|
+
align-self: flex-start;
|
|
170
|
+
padding: 0.2rem 0.6rem;
|
|
171
|
+
border-radius: 999px;
|
|
172
|
+
background: var(--bg-section);
|
|
173
|
+
color: var(--muted);
|
|
174
|
+
font-family: var(--font-heading);
|
|
175
|
+
font-size: 0.72rem;
|
|
176
|
+
font-weight: 600;
|
|
177
|
+
letter-spacing: 0.03em;
|
|
178
|
+
}
|
|
179
|
+
.b-media-cards__text {
|
|
180
|
+
margin: 0;
|
|
181
|
+
color: var(--muted);
|
|
182
|
+
line-height: 1.6;
|
|
183
|
+
}
|
|
184
|
+
.b-media-cards__cta {
|
|
185
|
+
margin-top: auto;
|
|
186
|
+
padding-top: 0.5rem;
|
|
187
|
+
font-family: var(--font-heading);
|
|
188
|
+
font-size: 0.85rem;
|
|
189
|
+
font-weight: 600;
|
|
190
|
+
color: var(--gold);
|
|
191
|
+
}
|
|
192
|
+
</style>
|
|
@@ -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
|
@@ -442,6 +442,34 @@ export const noticeBlock = z.object({
|
|
|
442
442
|
link: z.object({ label: z.string(), href: z.string() }).optional(),
|
|
443
443
|
});
|
|
444
444
|
|
|
445
|
+
/** MediaCards — a grid of rich content cards with the IMAGE as a header on top
|
|
446
|
+
* (cover-cropped), then an optional icon badge, title, meta line, description and
|
|
447
|
+
* CTA. The image-HEADER pattern (distinct from `tiles`, which puts the image
|
|
448
|
+
* BEHIND the text) — for groups, team, services, events where the photo AND copy
|
|
449
|
+
* must both stay legible. Whole card links when a card has `href`. */
|
|
450
|
+
export const mediaCardsBlock = z.object({
|
|
451
|
+
type: z.literal('mediaCards'),
|
|
452
|
+
enabled: z.boolean().default(true),
|
|
453
|
+
title: z.string().optional(),
|
|
454
|
+
intro: z.string().optional(),
|
|
455
|
+
columns: z.union([z.literal(2), z.literal(3)]).default(3),
|
|
456
|
+
cards: z
|
|
457
|
+
.array(
|
|
458
|
+
z.object({
|
|
459
|
+
image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
|
|
460
|
+
icon: z.string().optional(),
|
|
461
|
+
title: z.string(),
|
|
462
|
+
/** A short meta line shown as a pill (e.g. "Sundays 10:45 · CTK"). */
|
|
463
|
+
meta: z.string().optional(),
|
|
464
|
+
text: z.string().optional(),
|
|
465
|
+
href: z.string().optional(),
|
|
466
|
+
/** CTA label when the card links (defaults to "Learn more"). */
|
|
467
|
+
linkLabel: z.string().optional(),
|
|
468
|
+
})
|
|
469
|
+
)
|
|
470
|
+
.default([]),
|
|
471
|
+
});
|
|
472
|
+
|
|
445
473
|
export type RecipeBlock =
|
|
446
474
|
| z.infer<typeof heroBlock>
|
|
447
475
|
| z.infer<typeof featureCardsBlock>
|
|
@@ -464,7 +492,8 @@ export type RecipeBlock =
|
|
|
464
492
|
| z.infer<typeof latestPostsBlock>
|
|
465
493
|
| z.infer<typeof mapEmbedBlock>
|
|
466
494
|
| z.infer<typeof tableBlock>
|
|
467
|
-
| z.infer<typeof noticeBlock
|
|
495
|
+
| z.infer<typeof noticeBlock>
|
|
496
|
+
| z.infer<typeof mediaCardsBlock>;
|
|
468
497
|
|
|
469
498
|
/* ── Level-1 layout / container primitives ─────────────────────────────────
|
|
470
499
|
* These hold child blocks, so the schema is **recursive** (a container's
|
|
@@ -572,6 +601,7 @@ export const blockSchema = z.discriminatedUnion('type', [
|
|
|
572
601
|
mapEmbedBlock,
|
|
573
602
|
tableBlock,
|
|
574
603
|
noticeBlock,
|
|
604
|
+
mediaCardsBlock,
|
|
575
605
|
sectionBlock,
|
|
576
606
|
gridBlock,
|
|
577
607
|
stackBlock,
|
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/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.7",
|
|
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",
|