Skip to content
Open
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
37 changes: 37 additions & 0 deletions packages/blocks-cli/scripts/migrate/transforms/ctx-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,43 @@ describe("transformCtxCompat", () => {
expect(r.content).toContain("ctx?: AppContext");
});

// The scanner walks raw characters, so without a literal/comment guard it
// rewrote `ctx.` inside strings too — patching the call site correctly while
// silently corrupting the message next to it.
it("does not rewrite ctx inside string literals", () => {
const r = transformCtxCompat(withLoader(" console.log('ctx.device:', ctx.device);"));
expect(r.content).toContain("'ctx.device:'");
expect(r.content).toContain("console.log('ctx.device:', ctx?.device)");
});

it("does not rewrite ctx inside double-quoted strings or escaped quotes", () => {
const r = transformCtxCompat(
withLoader(' const s = "he said \\"ctx.device\\""; return ctx.device;'),
);
expect(r.content).toContain('"he said \\"ctx.device\\""');
expect(r.content).toContain("return ctx?.device");
});

it("does not rewrite ctx inside comments", () => {
const r = transformCtxCompat(
withLoader(" // don't touch ctx.device here\n /* nor ctx.invoke */\n return ctx.device;"),
);
expect(r.content).toContain("// don't touch ctx.device here");
expect(r.content).toContain("/* nor ctx.invoke */");
expect(r.content).toContain("return ctx?.device");
});

it("does not rewrite template text but DOES rewrite ${} interpolation", () => {
const r = transformCtxCompat(withLoader(" const u = `ctx.device is ${ctx.vtex.account}`;"));
// literal text stays, the interpolated expression is real code
expect(r.content).toContain("`ctx.device is ${ctx?.vtex?.account}`");
});

it("handles nested template literals", () => {
const r = transformCtxCompat(withLoader(" const u = `a${`b${ctx.device}`}c`;"));
expect(r.content).toContain("`a${`b${ctx?.device}`}c`");
});

it("does not match identifiers that merely contain ctx", () => {
const src = withLoader(" const c = canvasCtx.foo;\n const d = a.ctx.bar;");
const r = transformCtxCompat(src);
Expand Down
92 changes: 92 additions & 0 deletions packages/blocks-cli/scripts/migrate/transforms/ctx-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,99 @@ export function transformCtxCompat(content: string): TransformResult {
let i = 0;
let count = 0;

// Contexts we are inside of, innermost last. Without this the scanner walks
// raw characters and happily rewrites `ctx.` inside string literals and
// comments: `console.log('ctx.device:', ctx.device)` became
// `console.log('ctx?.device:', ctx?.device)` — the call site correctly
// patched, the message silently corrupted.
//
// A template literal pushes "template" (its text is data), and a `${` inside
// one pushes "expr" (real code, where rewriting must resume) — so
// `` `${ctx.device}` `` is still rewritten while `` `ctx.device` `` is not.
//
// Regex literals are deliberately NOT tracked: telling `/re/` from division
// needs real parsing, and guessing wrong would consume live code. A `ctx.`
// inside a regex in a loader is vanishingly rare next to that risk.
const stack: ("template" | "expr")[] = [];

while (i < content.length) {
const inTemplateText = stack[stack.length - 1] === "template";

if (inTemplateText) {
if (content[i] === "\\") {
out += content.slice(i, i + 2);
i += 2;
continue;
}
if (content[i] === "`") {
stack.pop();
out += content[i];
i += 1;
continue;
}
if (content.startsWith("${", i)) {
stack.push("expr");
out += "${";
i += 2;
continue;
}
out += content[i];
i += 1;
continue;
}

// Line comment — copy to end of line.
if (content.startsWith("//", i)) {
const nl = content.indexOf("\n", i);
const end = nl === -1 ? content.length : nl;
out += content.slice(i, end);
i = end;
continue;
}

// Block comment — copy to the closing delimiter.
if (content.startsWith("/*", i)) {
const close = content.indexOf("*/", i + 2);
const end = close === -1 ? content.length : close + 2;
out += content.slice(i, end);
i = end;
continue;
}

// Single/double quoted string — copy to the unescaped closing quote.
if (content[i] === "'" || content[i] === '"') {
const quote = content[i];
let j = i + 1;
while (j < content.length) {
if (content[j] === "\\") {
j += 2;
continue;
}
if (content[j] === quote || content[j] === "\n") {
j += 1;
break;
}
j += 1;
}
out += content.slice(i, j);
i = j;
continue;
}

if (content[i] === "`") {
stack.push("template");
out += content[i];
i += 1;
continue;
}

if (content[i] === "}" && stack[stack.length - 1] === "expr") {
stack.pop();
out += content[i];
i += 1;
continue;
}

const isCtxToken =
content.startsWith("ctx", i) &&
!isIdentChar(content[i - 1]) &&
Expand Down