ferst-core 0.1.0 → 0.2.1

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.
Files changed (70) hide show
  1. package/LICENSE +102 -201
  2. package/LICENSE-Apache-2.0 +201 -0
  3. package/NOTICE +24 -5
  4. package/README.md +89 -73
  5. package/bin/check-thin.mjs +74 -0
  6. package/components/BlockRenderer.astro +89 -0
  7. package/components/Button.astro +85 -33
  8. package/components/CalendarEmbed.astro +188 -0
  9. package/components/Card.astro +7 -17
  10. package/components/Icon.astro +35 -0
  11. package/components/PostArticle.astro +133 -0
  12. package/components/PostCard.astro +93 -225
  13. package/components/SiteFooter.astro +2 -2
  14. package/components/SiteHeader.astro +104 -51
  15. package/components/blocks/Badge.astro +29 -0
  16. package/components/blocks/ButtonBlock.astro +13 -0
  17. package/components/blocks/Divider.astro +12 -0
  18. package/components/blocks/Grid.astro +48 -0
  19. package/components/blocks/Heading.astro +42 -0
  20. package/components/blocks/ImageBlock.astro +80 -0
  21. package/components/blocks/List.astro +78 -0
  22. package/components/blocks/Prose.astro +29 -0
  23. package/components/blocks/Quote.astro +32 -0
  24. package/components/blocks/Section.astro +52 -0
  25. package/components/blocks/Spacer.astro +20 -0
  26. package/components/blocks/Stack.astro +30 -0
  27. package/components/blocks/Stat.astro +30 -0
  28. package/components/recipes/Accordion.astro +84 -0
  29. package/components/recipes/Announcement.astro +74 -0
  30. package/components/recipes/Banner.astro +134 -0
  31. package/components/recipes/Bento.astro +138 -0
  32. package/components/recipes/ContactForm.astro +172 -0
  33. package/components/recipes/Cta.astro +83 -0
  34. package/components/recipes/Faq.astro +80 -0
  35. package/components/recipes/FeatureCards.astro +105 -0
  36. package/components/recipes/Gallery.astro +55 -0
  37. package/components/recipes/Hero.astro +191 -0
  38. package/components/recipes/Logos.astro +77 -0
  39. package/components/recipes/Marquee.astro +75 -0
  40. package/components/recipes/Pricing.astro +149 -0
  41. package/components/recipes/Stats.astro +124 -0
  42. package/components/recipes/Steps.astro +120 -0
  43. package/components/recipes/Tabs.astro +133 -0
  44. package/components/recipes/Testimonial.astro +66 -0
  45. package/components/recipes/Tiles.astro +248 -0
  46. package/content/blocks.ts +534 -0
  47. package/content/calendar.ts +40 -0
  48. package/content/collections.ts +25 -14
  49. package/content/posts.ts +89 -0
  50. package/content/schemas.ts +58 -123
  51. package/layouts/Base.astro +30 -56
  52. package/lib/calendar.ts +12 -0
  53. package/lib/icons.ts +64 -0
  54. package/lib/pages.ts +42 -0
  55. package/lib/posts.ts +93 -0
  56. package/lib/siteSettings.ts +18 -36
  57. package/lib/themeTokens.ts +163 -0
  58. package/package.json +61 -48
  59. package/styles/theme.css +42 -0
  60. package/components/DocLayout.astro +0 -292
  61. package/components/GroupCard.astro +0 -164
  62. package/components/LatestPostList.astro +0 -64
  63. package/components/PaginationNav.astro +0 -81
  64. package/components/PostsToolbar.astro +0 -83
  65. package/content/filters.ts +0 -107
  66. package/lib/mailLinks.ts +0 -13
  67. package/lib/mapsLink.ts +0 -7
  68. package/utils/excerpt.ts +0 -56
  69. package/utils/formatDate.ts +0 -22
  70. package/utils/freshness.ts +0 -5
