@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.
|
|
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.
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
if (elName) return `${elName}[]`;
|
|
354
|
-
}
|
|
385
|
+
const elName = elType?.getSymbol()?.getName() ?? elType?.getAliasSymbol()?.getName() ?? null;
|
|
386
|
+
return elName ? `${elName}[]` : null;
|
|
355
387
|
}
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[] };
|