@decocms/blocks-cli 7.16.3 → 7.16.4
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
|
+
"version": "7.16.4",
|
|
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.
|
|
33
|
+
"@decocms/blocks": "7.16.4",
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
|
59
|
-
// Key: filename
|
|
60
|
-
expect(emitted).toContain(`${JSON.stringify(
|
|
61
|
-
// Specifier: the raw on-disk filename, relative to the emitted module
|
|
62
|
-
|
|
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
|
-
//
|
|
65
|
-
expect(emitted).not.toContain("pages-PDP%
|
|
66
|
-
expect(emitted).not.toContain("pages-Home
|
|
67
|
-
expect(emitted).not.toContain("collections
|
|
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
|
-
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
59
|
-
"// format @decocms/blocks/cms/loadDecofileDirectory produces (page
|
|
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 (
|
|
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("};");
|