ferst-core 0.8.0 → 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.
@@ -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
- syncHeaderHeight();
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() {
@@ -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
@@ -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
  };
@@ -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. -->
@@ -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.0",
3
+ "version": "0.8.1",
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",
@@ -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 uses:
5
- * every block type on its pages (third-party embeds such as maps or video) and whether a
6
- * calendar is configured. A site with its own `src/pages/cookie-policy.astro` keeps it
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 { blockTypesIn, scanCookieUses } from '../content/cookies';
14
+ import { scanCookieUses, type BlockLike } from '../content/cookies';
14
15
  import { pageSchema } from '../content/blocks';
15
16
 
16
- const blockTypes = new Set<string>();
17
+ const blocks: BlockLike[] = [];
17
18
  for (const entry of await getPageEntries()) {
18
19
  const page = pageSchema.safeParse(entry.data);
19
- if (page.success) blockTypesIn(page.data.blocks as Array<{ type: string; blocks?: unknown }>, blockTypes);
20
+ if (page.success) blocks.push(...(page.data.blocks as BlockLike[]));
20
21
  }
21
22
  const cal = await getCalendarSettings();
22
- const model = scanCookieUses({ blockTypes, calendarConfigured: Boolean(cal.embedUrl) });
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.">