vendor.ts
5.4 kB · typescript · 156 lines
1import { mkdir, readFile, writeFile } from "node:fs/promises";2import { join, resolve } from "node:path";34const UA =5 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36";6const CSS2 = "https://fonts.googleapis.com/css2";7const OFL = "https://raw.githubusercontent.com/google/fonts/main/ofl";89export type Family = {10 family: string;11 file: string;12 axes?: string;13 weight: string;14 display?: string;15 shards?: boolean;16 licence: string;17};1819export type Icons = Family & { names: string[]; full: string };2021export type Fonts = { out: string; faces: Family[]; icons?: Icons; keep?: string };2223/* FETCH */2425async function get(url: string): Promise<Uint8Array> {26 const res = await fetch(url, { headers: { "User-Agent": UA } });27 if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);28 return new Uint8Array(await res.arrayBuffer());29}3031async function text(url: string): Promise<string> {32 return new TextDecoder().decode(await get(url));33}3435const query = (one: Family) => `family=${one.family.replace(/ /g, "+")}${one.axes ? `:${one.axes}` : ""}`;3637const licence = (name: string) => (/^https?:/.test(name) ? name : `${OFL}/${name}/OFL.txt`);3839/* PARSE */4041type Face = { subset: string; url: string; range: string | null };4243function faces(css: string): Face[] {44 const out: Face[] = [];45 let subset = "";46 const comment = /\/\*\s*([^*]+?)\s*\*\//g;47 const block = /@font-face\s*\{([^}]+)\}/g;48 let at = 0;49 let found: RegExpExecArray | null;50 while ((found = block.exec(css)) !== null) {51 comment.lastIndex = at;52 let label = "";53 let tag: RegExpExecArray | null;54 while ((tag = comment.exec(css)) !== null && tag.index < found.index) label = tag[1];55 subset = label || subset;56 const body = found[1];57 const url = /url\((\S+?)\)\s*format/.exec(body);58 if (!url) continue;59 const range = /unicode-range:\s*([^;]+);/.exec(body);60 out.push({ subset, url: url[1], range: range ? range[1].trim() : null });61 at = block.lastIndex;62 }63 return out;64}6566function latin(css: string): Face {67 const all = faces(css);68 const hit = all.find((f) => f.subset === "latin");69 if (!hit) throw new Error("no latin subset in css");70 return hit;71}7273/* FACES */7475function rule(family: string, file: string, weight: string, display: string, range: string | null) {76 const lines = [77 "@font-face {",78 ` font-family: "${family}";`,79 " font-style: normal;",80 ` font-weight: ${weight};`,81 ` src: url("${file}") format("woff2");`,82 ` font-display: ${display};`,83 ];84 if (range) lines.push(` unicode-range: ${range};`);85 lines.push("}");86 return lines.join("\n");87}8889/* MAIN */9091export async function main(root: string) {92 const config = JSON.parse(await readFile(join(root, "site.json"), "utf8")) as { fonts?: Fonts };93 const fonts = config.fonts;94 if (!fonts) throw new Error("vendor: site.json carries no fonts block");95 const out = resolve(root, fonts.out);96 await mkdir(out, { recursive: true });9798 const sizes: [string, number][] = [];99 const save = async (name: string, data: Uint8Array | string) => {100 const body = typeof data === "string" ? new TextEncoder().encode(data) : data;101 await writeFile(join(out, name), body);102 sizes.push([name, body.byteLength]);103 };104 const font = async (name: string, url: string) => {105 const data = await get(url);106 const tag = String.fromCharCode(data[0], data[1], data[2], data[3]);107 if (tag !== "wOF2") throw new Error(`${name}: expected wOF2 magic, got ${JSON.stringify(tag)}`);108 await save(name, data);109 };110111 const sheet: string[] = [];112 const licences: [string, string][] = [];113114 for (const one of fonts.faces) {115 const css = await text(`${CSS2}?${query(one)}`);116 const display = one.display ?? "swap";117 if (one.shards) {118 for (const [i, shard] of faces(css).entries()) {119 const name = `${one.file}.${i}.woff2`;120 await font(name, shard.url);121 sheet.push(rule(one.family, name, one.weight, display, shard.range));122 }123 } else {124 const hit = latin(css);125 const name = `${one.file}.woff2`;126 await font(name, hit.url);127 sheet.push(rule(one.family, name, one.weight, display, hit.range));128 }129 licences.push([`LICENSE-${one.file}.txt`, licence(one.licence)]);130 }131132 const icons = fonts.icons;133 let subset = true;134 if (icons) {135 const name = `${icons.file}.woff2`;136 try {137 const css = await text(`${CSS2}?${query(icons)}&icon_names=${[...icons.names].sort().join(",")}`);138 await font(name, faces(css)[0].url);139 } catch (err) {140 subset = false;141 console.log(`icons subset failed (${err}); falling back to the full variable font`);142 await font(name, icons.full);143 }144 sheet.push(rule(icons.family, name, icons.weight, icons.display ?? "block", null));145 licences.push([`LICENSE-${icons.file}.txt`, licence(icons.licence)]);146 }147148 const kept = fonts.keep ? (await readFile(resolve(root, fonts.keep), "utf8")).trim() : "";149 await save("fonts.css", sheet.join("\n\n") + (kept ? `\n\n${kept}` : "") + "\n");150 if (icons) await save(`${icons.file}.json`, JSON.stringify(icons.names, null, 2) + "\n");151 for (const [name, url] of licences) await save(name, await get(url));152153 const width = Math.max(...sizes.map(([name]) => name.length));154 for (const [name, n] of sizes) console.log(`${fonts.out}/${name.padEnd(width)} ${n} bytes`);155 if (icons) console.log(`icons: ${icons.names.length} names, ${subset ? "subset" : "FULL FONT (subset failed)"}`);156}