@koehler8/cms 1.4.1 → 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,42 @@
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
+
3
40
  ## 1.4.1
4
41
 
5
42
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.4.1",
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
+ }
@@ -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',