@decocms/blocks-cli 7.3.1 → 7.4.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.1",
3
+ "version": "7.4.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": {
@@ -34,7 +34,7 @@
34
34
  "lint:unused": "knip"
35
35
  },
36
36
  "dependencies": {
37
- "@decocms/blocks": "7.3.1",
37
+ "@decocms/blocks": "7.4.0",
38
38
  "ts-morph": "^27.0.0",
39
39
  "tsx": "^4.19.0"
40
40
  },
@@ -1,6 +1,29 @@
1
1
  import { Project } from "ts-morph";
2
2
  import { describe, expect, it } from "vitest";
3
- import { WIDGET_TYPE_FORMATS, applyWidgetFormat, typeToJsonSchema } from "./generate-schema";
3
+ import {
4
+ WIDGET_TYPE_FORMATS,
5
+ applyWidgetFormat,
6
+ definitionIdForPath,
7
+ typeToJsonSchema,
8
+ } from "./generate-schema";
9
+
10
+ describe("definitionIdForPath", () => {
11
+ it("is repo-relative, never absolute", () => {
12
+ const id = definitionIdForPath(
13
+ "/Users/anyone/code/mysite/src/sections/Hero.tsx",
14
+ "/Users/anyone/code/mysite",
15
+ );
16
+ expect(Buffer.from(id, "base64").toString()).toBe("src/sections/Hero.tsx");
17
+ });
18
+
19
+ it("normalizes file:// prefixes from ts-morph", () => {
20
+ const id = definitionIdForPath(
21
+ "file:///Users/anyone/code/mysite/src/sections/Hero.tsx",
22
+ "/Users/anyone/code/mysite",
23
+ );
24
+ expect(Buffer.from(id, "base64").toString()).toBe("src/sections/Hero.tsx");
25
+ });
26
+ });
4
27
 
