@decocms/blocks-cli 7.22.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.22.2",
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.22.2",
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
- entries.push(` "site/sections/SEOPDP.tsx": async (props: any, _req) => {`);
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, { seo: {} } as any);`);
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 → not available, flag
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("MANUAL: ctx.response usage found — FnContext in @decocms/blocks does not have response object");
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)