@decocms/blocks-cli 7.17.0 → 7.18.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.17.0",
3
+ "version": "7.18.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": {
@@ -30,7 +30,7 @@
30
30
  "lint:unused": "knip"
31
31
  },
32
32
  "dependencies": {
33
- "@decocms/blocks": "7.17.0",
33
+ "@decocms/blocks": "7.18.0",
34
34
  "ts-morph": "^27.0.0",
35
35
  "tsx": "^4.22.5"
36
36
  },
@@ -5,9 +5,11 @@ import * as path from "node:path";
5
5
  import { Project } from "ts-morph";
6
6
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
7
  import {
8
+ EITRI_FORMAT_ALIASES,
8
9
  WIDGET_TYPE_FORMATS,
9
10
  applyWidgetFormat,
10
11
  definitionIdForPath,
12
+ normalizeFormats,
11
13
  typeToJsonSchema,
12
14
  } from "./generate-schema";
13
15
 
@@ -29,6 +31,38 @@ describe("definitionIdForPath", () => {
29
31
  });
30
32
  });
31
33
 
34
+ describe("normalizeFormats (Eitri @format aliases)", () => {
35
+ it("remaps a known alias in a nested prop schema", () => {
36
+ const defs = {
37
+ "abc@Props": {
38
+ type: "object",
39
+ properties: {
40
+ datetime: { type: "string", format: "datetime", title: "Publish date." },
41
+ post: { type: "string", format: "textarea" },
42
+ },
43
+ },
44
+ };
45
+ normalizeFormats(defs, EITRI_FORMAT_ALIASES);
46
+ expect(defs["abc@Props"].properties.datetime.format).toBe("date-time");
47
+ // textarea is already a valid widget format — left untouched.
48
+ expect(defs["abc@Props"].properties.post.format).toBe("textarea");
49
+ });
50
+
51
+ it("recurses through arrays and leaves unknown formats alone", () => {
52
+ const node = {
53
+ items: [{ format: "datetime" }, { format: "email" }],
54
+ };
55
+ normalizeFormats(node, EITRI_FORMAT_ALIASES);
56
+ expect(node.items[0].format).toBe("date-time");
57
+ expect(node.items[1].format).toBe("email");
58
+ });
59
+
60
+ it("is a no-op on primitives / null", () => {
61
+ expect(() => normalizeFormats(null, EITRI_FORMAT_ALIASES)).not.toThrow();
62
+ expect(() => normalizeFormats("datetime", EITRI_FORMAT_ALIASES)).not.toThrow();
63
+ });
64
+ });
65
+
32
66
  describe("applyWidgetFormat", () => {
33
67
  it("recovers an unresolved widget alias (empty schema) as string + format", () => {
34
68
  // When a widget alias like `Color` is imported from a module ts-morph can't
@@ -157,6 +191,66 @@ describe("typeToJsonSchema with intersection types", () => {
157
191
  }, 30_000);
158
192
  });
159
193
 
194
+ describe("typeToJsonSchema loader block-ref matching", () => {
195
+ // Regression: a loader that returns an array whose element type has no
196
+ // resolvable name (`VNode[]`, `string[]`, …) used to be bucketed under the
197
+ // generic key "Array". Because an array type's OWN symbol name is also
198
+ // "Array", EVERY array-of-objects section prop (`Collection[]`, `Tab[]`)
199
+ // matched that bucket and collapsed into a block-ref picker — so the array
200
+ // was no longer editable and its item fields (label, nested loaders) all
201
+ // disappeared from the CMS form. The two guards (drop the "Array" bucket at
202
+ // registration; never look it up from an array prop's symbol name) keep the
203
+ // array inline and editable.
204
+ it("does not collapse an array-of-objects prop into a block-ref via the generic 'Array' bucket", () => {
205
+ const project = new Project({
206
+ useInMemoryFileSystem: true,
207
+ compilerOptions: { skipLibCheck: true },
208
+ });
209
+ const sf = project.createSourceFile(
210
+ "props.ts",
211
+ `
212
+ interface Product { productID: string; name: string; }
213
+ interface Collection { label: string; products: Product[] | null; }
214
+ export interface Props {
215
+ collections: Collection[];
216
+ topLevelProducts?: Product[] | null;
217
+ }
218
+ `,
219
+ );
220
+ const ctx = {
221
+ // Mimics a site where List/Sections (VNode[]) and skuList (string[])
222
+ // would otherwise land in an "Array" bucket, plus a real Product[] loader.
223
+ outputTypeToLoaderKeys: new Map<string, string[]>([
224
+ ["Product[]", ["site/loaders/algolia/products/list.ts"]],
225
+ ["Array", ["site/loaders/List/Sections.tsx", "site/loaders/skuList.ts"]],
226
+ ]),
227
+ };
228
+
229
+ const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType(), new Set(), ctx);
230
+ const collections = schema.properties.collections;
231
+
232
+ // Stays an editable array of objects — NOT a block-ref anyOf.
233
+ expect(collections.type).toBe("array");
234
+ expect(collections.anyOf).toBeUndefined();
235
+
236
+ const item = collections.items;
237
+ expect(item.type).toBe("object");
238
+ expect(item.properties.label).toMatchObject({ type: "string" });
239
+ // The nested Product[] field still resolves to a loader picker (its type
240
+ // name matches a registered loader output).
241
+ expect(item.properties.products.anyOf).toEqual([
242
+ { $ref: "#/definitions/Resolvable" },
243
+ { $ref: "#/definitions/c2l0ZS9sb2FkZXJzL2FsZ29saWEvcHJvZHVjdHMvbGlzdC50cw==" },
244
+ ]);
245
+
246
+ // Control: a top-level Product[] prop still resolves to a loader picker.
247
+ expect(schema.properties.topLevelProducts.anyOf).toEqual([
248
+ { $ref: "#/definitions/Resolvable" },
249
+ { $ref: "#/definitions/c2l0ZS9sb2FkZXJzL2FsZ29saWEvcHJvZHVjdHMvbGlzdC50cw==" },
250
+ ]);
251
+ }, 30_000);
252
+ });
253
+
160
254
  // ---------------------------------------------------------------------------
161
255
  // Default output path (.deco/) + legacy warning — subprocess, mirrors the
162
256
  // pattern in generate-sections.test.ts. generate-schema.ts IS guarded by
@@ -236,6 +236,35 @@ export const WIDGET_TYPE_FORMATS: Record<string, string> = {
236
236
  DateTimeWidget: "date-time",
237
237
  };
238
238
 
239
+ /**
240
+ * Eitri authors annotate fields with JSDoc `@format` using Eitri's own vocab
241
+ * (e.g. `@format datetime`). Map those onto the JSON-Schema `format` values the
242
+ * Studio widget layer understands. `textarea` already matches, so only the
243
+ * divergent ones need remapping. Applied only for --platform eitri.
244
+ */
245
+ export const EITRI_FORMAT_ALIASES: Record<string, string> = {
246
+ datetime: "date-time",
247
+ };
248
+
249
+ /**
250
+ * Recursively remap `format` string values in a JSON-Schema tree using the
251
+ * given alias map. Mutates in place; only touches `format` fields whose value
252
+ * is a known alias, so unrelated schema is untouched.
253
+ */
254
+ export function normalizeFormats(node: unknown, aliases: Record<string, string>): void {
255
+ if (Array.isArray(node)) {
256
+ for (const item of node) normalizeFormats(item, aliases);
257
+ return;
258
+ }
259
+ if (node && typeof node === "object") {
260
+ const obj = node as Record<string, unknown>;
261
+ if (typeof obj.format === "string" && aliases[obj.format]) {
262
+ obj.format = aliases[obj.format];
263
+ }
264
+ for (const key of Object.keys(obj)) normalizeFormats(obj[key], aliases);
265
+ }
266
+ }
267
+
239
268
  /**
240
269
  * Detect known widget types and set the appropriate format.
241
270
  */
@@ -345,15 +374,21 @@ function extractLoaderOutputTypeName(sourceFile: SourceFile): string | null {
345
374
  const nonNull = ret.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
346
375
  if (nonNull.length === 1) ret = nonNull[0];
347
376
  }
348
- // Unwrap array element type — Product[] → "Product[]" (keyed as array)
377
+ // Unwrap array element type — Product[] → "Product[]" (keyed as array).
378
+ // When the element type has no resolvable name (primitives like `string[]`,
379
+ // or opaque types like `VNode[]`), there is NO meaningful output-type key.
380
+ // Returning the generic `Array` symbol here would bucket the loader under
381
+ // "Array", which then collides with EVERY array-typed section prop
382
+ // (`Collection[]`, `Tab[]`, …) and wrongly turns them into loader pickers.
349
383
  if (ret.isArray()) {
350
384
  const elType = ret.getArrayElementType();
351
- if (elType) {
352
- const elName = elType.getSymbol()?.getName() ?? elType.getAliasSymbol()?.getName() ?? null;
353
- if (elName) return `${elName}[]`;
354
- }
385
+ const elName = elType?.getSymbol()?.getName() ?? elType?.getAliasSymbol()?.getName() ?? null;
386
+ return elName ? `${elName}[]` : null;
355
387
  }
356
- return ret.getSymbol()?.getName() ?? ret.getAliasSymbol()?.getName() ?? null;
388
+ // Reject anonymous object returns (`{ }` → symbol name "__type"): they are
389
+ // not a nameable output type and would over-match anonymous-object props.
390
+ const name = ret.getSymbol()?.getName() ?? ret.getAliasSymbol()?.getName() ?? null;
391
+ return name && name !== "__type" ? name : null;
357
392
  }
