ferst-core 0.7.4 → 0.7.6

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/integration.mjs CHANGED
@@ -27,7 +27,45 @@
27
27
  * the site has its own `src/pages/cookie-policy.astro`.
28
28
  * @returns {import('astro').AstroIntegration}
29
29
  */
30
- import { existsSync } from 'node:fs';
30
+ import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync } from 'node:fs';
31
+
32
+ /** The self-hosted fonts the engine's CSS requests (/fonts/*.woff2), shipped in the package. */
33
+ const FONTS_DIR = new URL('./fonts/', import.meta.url);
34
+ const FONT_URL = /^\/fonts\/([a-z0-9-]+\.woff2)(?:\?.*)?$/;
35
+
36
+ /**
37
+ * ferstFonts() — serves the engine's self-hosted fonts in `astro dev` and copies them into
38
+ * every build's `/fonts/`. Included in ferst(); usable on its own (the showcase). A file
39
+ * the site already has in its own `public/fonts/` is never overwritten.
40
+ * @returns {import('astro').AstroIntegration}
41
+ */
42
+ export function ferstFonts() {
43
+ return {
44
+ name: 'ferst-fonts',
45
+ hooks: {
46
+ 'astro:server:setup': ({ server }) => {
47
+ server.middlewares.use((req, res, next) => {
48
+ const m = req.url && req.url.match(FONT_URL);
49
+ if (!m) return next();
50
+ const file = new URL(m[1], FONTS_DIR);
51
+ if (!existsSync(file)) return next();
52
+ res.setHeader('Content-Type', 'font/woff2');
53
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
54
+ createReadStream(file).pipe(res);
55
+ });
56
+ },
57
+ 'astro:build:done': async ({ dir }) => {
58
+ const out = new URL('fonts/', dir);
59
+ mkdirSync(out, { recursive: true });
60
+ for (const name of readdirSync(FONTS_DIR)) {
61
+ if (!name.endsWith('.woff2')) continue;
62
+ const dest = new URL(name, out);
63
+ if (!existsSync(dest)) copyFileSync(new URL(name, FONTS_DIR), dest);
64
+ }
65
+ },
66
+ },
67
+ };
68
+ }
31
69
 
32
70
  export default function ferst(options = {}) {
33
71
  const { calendar = true, blog = true, cookiePolicy = true } = options;
@@ -78,6 +116,8 @@ export default function ferst(options = {}) {
78
116
  });
79
117
  }
80
118
  },
119
+ // The engine's self-hosted fonts: served in dev, copied into every build.
120
+ ...ferstFonts().hooks,
81
121
  },
82
122
  };
83
123
  }
@@ -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,8 @@ const metaDescription = description ?? identity.description;
214
215
  <SiteFooter
215
216
  facebookUrl={contact.facebookUrl}
216
217
  footerLinks={navigation.footer}
218
+ groups={navigation.footerGroups}
219
+ cta={navigation.cta}
217
220
  footerClass={footerClass}
218
221
  shortName={identity.shortName}
219
222
  tagline={identity.description}
package/lib/icons.ts CHANGED
@@ -16,6 +16,16 @@
16
16
  * dropped in favour of the entries already here.
17
17
  */
