@decocms/blocks-cli 7.65.0 → 7.65.2

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.65.0",
3
+ "version": "7.65.2",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "lint:unused": "knip"
34
34
  },
35
35
  "dependencies": {
36
- "@decocms/blocks": "7.65.0",
36
+ "@decocms/blocks": "7.65.2",
37
37
  "ts-morph": "^27.0.0",
38
38
  "tsx": "^4.22.5"
39
39
  },
@@ -0,0 +1,186 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
6
+
7
+ interface Schema {
8
+ type?: string;
9
+ $ref?: string;
10
+ allOf?: Schema[];
11
+ anyOf?: Schema[];
12
+ properties?: Record<string, Schema>;
13
+ items?: Schema;
14
+ required?: string[];
15
+ }
16
+
17
+ // Exercise loader discovery AND prop matching through the real CLI. A context
18
+ // pre-seeded with "Settings" would miss the bug in output-type registration.
19
+ describe("generate-schema named loader aliases", () => {
20
+ let fixture: string;
21
+ let props: Schema;
22
+
23
+ beforeAll(() => {
24
+ fixture = mkdtempSync(path.join(tmpdir(), "schema-loader-aliases-"));
25
+ const write = (relative: string, contents: string) => {
26
+ const file = path.join(fixture, relative);
27
+ mkdirSync(path.dirname(file), { recursive: true });
28
+ writeFileSync(file, contents);
29
+ };
30
+ write(
31
+ "tsconfig.json",
32
+ JSON.stringify({
33
+ compilerOptions: {
34
+ target: "ES2022",
35
+ module: "ESNext",
36
+ moduleResolution: "Bundler",
37
+ strict: true,
38
+ },
39
+ }),
40
+ );
41
+ write(
42
+ "src/types.ts",
43
+ `export interface Config { title: string; modules: unknown; }
44
+ export type Settings = Omit<Config, "modules">;
45
+ export type Picked = Pick<Config, "title">;
46
+ export type Plain = { label: string };
47
+ export type Tree = { id: string; children?: Tree[] };
48
+ export type Generic<T> = { value: T };
49
+ export interface Product { productID: string; }
50
+ export interface Collection { label: string; settings: Settings; }
51
+ export type Unrelated = { title: string };`,
52
+ );
53
+ const loaders: Record<string, string> = {
54
+ settings: "Settings",
55
+ nullable: "Promise<Settings | null>",
56
+ picked: "Picked",
57
+ plain: "Plain",
58
+ list: "Promise<Settings[] | null>",
59
+ tree: "Tree",
60
+ products: "Product[]",
61
+ anonymous: "{ title: string }",
62
+ anonymousList: "{ label: string }[]",
63
+ strings: "string[]",
64
+ directOmit: 'Omit<Config, "title">',
65
+ generic: "Generic<string>",
66
+ };
67
+ for (const [name, output] of Object.entries(loaders)) {
68
+ write(
69
+ `src/loaders/${name}.ts`,
70
+ `import type { Config, Settings, Picked, Plain, Tree, Generic, Product } from "../types";
71
+ export default function load(): ${output} { throw new Error("fixture only"); }`,
72
+ );
73
+ }
74
+ write(
75
+ "src/sections/Example.tsx",
76
+ `import type { Config, Settings as SiteSettings, Picked, Plain, Tree,
77
+ Generic, Product, Collection, Unrelated } from "../types";
78
+ export interface Props {
79
+ settings: SiteSettings;
80
+ picked?: Picked | null;
81
+ plain: Plain;
82
+ list: SiteSettings[];
83
+ nullableList?: Array<SiteSettings> | null;
84
+ tree: Tree;
85
+ products: Product[];
86
+ collections: Collection[];
87
+ anonymous: { title: string };
88
+ anonymousList: { other: number }[];
89
+ strings: string[];
90
+ unrelated: Unrelated;
91
+ directOmit: Omit<Config, "modules">;
92
+ generic: Generic<number>;
93
+ }
94
+ export default function Example(_props: Props) { return null; }`,
95
+ );
96
+
97
+ const run = spawnSync(
98
+ process.execPath,
99
+ [
100
+ "--import",
101
+ import.meta.resolve("tsx"),
102
+ path.resolve(__dirname, "generate-schema.ts"),
103
+ "--skip-apps",
104
+ ],
105
+ { cwd: fixture, encoding: "utf8", timeout: 30_000 },
106
+ );
107
+ expect(run.status, run.error?.message ?? run.stderr).toBe(0);
108
+ const meta = JSON.parse(readFileSync(path.join(fixture, ".deco/meta.gen.json"), "utf8"));
109
+ const section = meta.schema.definitions[btoa("site/sections/Example.tsx")];
110
+ const ref = section.allOf.find((part: Schema) => part.$ref).$ref;
111
+ props = meta.schema.definitions[ref.replace("#/definitions/", "")];
112
+ }, 30_000);
113
+
114
+ afterAll(() => {
115
+ if (fixture) rmSync(fixture, { recursive: true, force: true });
116
+ });
117
+
118
+ function field(name: string): Schema {
119
+ const schema = props.properties?.[name];
120
+ expect(schema, name).toBeDefined();
121
+ return schema!;
122
+ }
123
+
124
+ function inline(schema: Schema): Schema {
125
+ return schema.anyOf?.find((branch) => branch.type) ?? schema;
126
+ }
127
+
128
+ function expectLoader(schema: Schema, name: string) {
129
+ expect(schema.anyOf).toEqual(expect.arrayContaining([{ $ref: "#/definitions/Resolvable" }]));
130
+ expect(schema.anyOf).toContainEqual({
131
+ $ref: `#/definitions/${btoa(`site/loaders/${name}.ts`)}`,
132
+ });
133
+ expect(schema.properties).toBeUndefined();
134
+ expect(schema.anyOf).not.toContainEqual({
135
+ $ref: `#/definitions/${btoa("site/loaders/anonymousList.ts")}`,
136
+ });
137
+ }
138
+
139
+ it("matches an imported Omit alias, including a nullable async loader", () => {
140
+ expectLoader(field("settings"), "settings");
141
+ expectLoader(field("settings"), "nullable");
142
+ expect(props.required).toContain("settings");
143
+ });
144
+
145
+ it("matches Pick and plain object aliases without changing optionality", () => {
146
+ expectLoader(field("picked"), "picked");
147
+ expectLoader(field("plain"), "plain");
148
+ expect(props.required).not.toContain("picked");
149
+ });
150
+
151
+ it("matches aliases through arrays, Promise and nullable wrappers", () => {
152
+ expectLoader(field("list"), "list");
153
+ expectLoader(field("nullableList"), "list");
154
+ expect(props.required).not.toContain("nullableList");
155
+ });
156
+
157
+ it("matches a recursive alias without expanding its fields", () => {
158
+ expectLoader(field("tree"), "tree");
159
+ });
160
+
161
+ it("keeps named-interface array loaders working", () => {
162
+ expectLoader(field("products"), "products");
163
+ });
164
+
165
+ it("keeps unrelated object arrays editable and resolves their nested settings", () => {
166
+ const collections = inline(field("collections"));
167
+ expect(collections.type).toBe("array");
168
+ expect(collections.items?.properties?.label.type).toBe("string");
169
+ expectLoader(collections.items!.properties!.settings, "settings");
170
+ });
171
+
172
+ it("does not collapse unrelated anonymous arrays into an __type[] bucket", () => {
173
+ expect(inline(field("anonymousList")).items?.properties?.other.type).toBe("number");
174
+ });
175
+
176
+ it("does not register generic utility types or match different instantiations", () => {
177
+ expect(inline(field("directOmit")).properties?.title.type).toBe("string");
178
+ expect(inline(field("generic")).properties?.value.type).toBe("number");
179
+ });
180
+
181
+ it("does not match primitives, anonymous objects or unrelated named aliases", () => {
182
+ expect(inline(field("strings")).items?.type).toBe("string");
183
+ expect(inline(field("anonymous")).properties?.title.type).toBe("string");
184
+ expect(inline(field("unrelated")).properties?.title.type).toBe("string");
185
+ });
186
+ });
@@ -369,6 +369,37 @@ interface GenerationContext {
369
369
  outputTypeToLoaderKeys: Map<string, string[]>;
370
370
  }
