@decocms/blocks-cli 7.16.3 → 7.16.5

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.16.3",
3
+ "version": "7.16.5",
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.16.3",
33
+ "@decocms/blocks": "7.16.5",
34
34
  "ts-morph": "^27.0.0",
35
35
  "tsx": "^4.22.5"
36
36
  },
@@ -5,15 +5,16 @@
5
5
  * block filenames (the shapes found in production `.deco/blocks` corpora:
6
6
  * double-encoded `%2520`, single-encoded `%20`, parentheses, encoded slashes
7
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.
8
+ * consume it: keys URL-decoded once (parseBlockId), import specifiers raw,
9
+ * `tsc` accepts it, and importing it (via a tsx child process from the
10
+ * fixture dir, resolving the raw-filename JSON import specifiers against the
11
+ * real files on disk) yields the parsed contents.
11
12
  */
12
13
  import * as cp from "node:child_process";
13
14
  import * as fs from "node:fs";
14
15
  import * as os from "node:os";
15
16
  import * as path from "node:path";
16
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
18
  import { generateBlocksManifest } from "./generate-blocks-manifest";
18
19
 
19
20
  const SCRIPT = path.resolve(__dirname, "generate-blocks-manifest.ts");
@@ -25,6 +26,14 @@ const HOSTILE_FIXTURE: Record<string, unknown> = {
25
26
  "pages-Calçados-42": { utf8: "çãé" },
26
27
  };
27
28
 
29
+ // filename stem → emitted key: URL-decoded exactly once (parseBlockId).
30
+ const DECODED_KEY: Record<string, string> = {
31
+ "pages-PDP%2520Box-102215": "pages-PDP%20Box-102215",
32
+ "pages-Home%20(principal)-287364": "pages-Home (principal)-287364",
33
+ "collections%2Fblog%2Fauthors%2Fx": "collections/blog/authors/x",
34
+ "pages-Calçados-42": "pages-Calçados-42",
35
+ };
36
+
28
37
  describe("generate-blocks-manifest", () => {
29
38
  let tmpDir: string;
30
39
  let blocksDir: string;
@@ -47,7 +56,7 @@ describe("generate-blocks-manifest", () => {
47
56
  }
48
57
  };
49
58
 
50
- it("emits verbatim keys and raw-filename import specifiers for hostile names", async () => {
59
+ it("emits once-decoded keys and raw-filename import specifiers for hostile names", async () => {
51
60
  writeFixture();
52
61
  const result = await generateBlocksManifest({ blocksDir, outFile, silent: true });
53
62
  expect(result.count).toBe(4);
@@ -55,16 +64,43 @@ describe("generate-blocks-manifest", () => {
55
64
  expect(result.written).toBe(true);
56
65
 
57
66
  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`)};`);
67
+ for (const stem of Object.keys(HOSTILE_FIXTURE)) {
68
+ // Key: parseBlockId(<filename>)the stem URL-decoded once.
69
+ expect(emitted).toContain(`${JSON.stringify(DECODED_KEY[stem])}: `);
70
+ // Specifier: the raw on-disk filename, relative to the emitted module
71
+ // bundlers never URL-decode specifiers, so no normalizing there.
72
+ expect(emitted).toContain(`from ${JSON.stringify(`./blocks/${stem}.json`)};`);
63
73
  }
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");
74
+ // Raw stems must not leak into keys (only into import specifiers).
75
+ expect(emitted).not.toContain('"pages-PDP%2520Box-102215": ');
76
+ expect(emitted).not.toContain('"pages-Home%20(principal)-287364": ');
77
+ expect(emitted).not.toContain('"collections%2Fblog%2Fauthors%2Fx": ');
78
+ });
79
+
80
+ it("keeps a stem verbatim as key when it is not valid percent-encoding", async () => {
81
+ writeFixture({ "pages-50% off-7": { x: 1 } });
82
+ await generateBlocksManifest({ blocksDir, outFile, silent: true });
83
+
84
+ const emitted = fs.readFileSync(outFile, "utf-8");
85
+ expect(emitted).toContain('"pages-50% off-7": _b0');
86
+ });
87
+
88
+ it("dedupes filenames that decode to the same key (last sorted wins, warns)", async () => {
89
+ writeFixture({ "A B": { raw: true }, "A%20B": { encoded: true } });
90
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
91
+ try {
92
+ await generateBlocksManifest({ blocksDir, outFile, silent: true });
93
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("A%20B.json"));
94
+ } finally {
95
+ warn.mockRestore();
96
+ }
97
+
98
+ const emitted = fs.readFileSync(outFile, "utf-8");
99
+ // One property only (duplicate object keys are a TS error), keyed "A B",
100
+ // bound to the LAST sorted filename ("A B.json" < "A%20B.json").
101
+ expect(emitted.match(/"A B": /g)).toHaveLength(1);
102
+ expect(emitted).not.toContain('from "./blocks/A B.json";');
103
+ expect(emitted).toContain('from "./blocks/A%20B.json";');
68
104
  });
69
105
 
70
106
  it("compiles under tsc (module esnext, bundler resolution, resolveJsonModule)", async () => {
@@ -105,7 +141,10 @@ describe("generate-blocks-manifest", () => {
105
141
  const r = cp.spawnSync("npx", ["tsx", runner], { encoding: "utf8", cwd: tmpDir });
106
142
  expect(r.stderr).toBe("");
107
143
  expect(r.status).toBe(0);
108
- expect(JSON.parse(r.stdout)).toEqual(HOSTILE_FIXTURE);
144
+ const expected = Object.fromEntries(
145
+ Object.entries(HOSTILE_FIXTURE).map(([stem, v]) => [DECODED_KEY[stem], v]),
146
+ );
147
+ expect(JSON.parse(r.stdout)).toEqual(expected);
109
148
  }, 30_000);
110
149
 
111
150
  it("is idempotent: regeneration over an unchanged block set writes nothing", async () => {
@@ -8,10 +8,13 @@
8
8
  * Reads .deco/blocks/*.json and emits blocksManifest.gen.ts — a module that
9
9
  * STATICALLY imports every block file and re-exports them as one
10
10
  * `Record<string, unknown>` keyed exactly like
11
- * `@decocms/blocks/cms/loadDecofileDirectory`: filename minus the `.json`
12
- * extension, verbatim (no URL-decoding, no renaming the `pages-` filename
13
- * prefix on page blocks is load-bearing, see loadDecofileDirectory's doc
14
- * comment).
11
+ * `@decocms/blocks/cms/loadDecofileDirectory`: `parseBlockId(<filename>)`,
12
+ * the filename stem URL-decoded ONCE (classic deco stores blocks as
13
+ * `encodeURIComponent(<block id>).json`; content references saved blocks by
14
+ * the decoded id, and the Studio editor writes back to
15
+ * `encodeURIComponent(<key>).json`). No renaming beyond that decode — the
16
+ * `pages-` filename prefix on page blocks is load-bearing, see
17
+ * loadDecofileDirectory's doc comment.
15
18
  *
16
19
  * Why static imports instead of the runtime fs read: a plain
17
20
  * `loadDecofileDirectory(".deco/blocks")` is invisible to the bundler, so in
@@ -44,6 +47,7 @@
44
47
  */
45
48
  import fs from "node:fs";
46
49
  import path from "node:path";
50
+ import { parseBlockId } from "@decocms/blocks/cms/loadDecofileDirectory";
47
51
 
48
52
  // Header of the emitted module. Kept as line comments and deliberately free
49
53
  // of interpolated filenames: block filenames can contain almost any
@@ -55,9 +59,9 @@ const HEADER = [
55
59
  "// Auto-generated by @decocms/blocks-cli/scripts/generate-blocks-manifest.ts — do not edit.",
56
60
  "//",
57
61
  "// Static-import manifest of every JSON decofile in the blocks directory,",
58
- "// keyed by filename minus the .json extension, VERBATIM — the same key",
59
- "// format @decocms/blocks/cms/loadDecofileDirectory produces (page blocks",
60
- "// keep their load-bearing `pages-` filename prefix).",
62
+ "// keyed by parseBlockId(<filename>) the stem URL-decoded once — the same",
63
+ "// key format @decocms/blocks/cms/loadDecofileDirectory produces (page",
64
+ "// blocks keep their load-bearing `pages-` filename prefix).",
61
65
  "//",
62
66
  "// Because every block file is a static import, the bundler's module graph",
63
67
  "// owns them: in `next dev`, editing a block JSON re-evaluates the server",
@@ -95,7 +99,24 @@ function renderManifest(blocksDir: string, outFile: string, files: string[]): st
95
99
 
96
100
  const sorted = [...files].sort(); // code-unit sort — locale-independent, diff-stable
97
101
 
102
+ // Two filenames can decode to the same block id (`A B.json` + `A%20B.json`).
103
+ // Mirror loadDecofileDirectory: last sorted filename wins, warn about the
104
+ // shadowed one. Emitting both would also be a TS error (duplicate object
105
+ // key), so the shadowed file is not imported at all.
106
+ const keyToIndex = new Map<string, number>();
98
107
  for (let i = 0; i < sorted.length; i++) {
108
+ const key = parseBlockId(sorted[i]);
109
+ const prev = keyToIndex.get(key);
110
+ if (prev !== undefined) {
111
+ console.warn(
112
+ `generate-blocks-manifest: block id ${JSON.stringify(key)} maps to multiple files; ${JSON.stringify(sorted[i])} wins`,
113
+ );
114
+ }
115
+ keyToIndex.set(key, i);
116
+ }
117
+ const emitted = [...keyToIndex.entries()]; // insertion order = sorted order
118
+
119
+ for (const [, i] of emitted) {
99
120
  // Import specifier: the RAW filename, relative to the emitted module.
100
121
  // JSON.stringify provides all necessary escaping — bundlers do not
101
122
  // URL-decode specifiers, so no percent-escaping/normalizing here.
@@ -108,8 +129,7 @@ function renderManifest(blocksDir: string, outFile: string, files: string[]): st
108
129
 
109
130
  lines.push("");
110
131
  lines.push("const blocks: Record<string, unknown> = {");
111
- for (let i = 0; i < sorted.length; i++) {
112
- const key = sorted[i].slice(0, -".json".length);
132
+ for (const [key, i] of emitted) {
113
133
  lines.push(` ${JSON.stringify(key)}: _b${i},`);
114
134
  }
115
135
  lines.push("};");