Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/lint/src/rules/gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,81 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";

describe("GSAP rules", () => {
it("errors when a parsed GSAP tween executes a raw digit-leading id selector", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="123-frame"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#123-frame", { opacity: 1, duration: 0.5 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;

const result = await lintHyperframeHtml(html);

expect(
result.findings.find((finding) => finding.code === "invalid_raw_selector_execution"),
).toMatchObject({ severity: "error", elementId: "123-frame", selector: "#123-frame" });
expect(
result.findings.find((finding) => finding.code === "id_requires_css_escape")?.severity,
).toBe("warning");
});

it("errors for literal querySelector and querySelectorAll calls with raw unsafe ids", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="123-frame"></div>
<div id="456-card"></div>
</div>
<script>
document.querySelector("#123-frame");
document.querySelectorAll('#456-card');
window.__timelines = {};
</script>
</body></html>`;

const result = await lintHyperframeHtml(html);
const findings = result.findings.filter(
(finding) => finding.code === "invalid_raw_selector_execution",
);

expect(findings.map((finding) => finding.elementId).sort()).toEqual(["123-frame", "456-card"]);
});

it("keeps warning-only behavior for unused and safely escaped digit-leading ids", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="123-unused"></div>
<div id="456-escaped"></div>
<div id="789-dynamic"></div>
</div>
<script>
const unused = document.getElementById("123-unused");
const tl = gsap.timeline({ paused: true });
tl.to(unused, { opacity: 1, duration: 0.5 }, 0);
tl.to("#\\\\34 56-escaped", { opacity: 1, duration: 0.5 }, 0);
document.querySelector("#\\\\34 56-escaped");
document.querySelector("#" + CSS.escape("789-dynamic"));
window.__timelines = { c1: tl };
</script>
</body></html>`;

const result = await lintHyperframeHtml(html);

expect(
result.findings.filter((finding) => finding.code === "invalid_raw_selector_execution"),
).toEqual([]);
expect(
result.findings.filter((finding) => finding.code === "id_requires_css_escape"),
).toHaveLength(3);
});

