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>
123 lines
4.8 KiB
JavaScript
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(/&/g, "&").replace(/</g, "<").replace(/>/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/",
|
|
);
|