mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
Merge pull request #1068 from func25/fix/subcomp-local-script-bundling
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
import {
|
||||
scopeCssToComposition,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
wrapScopedCompositionScript,
|
||||
} from "./compositionScoping";
|
||||
|
||||
describe("composition scoping", () => {
|
||||
it("scopes regular selectors while preserving global at-rules", () => {
|
||||
@@ -568,6 +572,26 @@ window.__afterTimeline = window.__timelines.scene;
|
||||
expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element');
|
||||
});
|
||||
|
||||
it("wraps scoped composition script source as a string literal", () => {
|
||||
const wrapped = wrapScopedCompositionScript(
|
||||
'window.payload = "</script><script>window.pwned = true;</script>";',
|
||||
"scene",
|
||||
);
|
||||
|
||||
expect(wrapped).toContain('Function("document", "gsap", "window", "__hyperframes", ');
|
||||
expect(wrapped).toContain('\\"</script><script>window.pwned = true;</script>\\"');
|
||||
});
|
||||
|
||||
it("wraps unscoped composition script source as a string literal", () => {
|
||||
const wrapped = wrapInlineScriptWithErrorBoundary(
|
||||
'window.payload = "</script><script>window.pwned = true;</script>";',
|
||||
"[HyperFrames] composition script error:",
|
||||
);
|
||||
|
||||
expect(wrapped).toContain("Function(");
|
||||
expect(wrapped).toContain('\\"</script><script>window.pwned = true;</script>\\"');
|
||||
});
|
||||
|
||||
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
`#intro { background: #111; }
|
||||
|
||||
@@ -216,6 +216,7 @@ export function wrapScopedCompositionScript(
|
||||
const authoredRootIdFormsLiteral = JSON.stringify(
|
||||
getAuthoredRootIdSelectorForms(authoredRootId?.trim() || ""),
|
||||
);
|
||||
const sourceLiteral = JSON.stringify(source);
|
||||
return `(function(){
|
||||
var __hfCompId = ${compositionIdLiteral};
|
||||
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
|
||||
@@ -485,9 +486,8 @@ export function wrapScopedCompositionScript(
|
||||
});
|
||||
var __hfRun = function() {
|
||||
try {
|
||||
(function(document, gsap, window, __hyperframes) {
|
||||
${source}
|
||||
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes);
|
||||
var __hfScript = Function("document", "gsap", "window", "__hyperframes", ${sourceLiteral});
|
||||
__hfScript.call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes);
|
||||
} catch (_err) {
|
||||
console.error(__hfErrorLabel, __hfCompId, _err);
|
||||
}
|
||||
@@ -496,3 +496,7 @@ ${source}
|
||||
__hfRun();
|
||||
})();`;
|
||||
}
|
||||
|
||||
export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: string): string {
|
||||
return `(function(){ try { Function(${JSON.stringify(source)}).call(window); } catch (_err) { console.error(${JSON.stringify(errorLabel)}, _err); } })();`;
|
||||
}
|
||||
|
||||
@@ -250,6 +250,79 @@ describe("bundleToSingleHtml", () => {
|
||||
expect(hostEl?.hasAttribute("data-composition-src")).toBe(false);
|
||||
});
|
||||
|
||||
it("inlines local scripts referenced by sub-compositions into the bundle", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
</head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="scene-host"
|
||||
data-composition-id="scene"
|
||||
data-composition-src="compositions/scene.html"
|
||||
data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
|
||||
</body></html>`,
|
||||
"compositions/scene.html": `<template id="scene-template">
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<div id="scene-copy">Scene</div>
|
||||
<script src="vendor/effect-plugin.js"></script>
|
||||
<script src="assets/scene-runtime.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["scene"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`,
|
||||
"vendor/effect-plugin.js": `window.PowerGlitch = { glitch(){ return { startGlitch(){}, stopGlitch(){} }; } };`,
|
||||
"assets/scene-runtime.js": `window.__HF_SHARED_TEST__ = "shared-runtime-loaded";`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
expect(bundled).toContain('__HF_SHARED_TEST__ = "shared-runtime-loaded"');
|
||||
expect(bundled).toContain("window.PowerGlitch = { glitch()");
|
||||
expect(bundled).not.toContain('src="assets/scene-runtime.js"');
|
||||
expect(bundled).not.toContain('src="vendor/effect-plugin.js"');
|
||||
});
|
||||
|
||||
it("preserves local sub-composition script order before inline scene scripts", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
</head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div
|
||||
id="scene-host"
|
||||
data-composition-id="scene"
|
||||
data-composition-src="compositions/scene.html"
|
||||
data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
|
||||
</body></html>`,
|
||||
"compositions/scene.html": `<template id="scene-template">
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<script src="assets/component-runtime.js"></script>
|
||||
<script>
|
||||
window.__HF_COMPONENT_CALL__ = true;
|
||||
window.Component.mount("#scene-host");
|
||||
</script>
|
||||
</div>
|
||||
</template>`,
|
||||
"assets/component-runtime.js": `window.__HF_COMPONENT_DEF__ = true; window.Component = { mount(){ window.__HF_COMPONENT_MOUNTED__ = true; } };`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
const componentIndex = bundled.indexOf("__HF_COMPONENT_DEF__");
|
||||
const sceneIndex = bundled.indexOf("__HF_COMPONENT_CALL__");
|
||||
|
||||
expect(componentIndex).toBeGreaterThan(-1);
|
||||
expect(sceneIndex).toBeGreaterThan(-1);
|
||||
expect(componentIndex).toBeLessThan(sceneIndex);
|
||||
});
|
||||
|
||||
it("does not duplicate CDN scripts already present in the main document", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
@@ -685,7 +758,8 @@ describe("bundleToSingleHtml", () => {
|
||||
expect(bundled).toContain('[data-composition-id="scene"] .title { color: red; }');
|
||||
expect(bundled).toContain("new Proxy(window.document");
|
||||
expect(bundled).toContain("new Proxy(__hfBaseGsap");
|
||||
expect(bundled).toContain('tl.to(".title"');
|
||||
expect(bundled).toContain('Function("document", "gsap", "window", "__hyperframes",');
|
||||
expect(bundled).toContain("tl.to('.title'");
|
||||
});
|
||||
|
||||
it("isolates sibling instances of the same external sub-composition", async () => {
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
stripEmbeddedRuntimeScripts,
|
||||
} from "./htmlDocument";
|
||||
// rewriteSubCompPaths functions are used by inlineSubCompositions (shared module)
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
import {
|
||||
scopeCssToComposition,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
wrapScopedCompositionScript,
|
||||
} from "./compositionScoping";
|
||||
import { validateHyperframeHtmlContract } from "./staticGuard";
|
||||
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
||||
import { readDeclaredDefaults } from "../runtime/getVariables";
|
||||
@@ -718,12 +722,34 @@ export async function bundleToSingleHtml(
|
||||
},
|
||||
});
|
||||
const compStyleChunks: string[] = [...subCompResult.styles];
|
||||
const compScriptChunks: string[] = [...subCompResult.scripts];
|
||||
const compExternalScriptSrcs: string[] = [...subCompResult.externalScriptSrcs];
|
||||
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 = safePath(projectDir, extSrc);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js != null) {
|
||||
compScriptChunks.push(js);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!document.querySelector(`script[src="${extSrc}"]`)) {
|
||||
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)
|
||||
@@ -773,8 +799,23 @@ export async function bundleToSingleHtml(
|
||||
for (const scriptEl of [...innerRoot.querySelectorAll("script")]) {
|
||||
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
|
||||
if (externalSrc) {
|
||||
if (!compExternalScriptSrcs.includes(externalSrc)) {
|
||||
compExternalScriptSrcs.push(externalSrc);
|
||||
if (!seenCompScriptSrcs.has(externalSrc)) {
|
||||
seenCompScriptSrcs.add(externalSrc);
|
||||
if (isRelativeUrl(externalSrc)) {
|
||||
const jsPath = safePath(projectDir, externalSrc);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js != null) {
|
||||
compScriptChunks.push(js);
|
||||
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
|
||||
const extScript = document.createElement("script");
|
||||
extScript.setAttribute("src", externalSrc);
|
||||
document.body.appendChild(extScript);
|
||||
}
|
||||
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
|
||||
const extScript = document.createElement("script");
|
||||
extScript.setAttribute("src", externalSrc);
|
||||
document.body.appendChild(extScript);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
compScriptChunks.push(
|
||||
@@ -787,7 +828,10 @@ export async function bundleToSingleHtml(
|
||||
runtimeCompId || compId,
|
||||
authoredRootId,
|
||||
)
|
||||
: `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
: wrapInlineScriptWithErrorBoundary(
|
||||
scriptEl.textContent || "",
|
||||
"[HyperFrames] composition script error:",
|
||||
),
|
||||
);
|
||||
}
|
||||
scriptEl.remove();
|
||||
@@ -810,8 +854,23 @@ export async function bundleToSingleHtml(
|
||||
for (const scriptEl of [...innerDoc.querySelectorAll("script")]) {
|
||||
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
|
||||
if (externalSrc) {
|
||||
if (!compExternalScriptSrcs.includes(externalSrc)) {
|
||||
compExternalScriptSrcs.push(externalSrc);
|
||||
if (!seenCompScriptSrcs.has(externalSrc)) {
|
||||
seenCompScriptSrcs.add(externalSrc);
|
||||
if (isRelativeUrl(externalSrc)) {
|
||||
const jsPath = safePath(projectDir, externalSrc);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js != null) {
|
||||
compScriptChunks.push(js);
|
||||
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
|
||||
const extScript = document.createElement("script");
|
||||
extScript.setAttribute("src", externalSrc);
|
||||
document.body.appendChild(extScript);
|
||||
}
|
||||
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
|
||||
const extScript = document.createElement("script");
|
||||
extScript.setAttribute("src", externalSrc);
|
||||
document.body.appendChild(extScript);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
compScriptChunks.push(
|
||||
@@ -823,7 +882,10 @@ export async function bundleToSingleHtml(
|
||||
runtimeScope,
|
||||
runtimeCompId || compId,
|
||||
)
|
||||
: `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
: wrapInlineScriptWithErrorBoundary(
|
||||
scriptEl.textContent || "",
|
||||
"[HyperFrames] composition script error:",
|
||||
),
|
||||
);
|
||||
}
|
||||
scriptEl.remove();
|
||||
@@ -839,14 +901,6 @@ export async function bundleToSingleHtml(
|
||||
|
||||
// Inject external scripts from sub-compositions (e.g., Lottie CDN)
|
||||
// that aren't already present in the main document.
|
||||
for (const extSrc of compExternalScriptSrcs) {
|
||||
if (!document.querySelector(`script[src="${extSrc}"]`)) {
|
||||
const extScript = document.createElement("script");
|
||||
extScript.setAttribute("src", extSrc);
|
||||
document.body.appendChild(extScript);
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of compExternalLinks) {
|
||||
const escapedHref = link.href.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
if (!document.querySelector(`link[href="${escapedHref}"]`)) {
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
rewriteCssAssetUrls,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "./rewriteSubCompPaths";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
import {
|
||||
scopeCssToComposition,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
wrapScopedCompositionScript,
|
||||
} from "./compositionScoping";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public interface
|
||||
@@ -106,6 +110,7 @@ export interface InlineSubCompositionsResult {
|
||||
styles: string[];
|
||||
scripts: string[];
|
||||
externalScriptSrcs: string[];
|
||||
scriptItems: Array<{ kind: "inline"; content: string } | { kind: "external"; src: string }>;
|
||||
externalLinks: { href: string; rel: string; crossorigin?: string }[];
|
||||
variablesByComp: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
@@ -160,6 +165,7 @@ export function inlineSubCompositions(
|
||||
const styles: string[] = [];
|
||||
const scripts: string[] = [];
|
||||
const externalScriptSrcs: string[] = [];
|
||||
const scriptItems: InlineSubCompositionsResult["scriptItems"] = [];
|
||||
const externalLinks: { href: string; rel: string; crossorigin?: string }[] = [];
|
||||
const seenLinkHrefs = new Set<string>();
|
||||
const variablesByComp: Record<string, Record<string, unknown>> = {};
|
||||
@@ -232,8 +238,11 @@ export function inlineSubCompositions(
|
||||
}
|
||||
for (const s of [...compDoc.head.querySelectorAll("script")]) {
|
||||
const externalSrc = (s.getAttribute("src") || "").trim();
|
||||
if (externalSrc && !externalScriptSrcs.includes(externalSrc)) {
|
||||
externalScriptSrcs.push(externalSrc);
|
||||
if (externalSrc) {
|
||||
if (!externalScriptSrcs.includes(externalSrc)) {
|
||||
externalScriptSrcs.push(externalSrc);
|
||||
}
|
||||
scriptItems.push({ kind: "external", src: externalSrc });
|
||||
}
|
||||
}
|
||||
for (const link of [
|
||||
@@ -271,19 +280,20 @@ export function inlineSubCompositions(
|
||||
if (!externalScriptSrcs.includes(externalSrc)) {
|
||||
externalScriptSrcs.push(externalSrc);
|
||||
}
|
||||
scriptItems.push({ kind: "external", src: externalSrc });
|
||||
} else {
|
||||
scripts.push(
|
||||
scopeCompId
|
||||
? wrapScopedCompositionScript(
|
||||
s.textContent || "",
|
||||
scopeCompId,
|
||||
scriptErrorLabel,
|
||||
runtimeScope || undefined,
|
||||
runtimeCompId || scopeCompId,
|
||||
authoredRootId,
|
||||
)
|
||||
: `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error(${JSON.stringify(scriptErrorLabel)}, _err); } })();`,
|
||||
);
|
||||
const wrappedScript = scopeCompId
|
||||
? wrapScopedCompositionScript(
|
||||
s.textContent || "",
|
||||
scopeCompId,
|
||||
scriptErrorLabel,
|
||||
runtimeScope || undefined,
|
||||
runtimeCompId || scopeCompId,
|
||||
authoredRootId,
|
||||
)
|
||||
: wrapInlineScriptWithErrorBoundary(s.textContent || "", scriptErrorLabel);
|
||||
scripts.push(wrappedScript);
|
||||
scriptItems.push({ kind: "inline", content: wrappedScript });
|
||||
}
|
||||
s.remove();
|
||||
}
|
||||
@@ -359,5 +369,5 @@ export function inlineSubCompositions(
|
||||
hostEl.removeAttribute("data-composition-src");
|
||||
}
|
||||
|
||||
return { styles, scripts, externalScriptSrcs, externalLinks, variablesByComp };
|
||||
return { styles, scripts, externalScriptSrcs, scriptItems, externalLinks, variablesByComp };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user