feat: add @hyperframes/fonts package with build-time font embedding

Extract the font injection logic from the producer's deterministicFonts.ts
into a standalone @hyperframes/fonts package. The package uses a build-time
script to read woff2 files from @fontsource/* packages and embed them as
base64 data URIs, eliminating runtime filesystem access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-03-24 14:49:03 -04:00
co-authored by Claude Opus 4.6
parent 61c5257402
commit 2f0aabcaee
24 changed files with 7407 additions and 311 deletions
+3
View File
@@ -5,6 +5,9 @@ node_modules/
dist/
*.tsbuildinfo
# Generated font data (rebuilt from @fontsource/* packages)
packages/fonts/src/generated/
# Environment
.env
.env.local
+3
View File
@@ -11,6 +11,9 @@ const config: KnipConfig = {
"packages/engine": {
entry: ["src/index.ts"],
},
"packages/fonts": {
entry: ["src/index.ts", "src/catalog.ts"],
},
"packages/producer": {
entry: ["src/index.ts", "src/server.ts"],
},
+104
View File
@@ -0,0 +1,104 @@
{
"name": "@hyperframes/fonts",
"version": "0.1.2",
"description": "Deterministic font injection with build-time woff2 data URI embedding",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./inter": "./src/inter.ts",
"./montserrat": "./src/montserrat.ts",
"./outfit": "./src/outfit.ts",
"./nunito": "./src/nunito.ts",
"./oswald": "./src/oswald.ts",
"./league-gothic": "./src/league-gothic.ts",
"./archivo-black": "./src/archivo-black.ts",
"./space-mono": "./src/space-mono.ts",
"./ibm-plex-mono": "./src/ibm-plex-mono.ts",
"./jetbrains-mono": "./src/jetbrains-mono.ts",
"./eb-garamond": "./src/eb-garamond.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./inter": {
"import": "./dist/inter.js",
"types": "./dist/inter.d.ts"
},
"./montserrat": {
"import": "./dist/montserrat.js",
"types": "./dist/montserrat.d.ts"
},
"./outfit": {
"import": "./dist/outfit.js",
"types": "./dist/outfit.d.ts"
},
"./nunito": {
"import": "./dist/nunito.js",
"types": "./dist/nunito.d.ts"
},
"./oswald": {
"import": "./dist/oswald.js",
"types": "./dist/oswald.d.ts"
},
"./league-gothic": {
"import": "./dist/league-gothic.js",
"types": "./dist/league-gothic.d.ts"
},
"./archivo-black": {
"import": "./dist/archivo-black.js",
"types": "./dist/archivo-black.d.ts"
},
"./space-mono": {
"import": "./dist/space-mono.js",
"types": "./dist/space-mono.d.ts"
},
"./ibm-plex-mono": {
"import": "./dist/ibm-plex-mono.js",
"types": "./dist/ibm-plex-mono.d.ts"
},
"./jetbrains-mono": {
"import": "./dist/jetbrains-mono.js",
"types": "./dist/jetbrains-mono.d.ts"
},
"./eb-garamond": {
"import": "./dist/eb-garamond.js",
"types": "./dist/eb-garamond.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "tsx scripts/generate-font-data.ts && tsc",
"generate": "tsx scripts/generate-font-data.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"linkedom": "^0.18.12"
},
"devDependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/league-gothic": "^5.2.8",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/nunito": "^5.2.7",
"@fontsource/oswald": "^5.2.8",
"@fontsource/outfit": "^5.2.8",
"@fontsource/space-mono": "^5.2.9",
"@types/node": "^22.10.1",
"tsx": "^4.7.0",
"typescript": "^5.7.2"
},
"engines": {
"node": ">=22"
}
}
@@ -0,0 +1,114 @@
#!/usr/bin/env tsx
/**
* Build-time script that reads woff2 font files from @fontsource/* packages,
* base64-encodes them, and writes a generated TypeScript module with data URIs.
*
* Usage:
* tsx scripts/generate-font-data.ts
* pnpm generate
*/
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
// Import the canonical fonts catalog from source
import { CANONICAL_FONTS } from "../src/catalog.js";
const require = createRequire(import.meta.url);
const scriptDir = dirname(fileURLToPath(import.meta.url));
const generatedDir = join(scriptDir, "..", "src", "generated");
function resolvePackageRoot(packageName: string): string {
try {
const packageJsonPath = require.resolve(`${packageName}/package.json`);
return dirname(packageJsonPath);
} catch {
throw new Error(
`Could not resolve ${packageName}. Make sure it is installed (run "bun install" or "pnpm install").`,
);
}
}
function resolveWoff2File(packageName: string, slug: string, weight: number): string {
const root = resolvePackageRoot(packageName);
const filesDir = join(root, "files");
let files: string[];
try {
files = readdirSync(filesDir);
} catch {
throw new Error(`Could not read files directory for ${packageName} at ${filesDir}`);
}
// Try exact match first
const exact = `${slug}-latin-${weight}-normal.woff2`;
if (files.includes(exact)) {
return join(filesDir, exact);
}
// Relaxed match: any file containing "-latin-" and ending with the weight pattern
const relaxed = files.find(
(file) => file.endsWith(`-${weight}-normal.woff2`) && file.includes("-latin-"),
);
if (relaxed) {
return join(filesDir, relaxed);
}
throw new Error(
`No woff2 font file found for ${packageName} weight=${weight}. ` +
`Looked for "${exact}" in ${filesDir}. ` +
`Available files: ${files.filter((f) => f.endsWith(".woff2")).join(", ")}`,
);
}
function main() {
console.log("[generate-font-data] Starting font data generation...");
const entries: Array<{ key: string; dataUri: string }> = [];
let totalBytes = 0;
for (const font of CANONICAL_FONTS) {
const slug = font.fontsourcePackage.replace("@fontsource/", "");
for (const weight of font.weights) {
const filePath = resolveWoff2File(font.fontsourcePackage, slug, weight);
const content = readFileSync(filePath);
const dataUri = `data:font/woff2;base64,${content.toString("base64")}`;
const key = `${font.importName}:${weight}`;
entries.push({ key, dataUri });
totalBytes += content.byteLength;
console.log(` ${key}${(content.byteLength / 1024).toFixed(1)} KB`);
}
}
// Build the output file
const lines = [
"/**",
" * AUTO-GENERATED — do not edit manually.",
" * Run `pnpm generate` (or `tsx scripts/generate-font-data.ts`) to regenerate.",
" */",
"export const FONT_DATA: Record<string, string> = {",
];
for (const { key, dataUri } of entries) {
lines.push(` "${key}": "${dataUri}",`);
}
lines.push("};");
lines.push("");
mkdirSync(generatedDir, { recursive: true });
const outputPath = join(generatedDir, "font-data.ts");
writeFileSync(outputPath, lines.join("\n"), "utf-8");
console.log(
`[generate-font-data] Wrote ${entries.length} font entries ` +
`(${(totalBytes / 1024 / 1024).toFixed(2)} MB total) → ${outputPath}`,
);
}
main();
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'Archivo Black',
importName: 'archivo-black',
category: 'display',
weights: [400] as const,
});
export const fontFamily = 'Archivo Black' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+26
View File
@@ -0,0 +1,26 @@
import { FONT_DATA } from './generated/font-data.js';
export type FontInfo = {
fontFamily: string;
importName: string;
category: string;
weights: readonly number[];
};
export function buildFontFace(info: FontInfo, options?: { weights?: number[] }): string {
const selectedWeights = options?.weights ?? [...info.weights];
const blocks: string[] = [];
for (const weight of selectedWeights) {
const key = `${info.importName}:${weight}`;
const dataUri = FONT_DATA[key];
if (!dataUri) continue;
blocks.push(`@font-face {
font-family: '${info.fontFamily}';
font-style: normal;
font-weight: ${weight};
font-display: swap;
src: url('${dataUri}') format('woff2');
}`);
}
return blocks.join('\n\n');
}
+136
View File
@@ -0,0 +1,136 @@
export type FontMeta = {
name: string;
importName: string;
category: "sans-serif" | "serif" | "monospace" | "display";
weights: readonly number[];
fontsourcePackage: string;
};
export const CANONICAL_FONTS: FontMeta[] = [
{
name: "Inter",
importName: "inter",
category: "sans-serif",
weights: [400, 700, 900],
fontsourcePackage: "@fontsource/inter",
},
{
name: "Montserrat",
importName: "montserrat",
category: "sans-serif",
weights: [400, 700, 900],
fontsourcePackage: "@fontsource/montserrat",
},
{
name: "Outfit",
importName: "outfit",
category: "sans-serif",
weights: [400, 700, 900],
fontsourcePackage: "@fontsource/outfit",
},
{
name: "Nunito",
importName: "nunito",
category: "sans-serif",
weights: [400, 700, 900],
fontsourcePackage: "@fontsource/nunito",
},
{
name: "Oswald",
importName: "oswald",
category: "display",
weights: [400, 700],
fontsourcePackage: "@fontsource/oswald",
},
{
name: "League Gothic",
importName: "league-gothic",
category: "display",
weights: [400],
fontsourcePackage: "@fontsource/league-gothic",
},
{
name: "Archivo Black",
importName: "archivo-black",
category: "display",
weights: [400],
fontsourcePackage: "@fontsource/archivo-black",
},
{
name: "Space Mono",
importName: "space-mono",
category: "monospace",
weights: [400, 700],
fontsourcePackage: "@fontsource/space-mono",
},
{
name: "IBM Plex Mono",
importName: "ibm-plex-mono",
category: "monospace",
weights: [400, 700],
fontsourcePackage: "@fontsource/ibm-plex-mono",
},
{
name: "JetBrains Mono",
importName: "jetbrains-mono",
category: "monospace",
weights: [400, 700],
fontsourcePackage: "@fontsource/jetbrains-mono",
},
{
name: "EB Garamond",
importName: "eb-garamond",
category: "serif",
weights: [400, 700],
fontsourcePackage: "@fontsource/eb-garamond",
},
];
/**
* Maps normalized font family names (lowercase) to canonical font import names.
* Includes aliases for common system fonts that should be substituted.
*/
export const FONT_ALIASES: Record<string, string> = {
inter: "inter",
"helvetica neue": "inter",
helvetica: "inter",
arial: "inter",
"helvetica bold": "inter",
montserrat: "montserrat",
futura: "montserrat",
"din alternate": "montserrat",
"arial black": "montserrat",
outfit: "outfit",
nunito: "nunito",
oswald: "oswald",
"bebas neue": "league-gothic",
"league gothic": "league-gothic",
"archivo black": "archivo-black",
"space mono": "space-mono",
"ibm plex mono": "ibm-plex-mono",
"jetbrains mono": "jetbrains-mono",
"courier new": "jetbrains-mono",
courier: "jetbrains-mono",
"eb garamond": "eb-garamond",
garamond: "eb-garamond",
};
/**
* CSS generic family keywords that should not be resolved to a specific font.
*/
export const GENERIC_FAMILIES = new Set([
"sans-serif",
"serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-sans-serif",
"ui-serif",
"ui-monospace",
"emoji",
"math",
"fangsong",
"-apple-system",
"blinkmacsystemfont",
]);
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'EB Garamond',
importName: 'eb-garamond',
category: 'serif',
weights: [400, 700] as const,
});
export const fontFamily = 'EB Garamond' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'IBM Plex Mono',
importName: 'ibm-plex-mono',
category: 'monospace',
weights: [400, 700] as const,
});
export const fontFamily = 'IBM Plex Mono' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+20
View File
@@ -0,0 +1,20 @@
export { CANONICAL_FONTS, FONT_ALIASES, GENERIC_FAMILIES, type FontMeta } from "./catalog.js";
export { injectDeterministicFontFaces } from "./inject.js";
export { FONT_DATA } from "./generated/font-data.js";
export { buildFontFace, type FontInfo } from "./base.js";
export function getAvailableFonts() {
return [
{ fontFamily: 'Inter' as const, importName: 'inter' as const, load: () => import('./inter.js') },
{ fontFamily: 'Montserrat' as const, importName: 'montserrat' as const, load: () => import('./montserrat.js') },
{ fontFamily: 'Outfit' as const, importName: 'outfit' as const, load: () => import('./outfit.js') },
{ fontFamily: 'Nunito' as const, importName: 'nunito' as const, load: () => import('./nunito.js') },
{ fontFamily: 'Oswald' as const, importName: 'oswald' as const, load: () => import('./oswald.js') },
{ fontFamily: 'League Gothic' as const, importName: 'league-gothic' as const, load: () => import('./league-gothic.js') },
{ fontFamily: 'Archivo Black' as const, importName: 'archivo-black' as const, load: () => import('./archivo-black.js') },
{ fontFamily: 'Space Mono' as const, importName: 'space-mono' as const, load: () => import('./space-mono.js') },
{ fontFamily: 'IBM Plex Mono' as const, importName: 'ibm-plex-mono' as const, load: () => import('./ibm-plex-mono.js') },
{ fontFamily: 'JetBrains Mono' as const, importName: 'jetbrains-mono' as const, load: () => import('./jetbrains-mono.js') },
{ fontFamily: 'EB Garamond' as const, importName: 'eb-garamond' as const, load: () => import('./eb-garamond.js') },
];
}
+150
View File
@@ -0,0 +1,150 @@
import { parseHTML } from "linkedom";
import { CANONICAL_FONTS, FONT_ALIASES, GENERIC_FAMILIES } from "./catalog.js";
import { FONT_DATA } from "./generated/font-data.js";
/**
* Build a lookup from importName → FontMeta for fast access.
*/
const FONT_BY_IMPORT_NAME = new Map(CANONICAL_FONTS.map((f) => [f.importName, f]));
function normalizeFamilyName(family: string): string {
return family
.trim()
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
}
function extractExistingFontFaces(html: string): Set<string> {
const families = new Set<string>();
const fontFaceRegex = /@font-face\s*\{[\s\S]*?font-family\s*:\s*([^;]+);[\s\S]*?\}/gi;
for (const match of html.matchAll(fontFaceRegex)) {
const raw = match[1] || "";
const normalized = normalizeFamilyName(raw);
if (normalized) {
families.add(normalized);
}
}
return families;
}
function extractRequestedFontFamilies(html: string): Map<string, string> {
const requested = new Map<string, string>();
const addFamilyList = (value: string) => {
for (const family of value.split(",")) {
const originalCase = family
.trim()
.replace(/^['"]|['"]$/g, "")
.trim();
const normalized = originalCase.toLowerCase();
if (!normalized || GENERIC_FAMILIES.has(normalized)) {
continue;
}
if (!requested.has(normalized)) {
requested.set(normalized, originalCase);
}
}
};
const fontFamilyRegex = /font-family\s*:\s*([^;}{]+)[;}]?/gi;
for (const match of html.matchAll(fontFamilyRegex)) {
addFamilyList(match[1] || "");
}
const dataFontFamilyRegex = /data-font-family=["']([^"']+)["']/gi;
for (const match of html.matchAll(dataFontFamilyRegex)) {
addFamilyList(match[1] || "");
}
return requested;
}
function fontDataUri(importName: string, weight: number): string | undefined {
const key = `${importName}:${weight}`;
return FONT_DATA[key];
}
function buildFontFaceCss(requestedFamilies: Map<string, string>): {
css: string;
unresolved: string[];
} {
const rules: string[] = [];
const unresolved: string[] = [];
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
const canonicalKey = FONT_ALIASES[normalizedFamily];
if (!canonicalKey) {
unresolved.push(originalCaseFamily);
continue;
}
const canonical = FONT_BY_IMPORT_NAME.get(canonicalKey);
if (!canonical) continue;
for (const weight of canonical.weights) {
const src = fontDataUri(canonical.importName, weight);
if (!src) continue;
rules.push(
[
"@font-face {",
` font-family: "${originalCaseFamily}";`,
` src: url("${src}") format("woff2");`,
` font-style: normal;`,
` font-weight: ${weight};`,
" font-display: block;",
"}",
].join("\n"),
);
}
}
return {
css: rules.join("\n\n").trim(),
unresolved: unresolved.sort(),
};
}
export function injectDeterministicFontFaces(html: string): string {
const existingFaces = extractExistingFontFaces(html);
const requestedFamilies = extractRequestedFontFamilies(html);
const pendingFamilies = new Map<string, string>();
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
if (!existingFaces.has(normalizedFamily)) {
pendingFamilies.set(normalizedFamily, originalCaseFamily);
}
}
if (pendingFamilies.size === 0) {
return html;
}
const { css, unresolved } = buildFontFaceCss(pendingFamilies);
if (!css) {
if (unresolved.length > 0) {
console.warn(`[Compiler] No deterministic font mapping for: ${unresolved.join(", ")}`);
}
return html;
}
const { document } = parseHTML(html);
const head = document.querySelector("head");
if (!head) {
return html;
}
const styleEl = document.createElement("style");
styleEl.setAttribute("data-hyperframes-deterministic-fonts", "true");
styleEl.textContent = css;
head.insertBefore(styleEl, head.firstChild);
console.log(
`[Compiler] Injected deterministic @font-face rules for ${pendingFamilies.size - unresolved.length} requested font families`,
);
if (unresolved.length > 0) {
console.warn(`[Compiler] Unresolved font families left dynamic: ${unresolved.join(", ")}`);
}
return document.toString();
}
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'Inter',
importName: 'inter',
category: 'sans-serif',
weights: [400, 700, 900] as const,
});
export const fontFamily = 'Inter' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'JetBrains Mono',
importName: 'jetbrains-mono',
category: 'monospace',
weights: [400, 700] as const,
});
export const fontFamily = 'JetBrains Mono' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'League Gothic',
importName: 'league-gothic',
category: 'display',
weights: [400] as const,
});
export const fontFamily = 'League Gothic' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'Montserrat',
importName: 'montserrat',
category: 'sans-serif',
weights: [400, 700, 900] as const,
});
export const fontFamily = 'Montserrat' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'Nunito',
importName: 'nunito',
category: 'sans-serif',
weights: [400, 700, 900] as const,
});
export const fontFamily = 'Nunito' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'Oswald',
importName: 'oswald',
category: 'display',
weights: [400, 700] as const,
});
export const fontFamily = 'Oswald' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'Outfit',
importName: 'outfit',
category: 'sans-serif',
weights: [400, 700, 900] as const,
});
export const fontFamily = 'Outfit' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+14
View File
@@ -0,0 +1,14 @@
import { buildFontFace, type FontInfo } from './base.js';
export const getInfo = (): FontInfo => ({
fontFamily: 'Space Mono',
importName: 'space-mono',
category: 'monospace',
weights: [400, 700] as const,
});
export const fontFamily = 'Space Mono' as const;
export function fontFace(options?: { weights?: number[] }): string {
return buildFontFace(getInfo(), options);
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+3
View File
@@ -27,6 +27,9 @@ const workspaceAliasPlugin = {
build.onResolve({ filter: /^@hyperframes\/core\/lint$/ }, () => ({
path: resolve(scriptDir, "../core/src/lint/index.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/fonts$/ }, () => ({
path: resolve(scriptDir, "../fonts/src/index.ts"),
}));
},
};
+1 -11
View File
@@ -39,20 +39,10 @@
"prepublishOnly": "pnpm build"
},
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/league-gothic": "^5.2.8",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/nunito": "^5.2.7",
"@fontsource/oswald": "^5.2.8",
"@fontsource/outfit": "^5.2.8",
"@fontsource/space-mono": "^5.2.9",
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
"@hyperframes/engine": "workspace:^",
"@hyperframes/fonts": "workspace:^",
"hono": "^4.6.0",
"linkedom": "^0.18.12",
"puppeteer": "^24.0.0",
@@ -1,300 +1 @@
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import { parseHTML } from "linkedom";
const require = createRequire(import.meta.url);
type FontFaceSpec = {
weight: string;
style?: "normal" | "italic";
};
type CanonicalFontSpec = {
packageName: string;
faces: FontFaceSpec[];
};
const GENERIC_FAMILIES = new Set([
"sans-serif",
"serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-sans-serif",
"ui-serif",
"ui-monospace",
"emoji",
"math",
"fangsong",
"-apple-system",
"blinkmacsystemfont",
]);
const CANONICAL_FONTS: Record<string, CanonicalFontSpec> = {
inter: {
packageName: "@fontsource/inter",
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
},
montserrat: {
packageName: "@fontsource/montserrat",
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
},
outfit: {
packageName: "@fontsource/outfit",
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
},
nunito: {
packageName: "@fontsource/nunito",
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
},
oswald: {
packageName: "@fontsource/oswald",
faces: [{ weight: "400" }, { weight: "700" }],
},
"league-gothic": {
packageName: "@fontsource/league-gothic",
faces: [{ weight: "400" }],
},
"archivo-black": {
packageName: "@fontsource/archivo-black",
faces: [{ weight: "400" }],
},
"space-mono": {
packageName: "@fontsource/space-mono",
faces: [{ weight: "400" }, { weight: "700" }],
},
"ibm-plex-mono": {
packageName: "@fontsource/ibm-plex-mono",
faces: [{ weight: "400" }, { weight: "700" }],
},
"jetbrains-mono": {
packageName: "@fontsource/jetbrains-mono",
faces: [{ weight: "400" }, { weight: "700" }],
},
"eb-garamond": {
packageName: "@fontsource/eb-garamond",
faces: [{ weight: "400" }, { weight: "700" }],
},
};
const FONT_ALIASES: Record<string, keyof typeof CANONICAL_FONTS> = {
inter: "inter",
"helvetica neue": "inter",
helvetica: "inter",
arial: "inter",
"helvetica bold": "inter",
montserrat: "montserrat",
futura: "montserrat",
"din alternate": "montserrat",
"arial black": "montserrat",
outfit: "outfit",
nunito: "nunito",
oswald: "oswald",
"bebas neue": "league-gothic",
"league gothic": "league-gothic",
"archivo black": "archivo-black",
"space mono": "space-mono",
"ibm plex mono": "ibm-plex-mono",
"jetbrains mono": "jetbrains-mono",
"courier new": "jetbrains-mono",
courier: "jetbrains-mono",
"eb garamond": "eb-garamond",
garamond: "eb-garamond",
};
const PACKAGE_ROOT_CACHE = new Map<string, string>();
const FONT_DATA_URI_CACHE = new Map<string, string>();
function normalizeFamilyName(family: string): string {
return family
.trim()
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
}
function packageRoot(packageName: string): string {
const cached = PACKAGE_ROOT_CACHE.get(packageName);
if (cached) {
return cached;
}
const packageJsonPath = require.resolve(`${packageName}/package.json`);
const root = dirname(packageJsonPath);
PACKAGE_ROOT_CACHE.set(packageName, root);
return root;
}
function resolveFontFile(
packageName: string,
weight: string,
style: "normal" | "italic" = "normal",
): string {
const root = packageRoot(packageName);
const filesDir = join(root, "files");
const slug = packageName.replace("@fontsource/", "");
const files = readdirSync(filesDir);
const exact = `${slug}-latin-${weight}-${style}.woff2`;
if (files.includes(exact)) {
return join(filesDir, exact);
}
const relaxed = files.find((file) => {
return file.endsWith(`-${weight}-${style}.woff2`) && file.includes("-latin-");
});
if (relaxed) {
return join(filesDir, relaxed);
}
throw new Error(
`No deterministic font asset found for ${packageName} weight=${weight} style=${style}`,
);
}
function fontDataUri(
packageName: string,
weight: string,
style: "normal" | "italic" = "normal",
): string {
const key = `${packageName}:${weight}:${style}`;
const cached = FONT_DATA_URI_CACHE.get(key);
if (cached) {
return cached;
}
const fontPath = resolveFontFile(packageName, weight, style);
const content = readFileSync(fontPath);
const uri = `data:font/woff2;base64,${content.toString("base64")}`;
FONT_DATA_URI_CACHE.set(key, uri);
return uri;
}
function extractExistingFontFaces(html: string): Set<string> {
const families = new Set<string>();
const fontFaceRegex = /@font-face\s*\{[\s\S]*?font-family\s*:\s*([^;]+);[\s\S]*?\}/gi;
for (const match of html.matchAll(fontFaceRegex)) {
const raw = match[1] || "";
const normalized = normalizeFamilyName(raw);
if (normalized) {
families.add(normalized);
}
}
return families;
}
function extractRequestedFontFamilies(html: string): Map<string, string> {
const requested = new Map<string, string>();
const addFamilyList = (value: string) => {
for (const family of value.split(",")) {
const originalCase = family
.trim()
.replace(/^['"]|['"]$/g, "")
.trim();
const normalized = originalCase.toLowerCase();
if (!normalized || GENERIC_FAMILIES.has(normalized)) {
continue;
}
if (!requested.has(normalized)) {
requested.set(normalized, originalCase);
}
}
};
const fontFamilyRegex = /font-family\s*:\s*([^;}{]+)[;}]?/gi;
for (const match of html.matchAll(fontFamilyRegex)) {
addFamilyList(match[1] || "");
}
const dataFontFamilyRegex = /data-font-family=["']([^"']+)["']/gi;
for (const match of html.matchAll(dataFontFamilyRegex)) {
addFamilyList(match[1] || "");
}
return requested;
}
function buildFontFaceCss(requestedFamilies: Map<string, string>): {
css: string;
unresolved: string[];
} {
const rules: string[] = [];
const unresolved: string[] = [];
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
const canonicalKey = FONT_ALIASES[normalizedFamily];
if (!canonicalKey) {
unresolved.push(originalCaseFamily);
continue;
}
const canonical = CANONICAL_FONTS[canonicalKey];
if (!canonical) continue;
for (const face of canonical.faces) {
const style = face.style || "normal";
const src = fontDataUri(canonical.packageName, face.weight, style);
rules.push(
[
"@font-face {",
` font-family: "${originalCaseFamily}";`,
` src: url("${src}") format("woff2");`,
` font-style: ${style};`,
` font-weight: ${face.weight};`,
" font-display: block;",
"}",
].join("\n"),
);
}
}
return {
css: rules.join("\n\n").trim(),
unresolved: unresolved.sort(),
};
}
export function injectDeterministicFontFaces(html: string): string {
const existingFaces = extractExistingFontFaces(html);
const requestedFamilies = extractRequestedFontFamilies(html);
const pendingFamilies = new Map<string, string>();
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
if (!existingFaces.has(normalizedFamily)) {
pendingFamilies.set(normalizedFamily, originalCaseFamily);
}
}
if (pendingFamilies.size === 0) {
return html;
}
const { css, unresolved } = buildFontFaceCss(pendingFamilies);
if (!css) {
if (unresolved.length > 0) {
console.warn(`[Compiler] No deterministic font mapping for: ${unresolved.join(", ")}`);
}
return html;
}
const { document } = parseHTML(html);
const head = document.querySelector("head");
if (!head) {
return html;
}
const styleEl = document.createElement("style");
styleEl.setAttribute("data-hyperframes-deterministic-fonts", "true");
styleEl.textContent = css;
head.insertBefore(styleEl, head.firstChild);
console.log(
`[Compiler] Injected deterministic @font-face rules for ${pendingFamilies.size - unresolved.length} requested font families`,
);
if (unresolved.length > 0) {
console.warn(`[Compiler] Unresolved font families left dynamic: ${unresolved.join(", ")}`);
}
return document.toString();
}
export { injectDeterministicFontFaces } from "@hyperframes/fonts";
+6674
View File
File diff suppressed because it is too large Load Diff