@decocms/blocks-cli 7.22.1 → 7.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/migrate/phase-transform.ts +5 -1
- package/scripts/migrate/templates/section-loaders.ts +6 -4
- package/scripts/migrate/transforms/ctx-compat.test.ts +66 -0
- package/scripts/migrate/transforms/ctx-compat.ts +151 -0
- package/scripts/migrate/transforms/fresh-apis.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.23.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"lint:unused": "knip"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@decocms/blocks": "7.
|
|
34
|
+
"@decocms/blocks": "7.23.0",
|
|
35
35
|
"ts-morph": "^27.0.0",
|
|
36
36
|
"tsx": "^4.22.5"
|
|
37
37
|
},
|
|
@@ -6,6 +6,7 @@ import { log, logPhase } from "./types";
|
|
|
6
6
|
import { transformImports } from "./transforms/imports";
|
|
7
7
|
import { transformJsx } from "./transforms/jsx";
|
|
8
8
|
import { transformFreshApis } from "./transforms/fresh-apis";
|
|
9
|
+
import { transformCtxCompat } from "./transforms/ctx-compat";
|
|
9
10
|
import { transformDenoIsms } from "./transforms/deno-isms";
|
|
10
11
|
import { transformTailwind } from "./transforms/tailwind";
|
|
11
12
|
import { transformDeadCode } from "./transforms/dead-code";
|
|
@@ -55,16 +56,19 @@ function applyTransforms(content: string, filePath: string, ctx?: MigrationConte
|
|
|
55
56
|
return { content, changed: false, notes: [] };
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
// Pipeline: imports → jsx → htmx-on-events → fresh-apis → dead-code → deno-isms → tailwind
|
|
59
|
+
// Pipeline: imports → jsx → htmx-on-events → fresh-apis → ctx-compat → dead-code → deno-isms → tailwind
|
|
59
60
|
// htmx-on-events runs after jsx (which renames class/onChange) and
|
|
60
61
|
// before fresh-apis (which removes useScript imports the htmx
|
|
61
62
|
// codemod's TODO might still reference). The codemod is a no-op on
|
|
62
63
|
// files without hx-on, so it never adds latency to non-htmx sites.
|
|
64
|
+
// ctx-compat runs after fresh-apis so it sees the settled loader body; it's
|
|
65
|
+
// a no-op on files without a `loader` export (#305).
|
|
63
66
|
const pipeline: Array<{ name: string; fn: (content: string) => TransformResult }> = [
|
|
64
67
|
{ name: "imports", fn: (c) => transformImports(c, ctx?.islandWrapperTargets) },
|
|
65
68
|
{ name: "jsx", fn: transformJsx },
|
|
66
69
|
{ name: "htmx-on-events", fn: transformHtmxOnEvents },
|
|
67
70
|
{ name: "fresh-apis", fn: transformFreshApis },
|
|
71
|
+
{ name: "ctx-compat", fn: transformCtxCompat },
|
|
68
72
|
{ name: "dead-code", fn: (c) => transformDeadCode(c, ctx?.platform) },
|
|
69
73
|
{ name: "deno-isms", fn: transformDenoIsms },
|
|
70
74
|
{ name: "tailwind", fn: transformTailwind },
|
|
@@ -314,9 +314,11 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
|
|
|
314
314
|
// ---------- SEO + analytics delegation ----------
|
|
315
315
|
if (hasSEOPDP) {
|
|
316
316
|
entries.push(``);
|
|
317
|
-
|
|
317
|
+
// The framework supplies the real compat ctx as the 3rd arg (#305) —
|
|
318
|
+
// forward it instead of faking `{ seo: {} }` (which violated policy D3).
|
|
319
|
+
entries.push(` "site/sections/SEOPDP.tsx": async (props: any, _req, ctx) => {`);
|
|
318
320
|
entries.push(` const mod = await import("../sections/SEOPDP");`);
|
|
319
|
-
entries.push(` const result = mod.loader(props, _req,
|
|
321
|
+
entries.push(` const result = mod.loader(props, _req, ctx);`);
|
|
320
322
|
entries.push(` return result ?? props;`);
|
|
321
323
|
entries.push(` },`);
|
|
322
324
|
}
|
|
@@ -329,9 +331,9 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
|
|
|
329
331
|
}
|
|
330
332
|
|
|
331
333
|
if (hasIsEvents) {
|
|
332
|
-
entries.push(` "site/sections/Analytics/IsEvents.tsx": async (props: any, req) => {`);
|
|
334
|
+
entries.push(` "site/sections/Analytics/IsEvents.tsx": async (props: any, req, ctx) => {`);
|
|
333
335
|
entries.push(` const mod = await import("../sections/Analytics/IsEvents");`);
|
|
334
|
-
entries.push(` return mod.loader(props, req) as unknown as Record<string, unknown>;`);
|
|
336
|
+
entries.push(` return mod.loader(props, req, ctx) as unknown as Record<string, unknown>;`);
|
|
335
337
|
entries.push(` },`);
|
|
336
338
|
}
|
|
337
339
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { transformCtxCompat } from "./ctx-compat";
|
|
3
|
+
|
|
4
|
+
const withLoader = (body: string) =>
|
|
5
|
+
`export const loader = (props: Props, req: Request, ctx?: AppContext) => {\n${body}\n};`;
|
|
6
|
+
|
|
7
|
+
describe("transformCtxCompat", () => {
|
|
8
|
+
it("optional-chains a deep app-state read", () => {
|
|
9
|
+
const src = withLoader(" const ext = ctx.salesforce.cartExtension[0];");
|
|
10
|
+
const r = transformCtxCompat(src);
|
|
11
|
+
expect(r.changed).toBe(true);
|
|
12
|
+
expect(r.content).toContain("ctx?.salesforce?.cartExtension?.[0]");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("optional-chains ctx.device and ctx.invoke calls (harmless no-op safety)", () => {
|
|
16
|
+
const src = withLoader(
|
|
17
|
+
" const isMobile = ctx.device !== 'desktop';\n const page = await ctx.invoke.vtex.loaders.product.detailsPageGQL({ slug });",
|
|
18
|
+
);
|
|
19
|
+
const r = transformCtxCompat(src);
|
|
20
|
+
expect(r.content).toContain("ctx?.device !== 'desktop'");
|
|
21
|
+
expect(r.content).toContain("ctx?.invoke?.vtex?.loaders?.product?.detailsPageGQL({ slug })");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("leaves already-optional chains intact (no double ??.)", () => {
|
|
25
|
+
const src = withLoader(" const x = ctx?.salesforce?.cartExtension?.[0];");
|
|
26
|
+
const r = transformCtxCompat(src);
|
|
27
|
+
// No change needed → not flagged as changed.
|
|
28
|
+
expect(r.content).toContain("ctx?.salesforce?.cartExtension?.[0]");
|
|
29
|
+
expect(r.content).not.toContain("??.");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("does not optional-chain an assignment target (would be a syntax error)", () => {
|
|
33
|
+
const src = withLoader(" ctx.state.count = 1;");
|
|
34
|
+
const r = transformCtxCompat(src);
|
|
35
|
+
expect(r.content).toContain("ctx.state.count = 1;");
|
|
36
|
+
expect(r.content).not.toContain("ctx?.state?.count =");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("still rewrites reads while leaving assignment targets alone", () => {
|
|
40
|
+
const src = withLoader(" ctx.state.count = ctx.state.count + 1;");
|
|
41
|
+
const r = transformCtxCompat(src);
|
|
42
|
+
// LHS untouched, RHS optional-chained.
|
|
43
|
+
expect(r.content).toContain("ctx.state.count = ctx?.state?.count + 1;");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("is a no-op on files without a loader export", () => {
|
|
47
|
+
const src = "const ctx = canvas.getContext('2d');\nctx.fillRect(0, 0, 1, 1);";
|
|
48
|
+
const r = transformCtxCompat(src);
|
|
49
|
+
expect(r.changed).toBe(false);
|
|
50
|
+
expect(r.content).toBe(src);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does not touch the ctx parameter declaration", () => {
|
|
54
|
+
const src = withLoader(" return props;");
|
|
55
|
+
const r = transformCtxCompat(src);
|
|
56
|
+
expect(r.changed).toBe(false);
|
|
57
|
+
expect(r.content).toContain("ctx?: AppContext");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not match identifiers that merely contain ctx", () => {
|
|
61
|
+
const src = withLoader(" const c = canvasCtx.foo;\n const d = a.ctx.bar;");
|
|
62
|
+
const r = transformCtxCompat(src);
|
|
63
|
+
expect(r.content).toContain("canvasCtx.foo");
|
|
64
|
+
expect(r.content).toContain("a.ctx.bar");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { TransformResult } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Make ported deco.cx section loaders defensive about the compat `ctx`
|
|
5
|
+
* (issue #305).
|
|
6
|
+
*
|
|
7
|
+
* The framework now hands section loaders a real 3rd-arg `ctx`
|
|
8
|
+
* (`@decocms/blocks`'s `buildSectionLoaderContext`) with `device`, `invoke`,
|
|
9
|
+
* `response` and per-app state (`ctx.vtex`, `ctx.salesforce`, …). But an app
|
|
10
|
+
* that isn't configured on the target site yields `undefined`, so a
|
|
11
|
+
* *non-optional* deep read like `ctx.salesforce.cartExtension[0]` still throws
|
|
12
|
+
* — and `withSectionLoader`'s try/catch would swallow it, dropping the
|
|
13
|
+
* section's props (blank render). The hand-fixed reference migration
|
|
14
|
+
* (`granadobr-tanstack`) solves this by optional-chaining every `ctx` read.
|
|
15
|
+
*
|
|
16
|
+
* This codemod reproduces that: every `ctx.` member-access chain becomes an
|
|
17
|
+
* optional chain (`ctx?.a?.b?.[0]`). Optional chaining short-circuits to
|
|
18
|
+
* `undefined` instead of throwing, which is exactly the defensive behavior the
|
|
19
|
+
* working migration relies on. `ctx.device`/`ctx.invoke` are always present so
|
|
20
|
+
* the extra `?.` is a harmless no-op there.
|
|
21
|
+
*
|
|
22
|
+
* Scope: only files that export a `loader` (section/loader files), so an
|
|
23
|
+
* unrelated `ctx` (e.g. a canvas 2D context) elsewhere isn't touched.
|
|
24
|
+
* Assignment targets (`ctx.x = …`, invalid as an optional chain) are skipped.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const IDENT = /[A-Za-z0-9_$]/;
|
|
28
|
+
|
|
29
|
+
const LOADER_EXPORT_RE =
|
|
30
|
+
/export\s+(?:const|(?:async\s+)?function)\s+loader\b|export\s*\{\s*[^}]*\bloader\b/;
|
|
31
|
+
|
|
32
|
+
function isIdentChar(c: string | undefined): boolean {
|
|
33
|
+
return c !== undefined && IDENT.test(c);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Scan a single `ctx` member-access chain starting at `start` (which must
|
|
38
|
+
* point at the `c` of `ctx`). Returns the rewritten (optional) chain, the
|
|
39
|
+
* original text, the index just past the chain, and whether the chain is the
|
|
40
|
+
* target of an assignment (in which case it must NOT be optional-chained).
|
|
41
|
+
*/
|
|
42
|
+
function scanChain(
|
|
43
|
+
code: string,
|
|
44
|
+
start: number,
|
|
45
|
+
): { rewritten: string; original: string; end: number; isAssignTarget: boolean } {
|
|
46
|
+
let i = start + 3; // past "ctx"
|
|
47
|
+
const pieces: string[] = ["ctx"];
|
|
48
|
+
|
|
49
|
+
while (i < code.length) {
|
|
50
|
+
if (code.startsWith("?.", i)) {
|
|
51
|
+
// already optional — keep it, then consume the identifier OR, for an
|
|
52
|
+
// optional computed access (`?.[expr]`), the balanced bracket group.
|
|
53
|
+
i += 2;
|
|
54
|
+
if (code[i] === "[") {
|
|
55
|
+
let depth = 1;
|
|
56
|
+
let j = i + 1;
|
|
57
|
+
while (j < code.length && depth > 0) {
|
|
58
|
+
if (code[j] === "[") depth++;
|
|
59
|
+
else if (code[j] === "]") depth--;
|
|
60
|
+
j++;
|
|
61
|
+
}
|
|
62
|
+
pieces.push("?.[", code.slice(i + 1, j));
|
|
63
|
+
i = j;
|
|
64
|
+
} else {
|
|
65
|
+
let j = i;
|
|
66
|
+
while (j < code.length && isIdentChar(code[j])) j++;
|
|
67
|
+
pieces.push("?.", code.slice(i, j));
|
|
68
|
+
i = j;
|
|
69
|
+
}
|
|
70
|
+
} else if (code[i] === ".") {
|
|
71
|
+
i += 1;
|
|
72
|
+
let j = i;
|
|
73
|
+
while (j < code.length && isIdentChar(code[j])) j++;
|
|
74
|
+
pieces.push("?.", code.slice(i, j));
|
|
75
|
+
i = j;
|
|
76
|
+
} else if (code[i] === "[") {
|
|
77
|
+
let depth = 1;
|
|
78
|
+
let j = i + 1;
|
|
79
|
+
while (j < code.length && depth > 0) {
|
|
80
|
+
if (code[j] === "[") depth++;
|
|
81
|
+
else if (code[j] === "]") depth--;
|
|
82
|
+
j++;
|
|
83
|
+
}
|
|
84
|
+
pieces.push("?.[", code.slice(i + 1, j)); // slice includes closing "]"
|
|
85
|
+
i = j;
|
|
86
|
+
} else {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Peek past trailing whitespace to detect an assignment target.
|
|
92
|
+
let k = i;
|
|
93
|
+
while (k < code.length && (code[k] === " " || code[k] === "\t")) k++;
|
|
94
|
+
const isAssignTarget = code[k] === "=" && code[k + 1] !== "=" && code[k + 1] !== ">";
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
rewritten: pieces.join(""),
|
|
98
|
+
original: code.slice(start, i),
|
|
99
|
+
end: i,
|
|
100
|
+
isAssignTarget,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function transformCtxCompat(content: string): TransformResult {
|
|
105
|
+
// Only touch files that actually export a loader — avoids rewriting an
|
|
106
|
+
// unrelated `ctx` variable (canvas context, etc.) in components.
|
|
107
|
+
if (!LOADER_EXPORT_RE.test(content)) {
|
|
108
|
+
return { content, changed: false, notes: [] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let out = "";
|
|
112
|
+
let i = 0;
|
|
113
|
+
let count = 0;
|
|
114
|
+
|
|
115
|
+
while (i < content.length) {
|
|
116
|
+
const isCtxToken =
|
|
117
|
+
content.startsWith("ctx", i) &&
|
|
118
|
+
!isIdentChar(content[i - 1]) &&
|
|
119
|
+
content[i - 1] !== "." &&
|
|
120
|
+
(content[i + 3] === "." || content[i + 3] === "[" || content.startsWith("?.", i + 3));
|
|
121
|
+
|
|
122
|
+
if (isCtxToken) {
|
|
123
|
+
const { rewritten, original, end, isAssignTarget } = scanChain(content, i);
|
|
124
|
+
if (!isAssignTarget && rewritten !== original) {
|
|
125
|
+
out += rewritten;
|
|
126
|
+
count++;
|
|
127
|
+
} else {
|
|
128
|
+
out += original;
|
|
129
|
+
}
|
|
130
|
+
i = end;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
out += content[i];
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (count === 0) {
|
|
139
|
+
return { content, changed: false, notes: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
content: out,
|
|
144
|
+
changed: true,
|
|
145
|
+
notes: [
|
|
146
|
+
`Optional-chained ${count} ctx.* read(s) so unconfigured app state degrades to undefined instead of throwing (#305)`,
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const _internals = { scanChain, LOADER_EXPORT_RE };
|
|
@@ -202,9 +202,11 @@ export function transformFreshApis(content: string): TransformResult {
|
|
|
202
202
|
notes.push("Removed allowCorsFor (not needed in TanStack)");
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
// ctx.response.headers →
|
|
205
|
+
// ctx.response.headers → supported by the compat ctx (#305): section loaders
|
|
206
|
+
// receive a 3rd-arg ctx whose `response.headers` maps to
|
|
207
|
+
// RequestContext.responseHeaders. Just an INFO note (no manual work).
|
|
206
208
|
if (result.includes("ctx.response")) {
|
|
207
|
-
notes.push("
|
|
209
|
+
notes.push("INFO: ctx.response.headers is provided by the compat ctx (RequestContext.responseHeaders) inside the worker request scope; writes are dropped on the dev/SPA serverFn path");
|
|
208
210
|
}
|
|
209
211
|
|
|
210
212
|
// { crypto } from "@std/crypto" → use globalThis.crypto (Web Crypto API)
|