@decocms/blocks-cli 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +44 -0
- package/scripts/analyze-traces.mjs +1117 -0
- package/scripts/audit-observability-config.test.ts +446 -0
- package/scripts/audit-observability-config.ts +511 -0
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/fast-deploy-kv.test.ts +131 -0
- package/scripts/generate-blocks.test.ts +94 -0
- package/scripts/generate-blocks.ts +274 -0
- package/scripts/generate-invoke.test.ts +195 -0
- package/scripts/generate-invoke.ts +469 -0
- package/scripts/generate-loaders.ts +217 -0
- package/scripts/generate-schema.ts +1287 -0
- package/scripts/generate-sections.ts +237 -0
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +142 -0
- package/scripts/lib/cf-kv-rest.ts +78 -0
- package/scripts/lib/jsonc.ts +122 -0
- package/scripts/lib/kv-snapshot.ts +51 -0
- package/scripts/lib/read-decofile.ts +70 -0
- package/scripts/lib/sync-helpers.ts +44 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/analyzers/island-classifier.ts +96 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +147 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +782 -0
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/phase-cleanup.test.ts +141 -0
- package/scripts/migrate/phase-cleanup.ts +1588 -0
- package/scripts/migrate/phase-compile.test.ts +193 -0
- package/scripts/migrate/phase-compile.ts +177 -0
- package/scripts/migrate/phase-report.ts +243 -0
- package/scripts/migrate/phase-scaffold.ts +593 -0
- package/scripts/migrate/phase-transform.ts +310 -0
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +572 -0
- package/scripts/migrate/post-cleanup/rules.ts +1708 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate/templates/app-css.ts +366 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +230 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
- package/scripts/migrate/templates/hooks.test.ts +141 -0
- package/scripts/migrate/templates/hooks.ts +152 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/lib-utils.test.ts +139 -0
- package/scripts/migrate/templates/lib-utils.ts +326 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +177 -0
- package/scripts/migrate/templates/routes.ts +237 -0
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +456 -0
- package/scripts/migrate/templates/server-entry.ts +561 -0
- package/scripts/migrate/templates/setup.ts +148 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/types-gen.ts +174 -0
- package/scripts/migrate/templates/ui-components.ts +144 -0
- package/scripts/migrate/templates/vite-config.ts +101 -0
- package/scripts/migrate/transforms/dead-code.ts +455 -0
- package/scripts/migrate/transforms/deno-isms.ts +85 -0
- package/scripts/migrate/transforms/fresh-apis.ts +223 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
- package/scripts/migrate/transforms/imports.ts +385 -0
- package/scripts/migrate/transforms/jsx.ts +317 -0
- package/scripts/migrate/transforms/section-conventions.ts +210 -0
- package/scripts/migrate/transforms/tailwind.ts +739 -0
- package/scripts/migrate/types.ts +244 -0
- package/scripts/migrate-blocks-to-kv.ts +104 -0
- package/scripts/migrate-post-cleanup.ts +191 -0
- package/scripts/migrate-to-cf-observability.test.ts +215 -0
- package/scripts/migrate-to-cf-observability.ts +699 -0
- package/scripts/migrate.ts +282 -0
- package/scripts/smoke-otlp-errorlog.ts +46 -0
- package/scripts/smoke-otlp-meter.ts +48 -0
- package/scripts/sync-blocks-to-kv.ts +153 -0
- package/scripts/tailwind-lint.ts +518 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Schema Generator for deco admin compatibility.
|
|
6
|
+
*
|
|
7
|
+
* Scans src/sections/, src/loaders/, and src/apps/ for TypeScript files, parses
|
|
8
|
+
* their Props interfaces, and generates JSON Schema 7 definitions in the format
|
|
9
|
+
* expected by the deco admin (/deco/meta endpoint).
|
|
10
|
+
*
|
|
11
|
+
* Usage (from site root):
|
|
12
|
+
* npx tsx node_modules/@decocms/blocks-cli/scripts/generate-schema.ts [options]
|
|
13
|
+
*
|
|
14
|
+
* Options:
|
|
15
|
+
* --namespace Section namespace (default: "site")
|
|
16
|
+
* --site Site name (default: "storefront")
|
|
17
|
+
* --version Framework version (default: "1.0.0")
|
|
18
|
+
* --sections Sections directory (default: "src/sections")
|
|
19
|
+
* --loaders Loaders directory (default: "src/loaders")
|
|
20
|
+
* --apps Apps directory (default: "src/apps")
|
|
21
|
+
* --skip-apps Skip app schema generation
|
|
22
|
+
* --out Output file (default: "src/server/admin/meta.gen.json")
|
|
23
|
+
* --platform Platform name (default: "cloudflare")
|
|
24
|
+
*/
|
|
25
|
+
import {
|
|
26
|
+
type Symbol as MorphSymbol,
|
|
27
|
+
Node,
|
|
28
|
+
Project,
|
|
29
|
+
type SourceFile,
|
|
30
|
+
SyntaxKind,
|
|
31
|
+
type Type,
|
|
32
|
+
} from "ts-morph";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// CLI arg parsing
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
const argv = process.argv.slice(2);
|
|
38
|
+
function arg(name: string, fallback: string): string {
|
|
39
|
+
const idx = argv.indexOf(`--${name}`);
|
|
40
|
+
return idx !== -1 && argv[idx + 1] ? argv[idx + 1] : fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SITE_NAMESPACE = arg("namespace", "site");
|
|
44
|
+
const SITE_NAME = arg("site", "storefront");
|
|
45
|
+
const FRAMEWORK_VERSION = arg("version", "1.0.0");
|
|
46
|
+
const SECTIONS_REL = arg("sections", "src/sections");
|
|
47
|
+
const LOADERS_REL = arg("loaders", "src/loaders");
|
|
48
|
+
const APPS_REL = arg("apps", "src/apps");
|
|
49
|
+
const SKIP_APPS = argv.includes("--skip-apps");
|
|
50
|
+
const OUT_REL = arg("out", "src/server/admin/meta.gen.json");
|
|
51
|
+
const PLATFORM = arg("platform", "cloudflare");
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Interfaces
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
interface MetaResponse {
|
|
57
|
+
major: number;
|
|
58
|
+
version: string;
|
|
59
|
+
namespace: string;
|
|
60
|
+
site: string;
|
|
61
|
+
manifest: { blocks: Record<string, Record<string, any>> };
|
|
62
|
+
schema: { definitions: Record<string, any>; root: Record<string, any> };
|
|
63
|
+
platform: string;
|
|
64
|
+
cloudProvider: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
function toBase64(str: string): string {
|
|
71
|
+
return Buffer.from(str).toString("base64");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Map JSDoc tags to JSON Schema 7 keywords.
|
|
76
|
+
* Supports all 20+ tags from deco-cx/deco.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* Tags that receive special type coercion (not just string passthrough).
|
|
80
|
+
* Matches the original deco-cx/deco parseJSDocAttribute behaviour.
|
|
81
|
+
*/
|
|
82
|
+
const NUMERIC_TAGS = new Set([
|
|
83
|
+
"maximum",
|
|
84
|
+
"minimum",
|
|
85
|
+
"exclusiveMaximum",
|
|
86
|
+
"exclusiveMinimum",
|
|
87
|
+
"multipleOf",
|
|
88
|
+
"maxLength",
|
|
89
|
+
"minLength",
|
|
90
|
+
"maxItems",
|
|
91
|
+
"minItems",
|
|
92
|
+
"maxProperties",
|
|
93
|
+
"minProperties",
|
|
94
|
+
]);
|
|
95
|
+
const BOOLEAN_TAGS = new Set(["readOnly", "writeOnly", "deprecated", "uniqueItems", "ignore"]);
|
|
96
|
+
|
|
97
|
+
function applyJsDocToSchema(schema: any, tags: Record<string, string>): void {
|
|
98
|
+
for (const [tag, value] of Object.entries(tags)) {
|
|
99
|
+
if (tag === "ignore") continue;
|
|
100
|
+
|
|
101
|
+
// Tags with special coercion
|
|
102
|
+
if (tag === "hide") {
|
|
103
|
+
schema.hide = "true";
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (tag === "default") {
|
|
108
|
+
if (value === "true") schema.default = true;
|
|
109
|
+
else if (value === "false") schema.default = false;
|
|
110
|
+
else if (value === "null") schema.default = null;
|
|
111
|
+
else if (!isNaN(Number(value)) && value.trim() !== "") schema.default = Number(value);
|
|
112
|
+
else {
|
|
113
|
+
try {
|
|
114
|
+
schema.default = JSON.parse(value);
|
|
115
|
+
} catch {
|
|
116
|
+
schema.default = value;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (tag === "examples") {
|
|
123
|
+
const lines = value
|
|
124
|
+
.split("\n")
|
|
125
|
+
.map((l) => l.trim())
|
|
126
|
+
.filter(Boolean);
|
|
127
|
+
schema.examples =
|
|
128
|
+
lines.length > 1
|
|
129
|
+
? lines
|
|
130
|
+
: (() => {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(value);
|
|
133
|
+
} catch {
|
|
134
|
+
return [value];
|
|
135
|
+
}
|
|
136
|
+
})();
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (NUMERIC_TAGS.has(tag)) {
|
|
141
|
+
schema[tag] = Number(value);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (BOOLEAN_TAGS.has(tag)) {
|
|
145
|
+
schema[tag] = value === "true";
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Everything else: pass through as-is (matching original deco behaviour)
|
|
150
|
+
// Covers: title, description, format, widget, icon, titleBy, mode,
|
|
151
|
+
// hideOption, label, options, pattern, section, group, placeholder, etc.
|
|
152
|
+
schema[tag] = value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const WIDGET_TYPE_FORMATS: Record<string, string> = {
|
|
157
|
+
ImageWidget: "image-uri",
|
|
158
|
+
VideoWidget: "video-uri",
|
|
159
|
+
HTMLWidget: "html",
|
|
160
|
+
RichText: "rich-text",
|
|
161
|
+
Color: "color",
|
|
162
|
+
Secret: "password",
|
|
163
|
+
TextArea: "textarea",
|
|
164
|
+
Code: "code",
|
|
165
|
+
DateTimeWidget: "date-time",
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Detect known widget types and set the appropriate format.
|
|
170
|
+
*/
|
|
171
|
+
function applyWidgetDetection(schema: any, typeText: string): void {
|
|
172
|
+
if (schema.format) return;
|
|
173
|
+
|
|
174
|
+
for (const [widgetType, format] of Object.entries(WIDGET_TYPE_FORMATS)) {
|
|
175
|
+
if (typeText === widgetType || typeText.includes(widgetType)) {
|
|
176
|
+
schema.format = format;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Smart widget format application that handles arrays, nullable types,
|
|
184
|
+
* and union types by applying the format to the correct inner schema.
|
|
185
|
+
*/
|
|
186
|
+
function applyWidgetFormat(schema: any, typeHint: string): void {
|
|
187
|
+
const matchedFormat = Object.entries(WIDGET_TYPE_FORMATS).find(
|
|
188
|
+
([wt]) => typeHint === wt || typeHint.includes(wt),
|
|
189
|
+
)?.[1];
|
|
190
|
+
|
|
191
|
+
if (!matchedFormat) {
|
|
192
|
+
applyWidgetDetection(schema, typeHint);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (schema.type === "string" && !schema.format) {
|
|
197
|
+
schema.format = matchedFormat;
|
|
198
|
+
} else if (schema.type === "array" && schema.items) {
|
|
199
|
+
if (schema.items.type === "string" && !schema.items.format) {
|
|
200
|
+
schema.items.format = matchedFormat;
|
|
201
|
+
}
|
|
202
|
+
} else if (schema.anyOf) {
|
|
203
|
+
for (const variant of schema.anyOf) {
|
|
204
|
+
if (variant.type === "string" && !variant.format) {
|
|
205
|
+
variant.format = matchedFormat;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Well-known definition key for Section type references resolved by composeMeta
|
|
212
|
+
const SECTION_REF_DEF_KEY = "__SECTION_REF__";
|
|
213
|
+
// Well-known definition key for Resolvable (saved blocks picker)
|
|
214
|
+
const RESOLVABLE_KEY = "Resolvable";
|
|
215
|
+
|
|
216
|
+
// Only truly React-internal props that are never user-defined.
|
|
217
|
+
// Do NOT include "children", "type", "props", or "key" — those are commonly
|
|
218
|
+
// used as legitimate property names in data interfaces (e.g. SelectedFacet
|
|
219
|
+
// uses { key: string; value: string }).
|
|
220
|
+
// Note: React's JSX `key` is a special attribute, not a TypeScript interface
|
|
221
|
+
// property — it never appears in Props/data interfaces and must not be filtered.
|
|
222
|
+
const REACT_INTERNAL_PROPS = new Set([
|
|
223
|
+
"ref",
|
|
224
|
+
"then",
|
|
225
|
+
"catch",
|
|
226
|
+
"finally",
|
|
227
|
+
"$$typeof",
|
|
228
|
+
"_owner",
|
|
229
|
+
"_store",
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
interface GenerationContext {
|
|
233
|
+
outputTypeToLoaderKeys: Map<string, string[]>;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Extract the return type name of a loader's default export.
|
|
238
|
+
* Unwraps Promise<T> and T | null wrappers.
|
|
239
|
+
*/
|
|
240
|
+
function extractLoaderOutputTypeName(sourceFile: SourceFile): string | null {
|
|
241
|
+
const sym = sourceFile.getDefaultExportSymbol();
|
|
242
|
+
if (!sym) return null;
|
|
243
|
+
const callSigs = sym.getTypeAtLocation(sourceFile).getCallSignatures();
|
|
244
|
+
if (!callSigs.length) return null;
|
|
245
|
+
let ret = callSigs[0].getReturnType();
|
|
246
|
+
if (ret.getSymbol()?.getName() === "Promise") {
|
|
247
|
+
const args = ret.getTypeArguments();
|
|
248
|
+
if (args.length) ret = args[0];
|
|
249
|
+
}
|
|
250
|
+
if (ret.isUnion()) {
|
|
251
|
+
const nonNull = ret.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
|
|
252
|
+
if (nonNull.length === 1) ret = nonNull[0];
|
|
253
|
+
}
|
|
254
|
+
// Unwrap array element type — Product[] → "Product[]" (keyed as array)
|
|
255
|
+
if (ret.isArray()) {
|
|
256
|
+
const elType = ret.getArrayElementType();
|
|
257
|
+
if (elType) {
|
|
258
|
+
const elName = elType.getSymbol()?.getName() ?? elType.getAliasSymbol()?.getName() ?? null;
|
|
259
|
+
if (elName) return `${elName}[]`;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return ret.getSymbol()?.getName() ?? ret.getAliasSymbol()?.getName() ?? null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?: GenerationContext): any {
|
|
266
|
+
const typeText = type.getText();
|
|
267
|
+
if (visited.has(typeText)) return { type: "object" };
|
|
268
|
+
visited.add(typeText);
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
// any / unknown → accept anything
|
|
272
|
+
if (type.isAny() || type.isUnknown()) return {};
|
|
273
|
+
|
|
274
|
+
// ReactNode, JSX.Element, VNode → hide from form
|
|
275
|
+
if (
|
|
276
|
+
/\bReactNode\b|\bJSX\.Element\b|\bReactElement\b|\bVNode\b|\bComponentChildren\b/.test(
|
|
277
|
+
typeText,
|
|
278
|
+
)
|
|
279
|
+
) {
|
|
280
|
+
return { type: "object", hide: "true" };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (type.isString() || type.isStringLiteral()) {
|
|
284
|
+
return type.isStringLiteral()
|
|
285
|
+
? { type: "string", const: type.getLiteralValue() }
|
|
286
|
+
: { type: "string" };
|
|
287
|
+
}
|
|
288
|
+
if (type.isNumber() || type.isNumberLiteral()) return { type: "number" };
|
|
289
|
+
if (type.isBoolean() || type.isBooleanLiteral()) return { type: "boolean" };
|
|
290
|
+
if (type.isNull() || type.isUndefined()) return { type: "null" };
|
|
291
|
+
|
|
292
|
+
if (type.isArray()) {
|
|
293
|
+
const el = type.getArrayElementType();
|
|
294
|
+
return el
|
|
295
|
+
? { type: "array", items: typeToJsonSchema(el, new Set(visited), ctx) }
|
|
296
|
+
: { type: "array" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (type.isUnion()) {
|
|
300
|
+
const parts = type.getUnionTypes();
|
|
301
|
+
const nonNull = parts.filter((t) => !t.isNull() && !t.isUndefined());
|
|
302
|
+
const isNullable = nonNull.length < parts.length;
|
|
303
|
+
|
|
304
|
+
if (nonNull.length === 1) {
|
|
305
|
+
const inner = typeToJsonSchema(nonNull[0], new Set(visited), ctx);
|
|
306
|
+
return isNullable ? { ...inner, nullable: true } : inner;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// boolean? → true | false | undefined → collapse to { type: "boolean" }
|
|
310
|
+
if (nonNull.every((t) => t.isBooleanLiteral())) {
|
|
311
|
+
const result: any = { type: "boolean" };
|
|
312
|
+
if (isNullable) result.nullable = true;
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (nonNull.every((t) => t.isStringLiteral())) {
|
|
317
|
+
const result: any = { type: "string", enum: nonNull.map((t) => t.getLiteralValue()) };
|
|
318
|
+
if (isNullable) result.nullable = true;
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 1 | 2 | 3 → { type: "number", enum: [1, 2, 3] }
|
|
323
|
+
if (nonNull.every((t) => t.isNumberLiteral())) {
|
|
324
|
+
const result: any = { type: "number", enum: nonNull.map((t) => t.getLiteralValue()) };
|
|
325
|
+
if (isNullable) result.nullable = true;
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// General anyOf — try to add title to each variant for discriminated unions
|
|
330
|
+
const anyOf = nonNull.map((t) => {
|
|
331
|
+
const schema = typeToJsonSchema(t, new Set(visited), ctx);
|
|
332
|
+
if (!schema.title && schema.type === "object") {
|
|
333
|
+
const sym = t.getAliasSymbol() ?? t.getSymbol();
|
|
334
|
+
const symName = sym?.getName();
|
|
335
|
+
if (symName && symName !== "__type" && symName !== "default") {
|
|
336
|
+
schema.title = symName;
|
|
337
|
+
}
|
|
338
|
+
// Fallback: use a const discriminator field value as title
|
|
339
|
+
if (!schema.title && schema.properties) {
|
|
340
|
+
for (const v of Object.values(schema.properties) as any[]) {
|
|
341
|
+
if (v?.const !== undefined) {
|
|
342
|
+
schema.title = String(v.const);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return schema;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const result: any = { anyOf };
|
|
352
|
+
if (isNullable) result.nullable = true;
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (type.isObject() || type.isInterface()) {
|
|
357
|
+
// Record<K,V> → { type: "object", additionalProperties: V-schema }
|
|
358
|
+
const stringIdx = type.getStringIndexType();
|
|
359
|
+
const numberIdx = type.getNumberIndexType();
|
|
360
|
+
if ((stringIdx || numberIdx) && type.getProperties().length === 0) {
|
|
361
|
+
const valType = (stringIdx || numberIdx)!;
|
|
362
|
+
return {
|
|
363
|
+
type: "object",
|
|
364
|
+
additionalProperties: typeToJsonSchema(valType, new Set(visited)),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const properties: Record<string, any> = {};
|
|
369
|
+
const required: string[] = [];
|
|
370
|
+
|
|
371
|
+
for (const prop of type.getProperties()) {
|
|
372
|
+
const name = prop.getName();
|
|
373
|
+
if (name.startsWith("_") || name.startsWith("$") || name === "@type") continue;
|
|
374
|
+
if (REACT_INTERNAL_PROPS.has(name)) continue;
|
|
375
|
+
|
|
376
|
+
// getValueDeclaration() returns undefined for computed/mapped-type
|
|
377
|
+
// properties (e.g. `Omit<Props, "isMobile">`). Fall back to the first
|
|
378
|
+
// available declaration, or skip if none exists at all.
|
|
379
|
+
const decl = prop.getValueDeclaration() ?? prop.getDeclarations()[0];
|
|
380
|
+
if (!decl) continue;
|
|
381
|
+
const propType = prop.getTypeAtLocation(decl);
|
|
382
|
+
|
|
383
|
+
const tags = getJsDocTags(prop);
|
|
384
|
+
if (tags.ignore) continue;
|
|
385
|
+
|
|
386
|
+
// Get AST type-annotation text before resolving
|
|
387
|
+
let typeHint = propType.getText();
|
|
388
|
+
const typeNode =
|
|
389
|
+
decl.getChildrenOfKind?.(SyntaxKind.TypeReference)?.[0] ??
|
|
390
|
+
decl.getChildAtIndex?.(decl.getChildCount?.() - 1);
|
|
391
|
+
if (typeNode && Node.isTypeReference(typeNode)) {
|
|
392
|
+
typeHint = typeNode.getText();
|
|
393
|
+
} else if (Node.isPropertySignature(decl) || Node.isPropertyDeclaration(decl)) {
|
|
394
|
+
const tn = (decl as any).getTypeNode?.();
|
|
395
|
+
if (tn) typeHint = tn.getText();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Section type → section picker reference (resolved by composeMeta)
|
|
399
|
+
const baseHint = typeHint.replace(/\s*\|\s*(null|undefined)/g, "").trim();
|
|
400
|
+
if (baseHint === "Section" || baseHint === "Section[]" || baseHint === "Section[] | null") {
|
|
401
|
+
const isArray = baseHint.includes("[]");
|
|
402
|
+
const sectionSchema: any = isArray
|
|
403
|
+
? {
|
|
404
|
+
type: "array",
|
|
405
|
+
items: { $ref: `#/definitions/${SECTION_REF_DEF_KEY}` },
|
|
406
|
+
title: name.charAt(0).toUpperCase() + name.slice(1),
|
|
407
|
+
}
|
|
408
|
+
: {
|
|
409
|
+
$ref: `#/definitions/${SECTION_REF_DEF_KEY}`,
|
|
410
|
+
title: name.charAt(0).toUpperCase() + name.slice(1),
|
|
411
|
+
};
|
|
412
|
+
if (prop.isOptional() || typeHint.includes("null") || typeHint.includes("undefined")) {
|
|
413
|
+
sectionSchema.nullable = true;
|
|
414
|
+
}
|
|
415
|
+
applyJsDocToSchema(sectionSchema, tags);
|
|
416
|
+
properties[name] = sectionSchema;
|
|
417
|
+
if (!prop.isOptional()) required.push(name);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Loader output type → block-ref: emit anyOf [Resolvable, ...matchingLoaders]
|
|
422
|
+
// baseHint strips "| null | undefined" so "ProductListingPage | null" → "ProductListingPage"
|
|
423
|
+
if (ctx?.outputTypeToLoaderKeys) {
|
|
424
|
+
const typeSym = propType.getSymbol() ?? propType.getAliasSymbol();
|
|
425
|
+
const outputTypeName = typeSym?.getName() ?? baseHint;
|
|
426
|
+
let matchingLoaders =
|
|
427
|
+
ctx.outputTypeToLoaderKeys.get(outputTypeName) ??
|
|
428
|
+
(outputTypeName !== baseHint ? ctx.outputTypeToLoaderKeys.get(baseHint) : undefined);
|
|
429
|
+
// If no match yet and the prop type is an array, try element type name + "[]"
|
|
430
|
+
if (!matchingLoaders?.length) {
|
|
431
|
+
let arrayElementType = propType.isArray() ? propType.getArrayElementType() : null;
|
|
432
|
+
if (!arrayElementType && propType.isUnion()) {
|
|
433
|
+
const nonNull = propType.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
|
|
434
|
+
if (nonNull.length === 1 && nonNull[0].isArray()) {
|
|
435
|
+
arrayElementType = nonNull[0].getArrayElementType();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (arrayElementType) {
|
|
439
|
+
const elName = arrayElementType.getSymbol()?.getName() ?? arrayElementType.getAliasSymbol()?.getName();
|
|
440
|
+
if (elName) {
|
|
441
|
+
matchingLoaders = ctx.outputTypeToLoaderKeys.get(`${elName}[]`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (matchingLoaders?.length) {
|
|
446
|
+
const blockRefSchema: any = {
|
|
447
|
+
anyOf: [
|
|
448
|
+
{ $ref: `#/definitions/${RESOLVABLE_KEY}` },
|
|
449
|
+
...matchingLoaders.map((k) => ({ $ref: `#/definitions/${toBase64(k)}` })),
|
|
450
|
+
],
|
|
451
|
+
title: name.charAt(0).toUpperCase() + name.slice(1),
|
|
452
|
+
};
|
|
453
|
+
if (prop.isOptional() || typeHint.includes("null") || typeHint.includes("undefined")) {
|
|
454
|
+
blockRefSchema.nullable = true;
|
|
455
|
+
}
|
|
456
|
+
applyJsDocToSchema(blockRefSchema, tags);
|
|
457
|
+
properties[name] = blockRefSchema;
|
|
458
|
+
if (!prop.isOptional()) required.push(name);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const schema = typeToJsonSchema(propType, new Set(visited), ctx);
|
|
464
|
+
|
|
465
|
+
applyJsDocToSchema(schema, tags);
|
|
466
|
+
applyWidgetFormat(schema, typeHint);
|
|
467
|
+
if (typeHint.includes("Secret")) {
|
|
468
|
+
schema.type = schema.type ?? "string";
|
|
469
|
+
schema.format = "password";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!schema.title) schema.title = name.charAt(0).toUpperCase() + name.slice(1);
|
|
473
|
+
|
|
474
|
+
properties[name] = schema;
|
|
475
|
+
if (!prop.isOptional()) required.push(name);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const result: any = { type: "object", properties };
|
|
479
|
+
if (required.length > 0) result.required = required;
|
|
480
|
+
|
|
481
|
+
const ifaceSym = type.getAliasSymbol() ?? type.getSymbol();
|
|
482
|
+
if (ifaceSym) {
|
|
483
|
+
const ifaceTags = getJsDocTags(ifaceSym);
|
|
484
|
+
applyJsDocToSchema(result, ifaceTags);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return { type: "string" };
|
|
491
|
+
} finally {
|
|
492
|
+
visited.delete(typeText);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function getJsDocTags(symbol: MorphSymbol): Record<string, string> {
|
|
497
|
+
const tags: Record<string, string> = {};
|
|
498
|
+
for (const decl of symbol.getDeclarations()) {
|
|
499
|
+
const jsDocs = Node.isJSDocable(decl) ? decl.getJsDocs() : [];
|
|
500
|
+
for (const doc of jsDocs) {
|
|
501
|
+
const desc = doc.getDescription().trim();
|
|
502
|
+
if (desc) tags.description = desc;
|
|
503
|
+
for (const tag of doc.getTags()) {
|
|
504
|
+
tags[tag.getTagName()] = tag.getCommentText()?.trim() || "true";
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return tags;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Extract the first parameter's type from a component's default export
|
|
513
|
+
* using the type checker. Works regardless of whether the export is a
|
|
514
|
+
* function declaration, arrow function, const assignment, or re-export.
|
|
515
|
+
*/
|
|
516
|
+
function extractDefaultExportPropsType(sourceFile: import("ts-morph").SourceFile): Type | null {
|
|
517
|
+
const symbol = sourceFile.getDefaultExportSymbol();
|
|
518
|
+
if (!symbol) return null;
|
|
519
|
+
|
|
520
|
+
const exportType = symbol.getTypeAtLocation(sourceFile);
|
|
521
|
+
const callSigs = exportType.getCallSignatures();
|
|
522
|
+
if (callSigs.length === 0) return null;
|
|
523
|
+
|
|
524
|
+
const params = callSigs[0].getParameters();
|
|
525
|
+
if (params.length === 0) return null;
|
|
526
|
+
|
|
527
|
+
const paramType = params[0].getTypeAtLocation(sourceFile);
|
|
528
|
+
if (paramType.isAny() || paramType.getText() === "{}") return null;
|
|
529
|
+
|
|
530
|
+
return paramType;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Extract the first parameter type of an exported `loader` function.
|
|
535
|
+
* When a section file co-exports a loader, the loader's input type defines
|
|
536
|
+
* the CMS schema (what the user configures), NOT the component's Props.
|
|
537
|
+
*/
|
|
538
|
+
function extractLoaderInputType(sourceFile: import("ts-morph").SourceFile): Type | null {
|
|
539
|
+
// Check for `export const loader = ...` or `export function loader(...)`
|
|
540
|
+
for (const sym of sourceFile.getExportSymbols()) {
|
|
541
|
+
if (sym.getName() !== "loader") continue;
|
|
542
|
+
|
|
543
|
+
const decls = sym.getDeclarations();
|
|
544
|
+
for (const decl of decls) {
|
|
545
|
+
let loaderType: Type | null = null;
|
|
546
|
+
|
|
547
|
+
if (Node.isVariableDeclaration(decl)) {
|
|
548
|
+
loaderType = decl.getType();
|
|
549
|
+
} else if (Node.isFunctionDeclaration(decl)) {
|
|
550
|
+
loaderType = decl.getType();
|
|
551
|
+
} else if (Node.isExportSpecifier(decl)) {
|
|
552
|
+
// Re-exported: `export { loader } from "..."`
|
|
553
|
+
loaderType = decl.getType();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!loaderType) continue;
|
|
557
|
+
|
|
558
|
+
const callSigs = loaderType.getCallSignatures();
|
|
559
|
+
if (callSigs.length === 0) continue;
|
|
560
|
+
|
|
561
|
+
const params = callSigs[0].getParameters();
|
|
562
|
+
if (params.length === 0) continue;
|
|
563
|
+
|
|
564
|
+
const paramType = params[0].getTypeAtLocation(sourceFile);
|
|
565
|
+
if (paramType.isAny() || paramType.getText() === "{}") continue;
|
|
566
|
+
|
|
567
|
+
return paramType;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Resolve a module specifier to an absolute file path.
|
|
575
|
+
*/
|
|
576
|
+
function resolveModulePath(
|
|
577
|
+
moduleSpec: string,
|
|
578
|
+
fromFile: string,
|
|
579
|
+
projectRoot: string,
|
|
580
|
+
): string | null {
|
|
581
|
+
let target = moduleSpec;
|
|
582
|
+
if (target.startsWith("~/")) {
|
|
583
|
+
target = path.resolve(projectRoot, "src", target.slice(2));
|
|
584
|
+
} else if (target.startsWith("./") || target.startsWith("../")) {
|
|
585
|
+
target = path.resolve(path.dirname(fromFile), target);
|
|
586
|
+
}
|
|
587
|
+
if (!target.match(/\.(tsx?|jsx?)$/)) {
|
|
588
|
+
for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
|
|
589
|
+
if (fs.existsSync(target + ext)) return target + ext;
|
|
590
|
+
}
|
|
591
|
+
if (fs.existsSync(path.join(target, "index.tsx"))) return path.join(target, "index.tsx");
|
|
592
|
+
if (fs.existsSync(path.join(target, "index.ts"))) return path.join(target, "index.ts");
|
|
593
|
+
}
|
|
594
|
+
return fs.existsSync(target) ? target : null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
type SourceFileCache = Map<string, SourceFile>;
|
|
598
|
+
type ModuleResolutionCache = Map<string, string | null>;
|
|
599
|
+
type PropsSchemaCache = Map<string, any>;
|
|
600
|
+
|
|
601
|
+
function getSourceFile(
|
|
602
|
+
project: import("ts-morph").Project,
|
|
603
|
+
filePath: string,
|
|
604
|
+
cache: SourceFileCache,
|
|
605
|
+
): SourceFile {
|
|
606
|
+
const normalizedPath = path.resolve(filePath);
|
|
607
|
+
const cached = cache.get(normalizedPath);
|
|
608
|
+
if (cached) return cached;
|
|
609
|
+
|
|
610
|
+
const sourceFile =
|
|
611
|
+
project.getSourceFile(normalizedPath) ?? project.addSourceFileAtPath(normalizedPath);
|
|
612
|
+
cache.set(normalizedPath, sourceFile);
|
|
613
|
+
return sourceFile;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function resolveModulePathCached(
|
|
617
|
+
moduleSpec: string,
|
|
618
|
+
fromFile: string,
|
|
619
|
+
projectRoot: string,
|
|
620
|
+
cache: ModuleResolutionCache,
|
|
621
|
+
): string | null {
|
|
622
|
+
const key = `${fromFile}\0${moduleSpec}`;
|
|
623
|
+
if (cache.has(key)) return cache.get(key) ?? null;
|
|
624
|
+
|
|
625
|
+
const resolved = resolveModulePath(moduleSpec, fromFile, projectRoot);
|
|
626
|
+
cache.set(key, resolved);
|
|
627
|
+
return resolved;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Recursively follow `export { default } from "..."` chains (up to maxDepth hops)
|
|
632
|
+
* and try to extract Props from each target file.
|
|
633
|
+
*/
|
|
634
|
+
function resolvePropsViaReExport(
|
|
635
|
+
project: import("ts-morph").Project,
|
|
636
|
+
sourceFile: import("ts-morph").SourceFile,
|
|
637
|
+
filePath: string,
|
|
638
|
+
projectRoot: string,
|
|
639
|
+
maxDepth: number,
|
|
640
|
+
sourceFileCache: SourceFileCache,
|
|
641
|
+
moduleResolutionCache: ModuleResolutionCache,
|
|
642
|
+
propsSchemaCache: PropsSchemaCache,
|
|
643
|
+
ctx?: GenerationContext,
|
|
644
|
+
): any | null {
|
|
645
|
+
if (maxDepth <= 0) return null;
|
|
646
|
+
|
|
647
|
+
for (const exportDecl of sourceFile.getExportDeclarations()) {
|
|
648
|
+
const moduleSpec = exportDecl.getModuleSpecifierValue();
|
|
649
|
+
if (!moduleSpec) continue;
|
|
650
|
+
const hasDefault = exportDecl.getNamedExports().some((n) => {
|
|
651
|
+
const name = n.getName();
|
|
652
|
+
const alias = n.getAliasNode()?.getText();
|
|
653
|
+
return name === "default" || alias === "default";
|
|
654
|
+
});
|
|
655
|
+
if (!hasDefault) continue;
|
|
656
|
+
|
|
657
|
+
const targetPath = resolveModulePathCached(
|
|
658
|
+
moduleSpec,
|
|
659
|
+
filePath,
|
|
660
|
+
projectRoot,
|
|
661
|
+
moduleResolutionCache,
|
|
662
|
+
);
|
|
663
|
+
if (!targetPath) continue;
|
|
664
|
+
|
|
665
|
+
const cachedProps = propsSchemaCache.get(targetPath);
|
|
666
|
+
if (cachedProps) return cachedProps;
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
const targetFile = getSourceFile(project, targetPath, sourceFileCache);
|
|
670
|
+
|
|
671
|
+
// When the target file exports a loader, the loader's first parameter
|
|
672
|
+
// type defines the CMS input schema. This takes priority over a named
|
|
673
|
+
// Props interface, which may be a sub-type used internally.
|
|
674
|
+
const loaderInputType = extractLoaderInputType(targetFile);
|
|
675
|
+
if (loaderInputType) {
|
|
676
|
+
const schema = typeToJsonSchema(loaderInputType, undefined, ctx);
|
|
677
|
+
propsSchemaCache.set(targetPath, schema);
|
|
678
|
+
return schema;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const targetProps = targetFile.getInterface("Props");
|
|
682
|
+
if (targetProps) {
|
|
683
|
+
const schema = typeToJsonSchema(targetProps.getType(), undefined, ctx);
|
|
684
|
+
propsSchemaCache.set(targetPath, schema);
|
|
685
|
+
return schema;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const targetAlias = targetFile.getTypeAlias("Props");
|
|
689
|
+
if (targetAlias) {
|
|
690
|
+
const schema = typeToJsonSchema(targetAlias.getType(), undefined, ctx);
|
|
691
|
+
propsSchemaCache.set(targetPath, schema);
|
|
692
|
+
return schema;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Type-checker approach: extract from default export call signature
|
|
696
|
+
const propsType = extractDefaultExportPropsType(targetFile);
|
|
697
|
+
if (propsType) {
|
|
698
|
+
const schema = typeToJsonSchema(propsType, undefined, ctx);
|
|
699
|
+
propsSchemaCache.set(targetPath, schema);
|
|
700
|
+
return schema;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Recurse: target might also re-export from another file
|
|
704
|
+
const deeper = resolvePropsViaReExport(
|
|
705
|
+
project,
|
|
706
|
+
targetFile,
|
|
707
|
+
targetPath,
|
|
708
|
+
projectRoot,
|
|
709
|
+
maxDepth - 1,
|
|
710
|
+
sourceFileCache,
|
|
711
|
+
moduleResolutionCache,
|
|
712
|
+
propsSchemaCache,
|
|
713
|
+
ctx,
|
|
714
|
+
);
|
|
715
|
+
if (deeper) {
|
|
716
|
+
propsSchemaCache.set(targetPath, deeper);
|
|
717
|
+
return deeper;
|
|
718
|
+
}
|
|
719
|
+
} catch {
|
|
720
|
+
// Target file couldn't be parsed
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function findTsxFiles(dir: string): string[] {
|
|
727
|
+
const results: string[] = [];
|
|
728
|
+
if (!fs.existsSync(dir)) return results;
|
|
729
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
730
|
+
const full = path.join(dir, entry.name);
|
|
731
|
+
if (entry.isDirectory()) results.push(...findTsxFiles(full));
|
|
732
|
+
else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) results.push(full);
|
|
733
|
+
}
|
|
734
|
+
return results;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
// Main
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
function generateMeta(): MetaResponse {
|
|
741
|
+
const root = process.cwd();
|
|
742
|
+
const sectionsDir = path.resolve(root, SECTIONS_REL);
|
|
743
|
+
const loadersDir = path.resolve(root, LOADERS_REL);
|
|
744
|
+
const srcDir = path.join(root, "src");
|
|
745
|
+
|
|
746
|
+
const project = new Project({
|
|
747
|
+
tsConfigFilePath: path.join(root, "tsconfig.json"),
|
|
748
|
+
skipAddingFilesFromTsConfig: true,
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const definitions: Record<string, any> = {};
|
|
752
|
+
const sectionBlocks: Record<string, any> = {};
|
|
753
|
+
const loaderBlocks: Record<string, any> = {};
|
|
754
|
+
const sectionRootAnyOf: any[] = [];
|
|
755
|
+
const loaderRootAnyOf: any[] = [{ $ref: `#/definitions/${RESOLVABLE_KEY}` }];
|
|
756
|
+
const outputTypeToLoaderKeys = new Map<string, string[]>();
|
|
757
|
+
const sourceFileCache: SourceFileCache = new Map();
|
|
758
|
+
const moduleResolutionCache: ModuleResolutionCache = new Map();
|
|
759
|
+
const propsSchemaCache: PropsSchemaCache = new Map();
|
|
760
|
+
|
|
761
|
+
// Resolvable: the admin's deRefUntil expects the LITERAL key "Resolvable",
|
|
762
|
+
// not a base64-encoded version. We store both for compatibility.
|
|
763
|
+
const resolvableB64Key = toBase64("Resolvable");
|
|
764
|
+
const resolvableDef = {
|
|
765
|
+
title: "Select from saved",
|
|
766
|
+
type: "object",
|
|
767
|
+
required: ["__resolveType"],
|
|
768
|
+
additionalProperties: true,
|
|
769
|
+
properties: { __resolveType: { type: "string" } },
|
|
770
|
+
};
|
|
771
|
+
definitions[RESOLVABLE_KEY] = resolvableDef;
|
|
772
|
+
definitions[resolvableB64Key] = resolvableDef;
|
|
773
|
+
sectionRootAnyOf.push({ $ref: `#/definitions/${RESOLVABLE_KEY}` });
|
|
774
|
+
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
// First pass: scan loaders — build input schemas + outputTypeToLoaderKeys map
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
const loaderFiles = fs.existsSync(loadersDir) ? findTsxFiles(loadersDir) : [];
|
|
779
|
+
console.log(`Found ${loaderFiles.length} loader files`);
|
|
780
|
+
for (const filePath of loaderFiles) {
|
|
781
|
+
getSourceFile(project, filePath, sourceFileCache);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
for (const filePath of loaderFiles) {
|
|
785
|
+
const relativePath = path.relative(srcDir, filePath).replaceAll("\\", "/");
|
|
786
|
+
const loaderKey = `${SITE_NAMESPACE}/${relativePath}`;
|
|
787
|
+
|
|
788
|
+
try {
|
|
789
|
+
const sourceFile = getSourceFile(project, filePath, sourceFileCache);
|
|
790
|
+
|
|
791
|
+
// Extract Props (input schema)
|
|
792
|
+
let propsSchema: any = null;
|
|
793
|
+
const propsInterface = sourceFile.getInterface("Props");
|
|
794
|
+
if (propsInterface) propsSchema = typeToJsonSchema(propsInterface.getType());
|
|
795
|
+
|
|
796
|
+
const propsTypeAlias = sourceFile.getTypeAlias("Props");
|
|
797
|
+
if (!propsSchema && propsTypeAlias) propsSchema = typeToJsonSchema(propsTypeAlias.getType());
|
|
798
|
+
|
|
799
|
+
if (!propsSchema) {
|
|
800
|
+
const localPropsType = extractDefaultExportPropsType(sourceFile);
|
|
801
|
+
if (localPropsType) propsSchema = typeToJsonSchema(localPropsType);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (!propsSchema) propsSchema = { type: "object", properties: {} };
|
|
805
|
+
|
|
806
|
+
// Register flat loader definition (Bug #2: spread props, not nested under "props:")
|
|
807
|
+
const loaderDefKey = toBase64(loaderKey);
|
|
808
|
+
definitions[loaderDefKey] = {
|
|
809
|
+
title: loaderKey,
|
|
810
|
+
type: "object",
|
|
811
|
+
required: ["__resolveType", ...(propsSchema?.required || [])],
|
|
812
|
+
properties: {
|
|
813
|
+
__resolveType: { type: "string", enum: [loaderKey], default: loaderKey },
|
|
814
|
+
...(propsSchema?.properties || {}),
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
loaderBlocks[loaderKey] = {
|
|
819
|
+
$ref: `#/definitions/${loaderDefKey}`,
|
|
820
|
+
namespace: SITE_NAMESPACE,
|
|
821
|
+
};
|
|
822
|
+
loaderRootAnyOf.push({ $ref: `#/definitions/${loaderDefKey}` });
|
|
823
|
+
|
|
824
|
+
// Extract return type name for block-ref detection in sections (Bug #3)
|
|
825
|
+
const outputTypeName = extractLoaderOutputTypeName(sourceFile);
|
|
826
|
+
if (outputTypeName) {
|
|
827
|
+
const existing = outputTypeToLoaderKeys.get(outputTypeName) ?? [];
|
|
828
|
+
existing.push(loaderKey);
|
|
829
|
+
outputTypeToLoaderKeys.set(outputTypeName, existing);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const propCount = Object.keys(propsSchema.properties || {}).length;
|
|
833
|
+
console.log(
|
|
834
|
+
` ✓ loader ${loaderKey} (${propCount} props${outputTypeName ? ` → ${outputTypeName}` : ""})`,
|
|
835
|
+
);
|
|
836
|
+
} catch (e) {
|
|
837
|
+
console.warn(` ✗ loader ${loaderKey}: ${(e as Error).message}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ---------------------------------------------------------------------------
|
|
842
|
+
// App loaders pass: walk @decocms/apps/*/loaders/ directories and register
|
|
843
|
+
// each .ts file as a CMS loader. CMS keys are derived from the file path
|
|
844
|
+
// (e.g. "vtex/loaders/intelligentSearch/productList.ts").
|
|
845
|
+
//
|
|
846
|
+
// Only apps installed in src/apps/ are scanned. An app bridge file that
|
|
847
|
+
// re-exports from "@decocms/apps/{namespace}/mod" signals the namespace.
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
const appsPkgDir = path.resolve(root, "node_modules/@decocms/apps");
|
|
850
|
+
|
|
851
|
+
/** Detect installed app namespaces from src/apps/ bridge files. */
|
|
852
|
+
function detectInstalledAppNamespaces(): Set<string> {
|
|
853
|
+
const namespaces = new Set<string>();
|
|
854
|
+
const siteAppsDir = path.resolve(root, APPS_REL);
|
|
855
|
+
if (!fs.existsSync(siteAppsDir)) return namespaces;
|
|
856
|
+
|
|
857
|
+
const appFiles = findTsxFiles(siteAppsDir);
|
|
858
|
+
const re = /["']@decocms\/apps\/([^/]+)\/mod["']/;
|
|
859
|
+
for (const filePath of appFiles) {
|
|
860
|
+
try {
|
|
861
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
862
|
+
const match = content.match(re);
|
|
863
|
+
if (match) namespaces.add(match[1]);
|
|
864
|
+
} catch { /* skip unreadable files */ }
|
|
865
|
+
}
|
|
866
|
+
return namespaces;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Discover app loader files via filesystem walk (scoped to installed apps)
|
|
870
|
+
function discoverAppLoaders(): Array<{ cmsKey: string; sourceFile: string; namespace: string }> {
|
|
871
|
+
const result: Array<{ cmsKey: string; sourceFile: string; namespace: string }> = [];
|
|
872
|
+
if (!fs.existsSync(appsPkgDir)) return result;
|
|
873
|
+
|
|
874
|
+
const installed = detectInstalledAppNamespaces();
|
|
875
|
+
if (installed.size === 0) return result;
|
|
876
|
+
|
|
877
|
+
for (const namespace of installed) {
|
|
878
|
+
const loadersDir = path.join(appsPkgDir, namespace, "loaders");
|
|
879
|
+
if (!fs.existsSync(loadersDir)) continue;
|
|
880
|
+
|
|
881
|
+
const files = fs.readdirSync(loadersDir, { recursive: true }) as string[];
|
|
882
|
+
for (const relFile of files) {
|
|
883
|
+
const rel = String(relFile);
|
|
884
|
+
if (!rel.endsWith(".ts") && !rel.endsWith(".tsx")) continue;
|
|
885
|
+
// Skip index/barrel files, tests, and internal files
|
|
886
|
+
const basename = path.basename(rel);
|
|
887
|
+
if (basename === "index.ts" || basename.startsWith("_")) continue;
|
|
888
|
+
if (rel.includes("__tests__") || rel.includes("__test__") || rel.endsWith(".test.ts")) continue;
|
|
889
|
+
|
|
890
|
+
const cmsKey = `${namespace}/loaders/${rel.replace(/\\/g, "/")}`;
|
|
891
|
+
const sourceFile = `${namespace}/loaders/${rel.replace(/\\/g, "/")}`;
|
|
892
|
+
result.push({ cmsKey, sourceFile, namespace });
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return result.sort((a, b) => a.cmsKey.localeCompare(b.cmsKey));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const appLoaders = discoverAppLoaders();
|
|
900
|
+
|
|
901
|
+
// De-duplicate: when re-export files point to the same source, parse once
|
|
902
|
+
const appLoaderCache = new Map<string, {
|
|
903
|
+
propsSchema: any;
|
|
904
|
+
outputTypeName: string | null;
|
|
905
|
+
title: string | null;
|
|
906
|
+
hidden: boolean;
|
|
907
|
+
}>();
|
|
908
|
+
|
|
909
|
+
const installedNs = detectInstalledAppNamespaces();
|
|
910
|
+
console.log(`Installed app namespaces: ${[...installedNs].join(", ") || "(none)"}`);
|
|
911
|
+
console.log(`Scanning app loaders from @decocms/apps (${appLoaders.length} files)...`);
|
|
912
|
+
for (const appLoader of appLoaders) {
|
|
913
|
+
const absSourceFile = path.resolve(appsPkgDir, appLoader.sourceFile);
|
|
914
|
+
|
|
915
|
+
try {
|
|
916
|
+
// Resolve the real path to de-duplicate re-exports pointing to the same file
|
|
917
|
+
const realPath = fs.realpathSync(absSourceFile);
|
|
918
|
+
let cached = appLoaderCache.get(realPath);
|
|
919
|
+
if (!cached) {
|
|
920
|
+
const sourceFile = getSourceFile(project, absSourceFile, sourceFileCache);
|
|
921
|
+
|
|
922
|
+
// Skip files without a default export (barrel files, utility modules)
|
|
923
|
+
const defaultSym = sourceFile.getDefaultExportSymbol();
|
|
924
|
+
if (defaultSym == null) continue;
|
|
925
|
+
|
|
926
|
+
// A loader may control how it appears in the admin picker via JSDoc tags
|
|
927
|
+
// on its default export:
|
|
928
|
+
// @title — overrides the picker label. This is how compat re-export
|
|
929
|
+
// aliases (which would otherwise all beautify to the same
|
|
930
|
+
// name from their path) disambiguate themselves. Only a
|
|
931
|
+
// slash-free title is honored; paths fall back to the key.
|
|
932
|
+
// @ignore — hides the loader from the pickers (redundant path aliases)
|
|
933
|
+
// while still emitting its definition so pre-existing blocks
|
|
934
|
+
// that reference it keep resolving and rendering.
|
|
935
|
+
const loaderTags = getJsDocTags(defaultSym);
|
|
936
|
+
const titleTag = loaderTags.title;
|
|
937
|
+
const title = titleTag && !titleTag.includes("/") ? titleTag : null;
|
|
938
|
+
const hidden = !!loaderTags.ignore;
|
|
939
|
+
|
|
940
|
+
// Extract Props (input schema)
|
|
941
|
+
let propsSchema: any = null;
|
|
942
|
+
const propsInterface = sourceFile.getInterface("Props");
|
|
943
|
+
if (propsInterface) propsSchema = typeToJsonSchema(propsInterface.getType());
|
|
944
|
+
|
|
945
|
+
const propsTypeAlias = sourceFile.getTypeAlias("Props");
|
|
946
|
+
if (!propsSchema && propsTypeAlias) propsSchema = typeToJsonSchema(propsTypeAlias.getType());
|
|
947
|
+
|
|
948
|
+
if (!propsSchema) {
|
|
949
|
+
const localPropsType = extractDefaultExportPropsType(sourceFile);
|
|
950
|
+
if (localPropsType) propsSchema = typeToJsonSchema(localPropsType);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (!propsSchema) propsSchema = { type: "object", properties: {} };
|
|
954
|
+
|
|
955
|
+
const outputTypeName = extractLoaderOutputTypeName(sourceFile);
|
|
956
|
+
cached = { propsSchema, outputTypeName, title, hidden };
|
|
957
|
+
appLoaderCache.set(realPath, cached);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const { propsSchema, outputTypeName, title, hidden } = cached;
|
|
961
|
+
const loaderDefKey = toBase64(appLoader.cmsKey);
|
|
962
|
+
|
|
963
|
+
definitions[loaderDefKey] = {
|
|
964
|
+
title: title ?? appLoader.cmsKey,
|
|
965
|
+
type: "object",
|
|
966
|
+
required: ["__resolveType", ...(propsSchema?.required || [])],
|
|
967
|
+
properties: {
|
|
968
|
+
__resolveType: { type: "string", enum: [appLoader.cmsKey], default: appLoader.cmsKey },
|
|
969
|
+
...(propsSchema?.properties || {}),
|
|
970
|
+
},
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
loaderBlocks[appLoader.cmsKey] = {
|
|
974
|
+
$ref: `#/definitions/${loaderDefKey}`,
|
|
975
|
+
namespace: appLoader.namespace,
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
// `@ignore`d loaders keep their definition (so existing blocks resolve)
|
|
979
|
+
// but are withheld from both the root loader union and the per-output-type
|
|
980
|
+
// pickers, so users can't pick them for new blocks.
|
|
981
|
+
if (!hidden) {
|
|
982
|
+
loaderRootAnyOf.push({ $ref: `#/definitions/${loaderDefKey}` });
|
|
983
|
+
|
|
984
|
+
// Register output type for block-ref resolution in sections
|
|
985
|
+
if (outputTypeName) {
|
|
986
|
+
const existing = outputTypeToLoaderKeys.get(outputTypeName) ?? [];
|
|
987
|
+
existing.push(appLoader.cmsKey);
|
|
988
|
+
outputTypeToLoaderKeys.set(outputTypeName, existing);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const propCount = Object.keys(propsSchema.properties || {}).length;
|
|
993
|
+
console.log(
|
|
994
|
+
` ${hidden ? "·" : "✓"} app loader ${appLoader.cmsKey} (${propCount} props${outputTypeName ? ` → ${outputTypeName}` : ""}${hidden ? ", hidden" : ""})`,
|
|
995
|
+
);
|
|
996
|
+
} catch (e) {
|
|
997
|
+
console.warn(` ✗ app loader ${appLoader.cmsKey}: ${(e as Error).message}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const ctx: GenerationContext = { outputTypeToLoaderKeys };
|
|
1002
|
+
|
|
1003
|
+
// ---------------------------------------------------------------------------
|
|
1004
|
+
// Commerce "extension wrapper" loaders (deco-cx parity).
|
|
1005
|
+
//
|
|
1006
|
+
// deco-cx/apps ships `commerce/loaders/product/extensions/{listingPage,detailsPage}.ts`
|
|
1007
|
+
// which wrap a base loader: `{ data: <PLP/PDP loader>, extensions: ExtensionOf<T>[] }`.
|
|
1008
|
+
// These wrappers live in @decocms/apps (node_modules), so they are never scanned
|
|
1009
|
+
// from `src/loaders/` — without emitting their schema the admin renders an empty
|
|
1010
|
+
// config for any page whose `page` prop uses the wrapper. We emit them here using
|
|
1011
|
+
// the output-type → loader map so `data` becomes a picker of the site's matching
|
|
1012
|
+
// loaders (e.g. DeliveryPromiseProductListingPage), exactly like the old admin
|
|
1013
|
+
// ("Extend your product" → "Data" → "The data Extensions").
|
|
1014
|
+
const COMMERCE_EXTENSION_WRAPPERS = [
|
|
1015
|
+
{
|
|
1016
|
+
key: "commerce/loaders/product/extensions/listingPage.ts",
|
|
1017
|
+
outputType: "ProductListingPage",
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
key: "commerce/loaders/product/extensions/detailsPage.ts",
|
|
1021
|
+
outputType: "ProductDetailsPage",
|
|
1022
|
+
},
|
|
1023
|
+
];
|
|
1024
|
+
for (const wrapper of COMMERCE_EXTENSION_WRAPPERS) {
|
|
1025
|
+
const matchingLoaders = outputTypeToLoaderKeys.get(wrapper.outputType) ?? [];
|
|
1026
|
+
const wrapperDefKey = toBase64(wrapper.key);
|
|
1027
|
+
definitions[wrapperDefKey] = {
|
|
1028
|
+
title: wrapper.key,
|
|
1029
|
+
type: "object",
|
|
1030
|
+
required: ["__resolveType"],
|
|
1031
|
+
properties: {
|
|
1032
|
+
__resolveType: { type: "string", enum: [wrapper.key], default: wrapper.key },
|
|
1033
|
+
data: {
|
|
1034
|
+
title: "Data",
|
|
1035
|
+
description: "Here comes your products or anything that can be extensible.",
|
|
1036
|
+
anyOf: [
|
|
1037
|
+
{ $ref: `#/definitions/${RESOLVABLE_KEY}` },
|
|
1038
|
+
...matchingLoaders.map((k) => ({ $ref: `#/definitions/${toBase64(k)}` })),
|
|
1039
|
+
],
|
|
1040
|
+
},
|
|
1041
|
+
extensions: {
|
|
1042
|
+
type: "array",
|
|
1043
|
+
title: "The data Extensions",
|
|
1044
|
+
items: { anyOf: [{ $ref: `#/definitions/${RESOLVABLE_KEY}` }] },
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
};
|
|
1048
|
+
loaderBlocks[wrapper.key] = {
|
|
1049
|
+
$ref: `#/definitions/${wrapperDefKey}`,
|
|
1050
|
+
namespace: "commerce",
|
|
1051
|
+
};
|
|
1052
|
+
loaderRootAnyOf.push({ $ref: `#/definitions/${wrapperDefKey}` });
|
|
1053
|
+
console.log(
|
|
1054
|
+
` ✓ commerce extension wrapper ${wrapper.key} (data → ${matchingLoaders.length} loader(s))`,
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// ---------------------------------------------------------------------------
|
|
1059
|
+
// Second pass: scan sections
|
|
1060
|
+
// ---------------------------------------------------------------------------
|
|
1061
|
+
|
|
1062
|
+
if (!fs.existsSync(sectionsDir)) {
|
|
1063
|
+
console.error(`Sections directory not found: ${sectionsDir}`);
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const sectionFiles = findTsxFiles(sectionsDir);
|
|
1068
|
+
console.log(`Found ${sectionFiles.length} section files`);
|
|
1069
|
+
for (const filePath of sectionFiles) {
|
|
1070
|
+
getSourceFile(project, filePath, sourceFileCache);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
for (const filePath of sectionFiles) {
|
|
1074
|
+
const relativePath = path.relative(srcDir, filePath).replaceAll("\\", "/");
|
|
1075
|
+
const blockKey = `${SITE_NAMESPACE}/${relativePath}`;
|
|
1076
|
+
|
|
1077
|
+
try {
|
|
1078
|
+
const sourceFile = getSourceFile(project, filePath, sourceFileCache);
|
|
1079
|
+
|
|
1080
|
+
let propsSchema: any = null;
|
|
1081
|
+
|
|
1082
|
+
// Strategy 0: If the section file exports a loader (directly or via
|
|
1083
|
+
// re-export), the loader's first parameter type defines the CMS input.
|
|
1084
|
+
// This takes priority over a named Props interface which may be a
|
|
1085
|
+
// sub-type used internally by the component.
|
|
1086
|
+
const loaderInputType = extractLoaderInputType(sourceFile);
|
|
1087
|
+
if (loaderInputType) {
|
|
1088
|
+
propsSchema = typeToJsonSchema(loaderInputType, undefined, ctx);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Strategy 1: Local Props interface/type alias in the section file
|
|
1092
|
+
const propsInterface = sourceFile.getInterface("Props");
|
|
1093
|
+
if (!propsSchema && propsInterface) propsSchema = typeToJsonSchema(propsInterface.getType(), undefined, ctx);
|
|
1094
|
+
|
|
1095
|
+
const propsTypeAlias = sourceFile.getTypeAlias("Props");
|
|
1096
|
+
if (!propsSchema && propsTypeAlias)
|
|
1097
|
+
propsSchema = typeToJsonSchema(propsTypeAlias.getType(), undefined, ctx);
|
|
1098
|
+
|
|
1099
|
+
// Strategy 2: Follow re-exports recursively (up to 3 hops)
|
|
1100
|
+
// Handles: section → island → component chains
|
|
1101
|
+
if (!propsSchema) {
|
|
1102
|
+
propsSchema = resolvePropsViaReExport(
|
|
1103
|
+
project,
|
|
1104
|
+
sourceFile,
|
|
1105
|
+
filePath,
|
|
1106
|
+
root,
|
|
1107
|
+
3,
|
|
1108
|
+
sourceFileCache,
|
|
1109
|
+
moduleResolutionCache,
|
|
1110
|
+
propsSchemaCache,
|
|
1111
|
+
ctx,
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Strategy 4: Default export call signature in the section file via type checker
|
|
1116
|
+
if (!propsSchema) {
|
|
1117
|
+
const localPropsType = extractDefaultExportPropsType(sourceFile);
|
|
1118
|
+
if (localPropsType) {
|
|
1119
|
+
propsSchema = typeToJsonSchema(localPropsType, undefined, ctx);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (!propsSchema) propsSchema = { type: "object", properties: {} };
|
|
1124
|
+
|
|
1125
|
+
const propCount = Object.keys(propsSchema.properties || {}).length;
|
|
1126
|
+
|
|
1127
|
+
const propsDefKey = toBase64(`file:///${filePath.replaceAll("\\", "/")}`) + "@Props";
|
|
1128
|
+
definitions[propsDefKey] = propsSchema;
|
|
1129
|
+
|
|
1130
|
+
const sectionDefKey = toBase64(blockKey);
|
|
1131
|
+
definitions[sectionDefKey] = {
|
|
1132
|
+
title: blockKey,
|
|
1133
|
+
type: "object",
|
|
1134
|
+
allOf: [{ $ref: `#/definitions/${propsDefKey}` }],
|
|
1135
|
+
required: ["__resolveType"],
|
|
1136
|
+
properties: {
|
|
1137
|
+
__resolveType: { type: "string", enum: [blockKey], default: blockKey },
|
|
1138
|
+
},
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
sectionBlocks[blockKey] = {
|
|
1142
|
+
$ref: `#/definitions/${sectionDefKey}`,
|
|
1143
|
+
namespace: SITE_NAMESPACE,
|
|
1144
|
+
};
|
|
1145
|
+
sectionRootAnyOf.push({
|
|
1146
|
+
$ref: `#/definitions/${sectionDefKey}`,
|
|
1147
|
+
inputSchema: `#/definitions/${propsDefKey}`,
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
console.log(` ${propCount > 0 ? "✓" : "○"} ${blockKey} (${propCount} props)`);
|
|
1151
|
+
} catch (e) {
|
|
1152
|
+
console.warn(` ✗ ${blockKey}: ${(e as Error).message}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// ---------------------------------------------------------------------------
|
|
1157
|
+
// Third pass: scan installed app bridges (src/apps/)
|
|
1158
|
+
// ---------------------------------------------------------------------------
|
|
1159
|
+
const appBlocks: Record<string, any> = {};
|
|
1160
|
+
const appRootAnyOf: any[] = [];
|
|
1161
|
+
if (!SKIP_APPS) {
|
|
1162
|
+
const appsDir = path.resolve(root, APPS_REL);
|
|
1163
|
+
const appFiles = fs.existsSync(appsDir) ? findTsxFiles(appsDir) : [];
|
|
1164
|
+
console.log(`Found ${appFiles.length} app files`);
|
|
1165
|
+
for (const filePath of appFiles) {
|
|
1166
|
+
getSourceFile(project, filePath, sourceFileCache);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
for (const filePath of appFiles) {
|
|
1170
|
+
const relativePath = path.relative(srcDir, filePath).replaceAll("\\", "/");
|
|
1171
|
+
const blockKey = `${SITE_NAMESPACE}/${relativePath}`;
|
|
1172
|
+
|
|
1173
|
+
if (
|
|
1174
|
+
!blockKey.startsWith(`${SITE_NAMESPACE}/apps/`) ||
|
|
1175
|
+
!blockKey.endsWith(".ts") ||
|
|
1176
|
+
blockKey.includes("/_")
|
|
1177
|
+
) {
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
try {
|
|
1182
|
+
const sourceFile = getSourceFile(project, filePath, sourceFileCache);
|
|
1183
|
+
|
|
1184
|
+
let propsSchema: any = null;
|
|
1185
|
+
|
|
1186
|
+
const propsInterface = sourceFile.getInterface("Props");
|
|
1187
|
+
if (propsInterface) propsSchema = typeToJsonSchema(propsInterface.getType(), undefined, ctx);
|
|
1188
|
+
|
|
1189
|
+
const propsTypeAlias = sourceFile.getTypeAlias("Props");
|
|
1190
|
+
if (!propsSchema && propsTypeAlias)
|
|
1191
|
+
propsSchema = typeToJsonSchema(propsTypeAlias.getType(), undefined, ctx);
|
|
1192
|
+
|
|
1193
|
+
if (!propsSchema) {
|
|
1194
|
+
propsSchema = resolvePropsViaReExport(
|
|
1195
|
+
project,
|
|
1196
|
+
sourceFile,
|
|
1197
|
+
filePath,
|
|
1198
|
+
root,
|
|
1199
|
+
3,
|
|
1200
|
+
sourceFileCache,
|
|
1201
|
+
moduleResolutionCache,
|
|
1202
|
+
propsSchemaCache,
|
|
1203
|
+
ctx,
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (!propsSchema) {
|
|
1208
|
+
const localPropsType = extractDefaultExportPropsType(sourceFile);
|
|
1209
|
+
if (localPropsType) {
|
|
1210
|
+
propsSchema = typeToJsonSchema(localPropsType, undefined, ctx);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (!propsSchema) propsSchema = { type: "object", properties: {} };
|
|
1215
|
+
|
|
1216
|
+
const propCount = Object.keys(propsSchema.properties || {}).length;
|
|
1217
|
+
|
|
1218
|
+
const propsDefKey = toBase64(`file:///${filePath.replaceAll("\\", "/")}`) + "@Props";
|
|
1219
|
+
definitions[propsDefKey] = propsSchema;
|
|
1220
|
+
|
|
1221
|
+
const appDefKey = toBase64(blockKey);
|
|
1222
|
+
definitions[appDefKey] = {
|
|
1223
|
+
title: blockKey,
|
|
1224
|
+
type: "object",
|
|
1225
|
+
allOf: [{ $ref: `#/definitions/${propsDefKey}` }],
|
|
1226
|
+
required: ["__resolveType"],
|
|
1227
|
+
properties: {
|
|
1228
|
+
__resolveType: { type: "string", enum: [blockKey], default: blockKey },
|
|
1229
|
+
},
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
appBlocks[blockKey] = {
|
|
1233
|
+
$ref: `#/definitions/${appDefKey}`,
|
|
1234
|
+
namespace: SITE_NAMESPACE,
|
|
1235
|
+
};
|
|
1236
|
+
appRootAnyOf.push({
|
|
1237
|
+
$ref: `#/definitions/${appDefKey}`,
|
|
1238
|
+
inputSchema: `#/definitions/${propsDefKey}`,
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
console.log(` ${propCount > 0 ? "✓" : "○"} ${blockKey} (${propCount} props)`);
|
|
1242
|
+
} catch (e) {
|
|
1243
|
+
console.warn(` ✗ ${blockKey}: ${(e as Error).message}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Pages, matchers, etc. are injected at runtime by composeMeta() in src/admin/schema.ts.
|
|
1249
|
+
// Site-level loaders are generated here (first pass above).
|
|
1250
|
+
const emptyAnyOf = { anyOf: [] as any[] };
|
|
1251
|
+
return {
|
|
1252
|
+
major: 1,
|
|
1253
|
+
version: FRAMEWORK_VERSION,
|
|
1254
|
+
namespace: SITE_NAMESPACE,
|
|
1255
|
+
site: SITE_NAME,
|
|
1256
|
+
manifest: { blocks: { sections: sectionBlocks, loaders: loaderBlocks, apps: appBlocks } },
|
|
1257
|
+
schema: {
|
|
1258
|
+
definitions,
|
|
1259
|
+
root: {
|
|
1260
|
+
sections: { anyOf: sectionRootAnyOf },
|
|
1261
|
+
loaders: { anyOf: loaderRootAnyOf },
|
|
1262
|
+
actions: emptyAnyOf,
|
|
1263
|
+
pages: emptyAnyOf,
|
|
1264
|
+
handlers: emptyAnyOf,
|
|
1265
|
+
matchers: emptyAnyOf,
|
|
1266
|
+
flags: emptyAnyOf,
|
|
1267
|
+
functions: emptyAnyOf,
|
|
1268
|
+
apps: { anyOf: appRootAnyOf },
|
|
1269
|
+
},
|
|
1270
|
+
},
|
|
1271
|
+
platform: PLATFORM,
|
|
1272
|
+
cloudProvider: PLATFORM,
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const meta = generateMeta();
|
|
1277
|
+
const outPath = path.resolve(process.cwd(), OUT_REL);
|
|
1278
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
1279
|
+
fs.writeFileSync(outPath, JSON.stringify(meta, null, 2));
|
|
1280
|
+
|
|
1281
|
+
const defCount = Object.keys(meta.schema.definitions).length;
|
|
1282
|
+
const secCount = Object.keys(meta.manifest.blocks.sections || {}).length;
|
|
1283
|
+
const ldrCount = Object.keys(meta.manifest.blocks.loaders || {}).length;
|
|
1284
|
+
const appCount = Object.keys(meta.manifest.blocks.apps || {}).length;
|
|
1285
|
+
console.log(
|
|
1286
|
+
`\nGenerated schema: ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`,
|
|
1287
|
+
);
|