@decocms/blocks-cli 7.17.1 → 7.19.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 +2 -2
- package/scripts/generate-schema.test.ts +34 -0
- package/scripts/generate-schema.ts +57 -4
- package/scripts/generate.test.ts +38 -1
- package/scripts/generate.ts +64 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.19.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.19.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[] };
|
package/scripts/generate.test.ts
CHANGED
|
@@ -15,7 +15,7 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
18
|
-
import { parseCliOptions } from "./generate";
|
|
18
|
+
import { buildPlan, parseCliOptions } from "./generate";
|
|
19
19
|
|
|
20
20
|
const SCRIPT = path.resolve(__dirname, "generate.ts");
|
|
21
21
|
|
|
@@ -213,6 +213,43 @@ describe("parseCliOptions", () => {
|
|
|
213
213
|
expect(parseCliOptions(["--no-registry"]).registry).toBe(false);
|
|
214
214
|
expect(parseCliOptions([]).registry).toBe(null);
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
it("accepts --root and a bare positional path as the effective root", () => {
|
|
218
|
+
expect(parseCliOptions(["--root", "apps/foo"]).root).toBe("apps/foo");
|
|
219
|
+
expect(parseCliOptions(["apps/foo"]).root).toBe("apps/foo");
|
|
220
|
+
expect(parseCliOptions([]).root).toBe(null);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("rejects a second positional but still rejects unknown flags", () => {
|
|
224
|
+
expect(() => parseCliOptions(["a", "b"])).toThrow(/Unknown option/);
|
|
225
|
+
expect(() => parseCliOptions(["--wat"])).toThrow(/Unknown option/);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("buildPlan --platform eitri", () => {
|
|
230
|
+
it("runs only schema + blocks and makes schema self-contained", () => {
|
|
231
|
+
// makeFixture installs @decocms/tanstack + src/sections + src/loaders, which
|
|
232
|
+
// would normally enable sections/loaders/invoke; --platform eitri overrides.
|
|
233
|
+
const dir = makeFixture({ withInvoke: true });
|
|
234
|
+
try {
|
|
235
|
+
const plans = buildPlan(dir, parseCliOptions(["--platform", "eitri"]));
|
|
236
|
+
const byName = Object.fromEntries(plans.map((p) => [p.name, p]));
|
|
237
|
+
|
|
238
|
+
expect(byName.schema.enabled).toBe(true);
|
|
239
|
+
expect(byName.blocks.enabled).toBe(true);
|
|
240
|
+
for (const name of ["manifest", "sections", "loaders", "invoke"]) {
|
|
241
|
+
expect(byName[name].enabled).toBe(false);
|
|
242
|
+
expect(byName[name].disabledReason).toBe("not used by --platform eitri");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// schema is asked to bake composeMeta's framework block types in.
|
|
246
|
+
expect(byName.schema.args).toContain("--compose");
|
|
247
|
+
const fwIdx = byName.schema.args.indexOf("--framework");
|
|
248
|
+
expect(byName.schema.args[fwIdx + 1]).toBe("eitri");
|
|
249
|
+
} finally {
|
|
250
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
216
253
|
});
|
|
217
254
|
|
|
218
255
|
// ---------------------------------------------------------------------------
|
package/scripts/generate.ts
CHANGED
|
@@ -171,6 +171,15 @@ Selection:
|
|
|
171
171
|
--skip <names> Comma-separated generators to exclude
|
|
172
172
|
--force Ignore the cache; run everything selected
|
|
173
173
|
--dry-run Print what would run / skip / stay disabled, then exit
|
|
174
|
+
--root <dir> Effective working directory (also accepted as a bare
|
|
175
|
+
positional path). Input dirs + .deco/ output resolve
|
|
176
|
+
against it, so a monorepo can target a sub-app folder
|
|
177
|
+
without cd. (default: process.cwd())
|
|
178
|
+
|
|
179
|
+
Platform:
|
|
180
|
+
--platform <name> Forwarded to schema. With "eitri": runs ONLY schema +
|
|
181
|
+
blocks (skips manifest/sections/loaders/invoke) and makes
|
|
182
|
+
the schema self-contained (composeMeta at gen time).
|
|
174
183
|
|
|
175
184
|
Forwarded to the individual generators:
|
|
176
185
|
--blocks-dir <dir> blocks + manifest input (default .deco/blocks)
|
|
@@ -220,6 +229,13 @@ export interface CliOptions {
|
|
|
220
229
|
namespace: string | null;
|
|
221
230
|
platform: string | null;
|
|
222
231
|
skipApps: boolean;
|
|
232
|
+
/**
|
|
233
|
+
* Effective working directory. `.deco/` output and input dirs resolve
|
|
234
|
+
* against this instead of process.cwd(), so a monorepo can target a
|
|
235
|
+
* sub-app folder without `cd`. Set via `--root <dir>` or a bare positional
|
|
236
|
+
* path. Relative to process.cwd().
|
|
237
|
+
*/
|
|
238
|
+
root: string | null;
|
|
223
239
|
}
|
|
224
240
|
|
|
225
241
|
function parseNames(raw: string, flag: string): GeneratorName[] {
|
|
@@ -257,6 +273,7 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
|
|
257
273
|
namespace: null,
|
|
258
274
|
platform: null,
|
|
259
275
|
skipApps: false,
|
|
276
|
+
root: null,
|
|
260
277
|
};
|
|
261
278
|
|
|
262
279
|
const valueOf = (i: number, flag: string): string => {
|
|
@@ -335,7 +352,18 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
|
|
335
352
|
case "--skip-apps":
|
|
336
353
|
opts.skipApps = true;
|
|
337
354
|
break;
|
|
355
|
+
case "--root":
|
|
356
|
+
opts.root = valueOf(i, a);
|
|
357
|
+
i++;
|
|
358
|
+
break;
|
|
338
359
|
default:
|
|
360
|
+
// A single bare (non-flag) argument is accepted as the root path, so
|
|
361
|
+
// `generate ./apps/foo` works like `generate --root ./apps/foo`.
|
|
362
|
+
// Anything else (or a second positional) is still a hard error.
|
|
363
|
+
if (!a.startsWith("--") && opts.root === null) {
|
|
364
|
+
opts.root = a;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
339
367
|
throw new Error(`Unknown option "${a}". Run with --help for usage.`);
|
|
340
368
|
}
|
|
341
369
|
}
|
|
@@ -625,6 +653,9 @@ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
|
|
|
625
653
|
...(opts.namespace ? ["--namespace", opts.namespace] : []),
|
|
626
654
|
...(opts.platform ? ["--platform", opts.platform] : []),
|
|
627
655
|
...(opts.skipApps ? ["--skip-apps"] : []),
|
|
656
|
+
// Eitri (and any FS-only consumer) needs a self-contained meta.gen.json:
|
|
657
|
+
// bake composeMeta's framework block types in at generation time.
|
|
658
|
+
...(opts.platform === "eitri" ? ["--compose", "--framework", "eitri"] : []),
|
|
628
659
|
],
|
|
629
660
|
stage: 2,
|
|
630
661
|
...enabledIf(
|
|
@@ -661,6 +692,26 @@ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
|
|
|
661
692
|
},
|
|
662
693
|
];
|
|
663
694
|
|
|
695
|
+
// --platform eitri: deco only AUTHORS config for Eitri (Eitri renders on its
|
|
696
|
+
// own mobile runtime), so the only useful artifacts are the self-contained
|
|
697
|
+
// meta.gen.json (schema) and the bundled decofile snapshot (blocks). The
|
|
698
|
+
// React-runtime generators (manifest/sections/loaders/invoke) would be dead
|
|
699
|
+
// weight, and `sections` in particular would otherwise auto-enable because
|
|
700
|
+
// src/sections exists. Applied before --only/--skip so those still win.
|
|
701
|
+
if (opts.platform === "eitri") {
|
|
702
|
+
for (const plan of plans) {
|
|
703
|
+
if (plan.name === "blocks") {
|
|
704
|
+
// Emit the snapshot even when .deco/blocks is empty (a fresh Eitri app
|
|
705
|
+
// has no authored content yet) — generate-blocks writes an empty barrel.
|
|
706
|
+
plan.enabled = true;
|
|
707
|
+
plan.disabledReason = undefined;
|
|
708
|
+
} else if (plan.name !== "schema") {
|
|
709
|
+
plan.enabled = false;
|
|
710
|
+
plan.disabledReason = "not used by --platform eitri";
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
664
715
|
// Apply --only / --skip on top of auto-enablement. --only also FORCES a
|
|
665
716
|
// generator on (explicit intent beats detection) unless its hard inputs
|
|
666
717
|
// are missing in a way that would just crash the child.
|
|
@@ -965,6 +1016,19 @@ export async function runGenerate(argv: string[], cwd = process.cwd()): Promise<
|
|
|
965
1016
|
return 0;
|
|
966
1017
|
}
|
|
967
1018
|
|
|
1019
|
+
// --root / positional path relocates the effective working directory: every
|
|
1020
|
+
// input dir and the `.deco/` output tree resolve against it (child
|
|
1021
|
+
// generators are spawned with this cwd), so a monorepo can target a sub-app
|
|
1022
|
+
// folder without an explicit `cd`.
|
|
1023
|
+
if (opts.root) {
|
|
1024
|
+
cwd = path.resolve(cwd, opts.root);
|
|
1025
|
+
if (!fs.existsSync(cwd)) {
|
|
1026
|
+
console.error(`[generate] --root path does not exist: ${cwd}`);
|
|
1027
|
+
return 1;
|
|
1028
|
+
}
|
|
1029
|
+
console.log(`[generate] root: ${cwd}`);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
968
1032
|
const totalStarted = Date.now();
|
|
969
1033
|
const plans = buildPlan(cwd, opts);
|
|
970
1034
|
const versions = decoPackageVersions(cwd);
|