115 lines
5.7 KiB
JavaScript
115 lines
5.7 KiB
JavaScript
// Fetch & self-host the web fonts used by the 5 font styles (styles.css [data-font]).
|
|
// Downloads chunked woff2 from jsDelivr into app/fonts/<dir>/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 `<slug>-<subset>-<weight>-<style>.woff2` -> {subset, weight}.
|
|
function parseName(file, slug) {
|
|
let base = file.replace(/\.woff2$/, '').replace(/-(normal|italic)$/, '');
|
|
const wm = base.match(/-(\d{2,3}|wght)$/);
|
|
const weight = wm ? wm[1] : '';
|
|
base = base.replace(/-(\d{2,3}|wght)$/, '');
|
|
const subset = base.startsWith(slug + '-') ? base.slice(slug.length + 1) : base;
|
|
return { subset, weight };
|
|
}
|
|
|
|
async function run() {
|
|
await rm(FONTS_DIR, { recursive: true, force: true });
|
|
const cssParts = ['/* Auto-generated by tools/fetch-fonts.mjs — do not edit by hand. */\n'];
|
|
const downloads = []; // {url, dest}
|
|
|
|
for (const f of FONTS) {
|
|
const kept = [];
|
|
for (const cssFile of f.css) {
|
|
const sheet = await fetchText(`${CDN}/${f.pkg}/${cssFile}`);
|
|
for (const block of splitFaces(sheet)) {
|
|
const file = woff2Name(block);
|
|
if (!file) continue;
|
|
if (!f.keepAll) {
|
|
const { subset, weight } = parseName(file, f.dir);
|
|
if (f.subsets && !f.subsets.includes(subset)) continue;
|
|
if (f.weights && weight && !f.weights.includes(weight)) continue;
|
|
}
|
|
const range = field(block, 'unicode-range');
|
|
const weight = field(block, 'font-weight') || '400';
|
|
const style = field(block, 'font-style') || 'normal';
|
|
const fmt = /woff2-variations/.test(block) ? 'woff2-variations' : 'woff2';
|
|
kept.push(`@font-face{font-family:'${f.family}';font-style:${style};font-weight:${weight};font-display:swap;src:url(/fonts/${f.dir}/files/${file}) format('${fmt}');${range ? `unicode-range:${range};` : ''}}`);
|
|
downloads.push({ url: `${CDN}/${f.pkg}/files/${file}`, dest: path.join(FONTS_DIR, f.dir, 'files', file) });
|
|
}
|
|
}
|
|
// De-dup woff2 (a file may appear in multiple css); keep unique faces too.
|
|
cssParts.push(`/* ${f.family} (${f.dir}) — ${kept.length} faces */`, ...[...new Set(kept)], '');
|
|
console.log(`${f.family}: ${kept.length} faces`);
|
|
}
|
|
|
|
// Download unique files with bounded concurrency.
|
|
const uniq = [...new Map(downloads.map(d => [d.dest, d])).values()];
|
|
await mkdir(path.dirname(OUT_CSS), { recursive: true });
|
|
let done = 0;
|
|
await pool(uniq, 16, async (d) => {
|
|
await mkdir(path.dirname(d.dest), { recursive: true });
|
|
await writeFile(d.dest, await fetchBuf(d.url));
|
|
if (++done % 25 === 0 || done === uniq.length) console.log(` downloaded ${done}/${uniq.length}`);
|
|
});
|
|
|
|
await writeFile(OUT_CSS, cssParts.join('\n'), 'utf8');
|
|
console.log(`\nWrote ${OUT_CSS} and ${uniq.length} woff2 files under ${FONTS_DIR}`);
|
|
}
|
|
|
|
run().catch(e => { console.error(e); process.exit(1); });
|