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.
@@ -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 recipes/templates the site
5
- * uses — functionality-driven, never hand-written or client-specific.
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 (its `blocks` and/or a
15
- * detection flag); the policy page then lists it automatically wherever it's used.
16
- * Pure + framework-free so it's unit-testable without a render.
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 of these block `type`s appears anywhere on the site. */
38
- blocks?: string[];
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
- blocks: ['locations', 'mapEmbed'],
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
- blocks: ['calendar'],
114
+ loads: (b) => b.type === 'calendar',
80
115
  calendar: true,
81
116
  },
82
117
  {
83
- id: 'video',
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: 'Embedded video (YouTube / Vimeo)',
86
- provider: 'Google (YouTube) or Vimeo',
87
- purpose: 'Plays videos embedded on our pages. The provider may set cookies when a video loads.',
88
- storage: 'Third-party cookies set by the video provider.',
89
- blocks: ['video'],
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 `type` that appears anywhere on the site. */
95
- blockTypes: Iterable<string>;
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 types = new Set(input.blockTypes);
108
- const additional = ADDITIONAL.filter(
109
- (u) => (u.blocks?.some((b) => types.has(b)) ?? false) || (u.calendar === true && !!input.calendarConfigured),
110
- ).map(({ blocks, calendar, ...use }) => use); // drop detection fields from the view model
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
+ }
@@ -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
- * @returns {import('astro').AstroIntegration}
29
- */
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
- }
69
-
70
- export default function ferst(options = {}) {
71
- const { calendar = true, blog = true, cookiePolicy = true } = options;
72
- return {
73
- name: 'ferst',
74
- hooks: {
75
- 'astro:config:setup': ({ injectRoute, config }) => {
76
- // Pages + home: the reserved `index` entry renders at `/`, every other
77
- // `pages` entry at its own slug (see lib/pages.ts `getSiteRoutes`).
78
- injectRoute({
79
- pattern: '/[...slug]',
80
- entrypoint: 'ferst-core/routes/[...slug].astro',
81
- });
82
- if (calendar) {
83
- injectRoute({
84
- pattern: '/calendar',
85
- entrypoint: 'ferst-core/routes/calendar/index.astro',
86
- });
87
- }
88
- if (blog) {
89
- // The posts capability: index, individual post, and tag pages. Reads
90
- // the `posts`/`tags`/`postsSettings` collections (content/collections.ts).
91
- injectRoute({
92
- pattern: '/posts',
93
- entrypoint: 'ferst-core/routes/posts/index.astro',
94
- });
95
- injectRoute({
96
- pattern: '/posts/[...slug]',
97
- entrypoint: 'ferst-core/routes/posts/[...slug].astro',
98
- });
99
- injectRoute({
100
- pattern: '/posts/tag/[...tag]',
101
- entrypoint: 'ferst-core/routes/posts/tag/[...tag].astro',
102
- });
103
- // Index pages 2+ (page 1 is /posts; tag pages paginate inside their own route).
104
- injectRoute({
105
- pattern: '/posts/page/[page]',
106
- entrypoint: 'ferst-core/routes/posts/page/[page].astro',
107
- });
108
- }
109
- // The cookie banner links here on every page, so it must always resolve. A site's
110
- // own hand-built page wins (Astro would otherwise see two routes for one path).
111
- const bespoke = config?.root && existsSync(new URL('src/pages/cookie-policy.astro', config.root));
112
- if (cookiePolicy && !bespoke) {
113
- injectRoute({
114
- pattern: '/cookie-policy',
115
- entrypoint: 'ferst-core/routes/cookie-policy.astro',
116
- });
117
- }
118
- },
119
- // The engine's self-hosted fonts: served in dev, copied into every build.
120
- ...ferstFonts().hooks,
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
+ }