Skip to content
Merged
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
123 changes: 123 additions & 0 deletions packages/lint/src/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,94 @@ describe("composition rules", () => {
expect(findings[0]?.fixHint).toContain('[data-composition-id="scene"][data-start="0"]');
});

it("keeps reporting a missing duration source when the same script defeats the lexer", async () => {
const html = `
<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
var of = 2; var r = of /2;
// window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/main.html" });
const findings = result.findings.filter(
(f) => f.code === "root_composition_missing_duration_source",
);
expect(findings.length).toBe(1);
expect(findings[0]?.severity).toBe("error");
});

it("reports a split data-attribute selector that lives only in a script string literal", async () => {
const html = `
<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
document.querySelector('[data-composition-id="main" data-start="0"]');
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/main.html" });
const findings = result.findings.filter((f) => f.code === "split_data_attribute_selector");
expect(findings.length).toBe(1);
expect(findings[0]?.severity).toBe("error");
});

it("does not report a template-literal selector that only appears in a comment or a string", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"><code id="snippet"></code></div>
<script>
window.__timelines = window.__timelines || {};
// Hardcoded on purpose — do NOT use document.querySelector(\`#\${id}\`) here.
const SAMPLE = 'document.querySelector(\`[data-composition-id="\${compId}"]\`)';
document.getElementById("snippet").textContent = SAMPLE;
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "template_literal_selector")).toBeUndefined();
});