371
371
 
372
+ function namedLoaderType(type: Type | undefined): string | null {
373
+ if (!type) return null;
374
+ const name = type.getSymbol()?.getName();
375
+ if (
376
+ name &&
377
+ name !== "__type" &&
378
+ name !== "__object" &&
379
+ name !== "Array" &&
380
+ name !== "ReadonlyArray"
381
+ ) {
382
+ return name;
383
+ }
384
+ // Mapped/object aliases have a synthetic symbol but retain their own name.
385
+ // Bare Omit<T, K>/Generic<T> must not share a bucket across instantiations.
386
+ const alias = type.getAliasSymbol();
387
+ return alias && type.getAliasTypeArguments().length === 0 ? alias.getName() : null;
388
+ }
389
+
390
+ /** Same bounded name lookup for loader outputs and section props; no shape matching. */
391
+ function loaderTypeName(type: Type): string | null {
392
+ if (type.isUnion()) {
393
+ const nonNull = type.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
394
+ if (nonNull.length === 1) type = nonNull[0];
395
+ }
396
+ if (type.isArray()) {
397
+ const name = namedLoaderType(type.getArrayElementType());
398
+ return name ? `${name}[]` : null;
399
+ }
400
+ return namedLoaderType(type);
401
+ }
402
+
372
403
  /**
373
404
  * Extract the return type name of a loader's default export.
374
405
  * Unwraps Promise<T> and T | null wrappers.
@@ -383,25 +414,7 @@ function extractLoaderOutputTypeName(sourceFile: SourceFile): string | null {
383
414
  const args = ret.getTypeArguments();
384
415
  if (args.length) ret = args[0];
385
416
  }
386
- if (ret.isUnion()) {
387
- const nonNull = ret.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
388
- if (nonNull.length === 1) ret = nonNull[0];
389
- }
390
- // Unwrap array element type — Product[] → "Product[]" (keyed as array).
391
- // When the element type has no resolvable name (primitives like `string[]`,
392
- // or opaque types like `VNode[]`), there is NO meaningful output-type key.
393
- // Returning the generic `Array` symbol here would bucket the loader under
394
- // "Array", which then collides with EVERY array-typed section prop
395
- // (`Collection[]`, `Tab[]`, …) and wrongly turns them into loader pickers.
396
- if (ret.isArray()) {
397
- const elType = ret.getArrayElementType();
398
- const elName = elType?.getSymbol()?.getName() ?? elType?.getAliasSymbol()?.getName() ?? null;
399
- return elName ? `${elName}[]` : null;
400
- }
401
- // Reject anonymous object returns (`{ … }` → symbol name "__type"): they are
402
- // not a nameable output type and would over-match anonymous-object props.
403
- const name = ret.getSymbol()?.getName() ?? ret.getAliasSymbol()?.getName() ?? null;
404
- return name && name !== "__type" ? name : null;
417
+ return loaderTypeName(ret);
405
418
  }
406
419
 
407
420
  export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?: GenerationContext): any {
@@ -619,32 +632,10 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
619
632
  // Loader output type → block-ref: emit anyOf [Resolvable, ...matchingLoaders]
620
633
  // baseHint strips "| null | undefined" so "ProductListingPage | null" → "ProductListingPage"
621
634
  if (ctx?.outputTypeToLoaderKeys) {
622
- const typeSym = propType.getSymbol() ?? propType.getAliasSymbol();
623
- // An array type's symbol name is always the generic "Array", which is
624
- // never a meaningful loader output key — fall back to the AST type
625
- // text (`baseHint`, e.g. "Collection[]") instead. The element-based
626
- // `${elName}[]` lookup below handles arrays precisely.
627
- const symName = typeSym?.getName();
628
- const outputTypeName = symName && symName !== "Array" ? symName : baseHint;
629
- let matchingLoaders =
630
- ctx.outputTypeToLoaderKeys.get(outputTypeName) ??
635
+ const outputTypeName = loaderTypeName(propType);
636
+ const matchingLoaders =
637
+ (outputTypeName ? ctx.outputTypeToLoaderKeys.get(outputTypeName) : undefined) ??
631
638
  (outputTypeName !== baseHint ? ctx.outputTypeToLoaderKeys.get(baseHint) : undefined);
632
- // If no match yet and the prop type is an array, try element type name + "[]"
633
- if (!matchingLoaders?.length) {
634
- let arrayElementType = propType.isArray() ? propType.getArrayElementType() : null;
635
- if (!arrayElementType && propType.isUnion()) {
636
- const nonNull = propType.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
637
- if (nonNull.length === 1 && nonNull[0].isArray()) {
638
- arrayElementType = nonNull[0].getArrayElementType();
639
- }
640
- }
641
- if (arrayElementType) {
642
- const elName = arrayElementType.getSymbol()?.getName() ?? arrayElementType.getAliasSymbol()?.getName();
643
- if (elName) {
644
- matchingLoaders = ctx.outputTypeToLoaderKeys.get(`${elName}[]`);
645
- }
646
- }
647
- }
648
639
  if (matchingLoaders?.length) {
649
640
  const blockRefSchema: any = {
650
641
  anyOf: [