@decocms/blocks-cli 7.8.0 → 7.9.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.8.0",
3
+ "version": "7.9.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": {
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "exports": {
22
22
  "./generate-blocks": "./scripts/generate-blocks.ts",
23
+ "./generate-blocks-manifest": "./scripts/generate-blocks-manifest.ts",
23
24
  "./generate-schema": "./scripts/generate-schema.ts",
24
25
  "./generate-invoke": "./scripts/generate-invoke.ts",
25
26
  "./migrate": "./scripts/migrate.ts",
@@ -34,7 +35,7 @@
34
35
  "lint:unused": "knip"
35
36
  },
36
37
  "dependencies": {
37
- "@decocms/blocks": "7.8.0",
38
+ "@decocms/blocks": "7.9.0",
38
39
  "ts-morph": "^27.0.0",
39
40
  "tsx": "^4.22.5"
40
41
  },
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Integration test for `generate-blocks-manifest.ts`.
3
+ *
4
+ * Drives the programmatic entry against a tmp fixture of hostile-but-real
5
+ * block filenames (the shapes found in production `.deco/blocks` corpora:
6
+ * double-encoded `%2520`, single-encoded `%20`, parentheses, encoded slashes
7
+ * `%2F`, raw UTF-8), then verifies the emitted module the way a site would
8
+ * consume it: keys verbatim, `tsc` accepts it, and importing it (via a tsx
9
+ * child process from the fixture dir, resolving the raw-filename JSON import
10
+ * specifiers against the real files on disk) yields the parsed contents.
11
+ */
12
+ import * as cp from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
17
+ import { generateBlocksManifest } from "./generate-blocks-manifest";
18
+
19
+ const SCRIPT = path.resolve(__dirname, "generate-blocks-manifest.ts");
20
+
21
+ const HOSTILE_FIXTURE: Record<string, unknown> = {
22
+ "pages-PDP%2520Box-102215": { path: "/pdp-box", encoded: "double" },
23
+ "pages-Home%20(principal)-287364": { path: "/", parens: true },
24
+ "collections%2Fblog%2Fauthors%2Fx": { nested: "encoded-slash" },
25
+ "pages-Calçados-42": { utf8: "çãé" },
26
+ };
27
+
28
+ describe("generate-blocks-manifest", () => {
29
+ let tmpDir: string;
30
+ let blocksDir: string;
31
+ let outFile: string;
32
+
33
+ beforeEach(() => {
34
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-blocks-manifest-"));
35
+ blocksDir = path.join(tmpDir, ".deco", "blocks");
36
+ outFile = path.join(tmpDir, ".deco", "blocksManifest.gen.ts");
37
+ fs.mkdirSync(blocksDir, { recursive: true });
38
+ });
39
+
40
+ afterEach(() => {
41
+ fs.rmSync(tmpDir, { recursive: true, force: true });
42
+ });
43
+
44
+ const writeFixture = (fixture: Record<string, unknown> = HOSTILE_FIXTURE) => {
45
+ for (const [key, value] of Object.entries(fixture)) {
46
+ fs.writeFileSync(path.join(blocksDir, `${key}.json`), JSON.stringify(value));
47
+ }
48
+ };
49
+
50
+ it("emits verbatim keys and raw-filename import specifiers for hostile names", async () => {
51
+ writeFixture();
52
+ const result = await generateBlocksManifest({ blocksDir, outFile, silent: true });
53
+ expect(result.count).toBe(4);
54
+ expect(result.empty).toBe(false);
55
+ expect(result.written).toBe(true);
56
+
57
+ const emitted = fs.readFileSync(outFile, "utf-8");
58
+ for (const key of Object.keys(HOSTILE_FIXTURE)) {
59
+ // Key: filename minus .json, verbatim — no decoding of %2520/%20/%2F.
60
+ expect(emitted).toContain(`${JSON.stringify(key)}: `);
61
+ // Specifier: the raw on-disk filename, relative to the emitted module.
62
+ expect(emitted).toContain(`from ${JSON.stringify(`./blocks/${key}.json`)};`);
63
+ }
64
+ // Never URL-decoded anywhere in the module.
65
+ expect(emitted).not.toContain("pages-PDP%20Box");
66
+ expect(emitted).not.toContain("pages-Home (principal)");
67
+ expect(emitted).not.toContain("collections/blog");
68
+ });
69
+
70
+ it("compiles under tsc (module esnext, bundler resolution, resolveJsonModule)", async () => {
71
+ writeFixture();
72
+ await generateBlocksManifest({ blocksDir, outFile, silent: true });
73
+
74
+ const r = cp.spawnSync(
75
+ "npx",
76
+ [
77
+ "tsc",
78
+ "--noEmit",
79
+ "--strict",
80
+ "--module",
81
+ "esnext",
82
+ "--moduleResolution",
83
+ "bundler",
84
+ "--target",
85
+ "es2022",
86
+ "--resolveJsonModule",
87
+ "--skipLibCheck",
88
+ outFile,
89
+ ],
90
+ { encoding: "utf8" },
91
+ );
92
+ expect(r.stdout + r.stderr).not.toMatch(/error TS/);
93
+ expect(r.status).toBe(0);
94
+ }, 30_000);
95
+
96
+ it("importing the emitted module (tsx, from the fixture dir) yields the parsed contents", async () => {
97
+ writeFixture();
98
+ await generateBlocksManifest({ blocksDir, outFile, silent: true });
99
+
100
+ const runner = path.join(tmpDir, ".deco", "runner.ts");
101
+ fs.writeFileSync(
102
+ runner,
103
+ 'import blocks from "./blocksManifest.gen";\nconsole.log(JSON.stringify(blocks));\n',
104
+ );
105
+ const r = cp.spawnSync("npx", ["tsx", runner], { encoding: "utf8", cwd: tmpDir });
106
+ expect(r.stderr).toBe("");
107
+ expect(r.status).toBe(0);
108
+ expect(JSON.parse(r.stdout)).toEqual(HOSTILE_FIXTURE);
109
+ }, 30_000);
110
+
111
+ it("is idempotent: regeneration over an unchanged block set writes nothing", async () => {
112
+ writeFixture();
113
+ const first = await generateBlocksManifest({ blocksDir, outFile, silent: true });
114
+ expect(first.written).toBe(true);
115
+ const emittedFirst = fs.readFileSync(outFile, "utf-8");
116
+
117
+ const second = await generateBlocksManifest({ blocksDir, outFile, silent: true });
118
+ expect(second.written).toBe(false);
119
+ expect(fs.readFileSync(outFile, "utf-8")).toBe(emittedFirst);
120
+ });
121
+
122
+ it("orders imports deterministically regardless of readdir order (sorted filenames)", async () => {
123
+ writeFixture({ "z-last": { z: 1 }, "a-first": { a: 1 }, "m-mid": { m: 1 } });
124
+ await generateBlocksManifest({ blocksDir, outFile, silent: true });
125
+
126
+ const emitted = fs.readFileSync(outFile, "utf-8");
127
+ const importOrder = [...emitted.matchAll(/from "\.\/blocks\/([^"]+)\.json";/g)].map(
128
+ (m) => m[1],
129
+ );
130
+ expect(importOrder).toEqual(["a-first", "m-mid", "z-last"]);
131
+ });
132
+
133
+ it("ignores non-json entries and subdirectories (top-level *.json only)", async () => {
134
+ writeFixture({ real: { ok: true } });
135
+ fs.writeFileSync(path.join(blocksDir, "notes.txt"), "not a block");
136
+ fs.mkdirSync(path.join(blocksDir, "nested"));
137
+ fs.writeFileSync(path.join(blocksDir, "nested", "deep.json"), "{}");
138
+
139
+ const result = await generateBlocksManifest({ blocksDir, outFile, silent: true });
140
+ expect(result.count).toBe(1);
141
+ const emitted = fs.readFileSync(outFile, "utf-8");
142
+ expect(emitted).toContain('"real": _b0');
143
+ expect(emitted).not.toContain("notes.txt");
144
+ expect(emitted).not.toContain("deep.json");
145
+ });
146
+
147
+ it("emits an empty manifest when the blocks dir is missing", async () => {
148
+ fs.rmSync(blocksDir, { recursive: true, force: true });
149
+ const result = await generateBlocksManifest({ blocksDir, outFile, silent: true });
150
+ expect(result.count).toBe(0);
151
+ expect(result.empty).toBe(true);
152
+
153
+ const emitted = fs.readFileSync(outFile, "utf-8");
154
+ expect(emitted).toContain("const blocks: Record<string, unknown> = {");
155
+ expect(emitted).toContain("export default blocks;");
156
+ expect(emitted).not.toContain("import _b");
157
+ });
158
+
159
+ it("never lets a generated comment contain the block-comment terminator", async () => {
160
+ // Filenames land ONLY inside JSON.stringify-quoted string literals — a
161
+ // name containing `*` + `/` must not be able to truncate any comment in
162
+ // the emitted module (past incident with interpolated doc comments).
163
+ writeFixture({ "weird-*∕name": { ok: true }, "star*": { s: 1 } });
164
+ await generateBlocksManifest({ blocksDir, outFile, silent: true });
165
+
166
+ const emitted = fs.readFileSync(outFile, "utf-8");
167
+ const commentLines = emitted.split("\n").filter((l) => l.startsWith("//"));
168
+ for (const line of commentLines) {
169
+ expect(line).not.toContain("*/");
170
+ expect(line).not.toContain("weird");
171
+ }
172
+ });
173
+
174
+ it("runs as a CLI with --blocks-dir/--out-file overrides", () => {
175
+ writeFixture({ "cli-block": { via: "cli" } });
176
+ const cliOut = path.join(tmpDir, "custom", "manifest.gen.ts");
177
+
178
+ const r = cp.spawnSync(
179
+ "npx",
180
+ ["tsx", SCRIPT, "--blocks-dir", blocksDir, "--out-file", cliOut],
181
+ { encoding: "utf8", cwd: tmpDir },
182
+ );
183
+ expect(r.status).toBe(0);
184
+ expect(r.stdout).toContain("Generated static-import manifest for 1 blocks");
185
+
186
+ const emitted = fs.readFileSync(cliOut, "utf-8");
187
+ expect(emitted).toContain('"cli-block": _b0');
188
+ // Specifier is relative to the out file's own directory.
189
+ expect(emitted).toContain('from "../.deco/blocks/cli-block.json";');
190
+ }, 30_000);
191
+ });
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Reads .deco/blocks/*.json and emits blocksManifest.gen.ts — a module that
4
+ * STATICALLY imports every block file and re-exports them as one
5
+ * `Record<string, unknown>` keyed exactly like
6
+ * `@decocms/blocks/cms/loadDecofileDirectory`: filename minus the `.json`
7
+ * extension, verbatim (no URL-decoding, no renaming — the `pages-` filename
8
+ * prefix on page blocks is load-bearing, see loadDecofileDirectory's doc
9
+ * comment).
10
+ *
11
+ * Why static imports instead of the runtime fs read: a plain
12
+ * `loadDecofileDirectory(".deco/blocks")` is invisible to the bundler, so in
13
+ * `next dev` editing a block JSON invalidates nothing (no CMS content
14
+ * hot-reload) and production deploys need `outputFileTracingIncludes` hacks
15
+ * to ship the directory. With the manifest, Next's own module graph owns the
16
+ * files: editing an imported JSON re-evaluates the server module graph
17
+ * (~120–165ms measured), which also resets module-scope memos like
18
+ * `createNextSetup`'s bootstrap cache — content edits reload naturally, and
19
+ * the JSON is bundled into the build output. The trade-off: adding or
20
+ * removing a block FILE requires re-running this generator (content edits do
21
+ * not), so wire it into the site's `generate` chain.
22
+ *
23
+ * Import specifiers use the RAW on-disk filename. Verified against webpack,
24
+ * Turbopack, and Vite: specifiers are opaque strings to all three — `%2520`,
25
+ * `%20`, parentheses, `%C3%A7`, `%2F` etc. pass through verbatim and nothing
26
+ * URL-decodes, so JSON.stringify-quoting the specifier (and the key) is all
27
+ * the escaping needed.
28
+ *
29
+ * Usage (from site root):
30
+ * npx tsx node_modules/@decocms/blocks-cli/scripts/generate-blocks-manifest.ts
31
+ *
32
+ * CLI:
33
+ * --blocks-dir override input (default: .deco/blocks)
34
+ * --out-file override output (default: .deco/blocksManifest.gen.ts)
35
+ *
36
+ * Programmatic:
37
+ * import { generateBlocksManifest } from "@decocms/blocks-cli/generate-blocks-manifest";
38
+ * await generateBlocksManifest({ blocksDir, outFile });
39
+ */
40
+ import fs from "node:fs";
41
+ import path from "node:path";
42
+
43
+ // Header of the emitted module. Kept as line comments and deliberately free
44
+ // of interpolated filenames: block filenames can contain almost any
45
+ // character, and a filename containing the sequence `*` + `/` inside a
46
+ // generated /** ... */ block would terminate the comment early and corrupt
47
+ // the module (a past incident). Filenames only ever appear inside
48
+ // JSON.stringify-quoted string literals below.
49
+ const HEADER = [
50
+ "// Auto-generated by @decocms/blocks-cli/scripts/generate-blocks-manifest.ts — do not edit.",
51
+ "//",
52
+ "// Static-import manifest of every JSON decofile in the blocks directory,",
53
+ "// keyed by filename minus the .json extension, VERBATIM — the same key",
54
+ "// format @decocms/blocks/cms/loadDecofileDirectory produces (page blocks",
55
+ "// keep their load-bearing `pages-` filename prefix).",
56
+ "//",
57
+ "// Because every block file is a static import, the bundler's module graph",
58
+ "// owns them: in `next dev`, editing a block JSON re-evaluates the server",
59
+ "// module graph (CMS content hot-reload), and production builds bundle the",
60
+ "// content (no outputFileTracingIncludes needed). Adding or removing a",
61
+ "// block FILE requires re-running the generator; editing content does not.",
62
+ "//",
63
+ "// Regenerate:",
64
+ "// npx tsx node_modules/@decocms/blocks-cli/scripts/generate-blocks-manifest.ts",
65
+ "",
66
+ ].join("\n");
67
+
68
+ export interface GenerateBlocksManifestOptions {
69
+ blocksDir: string;
70
+ outFile: string;
71
+ /** Suppress the per-run summary log. Defaults to false. */
72
+ silent?: boolean;
73
+ }
74
+
75
+ export interface GenerateBlocksManifestResult {
76
+ count: number;
77
+ outFile: string;
78
+ /** True when the blocks dir was missing and an empty manifest was emitted. */
79
+ empty: boolean;
80
+ /**
81
+ * True when the manifest on disk actually changed. Regeneration is
82
+ * idempotent: an unchanged block set rewrites nothing, so watchers (and
83
+ * Next's module graph) are not tickled by no-op runs.
84
+ */
85
+ written: boolean;
86
+ }
87
+
88
+ function renderManifest(blocksDir: string, outFile: string, files: string[]): string {
89
+ const lines: string[] = [HEADER];
90
+
91
+ const sorted = [...files].sort(); // code-unit sort — locale-independent, diff-stable
92
+
93
+ for (let i = 0; i < sorted.length; i++) {
94
+ // Import specifier: the RAW filename, relative to the emitted module.
95
+ // JSON.stringify provides all necessary escaping — bundlers do not
96
+ // URL-decode specifiers, so no percent-escaping/normalizing here.
97
+ let spec = path
98
+ .relative(path.dirname(outFile), path.join(blocksDir, sorted[i]))
99
+ .replace(/\\/g, "/");
100
+ if (!spec.startsWith(".")) spec = `./${spec}`;
101
+ lines.push(`import _b${i} from ${JSON.stringify(spec)};`);
102
+ }
103
+
104
+ lines.push("");
105
+ lines.push("const blocks: Record<string, unknown> = {");
106
+ for (let i = 0; i < sorted.length; i++) {
107
+ const key = sorted[i].slice(0, -".json".length);
108
+ lines.push(` ${JSON.stringify(key)}: _b${i},`);
109
+ }
110
+ lines.push("};");
111
+ lines.push("");
112
+ lines.push("export default blocks;");
113
+ lines.push("");
114
+
115
+ return lines.join("\n");
116
+ }
117
+
118
+ async function writeIfChanged(outFile: string, content: string): Promise<boolean> {
119
+ let existing: string | undefined;
120
+ try {
121
+ existing = await fs.promises.readFile(outFile, "utf-8");
122
+ } catch {}
123
+ if (existing === content) return false;
124
+ await fs.promises.mkdir(path.dirname(outFile), { recursive: true });
125
+ await fs.promises.writeFile(outFile, content);
126
+ return true;
127
+ }
128
+
129
+ export async function generateBlocksManifest(
130
+ options: GenerateBlocksManifestOptions,
131
+ ): Promise<GenerateBlocksManifestResult> {
132
+ const blocksDir = path.resolve(options.blocksDir);
133
+ const outFile = path.resolve(options.outFile);
134
+ const silent = options.silent ?? false;
135
+
136
+ if (!fs.existsSync(blocksDir)) {
137
+ if (!silent) {
138
+ console.warn(`Blocks directory not found: ${blocksDir} — generating empty manifest.`);
139
+ }
140
+ const written = await writeIfChanged(outFile, renderManifest(blocksDir, outFile, []));
141
+ return { count: 0, outFile, empty: true, written };
142
+ }
143
+
144
+ // Top-level *.json files only — mirrors loadDecofileDirectory, which
145
+ // filters `entry.isFile()` and never recurses (nested paths live encoded
146
+ // in the FILENAME, e.g. `collections%2Fblog%2F....json`).
147
+ const files = (await fs.promises.readdir(blocksDir, { withFileTypes: true }))
148
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
149
+ .map((entry) => entry.name);
150
+
151
+ const written = await writeIfChanged(outFile, renderManifest(blocksDir, outFile, files));
152
+
153
+ if (!silent) {
154
+ console.log(
155
+ `Generated static-import manifest for ${files.length} blocks → ` +
156
+ `${path.relative(process.cwd(), outFile)}${written ? "" : " (unchanged)"}`,
157
+ );
158
+ }
159
+
160
+ return { count: files.length, outFile, empty: false, written };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // CLI shim — same pattern as generate-blocks.ts.
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function isMainModule(): boolean {
168
+ // tsx/node ESM: import.meta.url matches process.argv[1] when invoked directly.
169
+ // Use a forgiving comparison so it works under both `tsx script.ts` and
170
+ // `node --import tsx script.ts`.
171
+ const entry = process.argv[1];
172
+ if (!entry) return false;
173
+ try {
174
+ const entryUrl = new URL(`file://${path.resolve(entry)}`).href;
175
+ return import.meta.url === entryUrl;
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ if (isMainModule()) {
182
+ const args = process.argv.slice(2);
183
+ const arg = (name: string, fallback: string): string => {
184
+ const idx = args.indexOf(`--${name}`);
185
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
186
+ };
187
+
188
+ const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks"));
189
+ const outFile = path.resolve(process.cwd(), arg("out-file", ".deco/blocksManifest.gen.ts"));
190
+
191
+ generateBlocksManifest({ blocksDir, outFile }).catch((err) => {
192
+ console.error(err);
193
+ process.exit(1);
194
+ });
195
+ }