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
68 changes: 55 additions & 13 deletions src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
* <div | <MyComponent | <ns:tag
* Does NOT match:
* </div | <> | </ | <= | <<
* Accessor<boolean> | createContext<Type> (TypeScript generics)
*
* The negative lookbehind (?<!\w) ensures the `<` is not preceded by a
* word character, which distinguishes JSX tags from TypeScript generics.
*
* Capture group 1 = everything before we inject the attribute
* We track line numbers ourselves for accuracy.
* Candidate regex for JSX opening tags. The negative lookbehind (?<!\w)
* filters TypeScript generics (where `<` directly follows an identifier).
* Comparison operators (where `<` is preceded by whitespace) are filtered
* by the isLikelyJsx() context check in the match loop.
*/
const JSX_OPEN_TAG_RE =
/(?<!\w)(<\s*)([A-Z_a-z][\w.:-]*)(\s|\/?>)/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,
Expand Down Expand Up @@ -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);
Expand Down
76 changes: 76 additions & 0 deletions tests/vite-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,82 @@ describe("transform", () => {
expect(result.code).not.toContain("Accessor<boolean data-solid");
});

test("does not inject into comparison operators", () => {
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 <div>hello</div>;\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() && <div>visible</div>;`;
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 ? <div>a</div> : <span>b</span>;`;
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 <div>{isSmall ? <span>small</span> : <span>big</span>}</div>;`,
`}`,
].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 <div>hello</div>;\n}`;
Expand Down