ferst-core 0.7.2 → 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 +10 -0
- package/components/Pagination.astro +51 -0
- package/components/SiteFooter.astro +274 -270
- package/components/SiteHeader.astro +1243 -1183
- package/components/blocks/Heading.astro +51 -46
- package/components/blocks/Prose.astro +10 -2
- package/components/blocks/Quote.astro +40 -38
- package/components/recipes/Accordion.astro +91 -84
- package/components/recipes/Contacts.astro +139 -0
- package/components/recipes/Document.astro +157 -0
- package/components/recipes/FeatureCards.astro +112 -111
- package/components/recipes/Hero.astro +52 -4
- package/components/recipes/LinkRow.astro +91 -0
- package/components/recipes/MediaCards.astro +213 -212
- package/components/recipes/MediaText.astro +92 -0
- package/components/recipes/Notice.astro +90 -89
- package/components/recipes/Stats.astro +152 -145
- package/components/recipes/Table.astro +101 -97
- package/components/recipes/Tabs.astro +134 -133
- package/components/recipes/Tiles.astro +4 -2
- package/components/recipes/Timetable.astro +189 -0
- package/components/recipes/Video.astro +177 -157
- package/content/blocks.ts +113 -1
- package/content/cookies.ts +10 -0
- package/content/schemas.ts +167 -161
- package/examples/primitives.ts +2 -0
- package/examples/recipes.ts +7 -0
- package/integration.mjs +83 -64
- package/layouts/Base.astro +483 -475
- package/lib/documents.ts +68 -62
- package/lib/embed-consent.ts +78 -73
- package/lib/inline-markdown.ts +6 -0
- package/lib/links.ts +22 -0
- package/lib/paginate.ts +23 -0
- package/lib/posts.ts +20 -2
- package/lib/siteSettings.ts +61 -60
- package/lib/slug.ts +32 -0
- package/lib/table.ts +10 -0
- package/lib/timetable.ts +27 -0
- package/lib/video.ts +101 -75
- package/package.json +1 -1
- package/routes/cookie-policy.astro +27 -0
- package/routes/posts/index.astro +36 -31
- package/routes/posts/page/[page].astro +36 -0
- package/routes/posts/tag/[...tag].astro +41 -37
package/lib/documents.ts
CHANGED
|
@@ -1,62 +1,68 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `documents` block's list logic: newest first (dated items by date, undated ones
|
|
3
|
-
* after, in the order given), an optional grouping by year, a file-type label from the
|
|
4
|
-
* extension, and a readable date. Pure — the component only renders the result.
|
|
5
|
-
*/
|
|
6
|
-
export interface DocumentItem {
|
|
7
|
-
title: string;
|
|
8
|
-
file: string;
|
|
9
|
-
date?: string;
|
|
10
|
-
note?: string;
|
|
11
|
-
}
|
|
12
|
-
export interface DocumentRow extends DocumentItem {
|
|
13
|
-
kind: string;
|
|
14
|
-
dateLabel?: string;
|
|
15
|
-
}
|
|
16
|
-
export interface DocumentGroup {
|
|
17
|
-
year: string | null;
|
|
18
|
-
items: DocumentRow[];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const KINDS: Record<string, string> = {
|
|
22
|
-
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel', csv: 'CSV',
|
|
23
|
-
ppt: 'PowerPoint', pptx: 'PowerPoint', odt: 'Document', rtf: 'Document', txt: 'Text',
|
|
24
|
-
jpg: 'Image', jpeg: 'Image', png: 'Image', zip: 'ZIP',
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export function fileKind(file: string): string {
|
|
28
|
-
const ext = file.split(/[?#]/)[0].split('.').pop()?.toLowerCase() ?? '';
|
|
29
|
-
return KINDS[ext] ?? 'File';
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A
|
|
33
|
-
function
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The `documents` block's list logic: newest first (dated items by date, undated ones
|
|
3
|
+
* after, in the order given), an optional grouping by year, a file-type label from the
|
|
4
|
+
* extension, and a readable date. Pure — the component only renders the result.
|
|
5
|
+
*/
|
|
6
|
+
export interface DocumentItem {
|
|
7
|
+
title: string;
|
|
8
|
+
file: string;
|
|
9
|
+
date?: string;
|
|
10
|
+
note?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface DocumentRow extends DocumentItem {
|
|
13
|
+
kind: string;
|
|
14
|
+
dateLabel?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface DocumentGroup {
|
|
17
|
+
year: string | null;
|
|
18
|
+
items: DocumentRow[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KINDS: Record<string, string> = {
|
|
22
|
+
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel', csv: 'CSV',
|
|
23
|
+
ppt: 'PowerPoint', pptx: 'PowerPoint', odt: 'Document', rtf: 'Document', txt: 'Text',
|
|
24
|
+
jpg: 'Image', jpeg: 'Image', png: 'Image', zip: 'ZIP',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function fileKind(file: string): string {
|
|
28
|
+
const ext = file.split(/[?#]/)[0].split('.').pop()?.toLowerCase() ?? '';
|
|
29
|
+
return KINDS[ext] ?? 'File';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A YYYY-MM-DD date as "4 October 2026" (UTC, so it never shifts a day); else undefined. */
|
|
33
|
+
export function longDate(d: string | undefined): string | undefined {
|
|
34
|
+
const t = parseDate(d);
|
|
35
|
+
return t ? t.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }) : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A valid ISO-ish date (YYYY-MM-DD…) → a Date, else null. */
|
|
39
|
+
function parseDate(d: string | undefined): Date | null {
|
|
40
|
+
if (!d || !/^\d{4}-\d{2}-\d{2}/.test(d)) return null;
|
|
41
|
+
const t = new Date(d.slice(0, 10) + 'T00:00:00Z');
|
|
42
|
+
return Number.isNaN(t.getTime()) ? null : t;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function documentGroups(items: DocumentItem[], groupByYear = false): DocumentGroup[] {
|
|
46
|
+
const rows = items.map((item, i) => {
|
|
47
|
+
const d = parseDate(item.date);
|
|
48
|
+
return {
|
|
49
|
+
row: {
|
|
50
|
+
...item,
|
|
51
|
+
kind: fileKind(item.file),
|
|
52
|
+
dateLabel: longDate(item.date),
|
|
53
|
+
} as DocumentRow,
|
|
54
|
+
time: d?.getTime() ?? null,
|
|
55
|
+
year: d ? String(d.getUTCFullYear()) : null,
|
|
56
|
+
i,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
rows.sort((a, b) => (a.time !== null && b.time !== null ? b.time - a.time : a.time !== null ? -1 : b.time !== null ? 1 : a.i - b.i));
|
|
60
|
+
if (!groupByYear) return [{ year: null, items: rows.map((r) => r.row) }];
|
|
61
|
+
const groups: DocumentGroup[] = [];
|
|
62
|
+
for (const r of rows) {
|
|
63
|
+
const last = groups[groups.length - 1];
|
|
64
|
+
if (last && last.year === r.year) last.items.push(r.row);
|
|
65
|
+
else groups.push({ year: r.year, items: [r.row] });
|
|
66
|
+
}
|
|
67
|
+
return groups;
|
|
68
|
+
}
|
package/lib/embed-consent.ts
CHANGED
|
@@ -1,73 +1,78 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared client-side loader for consent-gated third-party embeds (Google Calendar,
|
|
3
|
-
* Google Maps, the Locations tiles). One implementation, so every embed gates,
|
|
4
|
-
* loads and reacts to consent identically — instead of the same ~25-line script
|
|
5
|
-
* copied into each component (where a fix, or a bug, lands in only one copy).
|
|
6
|
-
*
|
|
7
|
-
* Behaviour: nothing loads until the visitor consents. Accepting the site cookie
|
|
8
|
-
* banner (or the footer preferences panel) grants the shared consent and
|
|
9
|
-
* auto-loads EVERY registered embed on the page; the per-embed button is a
|
|
10
|
-
* fallback that grants that same shared consent. Consent is remembered per
|
|
11
|
-
* visitor. Import from a component `<script>` (it touches the DOM).
|
|
12
|
-
*
|
|
13
|
-
* Each embed root element carries the data the loader needs:
|
|
14
|
-
* data-embed-url the https URL to load (required)
|
|
15
|
-
* data-title the iframe title (optional)
|
|
16
|
-
* data-height pixel height, for fixed-height embeds (optional)
|
|
17
|
-
* and contains a `frame` slot (filled with the iframe), a `consent` card (hidden
|
|
18
|
-
* once loaded) and a `button` that grants consent.
|
|
19
|
-
*/
|
|
20
|
-
import { hasConsent, setConsent, onConsentChange } from './consent-client';
|
|
21
|
-
|
|
22
|
-
export interface EmbedConsentConfig {
|
|
23
|
-
/** Selector for each embed root (one per embed on the page). */
|
|
24
|
-
root: string;
|
|
25
|
-
/** Selector, within a root, of the empty slot the iframe is injected into. */
|
|
26
|
-
frame: string;
|
|
27
|
-
/** Selector, within a root, of the consent card to hide once loaded. */
|
|
28
|
-
consent: string;
|
|
29
|
-
/** Selector, within a root, of the button that grants consent. */
|
|
30
|
-
button: string;
|
|
31
|
-
/** Build the iframe for one root — lets each embed set its own src / size / attrs. */
|
|
32
|
-
buildFrame: (el: HTMLElement, url: string) => HTMLIFrameElement;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Wire up every consent-gated embed matching `cfg.root`. Call once per component. */
|
|
36
|
-
export function initEmbedConsent(cfg: EmbedConsentConfig): void {
|
|
37
|
-
const roots = document.querySelectorAll<HTMLElement>(cfg.root);
|
|
38
|
-
if (!roots.length) return;
|
|
39
|
-
|
|
40
|
-
function load(el: HTMLElement): void {
|
|
41
|
-
if (el.dataset.loaded === 'true') return;
|
|
42
|
-
const url = el.dataset.embedUrl;
|
|
43
|
-
const slot = el.querySelector<HTMLElement>(cfg.frame);
|
|
44
|
-
if (!url || !slot) return;
|
|
45
|
-
slot.replaceChildren(cfg.buildFrame(el, url));
|
|
46
|
-
el.dataset.loaded = 'true';
|
|
47
|
-
const card = el.querySelector<HTMLElement>(cfg.consent);
|
|
48
|
-
if (card) card.hidden = true;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
f
|
|
70
|
-
f.
|
|
71
|
-
f.
|
|
72
|
-
|
|
73
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shared client-side loader for consent-gated third-party embeds (Google Calendar,
|
|
3
|
+
* Google Maps, the Locations tiles). One implementation, so every embed gates,
|
|
4
|
+
* loads and reacts to consent identically — instead of the same ~25-line script
|
|
5
|
+
* copied into each component (where a fix, or a bug, lands in only one copy).
|
|
6
|
+
*
|
|
7
|
+
* Behaviour: nothing loads until the visitor consents. Accepting the site cookie
|
|
8
|
+
* banner (or the footer preferences panel) grants the shared consent and
|
|
9
|
+
* auto-loads EVERY registered embed on the page; the per-embed button is a
|
|
10
|
+
* fallback that grants that same shared consent. Consent is remembered per
|
|
11
|
+
* visitor. Import from a component `<script>` (it touches the DOM).
|
|
12
|
+
*
|
|
13
|
+
* Each embed root element carries the data the loader needs:
|
|
14
|
+
* data-embed-url the https URL to load (required)
|
|
15
|
+
* data-title the iframe title (optional)
|
|
16
|
+
* data-height pixel height, for fixed-height embeds (optional)
|
|
17
|
+
* and contains a `frame` slot (filled with the iframe), a `consent` card (hidden
|
|
18
|
+
* once loaded) and a `button` that grants consent.
|
|
19
|
+
*/
|
|
20
|
+
import { hasConsent, setConsent, onConsentChange } from './consent-client';
|
|
21
|
+
|
|
22
|
+
export interface EmbedConsentConfig {
|
|
23
|
+
/** Selector for each embed root (one per embed on the page). */
|
|
24
|
+
root: string;
|
|
25
|
+
/** Selector, within a root, of the empty slot the iframe is injected into. */
|
|
26
|
+
frame: string;
|
|
27
|
+
/** Selector, within a root, of the consent card to hide once loaded. */
|
|
28
|
+
consent: string;
|
|
29
|
+
/** Selector, within a root, of the button that grants consent. */
|
|
30
|
+
button: string;
|
|
31
|
+
/** Build the iframe for one root — lets each embed set its own src / size / attrs. */
|
|
32
|
+
buildFrame: (el: HTMLElement, url: string) => HTMLIFrameElement;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Wire up every consent-gated embed matching `cfg.root`. Call once per component. */
|
|
36
|
+
export function initEmbedConsent(cfg: EmbedConsentConfig): void {
|
|
37
|
+
const roots = document.querySelectorAll<HTMLElement>(cfg.root);
|
|
38
|
+
if (!roots.length) return;
|
|
39
|
+
|
|
40
|
+
function load(el: HTMLElement): void {
|
|
41
|
+
if (el.dataset.loaded === 'true') return;
|
|
42
|
+
const url = el.dataset.embedUrl;
|
|
43
|
+
const slot = el.querySelector<HTMLElement>(cfg.frame);
|
|
44
|
+
if (!url || !slot) return;
|
|
45
|
+
slot.replaceChildren(cfg.buildFrame(el, url));
|
|
46
|
+
el.dataset.loaded = 'true';
|
|
47
|
+
const card = el.querySelector<HTMLElement>(cfg.consent);
|
|
48
|
+
if (card) card.hidden = true;
|
|
49
|
+
}
|
|
50
|
+
// An embed marked `data-on-demand="true"` (e.g. a live stream that's often off air)
|
|
51
|
+
// never loads just because consent exists: only its own button loads it.
|
|
52
|
+
const loadAll = (): void => roots.forEach((el) => { if (el.dataset.onDemand !== 'true') load(el); });
|
|
53
|
+
|
|
54
|
+
if (hasConsent()) loadAll();
|
|
55
|
+
onConsentChange((allowed) => { if (allowed) loadAll(); });
|
|
56
|
+
roots.forEach((el) => {
|
|
57
|
+
el.querySelector<HTMLButtonElement>(cfg.button)?.addEventListener('click', () => {
|
|
58
|
+
setConsent(true);
|
|
59
|
+
if (el.dataset.onDemand === 'true') load(el);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Build a standard lazy embed iframe (100% width, 0 border). */
|
|
65
|
+
export function makeEmbedFrame(
|
|
66
|
+
src: string,
|
|
67
|
+
opts: { title?: string; height?: string; referrer?: boolean } = {},
|
|
68
|
+
): HTMLIFrameElement {
|
|
69
|
+
const f = document.createElement('iframe');
|
|
70
|
+
f.src = src;
|
|
71
|
+
if (opts.title) f.title = opts.title;
|
|
72
|
+
f.loading = 'lazy';
|
|
73
|
+
if (opts.referrer) f.referrerPolicy = 'no-referrer-when-downgrade';
|
|
74
|
+
f.style.width = '100%';
|
|
75
|
+
f.style.height = opts.height ?? '100%';
|
|
76
|
+
f.style.border = '0';
|
|
77
|
+
return f;
|
|
78
|
+
}
|
package/lib/inline-markdown.ts
CHANGED
|
@@ -43,6 +43,12 @@ export function textWithBreaks(text: string | undefined | null): string {
|
|
|
43
43
|
return text ? escapeHtml(text).replace(/\n/g, '<br>') : '';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Inline formatting (bold, italic, line breaks) WITHOUT links: `[label](href)` becomes
|
|
47
|
+
* its label. For text inside something that is already a link (a linked tile or card). */
|
|
48
|
+
export function inlineMarkdownNoLinks(text: string | undefined | null): string {
|
|
49
|
+
return text ? inlineFormat(escapeHtml(text.replace(LINK_RE, '$1'))) : '';
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
export function inlineMarkdown(text: string | undefined | null): string {
|
|
47
53
|
if (!text) return '';
|
|
48
54
|
let out = '';
|
package/lib/links.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
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/paginate.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagination for post listings (the index and tag pages). Page 1 lives at the listing's
|
|
3
|
+
* own URL; later pages at `<base>/page/<n>`. Pure, unit-tested.
|
|
4
|
+
*/
|
|
5
|
+
export function pageCount(total: number, perPage: number): number {
|
|
6
|
+
return Math.max(1, Math.ceil(total / Math.max(1, perPage)));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function pageSlice<T>(items: T[], page: number, perPage: number): T[] {
|
|
10
|
+
return items.slice((page - 1) * perPage, page * perPage);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function pageHref(base: string, page: number): string {
|
|
14
|
+
return page <= 1 ? base : `${base}/page/${page}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Newer (prev) and older (next) page links, where they exist. */
|
|
18
|
+
export function pagerLinks(base: string, page: number, pages: number): { prev?: string; next?: string } {
|
|
19
|
+
return {
|
|
20
|
+
prev: page > 1 ? pageHref(base, page - 1) : undefined,
|
|
21
|
+
next: page < pages ? pageHref(base, page + 1) : undefined,
|
|
22
|
+
};
|
|
23
|
+
}
|
package/lib/posts.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* // <PostArticle ...><Content /></PostArticle>
|
|
14
14
|
*/
|
|
15
15
|
import { getCollection, getEntry, type CollectionEntry } from 'astro:content';
|
|
16
|
+
import { pageCount } from './paginate';
|
|
16
17
|
import {
|
|
17
18
|
postsSettingsSchema,
|
|
18
19
|
tagSchema,
|
|
@@ -92,6 +93,23 @@ export async function getPostRoutes() {
|
|
|
92
93
|
|
|
93
94
|
/** `getStaticPaths` for a tag route (`tags/[...tag].astro`). */
|
|
94
95
|
export async function getTagRoutes() {
|
|
95
|
-
const tags = await getUsedTags();
|
|
96
|
-
|
|
96
|
+
const [tags, settings] = await Promise.all([getUsedTags(), getPostsSettings()]);
|
|
97
|
+
// Page 1 at /posts/tag/<slug>; later pages at /posts/tag/<slug>/page/<n>.
|
|
98
|
+
return tags.flatMap((tag) => {
|
|
99
|
+
const pages = pageCount(tag.count, settings.perPage);
|
|
100
|
+
return Array.from({ length: pages }, (_, i) => ({
|
|
101
|
+
params: { tag: i === 0 ? tag.slug : `${tag.slug}/page/${i + 1}` },
|
|
102
|
+
props: { tag, page: i + 1, pages },
|
|
103
|
+
}));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `/posts/page/<n>` for n ≥ 2 (page 1 is `/posts` itself). */
|
|
108
|
+
export async function getIndexPageRoutes() {
|
|
109
|
+
const [posts, settings] = await Promise.all([getIndexPosts(), getPostsSettings()]);
|
|
110
|
+
const pages = pageCount(posts.length, settings.perPage);
|
|
111
|
+
return Array.from({ length: Math.max(0, pages - 1) }, (_, i) => ({
|
|
112
|
+
params: { page: String(i + 2) },
|
|
113
|
+
props: { page: i + 2, pages },
|
|
114
|
+
}));
|
|
97
115
|
}
|
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/lib/timetable.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `timetable` recipe's logic: which days a place actually has times on, and the
|
|
3
|
+
* rows of the side-by-side table (days down the side, a cell per place). Pure.
|
|
4
|
+
*/
|
|
5
|
+
export interface DaySchedule { day: string; times: string[] }
|
|
6
|
+
export interface Place { name: string; schedule: DaySchedule[] }
|
|
7
|
+
|
|
8
|
+
/** Only the days with at least one real time (blank strings don't count). */
|
|
9
|
+
export function daysWithTimes(schedule: DaySchedule[]): DaySchedule[] {
|
|
10
|
+
return schedule
|
|
11
|
+
.map((d) => ({ day: d.day, times: d.times.map((t) => t.trim()).filter(Boolean) }))
|
|
12
|
+
.filter((d) => d.times.length > 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Table rows: every day some place uses, in first-seen order, with that day's times per place. */
|
|
16
|
+
export function timetableRows(places: Place[]): Array<{ day: string; cells: string[][] }> {
|
|
17
|
+
const days: string[] = [];
|
|
18
|
+
const byPlace = places.map((p) => {
|
|
19
|
+
const map = new Map<string, string[]>();
|
|
20
|
+
for (const d of daysWithTimes(p.schedule)) {
|
|
21
|
+
map.set(d.day, d.times);
|
|
22
|
+
if (!days.includes(d.day)) days.push(d.day);
|
|
23
|
+
}
|
|
24
|
+
return map;
|
|
25
|
+
});
|
|
26
|
+
return days.map((day) => ({ day, cells: byPlace.map((m) => m.get(day) ?? []) }));
|
|
27
|
+
}
|