Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2514,6 +2514,190 @@ describe("bundleToSingleHtml script order", () => {
}
});

const LOCAL_ORDER_GSAP = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js";
async function bundledBody(scripts: string, files: Record<string, string>) {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><body>
<div data-composition-id="root" data-width="320" data-height="180"></div>
${scripts}
</body></html>`,
...files,
});
try {
const { document } = parseHTML(await bundleToSingleHtml(dir));
return [...document.querySelectorAll("body script")];
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

it("keeps each local script in its own place around a CDN script", async () => {
const scripts = await bundledBody(
`<script src="setup.js"></script>
<script src="${LOCAL_ORDER_GSAP}"></script>
<script src="main.js"></script>`,
{ "setup.js": "window.SETUP_RAN = 1;", "main.js": "window.MAIN_RAN = gsap.version;" },
);
const setupAt = scripts.findIndex((el) => el.textContent?.includes("SETUP_RAN"));
const gsapAt = scripts.findIndex((el) => el.getAttribute("src") === LOCAL_ORDER_GSAP);
const mainAt = scripts.findIndex((el) => el.textContent?.includes("MAIN_RAN"));
expect(setupAt).toBeGreaterThan(-1);
expect(setupAt).toBeLessThan(gsapAt);
expect(gsapAt).toBeLessThan(mainAt);
});

it.each(["defer", "async"])(
"inlines a local %s script in its own tag, keeping the attribute so the runtime times it",
async (when) => {
const scripts = await bundledBody(
`<script>window.FIRST = 1;</script>
<script ${when} src="main.js"></script>
<script>window.LAST = 1;</script>`,
{ "main.js": "window.MAIN_RAN = 1;" },
);
const main = scripts.find((el) => el.textContent?.includes("MAIN_RAN"));
expect(main?.hasAttribute(when)).toBe(true);
expect(main?.hasAttribute("src")).toBe(false);
expect(main?.getAttribute("data-hf-inlined-src")).toBe("main.js");
expect(main?.textContent).not.toMatch(/FIRST|LAST/);
},
);

it.each(["", ' type="text/babel"'])(
"escapes a local file's script-closing text, so the page keeps its structure (%s)",
async (type) => {
const scripts = await bundledBody(
`<script${type} src="main.js"></script><p id="after">ok</p>`,
{
"main.js": `window.CLOSE = "</script><p id='leak'>x</p>"; window.OPEN = "<!-- <script>";`,
},
);
const holder = scripts.find((el) => el.textContent?.includes("window.CLOSE"));
expect(holder?.textContent).toContain("window.OPEN");
expect(holder?.textContent).not.toMatch(/<\/script|<!--/i);
expect(holder?.ownerDocument.getElementById("leak")).toBeNull();
},
);

it.each([
["a local file", `<script nomodule src="legacy.js"></script>`],
["an inline script", `<script nomodule>window.LEGACY_RAN = 1;</script>`],
])("keeps nomodule on %s, so modern browsers still skip it", async (_, legacy) => {
const scripts = await bundledBody(
`<script>window.FIRST = 1;</script>\n ${legacy}\n <script>window.LAST = 1;</script>`,
{
"legacy.js": "window.LEGACY_RAN = 1;",
},
);
const holder = scripts.find((el) => el.textContent?.includes("LEGACY_RAN"));
expect(holder?.hasAttribute("nomodule")).toBe(true);
expect(holder?.textContent).not.toMatch(/FIRST|LAST/);
});

it("keeps a local script's non-JavaScript type, so it is not run as JavaScript", async () => {
const scripts = await bundledBody(`<script type="text/babel" src="app.jsx"></script>`, {
"app.jsx": "const App = () => <div />;",
});
const holder = scripts.find((el) => el.textContent?.includes("const App"));
expect(holder?.getAttribute("type")).toBe("text/babel");
});

it.each(["", ' type="text/babel"'])(
"keeps a local file's unicode regex for <!-- valid JavaScript (%s)",
async (type) => {
const scripts = await bundledBody(`<script${type} src="main.js"></script>`, {
"main.js": "window.HAS_COMMENT = /<!--/u.test(document.body.innerHTML);",
});
const holder = scripts.find((el) => el.textContent?.includes("HAS_COMMENT"));
expect(() => new Function(holder?.textContent ?? "")).not.toThrow();
},
);

it("keeps an authored script that reads the runtime global", async () => {
const scripts = await bundledBody(
`<script>if (window.__hyperframeRuntime) window.AUTHOR_SEEN = 1;</script>`,
{},
);
expect(scripts.some((el) => el.textContent?.includes("AUTHOR_SEEN"))).toBe(true);
});

it("keeps a run that merges a local file with a legacy <!-- comment valid, every script in order", async () => {
const scripts = await bundledBody(
`<script src="esc.js"></script>
<script src="legacy.js"></script>
<script>window.RAN.push("a");</script>
<script>window.RAN.push("b");</script>
<script src="plain.js"></script>`,
{
"esc.js": 'window.RAN = ["esc"]; window.CLOSE = "</script>";',
"legacy.js": '<!-- a legacy comment\nwindow.RAN.push("legacy");',
"plain.js": 'window.RAN.push("plain");',
},
);
const merged = scripts.find((el) => el.textContent?.includes('"plain"'));
expect(merged?.textContent).not.toMatch(/<\/script|<!--/i);
const page = { RAN: [] as string[] };
new Function("window", merged?.textContent ?? "")(page);
expect(page.RAN).toEqual(["esc", "legacy", "a", "b", "plain"]);
});

it.each([
["in <head>", `<script src="legacy.js"></script>`, ""],
["deferred in <body>", "", `<script defer src="legacy.js"></script>`],
])(
"keeps a local file with a legacy <!-- comment valid when it is inlined on its own (%s)",
async (_, head, body) => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head>${head}</head><body>
<div data-composition-id="root" data-width="320" data-height="180"></div>
${body}
</body></html>`,
"legacy.js": '<!-- a legacy comment\nwindow.RAN = ["legacy"];',
});
try {
const { document } = parseHTML(await bundleToSingleHtml(dir));
const file = document.querySelector("script[data-hf-inlined-src]");
expect(file?.textContent).not.toContain("<!--");
const page = { RAN: [] as string[] };
new Function("window", file?.textContent ?? "")(page);
expect(page.RAN).toEqual(["legacy"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);

it("leaves a local non-JavaScript file's text as written", async () => {
const scripts = await bundledBody(`<script type="application/json" src="data.json"></script>`, {
"data.json": "[1,2]",
});
expect(scripts.find((el) => el.getAttribute("type") === "application/json")?.textContent).toBe(
"[1,2]",
);
});

it("still adds the runtime when an authored script mentions its marker attribute", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<div data-composition-id="root" data-width="320" data-height="180"></div>
<script>window.MARKER = document.querySelector("[data-hyperframes-preview-runtime]");</script>
</body></html>`,
});
try {
const { document } = parseHTML(await bundleToSingleHtml(dir));
const scripts = [...document.querySelectorAll("script")];
expect(
scripts.filter((el) => el.hasAttribute("data-hyperframes-preview-runtime")),
).toHaveLength(1);
expect(scripts.some((el) => el.textContent?.includes("window.MARKER"))).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("still merges adjacent inline scripts into one at the end of the body", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
Expand Down
55 changes: 15 additions & 40 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
deferScriptsUntilFonts,
UNCONDITIONAL_CSS_KEY,
headStyleRuns,
INLINED_FILE_ATTR,
inlineScriptRuns,
isJavaScriptType,
styleElementsFor,
type CompositionStyle,
} from "./scriptRuns";
Expand All @@ -28,7 +30,8 @@ import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import {
RUNTIME_BOOTSTRAP_ATTR,
insertBeforeCloseTag,
escapeInlineScriptSource,
insertRuntimeTag,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument";
Expand Down Expand Up @@ -62,7 +65,6 @@ function getRuntimeScriptUrl(): string {

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).
Expand All @@ -82,19 +84,7 @@ function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" =
const inlinedRuntime = getHyperframeRuntimeScript();
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`;
}
const withHead = insertBeforeCloseTag(sanitized, "head", `${tag}\n`);
if (withHead !== null) return withHead;
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;
return insertRuntimeTag(sanitized, tag);
}

function isRelativeUrl(url: string): boolean {
Expand Down Expand Up @@ -749,7 +739,7 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void {
for (const el of members) el.remove();
if (!mergedJs) continue;
const inlineScript = document.createElement("script");
inlineScript.textContent = stripJsCommentsParserSafe(mergedJs);
inlineScript.textContent = escapeInlineScriptSource(stripJsCommentsParserSafe(mergedJs));
if (anchor) anchor.before(inlineScript);
else document.body.appendChild(inlineScript);
}
Expand Down Expand Up @@ -1226,9 +1216,6 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
templateEl.remove();
}

// 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;
Expand All @@ -1240,27 +1227,9 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
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);
}
el.setAttribute(INLINED_FILE_ATTR, src);
el.removeAttribute("src");
el.textContent = js;
}

