/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
tools/fetch-fonts.mjs
107 строк
5 KB
Maksim Ratnikov
fix: real Plex weights for Cyrillic + move prompt reference out of toolbars
23 июл 2026, 23:59
23 июл 2026, 23:59
c023dcf
Код
Авторство
О чём код?
// Fetches IBM Plex Mono (Regular/Medium/Bold) and IBM Plex Sans // (Regular/Medium/SemiBold/Bold) WOFF2 files from the Google Fonts CSS endpoint // and writes them into lab/fonts/. Each (family, weight) is stored as TWO files — // the cyrillic and the latin subset — together with lab/fonts/manifest.json that // records the unicode-range of every file. build-lab.mjs reads the manifest and // emits one @font-face per file, so Cyrillic and Latin text render with the same // family instead of falling back to a system font for the missing script. // // Run manually when fonts need refreshing. NOT part of the build pipeline. // // Usage: node tools/fetch-fonts.mjs import { writeFileSync, mkdirSync, readdirSync, unlinkSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const ROOT = resolve(__dirname, ".."); const OUT_DIR = resolve(ROOT, "lab/fonts"); mkdirSync(OUT_DIR, { recursive: true }); // Google Fonts CSS API serves WOFF2 URLs grouped by unicode-range subset. // IBM Plex Sans is served as a VARIABLE font: the 100..700 range request returns // one file per subset with font-weight: 100 700, so every UI weight (400-700) // renders from real interpolated instances instead of synthetic bold. // IBM Plex Mono is static on Google Fonts, so it is fetched per weight. const CSS_URL = "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700" + "&family=IBM+Plex+Sans:wght@100..700&display=swap"; const HEADERS = { // Modern UA so Google returns WOFF2 (older UAs get TTF/EOT). "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36" }; const WEIGHT_WORD = { 400: "Regular", 500: "Medium", 600: "SemiBold", 700: "Bold" }; function targetFilename(family, weight, subset) { const stem = family === "IBM Plex Mono" ? "IBMPlexMono" : "IBMPlexSans"; const word = weight.includes(" ") ? "Variable" : WEIGHT_WORD[parseInt(weight, 10)]; return `${stem}-${word}.${subset}.woff2`; } // The CSS endpoint returns multiple @font-face blocks per family/weight, one per // subset (latin, latin-ext, cyrillic, cyrillic-ext, vietnamese...). Subset names // appear as comments (/* cyrillic */) right above each block. function parseFaces(css) { const faces = []; const re = /\/\*\s*([a-z-]+)\s*\*\/\s*@font-face\s*\{([^}]+)\}/g; for (const match of css.matchAll(re)) { const subset = match[1]; const block = match[2]; const family = (block.match(/font-family:\s*'([^']+)'/) || [])[1]; // Single weight ("400") or a variable-font range ("100 700"). const weight = ((block.match(/font-weight:\s*([\d ]+);/) || [])[1] || "").trim(); const url = (block.match(/url\((https:\/\/fonts\.gstatic\.com\/[^)]+\.woff2)\)/) || [])[1]; const range = ((block.match(/unicode-range:\s*([^;]+);/) || [])[1] || "").trim(); if (!family || !weight || !url || !range) continue; faces.push({ family, weight, subset, url, range }); } return faces; } async function fetchBytes(url) { const res = await fetch(url, { headers: HEADERS }); if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); return new Uint8Array(await res.arrayBuffer()); } async function main() { console.log("fetch-fonts: requesting CSS..."); const cssRes = await fetch(CSS_URL, { headers: HEADERS }); if (!cssRes.ok) throw new Error(`HTTP ${cssRes.status} fetching CSS`); const css = await cssRes.text(); const wanted = parseFaces(css).filter((face) => face.subset === "cyrillic" || face.subset === "latin"); if (!wanted.length) throw new Error("no cyrillic/latin faces found in CSS response"); for (const name of readdirSync(OUT_DIR)) { if (name.endsWith(".woff2") || name === "manifest.json") unlinkSync(resolve(OUT_DIR, name)); } const manifest = []; const seen = new Set(); for (const face of wanted) { const key = `${face.family}|${face.weight}|${face.subset}`; if (seen.has(key)) continue; seen.add(key); const file = targetFilename(face.family, face.weight, face.subset); console.log(`fetch-fonts: ${file} <- ${face.url}`); const bytes = await fetchBytes(face.url); writeFileSync(resolve(OUT_DIR, file), bytes); manifest.push({ file, family: face.family, weight: face.weight, subset: face.subset, range: face.range }); } manifest.sort((a, b) => a.file.localeCompare(b.file)); writeFileSync(resolve(OUT_DIR, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); console.log(`fetch-fonts: wrote ${manifest.length} files + manifest.json to lab/fonts/`); } main().catch((err) => { console.error(`fetch-fonts: ${err.stack || err.message}`); process.exit(1); });