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.
197 lines
6 KiB
TypeScript
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";
|