18
18
  export const ICONS: Record<string, string> = {
19
+ // ── Shapes added in 0.7.6 (named for what they depict, not for a sector) ──
20
+ steeple: '<path d="M12 2v3M10.5 3.5h3"></path><path d="M12 5 8 10v11h8V10z"></path><path d="M8 13l-4 2.5V21M16 13l4 2.5V21M3 21h18"></path><path d="M11 21v-3.5a1 1 0 0 1 2 0V21"></path>',
21
+ cross: '<path d="M12 3v18M7 8h10"></path>',
22
+ chalice: '<path d="M7 3h10c0 4.2-2.2 7-5 7S7 7.2 7 3z"></path><path d="M12 10v7M8.5 21h7M9.5 17h5"></path>',
23
+ shell: '<path d="M12 20 4.2 9.5a8.5 8.5 0 0 1 15.6 0z"></path><path d="M12 20 7.4 6.6M12 20V4.6M12 20l4.6-13.4"></path>',
24
+ jar: '<path d="M9.5 3h5M10.5 3v2.5L8 8.5v10a2.5 2.5 0 0 0 2.5 2.5h3a2.5 2.5 0 0 0 2.5-2.5v-10l-2.5-3V3"></path><path d="M8 13h8"></path>',
25
+ rings: '<circle cx="9" cy="15" r="5"></circle><circle cx="15" cy="15" r="5"></circle><path d="m15 4.5 1.6 1.6L15 7.7l-1.6-1.6z"></path><path d="M15 7.7V10"></path>',
26
+ stole: '<path d="M8.5 3c.4 6 .2 12-1.5 18M15.5 3c-.4 6-.2 12 1.5 18"></path><path d="M8.5 3c1.8 1.6 5.2 1.6 7 0"></path><path d="M7.4 17.5h2.4M14.2 17.5h2.4"></path>',
27
+ candle: '<path d="M12 3c1.1 1.4 1.6 2.4 1.6 3.2a1.6 1.6 0 0 1-3.2 0c0-.8.5-1.8 1.6-3.2z"></path><rect x="9" y="10" width="6" height="11" rx="1"></rect>',
28
+
19
29
  landmark: '<path d="M12 3v4M9 7h6M6 21V10l6-4 6 4v11M6 21h12M10 21v-5h4v5"></path>',
20
30
  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
31
  drop: '<path d="M12 3c3 4 6 8.5 6 12a6 6 0 1 1-12 0c0-3.5 3-8 6-12z"></path>',
package/lib/links.ts CHANGED
@@ -1,12 +1,24 @@
1
- /**
2
- * The icon a link deserves, read from the link itself — so an editor writes a phone
3
- * number, an email or a web address and the row shows the right symbol without choosing.
4
- */
5
- import type { IconKey } from './icons';
6
-
7
- export function linkIcon(href: string): IconKey {
8
- if (/^tel:/i.test(href)) return 'phone';
9
- if (/^mailto:/i.test(href)) return 'email';
10
- if (/^https?:\/\//i.test(href)) return 'globe';
11
- return 'arrow';
12
- }
1
+ /**
2
+ * The icon a link deserves, read from the link itself — so an editor writes a phone
3
+ * number, an email or a web address and the row shows the right symbol without choosing.
4
+ */
5
+ import type { IconKey } from './icons';
6
+
7
+ export function linkIcon(href: string): IconKey {
8
+ if (/^tel:/i.test(href)) return 'phone';
9
+ if (/^mailto:/i.test(href)) return 'email';
10
+ if (/^https?:\/\//i.test(href)) return 'globe';
11
+ return 'arrow';
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
+ // An extension ("ext. 204", "x204", "(extension 204)") is shown but never dialled.
17
+ const number = phone.replace(/\(?\s*(?:ext\.?|extension|x)\s*\d+\s*\)?\s*$/i, '');
18
+ return 'tel:' + number.replace(/\(0\)/g, '').replace(/[^\d+]/g, '');
19
+ }
20
+
21
+ /** A mailto: link, with an optional pre-filled subject. */
22
+ export function mailtoHref(email: string, subject?: string): string {
23
+ return `mailto:${email}${subject ? `?subject=${encodeURIComponent(subject)}` : ''}`;
24
+ }
package/lib/nav.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The header's call-to-action button (navbarSettings.cta) wins over a plain menu link to
3
+ * the same place, so a site never shows Contact twice (once as a link, once as the button).
4
+ * Migrated sites' old menus almost always carry a Contact link. Pure.
5
+ */
6
+ const norm = (href: string) => href.replace(/\/+$/, '') || '/';
7
+
8
+ export function navWithoutCta<T extends { href: string }>(links: T[], cta: { href: string } | null | undefined): T[] {
9
+ if (!cta) return links;
10
+ const target = norm(cta.href);
11
+ return links.filter((l) => norm(l.href) !== target);
12
+ }
@@ -1,60 +1,62 @@
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
- theme: themeSettingsSchema.parse(themeEntry?.data ?? {}),
57
- };
58
-
59
- return cached;
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[]; footerGroups: Array<{ heading: string; links: 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
+ footerGroups: footerSettingsSchema.parse(footerEntry?.data ?? {}).groups,
55
+ dropdown: navbarSettingsSchema.parse(navbarEntry?.data ?? {}).dropdown,
56
+ cta: navbarSettingsSchema.parse(navbarEntry?.data ?? {}).cta,
57
+ },
58
+ theme: themeSettingsSchema.parse(themeEntry?.data ?? {}),
59
+ };
60
+
61
+ return cached;
62
+ }
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,68 +1,69 @@
1
- {
2
- "name": "ferst-core",
3
- "version": "0.7.4",
4
- "type": "module",
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
- "license": "BUSL-1.1",
7
- "author": "MyAI4 Ltd (https://ferst.co.uk)",
8
- "homepage": "https://ferst.co.uk",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/MyAI4WebDev/myai4-ferst-core.git",
12
- "directory": "packages/core"
13
- },
14
- "bugs": {
15
- "url": "https://github.com/MyAI4WebDev/myai4-ferst-core/issues"
16
- },
17
- "keywords": [
18
- "astro",
19
- "astro-component",
20
- "cms",
21
- "sveltia",
22
- "website",
23
- "design-tokens",
24
- "ferst"
25
- ],
26
- "scripts": {
27
- "test": "vitest run",
28
- "test:watch": "vitest"
29
- },
30
- "sideEffects": [
31
- "**/*.css"
32
- ],
33
- "files": [
34
- "bin",
35
- "cms",
36
- "components",
37
- "integration.mjs",
38
- "layouts",
39
- "content",
40
- "examples",
41
- "lib",
42
- "routes",
43
- "utils",
44
- "styles",
45
- "README.md",
46
- "NOTICE",
47
- "LICENSE",
48
- "LICENSE-Apache-2.0"
49
- ],
50
- "bin": {
51
- "ferst-check-thin": "bin/check-thin.mjs",
52
- "ferst-set-cms-branch": "bin/set-cms-branch.mjs",
53
- "ferst-cms-config": "bin/cms-config.mjs",
54
- "ferst-update": "bin/ferst-update.mjs"
55
- },
56
- "dependencies": {
57
- "zod": "^3.25.0"
58
- },
59
- "devDependencies": {
60
- "vitest": "^3.0.0"
61
- },
62
- "peerDependencies": {
63
- "astro": "^5.0.0"
64
- },
65
- "publishConfig": {
66
- "access": "public"
67
- }
68
- }
1
+ {
2
+ "name": "ferst-core",
3
+ "version": "0.7.6",
4
+ "type": "module",
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
+ "license": "BUSL-1.1",
7
+ "author": "MyAI4 Ltd (https://ferst.co.uk)",
8
+ "homepage": "https://ferst.co.uk",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MyAI4WebDev/myai4-ferst-core.git",
12
+ "directory": "packages/core"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/MyAI4WebDev/myai4-ferst-core/issues"
16
+ },
17
+ "keywords": [
18
+ "astro",
19
+ "astro-component",
20
+ "cms",
21
+ "sveltia",
22
+ "website",
23
+ "design-tokens",
24
+ "ferst"
25
+ ],
26
+ "scripts": {
27
+ "test": "vitest run",
28
+ "test:watch": "vitest"
29
+ },
30
+ "sideEffects": [
31
+ "**/*.css"
32
+ ],
33
+ "files": [
34
+ "bin",
35
+ "cms",
36
+ "components",
37
+ "integration.mjs",
38
+ "layouts",
39
+ "content",
40
+ "examples",
41
+ "lib",
42
+ "routes",
43
+ "utils",
44
+ "styles",
45
+ "fonts",
46
+ "README.md",
47
+ "NOTICE",
48
+ "LICENSE",
49
+ "LICENSE-Apache-2.0"
50
+ ],
51
+ "bin": {
52
+ "ferst-check-thin": "bin/check-thin.mjs",
53
+ "ferst-set-cms-branch": "bin/set-cms-branch.mjs",
54
+ "ferst-cms-config": "bin/cms-config.mjs",
55
+ "ferst-update": "bin/ferst-update.mjs"
56
+ },
57
+ "dependencies": {
58
+ "zod": "^3.25.0"
59
+ },
60
+ "devDependencies": {
61
+ "vitest": "^3.0.0"
62
+ },
63
+ "peerDependencies": {
64
+ "astro": "^5.0.0"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ }
69
+ }