Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 7982d433a32c69a43548d4a9ac8f6d0a98c45200
parent c0e6bbbabb2c835d10460b6f93ed333ac16a2a3d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 21:26:55 -0400

common: brandIcons — every icon file rendered from the mark at build (next/og PNGs, a PNG-payload ICO) (brand S1)

ICON_FILES names the seven /icons/ URLs; renderIconPng puts markSvg into
ImageResponse as an <img> data URI, so the raster is the SVG's geometry;
pngToIco packs 16/32/48 for /favicon.ico; siteIconPalette lights a child
site's icon with its accent's on-dark value; ICON_METADATA is the one <head>
link list both layouts use. next/og runs under tsx, so the tests check the
PNG IHDR as well as the ICO directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/lib/brandIcons.test.ts | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/brandIcons.ts | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 308 insertions(+), 0 deletions(-)

diff --git a/common/lib/brandIcons.test.ts b/common/lib/brandIcons.test.ts @@ -0,0 +1,148 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ACCENTS, ICON_PALETTES, markSvg } from "./brand"; +import { resolveAccent } from "./accent"; +import { + FAVICON_SIZES, + ICON_FILES, + ICON_METADATA, + iconFile, + pngToIco, + renderFaviconIco, + renderIconFile, + renderIconPng, + siteIconPalette, +} from "./brandIcons"; + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +// A PNG's first chunk is IHDR: length(4) "IHDR"(4) width(4) height(4), big-endian. +function ihdr(png: Uint8Array): { width: number; height: number } { + assert.deepEqual([...png.subarray(0, 8)], PNG_MAGIC, "PNG magic"); + assert.equal(Buffer.from(png.subarray(12, 16)).toString("latin1"), "IHDR"); + const v = new DataView(png.buffer, png.byteOffset, png.byteLength); + return { width: v.getUint32(16), height: v.getUint32(20) }; +} + +test("ICON_FILES: the seven URLs the manifest, <head>, OG image and PWAs name", () => { + assert.deepEqual( + ICON_FILES.map((f) => f.file), + [ + "icon.svg", + "maskable.svg", + "icon-32.png", + "icon-192.png", + "icon-512.png", + "maskable-512.png", + "apple-touch-icon.png", + ], + ); + const sizes = Object.fromEntries( + ICON_FILES.flatMap((f) => (f.format === "png" ? [[f.file, f.size]] : [])), + ); + assert.deepEqual(sizes, { + "icon-32.png": 32, + "icon-192.png": 192, + "icon-512.png": 512, + "maskable-512.png": 512, + "apple-touch-icon.png": 180, + }); + assert.equal(iconFile("maskable-512.png")?.variant, "maskable"); + assert.equal(iconFile("apple-touch-icon.png")?.variant, "apple"); + assert.equal(iconFile("favicon.ico"), undefined); +}); + +test("ICON_METADATA links only files the routes serve, SVG first", () => { + const served = new Set(ICON_FILES.map((f) => `/icons/${f.file}`)); + const links = [...ICON_METADATA.icon, ...ICON_METADATA.apple]; + for (const l of links) assert.ok(served.has(l.url), l.url); + assert.deepEqual(ICON_METADATA.icon[0], { url: "/icons/icon.svg", type: "image/svg+xml" }); + for (const l of ICON_METADATA.icon.slice(1)) { + const f = iconFile(l.url.slice("/icons/".length)); + assert.ok(f && f.format === "png"); + assert.equal(l.sizes, `${f.size}x${f.size}`); + } +}); + +test("siteIconPalette: the child ground, lit by the accent's on-dark value", () => { + assert.deepEqual(siteIconPalette("brass"), { + ground: ICON_PALETTES.child.ground, + dim: ICON_PALETTES.child.dim, + lit: ACCENTS.brass.onDark, + }); + // Absent or malformed reads as Signal. + assert.equal(siteIconPalette(undefined).lit, ACCENTS.signal.onDark); + assert.equal(siteIconPalette("not-a-colour").lit, ACCENTS.signal.onDark); + // A custom hex is lit with its dark-ground fit, the value the header uses. + assert.equal(siteIconPalette("#cc3366").lit, resolveAccent("#cc3366").dark); + assert.equal(siteIconPalette("#cc3366").lit, "#d14574"); +}); + +test("pngToIco: ICONDIR, one ICONDIRENTRY per image, then the payloads verbatim", () => { + const a = Uint8Array.from([1, 2, 3]); + const b = Uint8Array.from([4, 5, 6, 7, 8]); + const ico = pngToIco([ + { size: 16, png: a }, + { size: 256, png: b }, + ]); + const v = new DataView(ico.buffer, ico.byteOffset, ico.byteLength); + // 00 00 01 00: reserved, type 1 (icon); then the count. + assert.deepEqual([...ico.subarray(0, 4)], [0, 0, 1, 0]); + assert.equal(v.getUint16(4, true), 2); + assert.equal(ico.byteLength, 6 + 16 * 2 + a.byteLength + b.byteLength); + // Entry 1: 16×16, 1 plane, 32 bpp, 3 bytes at offset 38. + assert.deepEqual([ico[6], ico[7], ico[8], ico[9]], [16, 16, 0, 0]); + assert.equal(v.getUint16(10, true), 1); + assert.equal(v.getUint16(12, true), 32); + assert.equal(v.getUint32(14, true), 3); + assert.equal(v.getUint32(18, true), 38); + // Entry 2: 256 is written as 0; its payload follows the first. + assert.deepEqual([ico[22], ico[23]], [0, 0]); + assert.equal(v.getUint32(30, true), 5); + assert.equal(v.getUint32(34, true), 41); + assert.deepEqual([...ico.subarray(38, 41)], [...a]); + assert.deepEqual([...ico.subarray(41)], [...b]); + assert.throws(() => pngToIco([{ size: 0, png: a }]), /1\.\.256/); + assert.throws(() => pngToIco([{ size: 257, png: a }]), /1\.\.256/); +}); + +test("renderIconPng: a PNG of the requested size, per variant", async () => { + for (const [variant, size] of [ + ["any", 32], + ["any", 192], + ["maskable", 512], + ["apple", 180], + ] as const) { + const png = await renderIconPng(ICON_PALETTES.archilyzer, { variant, size }); + assert.deepEqual(ihdr(png), { width: size, height: size }, `${variant} ${size}`); + } +}); + +test("renderFaviconIco: 16/32/48 PNG entries whose IHDR matches the directory", async () => { + const ico = await renderFaviconIco(siteIconPalette("violet")); + const v = new DataView(ico.buffer, ico.byteOffset, ico.byteLength); + assert.deepEqual([...ico.subarray(0, 4)], [0, 0, 1, 0]); + assert.equal(v.getUint16(4, true), FAVICON_SIZES.length); + FAVICON_SIZES.forEach((size, i) => { + const e = 6 + 16 * i; + assert.equal(ico[e], size); + assert.equal(ico[e + 1], size); + const len = v.getUint32(e + 8, true); + const off = v.getUint32(e + 12, true); + assert.deepEqual(ihdr(ico.subarray(off, off + len)), { width: size, height: size }); + }); +}); + +test("renderIconFile: SVGs are markSvg verbatim, PNGs are PNGs, anything else is null", async () => { + const palette = siteIconPalette("green"); + const svg = await renderIconFile("icon.svg", palette); + assert.deepEqual(svg, { body: markSvg(palette), contentType: "image/svg+xml" }); + const maskable = await renderIconFile("maskable.svg", palette); + assert.equal(maskable?.body, markSvg(palette, { variant: "maskable" })); + const apple = await renderIconFile("apple-touch-icon.png", palette); + assert.equal(apple?.contentType, "image/png"); + assert.ok(apple && typeof apple.body !== "string"); + assert.deepEqual(ihdr(apple.body as Uint8Array), { width: 180, height: 180 }); + assert.equal(await renderIconFile("icon-64.png", palette), null); + assert.equal(await renderIconFile("../site.json", palette), null); +}); diff --git a/common/lib/brandIcons.ts b/common/lib/brandIcons.ts @@ -0,0 +1,160 @@ +// THE ICON FILES — every favicon, app icon and touch icon the family ships, +// rendered from the one mark (lib/brand.ts markSvg) at build time instead of +// committed as binaries. A site's icons are lit with its own accent, so they +// cannot be committed once for everyone. +// +// Served by a static GET route handler in each app (export/app/icons/[file], +// homepage/app/icons/[file], and a favicon.ico route beside them). Under +// `output: "export"` Next copies such a handler's body byte-for-byte to its +// exact path (next/dist/export/index.js, the `.body` branch), so +// `out/icons/icon-192.png` is a real PNG at a stable URL — `app/icon.tsx` +// cannot do this: its URLs are always hashed. +// +// SERVER-ONLY: `next/og` (satori + resvg, bundled with Next) renders the PNGs +// on the node runtime. The SVG goes in as an <img> data URI, so the raster is +// exactly markSvg's geometry — no second drawing of the mark to drift. + +import { createElement } from "react"; +import { ImageResponse } from "next/og"; +import { + childIconPalette, + markSvg, + type IconPalette, + type MarkVariant, +} from "./brand"; +import { resolveAccent } from "./accent"; + +export type IconFile = + | { file: string; format: "svg"; variant: MarkVariant; contentType: "image/svg+xml" } + | { file: string; format: "png"; variant: MarkVariant; size: number; contentType: "image/png" }; + +// Every file under /icons/, in the order the manifest and <head> want them. +// The URLs are a contract: the manifest, both layouts' `icons` metadata, the +// service workers' `/icons/` cache-first rule, the homepage's OG image and +// installed PWAs all name them. +export const ICON_FILES: ReadonlyArray<IconFile> = [ + { file: "icon.svg", format: "svg", variant: "any", contentType: "image/svg+xml" }, + { file: "maskable.svg", format: "svg", variant: "maskable", contentType: "image/svg+xml" }, + { file: "icon-32.png", format: "png", variant: "any", size: 32, contentType: "image/png" }, + { file: "icon-192.png", format: "png", variant: "any", size: 192, contentType: "image/png" }, + { file: "icon-512.png", format: "png", variant: "any", size: 512, contentType: "image/png" }, + { file: "maskable-512.png", format: "png", variant: "maskable", size: 512, contentType: "image/png" }, + { file: "apple-touch-icon.png", format: "png", variant: "apple", size: 180, contentType: "image/png" }, +]; + +// The <head> icon links, shared by the export and homepage layouts: the SVG +// first (every current browser takes it, and it stays sharp at any size), the +// 32 px PNG for tabs that will not, the two app sizes, and the touch icon. +// /favicon.ico is served too, but is not linked. (Not typed with Next's +// `Metadata`: importing the `next` root types into common adds Next's global +// ProcessEnv augmentation to every common test's program.) +export const ICON_METADATA = { + icon: [ + { url: "/icons/icon.svg", type: "image/svg+xml" }, + { url: "/icons/icon-32.png", sizes: "32x32", type: "image/png" }, + { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }], +}; + +export function iconFile(name: string): IconFile | undefined { + return ICON_FILES.find((f) => f.file === name); +} + +// The sizes packed into /favicon.ico. Browsers that still ask for it pick the +// entry nearest their tab size. +export const FAVICON_SIZES = [16, 32, 48] as const; + +// The palette an archive site's icons are lit with: the child ground, and its +// accent's on-dark value (an absent accent reads as Signal; a custom hex is +// fitted to the dark ground). The hub, homepage and editor use +// ICON_PALETTES.archilyzer instead. +export function siteIconPalette(accent: unknown): IconPalette { + return childIconPalette(resolveAccent(accent).dark); +} + +export function svgDataUri(svg: string): string { + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; +} + +// The mark as a PNG of `size` × `size`. satori lays out one <img> of the SVG; +// resvg rasterises it. +export async function renderIconPng( + palette: IconPalette, + opts: { variant?: MarkVariant; size: number }, +): Promise<Uint8Array> { + const { size } = opts; + const src = svgDataUri(markSvg(palette, { variant: opts.variant ?? "any" })); + const res = new ImageResponse( + createElement("img", { src, width: size, height: size, alt: "" }), + { width: size, height: size }, + ); + return new Uint8Array(await res.arrayBuffer()); +} + +// A PNG-payload ICO: the 6-byte ICONDIR, one 16-byte ICONDIRENTRY per image, +// then the PNG files verbatim (Vista+ and every current browser read PNG +// entries). A width or height of 256 is written as 0, per the format. +export function pngToIco(images: ReadonlyArray<{ size: number; png: Uint8Array }>): Uint8Array { + const HEADER = 6; + const ENTRY = 16; + const total = + HEADER + ENTRY * images.length + images.reduce((n, i) => n + i.png.byteLength, 0); + const out = new Uint8Array(total); + const view = new DataView(out.buffer); + view.setUint16(0, 0, true); // reserved + view.setUint16(2, 1, true); // type 1 = icon + view.setUint16(4, images.length, true); + let offset = HEADER + ENTRY * images.length; + images.forEach(({ size, png }, i) => { + if (!Number.isInteger(size) || size < 1 || size > 256) { + throw new Error(`pngToIco: size must be 1..256, got ${size}`); + } + const e = HEADER + ENTRY * i; + view.setUint8(e, size === 256 ? 0 : size); // width + view.setUint8(e + 1, size === 256 ? 0 : size); // height + view.setUint8(e + 2, 0); // palette colours (none) + view.setUint8(e + 3, 0); // reserved + view.setUint16(e + 4, 1, true); // colour planes + view.setUint16(e + 6, 32, true); // bits per pixel + view.setUint32(e + 8, png.byteLength, true); // payload size + view.setUint32(e + 12, offset, true); // payload offset + out.set(png, offset); + offset += png.byteLength; + }); + return out; +} + +export async function renderFaviconIco(palette: IconPalette): Promise<Uint8Array> { + const images = await Promise.all( + FAVICON_SIZES.map(async (size) => ({ size, png: await renderIconPng(palette, { size }) })), + ); + return pngToIco(images); +} + +// One /icons/<file>'s bytes and type, or null for a name that is not in +// ICON_FILES. +export async function renderIconFile( + name: string, + palette: IconPalette, +): Promise<{ body: Uint8Array | string; contentType: string } | null> { + const f = iconFile(name); + if (!f) return null; + if (f.format === "svg") { + return { body: markSvg(palette, { variant: f.variant }), contentType: f.contentType }; + } + return { + body: await renderIconPng(palette, { variant: f.variant, size: f.size }), + contentType: f.contentType, + }; +} + +// A route handler's Response for a rendered body. Revalidating, because a +// site's accent (and so its icons) can change between deploys at the same URL. +// In a static export only the body survives; the host sets the real headers. +export function iconResponse(body: Uint8Array | string, contentType: string): Response { + return new Response(typeof body === "string" ? body : new Blob([body as BlobPart]), { + headers: { "content-type": contentType, "cache-control": "public, max-age=0, must-revalidate" }, + }); +}