mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
perf(core): reuse fonts, asset paths and scene parses across a page build (#5954)
* perf(core): a project with many composition variables builds without a style scan per variable * perf(core): a build checks each project path once, however often a scene is mounted * perf(core): collecting a page's characters for fonts no longer copies the page into arrays * perf(core): a warm font cache builds the page without asking Google Fonts again The woff2 files were cached on disk, but the CSS lookup that names them was only cached in memory. Each new process asked fonts.googleapis.com first, and offline the lookup failed before the cached woff2 files were ever reached. The lookup is now kept on disk beside them, keyed by its request URL. Only a lookup that named faces is kept, written aside and renamed into place. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf(core): a build resolves and reads each inlined asset once, however often a scene is mounted Inlining a relative asset as a data: URL ran isSafePath on it, which realpaths the path and every ancestor, then statted, read and base64-encoded the file. It did so for every url() of every mount, so a scene mounted 160 times paid for the same few files 160 times. The answers are now kept for the build: a path's safety is shared with the entry-path checks, and an asset's data: URL (or its too-large warning) is kept by file path. The bundler also tells onRead about each path once per build. The preview's read tracker realpaths and readlinks every path it is told about, and it was told again for every mount. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf(core): a scene mounted many times is read, parsed and scoped once per build Every mount of a sub-composition re-read its file, re-stamped its hf ids, parsed it four times (twice to check it is usable, twice to mount it) and re-ran postcss over each of its stylesheets, though only the scope selector, ids and variables differ between mounts. - The bundler reads and stamps each scene file once per build. - inlineSubCompositions parses each file once, answers the usability check from that parse, and gives each mount its own clone to edit. - createCompositionCssScoper scopes a sheet once around a placeholder scope shaped like the real ones, then substitutes each mount's scope. It falls back to scopeCssToComposition for any scope or id outside [\w.:-], a scope equal to the authored id, a sheet holding the placeholder, or a selector kept raw for a comment. A golden test pins the preview and render pages of a representative project (goldens written by the build before this change), and a fuzz test compares the scoper with scopeCssToComposition over random sheets and scopes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Miguel Angel Simon Sierra
parent
cfdddd8a90
commit
884d8690af
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment node
|
||||
import postcss from "postcss";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCompositionCssScoper, scopeCssToComposition } from "./compositionScoping";
|
||||
|
||||
const scopeOf = (id: string) => `[data-composition-id="${id}"]`;
|
||||
|
||||
// Selector pieces that reach every branch of the scoping: root ids with and without timing
|
||||
// attributes, document-level selectors, authored root ids, comments, nesting and global at-rules.
|
||||
const piecesFor = (id: string) => [
|
||||
".a",
|
||||
"#card-root",
|
||||
`[data-composition-id="${id}"]`,
|
||||
`[data-composition-id='${id}']`,
|
||||
`[data-composition-id^="${id}"]`,
|
||||
'[data-start="0"]',
|
||||
'[data-duration="2"]',
|
||||
`[data-start="[data-composition-id='${id}']"]`,
|
||||
"html",
|
||||
"body",
|
||||
":root",
|
||||
"*",
|
||||
" ",
|
||||
" > ",
|
||||
", ",
|
||||
"/* note */",
|
||||
":hover",
|
||||
`[data-composition-id="${id}__hf2"]`,
|
||||
'[data-composition-id="hf-scope-placeholder"]',
|
||||
"&",
|
||||
];
|
||||
const WRAPPERS = [
|
||||
(rule: string) => rule,
|
||||
(rule: string) => `@media (min-width: 10px) { ${rule} }`,
|
||||
(rule: string) => `@keyframes k { from { opacity: 0; } } ${rule}`,
|
||||
(rule: string) => `.outer { ${rule} }`,
|
||||
];
|
||||
|
||||
function randomSheets(id: string, count: number): string[] {
|
||||
const pieces = piecesFor(id);
|
||||
// mulberry32: a seeded generator whose low bits are as random as its high ones.
|
||||
let seed = 7;
|
||||
const next = (n: number) => {
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) % n;
|
||||
};
|
||||
return Array.from({ length: count }, () => {
|
||||
const rules = Array.from({ length: 1 + next(4) }, () => {
|
||||
const selector = Array.from({ length: 1 + next(5) }, () => pieces[next(pieces.length)]);
|
||||
return WRAPPERS[next(WRAPPERS.length)]!(`${selector.join("")} { color: red; }`);
|
||||
});
|
||||
return rules.join("\n");
|
||||
});
|
||||
}
|
||||
|
||||
const scopesFor = (id: string) => [
|
||||
scopeOf(`${id}__hf1`),
|
||||
scopeOf(`${id}__hf2`),
|
||||
scopeOf(id),
|
||||
scopeOf("other"),
|
||||
scopeOf("price$&tag"),
|
||||
'[data-composition-id="quote\\"d"]',
|
||||
"",
|
||||
];
|
||||
|
||||
describe("createCompositionCssScoper", () => {
|
||||
it("scopes every sheet exactly as scopeCssToComposition does, whatever the mount's scope", () => {
|
||||
const scope = createCompositionCssScoper();
|
||||
for (const id of ["card", "c.a:rd", 'ca"rd', "ca]rd", "hf-scope-placeholder"]) {
|
||||
for (const css of randomSheets(id, 300)) {
|
||||
for (const compound of [false, true]) {
|
||||
for (const authoredRootId of [undefined, "card-root"]) {
|
||||
for (const selector of scopesFor(id)) {
|
||||
const options = { compoundAuthoredRoot: compound, scopeRootSelectors: true };
|
||||
expect(scope(css, id, selector, authoredRootId, options)).toBe(
|
||||
scopeCssToComposition(css, id, selector, authoredRootId, options),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("parses a sheet once for all of its mounts", () => {
|
||||
const parse = vi.spyOn(postcss, "parse");
|
||||
const scope = createCompositionCssScoper();
|
||||
const css = `.title { color: var(--accent); } #card-root .row { background: url(a.png); }`;
|
||||
const scoped = [1, 2, 3, 4].map((n) =>
|
||||
scope(css, "card", scopeOf(`card__hf${n}`), "card-root"),
|
||||
);
|
||||
expect(parse).toHaveBeenCalledOnce();
|
||||
parse.mockRestore();
|
||||
scoped.forEach((out, i) => {
|
||||
expect(out).toBe(
|
||||
scopeCssToComposition(css, "card", scopeOf(`card__hf${i + 1}`), "card-root"),
|
||||
);
|
||||
expect(out).toContain(`${scopeOf(`card__hf${i + 1}`)} .title`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -161,43 +161,129 @@ function isNestedInsideAnotherRule(rule: Rule): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
type ScopeOptions = { compoundAuthoredRoot?: boolean; scopeRootSelectors?: boolean };
|
||||
|
||||
export function scopeCssToComposition(
|
||||
css: string,
|
||||
compositionId: string,
|
||||
scopeSelectorOverride?: string,
|
||||
authoredRootId?: string | null,
|
||||
options?: { compoundAuthoredRoot?: boolean; scopeRootSelectors?: boolean },
|
||||
options?: ScopeOptions,
|
||||
): string {
|
||||
const trimmedCompositionId = compositionId.trim();
|
||||
if (!css || !trimmedCompositionId) return css;
|
||||
const scope =
|
||||
scopeSelectorOverride ||
|
||||
`[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`;
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
root = postcss.parse(css);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
const root = parseCss(css);
|
||||
return root ? scopeParsedCss(root, scope, trimmedCompositionId, authoredRootId, options) : "";
|
||||
}
|
||||
|
||||
function parseCss(css: string): postcss.Root | null {
|
||||
try {
|
||||
return postcss.parse(css);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function forEachScopedRule(root: postcss.Root, visit: (rule: Rule) => void): void {
|
||||
root.walkRules((rule) => {
|
||||
if (isInsideGlobalAtRule(rule)) return;
|
||||
if (isNestedInsideAnotherRule(rule)) return;
|
||||
visit(rule);
|
||||
});
|
||||
}
|
||||
|
||||
function scopeParsedCss(
|
||||
root: postcss.Root,
|
||||
scope: string,
|
||||
compositionId: string,
|
||||
authoredRootId: string | null | undefined,
|
||||
options: ScopeOptions | undefined,
|
||||
): string {
|
||||
forEachScopedRule(root, (rule) => {
|
||||
rule.selectors = rule.selectors.map((selector) =>
|
||||
scopeSelector(
|
||||
selector,
|
||||
scope,
|
||||
trimmedCompositionId,
|
||||
compositionId,
|
||||
authoredRootId,
|
||||
options?.compoundAuthoredRoot,
|
||||
options?.scopeRootSelectors,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return root.toResult({ map: false }).css;
|
||||
}
|
||||
|
||||
// Shaped like every scope it stands in for, so each selector test treats the two alike; its only `[` is
|
||||
// its first character, so no occurrence of it overlaps another or the text around it.
|
||||
const PLACEHOLDER_ID = "hf-scope-placeholder";
|
||||
const PLACEHOLDER_SCOPE = `[data-composition-id="${PLACEHOLDER_ID}"]`;
|
||||
// No quote, bracket, backslash, space or `$` (a String.replace pattern): such a scope lands verbatim.
|
||||
const PLAIN_ID = /^[\w.:-]+$/;
|
||||
const PLAIN_SCOPE = /^\[data-composition-id="([\w.:-]+)"\]$/;
|
||||
|
||||
/** `scopeCssToComposition` for sheets mounted many times: each sheet is scoped once around a
|
||||
* placeholder scope, which each mount's own scope then replaces. */
|
||||
export function createCompositionCssScoper(): typeof scopeCssToComposition {
|
||||
const templates = new Map<string, string | null>();
|
||||
return (css, compositionId, scopeSelectorOverride, authoredRootId, options) => {
|
||||
const trimmedId = compositionId.trim();
|
||||
const scope = scopeSelectorOverride ?? "";
|
||||
const scopeId = PLAIN_SCOPE.exec(scope)?.[1];
|
||||
if (
|
||||
!scopeId ||
|
||||
scopeId === trimmedId ||
|
||||
!PLAIN_ID.test(trimmedId) ||
|
||||
trimmedId === PLACEHOLDER_ID
|
||||
)
|
||||
return scopeCssToComposition(
|
||||
css,
|
||||
compositionId,
|
||||
scopeSelectorOverride,
|
||||
authoredRootId,
|
||||
options,
|
||||
);
|
||||
const key = JSON.stringify([
|
||||
trimmedId,
|
||||
authoredRootId ?? null,
|
||||
Boolean(options?.compoundAuthoredRoot),
|
||||
Boolean(options?.scopeRootSelectors),
|
||||
css,
|
||||
]);
|
||||
let template = templates.get(key);
|
||||
if (template === undefined) {
|
||||
template = scopeTemplate(css, trimmedId, authoredRootId, options);
|
||||
templates.set(key, template);
|
||||
}
|
||||
return template === null
|
||||
? scopeCssToComposition(css, compositionId, scopeSelectorOverride, authoredRootId, options)
|
||||
: template.split(PLACEHOLDER_SCOPE).join(scope);
|
||||
};
|
||||
}
|
||||
|
||||
/** Null when the placeholder is already in the sheet, or a selector keeps its raw text (it holds a comment). */
|
||||
function scopeTemplate(
|
||||
css: string,
|
||||
compositionId: string,
|
||||
authoredRootId: string | null | undefined,
|
||||
options: ScopeOptions | undefined,
|
||||
): string | null {
|
||||
if (css.includes(PLACEHOLDER_SCOPE)) return null;
|
||||
if (!css) return css;
|
||||
const root = parseCss(css);
|
||||
if (!root) return "";
|
||||
let rawSelector = false;
|
||||
forEachScopedRule(root, (rule) => {
|
||||
rawSelector ||= Boolean(rule.raws.selector);
|
||||
});
|
||||
return rawSelector
|
||||
? null
|
||||
: scopeParsedCss(root, PLACEHOLDER_SCOPE, compositionId, authoredRootId, options);
|
||||
}
|
||||
|
||||
function isFontFaceAtRule(node: { type: string; name?: string }): node is AtRule {
|
||||
return node.type === "atrule" && (node as AtRule).name.toLowerCase() === "font-face";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @vitest-environment node
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { bundleToSingleHtml } from "./htmlBundler";
|
||||
|
||||
const checked = vi.hoisted(() => [] as string[]);
|
||||
@@ -20,6 +20,10 @@ vi.mock("../safePath.js", async (importOriginal) => {
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-bundle-entry-paths-"));
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
beforeEach(() => {
|
||||
checked.length = 0;
|
||||
});
|
||||
const repeatsIn = (paths: string[]) => paths.filter((path, at) => paths.indexOf(path) !== at);
|
||||
|
||||
it("checks each project path once per build, however many times a sub-composition is mounted", async () => {
|
||||
mkdirSync(join(dir, "compositions"), { recursive: true });
|
||||
@@ -53,6 +57,55 @@ ${[0, 2, 4].map(mount).join("\n")}
|
||||
|
||||
expect(bundled).toContain("url(compositions/texture.png)");
|
||||
expect(bundled).toContain("url(assets/logo.png)");
|
||||
const repeated = checked.filter((path, at) => checked.indexOf(path) !== at);
|
||||
expect(repeated).toEqual([]);
|
||||
expect(repeatsIn(checked)).toEqual([]);
|
||||
});
|
||||
|
||||
it("inlines each asset of a scene mounted many times after one check and one read of its path", async () => {
|
||||
const project = mkdtempSync(join(dir, "inline-"));
|
||||
mkdirSync(join(project, "compositions"), { recursive: true });
|
||||
mkdirSync(join(project, "assets", "real"), { recursive: true });
|
||||
writeFileSync(join(project, "assets", "real", "logo.png"), "linked-png");
|
||||
// Windows needs a privilege to create symlinks; a copy builds the same page.
|
||||
const link = join(project, "assets", "logo.png");
|
||||
if (process.platform === "win32") copyFileSync(join(project, "assets", "real", "logo.png"), link);
|
||||
else symlinkSync(join("real", "logo.png"), link);
|
||||
writeFileSync(join(project, "compositions", "grain.png"), "grain-png");
|
||||
writeFileSync(
|
||||
join(project, "compositions", "card.html"),
|
||||
`<!doctype html><html><head><style>
|
||||
.card { background: url(../assets/logo.png); }
|
||||
.card .bg { background: url(grain.png), url(../assets/missing.png); }
|
||||
</style></head><body>
|
||||
<div data-composition-id="card" data-width="320" data-height="180">
|
||||
<img src="../assets/logo.png" alt=""><div class="bg" style="mask: url(../assets/logo.png)"></div>
|
||||
</div></body></html>`,
|
||||
);
|
||||
const mounts = [0, 2, 4, 6].map(
|
||||
(start) =>
|
||||
`<div data-composition-id="card" data-composition-src="compositions/card.html" data-start="${start}" data-duration="2"></div>`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(project, "index.html"),
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="main" data-width="320" data-height="180" data-start="0" data-duration="8">
|
||||
${mounts.join("\n")}
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`,
|
||||
);
|
||||
const reads: string[] = [];
|
||||
|
||||
const bundled = await bundleToSingleHtml(project, { onRead: (path) => reads.push(path) });
|
||||
|
||||
const logo = `data:image/png;base64,${Buffer.from("linked-png").toString("base64")}`;
|
||||
expect(bundled.split(`url(${logo})`)).toHaveLength(1 + 8);
|
||||
expect(bundled.split(`src="${logo}"`)).toHaveLength(1 + 4);
|
||||
expect(bundled).toContain(
|
||||
`url(data:image/png;base64,${Buffer.from("grain-png").toString("base64")})`,
|
||||
);
|
||||
expect(bundled).toContain("url(assets/missing.png)");
|
||||
expect(checked).toContain(join(project, "assets", "missing.png"));
|
||||
expect(repeatsIn(checked)).toEqual([]);
|
||||
expect(reads).toContain(join(project, "assets", "logo.png"));
|
||||
expect(repeatsIn(reads)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -373,31 +373,48 @@ function warnAssetTooLargeToInline(assetPath: string, byteLength: number): void
|
||||
);
|
||||
}
|
||||
|
||||
function maybeInlineRelativeAssetUrl(
|
||||
urlValue: string,
|
||||
projectDir: string,
|
||||
type AssetInliner = (urlValue: string) => string | null;
|
||||
type InlinableAsset = { dataUrl: string | null } | { tooLarge: number };
|
||||
|
||||
/** Inlines relative asset URLs as data: URLs, reading each project file once per build. */
|
||||
function createAssetInliner(
|
||||
resolveProjectPath: (relativePath: string) => string | null,
|
||||
inlineAssets: boolean,
|
||||
): string | null {
|
||||
if (!inlineAssets) return null;
|
||||
if (!urlValue || !isRelativeUrl(urlValue)) return null;
|
||||
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
|
||||
if (!basePath) return null;
|
||||
const filePath = resolveWithinProject(projectDir, decodeWellFormedEscapes(basePath));
|
||||
if (!filePath) return null;
|
||||
const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? "";
|
||||
const mimeType = INLINE_MIME[ext];
|
||||
if (!mimeType) return null;
|
||||
// Size-check before reading: an oversized asset must not be pulled into memory
|
||||
// just to be discarded.
|
||||
const byteLength = safeStatSize(filePath);
|
||||
if (byteLength !== null && byteLength > MAX_INLINE_ASSET_BYTES) {
|
||||
warnAssetTooLargeToInline(basePath, byteLength);
|
||||
return null;
|
||||
}
|
||||
const content = safeReadFileBuffer(filePath);
|
||||
if (content == null) return null;
|
||||
const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`;
|
||||
return appendSuffixToUrl(dataUrl, suffix);
|
||||
): AssetInliner {
|
||||
const assets = new Map<string, InlinableAsset>();
|
||||
const readAsset = (filePath: string, mimeType: string): InlinableAsset => {
|
||||
// Size-check before reading: an oversized asset must not be pulled into memory
|
||||
// just to be discarded.
|
||||
const byteLength = safeStatSize(filePath);
|
||||
if (byteLength !== null && byteLength > MAX_INLINE_ASSET_BYTES) return { tooLarge: byteLength };
|
||||
const content = safeReadFileBuffer(filePath);
|
||||
return {
|
||||
dataUrl: content === null ? null : `data:${mimeType};base64,${content.toString("base64")}`,
|
||||
};
|
||||
};
|
||||
return (urlValue) => {
|
||||
if (!inlineAssets) return null;
|
||||
if (!urlValue || !isRelativeUrl(urlValue)) return null;
|
||||
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
|
||||
if (!basePath) return null;
|
||||
const filePath = resolveProjectPath(decodeWellFormedEscapes(basePath));
|
||||
if (!filePath) return null;
|
||||
const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? "";
|
||||
const mimeType = INLINE_MIME[ext];
|
||||
if (!mimeType) return null;
|
||||
let asset = assets.get(filePath);
|
||||
if (asset === undefined) {
|
||||
asset = readAsset(filePath, mimeType);
|
||||
assets.set(filePath, asset);
|
||||
} else if ("dataUrl" in asset) {
|
||||
noteRead(filePath);
|
||||
}
|
||||
if ("tooLarge" in asset) {
|
||||
warnAssetTooLargeToInline(basePath, asset.tooLarge);
|
||||
return null;
|
||||
}
|
||||
return asset.dataUrl === null ? null : appendSuffixToUrl(asset.dataUrl, suffix);
|
||||
};
|
||||
}
|
||||
|
||||
function isExternalSvgFragmentUse(el: Element, attr: string, urlValue: string): boolean {
|
||||
@@ -419,7 +436,7 @@ function warnColorGradingLutNotInlined(lutSrc: string): void {
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: string): string {
|
||||
function rewriteColorGradingLutWithInlinedAssets(value: string, inlineAsset: AssetInliner): string {
|
||||
if (!value.trim().startsWith("{")) return value;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
@@ -433,7 +450,7 @@ function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: stri
|
||||
if (typeof lut === "string") {
|
||||
// Gated by inlineAssets and inlineColorGradingLuts at the call site above;
|
||||
// this call only runs once both have already passed.
|
||||
const inlined = maybeInlineRelativeAssetUrl(lut, projectDir, true);
|
||||
const inlined = inlineAsset(lut);
|
||||
if (!inlined) {
|
||||
warnColorGradingLutNotInlined(lut);
|
||||
return value;
|
||||
@@ -444,7 +461,7 @@ function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: stri
|
||||
if (typeof lut !== "object" || lut === null || Array.isArray(lut)) return value;
|
||||
const lutSrc = Reflect.get(lut, "src");
|
||||
if (typeof lutSrc !== "string") return value;
|
||||
const inlined = maybeInlineRelativeAssetUrl(lutSrc, projectDir, true);
|
||||
const inlined = inlineAsset(lutSrc);
|
||||
if (!inlined) {
|
||||
warnColorGradingLutNotInlined(lutSrc);
|
||||
return value;
|
||||
@@ -453,11 +470,7 @@ function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: stri
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
function rewriteSrcsetWithInlinedAssets(
|
||||
srcsetValue: string,
|
||||
projectDir: string,
|
||||
inlineAssets: boolean,
|
||||
): string {
|
||||
function rewriteSrcsetWithInlinedAssets(srcsetValue: string, inlineAsset: AssetInliner): string {
|
||||
if (!srcsetValue) return srcsetValue;
|
||||
return srcsetValue
|
||||
.split(",")
|
||||
@@ -466,27 +479,19 @@ function rewriteSrcsetWithInlinedAssets(
|
||||
if (!candidate) return candidate;
|
||||
const parts = candidate.split(/\s+/);
|
||||
if (parts.length === 0) return candidate;
|
||||
const maybeInlined = maybeInlineRelativeAssetUrl(parts[0] ?? "", projectDir, inlineAssets);
|
||||
const maybeInlined = inlineAsset(parts[0] ?? "");
|
||||
if (maybeInlined) parts[0] = maybeInlined;
|
||||
return parts.join(" ");
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function rewriteCssUrlsWithInlinedAssets(
|
||||
cssText: string,
|
||||
projectDir: string,
|
||||
inlineAssets: boolean,
|
||||
): string {
|
||||
function rewriteCssUrlsWithInlinedAssets(cssText: string, inlineAsset: AssetInliner): string {
|
||||
if (!cssText) return cssText;
|
||||
return cssText.replace(
|
||||
/\burl\(\s*(["']?)([^)"']+)\1\s*\)/g,
|
||||
(_full, quote: string, rawUrl: string) => {
|
||||
const maybeInlined = maybeInlineRelativeAssetUrl(
|
||||
(rawUrl || "").trim(),
|
||||
projectDir,
|
||||
inlineAssets,
|
||||
);
|
||||
const maybeInlined = inlineAsset((rawUrl || "").trim());
|
||||
if (!maybeInlined) return _full;
|
||||
return `url(${quote || ""}${maybeInlined}${quote || ""})`;
|
||||
},
|
||||
@@ -909,6 +914,7 @@ export interface BundleOptions {
|
||||
sceneParts?: boolean;
|
||||
/** Warn when the compiled HTML breaks the HyperFrames contract (default true). */
|
||||
staticGuard?: boolean;
|
||||
/** Called once per build for each project file the build reads or looks for. */
|
||||
onRead?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
@@ -1037,7 +1043,15 @@ function hoistCompositionScripts(
|
||||
|
||||
export function bundleToSingleHtml(projectDir: string, options?: BundleOptions): Promise<string> {
|
||||
const bundle = () => bundleProject(projectDir, options);
|
||||
return options?.onRead ? bundleReads.run(options.onRead, bundle) : bundle();
|
||||
const onRead = options?.onRead;
|
||||
if (!onRead) return bundle();
|
||||
const noted = new Set<string>();
|
||||
const noteOnce = (filePath: string) => {
|
||||
if (noted.has(filePath)) return;
|
||||
noted.add(filePath);
|
||||
onRead(filePath);
|
||||
};
|
||||
return bundleReads.run(noteOnce, bundle);
|
||||
}
|
||||
|
||||
// A <template> scene has no root or GSAP of its own: bundle it inside the index that mounts it, as render does.
|
||||
@@ -1064,16 +1078,29 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
|
||||
const mountedShell = readMountedSceneShell(projectDir, entryFile, entryHtml);
|
||||
const sourceDir = mountedShell ? projectDir : dirname(indexPath);
|
||||
// Every mount of a sub-composition asks again for each of its url()s; isSafePath realpaths the whole chain.
|
||||
const safePaths = new Map<string, boolean>();
|
||||
const isSafeProjectPath = (resolved: string): boolean => {
|
||||
let safe = safePaths.get(resolved);
|
||||
if (safe === undefined) {
|
||||
safe = isSafePath(projectDir, resolved);
|
||||
safePaths.set(resolved, safe);
|
||||
}
|
||||
return safe;
|
||||
};
|
||||
const entryPaths = new Map<string, string | null>();
|
||||
const resolveEntryPath = (relativePath: string): string | null => {
|
||||
let entry = entryPaths.get(relativePath);
|
||||
if (entry === undefined) {
|
||||
const resolved = resolve(sourceDir, relativePath);
|
||||
entry = isSafePath(projectDir, resolved) ? resolved : null;
|
||||
entry = isSafeProjectPath(resolved) ? resolved : null;
|
||||
entryPaths.set(relativePath, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
const resolveProjectPath = (relativePath: string): string | null => {
|
||||
const resolved = resolve(projectDir, relativePath);
|
||||
return isSafeProjectPath(resolved) ? resolved : null;
|
||||
};
|
||||
|
||||
const resolveEntryUrl = (url: string): string | null => resolveEntryPath(decodedUrlPath(url));
|
||||
|
||||
@@ -1134,12 +1161,21 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
|
||||
const subCompositionHosts = trackedCompositionHosts.filter((host) =>
|
||||
host.hasAttribute("data-composition-src"),
|
||||
);
|
||||
const sceneSources = new Map<string, string | null>();
|
||||
const subCompResult = inlineSubCompositions(document, subCompositionHosts, {
|
||||
resolveHtml: (srcPath: string) => {
|
||||
if (!isRelativeUrl(srcPath)) return null;
|
||||
const compPath = resolveEntryPath(srcPath);
|
||||
const html = compPath ? safeReadFile(compPath) : null;
|
||||
return html === null ? null : readSource(html);
|
||||
if (!compPath) return null;
|
||||
let source = sceneSources.get(compPath);
|
||||
if (source === undefined) {
|
||||
const html = safeReadFile(compPath);
|
||||
source = html === null ? null : readSource(html);
|
||||
sceneSources.set(compPath, source);
|
||||
} else {
|
||||
noteRead(compPath);
|
||||
}
|
||||
return source;
|
||||
},
|
||||
parseHtml: parseHTMLContent,
|
||||
hostIdentityMap: hostIdentityByElement,
|
||||
@@ -1396,6 +1432,7 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
|
||||
|
||||
// Inline textual assets
|
||||
const inlineAssets = options?.inlineAssets !== false;
|
||||
const inlineAsset = createAssetInliner(resolveProjectPath, inlineAssets);
|
||||
for (const el of [...document.querySelectorAll("[src], [href], [poster], [xlink\\:href]")]) {
|
||||
for (const attr of ["src", "href", "poster", "xlink:href"] as const) {
|
||||
const value = el.getAttribute(attr);
|
||||
@@ -1405,14 +1442,13 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
|
||||
// origin and triggers "Unsafe attempt to load URL ... from frame".
|
||||
// Keep the project-relative URL; render/check servers already expose it.
|
||||
if (isExternalSvgFragmentUse(el, attr, value)) continue;
|
||||
const inlined = maybeInlineRelativeAssetUrl(value, projectDir, inlineAssets);
|
||||
const inlined = inlineAsset(value);
|
||||
if (inlined) el.setAttribute(attr, inlined);
|
||||
}
|
||||
}
|
||||
for (const el of [...document.querySelectorAll("[srcset]")]) {
|
||||
const srcset = el.getAttribute("srcset");
|
||||
if (srcset)
|
||||
el.setAttribute("srcset", rewriteSrcsetWithInlinedAssets(srcset, projectDir, inlineAssets));
|
||||
if (srcset) el.setAttribute("srcset", rewriteSrcsetWithInlinedAssets(srcset, inlineAsset));
|
||||
}
|
||||
// Before inlining, so postcss reads paths not font bytes.
|
||||
const liveStyles = [...document.querySelectorAll("style")].filter(isAlwaysAppliedStyle);
|
||||
@@ -1430,16 +1466,12 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
|
||||
document.head.prepend(holder);
|
||||
}
|
||||
for (const styleEl of document.querySelectorAll("style")) {
|
||||
styleEl.textContent = rewriteCssUrlsWithInlinedAssets(
|
||||
styleEl.textContent || "",
|
||||
projectDir,
|
||||
inlineAssets,
|
||||
);
|
||||
styleEl.textContent = rewriteCssUrlsWithInlinedAssets(styleEl.textContent || "", inlineAsset);
|
||||
}
|
||||
for (const el of [...document.querySelectorAll("[style]")]) {
|
||||
el.setAttribute(
|
||||
"style",
|
||||
rewriteCssUrlsWithInlinedAssets(el.getAttribute("style") || "", projectDir, inlineAssets),
|
||||
rewriteCssUrlsWithInlinedAssets(el.getAttribute("style") || "", inlineAsset),
|
||||
);
|
||||
}
|
||||
if (inlineAssets && options?.inlineColorGradingLuts !== false) {
|
||||
@@ -1448,7 +1480,7 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
|
||||
if (value) {
|
||||
el.setAttribute(
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
rewriteColorGradingLutWithInlinedAssets(value, projectDir),
|
||||
rewriteColorGradingLutWithInlinedAssets(value, inlineAsset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment node
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, expect, it, vi } from "vitest";
|
||||
import { parseHostVariableValues, readDeclaredDefaults } from "../runtime/getVariables";
|
||||
import { scopeCssToComposition } from "./compositionScoping";
|
||||
import {
|
||||
assignBundledRuntimeCompositionIds,
|
||||
bundleToSingleHtml,
|
||||
prepareFlattenedInnerRoot,
|
||||
} from "./htmlBundler";
|
||||
import { parseHTMLContent } from "./htmlDocument";
|
||||
import { inlineSubCompositions } from "./inlineSubCompositions";
|
||||
|
||||
const stamped = vi.hoisted(() => [] as string[]);
|
||||
|
||||
vi.mock("@hyperframes/parsers/hf-ids", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@hyperframes/parsers/hf-ids")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureHfIds: (html: string) => {
|
||||
stamped.push(html);
|
||||
return actual.ensureHfIds(html);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const CARD_CSS = `.card { color: var(--accent); }
|
||||
#card-root .title { background: url(texture.png); }
|
||||
[data-composition-id="card"][data-start="0"] .intro { opacity: 0; }`;
|
||||
const CARD = `<!doctype html>
|
||||
<html data-composition-variables='[{"id":"accent","type":"color","default":"red"},{"id":"title","type":"string","default":"Card"}]'>
|
||||
<head><style>${CARD_CSS}</style></head>
|
||||
<body>
|
||||
<div id="card-root" data-composition-id="card" data-width="320" data-height="180">
|
||||
<h1 class="title intro">Title</h1>
|
||||
<img src="logo.png" alt="">
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines.card = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
|
||||
const mount = (n: number) =>
|
||||
`<div data-composition-id="card" data-composition-src="compositions/card.html" data-start="${n * 2}"` +
|
||||
` data-duration="2" data-variable-values='{"accent":"#00000${n}"}'></div>`;
|
||||
|
||||
it("parses a scene once however often it is mounted, and gives each mount its own scope and variables", () => {
|
||||
const document = parseHTMLContent(
|
||||
`<!doctype html><html><head></head><body><div data-composition-id="main">${[1, 2, 3].map(mount).join("")}</div></body></html>`,
|
||||
);
|
||||
const hosts = [...document.querySelectorAll("[data-composition-src]")];
|
||||
const parseHtml = vi.fn(parseHTMLContent);
|
||||
|
||||
const result = inlineSubCompositions(document, hosts, {
|
||||
resolveHtml: () => CARD,
|
||||
parseHtml,
|
||||
hostIdentityMap: assignBundledRuntimeCompositionIds(hosts),
|
||||
rewriteInlineStyles: true,
|
||||
flattenInnerRoot: prepareFlattenedInnerRoot,
|
||||
readVariableDefaults: readDeclaredDefaults,
|
||||
parseHostVariables: parseHostVariableValues,
|
||||
assetExists: () => true,
|
||||
});
|
||||
|
||||
// The file, then its body: once each, not once per mount.
|
||||
expect(parseHtml).toHaveBeenCalledTimes(2);
|
||||
const scopes = [1, 2, 3].map((n) => `[data-composition-id="card__hf${n}"]`);
|
||||
const sheet = CARD_CSS.replace("url(texture.png)", "url(compositions/texture.png)");
|
||||
expect(result.styles.map((style) => style.css)).toEqual(
|
||||
scopes.map((scope) =>
|
||||
scopeCssToComposition(sheet, "card", scope, "card-root", { scopeRootSelectors: true }),
|
||||
),
|
||||
);
|
||||
expect(result.variablesByComp).toEqual({
|
||||
card__hf1: { accent: "#000001", title: "Card" },
|
||||
card__hf2: { accent: "#000002", title: "Card" },
|
||||
card__hf3: { accent: "#000003", title: "Card" },
|
||||
});
|
||||
hosts.forEach((host, i) => {
|
||||
expect(host.getAttribute("data-composition-id")).toBe(`card__hf${i + 1}`);
|
||||
expect(host.querySelector("img")?.getAttribute("src")).toBe("compositions/logo.png");
|
||||
expect(host.querySelectorAll("h1.title")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-scene-reuse-"));
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it("reads and stamps a scene file once per build however often it is mounted", async () => {
|
||||
mkdirSync(join(dir, "compositions"), { recursive: true });
|
||||
writeFileSync(join(dir, "compositions", "card.html"), CARD);
|
||||
writeFileSync(
|
||||
join(dir, "index.html"),
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="main" data-width="320" data-height="180" data-start="0" data-duration="8">
|
||||
${[1, 2, 3, 4].map(mount).join("\n")}
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`,
|
||||
);
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir, { stampHfIds: true, inlineAssets: false });
|
||||
|
||||
expect(stamped.filter((html) => html === CARD)).toHaveLength(1);
|
||||
for (const n of [1, 2, 3, 4])
|
||||
expect(bundled).toContain(`[data-composition-id="card__hf${n}"] .card`);
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from "./rewriteSubCompPaths";
|
||||
import { warnUnknownEnumValues } from "../runtime/getVariables";
|
||||
import {
|
||||
scopeCssToComposition,
|
||||
createCompositionCssScoper,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
scopedModulePrelude,
|
||||
wrapScopedCompositionScript,
|
||||
@@ -287,6 +287,8 @@ export interface InlineSubCompositionsResult {
|
||||
// Default helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isDocument = (node: Node): node is Document => node.nodeType === 9;
|
||||
|
||||
function defaultBuildScopeSelector(compId: string): string {
|
||||
const escaped = compId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
return `[data-composition-id="${escaped}"]`;
|
||||
@@ -344,6 +346,24 @@ export function inlineSubCompositions(
|
||||
const externalLinks: ExternalLink[] = [];
|
||||
const variablesByComp: Record<string, Record<string, unknown>> = {};
|
||||
const svgIdScopes: Array<SvgIdScope & { styleStart: number; styleEnd: number }> = [];
|
||||
// A file mounted many times reads the same; only its scope, ids and variables differ per mount.
|
||||
// Each file is parsed once and only read; a mount edits its own copy.
|
||||
const parsed = new Map<string, Document>();
|
||||
const parseOnce = (html: string): Document => {
|
||||
let doc = parsed.get(html);
|
||||
if (!doc) {
|
||||
doc = parseHtml(html);
|
||||
parsed.set(html, doc);
|
||||
}
|
||||
return doc;
|
||||
};
|
||||
const parseCopy = (html: string): Document => {
|
||||
const copy = parseOnce(html).cloneNode(true);
|
||||
return isDocument(copy) ? copy : parseHtml(html);
|
||||
};
|
||||
const usability = new Map<string, ReturnType<typeof checkSubCompositionUsability>>();
|
||||
const sheetsWithAssetUrls = new Map<string, string>();
|
||||
const scopeCssToComposition = createCompositionCssScoper();
|
||||
|
||||
const sceneHosts = new Map<string, Element>();
|
||||
const queue = hosts.map((element) => ({
|
||||
@@ -363,7 +383,11 @@ export function inlineSubCompositions(
|
||||
// tolerant (skip, don't throw) — preview and studio must keep bundling
|
||||
// around a scene that's still being authored. Lint and the render
|
||||
// pre-flight check use the same helper to fail loudly instead.
|
||||
const validity = checkSubCompositionUsability(compHtml, parseHtml);
|
||||
let validity = compHtml == null ? undefined : usability.get(compHtml);
|
||||
if (!validity) {
|
||||
validity = checkSubCompositionUsability(compHtml, parseOnce);
|
||||
if (compHtml != null) usability.set(compHtml, validity);
|
||||
}
|
||||
if (!validity.ok) {
|
||||
onMissingComposition?.(src, validity.detail);
|
||||
continue;
|
||||
@@ -376,7 +400,7 @@ export function inlineSubCompositions(
|
||||
continue;
|
||||
}
|
||||
|
||||
const compDoc = parseHtml(compHtml);
|
||||
const compDoc = parseCopy(compHtml);
|
||||
|
||||
// Determine composition IDs
|
||||
let compId: string | null;
|
||||
@@ -395,7 +419,7 @@ export function inlineSubCompositions(
|
||||
onMissingComposition?.(src);
|
||||
continue;
|
||||
}
|
||||
const contentDoc = parseHtml(contentHtml);
|
||||
const contentDoc = parseCopy(contentHtml);
|
||||
if (!contentDoc.documentElement) {
|
||||
onMissingComposition?.(src);
|
||||
continue;
|
||||
@@ -463,7 +487,12 @@ export function inlineSubCompositions(
|
||||
// sub-comp's html/body/:root rules from clobbering the host document (they
|
||||
// are remapped to the composition box); see compositionScoping.
|
||||
const scopeSubStyle = (raw: string): string => {
|
||||
const css = rewriteCssAssetUrls(raw, src, assetExists);
|
||||
const sheetKey = JSON.stringify([src, raw]);
|
||||
let css = sheetsWithAssetUrls.get(sheetKey);
|
||||
if (css === undefined) {
|
||||
css = rewriteCssAssetUrls(raw, src, assetExists);
|
||||
sheetsWithAssetUrls.set(sheetKey, css);
|
||||
}
|
||||
return scopeCompId
|
||||
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
|
||||
compoundAuthoredRoot: compoundAuthoredRoot === true,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment node
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { BundleOptions } from "./htmlBundler";
|
||||
|
||||
// A project shaped like a heavy film: scenes mounted many times with their own variables, a
|
||||
// nested scene, a <template> scene, url()s through a symlink and to a missing file, and a
|
||||
// Google font. The goldens are the pages this project built before a build reused anything
|
||||
// across mounts; any change that is meant to alter the page updates them (vitest -u).
|
||||
const goldens = join(dirname(fileURLToPath(import.meta.url)), "__goldens__");
|
||||
|
||||
const CARD = `<!doctype html>
|
||||
<html data-composition-variables='[{"id":"accent","type":"color","default":"#ff3366"},{"id":"title","type":"string","default":"Card"},{"id":"size","type":"number","default":48}]'>
|
||||
<head>
|
||||
<style>
|
||||
html, body { margin: 0; }
|
||||
#card-root { position: relative; background: url(../assets/tile.png); }
|
||||
#card-root .title { color: var(--accent); font-size: calc(var(--size) * 1px); font-family: "Fixture Slab", serif; }
|
||||
.row .cell { background-image: url(grain.png), url("../assets/linked.png"); }
|
||||
.row .gap { cursor: url(../assets/missing.png), auto; }
|
||||
[data-composition-id="card"][data-start="0"] .intro { opacity: 0; }
|
||||
[data-composition-id="card"] .frame:hover, .frame > .cell::before { content: "→"; }
|
||||
.a /* a comment */ .b { color: blue; }
|
||||
@keyframes card-pulse { from { opacity: 0.2; } to { opacity: 1; } }
|
||||
@media (min-width: 100px) { .frame .wide { display: block; } }
|
||||
@font-face { font-family: "Card Local"; src: url(../assets/tile.png); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="card-root" data-composition-id="card" data-width="320" data-height="180" class="frame">
|
||||
<h1 class="title intro">Title</h1>
|
||||
<img src="../assets/linked.png" alt="">
|
||||
<svg width="10" height="10"><clipPath id="clip"><rect width="5" height="5"/></clipPath><rect clip-path="url(#clip)" width="10" height="10"/></svg>
|
||||
<div class="row" style="background: url(../assets/linked.png) no-repeat"><span class="cell">Cell</span><span class="gap"></span></div>
|
||||
<div class="nested" data-composition-id="badge" data-composition-src="compositions/badge.html" data-start="0" data-duration="1"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.querySelector('[data-composition-id="card"]');
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines.card = gsap.timeline({ paused: true }).from(root.querySelector(".title"), { opacity: 0 });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const BADGE = `<template id="badge-template">
|
||||
<div data-composition-id="badge" data-width="80" data-height="40">
|
||||
<span class="label">New</span>
|
||||
<style>.label { color: var(--tone, gold); background: url(../assets/tile.png); }</style>
|
||||
</div>
|
||||
</template>`;
|
||||
|
||||
const SOLO = `<!doctype html><html><head><style>
|
||||
.solo { color: teal; background: url(grain.png); }
|
||||
</style></head><body>
|
||||
<div data-composition-id="solo" data-width="320" data-height="180"><p class="solo">Solo</p></div>
|
||||
</body></html>`;
|
||||
|
||||
function writeProject(dir: string): void {
|
||||
mkdirSync(join(dir, "compositions"), { recursive: true });
|
||||
mkdirSync(join(dir, "assets", "real"), { recursive: true });
|
||||
writeFileSync(join(dir, "assets", "tile.png"), "tile-png");
|
||||
writeFileSync(join(dir, "assets", "real", "linked.png"), "linked-png");
|
||||
// Windows needs a privilege to create symlinks; a copy builds the same page.
|
||||
const link = join(dir, "assets", "linked.png");
|
||||
if (process.platform === "win32") copyFileSync(join(dir, "assets", "real", "linked.png"), link);
|
||||
else symlinkSync(join("real", "linked.png"), link);
|
||||
writeFileSync(join(dir, "compositions", "grain.png"), "grain-png");
|
||||
writeFileSync(join(dir, "compositions", "card.html"), CARD);
|
||||
writeFileSync(join(dir, "compositions", "badge.html"), BADGE);
|
||||
writeFileSync(join(dir, "compositions", "solo.html"), SOLO);
|
||||
const cards = [1, 2, 3].map(
|
||||
(n) =>
|
||||
`<div class="clip" data-composition-id="card" data-composition-src="compositions/card.html" data-start="${n * 2}" data-duration="2" data-track-index="${n % 2}" data-variable-values='{"accent":"#00000${n}","title":"Card ${n}","size":${40 + n}}'></div>`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, "index.html"),
|
||||
`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fixture+Slab:wght@400;700&display=swap">
|
||||
<style>body { margin: 0; font-family: "Fixture Slab", serif; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main" data-composition-id="main" data-width="320" data-height="180" data-start="0" data-duration="12">
|
||||
${cards.join("\n")}
|
||||
<div class="clip" data-composition-id="solo" data-composition-src="compositions/solo.html" data-start="10" data-duration="2"></div>
|
||||
<div class="clip" data-composition-id="badge" data-composition-src="compositions/badge.html" data-start="0" data-duration="1" data-variable-values='{"tone":"silver"}'></div>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines.main = gsap.timeline({ paused: true });</script>
|
||||
</body>
|
||||
</html>`,
|
||||
);
|
||||
}
|
||||
|
||||
const FACE_URL = "https://fonts.gstatic.com/s/fixtureslab/v1/latin.woff2";
|
||||
const fontsFromGoogle = (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.includes("css2"))
|
||||
return new Response(
|
||||
`@font-face { font-family: 'Fixture Slab'; font-style: normal; font-weight: 400; src: url(${FACE_URL}) format('woff2'); unicode-range: U+0000-00FF; }`,
|
||||
{ status: 200 },
|
||||
);
|
||||
if (url === FACE_URL) return new Response("FIXTURE_SLAB", { status: 200 });
|
||||
return new Response("", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
const offline = vi.fn(async (input: unknown): Promise<Response> => {
|
||||
throw new TypeError(`fetch failed: ${String(input)}`);
|
||||
});
|
||||
|
||||
const PREVIEW: BundleOptions = {
|
||||
runtime: "placeholder",
|
||||
inlineAssets: false,
|
||||
staticGuard: false,
|
||||
sceneParts: true,
|
||||
stampHfIds: true,
|
||||
onRead: () => {},
|
||||
};
|
||||
const RENDER: BundleOptions = { runtime: "placeholder", staticGuard: false };
|
||||
|
||||
let project: string;
|
||||
let fontCache: string;
|
||||
let previousFontCache: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
project = mkdtempSync(join(tmpdir(), "hf-page-build-golden-"));
|
||||
fontCache = mkdtempSync(join(tmpdir(), "hf-page-build-fonts-"));
|
||||
previousFontCache = process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
process.env.HYPERFRAMES_FONT_CACHE_DIR = fontCache;
|
||||
writeProject(project);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (previousFontCache === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
else process.env.HYPERFRAMES_FONT_CACHE_DIR = previousFontCache;
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
rmSync(fontCache, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A fresh copy of the modules is a new process: nothing held in memory, the font cache on disk kept.
|
||||
async function buildPage(options: BundleOptions, fetchImpl: typeof fetch): Promise<string> {
|
||||
vi.resetModules();
|
||||
const { bundleToSingleHtml } = await import("./htmlBundler");
|
||||
const { injectDeterministicFontFaces } = await import("../fonts/deterministicFonts");
|
||||
const quiet = { warn: () => {}, info: () => {} };
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const bundled = await bundleToSingleHtml(project, options);
|
||||
return await injectDeterministicFontFaces(bundled, { fetchImpl, logger: quiet });
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{ name: "preview", options: PREVIEW },
|
||||
{ name: "render", options: RENDER },
|
||||
])("the $name page of a project with repeated scenes", ({ name, options }) => {
|
||||
it("is the page the build made before it reused work across mounts", async () => {
|
||||
await expect(await buildPage(options, fontsFromGoogle)).toMatchFileSnapshot(
|
||||
join(goldens, `pageBuild.${name}.html`),
|
||||
);
|
||||
});
|
||||
|
||||
it("is the same page from a new process offline, once the font cache is warm", async () => {
|
||||
const online = await buildPage(options, fontsFromGoogle);
|
||||
offline.mockClear();
|
||||
const again = await buildPage(options, offline as unknown as typeof fetch);
|
||||
expect(offline).not.toHaveBeenCalled();
|
||||
expect(again).toBe(online);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const FACE_URL = "https://fonts.gstatic.com/s/diskfam/v1/latin.woff2";
|
||||
const CSS = `/* latin */
|
||||
@font-face {
|
||||
font-family: 'DiskFam';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(${FACE_URL}) format('woff2');
|
||||
unicode-range: U+0000-00FF;
|
||||
}`;
|
||||
const HTML = `<!doctype html><html><head><style>
|
||||
h1 { font-family: "DiskFam", sans-serif; }
|
||||
</style></head><body><h1>Offline</h1></body></html>`;
|
||||
const quiet = { warn: () => {}, info: () => {} };
|
||||
|
||||
let cacheDir: string;
|
||||
let previousCacheDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousCacheDir = process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
cacheDir = mkdtempSync(join(tmpdir(), "hf-font-css-disk-"));
|
||||
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousCacheDir === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
else process.env.HYPERFRAMES_FONT_CACHE_DIR = previousCacheDir;
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A fresh copy of the module is a new process: its lookups held in memory start empty, the disk does not.
|
||||
async function buildInNewProcess(fetchImpl: typeof fetch): Promise<string> {
|
||||
vi.resetModules();
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
return injectDeterministicFontFaces(HTML, { fetchImpl, logger: quiet });
|
||||
}
|
||||
|
||||
function googleFonts(cssStatus: number, css: string): { fetchImpl: typeof fetch; urls: string[] } {
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (url.includes("css2")) return new Response(css, { status: cssStatus });
|
||||
if (url === FACE_URL) return new Response("DISK_FACE", { status: 200 });
|
||||
return new Response("", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
return { fetchImpl, urls };
|
||||
}
|
||||
|
||||
const offline = vi.fn(async (input: unknown): Promise<Response> => {
|
||||
throw new TypeError(`fetch failed: ${String(input)}`);
|
||||
});
|
||||
|
||||
it("builds the same page in a new process without the network once the font cache is warm", async () => {
|
||||
const online = googleFonts(200, CSS);
|
||||
const first = await buildInNewProcess(online.fetchImpl);
|
||||
expect(online.urls.filter((url) => url.includes("css2"))).toHaveLength(1);
|
||||
expect(first).toContain(Buffer.from("DISK_FACE").toString("base64"));
|
||||
|
||||
offline.mockClear();
|
||||
const second = await buildInNewProcess(offline as unknown as typeof fetch);
|
||||
|
||||
expect(offline).not.toHaveBeenCalled();
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "a lookup that named no faces", status: 200, css: "/* no faces */" },
|
||||
{ label: "a lookup Google refused", status: 503, css: "" },
|
||||
])("asks the network again in a new process after $label", async ({ status, css }) => {
|
||||
await buildInNewProcess(googleFonts(status, css).fetchImpl);
|
||||
|
||||
const again = googleFonts(200, CSS);
|
||||
const page = await buildInNewProcess(again.fetchImpl);
|
||||
|
||||
expect(again.urls.filter((url) => url.includes("css2"))).toHaveLength(1);
|
||||
expect(page).toContain(Buffer.from("DISK_FACE").toString("base64"));
|
||||
});
|
||||
@@ -15,7 +15,10 @@
|
||||
* The tests inject `fetchImpl` so no real network call happens.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
_clearGoogleFontCssCacheForTests,
|
||||
FONT_FETCH_FAILED,
|
||||
@@ -31,6 +34,21 @@ import {
|
||||
// an earlier test would silently skip this test's own fetchImpl.
|
||||
beforeEach(() => _clearGoogleFontCssCacheForTests());
|
||||
|
||||
let cacheDir: string;
|
||||
let prevCacheEnv: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
cacheDir = mkdtempSync(join(tmpdir(), "hf-font-fail-closed-"));
|
||||
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (prevCacheEnv === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
else process.env.HYPERFRAMES_FONT_CACHE_DIR = prevCacheEnv;
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// HTML that requests a font NOT in FONT_ALIASES, so the resolver falls
|
||||
// through to the Google Fonts fetch path. (Bundled fonts like Inter
|
||||
// bypass fetch entirely.)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -1478,9 +1486,47 @@ type GoogleFontCssResult =
|
||||
|
||||
const googleFontCssCache = new Map<string, Promise<GoogleFontCssResult>>();
|
||||
|
||||
/** Test-only reset — the cache is otherwise process-lifetime, shared across calls. */
|
||||
const GOOGLE_FONT_CSS_CACHE_DIR = "_css";
|
||||
|
||||
/** Test-only reset of the process and disk caches, so the next lookup reaches `fetchImpl`. */
|
||||
export function _clearGoogleFontCssCacheForTests(): void {
|
||||
googleFontCssCache.clear();
|
||||
rmSync(join(resolveFontCacheRoot(), GOOGLE_FONT_CSS_CACHE_DIR), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// The lookup names the woff2 files, so it is cached on disk beside them; otherwise each new process
|
||||
// asks the network first and, offline, never reaches them. No family slug has an underscore.
|
||||
function googleFontCssCachePath(url: string): string {
|
||||
return join(
|
||||
fontCacheDir(GOOGLE_FONT_CSS_CACHE_DIR),
|
||||
`${createHash("sha256").update(url).digest("hex")}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function readCachedGoogleFontCss(url: string): GoogleFontCssResult | null {
|
||||
let cached: unknown;
|
||||
try {
|
||||
cached = JSON.parse(readFileSync(googleFontCssCachePath(url), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof cached !== "object" || cached === null) return null;
|
||||
const body: unknown = Reflect.get(cached, "body");
|
||||
const status: unknown = Reflect.get(cached, "status");
|
||||
return typeof body === "string" && typeof status === "number" ? { ok: true, body, status } : null;
|
||||
}
|
||||
|
||||
/** Keeps only a lookup that named faces; written aside and renamed, so a reader never sees half of it. */
|
||||
function writeCachedGoogleFontCss(url: string, body: string, status: number): void {
|
||||
if (!/@font-face/i.test(body)) return;
|
||||
const path = googleFontCssCachePath(url);
|
||||
const partial = `${path}.${process.pid}.tmp`;
|
||||
try {
|
||||
writeFileSync(partial, JSON.stringify({ status, body }), { mode: 0o644 });
|
||||
renameSync(partial, path);
|
||||
} catch {
|
||||
rmSync(partial, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Rejects with `signal`'s own abort reason without cancelling `promise` itself. */
|
||||
@@ -1504,6 +1550,11 @@ function fetchGoogleFontCss(
|
||||
const mode = options.failClosedFontFetch ? `closed${options.retryPolicy.maxAttempts}` : "open";
|
||||
const key = `${mode}:${url}`;
|
||||
let shared = googleFontCssCache.get(key);
|
||||
const cached = shared ? null : readCachedGoogleFontCss(url);
|
||||
if (cached) {
|
||||
shared = Promise.resolve(cached);
|
||||
googleFontCssCache.set(key, shared);
|
||||
}
|
||||
attempt.cssCache = shared ? "hit" : "fresh";
|
||||
if (!shared) {
|
||||
// The shared fetch must not carry any one caller's abortSignal, or that
|
||||
@@ -1516,8 +1567,10 @@ function fetchGoogleFontCss(
|
||||
"Google Fonts CSS",
|
||||
{ ...options, abortSignal: undefined },
|
||||
).then((result) => {
|
||||
if (result.ok)
|
||||
if (result.ok) {
|
||||
writeCachedGoogleFontCss(url, result.body, result.response.status);
|
||||
return { ok: true as const, body: result.body, status: result.response.status };
|
||||
}
|
||||
// A transient status must not stick for the process lifetime.
|
||||
if (isRetryableFontFetchStatus(result.response.status)) googleFontCssCache.delete(key);
|
||||
return { ok: false as const, status: result.response.status };
|
||||
|
||||
Reference in New Issue
Block a user