@decocms/blocks-cli 7.16.7 → 7.17.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.16.7",
3
+ "version": "7.17.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.16.7",
33
+ "@decocms/blocks": "7.17.0",
34
34
  "ts-morph": "^27.0.0",
35
35
  "tsx": "^4.22.5"
36
36
  },
@@ -100,6 +100,63 @@ describe("typeToJsonSchema with an unresolvable widget alias import", () => {
100
100
  }, 30_000);
101
101
  });
102
102
 
103
+ describe("typeToJsonSchema with intersection types", () => {
104
+ it("keeps a branded primitive (`string & { __brand }`) as a string", () => {
105
+ const project = new Project({
106
+ useInMemoryFileSystem: true,
107
+ compilerOptions: { skipLibCheck: true },
108
+ });
109
+ const sf = project.createSourceFile(
110
+ "props.ts",
111
+ `
112
+ type Slug = string & { readonly __brand: unique symbol };
113
+ export interface Props { slug?: Slug }
114
+ `,
115
+ );
116
+ const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType());
117
+ expect(schema.properties.slug.type).toBe("string");
118
+ }, 30_000);
119
+
120
+ it("expands an object intersection into a merged field set (recursive menu shape)", () => {
121
+ const project = new Project({
122
+ useInMemoryFileSystem: true,
123
+ compilerOptions: { skipLibCheck: true },
124
+ });
125
+ // Mirrors the granado header `SiteNavigationElement` recursive workaround:
126
+ // nested children written as `Leaf & { children?: Array<…> }`. Before the
127
+ // intersection branch these collapsed to `children: { items: { type: "string" } }`.
128
+ const sf = project.createSourceFile(
129
+ "props.ts",
130
+ `
131
+ interface Leaf {
132
+ /** The name of the item. */
133
+ name?: string;
134
+ /** URL of the item. */
135
+ url?: string;
136
+ }
137
+ export interface Props {
138
+ navItems?: Array<
139
+ Leaf & {
140
+ children?: Array<Leaf & { children?: Leaf[] }>;
141
+ }
142
+ >;
143
+ }
144
+ `,
145
+ );
146
+ const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType());
147
+
148
+ const item = schema.properties.navItems.items;
149
+ expect(item.type).toBe("object");
150
+ expect(Object.keys(item.properties).sort()).toEqual(["children", "name", "url"]);
151
+
152
+ const child = item.properties.children.items;
153
+ expect(child.type).toBe("object");
154
+ expect(child.properties.name.type).toBe("string");
155
+ expect(child.properties.url.type).toBe("string");
156
+ expect(child.properties.children.items.type).toBe("object");
157
+ }, 30_000);
158
+ });
159
+
103
160
  // ---------------------------------------------------------------------------
104
161
  // Default output path (.deco/) + legacy warning — subprocess, mirrors the
105
162
  // pattern in generate-sections.test.ts. generate-schema.ts IS guarded by
@@ -27,6 +27,12 @@ import { fileURLToPath } from "node:url";
27
27
  * --skip-apps Skip app schema generation
28
28
  * --out Output file (default: ".deco/meta.gen.json")
29
29
  * --platform Platform name (default: "cloudflare")
30
+ * --compose Run composeMeta() before writing so the output is
31
+ * SELF-CONTAINED (bakes in Page, matchers, __SECTION_REF__,
32
+ * Resolvable). For consumers that read meta.gen.json straight
33
+ * from disk with no runtime (FS-based Studio / Eitri stack).
34
+ * --framework With --compose, the value written to the `framework` field
35
+ * (default: "tanstack-start").
30
36
  *
31
37
  * If no `--out` is passed and the OLD default (src/server/admin/meta.gen.json)
32
38
  * still exists on disk, a one-line legacy warning is printed to stderr and the
@@ -75,6 +81,15 @@ let APPS_REL = "src/apps";
75
81
  let SKIP_APPS = false;
76
82
  let OUT_REL = NEW_DEFAULT_OUT_REL;
77
83
  let PLATFORM = "cloudflare";
84
+ // When true, run composeMeta() over the generated site meta before writing, so
85
+ // the output file is SELF-CONTAINED — it carries the framework block types
86
+ // (Page, matchers, __SECTION_REF__, Resolvable) that composeMeta otherwise
87
+ // injects at runtime. Required by consumers that read meta.gen.json straight
88
+ // from the filesystem with no runtime (e.g. the FS-based Studio / Eitri stack).
89
+ let COMPOSE = false;
90
+ // Value written to the composed meta's `framework` field (only used with
91
+ // --compose). Defaults to composeMeta's historical "tanstack-start".
92
+ let FRAMEWORK = "tanstack-start";
78
93
 
