// Fetch & self-host the web fonts used by the 5 font styles (styles.css [data-font]). // Downloads chunked woff2 from jsDelivr into app/fonts//files/, and generates // app/css/fonts.css with @font-face rules pointing at the local /fonts/ route. // // Run from anywhere: node tools/fetch-fonts.mjs // Requires Node 18+ (global fetch). Re-run to refresh fonts. import { mkdir, writeFile, rm } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const FONTS_DIR = path.join(ROOT, 'app', 'fonts'); const OUT_CSS = path.join(ROOT, 'app', 'css', 'fonts.css'); const CDN = 'https://cdn.jsdelivr.net/npm'; // Per-font manifest. `dir` doubles as the woff2 filename slug. Latin UI fonts use the // variable packages (single file, all weights); CJK fonts use static 400/700 chunks. const FONTS = [ { dir: 'inter', pkg: '@fontsource-variable/inter', family: 'Inter', css: ['index.css'], variable: true, subsets: ['latin', 'latin-ext'] }, { dir: 'nunito', pkg: '@fontsource-variable/nunito', family: 'Nunito', css: ['index.css'], variable: true, subsets: ['latin', 'latin-ext'] }, { dir: 'jetbrains-mono', pkg: '@fontsource-variable/jetbrains-mono', family: 'JetBrains Mono', css: ['index.css'], variable: true, subsets: ['latin', 'latin-ext'] }, { dir: 'noto-sans-jp', pkg: '@fontsource/noto-sans-jp', family: 'Noto Sans JP', css: ['index.css', 'japanese.css'], weights: ['400', '700'], subsets: ['latin', 'latin-ext', 'japanese'] }, { dir: 'm-plus-rounded-1c', pkg: '@fontsource/m-plus-rounded-1c', family: 'M PLUS Rounded 1c', css: ['index.css', 'japanese.css'], weights: ['400', '700'], subsets: ['latin', 'latin-ext', 'japanese'] }, // 文楷 (cn-font-split micro-chunked, ~190 blocks). Keep every block; family already matches the stack. { dir: 'wenkai', pkg: 'lxgw-wenkai-screen-webfont@1.7.0', family: 'LXGW WenKai Screen', css: ['lxgwwenkaiscreen.css'], keepAll: true }, ]; async function fetchText(url) { const r = await fetch(url); if (!r.ok) throw new Error(`GET ${url} -> ${r.status}`); return r.text(); } async function fetchBuf(url) { const r = await fetch(url); if (!r.ok) throw new Error(`GET ${url} -> ${r.status}`); return Buffer.from(await r.arrayBuffer()); } // Run async tasks with bounded concurrency. async function pool(items, limit, fn) { const q = [...items]; const workers = Array.from({ length: Math.min(limit, q.length) }, async () => { while (q.length) await fn(q.shift()); }); await Promise.all(workers); } // Split a fontsource/cn-font-split stylesheet into individual @font-face blocks. function splitFaces(css) { return [...css.matchAll(/@font-face\s*\{[^}]*\}/g)].map(m => m[0]); } function field(block, name) { const m = block.match(new RegExp(name + '\\s*:\\s*([^;}]+)', 'i')); return m ? m[1].trim() : ''; } function woff2Name(block) { const m = block.match(/url\(['"]?([^'")]+?\.woff2)['"]?\)/i); return m ? path.basename(m[1]) : ''; } // Parse `---