@decocms/start 2.4.2 → 2.6.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.
Files changed (48) hide show
  1. package/.agents/skills/deco-to-tanstack-migration/SKILL.md +1 -0
  2. package/.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +163 -0
  3. package/.agents/skills/deco-to-tanstack-migration/references/vite-config/README.md +27 -2
  4. package/.agents/skills/deco-to-tanstack-migration/templates/vite-config.md +143 -68
  5. package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +1 -0
  6. package/.cursor/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +163 -0
  7. package/.cursor/skills/deco-to-tanstack-migration/references/vite-config/README.md +27 -2
  8. package/.cursor/skills/deco-to-tanstack-migration/templates/vite-config.md +143 -68
  9. package/package.json +1 -1
  10. package/scripts/deco-migrate-cli.ts +1 -1
  11. package/scripts/migrate/analyzers/island-classifier.ts +2 -2
  12. package/scripts/migrate/analyzers/loader-inventory.ts +2 -2
  13. package/scripts/migrate/analyzers/section-metadata.ts +2 -2
  14. package/scripts/migrate/analyzers/theme-extractor.ts +2 -2
  15. package/scripts/migrate/phase-analyze.ts +6 -6
  16. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  17. package/scripts/migrate/phase-cleanup.ts +134 -35
  18. package/scripts/migrate/phase-report.ts +2 -2
  19. package/scripts/migrate/phase-scaffold.ts +26 -22
  20. package/scripts/migrate/phase-transform.ts +9 -9
  21. package/scripts/migrate/phase-verify.ts +2 -2
  22. package/scripts/migrate/templates/app-css.ts +2 -2
  23. package/scripts/migrate/templates/cache-config.ts +1 -1
  24. package/scripts/migrate/templates/commerce-loaders.ts +1 -1
  25. package/scripts/migrate/templates/hooks.ts +1 -1
  26. package/scripts/migrate/templates/lib-utils.test.ts +91 -0
  27. package/scripts/migrate/templates/lib-utils.ts +51 -19
  28. package/scripts/migrate/templates/package-json.ts +1 -1
  29. package/scripts/migrate/templates/routes.ts +1 -1
  30. package/scripts/migrate/templates/sdk-gen.ts +1 -1
  31. package/scripts/migrate/templates/section-loaders.ts +1 -1
  32. package/scripts/migrate/templates/server-entry.ts +1 -1
  33. package/scripts/migrate/templates/setup.ts +1 -1
  34. package/scripts/migrate/templates/types-gen.ts +1 -1
  35. package/scripts/migrate/templates/ui-components.ts +1 -1
  36. package/scripts/migrate/templates/vite-config.ts +1 -1
  37. package/scripts/migrate/templates/wrangler.ts +1 -1
  38. package/scripts/migrate/transforms/dead-code.ts +1 -1
  39. package/scripts/migrate/transforms/deno-isms.ts +1 -1
  40. package/scripts/migrate/transforms/fresh-apis.ts +1 -1
  41. package/scripts/migrate/transforms/imports.ts +1 -1
  42. package/scripts/migrate/transforms/jsx.ts +1 -1
  43. package/scripts/migrate/transforms/section-conventions.ts +1 -1
  44. package/scripts/migrate/transforms/tailwind.ts +1 -1
  45. package/scripts/migrate.ts +8 -8
  46. package/src/cms/sectionLoaders.test.ts +4 -1
  47. package/src/vite/plugin.js +47 -10
  48. package/vitest.config.ts +11 -1
@@ -1,12 +1,18 @@
1
1
  # Vite Configuration
2
2
 
3
- ## Final Config (Post-Migration)
3
+ For the canonical battle-tested template (with VTEX dev proxy, CSP headers,
4
+ React Compiler, dedupe, framework plugin, and rollup chunk strategy) see
5
+ [`../../templates/vite-config.md`](../../templates/vite-config.md).
4
6
 
5
- After all imports are rewritten, the config should be minimal:
7
+ This page covers the post-migration **minimum viable config** if you've
8
+ stripped everything optional. Real sites should use the full template.
9
+
10
+ ## Minimum Viable Config
6
11
 
