diff --git a/build.ts b/build.ts index 0482ad2..808a90a 100644 --- a/build.ts +++ b/build.ts @@ -12,7 +12,7 @@ const result = await Bun.build({ minify: true, splitting: true, target: "bun", - external: ["@napi-rs/canvas"], + external: ["@napi-rs/canvas", "/fonts/browser/*"], sourcemap: "linked", define: { "process.env.NODE_ENV": JSON.stringify("production"), @@ -31,3 +31,8 @@ await mkdir(path.join(outdir, "fonts"), { recursive: true }); for (const file of ["InstrumentSerif-Regular.ttf", "NotoSans-Regular.ttf", "NotoSans-Medium.ttf", "NotoSans-SemiBold.ttf"]) { await copyFile(`src/assets/fonts/${file}`, path.join(outdir, "fonts", file)); } + +await mkdir(path.join(outdir, "fonts/browser"), { recursive: true }); +for (const file of ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"]) { + await copyFile(`src/assets/fonts/${file}.woff2`, path.join(outdir, `fonts/browser/${file}.woff2`)); +} diff --git a/bunfig.toml b/bunfig.toml index 0d0612c..fa3b71d 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -3,5 +3,5 @@ env = false [serve.static] -plugins = ["bun-plugin-tailwind"] +plugins = ["bun-plugin-tailwind", "./scripts/browser-fonts-plugin.ts"] env = "BUN_PUBLIC_*" diff --git a/scripts/browser-fonts-plugin.ts b/scripts/browser-fonts-plugin.ts new file mode 100644 index 0000000..e142fcc --- /dev/null +++ b/scripts/browser-fonts-plugin.ts @@ -0,0 +1,9 @@ +import type { BunPlugin } from "bun"; + +/** Match the production bundler's external font URLs in the HTML dev server. */ +export default { + name: "external-browser-fonts", + setup(build) { + build.onResolve({ filter: /^\/fonts\/browser\// }, args => ({ path: args.path, external: true })); + }, +} satisfies BunPlugin; diff --git a/scripts/dashboard-preview.ts b/scripts/dashboard-preview.ts index 770aefb..a47a8e0 100644 --- a/scripts/dashboard-preview.ts +++ b/scripts/dashboard-preview.ts @@ -1,3 +1,4 @@ +import { browserFont } from "../src/browser-fonts"; // Isolated browser QA server. Never opens or changes the user's database. import { sendDueReminders } from "../src/reminders/worker"; import type { DiscordFetch } from "../src/sharing/routes"; @@ -55,6 +56,7 @@ const server = Bun.serve({ hostname: "127.0.0.1", port, routes: { + "/fonts/browser/*": browserFont, "/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"), "/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }), "/__preview/login": () => diff --git a/src/assets/fonts/InstrumentSerif-Italic.woff2 b/src/assets/fonts/InstrumentSerif-Italic.woff2 new file mode 100644 index 0000000..444b688 Binary files /dev/null and b/src/assets/fonts/InstrumentSerif-Italic.woff2 differ diff --git a/src/assets/fonts/InstrumentSerif-Regular.woff2 b/src/assets/fonts/InstrumentSerif-Regular.woff2 new file mode 100644 index 0000000..6580bf1 Binary files /dev/null and b/src/assets/fonts/InstrumentSerif-Regular.woff2 differ diff --git a/src/assets/fonts/NotoSans-Medium.woff2 b/src/assets/fonts/NotoSans-Medium.woff2 new file mode 100644 index 0000000..c66dc4a Binary files /dev/null and b/src/assets/fonts/NotoSans-Medium.woff2 differ diff --git a/src/assets/fonts/NotoSans-Regular.woff2 b/src/assets/fonts/NotoSans-Regular.woff2 new file mode 100644 index 0000000..9465704 Binary files /dev/null and b/src/assets/fonts/NotoSans-Regular.woff2 differ diff --git a/src/assets/fonts/NotoSans-SemiBold.woff2 b/src/assets/fonts/NotoSans-SemiBold.woff2 new file mode 100644 index 0000000..6a9e444 Binary files /dev/null and b/src/assets/fonts/NotoSans-SemiBold.woff2 differ diff --git a/src/assets/fonts/README.md b/src/assets/fonts/README.md index b1890d9..d9012fe 100644 --- a/src/assets/fonts/README.md +++ b/src/assets/fonts/README.md @@ -12,3 +12,24 @@ Downloaded September 7, 2026. The files are versioned in this repository and cop into the production image. Browser and native canvas renderers use the alias `Mina Sans`; no system sans-serif font is selected for card text. The native font loader fails startup if a required font cannot be registered. + +Browser WOFF2 copies are lossless conversions of these exact TTF files using +FontTools with Brotli; no subsetting is applied. All glyphs and Unicode mappings +are retained (3,748 glyphs for each Noto Sans weight, 377 for each Instrument +Serif style). Native canvas keeps using the original TTFs. CSS uses separately +cacheable `/fonts/browser/*.woff2` URLs so font bytes do not block CSS parsing. + +Regenerate with `uv run --with fonttools --with brotli python`: + +```python +from pathlib import Path +from fontTools.ttLib import TTFont +for path in Path("src/assets/fonts").glob("*.ttf"): + font = TTFont(path) + cmap, glyphs = font.getBestCmap(), font.getGlyphOrder() + font.flavor = "woff2" + font.save(path.with_suffix(".woff2")) + restored = TTFont(path.with_suffix(".woff2")) + assert restored.getBestCmap() == cmap + assert restored.getGlyphOrder() == glyphs +``` diff --git a/src/browser-fonts.test.ts b/src/browser-fonts.test.ts new file mode 100644 index 0000000..e90bed8 --- /dev/null +++ b/src/browser-fonts.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { browserFont } from "./browser-fonts"; +test("browser fonts are external WOFF2 files with conditional caching and a fixed allowlist", async () => { + const url = "http://localhost/fonts/browser/InstrumentSerif-Regular.woff2"; + const response = browserFont(new Request(url)); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("font/woff2"); + const bytes = new Uint8Array(await response.arrayBuffer()); + expect(new TextDecoder().decode(bytes.slice(0, 4))).toBe("wOF2"); + expect(browserFont(new Request(url, { headers: { "If-None-Match": response.headers.get("ETag")! } })).status).toBe(304); + expect(browserFont(new Request("http://localhost/fonts/browser/.env")).status).toBe(404); + expect(browserFont(new Request("http://localhost/fonts/browser/%2e%2e%2f.env")).status).toBe(404); + expect(await browserFont(new Request(url, { method: "HEAD" })).text()).toBe(""); +}); diff --git a/src/browser-fonts.ts b/src/browser-fonts.ts new file mode 100644 index 0000000..b720907 --- /dev/null +++ b/src/browser-fonts.ts @@ -0,0 +1,18 @@ +import path from "node:path"; + +const names = ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"]; +const directory = process.env.NODE_ENV === "production" ? "fonts/browser" : "src/assets/fonts"; +const fonts: Map; etag: string }> = new Map(names.map(name => { + const file = Bun.file(path.resolve(directory, `${name}.woff2`)); + return [`${name}.woff2`, { file, etag: `"${file.size.toString(16)}-${file.lastModified.toString(16)}"` }] as const; +})); + +/** Explicit allowlist: font URLs never grant access to other filesystem paths. */ +export function browserFont(request: Request): Response { + const name = new URL(request.url).pathname.slice("/fonts/browser/".length); + const font = fonts.get(name); + if (!font || !["GET", "HEAD"].includes(request.method)) return new Response(null, { status: 404 }); + const headers = { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=3600", ETag: font.etag }; + if (request.headers.get("If-None-Match") === font.etag) return new Response(null, { status: 304, headers }); + return new Response(request.method === "HEAD" ? null : font.file, { headers }); +} diff --git a/src/index.ts b/src/index.ts index be1e836..118f13f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { browserFont } from "./browser-fonts"; import { startReminders } from "./reminders/worker"; import { backupConfig, startBackups } from "./ops/backups"; import { databasePath } from "./db/config"; @@ -28,6 +29,7 @@ const server = Bun.serve({ hostname: process.env.HOST ?? "127.0.0.1", port: Number(process.env.PORT ?? 3000), routes: { + "/fonts/browser/*": browserFont, "/api": req => app.fetch(req), "/api/*": req => app.fetch(req), "/*": index, diff --git a/styles/design-system.css b/styles/design-system.css index c77d15b..ac3aed5 100644 --- a/styles/design-system.css +++ b/styles/design-system.css @@ -1,34 +1,34 @@ @font-face { font-family: "Mina Sans"; - src: url("../src/assets/fonts/NotoSans-Regular.ttf") format("truetype"); + src: url("/fonts/browser/NotoSans-Regular.woff2") format("woff2"); font-style: normal; font-weight: 400; font-display: swap; } @font-face { font-family: "Mina Sans"; - src: url("../src/assets/fonts/NotoSans-Medium.ttf") format("truetype"); + src: url("/fonts/browser/NotoSans-Medium.woff2") format("woff2"); font-style: normal; font-weight: 500; font-display: swap; } @font-face { font-family: "Mina Sans"; - src: url("../src/assets/fonts/NotoSans-SemiBold.ttf") format("truetype"); + src: url("/fonts/browser/NotoSans-SemiBold.woff2") format("woff2"); font-style: normal; font-weight: 600; font-display: swap; } @font-face { font-family: "Instrument Serif"; - src: url("../src/assets/fonts/InstrumentSerif-Regular.ttf") format("truetype"); + src: url("/fonts/browser/InstrumentSerif-Regular.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } @font-face { font-family: "Instrument Serif"; - src: url("../src/assets/fonts/InstrumentSerif-Italic.ttf") format("truetype"); + src: url("/fonts/browser/InstrumentSerif-Italic.woff2") format("woff2"); font-weight: 400; font-style: italic; font-display: swap;