5
28
  describe("applyWidgetFormat", () => {
6
29
  it("recovers an unresolved widget alias (empty schema) as string + format", () => {
@@ -31,6 +31,7 @@ import {
31
31
  SyntaxKind,
32
32
  type Type,
33
33
  } from "ts-morph";
34
+ import { isExcludedCodegenFile } from "./lib/codegenExclusions";
34
35
 
35
36
  // ---------------------------------------------------------------------------
36
37
  // CLI arg parsing
@@ -72,6 +73,18 @@ function toBase64(str: string): string {
72
73
  return Buffer.from(str).toString("base64");
73
74
  }
74
75
 
76
+ /**
77
+ * Definition IDs must be stable across machines: the raw ts-morph path is
78
+ * absolute (`file:///Users/<user>/...`), which made meta.gen.json differ
79
+ * per machine and destabilized the /live/_meta ETag. IDs are opaque to the
80
+ * admin — only internal $ref consistency matters — so relativize to root.
81
+ */
82
+ export function definitionIdForPath(filePath: string, rootDir: string): string {
83
+ const cleaned = filePath.replace(/^file:\/+/, "/");
84
+ const rel = path.relative(rootDir, cleaned).replaceAll("\\", "/");
85
+ return toBase64(rel.startsWith("..") ? cleaned : rel);
86
+ }
87
+
75
88
  /**
76
89
  * Map JSDoc tags to JSON Schema 7 keywords.
77
90
  * Supports all 20+ tags from deco-cx/deco.
@@ -734,6 +747,7 @@ function findTsxFiles(dir: string): string[] {
734
747
  const results: string[] = [];
735
748
  if (!fs.existsSync(dir)) return results;
736
749
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
750
+ if (isExcludedCodegenFile(entry.name)) continue;
737
751
  const full = path.join(dir, entry.name);
738
752
  if (entry.isDirectory()) results.push(...findTsxFiles(full));
739
753
  else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) results.push(full);
@@ -1141,7 +1155,7 @@ function generateMeta(): MetaResponse {
1141
1155
 
1142
1156
  const propCount = Object.keys(propsSchema.properties || {}).length;
1143
1157
 
1144
- const propsDefKey = toBase64(`file:///${filePath.replaceAll("\\", "/")}`) + "@Props";
1158
+ const propsDefKey = definitionIdForPath(filePath, root) + "@Props";
1145
1159
  definitions[propsDefKey] = propsSchema;
1146
1160
 
1147
1161
  const sectionDefKey = toBase64(blockKey);
@@ -1232,7 +1246,7 @@ function generateMeta(): MetaResponse {
1232
1246
 
1233
1247
  const propCount = Object.keys(propsSchema.properties || {}).length;
1234
1248
 
1235
- const propsDefKey = toBase64(`file:///${filePath.replaceAll("\\", "/")}`) + "@Props";
1249
+ const propsDefKey = definitionIdForPath(filePath, root) + "@Props";
1236
1250
  definitions[propsDefKey] = propsSchema;
1237
1251
 
1238
1252
  const appDefKey = toBase64(blockKey);
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Integration test for `generate-sections.ts`.
3
+ *
4
+ * Drives the script as a child process against a tmp sections-dir fixture,
5
+ * mirroring the pattern used by migrate-to-cf-observability.test.ts. The
6
+ * script has no `isMainModule()` guard (unlike generate-schema.ts) — it runs
7
+ * its filesystem walk and write on import — so it's exercised as a subprocess
8
+ * rather than imported directly.
9
+ *
10
+ * Verifies the operationally important behavior: a co-located test/spec/
11
+ * stories/gen file sitting next to a real section must never be walked into
12
+ * sectionMeta (the fila incident this generator's `walkDir` reproduced:
13
+ * `sections.test.ts` became a bogus section in a site's generated output).
14
+ */
15
+ import * as cp from "node:child_process";
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
21
+
22
+ const SCRIPT = path.resolve(__dirname, "generate-sections.ts");
23
+
24
+ function runGenerator(args: string[]): { stdout: string; stderr: string; code: number } {
25
+ const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8" });
26
+ return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? 0 };
27
+ }
28
+
29
+ describe("generate-sections walkDir exclusions", () => {
30
+ let tmpDir: string;
31
+ let sectionsDir: string;
32
+ let outFile: string;
33
+
34
+ beforeEach(() => {
35
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-"));
36
+ sectionsDir = path.join(tmpDir, "sections");
37
+ outFile = path.join(tmpDir, "out", "sections.gen.ts");
38
+ fs.mkdirSync(sectionsDir, { recursive: true });
39
+ });
40
+
41
+ afterEach(() => {
42
+ fs.rmSync(tmpDir, { recursive: true, force: true });
43
+ });
44
+
45
+ it("excludes co-located test/spec/stories/gen files from the generated sectionMeta", () => {
46
+ fs.writeFileSync(
47
+ path.join(sectionsDir, "Hero.tsx"),
48
+ `export const eager = true;\nexport default function Hero() { return null; }\n`,
49
+ );
50
+ fs.writeFileSync(
51
+ path.join(sectionsDir, "Hero.test.tsx"),
52
+ `export const eager = true;\nexport default function HeroTest() { return null; }\n`,
53
+ );
54
+ fs.writeFileSync(
55
+ path.join(sectionsDir, "Hero.stories.tsx"),
56
+ `export const eager = true;\nexport default function HeroStories() { return null; }\n`,
57
+ );
58
+ fs.writeFileSync(
59
+ path.join(sectionsDir, "sections.gen.ts"),
60
+ `export const eager = true;\n`,
61
+ );
62
+
63
+ const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]);
64
+ expect(code).toBe(0);
65
+
66
+ const generated = fs.readFileSync(outFile, "utf-8");
67
+ expect(generated).toContain("site/sections/Hero.tsx");
68
+ expect(generated).not.toContain("Hero.test.tsx");
69
+ expect(generated).not.toContain("Hero.stories.tsx");
70
+ expect(generated).not.toContain("sections.gen.ts");
71
+ });
72
+ });
73
+
74
+ describe("generate-sections --registry", () => {
75
+ let tmpDir: string;
76
+ let sectionsDir: string;
77
+ let outFile: string;
78
+
79
+ beforeEach(() => {
80
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-registry-"));
81
+ sectionsDir = path.join(tmpDir, "sections");
82
+ outFile = path.join(tmpDir, "out", "sections.gen.ts");
83
+ fs.mkdirSync(path.join(sectionsDir, "Nested"), { recursive: true });
84
+ });
85
+
86
+ afterEach(() => {
87
+ fs.rmSync(tmpDir, { recursive: true, force: true });
88
+ });
89
+
90
+ function expectedImportPath(filePath: string): string {
91
+ let rel = path.relative(path.dirname(outFile), filePath).replace(/\\/g, "/");
92
+ if (!rel.startsWith(".")) rel = `./${rel}`;
93
+ return rel.replace(/\.tsx?$/, "");
94
+ }
95
+
96
+ it("emits sectionImports keyed glob-style with relative dynamic imports, built from all scanned section files (not just convention-carrying ones)", () => {
97
+ const heroPath = path.join(sectionsDir, "Hero.tsx");
98
+ const promoPath = path.join(sectionsDir, "Nested", "Promo.tsx");
99
+ fs.writeFileSync(
100
+ heroPath,
101
+ "export const sync = true\nexport default function Hero() { return null }\n",
102
+ );
103
+ // No convention exports — regression guard: without --registry this file
104
+ // never makes it into `entries`, so the registry must be built from the
105
+ // raw `sectionFiles` walk, not from `entries`.
106
+ fs.writeFileSync(
107
+ promoPath,
108
+ "export default function Promo() { return null }\n",
109
+ );
110
+
111
+ const { code } = runGenerator([
112
+ "--sections-dir", sectionsDir,
113
+ "--out-file", outFile,
114
+ "--registry",
115
+ ]);
116
+ expect(code).toBe(0);
117
+
118
+ const generated = fs.readFileSync(outFile, "utf-8");
119
+ expect(generated).toContain("export const sectionImports");
120
+ expect(generated).toContain(
121
+ `"./sections/Hero.tsx": () => import("${expectedImportPath(heroPath)}")`,
122
+ );
123
+ expect(generated).toContain(
124
+ `"./sections/Nested/Promo.tsx": () => import("${expectedImportPath(promoPath)}")`,
125
+ );
126
+ });
127
+
128
+ it("does not emit sectionImports without the --registry flag", () => {
129
+ fs.writeFileSync(
130
+ path.join(sectionsDir, "Hero.tsx"),
131
+ "export const sync = true\nexport default function Hero() { return null }\n",
132
+ );
133
+ fs.writeFileSync(
134
+ path.join(sectionsDir, "Nested", "Promo.tsx"),
135
+ "export default function Promo() { return null }\n",
136
+ );
137
+
138
+ const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]);
139
+ expect(code).toBe(0);
140
+
141
+ const generated = fs.readFileSync(outFile, "utf-8");
142
+ expect(generated).not.toContain("sectionImports");
143
+ });
144
+
145
+ it("emits a doc comment that does not self-terminate early, and the resulting file is importable (regression: a literal `**/` inside the emitted /** */ comment used to close it prematurely, leaving prose as bare statements and making every --registry output invalid TypeScript)", () => {
146
+ const heroPath = path.join(sectionsDir, "Hero.tsx");
147
+ fs.writeFileSync(
148
+ heroPath,
149
+ "export const sync = true\nexport default function Hero() { return null }\n",
150
+ );
151
+
152
+ const { code } = runGenerator([
153
+ "--sections-dir", sectionsDir,
154
+ "--out-file", outFile,
155
+ "--registry",
156
+ ]);
157
+ expect(code).toBe(0);
158
+
159
+ const generated = fs.readFileSync(outFile, "utf-8");
160
+
161
+ // Pin the regression directly: the doc comment's body (everything
162
+ // between the opening `/**` and its own closing `*/`) must contain
163
+ // exactly one `*/` — the intended closing marker itself, at the very
164
+ // end. A premature `*/` embedded in the prose (e.g. from a literal
165
+ // `**/*.tsx` glob pattern) would close the block comment early and
166
+ // leave the rest of the doc text as bare top-level statements.
167
+ const docCommentStart = generated.indexOf("/**\n * Lazy section registry");
168
+ const exportStart = generated.indexOf("export const sectionImports");
169
+ expect(docCommentStart).toBeGreaterThan(-1);
170
+ expect(exportStart).toBeGreaterThan(docCommentStart);
171
+ const docComment = generated.slice(docCommentStart + "/**".length, exportStart);
172
+ const closeMarkerCount = (docComment.match(/\*\//g) ?? []).length;
173
+ expect(closeMarkerCount).toBe(1);
174
+ expect(docComment.trimEnd().endsWith("*/")).toBe(true);
175
+
176
+ // Strongest available check: actually import the generated file through
177
+ // tsx (esbuild) and confirm it parses/executes as valid TS/ESM and
178
+ // exports `sectionImports`. A premature `*/` would leave trailing prose
179
+ // as bare top-level statements, which fails to parse. `tsx -e` evaluates
180
+ // its argument as CommonJS (named exports of a dynamic `import()` come
181
+ // back CJS-interop-wrapped), so write a real `.mjs` file instead and run
182
+ // that — mirrors the check used to hand-verify this fix.
183
+ const checkerFile = path.join(tmpDir, "check-import.mjs");
184
+ fs.writeFileSync(
185
+ checkerFile,
186
+ [
187
+ `import { pathToFileURL } from "node:url";`,
188
+ `const m = await import(${JSON.stringify(pathToFileURL(outFile).href)});`,
189
+ `if (typeof m.sectionImports !== "object" || m.sectionImports === null) {`,
190
+ ` throw new Error("sectionImports missing or not an object");`,
191
+ `}`,
192
+ ].join("\n"),
193
+ );
194
+
195
+ const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" });
196
+ expect(importResult.status, importResult.stderr).toBe(0);
197
+ }, 30_000);
198
+ });
@@ -17,9 +17,16 @@
17
17
  * CLI:
18
18
  * --sections-dir override input (default: src/sections)
19
19
  * --out-file override output (default: src/server/cms/sections.gen.ts)
20
+ * --registry also emit `sectionImports` — a lazy section-import map
21
+ * keyed glob-style (`./sections/...`), the Next.js/webpack
22
+ * equivalent of Vite's `import.meta.glob("./sections/**\/*.tsx")`.
23
+ * Built from every scanned section file, not just the ones
24
+ * carrying convention exports. Off by default so existing
25
+ * Vite sites regenerating sections.gen.ts in CI see zero diff.
20
26
  */
21
27
  import fs from "node:fs";
22
28
  import path from "node:path";
29
+ import { isExcludedCodegenFile } from "./lib/codegenExclusions";
23
30
 
24
31
  const args = process.argv.slice(2);
25
32
  function arg(name: string, fallback: string): string {
@@ -29,6 +36,7 @@ function arg(name: string, fallback: string): string {
29
36
 
30
37
  const sectionsDir = path.resolve(process.cwd(), arg("sections-dir", "src/sections"));
31
38
  const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/sections.gen.ts"));
39
+ const EMIT_REGISTRY = args.includes("--registry");
32
40
 
33
41
  interface SectionMeta {
34
42
  eager?: boolean;
@@ -92,7 +100,10 @@ function walkDir(dir: string, base: string = dir): string[] {
92
100
  const fullPath = path.join(dir, entry.name);
93
101
  if (entry.isDirectory()) {
94
102
  results.push(...walkDir(fullPath, base));
95
- } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
103
+ } else if (
104
+ (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) &&
105
+ !isExcludedCodegenFile(entry.name)
106
+ ) {
96
107
  results.push(fullPath);
97
108
  }
98
109
  }
@@ -223,6 +234,25 @@ if (allFallbacks.length > 0) {
223
234
  }
224
235
  lines.push("");
225
236
 
237
+ // Lazy section-import registry — opt-in via --registry, built from every
238
+ // scanned section file (not just convention-carrying `entries`).
239
+ if (EMIT_REGISTRY) {
240
+ lines.push("");
241
+ lines.push("/**");
242
+ lines.push(" * Lazy section registry — the Next.js/webpack equivalent of Vite's");
243
+ lines.push(" * `import.meta.glob` scan over every file under ./sections, recursively.");
244
+ lines.push(" * Keys use the glob-style `./sections/...` form so this map drops");
245
+ lines.push(" * straight into `createSiteSetup({ sections })` / `createNextSetup({ sections })`.");
246
+ lines.push(" */");
247
+ lines.push("export const sectionImports: Record<string, () => Promise<any>> = {");
248
+ for (const filePath of sectionFiles) {
249
+ const rel = path.relative(sectionsDir, filePath).replace(/\\/g, "/");
250
+ const importPath = relativeImportPath(outFile, filePath);
251
+ lines.push(` "./sections/${rel}": () => import("${importPath}"),`);
252
+ }
253
+ lines.push("};");
254
+ }
255
+
226
256
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
227
257
  fs.writeFileSync(outFile, lines.join("\n"));
228
258
 
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isExcludedCodegenFile } from "./codegenExclusions";
3
+
4
+ describe("isExcludedCodegenFile", () => {
5
+ it.each([
6
+ "Hero.test.tsx",
7
+ "Hero.test.ts",
8
+ "Hero.spec.tsx",
9
+ "Hero.stories.tsx",
10
+ "sections.gen.ts",
11
+ "meta.gen.json",
12
+ ])("excludes %s", (name) => {
13
+ expect(isExcludedCodegenFile(name)).toBe(true);
14
+ });
15
+
16
+ it.each(["Hero.tsx", "Product/SearchResult.tsx", "testimonials.tsx", "generic.ts"])(
17
+ "keeps %s",
18
+ (name) => {
19
+ expect(isExcludedCodegenFile(name)).toBe(false);
20
+ },
21
+ );
22
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Files the codegen scanners must never treat as section/loader sources.
3
+ * `generate-schema.ts` once emitted a site's co-located test file as a
4
+ * section block (it scans every .ts/.tsx under the sections dir), so both
5
+ * generators route their directory walks through this predicate.
6
+ */
7
+ const EXCLUDED_SUFFIX_RE = /\.(test|spec|stories|gen)\.(ts|tsx|js|jsx|json)$/;
8
+
9
+ export function isExcludedCodegenFile(fileName: string): boolean {
10
+ return EXCLUDED_SUFFIX_RE.test(fileName);
11
+ }