it("errors when window.__timelines is registered BEFORE the fonts.ready build", async () => {
const html = `
<html><body>
Expand Down
50 changes: 50 additions & 0 deletions packages/lint/src/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap
return mod.parseGsapScriptAcorn as unknown as (script: string) => LintParsedGsap;
}

async function loadExtractLiteralQuerySelectorCalls(): Promise<
(script: string) => Array<{ selector: string; raw: string }>
> {
const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
return mod.extractLiteralQuerySelectorCalls;
}

async function loadExtractLiteralGsapSelectorCalls(): Promise<
(script: string) => Array<{ selector: string; raw: string }>
> {
const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
return mod.extractLiteralGsapSelectorCalls;
}

async function loadGsapScriptMotionPathFirstUseIndex(): Promise<(script: string) => number | null> {
const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
return mod.gsapScriptMotionPathFirstUseIndex;
Expand Down Expand Up @@ -1040,6 +1054,42 @@ function collectCssOpacityZeroSelectors(

// fallow-ignore-next-line complexity
export const gsapRules: LintRule<LintContext>[] = [
// invalid_raw_selector_execution
async ({ tags, scripts }) => {
const unsafeIds = tags
.map((tag) => readAttr(tag.raw, "id"))
.filter((id): id is string => Boolean(id && /^\d/.test(id)));
if (unsafeIds.length === 0) return [];

const findUnsafeId = (selector: string): string | undefined =>
unsafeIds.find((id) => new RegExp(`#${escapeRegExp(id)}(?![\\w-])`).test(selector));
const findings: HyperframeLintFinding[] = [];
const reportedIds = new Set<string>();
const report = (selector: string, snippet: string) => {
const id = findUnsafeId(selector);
if (!id || reportedIds.has(id)) return;
reportedIds.add(id);
findings.push({
code: "invalid_raw_selector_execution",
severity: "error",
message: `The raw selector "#${id}" is executed, but digit-leading IDs are not valid unescaped CSS selectors and throw a SyntaxError in the browser.`,
selector: `#${id}`,
elementId: id,
fixHint:
"Rename the id to start with a letter (recommended), or construct the selector with `#${CSS.escape(id)}` before passing it to GSAP/querySelector.",
snippet: truncateSnippet(snippet),
});
};

const extractQueryCalls = await loadExtractLiteralQuerySelectorCalls();
const extractGsapCalls = await loadExtractLiteralGsapSelectorCalls();
for (const script of scripts) {
for (const call of extractGsapCalls(script.content)) report(call.selector, call.raw);
for (const call of extractQueryCalls(script.content)) report(call.selector, call.raw);
}
return findings;
},

// overlapping_gsap_tweens + gsap_animates_clip_element
// fallow-ignore-next-line complexity
async ({ source, tags, scripts, styles, rootCompositionId }) => {
Expand Down
88 changes: 88 additions & 0 deletions packages/parsers/src/gsapParserAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,54 @@ function parseProgram(script: string): any {
}
}

export interface LiteralQuerySelectorCall {
selector: string;
raw: string;
}

export interface LiteralGsapSelectorCall {
selector: string;
raw: string;
}

/**
* Return statically literal selectors passed to querySelector/querySelectorAll.
* Dynamic expressions are intentionally omitted: callers use this only when
* the browser-invalid selector can be proven from source.
*/
export function extractLiteralQuerySelectorCalls(script: string): LiteralQuerySelectorCall[] {
try {
const ast = parseProgram(script);
const calls: LiteralQuerySelectorCall[] = [];
acornWalk.simple(ast, {
CallExpression(node: any) {
const callee = node.callee;
if (callee?.type !== "MemberExpression") return;
const method = callee.computed
? callee.property?.type === "Literal"
? callee.property.value
: undefined
: callee.property?.type === "Identifier"
? callee.property.name
: undefined;
if (method !== "querySelector" && method !== "querySelectorAll") return;
const arg = node.arguments?.[0];
const selector =
arg?.type === "Literal" && typeof arg.value === "string"
? arg.value
: arg?.type === "TemplateLiteral" && arg.expressions?.length === 0
? arg.quasis?.[0]?.value?.cooked
: undefined;
if (typeof selector !== "string") return;
calls.push({ selector, raw: script.slice(node.start, node.end) });
},
});
return calls;
} catch {
return [];
}
}

// ── Types ────────────────────────────────────────────────────────────────────

type ScopeBindings = ReadonlyMap<string, number | string | boolean>;
Expand Down Expand Up @@ -1850,6 +1898,46 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor

// ── Public API ────────────────────────────────────────────────────────────────

/**
* Return only GSAP tween calls whose target argument is itself a string
* literal. Element variables and computed selectors are omitted, even when
* the full parser can resolve them to an equivalent selector.
*/
export function extractLiteralGsapSelectorCalls(script: string): LiteralGsapSelectorCall[] {
try {
const ast = parseProgram(script);
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast, scope);
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const timelineVar = timelineRootSource(ref, script);
if (ref.kind === "identifier") {
try {
inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope));
} catch {
// Fall back to calls visible in the original AST.
}
}
const identifierBindings = collectIdentifierBindingIndex(ast);
const targetBindings = collectTargetBindings(ast, scope, identifierBindings);
const calls = findAllTweenCalls(ast, ref, scope, targetBindings);
const result: LiteralGsapSelectorCall[] = [];
for (const call of calls) {
const target = call.node.arguments?.[0];
const selector =
target?.type === "Literal" && typeof target.value === "string"
? target.value
: target?.type === "TemplateLiteral" && target.expressions?.length === 0
? target.quasis?.[0]?.value?.cooked
: undefined;
if (typeof selector !== "string") continue;
result.push({ selector, raw: script.slice(call.node.start, call.node.end) });
}
return result;
} catch {
return [];
}
}

/**
* Browser-safe equivalent of `parseGsapScript` (gsapParser.ts).
* Uses acorn + acorn-walk instead of recast + @babel/parser.
Expand Down
Loading