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
package/content/posts.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The posts/blog capability contract — white-room, rebuilt on the block-system-era
|
|
2
|
+
// core (no CTK code). A capability owns its own content schema (this file), its
|
|
3
|
+
// collections (collections.ts), its rendering (components/Post*.astro) and its
|
|
4
|
+
// data helpers (lib/posts.ts); a client repo carries only the post/tag DATA + a
|
|
5
|
+
// couple of thin routes. Posts are ubiquitous/replicable → public (Tier 1a).
|
|
6
|
+
//
|
|
7
|
+
// Posts are authored as MARKDOWN (the editor-friendly long-form format, a Sveltia
|
|
8
|
+
// markdown pane) + frontmatter metadata; the page a post renders into is composed
|
|
9
|
+
// from the core chrome + a post layout. Tags are a small DATA collection so a tag
|
|
10
|
+
// carries a label + description (canonical), and posts reference tags by slug.
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
/** A tag — canonical metadata so tag pages can show a real label + description,
|
|
14
|
+
* and posts reference tags by slug (the tag file's id). */
|
|
15
|
+
export const tagSchema = z.object({
|
|
16
|
+
label: z.string(),
|
|
17
|
+
description: z.string().default(''),
|
|
18
|
+
/** Keep the tag defined but hide it from tag listings. */
|
|
19
|
+
hidden: z.boolean().default(false),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** A blog/news post: a markdown body (the collection content) + this frontmatter. */
|
|
23
|
+
export const postSchema = z.object({
|
|
24
|
+
title: z.string(),
|
|
25
|
+
/** Publish date — drives ordering and the visible date. */
|
|
26
|
+
date: z.coerce.date(),
|
|
27
|
+
/** Short summary for cards / SEO. Falls back to a body excerpt when absent. */
|
|
28
|
+
summary: z.string().optional(),
|
|
29
|
+
/** Tag slugs (each matches a `tags/<slug>` entry). */
|
|
30
|
+
tags: z.array(z.string()).default([]),
|
|
31
|
+
/** Optional hero / preview image. */
|
|
32
|
+
heroImage: z.string().optional(),
|
|
33
|
+
/** Optional downloadable file attached to the post (e.g. a PDF newsletter or
|
|
34
|
+
* notice). Rendered as a download link on the post. */
|
|
35
|
+
attachment: z.string().optional(),
|
|
36
|
+
/** Keep the post in the repo but exclude it from the build. */
|
|
37
|
+
draft: z.boolean().default(false),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Blog-wide settings — one focused CMS pane. */
|
|
41
|
+
export const postsSettingsSchema = z.object({
|
|
42
|
+
/** Heading for the main news index. */
|
|
43
|
+
title: z.string().default('News'),
|
|
44
|
+
/** Optional intro copy for the index. */
|
|
45
|
+
intro: z.string().default(''),
|
|
46
|
+
/** Posts per page in the paginated index. */
|
|
47
|
+
perPage: z.number().int().min(1).default(9),
|
|
48
|
+
/** Tag slugs excluded from the MAIN index (e.g. a `newsletter` tag that has its
|
|
49
|
+
* own view). Kept generic — no capability knows what "newsletter" means. */
|
|
50
|
+
excludeFromIndex: z.array(z.string()).default([]),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export type Tag = z.infer<typeof tagSchema>;
|
|
54
|
+
export type Post = z.infer<typeof postSchema>;
|
|
55
|
+
export type PostsSettings = z.infer<typeof postsSettingsSchema>;
|
|
56
|
+
|
|
57
|
+
// ── Pure selection helpers ────────────────────────────────────────────────────
|
|
58
|
+
// Kept here (no `astro:content` import) so they're unit-testable as plain
|
|
59
|
+
// functions; lib/posts.ts wraps `getCollection` around them. Generic over the
|
|
60
|
+
// minimal shape they read, so they work on real collection entries.
|
|
61
|
+
|
|
62
|
+
/** Newest first, by `data.date`. Does not mutate the input. */
|
|
63
|
+
export function sortByDateDesc<T extends { data: { date: Date } }>(posts: T[]): T[] {
|
|
64
|
+
return [...posts].sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Drop posts flagged `draft`. */
|
|
68
|
+
export function dropDrafts<T extends { data: { draft?: boolean } }>(posts: T[]): T[] {
|
|
69
|
+
return posts.filter((p) => !p.data.draft);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Drop posts carrying ANY of the excluded tag slugs. */
|
|
73
|
+
export function excludeTags<T extends { data: { tags?: string[] } }>(posts: T[], exclude: string[]): T[] {
|
|
74
|
+
if (exclude.length === 0) return posts;
|
|
75
|
+
const set = new Set(exclude);
|
|
76
|
+
return posts.filter((p) => !(p.data.tags ?? []).some((t) => set.has(t)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Keep only posts carrying the given tag slug. */
|
|
80
|
+
export function withTag<T extends { data: { tags?: string[] } }>(posts: T[], tag: string): T[] {
|
|
81
|
+
return posts.filter((p) => (p.data.tags ?? []).includes(tag));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Count how many posts carry each tag slug. */
|
|
85
|
+
export function tagCounts(posts: Array<{ data: { tags?: string[] } }>): Record<string, number> {
|
|
86
|
+
const counts: Record<string, number> = {};
|
|
87
|
+
for (const p of posts) for (const t of p.data.tags ?? []) counts[t] = (counts[t] ?? 0) + 1;
|
|
88
|
+
return counts;
|
|
89
|
+
}
|
package/content/schemas.ts
CHANGED
|
@@ -1,52 +1,10 @@
|
|
|
1
|
-
// Pure Zod schemas
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Pure Zod schemas for the site-settings collections the chrome reads (identity +
|
|
2
|
+
// contact, logos, navigation, footer, theme tokens). Separated from config.ts so
|
|
3
|
+
// they can be unit-tested without `astro:content`. The page CONTENT model is the
|
|
4
|
+
// block palette in `content/blocks.ts` — a page is an ordered list of typed
|
|
5
|
+
// blocks, not a bespoke per-page-type schema.
|
|
4
6
|
import { z } from 'zod';
|
|
5
7
|
|
|
6
|
-
/** One “Get Involved” card on the home page (`GroupCard`). */
|
|
7
|
-
export const homepageGroupSchema = z.object({
|
|
8
|
-
title: z.string(),
|
|
9
|
-
description: z.string(),
|
|
10
|
-
icon: z.enum(['prayer', 'charity', 'ordinariate', 'youth']).optional(),
|
|
11
|
-
/** Shown when `linkHref` is not set — mailto handled in `GroupCard`. */
|
|
12
|
-
contact: z.string().optional(),
|
|
13
|
-
contactLabel: z.string().optional(),
|
|
14
|
-
linkHref: z.string().optional(),
|
|
15
|
-
imageSrc: z.string().optional(),
|
|
16
|
-
imageAlt: z.string().optional(),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export const homepageSchema = z.object({
|
|
20
|
-
heroImage: z.string().optional(),
|
|
21
|
-
groups: z.array(homepageGroupSchema).default([]),
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Editor-managed tag vocabulary. A post's `tags` array stores these entries'
|
|
26
|
-
* `name` values directly (not a slug/reference) — see the CMS `relation`
|
|
27
|
-
* field config in scripts/build-admin-config.mjs — so this collection exists
|
|
28
|
-
* purely to give editors a searchable, growable, typo-resistant picker
|
|
29
|
-
* instead of a hardcoded list. The site itself never reads this collection;
|
|
30
|
-
* only the CMS authoring UI does.
|
|
31
|
-
*/
|
|
32
|
-
export const tagSchema = z.object({
|
|
33
|
-
name: z.string(),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
/* ────────────────────────────────────────────────────────────────────────
|
|
37
|
-
* Site settings — split into several small single-purpose collections
|
|
38
|
-
* (logo / posts / calendar / navigation / identity+contact) instead of one
|
|
39
|
-
* big file, so each one is also its own focused Sveltia CMS pane rather
|
|
40
|
-
* than everything mushed onto a single long form. `src/lib/siteSettings.ts`
|
|
41
|
-
* merges all of these back into one `SiteSettings`-shaped object at read
|
|
42
|
-
* time, so every consumer keeps reading a single combined shape — only the
|
|
43
|
-
* underlying storage and CMS authoring surface are split.
|
|
44
|
-
*
|
|
45
|
-
* Every nested object is `.default({})` so the data file only has to
|
|
46
|
-
* supply the keys it wants to override; defaults fill in the rest at
|
|
47
|
-
* parse time. That keeps schemas additive and JSON edits small.
|
|
48
|
-
* ──────────────────────────────────────────────────────────────────── */
|
|
49
|
-
|
|
50
8
|
const navLinkSchema = z.object({
|
|
51
9
|
label: z.string(),
|
|
52
10
|
href: z.string(),
|
|
@@ -67,31 +25,16 @@ const navGroupSchema = z.object({
|
|
|
67
25
|
items: z.array(navGroupItemSchema).default([]),
|
|
68
26
|
});
|
|
69
27
|
|
|
70
|
-
const churchSchema = z.object({
|
|
71
|
-
name: z.string(),
|
|
72
|
-
/** One physical-address line per array entry; rendered with <br /> between. */
|
|
73
|
-
addressLines: z.array(z.string()).default([]),
|
|
74
|
-
/** Free-form Google Maps query; the page wraps it in maps.google.com/?q=. */
|
|
75
|
-
mapsQuery: z.string().optional(),
|
|
76
|
-
});
|
|
77
|
-
|
|
78
28
|
const siteIdentitySchema = z
|
|
79
29
|
.object({
|
|
80
|
-
/** Used in <title> as " — {siteTitle}" and
|
|
81
|
-
* Generic default — every client overrides via their siteSettings content. */
|
|
30
|
+
/** Used in <title> as " — {siteTitle}" and the description fallback. */
|
|
82
31
|
siteTitle: z.string().default('Your Site'),
|
|
83
|
-
/** Short brand label — used in alt text. */
|
|
32
|
+
/** Short brand label — used in alt text and the footer. */
|
|
84
33
|
shortName: z.string().default('Your Site'),
|
|
85
34
|
/** Default <meta name="description"> when a page doesn't supply its own. */
|
|
86
35
|
description: z.string().default('A website built with Ferst.'),
|
|
87
|
-
/**
|
|
88
|
-
diocese: z.string().default(''),
|
|
89
|
-
/** Town / city — appears under the hero title and elsewhere. */
|
|
36
|
+
/** Town / city — appended to the brand in alt text. */
|
|
90
37
|
location: z.string().default(''),
|
|
91
|
-
/** Home-page hero copy. */
|
|
92
|
-
heroDiocese: z.string().default(''),
|
|
93
|
-
heroTitle: z.string().default('Welcome'),
|
|
94
|
-
heroLocation: z.string().default(''),
|
|
95
38
|
})
|
|
96
39
|
.default({});
|
|
97
40
|
|
|
@@ -99,27 +42,24 @@ const siteContactSchema = z
|
|
|
99
42
|
.object({
|
|
100
43
|
/** Human-readable phone number. Blank by default — client provides. */
|
|
101
44
|
phoneDisplay: z.string().default(''),
|
|
102
|
-
/** Digits-only form used in `tel:` href. */
|
|
45
|
+
/** Digits-only form used in a `tel:` href. */
|
|
103
46
|
phoneTel: z.string().default(''),
|
|
104
47
|
email: z.string().default(''),
|
|
105
48
|
facebookUrl: z.string().default(''),
|
|
106
|
-
/** One entry per physical location/site. Iterated on /contact/. */
|
|
107
|
-
churches: z.array(churchSchema).default([]),
|
|
108
49
|
})
|
|
109
50
|
.default({});
|
|
110
51
|
|
|
111
|
-
/** Site identity + contact details.
|
|
112
|
-
*
|
|
113
|
-
* its own collection — see below. */
|
|
52
|
+
/** Site identity + contact details. Logos, navigation, footer and theme tokens
|
|
53
|
+
* each have their own collection (below) so each is a focused CMS pane. */
|
|
114
54
|
export const siteSettingsSchema = z.object({
|
|
115
55
|
identity: siteIdentitySchema,
|
|
116
56
|
contact: siteContactSchema,
|
|
117
57
|
});
|
|
118
58
|
|
|
119
59
|
export const logoSettingsSchema = z.object({
|
|
120
|
-
/** Primary/header logo — light theme variant. Optional
|
|
60
|
+
/** Primary/header logo — light theme variant. Optional (SiteHeader falls back to a wordmark). */
|
|
121
61
|
primaryLogoLight: z.string().optional(),
|
|
122
|
-
/** Primary/header logo — dark theme variant. Optional
|
|
62
|
+
/** Primary/header logo — dark theme variant. Optional. */
|
|
123
63
|
primaryLogoDark: z.string().optional(),
|
|
124
64
|
/** Secondary/footer logo — light theme variant (optional). */
|
|
125
65
|
secondaryLogoLight: z.string().optional(),
|
|
@@ -134,21 +74,10 @@ export const logoSettingsSchema = z.object({
|
|
|
134
74
|
.default({}),
|
|
135
75
|
});
|
|
136
76
|
|
|
137
|
-
export const postsSettingsSchema = z.object({
|
|
138
|
-
postsPerPage: z.number().int().min(1).max(20).default(5),
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
export const calendarSettingsSchema = z.object({
|
|
142
|
-
/** Google Calendar ID (e.g. "abc123@group.calendar.google.com") embedded on /calendar/.
|
|
143
|
-
* Blank by default — client provides; the calendar page can hide itself when unset. */
|
|
144
|
-
calendarId: z.string().default(''),
|
|
145
|
-
});
|
|
146
|
-
|
|
147
77
|
export const navbarSettingsSchema = z.object({
|
|
148
78
|
/** Simple top-level links. Empty by default — every client defines its own nav. */
|
|
149
79
|
primary: z.array(navLinkSchema).default([]),
|
|
150
|
-
/** Optional dropdown / mega-menu group
|
|
151
|
-
* Rendered by SiteHeader only when present — the core ships none. */
|
|
80
|
+
/** Optional dropdown / mega-menu group. Rendered by SiteHeader only when present. */
|
|
152
81
|
dropdown: navGroupSchema.optional(),
|
|
153
82
|
});
|
|
154
83
|
|
|
@@ -158,53 +87,59 @@ export const footerSettingsSchema = z.object({
|
|
|
158
87
|
});
|
|
159
88
|
|
|
160
89
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* the rest
|
|
90
|
+
* The brand — a client's design tokens, set in the CMS. The whole system is
|
|
91
|
+
* built to derive from just THREE source colours: `brand` (interactive accent),
|
|
92
|
+
* `ink` (text) and `surface` (background). `lib/themeTokens.ts` expands those
|
|
93
|
+
* into ~40 working tokens (light via `color-mix`, dark via an OKLCH translation
|
|
94
|
+
* — lightness flipped, hue kept, chroma damped so nothing turns neon), and
|
|
95
|
+
* `Base.astro` emits the result as a `<style>` that layers over the core's
|
|
96
|
+
* NEUTRAL defaults in `styles/theme.css`. Fonts and the four surface "knobs"
|
|
97
|
+
* (corners / elevation / stroke / fields) are the rest of the site-wide look —
|
|
98
|
+
* each set once, applied everywhere. `advanced.light/.dark` are a raw
|
|
99
|
+
* per-token escape hatch (power users / AI composition / back-compat) layered
|
|
100
|
+
* LAST. The core ships none of this — a brand lives in the client's repo (or
|
|
101
|
+
* the CMS), never baked into the shared core.
|
|
102
|
+
*
|
|
103
|
+
* This shape is what the Sveltia token pane edits (apps/showcase/public/admin):
|
|
104
|
+
* a client picks three colours + a font pairing + four knobs, not 40 tokens.
|
|
169
105
|
*/
|
|
170
106
|
export const themeSettingsSchema = z.object({
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
107
|
+
/** Interactive accent — buttons, links, icons, highlights. Blank ⇒ neutral. */
|
|
108
|
+
brand: z.string().default(''),
|
|
109
|
+
/** Text colour — headings & body. Blank ⇒ neutral. */
|
|
110
|
+
ink: z.string().default(''),
|
|
111
|
+
/** Background — page, cards, sections (tints derived from it). Blank ⇒ neutral. */
|
|
112
|
+
surface: z.string().default(''),
|
|
113
|
+
/** Site-wide font pairing (role → CSS font-family stack). Blank ⇒ core default. */
|
|
114
|
+
fonts: z
|
|
115
|
+
.object({
|
|
116
|
+
heading: z.string().default(''),
|
|
117
|
+
body: z.string().default(''),
|
|
118
|
+
display: z.string().default(''),
|
|
119
|
+
})
|
|
120
|
+
.default({}),
|
|
121
|
+
/** Corner radius applied to every card / tile / input / button. */
|
|
122
|
+
corners: z.enum(['sharp', 'rounded', 'soft']).default('rounded'),
|
|
123
|
+
/** Card/tile elevation — `flat` removes the soft shadow site-wide. */
|
|
124
|
+
elevation: z.enum(['raised', 'flat']).default('raised'),
|
|
125
|
+
/** Card/tile stroke — `off` removes the 1px border site-wide. */
|
|
126
|
+
stroke: z.enum(['on', 'off']).default('on'),
|
|
127
|
+
/** Input treatment — a single site-wide choice (a `data-field` attr on <html>). */
|
|
128
|
+
fields: z.enum(['boxed', 'filled', 'underline']).default('boxed'),
|
|
129
|
+
/** Raw per-token overrides, layered last. Rarely needed. */
|
|
130
|
+
advanced: z
|
|
131
|
+
.object({
|
|
132
|
+
light: z.record(z.string()).default({}),
|
|
133
|
+
dark: z.record(z.string()).default({}),
|
|
134
|
+
})
|
|
135
|
+
.default({}),
|
|
194
136
|
});
|
|
195
137
|
|
|
196
|
-
export type Post = z.infer<typeof postSchema>;
|
|
197
|
-
export type Tag = z.infer<typeof tagSchema>;
|
|
198
|
-
export type Homepage = z.infer<typeof homepageSchema>;
|
|
199
|
-
export type HomepageGroup = z.infer<typeof homepageGroupSchema>;
|
|
200
138
|
export type SiteSettings = z.infer<typeof siteSettingsSchema>;
|
|
201
139
|
export type LogoSettings = z.infer<typeof logoSettingsSchema>;
|
|
202
|
-
export type PostsSettings = z.infer<typeof postsSettingsSchema>;
|
|
203
|
-
export type CalendarSettings = z.infer<typeof calendarSettingsSchema>;
|
|
204
140
|
export type NavbarSettings = z.infer<typeof navbarSettingsSchema>;
|
|
205
141
|
export type FooterSettings = z.infer<typeof footerSettingsSchema>;
|
|
206
142
|
export type ThemeSettings = z.infer<typeof themeSettingsSchema>;
|
|
207
143
|
export type NavLink = z.infer<typeof navLinkSchema>;
|
|
208
144
|
export type NavGroup = z.infer<typeof navGroupSchema>;
|
|
209
145
|
export type NavGroupItem = z.infer<typeof navGroupItemSchema>;
|
|
210
|
-
export type Church = z.infer<typeof churchSchema>;
|
package/layouts/Base.astro
CHANGED
|
@@ -26,6 +26,7 @@ import SiteFooter from '../components/SiteFooter.astro';
|
|
|
26
26
|
import CookiePreferences from '../components/CookiePreferences.astro';
|
|
27
27
|
import SecondaryLogo from '../components/SecondaryLogo.astro';
|
|
28
28
|
import { loadSiteSettings } from '../lib/siteSettings';
|
|
29
|
+
import { buildThemeStyle } from '../lib/themeTokens';
|
|
29
30
|
|
|
30
31
|
interface Props {
|
|
31
32
|
title: string;
|
|
@@ -53,32 +54,20 @@ const { identity, contact, navigation, theme } = settings;
|
|
|
53
54
|
|
|
54
55
|
const path = Astro.url.pathname;
|
|
55
56
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// (
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
const decls = Object.entries(tokens ?? {})
|
|
64
|
-
.filter(([k, v]) => /^--?[a-zA-Z0-9-]+$/.test(k) && !/[<>{};]/.test(String(v)))
|
|
65
|
-
.map(([k, v]) => `${k.startsWith('--') ? k : `--${k}`}:${String(v).trim()}`)
|
|
66
|
-
.join(';');
|
|
67
|
-
return decls ? `${selector}{${decls}}` : '';
|
|
68
|
-
}
|
|
69
|
-
const themeCss = [
|
|
70
|
-
tokenBlock(':root:root', theme.light),
|
|
71
|
-
tokenBlock(':root[data-theme="dark"]', theme.dark),
|
|
72
|
-
]
|
|
73
|
-
.filter(Boolean)
|
|
74
|
-
.join('');
|
|
57
|
+
// The brand → an inline <style> that layers over the core's neutral defaults
|
|
58
|
+
// (styles/theme.css). `buildThemeStyle` derives ~40 working tokens from the
|
|
59
|
+
// three source colours (light color-mix + dark OKLCH) and emits the font/knob
|
|
60
|
+
// overrides too; selectors are doubled (`:root:root`) so they win by
|
|
61
|
+
// specificity regardless of stylesheet order. `themeField` is the site-wide
|
|
62
|
+
// input treatment, applied as a `data-field` attribute on <html>.
|
|
63
|
+
const { css: themeCss, field: themeField } = buildThemeStyle(theme);
|
|
75
64
|
|
|
76
|
-
// Logos
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
const primaryLogoLight = settings.primaryLogoLight
|
|
81
|
-
const primaryLogoDark = settings.primaryLogoDark ??
|
|
65
|
+
// Logos are optional. When a client hasn't configured one, SiteHeader renders a
|
|
66
|
+
// text wordmark (the site name) rather than pointing <img> at a baked-in path
|
|
67
|
+
// that may not exist — a broken logo by default was the old bug. Dark falls back
|
|
68
|
+
// to the light variant when only one is supplied.
|
|
69
|
+
const primaryLogoLight = settings.primaryLogoLight;
|
|
70
|
+
const primaryLogoDark = settings.primaryLogoDark ?? settings.primaryLogoLight;
|
|
82
71
|
const secondaryLogoLight = settings.secondaryLogoLight;
|
|
83
72
|
const secondaryLogoDark = settings.secondaryLogoDark;
|
|
84
73
|
const secondaryLogoLink = settings.secondaryLogoLink;
|
|
@@ -87,7 +76,7 @@ const metaDescription = description ?? identity.description;
|
|
|
87
76
|
---
|
|
88
77
|
|
|
89
78
|
<!doctype html>
|
|
90
|
-
<html lang="en-GB">
|
|
79
|
+
<html lang="en-GB" data-field={themeField}>
|
|
91
80
|
<head>
|
|
92
81
|
<meta charset="utf-8" />
|
|
93
82
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -117,11 +106,13 @@ const metaDescription = description ?? identity.description;
|
|
|
117
106
|
}
|
|
118
107
|
document.documentElement.dataset.theme = t;
|
|
119
108
|
var logoHref = t === 'dark' ? primaryLogoDark : primaryLogoLight;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
109
|
+
if (logoHref) {
|
|
110
|
+
var link = document.createElement('link');
|
|
111
|
+
link.rel = 'preload';
|
|
112
|
+
link.as = 'image';
|
|
113
|
+
link.href = logoHref;
|
|
114
|
+
document.head.appendChild(link);
|
|
115
|
+
}
|
|
125
116
|
})();
|
|
126
117
|
</script>
|
|
127
118
|
<script>
|
|
@@ -189,7 +180,7 @@ const metaDescription = description ?? identity.description;
|
|
|
189
180
|
overscroll-behavior-x: none;
|
|
190
181
|
}
|
|
191
182
|
body {
|
|
192
|
-
font-family:
|
|
183
|
+
font-family: var(--font-body);
|
|
193
184
|
color: var(--fg);
|
|
194
185
|
background: var(--bg);
|
|
195
186
|
line-height: 1.6;
|
|
@@ -200,10 +191,10 @@ const metaDescription = description ?? identity.description;
|
|
|
200
191
|
overscroll-behavior-x: none;
|
|
201
192
|
}
|
|
202
193
|
h1, h2, h3, h4 {
|
|
203
|
-
font-family:
|
|
194
|
+
font-family: var(--font-display);
|
|
204
195
|
}
|
|
205
196
|
h1 {
|
|
206
|
-
font-family:
|
|
197
|
+
font-family: var(--font-heading);
|
|
207
198
|
color: var(--fg);
|
|
208
199
|
font-size: clamp(2rem, 0.95rem + 4.5vw, 3.35rem);
|
|
209
200
|
font-weight: 700;
|
|
@@ -211,19 +202,15 @@ const metaDescription = description ?? identity.description;
|
|
|
211
202
|
letter-spacing: -0.01em;
|
|
212
203
|
margin: 0;
|
|
213
204
|
}
|
|
214
|
-
.site-main h1 {
|
|
215
|
-
text-align: center;
|
|
216
|
-
}
|
|
217
205
|
h2 {
|
|
218
|
-
font-family:
|
|
206
|
+
font-family: var(--font-display);
|
|
219
207
|
color: var(--fg);
|
|
220
208
|
font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
|
|
221
209
|
line-height: 1.3;
|
|
222
210
|
margin: 0;
|
|
223
|
-
text-align: center;
|
|
224
211
|
}
|
|
225
212
|
h3 {
|
|
226
|
-
font-family:
|
|
213
|
+
font-family: var(--font-heading);
|
|
227
214
|
color: var(--fg);
|
|
228
215
|
font-size: clamp(1.375rem, 1.2rem + 0.55vw, 1.625rem);
|
|
229
216
|
font-weight: 600;
|
|
@@ -231,7 +218,7 @@ const metaDescription = description ?? identity.description;
|
|
|
231
218
|
margin: 0;
|
|
232
219
|
}
|
|
233
220
|
h4 {
|
|
234
|
-
font-family:
|
|
221
|
+
font-family: var(--font-heading);
|
|
235
222
|
color: var(--accent);
|
|
236
223
|
font-size: clamp(0.6875rem, 0.9vw, 0.75rem);
|
|
237
224
|
font-weight: 500;
|
|
@@ -241,7 +228,7 @@ const metaDescription = description ?? identity.description;
|
|
|
241
228
|
}
|
|
242
229
|
|
|
243
230
|
p, li {
|
|
244
|
-
font-family:
|
|
231
|
+
font-family: var(--font-body);
|
|
245
232
|
color: var(--muted);
|
|
246
233
|
font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
|
|
247
234
|
font-weight: 400;
|
|
@@ -327,7 +314,7 @@ const metaDescription = description ?? identity.description;
|
|
|
327
314
|
}
|
|
328
315
|
}
|
|
329
316
|
a {
|
|
330
|
-
font-family:
|
|
317
|
+
font-family: var(--font-body);
|
|
331
318
|
font-size: clamp(0.875rem, 0.82rem + 0.25vw, 1rem);
|
|
332
319
|
font-weight: 600;
|
|
333
320
|
color: var(--accent);
|
|
@@ -397,18 +384,5 @@ const metaDescription = description ?? identity.description;
|
|
|
397
384
|
.site-main { padding-inline: 1rem; }
|
|
398
385
|
}
|
|
399
386
|
</style>
|
|
400
|
-
|
|
401
|
-
<script is:inline>
|
|
402
|
-
// Remove the "New" flag from posts older than 3 days. Inline so it
|
|
403
|
-
// runs as soon as it's parsed (before LCP), without an extra request.
|
|
404
|
-
(function () {
|
|
405
|
-
var WINDOW = 3 * 24 * 60 * 60 * 1000;
|
|
406
|
-
var now = Date.now();
|
|
407
|
-
document.querySelectorAll('.new-flag[data-fresh-since]').forEach(function (el) {
|
|
408
|
-
var ts = Date.parse(el.getAttribute('data-fresh-since') || '');
|
|
409
|
-
if (!isNaN(ts) && now - ts >= WINDOW) el.remove();
|
|
410
|
-
});
|
|
411
|
-
})();
|
|
412
|
-
</script>
|
|
413
387
|
</body>
|
|
414
388
|
</html>
|
package/lib/calendar.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The calendar capability's data side — a tiny loader so a client's `/calendar`
|
|
3
|
+
* route stays thin. The consent gate + iframe live in components/CalendarEmbed.astro.
|
|
4
|
+
*/
|
|
5
|
+
import { getEntry } from 'astro:content';
|
|
6
|
+
import { calendarSettingsSchema, type CalendarSettings } from '../content/calendar';
|
|
7
|
+
|
|
8
|
+
/** Calendar settings (defaults applied, so a missing file is fine). */
|
|
9
|
+
export async function getCalendarSettings(): Promise<CalendarSettings> {
|
|
10
|
+
const entry = await getEntry('calendarSettings', 'index');
|
|
11
|
+
return calendarSettingsSchema.parse(entry?.data ?? {});
|
|
12
|
+
}
|
package/lib/icons.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single inline-SVG icon registry for ferst-core (Tier 1a substrate).
|
|
3
|
+
*
|
|
4
|
+
* One registry, one shape: each value is the raw markup that goes *inside* an
|
|
5
|
+
* `<svg>` — see `components/Icon.astro`, the only intended consumer, for the
|
|
6
|
+
* wrapping attributes. Icons render with `stroke="currentColor"` so they follow
|
|
7
|
+
* the caller's `color` and switch with the site's light/dark theme automatically.
|
|
8
|
+
*
|
|
9
|
+
* Keys are deliberately **business-agnostic** (`landmark`, not `church`;
|
|
10
|
+
* `map-pin`, not `parish-office`) — this set is part of the reusable substrate.
|
|
11
|
+
*
|
|
12
|
+
* History: this merges CTK's two divergent registries (`lib/icons.ts` raw
|
|
13
|
+
* markup + `lib/tagIcons` structured objects) into one — the seven shapes below
|
|
14
|
+
* the divider were ported from the tag set (Lucide-derived), converted to the
|
|
15
|
+
* same markup shape, with duplicates (calendar/heart/tag/people/email/book)
|
|
16
|
+
* dropped in favour of the entries already here.
|
|
17
|
+
*/
|
|
18
|
+
export const ICONS: Record<string, string> = {
|
|
19
|
+
landmark: '<path d="M12 3v4M9 7h6M6 21V10l6-4 6 4v11M6 21h12M10 21v-5h4v5"></path>',
|
|
20
|
+
canopy: '<polygon points="12 3 3 9 21 9"></polygon><line x1="4" y1="9" x2="4" y2="19"></line><line x1="9" y1="9" x2="9" y2="19"></line><line x1="15" y1="9" x2="15" y2="19"></line><line x1="20" y1="9" x2="20" y2="19"></line><line x1="2" y1="22" x2="22" y2="22"></line>',
|
|
21
|
+
drop: '<path d="M12 3c3 4 6 8.5 6 12a6 6 0 1 1-12 0c0-3.5 3-8 6-12z"></path>',
|
|
22
|
+
cup: '<path d="M7 4h10M8 4c0 3 .5 6 4 6s4-3 4-6M12 10v7M9 21h6"></path>',
|
|
23
|
+
flame: '<path d="M12 2c1.5 2.5-1 4-1 6.5a3 3 0 1 0 6 0c0-1-.4-1.8-1-2.3.6 3-1.7 5.3-4 5.3a5 5 0 0 1-5-5c0-3.5 3-4.2 5-4.5z"></path>',
|
|
24
|
+
heart: '<path d="M12 20s-7-4.2-9.3-8.7A5 5 0 0 1 12 6a5 5 0 0 1 9.3 5.3C19 15.8 12 20 12 20z"></path>',
|
|
25
|
+
flask: '<path d="M10 3h4M11 3v3.5c0 .5-.2 1-.6 1.4C8.8 9.5 8 11.3 8 13a4 4 0 0 0 8 0c0-1.7-.8-3.5-2.4-5.1a2 2 0 0 1-.6-1.4V3"></path>',
|
|
26
|
+
link: '<circle cx="9" cy="14" r="5"></circle><circle cx="15" cy="14" r="5"></circle>',
|
|
27
|
+
document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><path d="M14 2v6h6"></path><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="15" y2="17"></line>',
|
|
28
|
+
checklist: '<rect x="3" y="4" width="18" height="16" rx="2"></rect><path d="m7 9 2 2 4-4"></path><line x1="13" y1="15" x2="17" y2="15"></line><line x1="7" y1="15" x2="9" y2="15"></line>',
|
|
29
|
+
people: '<circle cx="12" cy="8" r="3.2"></circle><path d="M5 20c0-3.5 3-6 7-6s7 2.5 7 6"></path>',
|
|
30
|
+
sparkle: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2 2M16 16l2 2M6 18l2-2M16 8l2-2"></path>',
|
|
31
|
+
coins: '<ellipse cx="12" cy="6" rx="7" ry="3"></ellipse><path d="M5 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6M5 12v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6"></path>',
|
|
32
|
+
gift: '<rect x="3.5" y="8" width="17" height="4.5" rx="1"></rect><path d="M5 12.5V19a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6.5"></path><path d="M12 8v13"></path><path d="M12 8H8.2a2.2 2.2 0 1 1 0-4.5C10.8 3.5 12 8 12 8Z"></path><path d="M12 8h3.8a2.2 2.2 0 1 0 0-4.5C13.2 3.5 12 8 12 8Z"></path>',
|
|
33
|
+
music: '<path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle>',
|
|
34
|
+
book: '<path d="M4 19.5V5a2 2 0 0 1 2-2h13v15H6a2 2 0 0 0-2 2Zm0 0a2 2 0 0 0 2 2h13"></path>',
|
|
35
|
+
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>',
|
|
36
|
+
building: '<polyline points="3 11 12 4 21 11"></polyline><rect x="6" y="11" width="12" height="9"></rect><rect x="10" y="15" width="4" height="5"></rect>',
|
|
37
|
+
shield: '<path d="M12 2 20 5 20 11 C20 16 16.5 20 12 22 C7.5 20 4 16 4 11 L4 5 Z"></path>',
|
|
38
|
+
tag: '<path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.5 3H4a1 1 0 0 0-1 1v5.5a2 2 0 0 0 .83 1.5l9.58 9.59a2 2 0 0 0 2.83 0l4.35-4.35a2 2 0 0 0 0-2.83z"></path><circle cx="7.5" cy="7.5" r="1.3"></circle>',
|
|
39
|
+
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 9.91a16 16 0 0 0 6.18 6.18l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"></path>',
|
|
40
|
+
email: '<rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>',
|
|
41
|
+
chat: '<path d="M20 11.5a7.5 7.5 0 0 1-10.8 6.8L4 19.5l1.2-4.1A7.5 7.5 0 1 1 20 11.5Z"></path><path d="M8.5 11.5h.01M12 11.5h.01M15.5 11.5h.01"></path>',
|
|
42
|
+
globe: '<circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>',
|
|
43
|
+
check: '<path d="M20 6 9 17l-5-5"></path>',
|
|
44
|
+
arrow: '<path d="M5 12h14M13 6l6 6-6 6"></path>',
|
|
45
|
+
|
|
46
|
+
// ── merged from the former tag-icon set (Lucide-derived, ISC) ──────────────
|
|
47
|
+
megaphone: '<path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z"></path><path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14"></path><path d="M8 6v8"></path>',
|
|
48
|
+
newspaper: '<path d="M15 18h-5"></path><path d="M18 14h-8"></path><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"></path><rect x="10" y="6" width="8" height="4" rx="1"></rect>',
|
|
49
|
+
star: '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"></path>',
|
|
50
|
+
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"></path><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path>',
|
|
51
|
+
flag: '<path d="M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528"></path>',
|
|
52
|
+
'map-pin': '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle>',
|
|
53
|
+
'book-open': '<path d="M12 5v16"></path><path d="M20.001 19A2 2 0 0 0 22 17V5a2 2 0 0 0-1.999-2L16 3.002A5 5 0 0 0 12 5a5 5 0 0 0-4-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 1.999 2H8a5 5 0 0 1 4 2 5 5 0 0 1 4-2z"></path>',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type IconKey = keyof typeof ICONS;
|
|
57
|
+
|
|
58
|
+
/** All registry keys, for validation / CMS select generation. */
|
|
59
|
+
export const iconKeys = Object.keys(ICONS) as IconKey[];
|
|
60
|
+
|
|
61
|
+
/** Runtime guard — true when `value` names an icon in the registry. */
|
|
62
|
+
export function isIconKey(value: unknown): value is IconKey {
|
|
63
|
+
return typeof value === 'string' && value in ICONS;
|
|
64
|
+
}
|
package/lib/pages.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client block-page renderer's data side. A client's whole site is (chrome
|
|
3
|
+
* aside) a set of JSON files in the `pages` collection — one file per page, its
|
|
4
|
+
* filename the URL slug — each an ordered list of typed blocks (content/blocks.ts).
|
|
5
|
+
* The client's `src/pages/[...slug].astro` catch-all turns these into routes:
|
|
6
|
+
*
|
|
7
|
+
* // src/pages/[...slug].astro (a real client's is exactly this — thin)
|
|
8
|
+
* import Base from 'ferst-core/layouts/Base.astro';
|
|
9
|
+
* import BlockRenderer from 'ferst-core/components/BlockRenderer.astro';
|
|
10
|
+
* import { pageSchema } from 'ferst-core/content/blocks';
|
|
11
|
+
* import { getPageRoutes } from 'ferst-core/lib/pages';
|
|
12
|
+
* export const getStaticPaths = getPageRoutes;
|
|
13
|
+
* const { entry } = Astro.props;
|
|
14
|
+
* const page = pageSchema.parse(entry.data);
|
|
15
|
+
* // <Base title={page.title} description={page.description} heroImage={page.heroImage}>
|
|
16
|
+
* // <BlockRenderer blocks={page.blocks} />
|
|
17
|
+
* // </Base>
|
|
18
|
+
*
|
|
19
|
+
* Keeping this in the core means the routing convention is single-source: a
|
|
20
|
+
* client repo carries only content + a two-line catch-all, never a copy of the
|
|
21
|
+
* mechanism. Explicit `.astro` routes always win over the catch-all, so a client
|
|
22
|
+
* can still hand-build a one-off page beside the JSON-driven ones.
|
|
23
|
+
*/
|
|
24
|
+
import { getCollection } from 'astro:content';
|
|
25
|
+
|
|
26
|
+
/** All non-draft `pages` entries. */
|
|
27
|
+
export async function getPageEntries() {
|
|
28
|
+
const all = await getCollection('pages');
|
|
29
|
+
return all.filter((entry) => !entry.data.draft);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A drop-in `getStaticPaths` for a client's `[...slug].astro`: one route per
|
|
34
|
+
* non-draft page, keyed by the file's slug, with the entry passed as a prop.
|
|
35
|
+
*/
|
|
36
|
+
export async function getPageRoutes() {
|
|
37
|
+
const entries = await getPageEntries();
|
|
38
|
+
return entries.map((entry) => ({
|
|
39
|
+
params: { slug: entry.id },
|
|
40
|
+
props: { entry },
|
|
41
|
+
}));
|
|
42
|
+
}
|