diff --git a/bun.lock b/bun.lock
index 64948f4e616..28dfb581fd0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -116,6 +116,7 @@
"@hyperframes/lint": "workspace:*",
"@hyperframes/parsers": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
+ "acorn": "^8.17.0",
"bpm-detective": "^2.0.5",
"linkedom": "^0.18.12",
"postcss": "^8.5.8",
diff --git a/packages/core/package.json b/packages/core/package.json
index 9f647802bc6..1d657012ea6 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -902,6 +902,7 @@
"@hyperframes/lint": "workspace:*",
"@hyperframes/parsers": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
+ "acorn": "^8.17.0",
"bpm-detective": "^2.0.5",
"linkedom": "^0.18.12",
"postcss": "^8.5.8",
diff --git a/packages/core/src/compiler/compositionAssembly.test.ts b/packages/core/src/compiler/compositionAssembly.test.ts
index f6a256a44b4..76254294f8a 100644
--- a/packages/core/src/compiler/compositionAssembly.test.ts
+++ b/packages/core/src/compiler/compositionAssembly.test.ts
@@ -126,6 +126,14 @@ describe("planCompositionAssembly", () => {
expect(ids(plan.scriptSources)).toEqual(["head-script", "body-script"]);
});
+ it.each([
+ [``, false],
+ [``, true],
+ ])("extracts %s only when a browser runs it: %s", (script, runs) => {
+ const plan = planFor(`${script}
`, "s");
+ expect(plan.scriptSources).toHaveLength(runs ? 1 : 0);
+ });
+
it("does not treat a templated composition's page head as an asset source", () => {
// The head belongs to the host page, not to the composition; only the
// non-templated (full-document) shape carries composition assets in
.
diff --git a/packages/core/src/compiler/compositionAssembly.ts b/packages/core/src/compiler/compositionAssembly.ts
index 256ae161c43..97198ecb390 100644
--- a/packages/core/src/compiler/compositionAssembly.ts
+++ b/packages/core/src/compiler/compositionAssembly.ts
@@ -34,7 +34,64 @@ const SCRIPT_SELECTOR = "script";
*/
const HOISTED_LINK_SELECTOR = 'link[rel="stylesheet"], link[rel="preconnect"]';
-export const EXTRACTED_COMPOSITION_ASSET_SELECTOR = `${STYLE_SELECTOR}, ${SCRIPT_SELECTOR}, ${HOISTED_LINK_SELECTOR}`;
+// The HTML spec's JavaScript MIME type essence matches: a script with any of these types runs as classic.
+const JAVASCRIPT_TYPES = new Set([
+ "",
+ "application/ecmascript",
+ "application/javascript",
+ "application/x-ecmascript",
+ "application/x-javascript",
+ "text/ecmascript",
+ "text/javascript",
+ "text/javascript1.0",
+ "text/javascript1.1",
+ "text/javascript1.2",
+ "text/javascript1.3",
+ "text/javascript1.4",
+ "text/javascript1.5",
+ "text/jscript",
+ "text/livescript",
+ "text/x-ecmascript",
+ "text/x-javascript",
+]);
+
+const scriptType = (el: AssemblyAttributed) => (el.getAttribute("type") || "").trim().toLowerCase();
+
+export function isJavaScriptType(el: AssemblyAttributed): boolean {
+ return JAVASCRIPT_TYPES.has(scriptType(el));
+}
+
+/**
+ * A data block (application/json, text/template, ...) or a classic nomodule script never runs,
+ * so it is mounted as authored.
+ */
+function isExecutableScript(el: AssemblyAttributed): boolean {
+ const type = scriptType(el);
+ if (type === "module" || type === "importmap") return true;
+ return JAVASCRIPT_TYPES.has(type) && el.getAttribute("nomodule") === null;
+}
+
+function inertScripts(
+ node: AssemblyQueryable | null | undefined,
+): TElement[] {
+ return toArray(node?.querySelectorAll(SCRIPT_SELECTOR)).filter((el) => !isExecutableScript(el));
+}
+
+export function executableScripts(
+ node: AssemblyQueryable | null | undefined,
+): TElement[] {
+ return toArray(node?.querySelectorAll(SCRIPT_SELECTOR)).filter(isExecutableScript);
+}
+
+/** The styles, scripts and links both paths extract, so the mounted copy must drop them. */
+export function extractedCompositionAssets(
+ node: AssemblyQueryable,
+): TElement[] {
+ return [
+ ...toArray(node.querySelectorAll(`${STYLE_SELECTOR}, ${HOISTED_LINK_SELECTOR}`)),
+ ...executableScripts(node),
+ ];
+}
/**
* The compiler's nesting cap, enforced against the ancestry chain rather than a
@@ -136,6 +193,9 @@ export interface CompositionAssemblyPlan {
/** Head and content links to hoist into the host document. */
linkSources: TElement[];
+ /** Inert scripts the mounted root would leave behind; both paths mount them next to it. */
+ inertScriptsOutsideRoot: TElement[];
+
/**
* Nodes that may declare the composition's variable defaults, in precedence
* order — later wins. Full-document compositions declare on ``;
@@ -159,9 +219,9 @@ function toArray(items: Iterable | null | undefined): TEleme
* how it is identified. Pure: it reads attributes and runs selectors, and does
* not mutate, fetch, or touch a filesystem.
*/
-export function planCompositionAssembly(
- input: CompositionAssemblyInput,
-): CompositionAssemblyPlan {
+export function planCompositionAssembly<
+ TElement extends AssemblyAttributed & AssemblyQueryable,
+>(input: CompositionAssemblyInput): CompositionAssemblyPlan {
const { contentNode, head, documentElement, hasTemplate, compositionId } = input;
const compositionRoots = toArray(contentNode.querySelectorAll(COMPOSITION_ROOT_SELECTOR));
@@ -177,6 +237,7 @@ export function planCompositionAssembly(
// A templated composition's belongs to its host page, not to it.
const assetHead = hasTemplate ? null : (head ?? null);
+ const mountedInert = innerRoot ? inertScripts(innerRoot) : [];
return {
innerRoot,
@@ -187,14 +248,15 @@ export function planCompositionAssembly(
...toArray(assetHead?.querySelectorAll(STYLE_SELECTOR)),
...toArray(contentNode.querySelectorAll(STYLE_SELECTOR)),
],
- scriptSources: [
- ...toArray(assetHead?.querySelectorAll(SCRIPT_SELECTOR)),
- ...toArray(contentNode.querySelectorAll(SCRIPT_SELECTOR)),
- ],
+ scriptSources: [...executableScripts(assetHead), ...executableScripts(contentNode)],
linkSources: [
...toArray(head?.querySelectorAll(HOISTED_LINK_SELECTOR)),
...toArray(contentNode.querySelectorAll(HOISTED_LINK_SELECTOR)),
],
+ inertScriptsOutsideRoot: [
+ ...inertScripts(assetHead),
+ ...(innerRoot ? inertScripts(contentNode).filter((el) => !mountedInert.includes(el)) : []),
+ ],
variableDefaultCarriers: [documentElement, innerRoot].filter(
(carrier): carrier is TElement => carrier != null,
),
diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts
index 76588417f06..abbdafe8f37 100644
--- a/packages/core/src/compiler/htmlBundler.test.ts
+++ b/packages/core/src/compiler/htmlBundler.test.ts
@@ -3,8 +3,13 @@ import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync, rmSync } from "node
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
+import { parse as parseJs } from "acorn";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
-import { bundleToSingleHtml, emitRootCompositionVariableStyles } from "./htmlBundler";
+import {
+ bundleToSingleHtml,
+ emitRootCompositionVariableStyles,
+ parsesAsScript,
+} from "./htmlBundler";
import { ensureExternalScriptTag } from "./externalScripts";
import { resetUnknownEnumWarnings } from "../runtime/getVariables";
import { sanitizeCssValue } from "../runtime/applyVariableBindings";
@@ -2870,6 +2875,237 @@ describe("bundleToSingleHtml script order", () => {
});
});
+describe("bundleToSingleHtml composition scripts that are not JavaScript", () => {
+ const sceneFile = (id: string, extra: string) => `
+
+ ${extra}
+
+
`;
+
+ async function bundleFilm(
+ sceneExtra: string,
+ options?: { sceneParts?: boolean },
+ files: Record = {},
+ ) {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+`,
+ "compositions/intro.html": sceneFile("intro", ""),
+ "compositions/scene.html": sceneFile("scene", sceneExtra),
+ ...files,
+ });
+ try {
+ const html = await bundleToSingleHtml(dir, options);
+ return Object.assign(parseHTML(html).document, { html });
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ const runnable = (document: Document) =>
+ [...document.querySelectorAll("body script:not([src]):not([nomodule])")].filter((el) =>
+ [null, AFTER_FONTS_SCRIPT_TYPE].includes(el.getAttribute("type")),
+ );
+ const parses = (el: Element) => {
+ try {
+ parseJs(el.textContent ?? "", { ecmaVersion: "latest", sourceType: "script" });
+ return true;
+ } catch {
+ return false;
+ }
+ };
+ const timelinesThatRun = (document: Document) =>
+ ["main", "intro", "scene"].filter((id) =>
+ runnable(document).some(
+ (el) => parses(el) && (el.textContent ?? "").includes(`__timelines.${id} =`),
+ ),
+ );
+
+ it("keeps a sub-composition's JSON data script readable and out of the JavaScript", async () => {
+ const document = await bundleFilm(
+ ``,
+ );
+ const meta = document.querySelector('script[type="application/json"]');
+ expect(meta?.getAttribute("id")).toBe("meta");
+ expect(JSON.parse(meta?.textContent ?? "")).toEqual({ title: "x", beats: [1, 2] });
+ expect(runnable(document).every(parses)).toBe(true);
+ expect(runnable(document).some((el) => el.textContent?.includes('"beats"'))).toBe(false);
+ expect(timelinesThatRun(document)).toEqual(["main", "intro", "scene"]);
+ });
+
+ // Bun 1.3's vm.Script accepts all of these; CI runs Node, so this pins the rules, not the engine.
+ it.each([
+ ["window.broken = {:", false],
+ ["window.broken = 1; return;", false],
+ ["export const broken = 1;", false],
+ ["window.ok = 1;", true],
+ ])("parsesAsScript(%j) is %s", (source, parses) => {
+ expect(parsesAsScript(source)).toBe(parses);
+ });
+
+ const BROKEN_INLINE = [``, {}] as const;
+ // An inline script is wrapped in a function, where return is legal; a local file is not.
+ const BROKEN_RETURN = [
+ ``,
+ { "compositions/return.js": "window.broken = 1; return;" },
+ ] as const;
+ const BROKEN_FILE = [
+ ``,
+ { "compositions/lib.js": "export const broken = 1;" },
+ ] as const;
+ it.each([
+ [false, ...BROKEN_INLINE],
+ [true, ...BROKEN_INLINE],
+ [false, ...BROKEN_FILE],
+ [true, ...BROKEN_FILE],
+ [false, ...BROKEN_RETURN],
+ [true, ...BROKEN_RETURN],
+ ])(
+ "keeps a sub-composition script that does not parse apart, so the others still run (sceneParts %s, %s)",
+ async (sceneParts, extra, files) => {
+ const document = await bundleFilm(extra, { sceneParts }, files);
+ const broken = runnable(document).filter((el) => !parses(el));
+ expect(broken).toHaveLength(1);
+ expect(broken[0]!.textContent).toContain("broken");
+ expect(timelinesThatRun(document)).toEqual(["main", "intro", "scene"]);
+ },
+ );
+
+ it("mounts a JSON data script authored outside the composition root next to it", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+`,
+ "compositions/scene.html": `
+
+
+`,
+ "compositions/page.html": `
+
+`,
+ });
+ try {
+ const document = parseHTML(await bundleToSingleHtml(dir)).document;
+ const beside = document.querySelector('[data-composition-id="scene"] #beside');
+ const inHead = document.querySelector('[data-composition-id="page"] #in-head');
+ expect(JSON.parse(beside?.textContent ?? "")).toEqual({ where: "template" });
+ expect(JSON.parse(inHead?.textContent ?? "")).toEqual({ where: "head" });
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("does not run a sub-composition's nomodule script", async () => {
+ const document = await bundleFilm(``);
+ expect(runnable(document).some((el) => el.textContent?.includes("LEGACY_ONLY"))).toBe(false);
+ });
+
+ it("runs an inline template composition's module script as a module and merges its import map", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
+
+`,
+ });
+ try {
+ const document = parseHTML(await bundleToSingleHtml(dir)).document;
+ expect(runnable(document).some((el) => el.textContent?.includes("CARD_MODULE"))).toBe(false);
+ const modules = [...document.querySelectorAll("script")].filter((el) =>
+ (el.getAttribute("type") ?? "").includes("module"),
+ );
+ expect(modules.some((el) => el.textContent?.includes("CARD_MODULE"))).toBe(true);
+ const map = document.querySelector('script[type="importmap"]');
+ expect(JSON.parse(map?.textContent ?? "{}").imports).toEqual({ lib: "./lib.js" });
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("keeps an inline template composition's JSON data script readable and out of the JavaScript", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
+
+`,
+ });
+ try {
+ const document = parseHTML(await bundleToSingleHtml(dir)).document;
+ const meta = document.querySelector('[data-composition-id="card"] #card-meta');
+ expect(meta?.getAttribute("type")).toBe("application/json");
+ expect(JSON.parse(meta?.textContent ?? "")).toEqual({ title: "x" });
+ expect(runnable(document).every(parses)).toBe(true);
+ expect(runnable(document).some((el) => el.textContent?.includes("__timelines.card ="))).toBe(
+ true,
+ );
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("mounts a JSON data script authored beside the root of an inline template composition", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
+
+`,
+ });
+ try {
+ const document = parseHTML(await bundleToSingleHtml(dir)).document;
+ const beside = document.querySelector('[data-composition-id="card"] #beside');
+ expect(JSON.parse(beside?.textContent ?? "")).toEqual({ where: "template" });
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it.each([
+ [false, ``],
+ [true, ``],
+ [true, ""],
+ ])(
+ "escapes a composition script it does not merge (sceneParts %s, extra %j)",
+ async (sceneParts, extra) => {
+ const document = await bundleFilm(
+ `${extra}`,
+ { sceneParts },
+ { "compositions/lib.js": 'window.tag = "LEAK