#!/usr/bin/env node /** * build-font-index: rebuild skill/scripts/data/font-index.json, the fingerprint * index of the Google Fonts catalog that `font-match.mjs --rank` uses as its * candidate generator. * * This is a RELEASE-TIME step, not part of `bun run build`. It needs the * network (Google Fonts metadata + CSS) and Playwright Chromium, renders every * latin family at up to three weights (300 / 400 / 700, whichever the family * ships) at two cap heights (48px and 14px), fingerprints each render with * skill/scripts/lib/font-fingerprint.mjs, and packs the vectors with * skill/scripts/lib/font-index.mjs. A full run is ~6,000 renders and takes * 20-40 minutes. Rerun it when the fingerprint's FEATURES or STATS change * (the index stores only the features the fitted distance weights) or when * the catalog has moved on enough to matter; commit the regenerated JSON. * * node scripts/build-font-index.mjs [--out skill/scripts/data/font-index.json] * [--sample N] # first N families only (tests, smoke) * [--families "Inter,Oswald"] * [--metadata path] # cached fonts.google.com/metadata/fonts JSON * [--resume] # keep entries already in --out * * One-time setup: `npx playwright install chromium`. */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; import { fingerprint } from '../skill/scripts/lib/font-fingerprint.mjs'; import { decodePng } from '../skill/scripts/lib/png.mjs'; import { INDEX_PATH, INDEX_SIZES, INDEX_FEATURES, CATEGORIES, packVector } from '../skill/scripts/lib/font-index.mjs'; const require = createRequire(import.meta.url); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); /** Text every catalog face is rendered with; covers caps, x-height letters, ascenders, descenders, digits. */ export const INDEX_TEXT = 'The quick brown fox jumps over the lazy dog 0123456789 HAMBURGEVONS'; export const INDEX_WEIGHTS = [300, 400, 700]; const METADATA_URL = 'https://fonts.google.com/metadata/fonts'; const CATEGORY_MAP = { 'Sans Serif': 'sans', Serif: 'serif', Display: 'display', Handwriting: 'handwriting', Monospace: 'mono' }; function arg(name, fallback = null) { const i = process.argv.indexOf(`--${name}`); if (i === -1) return fallback; const v = process.argv[i + 1]; return v && !v.startsWith('--') ? v : fallback; } const has = (name) => process.argv.includes(`--${name}`); /** Catalog entries [{ family, weight, category, variable }] from the Google Fonts metadata document. */ export function entriesFromMetadata(meta, { weights = INDEX_WEIGHTS } = {}) { const list = (meta.familyMetadataList || []).filter((x) => (x.subsets || []).includes('latin')); const out = []; for (const x of list) { const ax = (x.axes || []).find((a) => a.tag === 'wght'); const ks = Object.keys(x.fonts || {}).filter((k) => !k.endsWith('i')).map(Number); const ws = weights.filter((t) => (ax ? t >= ax.min && t <= ax.max : ks.includes(t))); for (const w of ws) out.push({ family: x.family, weight: w, category: CATEGORY_MAP[x.category] || String(x.category || '').toLowerCase(), variable: !!ax }); } return out; } /** Serialize decoded entries into the on-disk shape (see lib/font-index.mjs). */ export function serializeIndex(entries, { text = INDEX_TEXT, sizes = INDEX_SIZES } = {}) { const rows = entries.map((e) => [e.family, e.weight, Math.max(0, CATEGORIES.indexOf(e.category)), e.variable ? 1 : 0, ...sizes.map((sz) => (e.fp?.[sz] ? packVector(e.fp[sz]) : null))]); return JSON.stringify({ schema: 1, text, sizes, features: INDEX_FEATURES, categories: CATEGORIES, entries: rows }); } const gfHref = (f, w) => `https://fonts.googleapis.com/css2?family=${encodeURIComponent(f).replace(/%20/g, '+')}:wght@${w}&display=block`; /** Render one face at a target cap height on an open page and return its fingerprint (or null when the face did not load). */ async function fingerprintFace(page, e, targetCap, text) { let size = Math.round(targetCap * 1.4), fp = null; for (let pass = 0; pass < 2; pass++) { await page.evaluate(({ family, weight, size, text }) => { document.body.innerHTML = `