@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 +37 -0
- package/package.json +1 -1
- package/scripts/generate-public-assets.js +66 -12
- package/scripts/lib/ico.mjs +116 -0
- package/src/utils/webAppManifest.js +9 -3
- package/vite-plugin.js +5 -1
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,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 = [
|
|
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
|
-
|
|
425
|
-
fs.writeFileSync(
|
|
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
|
|
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
|
-
|
|
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',
|