Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/fonts/aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
export {
FONT_ALIAS_MAP,
FONT_ALIAS_KEYS,
GOOGLE_FONT_FAMILY_ALIASES,
GOOGLE_FONT_FAMILY_ALIAS_KEYS,
CANONICAL_FONT_DISPLAY_NAMES,
resolveAliasDisplayName,
} from "@hyperframes/parsers";
21 changes: 21 additions & 0 deletions packages/lint/src/rules/fonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,27 @@ describe("font rules", () => {
expect(findings).toHaveLength(0);
});

for (const family of [
"FredericktheGreat",
"Pretendard",
"Pyidaungsu",
"Yantra Manav",
"Noto Serif Arabic",
"Noto Serif Urdu",
"Noto Serif VI",
"Noto Sans Greek",
"Noto Sans Odia",
"Noto Sans Urdu",
]) {
it(`does not flag the producer-resolved Google family alias ${family}`, async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: '${family}', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
}

it("still flags Google-Fonts-only fonts not pre-bundled", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Geist', sans-serif; }</style>
Expand Down
7 changes: 6 additions & 1 deletion packages/lint/src/rules/fonts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers/composition";
import {
FONT_ALIAS_KEYS,
GOOGLE_FONT_FAMILY_ALIAS_KEYS,
resolveAliasDisplayName,
} from "@hyperframes/parsers/composition";
import type { LintContext, HyperframeLintFinding } from "../context";
import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition";

Expand Down Expand Up @@ -223,6 +227,7 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
(name) =>
!declared.has(name) &&
!FONT_ALIAS_KEYS.has(name) &&
!GOOGLE_FONT_FAMILY_ALIAS_KEYS.has(name) &&
!googleFonts.has(name.replace(/\+/g, " ")),
);
if (undeclared.length === 0) return findings;
Expand Down
2 changes: 2 additions & 0 deletions packages/parsers/src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export * from "./types.js";
export {
FONT_ALIAS_MAP,
FONT_ALIAS_KEYS,
GOOGLE_FONT_FAMILY_ALIASES,
GOOGLE_FONT_FAMILY_ALIAS_KEYS,
CANONICAL_FONT_DISPLAY_NAMES,
resolveAliasDisplayName,
} from "./fontAliases.js";
Expand Down
25 changes: 25 additions & 0 deletions packages/parsers/src/fontAliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const FONT_ALIAS_MAP = {
arial: "inter",
"helvetica bold": "inter",
futura: "montserrat",
"futura std": "montserrat",
"din alternate": "montserrat",
"arial black": "montserrat",
"bebas neue": "league-gothic",
Expand Down Expand Up @@ -62,6 +63,7 @@ export const FONT_ALIAS_MAP = {
corbel: "inter",
"lucida sans": "inter",
"lucida sans unicode": "inter",
"ms sans serif": "inter",

// ── Linux sans-serif system fonts → inter ─────────────────────────────
"noto sans": "inter",
Expand Down Expand Up @@ -93,6 +95,29 @@ export const FONT_ALIAS_MAP = {

export const FONT_ALIAS_KEYS: ReadonlySet<string> = new Set(Object.keys(FONT_ALIAS_MAP));

/**
* Authoring names that are not served by Google Fonts verbatim, but have a
* deterministic, script-compatible Google family. Unlike FONT_ALIAS_MAP,
* these preserve the authored CSS family and only change the upstream fetch
* name used to build its injected @font-face rules.
*/
export const GOOGLE_FONT_FAMILY_ALIASES: Readonly<Record<string, string>> = {
frederickthegreat: "Fredericka the Great",
pretendard: "Noto Sans KR",
pyidaungsu: "Noto Sans Myanmar",
"yantra manav": "Yantramanav",
"noto serif arabic": "Noto Naskh Arabic",
"noto serif urdu": "Noto Nastaliq Urdu",
"noto serif vi": "Noto Serif",
"noto sans greek": "Noto Sans",
"noto sans odia": "Noto Sans Oriya",
"noto sans urdu": "Noto Sans Arabic",
};

export const GOOGLE_FONT_FAMILY_ALIAS_KEYS: ReadonlySet<string> = new Set(
Object.keys(GOOGLE_FONT_FAMILY_ALIASES),
);

/**
* Human-readable display names for canonical font slugs. Used by the lint
* rule to tell authors what their aliased font will render as.
Expand Down
2 changes: 2 additions & 0 deletions packages/parsers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
export {
FONT_ALIAS_MAP,
FONT_ALIAS_KEYS,
GOOGLE_FONT_FAMILY_ALIASES,
GOOGLE_FONT_FAMILY_ALIAS_KEYS,
CANONICAL_FONT_DISPLAY_NAMES,
resolveAliasDisplayName,
} from "./fontAliases.js";
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { describe, expect, it } from "bun:test";
import { parseHTML } from "linkedom";
import {
FONT_FETCH_FAILED,
FontFetchError,
Expand Down Expand Up @@ -141,6 +142,41 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
});
}

for (const [authoredFamily, googleFamily] of [
["FredericktheGreat", "Fredericka the Great"],
["Pretendard", "Noto Sans KR"],
["Pyidaungsu", "Noto Sans Myanmar"],
["Yantra Manav", "Yantramanav"],
["Noto Serif Arabic", "Noto Naskh Arabic"],
["Noto Serif Urdu", "Noto Nastaliq Urdu"],
["Noto Serif VI", "Noto Serif"],
["Noto Sans Greek", "Noto Sans"],
["Noto Sans Odia", "Noto Sans Oriya"],
["Noto Sans Urdu", "Noto Sans Arabic"],
] as const) {
it(`resolves observed family alias ${authoredFamily} through ${googleFamily}`, async () => {
const cssRequests: string[] = [];
const html = `<!doctype html><html><head><style>
body { font-family: "${authoredFamily}", sans-serif; }
</style></head><body><p>hello</p></body></html>`;

const result = await injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: makeGoogleFontFetch(cssRequests),
});

expect(cssRequests).toHaveLength(1);
const familyParam = new URL(cssRequests[0]!).searchParams.get("family");
expect(familyParam?.startsWith(`${googleFamily}:`)).toBe(true);
const { document } = parseHTML(result);
const injectedCss = document.querySelector(
'style[data-hyperframes-deterministic-fonts="true"]',
)?.textContent;
expect(injectedCss).toContain(`font-family: "${authoredFamily}"`);
});
}

it("throws FontFetchError on a network failure", async () => {
let caught: unknown;
try {
Expand Down
2 changes: 2 additions & 0 deletions packages/producer/src/services/deterministicFonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ describe("FONT_ALIASES cross-platform coverage", () => {
expect(FONT_ALIASES["courier new"]).toBe("jetbrains-mono");
expect(FONT_ALIASES["segoe ui"]).toBe("roboto");
expect(FONT_ALIASES["futura"]).toBe("montserrat");
expect(FONT_ALIASES["futura std"]).toBe("montserrat");
expect(FONT_ALIASES["ms sans serif"]).toBe("inter");
expect(FONT_ALIASES["bebas neue"]).toBe("league-gothic");
});

Expand Down
5 changes: 3 additions & 2 deletions packages/producer/src/services/deterministicFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { defaultLogger } from "../logger.js";

import { FONT_ALIAS_MAP } from "@hyperframes/core/fonts/aliases";
import { FONT_ALIAS_MAP, GOOGLE_FONT_FAMILY_ALIASES } from "@hyperframes/core/fonts/aliases";
import {
locateSystemFontVariants,
SYSTEM_FONT_SIZE_LIMIT,
Expand Down Expand Up @@ -748,7 +748,8 @@ async function fetchGoogleFont(
// space. Resolve that URL-style spelling through the canonical Google family
// while preserving `familyName` for the emitted @font-face alias so the
// authored CSS still matches it.
const googleFamilyName = familyName.replace(/\+/g, " ");
const googleFamilyName =
GOOGLE_FONT_FAMILY_ALIASES[normalizeFamilyName(familyName)] ?? familyName.replace(/\+/g, " ");
const encodedFamily = encodeURIComponent(googleFamilyName);
const textParam = fontText ? `&text=${encodeURIComponent(fontText)}` : "";
const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700${textParam}`;
Expand Down
Loading