@@ -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
+ }
@@ -1,52 +1,10 @@
1
- // Pure Zod schemas, separated from config.ts so they can be unit-tested
2
- // without pulling in `astro:content` (a virtual module that only exists
3
- // during Astro's build). config.ts wraps these with defineCollection.
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 in the document description fallback.
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
- /** Optional org/parent line (e.g. a diocese, a group) blank by default. */
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. Everything else that used to live
112
- * alongside these (logos, posts-per-page, calendar ID, navigation) now has
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 Base.astro falls back to a baked-in path. */
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 — Base.astro falls back to a baked-in path. */
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 (e.g. an "About" or "Services" menu).
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
- * Client-overridable design tokens. Each key maps to a CSS custom property
162
- * (a leading `--` is added if omitted); the values are emitted into a
163
- * `<style>` in the document head that overrides the core's NEUTRAL defaults
164
- * in `styles/theme.css`. The core ships none every client brings its own
165
- * palette here, so brand colours live in the client's repo (or, later, the
166
- * CMS), never baked into the shared core. `light` applies at `:root`, `dark`
167
- * under `[data-theme="dark"]`; only the keys a client sets are overridden,
168
- * the rest fall through to the neutral defaults.
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
- light: z.record(z.string()).default({}),
172
- dark: z.record(z.string()).default({}),
173
- });
174
-
175
- export const postSchema = z.object({
176
- title: z.string(),
177
- /** Author byline shown in the CMS posts list and publicly under the post title.
178
- * Expected on every post except newsletters, but Sveltia CMS can't enforce that
179
- * conditionally, so it's optional here too (communicated via the CMS field hint). */
180
- author: z.string().optional(),
181
- date: z.coerce.date(),
182
- /** A `newsletter`-tagged post is treated as a newsletter throughout the site
183
- * (tag-driven, not a separate content type) — see src/content/filters.ts. */
184
- tags: z.array(z.string()).default([]),
185
- image: z.string().optional(),
186
- /** Optional file (PDF, image, document). Expected for newsletter-tagged
187
- * posts, which are otherwise body-less communicated via the CMS hint,
188
- * same conditional-required pattern as `author`. */
189
- attachment: z.string().optional(),
190
- // Visibility states. 'scheduled' is hidden from the site exactly like
191
- // 'draft'; a separate cron worker flips due 'scheduled' posts to
192
- // 'published' (commit → rebuild) once their `date` has passed.
193
- status: z.enum(['published', 'draft', 'scheduled', 'archived']).default('published'),
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>;
@@ -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
- // Client design-token overrides → an inline <style> that layers over the
57
- // core's neutral defaults (styles/theme.css). Selectors are doubled
58
- // (`:root:root`, `:root[data-theme="dark"]`) so they win by specificity
59
- // regardless of stylesheet order. Keys are sanitised to `--custom-prop`
60
- // form and values that could break out of the declaration are dropped —
61
- // the source is trusted repo JSON, but this keeps a stray value honest.
62
- function tokenBlock(selector: string, tokens: Record<string, string>): string {
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: schema marks them optional so legacy data files keep validating.
77
- // Fall back to paths that exist under `public/uploads/` in this repo (there is
78
- // no `uploads/branding/` treeusing it here caused broken images whenever
79
- // JSON omitted the keys or site settings fell back to schema defaults).
80
- const primaryLogoLight = settings.primaryLogoLight ?? '/uploads/logo-light.png';
81
- const primaryLogoDark = settings.primaryLogoDark ?? '/uploads/logo-dark.png';
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
- var link = document.createElement('link');
121
- link.rel = 'preload';
122
- link.as = 'image';
123
- link.href = logoHref;
124
- document.head.appendChild(link);
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: 'Inter', 'Lato', Georgia, serif;
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: 'Playfair Display', Georgia, serif;
194
+ font-family: var(--font-display);
204
195
  }
205
196
  h1 {
206
- font-family: 'Poppins', sans-serif;
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: 'Playfair Display', Georgia, serif;
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: 'Poppins', sans-serif;
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: 'Poppins', sans-serif;
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: 'Inter', sans-serif;
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: 'Inter', sans-serif;
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>
@@ -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
+ }