export async function buildFonts(fonts: sg.Font[]) { if (fonts.length === 0) return; const temp = path.resolve(".clover/font"); const venv = path.join(temp, "venv"); const bin = path.join(venv, "bin"); if (!fs.existsSync(venv)) { await subprocess.exec("python", ["-m", "venv", venv]); } if (!fs.existsSync(path.join(bin, "pyftsubset"))) { await subprocess.exec(path.join(bin, "pip"), [ "install", "fonttools==4.38.0", "brotli==1.0.7", ]); } const instances = new async.OnceMap(); async function makeInstance(input: string, vars: sg.FontVars) { const args = Object.entries(vars).map((x) => { const lower = x[0].toLowerCase(); const k = lower === "wght" || lower === "slnt" ? lower : x[0]; const v = Array.isArray(x[1]) ? x[1].join(":") : x[1]; return `${k}=${v}`; }).sort(); const hash = crypto .createHash("sha256") .update(`${input}${args.join(" ")}`) .digest("hex") .slice(0, 16) .toLowerCase(); const outfile = path.join(temp, `${hash}${path.extname(input)}`); await instances.get(outfile, async () => { await fs.rm(outfile, { force: true }); await subprocess.exec(path.join(bin, "fonttools"), [ "varLib.instancer", input, ...args, `--output=${outfile}`, ]); ASSERT(fs.existsSync(outfile)); }); return outfile; } await Promise.all(fonts.map((font) => incr.work(async (io, font) => { const baseFile = await fetchFont(font.name, font.sources); await Promise.all(font.subsets.map(async (subset) => { let file = baseFile; if (subset.vars) { file = await makeInstance(baseFile, subset.vars); } const unicodes = fontRangesToString( Array.isArray(subset.unicodes) ? subset.unicodes : [subset.unicodes], ); const hash = crypto .createHash("sha256") .update(`${file}${unicodes}`) .digest("hex") .slice(0, 16) .toLowerCase(); const woff = path.join(temp, hash + ".woff2"); await subprocess.exec(path.join(bin, "pyftsubset"), [ file, "--flavor=woff2", `--output-file=${woff}`, ...subset.layoutFeatures ? [`--layout-features=${subset.layoutFeatures.join(",")}`] : [], `--unicodes=${unicodes}`, ]); await io.writeAsset({ pathname: subset.asset.replace("[ext]", "woff2"), buffer: await fs.readFile(woff), }); })); }, font) )); } export async function fetchFont(name: string, sources: string[]) { const errs = []; for (const source of sources) { const cacheName = path.join("", name + path.extname(source)); if (fs.existsSync(cacheName)) return cacheName; if (source.startsWith("https://")) { try { const response = await fetch(source); if (response.ok) { await fs.writeMkdir( cacheName, Buffer.from(await response.arrayBuffer()), ); } else { errs.push( new Error( `Fetching from ${source} failed: ${response.status} ${response.statusText}`, ), ); continue; } } catch (err) { errs.push(`Fetching from ${source} failed: ${error.message(err)}`); continue; } } if (path.isAbsolute(source)) { if (fs.existsSync(source)) return source; errs.push(new Error(`Font not available at absolute path ${source}`)); } else if (!source.includes("/") && !source.includes("\\")) { const home = os.homedir(); const bases = process.platform === "win32" ? [ "\\Windows\\Fonts", path.win32.join(home, "AppData\\Local\\Microsoft\\Windows\\Fonts"), ] : process.platform === "darwin" ? [ "/Library/Fonts", path.posix.join(home, "Library/Fonts"), ] : [ "/usr/share/fonts", "/usr/local/share/fonts", path.posix.join(home, ".local/share/fonts"), ]; for (const base of bases) { const found = fs.readDirRecOptionalSync(base) .find((file) => path.basename(file) === source); if (found) { return path.join(base, found); } } errs.push( new Error( `Font file ${source} not found in the following directories: ${ bases.join(", ") }`, ), ); } } throw new AggregateError(errs, `Failed to fetch the ${name} font`); } function fontRangesToString(ranges: sg.FontRange[]): string { const segments: Array<{ start: number; end: number }> = []; ranges.forEach((range) => { if (typeof range === "string") { for (let i = 0; i < range.length; i++) { const cp = range.codePointAt(i) || 0; segments.push({ start: cp, end: cp }); } } else if (typeof range === "number") { segments.push({ start: range, end: range }); } else { segments.push({ start: range.start, end: range.end }); } }); segments.sort((a, b) => a.start - b.start); const merged: Array<{ start: number; end: number }> = []; for (const seg of segments) { const last = merged[merged.length - 1]; if (last && seg.start <= last.end + 1) { last.end = Math.max(last.end, seg.end); } else { merged.push({ ...seg }); } } return merged.map(({ start, end }) => start === end ? `U+${start.toString(16).toUpperCase().padStart(4, "0")}` : `U+${start.toString(16).toUpperCase().padStart(4, "0")}-${ end.toString(16).toUpperCase().padStart(4, "0") }` ).join(","); } import * as fs from "#sitegen/fs"; import * as os from "node:os"; import * as path from "node:path"; import * as sg from "#sitegen"; import * as subprocess from "#sitegen/subprocess"; import * as incr from "./incremental.ts"; import * as crypto from "node:crypto"; import * as async from "#sitegen/async"; import * as error from "#sitegen/error";