7
12
  ```typescript
8
13
  import { cloudflare } from "@cloudflare/vite-plugin";
9
14
  import { tanstackStart } from "@tanstack/react-start/plugin/vite";
15
+ import { decoVitePlugin } from "@decocms/start/vite";
10
16
  import react from "@vitejs/plugin-react";
11
17
  import tailwindcss from "@tailwindcss/vite";
12
18
  import { defineConfig } from "vite";
@@ -24,8 +30,23 @@ export default defineConfig({
24
30
  },
25
31
  }),
26
32
  tailwindcss(),
33
+ // Required — server-only stubs, blocks.gen.ts fast-path, meta.gen
34
+ // client stub, daemon/tunnel for dev mode. Without this, client
35
+ // bundle crashes on node:async_hooks / react-dom/server transitively
36
+ // imported by @decocms/start.
37
+ decoVitePlugin(),
27
38
  ],
28
39
  resolve: {
40
+ // Required — dedupe React/TanStack/Deco packages so there's only one
41
+ // instance of each. Without this you get "Invalid hook call" errors.
42
+ dedupe: [
43
+ "@decocms/start",
44
+ "@decocms/apps",
45
+ "@tanstack/react-start",
46
+ "@tanstack/react-router",
47
+ "react",
48
+ "react-dom",
49
+ ],
29
50
  alias: {
30
51
  "~": srcDir,
31
52
  },
@@ -35,6 +56,10 @@ export default defineConfig({
35
56
 
36
57
  **One alias only**: `~` -> `src/`. Nothing else.
37
58
 
59
+ The `decoVitePlugin()` call is **mandatory** — the older skill examples
60
+ that omitted it (or inlined the stub logic) reflect the pre-2.x state of
61
+ `@decocms/start` and will produce build/runtime failures on current versions.
62
+
38
63
  ## tsconfig.json
39
64
 
40
65
  Must mirror the Vite alias:
@@ -1,10 +1,13 @@
1
1
  # vite.config.ts Template
2
2
 
3
- Battle-tested configuration from espacosmart-storefront.
3
+ Battle-tested configuration. Uses the framework's `decoVitePlugin()` for the
4
+ server-only stub layer (rather than re-implementing it inline like older
5
+ sites did).
4
6
 
5
7
  ```typescript
6
8
  import { cloudflare } from "@cloudflare/vite-plugin";
7
9
  import { tanstackStart } from "@tanstack/react-start/plugin/vite";
10
+ import { decoVitePlugin } from "@decocms/start/vite";
8
11
  import react from "@vitejs/plugin-react";
9
12
  import tailwindcss from "@tailwindcss/vite";
10
13
  import { defineConfig } from "vite";
@@ -12,90 +15,86 @@ import path from "path";
12
15
 
13
16
  const srcDir = path.resolve(__dirname, "src");
14
17
 
