sitegen/framework/font.ts
clover caruso b304e2ac9f feat: metadata generation library
this takes what i love about 'next/meta' (originally imported via my
'next-metadata' port) and consolidates it into a simple 250 line
library. instead of supporting all meta tags under the sun, only the
most essential ones are exposed. less common meta tags can be added with
JSX under the 'extra' field.

a common problem i had with next-metadata was that open graph embeds
copied a lot of data from the main meta tags. to solve this, a highly
opiniated 'embed' option exists, which simply passing '{}' will trigger
the default behavior of copying the meta title, description, and
canonical url into the open graph meta tags.
2025-08-15 22:31:28 -07:00

197 lines
6 KiB
TypeScript

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<void>();
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";