@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.
- package/package.json +44 -0
- package/scripts/analyze-traces.mjs +1117 -0
- package/scripts/audit-observability-config.test.ts +446 -0
- package/scripts/audit-observability-config.ts +511 -0
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/fast-deploy-kv.test.ts +131 -0
- package/scripts/generate-blocks.test.ts +94 -0
- package/scripts/generate-blocks.ts +274 -0
- package/scripts/generate-invoke.test.ts +195 -0
- package/scripts/generate-invoke.ts +469 -0
- package/scripts/generate-loaders.ts +217 -0
- package/scripts/generate-schema.ts +1287 -0
- package/scripts/generate-sections.ts +237 -0
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +142 -0
- package/scripts/lib/cf-kv-rest.ts +78 -0
- package/scripts/lib/jsonc.ts +122 -0
- package/scripts/lib/kv-snapshot.ts +51 -0
- package/scripts/lib/read-decofile.ts +70 -0
- package/scripts/lib/sync-helpers.ts +44 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/analyzers/island-classifier.ts +96 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +147 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +782 -0
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/phase-cleanup.test.ts +141 -0
- package/scripts/migrate/phase-cleanup.ts +1588 -0
- package/scripts/migrate/phase-compile.test.ts +193 -0
- package/scripts/migrate/phase-compile.ts +177 -0
- package/scripts/migrate/phase-report.ts +243 -0
- package/scripts/migrate/phase-scaffold.ts +593 -0
- package/scripts/migrate/phase-transform.ts +310 -0
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +572 -0
- package/scripts/migrate/post-cleanup/rules.ts +1708 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate/templates/app-css.ts +366 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +230 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
- package/scripts/migrate/templates/hooks.test.ts +141 -0
- package/scripts/migrate/templates/hooks.ts +152 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/lib-utils.test.ts +139 -0
- package/scripts/migrate/templates/lib-utils.ts +326 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +177 -0
- package/scripts/migrate/templates/routes.ts +237 -0
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +456 -0
- package/scripts/migrate/templates/server-entry.ts +561 -0
- package/scripts/migrate/templates/setup.ts +148 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/types-gen.ts +174 -0
- package/scripts/migrate/templates/ui-components.ts +144 -0
- package/scripts/migrate/templates/vite-config.ts +101 -0
- package/scripts/migrate/transforms/dead-code.ts +455 -0
- package/scripts/migrate/transforms/deno-isms.ts +85 -0
- package/scripts/migrate/transforms/fresh-apis.ts +223 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
- package/scripts/migrate/transforms/imports.ts +385 -0
- package/scripts/migrate/transforms/jsx.ts +317 -0
- package/scripts/migrate/transforms/section-conventions.ts +210 -0
- package/scripts/migrate/transforms/tailwind.ts +739 -0
- package/scripts/migrate/types.ts +244 -0
- package/scripts/migrate-blocks-to-kv.ts +104 -0
- package/scripts/migrate-post-cleanup.ts +191 -0
- package/scripts/migrate-to-cf-observability.test.ts +215 -0
- package/scripts/migrate-to-cf-observability.ts +699 -0
- package/scripts/migrate.ts +282 -0
- package/scripts/smoke-otlp-errorlog.ts +46 -0
- package/scripts/smoke-otlp-meter.ts +48 -0
- package/scripts/sync-blocks-to-kv.ts +153 -0
- package/scripts/tailwind-lint.ts +518 -0
- 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
|
+
);
|