diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts
index 701807f230..91452e58ac 100644
--- a/packages/lint/src/rules/composition.test.ts
+++ b/packages/lint/src/rules/composition.test.ts
@@ -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 = `
+
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 = `
+
+
+
+`;
+ 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 = `
+
+
+
+`;
+ 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 = `
+
+
+
+
+`;
+ 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 = `
@@ -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 = `
+
+
+
+`;
+ 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 = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(
+ result.findings.find((f) => f.code === "requestanimationframe_in_composition")?.severity,
+ ).toBe("error");
+ });
});
describe("missing_data_no_timeline", () => {
diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts
index d72a10575a..46b3cc084a 100644
--- a/packages/lint/src/rules/composition.ts
+++ b/packages/lint/src/rules/composition.ts
@@ -4,7 +4,9 @@ import {
readAttr,
readDecodedAttr,
readJsonAttr,
+ stripCssComments,
stripJsComments,
+ stripJsCode,
truncateSnippet,
WINDOW_TIMELINE_ASSIGN_PATTERN,
} from "../utils";
@@ -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;
},
@@ -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",
@@ -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),
+ ),
});
}
}
@@ -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",
diff --git a/packages/lint/src/utils.test.ts b/packages/lint/src/utils.test.ts
new file mode 100644
index 0000000000..a024e19bad
--- /dev/null
+++ b/packages/lint/src/utils.test.ts
@@ -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}");
+ }
+ });
+});
diff --git a/packages/lint/src/utils.ts b/packages/lint/src/utils.ts
index 4487d7f812..f6bc4b0e7a 100644
--- a/packages/lint/src/utils.ts
+++ b/packages/lint/src/utils.ts
@@ -335,24 +335,68 @@ export function stripStringLiterals(source: string): string {
}
// fallow-ignore-next-line complexity
-export function stripJsComments(source: string): string {
+function scanJsComments(source: string): { out: string; balanced: boolean } {
let out = "";
let i = 0;
let quote: "'" | '"' | "`" | null = null;
let escaped = false;
+ let inRegex = false;
+ let inRegexClass = false;
+ let regexMisread = false;
+ const ctx = new CodeContext();
+
+ const emitCode = (ch: string) => {
+ out += ch;
+ ctx.push(ch);
+ };
+ const emitOpaque = (ch: string) => {
+ out += ch;
+ ctx.push(" ");
+ };
while (i < source.length) {
const ch = source[i] ?? "";
const next = source[i + 1] ?? "";
- if (quote) {
+ if (inRegex) {
out += ch;
if (escaped) {
escaped = false;
+ if (ch === "\n" || ch === "\r") {
+ inRegex = false;
+ inRegexClass = false;
+ regexMisread = true;
+ }
+ } else if (ch === "\\") {
+ escaped = true;
+ } else if (ch === "[") {
+ inRegexClass = true;
+ } else if (ch === "]") {
+ inRegexClass = false;
+ } else if (ch === "/" && !inRegexClass) {
+ inRegex = false;
+ ctx.push(ch);
+ } else if (ch === "\n" || ch === "\r") {
+ inRegex = false;
+ inRegexClass = false;
+ regexMisread = true;
+ }
+ i += 1;
+ continue;
+ }
+
+ if (quote) {
+ if (escaped) {
+ escaped = false;
+ emitOpaque(ch);
} else if (ch === "\\") {
escaped = true;
+ emitOpaque(ch);
} else if (ch === quote) {
quote = null;
+ emitCode(ch);
+ } else {
+ emitOpaque(ch);
}
i += 1;
continue;
@@ -360,16 +404,19 @@ export function stripJsComments(source: string): string {
if (ch === "'" || ch === '"' || ch === "`") {
quote = ch;
- out += ch;
+ emitCode(ch);
i += 1;
continue;
}
if (ch === "/" && next === "/") {
out += " ";
+ ctx.push(" ");
+ ctx.push(" ");
i += 2;
while (i < source.length && source[i] !== "\n" && source[i] !== "\r") {
out += " ";
+ ctx.push(" ");
i += 1;
}
continue;
@@ -377,21 +424,272 @@ export function stripJsComments(source: string): string {
if (ch === "/" && next === "*") {
out += " ";
+ ctx.push(" ");
+ ctx.push(" ");
i += 2;
while (i < source.length) {
const blockCh = source[i] ?? "";
const blockNext = source[i + 1] ?? "";
if (blockCh === "*" && blockNext === "/") {
out += " ";
+ ctx.push(" ");
+ ctx.push(" ");
i += 2;
break;
}
- out += blockCh === "\n" || blockCh === "\r" ? blockCh : " ";
+ const kept = blockCh === "\n" || blockCh === "\r" ? blockCh : " ";
+ out += kept;
+ ctx.push(kept);
i += 1;
}
continue;
}
+ if (ch === "/" && ctx.startsRegexLiteral()) {
+ inRegex = true;
+ emitCode(ch);
+ i += 1;
+ continue;
+ }
+
+ emitCode(ch);
+ i += 1;
+ }
+
+ return { out, balanced: quote === null && !inRegex && !regexMisread };
+}
+
+export function stripJsComments(source: string): string {
+ return scanJsComments(source).out;
+}
+
+export function stripJsCode(source: string): string {
+ const { out, balanced } = scanJsComments(source);
+ return balanced ? stripJsStringLiterals(out) : source;
+}
+
+const REGEX_ALLOWED_BEFORE = new Set("=(,:[!&|?{};+-*%^~<>");
+const REGEX_ALLOWED_KEYWORDS = new Set([
+ "return",
+ "typeof",
+ "instanceof",
+ "in",
+ "of",
+ "new",
+ "delete",
+ "void",
+ "case",
+ "do",
+ "else",
+ "yield",
+ "await",
+]);
+
+const WORD_CHAR = /[A-Za-z0-9_$]/;
+
+/**
+ * Tracks just enough emitted context to tell a regex literal from a division: the last
+ * two significant characters and the trailing identifier. Carried incrementally because
+ * re-scanning the accumulated output per candidate slash is quadratic — a composition
+ * with one inlined vendor bundle took 58x longer to lint.
+ */
+class CodeContext {
+ private last = "";
+ private prev = "";
+ private word = "";
+ private wordEnded = false;
+ private wordAfterDot = false;
+
+ push(ch: string): void {
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
+ this.wordEnded = true;
+ return;
+ }
+ if (WORD_CHAR.test(ch)) {
+ if (this.wordEnded || this.word === "") this.wordAfterDot = this.last === ".";
+ this.word = this.wordEnded ? ch : this.word + ch;
+ } else {
+ this.word = "";
+ this.wordAfterDot = false;
+ }
+ this.wordEnded = false;
+ this.prev = this.last;
+ this.last = ch;
+ }
+
+ startsRegexLiteral(): boolean {
+ if (this.last === "") return true;
+ if (WORD_CHAR.test(this.last))
+ return !this.wordAfterDot && REGEX_ALLOWED_KEYWORDS.has(this.word);
+ if ((this.last === "+" || this.last === "-") && this.prev === this.last) return false;
+ return REGEX_ALLOWED_BEFORE.has(this.last);
+ }
+}
+
+/**
+ * Blanks string, template-literal and regex-literal *contents* (delimiters, length
+ * and newline positions kept) so a rule scanning for an API call does not match one
+ * a composition merely renders as on-screen text. Template `${…}` expressions stay —
+ * they are code. Returns the source untouched if the scan ends mid-literal, so a
+ * parse this scanner cannot model degrades to the caller's pre-existing behaviour
+ * rather than silently blanking real code on an `error`-severity gate.
+ */
+// fallow-ignore-next-line complexity
+export function stripJsStringLiterals(source: string): string {
+ let out = "";
+ let i = 0;
+ const templateBraces: number[] = [];
+ const ctx = new CodeContext();
+ let quote: "'" | '"' | "`" | null = null;
+ let escaped = false;
+ let inRegex = false;
+ let inRegexClass = false;
+ let regexMisread = false;
+
+ const blank = (ch: string) => (ch === "\n" || ch === "\r" ? ch : " ");
+ const emit = (text: string) => {
+ out += text;
+ for (const ch of text) ctx.push(ch);
+ };
+
+ while (i < source.length) {
+ const ch = source[i] ?? "";
+ const next = source[i + 1] ?? "";
+
+ if (inRegex) {
+ if (escaped) {
+ escaped = false;
+ if (ch === "\n" || ch === "\r") {
+ inRegex = false;
+ inRegexClass = false;
+ regexMisread = true;
+ }
+ emit(blank(ch));
+ } else if (ch === "\\") {
+ escaped = true;
+ emit(" ");
+ } else if (ch === "[") {
+ inRegexClass = true;
+ emit(" ");
+ } else if (ch === "]") {
+ inRegexClass = false;
+ emit(" ");
+ } else if (ch === "/" && !inRegexClass) {
+ inRegex = false;
+ emit(ch);
+ } else if (ch === "\n" || ch === "\r") {
+ inRegex = false;
+ inRegexClass = false;
+ escaped = false;
+ regexMisread = true;
+ emit(ch);
+ } else {
+ emit(" ");
+ }
+ i += 1;
+ continue;
+ }
+
+ if (quote) {
+ if (escaped) {
+ escaped = false;
+ emit(blank(ch));
+ } else if (ch === "\\") {
+ escaped = true;
+ emit(" ");
+ } else if (ch === quote) {
+ quote = null;
+ emit(ch);
+ } else if (ch === "`" || quote !== "`" || ch !== "$" || next !== "{") {
+ emit(blank(ch));
+ } else {
+ templateBraces.push(0);
+ quote = null;
+ emit("${");
+ i += 2;
+ continue;
+ }
+ i += 1;
+ continue;
+ }
+
+ if (ch === "'" || ch === '"' || ch === "`") {
+ quote = ch;
+ emit(ch);
+ i += 1;
+ continue;
+ }
+
+ if (ch === "/" && next !== "/" && next !== "*" && ctx.startsRegexLiteral()) {
+ inRegex = true;
+ emit(ch);
+ i += 1;
+ continue;
+ }
+
+ if (templateBraces.length > 0) {
+ const depth = templateBraces[templateBraces.length - 1] ?? 0;
+ if (ch === "{") templateBraces[templateBraces.length - 1] = depth + 1;
+ else if (ch === "}") {
+ if (depth === 0) {
+ templateBraces.pop();
+ quote = "`";
+ emit(ch);
+ i += 1;
+ continue;
+ }
+ templateBraces[templateBraces.length - 1] = depth - 1;
+ }
+ }
+
+ emit(ch);
+ i += 1;
+ }
+
+ if (quote !== null || templateBraces.length > 0 || inRegex || regexMisread) return source;
+ return out;
+}
+
+/**
+ * Drops CSS comments without following a `/*` that only appears inside a string —
+ * a slide printing comment markers as content otherwise pairs two of them and
+ * deletes the real rules in between.
+ */
+// fallow-ignore-next-line complexity
+export function stripCssComments(source: string): string {
+ let out = "";
+ let i = 0;
+ let quote: "'" | '"' | null = null;
+
+ while (i < source.length) {
+ const ch = source[i] ?? "";
+ if (quote) {
+ out += ch;
+ if (ch === "\\") {
+ out += source[i + 1] ?? "";
+ i += 2;
+ continue;
+ }
+ if (ch === quote) quote = null;
+ i += 1;
+ continue;
+ }
+ if (ch === '"' || ch === "'") {
+ quote = ch;
+ out += ch;
+ i += 1;
+ continue;
+ }
+ if (ch === "/" && source[i + 1] === "*") {
+ const end = source.indexOf("*/", i + 2);
+ const stop = end === -1 ? source.length : end + 2;
+ for (let j = i; j < stop; j += 1) {
+ const c = source[j] ?? "";
+ out += c === "\n" || c === "\r" ? c : " ";
+ }
+ i = stop;
+ continue;
+ }
out += ch;
i += 1;
}