Files
heygen-com--hyperframes/packages/core/src/compiler/htmlBundler.ts
T
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

1260 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
import { parseHostVariableValues } from "../runtime/getVariables";
import { cssVariableName } from "../tokenSlug";
import { readFileSync, existsSync } from "fs";
import { resolve, relative, dirname, isAbsolute, sep } from "path";
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import {
RUNTIME_BOOTSTRAP_ATTR,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument";
// rewriteSubCompPaths functions are used by inlineSubCompositions (shared module)
import {
buildVariablesByCompScript,
scopeCssToComposition,
wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript,
} from "./compositionScoping";
import { validateHyperframeHtmlContract } from "./staticGuard";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
import { readDeclaredDefaults } from "../runtime/getVariables";
import { inlineSubCompositions } from "./inlineSubCompositions";
import { queryByAttr } from "../utils/cssSelector";
import { isSafePath, resolveWithinProject } from "../safePath.js";
import { HF_COLOR_GRADING_ATTR } from "../colorGrading";
const DEFAULT_RUNTIME_SCRIPT_URL = "";
function getRuntimeScriptUrl(): string {
const configured = (process.env.HYPERFRAME_RUNTIME_URL || "").trim();
return configured || DEFAULT_RUNTIME_SCRIPT_URL;
}
function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" = "inline"): string {
const sanitized = stripEmbeddedRuntimeScripts(html);
if (sanitized.includes(RUNTIME_BOOTSTRAP_ATTR)) return sanitized;
// Three modes for the runtime <script>:
// 1. HYPERFRAME_RUNTIME_URL env var set → emit src="<url>" (production CDN deploy).
// 2. runtime: "placeholder" passed → emit src="" for the caller to substitute
// (studio + vite preview hot-load a local
// runtime endpoint via string replace).
// 3. runtime: "inline" (default) → embed the IIFE body directly so the
// bundle is genuinely self-contained.
const runtimeScriptUrl = getRuntimeScriptUrl();
let tag: string;
if (runtimeScriptUrl) {
const escaped = runtimeScriptUrl.replace(/"/g, "&quot;");
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src="${escaped}"></script>`;
} else if (runtimeMode === "placeholder") {
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src=""></script>`;
} else {
const inlinedRuntime = getHyperframeRuntimeScript();
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`;
}
if (sanitized.includes("</head>")) {
// Use a function replacer so `String.prototype.replace`'s substitution
// patterns (`$&`, `$$`, `$'`, `` $` ``, `$1``$99`) inside the inlined
// runtime IIFE are passed through verbatim. The minified runtime
// contains the literal sequence `$&` as part of legitimate JS, and
// the older `(pattern, string)` form would expand it to the matched
// `</head>`, silently corrupting the runtime and breaking every
// timeline in the bundle with a parse-time SyntaxError.
return sanitized.replace("</head>", () => `${tag}\n</head>`);
}
const htmlOpenMatch = sanitized.match(/<html\b[^>]*>/i);
if (htmlOpenMatch?.index != null) {
const insertPos = htmlOpenMatch.index + htmlOpenMatch[0].length;
return `${sanitized.slice(0, insertPos)}<head>${tag}</head>${sanitized.slice(insertPos)}`;
}
const doctypeIdx = sanitized.toLowerCase().indexOf("<!doctype");
if (doctypeIdx >= 0) {
const insertPos = sanitized.indexOf(">", doctypeIdx) + 1;
return sanitized.slice(0, insertPos) + tag + sanitized.slice(insertPos);
}
return tag + sanitized;
}
function isRelativeUrl(url: string): boolean {
return !isNonRelativeUrl(url) && !isAbsolute(url);
}
function safeReadFile(filePath: string): string | null {
if (!existsSync(filePath)) return null;
try {
return readFileSync(filePath, "utf-8");
} catch {
return null;
}
}
const CSS_IMPORT_RE =
/@import\s+(?:url\(\s*(["']?)([^)"']+)\1\s*\)|(["'])([^"']+)\3)\s*([^;]*);\s*/g;
const CSS_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
function withCommentsStripped<T>(
css: string,
fn: (stripped: string) => T,
): { result: T; restore: (s: string) => string } {
const comments: string[] = [];
const stripped = css.replace(CSS_COMMENT_RE, (m) => {
const idx = comments.length;
comments.push(m);
return `/*__hf_c${idx}__*/`;
});
const result = fn(stripped);
const restore = (s: string) => {
let out = s;
for (let i = 0; i < comments.length; i++) {
out = out.replace(`/*__hf_c${i}__*/`, comments[i]!);
}
return out;
};
return { result, restore };
}
function rebaseCssUrls(css: string, cssFileDir: string, projectDir: string): string {
const resolvedRoot = resolve(projectDir);
const resolvedDir = resolve(cssFileDir);
if (resolvedDir === resolvedRoot) return css;
return css.replace(CSS_URL_RE, (full, quote: string, urlValue: string) => {
if (!urlValue || !isRelativeUrl(urlValue)) return full;
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
if (!basePath) return full;
const absolutePath = resolve(resolvedDir, basePath);
const rebased = relative(resolvedRoot, absolutePath).split(sep).join("/");
if (rebased === basePath) return full;
return `url(${quote || ""}${rebased}${suffix}${quote || ""})`;
});
}
function rebaseRelativePath(urlValue: string, fromDir: string, toDir: string): string {
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
if (!basePath) return urlValue;
const absolutePath = resolve(fromDir, basePath);
const rebased = relative(resolve(toDir), absolutePath).split(sep).join("/");
return appendSuffixToUrl(rebased, suffix);
}
function rebaseSrcsetPaths(srcsetValue: string, fromDir: string, toDir: string): string {
if (!srcsetValue) return srcsetValue;
return srcsetValue
.split(",")
.map((rawCandidate) => {
const candidate = rawCandidate.trim();
if (!candidate) return candidate;
const parts = candidate.split(/\s+/);
const first = parts[0] ?? "";
if (parts.length === 0 || !isRelativeUrl(first)) return candidate;
parts[0] = rebaseRelativePath(first, fromDir, toDir);
return parts.join(" ");
})
.join(", ");
}
function rebaseColorGradingLutPath(value: string, fromDir: string, toDir: string): string {
if (!value.trim().startsWith("{")) return value;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return value;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return value;
const lut = Reflect.get(parsed, "lut");
if (typeof lut === "string") {
if (!isRelativeUrl(lut)) return value;
Reflect.set(parsed, "lut", rebaseRelativePath(lut, fromDir, toDir));
return JSON.stringify(parsed);
}
if (typeof lut !== "object" || lut === null || Array.isArray(lut)) return value;
const lutSrc = Reflect.get(lut, "src");
if (typeof lutSrc !== "string" || !isRelativeUrl(lutSrc)) return value;
Reflect.set(lut, "src", rebaseRelativePath(lutSrc, fromDir, toDir));
return JSON.stringify(parsed);
}
function rebaseEntryAuthoredAssetPaths(
document: Document,
sourceDir: string,
projectDir: string,
): void {
for (const styleEl of [...document.querySelectorAll("style")]) {
styleEl.textContent = rebaseCssUrls(styleEl.textContent || "", sourceDir, projectDir);
}
for (const el of [...document.querySelectorAll("[style]")]) {
const styleAttr = el.getAttribute("style");
if (styleAttr) el.setAttribute("style", rebaseCssUrls(styleAttr, sourceDir, projectDir));
}
for (const el of [...document.querySelectorAll("[src], [href], [poster], [xlink\\:href]")]) {
if (el.tagName === "LINK" && (el.getAttribute("rel") || "").toLowerCase() === "stylesheet")
continue;
if (el.tagName === "SCRIPT" && el.hasAttribute("src")) continue;
for (const attr of ["src", "href", "poster", "xlink:href"] as const) {
const value = el.getAttribute(attr);
if (!value || !isRelativeUrl(value)) continue;
el.setAttribute(attr, rebaseRelativePath(value, sourceDir, projectDir));
}
}
for (const el of [...document.querySelectorAll("[srcset]")]) {
const srcset = el.getAttribute("srcset");
if (srcset) el.setAttribute("srcset", rebaseSrcsetPaths(srcset, sourceDir, projectDir));
}
for (const el of [...document.querySelectorAll(`[${HF_COLOR_GRADING_ATTR}]`)]) {
const value = el.getAttribute(HF_COLOR_GRADING_ATTR);
if (value)
el.setAttribute(
HF_COLOR_GRADING_ATTR,
rebaseColorGradingLutPath(value, sourceDir, projectDir),
);
}
}
function inlineCssFile(
css: string,
cssFileDir: string,
projectDir: string,
visited: Set<string> = new Set(),
): string {
const { result: strippedCss, restore: restoreComments } = withCommentsStripped(css, (s) => s);
const importPlaceholders: string[] = [];
const withPlaceholders = strippedCss.replace(
CSS_IMPORT_RE,
(full, _q1, urlPath, _q2, barePath, mediaQuery) => {
const importPath = urlPath ?? barePath;
if (!importPath || !isRelativeUrl(importPath)) return full;
const resolved = resolve(cssFileDir, importPath);
// @import is resolved relative to the CSS file, but must stay within the
// project root; isSafePath also blocks symlink escapes (content is inlined).
if (!isSafePath(projectDir, resolved)) return full;
if (visited.has(resolved)) return "";
const content = safeReadFile(resolved);
if (content == null) return full;
visited.add(resolved);
const inlined = inlineCssFile(content, dirname(resolved), projectDir, visited);
const trimmedMedia = (mediaQuery || "").trim();
const block = trimmedMedia ? `@media ${trimmedMedia} {\n${inlined}\n}\n` : inlined + "\n";
const idx = importPlaceholders.length;
importPlaceholders.push(block);
return `/*__hf_import_${idx}__*/`;
},
);
let rebased = rebaseCssUrls(withPlaceholders, cssFileDir, projectDir);
rebased = restoreComments(rebased);
for (let i = 0; i < importPlaceholders.length; i++) {
rebased = rebased.replace(`/*__hf_import_${i}__*/`, importPlaceholders[i]!);
}
return rebased;
}
function safeReadFileBuffer(filePath: string): Buffer | null {
if (!existsSync(filePath)) return null;
try {
return readFileSync(filePath);
} catch {
return null;
}
}
function splitUrlSuffix(urlValue: string): { basePath: string; suffix: string } {
const queryIdx = urlValue.indexOf("?");
const hashIdx = urlValue.indexOf("#");
if (queryIdx < 0 && hashIdx < 0) return { basePath: urlValue, suffix: "" };
const cutIdx = queryIdx < 0 ? hashIdx : hashIdx < 0 ? queryIdx : Math.min(queryIdx, hashIdx);
return { basePath: urlValue.slice(0, cutIdx), suffix: urlValue.slice(cutIdx) };
}
function appendSuffixToUrl(baseUrl: string, suffix: string): string {
if (!suffix) return baseUrl;
if (suffix.startsWith("#")) return `${baseUrl}${suffix}`;
if (suffix.startsWith("?")) {
const queryWithOptionalHash = suffix.slice(1);
if (!queryWithOptionalHash) return baseUrl;
const hashIdx = queryWithOptionalHash.indexOf("#");
const queryPart =
hashIdx >= 0 ? queryWithOptionalHash.slice(0, hashIdx) : queryWithOptionalHash;
const hashPart = hashIdx >= 0 ? queryWithOptionalHash.slice(hashIdx) : "";
if (!queryPart) return `${baseUrl}${hashPart}`;
const joiner = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${joiner}${queryPart}${hashPart}`;
}
return baseUrl;
}
const INLINE_MIME: Record<string, string> = {
".svg": "image/svg+xml",
".json": "application/json",
".txt": "text/plain",
".cube": "text/plain",
".xml": "application/xml",
};
function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): string | null {
if (!urlValue || !isRelativeUrl(urlValue)) return null;
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
if (!basePath) return null;
const filePath = resolveWithinProject(projectDir, basePath);
if (!filePath) return null;
const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? "";
const mimeType = INLINE_MIME[ext];
if (!mimeType) return null;
const content = safeReadFileBuffer(filePath);
if (content == null) return null;
const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`;
return appendSuffixToUrl(dataUrl, suffix);
}
function warnColorGradingLutNotInlined(lutSrc: string): void {
const trimmed = lutSrc.trim();
if (!isRelativeUrl(trimmed)) return;
console.warn(
`[HyperFrames] Could not inline color grading LUT "${trimmed}". The rendered bundle may not be self-contained.`,
);
}
// fallow-ignore-next-line complexity
function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: string): string {
if (!value.trim().startsWith("{")) return value;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return value;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return value;
const lut = Reflect.get(parsed, "lut");
if (typeof lut === "string") {
const inlined = maybeInlineRelativeAssetUrl(lut, projectDir);
if (!inlined) {
warnColorGradingLutNotInlined(lut);
return value;
}
Reflect.set(parsed, "lut", inlined);
return JSON.stringify(parsed);
}
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);
if (!inlined) {
warnColorGradingLutNotInlined(lutSrc);
return value;
}
Reflect.set(lut, "src", inlined);
return JSON.stringify(parsed);
}
function rewriteSrcsetWithInlinedAssets(srcsetValue: string, projectDir: string): string {
if (!srcsetValue) return srcsetValue;
return srcsetValue
.split(",")
.map((rawCandidate) => {
const candidate = rawCandidate.trim();
if (!candidate) return candidate;
const parts = candidate.split(/\s+/);
if (parts.length === 0) return candidate;
const maybeInlined = maybeInlineRelativeAssetUrl(parts[0] ?? "", projectDir);
if (maybeInlined) parts[0] = maybeInlined;
return parts.join(" ");
})
.join(", ");
}
function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): string {
if (!cssText) return cssText;
return cssText.replace(
/\burl\(\s*(["']?)([^)"']+)\1\s*\)/g,
(_full, quote: string, rawUrl: string) => {
const maybeInlined = maybeInlineRelativeAssetUrl((rawUrl || "").trim(), projectDir);
if (!maybeInlined) return _full;
return `url(${quote || ""}${maybeInlined}${quote || ""})`;
},
);
}
function cssAttributeSelector(attr: string, value: string): string {
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `[${attr}="${escaped}"]`;
}
function uniqueCompositionId(baseId: string, index: number): string {
return `${baseId}__hf${index}`;
}
export type BundledHostCompositionIdentity = {
authoredCompositionId: string | null;
runtimeCompositionId: string | null;
};
function getBundledHostCompositionIdentity(host: Element): BundledHostCompositionIdentity {
const currentCompositionId = (host.getAttribute("data-composition-id") || "").trim() || null;
const authoredCompositionId =
(host.getAttribute("data-hf-original-composition-id") || currentCompositionId || "").trim() ||
null;
return {
authoredCompositionId,
runtimeCompositionId: currentCompositionId,
};
}
function getBundledTrackedCompositionHosts(document: Document): Element[] {
const hosts = Array.from(
document.querySelectorAll<Element>("[data-composition-src], [data-composition-id]"),
);
return hosts.filter((host) => {
if (host.hasAttribute("data-composition-src")) return true;
const authoredCompositionId = getBundledHostCompositionIdentity(host).authoredCompositionId;
if (!authoredCompositionId) return false;
return !!document.getElementById(`${authoredCompositionId}-template`);
});
}
function shouldAssignBundledRuntimeCompositionId(host: Element, document: Document): boolean {
if (host.hasAttribute("data-composition-src")) return true;
const authoredCompositionId = getBundledHostCompositionIdentity(host).authoredCompositionId;
if (!authoredCompositionId) return false;
if (!document.getElementById(`${authoredCompositionId}-template`)) return false;
return host.children.length === 0;
}
function countBundledAuthoredCompositionIds(hosts: Element[]): Map<string, number> {
const counts = new Map<string, number>();
for (const host of hosts) {
const authoredCompositionId = getBundledHostCompositionIdentity(host).authoredCompositionId;
if (!authoredCompositionId) continue;
counts.set(authoredCompositionId, (counts.get(authoredCompositionId) || 0) + 1);
}
return counts;
}
// fallow-ignore-next-line complexity
export function assignBundledRuntimeCompositionIds(
hosts: Element[],
counts: Map<string, number> = countBundledAuthoredCompositionIds(hosts),
): Map<Element, BundledHostCompositionIdentity> {
const instanceByCompositionId = new Map<string, number>();
const identities = new Map<Element, BundledHostCompositionIdentity>();
for (const host of hosts) {
const { authoredCompositionId, runtimeCompositionId: previousRuntimeCompositionId } =
getBundledHostCompositionIdentity(host);
const shouldAssign = shouldAssignBundledRuntimeCompositionId(host, host.ownerDocument);
if (!authoredCompositionId) {
identities.set(host, {
authoredCompositionId: null,
runtimeCompositionId: previousRuntimeCompositionId,
});
continue;
}
const duplicateInstance = (counts.get(authoredCompositionId) || 0) > 1;
let runtimeCompositionId = previousRuntimeCompositionId || authoredCompositionId;
if (shouldAssign) {
const instanceIndex = duplicateInstance
? (instanceByCompositionId.get(authoredCompositionId) || 0) + 1
: 0;
if (duplicateInstance) {
instanceByCompositionId.set(authoredCompositionId, instanceIndex);
host.setAttribute("data-hf-original-composition-id", authoredCompositionId);
} else {
host.removeAttribute("data-hf-original-composition-id");
}
runtimeCompositionId = duplicateInstance
? uniqueCompositionId(authoredCompositionId, instanceIndex)
: authoredCompositionId;
host.setAttribute("data-composition-id", runtimeCompositionId);
}
identities.set(host, {
authoredCompositionId,
runtimeCompositionId,
});
}
return identities;
}
export function prepareFlattenedInnerRoot(innerRoot: Element): Element {
const prepared = innerRoot.cloneNode(true) as Element;
markFlattenedInnerRoot(prepared);
const w = prepared.getAttribute("data-width");
const h = prepared.getAttribute("data-height");
const widthVal = w ? `${w}px` : "100%";
const heightVal = h ? `${h}px` : "100%";
const existingStyle = (prepared.getAttribute("style") || "").trim();
const fill = `width:${widthVal};height:${heightVal}`;
prepared.setAttribute("style", existingStyle ? `${existingStyle};${fill}` : fill);
return prepared;
}
function enforceCompositionPixelSizing(document: Document): void {
const compositionEls = [
...document.querySelectorAll("[data-composition-id][data-width][data-height]"),
];
if (compositionEls.length === 0) return;
const sizeMap = new Map<string, { w: number; h: number }>();
for (const el of compositionEls) {
const compId = el.getAttribute("data-composition-id");
const w = Number(el.getAttribute("data-width"));
const h = Number(el.getAttribute("data-height"));
if (compId && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
sizeMap.set(compId, { w, h });
}
}
if (sizeMap.size === 0) return;
for (const styleEl of document.querySelectorAll("style")) {
let css = styleEl.textContent || "";
let modified = false;
for (const [compId, { w, h }] of sizeMap) {
const escaped = compId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const blockRe = new RegExp(
`(\\[data-composition-id=["']${escaped}["']\\]\\s*\\{)([^}]*)(})`,
"g",
);
css = css.replace(blockRe, (_, open, body, close) => {
const newBody = body
.replace(/(\bwidth\s*:\s*)100%/g, `$1${w}px`)
.replace(/(\bheight\s*:\s*)100%/g, `$1${h}px`);
if (newBody !== body) modified = true;
return open + newBody + close;
});
}
if (modified) styleEl.textContent = css;
}
}
function autoHealMissingCompositionIds(document: Document): void {
const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
const referencedIds = new Set<string>();
for (const el of document.querySelectorAll("style, script")) {
const text = (el.textContent || "").trim();
if (!text) continue;
let match: RegExpExecArray | null;
while ((match = compositionIdRe.exec(text)) !== null) {
const compId = (match[1] || "").trim();
if (compId) referencedIds.add(compId);
}
}
if (referencedIds.size === 0) return;
const existingIds = new Set<string>();
for (const el of document.querySelectorAll("[data-composition-id]")) {
const id = (el.getAttribute("data-composition-id") || "").trim();
if (id) existingIds.add(id);
}
for (const compId of referencedIds) {
if (compId === "root" || existingIds.has(compId)) continue;
const candidates = [`${compId}-layer`, `${compId}-comp`, compId];
for (const targetId of candidates) {
const found = document.getElementById(targetId);
if (found && !found.getAttribute("data-composition-id")) {
found.setAttribute("data-composition-id", compId);
break;
}
}
}
}
function coalesceHeadStylesAndBodyScripts(document: Document): void {
const headStyleEls = [...document.querySelectorAll("head style")];
if (headStyleEls.length > 1) {
const imports: string[] = [];
const cssParts: string[] = [];
const seenImports = new Set<string>();
for (const el of headStyleEls) {
const raw = (el.textContent || "").trim();
if (!raw) continue;
const nonImportCss = raw.replace(CSS_IMPORT_RE, (match) => {
const cleaned = match.trim();
if (!seenImports.has(cleaned)) {
seenImports.add(cleaned);
imports.push(cleaned);
}
return "";
});
const trimmed = nonImportCss.trim();
if (trimmed) cssParts.push(trimmed);
}
const merged = [...imports, ...cssParts].join("\n\n").trim();
if (merged) {
headStyleEls[0]!.textContent = merged;
for (let i = 1; i < headStyleEls.length; i++) headStyleEls[i]!.remove();
}
}
const bodyInlineScripts = [...document.querySelectorAll("body script")].filter((el) => {
if (el.hasAttribute(RUNTIME_BOOTSTRAP_ATTR) || el.hasAttribute("src")) return false;
const type = (el.getAttribute("type") || "").trim().toLowerCase();
return !type || type === "text/javascript" || type === "application/javascript";
});
if (bodyInlineScripts.length > 0) {
const mergedJs = joinJsChunks(bodyInlineScripts.map((el) => el.textContent || ""));
for (const el of bodyInlineScripts) el.remove();
if (mergedJs) {
const stripped = stripJsCommentsParserSafe(mergedJs);
const inlineScript = document.createElement("script");
inlineScript.textContent = stripped;
document.body.appendChild(inlineScript);
}
}
}
/**
* Force subpixel glyph positioning so headless rendering paths
* (chrome-headless-shell with BeginFrame) lay text out identically to full
* Chrome. `text-rendering: auto` resolves to `optimizeSpeed` (integer glyph
* advances) in headless-shell but `geometricPrecision` in full Chrome, which
* shifts line-wrap points and any animation that reads measured text width.
* Mirrors the producer's `injectTextRenderingRule` so bundled previews and
* compiled renders stay byte-aligned. `*` has zero specificity, so authored
* class/id rules still override.
*/
function injectTextRenderingRule(document: Document): void {
const head = document.head;
if (!head) return;
if (document.querySelector("style[data-hyperframes-text-rendering]")) return;
const styleEl = document.createElement("style");
styleEl.setAttribute("data-hyperframes-text-rendering", "true");
styleEl.textContent = "html,body,*{text-rendering:geometricPrecision}";
head.insertBefore(styleEl, head.firstChild);
}
/**
* Concatenate JS chunks safely. Goals:
* - Each chunk's last statement is terminated, so joining can't introduce ASI
* surprises (e.g. `a()` followed by `(b)()` — the second chunk would parse
* as a call on the first's return value).
* - In the common case (chunk already ends with `;` — typical of esbuild
* output and IIFE-wrapped composition scripts ending in `})();`), the join
* produces clean output: chunks separated by `\n` with no stray bare
* semicolon lines.
* - Defensive against trailing line comments. If a chunk ends with `// ...`
* and we appended `;` on the same line, the appended semicolon would be
* swallowed by the comment, leaving the next chunk's first statement
* attached to the previous chunk's last expression — exactly the ASI
* hazard this helper exists to prevent. So when a chunk doesn't already
* end in `;`, we append `\n;` instead — the newline closes any line
* comment, and the standalone `;` becomes the statement separator.
*/
function joinJsChunks(chunks: string[]): string {
return chunks
.map((chunk) => chunk.trim())
.filter((chunk) => chunk.length > 0)
.map((chunk) => (chunk.endsWith(";") ? chunk : chunk + "\n;"))
.join("\n");
}
function stripJsCommentsParserSafe(source: string): string {
if (!source) return source;
try {
const result = transformSync(source, { loader: "js", minify: false, legalComments: "none" });
return result.code.trim();
} catch {
return source;
}
}
export interface BundleOptions {
/** Project-relative HTML entry to bundle. Defaults to `index.html`. */
entryFile?: string;
/** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */
probeMediaDuration?: MediaDurationProber;
/**
* How to handle the HyperFrames runtime <script> tag. Default: `"inline"`.
*
* - `"inline"` — embed the runtime IIFE body directly into the bundle. Produces
* genuinely self-contained HTML. Right for CLI render output, validate,
* snapshot, and any "ship a single .html file" use case.
* - `"placeholder"` — emit `<script ... src=""></script>` so the caller can
* substitute it with a real URL via string replace. Used by the dev studio
* server and vite preview to point at a local runtime endpoint, which keeps
* the runtime cacheable across hot-reloads instead of re-inlining ~150 KB
* on every change.
*
* The `HYPERFRAME_RUNTIME_URL` env var, when set, takes precedence over both
* modes and emits `<script ... src="<URL>">` directly.
*/
runtime?: "inline" | "placeholder";
/**
* Inline .cube LUTs referenced from data-color-grading. Default: true for
* self-contained renders/exports. Studio preview disables this so the editor
* keeps showing project asset paths instead of giant data URLs.
*/
inlineColorGradingLuts?: boolean;
}
/**
* Bundle a project's index.html into a single self-contained HTML file.
*
* - Compiles timing attributes and optionally resolves media durations
* - Injects the HyperFrames runtime script
* - Inlines local CSS and JS files
* - Inlines sub-composition HTML fragments (data-composition-src)
* - Inlines small textual assets as data URLs
*/
function ensureExternalScriptTag(doc: Document, src: string): void {
if (queryByAttr(doc, "src", src, "script")) return;
const el = doc.createElement("script");
el.setAttribute("src", src);
doc.body.appendChild(el);
}
function hoistExternalScript(
src: string,
projectDir: string,
doc: Document,
seenSrcs: Set<string>,
chunks: string[],
): void {
if (seenSrcs.has(src)) return;
seenSrcs.add(src);
if (!isNonRelativeUrl(src) && !isAbsolute(src)) {
const jsPath = resolveWithinProject(projectDir, src);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js != null) {
chunks.push(js);
return;
}
}
ensureExternalScriptTag(doc, src);
}
function hoistCompositionScripts(
container: { querySelectorAll: (sel: string) => NodeListOf<Element> },
opts: {
projectDir: string;
document: Document;
compId: string | null;
runtimeScope: string | undefined;
runtimeCompId: string | undefined;
authoredRootId: string | undefined;
seenCompScriptSrcs: Set<string>;
compScriptChunks: string[];
},
): void {
for (const scriptEl of [...container.querySelectorAll("script")]) {
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
hoistExternalScript(
externalSrc,
opts.projectDir,
opts.document,
opts.seenCompScriptSrcs,
opts.compScriptChunks,
);
} else {
opts.compScriptChunks.push(
opts.compId
? wrapScopedCompositionScript(
scriptEl.textContent || "",
opts.compId,
"[HyperFrames] composition script error:",
opts.runtimeScope,
opts.runtimeCompId || opts.compId,
opts.authoredRootId,
)
: wrapInlineScriptWithErrorBoundary(
scriptEl.textContent || "",
"[HyperFrames] composition script error:",
),
);
}
scriptEl.remove();
}
}
export async function bundleToSingleHtml(
projectDir: string,
options?: BundleOptions,
): Promise<string> {
const entryFile = options?.entryFile ?? "index.html";
const indexPath = resolveWithinProject(projectDir, entryFile);
if (!indexPath || !existsSync(indexPath)) {
throw new Error(`${entryFile} not found in project directory`);
}
const sourceDir = dirname(indexPath);
const resolveEntryPath = (relativePath: string): string | null => {
const resolved = resolve(sourceDir, relativePath);
return isSafePath(projectDir, resolved) ? resolved : null;
};
const rawHtml = readFileSync(indexPath, "utf-8");
const compiled = await compileHtml(rawHtml, sourceDir, options?.probeMediaDuration);
const staticGuard = await validateHyperframeHtmlContract(compiled);
if (!staticGuard.isValid) {
console.warn(
`[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`,
);
}
const withInterceptor = injectInterceptor(compiled, options?.runtime ?? "inline");
const document = parseHTMLContent(withInterceptor);
if (resolve(sourceDir) !== resolve(projectDir)) {
rebaseEntryAuthoredAssetPaths(document, sourceDir, projectDir);
}
// Inline local CSS
const localCssChunks: string[] = [];
let cssAnchorPlaced = false;
for (const el of [...document.querySelectorAll('link[rel="stylesheet"]')]) {
const href = el.getAttribute("href");
if (!href || !isRelativeUrl(href)) continue;
const cssPath = resolveEntryPath(href);
if (!cssPath) continue;
const css = safeReadFile(cssPath);
if (css == null) continue;
localCssChunks.push(inlineCssFile(css, dirname(cssPath), projectDir));
if (!cssAnchorPlaced) {
const anchor = document.createElement("style");
anchor.setAttribute("data-hf-bundled-local-css", "1");
el.replaceWith(anchor);
cssAnchorPlaced = true;
} else {
el.remove();
}
}
if (localCssChunks.length > 0) {
const anchor = document.querySelector('style[data-hf-bundled-local-css="1"]');
if (anchor) {
anchor.removeAttribute("data-hf-bundled-local-css");
anchor.textContent = localCssChunks.join("\n\n");
} else {
const style = document.createElement("style");
style.textContent = localCssChunks.join("\n\n");
document.head.appendChild(style);
}
}
// Inline local JS
const localJsChunks: string[] = [];
let jsAnchorPlaced = false;
for (const el of [...document.querySelectorAll("script[src]")]) {
const src = el.getAttribute("src");
if (!src || !isRelativeUrl(src)) continue;
const jsPath = resolveEntryPath(src);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js == null) continue;
localJsChunks.push(js);
if (!jsAnchorPlaced) {
const anchor = document.createElement("script");
anchor.setAttribute("data-hf-bundled-local-js", "1");
el.replaceWith(anchor);
jsAnchorPlaced = true;
} else {
el.remove();
}
}
if (localJsChunks.length > 0) {
const anchor = document.querySelector('script[data-hf-bundled-local-js="1"]');
const joinedJs = joinJsChunks(localJsChunks);
if (anchor) {
anchor.removeAttribute("data-hf-bundled-local-js");
anchor.textContent = joinedJs;
} else {
const script = document.createElement("script");
script.textContent = joinedJs;
document.body.appendChild(script);
}
}
// Inline sub-compositions (via shared function)
const trackedCompositionHosts = getBundledTrackedCompositionHosts(document);
const hostIdentityByElement = assignBundledRuntimeCompositionIds(trackedCompositionHosts);
const subCompositionHosts = trackedCompositionHosts.filter((host) =>
host.hasAttribute("data-composition-src"),
);
const subCompResult = inlineSubCompositions(document, subCompositionHosts, {
resolveHtml: (srcPath: string) => {
if (!isRelativeUrl(srcPath)) return null;
const compPath = resolveEntryPath(srcPath);
return compPath ? safeReadFile(compPath) : null;
},
parseHtml: parseHTMLContent,
hostIdentityMap: hostIdentityByElement,
rewriteInlineStyles: true,
flattenInnerRoot: prepareFlattenedInnerRoot,
readVariableDefaults: readDeclaredDefaults,
parseHostVariables: parseHostVariableValues,
buildScopeSelector: (compId: string) => cssAttributeSelector("data-composition-id", compId),
scriptErrorLabel: "[HyperFrames] composition script error:",
onMissingComposition: (srcPath: string, reason?: string) => {
console.warn(
`[Bundler] Skipping sub-composition "${srcPath}": ${reason ?? "the file could not be found"}.`,
);
},
});
const compStyleChunks: string[] = [...subCompResult.styles];
const compScriptChunks: string[] = [];
const compExternalLinks = [...subCompResult.externalLinks];
const compVariablesByComp: Record<string, Record<string, unknown>> = {
...subCompResult.variablesByComp,
};
const seenCompScriptSrcs = new Set<string>();
for (const scriptItem of subCompResult.scriptItems) {
if (scriptItem.kind === "inline") {
compScriptChunks.push(scriptItem.content);
continue;
}
const extSrc = scriptItem.src;
if (seenCompScriptSrcs.has(extSrc)) continue;
seenCompScriptSrcs.add(extSrc);
if (isRelativeUrl(extSrc)) {
const jsPath = resolveEntryPath(extSrc);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js != null) {
compScriptChunks.push(js);
continue;
}
}
if (!queryByAttr(document, "src", extSrc, "script")) {
const extScript = document.createElement("script");
extScript.setAttribute("src", extSrc);
document.body.appendChild(extScript);
}
}
// Inline template compositions: inject <template id="X-template"> content into
// matching empty host elements with data-composition-id="X" (no data-composition-src)
const candidateInlineHosts = trackedCompositionHosts.filter(
(host) => !host.hasAttribute("data-composition-src"),
);
for (const templateEl of [...document.querySelectorAll("template[id]")]) {
const templateId = templateEl.getAttribute("id") || "";
const match = templateId.match(/^(.+)-template$/);
if (!match) continue;
const compId = match[1];
if (!compId) continue;
const hosts = candidateInlineHosts.filter(
(host) =>
hostIdentityByElement.get(host)?.authoredCompositionId === compId &&
host.children.length === 0,
);
if (hosts.length === 0) continue;
const templateHtml = templateEl.innerHTML || "";
for (const host of hosts) {
const hostIdentity = hostIdentityByElement.get(host);
const runtimeCompId = hostIdentity?.runtimeCompositionId || compId;
const innerDoc = parseHTMLContent(templateHtml);
const innerRoot = queryByAttr(innerDoc, "data-composition-id", compId);
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
const runtimeScope = runtimeCompId
? cssAttributeSelector("data-composition-id", runtimeCompId)
: "";
const mergedVariables = runtimeCompId ? parseHostVariableValues(host) : {};
if (runtimeCompId && Object.keys(mergedVariables).length > 0) {
compVariablesByComp[runtimeCompId] = mergedVariables;
}
pushSubCompVariableStyles(
innerDoc,
innerRoot,
mergedVariables,
runtimeScope,
compStyleChunks,
);
if (innerRoot) {
// Hoist styles into the collected style chunks
for (const styleEl of [...innerRoot.querySelectorAll("style")]) {
const css = styleEl.textContent || "";
compStyleChunks.push(
compId
? scopeCssToComposition(css, compId, runtimeScope, authoredRootId, {
scopeRootSelectors: true,
})
: css,
);
styleEl.remove();
}
hoistCompositionScripts(innerRoot, {
projectDir,
document,
compId,
runtimeScope,
runtimeCompId,
authoredRootId: authoredRootId ?? undefined,
seenCompScriptSrcs,
compScriptChunks,
});
// Copy dimension attributes from inner root to host if not already set
const innerW = innerRoot.getAttribute("data-width");
const innerH = innerRoot.getAttribute("data-height");
if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW);
if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH);
const preparedInnerRoot = prepareFlattenedInnerRoot(innerRoot);
host.innerHTML = preparedInnerRoot.outerHTML || "";
} else {
// No matching inner root — inject all template content directly
for (const styleEl of [...innerDoc.querySelectorAll("style")]) {
const css = styleEl.textContent || "";
compStyleChunks.push(
compId
? scopeCssToComposition(css, compId, runtimeScope, undefined, {
scopeRootSelectors: true,
})
: css,
);
styleEl.remove();
}
hoistCompositionScripts(innerDoc, {
projectDir,
document,
compId,
runtimeScope,
runtimeCompId,
authoredRootId: undefined,
seenCompScriptSrcs,
compScriptChunks,
});
host.innerHTML = innerDoc.body.innerHTML || "";
}
}
// Remove the template element from the document
templateEl.remove();
}
// Inject external scripts from sub-compositions (e.g., Lottie CDN)
// that aren't already present in the main document.
for (const link of compExternalLinks) {
const escapedHref = link.href.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
if (!document.querySelector(`link[href="${escapedHref}"]`)) {
const linkEl = document.createElement("link");
linkEl.setAttribute("rel", link.rel);
linkEl.setAttribute("href", link.href);
if (link.crossorigin != null) linkEl.setAttribute("crossorigin", link.crossorigin);
document.head.appendChild(linkEl);
}
}
if (compStyleChunks.length) {
const style = document.createElement("style");
style.textContent = compStyleChunks.join("\n\n");
document.head.appendChild(style);
}
const variablesByCompScript = buildVariablesByCompScript(compVariablesByComp);
if (variablesByCompScript) {
compScriptChunks.unshift(variablesByCompScript);
}
if (compScriptChunks.length) {
const compScript = document.createElement("script");
compScript.textContent = joinJsChunks(compScriptChunks);
document.body.appendChild(compScript);
}
emitRootCompositionVariableStyles(document, compVariablesByComp);
enforceCompositionPixelSizing(document);
autoHealMissingCompositionIds(document);
coalesceHeadStylesAndBodyScripts(document);
injectTextRenderingRule(document);
// Inline textual assets
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);
if (!value) continue;
const inlined = maybeInlineRelativeAssetUrl(value, projectDir);
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));
}
for (const styleEl of document.querySelectorAll("style")) {
styleEl.textContent = rewriteCssUrlsWithInlinedAssets(styleEl.textContent || "", projectDir);
}
for (const el of [...document.querySelectorAll("[style]")]) {
el.setAttribute(
"style",
rewriteCssUrlsWithInlinedAssets(el.getAttribute("style") || "", projectDir),
);
}
if (options?.inlineColorGradingLuts !== false) {
for (const el of [...document.querySelectorAll(`[${HF_COLOR_GRADING_ATTR}]`)]) {
const value = el.getAttribute(HF_COLOR_GRADING_ATTR);
if (value) {
el.setAttribute(
HF_COLOR_GRADING_ATTR,
rewriteColorGradingLutWithInlinedAssets(value, projectDir),
);
}
}
}
return document.toString();
}
/** One stylesheet rule defining primitive composition variables under `selector`. */
function compositionVariablesCssBlock(
variables: Record<string, unknown>,
selector: string,
): string | null {
const lines: string[] = [];
for (const [id, value] of Object.entries(variables)) {
if ((typeof value === "string" && value !== "") || typeof value === "number") {
lines.push(` ${cssVariableName(id)}: ${String(value)};`);
}
}
if (lines.length === 0) return null;
return `${selector} {\n${lines.join("\n")}\n}`;
}
/**
* Compile-time counterpart of the runtime's injectCompositionCssVariables:
* every element declaring data-composition-variables gets a scoped stylesheet
* rule so var(--slug, literal) references resolve during body parse. The
* runtime injection remains define-if-absent, so it won't double-apply.
*
* `variablesByComp` (host-merged sub-composition values, keyed by runtime
* composition id) adds one rule per scope — the flattened inner root loses
* its data-composition-id, so the host selector is the only stable anchor.
* Exported for the producer's render compiler, which inlines sub-compositions
* through the shared module rather than this bundler.
* Returns whether a style element was appended.
*/
export function emitRootCompositionVariableStyles(
document: Document,
variablesByComp: Record<string, Record<string, unknown>> = {},
overrides: Record<string, unknown> = {},
): boolean {
const layerFor = makeVariableLayer(document, overrides);
const rules = [
...hostScopedVariableRules(variablesByComp, overrides),
...rootDeclaredVariableRules(document, layerFor),
...declarerVariableRules(document, layerFor),
];
if (rules.length === 0) return false;
const style = document.createElement("style");
style.setAttribute("data-hf-composition-variables", "");
style.textContent = rules.join("\n\n");
document.head.appendChild(style);
return true;
}
type VariableLayer = (
declared: Record<string, unknown>,
hostValues: Record<string, unknown>,
) => Record<string, unknown>;
/**
* Layering for one declarer: authored stylesheet definitions win over
* declared defaults (the runtime's define-if-absent, applied statically) —
* a var already defined in any authored <style> block is not emitted. Host
* values and --variables overrides are explicit intent, never filtered.
*/
function makeVariableLayer(document: Document, overrides: Record<string, unknown>): VariableLayer {
const authoredCss = [...document.querySelectorAll("style:not([data-hf-composition-variables])")]
.map((s) => s.textContent || "")
.join("\n");
const authoredDefines = (id: string): boolean =>
new RegExp(`${cssVariableName(id)}\\s*:`).test(authoredCss);
return (declared, hostValues) => {
const out: Record<string, unknown> = {};
for (const [id, value] of Object.entries(declared)) {
if (!authoredDefines(id)) out[id] = value;
}
for (const [id, value] of Object.entries(hostValues)) {
if (id in declared) out[id] = value;
}
for (const [id, value] of Object.entries(overrides)) {
if (id in declared || id in hostValues) out[id] = value;
}
return out;
};
}
/** Host-scoped rules: per-instance values inherited by the host's subtree. */
function hostScopedVariableRules(
variablesByComp: Record<string, Record<string, unknown>>,
overrides: Record<string, unknown>,
): string[] {
const rules: string[] = [];
for (const [compId, vars] of Object.entries(variablesByComp)) {
const withOverrides = { ...vars };
for (const [id, value] of Object.entries(overrides)) {
if (id in vars) withOverrides[id] = value;
}
const rule = compositionVariablesCssBlock(
withOverrides,
cssAttributeSelector("data-composition-id", compId),
);
if (rule) rules.push(rule);
}
return rules;
}
function rootDeclaredVariableRules(document: Document, layerFor: VariableLayer): string[] {
const htmlDeclared = readDeclaredDefaults(document.documentElement);
const htmlRule = compositionVariablesCssBlock(layerFor(htmlDeclared, {}), ":root");
return htmlRule ? [htmlRule] : [];
}
/**
* Declarer rules anchor on a per-instance marker attribute, not the
* composition id: two inlined instances of one sub-composition share a
* data-composition-id, and a shared selector would let instance A's rule
* restyle instance B. The nearest ancestor host's data-variable-values
* layer over the declared defaults (mirrors the runtime loader).
*/
function declarerVariableRules(document: Document, layerFor: VariableLayer): string[] {
const rules: string[] = [];
let markerSeq = 0;
for (const el of [...document.querySelectorAll("[data-composition-variables]")]) {
const declared = readDeclaredDefaults(el);
const hostEl =
typeof el.closest === "function" ? el.parentElement?.closest("[data-variable-values]") : null;
const hostValues = hostEl ? parseHostVariableValues(hostEl) : {};
const vars = layerFor(declared, hostValues);
if (Object.keys(vars).length === 0) continue;
markerSeq += 1;
el.setAttribute("data-hf-var-scope", String(markerSeq));
const rule = compositionVariablesCssBlock(vars, `[data-hf-var-scope="${markerSeq}"]`);
if (rule) rules.push(rule);
}
return rules;
}
/**
* Compile-time CSS custom properties for a sub-comp scope: declared defaults
* layered under per-instance host values, emitted as a stylesheet rule on the
* host selector. A stylesheet in <head> is in effect while the body parses,
* so eval-time reads (GSAP .from immediateRender, canvas tinting) see the
* right values — the runtime's DOMContentLoaded injection is too late for
* those on compiled pages.
*/
function pushSubCompVariableStyles(
innerDoc: Document,
innerRoot: Element | null,
mergedVariables: Record<string, unknown>,
runtimeScope: string,
compStyleChunks: string[],
): void {
if (!runtimeScope) return;
const declaredForCss = readDeclaredDefaults(innerDoc.documentElement);
const innerRootForVars = innerRoot ?? innerDoc.querySelector("[data-composition-variables]");
if (innerRootForVars) Object.assign(declaredForCss, readDeclaredDefaults(innerRootForVars));
const cssVars = compositionVariablesCssBlock(
{ ...declaredForCss, ...mergedVariables },
runtimeScope,
);
if (cssVars) compStyleChunks.push(cssVars);
}