for (const link of compExternalLinks) ensureExternalLinkTag(document, link);
Expand Down Expand Up @@ -1294,6 +1263,12 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
enforceCompositionPixelSizing(document);
autoHealMissingCompositionIds(document);
coalesceHeadStylesAndBodyScripts(document);
for (const el of document.querySelectorAll(`script[${INLINED_FILE_ATTR}]`)) {
const js = el.textContent ?? "";
el.textContent = escapeInlineScriptSource(
isJavaScriptType(el) ? stripJsCommentsParserSafe(js) : js,
);
}
deferScriptsUntilFonts(document, (el) => el.hasAttribute(RUNTIME_BOOTSTRAP_ATTR));
injectTextRenderingRule(document);

Expand Down
22 changes: 20 additions & 2 deletions packages/core/src/compiler/htmlDocument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ describe("htmlDocument helpers", () => {
expect(stripped).toContain("window.__renderReady");
});

it.each([
["reads the runtime global", "if (window.__hyperframeRuntime) window.seen = 1;"],
[
"queries the bootstrap attribute",
'document.querySelector("[data-hyperframes-preview-runtime]");',
],
["names a runtime file", 'console.log("hyperframe.runtime.iife.js");'],
["sets up window.__player", "window.__player = window.__player || {};"],
])("keeps an authored script that %s", (_, source) => {
const html = `<script>${source}</script>`;
expect(stripEmbeddedRuntimeScripts(html)).toBe(html);
});

