Files
joshuaandClaude Opus 5.5 54ae9c49a1 Tested: static Astro site answering hard Bible questions
Built from the kit specs (CLAUDE.md, docs/01-05): question index import,
status seals, connections graph, Pagefind search, Scripture margin,
and 157 evidence articles (drafts unless published) with sources and
verified "Learn more" links. Includes link, owner-notes, a11y and
publish scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:46:19 +02:00

123 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
// Renders the brand SVGs in brand/ into the icons and social card in public/.
// Run with: npm run icons (also runs as part of npm run build)
//
// sharp's SVG renderer only sees system fonts, and Literata / IBM Plex Sans aren't installed on most
// machines. So every <text> element is first converted to outlined paths using the real font files,
// which makes the PNGs look identical on any computer or build server.
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs";
import { createRequire } from "node:module";
import opentype from "opentype.js";
import sharp from "sharp";
const require = createRequire(import.meta.url);
const out = "public";
mkdirSync(out, { recursive: true });
const loadFont = (pkgFile) => {
const buf = readFileSync(require.resolve(pkgFile));
return opentype.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
};
const fonts = {
literata: loadFont("@fontsource/literata/files/literata-latin-600-normal.woff"),
plex: loadFont("@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-400-normal.woff"),
};
const attr = (tag, name) => tag.match(new RegExp(`\\s${name}="([^"]*)"`))?.[1];
// Serialize path commands ourselves: opentype.js 2.0's toPathData() occasionally emits "NaN"
// for some coordinates, which makes the SVG renderer stop drawing the rest of the text.
const n = (v) => Number(v.toFixed(2));
const pathData = (path) =>
path.commands
.map((c) => {
switch (c.type) {
case "M":
case "L":
return `${c.type}${n(c.x)} ${n(c.y)}`;
case "Q":
return `Q${n(c.x1)} ${n(c.y1)} ${n(c.x)} ${n(c.y)}`;
case "C":
return `C${n(c.x1)} ${n(c.y1)} ${n(c.x2)} ${n(c.y2)} ${n(c.x)} ${n(c.y)}`;
default:
return "Z";
}
})
.join("");
/** Replaces each <text> with a <path> outlined in the font its font-family names. */
function outlineText(svg) {
return svg.replace(/<text\b([^>]*)>([^<]*)<\/text>/g, (_, attrs, raw) => {
const family = attr(attrs, "font-family") ?? "";
const font = /literata/i.test(family) ? fonts.literata : fonts.plex;
const size = Number(attr(attrs, "font-size") ?? 16);
const text = raw.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
// SVG letter-spacing is in user units; opentype.js wants a fraction of the em.
const letterSpacing = Number(attr(attrs, "letter-spacing") ?? 0) / size;
const path = font.getPath(
text,
Number(attr(attrs, "x") ?? 0),
Number(attr(attrs, "y") ?? 0),
size,
{ kerning: true, letterSpacing },
);
const d = pathData(path);
if (d.includes("NaN")) throw new Error(`Could not outline "${raw}"`);
return `<path d="${d}" fill="${attr(attrs, "fill") ?? "#000"}"><title>${raw}</title></path>`;
});
}
const brand = (name) => readFileSync(`brand/${name}`, "utf8");
const png = (svg, size) =>
sharp(Buffer.from(svg), { density: 384 }).resize(size, size).png().toBuffer();
/** Packs PNG images into a .ico container (PNG-in-ICO, supported by every current browser). */
function toIco(images) {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2); // 1 = icon
header.writeUInt16LE(images.length, 4);
let offset = 6 + 16 * images.length;
const entries = images.map(({ size, data }) => {
const e = Buffer.alloc(16);
e.writeUInt8(size >= 256 ? 0 : size, 0);
e.writeUInt8(size >= 256 ? 0 : size, 1);
e.writeUInt16LE(1, 4); // colour planes
e.writeUInt16LE(32, 6); // bits per pixel
e.writeUInt32LE(data.length, 8);
e.writeUInt32LE(offset, 12);
offset += data.length;
return e;
});
return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]);
}
const favicon = brand("favicon.svg");
// iOS draws its own rounded corners and fills transparency with black, so give it a square tile.
const squareTile = favicon.replace(/\s+rx="[^"]*"/, "");
copyFileSync("brand/favicon.svg", `${out}/favicon.svg`);
writeFileSync(
`${out}/favicon.ico`,
toIco(
await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(favicon, size) }))),
),
);
writeFileSync(`${out}/apple-touch-icon.png`, await png(squareTile, 180));
writeFileSync(`${out}/icon-192.png`, await png(favicon, 192));
writeFileSync(`${out}/icon-512.png`, await png(favicon, 512));
const og = outlineText(brand("og-image.svg"));
writeFileSync(
`${out}/og-image.png`,
await sharp(Buffer.from(og)).resize(1200, 630).png().toBuffer(),
);
// An outlined copy of the full logo, safe to use anywhere the web fonts aren't loaded.
writeFileSync(`${out}/logo-full.svg`, outlineText(brand("logo-full.svg")));
console.log(
"Wrote favicon.svg, favicon.ico, apple-touch-icon.png, icon-192.png, icon-512.png, og-image.png and logo-full.svg to public/",
);