@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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
623
|
-
|
|
624
|
-
|
|
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: [
|