Archilyzer · Source

archilyzer

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

commit ae7d11a0d586311cfacf225656cd4f45863bb39f
parent beb2ccdf32fd995a88e27675c47cf0a12681a5b4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 22:19:50 -0400

common: brandIconFiles — the icon set as pure data, so next/og stays with the route handlers (brand S1 review)

ICON_FILES, iconFile, ICON_METADATA, FAVICON_SIZES and siteIconPalette move
to lib/brandIconFiles.ts, which imports no next. lib/brandIcons.ts keeps the
renderers beside next/og and is imported only by the four icon/favicon
routes; the layouts and export/app/lib/brand.ts (and so the Header and the
manifest) read the pure module, so next/og no longer enters every page's
server graph. A test pins both: the only app importers of lib/brandIcons are
the four routes, and brandIconFiles imports no next.

The unit test now also proves the PNG variant is drawn: pixel (0,0) of the
`any` 512 is transparent (the rounded corner) and of the maskable 512 and the
apple 180 is the opaque ground — read from the first scanline, which every PNG
filter leaves as stored. Making renderIconPng ignore the variant turns it red.

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

Diffstat:
Acommon/lib/brandIconFiles.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/brandIcons.test.ts | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcommon/lib/brandIcons.ts | 68++++++++------------------------------------------------------------
Mexport/app/icons/[file]/route.ts | 7++-----
Mexport/app/layout.tsx | 2+-
Mexport/app/lib/brand.ts | 2+-
Mhomepage/app/icons/[file]/route.ts | 7++-----
Mhomepage/app/layout.tsx | 2+-
8 files changed, 132 insertions(+), 78 deletions(-)