79
94
  if (isMainModule()) {
80
95
  SITE_NAMESPACE = arg("namespace", SITE_NAMESPACE);
@@ -87,6 +102,8 @@ if (isMainModule()) {
87
102
  const outFileExplicit = argv.includes("--out");
88
103
  OUT_REL = arg("out", NEW_DEFAULT_OUT_REL);
89
104
  PLATFORM = arg("platform", PLATFORM);
105
+ COMPOSE = argv.includes("--compose");
106
+ FRAMEWORK = arg("framework", FRAMEWORK);
90
107
  if (!outFileExplicit && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_REL))) {
91
108
  warnLegacyArtifact(OLD_DEFAULT_OUT_REL, NEW_DEFAULT_OUT_REL);
92
109
  }
@@ -430,6 +447,37 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
430
447
  return result;
431
448
  }
432
449
 
450
+ // Intersection (A & B). TypeScript merges the members, but ts-morph reports
451
+ // the type as neither object nor interface, so without this branch it falls
452
+ // through to the `{ type: "string" }` fallback — which is why recursive
453
+ // menu/navigation types written as `Leaf & { children?: Array<Leaf & …> }`
454
+ // lost every field and rendered as a bare string/block-ref in the CMS.
455
+ // Two shapes matter:
456
+ // - Branded primitives (`string & { __brand }`) → keep the primitive.
457
+ // - Object intersections → merge every member's properties into one object.
458
+ if (type.isIntersection()) {
459
+ const parts = type.getIntersectionTypes();
460
+ const primitive = parts.find((t) => t.isString() || t.isNumber() || t.isBoolean());
461
+ if (primitive) return typeToJsonSchema(primitive, new Set(visited), ctx);
462
+
463
+ const merged: any = { type: "object", properties: {} };
464
+ const required = new Set<string>();
465
+ for (const part of parts) {
466
+ const sub = typeToJsonSchema(part, new Set(visited), ctx);
467
+ if (sub?.type !== "object" || !sub.properties) continue;
468
+ Object.assign(merged.properties, sub.properties);
469
+ for (const r of sub.required ?? []) required.add(r);
470
+ // Carry object-level annotations (title, @titleBy, …) from members,
471
+ // first member wins so an earlier explicit value is never clobbered.
472
+ for (const [k, v] of Object.entries(sub)) {
473
+ if (k === "type" || k === "properties" || k === "required") continue;
474
+ if (!(k in merged)) merged[k] = v;
475
+ }
476
+ }
477
+ if (required.size > 0) merged.required = [...required];
478
+ return merged;
479
+ }
480
+
433
481
  if (type.isObject() || type.isInterface()) {
434
482
  // Runtime-injected platform types (loader `url: URL`, `req: Request`, …)
435
483
  // are not CMS-configurable — hide instead of expanding the whole DOM
@@ -1410,16 +1458,28 @@ function isMainModule(): boolean {
1410
1458
  }
1411
1459
 
1412
1460
  if (isMainModule()) {
1413
- const meta = generateMeta();
1414
- const outPath = path.resolve(process.cwd(), OUT_REL);
1415
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
1416
- fs.writeFileSync(outPath, JSON.stringify(meta, null, 2));
1417
-
1418
- const defCount = Object.keys(meta.schema.definitions).length;
1419
- const secCount = Object.keys(meta.manifest.blocks.sections || {}).length;
1420
- const ldrCount = Object.keys(meta.manifest.blocks.loaders || {}).length;
1421
- const appCount = Object.keys(meta.manifest.blocks.apps || {}).length;
1422
- console.log(
1423
- `\nGenerated schema: ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`,
1424
- );
1461
+ // Wrapped in an async IIFE so --compose can dynamically import composeMeta
1462
+ // ONLY when requested — the default path (and any test importing this
1463
+ // module's pure exports) never pulls in the @decocms/blocks/cms barrel.
1464
+ void (async () => {
1465
+ let meta = generateMeta();
1466
+ if (COMPOSE) {
1467
+ const { composeMeta } = await import("@decocms/blocks/cms");
1468
+ // composeMeta returns @decocms/blocks' MetaResponse (platform optional);
1469
+ // this file's local MetaResponse requires platform. It's always present
1470
+ // (composeMeta spreads siteMeta, which set it), so the cast is safe.
1471
+ meta = composeMeta(meta, { framework: FRAMEWORK }) as MetaResponse;
1472
+ }
1473
+ const outPath = path.resolve(process.cwd(), OUT_REL);
1474
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1475
+ fs.writeFileSync(outPath, JSON.stringify(meta, null, 2));
1476
+
1477
+ const defCount = Object.keys(meta.schema.definitions).length;
1478
+ const secCount = Object.keys(meta.manifest.blocks.sections || {}).length;
1479
+ const ldrCount = Object.keys(meta.manifest.blocks.loaders || {}).length;
1480
+ const appCount = Object.keys(meta.manifest.blocks.apps || {}).length;
1481
+ console.log(
1482
+ `\nGenerated schema${COMPOSE ? " (self-contained)" : ""}: ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`,
1483
+ );
1484
+ })();
1425
1485
  }