diff --git a/packages/core/src/fonts/aliases.ts b/packages/core/src/fonts/aliases.ts
index 14c7346b83..d88f5d5fc2 100644
--- a/packages/core/src/fonts/aliases.ts
+++ b/packages/core/src/fonts/aliases.ts
@@ -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";
diff --git a/packages/lint/src/rules/fonts.test.ts b/packages/lint/src/rules/fonts.test.ts
index 8facee61c9..4e8c808c7f 100644
--- a/packages/lint/src/rules/fonts.test.ts
+++ b/packages/lint/src/rules/fonts.test.ts
@@ -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 = `
+
+
`;
+ 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 = `
diff --git a/packages/lint/src/rules/fonts.ts b/packages/lint/src/rules/fonts.ts
index cba97865f2..d9fdefa548 100644
--- a/packages/lint/src/rules/fonts.ts
+++ b/packages/lint/src/rules/fonts.ts
@@ -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";
@@ -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;
diff --git a/packages/parsers/src/composition.ts b/packages/parsers/src/composition.ts
index ac078646ba..1d9c4c48e9 100644
--- a/packages/parsers/src/composition.ts
+++ b/packages/parsers/src/composition.ts
@@ -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";
diff --git a/packages/parsers/src/fontAliases.ts b/packages/parsers/src/fontAliases.ts
index 9328f0dc5a..a4fd03190d 100644
--- a/packages/parsers/src/fontAliases.ts
+++ b/packages/parsers/src/fontAliases.ts
@@ -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",
@@ -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",
@@ -93,6 +95,29 @@ export const FONT_ALIAS_MAP = {
export const FONT_ALIAS_KEYS: ReadonlySet
= 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> = {
+ 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 = 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.
diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts
index 2baf100c85..93d919af0d 100644
--- a/packages/parsers/src/index.ts
+++ b/packages/parsers/src/index.ts
@@ -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";
diff --git a/packages/producer/src/services/deterministicFonts-failClosed.test.ts b/packages/producer/src/services/deterministicFonts-failClosed.test.ts
index f2c7cb4c94..29c5000ec4 100644
--- a/packages/producer/src/services/deterministicFonts-failClosed.test.ts
+++ b/packages/producer/src/services/deterministicFonts-failClosed.test.ts
@@ -15,6 +15,7 @@
*/
import { describe, expect, it } from "bun:test";
+import { parseHTML } from "linkedom";
import {
FONT_FETCH_FAILED,
FontFetchError,
@@ -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 = `hello
`;
+
+ 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 {
diff --git a/packages/producer/src/services/deterministicFonts.test.ts b/packages/producer/src/services/deterministicFonts.test.ts
index 3dabac055a..b0bbd7f16b 100644
--- a/packages/producer/src/services/deterministicFonts.test.ts
+++ b/packages/producer/src/services/deterministicFonts.test.ts
@@ -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");
});
diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts
index df4664d4a1..742844154f 100644
--- a/packages/producer/src/services/deterministicFonts.ts
+++ b/packages/producer/src/services/deterministicFonts.ts
@@ -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,
@@ -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}`;