fix(core): bundled fonts in a linked stylesheet or a font shorthand are seen by the bundler (#6665)

* fix(core): a font declared in a linked stylesheet no longer trips the bundler's font warning

* fix(core): drop the guard's explanatory comment

* fix(core): bundled fonts named only in a CSS font shorthand load

* fix(core): a font shorthand loads faces but never fails the cloud system-font check

* test(producer): regenerate static-volume-future-set for the font shorthand face

* test(producer): regenerate two more fixtures for the font shorthand face
This commit is contained in:
Miguel Ángel
2026-10-28 14:08:07 +00:00
committed by GitHub
parent 5b5633fbbc
commit 67c7ec27d9
15 changed files with 121 additions and 22 deletions
@@ -2589,6 +2589,26 @@ describe("bundleToSingleHtml", () => {
}
});
it("reads a font declared in a linked stylesheet, as hyperframes lint does", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head><link rel="stylesheet" href="fonts.css"><style>#root { font-family: 'Hiragino Sans'; }</style></head><body>
<div id="root" data-composition-id="root" data-width="320" data-height="180" data-duration="1"></div>
<script>window.__timelines = {};</script>
</body></html>`,
"fonts.css": `@font-face { font-family: 'Hiragino Sans'; src: local('Hiragino Sans W3'); }`,
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await bundleToSingleHtml(dir);
const guard = warnSpy.mock.calls
.map((call) => String(call[0] ?? ""))
.filter((line) => line.includes("[StaticGuard]"));
expect(guard).toEqual([]);
} finally {
warnSpy.mockRestore();
}
});
it("runs the StaticGuard lint unless staticGuard is false", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body>
+5 -1
View File
@@ -1104,7 +1104,11 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
const compiled = await compileHtml(rawHtml, sourceDir, options?.probeMediaDuration);
if (options?.staticGuard !== false && !mountedShell) {
const staticGuard = await validateHyperframeHtmlContract(compiled);
const entryRel = relative(projectDir, indexPath).replace(/\\/g, "/");
const staticGuard = await validateHyperframeHtmlContract(compiled, {
dir: projectDir,
...(entryRel !== "index.html" && { compSrcPath: entryRel }),
});
if (!staticGuard.isValid) {
console.warn(
`[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`,
+8 -2
View File
@@ -1,4 +1,4 @@
import { lintHyperframeHtml } from "@hyperframes/lint";
import { collectExternalStyles, lintHyperframeHtml } from "@hyperframes/lint";
export type HyperframeStaticFailureReason =
| "missing_composition_id"
@@ -15,8 +15,14 @@ export type HyperframeStaticGuardResult = {
export async function validateHyperframeHtmlContract(
html: string,
project?: { dir: string; compSrcPath?: string },
): Promise<HyperframeStaticGuardResult> {
const result = await lintHyperframeHtml(html);
const result = await lintHyperframeHtml(
html,
project && {
externalStyles: collectExternalStyles(project.dir, html, project.compSrcPath),
},
);
const missingKeys = result.findings
.filter((finding) => finding.severity === "error")
.map((finding) => finding.message);
@@ -0,0 +1,47 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
let previousCacheDir: string | undefined;
let testCacheDir: string;
beforeAll(() => {
previousCacheDir = process.env.HYPERFRAMES_FONT_CACHE_DIR;
testCacheDir = mkdtempSync(join(tmpdir(), "hf-font-shorthand-"));
process.env.HYPERFRAMES_FONT_CACHE_DIR = testCacheDir;
});
afterAll(() => {
if (previousCacheDir === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
else process.env.HYPERFRAMES_FONT_CACHE_DIR = previousCacheDir;
rmSync(testCacheDir, { recursive: true, force: true });
});
const offline = (async () => {
throw new Error("offline");
}) as unknown as typeof fetch;
const inject = (css: string) =>
injectDeterministicFontFaces(`<html><head><style>${css}</style></head><body>x</body></html>`, {
fetchImpl: offline,
allowSystemFontCapture: false,
});
describe("a bundled family named only in the font shorthand loads", () => {
it.each([
["size and family", "p { font: 24px Inter, sans-serif; }"],
["weight, size, line-height", "p { font: 700 24px/1.2 Inter, sans-serif; }"],
["style, variant, weight, size", 'p { font: italic small-caps bold 1.5rem "Inter", serif; }'],
["a keyword size", "p { font: bold large Inter; }"],
["a calc() size", "p { font: calc(10px + 2vw) Inter; }"],
])("%s", async (_case, css) => {
expect(await inject(css)).toMatch(/@font-face\s*\{[^}]*font-family:\s*"Inter"/i);
});
it("ignores a system-font keyword", async () => {
expect(await inject("p { font: caption; }")).not.toContain("@font-face");
});
});
+21 -5
View File
@@ -245,7 +245,7 @@ export function normalizeSystemFontPrimaryFamilies(
}
/** Surfaces font-family is declared on in served HTML. */
export type FontFamilySurface = "font-family" | "data-font-family";
export type FontFamilySurface = "font-family" | "font" | "data-font-family";
export type FontFamilyDeclaration = {
surface: FontFamilySurface;
@@ -263,16 +263,32 @@ function collectCssCustomProperties(css: string, customProperties: Map<string, s
});
}
const FONT_SIZE_KEYWORD =
/^(?:xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large|smaller|larger)$/i;
const FONT_SIZE_TOKEN = /^(?:\d*\.?\d+[a-z%]+|(?:calc|min|max|clamp|var)\(.*\))(?:\/\S+)?$/i;
function fontShorthandFamilies(value: string): string {
for (const token of value.matchAll(/(?:[^\s(]|\([^)]*\))+/g)) {
const size = token[0].split("/")[0] ?? "";
if (FONT_SIZE_KEYWORD.test(size) || FONT_SIZE_TOKEN.test(token[0])) {
return value.slice(token.index + token[0].length).trim();
}
}
return "";
}
function* iterateCssRootFontFamilyDeclarations(
root: postcss.Root,
): Generator<FontFamilyDeclaration> {
const declarations: FontFamilyDeclaration[] = [];
root.walkDecls((decl) => {
if (decl.prop.toLowerCase() !== "font-family") return;
const prop = decl.prop.toLowerCase();
if (prop !== "font-family" && prop !== "font") return;
if (isFontFaceDeclaration(decl)) return;
const declaration = decl.value;
const declaration = prop === "font" ? fontShorthandFamilies(decl.value) : decl.value;
if (!declaration) return;
declarations.push({
surface: "font-family",
surface: prop,
declaration,
families: parseFontFamilyValue(declaration),
});
@@ -281,7 +297,7 @@ function* iterateCssRootFontFamilyDeclarations(
}
function* iterateCssFontFamilyDeclarations(css: string): Generator<FontFamilyDeclaration> {
if (!/font-family/i.test(css)) return;
if (!/font(?:-family)?\s*:/i.test(css)) return;
const root = parseCssRoot(css);
if (!root) return;
yield* iterateCssRootFontFamilyDeclarations(root);
+1 -1
View File
@@ -11,5 +11,5 @@ export {
LINT_RULE_COUNT,
LINT_RULE_GROUP_COUNTS,
} from "./hyperframeLinter.js";
export { lintProject, shouldBlockRender } from "./project.js";
export { collectExternalStyles, lintProject, shouldBlockRender } from "./project.js";
export type { ProjectLintResult } from "./project.js";
+1 -1
View File
@@ -137,7 +137,7 @@ function collectLocalStylesheets(
return styles;
}
function collectExternalStyles(
export function collectExternalStyles(
projectDir: string,
html: string,
compSrcPath?: string,
@@ -215,6 +215,11 @@ describe("validateNoSystemFonts", () => {
expect(() => validateNoSystemFonts(CLEAN_HTML)).not.toThrow();
});
it("passes a font shorthand with a generic primary, as it did before shorthands loaded faces", () => {
const html = `<!doctype html><html><head><style>p { font: 16px system-ui; } b { font: bold 56px sans-serif; }</style></head><body>x</body></html>`;
expect(() => validateNoSystemFonts(html)).not.toThrow();
});
it("passes when font-family is absent entirely (plain text composition)", () => {
expect(() =>
validateNoSystemFonts(`<!doctype html><html><body><p>no fonts here</p></body></html>`),
@@ -123,6 +123,7 @@ export function validateNoGpuEncode(config: ValidateNoGpuEncodeInput): void {
export function validateNoSystemFonts(compiledHtml: string): void {
const customProperties = collectFontFamilyCustomProperties(compiledHtml);
for (const { surface, declaration } of iterateFontFamilyDeclarations(compiledHtml)) {
if (surface === "font") continue;
const families = resolveFontFamilyDeclarationFamilies(declaration, customProperties);
if (families.length === 0) continue;
const primaryRaw = families[0]!;
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50bb159e7aad75a5672f4918411635b1fd5ef291e217f63734b4589ef949d263
size 1438
oid sha256:03446907f3ff3409c0a7fd32bce7d51c404355bf999423377a9766f9bf52873e
size 169894
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f7c0a9d6609ead40648c7a3fba862c7fbedc97bcf42700363613e509debc504
size 4474
oid sha256:8c28e8e4b657cce67f0a15ce40921646b7127dcaeeff97f5548923d9b1d76ee4
size 4386
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44f07badd4fc8b99f7430ea23d7009263787656ac725d0a70a9c68f5c15c9ab1
size 87166
oid sha256:9d1888293008d33b1771f8da2b2df54501b630c5d20879dd6dbd95443bebf6d2
size 278754
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9be475ab7e29955f7c05cc4b2ab2ea19d6dc484d45b662f8aa01fabf3f0d6b41
size 40122
oid sha256:5017910c1713b05470e9f539b2de19165a6678e83ed9e5476c56f2f78a3e1aad
size 41471
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8055e7dc3cc2ceb108b5336ed7dc20f76ae24677548ece73e6bf6b9f1213c460
size 2468
oid sha256:d9c2b42b7967f8fdd6630b81df59e67952ab6d9428531a031dcd87cd779ae83d
size 170510
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:570ed3a8bfae537da98ea6fb0c0284359827dbbc5e9e8398f2bcfe6f8091b8e0
size 43807
oid sha256:ecbb9a73421929a6ea4967e9e842bb3c917026a5ac4e5305f4ed8452cd9a9d08
size 35916