mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Claude Opus 4.6
parent
61c5257402
commit
2f0aabcaee
@@ -5,6 +5,9 @@ node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Generated font data (rebuilt from @fontsource/* packages)
|
||||
packages/fonts/src/generated/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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') },
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Generated
+6674
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user