ferst-core 0.7.13 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cms/config.template.yml +1 -0
- package/components/SiteHeader.astro +4 -1
- package/components/recipes/Hero.astro +533 -530
- package/content/cookies.ts +104 -31
- package/content/schemas.ts +4 -0
- package/integration.mjs +208 -123
- package/layouts/Base.astro +515 -498
- package/lib/seo.mjs +130 -0
- package/lib/site-icon.mjs +145 -0
- package/package.json +1 -1
- package/routes/404.astro +36 -0
- package/routes/cookie-policy.astro +8 -7
- package/routes/robots.txt.ts +15 -0
- package/routes/sitemap.xml.ts +37 -0
package/content/cookies.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cookie / third-party-storage registry + a scanner that reports which uses are
|
|
3
3
|
* actually active on a given site. This powers the auto-generated cookie policy
|
|
4
|
-
* page: its categories and items are DERIVED from the
|
|
5
|
-
*
|
|
4
|
+
* page: its categories and items are DERIVED from the blocks the site uses AND what
|
|
5
|
+
* each one actually loads — functionality-driven, never hand-written or
|
|
6
|
+
* client-specific. A hero playing a YouTube link loads YouTube (listed, and gated by
|
|
7
|
+
* consent); a hero playing a video file the site hosts loads nothing from anyone (not
|
|
8
|
+
* listed, plays straight away). A map with no embed address loads nothing.
|
|
6
9
|
*
|
|
7
10
|
* Two canonical categories:
|
|
8
11
|
* • necessary — the site's OWN functional storage (no third parties). Always
|
|
@@ -11,10 +14,13 @@
|
|
|
11
14
|
* they load. Gated behind the site's shared embed consent, and
|
|
12
15
|
* listed only when a recipe/template that uses them is present.
|
|
13
16
|
*
|
|
14
|
-
* Adding a new cookie-using recipe = one entry here (
|
|
15
|
-
* detection flag); the policy page then lists it
|
|
16
|
-
* Pure + framework-free so it's unit-testable
|
|
17
|
+
* Adding a new cookie-using recipe = one entry here (a `loads` rule reading the
|
|
18
|
+
* block's own data, and/or a detection flag); the policy page then lists it
|
|
19
|
+
* automatically wherever it's used. Pure + framework-free so it's unit-testable
|
|
20
|
+
* without a render.
|
|
17
21
|
*/
|
|
22
|
+
import { backgroundEmbed, videoEmbed } from '../lib/video';
|
|
23
|
+
import { isSafeEmbedUrl } from './calendar';
|
|
18
24
|
|
|
19
25
|
export type CookieCategory = 'necessary' | 'additional';
|
|
20
26
|
|
|
@@ -32,14 +38,40 @@ export interface CookieUse {
|
|
|
32
38
|
storage: string;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/** A block as stored in a page: its `type`, its own fields, and any child blocks. */
|
|
42
|
+
export type BlockLike = { type: string; blocks?: unknown } & Record<string, unknown>;
|
|
43
|
+
|
|
35
44
|
/** Additional uses carry detection rules; stripped before returning to the view. */
|
|
36
45
|
interface AdditionalUse extends CookieUse {
|
|
37
|
-
/** Present if any
|
|
38
|
-
|
|
46
|
+
/** Present if any block on the site loads this provider, judged from the block's data. */
|
|
47
|
+
loads?: (block: BlockLike) => boolean;
|
|
39
48
|
/** Present if the site has a Google Calendar configured (a dedicated page). */
|
|
40
49
|
calendar?: boolean;
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/** The video provider a block plays from (a video block's link, a hero's background), if any. */
|
|
53
|
+
function videoProviderOf(b: BlockLike): string | undefined {
|
|
54
|
+
if (b.type === 'video') return videoEmbed(typeof b.url === 'string' ? b.url : '')?.provider;
|
|
55
|
+
if (b.type === 'hero') {
|
|
56
|
+
if (b.layout !== 'media') return undefined; // only the media layout shows a background
|
|
57
|
+
const video = (b.media as { video?: unknown } | undefined)?.video;
|
|
58
|
+
return typeof video === 'string' ? backgroundEmbed(video)?.provider : undefined;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The provider a block shows a map from (its own label), if it has a map to show. */
|
|
64
|
+
function mapProviderOf(b: BlockLike): string | undefined {
|
|
65
|
+
const label = typeof b.provider === 'string' && b.provider.trim() ? b.provider.trim() : 'Google Maps';
|
|
66
|
+
if (b.type === 'mapEmbed') return isSafeEmbedUrl(typeof b.embedUrl === 'string' ? b.embedUrl : '') ? label : undefined;
|
|
67
|
+
if (b.type === 'locations' && Array.isArray(b.items)) {
|
|
68
|
+
const hasMap = b.items.some((i: { embedUrl?: unknown }) => isSafeEmbedUrl(typeof i?.embedUrl === 'string' ? i.embedUrl : ''));
|
|
69
|
+
return hasMap ? label : undefined;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const isGoogleMaps = (provider: string) => /google/i.test(provider);
|
|
74
|
+
|
|
43
75
|
/** The site's own functional storage — always present, always on. */
|
|
44
76
|
const NECESSARY: readonly CookieUse[] = [
|
|
45
77
|
{
|
|
@@ -67,7 +99,10 @@ const ADDITIONAL: readonly AdditionalUse[] = [
|
|
|
67
99
|
provider: 'Google',
|
|
68
100
|
purpose: 'Shows embedded maps of where to find us. Google may set cookies when a map loads.',
|
|
69
101
|
storage: 'Third-party cookies set by Google.',
|
|
70
|
-
|
|
102
|
+
loads: (b) => {
|
|
103
|
+
const provider = mapProviderOf(b);
|
|
104
|
+
return provider !== undefined && isGoogleMaps(provider);
|
|
105
|
+
},
|
|
71
106
|
},
|
|
72
107
|
{
|
|
73
108
|
id: 'google-calendar',
|
|
@@ -76,23 +111,46 @@ const ADDITIONAL: readonly AdditionalUse[] = [
|
|
|
76
111
|
provider: 'Google',
|
|
77
112
|
purpose: 'Shows the parish calendar. Google may set cookies when the calendar loads.',
|
|
78
113
|
storage: 'Third-party cookies set by Google.',
|
|
79
|
-
|
|
114
|
+
loads: (b) => b.type === 'calendar',
|
|
80
115
|
calendar: true,
|
|
81
116
|
},
|
|
82
117
|
{
|
|
83
|
-
id: '
|
|
118
|
+
id: 'youtube',
|
|
119
|
+
category: 'additional',
|
|
120
|
+
name: 'YouTube',
|
|
121
|
+
provider: 'Google',
|
|
122
|
+
purpose: 'Plays videos from YouTube on our pages. YouTube may store data on your device and see your IP address when a video plays.',
|
|
123
|
+
storage: 'Third-party cookies and local storage set by YouTube (Google).',
|
|
124
|
+
loads: (b) => videoProviderOf(b) === 'YouTube',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'vimeo',
|
|
84
128
|
category: 'additional',
|
|
85
|
-
name: '
|
|
86
|
-
provider: '
|
|
87
|
-
purpose: 'Plays videos
|
|
88
|
-
storage: 'Third-party cookies set by
|
|
89
|
-
|
|
129
|
+
name: 'Vimeo',
|
|
130
|
+
provider: 'Vimeo',
|
|
131
|
+
purpose: 'Plays videos from Vimeo on our pages. Vimeo may store data on your device and see your IP address when a video plays.',
|
|
132
|
+
storage: 'Third-party cookies set by Vimeo.',
|
|
133
|
+
loads: (b) => videoProviderOf(b) === 'Vimeo',
|
|
90
134
|
},
|
|
91
135
|
];
|
|
92
136
|
|
|
137
|
+
/** A map from a provider other than Google, named as the block names it (e.g. OpenStreetMap). */
|
|
138
|
+
function otherMapUse(provider: string): CookieUse {
|
|
139
|
+
const slug = provider.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
140
|
+
return {
|
|
141
|
+
id: `map-${slug}`,
|
|
142
|
+
category: 'additional',
|
|
143
|
+
name: provider,
|
|
144
|
+
provider,
|
|
145
|
+
purpose: `Shows embedded maps of where to find us. ${provider} may set cookies when a map loads.`,
|
|
146
|
+
storage: `Third-party cookies set by ${provider}.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
93
150
|
export interface CookieScanInput {
|
|
94
|
-
/** Every block
|
|
95
|
-
|
|
151
|
+
/** Every block on the site's pages. Container children (`section` / `grid` / `stack`)
|
|
152
|
+
* are walked, so an embed nested in a grid counts. */
|
|
153
|
+
blocks: Iterable<BlockLike>;
|
|
96
154
|
/** True when the site has a Google Calendar configured (dedicated calendar page). */
|
|
97
155
|
calendarConfigured?: boolean;
|
|
98
156
|
}
|
|
@@ -102,21 +160,36 @@ export interface CookiePolicyModel {
|
|
|
102
160
|
additional: CookieUse[];
|
|
103
161
|
}
|
|
104
162
|
|
|
163
|
+
/** Every block that renders, walking container children (`section` / `grid` / `stack`).
|
|
164
|
+
* A block switched off (`enabled: false`) renders nothing, and neither do its children. */
|
|
165
|
+
function allBlocks(blocks: Iterable<BlockLike>, acc: BlockLike[] = []): BlockLike[] {
|
|
166
|
+
for (const b of blocks) {
|
|
167
|
+
if (!b || typeof b !== 'object' || typeof b.type !== 'string' || b.enabled === false) continue;
|
|
168
|
+
acc.push(b);
|
|
169
|
+
if (Array.isArray(b.blocks)) allBlocks(b.blocks as BlockLike[], acc);
|
|
170
|
+
}
|
|
171
|
+
return acc;
|
|
172
|
+
}
|
|
173
|
+
|
|
105
174
|
/** Report the cookie uses actually active on a site, grouped by category. */
|
|
106
175
|
export function scanCookieUses(input: CookieScanInput): CookiePolicyModel {
|
|
107
|
-
const
|
|
108
|
-
const additional = ADDITIONAL.filter(
|
|
109
|
-
(u) => (u.blocks
|
|
110
|
-
).map(({
|
|
176
|
+
const blocks = allBlocks(input.blocks);
|
|
177
|
+
const additional: CookieUse[] = ADDITIONAL.filter(
|
|
178
|
+
(u) => (u.loads ? blocks.some(u.loads) : false) || (u.calendar === true && !!input.calendarConfigured),
|
|
179
|
+
).map(({ loads, calendar, ...use }) => use); // drop detection fields from the view model
|
|
180
|
+
// Maps from other providers, as the blocks name them, listed with the other maps.
|
|
181
|
+
const otherMaps = new Set(blocks.map(mapProviderOf).filter((p): p is string => p !== undefined && !isGoogleMaps(p)));
|
|
182
|
+
const afterMaps = additional.findIndex((u) => u.id !== 'google-maps');
|
|
183
|
+
additional.splice(afterMaps === -1 ? additional.length : afterMaps, 0, ...[...otherMaps].map(otherMapUse));
|
|
111
184
|
return { necessary: NECESSARY.map((u) => ({ ...u })), additional };
|
|
112
185
|
}
|
|
113
|
-
|
|
114
|
-
/** Every block `type` used in a list of blocks, walking container children
|
|
115
|
-
* (`section` / `grid` / `stack`), so the policy sees an embed nested in a grid. */
|
|
116
|
-
export function blockTypesIn(blocks: ReadonlyArray<{ type: string; blocks?: unknown }>, acc = new Set<string>()): Set<string> {
|
|
117
|
-
for (const b of blocks) {
|
|
118
|
-
acc.add(b.type);
|
|
119
|
-
if (Array.isArray(b.blocks)) blockTypesIn(b.blocks as Array<{ type: string; blocks?: unknown }>, acc);
|
|
120
|
-
}
|
|
121
|
-
return acc;
|
|
122
|
-
}
|
|
186
|
+
|
|
187
|
+
/** Every block `type` used in a list of blocks, walking container children
|
|
188
|
+
* (`section` / `grid` / `stack`), so the policy sees an embed nested in a grid. */
|
|
189
|
+
export function blockTypesIn(blocks: ReadonlyArray<{ type: string; blocks?: unknown }>, acc = new Set<string>()): Set<string> {
|
|
190
|
+
for (const b of blocks) {
|
|
191
|
+
acc.add(b.type);
|
|
192
|
+
if (Array.isArray(b.blocks)) blockTypesIn(b.blocks as Array<{ type: string; blocks?: unknown }>, acc);
|
|
193
|
+
}
|
|
194
|
+
return acc;
|
|
195
|
+
}
|
package/content/schemas.ts
CHANGED
|
@@ -65,6 +65,10 @@ export const logoSettingsSchema = z.object({
|
|
|
65
65
|
secondaryLogoLight: z.string().optional(),
|
|
66
66
|
/** Secondary/footer logo — dark theme variant (optional). */
|
|
67
67
|
secondaryLogoDark: z.string().optional(),
|
|
68
|
+
/** Browser icon (favicon): a square image, PNG or SVG, at least 48×48. Optional: blank
|
|
69
|
+
* uses the header logo when it's roughly square, else the site's initial in its brand
|
|
70
|
+
* colour (lib/site-icon.mjs). */
|
|
71
|
+
favicon: z.string().optional(),
|
|
68
72
|
/** Display name + target URL for the secondary/footer logo's link (optional). */
|
|
69
73
|
secondaryLogoLink: z
|
|
70
74
|
.object({
|
package/integration.mjs
CHANGED
|
@@ -1,123 +1,208 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ferst() — the Astro integration that makes a client site's ROUTING package-owned.
|
|
3
|
-
*
|
|
4
|
-
* A thin client adds `integrations: [ferst()]` to astro.config.mjs and carries
|
|
5
|
-
* ZERO page files: the page catch-all (which also owns the home page), the
|
|
6
|
-
* calendar route and the blog routes are injected from ferst-core. A routing fix
|
|
7
|
-
* then ships as a package bump + rebuild — the same channel component fixes travel
|
|
8
|
-
* through — instead of being hand-copied into every client repo.
|
|
9
|
-
*
|
|
10
|
-
* Irreducible caveat (Astro 5): content collections must be defined in the CONSUMER,
|
|
11
|
-
* so the one-line `src/content/config.ts` re-export stays. "Zero page files" is
|
|
12
|
-
* reachable; "zero files" is not. Explicit client `.astro` routes still win over
|
|
13
|
-
* these injected ones, so a one-off hand-built page is always possible.
|
|
14
|
-
*
|
|
15
|
-
* The blog routes (posts index / individual post / tag pages) inject as a set:
|
|
16
|
-
* the listing components (NewsList, PostsFilter) now live in core, so `/posts`,
|
|
17
|
-
* `/posts/[...slug]` and `/posts/tag/[...tag]` all ship from the package. Turn
|
|
18
|
-
* the whole capability off with `blog: false` (then a client wanting posts hand-
|
|
19
|
-
* builds its own routes over `ferst-core/lib/posts`).
|
|
20
|
-
*
|
|
21
|
-
* @param {object} [options]
|
|
22
|
-
* @param {boolean} [options.calendar=true] Inject the `/calendar` route.
|
|
23
|
-
* @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
|
|
24
|
-
* `/posts/[...slug]`, `/posts/tag/[...tag]`).
|
|
25
|
-
* @param {boolean} [options.cookiePolicy=true] Inject `/cookie-policy` (the page the cookie
|
|
26
|
-
* banner links to), generated from the blocks the site uses. Skipped automatically when
|
|
27
|
-
* the site has its own `src/pages/cookie-policy.astro`.
|
|
28
|
-
* @
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* @returns {import('astro').AstroIntegration}
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ferst() — the Astro integration that makes a client site's ROUTING package-owned.
|
|
3
|
+
*
|
|
4
|
+
* A thin client adds `integrations: [ferst()]` to astro.config.mjs and carries
|
|
5
|
+
* ZERO page files: the page catch-all (which also owns the home page), the
|
|
6
|
+
* calendar route and the blog routes are injected from ferst-core. A routing fix
|
|
7
|
+
* then ships as a package bump + rebuild — the same channel component fixes travel
|
|
8
|
+
* through — instead of being hand-copied into every client repo.
|
|
9
|
+
*
|
|
10
|
+
* Irreducible caveat (Astro 5): content collections must be defined in the CONSUMER,
|
|
11
|
+
* so the one-line `src/content/config.ts` re-export stays. "Zero page files" is
|
|
12
|
+
* reachable; "zero files" is not. Explicit client `.astro` routes still win over
|
|
13
|
+
* these injected ones, so a one-off hand-built page is always possible.
|
|
14
|
+
*
|
|
15
|
+
* The blog routes (posts index / individual post / tag pages) inject as a set:
|
|
16
|
+
* the listing components (NewsList, PostsFilter) now live in core, so `/posts`,
|
|
17
|
+
* `/posts/[...slug]` and `/posts/tag/[...tag]` all ship from the package. Turn
|
|
18
|
+
* the whole capability off with `blog: false` (then a client wanting posts hand-
|
|
19
|
+
* builds its own routes over `ferst-core/lib/posts`).
|
|
20
|
+
*
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {boolean} [options.calendar=true] Inject the `/calendar` route.
|
|
23
|
+
* @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
|
|
24
|
+
* `/posts/[...slug]`, `/posts/tag/[...tag]`).
|
|
25
|
+
* @param {boolean} [options.cookiePolicy=true] Inject `/cookie-policy` (the page the cookie
|
|
26
|
+
* banner links to), generated from the blocks the site uses. Skipped automatically when
|
|
27
|
+
* the site has its own `src/pages/cookie-policy.astro`.
|
|
28
|
+
* @param {boolean} [options.notFound=true] Inject the "page not found" page (`/404` →
|
|
29
|
+
* `404.html`). Cloudflare Pages answers a missing address with it and a real 404 status;
|
|
30
|
+
* without one it serves the home page with a 200 (a "soft 404"). Skipped automatically
|
|
31
|
+
* when the site has its own `src/pages/404.astro` or `public/404.html`.
|
|
32
|
+
* @param {boolean} [options.icons=true] The browser icon (lib/site-icon.mjs): `/favicon.ico` and
|
|
33
|
+
* `/apple-touch-icon.png` in every build, from the icon set in the CMS, else a roughly
|
|
34
|
+
* square logo, else the site's initial in its brand colour. A site's own files in
|
|
35
|
+
* `public/` win.
|
|
36
|
+
* @param {boolean} [options.seo=true] Search signals (lib/seo.mjs): `/robots.txt`, and once
|
|
37
|
+
* `ferst-site.json` has a `siteUrl`, `/sitemap.xml` and a canonical address on every page.
|
|
38
|
+
* A test copy (any branch but the production one) is marked noindex on every file. A
|
|
39
|
+
* site's own `public/robots.txt` or `public/sitemap.xml` wins.
|
|
40
|
+
* @returns {import('astro').AstroIntegration}
|
|
41
|
+
*/
|
|
42
|
+
import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
43
|
+
import { deploymentBranch } from './bin/cms-config.mjs';
|
|
44
|
+
import { seoContext, withNoindexHeader } from './lib/seo.mjs';
|
|
45
|
+
import { writeSiteIcons } from './lib/site-icon.mjs';
|
|
46
|
+
|
|
47
|
+
/** The self-hosted fonts the engine's CSS requests (/fonts/*.woff2), shipped in the package. */
|
|
48
|
+
const FONTS_DIR = new URL('./fonts/', import.meta.url);
|
|
49
|
+
const FONT_URL = /^\/fonts\/([a-z0-9-]+\.woff2)(?:\?.*)?$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ferstFonts() — serves the engine's self-hosted fonts in `astro dev` and copies them into
|
|
53
|
+
* every build's `/fonts/`. Included in ferst(); usable on its own (the showcase). A file
|
|
54
|
+
* the site already has in its own `public/fonts/` is never overwritten.
|
|
55
|
+
* @returns {import('astro').AstroIntegration}
|
|
56
|
+
*/
|
|
57
|
+
export function ferstFonts() {
|
|
58
|
+
return {
|
|
59
|
+
name: 'ferst-fonts',
|
|
60
|
+
hooks: {
|
|
61
|
+
'astro:server:setup': ({ server }) => {
|
|
62
|
+
server.middlewares.use((req, res, next) => {
|
|
63
|
+
const m = req.url && req.url.match(FONT_URL);
|
|
64
|
+
if (!m) return next();
|
|
65
|
+
const file = new URL(m[1], FONTS_DIR);
|
|
66
|
+
if (!existsSync(file)) return next();
|
|
67
|
+
res.setHeader('Content-Type', 'font/woff2');
|
|
68
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
69
|
+
createReadStream(file).pipe(res);
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
'astro:build:done': async ({ dir }) => {
|
|
73
|
+
const out = new URL('fonts/', dir);
|
|
74
|
+
mkdirSync(out, { recursive: true });
|
|
75
|
+
for (const name of readdirSync(FONTS_DIR)) {
|
|
76
|
+
if (!name.endsWith('.woff2')) continue;
|
|
77
|
+
const dest = new URL(name, out);
|
|
78
|
+
if (!existsSync(dest)) copyFileSync(new URL(name, FONTS_DIR), dest);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The site's `ferst-site.json`, or null (no file, or not JSON). */
|
|
86
|
+
function readSiteMeta(root) {
|
|
87
|
+
if (!root) return null;
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(readFileSync(new URL('ferst-site.json', root), 'utf8'));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Does the site ship this file in its own `public/`? (Its own file wins over ours.) */
|
|
96
|
+
const hasPublicFile = (root, name) => Boolean(root) && existsSync(new URL(`public/${name}`, root));
|
|
97
|
+
|
|
98
|
+
export default function ferst(options = {}) {
|
|
99
|
+
const { calendar = true, blog = true, cookiePolicy = true, notFound = true, icons = true, seo = true } = options;
|
|
100
|
+
const fonts = ferstFonts().hooks;
|
|
101
|
+
/** This build's search context, decided at setup and used again when the build is done. */
|
|
102
|
+
let search = null;
|
|
103
|
+
/** The site's folder, for the content and public files the build-done steps read. */
|
|
104
|
+
let siteRoot = null;
|
|
105
|
+
return {
|
|
106
|
+
name: 'ferst',
|
|
107
|
+
hooks: {
|
|
108
|
+
'astro:config:setup': ({ injectRoute, updateConfig, config }) => {
|
|
109
|
+
siteRoot = config?.root ?? null;
|
|
110
|
+
// Pages + home: the reserved `index` entry renders at `/`, every other
|
|
111
|
+
// `pages` entry at its own slug (see lib/pages.ts `getSiteRoutes`).
|
|
112
|
+
injectRoute({
|
|
113
|
+
pattern: '/[...slug]',
|
|
114
|
+
entrypoint: 'ferst-core/routes/[...slug].astro',
|
|
115
|
+
});
|
|
116
|
+
if (calendar) {
|
|
117
|
+
injectRoute({
|
|
118
|
+
pattern: '/calendar',
|
|
119
|
+
entrypoint: 'ferst-core/routes/calendar/index.astro',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (blog) {
|
|
123
|
+
// The posts capability: index, individual post, and tag pages. Reads
|
|
124
|
+
// the `posts`/`tags`/`postsSettings` collections (content/collections.ts).
|
|
125
|
+
injectRoute({
|
|
126
|
+
pattern: '/posts',
|
|
127
|
+
entrypoint: 'ferst-core/routes/posts/index.astro',
|
|
128
|
+
});
|
|
129
|
+
injectRoute({
|
|
130
|
+
pattern: '/posts/[...slug]',
|
|
131
|
+
entrypoint: 'ferst-core/routes/posts/[...slug].astro',
|
|
132
|
+
});
|
|
133
|
+
injectRoute({
|
|
134
|
+
pattern: '/posts/tag/[...tag]',
|
|
135
|
+
entrypoint: 'ferst-core/routes/posts/tag/[...tag].astro',
|
|
136
|
+
});
|
|
137
|
+
// Index pages 2+ (page 1 is /posts; tag pages paginate inside their own route).
|
|
138
|
+
injectRoute({
|
|
139
|
+
pattern: '/posts/page/[page]',
|
|
140
|
+
entrypoint: 'ferst-core/routes/posts/page/[page].astro',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// The cookie banner links here on every page, so it must always resolve. A site's
|
|
144
|
+
// own hand-built page wins (Astro would otherwise see two routes for one path).
|
|
145
|
+
const bespoke = config?.root && existsSync(new URL('src/pages/cookie-policy.astro', config.root));
|
|
146
|
+
if (cookiePolicy && !bespoke) {
|
|
147
|
+
injectRoute({
|
|
148
|
+
pattern: '/cookie-policy',
|
|
149
|
+
entrypoint: 'ferst-core/routes/cookie-policy.astro',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// A missing address must answer 404, not the home page (Cloudflare Pages serves the
|
|
153
|
+
// home page with a 200 when a build has no 404.html). The site's own page wins.
|
|
154
|
+
const ownNotFound = config?.root && existsSync(new URL('src/pages/404.astro', config.root));
|
|
155
|
+
if (notFound && !ownNotFound && !hasPublicFile(config?.root, '404.html')) {
|
|
156
|
+
injectRoute({ pattern: '/404', entrypoint: 'ferst-core/routes/404.astro' });
|
|
157
|
+
}
|
|
158
|
+
// Which optional routes this site has, for the pages that link to them (the sitemap,
|
|
159
|
+
// the "page not found" page).
|
|
160
|
+
updateConfig?.({
|
|
161
|
+
vite: {
|
|
162
|
+
define: {
|
|
163
|
+
'import.meta.env.FERST_ROUTES_BLOG': JSON.stringify(blog),
|
|
164
|
+
'import.meta.env.FERST_ROUTES_CALENDAR': JSON.stringify(calendar),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
// Search signals: the site's address and whether this deploy is the live site (the
|
|
169
|
+
// production branch) or a test copy, the same rule the CMS uses for its git-proxy.
|
|
170
|
+
if (seo) {
|
|
171
|
+
const root = config?.root;
|
|
172
|
+
search = seoContext({ siteMeta: readSiteMeta(root), branch: deploymentBranch(), env: process.env });
|
|
173
|
+
updateConfig?.({
|
|
174
|
+
...(search.siteUrl && !config?.site ? { site: search.siteUrl } : {}),
|
|
175
|
+
vite: {
|
|
176
|
+
define: {
|
|
177
|
+
'import.meta.env.FERST_SITE_URL': JSON.stringify(search.siteUrl ?? ''),
|
|
178
|
+
'import.meta.env.FERST_SEO_PRODUCTION': JSON.stringify(search.production),
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
if (!hasPublicFile(root, 'robots.txt')) {
|
|
183
|
+
injectRoute({ pattern: '/robots.txt', entrypoint: 'ferst-core/routes/robots.txt.ts' });
|
|
184
|
+
}
|
|
185
|
+
// A sitemap lists absolute addresses, so it needs the site's address.
|
|
186
|
+
if (search.siteUrl && !hasPublicFile(root, 'sitemap.xml')) {
|
|
187
|
+
injectRoute({ pattern: '/sitemap.xml', entrypoint: 'ferst-core/routes/sitemap.xml.ts' });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
// The engine's self-hosted fonts: served in dev, copied into every build.
|
|
192
|
+
'astro:server:setup': fonts['astro:server:setup'],
|
|
193
|
+
'astro:build:done': async (ctx) => {
|
|
194
|
+
await fonts['astro:build:done'](ctx);
|
|
195
|
+
// A test copy is noindex on every file: pages, and the PDFs and images beside them.
|
|
196
|
+
if (search && !search.production) {
|
|
197
|
+
const file = new URL('_headers', ctx.dir);
|
|
198
|
+
writeFileSync(file, withNoindexHeader(existsSync(file) ? readFileSync(file, 'utf8') : ''));
|
|
199
|
+
}
|
|
200
|
+
// The browser icon: /favicon.ico and /apple-touch-icon.png (sharp comes with Astro).
|
|
201
|
+
if (icons && siteRoot) {
|
|
202
|
+
const sharp = await import('sharp').then((m) => m.default ?? m).catch(() => null);
|
|
203
|
+
await writeSiteIcons({ root: siteRoot, outDir: ctx.dir, sharp, warn: (m) => (ctx.logger ? ctx.logger.warn(m) : console.warn(m)) });
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|