@decocms/blocks-cli 7.5.2 → 7.5.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 +2 -2
- package/scripts/generate-schema.ts +46 -15
- package/scripts/generate-sections.test.ts +67 -0
- package/scripts/generate-sections.ts +16 -2
- package/scripts/lib/codegenExclusions.test.ts +16 -6
- package/scripts/lib/codegenExclusions.ts +13 -1
- package/scripts/migrate/phase-cleanup.test.ts +70 -1
- package/scripts/migrate/phase-cleanup.ts +18 -3
- package/scripts/migrate/templates/package-json.ts +6 -0
- package/scripts/migrate/transforms/imports.test.ts +138 -0
- package/scripts/migrate/transforms/imports.ts +17 -1
- package/scripts/migrate/transforms/jsx.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.5.
|
|
3
|
+
"version": "7.5.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": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"lint:unused": "knip"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@decocms/blocks": "7.5.
|
|
37
|
+
"@decocms/blocks": "7.5.4",
|
|
38
38
|
"ts-morph": "^27.0.0",
|
|
39
39
|
"tsx": "^4.22.5"
|
|
40
40
|
},
|
|
@@ -40,6 +40,17 @@ import { warnLegacyArtifact } from "./lib/legacyArtifact";
|
|
|
40
40
|
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
42
|
// CLI arg parsing
|
|
43
|
+
//
|
|
44
|
+
// Guarded by isMainModule() (defined below — hoisted, so it's callable up
|
|
45
|
+
// here) so that importing this module for its pure exports
|
|
46
|
+
// (definitionIdForPath, applyWidgetFormat, typeToJsonSchema — see
|
|
47
|
+
// generate-schema.test.ts) never reads argv or touches the filesystem.
|
|
48
|
+
// Without the guard, every import used to run an `fs.existsSync` check
|
|
49
|
+
// against the *importing process's* cwd and could print a legacy-artifact
|
|
50
|
+
// warning to stderr as an import-time side effect, unrelated to whatever
|
|
51
|
+
// the test actually wanted to exercise. generateMeta() (below) and the
|
|
52
|
+
// final write are themselves only reached inside `if (isMainModule())`, so
|
|
53
|
+
// these vars only need real values in that same case.
|
|
43
54
|
// ---------------------------------------------------------------------------
|
|
44
55
|
const argv = process.argv.slice(2);
|
|
45
56
|
function arg(name: string, fallback: string): string {
|
|
@@ -47,20 +58,33 @@ function arg(name: string, fallback: string): string {
|
|
|
47
58
|
return idx !== -1 && argv[idx + 1] ? argv[idx + 1] : fallback;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
|
-
const SITE_NAMESPACE = arg("namespace", "site");
|
|
51
|
-
const SITE_NAME = arg("site", "storefront");
|
|
52
|
-
const FRAMEWORK_VERSION = arg("version", "1.0.0");
|
|
53
|
-
const SECTIONS_REL = arg("sections", "src/sections");
|
|
54
|
-
const LOADERS_REL = arg("loaders", "src/loaders");
|
|
55
|
-
const APPS_REL = arg("apps", "src/apps");
|
|
56
|
-
const SKIP_APPS = argv.includes("--skip-apps");
|
|
57
|
-
const OUT_FILE_EXPLICIT = argv.includes("--out");
|
|
58
61
|
const NEW_DEFAULT_OUT_REL = ".deco/meta.gen.json";
|
|
59
62
|
const OLD_DEFAULT_OUT_REL = "src/server/admin/meta.gen.json";
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
|
|
64
|
+
let SITE_NAMESPACE = "site";
|
|
65
|
+
let SITE_NAME = "storefront";
|
|
66
|
+
let FRAMEWORK_VERSION = "1.0.0";
|
|
67
|
+
let SECTIONS_REL = "src/sections";
|
|
68
|
+
let LOADERS_REL = "src/loaders";
|
|
69
|
+
let APPS_REL = "src/apps";
|
|
70
|
+
let SKIP_APPS = false;
|
|
71
|
+
let OUT_REL = NEW_DEFAULT_OUT_REL;
|
|
72
|
+
let PLATFORM = "cloudflare";
|
|
73
|
+
|
|
74
|
+
if (isMainModule()) {
|
|
75
|
+
SITE_NAMESPACE = arg("namespace", SITE_NAMESPACE);
|
|
76
|
+
SITE_NAME = arg("site", SITE_NAME);
|
|
77
|
+
FRAMEWORK_VERSION = arg("version", FRAMEWORK_VERSION);
|
|
78
|
+
SECTIONS_REL = arg("sections", SECTIONS_REL);
|
|
79
|
+
LOADERS_REL = arg("loaders", LOADERS_REL);
|
|
80
|
+
APPS_REL = arg("apps", APPS_REL);
|
|
81
|
+
SKIP_APPS = argv.includes("--skip-apps");
|
|
82
|
+
const outFileExplicit = argv.includes("--out");
|
|
83
|
+
OUT_REL = arg("out", NEW_DEFAULT_OUT_REL);
|
|
84
|
+
PLATFORM = arg("platform", PLATFORM);
|
|
85
|
+
if (!outFileExplicit && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_REL))) {
|
|
86
|
+
warnLegacyArtifact(OLD_DEFAULT_OUT_REL, NEW_DEFAULT_OUT_REL);
|
|
87
|
+
}
|
|
64
88
|
}
|
|
65
89
|
|
|
66
90
|
// ---------------------------------------------------------------------------
|
|
@@ -758,10 +782,17 @@ function findTsxFiles(dir: string): string[] {
|
|
|
758
782
|
const results: string[] = [];
|
|
759
783
|
if (!fs.existsSync(dir)) return results;
|
|
760
784
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
761
|
-
if (isExcludedCodegenFile(entry.name)) continue;
|
|
762
785
|
const full = path.join(dir, entry.name);
|
|
763
|
-
if (entry.isDirectory())
|
|
764
|
-
|
|
786
|
+
if (entry.isDirectory()) {
|
|
787
|
+
// The exclusion predicate targets generated/test *files* — a directory
|
|
788
|
+
// named e.g. `foo.gen.ts` is a real path segment and must still be walked.
|
|
789
|
+
results.push(...findTsxFiles(full));
|
|
790
|
+
} else if (
|
|
791
|
+
!isExcludedCodegenFile(entry.name) &&
|
|
792
|
+
(entry.name.endsWith(".tsx") || entry.name.endsWith(".ts"))
|
|
793
|
+
) {
|
|
794
|
+
results.push(full);
|
|
795
|
+
}
|
|
765
796
|
}
|
|
766
797
|
return results;
|
|
767
798
|
}
|
|
@@ -258,4 +258,71 @@ describe("generate-sections --registry", () => {
|
|
|
258
258
|
const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" });
|
|
259
259
|
expect(importResult.status, importResult.stderr).toBe(0);
|
|
260
260
|
}, 30_000);
|
|
261
|
+
|
|
262
|
+
it("ends with exactly one trailing newline and no doubled blank line before the registry block (output hygiene)", () => {
|
|
263
|
+
fs.writeFileSync(
|
|
264
|
+
path.join(sectionsDir, "Hero.tsx"),
|
|
265
|
+
"export const sync = true\nexport default function Hero() { return null }\n",
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const { code } = runGenerator([
|
|
269
|
+
"--sections-dir", sectionsDir,
|
|
270
|
+
"--out-file", outFile,
|
|
271
|
+
"--registry",
|
|
272
|
+
]);
|
|
273
|
+
expect(code).toBe(0);
|
|
274
|
+
|
|
275
|
+
const generated = fs.readFileSync(outFile, "utf-8");
|
|
276
|
+
expect(generated.endsWith("\n")).toBe(true);
|
|
277
|
+
expect(generated.endsWith("\n\n")).toBe(false);
|
|
278
|
+
// No run of 3+ consecutive newlines (i.e. no blank-line pair) anywhere,
|
|
279
|
+
// in particular right before the registry doc comment.
|
|
280
|
+
expect(generated).not.toMatch(/\n{3,}/);
|
|
281
|
+
}, 30_000);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("generate-sections output hygiene (non-registry)", () => {
|
|
285
|
+
let tmpDir: string;
|
|
286
|
+
let sectionsDir: string;
|
|
287
|
+
let outFile: string;
|
|
288
|
+
|
|
289
|
+
beforeEach(() => {
|
|
290
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-hygiene-"));
|
|
291
|
+
sectionsDir = path.join(tmpDir, "sections");
|
|
292
|
+
outFile = path.join(tmpDir, "out", "sections.gen.ts");
|
|
293
|
+
fs.mkdirSync(sectionsDir, { recursive: true });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
afterEach(() => {
|
|
297
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("ends with exactly one trailing newline without --registry", () => {
|
|
301
|
+
fs.writeFileSync(
|
|
302
|
+
path.join(sectionsDir, "Hero.tsx"),
|
|
303
|
+
"export const eager = true;\nexport default function Hero() { return null; }\n",
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]);
|
|
307
|
+
expect(code).toBe(0);
|
|
308
|
+
|
|
309
|
+
const generated = fs.readFileSync(outFile, "utf-8");
|
|
310
|
+
expect(generated.endsWith("\n")).toBe(true);
|
|
311
|
+
expect(generated.endsWith("\n\n")).toBe(false);
|
|
312
|
+
expect(generated).not.toMatch(/\n{3,}/);
|
|
313
|
+
}, 30_000);
|
|
314
|
+
|
|
315
|
+
it("ends with exactly one trailing newline when the sections dir is missing (empty-output early-exit path)", () => {
|
|
316
|
+
const missingSectionsDir = path.join(tmpDir, "does-not-exist");
|
|
317
|
+
|
|
318
|
+
const { code } = runGenerator([
|
|
319
|
+
"--sections-dir", missingSectionsDir,
|
|
320
|
+
"--out-file", outFile,
|
|
321
|
+
]);
|
|
322
|
+
expect(code).toBe(0);
|
|
323
|
+
|
|
324
|
+
const generated = fs.readFileSync(outFile, "utf-8");
|
|
325
|
+
expect(generated.endsWith("\n")).toBe(true);
|
|
326
|
+
expect(generated.endsWith("\n\n")).toBe(false);
|
|
327
|
+
}, 30_000);
|
|
261
328
|
});
|
|
@@ -248,7 +248,10 @@ lines.push("");
|
|
|
248
248
|
// Lazy section-import registry — opt-in via --registry, built from every
|
|
249
249
|
// scanned section file (not just convention-carrying `entries`).
|
|
250
250
|
if (EMIT_REGISTRY) {
|
|
251
|
-
lines.push("")
|
|
251
|
+
// NOTE: no extra `lines.push("")` here — the loadingFallbacks block above
|
|
252
|
+
// already pushed a single trailing blank line as its separator. Pushing
|
|
253
|
+
// another one here doubled the blank line before this comment block in
|
|
254
|
+
// every site's committed .deco/sections.gen.ts.
|
|
252
255
|
lines.push("/**");
|
|
253
256
|
lines.push(" * Lazy section registry — the Next.js/webpack equivalent of Vite's");
|
|
254
257
|
lines.push(" * `import.meta.glob` scan over every file under ./sections, recursively.");
|
|
@@ -265,7 +268,18 @@ if (EMIT_REGISTRY) {
|
|
|
265
268
|
}
|
|
266
269
|
|
|
267
270
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
268
|
-
|
|
271
|
+
// Output hygiene: several sections above push a trailing "" separator
|
|
272
|
+
// unconditionally (e.g. after the header comment, after the sync/fallback
|
|
273
|
+
// import blocks) — when a following section has nothing to emit (no sync
|
|
274
|
+
// imports, --registry off, etc.) those separators stack into a doubled
|
|
275
|
+
// blank line. Collapse any run of blank lines down to a single one, then
|
|
276
|
+
// normalize to exactly one trailing newline: without --registry, `lines`
|
|
277
|
+
// ends with a pushed "" separator (one trailing "\n" once joined); with
|
|
278
|
+
// --registry, the last pushed line is "};" (no trailing newline at all).
|
|
279
|
+
fs.writeFileSync(
|
|
280
|
+
outFile,
|
|
281
|
+
lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n"),
|
|
282
|
+
);
|
|
269
283
|
|
|
270
284
|
console.log(
|
|
271
285
|
`Generated section metadata for ${entries.length} sections → ${path.relative(process.cwd(), outFile)}`,
|
|
@@ -9,14 +9,24 @@ describe("isExcludedCodegenFile", () => {
|
|
|
9
9
|
"Hero.stories.tsx",
|
|
10
10
|
"sections.gen.ts",
|
|
11
11
|
"meta.gen.json",
|
|
12
|
+
// Bare names (no prefix before the dot) must be excluded too.
|
|
13
|
+
"test.ts",
|
|
14
|
+
"test.tsx",
|
|
15
|
+
"spec.tsx",
|
|
16
|
+
"stories.ts",
|
|
17
|
+
"gen.ts",
|
|
12
18
|
])("excludes %s", (name) => {
|
|
13
19
|
expect(isExcludedCodegenFile(name)).toBe(true);
|
|
14
20
|
});
|
|
15
21
|
|
|
16
|
-
it.each([
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
it.each([
|
|
23
|
+
"Hero.tsx",
|
|
24
|
+
"Product/SearchResult.tsx",
|
|
25
|
+
// Marker word embedded mid-identifier (not its own dot-delimited
|
|
26
|
+
// segment) must stay INCLUDED, even with the bare-name matching above.
|
|
27
|
+
"testimonials.tsx",
|
|
28
|
+
"generic.ts",
|
|
29
|
+
])("keeps %s", (name) => {
|
|
30
|
+
expect(isExcludedCodegenFile(name)).toBe(false);
|
|
31
|
+
});
|
|
22
32
|
});
|
|
@@ -3,8 +3,20 @@
|
|
|
3
3
|
* `generate-schema.ts` once emitted a site's co-located test file as a
|
|
4
4
|
* section block (it scans every .ts/.tsx under the sections dir), so both
|
|
5
5
|
* generators route their directory walks through this predicate.
|
|
6
|
+
*
|
|
7
|
+
* Matches both the usual `<name>.test.ts` co-located form and a bare
|
|
8
|
+
* `test.ts` / `spec.tsx` / `stories.ts` / `gen.ts` file (no prefix before
|
|
9
|
+
* the dot) — `(?:^|\.)` anchors the marker word to either the start of the
|
|
10
|
+
* filename or a preceding dot, so `testimonials.tsx` / `generic.ts` (marker
|
|
11
|
+
* word embedded mid-identifier, not its own dot-delimited segment) are
|
|
12
|
+
* correctly left INCLUDED.
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT: only ever apply this predicate to file entries, not
|
|
15
|
+
* directories, during a directory walk — a directory named e.g.
|
|
16
|
+
* `foo.gen.ts` is a real (if unusual) path segment, not a generated file,
|
|
17
|
+
* and must still be descended into.
|
|
6
18
|
*/
|
|
7
|
-
const EXCLUDED_SUFFIX_RE =
|
|
19
|
+
const EXCLUDED_SUFFIX_RE = /(?:^|\.)(test|spec|stories|gen)\.(ts|tsx|js|jsx|json)$/;
|
|
8
20
|
|
|
9
21
|
export function isExcludedCodegenFile(fileName: string): boolean {
|
|
10
22
|
return EXCLUDED_SUFFIX_RE.test(fileName);
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { writeImportedLibShims } from "./phase-cleanup";
|
|
5
|
+
import { upgradeSectionRenderer, writeImportedLibShims } from "./phase-cleanup";
|
|
6
6
|
import type { MigrationContext } from "./types";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -139,3 +139,72 @@ describe("writeImportedLibShims (integration)", () => {
|
|
|
139
139
|
}
|
|
140
140
|
});
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
describe("upgradeSectionRenderer (integration)", () => {
|
|
144
|
+
let tmpDir: string;
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "render-section-test-"));
|
|
148
|
+
fs.mkdirSync(path.join(tmpDir, "src", "components"), { recursive: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
afterEach(() => {
|
|
152
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it(
|
|
156
|
+
"adds the missing RenderSection import when a .Component call is " +
|
|
157
|
+
"converted to JSX usage and no import exists yet (regression: the " +
|
|
158
|
+
"inner check used to always be false)",
|
|
159
|
+
() => {
|
|
160
|
+
const file = path.join(tmpDir, "src", "components", "Nested.tsx");
|
|
161
|
+
fs.writeFileSync(
|
|
162
|
+
file,
|
|
163
|
+
`export function Nested({ block }: { block: any }) {\n` +
|
|
164
|
+
` return <block.Component {...block?.props} />;\n` +
|
|
165
|
+
`}\n`,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
upgradeSectionRenderer(makeCtx(tmpDir));
|
|
169
|
+
|
|
170
|
+
const result = fs.readFileSync(file, "utf-8");
|
|
171
|
+
expect(result).toContain(
|
|
172
|
+
`import { RenderSection } from "@decocms/blocks/hooks";`,
|
|
173
|
+
);
|
|
174
|
+
expect(result).toContain(`<RenderSection section={block} />`);
|
|
175
|
+
// Import must appear before its first usage.
|
|
176
|
+
expect(result.indexOf("import { RenderSection }")).toBeLessThan(
|
|
177
|
+
result.indexOf("<RenderSection"),
|
|
178
|
+
);
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
it("does not duplicate the import when one already exists", () => {
|
|
183
|
+
const file = path.join(tmpDir, "src", "components", "Nested.tsx");
|
|
184
|
+
fs.writeFileSync(
|
|
185
|
+
file,
|
|
186
|
+
`import { RenderSection } from "@decocms/blocks/hooks";\n\n` +
|
|
187
|
+
`export function Nested({ block }: { block: any }) {\n` +
|
|
188
|
+
` return <block.Component {...block?.props} />;\n` +
|
|
189
|
+
`}\n`,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
upgradeSectionRenderer(makeCtx(tmpDir));
|
|
193
|
+
|
|
194
|
+
const result = fs.readFileSync(file, "utf-8");
|
|
195
|
+
const importCount = (
|
|
196
|
+
result.match(/import\s*\{\s*RenderSection\s*\}\s*from/g) ?? []
|
|
197
|
+
).length;
|
|
198
|
+
expect(importCount).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does not touch files with no SectionRenderer/.Component usage", () => {
|
|
202
|
+
const file = path.join(tmpDir, "src", "components", "Plain.tsx");
|
|
203
|
+
const original = `export const Plain = () => <div>hi</div>;\n`;
|
|
204
|
+
fs.writeFileSync(file, original);
|
|
205
|
+
|
|
206
|
+
upgradeSectionRenderer(makeCtx(tmpDir));
|
|
207
|
+
|
|
208
|
+
expect(fs.readFileSync(file, "utf-8")).toBe(original);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -963,7 +963,7 @@ function convertDirectComponentCalls(src: string, onFix: (msg: string) => void):
|
|
|
963
963
|
* Also converts direct `<section.Component {...props}/>` patterns to use
|
|
964
964
|
* RenderSection for robustness.
|
|
965
965
|
*/
|
|
966
|
-
function upgradeSectionRenderer(ctx: MigrationContext) {
|
|
966
|
+
export function upgradeSectionRenderer(ctx: MigrationContext) {
|
|
967
967
|
rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
|
|
968
968
|
if (!relPath.endsWith(".tsx") && !relPath.endsWith(".ts")) return null;
|
|
969
969
|
|
|
@@ -975,6 +975,13 @@ function upgradeSectionRenderer(ctx: MigrationContext) {
|
|
|
975
975
|
// (RenderSection is a distinct component from tanstack's
|
|
976
976
|
// SectionRenderer — it lives in @decocms/blocks/hooks, not
|
|
977
977
|
// @decocms/tanstack.)
|
|
978
|
+
//
|
|
979
|
+
// HANDOFF MARKER: the "@decocms/start/hooks" specifier this matches
|
|
980
|
+
// is written earlier in the same migration run by
|
|
981
|
+
// transforms/jsx.ts (the `.Component`/`.props` → `<SectionRenderer>`
|
|
982
|
+
// rewrite), which deliberately emits that pre-split package path as
|
|
983
|
+
// a same-run handoff to this function. If the transform/cleanup
|
|
984
|
+
// phase order ever changes, this match target must move with it.
|
|
978
985
|
const sectionRendererImport =
|
|
979
986
|
/import\s*\{([^}]*)\bSectionRenderer\b([^}]*)\}\s*from\s*["']@decocms\/start\/hooks["']/g;
|
|
980
987
|
const newContent = result.replace(sectionRendererImport, (_m, before, after) => {
|
|
@@ -1001,8 +1008,16 @@ function upgradeSectionRenderer(ctx: MigrationContext) {
|
|
|
1001
1008
|
log(ctx, ` ${msg}: src/${relPath}`);
|
|
1002
1009
|
});
|
|
1003
1010
|
|
|
1004
|
-
// 4. Add RenderSection import if we introduced usages but no import exists
|
|
1005
|
-
|
|
1011
|
+
// 4. Add RenderSection import if we introduced usages but no import exists.
|
|
1012
|
+
// Must check for the actual import statement, not just the substring
|
|
1013
|
+
// "RenderSection" — every `<RenderSection` JSX usage also contains that
|
|
1014
|
+
// substring, so a naive `!result.includes("RenderSection")` check is
|
|
1015
|
+
// always false once step 2/3 above have run and this never fires.
|
|
1016
|
+
const hasRenderSectionImport =
|
|
1017
|
+
/import\s*\{[^}]*\bRenderSection\b[^}]*\}\s*from\s*["']@decocms\/blocks\/hooks["']/.test(
|
|
1018
|
+
result,
|
|
1019
|
+
);
|
|
1020
|
+
if (changed && result.includes("<RenderSection") && !hasRenderSectionImport) {
|
|
1006
1021
|
result = `import { RenderSection } from "@decocms/blocks/hooks";\n` + result;
|
|
1007
1022
|
}
|
|
1008
1023
|
|
|
@@ -100,6 +100,12 @@ export function generatePackageJson(ctx: MigrationContext): string {
|
|
|
100
100
|
// published in lockstep from the same monorepo release, so a single
|
|
101
101
|
// version lookup covers all of them (mirrors how consumer sites bump
|
|
102
102
|
// "@decocms/blocks/blocks-admin/nextjs ranges" together).
|
|
103
|
+
//
|
|
104
|
+
// If this ever stops being true (e.g. a package starts shipping
|
|
105
|
+
// independent version bumps), every `${frameworkVersion}` usage below
|
|
106
|
+
// (in `dependencies` and `devDependencies`) needs to become its own
|
|
107
|
+
// `getLatestVersion("<package>", "<fallback>")` call instead of sharing
|
|
108
|
+
// this one lookup.
|
|
103
109
|
const frameworkVersion = getLatestVersion("@decocms/blocks", "7.5.1");
|
|
104
110
|
|
|
105
111
|
const platformPackage: Partial<Record<typeof ctx.platform, string>> = {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { transformImports } from "./imports";
|
|
3
|
+
|
|
4
|
+
describe("transformImports — shopify hooks", () => {
|
|
5
|
+
it("rewrites the three known Shopify hooks to the site-local scaffolded files, mirroring VTEX", () => {
|
|
6
|
+
const src =
|
|
7
|
+
`import { useUser } from "apps/shopify/hooks/useUser.ts";\n` +
|
|
8
|
+
`import { useCart } from "apps/shopify/hooks/useCart.ts";\n` +
|
|
9
|
+
`import { useWishlist } from "apps/shopify/hooks/useWishlist.ts";\n`;
|
|
10
|
+
|
|
11
|
+
const r = transformImports(src);
|
|
12
|
+
|
|
13
|
+
expect(r.changed).toBe(true);
|
|
14
|
+
expect(r.content).toContain(`import { useUser } from "~/hooks/useUser";`);
|
|
15
|
+
expect(r.content).toContain(`import { useCart } from "~/hooks/useCart";`);
|
|
16
|
+
expect(r.content).toContain(
|
|
17
|
+
`import { useWishlist } from "~/hooks/useWishlist";`,
|
|
18
|
+
);
|
|
19
|
+
// Must never point at the nonexistent @decocms/apps-shopify/hooks/* target.
|
|
20
|
+
expect(r.content).not.toContain("@decocms/apps-shopify/hooks");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not rewrite an unknown shopify hook name to a nonexistent package export", () => {
|
|
24
|
+
// No shopify hook other than useCart/useUser/useWishlist has ever existed
|
|
25
|
+
// (verified against full git history), so there is no generic fallback
|
|
26
|
+
// rule anymore. Unhandled "apps/*" imports fall through to the general
|
|
27
|
+
// apps/ catch-all, which removes the import line entirely — surfacing as
|
|
28
|
+
// an obvious break at the usage site rather than an unresolvable module.
|
|
29
|
+
const src = `import { useSomethingElse } from "apps/shopify/hooks/useSomethingElse.ts";\n`;
|
|
30
|
+
|
|
31
|
+
const r = transformImports(src);
|
|
32
|
+
|
|
33
|
+
expect(r.content).not.toContain("@decocms/apps-shopify/hooks");
|
|
34
|
+
expect(r.content).not.toContain("apps/shopify/hooks");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("still rewrites shopify utils/actions/loaders to the real @decocms/apps-shopify package", () => {
|
|
38
|
+
const src =
|
|
39
|
+
`import { formatMoney } from "apps/shopify/utils/formatMoney.ts";\n` +
|
|
40
|
+
`import { addItems } from "apps/shopify/actions/cart/addItems.ts";\n` +
|
|
41
|
+
`import ProductList from "apps/shopify/loaders/ProductList.ts";\n`;
|
|
42
|
+
|
|
43
|
+
const r = transformImports(src);
|
|
44
|
+
|
|
45
|
+
expect(r.content).toContain(
|
|
46
|
+
`import { formatMoney } from "@decocms/apps-shopify/utils/formatMoney";`,
|
|
47
|
+
);
|
|
48
|
+
expect(r.content).toContain(
|
|
49
|
+
`import { addItems } from "@decocms/apps-shopify/actions/cart/addItems";`,
|
|
50
|
+
);
|
|
51
|
+
expect(r.content).toContain(
|
|
52
|
+
`import ProductList from "@decocms/apps-shopify/loaders/ProductList";`,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("transformImports — splitDecoHooksImports", () => {
|
|
58
|
+
it("splits a mixed useDevice + non-useDevice named import from the legacy @deco/deco/hooks specifier", () => {
|
|
59
|
+
const src = `import { useDevice, useScript, useSection } from "@deco/deco/hooks";\n`;
|
|
60
|
+
|
|
61
|
+
const r = transformImports(src);
|
|
62
|
+
|
|
63
|
+
expect(r.changed).toBe(true);
|
|
64
|
+
expect(r.content).toContain(
|
|
65
|
+
`import { useDevice } from "@decocms/blocks/sdk/useDevice";`,
|
|
66
|
+
);
|
|
67
|
+
expect(r.content).toContain(
|
|
68
|
+
`import { useScript, useSection } from "@decocms/blocks/sdk/useScript";`,
|
|
69
|
+
);
|
|
70
|
+
expect(r.notes).toContain(
|
|
71
|
+
"Split useDevice into separate import from @decocms/blocks/sdk/useDevice",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("emits only the useDevice import when useDevice is the sole named import", () => {
|
|
76
|
+
const src = `import { useDevice } from "@deco/deco/hooks";\n`;
|
|
77
|
+
|
|
78
|
+
const r = transformImports(src);
|
|
79
|
+
|
|
80
|
+
expect(r.content).toBe(
|
|
81
|
+
`import { useDevice } from "@decocms/blocks/sdk/useDevice";\n`,
|
|
82
|
+
);
|
|
83
|
+
expect(r.content).not.toContain("@decocms/blocks/sdk/useScript");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("leaves a non-useDevice-only import pointed at useScript, unsplit", () => {
|
|
87
|
+
const src = `import { useScript, useSection } from "@deco/deco/hooks";\n`;
|
|
88
|
+
|
|
89
|
+
const r = transformImports(src);
|
|
90
|
+
|
|
91
|
+
expect(r.content).toBe(
|
|
92
|
+
`import { useScript, useSection } from "@decocms/blocks/sdk/useScript";\n`,
|
|
93
|
+
);
|
|
94
|
+
expect(r.content).not.toContain("@decocms/blocks/sdk/useDevice");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("handles a `type` import with mixed useDevice/non-useDevice specifiers", () => {
|
|
98
|
+
const src = `import type { useDevice, useScript } from "@deco/deco/hooks";\n`;
|
|
99
|
+
|
|
100
|
+
const r = transformImports(src);
|
|
101
|
+
|
|
102
|
+
// The base IMPORT_RULES rewrite fires first (regardless of `type`), then
|
|
103
|
+
// splitDecoHooksImports re-splits the resulting line — confirming the
|
|
104
|
+
// regex-chain order (rewriteSpecifier → splitDecoHooksImports) still
|
|
105
|
+
// matches `import type { ... }` shapes, not just bare `import { ... }`.
|
|
106
|
+
expect(r.content).toContain(
|
|
107
|
+
`import { useDevice } from "@decocms/blocks/sdk/useDevice";`,
|
|
108
|
+
);
|
|
109
|
+
expect(r.content).toContain(
|
|
110
|
+
`import { useScript } from "@decocms/blocks/sdk/useScript";`,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("preserves aliased specifiers (useDevice as useDeviceHook) on the split line", () => {
|
|
115
|
+
const src = `import { useDevice as useDeviceHook, useScript } from "@deco/deco/hooks";\n`;
|
|
116
|
+
|
|
117
|
+
const r = transformImports(src);
|
|
118
|
+
|
|
119
|
+
expect(r.content).toContain(
|
|
120
|
+
`import { useDevice as useDeviceHook } from "@decocms/blocks/sdk/useDevice";`,
|
|
121
|
+
);
|
|
122
|
+
expect(r.content).toContain(
|
|
123
|
+
`import { useScript } from "@decocms/blocks/sdk/useScript";`,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("does not touch @decocms/blocks/sdk/useScript imports that were never routed through @deco/deco/hooks", () => {
|
|
128
|
+
// Guards the order-of-operations: splitDecoHooksImports must only ever
|
|
129
|
+
// fire as a post-process of the IMPORT_RULES rewrite, not independently
|
|
130
|
+
// match any pre-existing import already targeting useScript.
|
|
131
|
+
const src = `import { useScript } from "@decocms/blocks/sdk/useScript";\n`;
|
|
132
|
+
|
|
133
|
+
const r = transformImports(src);
|
|
134
|
+
|
|
135
|
+
expect(r.changed).toBe(false);
|
|
136
|
+
expect(r.content).toBe(src);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -85,7 +85,23 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
|
|
|
85
85
|
[/^"apps\/vtex\/types(?:\.ts)?"$/, `"@decocms/apps-vtex/types"`],
|
|
86
86
|
[/^"apps\/vtex\/mod(?:\.ts)?"$/, `"~/types/vtex-app"`],
|
|
87
87
|
// Apps — Shopify (hooks, utils, actions, loaders)
|
|
88
|
-
|
|
88
|
+
// Shopify hooks were never a real package export — not in the pre-split
|
|
89
|
+
// @decocms/apps monolith, not in @decocms/apps-shopify today (it ships
|
|
90
|
+
// no src/hooks/ dir and no ./hooks/* export; verified via `git log
|
|
91
|
+
// --diff-filter=A -- '**/shopify/hooks/**'` returning nothing across all
|
|
92
|
+
// history). The legacy migration reference
|
|
93
|
+
// (.agents/skills/deco-to-tanstack-migration/references/platform-hooks/README.md)
|
|
94
|
+
// confirms Shopify's useCart/useUser/useWishlist were always meant to be
|
|
95
|
+
// site-local no-op stubs, and templates/hooks.ts's generateHooks() still
|
|
96
|
+
// scaffolds them at src/hooks/use{Cart,User,Wishlist}.ts for every
|
|
97
|
+
// non-VTEX platform (shopify included). Mirror the VTEX rule shape below:
|
|
98
|
+
// route the three known hook names to the scaffolded local files, same as
|
|
99
|
+
// "apps/vtex/hooks/useUser" → "~/hooks/useUser" above. Do NOT reintroduce
|
|
100
|
+
// a generic "apps/shopify/hooks/$1" → "@decocms/apps-shopify/hooks/$1"
|
|
101
|
+
// fallback — that target has never existed.
|
|
102
|
+
[/^"apps\/shopify\/hooks\/useUser(?:\.ts)?"$/, `"~/hooks/useUser"`],
|
|
103
|
+
[/^"apps\/shopify\/hooks\/useCart(?:\.ts)?"$/, `"~/hooks/useCart"`],
|
|
104
|
+
[/^"apps\/shopify\/hooks\/useWishlist(?:\.ts)?"$/, `"~/hooks/useWishlist"`],
|
|
89
105
|
[/^"apps\/shopify\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/utils/$1"`],
|
|
90
106
|
[/^"apps\/shopify\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/actions/$1"`],
|
|
91
107
|
[/^"apps\/shopify\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/loaders/$1"`],
|
|
@@ -257,6 +257,14 @@ export function transformJsx(content: string): TransformResult {
|
|
|
257
257
|
// In TanStack Start, nested sections have Component as a string key, not a function.
|
|
258
258
|
// SectionRenderer from @decocms/start/hooks handles the lazy registry lookup.
|
|
259
259
|
//
|
|
260
|
+
// HANDOFF MARKER: "@decocms/start/hooks" below is intentionally the
|
|
261
|
+
// pre-split package path, not the real current one — it's a same-run
|
|
262
|
+
// handoff to phase-cleanup.ts's upgradeSectionRenderer(), which runs
|
|
263
|
+
// later in the same migration and rewrites this exact import specifier
|
|
264
|
+
// to `import { RenderSection } from "@decocms/blocks/hooks"`. If this
|
|
265
|
+
// transform's run order relative to the cleanup phase ever changes, that
|
|
266
|
+
// rewrite target must move with it.
|
|
267
|
+
//
|
|
260
268
|
// Gate on ANY variant of the .Component/.props pattern (simple, extra props, or multi-line).
|
|
261
269
|
const sectionPatternGate = /\.\s*Component[\s\n]+\{\.\.\.(\w+)\.props\}/;
|
|
262
270
|
if (sectionPatternGate.test(result)) {
|