diff --git a/common/lib/brandIconFiles.ts b/common/lib/brandIconFiles.ts @@ -0,0 +1,59 @@ +// THE ICON SET, AS DATA — which files exist under /icons/, how <head> links +// them, which sizes /favicon.ico packs, and which palette a site's icons are lit +// with. PURE (no `next/og`, no I/O), so the layouts, the export header and the +// manifest can read it without pulling the renderer into every page's server +// graph. The renderers live in lib/brandIcons.ts, which only the icon and +// favicon route handlers import. + +import { childIconPalette, 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/` network-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); +} diff --git a/common/lib/brandIcons.test.ts b/common/lib/brandIcons.test.ts @@ -1,5 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { inflateSync } from "node:zlib"; import { ACCENTS, ICON_PALETTES, markSvg } from "./brand"; import { resolveAccent } from "./accent"; import { @@ -7,12 +11,9 @@ import { ICON_FILES, ICON_METADATA, iconFile, - pngToIco, - renderFaviconIco, - renderIconFile, - renderIconPng, siteIconPalette, -} from "./brandIcons"; +} from "./brandIconFiles"; +import { pngToIco, renderFaviconIco, renderIconFile, renderIconPng } from "./brandIcons"; const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; @@ -24,6 +25,25 @@ function ihdr(png: Uint8Array): { width: number; height: number } { return { width: v.getUint32(16), height: v.getUint32(20) }; } +// The RGBA of pixel (0, 0). Every PNG filter predicts the first pixel of the +// first scanline from zeros, so after inflating the IDAT stream its bytes are +// the pixel as stored, whichever filter the encoder chose — no unfiltering. +function topLeftPixel(png: Uint8Array): number[] { + const b = Buffer.from(png); + assert.equal(b[24], 8, "bit depth 8"); + assert.equal(b[25], 6, "colour type 6 (RGBA)"); + const idat: Buffer[] = []; + for (let off = 8; off < b.length; ) { + const len = b.readUInt32BE(off); + if (b.toString("latin1", off + 4, off + 8) === "IDAT") idat.push(b.subarray(off + 8, off + 8 + len)); + off += 12 + len; + } + const raw = inflateSync(Buffer.concat(idat)); + return [...raw.subarray(1, 5)]; // byte 0 is the scanline's filter type +} + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + test("ICON_FILES: the seven URLs the manifest, <head>, OG image and PWAs name", () => { assert.deepEqual( ICON_FILES.map((f) => f.file), @@ -118,6 +138,15 @@ test("renderIconPng: a PNG of the requested size, per variant", async () => { } }); +test("renderIconPng draws the variant: any has transparent corners, maskable and apple are full-bleed", async () => { + const p = ICON_PALETTES.archilyzer; + const ground = [0x15, 0x1b, 0x20, 255]; // #151b20, opaque + const any = await renderIconPng(p, { variant: "any", size: 512 }); + assert.equal(topLeftPixel(any)[3], 0, "any: the rounded corner is transparent"); + assert.deepEqual(topLeftPixel(await renderIconPng(p, { variant: "maskable", size: 512 })), ground); + assert.deepEqual(topLeftPixel(await renderIconPng(p, { variant: "apple", size: 180 })), ground); +}); + 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); @@ -146,3 +175,27 @@ test("renderIconFile: SVGs are markSvg verbatim, PNGs are PNGs, anything else is assert.equal(await renderIconFile("icon-64.png", palette), null); assert.equal(await renderIconFile("../site.json", palette), null); }); + +// next/og belongs to the icon and favicon routes alone: a page, layout or +// header that imported lib/brandIcons would put the renderer in every page's +// server graph. The pure data is lib/brandIconFiles.ts, which must not import +// next at all. +test("only the icon and favicon route handlers import lib/brandIcons; brandIconFiles imports no next", () => { + const importers: string[] = []; + for (const app of ["export/app", "homepage/app", "editor/app"]) { + for (const rel of readdirSync(path.join(REPO, app), { recursive: true }) as string[]) { + if (!/\.tsx?$/.test(rel)) continue; + const src = readFileSync(path.join(REPO, app, rel), "utf8"); + if (src.includes('lib/brandIcons"')) importers.push(`${app}/${rel}`); + } + } + assert.deepEqual(importers.sort(), [ + "export/app/favicon.ico/route.ts", + "export/app/icons/[file]/route.ts", + "homepage/app/favicon.ico/route.ts", + "homepage/app/icons/[file]/route.ts", + ]); + const pure = readFileSync(path.join(REPO, "common/lib/brandIconFiles.ts"), "utf8"); + assert.doesNotMatch(pure, /from "next/); + assert.doesNotMatch(pure, /from "\.\/brandIcons"/); +}); diff --git a/common/lib/brandIcons.ts b/common/lib/brandIcons.ts @@ -10,69 +10,17 @@ // `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. +// SERVER-ONLY, and imported ONLY by those route handlers: `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. The icon set as data (ICON_FILES, +// ICON_METADATA, FAVICON_SIZES, siteIconPalette) is lib/brandIconFiles.ts, which +// is pure, so the layouts and the header never load this module. 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); -} +import { markSvg, type IconPalette, type MarkVariant } from "./brand"; +import { FAVICON_SIZES, iconFile } from "./brandIconFiles"; export function svgDataUri(svg: string): string { return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; diff --git a/export/app/icons/[file]/route.ts b/export/app/icons/[file]/route.ts @@ -1,8 +1,5 @@ -import { - ICON_FILES, - iconResponse, - renderIconFile, -} from "yt-dlp-transcript-common/lib/brandIcons"; +import { ICON_FILES } from "yt-dlp-transcript-common/lib/brandIconFiles"; +import { iconResponse, renderIconFile } from "yt-dlp-transcript-common/lib/brandIcons"; import { iconPalette } from "../../lib/brand"; // /icons/<file>: the site's icon set, rendered from the one mark at build time diff --git a/export/app/layout.tsx b/export/app/layout.tsx @@ -4,7 +4,7 @@ import { ThemeScript } from "yt-dlp-transcript-common/components/ThemeScript"; import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider"; import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider"; import { parseAccent, siteAccentVars } from "yt-dlp-transcript-common/lib/accent"; -import { ICON_METADATA } from "yt-dlp-transcript-common/lib/brandIcons"; +import { ICON_METADATA } from "yt-dlp-transcript-common/lib/brandIconFiles"; import { currentSite } from "./lib/site"; import { instanceMode, shipsPwa } from "./lib/mode"; import Header from "./components/Header"; diff --git a/export/app/lib/brand.ts b/export/app/lib/brand.ts @@ -1,5 +1,5 @@ import { ICON_PALETTES, type IconPalette } from "yt-dlp-transcript-common/lib/brand"; -import { siteIconPalette } from "yt-dlp-transcript-common/lib/brandIcons"; +import { siteIconPalette } from "yt-dlp-transcript-common/lib/brandIconFiles"; import type { BrandMarkPalette } from "yt-dlp-transcript-common/components/BrandMark"; import { currentSite } from "./site"; import { instanceMode } from "./mode"; diff --git a/homepage/app/icons/[file]/route.ts b/homepage/app/icons/[file]/route.ts @@ -1,9 +1,6 @@ import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; -import { - ICON_FILES, - iconResponse, - renderIconFile, -} from "yt-dlp-transcript-common/lib/brandIcons"; +import { ICON_FILES } from "yt-dlp-transcript-common/lib/brandIconFiles"; +import { iconResponse, renderIconFile } from "yt-dlp-transcript-common/lib/brandIcons"; // /icons/<file>: the project site's icon set — the parent mark, achromatic // (bone on slate), rendered at build time from common/lib/brand.ts. The twin diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx @@ -7,7 +7,7 @@ import { PROJECT_TAGLINE, PROJECT_URL, } from "yt-dlp-transcript-common/lib/project"; -import { ICON_METADATA } from "yt-dlp-transcript-common/lib/brandIcons"; +import { ICON_METADATA } from "yt-dlp-transcript-common/lib/brandIconFiles"; import Header from "./components/Header"; import Footer from "./components/Footer"; import "./globals.css";