fix(lint): address render preflight review feedback (#2739)

This commit is contained in:
James Russo
2026-07-22 22:59:12 -04:00
committed by GitHub
parent 948264d6b2
commit 7a294f1956
10 changed files with 788 additions and 8 deletions
+1
View File
@@ -176,6 +176,7 @@
"htmlparser2": "^10.1.0",
"linkedom": "^0.18.12",
"postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.4",
},
"devDependencies": {
"@types/node": "^25.0.10",
+2 -1
View File
@@ -56,7 +56,8 @@
"@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0",
"linkedom": "^0.18.12",
"postcss": "^8.5.8"
"postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.4"
},
"devDependencies": {
"@types/node": "^25.0.10",
+102
View File
@@ -848,6 +848,108 @@ body {
});
});
describe("repeated_id_descendant_selector", () => {
it("reports a selector that nests the same id inside itself", async () => {
const html = `<div id="scene_01" data-composition-id="root" data-width="1920" data-height="1080">
<style>#scene_01 #scene_01 .headline { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "repeated_id_descendant_selector");
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#scene_01 #scene_01 .headline");
});
it("does not report distinct descendant ids", async () => {
const html = `<div id="scene_01" data-composition-id="root" data-width="1920" data-height="1080">
<style>#scene_01 #headline { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
).toBeUndefined();
});
it.each(["#scene_01 > #scene_01", "#scene_01 .wrapper #scene_01"])(
"reports repeated ids across descendant combinators: %s",
async (selector) => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>${selector} { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
).toBeDefined();
},
);
it.each([
":is(#scene_01) #scene_01",
":where(#scene_01) #scene_01",
"#scene_01 :is(#scene_01)",
])("reports repeated ids required by selector pseudos: %s", async (selector) => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>${selector} { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
).toBeDefined();
});
it.each([
":is(#scene_01, .scene) #scene_01",
":not(#scene_01) #scene_01",
":has(#scene_01) #scene_01",
])("does not report ids that are not required by a selector pseudo: %s", async (selector) => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>${selector} { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
).toBeUndefined();
});
it.each(["& #scene_01 .headline", "#scene_01 .headline"])(
"reports repeated ids created by nested CSS: %s",
async (nestedSelector) => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>#scene_01 { ${nestedSelector} { color: red; } }</style>
</div>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "repeated_id_descendant_selector",
);
expect(finding).toBeDefined();
expect(finding?.selector).toContain("#scene_01 #scene_01");
},
);
it("preserves dollar sequences while resolving nested selectors", async () => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>#scene_01[data-query="$1"] { & #scene_01 { color: red; } }</style>
</div>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "repeated_id_descendant_selector",
);
expect(finding?.selector).toBe('#scene_01[data-query="$1"] #scene_01');
});
it.each(['[data-query="#scene_01 #scene_01"]', String.raw`#scene_01 #scene_01\:child`])(
"does not report non-repeated parsed ids: %s",
async (selector) => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>${selector} { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
).toBeUndefined();
},
);
});
it("warns when a timeline-visible element has no stable id for Studio editing", async () => {
const html = `
<html><body>
+111
View File
@@ -1,5 +1,6 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
import {
readAttr,
readDecodedAttr,
@@ -25,6 +26,87 @@ function selectorTargetsCompositionId(selector: string, compositionId: string):
).test(selector);
}
function repeatedDescendantId(selector: string): string | null {
let repeated: string | null = null;
const requiredPseudoIds = (pseudo: selectorParser.Pseudo): Set<string> => {
if (![":is", ":where"].includes(pseudo.value.toLowerCase()) || pseudo.nodes.length === 0) {
return new Set<string>();
}
const optionIdSets: Set<string>[] = [];
for (const option of pseudo.nodes) {
// Only promote ids from a single compound. For selector-list branches with
// combinators, determining which compound is the subject requires fuller
// selector semantics; skipping them avoids false positives.
if (option.nodes.some((node) => node.type === "combinator")) return new Set<string>();
const optionIds = new Set<string>(
option.nodes.filter((node) => node.type === "id").map((node) => node.value),
);
optionIdSets.push(optionIds);
}
const [firstOptionIds, ...remainingOptionIds] = optionIdSets;
return new Set<string>(
[...(firstOptionIds ?? [])].filter((id) =>
remainingOptionIds.every((optionIds) => optionIds.has(id)),
),
);
};
try {
selectorParser((root) => {
root.each((selectorNode) => {
const firstCompoundById = new Map<string, number>();
let compound = 0;
selectorNode.each((node) => {
if (repeated) return;
if (node.type === "combinator") {
compound += 1;
return;
}
const requiredIds =
node.type === "id"
? [node.value]
: node.type === "pseudo"
? [...requiredPseudoIds(node)]
: [];
for (const id of requiredIds) {
const firstCompound = firstCompoundById.get(id);
if (firstCompound !== undefined && firstCompound !== compound) {
repeated = id;
return;
}
firstCompoundById.set(id, compound);
}
});
});
}).processSync(selector);
} catch {
return null;
}
return repeated;
}
function resolvedRuleSelectors(rule: postcss.Rule): string[] {
let ancestor: postcss.AnyNode | undefined = rule.parent;
while (ancestor && ancestor.type !== "rule") ancestor = ancestor.parent;
if (!ancestor || ancestor.type !== "rule") return rule.selectors;
const parentSelectors = resolvedRuleSelectors(ancestor);
return parentSelectors.flatMap((parentSelector) =>
rule.selectors.map((childSelector) => {
const nestingToken = /(^|[\s>+~,(])&/g;
if (nestingToken.test(childSelector)) {
return childSelector.replace(
nestingToken,
(_, separator: string) => separator + parentSelector,
);
}
return `${parentSelector} ${childSelector}`;
}),
);
}
function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
return false;
@@ -321,6 +403,35 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
return findings;
},
// repeated_id_descendant_selector
({ styles }) => {
const findings: HyperframeLintFinding[] = [];
const reported = new Set<string>();
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkRules((rule) => {
for (const selector of resolvedRuleSelectors(rule)) {
const repeatedId = repeatedDescendantId(selector);
if (!repeatedId || reported.has(repeatedId)) continue;
reported.add(repeatedId);
findings.push({
code: "repeated_id_descendant_selector",
severity: "error",
message: `Selector "${selector}" requires #${repeatedId} to be nested inside another #${repeatedId}. IDs must be unique, so this selector cannot match a valid composition.`,
selector,
fixHint: `Remove the duplicate ancestor: change \`#${repeatedId} #${repeatedId}\` to \`#${repeatedId}\`.`,
});
}
});
}
return findings;
},
// invalid_inline_script_syntax (malformed close tag)
({ source }) => {
if (!INVALID_SCRIPT_CLOSE_PATTERN.test(source)) return [];
+28
View File
@@ -262,6 +262,34 @@ describe("font rules", () => {
expect(result.errorCount).toBe(0);
});
it("accepts a URL-style plus alias used literally as the CSS family", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@import url("https://fonts.googleapis.com/css2?family=DM+Mono&family=IBM+Plex+Mono&display=swap");
h1 { font-family: 'DM+Mono', monospace; }
code { font-family: 'IBM+Plex+Mono', monospace; }
</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
expect(result.errorCount).toBe(0);
});
it("does not decode percent escapes in a literal CSS family", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@import url("https://fonts.googleapis.com/css2?family=DM+Mono&display=swap");
h1 { font-family: 'DM%20Mono', monospace; }
</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(1);
});
it("still flags non-bundled families not covered by the Google Fonts URL", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
+4 -1
View File
@@ -220,7 +220,10 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
const googleFonts = collectGoogleFontFamilies(source, styles);
const undeclared = used.filter(
(name) => !declared.has(name) && !FONT_ALIAS_KEYS.has(name) && !googleFonts.has(name),
(name) =>
!declared.has(name) &&
!FONT_ALIAS_KEYS.has(name) &&
!googleFonts.has(name.replace(/\+/g, " ")),
);
if (undeclared.length === 0) return findings;
+294
View File
@@ -1011,6 +1011,300 @@ describe("GSAP rules", () => {
expect(finding).toBeDefined();
});
it("reports motionPath usage without MotionPathPlugin", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { motionPath: { path: "#route" }, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_plugin");
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("MotionPathPlugin");
});
it("reports standalone gsap.to motionPath usage without MotionPathPlugin", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>gsap.to("#dot", { motionPath: { path: "#route" }, duration: 1 });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
});
it("reports ESM motionPath usage without importing MotionPathPlugin", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import { gsap } from "https://cdn.jsdelivr.net/npm/gsap@3/index.js";
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { motionPath: { path: "#route" }, duration: 1 }, 0);
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
});
it("accepts motionPath usage when MotionPathPlugin is loaded", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js"></script>
<script>
gsap.registerPlugin(MotionPathPlugin);
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { motionPath: { path: "#route" }, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
});
it("reports MotionPathPlugin loaded after the animation script", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>gsap.to("#dot", { motionPath: { path: "#route" }, duration: 1 });</script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js"></script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
});
it.each(["defer", "async", 'type="module"'])(
"does not treat an earlier non-blocking %s plugin script as available to classic code",
async (attribute) => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script ${attribute} src="https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js"></script>
<script>gsap.to("#dot", { motionPath: { path: "#route" }, duration: 1 });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
},
);
it("treats defer on an inline classic tween as blocking", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js"></script>
<script defer>gsap.to("#dot", { motionPath: { path: "#route" } });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
});
it("accepts a deferred classic plugin before a non-async module tween", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js"></script>
<script type="module">gsap.to("#dot", { motionPath: { path: "#route" } });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
});
it("ignores async and defer text inside quoted attribute values", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script data-note="foo async defer" src="https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js"></script>
<script>gsap.to("#dot", { motionPath: { path: "#route" } });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
});
it("recognizes valid unquoted module attributes", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type=module src=https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js></script>
<script>gsap.to("#dot", { motionPath: { path: "#route" } });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
});
it("treats async on an inline classic plugin definition as blocking", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script async>const MotionPathPlugin = {};</script>
<script>gsap.to("#dot", { motionPath: { path: "#route" } });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
});
it("does not accept plugin-like substrings in import specifiers", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import kit from "./AutoMotionPathPluginKit.js";
gsap.to("#dot", { motionPath: { path: "#route" } });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
});
it("accepts a static plugin import in the same async module as the tween", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script async type="module">
import { gsap, MotionPathPlugin } from "gsap/all";
gsap.registerPlugin(MotionPathPlugin);
gsap.to("#dot", { motionPath: { path: "#route" }, duration: 1 });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
});
it("reports an inline plugin definition that occurs after the tween", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
gsap.to("#dot", { motionPath: { path: "#route" }, duration: 1 });
const MotionPathPlugin = {};
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
});
it("does not treat a long comment-only MotionPathPlugin mention as a loaded bundle", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>${" ".repeat(5100)}// TODO load MotionPathPlugin</script>
<script>gsap.to("#dot", { motionPath: { path: "#route" }, duration: 1 });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
});
it("does not treat a MotionPathPlugin string literal as a loaded bundle", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>const docs = "MotionPathPlugin";</script>
<script>gsap.to("#dot", { motionPath: { path: "#route" }, duration: 1 });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
});
it("still reports motionPath when code registers an unloaded plugin global", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
gsap.registerPlugin(MotionPathPlugin);
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { motionPath: { path: "#route" }, duration: 1 }, 0);
window.__timelines = { main: tl };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
});
it("does not treat an unrelated motionPath object as GSAP plugin usage", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
const mapOptions = { motionPath: { path: "#route" } };
console.log("motionPath: disabled", mapOptions);
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
});
it("allows sub-compositions to inherit MotionPathPlugin from their host", async () => {
const html = `
<template>
<div data-composition-id="scene" data-width="1920" data-height="1080">
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { motionPath: { path: "#route" }, duration: 1 }, 0);
window.__timelines = { scene: tl };
</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
});
it("accepts an inline ESM import of MotionPathPlugin", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import { gsap } from "https://cdn.jsdelivr.net/npm/gsap@3/index.js";
import { MotionPathPlugin } from "https://cdn.jsdelivr.net/npm/gsap@3/MotionPathPlugin.js";
gsap.registerPlugin(MotionPathPlugin);
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { motionPath: { path: "#route" }, duration: 1 }, 0);
window.__timelines = { main: tl };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
});
it("accepts a default-aliased ESM import sourced from MotionPathPlugin", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import { gsap } from "https://cdn.jsdelivr.net/npm/gsap@3/index.js";
import MP from "https://cdn.jsdelivr.net/npm/gsap@3/MotionPathPlugin.js";
gsap.registerPlugin(MP);
gsap.to("#dot", { motionPath: { path: "#route" }, duration: 1 });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
});
it("accepts a named MotionPathPlugin import from a barrel module", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import { gsap, MotionPathPlugin as MP } from "./vendor";
gsap.registerPlugin(MP);
gsap.to("#dot", { motionPath: { path: "#route" }, duration: 1 });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
});
it("does NOT report overlapping_gsap_tweens for distinct unresolved-target tweens", async () => {
// Each tween targets a DIFFERENT element via a target the parser cannot resolve
// statically (a helper call). Both collapse to the `__unresolved__` sentinel, but
+84
View File
@@ -26,6 +26,11 @@ async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap
const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
return mod.parseGsapScriptAcorn as unknown as (script: string) => LintParsedGsap;
}
async function loadGsapScriptMotionPathFirstUseIndex(): Promise<(script: string) => number | null> {
const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
return mod.gsapScriptMotionPathFirstUseIndex;
}
import type { LintContext } from "../context";
import type { HyperframeLintFinding, LintRule } from "../types";
import type { OpenTag } from "../utils";
@@ -1420,6 +1425,85 @@ export const gsapRules: LintRule<LintContext>[] = [
];
},
// missing_gsap_plugin
async ({ scripts, rawSource, options }) => {
const canInheritPluginFromHost =
options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
if (canInheritPluginFromHost) return [];
const gsapScriptMotionPathFirstUseIndex = await loadGsapScriptMotionPathFirstUseIndex();
const motionPathUseIndices = scripts.map((script) =>
gsapScriptMotionPathFirstUseIndex(script.content),
);
const firstMotionPathScriptIndex = motionPathUseIndices.findIndex((index) => index !== null);
const firstMotionPathUseIndex = motionPathUseIndices[firstMotionPathScriptIndex] ?? null;
const firstUseScript = scripts[firstMotionPathScriptIndex];
const executionMode = (attrs: string): "blocking" | "defer" | "module" | "async" => {
const tag = `<script ${attrs}>`;
const isModule = (readDecodedAttr(tag, "type") ?? "").toLowerCase() === "module";
const hasSrc = readDecodedAttr(tag, "src") !== null;
const hasAsync = readDecodedAttr(tag, "async") !== null;
const hasDefer = readDecodedAttr(tag, "defer") !== null;
if ((isModule || hasSrc) && hasAsync) return "async";
if (isModule) return "module";
if (hasSrc && hasDefer) return "defer";
return "blocking";
};
const firstUseMode = firstUseScript ? executionMode(firstUseScript.attrs) : "blocking";
const hasMotionPathPlugin = scripts
.slice(0, firstMotionPathScriptIndex + 1)
.some((script, candidateIndex) => {
const candidateMode = executionMode(script.attrs);
const sameScript = candidateIndex === firstMotionPathScriptIndex;
const candidateIsPostParse = candidateMode === "defer" || candidateMode === "module";
const firstUseIsPostParse = firstUseMode === "defer" || firstUseMode === "module";
const executesBeforeFirstUse =
sameScript ||
candidateMode === "blocking" ||
(candidateIsPostParse && firstUseIsPostParse);
if (!executesBeforeFirstUse || (!sameScript && candidateMode === "async")) return false;
const src = readAttr(`<script ${script.attrs}>`, "src") ?? "";
const uncommented = stripJsComments(script.content);
const hasStaticImport =
/\bimport\s+(?:[\s\S]*?\sfrom\s*)?["'][^"']*\bMotionPathPlugin\b[^"']*["']/.test(
uncommented,
) ||
/\bimport\s+(?:[\w$]+\s*,\s*)?\{[^}]*\bMotionPathPlugin\b[^}]*\}\s+from\s*["'][^"']+["']/.test(
uncommented,
) ||
/\bimport\s+MotionPathPlugin\s+from\s*["'][^"']+["']/.test(uncommented);
const inlinedMarkerIndex = script.content.search(/\/\*\s*inlined:.*MotionPathPlugin/i);
const definitionIndex = uncommented.search(
/\b(?:const|let|var|class|function)\s+MotionPathPlugin\b/,
);
if (sameScript) {
if (hasStaticImport) return true;
if (firstMotionPathUseIndex === null) return false;
return (
(inlinedMarkerIndex >= 0 && inlinedMarkerIndex < firstMotionPathUseIndex) ||
(definitionIndex >= 0 && definitionIndex < firstMotionPathUseIndex)
);
}
return (
/MotionPathPlugin/i.test(src) ||
hasStaticImport ||
inlinedMarkerIndex >= 0 ||
definitionIndex >= 0
);
});
if (firstMotionPathScriptIndex < 0 || hasMotionPathPlugin) return [];
return [
{
code: "missing_gsap_plugin",
severity: "error",
message:
"A GSAP tween uses motionPath, but MotionPathPlugin is not loaded. Core GSAP ignores this plugin-specific property, so the intended motion will not render.",
fixHint:
"Load MotionPathPlugin before the animation script and register it with gsap.registerPlugin(MotionPathPlugin), or replace motionPath with core GSAP x/y tweens.",
},
];
},
// audio_reactive_single_tween_per_group
// fallow-ignore-next-line complexity
({ scripts, styles }) => {
@@ -12,7 +12,7 @@
* commit adds the acorn motionPath parser itself.
*/
import { describe, it, expect } from "vitest";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
import { gsapScriptUsesMotionPath, parseGsapScriptAcorn } from "./gsapParserAcorn.js";
import { serializeGsapAnimations } from "./gsapSerialize.js";
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapSerialize.js";
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js";
@@ -936,6 +936,69 @@ describe("native GSAP keyframes parsing", () => {
// ── motionPath parsing ────────────────────────────────────────────────────────
describe("motionPath parsing", () => {
it("detects standalone and ESM MotionPathPlugin tween properties", () => {
expect(gsapScriptUsesMotionPath('gsap.to("#dot", { motionPath: { path: "#route" } });')).toBe(
true,
);
expect(
gsapScriptUsesMotionPath(`
import { gsap } from "gsap";
const tl = gsap.timeline();
tl.to("#dot", { motionPath: { path: "#route" } });
`),
).toBe(true);
expect(
gsapScriptUsesMotionPath('gsap.timeline().to("#dot", { motionPath: { path: "#route" } });'),
).toBe(true);
expect(
gsapScriptUsesMotionPath('gsap.to("#dot", { x: 10 }).to("#dot", { motionPath: {} });'),
).toBe(true);
expect(
gsapScriptUsesMotionPath(`
const vars = { x: 100 };
function build() {
const vars = { motionPath: { path: "#route" } };
return vars;
}
gsap.to("#dot", vars);
`),
).toBe(false);
expect(
gsapScriptUsesMotionPath(`
function buildTimeline() {
const tl = gsap.timeline();
return tl;
}
function unrelated() {
const tl = { to() {} };
tl.to("#dot", { motionPath: { path: "#route" } });
}
`),
).toBe(false);
expect(
gsapScriptUsesMotionPath(`
const intro = gsap.timeline();
const outro = gsap.timeline();
const fromVars = { motionPath: { path: "#route" } };
outro.to("#other", { opacity: 0 }).fromTo("#dot", fromVars, { opacity: 1 });
`),
).toBe(true);
expect(gsapScriptUsesMotionPath('const config = { motionPath: { path: "#route" } };')).toBe(
false,
);
});
it("parses ESM plugin-native selector paths even when they cannot become editable arc data", () => {
const result = parseGsapScriptAcorn(`
import { gsap } from "gsap";
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { motionPath: { path: "#route" }, duration: 1 }, 0);
`);
expect(result.animations).toHaveLength(1);
expect(result.animations[0]?.arcPath).toBeUndefined();
});
it("parses motionPath with waypoint array and curviness", () => {
const script = `
const tl = gsap.timeline({ paused: true });
+98 -5
View File
@@ -45,6 +45,22 @@ const SCOPE_NODE_TYPES = new Set([
"ArrowFunctionExpression",
]);
function parseProgram(script: string): any {
try {
return acorn.parse(script, {
ecmaVersion: "latest",
sourceType: "script",
locations: true,
});
} catch {
return acorn.parse(script, {
ecmaVersion: "latest",
sourceType: "module",
locations: true,
});
}
}
// ── Types ────────────────────────────────────────────────────────────────────
type ScopeBindings = ReadonlyMap<string, number | string | boolean>;
@@ -1850,11 +1866,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
*/
export function parseGsapScriptAcorn(script: string): ParsedGsap {
try {
const ast = acorn.parse(script, {
ecmaVersion: "latest",
sourceType: "script",
locations: true,
});
const ast = parseProgram(script);
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast, scope);
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
@@ -1921,6 +1933,87 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap {
}
}
/** Source offset of the first timeline or standalone GSAP MotionPathPlugin tween. */
export function gsapScriptMotionPathFirstUseIndex(script: string): number | null {
try {
const ast = parseProgram(script);
const scope = collectScopeBindings(ast);
const identifierBindings = collectIdentifierBindingIndex(ast);
const timelineRef = findTimelineVar(ast, scope).ref;
const timelineDeclarations = new Set<any>();
let firstUseIndex: number | null = null;
acornWalk.ancestor(ast, {
VariableDeclarator(node: any) {
if (node.id?.type === "Identifier" && isGsapTimelineCall(node.init)) {
timelineDeclarations.add(node);
}
},
AssignmentExpression(node: any, _: unknown, ancestors: any[]) {
if (node.left?.type === "Identifier" && isGsapTimelineCall(node.right)) {
const declaration = findVisibleIdentifierDeclaration(
node.left.name,
ancestors,
identifierBindings,
node.start,
);
if (declaration) timelineDeclarations.add(declaration.node);
}
},
} as any);
acornWalk.ancestor(ast, {
CallExpression(node: any, _: unknown, ancestors: any[]) {
const callee = node.callee;
const method = callee?.property?.name;
if (callee?.type !== "MemberExpression" || !GSAP_METHODS.has(method)) return;
let rootObject = callee.object;
while (rootObject?.type === "CallExpression") rootObject = rootObject.callee?.object;
const isGsapRooted = rootObject?.type === "Identifier" && rootObject.name === "gsap";
const visibleTimelineDeclaration =
rootObject?.type === "Identifier"
? findVisibleIdentifierDeclaration(
rootObject.name,
ancestors,
identifierBindings,
node.start,
)
: undefined;
const isTimelineTween =
(timelineRef?.kind === "member" ? isTimelineRootedCall(node, timelineRef) : false) ||
(!!visibleTimelineDeclaration &&
timelineDeclarations.has(visibleTimelineDeclaration.node));
if (!isGsapRooted && !isTimelineTween) return;
const varsArgs =
method === "fromTo" ? [node.arguments?.[1], node.arguments?.[2]] : [node.arguments?.[1]];
if (
varsArgs.some((varsArg) => {
if (findPropertyNode(varsArg, "motionPath")) return true;
if (varsArg?.type !== "Identifier") return false;
const declaration = findVisibleIdentifierDeclaration(
varsArg.name,
ancestors,
identifierBindings,
node.start,
);
return !!findPropertyNode(declaration?.node.init, "motionPath");
})
)
firstUseIndex = firstUseIndex === null ? node.start : Math.min(firstUseIndex, node.start);
},
} as any);
return firstUseIndex;
} catch {
return null;
}
}
/** True when a timeline or standalone GSAP tween authors a MotionPathPlugin property. */
export function gsapScriptUsesMotionPath(script: string): boolean {
return gsapScriptMotionPathFirstUseIndex(script) !== null;
}
// ── Label extraction (WS-C) ──────────────────────────────────────────────────
export interface GsapLabelEntry {