@koehler8/cms 1.4.0 → 1.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.2
4
+
5
+ ### Fixed
6
+
7
+ - **`manifest.json` no longer names an icon that 404s.** Its primary icon has
8
+ always been `/favicon-256.png`, but `cms-generate-public-assets` never left
9
+ that file behind: on the letter-glyph path it wrote it only as a temporary
10
+ input to the `.ico` and deleted it, and when a site ships its own
11
+ `site/favicon.ico` it returned before writing it at all. Every site in the
12
+ fleet served a manifest whose 256px icon 404'd (found on site-chrismaus,
13
+ which worked around it with `manifest.icons` overrides).
14
+
15
+ `cms-generate-public-assets` now always tries to publish
16
+ `public/favicon-256.png`:
17
+
18
+ - **Letter-glyph favicon** — the 256px glyph it already renders is kept.
19
+ - **Site-provided `favicon.ico`** — its 256px frame, extracted as-is (PNG
20
+ frames) or decoded (32-bit BMP frames, which canvas can't read). Failing
21
+ that, a **square** `site/assets/img/logo.png` scaled to 256px. A
22
+ non-square logo is never squashed into the icon, and a smaller favicon
23
+ frame is never upscaled.
24
+ - **Neither available** — no icon is written (a stale copy is removed) and
25
+ the script says how to fix it.
26
+
27
+ And the plugin now lists a default icon in `manifest.json` only if the file
28
+ is actually in `public/` when the build starts, so the manifest can't drift
29
+ from what ships. Author-supplied `site.manifest.icons` are untouched.
30
+
31
+ **No action needed in a site** beyond picking up this release. Across the
32
+ fleet, sites without their own `favicon.ico` get the letter glyph; most of
33
+ the rest have a 256px favicon frame or a square logo. Sites whose favicon
34
+ stops at 64px *and* whose logo is a wordmark (e.g. site-buildmill,
35
+ site-trifi) now list `favicon.ico` alone — correct, but a 256px frame in
36
+ `site/favicon.ico` would give them a sharp home-screen icon. Sites that
37
+ added `manifest.icons` overrides to work around this (site-chrismaus) can
38
+ keep them or drop them.
39
+
40
+ ## 1.4.1
41
+
42
+ ### Fixed
43
+
44
+ - **The embedded config payload is now actually used on locale-prefixed routes.**
45
+ vite-ssg runs the setup fn *before* `app.use(router)`, so `router.currentRoute`
46
+ is still `START_LOCATION` (`'/'`, `params: {}`) there — identically on every
47
+ page. During SSG that was already handled, because vite-ssg hands the route
48
+ over on `ctx.routePath`. On the **client** it calls the setup fn with no
49
+ argument at all, so there was nothing to resolve and the code fell through to
50
+ `START_LOCATION`.
51
+
52
+ `loadSiteConfig` derives its cache key from `params.locale`, so the key came
53
+ out `'default'` on every client page while the SSG had stamped the real locale
54
+ on `siteConfigLocale`. On a locale-prefixed route the two never matched, so
55
+ `primeConfigSync` was skipped, `hydrateFromSyncCache` missed, and the client
56
+ discarded the payload it had just been served and awaited a full config load
57
+ **inside the setup fn, on the critical path, before mount** — the exact
58
+ regression the priming machinery exists to prevent.
59
+
60
+ Measured on a multi-locale site, same machine and build, only this patch
61
+ differing. First config chunk on a `/th/` piece page: **44 ms before**
62
+ (DOMContentLoaded 34 ms — on the critical path), **1364 ms after** (deferred
63
+ to `requestIdleCallback`, as designed). The load is *deferred, not removed*:
64
+ `trimConfigToPage` stamps `pagesPartial` on every page, so the idle reload
65
+ still runs.
66
+
67
+ ⚠️ A cross-environment comparison hides this completely. A production bundle
68
+ that needs ~1.4 s to download and execute shows its config chunks at ~1607 ms,
69
+ which *looks* deferred when it is in fact immediate. Only a same-environment
70
+ A/B separates the two.
71
+
72
+ **No action needed in a site.** Single-locale sites already stamped `default`
73
+ and primed correctly, so their behaviour is unchanged; multi-locale sites gain
74
+ the deferral.
75
+
3
76
  ## 1.4.0
4
77
 
5
78
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  // scripts/generate-public-assets.js (run at build time)
2
- // Syncs favicon.ico, logo.png, and og-image.jpg for the site.
2
+ // Syncs favicon.ico, favicon-256.png, logo.png, and og-image.jpg for the site.
3
3
  //
4
4
  // Reads from CMS_SITE_DIR env var (e.g. /path/to/my-site/site/).
5
5
  // Assets are read from {CMS_SITE_DIR}/assets/, config from
@@ -9,6 +9,7 @@
9
9
  import fs from 'fs';
10
10
  import path from 'path';
11
11
  import { fileURLToPath } from 'url';
12
+ import { extractIcoFrame } from './lib/ico.mjs';
12
13
 
13
14
  // dotenv is optional — without it, config comes from the process environment.
14
15
  try {
@@ -22,10 +23,11 @@ try {
22
23
  // command still installs cleanly. This command DOES need them — fail loudly
23
24
  // with the fix instead of a bare module-not-found stack.
24
25
  let createCanvas;
26
+ let loadImage;
25
27
  let registerFont;
26
28
  let pngToIco;
27
29
  try {
28
- ({ createCanvas, registerFont } = await import('canvas'));
30
+ ({ createCanvas, loadImage, registerFont } = await import('canvas'));
29
31
  ({ default: pngToIco } = await import('png-to-ico'));
30
32
  } catch (err) {
31
33
  console.error('❌ cms-generate-public-assets requires the optional dependencies `canvas` and `png-to-ico`.');
@@ -53,6 +55,10 @@ if (!CMS_SITE_DIR || !fs.existsSync(CMS_SITE_DIR)) {
53
55
 
54
56
  const PUBLIC_DIR = path.join(path.dirname(CMS_SITE_DIR), 'public');
55
57
 
58
+ // Must match the primary icon in src/utils/webAppManifest.js.
59
+ const MANIFEST_ICON_SIZE = 256;
60
+ const MANIFEST_ICON_FILE = `favicon-${MANIFEST_ICON_SIZE}.png`;
61
+
56
62
  function resolveSiteAssetFile(relativePaths = []) {
57
63
  for (const rel of relativePaths) {
58
64
  if (!rel) continue;
@@ -339,6 +345,57 @@ function loadSiteTitle() {
339
345
  return '';
340
346
  }
341
347
 
348
+ // The Web App Manifest names /favicon-256.png as its primary icon
349
+ // (src/utils/webAppManifest.js). When the site ships its own favicon, build
350
+ // that file from the site's own art: the favicon's 256px frame first (the
351
+ // designed icon, no resampling), else a square logo scaled to fit. A
352
+ // wordmark is never squashed into a square and a small favicon is never
353
+ // upscaled. With neither, any stale copy is removed — the plugin lists only
354
+ // icons that exist in public/, so the manifest falls back to favicon.ico.
355
+ async function writeManifestIconFromSiteArt({ icoBuffer, logoSource, iconPath }) {
356
+ const frame = extractIcoFrame(icoBuffer, MANIFEST_ICON_SIZE);
357
+ if (frame?.format === 'png') {
358
+ fs.writeFileSync(iconPath, frame.data);
359
+ console.log(`✅ Wrote ${iconPath} from the favicon's ${MANIFEST_ICON_SIZE}px frame`);
360
+ return;
361
+ }
362
+ if (frame?.format === 'rgba') {
363
+ const canvas = createCanvas(frame.width, frame.height);
364
+ const ctx = canvas.getContext('2d');
365
+ const imageData = ctx.createImageData(frame.width, frame.height);
366
+ imageData.data.set(frame.rgba);
367
+ ctx.putImageData(imageData, 0, 0);
368
+ fs.writeFileSync(iconPath, canvas.toBuffer('image/png'));
369
+ console.log(`✅ Wrote ${iconPath} from the favicon's ${MANIFEST_ICON_SIZE}px frame`);
370
+ return;
371
+ }
372
+
373
+ if (logoSource) {
374
+ try {
375
+ const logo = await loadImage(logoSource);
376
+ if (logo.width === logo.height) {
377
+ const canvas = createCanvas(MANIFEST_ICON_SIZE, MANIFEST_ICON_SIZE);
378
+ const ctx = canvas.getContext('2d');
379
+ ctx.quality = 'best';
380
+ ctx.drawImage(logo, 0, 0, MANIFEST_ICON_SIZE, MANIFEST_ICON_SIZE);
381
+ fs.writeFileSync(iconPath, canvas.toBuffer('image/png'));
382
+ console.log(`✅ Wrote ${iconPath} from the ${logo.width}px square logo`);
383
+ return;
384
+ }
385
+ console.log(` Logo is ${logo.width}x${logo.height}, not square — not used as the manifest icon`);
386
+ } catch (err) {
387
+ console.warn(`⚠️ Could not read logo ${logoSource} for the manifest icon`, err);
388
+ }
389
+ }
390
+
391
+ fs.rmSync(iconPath, { force: true });
392
+ console.warn(
393
+ `⚠️ No ${MANIFEST_ICON_SIZE}px manifest icon: favicon.ico has no ${MANIFEST_ICON_SIZE}px frame and there is no square logo.`
394
+ + ` manifest.json will list favicon.ico only. Add a ${MANIFEST_ICON_SIZE}px frame to site/favicon.ico`
395
+ + ' or a square site/assets/img/logo.png to give installs a sharp icon.',
396
+ );
397
+ }
398
+
342
399
  async function generateFavicon() {
343
400
  const siteTitle = loadSiteTitle() || 'X';
344
401
  const letter = siteTitle.charAt(0).toUpperCase();
@@ -409,34 +466,31 @@ async function generateFavicon() {
409
466
  }
410
467
  }
411
468
 
469
+ const iconPath = path.join(publicDir, MANIFEST_ICON_FILE);
470
+
412
471
  const providedFavicon = resolveSiteAssetFile(['favicon.ico']);
413
472
  if (providedFavicon) {
414
473
  fs.copyFileSync(providedFavicon, icoPath);
415
474
  console.log(`✅ Found existing favicon; copied from ${providedFavicon}`);
475
+ await writeManifestIconFromSiteArt({ icoBuffer: fs.readFileSync(providedFavicon), logoSource, iconPath });
416
476
  return;
417
477
  }
418
478
 
419
479
  console.log(`Generating favicon.ico using letter "${letter}" from site "${siteTitle}"`);
420
480
 
421
- const sizes = [256, 128, 64, 48, 32, 16];
481
+ const sizes = [MANIFEST_ICON_SIZE, 128, 64, 48, 32, 16];
422
482
  const pngBuffers = sizes.map((s) => renderLetterPng(letter, s, { bg, fg }));
423
483
 
424
- const pngPath = path.join(publicDir, 'favicon-256.png');
425
- fs.writeFileSync(pngPath, pngBuffers[0]);
484
+ // The 256px glyph doubles as the manifest's primary icon, so it stays.
485
+ fs.writeFileSync(iconPath, pngBuffers[0]);
426
486
 
427
487
  const ico = await pngToIco(pngBuffers);
428
488
  fs.writeFileSync(icoPath, ico);
429
489
 
430
- try {
431
- fs.unlinkSync(pngPath);
432
- console.log(`🗑️ Removed temporary file ${pngPath}`);
433
- } catch (err) {
434
- console.warn(`⚠️ Could not remove temporary PNG: ${pngPath}`, err);
435
- }
436
-
437
490
  console.log(`✅ Created favicon.ico using letter "${letter}" from site "${siteTitle}"`);
438
491
  console.log(` Colors bg=${bg} fg=${fg}`);
439
492
  console.log(` Wrote: ${icoPath}`);
493
+ console.log(` Wrote: ${iconPath}`);
440
494
  }
441
495
 
442
496
  generateFavicon().catch((err) => {
@@ -0,0 +1,116 @@
1
+ // Minimal .ico reader for cms-generate-public-assets.
2
+ //
3
+ // The script needs one thing from a site-provided favicon.ico: its 256px
4
+ // frame, to publish as the Web App Manifest's /favicon-256.png. canvas can't
5
+ // load .ico at all, so this lists the frames and decodes the 32-bit BMP ones
6
+ // (what most encoders write); PNG-embedded frames are returned as-is.
7
+
8
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
9
+
10
+ function isPng(data) {
11
+ return data.length >= 24 && data.subarray(0, 8).equals(PNG_SIGNATURE);
12
+ }
13
+
14
+ /**
15
+ * List a .ico's frames. Dimensions come from the frame itself (PNG IHDR or
16
+ * BMP header), not the directory entry, whose single byte can't say 256+.
17
+ * Returns [] for anything that isn't a well-formed icon file.
18
+ */
19
+ export function readIcoFrames(buf) {
20
+ if (!Buffer.isBuffer(buf) || buf.length < 6) return [];
21
+ if (buf.readUInt16LE(0) !== 0 || buf.readUInt16LE(2) !== 1) return [];
22
+ const count = buf.readUInt16LE(4);
23
+ const frames = [];
24
+ for (let i = 0; i < count; i++) {
25
+ const entry = 6 + 16 * i;
26
+ if (entry + 16 > buf.length) break;
27
+ const size = buf.readUInt32LE(entry + 8);
28
+ const offset = buf.readUInt32LE(entry + 12);
29
+ if (!size || offset + size > buf.length) continue;
30
+ const data = buf.subarray(offset, offset + size);
31
+ if (isPng(data)) {
32
+ frames.push({ format: 'png', width: data.readUInt32BE(16), height: data.readUInt32BE(20), data });
33
+ } else if (data.length >= 40) {
34
+ // BMP frames store the XOR image and the AND mask stacked, so the
35
+ // header height is double the icon's.
36
+ frames.push({
37
+ format: 'bmp',
38
+ width: data.readInt32LE(4),
39
+ height: Math.abs(data.readInt32LE(8)) / 2,
40
+ data,
41
+ });
42
+ }
43
+ }
44
+ return frames;
45
+ }
46
+
47
+ /**
48
+ * Decode a 32bpp uncompressed BMP icon frame to straight RGBA. Returns null
49
+ * for any other depth or compression — those are rare enough in practice
50
+ * that the caller falls back to another source rather than decoding them.
51
+ */
52
+ export function decodeIcoBmp(data) {
53
+ if (!data || data.length < 40) return null;
54
+ const headerSize = data.readUInt32LE(0);
55
+ const width = data.readInt32LE(4);
56
+ const rawHeight = data.readInt32LE(8);
57
+ const bpp = data.readUInt16LE(14);
58
+ const compression = data.readUInt32LE(16);
59
+ if (bpp !== 32 || compression !== 0 || width <= 0 || rawHeight === 0) return null;
60
+
61
+ const height = Math.abs(rawHeight) / 2;
62
+ const bottomUp = rawHeight > 0;
63
+ const xorStride = width * 4;
64
+ const andStride = Math.ceil(width / 32) * 4;
65
+ const andStart = headerSize + xorStride * height;
66
+ if (!Number.isInteger(height) || andStart > data.length) return null;
67
+ const hasAndMask = andStart + andStride * height <= data.length;
68
+
69
+ const rgba = new Uint8ClampedArray(width * height * 4);
70
+ let usesAlpha = false;
71
+ for (let y = 0; y < height; y++) {
72
+ const row = bottomUp ? height - 1 - y : y;
73
+ for (let x = 0; x < width; x++) {
74
+ const s = headerSize + row * xorStride + x * 4;
75
+ const d = (y * width + x) * 4;
76
+ rgba[d] = data[s + 2];
77
+ rgba[d + 1] = data[s + 1];
78
+ rgba[d + 2] = data[s];
79
+ rgba[d + 3] = data[s + 3];
80
+ if (data[s + 3]) usesAlpha = true;
81
+ }
82
+ }
83
+
84
+ // Older encoders leave the alpha byte at 0 and carry transparency in the
85
+ // 1-bit AND mask alone; reading the zeros literally would blank the icon.
86
+ if (!usesAlpha) {
87
+ for (let y = 0; y < height; y++) {
88
+ const row = bottomUp ? height - 1 - y : y;
89
+ for (let x = 0; x < width; x++) {
90
+ const masked = hasAndMask
91
+ && (data[andStart + row * andStride + (x >> 3)] >> (7 - (x & 7))) & 1;
92
+ rgba[(y * width + x) * 4 + 3] = masked ? 0 : 255;
93
+ }
94
+ }
95
+ }
96
+
97
+ return { width, height, rgba };
98
+ }
99
+
100
+ /**
101
+ * The icon's square frame at exactly `size` px, ready to write:
102
+ * { format: 'png', data } — a PNG frame, byte-for-byte
103
+ * { format: 'rgba', width, height, rgba } — a decoded BMP frame
104
+ * or null when the icon has no usable frame at that size (upscaling a
105
+ * 64px favicon would only publish a blurry icon under a 256px label).
106
+ */
107
+ export function extractIcoFrame(buf, size) {
108
+ const frames = readIcoFrames(buf).filter((f) => f.width === size && f.height === size);
109
+ const png = frames.find((f) => f.format === 'png');
110
+ if (png) return { format: 'png', data: png.data };
111
+ for (const frame of frames) {
112
+ const decoded = decodeIcoBmp(frame.data);
113
+ if (decoded) return { format: 'rgba', ...decoded };
114
+ }
115
+ return null;
116
+ }
package/src/main.js CHANGED
@@ -8,6 +8,7 @@ import { routes, resolveHistory, applyRouterGuards } from './router/index.js';
8
8
  import { shouldEnableAnalytics, scheduleAnalyticsLoad, configureAnalyticsConsentMode } from './utils/cookieConsent.js';
9
9
  import { loadConfigData, primeConfigSync, trimConfigToPage, resolvePageIdForRoute } from './utils/loadConfig.js';
10
10
  import { applyInitialStateTrims } from './utils/initialStateTrim.js';
11
+ import { currentRouteHref } from './utils/currentRoute.js';
11
12
  import { persistAttributionFromLocation } from './utils/trackingContext.js';
12
13
  import { applyThemeVariables } from './themes/themeManager.js';
13
14
  import { setActiveThemeKey } from './utils/themeColors.js';
@@ -136,19 +137,22 @@ export function createCmsApp() {
136
137
  };
137
138
 
138
139
  const extractCurrentRoute = () => {
139
- // During SSG, vite-ssg passes the route being pre-rendered as a string
140
- // on `ctx.routePath` and only pushes the router AFTER this setup fn runs
141
- // — so `router.currentRoute` is still vue-router's START_LOCATION ('/')
142
- // here, identically on every page. Resolve `routePath` through the router
143
- // (a pure match, no navigation) to recover the real path + `params.locale`.
144
- // Without this, `resolveCurrentPageId` sees '/' for every route, so the
145
- // payload trim targets the wrong page — or, when '/' is ambiguous, none.
146
- const routePath = ctx?.routePath;
147
- if (typeof routePath === 'string' && routePath) {
140
+ // vite-ssg runs this setup fn BEFORE `app.use(router)`, so
141
+ // `router.currentRoute` is still vue-router's START_LOCATION ('/') here,
142
+ // identically on every page — during SSG *and* on the client. The href
143
+ // that IS authoritative differs per half (ctx.routePath vs the window's
144
+ // own location); currentRouteHref picks it, with the full account of why
145
+ // the client half is not optional. Resolve it through the router — a pure
146
+ // match, no navigation — to recover the real path + `params.locale`.
147
+ const href = currentRouteHref(
148
+ ctx,
149
+ typeof window !== 'undefined' ? window.location : null,
150
+ );
151
+ if (href) {
148
152
  try {
149
- return router.resolve(routePath);
153
+ return router.resolve(href);
150
154
  } catch {
151
- return { path: routePath, params: {} };
155
+ return { path: href, params: {} };
152
156
  }
153
157
  }
154
158
  if (ctx?.route) {
@@ -0,0 +1,49 @@
1
+ // Which href the app should resolve through the router to learn the route it is
2
+ // actually on.
3
+ //
4
+ // Both halves of the lifecycle are blind to this in the same way, for the same
5
+ // reason: vite-ssg runs the setup fn BEFORE `app.use(router)`, so
6
+ // `router.currentRoute` is still vue-router's START_LOCATION ('/', `params: {}`)
7
+ // — identically on every page.
8
+ //
9
+ // - During SSG, vite-ssg hands the route being pre-rendered over as a string on
10
+ // `ctx.routePath`, so that is authoritative.
11
+ // - On the CLIENT it calls the setup fn with no argument at all: no
12
+ // `ctx.routePath`, no `ctx.route`. The only authoritative source left is the
13
+ // window's own location.
14
+ //
15
+ // Falling through to START_LOCATION on the client is not a cosmetic miss. The
16
+ // config cache key is derived from `params.locale`, so it came out 'default' on
17
+ // every page while the SSG had stamped the real locale on `siteConfigLocale`.
18
+ // The two never matched on a locale-prefixed route, `primeConfigSync` was
19
+ // skipped, and the client discarded the embedded payload and re-fetched the
20
+ // whole config tree on the critical path — the exact regression the priming
21
+ // machinery exists to prevent. Measured on sparkbang.com/th/<piece>/ before the
22
+ // fix: 151 JS requests / 405 KB, 112 of them per-piece config chunks, while the
23
+ // 17 KB embedded payload was parsed and thrown away.
24
+ //
25
+ // Pure and dependency-free so the fall-through can be pinned by a unit test;
26
+ // `main.js` owns the router.resolve() call itself (a pure match, no navigation).
27
+ //
28
+ // Returns null when the caller should use `ctx.route` as-is, or when there is
29
+ // nothing authoritative to resolve and START_LOCATION is genuinely all there is.
30
+
31
+ /**
32
+ * @param {{routePath?: unknown, route?: unknown}|null|undefined} ctx
33
+ * The vite-ssg setup context. `routePath` is set during SSG only.
34
+ * @param {{pathname?: unknown, search?: unknown}|null|undefined} location
35
+ * `window.location` on the client; omit/null on the server.
36
+ * @returns {string|null} the href to resolve, or null to fall back.
37
+ */
38
+ export function currentRouteHref(ctx, location) {
39
+ const routePath = ctx?.routePath;
40
+ if (typeof routePath === 'string' && routePath) return routePath;
41
+ // A context that already carries a resolved route needs no href at all.
42
+ if (ctx?.route) return null;
43
+ const pathname = location?.pathname;
44
+ if (typeof pathname === 'string' && pathname) {
45
+ const search = typeof location?.search === 'string' ? location.search : '';
46
+ return `${pathname}${search}`;
47
+ }
48
+ return null;
49
+ }
@@ -18,7 +18,11 @@
18
18
  * "manifest.icons[0].type": "image/png"
19
19
  *
20
20
  * Defaults are derived from existing site config and the icons that
21
- * cms-generate-public-assets writes (favicon-256.png + favicon.ico).
21
+ * cms-generate-public-assets writes (favicon-256.png + favicon.ico). Pass
22
+ * `iconExists` to drop default icons whose file isn't there: the script
23
+ * can't always produce favicon-256.png (a site-provided favicon with no
24
+ * 256px frame and no square logo), and a listed icon that 404s is worse
25
+ * than an unlisted one. Author-supplied `manifest.icons` are never filtered.
22
26
  */
23
27
 
24
28
  const DEFAULT_DISPLAY = 'standalone';
@@ -45,7 +49,7 @@ function defaultIcons() {
45
49
  ];
46
50
  }
47
51
 
48
- export function buildWebAppManifest(siteConfig) {
52
+ export function buildWebAppManifest(siteConfig, { iconExists } = {}) {
49
53
  const site = (siteConfig && siteConfig.site) || {};
50
54
  const m = (site.manifest && typeof site.manifest === 'object') ? site.manifest : {};
51
55
 
@@ -66,7 +70,9 @@ export function buildWebAppManifest(siteConfig) {
66
70
  .filter((icon) => icon && typeof icon === 'object' && typeof icon.src === 'string')
67
71
  .map((icon) => ({ ...icon }))
68
72
  : null;
69
- const icons = customIcons && customIcons.length ? customIcons : defaultIcons();
73
+ const icons = customIcons && customIcons.length
74
+ ? customIcons
75
+ : defaultIcons().filter((icon) => typeof iconExists !== 'function' || iconExists(icon.src));
70
76
 
71
77
  const manifest = {
72
78
  name,
package/vite-plugin.js CHANGED
@@ -584,7 +584,11 @@ export default function cmsPlugin(options = {}) {
584
584
  // Web App Manifest. The template emits <link rel="manifest"> on every
585
585
  // page; browsers use this to enable Add-to-Home-Screen + control
586
586
  // splash colors when the site is launched from a homescreen icon.
587
- const manifest = buildWebAppManifest(siteConfig);
587
+ // Default icons are listed only if generate-public-assets (which runs
588
+ // before the build) actually put them in public/.
589
+ const manifest = buildWebAppManifest(siteConfig, {
590
+ iconExists: (src) => fs.existsSync(path.join(sitePublicDir, src.replace(/^\/+/, ''))),
591
+ });
588
592
  writeFileIfChanged(
589
593
  path.join(sitePublicDir, 'manifest.json'),
590
594
  JSON.stringify(manifest, null, 2) + '\n',