@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.
|
|
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.
|
|
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
|
-
|
|
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() : [];
|