it("reports a template-literal selector in code position and quotes the real source", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const compId = "main";
const el = document.querySelector(\`[data-composition-id="\${compId}"]\`);
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding?.severity).toBe("error");
expect(finding?.snippet).toContain("data-composition-id");
});

it("does not report a split data-attribute selector written inside a CSS comment", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<style>
/* never write [data-composition-id="main" data-start="0"] — split the brackets */
#root { background: #111; }
</style>
<script>
window.__timelines = window.__timelines || {};
// and not [data-composition-id="main" data-start="0"] in a JS comment either
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.filter((f) => f.code === "split_data_attribute_selector")).toHaveLength(
0,
);
});

describe("timed_element_missing_clip_class", () => {
it("flags element with data-start but no class='clip'", async () => {
const html = `
Expand Down Expand Up @@ -768,6 +856,41 @@ describe("composition rules", () => {
);
expect(finding).toBeUndefined();
});

it("does not flag a call the composition only renders as on-screen text", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><code id="snippet"></code></div>
<script>
window.__timelines = window.__timelines || {};
const fn = "step";
document.getElementById("snippet").textContent = "requestAnimationFrame(step);";
document.getElementById("snippet").title = \`requestAnimationFrame(\${fn});\`;
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "requestanimationframe_in_composition"),
).toBeUndefined();
});

it("still flags a call inside a template interpolation, which is code", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><code id="snippet"></code></div>
<script>
window.__timelines = window.__timelines || {};
const label = \`frame \${requestAnimationFrame(() => {})}\`;
document.getElementById("snippet").textContent = label;
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "requestanimationframe_in_composition")?.severity,
).toBe("error");
});
});

describe("missing_data_no_timeline", () => {
Expand Down
15 changes: 10 additions & 5 deletions packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
readAttr,
readDecodedAttr,
readJsonAttr,
stripCssComments,
stripJsComments,
stripJsCode,
truncateSnippet,
WINDOW_TIMELINE_ASSIGN_PATTERN,
} from "../utils";
Expand Down Expand Up @@ -487,8 +489,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
});
}
};
for (const style of styles) scan(style.content);
for (const script of scripts) scan(script.content);
for (const style of styles) scan(stripCssComments(style.content));
for (const script of scripts) scan(stripJsComments(script.content));
return findings;
},

Expand All @@ -498,8 +500,9 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
for (const script of scripts) {
const templateLiteralSelectorPattern =
/(?:querySelector|querySelectorAll)\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`\s*\)/g;
const scanned = stripJsCode(script.content);
let tlMatch: RegExpExecArray | null;
while ((tlMatch = templateLiteralSelectorPattern.exec(script.content)) !== null) {
while ((tlMatch = templateLiteralSelectorPattern.exec(scanned)) !== null) {
findings.push({
code: "template_literal_selector",
severity: "error",
Expand All @@ -508,7 +511,9 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
"The HTML bundler's CSS parser crashes on these. Use a hardcoded string instead.",
fixHint:
"Replace the template literal variable with a hardcoded string. The bundler's CSS parser cannot handle interpolated variables in script content.",
snippet: truncateSnippet(tlMatch[0]),
snippet: truncateSnippet(
script.content.slice(tlMatch.index, tlMatch.index + tlMatch[0].length),
),
});
}
}
Expand Down Expand Up @@ -648,7 +653,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const stripped = stripJsComments(script.content);
const stripped = stripJsCode(script.content);
if (/requestAnimationFrame\s*\(/.test(stripped)) {
findings.push({
code: "requestanimationframe_in_composition",
Expand Down
149 changes: 149 additions & 0 deletions packages/lint/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import { stripCssComments, stripJsComments, stripJsStringLiterals } from "./utils.js";

const scan = (src: string) => stripJsStringLiterals(stripJsComments(src));
const findsRaf = (src: string) => /requestAnimationFrame\s*\(/.test(scan(src));

describe("stripJsStringLiterals", () => {
it("blanks a call the composition only renders as text", () => {
expect(findsRaf('const CODE = "requestAnimationFrame(step);";')).toBe(false);
expect(findsRaf("const CODE = `requestAnimationFrame(${fn});`;")).toBe(false);
});

it("keeps a call in a template interpolation, which is code", () => {
expect(findsRaf("const label = `frame ${requestAnimationFrame(cb)}`;")).toBe(true);
});

it.each([
"const re = /[\"']/g;",
"const re = /'/;",
"const re = /[`]/;",
"const r = s.split(/['\"]/);",
'const r = s.replace(/[^a-z\']/g, "");',
"if (a) { } /'/.test(x);",
"const a = b / c / d;",
"const p = i++ / total;",
"const q = --i / total;",
])("does not let %j blank the rest of the script", (prefix) => {
expect(findsRaf(`${prefix}\nrequestAnimationFrame(step);`)).toBe(true);
});

it.each([
"function a(s) {\n let ok = flag\n return /'/.test(s)\n}\n",
"for (const x of /'/.source) {}\n",
])("keeps a call bracketed by two quote-bearing regexes after %j", (prefix) => {
expect(findsRaf(`${prefix}requestAnimationFrame(step);\n${prefix}`)).toBe(true);
});

it.each([
"const clip = { in: 0.5, out: 5.5 }; const r = clip.in / clip.out;",
"const r = data.new / 2;",
"const r = list.of / 2;",
"const r = sw.case / 2;",
"const r = o?.in / 2;",
"const p = i++ / total;",
"const q = --i / total;",
])("finds a call on the same line as %j", (prefix) => {
expect(findsRaf(`${prefix} requestAnimationFrame(step);`)).toBe(true);
});

it.each([
"foo(/abc\nrequestAnimationFrame(step);",
"var of = 2;\nvar r = of /2;\nrequestAnimationFrame(step);",
"var q = 1;\nx = /a\\\nrequestAnimationFrame(step);",
])("falls back to the source when a slash never closes on its line: %j", (src) => {
expect(scan(src)).toBe(src);
expect(findsRaf(src)).toBe(true);
});

it("falls back to the source when a backslash ends a mis-read regex line", () => {
const src = "var a = b in /x\\\n y = 'requestAnimationFrame(' / z /;";
expect(scan(src)).toBe(src);
expect(findsRaf(src)).toBe(true);
});

it("falls back to the source when the scan ends mid-literal", () => {
const src = 'const p = "C:\\Users\\demo\\";\nrequestAnimationFrame(step);';
expect(scan(src)).toBe(src);
expect(findsRaf(src)).toBe(true);
});

it("preserves length and newline positions", () => {
for (const src of [
'const a = "x\\\ny";\nrequestAnimationFrame(step);',
"const t = `a\nb${x}c\nd`;",
"const r = /a\\/b/g;\n",
]) {
const out = scan(src);
expect(out.length).toBe(src.length);
expect([...out].filter((c) => c === "\n").length).toBe(
[...src].filter((c) => c === "\n").length,
);
}
});
});

describe("stripJsStringLiterals scaling", () => {
it("stays linear in slash-dense input", () => {
const time = (n: number) => {
const src = "a=b/c;".repeat(n);
let best = Infinity;
for (let run = 0; run < 3; run += 1) {
const started = performance.now();
stripJsStringLiterals(src);
best = Math.min(best, performance.now() - started);
}
return best;
};
const small = Math.max(time(20_000), 0.5);
const large = time(160_000);
expect(large / small).toBeLessThan(24);
expect(large).toBeLessThan(2_000);
});
});

describe("stripJsComments", () => {
const strip = (src: string) => stripJsComments(src);

it("keeps a regex literal that ends in an escaped slash from opening a comment", () => {
const src = 'var proto = /^https?:\\/\\//; var el = document.querySelector("#hero");';
expect(strip(src)).toBe(src);
});

it("still strips a real comment that follows a regex literal", () => {
const src = "var proto = /^https?:\\/\\//; // trailing note\nvar x = 1;";
const out = strip(src);
expect(out).toContain("/^https?:\\/\\//;");
expect(out).not.toContain("trailing note");
expect(out).toHaveLength(src.length);
});

it("does not read a slash inside a string as a comment", () => {
const src = 'var s = "a // b"; var t = 1;';
expect(strip(src)).toBe(src);
});

it("falls back to the source when a slash never closes on its line", () => {
const src = "var of = 2;\nvar r = of /2; // note\n";
expect(strip(src)).toBe(src);
});
});

describe("stripCssComments", () => {
it("keeps a rule sandwiched between comment markers printed as content", () => {
const css =
'#o::before{content:"/*"}\n[data-composition-id="main" data-start="0"]{color:red}\n#c::after{content:"*/"}';
const out = stripCssComments(css);
expect(out.length).toBe(css.length);
expect(out).toContain('data-composition-id="main" data-start="0"');
});

it("blanks a real comment and an unterminated one, keeping length", () => {
for (const css of ["/* gone */#a{color:red}", "#a{color:red}/* open"]) {
const out = stripCssComments(css);
expect(out.length).toBe(css.length);
expect(out).not.toContain("/*");
expect(out).toContain("#a{color:red}");
}
});
});
Loading
Loading