fix(core): a film that loads GSAP from its own folder can swap edited scenes in place

The preview's scan of root scripts read a vendored gsap.min.js as film code,
so the library's own strings ("*" among them) refused every scene. A root
script that is GSAP's own build (its banner, then one minified line) is now
read as the library, as the CDN copy already is; code after the build is
still scanned.
This commit is contained in:
Miguel Ángel
2026-09-28 18:33:53 -04:00
parent a0c72ad64a
commit e22bbc2e83
2 changed files with 37 additions and 6 deletions
+22 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment node
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync, rmSync } from "node:fs";
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, symlinkSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
@@ -2465,6 +2465,27 @@ describe("bundleToSingleHtml sceneParts", () => {
expect(await swapMarks(rootProject(root))).toEqual([null, null]);
});
it("reads a vendored GSAP build as the library, and film code after it as film code", async () => {
const gsap = readFileSync(
join(
__dirname,
"../../../../skills/music-to-video/references/motion-primitives/assets/gsap.min.js",
),
"utf8",
);
const root = `<script src="assets/gsap.min.js"></script>`;
expect(await swapMarks(rootProject(root, { "assets/gsap.min.js": gsap }))).toEqual([
null,
null,
]);
const bundle = `${gsap}\ndocument.querySelector(".go").onclick = () => {};`;
// Read whole, so the library's own strings refuse as they did before.
expect(await swapMarks(rootProject(root, { "assets/gsap.min.js": bundle }))).toEqual([
selects("*"),
selects("*"),
]);
});
it("runs a scene's local script file in source order with its inline scripts, as a render does", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body>
@@ -61,6 +61,14 @@ const isNotASelector = (script: string, at: number, literal: string) =>
CREATES_ELEMENT_RE.test(script.slice(Math.max(0, at - 60), at)) ||
(literal.trim() === "*" && /,\s*$/.test(script.slice(Math.max(0, at - 20), at)));
// GSAP's own build as vendored beside a film: its banner, then one minified line. Film code has no such banner.
const isGsapBuild = (script: string) =>
/^\s*\/\*!\s*\*\s*GSAP \d+\.\d+\.\d+\s/.test(script) &&
script
.slice(script.indexOf("*/") + 2)
.split("\n")
.filter((line) => line.trim() && !line.startsWith("//# sourceMappingURL=")).length === 1;
/** Marks each scene whose nodes a script outside it names by selector, id or class: a swap would strand it. */
export function refuseSwapsReachedByRootScripts(document: Document, rootScripts: string[]): void {
const hosts = [...document.querySelectorAll(`[${SCENE_PART_ATTR}]`)];
@@ -91,11 +99,13 @@ export function refuseSwapsReachedByRootScripts(document: Document, rootScripts:
),
);
const literals = new Set(
rootScripts.flatMap((s) =>
[...s.matchAll(STRING_LITERAL_RE)]
.filter((m) => !isNotASelector(s, m.index, m[2] ?? ""))
.map((m) => m[2] ?? ""),
),
rootScripts
.filter((s) => !isGsapBuild(s))
.flatMap((s) =>
[...s.matchAll(STRING_LITERAL_RE)]
.filter((m) => !isNotASelector(s, m.index, m[2] ?? ""))
.map((m) => m[2] ?? ""),
),
);
for (const literal of literals) {
const open = hosts.filter((host) => !host.hasAttribute(SCENE_NO_SWAP_ATTR));