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(``, "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) => ``; + + 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