@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.2.2",
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.2.2",
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.mkdirSync(path.dirname(outFile), { recursive: true });
81
- fs.writeFileSync(jsonFile, "{}");
82
- fs.writeFileSync(outFile, TS_STUB);
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.readdirSync(blocksDir).filter((f) => f.endsWith(".json"));
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
- for (const file of files) {
94
- const { name, passes } = decodeBlockNameWithPasses(file);
95
- const fp = path.join(blocksDir, file);
96
- let parsed: unknown;
97
- try {
98
- parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
99
- } catch (e) {
100
- if (!silent) console.warn(`Failed to parse ${file}:`, e);
101
- continue;
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.mkdirSync(path.dirname(outFile), { recursive: true });
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.writeFileSync(jsonFile, jsonStr);
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.readFileSync(outFile, "utf-8");
179
+ existingTs = await fs.promises.readFile(outFile, "utf-8");
154
180
  } catch {}
155
181
  if (existingTs !== TS_STUB) {
156
- fs.writeFileSync(outFile, TS_STUB);
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
- const meta = generateMeta();
1287
- const outPath = path.resolve(process.cwd(), OUT_REL);
1288
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
1289
- fs.writeFileSync(outPath, JSON.stringify(meta, null, 2));
1290
-
1291
- const defCount = Object.keys(meta.schema.definitions).length;
1292
- const secCount = Object.keys(meta.manifest.blocks.sections || {}).length;
1293
- const ldrCount = Object.keys(meta.manifest.blocks.loaders || {}).length;
1294
- const appCount = Object.keys(meta.manifest.blocks.apps || {}).length;
1295
- console.log(
1296
- `\nGenerated schema: ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`,
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
+ }