@decocms/blocks-cli 7.7.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 +3 -2
- package/scripts/generate-blocks-manifest.test.ts +191 -0
- package/scripts/generate-blocks-manifest.ts +195 -0
- package/scripts/generate-invoke.test.ts +78 -0
- package/scripts/generate-invoke.ts +17 -8
- package/scripts/generate-sections.test.ts +69 -0
- package/scripts/generate-sections.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
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
|
+
}
|
|
@@ -190,3 +190,81 @@ describe("generate-invoke.ts — output shape", () => {
|
|
|
190
190
|
expect(generatedOutput).toContain("const result = await simulateCart(data);");
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
|
+
|
|
194
|
+
describe("generate-invoke.ts — default --apps-dir resolution", () => {
|
|
195
|
+
// Regression for the published-tarball layout: @decocms/apps-vtex 7.x
|
|
196
|
+
// ships its sources under src/ (`"files": ["src"]`), so invoke.ts lives
|
|
197
|
+
// at node_modules/@decocms/apps-vtex/src/invoke.ts — NOT at the package
|
|
198
|
+
// root. The old default only probed the root, never resolved on a site
|
|
199
|
+
// with npm-installed packages, and forced sites to pass
|
|
200
|
+
// `--apps-dir node_modules/@decocms/apps-vtex/src` by hand (granadobr's
|
|
201
|
+
// migration workaround). The default must probe <pkg>/invoke.ts first,
|
|
202
|
+
// then <pkg>/src/invoke.ts.
|
|
203
|
+
|
|
204
|
+
function makeSite(layout: "root" | "src"): { siteDir: string; cleanup: () => void } {
|
|
205
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-default-"));
|
|
206
|
+
const siteDir = path.join(tmp, "site");
|
|
207
|
+
const pkgDir = path.join(siteDir, "node_modules", "@decocms", "apps-vtex");
|
|
208
|
+
const appsDir = layout === "src" ? path.join(pkgDir, "src") : pkgDir;
|
|
209
|
+
fs.mkdirSync(path.join(appsDir, "actions"), { recursive: true });
|
|
210
|
+
fs.writeFileSync(path.join(appsDir, "invoke.ts"), FIXTURE_INVOKE_TS);
|
|
211
|
+
fs.writeFileSync(path.join(appsDir, "actions", "checkout.ts"), FIXTURE_ACTIONS_CHECKOUT_TS);
|
|
212
|
+
fs.writeFileSync(path.join(appsDir, "actions", "session.ts"), FIXTURE_ACTIONS_SESSION_TS);
|
|
213
|
+
fs.writeFileSync(path.join(appsDir, "types.ts"), FIXTURE_TYPES_TS);
|
|
214
|
+
return { siteDir, cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it("resolves the published layout (node_modules/@decocms/apps-vtex/src/invoke.ts) without --apps-dir", () => {
|
|
218
|
+
const { siteDir, cleanup } = makeSite("src");
|
|
219
|
+
try {
|
|
220
|
+
const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
|
|
221
|
+
const result = spawnSync("npx", ["tsx", GENERATOR, "--out-file", outFile], {
|
|
222
|
+
cwd: siteDir,
|
|
223
|
+
encoding: "utf8",
|
|
224
|
+
});
|
|
225
|
+
expect(result.status, result.stderr).toBe(0);
|
|
226
|
+
const generated = fs.readFileSync(outFile, "utf8");
|
|
227
|
+
// Relative `./actions/*` imports must still be rewritten to package
|
|
228
|
+
// subpaths — the exports map points them back into src/, so the src/
|
|
229
|
+
// nesting must not leak into the emitted specifiers.
|
|
230
|
+
expect(generated).toContain('from "@decocms/apps-vtex/actions/checkout"');
|
|
231
|
+
expect(generated).not.toContain("apps-vtex/src/");
|
|
232
|
+
expect(generated).toContain("export const vtexActions");
|
|
233
|
+
} finally {
|
|
234
|
+
cleanup();
|
|
235
|
+
}
|
|
236
|
+
}, 30_000);
|
|
237
|
+
|
|
238
|
+
it("still resolves invoke.ts at the package root (legacy dev-checkout layout) without --apps-dir", () => {
|
|
239
|
+
const { siteDir, cleanup } = makeSite("root");
|
|
240
|
+
try {
|
|
241
|
+
const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
|
|
242
|
+
const result = spawnSync("npx", ["tsx", GENERATOR, "--out-file", outFile], {
|
|
243
|
+
cwd: siteDir,
|
|
244
|
+
encoding: "utf8",
|
|
245
|
+
});
|
|
246
|
+
expect(result.status, result.stderr).toBe(0);
|
|
247
|
+
const generated = fs.readFileSync(outFile, "utf8");
|
|
248
|
+
expect(generated).toContain('from "@decocms/apps-vtex/actions/checkout"');
|
|
249
|
+
expect(generated).toContain("export const vtexActions");
|
|
250
|
+
} finally {
|
|
251
|
+
cleanup();
|
|
252
|
+
}
|
|
253
|
+
}, 30_000);
|
|
254
|
+
|
|
255
|
+
it("fails with a clear error when @decocms/apps-vtex cannot be found", () => {
|
|
256
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-missing-"));
|
|
257
|
+
try {
|
|
258
|
+
const outFile = path.join(tmp, "src", "server", "invoke.gen.ts");
|
|
259
|
+
const result = spawnSync("npx", ["tsx", GENERATOR, "--out-file", outFile], {
|
|
260
|
+
cwd: tmp,
|
|
261
|
+
encoding: "utf8",
|
|
262
|
+
});
|
|
263
|
+
expect(result.status).not.toBe(0);
|
|
264
|
+
expect(result.stderr).toContain("Could not find @decocms/apps-vtex");
|
|
265
|
+
expect(result.stderr).toContain("--apps-dir");
|
|
266
|
+
} finally {
|
|
267
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
}, 30_000);
|
|
270
|
+
});
|
|
@@ -55,17 +55,26 @@ function resolveAppsDir(): string {
|
|
|
55
55
|
const explicit = arg("apps-dir", "");
|
|
56
56
|
if (explicit) return path.resolve(cwd, explicit);
|
|
57
57
|
|
|
58
|
-
// Try common locations
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
58
|
+
// Try common locations: the installed @decocms/apps-vtex package first,
|
|
59
|
+
// then a raw apps-start checkout's vtex/ subdirectory as a legacy fallback
|
|
60
|
+
// for anyone still developing against the pre-split monorepo.
|
|
61
|
+
//
|
|
62
|
+
// For each root, invoke.ts may sit directly at the root (legacy dev
|
|
63
|
+
// checkouts) or under src/ — the published @decocms/apps-vtex tarball
|
|
64
|
+
// ships its sources under src/ (`"files": ["src"]`, `"main":
|
|
65
|
+
// "./src/index.ts"`), so on any site with npm-installed 7.x packages the
|
|
66
|
+
// file lives at node_modules/@decocms/apps-vtex/src/invoke.ts. The src/
|
|
67
|
+
// nesting doesn't affect the emitted imports: relative `./actions/*`
|
|
68
|
+
// specifiers are rewritten to `@decocms/apps-vtex/actions/*`, which the
|
|
69
|
+
// package's exports map points back into src/.
|
|
70
|
+
const roots = [
|
|
64
71
|
path.resolve(cwd, "node_modules/@decocms/apps-vtex"),
|
|
65
72
|
path.resolve(cwd, "../apps-start/vtex"),
|
|
66
73
|
];
|
|
67
|
-
for (const
|
|
68
|
-
|
|
74
|
+
for (const root of roots) {
|
|
75
|
+
for (const c of [root, path.join(root, "src")]) {
|
|
76
|
+
if (fs.existsSync(path.join(c, "invoke.ts"))) return c;
|
|
77
|
+
}
|
|
69
78
|
}
|
|
70
79
|
throw new Error("Could not find @decocms/apps-vtex. Use --apps-dir to specify its location.");
|
|
71
80
|
}
|
|
@@ -281,6 +281,75 @@ describe("generate-sections --registry", () => {
|
|
|
281
281
|
}, 30_000);
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
describe("generate-sections neverDefer convention", () => {
|
|
285
|
+
// Regression: the scanner recognized `export const neverDefer = true` and
|
|
286
|
+
// emitted `neverDefer: true` on the section's sectionMeta entry, but the
|
|
287
|
+
// SectionMetaEntry interface the SAME file declares omitted the field —
|
|
288
|
+
// so every generated file containing a neverDefer section failed the
|
|
289
|
+
// site's typecheck (TS2353 excess property), and sites hand-patched the
|
|
290
|
+
// interface only to have the next regeneration wipe the patch (miess's
|
|
291
|
+
// .deco/sections.gen.ts carried exactly that TODO). The emitted interface
|
|
292
|
+
// must match SectionMetaEntry in @decocms/blocks/cms.
|
|
293
|
+
let tmpDir: string;
|
|
294
|
+
let sectionsDir: string;
|
|
295
|
+
let outFile: string;
|
|
296
|
+
|
|
297
|
+
beforeEach(() => {
|
|
298
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-neverdefer-"));
|
|
299
|
+
sectionsDir = path.join(tmpDir, "sections");
|
|
300
|
+
outFile = path.join(tmpDir, "out", "sections.gen.ts");
|
|
301
|
+
fs.mkdirSync(sectionsDir, { recursive: true });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
afterEach(() => {
|
|
305
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("declares neverDefer on the emitted SectionMetaEntry interface and the generated file typechecks + imports", () => {
|
|
309
|
+
// Mirrors miess's src/sections/Product/SearchResult.tsx.
|
|
310
|
+
fs.writeFileSync(
|
|
311
|
+
path.join(sectionsDir, "SearchResult.tsx"),
|
|
312
|
+
"export const neverDefer = true;\nexport default function SearchResult() { return null; }\n",
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]);
|
|
316
|
+
expect(code).toBe(0);
|
|
317
|
+
|
|
318
|
+
const generated = fs.readFileSync(outFile, "utf-8");
|
|
319
|
+
// Entry carries the convention…
|
|
320
|
+
expect(generated).toMatch(/"site\/sections\/SearchResult\.tsx": \{ neverDefer: true \}/);
|
|
321
|
+
// …and the interface declares the field (optional boolean, matching
|
|
322
|
+
// SectionMetaEntry in @decocms/blocks/cms/applySectionConventions.ts).
|
|
323
|
+
expect(generated).toContain("neverDefer?: boolean;");
|
|
324
|
+
|
|
325
|
+
// The actual failure mode was a TYPECHECK error (excess property on the
|
|
326
|
+
// Record<string, SectionMetaEntry> literal), which a runtime import
|
|
327
|
+
// through tsx/esbuild would never catch — so run tsc on the output.
|
|
328
|
+
const tscResult = cp.spawnSync(
|
|
329
|
+
"npx",
|
|
330
|
+
["tsc", "--noEmit", "--strict", "--skipLibCheck", outFile],
|
|
331
|
+
{ encoding: "utf8" },
|
|
332
|
+
);
|
|
333
|
+
expect(tscResult.status, tscResult.stdout + tscResult.stderr).toBe(0);
|
|
334
|
+
|
|
335
|
+
// And keep the importability guarantee from the earlier template
|
|
336
|
+
// regression: the file must load as valid TS/ESM with the expected
|
|
337
|
+
// exports.
|
|
338
|
+
const checkerFile = path.join(tmpDir, "check-import.mjs");
|
|
339
|
+
fs.writeFileSync(
|
|
340
|
+
checkerFile,
|
|
341
|
+
[
|
|
342
|
+
`const m = await import(${JSON.stringify(pathToFileURL(outFile).href)});`,
|
|
343
|
+
`if (m.sectionMeta?.["site/sections/SearchResult.tsx"]?.neverDefer !== true) {`,
|
|
344
|
+
` throw new Error("neverDefer entry missing from sectionMeta");`,
|
|
345
|
+
`}`,
|
|
346
|
+
].join("\n"),
|
|
347
|
+
);
|
|
348
|
+
const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" });
|
|
349
|
+
expect(importResult.status, importResult.stderr).toBe(0);
|
|
350
|
+
}, 60_000);
|
|
351
|
+
});
|
|
352
|
+
|
|
284
353
|
describe("generate-sections output hygiene (non-registry)", () => {
|
|
285
354
|
let tmpDir: string;
|
|
286
355
|
let sectionsDir: string;
|
|
@@ -194,8 +194,14 @@ for (let i = 0; i < nonSyncFallbacks.length; i++) {
|
|
|
194
194
|
lines.push("");
|
|
195
195
|
|
|
196
196
|
// Metadata map
|
|
197
|
+
// Keep this emitted interface in sync with SectionMetaEntry in
|
|
198
|
+
// @decocms/blocks/cms (applySectionConventions.ts) — every convention the
|
|
199
|
+
// scanner can set on an entry must be declared here, or the generated file
|
|
200
|
+
// fails the site's typecheck (excess-property error) and sites end up
|
|
201
|
+
// hand-patching a file that the next regeneration wipes.
|
|
197
202
|
lines.push("export interface SectionMetaEntry {");
|
|
198
203
|
lines.push(" eager?: boolean;");
|
|
204
|
+
lines.push(" neverDefer?: boolean;");
|
|
199
205
|
lines.push(" cache?: string;");
|
|
200
206
|
lines.push(" layout?: boolean;");
|
|
201
207
|
lines.push(" sync?: boolean;");
|