@decocms/blocks-cli 7.8.0 → 7.10.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.8.0",
3
+ "version": "7.10.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,7 +19,9 @@
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",
24
+ "./generate-blocks-manifest": "./scripts/generate-blocks-manifest.ts",
23
25
  "./generate-schema": "./scripts/generate-schema.ts",
24
26
  "./generate-invoke": "./scripts/generate-invoke.ts",
25
27
  "./migrate": "./scripts/migrate.ts",
@@ -34,7 +36,7 @@
34
36
  "lint:unused": "knip"
35
37
  },
36
38
  "dependencies": {
37
- "@decocms/blocks": "7.8.0",
39
+ "@decocms/blocks": "7.10.0",
38
40
  "ts-morph": "^27.0.0",
39
41
  "tsx": "^4.22.5"
40
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
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Integration test for `generate-blocks-manifest.ts`.
3
+ *
4
+ * Drives the programmatic entry against a tmp fixture of hostile-but-real
5
+ * block filenames (the shapes found in production `.deco/blocks` corpora:
6
+ * double-encoded `%2520`, single-encoded `%20`, parentheses, encoded slashes
7
+ * `%2F`, raw UTF-8), then verifies the emitted module the way a site would
8
+ * consume it: keys verbatim, `tsc` accepts it, and importing it (via a tsx
9
+ * child process from the fixture dir, resolving the raw-filename JSON import
10
+ * specifiers against the real files on disk) yields the parsed contents.
11
+ */
12
+ import * as cp from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
17
+ import { generateBlocksManifest } from "./generate-blocks-manifest";
18
+
19
+ const SCRIPT = path.resolve(__dirname, "generate-blocks-manifest.ts");
20
+
21
+ const HOSTILE_FIXTURE: Record<string, unknown> = {
22
+ "pages-PDP%2520Box-102215": { path: "/pdp-box", encoded: "double" },
23
+ "pages-Home%20(principal)-287364": { path: "/", parens: true },
24
+ "collections%2Fblog%2Fauthors%2Fx": { nested: "encoded-slash" },
25
+ "pages-Calçados-42": { utf8: "çãé" },
26
+ };
27
+
28
+ describe("generate-blocks-manifest", () => {
29
+ let tmpDir: string;
30
+ let blocksDir: string;
31
+ let outFile: string;
32
+
33
+ beforeEach(() => {
34
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-blocks-manifest-"));
35
+ blocksDir = path.join(tmpDir, ".deco", "blocks");
36
+ outFile = path.join(tmpDir, ".deco", "blocksManifest.gen.ts");
37
+ fs.mkdirSync(blocksDir, { recursive: true });
38
+ });
39
+
40
+ afterEach(() => {
41
+ fs.rmSync(tmpDir, { recursive: true, force: true });
42
+ });
43
+
44
+ const writeFixture = (fixture: Record<string, unknown> = HOSTILE_FIXTURE) => {
45
+ for (const [key, value] of Object.entries(fixture)) {
46
+ fs.writeFileSync(path.join(blocksDir, `${key}.json`), JSON.stringify(value));
47
+ }
48
+ };
49
+
50
+ it("emits verbatim keys and raw-filename import specifiers for hostile names", async () => {
51
+ writeFixture();
52
+ const result = await generateBlocksManifest({ blocksDir, outFile, silent: true });
53
+ expect(result.count).toBe(4);
54
+ expect(result.empty).toBe(false);
55
+ expect(result.written).toBe(true);
56
+
57
+ const emitted = fs.readFileSync(outFile, "utf-8");
58
+ for (const key of Object.keys(HOSTILE_FIXTURE)) {
59
+ // Key: filename minus .json, verbatim — no decoding of %2520/%20/%2F.
60
+ expect(emitted).toContain(`${JSON.stringify(key)}: `);
61
+ // Specifier: the raw on-disk filename, relative to the emitted module.
62
+ expect(emitted).toContain(`from ${JSON.stringify(`./blocks/${key}.json`)};`);
63
+ }
64
+ // Never URL-decoded anywhere in the module.
65
+ expect(emitted).not.toContain("pages-PDP%20Box");
66
+ expect(emitted).not.toContain("pages-Home (principal)");
67
+ expect(emitted).not.toContain("collections/blog");
68
+ });
69
+
70
+ it("compiles under tsc (module esnext, bundler resolution, resolveJsonModule)", async () => {
71
+ writeFixture();
72
+ await generateBlocksManifest({ blocksDir, outFile, silent: true });
73
+
74
+ const r = cp.spawnSync(
75
+ "npx",
76
+ [
77
+ "tsc",
78
+ "--noEmit",
79
+ "--strict",
80
+ "--module",
81
+ "esnext",
82
+ "--moduleResolution",
83
+ "bundler",
84
+ "--target",
85
+ "es2022",
86
+ "--resolveJsonModule",
87
+ "--skipLibCheck",
88
+ outFile,
89
+ ],
90
+ { encoding: "utf8" },
91
+ );
92
+ expect(r.stdout + r.stderr).not.toMatch(/error TS/);
93
+ expect(r.status).toBe(0);
94
+ }, 30_000);
95
+
96
+ it("importing the emitted module (tsx, from the fixture dir) yields the parsed contents", async () => {
97
+ writeFixture();
98
+ await generateBlocksManifest({ blocksDir, outFile, silent: true });
99
+
100
+ const runner = path.join(tmpDir, ".deco", "runner.ts");
101
+ fs.writeFileSync(
102
+ runner,
103
+ 'import blocks from "./blocksManifest.gen";\nconsole.log(JSON.stringify(blocks));\n',
104
+ );
105
+ const r = cp.spawnSync("npx", ["tsx", runner], { encoding: "utf8", cwd: tmpDir });
106
+ expect(r.stderr).toBe("");
107
+ expect(r.status).toBe(0);
108
+ expect(JSON.parse(r.stdout)).toEqual(HOSTILE_FIXTURE);
109
+ }, 30_000);
110
+
111
+ it("is idempotent: regeneration over an unchanged block set writes nothing", async () => {
112
+ writeFixture();
113
+ const first = await generateBlocksManifest({ blocksDir, outFile, silent: true });
114
+ expect(first.written).toBe(true);
115
+ const emittedFirst = fs.readFileSync(outFile, "utf-8");
116
+
117
+ const second = await generateBlocksManifest({ blocksDir, outFile, silent: true });
118
+ expect(second.written).toBe(false);
119
+ expect(fs.readFileSync(outFile, "utf-8")).toBe(emittedFirst);
120
+ });
121
+
122
+ it("orders imports deterministically regardless of readdir order (sorted filenames)", async () => {
123
+ writeFixture({ "z-last": { z: 1 }, "a-first": { a: 1 }, "m-mid": { m: 1 } });
124
+ await generateBlocksManifest({ blocksDir, outFile, silent: true });
125
+
126
+ const emitted = fs.readFileSync(outFile, "utf-8");
127
+ const importOrder = [...emitted.matchAll(/from "\.\/blocks\/([^"]+)\.json";/g)].map(
128
+ (m) => m[1],
129
+ );
130
+ expect(importOrder).toEqual(["a-first", "m-mid", "z-last"]);
131
+ });
132
+
133
+ it("ignores non-json entries and subdirectories (top-level *.json only)", async () => {
134
+ writeFixture({ real: { ok: true } });
135
+ fs.writeFileSync(path.join(blocksDir, "notes.txt"), "not a block");
136
+ fs.mkdirSync(path.join(blocksDir, "nested"));
137
+ fs.writeFileSync(path.join(blocksDir, "nested", "deep.json"), "{}");
138
+
139
+ const result = await generateBlocksManifest({ blocksDir, outFile, silent: true });
140
+ expect(result.count).toBe(1);
141
+ const emitted = fs.readFileSync(outFile, "utf-8");
142
+ expect(emitted).toContain('"real": _b0');
143
+ expect(emitted).not.toContain("notes.txt");
144
+ expect(emitted).not.toContain("deep.json");
145
+ });
146
+
147
+ it("emits an empty manifest when the blocks dir is missing", async () => {
148
+ fs.rmSync(blocksDir, { recursive: true, force: true });
149
+ const result = await generateBlocksManifest({ blocksDir, outFile, silent: true });
150
+ expect(result.count).toBe(0);
151
+ expect(result.empty).toBe(true);
152
+
153
+ const emitted = fs.readFileSync(outFile, "utf-8");
154
+ expect(emitted).toContain("const blocks: Record<string, unknown> = {");
155
+ expect(emitted).toContain("export default blocks;");
156
+ expect(emitted).not.toContain("import _b");
157
+ });
158
+
159
+ it("never lets a generated comment contain the block-comment terminator", async () => {
160
+ // Filenames land ONLY inside JSON.stringify-quoted string literals — a
161
+ // name containing `*` + `/` must not be able to truncate any comment in
162
+ // the emitted module (past incident with interpolated doc comments).
163
+ writeFixture({ "weird-*∕name": { ok: true }, "star*": { s: 1 } });
164
+ await generateBlocksManifest({ blocksDir, outFile, silent: true });
165
+
166
+ const emitted = fs.readFileSync(outFile, "utf-8");
167
+ const commentLines = emitted.split("\n").filter((l) => l.startsWith("//"));
168
+ for (const line of commentLines) {
169
+ expect(line).not.toContain("*/");
170
+ expect(line).not.toContain("weird");
171
+ }
172
+ });
173
+
174
+ it("runs as a CLI with --blocks-dir/--out-file overrides", () => {
175
+ writeFixture({ "cli-block": { via: "cli" } });
176
+ const cliOut = path.join(tmpDir, "custom", "manifest.gen.ts");
177
+
178
+ const r = cp.spawnSync(
179
+ "npx",
180
+ ["tsx", SCRIPT, "--blocks-dir", blocksDir, "--out-file", cliOut],
181
+ { encoding: "utf8", cwd: tmpDir },
182
+ );
183
+ expect(r.status).toBe(0);
184
+ expect(r.stdout).toContain("Generated static-import manifest for 1 blocks");
185
+
186
+ const emitted = fs.readFileSync(cliOut, "utf-8");
187
+ expect(emitted).toContain('"cli-block": _b0');
188
+ // Specifier is relative to the out file's own directory.
189
+ expect(emitted).toContain('from "../.deco/blocks/cli-block.json";');
190
+ }, 30_000);
191
+ });
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Reads .deco/blocks/*.json and emits blocksManifest.gen.ts — a module that
4
+ * STATICALLY imports every block file and re-exports them as one
5
+ * `Record<string, unknown>` keyed exactly like
6
+ * `@decocms/blocks/cms/loadDecofileDirectory`: filename minus the `.json`
7
+ * extension, verbatim (no URL-decoding, no renaming — the `pages-` filename
8
+ * prefix on page blocks is load-bearing, see loadDecofileDirectory's doc
9
+ * comment).
10
+ *
11
+ * Why static imports instead of the runtime fs read: a plain
12
+ * `loadDecofileDirectory(".deco/blocks")` is invisible to the bundler, so in
13
+ * `next dev` editing a block JSON invalidates nothing (no CMS content
14
+ * hot-reload) and production deploys need `outputFileTracingIncludes` hacks
15
+ * to ship the directory. With the manifest, Next's own module graph owns the
16
+ * files: editing an imported JSON re-evaluates the server module graph
17
+ * (~120–165ms measured), which also resets module-scope memos like
18
+ * `createNextSetup`'s bootstrap cache — content edits reload naturally, and
19
+ * the JSON is bundled into the build output. The trade-off: adding or
20
+ * removing a block FILE requires re-running this generator (content edits do
21
+ * not), so wire it into the site's `generate` chain.
22
+ *
23
+ * Import specifiers use the RAW on-disk filename. Verified against webpack,
24
+ * Turbopack, and Vite: specifiers are opaque strings to all three — `%2520`,
25
+ * `%20`, parentheses, `%C3%A7`, `%2F` etc. pass through verbatim and nothing
26
+ * URL-decodes, so JSON.stringify-quoting the specifier (and the key) is all
27
+ * the escaping needed.
28
+ *
29
+ * Usage (from site root):
30
+ * npx tsx node_modules/@decocms/blocks-cli/scripts/generate-blocks-manifest.ts
31
+ *
32
+ * CLI:
33
+ * --blocks-dir override input (default: .deco/blocks)
34
+ * --out-file override output (default: .deco/blocksManifest.gen.ts)
35
+ *
36
+ * Programmatic:
37
+ * import { generateBlocksManifest } from "@decocms/blocks-cli/generate-blocks-manifest";
38
+ * await generateBlocksManifest({ blocksDir, outFile });
39
+ */
40
+ import fs from "node:fs";
41
+ import path from "node:path";
42
+
43
+ // Header of the emitted module. Kept as line comments and deliberately free
44
+ // of interpolated filenames: block filenames can contain almost any
45
+ // character, and a filename containing the sequence `*` + `/` inside a
46
+ // generated /** ... */ block would terminate the comment early and corrupt
47
+ // the module (a past incident). Filenames only ever appear inside
48
+ // JSON.stringify-quoted string literals below.
49
+ const HEADER = [
50
+ "// Auto-generated by @decocms/blocks-cli/scripts/generate-blocks-manifest.ts — do not edit.",
51
+ "//",
52
+ "// Static-import manifest of every JSON decofile in the blocks directory,",
53
+ "// keyed by filename minus the .json extension, VERBATIM — the same key",
54
+ "// format @decocms/blocks/cms/loadDecofileDirectory produces (page blocks",
55
+ "// keep their load-bearing `pages-` filename prefix).",
56
+ "//",
57
+ "// Because every block file is a static import, the bundler's module graph",
58
+ "// owns them: in `next dev`, editing a block JSON re-evaluates the server",
59
+ "// module graph (CMS content hot-reload), and production builds bundle the",
60
+ "// content (no outputFileTracingIncludes needed). Adding or removing a",
61
+ "// block FILE requires re-running the generator; editing content does not.",
62
+ "//",
63
+ "// Regenerate:",
64
+ "// npx tsx node_modules/@decocms/blocks-cli/scripts/generate-blocks-manifest.ts",
65
+ "",
66
+ ].join("\n");
67
+
68
+ export interface GenerateBlocksManifestOptions {
69
+ blocksDir: string;
70
+ outFile: string;
71
+ /** Suppress the per-run summary log. Defaults to false. */
72
+ silent?: boolean;
73
+ }
74
+
75
+ export interface GenerateBlocksManifestResult {
76
+ count: number;
77
+ outFile: string;
78
+ /** True when the blocks dir was missing and an empty manifest was emitted. */
79
+ empty: boolean;
80
+ /**
81
+ * True when the manifest on disk actually changed. Regeneration is
82
+ * idempotent: an unchanged block set rewrites nothing, so watchers (and
83
+ * Next's module graph) are not tickled by no-op runs.
84
+ */
85
+ written: boolean;
86
+ }
87
+
88
+ function renderManifest(blocksDir: string, outFile: string, files: string[]): string {
89
+ const lines: string[] = [HEADER];
90
+
91
+ const sorted = [...files].sort(); // code-unit sort — locale-independent, diff-stable
92
+
93
+ for (let i = 0; i < sorted.length; i++) {
94
+ // Import specifier: the RAW filename, relative to the emitted module.
95
+ // JSON.stringify provides all necessary escaping — bundlers do not
96
+ // URL-decode specifiers, so no percent-escaping/normalizing here.
97
+ let spec = path
98
+ .relative(path.dirname(outFile), path.join(blocksDir, sorted[i]))
99
+ .replace(/\\/g, "/");
100
+ if (!spec.startsWith(".")) spec = `./${spec}`;
101
+ lines.push(`import _b${i} from ${JSON.stringify(spec)};`);
102
+ }
103
+
104
+ lines.push("");
105
+ lines.push("const blocks: Record<string, unknown> = {");
106
+ for (let i = 0; i < sorted.length; i++) {
107
+ const key = sorted[i].slice(0, -".json".length);
108
+ lines.push(` ${JSON.stringify(key)}: _b${i},`);
109
+ }
110
+ lines.push("};");
111
+ lines.push("");
112
+ lines.push("export default blocks;");
113
+ lines.push("");
114
+
115
+ return lines.join("\n");
116
+ }
117
+
118
+ async function writeIfChanged(outFile: string, content: string): Promise<boolean> {
119
+ let existing: string | undefined;
120
+ try {
121
+ existing = await fs.promises.readFile(outFile, "utf-8");
122
+ } catch {}
123
+ if (existing === content) return false;
124
+ await fs.promises.mkdir(path.dirname(outFile), { recursive: true });
125
+ await fs.promises.writeFile(outFile, content);
126
+ return true;
127
+ }
128
+
129
+ export async function generateBlocksManifest(
130
+ options: GenerateBlocksManifestOptions,
131
+ ): Promise<GenerateBlocksManifestResult> {
132
+ const blocksDir = path.resolve(options.blocksDir);
133
+ const outFile = path.resolve(options.outFile);
134
+ const silent = options.silent ?? false;
135
+
136
+ if (!fs.existsSync(blocksDir)) {
137
+ if (!silent) {
138
+ console.warn(`Blocks directory not found: ${blocksDir} — generating empty manifest.`);
139
+ }
140
+ const written = await writeIfChanged(outFile, renderManifest(blocksDir, outFile, []));
141
+ return { count: 0, outFile, empty: true, written };
142
+ }
143
+
144
+ // Top-level *.json files only — mirrors loadDecofileDirectory, which
145
+ // filters `entry.isFile()` and never recurses (nested paths live encoded
146
+ // in the FILENAME, e.g. `collections%2Fblog%2F....json`).
147
+ const files = (await fs.promises.readdir(blocksDir, { withFileTypes: true }))
148
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
149
+ .map((entry) => entry.name);
150
+
151
+ const written = await writeIfChanged(outFile, renderManifest(blocksDir, outFile, files));
152
+
153
+ if (!silent) {
154
+ console.log(
155
+ `Generated static-import manifest for ${files.length} blocks → ` +
156
+ `${path.relative(process.cwd(), outFile)}${written ? "" : " (unchanged)"}`,
157
+ );
158
+ }
159
+
160
+ return { count: files.length, outFile, empty: false, written };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // CLI shim — same pattern as generate-blocks.ts.
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function isMainModule(): boolean {
168
+ // tsx/node ESM: import.meta.url matches process.argv[1] when invoked directly.
169
+ // Use a forgiving comparison so it works under both `tsx script.ts` and
170
+ // `node --import tsx script.ts`.
171
+ const entry = process.argv[1];
172
+ if (!entry) return false;
173
+ try {
174
+ const entryUrl = new URL(`file://${path.resolve(entry)}`).href;
175
+ return import.meta.url === entryUrl;
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ if (isMainModule()) {
182
+ const args = process.argv.slice(2);
183
+ const arg = (name: string, fallback: string): string => {
184
+ const idx = args.indexOf(`--${name}`);
185
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
186
+ };
187
+
188
+ const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks"));
189
+ const outFile = path.resolve(process.cwd(), arg("out-file", ".deco/blocksManifest.gen.ts"));
190
+
191
+ generateBlocksManifest({ blocksDir, outFile }).catch((err) => {
192
+ console.error(err);
193
+ process.exit(1);
194
+ });
195
+ }
@@ -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() : [];