358
393
 
359
394
  export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?: GenerationContext): any {
@@ -565,7 +600,12 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
565
600
  // baseHint strips "| null | undefined" so "ProductListingPage | null" → "ProductListingPage"
566
601
  if (ctx?.outputTypeToLoaderKeys) {
567
602
  const typeSym = propType.getSymbol() ?? propType.getAliasSymbol();
568
- const outputTypeName = typeSym?.getName() ?? baseHint;
603
+ // An array type's symbol name is always the generic "Array", which is
604
+ // never a meaningful loader output key — fall back to the AST type
605
+ // text (`baseHint`, e.g. "Collection[]") instead. The element-based
606
+ // `${elName}[]` lookup below handles arrays precisely.
607
+ const symName = typeSym?.getName();
608
+ const outputTypeName = symName && symName !== "Array" ? symName : baseHint;
569
609
  let matchingLoaders =
570
610
  ctx.outputTypeToLoaderKeys.get(outputTypeName) ??
571
611
  (outputTypeName !== baseHint ? ctx.outputTypeToLoaderKeys.get(baseHint) : undefined);
@@ -868,7 +908,13 @@ function resolvePropsViaReExport(
868
908
  return null;
869
909
  }
870
910
 
871
- function findTsxFiles(dir: string): string[] {
911
+ // Default scan extensions. Sections may widen this to also include .jsx/.js
912
+ // on stacks whose sections can be plain JavaScript (e.g. Eitri) — see
913
+ // SECTION_EXTS. Loaders/apps stay TS-only (their input types come from real
914
+ // TypeScript signatures, which JS files cannot express).
915
+ const DEFAULT_EXTS = [".tsx", ".ts"] as const;
916
+
917
+ function findTsxFiles(dir: string, exts: readonly string[] = DEFAULT_EXTS): string[] {
872
918
  const results: string[] = [];
873
919
  if (!fs.existsSync(dir)) return results;
874
920
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -876,10 +922,10 @@ function findTsxFiles(dir: string): string[] {
876
922
  if (entry.isDirectory()) {
877
923
  // The exclusion predicate targets generated/test *files* — a directory
878
924
  // named e.g. `foo.gen.ts` is a real path segment and must still be walked.
879
- results.push(...findTsxFiles(full));
925
+ results.push(...findTsxFiles(full, exts));
880
926
  } else if (
881
927
  !isExcludedCodegenFile(entry.name) &&
882
- (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts"))
928
+ exts.some((e) => entry.name.endsWith(e))
883
929
  ) {
884
930
  results.push(full);
885
931
  }
@@ -1186,6 +1232,11 @@ function generateMeta(): MetaResponse {
1186
1232
  ];
1187
1233
  for (const wrapper of COMMERCE_EXTENSION_WRAPPERS) {
1188
1234
  const matchingLoaders = outputTypeToLoaderKeys.get(wrapper.outputType) ?? [];
1235
+ // A wrapper whose base loader type has no matching loaders in the site is
1236
+ // useless (its `data` picker would only offer Resolvable). Non-commerce
1237
+ // sites (e.g. Eitri) have none, so skip it instead of injecting a phantom
1238
+ // commerce loader into the picker.
1239
+ if (matchingLoaders.length === 0) continue;
1189
1240
  const wrapperDefKey = toBase64(wrapper.key);
1190
1241
  definitions[wrapperDefKey] = {
1191
1242
  title: wrapper.key,
@@ -1227,7 +1278,13 @@ function generateMeta(): MetaResponse {
1227
1278
  process.exit(1);
1228
1279
  }
1229
1280
 
1230
- const sectionFiles = findTsxFiles(sectionsDir);
1281
+ // Eitri sections can be plain JavaScript (.js/.jsx) as well as TS; other
1282
+ // stacks stay TS-only so a stray .js helper in src/sections isn't mistaken
1283
+ // for a section. A JS file with no extractable Props still registers as a
1284
+ // (prop-less) section rather than being silently skipped.
1285
+ const sectionExts =
1286
+ PLATFORM === "eitri" ? [".tsx", ".ts", ".jsx", ".js"] : DEFAULT_EXTS;
1287
+ const sectionFiles = findTsxFiles(sectionsDir, sectionExts);
1231
1288
  console.log(`Found ${sectionFiles.length} section files`);
1232
1289
  for (const filePath of sectionFiles) {
1233
1290
  getSourceFile(project, filePath, sourceFileCache);
@@ -1408,6 +1465,13 @@ function generateMeta(): MetaResponse {
1408
1465
  }
1409
1466
  }
1410
1467
 
1468
+ // Eitri @format aliases → JSON-Schema formats (e.g. datetime → date-time).
1469
+ // Done as a final pass over the generated definitions so every prop schema,
1470
+ // however deeply nested, is normalized before it reaches Studio.
1471
+ if (PLATFORM === "eitri") {
1472
+ normalizeFormats(definitions, EITRI_FORMAT_ALIASES);
1473
+ }
1474
+
1411
1475
  // Pages, matchers, etc. are injected at runtime by composeMeta() in src/admin/schema.ts.
1412
1476
  // Site-level loaders are generated here (first pass above).
1413
1477
  const emptyAnyOf = { anyOf: [] as any[] };