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}