@decocms/blocks-cli 7.2.2 → 7.3.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 +2 -2
- package/scripts/generate-blocks.ts +53 -27
- package/scripts/generate-schema.test.ts +74 -0
- package/scripts/generate-schema.ts +45 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.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": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"lint:unused": "knip"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@decocms/blocks": "7.
|
|
37
|
+
"@decocms/blocks": "7.3.0",
|
|
38
38
|
"ts-morph": "^27.0.0",
|
|
39
39
|
"tsx": "^4.19.0"
|
|
40
40
|
},
|
|
@@ -77,39 +77,65 @@ export async function generateBlocks(
|
|
|
77
77
|
if (!silent) {
|
|
78
78
|
console.warn(`Blocks directory not found: ${blocksDir} — generating empty barrel.`);
|
|
79
79
|
}
|
|
80
|
-
fs.
|
|
81
|
-
fs.
|
|
82
|
-
fs.
|
|
80
|
+
await fs.promises.mkdir(path.dirname(outFile), { recursive: true });
|
|
81
|
+
await fs.promises.writeFile(jsonFile, "{}");
|
|
82
|
+
await fs.promises.writeFile(outFile, TS_STUB);
|
|
83
83
|
return { count: 0, collisions: 0, jsonFile, outFile, empty: true, blocks: {} };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
const files = fs.
|
|
86
|
+
const files = (await fs.promises.readdir(blocksDir)).filter((f) => f.endsWith(".json"));
|
|
87
87
|
|
|
88
88
|
// Read each file into a Candidate, then let the dedupe lib pick the winner
|
|
89
89
|
// per decoded key and report any collisions. See `lib/blocks-dedupe.ts` for
|
|
90
90
|
// the priority order and the rationale behind it (TL;DR: never use file size,
|
|
91
91
|
// don't trust mtime alone in CI clones).
|
|
92
|
+
//
|
|
93
|
+
// Reads run as bounded-concurrency async I/O (not a synchronous
|
|
94
|
+
// readFileSync/statSync loop) so this whole-directory scan yields the event
|
|
95
|
+
// loop between batches. On dev cold-start the Vite plugin fires this
|
|
96
|
+
// fire-and-forget alongside Vite's own startup; a synchronous scan of a few
|
|
97
|
+
// hundred `.deco/blocks` files blocked the loop long enough to delay `ready`
|
|
98
|
+
// by ~1s (materially worse under a CPU quota, e.g. a sandbox pod). Batched to
|
|
99
|
+
// keep the open-fd count bounded (avoid EMFILE on large decofiles).
|
|
92
100
|
const candidatesWithKeys: Array<{ candidate: Candidate; key: string }> = [];
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
const READ_BATCH = 64;
|
|
102
|
+
for (let i = 0; i < files.length; i += READ_BATCH) {
|
|
103
|
+
const batch = await Promise.all(
|
|
104
|
+
files.slice(i, i + READ_BATCH).map(async (file) => {
|
|
105
|
+
const fp = path.join(blocksDir, file);
|
|
106
|
+
try {
|
|
107
|
+
const [raw, stat] = await Promise.all([
|
|
108
|
+
fs.promises.readFile(fp, "utf-8"),
|
|
109
|
+
fs.promises.stat(fp),
|
|
110
|
+
]);
|
|
111
|
+
return { file, raw, mtimeMs: stat.mtimeMs };
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (!silent) console.warn(`Failed to read ${file}:`, e);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
for (const entry of batch) {
|
|
119
|
+
if (!entry) continue;
|
|
120
|
+
const { name, passes } = decodeBlockNameWithPasses(entry.file);
|
|
121
|
+
let parsed: unknown;
|
|
122
|
+
try {
|
|
123
|
+
parsed = JSON.parse(entry.raw);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
if (!silent) console.warn(`Failed to parse ${entry.file}:`, e);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
candidatesWithKeys.push({
|
|
129
|
+
key: name,
|
|
130
|
+
candidate: {
|
|
131
|
+
file: entry.file,
|
|
132
|
+
passes,
|
|
133
|
+
mtimeMs: entry.mtimeMs,
|
|
134
|
+
hasPath: blockHasPath(parsed),
|
|
135
|
+
parsed,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
102
138
|
}
|
|
103
|
-
candidatesWithKeys.push({
|
|
104
|
-
key: name,
|
|
105
|
-
candidate: {
|
|
106
|
-
file,
|
|
107
|
-
passes,
|
|
108
|
-
mtimeMs: fs.statSync(fp).mtimeMs,
|
|
109
|
-
hasPath: blockHasPath(parsed),
|
|
110
|
-
parsed,
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
139
|
}
|
|
114
140
|
|
|
115
141
|
const { winners, collisions } = mergeCandidates(candidatesWithKeys);
|
|
@@ -138,11 +164,11 @@ export async function generateBlocks(
|
|
|
138
164
|
blocks[singleDecodeBlockName(c.file)] = c.parsed;
|
|
139
165
|
}
|
|
140
166
|
|
|
141
|
-
fs.
|
|
167
|
+
await fs.promises.mkdir(path.dirname(outFile), { recursive: true });
|
|
142
168
|
|
|
143
169
|
// 1. Compact JSON — the real data (no pretty-printing to save ~40% size)
|
|
144
170
|
const jsonStr = JSON.stringify(blocks);
|
|
145
|
-
fs.
|
|
171
|
+
await fs.promises.writeFile(jsonFile, jsonStr);
|
|
146
172
|
|
|
147
173
|
// 2. Thin TS wrapper — just for TypeScript tooling and as a Vite load target.
|
|
148
174
|
// Only write if content differs to avoid triggering Vite's file watcher,
|
|
@@ -150,10 +176,10 @@ export async function generateBlocks(
|
|
|
150
176
|
// TanStack Router during dev hot-reload.
|
|
151
177
|
let existingTs: string | undefined;
|
|
152
178
|
try {
|
|
153
|
-
existingTs = fs.
|
|
179
|
+
existingTs = await fs.promises.readFile(outFile, "utf-8");
|
|
154
180
|
} catch {}
|
|
155
181
|
if (existingTs !== TS_STUB) {
|
|
156
|
-
fs.
|
|
182
|
+
await fs.promises.writeFile(outFile, TS_STUB);
|
|
157
183
|
}
|
|
158
184
|
|
|
159
185
|
if (!silent) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Project } from "ts-morph";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { WIDGET_TYPE_FORMATS, applyWidgetFormat, typeToJsonSchema } from "./generate-schema";
|
|
4
|
+
|
|
5
|
+
describe("applyWidgetFormat", () => {
|
|
6
|
+
it("recovers an unresolved widget alias (empty schema) as string + format", () => {
|
|
7
|
+
// When a widget alias like `Color` is imported from a module ts-morph can't
|
|
8
|
+
// resolve (remote/CDN), the type comes through as `any` and typeToJsonSchema
|
|
9
|
+
// returns {}. The intended widget must still be recovered.
|
|
10
|
+
const schema: any = {};
|
|
11
|
+
applyWidgetFormat(schema, "Color");
|
|
12
|
+
expect(schema).toEqual({ type: "string", format: "color" });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it.each(Object.entries(WIDGET_TYPE_FORMATS))(
|
|
16
|
+
"recovers the %s alias to { type: string, format: %s } from an empty schema",
|
|
17
|
+
(alias, format) => {
|
|
18
|
+
const schema: any = {};
|
|
19
|
+
applyWidgetFormat(schema, alias);
|
|
20
|
+
expect(schema).toEqual({ type: "string", format });
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
it("applies the format to a resolved string schema", () => {
|
|
25
|
+
const schema: any = { type: "string" };
|
|
26
|
+
applyWidgetFormat(schema, "Color");
|
|
27
|
+
expect(schema).toEqual({ type: "string", format: "color" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does not overwrite a schema that resolved to a $ref", () => {
|
|
31
|
+
const schema: any = { $ref: "#/definitions/Foo" };
|
|
32
|
+
applyWidgetFormat(schema, "Color");
|
|
33
|
+
expect(schema).toEqual({ $ref: "#/definitions/Foo" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not touch a schema for a non-widget type hint", () => {
|
|
37
|
+
const schema: any = {};
|
|
38
|
+
applyWidgetFormat(schema, "SomeRandomType");
|
|
39
|
+
expect(schema).toEqual({});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("typeToJsonSchema with an unresolvable widget alias import", () => {
|
|
44
|
+
it("emits { type: string, format: color } for a Color field imported from a CDN", () => {
|
|
45
|
+
const project = new Project({
|
|
46
|
+
useInMemoryFileSystem: true,
|
|
47
|
+
compilerOptions: { skipLibCheck: true, noResolve: false },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The import target is not resolvable, mirroring apps that import `Color`
|
|
51
|
+
// from a remote deco-cx/apps CDN URL — `Color` therefore resolves to `any`.
|
|
52
|
+
const sf = project.createSourceFile(
|
|
53
|
+
"props.ts",
|
|
54
|
+
`
|
|
55
|
+
import type { Color } from "https://cdn.example.com/admin/widgets.ts";
|
|
56
|
+
|
|
57
|
+
export interface Props {
|
|
58
|
+
/** @title Cor do Texto */
|
|
59
|
+
textLeftColor?: Color;
|
|
60
|
+
}
|
|
61
|
+
`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const propsType = sf.getInterfaceOrThrow("Props").getType();
|
|
65
|
+
const schema = typeToJsonSchema(propsType);
|
|
66
|
+
|
|
67
|
+
expect(schema.type).toBe("object");
|
|
68
|
+
expect(schema.properties.textLeftColor).toEqual({
|
|
69
|
+
title: "Cor do Texto",
|
|
70
|
+
type: "string",
|
|
71
|
+
format: "color",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
/**
|
|
5
6
|
* Schema Generator for deco admin compatibility.
|
|
6
7
|
*
|
|
@@ -153,7 +154,7 @@ function applyJsDocToSchema(schema: any, tags: Record<string, string>): void {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
const WIDGET_TYPE_FORMATS: Record<string, string> = {
|
|
157
|
+
export const WIDGET_TYPE_FORMATS: Record<string, string> = {
|
|
157
158
|
ImageWidget: "image-uri",
|
|
158
159
|
VideoWidget: "video-uri",
|
|
159
160
|
HTMLWidget: "html",
|
|
@@ -183,7 +184,7 @@ function applyWidgetDetection(schema: any, typeText: string): void {
|
|
|
183
184
|
* Smart widget format application that handles arrays, nullable types,
|
|
184
185
|
* and union types by applying the format to the correct inner schema.
|
|
185
186
|
*/
|
|
186
|
-
function applyWidgetFormat(schema: any, typeHint: string): void {
|
|
187
|
+
export function applyWidgetFormat(schema: any, typeHint: string): void {
|
|
187
188
|
const matchedFormat = Object.entries(WIDGET_TYPE_FORMATS).find(
|
|
188
189
|
([wt]) => typeHint === wt || typeHint.includes(wt),
|
|
189
190
|
)?.[1];
|
|
@@ -205,6 +206,12 @@ function applyWidgetFormat(schema: any, typeHint: string): void {
|
|
|
205
206
|
variant.format = matchedFormat;
|
|
206
207
|
}
|
|
207
208
|
}
|
|
209
|
+
} else if (!schema.type && !schema.$ref) {
|
|
210
|
+
// Widget alias (e.g. `Color`) not resolvable by ts-morph (remote/CDN import)
|
|
211
|
+
// → it came through as `any`, so typeToJsonSchema returned an empty schema.
|
|
212
|
+
// Every widget alias is string-based, so recover the intended widget here.
|
|
213
|
+
schema.type = "string";
|
|
214
|
+
schema.format = matchedFormat;
|
|
208
215
|
}
|
|
209
216
|
}
|
|
210
217
|
|
|
@@ -262,7 +269,7 @@ function extractLoaderOutputTypeName(sourceFile: SourceFile): string | null {
|
|
|
262
269
|
return ret.getSymbol()?.getName() ?? ret.getAliasSymbol()?.getName() ?? null;
|
|
263
270
|
}
|
|
264
271
|
|
|
265
|
-
function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?: GenerationContext): any {
|
|
272
|
+
export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?: GenerationContext): any {
|
|
266
273
|
const typeText = type.getText();
|
|
267
274
|
if (visited.has(typeText)) return { type: "object" };
|
|
268
275
|
visited.add(typeText);
|
|
@@ -1283,15 +1290,38 @@ function generateMeta(): MetaResponse {
|
|
|
1283
1290
|
};
|
|
1284
1291
|
}
|
|
1285
1292
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
);
|
|
1293
|
+
function isMainModule(): boolean {
|
|
1294
|
+
// True when this file is the process entrypoint (invoked directly), false when
|
|
1295
|
+
// it's imported (e.g. from tests) — the guard keeps the module importable
|
|
1296
|
+
// without triggering a full filesystem scan + write.
|
|
1297
|
+
//
|
|
1298
|
+
// Compare the *realpath* of both sides rather than the raw URL: under tsx /
|
|
1299
|
+
// pnpm the entrypoint is reached through symlinks (pnpm's node_modules,
|
|
1300
|
+
// macOS /tmp → /private/tmp), so argv[1] and import.meta.url spell the same
|
|
1301
|
+
// file differently. A raw string compare would silently return false and skip
|
|
1302
|
+
// generation. realpathSync collapses symlinks so both resolve identically.
|
|
1303
|
+
const entry = process.argv[1];
|
|
1304
|
+
if (!entry) return false;
|
|
1305
|
+
try {
|
|
1306
|
+
const entryPath = fs.realpathSync(path.resolve(entry));
|
|
1307
|
+
const selfPath = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
1308
|
+
return entryPath === selfPath;
|
|
1309
|
+
} catch {
|
|
1310
|
+
return false;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (isMainModule()) {
|
|
1315
|
+
const meta = generateMeta();
|
|
1316
|
+
const outPath = path.resolve(process.cwd(), OUT_REL);
|
|
1317
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
1318
|
+
fs.writeFileSync(outPath, JSON.stringify(meta, null, 2));
|
|
1319
|
+
|
|
1320
|
+
const defCount = Object.keys(meta.schema.definitions).length;
|
|
1321
|
+
const secCount = Object.keys(meta.manifest.blocks.sections || {}).length;
|
|
1322
|
+
const ldrCount = Object.keys(meta.manifest.blocks.loaders || {}).length;
|
|
1323
|
+
const appCount = Object.keys(meta.manifest.blocks.apps || {}).length;
|
|
1324
|
+
console.log(
|
|
1325
|
+
`\nGenerated schema: ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`,
|
|
1326
|
+
);
|
|
1327
|
+
}
|