@decocms/blocks-cli 7.0.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.
Files changed (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Scans @decocms/apps vtex/invoke.ts and generates a site-local invoke file
4
+ * with top-level createServerFn declarations.
5
+ *
6
+ * TanStack Start's compiler only transforms createServerFn().handler() when
7
+ * the call is at module top-level (assigned to a const). The factory pattern
8
+ * used in @decocms/apps/vtex/invoke.ts causes the "fast path" in the compiler
9
+ * to skip the .handler() calls because they're inside a function body.
10
+ *
11
+ * This script generates an equivalent file where each server function is a
12
+ * top-level const, which the compiler can correctly transform into RPC stubs.
13
+ *
14
+ * Usage (from site root):
15
+ * npx tsx node_modules/@decocms/blocks-cli/scripts/generate-invoke.ts
16
+ *
17
+ * Env / CLI:
18
+ * --out-file override output (default: src/server/invoke.gen.ts)
19
+ * --apps-dir override @decocms/apps location (default: auto-resolve from node_modules)
20
+ */
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import { Project, type PropertyAssignment, SyntaxKind } from "ts-morph";
24
+
25
+ const args = process.argv.slice(2);
26
+ function arg(name: string, fallback: string): string {
27
+ const idx = args.indexOf(`--${name}`);
28
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
29
+ }
30
+
31
+ const cwd = process.cwd();
32
+ const outFile = path.resolve(cwd, arg("out-file", "src/server/invoke.gen.ts"));
33
+
34
+ function resolveAppsDir(): string {
35
+ const explicit = arg("apps-dir", "");
36
+ if (explicit) return path.resolve(cwd, explicit);
37
+
38
+ // Try common locations
39
+ const candidates = [
40
+ path.resolve(cwd, "node_modules/@decocms/apps"),
41
+ path.resolve(cwd, "../apps-start"),
42
+ ];
43
+ for (const c of candidates) {
44
+ if (fs.existsSync(path.join(c, "vtex/invoke.ts"))) return c;
45
+ }
46
+ throw new Error("Could not find @decocms/apps. Use --apps-dir to specify its location.");
47
+ }
48
+
49
+ const appsDir = resolveAppsDir();
50
+ const invokeFile = path.join(appsDir, "vtex/invoke.ts");
51
+
52
+ if (!fs.existsSync(invokeFile)) {
53
+ console.error(`invoke.ts not found at: ${invokeFile}`);
54
+ process.exit(1);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Parse the source invoke.ts to extract action definitions
59
+ // ---------------------------------------------------------------------------
60
+
61
+ interface ActionDef {
62
+ name: string;
63
+ /** The import source for the action function (e.g., "@decocms/apps/vtex/actions/checkout") */
64
+ importSource: string;
65
+ /** The imported function name (e.g., "addItemsToCart") */
66
+ importedFn: string;
67
+ /** The input type as a string (e.g., "{ orderFormId: string; ... }") */
68
+ inputType: string;
69
+ /** The return type as a string (e.g., "OrderForm") */
70
+ returnType: string;
71
+ /** Whether to unwrap VtexFetchResult */
72
+ unwrap: boolean;
73
+ /** The body of the action call (e.g., "addItemsToCart(input.orderFormId, input.orderItems)") */
74
+ callBody: string;
75
+ }
76
+
77
+ const project = new Project({ compilerOptions: { strict: true } });
78
+ const sourceFile = project.addSourceFileAtPath(invokeFile);
79
+
80
+ // Collect all imports to know which functions come from where
81
+ const importMap = new Map<string, { source: string; importedName: string }>();
82
+ for (const imp of sourceFile.getImportDeclarations()) {
83
+ const source = imp.getModuleSpecifierValue();
84
+ for (const named of imp.getNamedImports()) {
85
+ const localName = named.getName();
86
+ const importedName = named.getAliasNode()?.getText() || localName;
87
+ importMap.set(localName, {
88
+ source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source,
89
+ importedName: localName,
90
+ });
91
+ }
92
+ }
93
+
94
+ // Collect type imports
95
+ const typeImportMap = new Map<string, { source: string; importedName: string }>();
96
+ for (const imp of sourceFile.getImportDeclarations()) {
97
+ if (!imp.isTypeOnly()) {
98
+ for (const named of imp.getNamedImports()) {
99
+ if (named.isTypeOnly()) {
100
+ const localName = named.getName();
101
+ const source = imp.getModuleSpecifierValue();
102
+ typeImportMap.set(localName, {
103
+ source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source,
104
+ importedName: localName,
105
+ });
106
+ }
107
+ }
108
+ }
109
+ if (imp.isTypeOnly()) {
110
+ const source = imp.getModuleSpecifierValue();
111
+ for (const named of imp.getNamedImports()) {
112
+ const localName = named.getName();
113
+ typeImportMap.set(localName, {
114
+ source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source,
115
+ importedName: localName,
116
+ });
117
+ }
118
+ }
119
+ }
120
+
121
+ // Find the invoke const and extract actions
122
+ const invokeVar = sourceFile.getVariableDeclaration("invoke");
123
+ if (!invokeVar) {
124
+ console.error("Could not find 'export const invoke' in invoke.ts");
125
+ process.exit(1);
126
+ }
127
+
128
+ const actions: ActionDef[] = [];
129
+ const invokeInit = invokeVar.getInitializer();
130
+ if (!invokeInit) {
131
+ console.error("invoke variable has no initializer");
132
+ process.exit(1);
133
+ }
134
+
135
+ // Navigate: invoke → .vtex → .actions → each property
136
+ const vtexProp = invokeInit
137
+ .asKindOrThrow(SyntaxKind.AsExpression)
138
+ .getExpression()
139
+ .asKindOrThrow(SyntaxKind.ObjectLiteralExpression)
140
+ .getProperty("vtex");
141
+
142
+ if (!vtexProp) {
143
+ console.error("Could not find 'vtex' property in invoke object");
144
+ process.exit(1);
145
+ }
146
+
147
+ const vtexObj = (vtexProp as PropertyAssignment)
148
+ .getInitializer()!
149
+ .asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
150
+
151
+ const actionsProp = vtexObj.getProperty("actions");
152
+ if (!actionsProp) {
153
+ console.error("Could not find 'actions' property in vtex object");
154
+ process.exit(1);
155
+ }
156
+
157
+ const actionsObj = (actionsProp as PropertyAssignment)
158
+ .getInitializer()!
159
+ .asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
160
+
161
+ for (const prop of actionsObj.getProperties()) {
162
+ if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
163
+ const pa = prop as PropertyAssignment;
164
+ const name = pa.getName();
165
+ const initText = pa.getInitializer()!.getText();
166
+
167
+ // Check if it uses createInvokeFn with unwrap
168
+ const unwrap = initText.includes("unwrap: true");
169
+
170
+ // Extract the arrow function body from createInvokeFn((input: ...) => ...)
171
+ // We'll parse the call expression to get the action call
172
+ const callExpr = pa.getInitializer()!;
173
+ let inputType = "any";
174
+ let callBody = "";
175
+
176
+ // Recursively unwrap AsExpression chains (e.g. `expr as unknown as Type`)
177
+ let createInvokeFnCall = callExpr;
178
+ while (createInvokeFnCall.getKind() === SyntaxKind.AsExpression) {
179
+ createInvokeFnCall = createInvokeFnCall.asKindOrThrow(SyntaxKind.AsExpression).getExpression();
180
+ }
181
+
182
+ // Now we have createInvokeFn(...) call
183
+ if (createInvokeFnCall.getKind() === SyntaxKind.CallExpression) {
184
+ const callArgs = createInvokeFnCall.asKindOrThrow(SyntaxKind.CallExpression).getArguments();
185
+ if (callArgs.length >= 1) {
186
+ const arrowFn = callArgs[0];
187
+ if (arrowFn.getKind() === SyntaxKind.ArrowFunction) {
188
+ const arrow = arrowFn.asKindOrThrow(SyntaxKind.ArrowFunction);
189
+ const params = arrow.getParameters();
190
+ if (params.length >= 1) {
191
+ const paramType = params[0].getTypeNode()?.getText() || "any";
192
+ inputType = paramType;
193
+ }
194
+ // Get the body (the actual action call)
195
+ const body = arrow.getBody();
196
+ callBody = body.getText();
197
+
198
+ // If body is a block, extract the expression
199
+ if (callBody.startsWith("{")) {
200
+ // It's a block body — skip for now, use simplified version
201
+ callBody = "";
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ // Determine which function is being called
208
+ let importedFn = "";
209
+ let importSource = "";
210
+ for (const [fnName, info] of importMap.entries()) {
211
+ if (callBody.includes(`${fnName}(`)) {
212
+ importedFn = fnName;
213
+ importSource = info.source;
214
+ break;
215
+ }
216
+ }
217
+
218
+ // Extract the return type from the outermost "as" assertion.
219
+ // For `expr as unknown as (ctx: ...) => Promise<T>`, the outermost
220
+ // AsExpression has the function type with Promise<T>.
221
+ let returnType = "any";
222
+ if (callExpr.getKind() === SyntaxKind.AsExpression) {
223
+ const asExpr = callExpr.asKindOrThrow(SyntaxKind.AsExpression);
224
+ const typeText = asExpr.getTypeNode()?.getText() || "";
225
+ if (typeText !== "unknown") {
226
+ const promiseMatch = typeText.match(/Promise<(.+)>$/s);
227
+ if (promiseMatch) {
228
+ returnType = promiseMatch[1].trim();
229
+ }
230
+ }
231
+ }
232
+
233
+ actions.push({
234
+ name,
235
+ importSource,
236
+ importedFn,
237
+ inputType,
238
+ returnType,
239
+ unwrap,
240
+ callBody,
241
+ });
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Generate the output file
246
+ // ---------------------------------------------------------------------------
247
+
248
+ // Collect unique imports needed
249
+ const fnImports = new Map<string, Set<string>>();
250
+ const typeImports = new Map<string, Set<string>>();
251
+
252
+ for (const action of actions) {
253
+ if (action.importSource && action.importedFn) {
254
+ if (!fnImports.has(action.importSource)) {
255
+ fnImports.set(action.importSource, new Set());
256
+ }
257
+ fnImports.get(action.importSource)!.add(action.importedFn);
258
+ }
259
+ }
260
+
261
+ // Add type imports referenced in inputType or returnType
262
+ for (const action of actions) {
263
+ const allText = action.inputType + action.returnType + action.callBody;
264
+ for (const [typeName, info] of typeImportMap.entries()) {
265
+ if (allText.includes(typeName)) {
266
+ if (!typeImports.has(info.source)) {
267
+ typeImports.set(info.source, new Set());
268
+ }
269
+ typeImports.get(info.source)!.add(typeName);
270
+ }
271
+ }
272
+ // Also check value imports that appear in the types (like SimulationItem)
273
+ for (const [fnName, info] of importMap.entries()) {
274
+ if (action.inputType.includes(fnName) && !fnImports.get(info.source)?.has(fnName)) {
275
+ if (!typeImports.has(info.source)) {
276
+ typeImports.set(info.source, new Set());
277
+ }
278
+ typeImports.get(info.source)!.add(fnName);
279
+ }
280
+ }
281
+ }
282
+
283
+ // Count how many actually parsed vs. stubbed
284
+ const parsed = actions.filter((a) => a.importedFn).length;
285
+ const stubbed = actions.length - parsed;
286
+ if (stubbed > 0) {
287
+ console.warn(`⚠ ${stubbed} action(s) could not be parsed — generated as stubs:`);
288
+ for (const a of actions) {
289
+ if (!a.importedFn) console.warn(` - ${a.name}`);
290
+ }
291
+ }
292
+
293
+ // Build output
294
+ let out = `// Auto-generated by @decocms/blocks-cli/scripts/generate-invoke.ts
295
+ // Do not edit manually. Re-run the generator to update.
296
+ //
297
+ // Each server function is a top-level const so TanStack Start's compiler
298
+ // can transform createServerFn().handler() into RPC stubs on the client.
299
+ //
300
+ // Site-specific extensions: import { vtexActions } from this file and merge
301
+ // with your own actions in a separate invoke.ts.
302
+ import { createServerFn } from "@tanstack/react-start";
303
+ `;
304
+
305
+ // Add function imports
306
+ for (const [source, fns] of fnImports) {
307
+ out += `import { ${[...fns].join(", ")} } from "${source}";\n`;
308
+ }
309
+
310
+ // Add type imports
311
+ for (const [source, types] of typeImports) {
312
+ // Don't duplicate if already imported as value
313
+ const valueImports = fnImports.get(source);
314
+ const onlyTypes = [...types].filter((t) => !valueImports?.has(t));
315
+ if (onlyTypes.length > 0) {
316
+ out += `import type { ${onlyTypes.join(", ")} } from "${source}";\n`;
317
+ }
318
+ }
319
+
320
+ // Imports required by the forwardResponseCookies bridge. They live next
321
+ // to the framework's RequestContext (where vtexFetchWithCookies stashes
322
+ // inbound Set-Cookie headers) and TanStack Start's response-header API
323
+ // (where we copy them onto the actual HTTP response).
324
+ out += `import {
325
+ getResponseHeaders,
326
+ setResponseHeader,
327
+ } from "@tanstack/react-start/server";
328
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
329
+ `;
330
+
331
+ out += `
332
+ function unwrapResult<T>(result: unknown): T {
333
+ if (result && typeof result === "object" && "data" in result) {
334
+ return (result as { data: T }).data;
335
+ }
336
+ return result as T;
337
+ }
338
+
339
+ /**
340
+ * Forward Set-Cookie headers captured in RequestContext.responseHeaders
341
+ * (by vtexFetchWithCookies) into TanStack Start's HTTP response.
342
+ *
343
+ * Without this bridge, HttpOnly cookies like \`checkout.vtex.com\` and
344
+ * \`CheckoutOrderFormOwnership\` that VTEX returns on cart-action responses
345
+ * stay trapped inside the AsyncLocalStorage-backed RequestContext and
346
+ * never reach the browser. The storefront's mini-cart drifts away from
347
+ * VTEX's server-side orderForm, and the user lands on /checkout with an
348
+ * empty cart.
349
+ *
350
+ * Cheap no-op when no Set-Cookie was captured (e.g. read-only actions),
351
+ * so every handler can call it unconditionally without branching on
352
+ * whether the underlying action uses vtexFetchWithCookies.
353
+ */
354
+ function forwardResponseCookies(): void {
355
+ const ctx = RequestContext.current;
356
+ if (!ctx) return;
357
+ const captured =
358
+ typeof ctx.responseHeaders.getSetCookie === "function"
359
+ ? ctx.responseHeaders.getSetCookie()
360
+ : [];
361
+ if (captured.length === 0) return;
362
+ const existing =
363
+ typeof getResponseHeaders().getSetCookie === "function"
364
+ ? getResponseHeaders().getSetCookie()
365
+ : [];
366
+ setResponseHeader("set-cookie", [...existing, ...captured]);
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Top-level server function declarations
371
+ // ---------------------------------------------------------------------------
372
+ `;
373
+
374
+ for (const action of actions) {
375
+ const varName = `$${action.name}`;
376
+
377
+ if (action.importedFn) {
378
+ // Emit the wrapper body verbatim. The arrow function in
379
+ // @decocms/apps/vtex/invoke.ts is the contract that maps the external
380
+ // invoke shape (what storefront callers send) to the internal action
381
+ // shape (what vtex/actions/* expects). Most wrappers are direct
382
+ // pass-throughs (`actionFn(data)`) but some adapt the payload
383
+ // (e.g. `createSession({ data })` wraps a flat session payload into
384
+ // CreateSessionProps). Hard-coding `${importedFn}(data)` silently
385
+ // dropped the wrap, producing typecheck errors at the call site of
386
+ // every adapting wrapper.
387
+ //
388
+ // Invariant: when action.importedFn is set, action.callBody was
389
+ // non-empty and contained `${importedFn}(` (that's how importedFn was
390
+ // discovered in the first place — see the importMap scan above).
391
+ // Block bodies clear callBody to "", which forces importedFn to ""
392
+ // and routes to the stub branch below.
393
+ //
394
+ // The arrow's parameter is `data` by convention across every wrapper
395
+ // in vtex/invoke.ts, and the generated handler destructures `{ data }`
396
+ // from the validator output, so callBody's `data` references resolve
397
+ // to the handler's local `data` without any rename.
398
+ //
399
+ // forwardResponseCookies() runs AFTER the action awaits, so any
400
+ // Set-Cookie that vtexFetchWithCookies captured onto
401
+ // RequestContext.responseHeaders gets promoted to the actual HTTP
402
+ // response. Safe no-op when the action didn't touch responseHeaders
403
+ // (e.g. masterData reads), so it's applied unconditionally — the
404
+ // alternative (a static allow-list of cookie-bearing actions)
405
+ // silently misses any new actions that start propagating cookies.
406
+ if (action.unwrap) {
407
+ out += `\nconst ${varName} = createServerFn({ method: "POST" })
408
+ .inputValidator((data: ${action.inputType}) => data)
409
+ .handler(async ({ data }): Promise<any> => {
410
+ const result = await ${action.callBody};
411
+ forwardResponseCookies();
412
+ return unwrapResult(result);
413
+ });\n`;
414
+ } else {
415
+ out += `\nconst ${varName} = createServerFn({ method: "POST" })
416
+ .inputValidator((data: ${action.inputType}) => data)
417
+ .handler(async ({ data }): Promise<any> => {
418
+ const result = await ${action.callBody};
419
+ forwardResponseCookies();
420
+ return result;
421
+ });\n`;
422
+ }
423
+ } else {
424
+ // Fallback: couldn't parse — generate a stub
425
+ out += `\n// TODO: could not auto-generate ${action.name} — add manually\nconst ${varName} = createServerFn({ method: "POST" })
426
+ .handler(async () => {
427
+ throw new Error("${action.name}: not implemented — regenerate invoke");
428
+ });\n`;
429
+ }
430
+ }
431
+
432
+ // Generate the vtexActions object (for composability with site-specific actions)
433
+ out += `
434
+ // ---------------------------------------------------------------------------
435
+ // Typed VTEX actions map — merge with site-specific actions in your invoke.ts
436
+ // ---------------------------------------------------------------------------
437
+
438
+ export const vtexActions = {
439
+ `;
440
+
441
+ for (const action of actions) {
442
+ const varName = `$${action.name}`;
443
+ if (action.returnType !== "any") {
444
+ out += ` ${action.name}: ${varName} as unknown as (ctx: { data: ${action.inputType} }) => Promise<${action.returnType}>,\n`;
445
+ } else {
446
+ out += ` ${action.name}: ${varName},\n`;
447
+ }
448
+ }
449
+
450
+ out += `} as const;
451
+
452
+ // Re-export OrderForm type (commonly imported from invoke by site components)
453
+ export type { OrderForm } from "@decocms/apps/vtex/types";
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Default invoke object — import this if you don't need site extensions
457
+ // ---------------------------------------------------------------------------
458
+
459
+ export const invoke = {
460
+ vtex: {
461
+ actions: vtexActions,
462
+ },
463
+ } as const;
464
+ `;
465
+
466
+ // Write output
467
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
468
+ fs.writeFileSync(outFile, out);
469
+ console.log(`Generated ${actions.length} server functions → ${path.relative(cwd, outFile)}`);
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Scans site loader and action files and generates a registry map
4
+ * for COMMERCE_LOADERS pass-through entries.
5
+ *
6
+ * Each loader/action file that exports a default function gets a generated
7
+ * entry like:
8
+ * "site/loaders/SAP/getUser": async (props, request) => {
9
+ * const mod = await import("../../loaders/SAP/getUser");
10
+ * return mod.default(props, request);
11
+ * },
12
+ *
13
+ * Both keyed with and without `.ts` suffix for CMS block compatibility.
14
+ *
15
+ * Files listed in --exclude are skipped (they need custom wiring in setup.ts).
16
+ *
17
+ * **Default behavior: register every loader/action file on disk.** This
18
+ * matches Fresh (`@deco/deco`) where `manifestGen.ts` auto-registers every
19
+ * file under `src/loaders/` and `src/actions/`, so code-driven invocations
20
+ * (`runtime.site.loaders.deliveryPromise({...})` from React/hooks/effects)
21
+ * resolve without manual registry wiring after a Fresh → TanStack migration.
22
+ *
23
+ * Opt-in CMS pruning (`--prune-by-decofile`): when supplied, the script
24
+ * walks every JSON file in the directory and collects the set of
25
+ * `__resolveType` references. Only loaders whose key appears in that set
26
+ * are emitted — trimming dead entries on sites that ONLY invoke loaders
27
+ * through CMS blocks. The previous `--decofile-dir` flag is a deprecated
28
+ * alias for the same behavior; it logs a warning on use.
29
+ *
30
+ * Usage (from site root):
31
+ * npx tsx node_modules/@decocms/blocks-cli/scripts/generate-loaders.ts
32
+ * npx tsx node_modules/@decocms/blocks-cli/scripts/generate-loaders.ts --prune-by-decofile .deco/blocks
33
+ *
34
+ * CLI:
35
+ * --loaders-dir override loaders input (default: src/loaders)
36
+ * --actions-dir override actions input (default: src/actions)
37
+ * --out-file override output (default: src/server/cms/loaders.gen.ts)
38
+ * --exclude comma-separated list of loader keys to skip (they have custom wiring)
39
+ * --prune-by-decofile only emit entries whose key appears as `__resolveType` in any JSON
40
+ * --decofile-dir @deprecated alias for --prune-by-decofile
41
+ */
42
+ import fs from "node:fs";
43
+ import path from "node:path";
44
+
45
+ const args = process.argv.slice(2);
46
+ function arg(name: string, fallback: string): string {
47
+ const idx = args.indexOf(`--${name}`);
48
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
49
+ }
50
+
51
+ const loadersDir = path.resolve(process.cwd(), arg("loaders-dir", "src/loaders"));
52
+ const actionsDir = path.resolve(process.cwd(), arg("actions-dir", "src/actions"));
53
+ const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/loaders.gen.ts"));
54
+ const excludeRaw = arg("exclude", "");
55
+ const excludeSet = new Set(excludeRaw.split(",").map((s) => s.trim()).filter(Boolean));
56
+
57
+ const pruneByDecofileRaw = arg("prune-by-decofile", "");
58
+ const legacyDecofileDirRaw = arg("decofile-dir", "");
59
+ if (legacyDecofileDirRaw && !pruneByDecofileRaw) {
60
+ console.warn(
61
+ "[generate-loaders] --decofile-dir is deprecated; use --prune-by-decofile <path> instead. " +
62
+ "The default (no flag) now registers every loader/action file, matching Fresh's auto-discovery.",
63
+ );
64
+ }
65
+ const pruneDirRaw = pruneByDecofileRaw || legacyDecofileDirRaw;
66
+ const decofileDir = pruneDirRaw ? path.resolve(process.cwd(), pruneDirRaw) : null;
67
+
68
+ function walkDir(dir: string): string[] {
69
+ const results: string[] = [];
70
+ if (!fs.existsSync(dir)) return results;
71
+
72
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
73
+ const fullPath = path.join(dir, entry.name);
74
+ if (entry.isDirectory()) {
75
+ results.push(...walkDir(fullPath));
76
+ } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
77
+ results.push(fullPath);
78
+ }
79
+ }
80
+ return results;
81
+ }
82
+
83
+ function fileToKey(filePath: string, baseDir: string, prefix: string): string {
84
+ const rel = path.relative(baseDir, filePath).replace(/\\/g, "/").replace(/\.tsx?$/, "");
85
+ return `${prefix}/${rel}`;
86
+ }
87
+
88
+ function relativeImportPath(from: string, to: string): string {
89
+ let rel = path.relative(path.dirname(from), to).replace(/\\/g, "/");
90
+ if (!rel.startsWith(".")) rel = `./${rel}`;
91
+ return rel.replace(/\.tsx?$/, "");
92
+ }
93
+
94
+ function hasDefaultExport(content: string): boolean {
95
+ return /export\s+default\b/.test(content) || /export\s*\{[^}]*\bdefault\b/.test(content);
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // CMS-referenced loader discovery
100
+ //
101
+ // Walk every JSON file under decofileDir and collect the set of strings that
102
+ // appear as `__resolveType` values. The migration script + generators emit
103
+ // pass-throughs for every loader/action file on disk; without this filter,
104
+ // 90%+ of those entries are dead code (the CMS never references them) and
105
+ // they pollute the type system and bundle.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ function collectResolveTypes(dir: string): Set<string> {
109
+ const found = new Set<string>();
110
+ if (!fs.existsSync(dir)) return found;
111
+
112
+ const RESOLVE_RE = /"__resolveType"\s*:\s*"([^"]+)"/g;
113
+
114
+ function visit(d: string) {
115
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
116
+ const fullPath = path.join(d, entry.name);
117
+ if (entry.isDirectory()) {
118
+ visit(fullPath);
119
+ } else if (entry.name.endsWith(".json")) {
120
+ const content = fs.readFileSync(fullPath, "utf-8");
121
+ let m: RegExpExecArray | null;
122
+ while ((m = RESOLVE_RE.exec(content)) !== null) {
123
+ found.add(m[1]);
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ visit(dir);
130
+ return found;
131
+ }
132
+
133
+ const cmsReferences = decofileDir ? collectResolveTypes(decofileDir) : null;
134
+
135
+ function isReferenced(key: string): boolean {
136
+ if (!cmsReferences) return true;
137
+ return cmsReferences.has(key) || cmsReferences.has(`${key}.ts`);
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+
142
+ interface LoaderEntry {
143
+ key: string;
144
+ importPath: string;
145
+ }
146
+
147
+ const entries: LoaderEntry[] = [];
148
+ let prunedCount = 0;
149
+
150
+ for (const filePath of walkDir(loadersDir)) {
151
+ const content = fs.readFileSync(filePath, "utf-8");
152
+ if (!hasDefaultExport(content)) continue;
153
+ const key = fileToKey(filePath, loadersDir, "site/loaders");
154
+ if (excludeSet.has(key) || excludeSet.has(`${key}.ts`)) continue;
155
+ if (!isReferenced(key)) {
156
+ prunedCount++;
157
+ continue;
158
+ }
159
+ entries.push({
160
+ key,
161
+ importPath: relativeImportPath(outFile, filePath),
162
+ });
163
+ }
164
+
165
+ for (const filePath of walkDir(actionsDir)) {
166
+ const content = fs.readFileSync(filePath, "utf-8");
167
+ if (!hasDefaultExport(content)) continue;
168
+ const key = fileToKey(filePath, actionsDir, "site/actions");
169
+ if (excludeSet.has(key) || excludeSet.has(`${key}.ts`)) continue;
170
+ if (!isReferenced(key)) {
171
+ prunedCount++;
172
+ continue;
173
+ }
174
+ entries.push({
175
+ key,
176
+ importPath: relativeImportPath(outFile, filePath),
177
+ });
178
+ }
179
+
180
+ entries.sort((a, b) => a.key.localeCompare(b.key));
181
+
182
+ const lines: string[] = [
183
+ "// Auto-generated by @decocms/blocks-cli/scripts/generate-loaders.ts",
184
+ "// Do not edit manually. Run `npm run generate:loaders` to update.",
185
+ "//",
186
+ "// Pass-through loader/action entries for COMMERCE_LOADERS.",
187
+ "// Custom-wired entries should be excluded via --exclude and added manually in setup.ts.",
188
+ "",
189
+ "export const siteLoaders: Record<string, (props: any, request?: Request) => Promise<any>> = {",
190
+ ];
191
+
192
+ // Cast the dynamic-import default to `any` so legacy 3-arg
193
+ // `(props, req, ctx)` Fresh/Deno loaders still type-check. Any ctx-dependent
194
+ // path in the loader body throws at runtime and must be refactored.
195
+ for (const entry of entries) {
196
+ lines.push(` "${entry.key}": async (props: any, request?: Request) => {`);
197
+ lines.push(` const mod = await import("${entry.importPath}");`);
198
+ lines.push(" return (mod.default as any)(props, request);");
199
+ lines.push(" },");
200
+ lines.push(` "${entry.key}.ts": async (props: any, request?: Request) => {`);
201
+ lines.push(` const mod = await import("${entry.importPath}");`);
202
+ lines.push(" return (mod.default as any)(props, request);");
203
+ lines.push(" },");
204
+ }
205
+
206
+ lines.push("};");
207
+ lines.push("");
208
+
209
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
210
+ fs.writeFileSync(outFile, lines.join("\n"));
211
+
212
+ const filterNote = cmsReferences
213
+ ? ` (filtered against ${cmsReferences.size} CMS __resolveType references; pruned ${prunedCount} dead entries)`
214
+ : "";
215
+ console.log(
216
+ `Generated ${entries.length} loader entries (${entries.length * 2} with .ts aliases) → ${path.relative(process.cwd(), outFile)}${filterNote}`,
217
+ );