ferst-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +73 -0
- package/components/Button.astro +208 -0
- package/components/Card.astro +59 -0
- package/components/CookiePreferences.astro +232 -0
- package/components/DocLayout.astro +292 -0
- package/components/GroupCard.astro +164 -0
- package/components/LatestPostList.astro +64 -0
- package/components/PaginationNav.astro +81 -0
- package/components/PostCard.astro +262 -0
- package/components/PostsToolbar.astro +83 -0
- package/components/SecondaryLogo.astro +63 -0
- package/components/SiteFooter.astro +245 -0
- package/components/SiteHeader.astro +1130 -0
- package/content/collections.ts +40 -0
- package/content/filters.ts +107 -0
- package/content/schemas.ts +210 -0
- package/layouts/Base.astro +414 -0
- package/lib/mailLinks.ts +13 -0
- package/lib/mapsLink.ts +7 -0
- package/lib/siteSettings.ts +78 -0
- package/package.json +48 -0
- package/styles/fonts.css +76 -0
- package/styles/theme.css +90 -0
- package/utils/excerpt.ts +56 -0
- package/utils/formatDate.ts +22 -0
- package/utils/freshness.ts +5 -0
- package/utils/scrollReveal.ts +40 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Collection definitions — the content *contract*. Core owns this; each consumer
|
|
2
|
+
// re-exports it from its own `src/content/config.ts` (Astro requires the config
|
|
3
|
+
// to live in the consumer). The content files themselves stay in the consumer.
|
|
4
|
+
import { defineCollection } from 'astro:content';
|
|
5
|
+
import {
|
|
6
|
+
homepageSchema,
|
|
7
|
+
postSchema,
|
|
8
|
+
siteSettingsSchema,
|
|
9
|
+
logoSettingsSchema,
|
|
10
|
+
postsSettingsSchema,
|
|
11
|
+
calendarSettingsSchema,
|
|
12
|
+
navbarSettingsSchema,
|
|
13
|
+
footerSettingsSchema,
|
|
14
|
+
themeSettingsSchema,
|
|
15
|
+
tagSchema,
|
|
16
|
+
} from './schemas';
|
|
17
|
+
|
|
18
|
+
const homepage = defineCollection({ type: 'data', schema: homepageSchema });
|
|
19
|
+
const posts = defineCollection({ type: 'content', schema: postSchema });
|
|
20
|
+
const siteSettings = defineCollection({ type: 'data', schema: siteSettingsSchema });
|
|
21
|
+
const logoSettings = defineCollection({ type: 'data', schema: logoSettingsSchema });
|
|
22
|
+
const postsSettings = defineCollection({ type: 'data', schema: postsSettingsSchema });
|
|
23
|
+
const calendarSettings = defineCollection({ type: 'data', schema: calendarSettingsSchema });
|
|
24
|
+
const navbarSettings = defineCollection({ type: 'data', schema: navbarSettingsSchema });
|
|
25
|
+
const footerSettings = defineCollection({ type: 'data', schema: footerSettingsSchema });
|
|
26
|
+
const themeSettings = defineCollection({ type: 'data', schema: themeSettingsSchema });
|
|
27
|
+
const tags = defineCollection({ type: 'content', schema: tagSchema });
|
|
28
|
+
|
|
29
|
+
export const collections = {
|
|
30
|
+
posts,
|
|
31
|
+
homepage,
|
|
32
|
+
siteSettings,
|
|
33
|
+
logoSettings,
|
|
34
|
+
postsSettings,
|
|
35
|
+
calendarSettings,
|
|
36
|
+
navbarSettings,
|
|
37
|
+
footerSettings,
|
|
38
|
+
themeSettings,
|
|
39
|
+
tags,
|
|
40
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Named predicates for the content collection filters used across the
|
|
2
|
+
// /posts/* page routes.
|
|
3
|
+
//
|
|
4
|
+
// Why this lives here:
|
|
5
|
+
// - DRY: each rule is defined once instead of duplicated as an inline
|
|
6
|
+
// arrow at every call site.
|
|
7
|
+
// - Testable: pure functions over Post data, no Astro runtime needed.
|
|
8
|
+
// - Self-documenting: at the page, `getCollection('posts', forArchive)`
|
|
9
|
+
// reads better than the equivalent arrow.
|
|
10
|
+
//
|
|
11
|
+
// Visibility model encoded here:
|
|
12
|
+
// - `draft` and `scheduled` posts are never visible (no listing, no detail
|
|
13
|
+
// page). A `scheduled` post is content that has been written ahead of time:
|
|
14
|
+
// a cron worker flips it to `published` once its `date` has passed, which
|
|
15
|
+
// is the only thing that makes it appear. Until then it is hidden exactly
|
|
16
|
+
// like a draft.
|
|
17
|
+
// - `published`: appear on /posts/ (paginated index); anything beyond
|
|
18
|
+
// `LATEST_NEWS_MAX` newest also appears on /posts/archive/ alongside
|
|
19
|
+
// posts explicitly marked `archived`.
|
|
20
|
+
// - `archived` posts appear on /posts/archive/ (and still get
|
|
21
|
+
// /posts/<slug>/).
|
|
22
|
+
// - "Newsletter" is a tag, not a separate content type — a post tagged
|
|
23
|
+
// `newsletter` is otherwise an ordinary post (same schema, same
|
|
24
|
+
// `/posts/<slug>/` detail page, same listing/archive rules above) that
|
|
25
|
+
// just typically has no body, only an attachment. `/posts/tag/newsletter/`
|
|
26
|
+
// is the generic per-tag archive every tag gets for free, no bespoke
|
|
27
|
+
// route needed.
|
|
28
|
+
import type { CollectionEntry } from 'astro:content';
|
|
29
|
+
import type { Post } from './schemas';
|
|
30
|
+
|
|
31
|
+
type PostEntry = { data: Post };
|
|
32
|
+
|
|
33
|
+
/** How many published news posts headline /posts/ before they roll into /posts/archive/. */
|
|
34
|
+
export const LATEST_NEWS_MAX = 6;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Statuses that are never shown on the public site — no listing, no detail
|
|
38
|
+
* page. `scheduled` joins `draft` here: it stays invisible until the cron
|
|
39
|
+
* worker promotes it to `published`.
|
|
40
|
+
*/
|
|
41
|
+
const isHidden = ({ data }: PostEntry): boolean =>
|
|
42
|
+
data.status === 'draft' || data.status === 'scheduled';
|
|
43
|
+
|
|
44
|
+
/** Listed on /posts/ (the news index). */
|
|
45
|
+
export function forLatestListing(entry: PostEntry): boolean {
|
|
46
|
+
return entry.data.status === 'published';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Has a generated /posts/<slug>/ detail page. Includes archived posts. */
|
|
50
|
+
export function forLatestDetail(entry: PostEntry): boolean {
|
|
51
|
+
return !isHidden(entry);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Single-entry archive predicate (tests, CMS docs). The live /posts/archive/
|
|
56
|
+
* list is built with `buildArchiveListing` (archived + published overflow).
|
|
57
|
+
*/
|
|
58
|
+
export function forArchive(entry: PostEntry): boolean {
|
|
59
|
+
return entry.data.status === 'archived';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* /posts/archive/: all `archived` posts, plus `published` news that are not
|
|
64
|
+
* among the `LATEST_NEWS_MAX` newest (same date order as /posts/).
|
|
65
|
+
*/
|
|
66
|
+
export function buildArchiveListing(
|
|
67
|
+
publishedPosts: CollectionEntry<'posts'>[],
|
|
68
|
+
archivedPosts: CollectionEntry<'posts'>[]
|
|
69
|
+
): CollectionEntry<'posts'>[] {
|
|
70
|
+
const sorted = [...publishedPosts].sort(
|
|
71
|
+
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
72
|
+
);
|
|
73
|
+
const afterLatest = sorted.slice(LATEST_NEWS_MAX);
|
|
74
|
+
return [...archivedPosts, ...afterLatest].sort(
|
|
75
|
+
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Back link on a news article under /posts/ (archive vs main list; same cut-off as LATEST_NEWS_MAX). */
|
|
80
|
+
export function newsDetailBackLink(
|
|
81
|
+
post: CollectionEntry<'posts'>,
|
|
82
|
+
publishedPosts: CollectionEntry<'posts'>[]
|
|
83
|
+
): { href: string; label: string } {
|
|
84
|
+
if (post.data.status === 'archived') {
|
|
85
|
+
return { href: '/posts/archive/', label: '← Back to archive' };
|
|
86
|
+
}
|
|
87
|
+
const sorted = [...publishedPosts].sort(
|
|
88
|
+
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
89
|
+
);
|
|
90
|
+
const topIds = new Set(sorted.slice(0, LATEST_NEWS_MAX).map((p) => p.id));
|
|
91
|
+
if (topIds.has(post.id)) {
|
|
92
|
+
return { href: '/posts/', label: '← Back to all news' };
|
|
93
|
+
}
|
|
94
|
+
return { href: '/posts/archive/', label: '← Back to archive' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A body-less post with an attachment (the typical newsletter shape) has
|
|
99
|
+
* nothing to show on a `/posts/<slug>/` detail page, so its card/row should
|
|
100
|
+
* open the attachment directly instead of linking internally. An ordinary
|
|
101
|
+
* post that happens to also carry an attachment still opens its detail page
|
|
102
|
+
* (where the attachment is offered as a separate link) since there's a body
|
|
103
|
+
* worth reading first.
|
|
104
|
+
*/
|
|
105
|
+
export function opensAttachmentDirectly(entry: PostEntry & { body?: string }): boolean {
|
|
106
|
+
return Boolean(entry.data.attachment) && !entry.body?.trim();
|
|
107
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
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.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
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
|
+
const navLinkSchema = z.object({
|
|
51
|
+
label: z.string(),
|
|
52
|
+
href: z.string(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** One item inside a nav dropdown / mega panel (client-configured, generic). */
|
|
56
|
+
const navGroupItemSchema = z.object({
|
|
57
|
+
title: z.string(),
|
|
58
|
+
desc: z.string().optional(),
|
|
59
|
+
href: z.string(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** An optional dropdown group in the primary nav (e.g. an "About" mega-menu
|
|
63
|
+
* or a "Services" menu). Entirely client-configured — the core ships none. */
|
|
64
|
+
const navGroupSchema = z.object({
|
|
65
|
+
label: z.string(),
|
|
66
|
+
href: z.string(),
|
|
67
|
+
items: z.array(navGroupItemSchema).default([]),
|
|
68
|
+
});
|
|
69
|
+
|
|
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
|
+
const siteIdentitySchema = z
|
|
79
|
+
.object({
|
|
80
|
+
/** Used in <title> as " — {siteTitle}" and in the document description fallback.
|
|
81
|
+
* Generic default — every client overrides via their siteSettings content. */
|
|
82
|
+
siteTitle: z.string().default('Your Site'),
|
|
83
|
+
/** Short brand label — used in alt text. */
|
|
84
|
+
shortName: z.string().default('Your Site'),
|
|
85
|
+
/** Default <meta name="description"> when a page doesn't supply its own. */
|
|
86
|
+
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. */
|
|
90
|
+
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
|
+
})
|
|
96
|
+
.default({});
|
|
97
|
+
|
|
98
|
+
const siteContactSchema = z
|
|
99
|
+
.object({
|
|
100
|
+
/** Human-readable phone number. Blank by default — client provides. */
|
|
101
|
+
phoneDisplay: z.string().default(''),
|
|
102
|
+
/** Digits-only form used in `tel:` href. */
|
|
103
|
+
phoneTel: z.string().default(''),
|
|
104
|
+
email: z.string().default(''),
|
|
105
|
+
facebookUrl: z.string().default(''),
|
|
106
|
+
/** One entry per physical location/site. Iterated on /contact/. */
|
|
107
|
+
churches: z.array(churchSchema).default([]),
|
|
108
|
+
})
|
|
109
|
+
.default({});
|
|
110
|
+
|
|
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. */
|
|
114
|
+
export const siteSettingsSchema = z.object({
|
|
115
|
+
identity: siteIdentitySchema,
|
|
116
|
+
contact: siteContactSchema,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const logoSettingsSchema = z.object({
|
|
120
|
+
/** Primary/header logo — light theme variant. Optional — Base.astro falls back to a baked-in path. */
|
|
121
|
+
primaryLogoLight: z.string().optional(),
|
|
122
|
+
/** Primary/header logo — dark theme variant. Optional — Base.astro falls back to a baked-in path. */
|
|
123
|
+
primaryLogoDark: z.string().optional(),
|
|
124
|
+
/** Secondary/footer logo — light theme variant (optional). */
|
|
125
|
+
secondaryLogoLight: z.string().optional(),
|
|
126
|
+
/** Secondary/footer logo — dark theme variant (optional). */
|
|
127
|
+
secondaryLogoDark: z.string().optional(),
|
|
128
|
+
/** Display name + target URL for the secondary/footer logo's link (optional). */
|
|
129
|
+
secondaryLogoLink: z
|
|
130
|
+
.object({
|
|
131
|
+
name: z.string().default(''),
|
|
132
|
+
url: z.string().default(''),
|
|
133
|
+
})
|
|
134
|
+
.default({}),
|
|
135
|
+
});
|
|
136
|
+
|
|
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
|
+
export const navbarSettingsSchema = z.object({
|
|
148
|
+
/** Simple top-level links. Empty by default — every client defines its own nav. */
|
|
149
|
+
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. */
|
|
152
|
+
dropdown: navGroupSchema.optional(),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export const footerSettingsSchema = z.object({
|
|
156
|
+
/** Footer legal/info nav. Empty by default — every client defines its own. */
|
|
157
|
+
footer: z.array(navLinkSchema).default([]),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
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.
|
|
169
|
+
*/
|
|
170
|
+
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'),
|
|
194
|
+
});
|
|
195
|
+
|
|
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
|
+
export type SiteSettings = z.infer<typeof siteSettingsSchema>;
|
|
201
|
+
export type LogoSettings = z.infer<typeof logoSettingsSchema>;
|
|
202
|
+
export type PostsSettings = z.infer<typeof postsSettingsSchema>;
|
|
203
|
+
export type CalendarSettings = z.infer<typeof calendarSettingsSchema>;
|
|
204
|
+
export type NavbarSettings = z.infer<typeof navbarSettingsSchema>;
|
|
205
|
+
export type FooterSettings = z.infer<typeof footerSettingsSchema>;
|
|
206
|
+
export type ThemeSettings = z.infer<typeof themeSettingsSchema>;
|
|
207
|
+
export type NavLink = z.infer<typeof navLinkSchema>;
|
|
208
|
+
export type NavGroup = z.infer<typeof navGroupSchema>;
|
|
209
|
+
export type NavGroupItem = z.infer<typeof navGroupItemSchema>;
|
|
210
|
+
export type Church = z.infer<typeof churchSchema>;
|