@decocms/blocks-cli 7.9.0 → 7.11.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.9.0",
3
+ "version": "7.11.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": {
@@ -19,6 +19,7 @@
19
19
  "deco-sync-blocks-to-kv": "./scripts/sync-blocks-to-kv.ts"
20
20
  },
21
21
  "exports": {
22
+ "./generate-app-schemas": "./scripts/generate-app-schemas.ts",
22
23
  "./generate-blocks": "./scripts/generate-blocks.ts",
23
24
  "./generate-blocks-manifest": "./scripts/generate-blocks-manifest.ts",
24
25
  "./generate-schema": "./scripts/generate-schema.ts",
@@ -35,7 +36,7 @@
35
36
  "lint:unused": "knip"
36
37
  },
37
38
  "dependencies": {
38
- "@decocms/blocks": "7.9.0",
39
+ "@decocms/blocks": "7.11.0",
39
40
  "ts-morph": "^27.0.0",
40
41
  "tsx": "^4.22.5"
41
42
  },
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Tests for generate-app-schemas.ts — the build-time extractor that gives app
3
+ * packages (apps-vtex, …) real loader/action props schemas in the admin meta,
4
+ * replacing the `__resolveType`-only stubs auto-registered at runtime.
5
+ *
6
+ * Drives generateAppSchemas() directly (the script is import-safe, guarded by
7
+ * isMainModule()) against a tmp fixture app package covering both key
8
+ * universes: file-path loader keys and manifest-flattened module keys.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
14
+ import {
15
+ type AppSchemasResult,
16
+ generateAppSchemas,
17
+ renderSchemasModule,
18
+ } from "./generate-app-schemas";
19
+
20
+ let tmpDir: string;
21
+ let result: AppSchemasResult;
22
+
23
+ function write(rel: string, content: string) {
24
+ const full = path.join(tmpDir, rel);
25
+ fs.mkdirSync(path.dirname(full), { recursive: true });
26
+ fs.writeFileSync(full, content);
27
+ }
28
+
29
+ beforeAll(() => {
30
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-app-schemas-"));
31
+
32
+ write(
33
+ "package.json",
34
+ JSON.stringify({ name: "@decocms/apps-fixture", version: "0.0.0", type: "module" }),
35
+ );
36
+ write(
37
+ "tsconfig.json",
38
+ JSON.stringify({
39
+ compilerOptions: {
40
+ target: "ES2022",
41
+ module: "ESNext",
42
+ moduleResolution: "bundler",
43
+ lib: ["ES2022", "DOM"],
44
+ strict: true,
45
+ },
46
+ }),
47
+ );
48
+
49
+ // File-path-keyed loader with a Props interface + JSDoc metadata.
50
+ write(
51
+ "src/loaders/detail/page.ts",
52
+ `export interface Props {
53
+ /** @title Slug */
54
+ slug?: string;
55
+ /** @description How many variants to fetch */
56
+ variantCount: number;
57
+ }
58
+ export default async function loader(props: Props): Promise<string | null> {
59
+ return props.slug ?? null;
60
+ }
61
+ `,
62
+ );
63
+
64
+ // Loader that takes no input at all.
65
+ write(
66
+ "src/loaders/noProps.ts",
67
+ `export default async function loader(): Promise<number> {
68
+ return 1;
69
+ }
70
+ `,
71
+ );
72
+
73
+ // Loader whose props are untyped — input exists but can't be described.
74
+ write(
75
+ "src/loaders/anyProps.ts",
76
+ `export default async function loader(props: any): Promise<unknown> {
77
+ return props;
78
+ }
79
+ `,
80
+ );
81
+
82
+ // Runtime-injected platform types (url: URL) must be hidden, not expanded,
83
+ // and must not stay required.
84
+ write(
85
+ "src/loaders/listing.ts",
86
+ `export interface Props {
87
+ url: URL;
88
+ sort?: "asc" | "desc";
89
+ count: number;
90
+ }
91
+ export default async function loader(props: Props): Promise<unknown[]> {
92
+ return [props];
93
+ }
94
+ `,
95
+ );
96
+
97
+ // Barrel — must not become a loader key.
98
+ write("src/loaders/index.ts", `export { default as page } from "./detail/page";\n`);
99
+
100
+ // Manifest module with named exports → flattened keys.
101
+ write(
102
+ "src/actions/cart.ts",
103
+ `export interface AddItemProps {
104
+ id: string;
105
+ qty?: number;
106
+ }
107
+ /** @title Add item to cart */
108
+ export async function addItem(props: AddItemProps): Promise<string> {
109
+ return props.id;
110
+ }
111
+ export async function clearCart(): Promise<void> {}
112
+ export type IgnoredType = { nope: true };
113
+ export const IGNORED_CONST = 42;
114
+ `,
115
+ );
116
+ write(
117
+ "src/loaders/things.ts",
118
+ `export async function listThings(props: { filter?: string }): Promise<string[]> {
119
+ return [props.filter ?? ""];
120
+ }
121
+ export default async function defaultThings(props: { limit: number }): Promise<string[]> {
122
+ return [String(props.limit)];
123
+ }
124
+ `,
125
+ );
126
+ write(
127
+ "src/manifest.gen.ts",
128
+ `import * as actions_cart from "./actions/cart";
129
+ import * as loaders_things from "./loaders/things";
130
+
131
+ const manifest = {
132
+ \tname: "fixture",
133
+ \tloaders: {
134
+ \t\t"fixture/loaders/things": loaders_things,
135
+ \t},
136
+ \tactions: {
137
+ \t\t"fixture/actions/cart": actions_cart,
138
+ \t},
139
+ \tsections: {},
140
+ } as const;
141
+
142
+ export default manifest;
143
+ `,
144
+ );
145
+
146
+ result = generateAppSchemas(tmpDir, "fixture");
147
+ }, 120_000);
148
+
149
+ afterAll(() => {
150
+ fs.rmSync(tmpDir, { recursive: true, force: true });
151
+ });
152
+
153
+ describe("file-path-keyed loaders", () => {
154
+ it("emits real props from the Props interface, under both bare and .ts keys", () => {
155
+ const withTs = result.loaders["fixture/loaders/detail/page.ts"];
156
+ const bare = result.loaders["fixture/loaders/detail/page"];
157
+ expect(withTs).toBeDefined();
158
+ expect(bare).toBe(withTs);
159
+
160
+ expect(withTs.properties?.slug).toMatchObject({ type: "string", title: "Slug" });
161
+ expect(withTs.properties?.variantCount).toMatchObject({
162
+ type: "number",
163
+ description: "How many variants to fetch",
164
+ });
165
+ expect(withTs.required).toEqual(["variantCount"]);
166
+ expect(withTs.additionalProperties).toBeUndefined();
167
+ });
168
+
169
+ it("emits an empty-props schema WITHOUT additionalProperties for no-input loaders", () => {
170
+ const schema = result.loaders["fixture/loaders/noProps.ts"];
171
+ expect(schema).toEqual({ type: "object", properties: {} });
172
+ });
173
+
174
+ it("falls back to the JSON-editor shape (additionalProperties) for untyped props", () => {
175
+ const schema = result.loaders["fixture/loaders/anyProps.ts"];
176
+ expect(schema).toEqual({ type: "object", additionalProperties: true });
177
+ });
178
+
179
+ it("hides runtime-injected platform types and drops them from required", () => {
180
+ const schema = result.loaders["fixture/loaders/listing.ts"];
181
+ expect(schema.properties?.url).toMatchObject({ type: "object", hide: "true" });
182
+ expect(schema.properties?.url.properties).toBeUndefined();
183
+ expect(schema.properties?.sort).toMatchObject({ type: "string", enum: ["asc", "desc"] });
184
+ expect(schema.required).toEqual(["count"]);
185
+ });
186
+
187
+ it("skips barrel index files", () => {
188
+ expect(result.loaders["fixture/loaders/index.ts"]).toBeUndefined();
189
+ expect(result.loaders["fixture/loaders/index"]).toBeUndefined();
190
+ });
191
+ });
192
+
193
+ describe("manifest-flattened keys", () => {
194
+ it("flattens named function exports to <moduleKey>/<fnName>", () => {
195
+ const addItem = result.actions["fixture/actions/cart/addItem"];
196
+ expect(addItem).toBeDefined();
197
+ expect(result.actions["fixture/actions/cart/addItem.ts"]).toBe(addItem);
198
+ expect(addItem.title).toBe("Add item to cart");
199
+ expect(addItem.properties?.id).toMatchObject({ type: "string" });
200
+ expect(addItem.required).toEqual(["id"]);
201
+ });
202
+
203
+ it("emits no-props actions without additionalProperties", () => {
204
+ expect(result.actions["fixture/actions/cart/clearCart"]).toEqual({
205
+ type: "object",
206
+ properties: {},
207
+ });
208
+ });
209
+
210
+ it("ignores type and constant exports", () => {
211
+ const keys = Object.keys(result.actions);
212
+ expect(keys.filter((k) => k.includes("IgnoredType"))).toEqual([]);
213
+ expect(keys.filter((k) => k.includes("IGNORED_CONST"))).toEqual([]);
214
+ });
215
+
216
+ it("registers a module's default export at the moduleKey itself", () => {
217
+ const def = result.loaders["fixture/loaders/things"];
218
+ expect(def.properties?.limit).toMatchObject({ type: "number" });
219
+ expect(result.loaders["fixture/loaders/things/listThings"].properties?.filter).toMatchObject({
220
+ type: "string",
221
+ });
222
+ });
223
+ });
224
+
225
+ describe("renderSchemasModule", () => {
226
+ it("emits a typed module with deduplicated schema consts shared across alias keys", () => {
227
+ const out = renderSchemasModule(result);
228
+ expect(out).toContain('import type { BlockPropsSchema } from "@decocms/blocks/cms/client";');
229
+ expect(out).toContain("export const loaderSchemas: Record<string, BlockPropsSchema>");
230
+ expect(out).toContain("export const actionSchemas: Record<string, BlockPropsSchema>");
231
+
232
+ // Alias keys must point at the same const (dedup by content).
233
+ const bare = out.match(/"fixture\/loaders\/detail\/page": (s\d+),/)?.[1];
234
+ const withTs = out.match(/"fixture\/loaders\/detail\/page\.ts": (s\d+),/)?.[1];
235
+ expect(bare).toBeDefined();
236
+ expect(bare).toBe(withTs);
237
+ });
238
+ });
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env tsx
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ /**
6
+ * App Schema Generator — real loader/action props schemas for app packages.
7
+ *
8
+ * The admin builds a block's props form from `/deco/meta`'s
9
+ * `schema.definitions[b64(key)]`. Site loaders get real schemas from
10
+ * generate-schema.ts, but app loaders/actions (@decocms/apps-vtex, ...) were
11
+ * only ever auto-registered as `__resolveType`-only stubs by
12
+ * registerCommerceLoaders() — the admin couldn't render any form for them.
13
+ *
14
+ * This script runs at app-package build time (Props are TS types, erased at
15
+ * runtime — this CANNOT run in the site) and emits a committed
16
+ * `src/schemas.gen.ts` artifact mapping every CMS-reachable key to its real
17
+ * props schema. The app's entrypoints feed it to registerAppSchemas()
18
+ * (@decocms/blocks/cms), which beats the runtime stubs by key.
19
+ *
20
+ * Two key universes are covered, matching how keys reach the CMS at runtime:
21
+ *
22
+ * 1. File-path keys — `<ns>/loaders/<relpath>.ts` (+ bare alias): what
23
+ * commerceLoaders maps and site decofiles reference. Every file under
24
+ * src/loaders/ with a default export.
25
+ * 2. Manifest-flattened keys — what setupApps() registers from
26
+ * src/manifest.gen.ts: `<moduleKey>` for a module's default export,
27
+ * `<moduleKey>/<fnName>` for named function exports (+ `.ts` siblings).
28
+ * e.g. "vtex/loaders/legacy" × `productListingPage` →
29
+ * "vtex/loaders/legacy/productListingPage". Covers actions too.
30
+ *
31
+ * Usage (from the app package root):
32
+ * bun ../blocks-cli/scripts/generate-app-schemas.ts [options]
33
+ *
34
+ * Options:
35
+ * --namespace CMS namespace (default: derived from package.json name,
36
+ * "@decocms/apps-<ns>" → "<ns>")
37
+ * --pkg App package root (default: cwd)
38
+ * --out Output file (default: src/schemas.gen.ts, relative to --pkg)
39
+ */
40
+ import { type Symbol as MorphSymbol, Node, Project, type SourceFile, type Type } from "ts-morph";
41
+ import { getJsDocTags, typeToJsonSchema } from "./generate-schema";
42
+ import { isExcludedCodegenFile } from "./lib/codegenExclusions";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Types
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** Mirrors BlockPropsSchema in @decocms/blocks/cms — kept structural so this
49
+ * script has no runtime dependency on the blocks package. */
50
+ export interface AppBlockPropsSchema {
51
+ type?: "object";
52
+ title?: string;
53
+ description?: string;
54
+ properties?: Record<string, any>;
55
+ required?: string[];
56
+ additionalProperties?: boolean;
57
+ /** Any other JSON Schema keyword or JSDoc passthrough tag (nullable, …). */
58
+ [keyword: string]: any;
59
+ }
60
+
61
+ export interface AppSchemasResult {
62
+ loaders: Record<string, AppBlockPropsSchema>;
63
+ actions: Record<string, AppBlockPropsSchema>;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Schema extraction helpers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** Props whose declared type is any/unknown — the block DOES take input, we
71
+ * just can't describe it. The stub shape keeps the admin's JSON editor. */
72
+ const UNKNOWN_PROPS: AppBlockPropsSchema = { type: "object", additionalProperties: true };
73
+ /** The block takes no input at all. Deliberately WITHOUT additionalProperties
74
+ * so the admin can say "takes no input". */
75
+ const NO_PROPS: AppBlockPropsSchema = { type: "object", properties: {} };
76
+
77
+ /**
78
+ * Normalize whatever typeToJsonSchema produced into a props-object schema.
79
+ * Handlers are invoked with a single props object; a first parameter that
80
+ * isn't object-shaped (e.g. `getCategoryTree(levels: number)`) can't be
81
+ * described as a props form — fall back to the JSON-editor shape.
82
+ */
83
+ function toPropsSchema(schema: any): AppBlockPropsSchema {
84
+ if (!schema || typeof schema !== "object") return UNKNOWN_PROPS;
85
+ // any/unknown → {} from typeToJsonSchema
86
+ if (Object.keys(schema).length === 0) return UNKNOWN_PROPS;
87
+ if (schema.type === "object" && schema.properties) return schema;
88
+ // Record<string, T> → { type: "object", additionalProperties: <T> }
89
+ if (schema.type === "object" && schema.additionalProperties !== undefined) {
90
+ return { type: "object", additionalProperties: true };
91
+ }
92
+ if (schema.type === "object") return { ...schema, properties: schema.properties ?? {} };
93
+ return UNKNOWN_PROPS;
94
+ }
95
+
96
+ /** Schema for a function's first parameter (a loader/action handler). */
97
+ function functionInputSchema(fnType: Type, location: Node): AppBlockPropsSchema | null {
98
+ const callSigs = fnType.getCallSignatures();
99
+ if (callSigs.length === 0) return null;
100
+ const params = callSigs[0].getParameters();
101
+ if (params.length === 0) return NO_PROPS;
102
+ const paramType = params[0].getTypeAtLocation(location);
103
+ if (paramType.isAny() || paramType.isUnknown()) return UNKNOWN_PROPS;
104
+ return toPropsSchema(typeToJsonSchema(paramType));
105
+ }
106
+
107
+ /** Apply a symbol's JSDoc @title (slash-free only) and description. */
108
+ function applyDocMeta(schema: AppBlockPropsSchema, symbol: MorphSymbol): AppBlockPropsSchema {
109
+ const tags = getJsDocTags(symbol);
110
+ const title = tags.title && !tags.title.includes("/") ? tags.title : undefined;
111
+ const description = tags.description;
112
+ if (!title && !description) return schema;
113
+ return {
114
+ ...(title ? { title } : {}),
115
+ ...(description ? { description } : {}),
116
+ ...schema,
117
+ };
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Discovery 1: file-path-keyed loaders (src/loaders/**)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function walkTsFiles(dir: string): string[] {
125
+ const results: string[] = [];
126
+ if (!fs.existsSync(dir)) return results;
127
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
128
+ const full = path.join(dir, entry.name);
129
+ if (entry.isDirectory()) {
130
+ if (entry.name === "__tests__" || entry.name === "__test__") continue;
131
+ results.push(...walkTsFiles(full));
132
+ } else if (
133
+ !isExcludedCodegenFile(entry.name) &&
134
+ (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) &&
135
+ entry.name !== "index.ts" &&
136
+ !entry.name.startsWith("_")
137
+ ) {
138
+ results.push(full);
139
+ }
140
+ }
141
+ return results;
142
+ }
143
+
144
+ function extractFileLoaderSchema(sourceFile: SourceFile): AppBlockPropsSchema | null {
145
+ const defaultSym = sourceFile.getDefaultExportSymbol();
146
+ if (!defaultSym) return null; // barrel/utility module, not a loader file
147
+
148
+ // Same extraction order as generate-schema.ts's app-loaders pass: a named
149
+ // Props interface/alias wins over the default export's first parameter.
150
+ let schema: any = null;
151
+ const propsInterface = sourceFile.getInterface("Props");
152
+ if (propsInterface) schema = typeToJsonSchema(propsInterface.getType());
153
+
154
+ if (!schema) {
155
+ const propsAlias = sourceFile.getTypeAlias("Props");
156
+ if (propsAlias) schema = typeToJsonSchema(propsAlias.getType());
157
+ }
158
+
159
+ if (schema) return applyDocMeta(toPropsSchema(schema), defaultSym);
160
+
161
+ const fnSchema = functionInputSchema(defaultSym.getTypeAtLocation(sourceFile), sourceFile);
162
+ return fnSchema ? applyDocMeta(fnSchema, defaultSym) : NO_PROPS;
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Discovery 2: manifest-flattened keys (src/manifest.gen.ts)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /** moduleKey → absolute module file path, per manifest category. */
170
+ function parseManifestModules(manifestFile: SourceFile): {
171
+ loaders: Map<string, string>;
172
+ actions: Map<string, string>;
173
+ } {
174
+ const result = { loaders: new Map<string, string>(), actions: new Map<string, string>() };
175
+
176
+ // identifier → module specifier, from `import * as loaders_x from "./loaders/x"`
177
+ const importMap = new Map<string, string>();
178
+ for (const imp of manifestFile.getImportDeclarations()) {
179
+ const ns = imp.getNamespaceImport();
180
+ if (ns) importMap.set(ns.getText(), imp.getModuleSpecifierValue());
181
+ }
182
+
183
+ const manifestVar = manifestFile.getVariableDeclaration("manifest");
184
+ let init = manifestVar?.getInitializer();
185
+ while (init && Node.isAsExpression(init)) init = init.getExpression();
186
+ if (!init || !Node.isObjectLiteralExpression(init)) return result;
187
+
188
+ const manifestDir = path.dirname(manifestFile.getFilePath());
189
+ for (const category of ["loaders", "actions"] as const) {
190
+ const prop = init.getProperty(category);
191
+ if (!prop || !Node.isPropertyAssignment(prop)) continue;
192
+ const value = prop.getInitializer();
193
+ if (!value || !Node.isObjectLiteralExpression(value)) continue;
194
+
195
+ for (const entry of value.getProperties()) {
196
+ if (!Node.isPropertyAssignment(entry)) continue;
197
+ const keyNode = entry.getNameNode();
198
+ const moduleKey = Node.isStringLiteral(keyNode) ? keyNode.getLiteralValue() : entry.getName();
199
+ const ident = entry.getInitializer()?.getText();
200
+ const spec = ident ? importMap.get(ident) : undefined;
201
+ if (!spec?.startsWith(".")) continue;
202
+
203
+ const base = path.resolve(manifestDir, spec);
204
+ for (const candidate of [base, `${base}.ts`, `${base}.tsx`, path.join(base, "index.ts")]) {
205
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
206
+ result[category].set(moduleKey, candidate);
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ }
212
+ return result;
213
+ }
214
+
215
+ /** Flatten a manifest module's function exports, mirroring setupApps():
216
+ * default → moduleKey; named fn → `${moduleKey}/${fnName}`. */
217
+ function flattenModuleSchemas(
218
+ moduleKey: string,
219
+ sourceFile: SourceFile,
220
+ into: Record<string, AppBlockPropsSchema>,
221
+ ) {
222
+ for (const sym of sourceFile.getExportSymbols()) {
223
+ const name = sym.getName();
224
+ const fnType = sym.getTypeAtLocation(sourceFile);
225
+ const schema = functionInputSchema(fnType, sourceFile);
226
+ if (!schema) continue; // type/constant export, not a handler
227
+
228
+ const key = name === "default" ? moduleKey : `${moduleKey}/${name}`;
229
+ into[key] = applyDocMeta(schema, sym);
230
+ }
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Main generation
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /** Add the `.ts` sibling for every bare key and vice versa, both pointing at
238
+ * the same schema object — mirrors runtime alias registration. */
239
+ function withKeyAliases(
240
+ schemas: Record<string, AppBlockPropsSchema>,
241
+ ): Record<string, AppBlockPropsSchema> {
242
+ const out: Record<string, AppBlockPropsSchema> = {};
243
+ for (const [key, schema] of Object.entries(schemas)) {
244
+ const bare = key.endsWith(".ts") ? key.slice(0, -3) : key;
245
+ // Bare-key entry wins ties with a .ts entry for the same block — both
246
+ // describe the same file, so any difference is extraction noise.
247
+ out[bare] = out[bare] ?? schema;
248
+ out[`${bare}.ts`] = out[`${bare}.ts`] ?? schema;
249
+ }
250
+ return out;
251
+ }
252
+
253
+ export function generateAppSchemas(pkgDir: string, namespace: string): AppSchemasResult {
254
+ const project = new Project({
255
+ tsConfigFilePath: path.join(pkgDir, "tsconfig.json"),
256
+ skipAddingFilesFromTsConfig: true,
257
+ });
258
+
259
+ const loaders: Record<string, AppBlockPropsSchema> = {};
260
+ const actions: Record<string, AppBlockPropsSchema> = {};
261
+
262
+ // Manifest-flattened keys first; file-path keys second so the more
263
+ // path-accurate extraction wins any (same-file) collision.
264
+ const manifestPath = path.join(pkgDir, "src", "manifest.gen.ts");
265
+ if (fs.existsSync(manifestPath)) {
266
+ const manifestFile = project.addSourceFileAtPath(manifestPath);
267
+ const modules = parseManifestModules(manifestFile);
268
+ for (const [category, into] of [
269
+ ["loaders", loaders],
270
+ ["actions", actions],
271
+ ] as const) {
272
+ for (const [moduleKey, filePath] of modules[category]) {
273
+ try {
274
+ flattenModuleSchemas(moduleKey, project.addSourceFileAtPath(filePath), into);
275
+ } catch (e) {
276
+ console.warn(` ✗ manifest module ${moduleKey}: ${(e as Error).message}`);
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ const loadersDir = path.join(pkgDir, "src", "loaders");
283
+ for (const filePath of walkTsFiles(loadersDir)) {
284
+ const rel = path.relative(loadersDir, filePath).replaceAll("\\", "/");
285
+ const cmsKey = `${namespace}/loaders/${rel}`;
286
+ try {
287
+ const schema = extractFileLoaderSchema(project.addSourceFileAtPath(filePath));
288
+ if (schema) loaders[cmsKey] = schema;
289
+ } catch (e) {
290
+ console.warn(` ✗ loader ${cmsKey}: ${(e as Error).message}`);
291
+ }
292
+ }
293
+
294
+ return {
295
+ loaders: withKeyAliases(loaders),
296
+ actions: withKeyAliases(actions),
297
+ };
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Module rendering
302
+ // ---------------------------------------------------------------------------
303
+
304
+ /** Render the schemas.gen.ts module. Schemas are deduplicated: each unique
305
+ * schema is emitted once and shared by all keys aliasing it. */
306
+ export function renderSchemasModule(result: AppSchemasResult): string {
307
+ const constByJson = new Map<string, string>();
308
+ const constDecls: string[] = [];
309
+
310
+ const constFor = (schema: AppBlockPropsSchema): string => {
311
+ const json = JSON.stringify(schema);
312
+ let name = constByJson.get(json);
313
+ if (!name) {
314
+ name = `s${constByJson.size}`;
315
+ constByJson.set(json, name);
316
+ constDecls.push(`const ${name}: BlockPropsSchema = ${JSON.stringify(schema, null, "\t")};`);
317
+ }
318
+ return name;
319
+ };
320
+
321
+ const renderRecord = (schemas: Record<string, AppBlockPropsSchema>): string => {
322
+ const entries = Object.keys(schemas)
323
+ .sort()
324
+ .map((key) => `\t${JSON.stringify(key)}: ${constFor(schemas[key])},`);
325
+ return `{\n${entries.join("\n")}\n}`;
326
+ };
327
+
328
+ // Records must render before constDecls is final, but constDecls prints first.
329
+ const loadersRecord = renderRecord(result.loaders);
330
+ const actionsRecord = renderRecord(result.actions);
331
+
332
+ return [
333
+ "// AUTO-GENERATED by @decocms/blocks-cli scripts/generate-app-schemas.ts — DO NOT EDIT",
334
+ "// Regenerate from this package's root: bun run generate:schemas",
335
+ "//",
336
+ "// Real props schemas for this app's loaders/actions, keyed by CMS key",
337
+ "// (both bare and `.ts` forms). Registered into the admin meta via",
338
+ "// registerAppSchemas() so the Studio can render real props forms instead",
339
+ "// of the __resolveType-only stubs from registerCommerceLoaders().",
340
+ 'import type { BlockPropsSchema } from "@decocms/blocks/cms/client";',
341
+ "",
342
+ ...constDecls,
343
+ "",
344
+ `export const loaderSchemas: Record<string, BlockPropsSchema> = ${loadersRecord};`,
345
+ "",
346
+ `export const actionSchemas: Record<string, BlockPropsSchema> = ${actionsRecord};`,
347
+ "",
348
+ ].join("\n");
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // CLI entry
353
+ // ---------------------------------------------------------------------------
354
+
355
+ function isMainModule(): boolean {
356
+ const entry = process.argv[1];
357
+ if (!entry) return false;
358
+ try {
359
+ return fs.realpathSync(path.resolve(entry)) === fs.realpathSync(fileURLToPath(import.meta.url));
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+
365
+ if (isMainModule()) {
366
+ const argv = process.argv.slice(2);
367
+ const arg = (name: string, fallback: string): string => {
368
+ const idx = argv.indexOf(`--${name}`);
369
+ return idx !== -1 && argv[idx + 1] ? argv[idx + 1] : fallback;
370
+ };
371
+
372
+ const pkgDir = path.resolve(arg("pkg", process.cwd()));
373
+ const pkgJsonPath = path.join(pkgDir, "package.json");
374
+ const pkgName: string = fs.existsSync(pkgJsonPath)
375
+ ? (JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")).name ?? "")
376
+ : "";
377
+ const defaultNamespace = pkgName.match(/^@decocms\/apps-(.+)$/)?.[1] ?? "";
378
+ const namespace = arg("namespace", defaultNamespace);
379
+ if (!namespace) {
380
+ console.error(
381
+ "Could not derive --namespace from package.json (expected @decocms/apps-<ns>); pass --namespace explicitly.",
382
+ );
383
+ process.exit(1);
384
+ }
385
+
386
+ const outPath = path.resolve(pkgDir, arg("out", "src/schemas.gen.ts"));
387
+ const result = generateAppSchemas(pkgDir, namespace);
388
+ fs.writeFileSync(outPath, renderSchemasModule(result));
389
+
390
+ const count = (r: Record<string, unknown>) => Object.keys(r).length;
391
+ console.log(
392
+ `Generated ${count(result.loaders)} loader keys, ${count(result.actions)} action keys → ${path.relative(pkgDir, outPath)}`,
393
+ );
394
+ }
@@ -284,6 +284,23 @@ const REACT_INTERNAL_PROPS = new Set([
284
284
  "_store",
285
285
  ]);
286
286
 
287
+ // Platform types injected at runtime, never configured through the CMS form.
288
+ // Collapsed to a hidden object in typeToJsonSchema (guarded by "is it really
289
+ // the lib declaration" so same-named site types still expand).
290
+ const RUNTIME_INJECTED_TYPES = new Set([
291
+ "URL",
292
+ "URLSearchParams",
293
+ "Request",
294
+ "Response",
295
+ "Headers",
296
+ "AbortSignal",
297
+ "ReadableStream",
298
+ "WritableStream",
299
+ "Blob",
300
+ "File",
301
+ "FormData",
302
+ ]);
303
+
287
304
  interface GenerationContext {
288
305
  outputTypeToLoaderKeys: Map<string, string[]>;
289
306
  }
@@ -409,6 +426,18 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
409
426
  }
410
427
 
411
428
  if (type.isObject() || type.isInterface()) {
429
+ // Runtime-injected platform types (loader `url: URL`, `req: Request`, …)
430
+ // are not CMS-configurable — hide instead of expanding the whole DOM
431
+ // interface into the form. Only collapse the real lib types: a site
432
+ // interface merely NAMED `Request` should still be expanded.
433
+ const symName = type.getSymbol()?.getName();
434
+ if (symName && RUNTIME_INJECTED_TYPES.has(symName)) {
435
+ const declFile = type.getSymbol()?.getDeclarations()?.[0]?.getSourceFile().getFilePath();
436
+ if (declFile?.includes("typescript/lib/") || declFile?.includes("@types/")) {
437
+ return { type: "object", hide: "true" };
438
+ }
439
+ }
440
+
412
441
  // Record<K,V> → { type: "object", additionalProperties: V-schema }
413
442
  const stringIdx = type.getStringIndexType();
414
443
  const numberIdx = type.getNumberIndexType();
@@ -435,6 +464,12 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
435
464
  if (!decl) continue;
436
465
  const propType = prop.getTypeAtLocation(decl);
437
466
 
467
+ // Methods (e.g. URL#toString on platform-ish objects) aren't data —
468
+ // a pure function type has no place in a props form.
469
+ if (propType.getCallSignatures().length > 0 && propType.getProperties().length === 0) {
470
+ continue;
471
+ }
472
+
438
473
  const tags = getJsDocTags(prop);
439
474
  if (tags.ignore) continue;
440
475
 
@@ -527,7 +562,9 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
527
562
  if (!schema.title) schema.title = name.charAt(0).toUpperCase() + name.slice(1);
528
563
 
529
564
  properties[name] = schema;
530
- if (!prop.isOptional()) required.push(name);
565
+ // A hidden prop (runtime-injected type, ReactNode, @hide) can never be
566
+ // filled in by the CMS user — requiring it would deadlock the form.
567
+ if (!prop.isOptional() && schema.hide !== "true") required.push(name);
531
568
  }
532
569
 
533
570
  const result: any = { type: "object", properties };
@@ -548,7 +585,7 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
548
585
  }
549
586
  }
550
587
 
551
- function getJsDocTags(symbol: MorphSymbol): Record<string, string> {
588
+ export function getJsDocTags(symbol: MorphSymbol): Record<string, string> {
552
589
  const tags: Record<string, string> = {};
553
590
  for (const decl of symbol.getDeclarations()) {
554
591
  const jsDocs = Node.isJSDocable(decl) ? decl.getJsDocs() : [];
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { MigrationContext } from "../types";
3
+ import { createContext } from "../types";
4
+ import { generateRoutes } from "./routes";
5
+
6
+ function makeCtx(platform: MigrationContext["platform"]): MigrationContext {
7
+ const ctx = createContext("/tmp/routes-template-fixture-site");
8
+ ctx.siteName = "acme-storefront";
9
+ ctx.platform = platform;
10
+ ctx.vtexAccount = platform === "vtex" ? "acme" : null;
11
+ return ctx;
12
+ }
13
+
14
+ /**
15
+ * Regression guard: the scaffolded deco admin route files must use the
16
+ * dev-HMR-safe `*RouteConfig()` factories — the only form @decocms/tanstack
17
+ * exports since 7.10.0 — never the removed pre-7.10.0 module-scope literals
18
+ * passed by reference. router-core's `update()` mutates the options object it
19
+ * receives (injects id/path); a shared literal got polluted on first
20
+ * execution, and any dev-HMR re-execution of the route file then threw
21
+ * "Route cannot have both an 'id' and a 'path' option", 500ing every route
22
+ * until the dev server restarted.
23
+ */
24
+ describe("scaffolded deco admin routes use HMR-safe factories", () => {
25
+ const routeCases = [
26
+ { file: "src/routes/deco/meta.ts", factory: "decoMetaRouteConfig", literal: "decoMetaRoute" },
27
+ {
28
+ file: "src/routes/deco/render.ts",
29
+ factory: "decoRenderRouteConfig",
30
+ literal: "decoRenderRoute",
31
+ },
32
+ {
33
+ file: "src/routes/deco/invoke.$.ts",
34
+ factory: "decoInvokeRouteConfig",
35
+ literal: "decoInvokeRoute",
36
+ },
37
+ ] as const;
38
+
39
+ for (const platform of ["vtex", "custom"] as const) {
40
+ describe(`platform: ${platform}`, () => {
41
+ const files = generateRoutes(makeCtx(platform));
42
+
43
+ for (const { file, factory, literal } of routeCases) {
44
+ it(`${file} calls ${factory}() and never passes ${literal} by reference`, () => {
45
+ const content = files[file];
46
+ expect(content, `${file} must be emitted`).toBeTypeOf("string");
47
+
48
+ // Factory form: createFileRoute("...")(decoXRouteConfig())
49
+ expect(content).toContain(`${factory}()`);
50
+ expect(content).toContain(`import { ${factory} } from "@decocms/tanstack"`);
51
+
52
+ // Forbidden form: createFileRoute("...")(decoXRoute) — shared
53
+ // literal by reference. `(?!Config)` keeps the factory call legal.
54
+ expect(content).not.toMatch(new RegExp(`\\)\\(${literal}(?!Config)\\s*\\)`));
55
+ });
56
+ }
57
+ });
58
+ }
59
+ });
@@ -210,26 +210,34 @@ function NotFoundPage() {
210
210
  `;
211
211
  }
212
212
 
213
+ // The deco admin routes use the `*RouteConfig()` factories — the only form
214
+ // @decocms/tanstack exports since 7.10.0. The pre-7.10.0 module-scope
215
+ // literals (decoMetaRoute/decoRenderRoute/decoInvokeRoute) were removed:
216
+ // passed by reference, router-core's update() mutated the shared object
217
+ // (injecting id/path), and any dev-HMR re-execution of the route file then
218
+ // threw "Route cannot have both an 'id' and a 'path' option", 500ing every
219
+ // route until dev restart.
220
+
213
221
  function generateDecoMeta(): string {
214
222
  return `import { createFileRoute } from "@tanstack/react-router";
215
- import { decoMetaRoute } from "@decocms/tanstack";
223
+ import { decoMetaRouteConfig } from "@decocms/tanstack";
216
224
 
217
- export const Route = createFileRoute("/deco/meta")(decoMetaRoute);
225
+ export const Route = createFileRoute("/deco/meta")(decoMetaRouteConfig());
218
226
  `;
219
227
  }
220
228
 
221
229
  function generateDecoInvoke(): string {
222
230
  return `import { createFileRoute } from "@tanstack/react-router";
223
- import { decoInvokeRoute } from "@decocms/tanstack";
231
+ import { decoInvokeRouteConfig } from "@decocms/tanstack";
224
232
 
225
- export const Route = createFileRoute("/deco/invoke/$")(decoInvokeRoute);
233
+ export const Route = createFileRoute("/deco/invoke/$")(decoInvokeRouteConfig());
226
234
  `;
227
235
  }
228
236
 
229
237
  function generateDecoRender(): string {
230
238
  return `import { createFileRoute } from "@tanstack/react-router";
231
- import { decoRenderRoute } from "@decocms/tanstack";
239
+ import { decoRenderRouteConfig } from "@decocms/tanstack";
232
240
 
233
- export const Route = createFileRoute("/deco/render")(decoRenderRoute);
241
+ export const Route = createFileRoute("/deco/render")(decoRenderRouteConfig());
234
242
  `;
235
243
  }