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
104 changes: 104 additions & 0 deletions src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,101 @@ const JSX_PRECEDING_KEYWORDS = new Set([
"return", "yield", "case", "default", "else",
]);

/**
* Builds a mask over `code` marking every character that lives inside a
* string literal, template-literal text, or comment — positions where a `<`
* can never start a JSX tag. Template-literal interpolations (`${ ... }`) are
* treated as code so JSX inside them is still detected.
*
* Without this, the scanner mangles angle-bracket text that merely appears
* inside a string. For example a CLI placeholder like
*
* `gh secret set ... --repo ${shq(repo() || "<owner>/<repo>")} ...`
*
* matches the JSX regex at `<repo>`; isLikelyJsx() then sees the preceding `/`
* (an "operator") and injects `data-solid-source="..."` straight into the
* string, whose embedded `"` breaks the literal and crashes the Babel parse.
*
* Note: regular-expression literals are not tracked (the regex-vs-division
* ambiguity needs a real tokenizer). A `<tag>` inside a regex literal remains
* a known edge case, but those are far rarer than strings and comments.
*/
function buildLiteralMask(code: string): Uint8Array {
const n = code.length;
const mask = new Uint8Array(n);

// Scanner modes. We keep a stack so a `${ }` interpolation can nest a fresh
// "code" region (and another template, string, etc.) inside a template.
type Mode = "code" | "sq" | "dq" | "tpl" | "line" | "block";
const stack: Mode[] = ["code"];

// Brace depth within code regions, plus the depth recorded when each `${`
// opened, so we know which `}` closes the interpolation.
let braceDepth = 0;
const interpDepths: number[] = [];

let i = 0;
while (i < n) {
const mode = stack[stack.length - 1]!;
const c = code[i]!;
const d = i + 1 < n ? code[i + 1]! : "";

switch (mode) {
case "code":
if (c === "/" && d === "/") { stack.push("line"); mask[i] = mask[i + 1] = 1; i += 2; }
else if (c === "/" && d === "*") { stack.push("block"); mask[i] = mask[i + 1] = 1; i += 2; }
else if (c === "'") { stack.push("sq"); mask[i] = 1; i++; }
else if (c === '"') { stack.push("dq"); mask[i] = 1; i++; }
else if (c === "`") { stack.push("tpl"); mask[i] = 1; i++; }
else if (c === "{") { braceDepth++; i++; }
else if (c === "}") {
braceDepth--;
// Closing brace of a `${ }` → resume the enclosing template literal.
if (interpDepths.length && interpDepths[interpDepths.length - 1] === braceDepth) {
interpDepths.pop();
stack.pop();
}
i++;
} else i++;
break;

case "line":
if (c === "\n") { stack.pop(); i++; } // newline is code
else { mask[i] = 1; i++; }
break;

case "block":
if (c === "*" && d === "/") { mask[i] = mask[i + 1] = 1; stack.pop(); i += 2; }
else { mask[i] = 1; i++; }
break;

case "sq":
case "dq": {
if (c === "\\") { mask[i] = 1; if (i + 1 < n) mask[i + 1] = 1; i += 2; break; }
mask[i] = 1;
if (c === (mode === "sq" ? "'" : '"') || c === "\n") stack.pop();
i++;
break;
}

case "tpl":
if (c === "\\") { mask[i] = 1; if (i + 1 < n) mask[i + 1] = 1; i += 2; }
else if (c === "`") { mask[i] = 1; stack.pop(); i++; }
else if (c === "$" && d === "{") {
// Enter an interpolation: its contents are code, not template text.
mask[i] = mask[i + 1] = 1;
interpDepths.push(braceDepth);
braceDepth++;
stack.push("code");
i += 2;
} else { mask[i] = 1; i++; }
break;
}
}

return mask;
}

/**
* Walks backwards from `<` (skipping whitespace) to determine whether
* it's a JSX opening tag or a less-than comparison.
Expand Down Expand Up @@ -117,6 +212,10 @@ function transformJsx(
// Strip the project root prefix to keep paths short
const shortFile = fileId.replace(/^\//, "");

// Mark characters that live inside strings, templates, or comments so the
// scanner never injects attributes into angle-bracket text that isn't JSX.
const literalMask = buildLiteralMask(code);

let result = "";
let lastIndex = 0;

Expand All @@ -132,6 +231,11 @@ function transformJsx(

const offset = match.index;

// Skip `<` that sits inside a string literal, template text, or comment.
if (literalMask[offset]) {
continue;
}

// Skip comparisons like `x < y` — only inject into actual JSX
if (!isLikelyJsx(code, offset)) {
continue;
Expand Down
57 changes: 57 additions & 0 deletions tests/vite-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,63 @@ describe("transform", () => {
expect(result.code).toContain("count() < maxItems");
});

test("does not inject into angle brackets inside a string literal", () => {
const plugin = createPlugin();
const code = `const placeholder = "<owner>/<repo>";`;
const result = (plugin as any).transform(code, "/project/src/foo.tsx");
// No JSX here — the angle brackets live inside a string.
expect(result).toBeNull();
});

test("does not mangle angle-bracket placeholders inside a template literal", () => {
// Regression: a CLI command built with a template literal whose
// interpolation contains a string with `<owner>/<repo>` used to get
// `data-solid-source="..."` injected into the string, which broke the
// Babel parse with `Unexpected token, expected ","`.
const plugin = createPlugin();
const code = [
`function App() {`,
` const ghCmd = () =>`,
' `gh secret set ${shq(name())} --repo ${shq(repo() || "<owner>/<repo>")} --body ${shq(key())}`',
` return <div>{ghCmd()}</div>;`,
`}`,
].join("\n");
const result = (plugin as any).transform(code, "/project/src/App.tsx");

expect(result).not.toBeNull();
// The placeholder string must be left exactly as-is.
expect(result.code).toContain('"<owner>/<repo>"');
expect(result.code).not.toContain("<repo data-solid-source");
// ...while the real JSX still gets a source attribute.
expect(result.code).toContain('data-solid-source="src/App.tsx:');
});

test("does not inject into angle brackets inside comments", () => {
const plugin = createPlugin();
const code = [
`function App() {`,
` // renders a <div> wrapper`,
` /* fallback is <span> */`,
` return null;`,
`}`,
].join("\n");
const result = (plugin as any).transform(code, "/project/src/App.tsx");
expect(result).toBeNull();
});

test("still injects into JSX inside a template-literal interpolation", () => {
const plugin = createPlugin();
// `<b>` is template text (skipped); `<Foo/>` lives in the ${} and is JSX.
const code = "const t = `<b>${cond ? <Foo /> : null}</b>`;";
const result = (plugin as any).transform(code, "/project/src/App.tsx");

expect(result).not.toBeNull();
expect(result.code).toContain('data-solid-component="Foo"');
// The literal `<b>` text must be untouched.
expect(result.code).toContain("`<b>");
expect(result.code).not.toContain("<b data-solid-source");
});

test("respects jsxLocation: false", () => {
const plugin = createPlugin({ jsxLocation: false });
const code = `function App() {\n return <div>hello</div>;\n}`;
Expand Down
Loading