ferst-core 0.8.0 → 0.8.2
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/content/cookies.ts +125 -32
- package/content/schemas.ts +4 -0
- package/integration.mjs +14 -1
- package/layouts/Base.astro +3 -0
- package/lib/pages.ts +13 -2
- package/lib/site-icon.mjs +145 -0
- package/package.json +1 -1
- package/routes/404.astro +15 -4
- package/routes/cookie-policy.astro +8 -7
- package/routes/sitemap.xml.ts +4 -4
package/cms/config.template.yml
CHANGED
|
@@ -94,6 +94,7 @@ collections:
|
|
|
94
94
|
- { name: primaryLogoDark, label: Header logo (dark), widget: image, required: false }
|
|
95
95
|
- { name: secondaryLogoLight, label: Footer logo (light), widget: image, required: false }
|
|
96
96
|
- { name: secondaryLogoDark, label: Footer logo (dark), widget: image, required: false }
|
|
97
|
+
- { name: favicon, label: Browser icon, widget: image, required: false, hint: "A square image (PNG or SVG), at least 48 x 48. Leave blank to use the header logo if it's square, or the site's initial in its brand colour." }
|
|
97
98
|
|
|
98
99
|
- name: navbar
|
|
99
100
|
label: Navigation
|
|
@@ -1046,7 +1046,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
|
|
|
1046
1046
|
};
|
|
1047
1047
|
siteHeader.addEventListener('transitionend', settled);
|
|
1048
1048
|
siteHeader.addEventListener('transitioncancel', settled);
|
|
1049
|
-
|
|
1049
|
+
// The first value is published below, once the crowding check has settled the layout.
|
|
1050
1050
|
}
|
|
1051
1051
|
|
|
1052
1052
|
// Crowding (#11): on desktop the menu is centred on the header. When the site name (or
|
|
@@ -1075,6 +1075,9 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
|
|
|
1075
1075
|
desktop.addEventListener('change', queue);
|
|
1076
1076
|
document.fonts?.ready.then(queue);
|
|
1077
1077
|
fitCheck();
|
|
1078
|
+
// Only now that the menu has its row (or not) is the header its real height: publishing
|
|
1079
|
+
// it before the check told the opener the one-row height until the next frame.
|
|
1080
|
+
syncHeaderHeight();
|
|
1078
1081
|
}
|
|
1079
1082
|
|
|
1080
1083
|
function openMega() {
|
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,50 @@ 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>;
|
|
154
|
+
/** @deprecated Block types alone, as before 0.8.1: judged by type, not by source (any
|
|
155
|
+
* video block counts as YouTube and Vimeo). Kept so a site's own older policy page
|
|
156
|
+
* still builds; pass `blocks`. */
|
|
157
|
+
blockTypes?: Iterable<string>;
|
|
96
158
|
/** True when the site has a Google Calendar configured (dedicated calendar page). */
|
|
97
159
|
calendarConfigured?: boolean;
|
|
98
160
|
}
|
|
@@ -102,21 +164,52 @@ export interface CookiePolicyModel {
|
|
|
102
164
|
additional: CookieUse[];
|
|
103
165
|
}
|
|
104
166
|
|
|
167
|
+
/** Every block that renders, walking container children (`section` / `grid` / `stack`).
|
|
168
|
+
* A block switched off (`enabled: false`) renders nothing, and neither do its children. */
|
|
169
|
+
function allBlocks(blocks: Iterable<BlockLike>, acc: BlockLike[] = []): BlockLike[] {
|
|
170
|
+
for (const b of blocks) {
|
|
171
|
+
if (!b || typeof b !== 'object' || typeof b.type !== 'string' || b.enabled === false) continue;
|
|
172
|
+
acc.push(b);
|
|
173
|
+
if (Array.isArray(b.blocks)) allBlocks(b.blocks as BlockLike[], acc);
|
|
174
|
+
}
|
|
175
|
+
return acc;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** How a provider was detected before 0.8.1, from block types alone. */
|
|
179
|
+
const BY_TYPE: Record<string, string[]> = {
|
|
180
|
+
'google-maps': ['locations', 'mapEmbed'],
|
|
181
|
+
'google-calendar': ['calendar'],
|
|
182
|
+
youtube: ['video'],
|
|
183
|
+
vimeo: ['video'],
|
|
184
|
+
};
|
|
185
|
+
|
|
105
186
|
/** Report the cookie uses actually active on a site, grouped by category. */
|
|
106
187
|
export function scanCookieUses(input: CookieScanInput): CookiePolicyModel {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
188
|
+
const necessary = NECESSARY.map((u) => ({ ...u }));
|
|
189
|
+
if (!input.blocks && input.blockTypes) {
|
|
190
|
+
const types = new Set(input.blockTypes);
|
|
191
|
+
const additional = ADDITIONAL.filter(
|
|
192
|
+
(u) => (BY_TYPE[u.id] ?? []).some((t) => types.has(t)) || (u.calendar === true && !!input.calendarConfigured),
|
|
193
|
+
).map(({ loads, calendar, ...use }) => use);
|
|
194
|
+
return { necessary, additional };
|
|
195
|
+
}
|
|
196
|
+
const blocks = allBlocks(input.blocks ?? []);
|
|
197
|
+
const additional: CookieUse[] = ADDITIONAL.filter(
|
|
198
|
+
(u) => (u.loads ? blocks.some(u.loads) : false) || (u.calendar === true && !!input.calendarConfigured),
|
|
199
|
+
).map(({ loads, calendar, ...use }) => use); // drop detection fields from the view model
|
|
200
|
+
// Maps from other providers, as the blocks name them, listed with the other maps.
|
|
201
|
+
const otherMaps = new Set(blocks.map(mapProviderOf).filter((p): p is string => p !== undefined && !isGoogleMaps(p)));
|
|
202
|
+
const afterMaps = additional.findIndex((u) => u.id !== 'google-maps');
|
|
203
|
+
additional.splice(afterMaps === -1 ? additional.length : afterMaps, 0, ...[...otherMaps].map(otherMapUse));
|
|
204
|
+
return { necessary, additional };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Every block `type` used in a list of blocks, walking container children
|
|
208
|
+
* (`section` / `grid` / `stack`), so the policy sees an embed nested in a grid. */
|
|
209
|
+
export function blockTypesIn(blocks: ReadonlyArray<{ type: string; blocks?: unknown }>, acc = new Set<string>()): Set<string> {
|
|
210
|
+
for (const b of blocks) {
|
|
211
|
+
acc.add(b.type);
|
|
212
|
+
if (Array.isArray(b.blocks)) blockTypesIn(b.blocks as Array<{ type: string; blocks?: unknown }>, acc);
|
|
213
|
+
}
|
|
214
|
+
return acc;
|
|
112
215
|
}
|
|
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
|
-
}
|
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
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
* `404.html`). Cloudflare Pages answers a missing address with it and a real 404 status;
|
|
30
30
|
* without one it serves the home page with a 200 (a "soft 404"). Skipped automatically
|
|
31
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.
|
|
32
36
|
* @param {boolean} [options.seo=true] Search signals (lib/seo.mjs): `/robots.txt`, and once
|
|
33
37
|
* `ferst-site.json` has a `siteUrl`, `/sitemap.xml` and a canonical address on every page.
|
|
34
38
|
* A test copy (any branch but the production one) is marked noindex on every file. A
|
|
@@ -38,6 +42,7 @@
|
|
|
38
42
|
import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
39
43
|
import { deploymentBranch } from './bin/cms-config.mjs';
|
|
40
44
|
import { seoContext, withNoindexHeader } from './lib/seo.mjs';
|
|
45
|
+
import { writeSiteIcons } from './lib/site-icon.mjs';
|
|
41
46
|
|
|
42
47
|
/** The self-hosted fonts the engine's CSS requests (/fonts/*.woff2), shipped in the package. */
|
|
43
48
|
const FONTS_DIR = new URL('./fonts/', import.meta.url);
|
|
@@ -91,14 +96,17 @@ function readSiteMeta(root) {
|
|
|
91
96
|
const hasPublicFile = (root, name) => Boolean(root) && existsSync(new URL(`public/${name}`, root));
|
|
92
97
|
|
|
93
98
|
export default function ferst(options = {}) {
|
|
94
|
-
const { calendar = true, blog = true, cookiePolicy = true, notFound = true, seo = true } = options;
|
|
99
|
+
const { calendar = true, blog = true, cookiePolicy = true, notFound = true, icons = true, seo = true } = options;
|
|
95
100
|
const fonts = ferstFonts().hooks;
|
|
96
101
|
/** This build's search context, decided at setup and used again when the build is done. */
|
|
97
102
|
let search = null;
|
|
103
|
+
/** The site's folder, for the content and public files the build-done steps read. */
|
|
104
|
+
let siteRoot = null;
|
|
98
105
|
return {
|
|
99
106
|
name: 'ferst',
|
|
100
107
|
hooks: {
|
|
101
108
|
'astro:config:setup': ({ injectRoute, updateConfig, config }) => {
|
|
109
|
+
siteRoot = config?.root ?? null;
|
|
102
110
|
// Pages + home: the reserved `index` entry renders at `/`, every other
|
|
103
111
|
// `pages` entry at its own slug (see lib/pages.ts `getSiteRoutes`).
|
|
104
112
|
injectRoute({
|
|
@@ -189,6 +197,11 @@ export default function ferst(options = {}) {
|
|
|
189
197
|
const file = new URL('_headers', ctx.dir);
|
|
190
198
|
writeFileSync(file, withNoindexHeader(existsSync(file) ? readFileSync(file, 'utf8') : ''));
|
|
191
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
|
+
}
|
|
192
205
|
},
|
|
193
206
|
},
|
|
194
207
|
};
|
package/layouts/Base.astro
CHANGED
|
@@ -107,6 +107,9 @@ const noindex = !indexable || import.meta.env.FERST_SEO_PRODUCTION === false;
|
|
|
107
107
|
{canonical && <link rel="canonical" href={canonical} />}
|
|
108
108
|
{noindex && <meta name="robots" content="noindex" />}
|
|
109
109
|
<title>{title} — {identity.siteTitle}</title>
|
|
110
|
+
<!-- The browser icon, written into every build by the ferst() integration (lib/site-icon). -->
|
|
111
|
+
<link rel="icon" href="/favicon.ico" sizes="32x32 48x48" />
|
|
112
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
110
113
|
{themeCss && <style is:inline set:html={themeCss} />}
|
|
111
114
|
<!-- Self-hosted (see src/styles/fonts.css) — h1 (Poppins 700) and body copy
|
|
112
115
|
(Inter 400) are what virtually every page needs at first paint. -->
|
package/lib/pages.ts
CHANGED
|
@@ -29,12 +29,21 @@ export async function getPageEntries() {
|
|
|
29
29
|
return all.filter((entry) => !entry.data.draft);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** The reserved entry a site may design as its own "page not found" page. */
|
|
33
|
+
export const NOT_FOUND_ID = '404';
|
|
34
|
+
|
|
35
|
+
/** The site's own "page not found" page, if it has designed one (a `404` entry). The
|
|
36
|
+
* injected /404 route shows it at every missing address; it is never a page of its own. */
|
|
37
|
+
export async function getNotFoundEntry() {
|
|
38
|
+
return (await getPageEntries()).find((entry) => entry.id === NOT_FOUND_ID);
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
/**
|
|
33
42
|
* A drop-in `getStaticPaths` for a client's `[...slug].astro`: one route per
|
|
34
43
|
* non-draft page, keyed by the file's slug, with the entry passed as a prop.
|
|
35
44
|
*/
|
|
36
45
|
export async function getPageRoutes() {
|
|
37
|
-
const entries = await getPageEntries();
|
|
46
|
+
const entries = (await getPageEntries()).filter((entry) => entry.id !== NOT_FOUND_ID);
|
|
38
47
|
return entries.map((entry) => ({
|
|
39
48
|
params: { slug: entry.id },
|
|
40
49
|
props: { entry },
|
|
@@ -58,7 +67,9 @@ export function pageRouteParams(id: string): { slug: string | undefined } {
|
|
|
58
67
|
* AND can pick its home layout in the builder like any other page.
|
|
59
68
|
*/
|
|
60
69
|
export async function getSiteRoutes() {
|
|
61
|
-
|
|
70
|
+
// The `404` entry is the site's "page not found" page, shown by the /404 route at every
|
|
71
|
+
// missing address; it must not also be a page at /404/ (or listed in the sitemap).
|
|
72
|
+
const entries = (await getPageEntries()).filter((entry) => entry.id !== NOT_FOUND_ID);
|
|
62
73
|
return entries.map((entry) => ({
|
|
63
74
|
params: pageRouteParams(entry.id),
|
|
64
75
|
props: { entry },
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's browser icon, written into every build by the ferst() integration:
|
|
3
|
+
* `/favicon.ico` (32 and 48 px, as PNGs inside the .ico, which every current browser
|
|
4
|
+
* reads) and `/apple-touch-icon.png` (180 px, opaque, for phones' home screens). Base links
|
|
5
|
+
* both. A migrated site's old `/favicon.ico` address keeps working this way too.
|
|
6
|
+
*
|
|
7
|
+
* Where the icon comes from, first match wins:
|
|
8
|
+
* 1. the browser icon set in the CMS (logoSettings `favicon`);
|
|
9
|
+
* 2. the site's header logo, when it's roughly square (a wide wordmark would shrink to an
|
|
10
|
+
* unreadable strip);
|
|
11
|
+
* 3. the site's initial, in white on its brand colour (or in ink on a light brand colour).
|
|
12
|
+
* A site's own `public/favicon.ico` or `public/apple-touch-icon.png` is never overwritten.
|
|
13
|
+
* Drawing needs `sharp` (installed with Astro). Without it the build goes on with a warning.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
|
|
17
|
+
/** A logo counts as square when its width over height is within these bounds. */
|
|
18
|
+
const SQUARE = [0.8, 1.25];
|
|
19
|
+
|
|
20
|
+
/** The first letter (or digit) of the site's name, upper case; '' when there's none. */
|
|
21
|
+
export function initialOf(name) {
|
|
22
|
+
const m = String(name ?? '').match(/[\p{L}\p{N}]/u);
|
|
23
|
+
return m ? m[0].toLocaleUpperCase('en-GB') : '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Relative luminance of a #rrggbb colour (0 dark … 1 light); null when it isn't one. */
|
|
27
|
+
function luminance(hex) {
|
|
28
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex ?? '').trim());
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
const [r, g, b] = [0, 2, 4].map((i) => {
|
|
31
|
+
const c = parseInt(m[1].slice(i, i + 2), 16) / 255;
|
|
32
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
33
|
+
});
|
|
34
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The initial on a rounded tile of the brand colour, as SVG. */
|
|
38
|
+
export function monogramSvg({ letter, background, ink = '#1e2433' }) {
|
|
39
|
+
const bg = luminance(background) === null ? '#1e2433' : background;
|
|
40
|
+
const fg = (luminance(bg) ?? 0) > 0.5 ? ink : '#ffffff';
|
|
41
|
+
return (
|
|
42
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 64 64">' +
|
|
43
|
+
`<rect width="64" height="64" rx="14" fill="${bg}"/>` +
|
|
44
|
+
(letter
|
|
45
|
+
? `<text x="32" y="44.5" text-anchor="middle" font-family="Poppins, Inter, Helvetica, Arial, sans-serif" font-weight="700" font-size="36" fill="${fg}">${letter}</text>`
|
|
46
|
+
: '') +
|
|
47
|
+
'</svg>'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An .ico file holding each image as a PNG (Windows Vista+ and every current browser). */
|
|
52
|
+
export function icoFromPngs(images) {
|
|
53
|
+
const header = Buffer.alloc(6);
|
|
54
|
+
header.writeUInt16LE(0, 0); // reserved
|
|
55
|
+
header.writeUInt16LE(1, 2); // 1 = icon
|
|
56
|
+
header.writeUInt16LE(images.length, 4);
|
|
57
|
+
let offset = 6 + 16 * images.length;
|
|
58
|
+
const entries = images.map(({ size, png }) => {
|
|
59
|
+
const e = Buffer.alloc(16);
|
|
60
|
+
e.writeUInt8(size >= 256 ? 0 : size, 0); // width (0 means 256)
|
|
61
|
+
e.writeUInt8(size >= 256 ? 0 : size, 1); // height
|
|
62
|
+
e.writeUInt16LE(1, 4); // colour planes
|
|
63
|
+
e.writeUInt16LE(32, 6); // bits per pixel
|
|
64
|
+
e.writeUInt32LE(png.length, 8);
|
|
65
|
+
e.writeUInt32LE(offset, 12);
|
|
66
|
+
offset += png.length;
|
|
67
|
+
return e;
|
|
68
|
+
});
|
|
69
|
+
return Buffer.concat([header, ...entries, ...images.map((i) => i.png)]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const readJson = (url) => {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(readFileSync(url, 'utf8'));
|
|
75
|
+
} catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** A site path such as `/uploads/crest.png` → its file in public/, if it's there. */
|
|
81
|
+
function publicFile(root, path) {
|
|
82
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.includes('..')) return null;
|
|
83
|
+
const file = new URL(`public${path}`, root);
|
|
84
|
+
return existsSync(file) ? file : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Write `favicon.ico` and `apple-touch-icon.png` into the build output. Never throws.
|
|
89
|
+
* @param {{ root: URL, outDir: URL, sharp: any, warn?: (message: string) => void }} p
|
|
90
|
+
* @returns {Promise<{ source: 'favicon' | 'logo' | 'initial' | null, wrote: string[] }>}
|
|
91
|
+
*/
|
|
92
|
+
export async function writeSiteIcons({ root, outDir, sharp, warn = (m) => console.warn(m) }) {
|
|
93
|
+
const wanted = ['favicon.ico', 'apple-touch-icon.png'].filter((name) => !existsSync(new URL(`public/${name}`, root)));
|
|
94
|
+
if (!wanted.length) return { source: null, wrote: [] };
|
|
95
|
+
if (!sharp) {
|
|
96
|
+
warn('[ferst] No browser icon written: the image library (sharp) is not installed.');
|
|
97
|
+
return { source: null, wrote: [] };
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const logos = readJson(new URL('src/content/logoSettings/index.json', root));
|
|
101
|
+
const settings = readJson(new URL('src/content/siteSettings/index.json', root));
|
|
102
|
+
const theme = readJson(new URL('src/content/themeSettings/index.json', root));
|
|
103
|
+
|
|
104
|
+
let source = 'initial';
|
|
105
|
+
let input = null;
|
|
106
|
+
const chosen = publicFile(root, logos.favicon);
|
|
107
|
+
if (chosen) {
|
|
108
|
+
source = 'favicon';
|
|
109
|
+
input = readFileSync(chosen);
|
|
110
|
+
} else {
|
|
111
|
+
const logo = publicFile(root, logos.primaryLogoLight);
|
|
112
|
+
if (logo) {
|
|
113
|
+
const { width, height } = await sharp(readFileSync(logo)).metadata();
|
|
114
|
+
if (width && height && width / height >= SQUARE[0] && width / height <= SQUARE[1]) {
|
|
115
|
+
source = 'logo';
|
|
116
|
+
input = readFileSync(logo);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!input) {
|
|
121
|
+
const name = settings.identity?.shortName || settings.identity?.siteTitle || '';
|
|
122
|
+
input = Buffer.from(monogramSvg({ letter: initialOf(name), background: theme.brand, ink: theme.ink }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const draw = (size) =>
|
|
126
|
+
sharp(input, { density: 384 })
|
|
127
|
+
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
128
|
+
.png();
|
|
129
|
+
const wrote = [];
|
|
130
|
+
if (wanted.includes('favicon.ico')) {
|
|
131
|
+
const images = await Promise.all([32, 48].map(async (size) => ({ size, png: await draw(size).toBuffer() })));
|
|
132
|
+
writeFileSync(new URL('favicon.ico', outDir), icoFromPngs(images));
|
|
133
|
+
wrote.push('favicon.ico');
|
|
134
|
+
}
|
|
135
|
+
if (wanted.includes('apple-touch-icon.png')) {
|
|
136
|
+
const ground = luminance(theme.surface) === null ? '#ffffff' : theme.surface;
|
|
137
|
+
writeFileSync(new URL('apple-touch-icon.png', outDir), await draw(180).flatten({ background: ground }).toBuffer());
|
|
138
|
+
wrote.push('apple-touch-icon.png');
|
|
139
|
+
}
|
|
140
|
+
return { source, wrote };
|
|
141
|
+
} catch (e) {
|
|
142
|
+
warn(`[ferst] No browser icon written: ${e?.message ?? e}`);
|
|
143
|
+
return { source: null, wrote: [] };
|
|
144
|
+
}
|
|
145
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
|
|
6
6
|
"license": "BUSL-1.1",
|
package/routes/404.astro
CHANGED
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
* a `404.html` it serves the HOME PAGE with a 200 instead (a "soft 404"): search engines
|
|
6
6
|
* would see the home page at every mistyped or retired address, and a broken link would
|
|
7
7
|
* look fine to visitors and to the migration's redirect check. A site with its own
|
|
8
|
-
* `src/pages/404.astro` keeps it (the integration skips this route then).
|
|
8
|
+
* `src/pages/404.astro` keeps it (the integration skips this route then). A site that has
|
|
9
|
+
* designed its "page not found" page as content (a `404` entry in `pages`) gets that page.
|
|
9
10
|
*/
|
|
10
11
|
import Base from '../layouts/Base.astro';
|
|
11
12
|
import BlockRenderer from '../components/BlockRenderer.astro';
|
|
12
13
|
import { pageSchema } from '../content/blocks';
|
|
14
|
+
import { getNotFoundEntry } from '../lib/pages';
|
|
15
|
+
|
|
16
|
+
const own = await getNotFoundEntry();
|
|
17
|
+
const ownPage = own ? pageSchema.parse(own.data) : null;
|
|
13
18
|
|
|
14
19
|
const blog = import.meta.env.FERST_ROUTES_BLOG !== false;
|
|
15
20
|
const page = pageSchema.parse({
|
|
@@ -31,6 +36,12 @@ const page = pageSchema.parse({
|
|
|
31
36
|
});
|
|
32
37
|
---
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
39
|
+
{ownPage ? (
|
|
40
|
+
<Base title={ownPage.title} description={ownPage.description ?? "This page isn't here."} align={ownPage.align} indexable={false}>
|
|
41
|
+
<BlockRenderer blocks={ownPage.blocks} />
|
|
42
|
+
</Base>
|
|
43
|
+
) : (
|
|
44
|
+
<Base title={page.title} description="This page isn't here." indexable={false}>
|
|
45
|
+
<BlockRenderer blocks={page.blocks} />
|
|
46
|
+
</Base>
|
|
47
|
+
)}
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* The cookie policy (`/cookie-policy`) — INJECTED by the ferst() integration, so the
|
|
4
|
-
* cookie banner's link always resolves. Generated from what this site actually
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* cookie banner's link always resolves. Generated from what this site actually loads:
|
|
5
|
+
* the blocks on its pages and their sources (a YouTube video is listed; a video file the
|
|
6
|
+
* site hosts is not; a map with no address loads nothing) and whether a calendar is
|
|
7
|
+
* configured. A site with its own `src/pages/cookie-policy.astro` keeps it
|
|
7
8
|
* (the integration skips this route then).
|
|
8
9
|
*/
|
|
9
10
|
import Base from '../layouts/Base.astro';
|
|
10
11
|
import CookiePolicy from '../components/CookiePolicy.astro';
|
|
11
12
|
import { getPageEntries } from '../lib/pages';
|
|
12
13
|
import { getCalendarSettings } from '../lib/calendar';
|
|
13
|
-
import {
|
|
14
|
+
import { scanCookieUses, type BlockLike } from '../content/cookies';
|
|
14
15
|
import { pageSchema } from '../content/blocks';
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
+
const blocks: BlockLike[] = [];
|
|
17
18
|
for (const entry of await getPageEntries()) {
|
|
18
19
|
const page = pageSchema.safeParse(entry.data);
|
|
19
|
-
if (page.success)
|
|
20
|
+
if (page.success) blocks.push(...(page.data.blocks as BlockLike[]));
|
|
20
21
|
}
|
|
21
22
|
const cal = await getCalendarSettings();
|
|
22
|
-
const model = scanCookieUses({
|
|
23
|
+
const model = scanCookieUses({ blocks, calendarConfigured: Boolean(cal.embedUrl) });
|
|
23
24
|
---
|
|
24
25
|
|
|
25
26
|
<Base title="Cookie Policy" description="What this site stores on your device, and the third-party content it can show.">
|
package/routes/sitemap.xml.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// and each tag page in use, and the calendar when it's set up. Drafts, hidden tags, later
|
|
4
4
|
// listing pages (/posts/page/2…) and the cookie policy stay out.
|
|
5
5
|
import type { APIRoute } from 'astro';
|
|
6
|
-
import {
|
|
6
|
+
import { getSiteRoutes } from '../lib/pages';
|
|
7
7
|
import { canonicalUrl, sitemapXml } from '../lib/seo.mjs';
|
|
8
8
|
|
|
9
9
|
export const prerender = true;
|
|
@@ -12,9 +12,9 @@ export const GET: APIRoute = async () => {
|
|
|
12
12
|
const site = String(import.meta.env.FERST_SITE_URL || '');
|
|
13
13
|
const entries: Array<{ loc: string; lastmod?: Date }> = [];
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
entries.push({ loc: canonicalUrl(site, slug ? `/${slug}` : '/') });
|
|
15
|
+
// Exactly the pages the site routes (so never its "page not found" page).
|
|
16
|
+
for (const { params } of await getSiteRoutes()) {
|
|
17
|
+
entries.push({ loc: canonicalUrl(site, params.slug ? `/${params.slug}` : '/') });
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if (import.meta.env.FERST_ROUTES_BLOG !== false) {
|