mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(lint): address render preflight review feedback (#2739)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user