it("strips a runtime file linked with a query string or uppercase name", () => {
const html = '<script src="/static/HYPERFRAME.RUNTIME.IIFE.JS?v=2"></script><p>kept</p>';
expect(stripEmbeddedRuntimeScripts(html)).toBe("<p>kept</p>");
});

it("does not treat non-script tags as scripts when stripping runtimes", () => {
const html = "<scripture>window.__playerReady = true;</scripture>";

Expand Down Expand Up @@ -99,7 +117,7 @@ describe("htmlDocument helpers", () => {
);

expect(injected).toContain("<\\/script ><script>window.pwned = true;<\\/script>");
expect(injected).toContain("<\\!-- kept as script text");
expect(injected).toContain("\\x3C!-- kept as script text");
expect(injected).not.toContain("</script ><script>window.pwned = true;");
});

Expand Down Expand Up @@ -148,7 +166,7 @@ describe("htmlDocument helpers", () => {
expect(stripped).toBe("<p>İİ</p><p>kept</p>");

const escaped = injectScriptsIntoHtml(page, ['x="İİ</SCRIPT>"'], []);
expect(escaped).toContain('<script>x="İİ<\\/script>"</script>');
expect(escaped).toContain('<script>x="İİ<\\/SCRIPT>"</script>');
});

it("skips a script tag written inside an attribute value", () => {
Expand Down
Loading
Loading