18
+ // VTEX dev proxy — adjust to your account / commerce backend.
19
+ const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT || "mystore";
20
+ const VTEX_ORIGIN = `https://${VTEX_ACCOUNT}.vtexcommercestable.com.br`;
21
+
15
22
  export default defineConfig({
23
+ server: {
24
+ port: parseInt(process.env.PORT ?? "5173", 10),
25
+ // When the deco daemon injects PORT, bind to all interfaces so Deno's
26
+ // TCP connect (127.0.0.1) can reach Vite regardless of IPv4/IPv6.
27
+ host: process.env.PORT ? "0.0.0.0" : undefined,
28
+ headers: {
29
+ // Allow embedding in iframes from trusted admin origins.
30
+ "Content-Security-Policy":
31
+ "frame-ancestors 'self' https://*.deco.studio http://localhost:* https://localhost:* https://admin.deco.cx https://studio.decocms.com",
32
+ },
33
+ proxy: {
34
+ "/api/": {
35
+ target: VTEX_ORIGIN,
36
+ changeOrigin: true,
37
+ cookieDomainRewrite: { "*": "" },
38
+ },
39
+ "/checkout": {
40
+ target: VTEX_ORIGIN,
41
+ changeOrigin: true,
42
+ cookieDomainRewrite: { "*": "" },
43
+ },
44
+ },
45
+ },
16
46
  plugins: [
17
47
  cloudflare({ viteEnvironment: { name: "ssr" } }),
18
48
  tanstackStart({ server: { entry: "server" } }),
19
49
  react({
20
50
  babel: {
21
- plugins: [
22
- ["babel-plugin-react-compiler", { target: "19" }],
23
- ],
51
+ plugins: [["babel-plugin-react-compiler", { target: "19" }]],
24
52
  },
25
53
  }),
26
54
  tailwindcss(),
27
- // CRITICAL: Stubs for client bundles that transitively import server modules.
28
- // Without this, client build crashes on node:async_hooks, react-dom/server, etc.
29
- {
30
- name: "deco-server-only-stubs",
31
- enforce: "pre" as const,
32
- resolveId(id, _importer, options) {
33
- if (options?.ssr) return undefined;
34
- const CLIENT_STUBS: Record<string, string> = {
35
- "react-dom/server": "\0stub:react-dom-server",
36
- "react-dom/server.browser": "\0stub:react-dom-server",
37
- "node:stream": "\0stub:node-stream",
38
- "node:stream/web": "\0stub:node-stream-web",
39
- "node:async_hooks": "\0stub:node-async-hooks",
40
- "tanstack-start-injected-head-scripts:v": "\0stub:tanstack-head-scripts",
41
- };
42
- return CLIENT_STUBS[id];
43
- },
44
- configEnvironment(name: string, env: any) {
45
- if (name === "ssr" || name === "client") {
46
- env.optimizeDeps = env.optimizeDeps || {};
47
- env.optimizeDeps.esbuildOptions = env.optimizeDeps.esbuildOptions || {};
48
- env.optimizeDeps.esbuildOptions.jsx = "automatic";
49
- env.optimizeDeps.esbuildOptions.jsxImportSource = "react";
50
- }
51
- },
52
- load(id) {
53
- if (id === "\0stub:react-dom-server") {
54
- return [
55
- "const noop = () => '';",
56
- "export const renderToString = noop;",
57
- "export const renderToStaticMarkup = noop;",
58
- "export const renderToReadableStream = noop;",
59
- "export const resume = noop;",
60
- "export const version = '19.0.0';",
61
- "export default { renderToString: noop, renderToStaticMarkup: noop, renderToReadableStream: noop, resume: noop, version: '19.0.0' };",
62
- ].join("\n");
63
- }
64
- if (id === "\0stub:node-stream") {
65
- return "export class PassThrough {}; export class Readable {}; export class Writable {}; export default { PassThrough, Readable, Writable };";
66
- }
67
- if (id === "\0stub:node-stream-web") {
68
- return "export const ReadableStream = globalThis.ReadableStream; export const WritableStream = globalThis.WritableStream; export const TransformStream = globalThis.TransformStream; export default { ReadableStream, WritableStream, TransformStream };";
69
- }
70
- if (id === "\0stub:node-async-hooks") {
71
- return [
72
- "class _ALS { getStore() { return undefined; } run(_store, fn, ...args) { return fn(...args); } enterWith() {} disable() {} }",
73
- "export const AsyncLocalStorage = _ALS;",
74
- "export const AsyncResource = class {};",
75
- "export function executionAsyncId() { return 0; }",
76
- "export function createHook() { return { enable() {}, disable() {} }; }",
77
- "export default { AsyncLocalStorage: _ALS, AsyncResource, executionAsyncId, createHook };",
78
- ].join("\n");
79
- }
80
- if (id === "\0stub:tanstack-head-scripts") {
81
- return "export const injectedHeadScripts = undefined;";
55
+ // Framework plugin provides server-only stubs (react-dom/server,
56
+ // node:async_hooks, etc.), blocks.gen.ts JSON-fast-path, meta.gen
57
+ // client stub, daemon/tunnel for dev mode, and correct manualChunks
58
+ // (NOT splitting @decocms/start / @decocms/apps which have circular
59
+ // re-exports). Replaces ~80 lines of boilerplate that older sites
60
+ // had inline.
61
+ decoVitePlugin(),
62
+ ],
63
+ build: {
64
+ sourcemap: "hidden",
65
+ rollupOptions: {
66
+ onLog(level, log, handler) {
67
+ // Silence harmless "dynamic import will not move module" warning
68
+ // emitted when a module is imported both statically and dynamically.
69
+ if (
70
+ log.code === "PLUGIN_WARNING" &&
71
+ log.plugin === "vite:reporter" &&
72
+ log.message?.includes("dynamic import will not move module")
73
+ ) {
74
+ return;
82
75
  }
76
+ handler(level, log);
83
77
  },
84
78
  },
85
- ],
86
- // Inject site name at build time (not runtime)
79
+ },
87
80
  define: {
81
+ // Inject site name at build time, not read at runtime.
88
82
  "process.env.DECO_SITE_NAME": JSON.stringify(
89
- process.env.DECO_SITE_NAME || "my-store"
83
+ process.env.DECO_SITE_NAME || "my-store",
90
84
  ),
91
85
  },
92
86
  esbuild: {
93
87
  jsx: "automatic",
94
88
  jsxImportSource: "react",
89
+ // Strip console.log in production; keep .warn / .error for debugging.
90
+ pure: ["console.log"],
95
91
  },
96
92
  resolve: {
97
- // CRITICAL: Without dedupe, multiple React/TanStack instances cause hook errors
93
+ // CRITICAL: without dedupe, multiple React/TanStack instances cause
94
+ // "Invalid hook call" errors at runtime.
98
95
  dedupe: [
96
+ "@decocms/start",
97
+ "@decocms/apps",
99
98
  "@tanstack/react-start",
100
99
  "@tanstack/react-router",
101
100
  "@tanstack/react-start-server",
@@ -115,8 +114,84 @@ export default defineConfig({
115
114
 
116
115
  ## Key Points
117
116
 
118
- 1. **deco-server-only-stubs plugin** — Required. Client bundles transitively import `node:async_hooks`, `react-dom/server`, etc. Without stubs, build crashes.
119
- 2. **resolve.dedupe** Required. Without it, multiple React instances cause "Invalid hook call" errors.
120
- 3. **process.env.DECO_SITE_NAME via define** Must be injected at build time, not read at runtime.
121
- 4. **React Compiler** — `babel-plugin-react-compiler` with target 19 for automatic memoization.
122
- 5. **esbuild.jsx** Must be `"automatic"` with `jsxImportSource: "react"` for proper JSX transform.
117
+ 1. **`decoVitePlugin()`** — Required. Replaces ~80 lines of inline boilerplate
118
+ that older sites had to copy. Provides:
119
+ - Client stubs for server-only modules (`react-dom/server`,
120
+ `node:async_hooks`, `node:stream`, etc.)
121
+ - `blocks.gen.ts` JSON fast-path (10-100x parse speedup for large registries)
122
+ - `meta.gen.{json,ts}` client stub (cuts admin schema 0.5-5MB out of
123
+ browser bundle)
124
+ - Daemon/tunnel for dev mode (when `DECO_SITE_NAME` env is set)
125
+ - Production `manualChunks` that does NOT split `@decocms/start` or
126
+ `@decocms/apps` (those have circular re-exports and crash when chunked
127
+ separately)
128
+ - `allowedHosts` for tunnel domains (`.deco.host`, `.decocdn.com`,
129
+ `.deco.studio`)
130
+ - JSX automatic / react import-source defaults
131
+
132
+ 2. **`resolve.dedupe`** — Required. Without it, multiple React instances
133
+ cause "Invalid hook call" errors. The list MUST include both
134
+ `@decocms/start` and `@decocms/apps` because they re-export TanStack
135
+ types and registry singletons.
136
+
137
+ 3. **`process.env.DECO_SITE_NAME` via `define`** — Must be injected at
138
+ build time, not read at runtime. Workers don't have a Node-style
139
+ `process.env` at runtime.
140
+
141
+ 4. **React Compiler** — `babel-plugin-react-compiler` with `target: "19"`
142
+ for automatic memoization. Requires `@vitejs/plugin-react`, not the
143
+ default SWC plugin.
144
+
145
+ 5. **`esbuild.jsx: "automatic"` with `jsxImportSource: "react"`** — Without
146
+ it, JSX falls back to `React.createElement` references that may not
147
+ resolve.
148
+
149
+ 6. **CSP `frame-ancestors`** — Required for the admin (`*.deco.studio`,
150
+ `admin.deco.cx`, `studio.decocms.com`) to embed previews in iframes.
151
+
152
+ 7. **VTEX dev proxy** — Local `/api/`, `/checkout` requests proxied to
153
+ the upstream commerce backend so cookie-based session works in dev
154
+ without CORS gymnastics.
155
+
156
+ ## What older site templates inline (and why this template doesn't)
157
+
158
+ Some older guides show two extra inline plugins:
159
+
160
+ ```ts
161
+ // site-manual-chunks — overrides framework default chunking
162
+ { name: "site-manual-chunks", config(_cfg, { command }) { ... } }
163
+
164
+ // deco-stub-meta-gen — stubs admin schema on client
165
+ { name: "deco-stub-meta-gen", enforce: "pre", resolveId(...), load(...) }
166
+ ```
167
+
168
+ Both are obsolete after `@decocms/start` >= 2.5.0:
169
+ - The framework's `manualChunks` no longer splits `@decocms/start` /
170
+ `@decocms/apps` (the old split caused circular-dep load-order crashes —
171
+ every site overrode it).
172
+ - The framework now stubs `meta.gen.{json,ts}` on the client by default.
173
+
174
+ If you're on an older version, keep the inline plugins until you can bump.
175
+
176
+ ## tsconfig.json (matches the Vite alias)
177
+
178
+ ```json
179
+ {
180
+ "compilerOptions": {
181
+ "jsx": "react-jsx",
182
+ "moduleResolution": "bundler",
183
+ "module": "ESNext",
184
+ "target": "ES2022",
185
+ "skipLibCheck": true,
186
+ "strictNullChecks": true,
187
+ "baseUrl": ".",
188
+ "paths": {
189
+ "~/*": ["./src/*"]
190
+ }
191
+ },
192
+ "include": ["src/**/*", "vite.config.ts"]
193
+ }
194
+ ```
195
+
196
+ No `$store/*`, `site/*`, `apps/*`, `preact`, `@preact/signals`,
197
+ `@deco/deco` paths. Those are all dead.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.4.2",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -25,7 +25,7 @@ import * as fs from "node:fs";
25
25
  import * as path from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import { execSync, spawnSync } from "node:child_process";
28
- import { banner, stat, red, green, yellow, cyan, bold, dim, icons } from "./migrate/colors.ts";
28
+ import { banner, stat, red, green, yellow, cyan, bold, dim, icons } from "./migrate/colors";
29
29
 
30
30
  const __filename = fileURLToPath(import.meta.url);
31
31
  const __dirname = path.dirname(__filename);
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { MigrationContext, IslandClassification } from "../types.ts";
4
- import { log } from "../types.ts";
3
+ import type { MigrationContext, IslandClassification } from "../types";
4
+ import { log } from "../types";
5
5
 
6
6
  const REEXPORT_RE = /^export\s+\{\s*default\s*\}\s+from\s+["']([^"']+)["']/m;
7
7
  const NAMED_REEXPORT_RE = /^export\s+\{[^}]+\}\s+from\s+["']([^"']+)["']/m;
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { MigrationContext, LoaderInfo, Platform } from "../types.ts";
4
- import { log } from "../types.ts";
3
+ import type { MigrationContext, LoaderInfo, Platform } from "../types";
4
+ import { log } from "../types";
5
5
 
6
6
  /** Well-known loaders that map directly to @decocms/apps equivalents */
7
7
  const APPS_EQUIVALENTS: Record<string, string> = {
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { MigrationContext, SectionMeta } from "../types.ts";
4
- import { log } from "../types.ts";
3
+ import type { MigrationContext, SectionMeta } from "../types";
4
+ import { log } from "../types";
5
5
 
6
6
  const HEADER_RE = /\bheader\b/i;
7
7
  const FOOTER_RE = /\bfooter\b/i;
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { MigrationContext } from "../types.ts";
4
- import { log } from "../types.ts";
3
+ import type { MigrationContext } from "../types";
4
+ import { log } from "../types";
5
5
 
6
6
  export interface ExtractedTheme {
7
7
  /** Raw CSS variable -> hex color map from DEFAULT_THEME */
@@ -5,11 +5,11 @@ import type {
5
5
  FileRecord,
6
6
  MigrationContext,
7
7
  Platform,
8
- } from "./types.ts";
9
- import { log, logPhase } from "./types.ts";
10
- import { extractSectionMetadata } from "./analyzers/section-metadata.ts";
11
- import { classifyIslands } from "./analyzers/island-classifier.ts";
12
- import { inventoryLoaders } from "./analyzers/loader-inventory.ts";
8
+ } from "./types";
9
+ import { log, logPhase } from "./types";
10
+ import { extractSectionMetadata } from "./analyzers/section-metadata";
11
+ import { classifyIslands } from "./analyzers/island-classifier";
12
+ import { inventoryLoaders } from "./analyzers/loader-inventory";
13
13
 
14
14
  const PATTERN_DETECTORS: Array<[DetectedPattern, RegExp]> = [
15
15
  ["preact-hooks", /from\s+["']preact\/hooks["']/],
@@ -390,7 +390,7 @@ function scanDir(
390
390
 
391
391
  let content = "";
392
392
  let patterns: DetectedPattern[] = [];
393
- let reExport = { is: false, target: undefined as string | undefined };
393
+ let reExport: { is: boolean; target?: string } = { is: false };
394
394
 
395
395
  if (isCode) {
396
396
  content = fs.readFileSync(fullPath, "utf-8");
@@ -0,0 +1,141 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { writeImportedLibShims } from "./phase-cleanup";
6
+ import type { MigrationContext } from "./types";
7
+
8
+ /**
9
+ * Build a minimal `MigrationContext` for integration tests of
10
+ * `writeImportedLibShims`. Only the fields that function reads are
11
+ * non-default; everything else is a placeholder with the right shape.
12
+ */
13
+ function makeCtx(sourceDir: string, dryRun = false): MigrationContext {
14
+ return {
15
+ sourceDir,
16
+ siteName: "test-site",
17
+ platform: "vtex",
18
+ vtexAccount: null,
19
+ gtmId: null,
20
+ importMap: {},
21
+ discoveredNpmDeps: {},
22
+ themeColors: {},
23
+ fontFamily: null,
24
+ files: [],
25
+ sectionMetas: [],
26
+ islandClassifications: [],
27
+ islandWrapperTargets: new Map(),
28
+ loaderInventory: [],
29
+ scaffoldedFiles: [],
30
+ transformedFiles: [],
31
+ deletedFiles: [],
32
+ movedFiles: [],
33
+ manualReviewItems: [],
34
+ frameworkFindings: [],
35
+ dryRun,
36
+ verbose: false,
37
+ };
38
+ }
39
+
40
+ describe("writeImportedLibShims (integration)", () => {
41
+ let tmpDir: string;
42
+
43
+ beforeEach(() => {
44
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lazy-shim-test-"));
45
+ fs.mkdirSync(path.join(tmpDir, "src", "loaders"), { recursive: true });
46
+ });
47
+
48
+ afterEach(() => {
49
+ fs.rmSync(tmpDir, { recursive: true, force: true });
50
+ });
51
+
52
+ it("writes nothing when no ~/lib imports are found", () => {
53
+ fs.writeFileSync(
54
+ path.join(tmpDir, "src", "loaders", "products.ts"),
55
+ `import { something } from "@decocms/apps/vtex";\nexport const x = 1;\n`,
56
+ );
57
+
58
+ writeImportedLibShims(makeCtx(tmpDir));
59
+
60
+ expect(fs.existsSync(path.join(tmpDir, "src", "lib"))).toBe(false);
61
+ });
62
+
63
+ it("writes only the shim files matching imports actually present in src/", () => {
64
+ fs.writeFileSync(
65
+ path.join(tmpDir, "src", "loaders", "search.ts"),
66
+ `import { getSegmentFromBag } from "~/lib/vtex-segment";\n` +
67
+ `import { toFilterSearchString } from "~/lib/filter-navigate";\n`,
68
+ );
69
+
70
+ writeImportedLibShims(makeCtx(tmpDir));
71
+
72
+ const libDir = path.join(tmpDir, "src", "lib");
73
+ expect(fs.existsSync(libDir)).toBe(true);
74
+ const written = fs.readdirSync(libDir).sort();
75
+ expect(written).toEqual(["filter-navigate.ts", "vtex-segment.ts"]);
76
+ });
77
+
78
+ it("writes nothing in dry-run mode (but does not throw)", () => {
79
+ fs.writeFileSync(
80
+ path.join(tmpDir, "src", "loaders", "search.ts"),
81
+ `import { getSegmentFromBag } from "~/lib/vtex-segment";\n`,
82
+ );
83
+
84
+ writeImportedLibShims(makeCtx(tmpDir, /* dryRun */ true));
85
+
86
+ expect(fs.existsSync(path.join(tmpDir, "src", "lib"))).toBe(false);
87
+ });
88
+
89
+ it("strips trailing .ts from the import specifier when scanning", () => {
90
+ // Some Fresh sites use explicit .ts extensions in their imports.
91
+ fs.writeFileSync(
92
+ path.join(tmpDir, "src", "loaders", "search.ts"),
93
+ `import { fn } from "~/lib/vtex-transform.ts";\n`,
94
+ );
95
+
96
+ writeImportedLibShims(makeCtx(tmpDir));
97
+
98
+ expect(
99
+ fs.existsSync(path.join(tmpDir, "src", "lib", "vtex-transform.ts")),
100
+ ).toBe(true);
101
+ });
102
+
103
+ it("ignores imports inside the lib dir itself (no self-amplification)", () => {
104
+ fs.mkdirSync(path.join(tmpDir, "src", "lib"));
105
+ fs.writeFileSync(
106
+ path.join(tmpDir, "src", "lib", "existing.ts"),
107
+ `import { x } from "~/lib/should-not-be-generated";\nexport const y = 1;\n`,
108
+ );
109
+
110
+ writeImportedLibShims(makeCtx(tmpDir));
111
+
112
+ // Only the existing file should remain; nothing new generated.
113
+ const files = fs.readdirSync(path.join(tmpDir, "src", "lib"));
114
+ expect(files).toEqual(["existing.ts"]);
115
+ });
116
+
117
+ it("scans .tsx files too, not just .ts", () => {
118
+ fs.mkdirSync(path.join(tmpDir, "src", "components"), { recursive: true });
119
+ fs.writeFileSync(
120
+ path.join(tmpDir, "src", "components", "Filter.tsx"),
121
+ `import { toFilterSearchString } from "~/lib/filter-navigate";\n` +
122
+ `export const C = () => null;\n`,
123
+ );
124
+
125
+ writeImportedLibShims(makeCtx(tmpDir));
126
+
127
+ expect(
128
+ fs.existsSync(path.join(tmpDir, "src", "lib", "filter-navigate.ts")),
129
+ ).toBe(true);
130
+ });
131
+
132
+ it("does nothing when src/ does not exist", () => {
133
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), "no-src-"));
134
+ try {
135
+ writeImportedLibShims(makeCtx(empty));
136
+ expect(fs.readdirSync(empty)).toEqual([]);
137
+ } finally {
138
+ fs.rmSync(empty, { recursive: true, force: true });
139
+ }
140
+ });
141
+ });