feat: font resolution pipeline — compositions capture and embed their own fonts (#1255)

Compositions are now self-contained: the compiler captures font files
and embeds them as woff2 data URIs, eliminating silent render-time
fallback when the render environment lacks the author's fonts.

Resolution order (each tier falls through to the next):
1. Existing @font-face → use as-is
2. Bundled alias (38 cross-platform mappings) → embed data URI
3. Google Fonts → fetch, cache, embed
4. Local system font → locate on OS, compress to woff2, embed
5. Local @font-face paths → read file, compress, inline as data URI
6. External CDN stylesheets → fetch CSS, extract @font-face, inline
7. Alias map fallback → closest bundled equivalent
8. Actionable error with guidance

Key changes:
- System font locator (macOS/Windows/Linux) with path-bounding and
  symlink defense (realpathSync + O_NOFOLLOW)
- woff2 compression via wawoff2 (WASM, cross-platform)
- Multi-weight/style variant capture with length-sorted token matching
- External stylesheet inlining with SSRF defense (assertPublicHttpsUrl,
  HTTPS-only, private-host blocking, 2MB cap, 4-concurrent limit)
- Studio auto-import via GET /fonts/file API + renderAliasFor() derived
  from shared FONT_ALIAS_MAP (no more hand-curated drift)
- failClosedFontFetch throws on unresolved fonts in distributed renders
- Single source of truth: @hyperframes/core/fonts/aliases
- system_font_will_alias lint rule (escalates to warning for distributed)
- Default to Inter + JetBrains Mono in templates and CSS reset
This commit is contained in:
Miguel Ángel
2026-06-07 17:52:19 -04:00
committed by GitHub
parent 4da567df22
commit 0bf15119f8
29 changed files with 2244 additions and 226 deletions
+56 -89
View File
@@ -1,40 +1,20 @@
import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
import { platform } from "node:os";
import type { Hono } from "hono";
import {
collectFontFileEntries,
fontDirectories,
locateSystemFont,
SYSTEM_FONT_SIZE_LIMIT,
} from "../../fonts/systemFontLocator";
const FONT_EXT_RE = /\.(otf|ttf|ttc|woff2?)$/i;
const MAX_FONT_RESULTS = 2000;
const GOOGLE_FONTS_METADATA_URL = "https://fonts.google.com/metadata/fonts";
const GOOGLE_FONTS_FETCH_TIMEOUT_MS = 3000;
let cachedFonts: string[] | null = null;
let cachedGoogleFonts: string[] | null = null;
const STYLE_SUFFIXES = new Set([
"black",
"bold",
"book",
"condensed",
"demi",
"demibold",
"display",
"extra",
"extrabold",
"hairline",
"heavy",
"italic",
"light",
"medium",
"normal",
"regular",
"roman",
"semibold",
"thin",
"ultra",
"ultralight",
]);
const GOOGLE_FONT_FALLBACKS = [
"Inter",
"Roboto",
@@ -62,46 +42,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function fontDirectories(): string[] {
const home = homedir();
if (platform() === "darwin") {
return [
join(home, "Library", "Fonts"),
"/Library/Fonts",
"/System/Library/Fonts",
"/System/Library/Fonts/Supplemental",
];
}
if (platform() === "win32") {
return [join(process.env.WINDIR || "C:\\Windows", "Fonts")];
}
return [
join(home, ".fonts"),
join(home, ".local", "share", "fonts"),
"/usr/local/share/fonts",
"/usr/share/fonts",
];
}
function toFamilyName(fileName: string): string | null {
const withoutExt = fileName.replace(FONT_EXT_RE, "");
if (!withoutExt || withoutExt.startsWith(".")) return null;
const spaced = withoutExt
.replace(/[_-]+/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
const words = spaced.split(" ").filter(Boolean);
while (words.length > 1 && STYLE_SUFFIXES.has((words.at(-1) ?? "").toLowerCase())) {
words.pop();
}
const family = words.join(" ").trim();
return family.length >= 2 ? family : null;
}
function collectMacSystemProfilerFonts(): string[] {
if (platform() !== "darwin") return [];
@@ -143,27 +83,8 @@ function collectMacSystemProfilerFonts(): string[] {
return fonts;
}
function collectFontsFromDir(dir: string, depth = 0): string[] {
if (!existsSync(dir) || depth > 2) return [];
const fonts: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
fonts.push(...collectFontsFromDir(fullPath, depth + 1));
continue;
}
if (!entry.isFile() || !FONT_EXT_RE.test(entry.name)) continue;
try {
if (!statSync(fullPath).isFile()) continue;
} catch {
continue;
}
const family = toFamilyName(entry.name);
if (family) fonts.push(family);
}
return fonts;
function collectFontsFromDir(dir: string): string[] {
return collectFontFileEntries(dir).map((e) => e.family);
}
function listInstalledFontFamilies(): string[] {
@@ -244,4 +165,50 @@ async function listGoogleFontFamilies(): Promise<string[]> {
export function registerFontRoutes(api: Hono): void {
api.get("/fonts", (c) => c.json({ fonts: listInstalledFontFamilies() }));
api.get("/fonts/google", async (c) => c.json({ fonts: await listGoogleFontFamilies() }));
// fallow-ignore-next-line complexity
api.get("/fonts/file", (c) => {
const family = c.req.query("family");
if (!family) return c.json({ error: "family parameter required" }, 400);
const located = locateSystemFont(family);
if (!located) return c.json({ error: "font not found" }, 404);
let fd: number;
try {
fd = openSync(located.path, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch {
return c.json({ error: "font file not accessible" }, 404);
}
try {
const stat = fstatSync(fd);
if (stat.size > SYSTEM_FONT_SIZE_LIMIT) {
return c.json({ error: "font file too large" }, 413);
}
const buffer = Buffer.alloc(stat.size);
readSync(fd, buffer, 0, stat.size, 0);
const mimeType =
located.format === "otf"
? "font/otf"
: located.format === "woff2"
? "font/woff2"
: located.format === "woff"
? "font/woff"
: located.format === "ttc"
? "font/collection"
: "font/ttf";
const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
return new Response(buffer, {
headers: {
"Content-Type": mimeType,
"Content-Disposition": `attachment; filename="${fileName}"`,
},
});
} catch {
return c.json({ error: "failed to read font file" }, 500);
} finally {
closeSync(fd);
}
});
}