diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 95441bf3f3..fc63c86054 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -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": ` +
+ +`, + "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": ` diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 37abbbb008..22327d4a15 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -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("; ")}`, diff --git a/packages/core/src/compiler/staticGuard.ts b/packages/core/src/compiler/staticGuard.ts index 568a6133a6..d13be15214 100644 --- a/packages/core/src/compiler/staticGuard.ts +++ b/packages/core/src/compiler/staticGuard.ts @@ -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 { - 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); diff --git a/packages/core/src/fonts/deterministicFonts-shorthand.test.ts b/packages/core/src/fonts/deterministicFonts-shorthand.test.ts new file mode 100644 index 0000000000..f2ae8112a1 --- /dev/null +++ b/packages/core/src/fonts/deterministicFonts-shorthand.test.ts @@ -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(`x`, { + 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"); + }); +}); diff --git a/packages/core/src/fonts/deterministicFonts.ts b/packages/core/src/fonts/deterministicFonts.ts index 316eb3ae30..2bb382872f 100644 --- a/packages/core/src/fonts/deterministicFonts.ts +++ b/packages/core/src/fonts/deterministicFonts.ts @@ -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 { 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 { - 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); diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index c6f707a9fe..060cbf1a92 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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"; diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index da71259d1e..bd31c920ce 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -137,7 +137,7 @@ function collectLocalStylesheets( return styles; } -function collectExternalStyles( +export function collectExternalStyles( projectDir: string, html: string, compSrcPath?: string, diff --git a/packages/producer/src/services/render/planValidation.test.ts b/packages/producer/src/services/render/planValidation.test.ts index df0dc15879..e8ad270612 100644 --- a/packages/producer/src/services/render/planValidation.test.ts +++ b/packages/producer/src/services/render/planValidation.test.ts @@ -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 = `x`; + expect(() => validateNoSystemFonts(html)).not.toThrow(); + }); + it("passes when font-family is absent entirely (plain text composition)", () => { expect(() => validateNoSystemFonts(`

no fonts here

`), diff --git a/packages/producer/src/services/render/planValidation.ts b/packages/producer/src/services/render/planValidation.ts index b072c42566..e933d40a48 100644 --- a/packages/producer/src/services/render/planValidation.ts +++ b/packages/producer/src/services/render/planValidation.ts @@ -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]!; diff --git a/packages/producer/tests/distributed/plan-v2-partial-color/output/compiled.html b/packages/producer/tests/distributed/plan-v2-partial-color/output/compiled.html index 86237903aa..d92822299c 100644 --- a/packages/producer/tests/distributed/plan-v2-partial-color/output/compiled.html +++ b/packages/producer/tests/distributed/plan-v2-partial-color/output/compiled.html @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:50bb159e7aad75a5672f4918411635b1fd5ef291e217f63734b4589ef949d263 -size 1438 +oid sha256:03446907f3ff3409c0a7fd32bce7d51c404355bf999423377a9766f9bf52873e +size 169894 diff --git a/packages/producer/tests/distributed/plan-v2-partial-color/output/output.mp4 b/packages/producer/tests/distributed/plan-v2-partial-color/output/output.mp4 index f3a4056d82..f0e9b805e5 100644 --- a/packages/producer/tests/distributed/plan-v2-partial-color/output/output.mp4 +++ b/packages/producer/tests/distributed/plan-v2-partial-color/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f7c0a9d6609ead40648c7a3fba862c7fbedc97bcf42700363613e509debc504 -size 4474 +oid sha256:8c28e8e4b657cce67f0a15ce40921646b7127dcaeeff97f5548923d9b1d76ee4 +size 4386 diff --git a/packages/producer/tests/distributed/static-volume-future-set/output/compiled.html b/packages/producer/tests/distributed/static-volume-future-set/output/compiled.html index c09aef5fde..ec8445d8dc 100644 --- a/packages/producer/tests/distributed/static-volume-future-set/output/compiled.html +++ b/packages/producer/tests/distributed/static-volume-future-set/output/compiled.html @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:44f07badd4fc8b99f7430ea23d7009263787656ac725d0a70a9c68f5c15c9ab1 -size 87166 +oid sha256:9d1888293008d33b1771f8da2b2df54501b630c5d20879dd6dbd95443bebf6d2 +size 278754 diff --git a/packages/producer/tests/distributed/static-volume-future-set/output/output.mp4 b/packages/producer/tests/distributed/static-volume-future-set/output/output.mp4 index 9445a62931..35d9f4fa6f 100644 --- a/packages/producer/tests/distributed/static-volume-future-set/output/output.mp4 +++ b/packages/producer/tests/distributed/static-volume-future-set/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9be475ab7e29955f7c05cc4b2ab2ea19d6dc484d45b662f8aa01fabf3f0d6b41 -size 40122 +oid sha256:5017910c1713b05470e9f539b2de19165a6678e83ed9e5476c56f2f78a3e1aad +size 41471 diff --git a/packages/producer/tests/gradient-text-layered-child/output/compiled.html b/packages/producer/tests/gradient-text-layered-child/output/compiled.html index 00d89c5a93..51ded702c5 100644 --- a/packages/producer/tests/gradient-text-layered-child/output/compiled.html +++ b/packages/producer/tests/gradient-text-layered-child/output/compiled.html @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8055e7dc3cc2ceb108b5336ed7dc20f76ae24677548ece73e6bf6b9f1213c460 -size 2468 +oid sha256:d9c2b42b7967f8fdd6630b81df59e67952ab6d9428531a031dcd87cd779ae83d +size 170510 diff --git a/packages/producer/tests/gradient-text-layered-child/output/output.mp4 b/packages/producer/tests/gradient-text-layered-child/output/output.mp4 index 71f60fffb2..ba90a0a174 100644 --- a/packages/producer/tests/gradient-text-layered-child/output/output.mp4 +++ b/packages/producer/tests/gradient-text-layered-child/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:570ed3a8bfae537da98ea6fb0c0284359827dbbc5e9e8398f2bcfe6f8091b8e0 -size 43807 +oid sha256:ecbb9a73421929a6ea4967e9e842bb3c917026a5ac4e5305f4ed8452cd9a9d08 +size 35916