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