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:
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";