Files
Stardream e3bf77296c
Build and Push Docker Image / build (push) Successful in 17s
Initial release
2026-06-16 20:27:36 +10:00

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); });