mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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("; ")}`,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user