@decocms/blocks-cli 7.17.1 → 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.1",
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.1",
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
@@ -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
  */
@@ -879,7 +908,13 @@ function resolvePropsViaReExport(
879
908
  return null;
880
909
  }
881
910
 
882
- 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[] {
883
918
  const results: string[] = [];
884
919
  if (!fs.existsSync(dir)) return results;
885
920
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -887,10 +922,10 @@ function findTsxFiles(dir: string): string[] {
887
922
  if (entry.isDirectory()) {
888
923
  // The exclusion predicate targets generated/test *files* — a directory
889
924
  // named e.g. `foo.gen.ts` is a real path segment and must still be walked.
890
- results.push(...findTsxFiles(full));
925
+ results.push(...findTsxFiles(full, exts));
891
926
  } else if (
892
927
  !isExcludedCodegenFile(entry.name) &&
893
- (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts"))
928
+ exts.some((e) => entry.name.endsWith(e))
894
929
  ) {
895
930
  results.push(full);
896
931
  }
@@ -1197,6 +1232,11 @@ function generateMeta(): MetaResponse {
1197
1232
  ];
1198
1233
  for (const wrapper of COMMERCE_EXTENSION_WRAPPERS) {
1199
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;
1200
1240
  const wrapperDefKey = toBase64(wrapper.key);
1201
1241
  definitions[wrapperDefKey] = {
1202
1242
  title: wrapper.key,
@@ -1238,7 +1278,13 @@ function generateMeta(): MetaResponse {
1238
1278
  process.exit(1);
1239
1279
  }
1240
1280
 
1241
- 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);
1242
1288
  console.log(`Found ${sectionFiles.length} section files`);
1243
1289
  for (const filePath of sectionFiles) {
1244
1290
  getSourceFile(project, filePath, sourceFileCache);
@@ -1419,6 +1465,13 @@ function generateMeta(): MetaResponse {
1419
1465
  }
1420
1466
  }
1421
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
+
1422
1475
  // Pages, matchers, etc. are injected at runtime by composeMeta() in src/admin/schema.ts.
1423
1476
  // Site-level loaders are generated here (first pass above).
1424
1477
  const emptyAnyOf = { anyOf: [] as any[] };