@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.5.2",
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.2",
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
- const OUT_REL = arg("out", NEW_DEFAULT_OUT_REL);
61
- const PLATFORM = arg("platform", "cloudflare");
62
- if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_REL))) {
63
- warnLegacyArtifact(OLD_DEFAULT_OUT_REL, NEW_DEFAULT_OUT_REL);
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()) results.push(...findTsxFiles(full));
764
- else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) results.push(full);
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
- fs.writeFileSync(outFile, lines.join("\n"));
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(["Hero.tsx", "Product/SearchResult.tsx", "testimonials.tsx", "generic.ts"])(
17
- "keeps %s",
18
- (name) => {
19
- expect(isExcludedCodegenFile(name)).toBe(false);
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 = /\.(test|spec|stories|gen)\.(ts|tsx|js|jsx|json)$/;
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
- if (changed && result.includes("<RenderSection") && !result.includes("RenderSection")) {
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
- [/^"apps\/shopify\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/hooks/$1"`],
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)) {