diff --git a/src/vite.ts b/src/vite.ts index fe85aa9..8b2cacf 100644 --- a/src/vite.ts +++ b/src/vite.ts @@ -32,25 +32,61 @@ import type { SolidGrabPluginOptions } from "./types.js"; // We inject `data-solid-source="file:line:col"` right after the tag name. /** - * Matches the opening of a JSX element: - *
| | createContext (TypeScript generics) - * - * The negative lookbehind (?)/g; +/** Matches component-style names: PascalCase or contains a dot (Foo.Bar). */ +const COMPONENT_NAME_RE = /^[A-Z]|[.]/; + +/** Keywords that can directly precede a JSX expression. */ +const JSX_PRECEDING_KEYWORDS = new Set([ + "return", "yield", "case", "default", "else", +]); + /** - * Matches component-style names: PascalCase or contains a dot (Foo.Bar). + * Walks backwards from `<` (skipping whitespace) to determine whether + * it's a JSX opening tag or a less-than comparison. + * + * - After `)`, `]`, quotes → comparison (end of expression) + * - After `(`, `{`, `=`, etc. → JSX (start of expression) + * - After a keyword like `return` → JSX + * - After any other identifier → comparison */ -const COMPONENT_NAME_RE = /^[A-Z]|[.]/; +function isLikelyJsx(code: string, ltIndex: number): boolean { + let i = ltIndex - 1; + while (i >= 0 && (code[i] === " " || code[i] === "\t" || code[i] === "\n" || code[i] === "\r")) { + i--; + } + + if (i < 0) return true; // Start of file + + const ch = code[i]!; + + // Expression-ending tokens → comparison + if (ch === ")" || ch === "]" || ch === '"' || ch === "'" || ch === "`") { + return false; + } + + // Operators/punctuation that introduce an expression → JSX + if ("({[,;:?=!>&|+-*/%^~".includes(ch)) { + return true; + } + + // Word character → JSX only if it's a keyword like `return` + if (/\w/.test(ch)) { + const end = i + 1; + while (i >= 0 && /\w/.test(code[i]!)) i--; + const word = code.slice(i + 1, end); + return JSX_PRECEDING_KEYWORDS.has(word); + } + + return true; // Conservative default +} function transformJsx( code: string, @@ -92,6 +128,12 @@ function transformJsx( const suffix = match[3]!; // space, `/>`, or `>` const offset = match.index; + + // Skip comparisons like `x < y` — only inject into actual JSX + if (!isLikelyJsx(code, offset)) { + continue; + } + const [line, col] = getLineCol(offset); const isComponent = COMPONENT_NAME_RE.test(tagName); diff --git a/tests/vite-plugin.test.ts b/tests/vite-plugin.test.ts index 5ff1fd0..74006a7 100644 --- a/tests/vite-plugin.test.ts +++ b/tests/vite-plugin.test.ts @@ -112,6 +112,82 @@ describe("transform", () => { expect(result.code).not.toContain("Accessor { + const plugin = createPlugin(); + const code = [ + `function foo() {`, + ` const x = count() < totalItems`, + ` if (a < b) {}`, + ` return userTier < minimumTier`, + `}`, + ].join("\n"); + const result = (plugin as any).transform(code, "/project/src/foo.tsx"); + expect(result).toBeNull(); + }); + + test("does not inject into comparison after function call", () => { + const plugin = createPlugin(); + const code = [ + `function foo() {`, + ` return visiblePageCount() < totalPages`, + `}`, + ].join("\n"); + const result = (plugin as any).transform(code, "/project/src/foo.tsx"); + expect(result).toBeNull(); + }); + + test("does not inject into comparison in conditional expression", () => { + const plugin = createPlugin(); + const code = [ + `if (`, + ` newIndex !== currentIndex() &&`, + ` newIndex >= 0 &&`, + ` newIndex < props.images.length`, + `) {}`, + ].join("\n"); + const result = (plugin as any).transform(code, "/project/src/foo.tsx"); + expect(result).toBeNull(); + }); + + test("injects into JSX after return keyword", () => { + const plugin = createPlugin(); + const code = `function App() {\n return
hello
;\n}`; + const result = (plugin as any).transform(code, "/project/src/App.tsx"); + expect(result).not.toBeNull(); + expect(result.code).toContain("data-solid-source="); + }); + + test("injects into JSX after logical operators", () => { + const plugin = createPlugin(); + const code = `const el = show() &&
visible
;`; + const result = (plugin as any).transform(code, "/project/src/App.tsx"); + expect(result).not.toBeNull(); + expect(result.code).toContain("data-solid-source="); + }); + + test("injects into JSX in ternary expression", () => { + const plugin = createPlugin(); + const code = `const el = cond ?
a
: b;`; + const result = (plugin as any).transform(code, "/project/src/App.tsx"); + expect(result).not.toBeNull(); + expect(result.code).toContain("data-solid-source="); + }); + + test("handles mixed comparisons and JSX in the same file", () => { + const plugin = createPlugin(); + const code = [ + `function App() {`, + ` const isSmall = count() < maxItems;`, + ` return
{isSmall ? small : big}
;`, + `}`, + ].join("\n"); + const result = (plugin as any).transform(code, "/project/src/App.tsx"); + expect(result).not.toBeNull(); + expect(result.code).toContain("data-solid-source="); + // Comparison should be untouched + expect(result.code).toContain("count() < maxItems"); + }); + test("respects jsxLocation: false", () => { const plugin = createPlugin({ jsxLocation: false }); const code = `function App() {\n return
hello
;\n}`;