// 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 `---