ferst-core 0.7.4 → 0.7.5
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/bin/ferst-update.mjs +33 -2
- package/cms/config.template.yml +214 -206
- package/components/BlockRenderer.astro +4 -0
- package/components/SiteFooter.astro +274 -270
- package/components/SiteHeader.astro +1243 -1183
- package/components/blocks/Heading.astro +5 -1
- package/components/recipes/Contacts.astro +139 -0
- package/components/recipes/Document.astro +157 -152
- package/components/recipes/MediaText.astro +92 -0
- package/components/recipes/Table.astro +5 -2
- package/content/blocks.ts +49 -1
- package/content/schemas.ts +167 -161
- package/examples/recipes.ts +2 -0
- package/layouts/Base.astro +2 -0
- package/lib/links.ts +10 -0
- package/lib/siteSettings.ts +61 -60
- package/lib/slug.ts +32 -0
- package/lib/table.ts +10 -0
- package/package.json +1 -1
package/content/schemas.ts
CHANGED
|
@@ -1,161 +1,167 @@
|
|
|
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.
|
|
6
|
-
import { z } from 'zod';
|
|
7
|
-
|
|
8
|
-
const navLinkSchema = z.object({
|
|
9
|
-
label: z.string(),
|
|
10
|
-
href: z.string(),
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
/** One item inside a nav dropdown / mega panel (client-configured, generic). */
|
|
14
|
-
const navGroupItemSchema = z.object({
|
|
15
|
-
title: z.string(),
|
|
16
|
-
desc: z.string().optional(),
|
|
17
|
-
href: z.string(),
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
/** An optional dropdown group in the primary nav (e.g. an "About" mega-menu
|
|
21
|
-
* or a "Services" menu). Entirely client-configured — the core ships none. */
|
|
22
|
-
const navGroupSchema = z.object({
|
|
23
|
-
label: z.string(),
|
|
24
|
-
href: z.string(),
|
|
25
|
-
items: z.array(navGroupItemSchema).default([]),
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const siteIdentitySchema = z
|
|
29
|
-
.object({
|
|
30
|
-
/** Used in <title> as " — {siteTitle}" and the description fallback. */
|
|
31
|
-
siteTitle: z.string().default('Your Site'),
|
|
32
|
-
/** Short brand label — used in alt text and the footer. */
|
|
33
|
-
shortName: z.string().default('Your Site'),
|
|
34
|
-
/** Default <meta name="description"> when a page doesn't supply its own. */
|
|
35
|
-
description: z.string().default('A website built with Ferst.'),
|
|
36
|
-
/** Town / city — appended to the brand in alt text. */
|
|
37
|
-
location: z.string().default(''),
|
|
38
|
-
})
|
|
39
|
-
.default({});
|
|
40
|
-
|
|
41
|
-
const siteContactSchema = z
|
|
42
|
-
.object({
|
|
43
|
-
/** Human-readable phone number. Blank by default — client provides. */
|
|
44
|
-
phoneDisplay: z.string().default(''),
|
|
45
|
-
/** Digits-only form used in a `tel:` href. */
|
|
46
|
-
phoneTel: z.string().default(''),
|
|
47
|
-
email: z.string().default(''),
|
|
48
|
-
facebookUrl: z.string().default(''),
|
|
49
|
-
})
|
|
50
|
-
.default({});
|
|
51
|
-
|
|
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. */
|
|
54
|
-
export const siteSettingsSchema = z.object({
|
|
55
|
-
identity: siteIdentitySchema,
|
|
56
|
-
contact: siteContactSchema,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
export const logoSettingsSchema = z.object({
|
|
60
|
-
/** Primary/header logo — light theme variant. Optional (SiteHeader falls back to a wordmark). */
|
|
61
|
-
primaryLogoLight: z.string().optional(),
|
|
62
|
-
/** Primary/header logo — dark theme variant. Optional. */
|
|
63
|
-
primaryLogoDark: z.string().optional(),
|
|
64
|
-
/** Secondary/footer logo — light theme variant (optional). */
|
|
65
|
-
secondaryLogoLight: z.string().optional(),
|
|
66
|
-
/** Secondary/footer logo — dark theme variant (optional). */
|
|
67
|
-
secondaryLogoDark: z.string().optional(),
|
|
68
|
-
/** Display name + target URL for the secondary/footer logo's link (optional). */
|
|
69
|
-
secondaryLogoLink: z
|
|
70
|
-
.object({
|
|
71
|
-
name: z.string().default(''),
|
|
72
|
-
url: z.string().default(''),
|
|
73
|
-
})
|
|
74
|
-
.default({}),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
export const navbarSettingsSchema = z.object({
|
|
78
|
-
/** Simple top-level links. Empty by default — every client defines its own nav. */
|
|
79
|
-
primary: z.array(navLinkSchema).default([]),
|
|
80
|
-
/** Optional dropdown / mega-menu group. Rendered by SiteHeader only when present. */
|
|
81
|
-
dropdown: navGroupSchema.optional(),
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* the
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
export type
|
|
161
|
-
export type
|
|
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.
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
const navLinkSchema = z.object({
|
|
9
|
+
label: z.string(),
|
|
10
|
+
href: z.string(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** One item inside a nav dropdown / mega panel (client-configured, generic). */
|
|
14
|
+
const navGroupItemSchema = z.object({
|
|
15
|
+
title: z.string(),
|
|
16
|
+
desc: z.string().optional(),
|
|
17
|
+
href: z.string(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** An optional dropdown group in the primary nav (e.g. an "About" mega-menu
|
|
21
|
+
* or a "Services" menu). Entirely client-configured — the core ships none. */
|
|
22
|
+
const navGroupSchema = z.object({
|
|
23
|
+
label: z.string(),
|
|
24
|
+
href: z.string(),
|
|
25
|
+
items: z.array(navGroupItemSchema).default([]),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const siteIdentitySchema = z
|
|
29
|
+
.object({
|
|
30
|
+
/** Used in <title> as " — {siteTitle}" and the description fallback. */
|
|
31
|
+
siteTitle: z.string().default('Your Site'),
|
|
32
|
+
/** Short brand label — used in alt text and the footer. */
|
|
33
|
+
shortName: z.string().default('Your Site'),
|
|
34
|
+
/** Default <meta name="description"> when a page doesn't supply its own. */
|
|
35
|
+
description: z.string().default('A website built with Ferst.'),
|
|
36
|
+
/** Town / city — appended to the brand in alt text. */
|
|
37
|
+
location: z.string().default(''),
|
|
38
|
+
})
|
|
39
|
+
.default({});
|
|
40
|
+
|
|
41
|
+
const siteContactSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
/** Human-readable phone number. Blank by default — client provides. */
|
|
44
|
+
phoneDisplay: z.string().default(''),
|
|
45
|
+
/** Digits-only form used in a `tel:` href. */
|
|
46
|
+
phoneTel: z.string().default(''),
|
|
47
|
+
email: z.string().default(''),
|
|
48
|
+
facebookUrl: z.string().default(''),
|
|
49
|
+
})
|
|
50
|
+
.default({});
|
|
51
|
+
|
|
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. */
|
|
54
|
+
export const siteSettingsSchema = z.object({
|
|
55
|
+
identity: siteIdentitySchema,
|
|
56
|
+
contact: siteContactSchema,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const logoSettingsSchema = z.object({
|
|
60
|
+
/** Primary/header logo — light theme variant. Optional (SiteHeader falls back to a wordmark). */
|
|
61
|
+
primaryLogoLight: z.string().optional(),
|
|
62
|
+
/** Primary/header logo — dark theme variant. Optional. */
|
|
63
|
+
primaryLogoDark: z.string().optional(),
|
|
64
|
+
/** Secondary/footer logo — light theme variant (optional). */
|
|
65
|
+
secondaryLogoLight: z.string().optional(),
|
|
66
|
+
/** Secondary/footer logo — dark theme variant (optional). */
|
|
67
|
+
secondaryLogoDark: z.string().optional(),
|
|
68
|
+
/** Display name + target URL for the secondary/footer logo's link (optional). */
|
|
69
|
+
secondaryLogoLink: z
|
|
70
|
+
.object({
|
|
71
|
+
name: z.string().default(''),
|
|
72
|
+
url: z.string().default(''),
|
|
73
|
+
})
|
|
74
|
+
.default({}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const navbarSettingsSchema = z.object({
|
|
78
|
+
/** Simple top-level links. Empty by default — every client defines its own nav. */
|
|
79
|
+
primary: z.array(navLinkSchema).default([]),
|
|
80
|
+
/** Optional dropdown / mega-menu group. Rendered by SiteHeader only when present. */
|
|
81
|
+
dropdown: navGroupSchema.optional(),
|
|
82
|
+
/** The call-to-action button in the header (and footer). Contact → /contact/ unless set;
|
|
83
|
+
* `null` hides it. */
|
|
84
|
+
cta: z
|
|
85
|
+
.object({ label: z.string(), href: z.string() })
|
|
86
|
+
.nullable()
|
|
87
|
+
.default({ label: 'Contact', href: '/contact/' }),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const footerSettingsSchema = z.object({
|
|
91
|
+
/** Footer legal/info nav. Empty by default — every client defines its own. */
|
|
92
|
+
footer: z.array(navLinkSchema).default([]),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The brand — a client's design tokens, set in the CMS. The whole system is
|
|
97
|
+
* built to derive from just THREE source colours: `brand` (interactive accent),
|
|
98
|
+
* `ink` (text) and `surface` (background). `lib/themeTokens.ts` expands those
|
|
99
|
+
* into ~40 working tokens (light via `color-mix`, dark via an OKLCH translation
|
|
100
|
+
* — lightness flipped, hue kept, chroma damped so nothing turns neon), and
|
|
101
|
+
* `Base.astro` emits the result as a `<style>` that layers over the core's
|
|
102
|
+
* NEUTRAL defaults in `styles/theme.css`. Fonts and the four surface "knobs"
|
|
103
|
+
* (corners / elevation / stroke / fields) are the rest of the site-wide look —
|
|
104
|
+
* each set once, applied everywhere. `advanced.light/.dark` are a raw
|
|
105
|
+
* per-token escape hatch (power users / AI composition / back-compat) layered
|
|
106
|
+
* LAST. The core ships none of this — a brand lives in the client's repo (or
|
|
107
|
+
* the CMS), never baked into the shared core.
|
|
108
|
+
*
|
|
109
|
+
* This shape is what the Sveltia token pane edits (apps/showcase/public/admin):
|
|
110
|
+
* a client picks three colours + a font pairing + four knobs, not 40 tokens.
|
|
111
|
+
*/
|
|
112
|
+
export const themeSettingsSchema = z.object({
|
|
113
|
+
/** Interactive accent — buttons, links, icons, highlights. Blank ⇒ neutral. */
|
|
114
|
+
brand: z.string().default(''),
|
|
115
|
+
/** Text colour — headings & body. Blank ⇒ neutral. */
|
|
116
|
+
ink: z.string().default(''),
|
|
117
|
+
/** Background — page, cards, sections (tints derived from it). Blank ⇒ neutral. */
|
|
118
|
+
surface: z.string().default(''),
|
|
119
|
+
/** Site-wide font pairing (role → CSS font-family stack). Blank ⇒ core default. */
|
|
120
|
+
fonts: z
|
|
121
|
+
.object({
|
|
122
|
+
heading: z.string().default(''),
|
|
123
|
+
body: z.string().default(''),
|
|
124
|
+
display: z.string().default(''),
|
|
125
|
+
})
|
|
126
|
+
.default({}),
|
|
127
|
+
/** Corner radius applied to every card / tile / input / button. */
|
|
128
|
+
corners: z.enum(['sharp', 'rounded', 'soft']).default('rounded'),
|
|
129
|
+
/** Card/tile elevation — `flat` removes the soft shadow site-wide. */
|
|
130
|
+
elevation: z.enum(['raised', 'flat']).default('raised'),
|
|
131
|
+
/** Card/tile stroke — `off` removes the 1px border site-wide. */
|
|
132
|
+
stroke: z.enum(['on', 'off']).default('on'),
|
|
133
|
+
/** Input treatment — a single site-wide choice (a `data-field` attr on <html>). */
|
|
134
|
+
fields: z.enum(['boxed', 'filled', 'underline']).default('boxed'),
|
|
135
|
+
/** Dark-mode formula knobs (root-derivation model). `darkDepth` = how light the
|
|
136
|
+
* dark page background sits (OKLCH L); `darkChroma` = the cap on how much colour
|
|
137
|
+
* the dark ground may carry. Both feed the AUTOMATIC dark roots. */
|
|
138
|
+
darkDepth: z.number().min(0.12).max(0.4).default(0.21),
|
|
139
|
+
darkChroma: z.number().min(0).max(0.15).default(0.05),
|
|
140
|
+
/** Explicit overrides for the three dark ROOTS. Each blank ⇒ the root is derived
|
|
141
|
+
* from its light source by the formula; set ⇒ every dark token in that role
|
|
142
|
+
* re-derives from this colour. Roots only (ground / text / accent) — never a
|
|
143
|
+
* lone downstream token — so the palette can never fall out of sync. */
|
|
144
|
+
darkOverrides: z
|
|
145
|
+
.object({
|
|
146
|
+
ground: z.string().default(''),
|
|
147
|
+
text: z.string().default(''),
|
|
148
|
+
accent: z.string().default(''),
|
|
149
|
+
})
|
|
150
|
+
.default({}),
|
|
151
|
+
/** Raw per-token overrides, layered last. Rarely needed. */
|
|
152
|
+
advanced: z
|
|
153
|
+
.object({
|
|
154
|
+
light: z.record(z.string()).default({}),
|
|
155
|
+
dark: z.record(z.string()).default({}),
|
|
156
|
+
})
|
|
157
|
+
.default({}),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export type SiteSettings = z.infer<typeof siteSettingsSchema>;
|
|
161
|
+
export type LogoSettings = z.infer<typeof logoSettingsSchema>;
|
|
162
|
+
export type NavbarSettings = z.infer<typeof navbarSettingsSchema>;
|
|
163
|
+
export type FooterSettings = z.infer<typeof footerSettingsSchema>;
|
|
164
|
+
export type ThemeSettings = z.infer<typeof themeSettingsSchema>;
|
|
165
|
+
export type NavLink = z.infer<typeof navLinkSchema>;
|
|
166
|
+
export type NavGroup = z.infer<typeof navGroupSchema>;
|
|
167
|
+
export type NavGroupItem = z.infer<typeof navGroupItemSchema>;
|
package/examples/recipes.ts
CHANGED
|
@@ -39,4 +39,6 @@ export const recipes: GalleryEntry[] = [
|
|
|
39
39
|
{ type: 'document', description: 'A policy or legal text as one block: sections numbered automatically, a contents list linking to each (sticky beside the text on wide screens), a “last updated” date, prose formatting in each section, print-friendly.', block: { type: 'document', intro: 'These conditions apply to every booking.', updated: '2026-10-04', sections: [{ title: 'Use of the hall', body: 'The hall may be used only for the purpose stated on the booking form.' }, { title: 'Bookings', body: 'Bookings are confirmed once the deposit is received.', items: ['Book through the office.', 'Bookings can’t be transferred.'] }, { title: 'Cancellation', body: 'Cancellations made less than **seven days** before the event forfeit the deposit.' }] } },
|
|
40
40
|
{ type: 'timetable', description: 'Weekly times for one or more places (service times, opening hours, sessions): a card per place, days with no times left out, notes with links under each. Set `layout: "table"` to compare places side by side.', block: { type: 'timetable', title: 'Service times', places: [{ name: "St Placeholder's", address: '1 Example Street', schedule: [{ day: 'Saturday', times: ['6:00pm (vigil)'] }, { day: 'Sunday', times: ['8:30am', '10:30am'] }, { day: 'Weekdays', times: ['9:30am'] }], notes: 'Confessions on Saturdays from 5pm. See [how to find us](#find-us).' }, { name: "St Sample's", schedule: [{ day: 'Saturday', times: [] }, { day: 'Sunday', times: ['9:00am'] }], notes: 'No Saturday service.' }] } },
|
|
41
41
|
{ type: 'timetable', description: 'The `table` layout: days down the side, one column per place; a day nobody uses is dropped.', block: { type: 'timetable', title: 'Opening hours', layout: 'table', places: [{ name: 'Library', schedule: [{ day: 'Monday', times: ['9am–5pm'] }, { day: 'Saturday', times: ['10am–1pm'] }] }, { name: 'Café', schedule: [{ day: 'Monday', times: ['8am–4pm'] }, { day: 'Sunday', times: [] }] }] } },
|
|
42
|
+
{ type: 'contacts', description: 'A compact directory of people or offices: role as the card title, phone and email as tap targets (email with an optional subject), a shared note per group (e.g. office hours) instead of repeating it on every card.', block: { type: 'contacts', title: 'Who to contact', groups: [{ heading: 'Parish office', note: 'Office hours: **Mon–Fri 9am–1pm**.', contacts: [{ role: 'Parish Secretary', name: 'Anne Example', phone: '01234 567890', email: 'office@example.org', emailSubject: 'Parish enquiry' }, { role: 'Safeguarding Representative', email: 'safeguarding@example.org' }] }, { heading: 'Clergy', contacts: [{ role: 'Parish Priest', name: 'Fr John Example', phone: '01234 567891' }] }] } },
|
|
43
|
+
{ type: 'mediaText', description: 'An image beside its text (start or end), stacking on a phone, the image keeping its shape. Text takes prose formatting; an optional button.', block: { type: 'mediaText', title: 'Getting married here', text: 'We are delighted that you are thinking of marrying in our church.\n\nPlease contact the parish office **at least six months** before the date.', image: { src: '/placeholder.svg', alt: 'The church interior decorated for a wedding', caption: 'The church, ready for a wedding' }, imageSide: 'end', cta: { label: 'Contact the office', href: '#' } } },
|
|
42
44
|
];
|
package/layouts/Base.astro
CHANGED
|
@@ -201,6 +201,7 @@ const metaDescription = description ?? identity.description;
|
|
|
201
201
|
brandAlt={brandAlt}
|
|
202
202
|
primaryNav={navigation.primary}
|
|
203
203
|
dropdown={navigation.dropdown}
|
|
204
|
+
cta={navigation.cta}
|
|
204
205
|
/>
|
|
205
206
|
|
|
206
207
|
<main class:list={['site-main', mainClass]} data-content-align={align}>
|
|
@@ -214,6 +215,7 @@ const metaDescription = description ?? identity.description;
|
|
|
214
215
|
<SiteFooter
|
|
215
216
|
facebookUrl={contact.facebookUrl}
|
|
216
217
|
footerLinks={navigation.footer}
|
|
218
|
+
cta={navigation.cta}
|
|
217
219
|
footerClass={footerClass}
|
|
218
220
|
shortName={identity.shortName}
|
|
219
221
|
tagline={identity.description}
|
package/lib/links.ts
CHANGED
|
@@ -10,3 +10,13 @@ export function linkIcon(href: string): IconKey {
|
|
|
10
10
|
if (/^https?:\/\//i.test(href)) return 'globe';
|
|
11
11
|
return 'arrow';
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
/** A tel: link from a number as an editor types it (spaces, brackets, a "(0)" dropped). */
|
|
15
|
+
export function telHref(phone: string): string {
|
|
16
|
+
return 'tel:' + phone.replace(/\(0\)/g, '').replace(/[^\d+]/g, '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A mailto: link, with an optional pre-filled subject. */
|
|
20
|
+
export function mailtoHref(email: string, subject?: string): string {
|
|
21
|
+
return `mailto:${email}${subject ? `?subject=${encodeURIComponent(subject)}` : ''}`;
|
|
22
|
+
}
|
package/lib/siteSettings.ts
CHANGED
|
@@ -1,60 +1,61 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Single accessor merging the site-settings content entries into one combined
|
|
3
|
-
* object. Storage is split across small collections (identity+contact, logos,
|
|
4
|
-
* navbar, footer, theme) so each is its own focused Sveltia CMS pane — see
|
|
5
|
-
* `content/schemas.ts`. Consumers read one combined shape though, so this loader
|
|
6
|
-
* re-assembles it (including the `navigation.primary` / `navigation.footer`
|
|
7
|
-
* grouping) at read time — the only place that needs to know storage is split.
|
|
8
|
-
*
|
|
9
|
-
* `getEntry(...)` yields `data` typed per-collection when the entry exists, but
|
|
10
|
-
* the content runtime doesn't always apply nested Zod defaults the way a plain
|
|
11
|
-
* `schema.parse()` does. We re-parse each entry through its own schema so nested
|
|
12
|
-
* defaults are fully populated, including when a file is missing.
|
|
13
|
-
*/
|
|
14
|
-
import { getEntry } from 'astro:content';
|
|
15
|
-
import {
|
|
16
|
-
footerSettingsSchema,
|
|
17
|
-
logoSettingsSchema,
|
|
18
|
-
navbarSettingsSchema,
|
|
19
|
-
siteSettingsSchema,
|
|
20
|
-
themeSettingsSchema,
|
|
21
|
-
type LogoSettings,
|
|
22
|
-
type NavLink,
|
|
23
|
-
type NavGroup,
|
|
24
|
-
type SiteSettings,
|
|
25
|
-
type ThemeSettings,
|
|
26
|
-
} from '../content/schemas';
|
|
27
|
-
|
|
28
|
-
export type LoadedSiteSettings = SiteSettings &
|
|
29
|
-
LogoSettings & {
|
|
30
|
-
navigation: { primary: NavLink[]; footer: NavLink[]; dropdown?: NavGroup };
|
|
31
|
-
/** Client-provided design-token overrides (empty objects if none). */
|
|
32
|
-
theme: ThemeSettings;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
let cached: LoadedSiteSettings | null = null;
|
|
36
|
-
|
|
37
|
-
export async function loadSiteSettings(): Promise<LoadedSiteSettings> {
|
|
38
|
-
if (cached) return cached;
|
|
39
|
-
|
|
40
|
-
const [siteSettingsEntry, logoEntry, navbarEntry, footerEntry, themeEntry] = await Promise.all([
|
|
41
|
-
getEntry('siteSettings', 'index'),
|
|
42
|
-
getEntry('logoSettings', 'index'),
|
|
43
|
-
getEntry('navbarSettings', 'index'),
|
|
44
|
-
getEntry('footerSettings', 'index'),
|
|
45
|
-
getEntry('themeSettings', 'index'),
|
|
46
|
-
]);
|
|
47
|
-
|
|
48
|
-
cached = {
|
|
49
|
-
...siteSettingsSchema.parse(siteSettingsEntry?.data ?? {}),
|
|
50
|
-
...logoSettingsSchema.parse(logoEntry?.data ?? {}),
|
|
51
|
-
navigation: {
|
|
52
|
-
primary: navbarSettingsSchema.parse(navbarEntry?.data ?? {}).primary,
|
|
53
|
-
footer: footerSettingsSchema.parse(footerEntry?.data ?? {}).footer,
|
|
54
|
-
dropdown: navbarSettingsSchema.parse(navbarEntry?.data ?? {}).dropdown,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Single accessor merging the site-settings content entries into one combined
|
|
3
|
+
* object. Storage is split across small collections (identity+contact, logos,
|
|
4
|
+
* navbar, footer, theme) so each is its own focused Sveltia CMS pane — see
|
|
5
|
+
* `content/schemas.ts`. Consumers read one combined shape though, so this loader
|
|
6
|
+
* re-assembles it (including the `navigation.primary` / `navigation.footer`
|
|
7
|
+
* grouping) at read time — the only place that needs to know storage is split.
|
|
8
|
+
*
|
|
9
|
+
* `getEntry(...)` yields `data` typed per-collection when the entry exists, but
|
|
10
|
+
* the content runtime doesn't always apply nested Zod defaults the way a plain
|
|
11
|
+
* `schema.parse()` does. We re-parse each entry through its own schema so nested
|
|
12
|
+
* defaults are fully populated, including when a file is missing.
|
|
13
|
+
*/
|
|
14
|
+
import { getEntry } from 'astro:content';
|
|
15
|
+
import {
|
|
16
|
+
footerSettingsSchema,
|
|
17
|
+
logoSettingsSchema,
|
|
18
|
+
navbarSettingsSchema,
|
|
19
|
+
siteSettingsSchema,
|
|
20
|
+
themeSettingsSchema,
|
|
21
|
+
type LogoSettings,
|
|
22
|
+
type NavLink,
|
|
23
|
+
type NavGroup,
|
|
24
|
+
type SiteSettings,
|
|
25
|
+
type ThemeSettings,
|
|
26
|
+
} from '../content/schemas';
|
|
27
|
+
|
|
28
|
+
export type LoadedSiteSettings = SiteSettings &
|
|
29
|
+
LogoSettings & {
|
|
30
|
+
navigation: { primary: NavLink[]; footer: NavLink[]; dropdown?: NavGroup; cta: { label: string; href: string } | null };
|
|
31
|
+
/** Client-provided design-token overrides (empty objects if none). */
|
|
32
|
+
theme: ThemeSettings;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let cached: LoadedSiteSettings | null = null;
|
|
36
|
+
|
|
37
|
+
export async function loadSiteSettings(): Promise<LoadedSiteSettings> {
|
|
38
|
+
if (cached) return cached;
|
|
39
|
+
|
|
40
|
+
const [siteSettingsEntry, logoEntry, navbarEntry, footerEntry, themeEntry] = await Promise.all([
|
|
41
|
+
getEntry('siteSettings', 'index'),
|
|
42
|
+
getEntry('logoSettings', 'index'),
|
|
43
|
+
getEntry('navbarSettings', 'index'),
|
|
44
|
+
getEntry('footerSettings', 'index'),
|
|
45
|
+
getEntry('themeSettings', 'index'),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
cached = {
|
|
49
|
+
...siteSettingsSchema.parse(siteSettingsEntry?.data ?? {}),
|
|
50
|
+
...logoSettingsSchema.parse(logoEntry?.data ?? {}),
|
|
51
|
+
navigation: {
|
|
52
|
+
primary: navbarSettingsSchema.parse(navbarEntry?.data ?? {}).primary,
|
|
53
|
+
footer: footerSettingsSchema.parse(footerEntry?.data ?? {}).footer,
|
|
54
|
+
dropdown: navbarSettingsSchema.parse(navbarEntry?.data ?? {}).dropdown,
|
|
55
|
+
cta: navbarSettingsSchema.parse(navbarEntry?.data ?? {}).cta,
|
|
56
|
+
},
|
|
57
|
+
theme: themeSettingsSchema.parse(themeEntry?.data ?? {}),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return cached;
|
|
61
|
+
}
|
package/lib/slug.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readable anchor ids for headings and document sections (`/sacraments#confirmation`).
|
|
3
|
+
* `slugify` is pure; `uniqueSlug` de-duplicates within one page using a Set the caller
|
|
4
|
+
* keeps per request (Astro.locals), so two "Mass times" headings become `mass-times` and
|
|
5
|
+
* `mass-times-2`.
|
|
6
|
+
*/
|
|
7
|
+
export function slugify(text: string): string {
|
|
8
|
+
const s = text
|
|
9
|
+
.normalize('NFKD')
|
|
10
|
+
.replace(/[̀-ͯ]/g, '') // accents
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/['’]/g, '') // apostrophes join the word ("joachim's" → "joachims")
|
|
13
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
14
|
+
.replace(/^-+|-+$/g, '')
|
|
15
|
+
.slice(0, 60)
|
|
16
|
+
.replace(/-+$/, '');
|
|
17
|
+
return s || 'section';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function uniqueSlug(text: string, used: Set<string>): string {
|
|
21
|
+
const base = slugify(text);
|
|
22
|
+
let slug = base;
|
|
23
|
+
for (let n = 2; used.has(slug); n++) slug = `${base}-${n}`;
|
|
24
|
+
used.add(slug);
|
|
25
|
+
return slug;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The per-page Set of anchor ids already used, kept on Astro.locals. */
|
|
29
|
+
export function pageSlugs(locals: unknown): Set<string> {
|
|
30
|
+
const bag = (locals ?? {}) as { __ferstSlugs?: Set<string> };
|
|
31
|
+
return (bag.__ferstSlugs ??= new Set<string>());
|
|
32
|
+
}
|
package/lib/table.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a table's columns to show: those with content in at least one body row. A column
|
|
3
|
+
* that's empty everywhere (e.g. an unused "Evening") is dropped rather than shown blank.
|
|
4
|
+
* A header-only table keeps all its columns. Pure.
|
|
5
|
+
*/
|
|
6
|
+
export function filledColumns(columns: string[], rows: string[][]): number[] {
|
|
7
|
+
const all = columns.map((_, i) => i);
|
|
8
|
+
if (!rows.length) return all;
|
|
9
|
+
return all.filter((i) => rows.some((r) => (r[i] ?? '').trim() !== ''));
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
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",
|