@decocms/blocks-cli 7.0.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 +44 -0
- package/scripts/analyze-traces.mjs +1117 -0
- package/scripts/audit-observability-config.test.ts +446 -0
- package/scripts/audit-observability-config.ts +511 -0
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/fast-deploy-kv.test.ts +131 -0
- package/scripts/generate-blocks.test.ts +94 -0
- package/scripts/generate-blocks.ts +274 -0
- package/scripts/generate-invoke.test.ts +195 -0
- package/scripts/generate-invoke.ts +469 -0
- package/scripts/generate-loaders.ts +217 -0
- package/scripts/generate-schema.ts +1287 -0
- package/scripts/generate-sections.ts +237 -0
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +142 -0
- package/scripts/lib/cf-kv-rest.ts +78 -0
- package/scripts/lib/jsonc.ts +122 -0
- package/scripts/lib/kv-snapshot.ts +51 -0
- package/scripts/lib/read-decofile.ts +70 -0
- package/scripts/lib/sync-helpers.ts +44 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/analyzers/island-classifier.ts +96 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +147 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +782 -0
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/phase-cleanup.test.ts +141 -0
- package/scripts/migrate/phase-cleanup.ts +1588 -0
- package/scripts/migrate/phase-compile.test.ts +193 -0
- package/scripts/migrate/phase-compile.ts +177 -0
- package/scripts/migrate/phase-report.ts +243 -0
- package/scripts/migrate/phase-scaffold.ts +593 -0
- package/scripts/migrate/phase-transform.ts +310 -0
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +572 -0
- package/scripts/migrate/post-cleanup/rules.ts +1708 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate/templates/app-css.ts +366 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +230 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
- package/scripts/migrate/templates/hooks.test.ts +141 -0
- package/scripts/migrate/templates/hooks.ts +152 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/lib-utils.test.ts +139 -0
- package/scripts/migrate/templates/lib-utils.ts +326 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +177 -0
- package/scripts/migrate/templates/routes.ts +237 -0
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +456 -0
- package/scripts/migrate/templates/server-entry.ts +561 -0
- package/scripts/migrate/templates/setup.ts +148 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/types-gen.ts +174 -0
- package/scripts/migrate/templates/ui-components.ts +144 -0
- package/scripts/migrate/templates/vite-config.ts +101 -0
- package/scripts/migrate/transforms/dead-code.ts +455 -0
- package/scripts/migrate/transforms/deno-isms.ts +85 -0
- package/scripts/migrate/transforms/fresh-apis.ts +223 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
- package/scripts/migrate/transforms/imports.ts +385 -0
- package/scripts/migrate/transforms/jsx.ts +317 -0
- package/scripts/migrate/transforms/section-conventions.ts +210 -0
- package/scripts/migrate/transforms/tailwind.ts +739 -0
- package/scripts/migrate/types.ts +244 -0
- package/scripts/migrate-blocks-to-kv.ts +104 -0
- package/scripts/migrate-post-cleanup.ts +191 -0
- package/scripts/migrate-to-cf-observability.test.ts +215 -0
- package/scripts/migrate-to-cf-observability.ts +699 -0
- package/scripts/migrate.ts +282 -0
- package/scripts/smoke-otlp-errorlog.ts +46 -0
- package/scripts/smoke-otlp-meter.ts +48 -0
- package/scripts/sync-blocks-to-kv.ts +153 -0
- package/scripts/tailwind-lint.ts +518 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { MigrationContext } from "./types";
|
|
4
|
+
import { log, logPhase } from "./types";
|
|
5
|
+
import {
|
|
6
|
+
LIB_TEMPLATES,
|
|
7
|
+
selectImportedLibTemplates,
|
|
8
|
+
} from "./templates/lib-utils";
|
|
9
|
+
|
|
10
|
+
/** Directories to remove entirely after migration */
|
|
11
|
+
const DIRS_TO_DELETE = [
|
|
12
|
+
"islands",
|
|
13
|
+
"routes",
|
|
14
|
+
"apps/deco",
|
|
15
|
+
"sdk/cart",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** Individual root files to delete */
|
|
19
|
+
const ROOT_FILES_TO_DELETE = [
|
|
20
|
+
"main.ts",
|
|
21
|
+
"dev.ts",
|
|
22
|
+
"deno.json",
|
|
23
|
+
"deno.lock",
|
|
24
|
+
"tailwind.css",
|
|
25
|
+
"tailwind.config.ts",
|
|
26
|
+
"runtime.ts",
|
|
27
|
+
// NOTE: `constants.ts` is intentionally NOT deleted here — it holds
|
|
28
|
+
// site-specific UI constants (form/drawer IDs, header heights, etc.)
|
|
29
|
+
// that components reference via `~/constants` or `../../constants`.
|
|
30
|
+
// We move it to `src/constants.ts` instead — see `moveRootConstantsToSrc`.
|
|
31
|
+
"fresh.gen.ts",
|
|
32
|
+
"manifest.gen.ts",
|
|
33
|
+
"fresh.config.ts",
|
|
34
|
+
"browserslist",
|
|
35
|
+
"bw_stats.json",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** SDK files that have framework equivalents */
|
|
39
|
+
const SDK_FILES_TO_DELETE = [
|
|
40
|
+
"sdk/clx.ts",
|
|
41
|
+
"sdk/useId.ts",
|
|
42
|
+
"sdk/usePlatform.tsx",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** Section/component wrappers that are no longer needed */
|
|
46
|
+
const WRAPPER_FILES_TO_DELETE = [
|
|
47
|
+
"components/Session.tsx",
|
|
48
|
+
"sections/Session.tsx",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Loaders that depend on deleted admin tooling or are replaced by commerce-loaders wrappers */
|
|
52
|
+
const LOADER_FILES_TO_DELETE = [
|
|
53
|
+
"loaders/availableIcons.ts",
|
|
54
|
+
"loaders/icons.ts",
|
|
55
|
+
"loaders/getUserGeolocation.ts",
|
|
56
|
+
"loaders/smartShelfForYou.ts",
|
|
57
|
+
// NOTE: intelligenseSearch.ts is intentionally KEPT — it's the autocomplete
|
|
58
|
+
// loader referenced by Searchbar, useSuggestions, and CMS blocks.
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function deleteFileIfExists(ctx: MigrationContext, relPath: string) {
|
|
62
|
+
const fullPath = path.join(ctx.sourceDir, relPath);
|
|
63
|
+
if (!fs.existsSync(fullPath)) return;
|
|
64
|
+
|
|
65
|
+
if (ctx.dryRun) {
|
|
66
|
+
log(ctx, `[DRY] Would delete: ${relPath}`);
|
|
67
|
+
ctx.deletedFiles.push(relPath);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fs.unlinkSync(fullPath);
|
|
72
|
+
ctx.deletedFiles.push(relPath);
|
|
73
|
+
log(ctx, `Deleted: ${relPath}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function deleteDirIfExists(ctx: MigrationContext, relPath: string) {
|
|
77
|
+
const fullPath = path.join(ctx.sourceDir, relPath);
|
|
78
|
+
if (!fs.existsSync(fullPath)) return;
|
|
79
|
+
|
|
80
|
+
if (ctx.dryRun) {
|
|
81
|
+
log(ctx, `[DRY] Would delete dir: ${relPath}/`);
|
|
82
|
+
ctx.deletedFiles.push(`${relPath}/`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
87
|
+
ctx.deletedFiles.push(`${relPath}/`);
|
|
88
|
+
log(ctx, `Deleted dir: ${relPath}/`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function moveStaticFiles(ctx: MigrationContext) {
|
|
92
|
+
const staticDir = path.join(ctx.sourceDir, "static");
|
|
93
|
+
if (!fs.existsSync(staticDir)) return;
|
|
94
|
+
|
|
95
|
+
const publicDir = path.join(ctx.sourceDir, "public");
|
|
96
|
+
|
|
97
|
+
function moveRecursive(dir: string) {
|
|
98
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
const srcPath = path.join(dir, entry.name);
|
|
101
|
+
const relFromStatic = path.relative(staticDir, srcPath);
|
|
102
|
+
const destPath = path.join(publicDir, relFromStatic);
|
|
103
|
+
|
|
104
|
+
// Skip generated files
|
|
105
|
+
if (
|
|
106
|
+
entry.name === "tailwind.css" || entry.name === "adminIcons.ts" ||
|
|
107
|
+
entry.name === "generate-icons.ts"
|
|
108
|
+
) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (entry.isDirectory()) {
|
|
113
|
+
moveRecursive(srcPath);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (ctx.dryRun) {
|
|
118
|
+
log(ctx, `[DRY] Would move: static/${relFromStatic} → public/${relFromStatic}`);
|
|
119
|
+
ctx.movedFiles.push({
|
|
120
|
+
from: `static/${relFromStatic}`,
|
|
121
|
+
to: `public/${relFromStatic}`,
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Ensure dest dir exists
|
|
127
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
128
|
+
fs.copyFileSync(srcPath, destPath);
|
|
129
|
+
ctx.movedFiles.push({
|
|
130
|
+
from: `static/${relFromStatic}`,
|
|
131
|
+
to: `public/${relFromStatic}`,
|
|
132
|
+
});
|
|
133
|
+
log(ctx, `Moved: static/${relFromStatic} → public/${relFromStatic}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
moveRecursive(staticDir);
|
|
138
|
+
|
|
139
|
+
// Now delete static/ dir
|
|
140
|
+
if (!ctx.dryRun) {
|
|
141
|
+
fs.rmSync(staticDir, { recursive: true, force: true });
|
|
142
|
+
log(ctx, "Deleted dir: static/");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Handle multi-brand static directories (static-cv/, static-lb/, etc.).
|
|
148
|
+
* The "primary" brand's assets go to public/.
|
|
149
|
+
*/
|
|
150
|
+
function moveMultiBrandStaticFiles(ctx: MigrationContext) {
|
|
151
|
+
const entries = fs.readdirSync(ctx.sourceDir, { withFileTypes: true });
|
|
152
|
+
const staticDirs = entries.filter(
|
|
153
|
+
(e) => e.isDirectory() && e.name.startsWith("static-"),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (staticDirs.length === 0) return;
|
|
157
|
+
|
|
158
|
+
// Use the first one as primary (or match by site name)
|
|
159
|
+
const primaryDir = staticDirs[0];
|
|
160
|
+
const primaryPath = path.join(ctx.sourceDir, primaryDir.name);
|
|
161
|
+
const publicDir = path.join(ctx.sourceDir, "public");
|
|
162
|
+
|
|
163
|
+
log(ctx, `Found multi-brand static dirs: ${staticDirs.map((d) => d.name).join(", ")}`);
|
|
164
|
+
log(ctx, `Using ${primaryDir.name} as primary → public/`);
|
|
165
|
+
|
|
166
|
+
function copyRecursive(dir: string, base: string) {
|
|
167
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
168
|
+
for (const item of items) {
|
|
169
|
+
const srcPath = path.join(dir, item.name);
|
|
170
|
+
const relFromBase = path.relative(base, srcPath);
|
|
171
|
+
const destPath = path.join(publicDir, relFromBase);
|
|
172
|
+
|
|
173
|
+
if (item.name === "tailwind.css" || item.name === "adminIcons.ts") continue;
|
|
174
|
+
// Skip partytown (not needed in Workers)
|
|
175
|
+
if (item.name === "~partytown" || item.name === "partytown") continue;
|
|
176
|
+
|
|
177
|
+
if (item.isDirectory()) {
|
|
178
|
+
copyRecursive(srcPath, base);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (ctx.dryRun) {
|
|
183
|
+
log(ctx, `[DRY] Would copy: ${primaryDir.name}/${relFromBase} → public/${relFromBase}`);
|
|
184
|
+
ctx.movedFiles.push({ from: `${primaryDir.name}/${relFromBase}`, to: `public/${relFromBase}` });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
189
|
+
fs.copyFileSync(srcPath, destPath);
|
|
190
|
+
ctx.movedFiles.push({ from: `${primaryDir.name}/${relFromBase}`, to: `public/${relFromBase}` });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
copyRecursive(primaryPath, primaryPath);
|
|
195
|
+
|
|
196
|
+
// Clean up all static-* dirs (both root and src/)
|
|
197
|
+
if (!ctx.dryRun) {
|
|
198
|
+
for (const d of staticDirs) {
|
|
199
|
+
const rootDir = path.join(ctx.sourceDir, d.name);
|
|
200
|
+
if (fs.existsSync(rootDir)) {
|
|
201
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
202
|
+
log(ctx, `Deleted: ${d.name}/`);
|
|
203
|
+
}
|
|
204
|
+
const srcDir = path.join(ctx.sourceDir, "src", d.name);
|
|
205
|
+
if (fs.existsSync(srcDir)) {
|
|
206
|
+
fs.rmSync(srcDir, { recursive: true, force: true });
|
|
207
|
+
log(ctx, `Deleted: src/${d.name}/`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function moveRootDirToSrc(ctx: MigrationContext, dir: string) {
|
|
214
|
+
const oldDir = path.join(ctx.sourceDir, dir);
|
|
215
|
+
const newDir = path.join(ctx.sourceDir, "src", dir);
|
|
216
|
+
if (!fs.existsSync(oldDir)) return;
|
|
217
|
+
|
|
218
|
+
if (ctx.dryRun) {
|
|
219
|
+
log(ctx, `[DRY] Would move: ${dir}/ → src/${dir}/`);
|
|
220
|
+
ctx.movedFiles.push({ from: `${dir}/`, to: `src/${dir}/` });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (fs.existsSync(newDir)) {
|
|
225
|
+
// Merge: copy files from old into new (don't overwrite existing)
|
|
226
|
+
copyRecursiveNoOverwrite(oldDir, newDir);
|
|
227
|
+
} else {
|
|
228
|
+
fs.mkdirSync(path.dirname(newDir), { recursive: true });
|
|
229
|
+
fs.cpSync(oldDir, newDir, { recursive: true });
|
|
230
|
+
}
|
|
231
|
+
fs.rmSync(oldDir, { recursive: true, force: true });
|
|
232
|
+
ctx.deletedFiles.push(`${dir}/`);
|
|
233
|
+
log(ctx, `Moved: ${dir}/ → src/${dir}/`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function copyRecursiveNoOverwrite(src: string, dest: string) {
|
|
237
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
const srcPath = path.join(src, entry.name);
|
|
240
|
+
const destPath = path.join(dest, entry.name);
|
|
241
|
+
if (entry.isDirectory()) {
|
|
242
|
+
fs.mkdirSync(destPath, { recursive: true });
|
|
243
|
+
copyRecursiveNoOverwrite(srcPath, destPath);
|
|
244
|
+
} else if (!fs.existsSync(destPath)) {
|
|
245
|
+
fs.copyFileSync(srcPath, destPath);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function cleanupOldSourceDirs(ctx: MigrationContext) {
|
|
251
|
+
// Dirs that the scaffold/transform phases already created under src/.
|
|
252
|
+
// Delete root copies when both exist.
|
|
253
|
+
const dirsToClean = [
|
|
254
|
+
"sections",
|
|
255
|
+
"components",
|
|
256
|
+
"sdk",
|
|
257
|
+
"loaders",
|
|
258
|
+
"actions",
|
|
259
|
+
"apps",
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
for (const dir of dirsToClean) {
|
|
263
|
+
const oldDir = path.join(ctx.sourceDir, dir);
|
|
264
|
+
const newDir = path.join(ctx.sourceDir, "src", dir);
|
|
265
|
+
if (fs.existsSync(oldDir) && fs.existsSync(newDir)) {
|
|
266
|
+
if (ctx.dryRun) {
|
|
267
|
+
log(ctx, `[DRY] Would delete old dir: ${dir}/ (moved to src/${dir}/)`);
|
|
268
|
+
ctx.deletedFiles.push(`${dir}/`);
|
|
269
|
+
} else {
|
|
270
|
+
fs.rmSync(oldDir, { recursive: true, force: true });
|
|
271
|
+
ctx.deletedFiles.push(`${dir}/`);
|
|
272
|
+
log(ctx, `Deleted old dir: ${dir}/ (now at src/${dir}/)`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Dirs that need to be MOVED (not just deleted) because scaffold doesn't
|
|
278
|
+
// create them under src/ but code references them via ~/utils, ~/types, etc.
|
|
279
|
+
const dirsToMove = ["utils", "types", "hooks", "contexts"];
|
|
280
|
+
for (const dir of dirsToMove) {
|
|
281
|
+
moveRootDirToSrc(ctx, dir);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Delete sections that were re-export wrappers (their islands are now sections) */
|
|
286
|
+
function cleanupReExportSections(ctx: MigrationContext) {
|
|
287
|
+
const reExports = ctx.files.filter(
|
|
288
|
+
(f) => f.category === "section" && f.isReExport && f.action === "delete",
|
|
289
|
+
);
|
|
290
|
+
for (const f of reExports) {
|
|
291
|
+
// These were already not transformed, just make sure we note them
|
|
292
|
+
log(ctx, `Skipped re-export wrapper: ${f.path}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Remove non-code files and directories that shouldn't be under src/ */
|
|
297
|
+
function cleanupJunkFromSrc(ctx: MigrationContext) {
|
|
298
|
+
const srcDir = path.join(ctx.sourceDir, "src");
|
|
299
|
+
if (!fs.existsSync(srcDir)) return;
|
|
300
|
+
|
|
301
|
+
// Remove dirs that don't belong in src/
|
|
302
|
+
const junkDirs = ["bin", "fonts", "tests", ".pilot", ".deco"];
|
|
303
|
+
for (const dir of junkDirs) {
|
|
304
|
+
const dirPath = path.join(srcDir, dir);
|
|
305
|
+
if (fs.existsSync(dirPath)) {
|
|
306
|
+
if (ctx.dryRun) {
|
|
307
|
+
log(ctx, `[DRY] Would delete junk dir: src/${dir}/`);
|
|
308
|
+
} else {
|
|
309
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
310
|
+
log(ctx, `Deleted junk from src/: ${dir}/`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Remove static-* dirs from src/
|
|
316
|
+
if (fs.existsSync(srcDir)) {
|
|
317
|
+
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
318
|
+
if (entry.isDirectory() && entry.name.startsWith("static-")) {
|
|
319
|
+
const dirPath = path.join(srcDir, entry.name);
|
|
320
|
+
if (ctx.dryRun) {
|
|
321
|
+
log(ctx, `[DRY] Would delete: src/${entry.name}/`);
|
|
322
|
+
} else {
|
|
323
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
324
|
+
log(ctx, `Deleted from src/: ${entry.name}/`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Remove non-code root files from src/
|
|
331
|
+
const junkFiles = [
|
|
332
|
+
"AGENTS.md", "biome.json", "blockedQs.ts", "islands.ts",
|
|
333
|
+
"lint-changed.sh", "redirects-vtex.csv", "search-urls-cvlb.csv",
|
|
334
|
+
"search.csv", "sync.sh", "yarn.lock",
|
|
335
|
+
];
|
|
336
|
+
for (const file of junkFiles) {
|
|
337
|
+
const filePath = path.join(srcDir, file);
|
|
338
|
+
if (fs.existsSync(filePath)) {
|
|
339
|
+
if (ctx.dryRun) {
|
|
340
|
+
log(ctx, `[DRY] Would delete: src/${file}`);
|
|
341
|
+
} else {
|
|
342
|
+
fs.unlinkSync(filePath);
|
|
343
|
+
log(ctx, `Deleted from src/: ${file}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Remove empty ({}) block stubs from .deco/blocks/.
|
|
351
|
+
* Some source repos have both `pages-Foo%20bar.json` (empty) and
|
|
352
|
+
* `pages-Foo%2520bar.json` (real data). generate-blocks.ts deduplicates
|
|
353
|
+
* by decoded key, and the empty stub can shadow the real file.
|
|
354
|
+
*/
|
|
355
|
+
function removeEmptyBlockStubs(ctx: MigrationContext) {
|
|
356
|
+
const blocksDir = path.join(ctx.sourceDir, ".deco", "blocks");
|
|
357
|
+
if (!fs.existsSync(blocksDir)) return;
|
|
358
|
+
|
|
359
|
+
const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json"));
|
|
360
|
+
for (const file of files) {
|
|
361
|
+
const fullPath = path.join(blocksDir, file);
|
|
362
|
+
const stat = fs.statSync(fullPath);
|
|
363
|
+
if (stat.size > 4) continue; // only target tiny files
|
|
364
|
+
const content = fs.readFileSync(fullPath, "utf-8").trim();
|
|
365
|
+
if (content === "{}" || content === "") {
|
|
366
|
+
if (ctx.dryRun) {
|
|
367
|
+
log(ctx, `[DRY] Would delete empty block stub: .deco/blocks/${file}`);
|
|
368
|
+
} else {
|
|
369
|
+
fs.unlinkSync(fullPath);
|
|
370
|
+
log(ctx, `Deleted empty block stub: .deco/blocks/${file}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function overrideDeviceContext(ctx: MigrationContext) {
|
|
377
|
+
const target = path.join(ctx.sourceDir, "src", "contexts", "device.tsx");
|
|
378
|
+
const dir = path.dirname(target);
|
|
379
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
380
|
+
const content = `import { useSyncExternalStore } from "react";
|
|
381
|
+
|
|
382
|
+
const MOBILE_QUERY = "(max-width: 767px)";
|
|
383
|
+
|
|
384
|
+
function subscribe(cb: () => void) {
|
|
385
|
+
if (typeof window === "undefined") return () => {};
|
|
386
|
+
const mql = window.matchMedia(MOBILE_QUERY);
|
|
387
|
+
mql.addEventListener("change", cb);
|
|
388
|
+
return () => mql.removeEventListener("change", cb);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function getSnapshot(): boolean {
|
|
392
|
+
return window.matchMedia(MOBILE_QUERY).matches;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function getServerSnapshot(): boolean {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Reactive mobile detection based on viewport width via matchMedia.
|
|
401
|
+
* SSR defaults to desktop (false); hydrates to the real value on mount.
|
|
402
|
+
*
|
|
403
|
+
* For server-side device detection (UA-based), use the section loader
|
|
404
|
+
* pattern: registerSectionLoaders injects \`isMobile\` as a prop.
|
|
405
|
+
*/
|
|
406
|
+
export const useDevice = () => {
|
|
407
|
+
const isMobile = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
408
|
+
return { isMobile };
|
|
409
|
+
};
|
|
410
|
+
`;
|
|
411
|
+
if (ctx.dryRun) {
|
|
412
|
+
log(ctx, "[DRY] Would override: src/contexts/device.tsx");
|
|
413
|
+
} else {
|
|
414
|
+
fs.writeFileSync(target, content);
|
|
415
|
+
log(ctx, "Overrode src/contexts/device.tsx with useSyncExternalStore implementation");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function rewriteRetryUtil(ctx: MigrationContext) {
|
|
420
|
+
const target = path.join(ctx.sourceDir, "src", "utils", "retry.ts");
|
|
421
|
+
if (!fs.existsSync(target)) return;
|
|
422
|
+
|
|
423
|
+
const content = `export const CONNECTION_CLOSED_MESSAGE = "connection closed before message completed";
|
|
424
|
+
|
|
425
|
+
function sleep(ms: number): Promise<void> {
|
|
426
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Simple retry utility — replaces cockatiel to avoid module-level AbortController
|
|
431
|
+
* (cockatiel's abort.js creates new AbortController() at module scope, which is
|
|
432
|
+
* forbidden in Cloudflare Workers global scope).
|
|
433
|
+
*
|
|
434
|
+
* Retries up to maxAttempts when the error matches the predicate.
|
|
435
|
+
* Uses exponential backoff: delay = min(initialDelay * exponent^attempt, maxDelay).
|
|
436
|
+
*/
|
|
437
|
+
export function retryExceptionOr500() {
|
|
438
|
+
return {
|
|
439
|
+
execute: async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
440
|
+
const maxAttempts = 3;
|
|
441
|
+
const initialDelay = 100;
|
|
442
|
+
const maxDelay = 5000;
|
|
443
|
+
const exponent = 2;
|
|
444
|
+
|
|
445
|
+
let lastErr: unknown;
|
|
446
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
447
|
+
try {
|
|
448
|
+
return await fn();
|
|
449
|
+
} catch (err) {
|
|
450
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
451
|
+
if (!message.includes(CONNECTION_CLOSED_MESSAGE)) {
|
|
452
|
+
throw err;
|
|
453
|
+
}
|
|
454
|
+
lastErr = err;
|
|
455
|
+
try {
|
|
456
|
+
console.error("retrying...", err);
|
|
457
|
+
} catch (_) {}
|
|
458
|
+
if (attempt < maxAttempts - 1) {
|
|
459
|
+
const delay = Math.min(initialDelay * Math.pow(exponent, attempt), maxDelay);
|
|
460
|
+
await sleep(delay);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
throw lastErr;
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
`;
|
|
469
|
+
if (ctx.dryRun) {
|
|
470
|
+
log(ctx, "[DRY] Would rewrite: src/utils/retry.ts");
|
|
471
|
+
} else {
|
|
472
|
+
fs.writeFileSync(target, content);
|
|
473
|
+
log(ctx, "Rewrote src/utils/retry.ts (replaced cockatiel with Workers-safe version)");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Add safety guards for common runtime patterns that crash in React strict mode
|
|
479
|
+
* or in Cloudflare Workers but worked silently in the old Deno/Preact stack.
|
|
480
|
+
*
|
|
481
|
+
* These are useEffect-level errors that React error boundaries catch and
|
|
482
|
+
* propagate, killing the entire section (e.g. the Header).
|
|
483
|
+
*/
|
|
484
|
+
function addRuntimeSafetyGuards(ctx: MigrationContext) {
|
|
485
|
+
rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
|
|
486
|
+
let result = content;
|
|
487
|
+
let changed = false;
|
|
488
|
+
|
|
489
|
+
// 1. Guard: `event.params.X = Y` → `if (event.params) event.params.X = Y`
|
|
490
|
+
const paramsAssignRe = /^(\s*)(event\.params\.(\w+)\s*=\s*.+;)$/gm;
|
|
491
|
+
const paramsRepl = result.replace(paramsAssignRe, (_m, indent, assignment) => {
|
|
492
|
+
return `${indent}if (event.params) ${assignment}`;
|
|
493
|
+
});
|
|
494
|
+
if (paramsRepl !== result) {
|
|
495
|
+
result = paramsRepl;
|
|
496
|
+
changed = true;
|
|
497
|
+
log(ctx, ` Added event.params guard: src/${relPath}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// 2. Guard: `.find(...).params` → `.find(...)?.params`
|
|
501
|
+
// Uses paren-counting to handle nested parens in callbacks like
|
|
502
|
+
// `.find((item) => item?.name === "deco").params`
|
|
503
|
+
result = addOptionalChainAfterFind(result, (msg) => {
|
|
504
|
+
changed = true;
|
|
505
|
+
log(ctx, ` ${msg}: src/${relPath}`);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// 3. Guard: undeclared variables used in if-conditions (ReferenceError).
|
|
509
|
+
// In the old Preact stack, some global signals/variables silently
|
|
510
|
+
// resolved to undefined. In React strict mode, bare references to
|
|
511
|
+
// undeclared variables throw ReferenceError which error boundaries catch.
|
|
512
|
+
// We detect variables referenced in the file that are never declared
|
|
513
|
+
// (const/let/var/param/import) and add typeof guards.
|
|
514
|
+
result = guardUndeclaredVariables(result, (msg) => {
|
|
515
|
+
changed = true;
|
|
516
|
+
log(ctx, ` ${msg}: src/${relPath}`);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (!changed) return null;
|
|
520
|
+
return result;
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Find `.find(...)` calls followed by `.params` (without `?.`) and insert
|
|
526
|
+
* optional chaining. Handles nested parentheses correctly.
|
|
527
|
+
*/
|
|
528
|
+
function addOptionalChainAfterFind(src: string, onFix: (msg: string) => void): string {
|
|
529
|
+
let result = src;
|
|
530
|
+
let searchFrom = 0;
|
|
531
|
+
|
|
532
|
+
while (true) {
|
|
533
|
+
const findIdx = result.indexOf(".find(", searchFrom);
|
|
534
|
+
if (findIdx === -1) break;
|
|
535
|
+
|
|
536
|
+
// Walk forward from the opening paren, counting depth
|
|
537
|
+
let depth = 1;
|
|
538
|
+
let i = findIdx + 6; // past ".find("
|
|
539
|
+
while (i < result.length && depth > 0) {
|
|
540
|
+
if (result[i] === "(") depth++;
|
|
541
|
+
if (result[i] === ")") depth--;
|
|
542
|
+
i++;
|
|
543
|
+
}
|
|
544
|
+
// i is now right after the matching ")"
|
|
545
|
+
// Check for `.params` without `?.`
|
|
546
|
+
if (result.slice(i, i + 7) === ".params" && result.slice(i - 1, i + 8) !== ")?.params") {
|
|
547
|
+
result = result.slice(0, i) + "?" + result.slice(i);
|
|
548
|
+
onFix("Added optional chain after .find()");
|
|
549
|
+
searchFrom = i + 8; // skip past the inserted "?.params"
|
|
550
|
+
} else {
|
|
551
|
+
searchFrom = i;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Detect variables used in `if (varName ...` or `if (varName && ...` that are
|
|
560
|
+
* never declared with const/let/var/function/import/param in the file, and
|
|
561
|
+
* wrap with `typeof varName !== "undefined"`.
|
|
562
|
+
*
|
|
563
|
+
* This prevents ReferenceError in React strict mode — the old Preact/Deno
|
|
564
|
+
* stack had more lenient scoping or these variables were injected by the runtime.
|
|
565
|
+
*/
|
|
566
|
+
function guardUndeclaredVariables(src: string, onFix: (msg: string) => void): string {
|
|
567
|
+
let result = src;
|
|
568
|
+
|
|
569
|
+
// Find all `if (someVar &&` or `if (someVar)` patterns where someVar
|
|
570
|
+
// is a bare identifier (not a property access, not a function call)
|
|
571
|
+
const ifBareVarRe = /\bif\s*\(\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:&&|\))/g;
|
|
572
|
+
const candidates = new Set<string>();
|
|
573
|
+
let match;
|
|
574
|
+
|
|
575
|
+
while ((match = ifBareVarRe.exec(result)) !== null) {
|
|
576
|
+
candidates.add(match[1]);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Filter to only truly undeclared variables
|
|
580
|
+
const reserved = new Set([
|
|
581
|
+
"true", "false", "null", "undefined", "this", "window", "document",
|
|
582
|
+
"globalThis", "console", "navigator", "location", "localStorage",
|
|
583
|
+
"sessionStorage", "fetch", "JSON", "Array", "Object", "Math",
|
|
584
|
+
"Date", "Error", "Promise", "Map", "Set", "RegExp", "Symbol",
|
|
585
|
+
"parseInt", "parseFloat", "isNaN", "isFinite", "NaN", "Infinity",
|
|
586
|
+
"setTimeout", "clearTimeout", "setInterval", "clearInterval",
|
|
587
|
+
"requestAnimationFrame", "cancelAnimationFrame", "event",
|
|
588
|
+
]);
|
|
589
|
+
|
|
590
|
+
for (const varName of candidates) {
|
|
591
|
+
if (reserved.has(varName)) continue;
|
|
592
|
+
|
|
593
|
+
// Check if the variable is declared anywhere in the file
|
|
594
|
+
const declPatterns = [
|
|
595
|
+
new RegExp(`\\b(?:const|let|var|function)\\s+${varName}\\b`),
|
|
596
|
+
new RegExp(`\\bimport\\b[^;]*\\b${varName}\\b`),
|
|
597
|
+
// Function parameter: `function foo(varName)` or `(varName) =>`
|
|
598
|
+
new RegExp(`\\(\\s*(?:[^)]*,\\s*)?${varName}\\s*(?:[:,][^)]*)?\\)\\s*(?:=>|\\{)`),
|
|
599
|
+
// Destructuring declaration: `const { varName }` or `let { x: varName }`
|
|
600
|
+
new RegExp(`(?:const|let|var)\\s+\\{[^}]*\\b${varName}\\b[^}]*\\}\\s*=`),
|
|
601
|
+
// For-of/for-in: `for (const varName of/in ...)`
|
|
602
|
+
new RegExp(`for\\s*\\(\\s*(?:const|let|var)\\s+${varName}\\b`),
|
|
603
|
+
];
|
|
604
|
+
|
|
605
|
+
const isDeclared = declPatterns.some((p) => p.test(result));
|
|
606
|
+
if (isDeclared) continue;
|
|
607
|
+
|
|
608
|
+
// This variable is used in an if-condition but never declared — wrap with typeof
|
|
609
|
+
const unsafePat = new RegExp(
|
|
610
|
+
`\\bif\\s*\\(\\s*${varName}\\b`,
|
|
611
|
+
"g",
|
|
612
|
+
);
|
|
613
|
+
const guardedResult = result.replace(
|
|
614
|
+
unsafePat,
|
|
615
|
+
`if (typeof ${varName} !== "undefined"`,
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
if (guardedResult !== result) {
|
|
619
|
+
result = guardedResult;
|
|
620
|
+
onFix(`Added typeof guard for undeclared variable "${varName}"`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return result;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Migrate account.json → src/constants/account.ts and rewrite imports.
|
|
629
|
+
*
|
|
630
|
+
* Old stack: root-level `account.json` containing e.g. `"casaevideo"`
|
|
631
|
+
* New stack: `src/constants/account.ts` exporting `accountName`
|
|
632
|
+
*
|
|
633
|
+
* Also rewrites every file that imports from `account.json` (via
|
|
634
|
+
* `$store/account.json`, `site/account.json`, or `~/account.json`)
|
|
635
|
+
* to use `import { accountName } from "~/constants/account"` instead.
|
|
636
|
+
*/
|
|
637
|
+
function migrateAccountJson(ctx: MigrationContext) {
|
|
638
|
+
// 1. Read the site name from account.json (check root, then src/)
|
|
639
|
+
let siteName: string | null = null;
|
|
640
|
+
for (const candidate of ["account.json", "src/account.json"]) {
|
|
641
|
+
const fullPath = path.join(ctx.sourceDir, candidate);
|
|
642
|
+
if (fs.existsSync(fullPath)) {
|
|
643
|
+
try {
|
|
644
|
+
const raw = fs.readFileSync(fullPath, "utf-8").trim();
|
|
645
|
+
siteName = JSON.parse(raw);
|
|
646
|
+
if (typeof siteName !== "string") siteName = null;
|
|
647
|
+
} catch { /* ignore parse errors */ }
|
|
648
|
+
// Delete the old JSON file
|
|
649
|
+
if (!ctx.dryRun) {
|
|
650
|
+
fs.unlinkSync(fullPath);
|
|
651
|
+
}
|
|
652
|
+
log(ctx, `Deleted: ${candidate}`);
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (!siteName) {
|
|
658
|
+
// Fallback: try to infer from deco blocks or directory name
|
|
659
|
+
const decofilePath = path.join(ctx.sourceDir, ".deco", "blocks", "vtex.json");
|
|
660
|
+
if (fs.existsSync(decofilePath)) {
|
|
661
|
+
try {
|
|
662
|
+
const vtexBlock = JSON.parse(fs.readFileSync(decofilePath, "utf-8"));
|
|
663
|
+
if (vtexBlock.account && typeof vtexBlock.account === "string") {
|
|
664
|
+
siteName = vtexBlock.account.replace(/newio$/, "").replace(/io$/, "");
|
|
665
|
+
}
|
|
666
|
+
} catch { /* ignore */ }
|
|
667
|
+
}
|
|
668
|
+
if (!siteName) {
|
|
669
|
+
siteName = path.basename(ctx.sourceDir).replace(/-migrated$/, "");
|
|
670
|
+
}
|
|
671
|
+
log(ctx, `Inferred site name: "${siteName}" (no account.json found)`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// 2. Create src/constants/account.ts
|
|
675
|
+
const constantsDir = path.join(ctx.sourceDir, "src", "constants");
|
|
676
|
+
const accountTsPath = path.join(constantsDir, "account.ts");
|
|
677
|
+
|
|
678
|
+
if (ctx.dryRun) {
|
|
679
|
+
log(ctx, `[DRY] Would create: src/constants/account.ts with accountName="${siteName}"`);
|
|
680
|
+
} else {
|
|
681
|
+
fs.mkdirSync(constantsDir, { recursive: true });
|
|
682
|
+
fs.writeFileSync(
|
|
683
|
+
accountTsPath,
|
|
684
|
+
`export const accountName = "${siteName}" as const;\nexport type AccountName = typeof accountName;\n`,
|
|
685
|
+
);
|
|
686
|
+
log(ctx, `Created: src/constants/account.ts (accountName="${siteName}")`);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// 3. Rewrite all files that import from account.json or ~/constants/account
|
|
690
|
+
// The transform phase may have already rewritten the specifier from
|
|
691
|
+
// `$store/account.json` → `~/constants/account`, but it only changes the
|
|
692
|
+
// specifier, not the binding style (default → named). We must fix both.
|
|
693
|
+
const accountJsonPattern =
|
|
694
|
+
/import\s+([\w{},\s*]+)\s+from\s+["'](?:\$store|site|~)\/account\.json["']\s*(?:(?:with|assert)\s*\{[^}]*\}\s*)?;?/g;
|
|
695
|
+
const accountTsDefaultPattern =
|
|
696
|
+
/import\s+(\w+)\s+from\s+["']~\/constants\/account["']\s*(?:(?:with|assert)\s*\{[^}]*\}\s*)?;?/g;
|
|
697
|
+
|
|
698
|
+
rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
|
|
699
|
+
if (!content.includes("account.json") && !content.includes("constants/account")) return null;
|
|
700
|
+
|
|
701
|
+
let changed = false;
|
|
702
|
+
let result = content;
|
|
703
|
+
|
|
704
|
+
// Capture old variable name before any replacements
|
|
705
|
+
const defaultImportMatch = content.match(
|
|
706
|
+
/import\s+(\w+)\s+from\s+["'](?:\$store|site|~)\/account\.json["']/,
|
|
707
|
+
) || content.match(
|
|
708
|
+
/import\s+(\w+)\s+from\s+["']~\/constants\/account["']/,
|
|
709
|
+
);
|
|
710
|
+
const oldVarName = defaultImportMatch?.[1];
|
|
711
|
+
|
|
712
|
+
// Fix account.json imports (pre-transform)
|
|
713
|
+
result = result.replace(accountJsonPattern, (_match, importName) => {
|
|
714
|
+
changed = true;
|
|
715
|
+
const trimmed = importName.trim();
|
|
716
|
+
if (trimmed.startsWith("{")) {
|
|
717
|
+
return `import ${trimmed} from "~/constants/account";`;
|
|
718
|
+
}
|
|
719
|
+
return `import { accountName } from "~/constants/account";`;
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
// Fix default imports from ~/constants/account (post-transform)
|
|
723
|
+
result = result.replace(accountTsDefaultPattern, (_match, varName) => {
|
|
724
|
+
if (varName.startsWith("{")) return _match; // already named
|
|
725
|
+
changed = true;
|
|
726
|
+
return `import { accountName } from "~/constants/account";`;
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
if (!changed) return null;
|
|
730
|
+
|
|
731
|
+
// Rename old variable references if the import used a different name
|
|
732
|
+
if (oldVarName && oldVarName !== "accountName" && oldVarName !== "{") {
|
|
733
|
+
result = result.replace(
|
|
734
|
+
new RegExp(`\\b${oldVarName}\\b`, "g"),
|
|
735
|
+
"accountName",
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
log(ctx, ` Rewrote account import: src/${relPath}`);
|
|
740
|
+
return result;
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function rewriteFilesRecursive(
|
|
745
|
+
ctx: MigrationContext,
|
|
746
|
+
dir: string,
|
|
747
|
+
transformer: (content: string, relPath: string) => string | null,
|
|
748
|
+
) {
|
|
749
|
+
if (!fs.existsSync(dir)) return;
|
|
750
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
751
|
+
for (const entry of entries) {
|
|
752
|
+
const fullPath = path.join(dir, entry.name);
|
|
753
|
+
if (entry.isDirectory()) {
|
|
754
|
+
if (entry.name === "node_modules" || entry.name === ".deco") continue;
|
|
755
|
+
rewriteFilesRecursive(ctx, fullPath, transformer);
|
|
756
|
+
} else if (/\.(tsx?|jsx?|mts|mjs)$/.test(entry.name)) {
|
|
757
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
758
|
+
const relPath = path.relative(path.join(ctx.sourceDir, "src"), fullPath);
|
|
759
|
+
const newContent = transformer(content, relPath);
|
|
760
|
+
if (newContent !== null && newContent !== content) {
|
|
761
|
+
if (!ctx.dryRun) {
|
|
762
|
+
fs.writeFileSync(fullPath, newContent);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Auto-fix section barrel files that re-export `default` but miss `LoadingFallback`.
|
|
771
|
+
* If the target component defines `LoadingFallback`, the section file should re-export it.
|
|
772
|
+
*/
|
|
773
|
+
function fixLoadingFallbackReExports(ctx: MigrationContext) {
|
|
774
|
+
const sectionsDir = path.join(ctx.sourceDir, "src", "sections");
|
|
775
|
+
if (!fs.existsSync(sectionsDir)) return;
|
|
776
|
+
|
|
777
|
+
function walk(dir: string) {
|
|
778
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
779
|
+
if (entry.isDirectory()) {
|
|
780
|
+
walk(path.join(dir, entry.name));
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (!entry.name.endsWith(".tsx") && !entry.name.endsWith(".ts")) continue;
|
|
784
|
+
|
|
785
|
+
const filePath = path.join(dir, entry.name);
|
|
786
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
787
|
+
|
|
788
|
+
// Match: `export { default } from "../../some/path"` (no LoadingFallback)
|
|
789
|
+
const reExportMatch = content.match(
|
|
790
|
+
/^export\s*\{\s*default\s*\}\s*from\s*["']([^"']+)["']\s*;?\s*$/m,
|
|
791
|
+
);
|
|
792
|
+
if (!reExportMatch) continue;
|
|
793
|
+
if (content.includes("LoadingFallback")) continue; // already has it
|
|
794
|
+
|
|
795
|
+
// Resolve the target module and check if it exports LoadingFallback
|
|
796
|
+
const targetRelPath = reExportMatch[1];
|
|
797
|
+
const resolved = path.resolve(path.dirname(filePath), targetRelPath);
|
|
798
|
+
const candidates = [
|
|
799
|
+
resolved + ".tsx", resolved + ".ts",
|
|
800
|
+
path.join(resolved, "index.tsx"), path.join(resolved, "index.ts"),
|
|
801
|
+
];
|
|
802
|
+
|
|
803
|
+
for (const candidate of candidates) {
|
|
804
|
+
if (!fs.existsSync(candidate)) continue;
|
|
805
|
+
const targetContent = fs.readFileSync(candidate, "utf-8");
|
|
806
|
+
if (/export\s+(?:function|const)\s+LoadingFallback\b/.test(targetContent)) {
|
|
807
|
+
// Add LoadingFallback to the re-export
|
|
808
|
+
const newContent = content.replace(
|
|
809
|
+
/export\s*\{\s*default\s*\}\s*from/,
|
|
810
|
+
"export { default, LoadingFallback } from",
|
|
811
|
+
);
|
|
812
|
+
if (newContent !== content) {
|
|
813
|
+
if (!ctx.dryRun) fs.writeFileSync(filePath, newContent);
|
|
814
|
+
const rel = path.relative(ctx.sourceDir, filePath);
|
|
815
|
+
log(ctx, ` Added LoadingFallback re-export: ${rel}`);
|
|
816
|
+
}
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
walk(sectionsDir);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Find the end index of a self-closing JSX tag starting from a position
|
|
828
|
+
* inside the tag body. Handles nested braces, brackets, and parens.
|
|
829
|
+
* Returns the index of `>` in `/>`, or -1 if not found.
|
|
830
|
+
*/
|
|
831
|
+
function findSelfClosingEnd(src: string, startIdx: number): number {
|
|
832
|
+
let i = startIdx;
|
|
833
|
+
while (i < src.length) {
|
|
834
|
+
const ch = src[i];
|
|
835
|
+
if (ch === "{" || ch === "[" || ch === "(") {
|
|
836
|
+
const close = ch === "{" ? "}" : ch === "[" ? "]" : ")";
|
|
837
|
+
let depth = 1;
|
|
838
|
+
i++;
|
|
839
|
+
while (i < src.length && depth > 0) {
|
|
840
|
+
if (src[i] === ch) depth++;
|
|
841
|
+
if (src[i] === close) depth--;
|
|
842
|
+
if (src[i] === '"' || src[i] === "'" || src[i] === "`") {
|
|
843
|
+
const q = src[i];
|
|
844
|
+
i++;
|
|
845
|
+
while (i < src.length && src[i] !== q) {
|
|
846
|
+
if (src[i] === "\\" && q !== "`") i++;
|
|
847
|
+
i++;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
i++;
|
|
851
|
+
}
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (ch === "/" && i + 1 < src.length && src[i + 1] === ">") {
|
|
855
|
+
return i + 1;
|
|
856
|
+
}
|
|
857
|
+
i++;
|
|
858
|
+
}
|
|
859
|
+
return -1;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Extract JSX prop assignments from a string like:
|
|
864
|
+
* items={[...]} offers={product.offers}
|
|
865
|
+
* Returns an array of { name, value } with balanced brace extraction.
|
|
866
|
+
*/
|
|
867
|
+
function extractJsxProps(src: string): Array<{ name: string; value: string }> {
|
|
868
|
+
const props: Array<{ name: string; value: string }> = [];
|
|
869
|
+
const propRe = /(\w+)\s*=\s*\{/g;
|
|
870
|
+
let match;
|
|
871
|
+
while ((match = propRe.exec(src)) !== null) {
|
|
872
|
+
const name = match[1];
|
|
873
|
+
let depth = 1;
|
|
874
|
+
let i = match.index + match[0].length;
|
|
875
|
+
while (i < src.length && depth > 0) {
|
|
876
|
+
if (src[i] === "{") depth++;
|
|
877
|
+
if (src[i] === "}") depth--;
|
|
878
|
+
if (src[i] === '"' || src[i] === "'" || src[i] === "`") {
|
|
879
|
+
const q = src[i];
|
|
880
|
+
i++;
|
|
881
|
+
while (i < src.length && src[i] !== q) {
|
|
882
|
+
if (src[i] === "\\" && q !== "`") i++;
|
|
883
|
+
i++;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (depth > 0) i++;
|
|
887
|
+
}
|
|
888
|
+
// i is at the closing }
|
|
889
|
+
const value = src.slice(match.index + match[0].length, i);
|
|
890
|
+
props.push({ name, value });
|
|
891
|
+
propRe.lastIndex = i + 1;
|
|
892
|
+
}
|
|
893
|
+
return props;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Convert `<varName.Component {...varName?.props} prop1={val1} />` patterns
|
|
898
|
+
* to `<RenderSection section={{...varName, prop1: val1}} />`.
|
|
899
|
+
*
|
|
900
|
+
* Handles multi-line JSX with nested braces (e.g. items={[{...}]}).
|
|
901
|
+
*/
|
|
902
|
+
function convertDirectComponentCalls(src: string, onFix: (msg: string) => void): string {
|
|
903
|
+
const componentCallRe = /<(\w+)\.Component\b/g;
|
|
904
|
+
let result = src;
|
|
905
|
+
let offset = 0;
|
|
906
|
+
let match;
|
|
907
|
+
|
|
908
|
+
// Reset lastIndex
|
|
909
|
+
componentCallRe.lastIndex = 0;
|
|
910
|
+
const replacements: Array<{ start: number; end: number; replacement: string }> = [];
|
|
911
|
+
|
|
912
|
+
while ((match = componentCallRe.exec(src)) !== null) {
|
|
913
|
+
const varName = match[1];
|
|
914
|
+
const tagStart = match.index;
|
|
915
|
+
|
|
916
|
+
// Find the self-closing end
|
|
917
|
+
const bodyStart = tagStart + match[0].length;
|
|
918
|
+
const endIdx = findSelfClosingEnd(src, bodyStart);
|
|
919
|
+
if (endIdx === -1) continue;
|
|
920
|
+
|
|
921
|
+
const fullTag = src.slice(tagStart, endIdx + 1);
|
|
922
|
+
const body = src.slice(bodyStart, endIdx - 1).trim(); // between <X.Component and />
|
|
923
|
+
|
|
924
|
+
// Verify there's a spread: {...varName?.props} or {...varName.props}
|
|
925
|
+
const spreadRe = new RegExp(`\\{\\.\\.\\.${varName}\\??\\.(props)\\}`, "g");
|
|
926
|
+
if (!spreadRe.test(body)) continue;
|
|
927
|
+
|
|
928
|
+
// Remove the spread from body and extract remaining props
|
|
929
|
+
const bodyWithoutSpread = body.replace(spreadRe, "").trim();
|
|
930
|
+
const additionalProps = extractJsxProps(bodyWithoutSpread);
|
|
931
|
+
|
|
932
|
+
let sectionExpr: string;
|
|
933
|
+
if (additionalProps.length === 0) {
|
|
934
|
+
sectionExpr = varName;
|
|
935
|
+
} else {
|
|
936
|
+
const propEntries = additionalProps
|
|
937
|
+
.map((p) => `${p.name}: ${p.value}`)
|
|
938
|
+
.join(", ");
|
|
939
|
+
sectionExpr = `{...${varName}, ${propEntries}}`;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const replacement = `<RenderSection section={${sectionExpr}} />`;
|
|
943
|
+
replacements.push({ start: tagStart, end: endIdx + 1, replacement });
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Apply replacements in reverse order to preserve indices
|
|
947
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
948
|
+
const r = replacements[i];
|
|
949
|
+
result = result.slice(0, r.start) + r.replacement + result.slice(r.end);
|
|
950
|
+
onFix(`Converted .Component direct call → RenderSection`);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return result;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Replace SectionRenderer with RenderSection in components.
|
|
958
|
+
*
|
|
959
|
+
* SectionRenderer (from DecoPageRenderer) requires section.Component to be a
|
|
960
|
+
* resolved function/string. RenderSection also handles bare { __resolveType }
|
|
961
|
+
* objects, which is how CMS blocks pass nested sections.
|
|
962
|
+
*
|
|
963
|
+
* Also converts direct `<section.Component {...props}/>` patterns to use
|
|
964
|
+
* RenderSection for robustness.
|
|
965
|
+
*/
|
|
966
|
+
function upgradeSectionRenderer(ctx: MigrationContext) {
|
|
967
|
+
rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
|
|
968
|
+
if (!relPath.endsWith(".tsx") && !relPath.endsWith(".ts")) return null;
|
|
969
|
+
|
|
970
|
+
let result = content;
|
|
971
|
+
let changed = false;
|
|
972
|
+
|
|
973
|
+
// 1. Replace `import { SectionRenderer } from "@decocms/start/hooks"`
|
|
974
|
+
// with `import { RenderSection } from "@decocms/start/hooks"`
|
|
975
|
+
const sectionRendererImport =
|
|
976
|
+
/import\s*\{([^}]*)\bSectionRenderer\b([^}]*)\}\s*from\s*["']@decocms\/start\/hooks["']/g;
|
|
977
|
+
const newContent = result.replace(sectionRendererImport, (_m, before, after) => {
|
|
978
|
+
changed = true;
|
|
979
|
+
return `import {${before}RenderSection${after}} from "@decocms/start/hooks"`;
|
|
980
|
+
});
|
|
981
|
+
if (newContent !== result) {
|
|
982
|
+
result = newContent;
|
|
983
|
+
log(ctx, ` Replaced SectionRenderer import → RenderSection: src/${relPath}`);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// 2. Replace JSX: <SectionRenderer section={x} /> → <RenderSection section={x} />
|
|
987
|
+
const sectionRendererJsx = /<SectionRenderer\b/g;
|
|
988
|
+
if (sectionRendererJsx.test(result)) {
|
|
989
|
+
result = result.replace(/<SectionRenderer\b/g, "<RenderSection");
|
|
990
|
+
changed = true;
|
|
991
|
+
log(ctx, ` Replaced <SectionRenderer → <RenderSection: src/${relPath}`);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// 3. Convert <varName.Component {...varName?.props} additionalProp={value} ... />
|
|
995
|
+
// to <RenderSection section={{...varName, additionalProp: value, ...}} />
|
|
996
|
+
result = convertDirectComponentCalls(result, (msg) => {
|
|
997
|
+
changed = true;
|
|
998
|
+
log(ctx, ` ${msg}: src/${relPath}`);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
// 4. Add RenderSection import if we introduced usages but no import exists
|
|
1002
|
+
if (changed && result.includes("<RenderSection") && !result.includes("RenderSection")) {
|
|
1003
|
+
result = `import { RenderSection } from "@decocms/start/hooks";\n` + result;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (!changed) return null;
|
|
1007
|
+
return result;
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Cleanup pass for VTEX-style transform / signature workarounds.
|
|
1013
|
+
*
|
|
1014
|
+
* Historical context:
|
|
1015
|
+
* Earlier versions of this script rewrote `@decocms/apps/vtex/utils/*` imports
|
|
1016
|
+
* to local `~/lib/vtex-*` shims because those modules did not exist yet in
|
|
1017
|
+
* `@decocms/apps`. They now do (apps-start exports `./vtex/utils/*` and
|
|
1018
|
+
* `./vtex/client` directly), so any further rewrite would actively REGRESS
|
|
1019
|
+
* working direct imports back into NO-OP shims and silently break runtime
|
|
1020
|
+
* behavior on every migrated site.
|
|
1021
|
+
*
|
|
1022
|
+
* Scope kept here:
|
|
1023
|
+
* - `@decocms/apps/vtex/loaders/intelligentSearch/*` → inline stubs
|
|
1024
|
+
* (loaders moved under `inline-loaders/` in apps-start; this is still a
|
|
1025
|
+
* real path-mismatch fixup)
|
|
1026
|
+
* - `LabelledFuzzy` type + `mapLabelledFuzzyToFuzzy` helper inlined when
|
|
1027
|
+
* `intelligentSearch/productListingPage` is imported
|
|
1028
|
+
* - `createHttpClient<Type>(...)` → `createHttpClient(...)` (Proxy handles types)
|
|
1029
|
+
* - `fetcher: fetchSafe,` parameter strip (Proxy uses native fetch)
|
|
1030
|
+
* - Inline stub hoisting: when a Fresh loader declares
|
|
1031
|
+
* `const getSegmentFromBag = ... = ({})` etc, hoist them into imports from
|
|
1032
|
+
* the corresponding `~/lib/*` shim so multiple loaders stay in sync
|
|
1033
|
+
*
|
|
1034
|
+
* What was removed:
|
|
1035
|
+
* The four `@decocms/apps/vtex/utils/* → ~/lib/vtex-*` rewrites that the
|
|
1036
|
+
* first-pass `transforms/imports.ts` (lines 50-52) already produces in the
|
|
1037
|
+
* correct, direct form. See discovery notes in MIGRATION_TOOLING_PLAN.md.
|
|
1038
|
+
*/
|
|
1039
|
+
function rewriteVtexUtilImports(ctx: MigrationContext) {
|
|
1040
|
+
// Intentionally empty — see docstring. First-pass `transforms/imports.ts`
|
|
1041
|
+
// already maps `apps/vtex/utils/*` and `apps/vtex/client` directly to the
|
|
1042
|
+
// `@decocms/apps` equivalents; rewriting them again here would dead-shim them.
|
|
1043
|
+
const importRewrites: Array<{ pattern: RegExp; replacement: string; desc: string }> = [];
|
|
1044
|
+
|
|
1045
|
+
rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
|
|
1046
|
+
if (!relPath.endsWith(".tsx") && !relPath.endsWith(".ts")) return null;
|
|
1047
|
+
|
|
1048
|
+
let result = content;
|
|
1049
|
+
let changed = false;
|
|
1050
|
+
|
|
1051
|
+
for (const rw of importRewrites) {
|
|
1052
|
+
if (rw.pattern.test(result)) {
|
|
1053
|
+
result = result.replace(rw.pattern, rw.replacement);
|
|
1054
|
+
changed = true;
|
|
1055
|
+
log(ctx, ` Import rewrite (${rw.desc}): src/${relPath}`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Replace entire import from productListingPage (module doesn't exist in @decocms/apps)
|
|
1060
|
+
const plpImport = /import\s*\{[^}]*\}\s*from\s*["'][^"']*intelligentSearch\/productListingPage["'];?\s*\n?/g;
|
|
1061
|
+
if (plpImport.test(result)) {
|
|
1062
|
+
result = result.replace(plpImport, `type LabelledFuzzy = "disabled" | "automatic" | "always";\nfunction mapLabelledFuzzyToFuzzy(fuzzy: LabelledFuzzy): string {\n const mapping: Record<LabelledFuzzy, string> = { disabled: "0", automatic: "auto", always: "1" };\n return mapping[fuzzy] ?? "0";\n}\n`);
|
|
1063
|
+
changed = true;
|
|
1064
|
+
log(ctx, ` Inlined LabelledFuzzy + mapLabelledFuzzyToFuzzy: src/${relPath}`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Strip generic type params from createHttpClient<Type>(...) → createHttpClient(...)
|
|
1068
|
+
// The Proxy-based createHttpClient handles all patterns at runtime.
|
|
1069
|
+
const typedClient = /\bcreateHttpClient<[^>]+>/g;
|
|
1070
|
+
if (typedClient.test(result)) {
|
|
1071
|
+
result = result.replace(typedClient, "createHttpClient");
|
|
1072
|
+
changed = true;
|
|
1073
|
+
log(ctx, ` Stripped generic type param from createHttpClient: src/${relPath}`);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Remove `fetcher: fetchSafe,` from createHttpClient options (Proxy uses native fetch)
|
|
1077
|
+
const fetcherParam = /,?\s*fetcher:\s*fetchSafe\s*,?/g;
|
|
1078
|
+
if (fetcherParam.test(result)) {
|
|
1079
|
+
result = result.replace(fetcherParam, (match) => {
|
|
1080
|
+
// If the fetcher was between two other params, keep one comma
|
|
1081
|
+
if (match.startsWith(",") && match.endsWith(",")) return ",";
|
|
1082
|
+
return "";
|
|
1083
|
+
});
|
|
1084
|
+
changed = true;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Replace inline getSegmentFromBag stub with import from ~/lib/vtex-segment
|
|
1088
|
+
const segmentStub = /^const getSegmentFromBag = \(_ctx: any\) => \(\{ value: \{\} as any \}\);\s*\n?/gm;
|
|
1089
|
+
if (segmentStub.test(result)) {
|
|
1090
|
+
result = result.replace(segmentStub, "");
|
|
1091
|
+
if (!result.includes("from \"~/lib/vtex-segment\"")) {
|
|
1092
|
+
result = `import { getSegmentFromBag } from "~/lib/vtex-segment";\n` + result;
|
|
1093
|
+
}
|
|
1094
|
+
changed = true;
|
|
1095
|
+
log(ctx, ` Replaced inline getSegmentFromBag stub → ~/lib/vtex-segment: src/${relPath}`);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Replace inline fetchSafe stub with import from ~/lib/fetch-utils
|
|
1099
|
+
const fetchSafeStub = /^const fetchSafe = async \(url:.*?\n/gm;
|
|
1100
|
+
if (fetchSafeStub.test(result)) {
|
|
1101
|
+
result = result.replace(fetchSafeStub, "");
|
|
1102
|
+
if (!result.includes("from \"~/lib/fetch-utils\"")) {
|
|
1103
|
+
result = `import { fetchSafe } from "~/lib/fetch-utils";\n` + result;
|
|
1104
|
+
}
|
|
1105
|
+
changed = true;
|
|
1106
|
+
log(ctx, ` Replaced inline fetchSafe stub → ~/lib/fetch-utils: src/${relPath}`);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Replace inline getISCookiesFromBag stub with import from ~/lib/vtex-intelligent-search
|
|
1110
|
+
const isCookiesStub = /^const getISCookiesFromBag = \(_ctx: any\) => \(\{\}\);\s*\n?/gm;
|
|
1111
|
+
if (isCookiesStub.test(result)) {
|
|
1112
|
+
result = result.replace(isCookiesStub, "");
|
|
1113
|
+
if (!result.includes("from \"~/lib/vtex-intelligent-search\"")) {
|
|
1114
|
+
result = `import { getISCookiesFromBag } from "~/lib/vtex-intelligent-search";\n` + result;
|
|
1115
|
+
}
|
|
1116
|
+
changed = true;
|
|
1117
|
+
log(ctx, ` Replaced inline getISCookiesFromBag stub → ~/lib/vtex-intelligent-search: src/${relPath}`);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Rewrite ~/utils/retry → @decocms/start/sdk/retry
|
|
1121
|
+
const retryImport = /from\s+["']~\/utils\/retry["']/g;
|
|
1122
|
+
if (retryImport.test(result)) {
|
|
1123
|
+
result = result.replace(retryImport, 'from "@decocms/start/sdk/retry"');
|
|
1124
|
+
changed = true;
|
|
1125
|
+
log(ctx, ` Rewrote retry import → @decocms/start/sdk/retry: src/${relPath}`);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Rewrite type-only imports from productListingPage (Props type)
|
|
1129
|
+
const plpTypeImport = /import\s+type\s*\{[^}]*\}\s*from\s*["'][^"']*intelligentSearch\/productListingPage["'];?\s*\n?/g;
|
|
1130
|
+
if (plpTypeImport.test(result)) {
|
|
1131
|
+
result = result.replace(plpTypeImport, `import type { PLPProps as Props } from "~/types/vtex-loaders";\n`);
|
|
1132
|
+
changed = true;
|
|
1133
|
+
log(ctx, ` Rewrote type import from productListingPage → ~/types/vtex-loaders: src/${relPath}`);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (!changed) return null;
|
|
1137
|
+
return result;
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Ensure useVariantPossiblities omit set includes "modalType" and "Modal Type".
|
|
1143
|
+
* These VTEX variant dimensions cause broken variant selectors on PDP if not omitted.
|
|
1144
|
+
*/
|
|
1145
|
+
function fixVariantOmitSet(ctx: MigrationContext) {
|
|
1146
|
+
const candidates = [
|
|
1147
|
+
path.join(ctx.sourceDir, "src", "sdk", "useVariantPossiblities.ts"),
|
|
1148
|
+
path.join(ctx.sourceDir, "src", "sdk", "useVariantPossibilities.ts"),
|
|
1149
|
+
];
|
|
1150
|
+
|
|
1151
|
+
for (const filePath of candidates) {
|
|
1152
|
+
if (!fs.existsSync(filePath)) continue;
|
|
1153
|
+
|
|
1154
|
+
let content = fs.readFileSync(filePath, "utf-8");
|
|
1155
|
+
// Check if the omit set already has modalType
|
|
1156
|
+
if (content.includes('"modalType"')) continue;
|
|
1157
|
+
|
|
1158
|
+
// Add "modalType" and "Modal Type" to the Set constructor
|
|
1159
|
+
const omitSetRe = /new Set\(\[([^\]]*)\]\)/;
|
|
1160
|
+
const match = content.match(omitSetRe);
|
|
1161
|
+
if (!match) continue;
|
|
1162
|
+
|
|
1163
|
+
const existingItems = match[1].trim();
|
|
1164
|
+
const newItems = existingItems
|
|
1165
|
+
? `${existingItems}, "modalType", "Modal Type"`
|
|
1166
|
+
: `"modalType", "Modal Type"`;
|
|
1167
|
+
|
|
1168
|
+
const newContent = content.replace(omitSetRe, `new Set([${newItems}])`);
|
|
1169
|
+
if (newContent !== content) {
|
|
1170
|
+
if (!ctx.dryRun) fs.writeFileSync(filePath, newContent);
|
|
1171
|
+
const rel = path.relative(ctx.sourceDir, filePath);
|
|
1172
|
+
log(ctx, ` Added modalType/Modal Type to omit set: ${rel}`);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Normalize import path casing to match the actual filesystem.
|
|
1179
|
+
* On macOS (case-insensitive), `~/components/Header/` and `~/components/header/`
|
|
1180
|
+
* resolve to the same directory. But on Linux (CI, production builds), mismatched
|
|
1181
|
+
* casing causes "module not found" errors.
|
|
1182
|
+
*
|
|
1183
|
+
* This function scans all source files for `~/` imports and checks whether the
|
|
1184
|
+
* referenced path actually exists with the correct casing. If not, it tries to
|
|
1185
|
+
* find the correct-cased path on the filesystem.
|
|
1186
|
+
*/
|
|
1187
|
+
function normalizeImportCasing(ctx: MigrationContext) {
|
|
1188
|
+
const srcDir = path.join(ctx.sourceDir, "src");
|
|
1189
|
+
if (!fs.existsSync(srcDir)) return;
|
|
1190
|
+
|
|
1191
|
+
// Build a map of all actual paths (with their real casing) under src/
|
|
1192
|
+
const realPaths = new Map<string, string>(); // lowercase → actual
|
|
1193
|
+
function indexDir(dir: string, prefix: string) {
|
|
1194
|
+
try {
|
|
1195
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1196
|
+
for (const entry of entries) {
|
|
1197
|
+
if (entry.name === "node_modules" || entry.name === ".deco") continue;
|
|
1198
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1199
|
+
realPaths.set(rel.toLowerCase(), rel);
|
|
1200
|
+
if (entry.isDirectory()) {
|
|
1201
|
+
indexDir(path.join(dir, entry.name), rel);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
} catch {}
|
|
1205
|
+
}
|
|
1206
|
+
indexDir(srcDir, "");
|
|
1207
|
+
|
|
1208
|
+
rewriteFilesRecursive(ctx, srcDir, (content, relPath) => {
|
|
1209
|
+
if (!content.includes("~/")) return null;
|
|
1210
|
+
|
|
1211
|
+
let result = content;
|
|
1212
|
+
let changed = false;
|
|
1213
|
+
|
|
1214
|
+
// Match imports/exports from "~/" paths
|
|
1215
|
+
const importRe = /(?:from|import\()\s*["'](~\/[^"']+)["']/g;
|
|
1216
|
+
let match;
|
|
1217
|
+
while ((match = importRe.exec(content)) !== null) {
|
|
1218
|
+
const importPath = match[1]; // e.g. ~/components/Header/Buttons/Cart/vtex
|
|
1219
|
+
const relToSrc = importPath.slice(2); // e.g. components/Header/Buttons/Cart/vtex
|
|
1220
|
+
|
|
1221
|
+
// Check with common extensions
|
|
1222
|
+
const candidates = [
|
|
1223
|
+
relToSrc,
|
|
1224
|
+
relToSrc + ".tsx",
|
|
1225
|
+
relToSrc + ".ts",
|
|
1226
|
+
relToSrc + "/index.tsx",
|
|
1227
|
+
relToSrc + "/index.ts",
|
|
1228
|
+
];
|
|
1229
|
+
|
|
1230
|
+
for (const candidate of candidates) {
|
|
1231
|
+
const lower = candidate.toLowerCase();
|
|
1232
|
+
const actual = realPaths.get(lower);
|
|
1233
|
+
if (actual && actual !== candidate) {
|
|
1234
|
+
// Casing mismatch — fix the import path
|
|
1235
|
+
let corrected = actual;
|
|
1236
|
+
// Strip extension if the original import didn't have one
|
|
1237
|
+
if (!relToSrc.match(/\.\w+$/)) {
|
|
1238
|
+
corrected = corrected.replace(/\.(tsx?|jsx?)$/, "");
|
|
1239
|
+
corrected = corrected.replace(/\/index$/, "");
|
|
1240
|
+
}
|
|
1241
|
+
const oldPath = importPath;
|
|
1242
|
+
const newPath = `~/${corrected}`;
|
|
1243
|
+
if (oldPath !== newPath) {
|
|
1244
|
+
result = result.replace(oldPath, newPath);
|
|
1245
|
+
changed = true;
|
|
1246
|
+
log(ctx, ` Fixed import casing: ${oldPath} → ${newPath} in src/${relPath}`);
|
|
1247
|
+
}
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
return changed ? result : null;
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Move root-level `constants.ts` → `src/constants.ts`.
|
|
1259
|
+
*
|
|
1260
|
+
* Old stack: a root-level `constants.ts` exporting site-wide UI constants
|
|
1261
|
+
* (MINICART_FORM_ID, SIDEMENU_DRAWER_ID, HEADER_HEIGHT, USER_ID, etc.) that
|
|
1262
|
+
* components reference via `../../constants` or `~/constants`.
|
|
1263
|
+
*
|
|
1264
|
+
* Without this step, `phase-cleanup` deletes the file and the build fails
|
|
1265
|
+
* with `Could not resolve "../../constants"` from many components. The CMS
|
|
1266
|
+
* doesn't reference these IDs, so a 1:1 file move is sufficient.
|
|
1267
|
+
*
|
|
1268
|
+
* If `src/constants.ts` already exists (rare — usually means the migration
|
|
1269
|
+
* was re-run), we leave it alone.
|
|
1270
|
+
*/
|
|
1271
|
+
function moveRootConstantsToSrc(ctx: MigrationContext) {
|
|
1272
|
+
const rootPath = path.join(ctx.sourceDir, "constants.ts");
|
|
1273
|
+
const srcPath = path.join(ctx.sourceDir, "src", "constants.ts");
|
|
1274
|
+
|
|
1275
|
+
if (!fs.existsSync(rootPath)) return;
|
|
1276
|
+
if (fs.existsSync(srcPath)) {
|
|
1277
|
+
log(ctx, `Skipped move: src/constants.ts already exists; deleting root constants.ts`);
|
|
1278
|
+
if (!ctx.dryRun) fs.unlinkSync(rootPath);
|
|
1279
|
+
ctx.deletedFiles.push("constants.ts");
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (ctx.dryRun) {
|
|
1284
|
+
log(ctx, `[DRY] Would move: constants.ts → src/constants.ts`);
|
|
1285
|
+
ctx.movedFiles.push({ from: "constants.ts", to: "src/constants.ts" });
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
fs.mkdirSync(path.dirname(srcPath), { recursive: true });
|
|
1290
|
+
fs.renameSync(rootPath, srcPath);
|
|
1291
|
+
ctx.movedFiles.push({ from: "constants.ts", to: "src/constants.ts" });
|
|
1292
|
+
log(ctx, `Moved: constants.ts → src/constants.ts`);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Rewrite the legacy multi-platform `loaders/minicart.ts` file.
|
|
1297
|
+
*
|
|
1298
|
+
* Old stack: `loaders/minicart.ts` runtime-dispatches on `usePlatform()` to
|
|
1299
|
+
* platform-specific loaders under `sdk/cart/{vtex,vnda,wake,linx,shopify,nuvemshop}/loader.ts`.
|
|
1300
|
+
* The cleanup phase already deletes `sdk/cart/` entirely, leaving the loader
|
|
1301
|
+
* with broken imports.
|
|
1302
|
+
*
|
|
1303
|
+
* New stack: the canonical Minicart contract + VTEX transform live in
|
|
1304
|
+
* `@decocms/apps/vtex/inline-loaders/minicart`. We replace the loader with a
|
|
1305
|
+
* thin VTEX-only re-export. Sites on Shopify/VNDA/Wake/Linx/Nuvemshop are
|
|
1306
|
+
* not currently in production on the new stack — when one is, swap this for
|
|
1307
|
+
* a runtime dispatcher again or add a platform-flagged rewrite.
|
|
1308
|
+
*/
|
|
1309
|
+
function rewriteMinicartLoader(ctx: MigrationContext) {
|
|
1310
|
+
const candidates = [
|
|
1311
|
+
path.join(ctx.sourceDir, "src", "loaders", "minicart.ts"),
|
|
1312
|
+
path.join(ctx.sourceDir, "loaders", "minicart.ts"),
|
|
1313
|
+
];
|
|
1314
|
+
|
|
1315
|
+
for (const filePath of candidates) {
|
|
1316
|
+
if (!fs.existsSync(filePath)) continue;
|
|
1317
|
+
|
|
1318
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
1319
|
+
// Only rewrite if it actually imports the legacy multi-platform sdk/cart layout.
|
|
1320
|
+
const isLegacyLoader = /from\s+["'](?:~|\.\.?)\/sdk\/cart\/(?:vtex|vnda|wake|linx|shopify|nuvemshop)\/loader["']/
|
|
1321
|
+
.test(content);
|
|
1322
|
+
if (!isLegacyLoader) continue;
|
|
1323
|
+
|
|
1324
|
+
const newContent = `// VTEX-only minicart loader.
|
|
1325
|
+
//
|
|
1326
|
+
// The legacy site shipped per-platform loaders behind a \`usePlatform()\`
|
|
1327
|
+
// switch (vnda, wake, linx, shopify, nuvemshop). The canonical minicart
|
|
1328
|
+
// contract now lives in \`@decocms/apps\`. Until a non-VTEX customer comes
|
|
1329
|
+
// online on the new stack, we re-export the framework loader directly.
|
|
1330
|
+
// TODO: when adding another platform, replace this with a runtime
|
|
1331
|
+
// dispatcher and import the matching framework loader.
|
|
1332
|
+
export { default } from "@decocms/apps/vtex/inline-loaders/minicart";
|
|
1333
|
+
export type { MinicartProps } from "@decocms/apps/vtex/inline-loaders/minicart";
|
|
1334
|
+
`;
|
|
1335
|
+
|
|
1336
|
+
if (ctx.dryRun) {
|
|
1337
|
+
log(ctx, `[DRY] Would rewrite: ${path.relative(ctx.sourceDir, filePath)} (VTEX-only re-export)`);
|
|
1338
|
+
} else {
|
|
1339
|
+
fs.writeFileSync(filePath, newContent);
|
|
1340
|
+
log(ctx, `Rewrote: ${path.relative(ctx.sourceDir, filePath)} (VTEX-only re-export of @decocms/apps/vtex/inline-loaders/minicart)`);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Fix APIs that don't exist in Cloudflare Workers:
|
|
1347
|
+
* - window.setTimeout → setTimeout
|
|
1348
|
+
* - window.clearTimeout → clearTimeout
|
|
1349
|
+
* - window.setInterval → setInterval
|
|
1350
|
+
* - window.clearInterval → clearInterval
|
|
1351
|
+
*/
|
|
1352
|
+
function fixWorkerIncompatibleApis(ctx: MigrationContext) {
|
|
1353
|
+
const replacements: Array<{ pattern: RegExp; replacement: string }> = [
|
|
1354
|
+
{ pattern: /\bwindow\.setTimeout\b/g, replacement: "setTimeout" },
|
|
1355
|
+
{ pattern: /\bwindow\.clearTimeout\b/g, replacement: "clearTimeout" },
|
|
1356
|
+
{ pattern: /\bwindow\.setInterval\b/g, replacement: "setInterval" },
|
|
1357
|
+
{ pattern: /\bwindow\.clearInterval\b/g, replacement: "clearInterval" },
|
|
1358
|
+
];
|
|
1359
|
+
|
|
1360
|
+
rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
|
|
1361
|
+
if (!relPath.endsWith(".tsx") && !relPath.endsWith(".ts")) return null;
|
|
1362
|
+
|
|
1363
|
+
let result = content;
|
|
1364
|
+
let changed = false;
|
|
1365
|
+
|
|
1366
|
+
for (const rp of replacements) {
|
|
1367
|
+
if (rp.pattern.test(result)) {
|
|
1368
|
+
result = result.replace(rp.pattern, rp.replacement);
|
|
1369
|
+
changed = true;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (changed) {
|
|
1374
|
+
log(ctx, ` Fixed Worker-incompatible APIs: src/${relPath}`);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
return changed ? result : null;
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
export function cleanup(ctx: MigrationContext): void {
|
|
1382
|
+
logPhase("Cleanup");
|
|
1383
|
+
|
|
1384
|
+
// 0. Remove empty block stubs that shadow real data
|
|
1385
|
+
console.log(" Removing empty block stubs...");
|
|
1386
|
+
removeEmptyBlockStubs(ctx);
|
|
1387
|
+
|
|
1388
|
+
// 1. Move static → public (handles static/, static-cv/, static-lb/, etc.)
|
|
1389
|
+
console.log(" Moving static assets → public/...");
|
|
1390
|
+
moveStaticFiles(ctx);
|
|
1391
|
+
moveMultiBrandStaticFiles(ctx);
|
|
1392
|
+
|
|
1393
|
+
// 2. Delete specific files
|
|
1394
|
+
console.log(" Deleting old files...");
|
|
1395
|
+
for (const file of ROOT_FILES_TO_DELETE) {
|
|
1396
|
+
deleteFileIfExists(ctx, file);
|
|
1397
|
+
}
|
|
1398
|
+
for (const file of SDK_FILES_TO_DELETE) {
|
|
1399
|
+
deleteFileIfExists(ctx, file);
|
|
1400
|
+
deleteFileIfExists(ctx, `src/${file}`);
|
|
1401
|
+
}
|
|
1402
|
+
for (const file of WRAPPER_FILES_TO_DELETE) {
|
|
1403
|
+
deleteFileIfExists(ctx, file);
|
|
1404
|
+
deleteFileIfExists(ctx, `src/${file}`);
|
|
1405
|
+
}
|
|
1406
|
+
for (const file of LOADER_FILES_TO_DELETE) {
|
|
1407
|
+
deleteFileIfExists(ctx, file);
|
|
1408
|
+
deleteFileIfExists(ctx, `src/${file}`);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// 3. Delete directories
|
|
1412
|
+
console.log(" Deleting old directories...");
|
|
1413
|
+
for (const dir of DIRS_TO_DELETE) {
|
|
1414
|
+
deleteDirIfExists(ctx, dir);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// 4. Clean up old source directories
|
|
1418
|
+
console.log(" Cleaning up old source dirs...");
|
|
1419
|
+
cleanupOldSourceDirs(ctx);
|
|
1420
|
+
cleanupReExportSections(ctx);
|
|
1421
|
+
cleanupJunkFromSrc(ctx);
|
|
1422
|
+
|
|
1423
|
+
// 5. Override contexts/device.tsx with SSR-safe useSyncExternalStore version.
|
|
1424
|
+
// The transform phase copies and transforms the source file (createContext-based),
|
|
1425
|
+
// but @decocms/start shell-renders sections without a Device.Provider, so we
|
|
1426
|
+
// must replace it with a standalone implementation.
|
|
1427
|
+
console.log(" Overriding contexts/device.tsx...");
|
|
1428
|
+
overrideDeviceContext(ctx);
|
|
1429
|
+
|
|
1430
|
+
// 6. Rewrite retry.ts to remove cockatiel (creates AbortController at module scope)
|
|
1431
|
+
console.log(" Rewriting utils/retry.ts...");
|
|
1432
|
+
rewriteRetryUtil(ctx);
|
|
1433
|
+
|
|
1434
|
+
// 7. Add safety guards for common runtime errors in migrated code
|
|
1435
|
+
console.log(" Adding runtime safety guards...");
|
|
1436
|
+
addRuntimeSafetyGuards(ctx);
|
|
1437
|
+
|
|
1438
|
+
// 8. Fix section barrel files missing LoadingFallback re-export
|
|
1439
|
+
console.log(" Fixing LoadingFallback re-exports...");
|
|
1440
|
+
fixLoadingFallbackReExports(ctx);
|
|
1441
|
+
|
|
1442
|
+
// 9. Replace SectionRenderer with RenderSection for nested sections
|
|
1443
|
+
console.log(" Upgrading SectionRenderer → RenderSection...");
|
|
1444
|
+
upgradeSectionRenderer(ctx);
|
|
1445
|
+
|
|
1446
|
+
// 10. Migrate account.json → src/constants/account.ts
|
|
1447
|
+
// Old stack has a root-level account.json containing the site name as a JSON string.
|
|
1448
|
+
// New stack uses a TS module `src/constants/account.ts` exporting `accountName`.
|
|
1449
|
+
// We also rewrite all imports that reference account.json.
|
|
1450
|
+
console.log(" Migrating account.json → src/constants/account.ts...");
|
|
1451
|
+
migrateAccountJson(ctx);
|
|
1452
|
+
|
|
1453
|
+
// 11. Rewrite VTEX utility imports to use ~/lib/ wrappers
|
|
1454
|
+
// The old stack imports from apps/vtex/utils/* which get rewritten to
|
|
1455
|
+
// @decocms/apps/vtex/utils/* — but the signatures are incompatible
|
|
1456
|
+
// and some types (VTEXCommerceStable) don't exist. Replace with
|
|
1457
|
+
// simplified ~/lib/ wrappers generated during scaffold.
|
|
1458
|
+
console.log(" Rewriting VTEX utility imports → ~/lib/ wrappers...");
|
|
1459
|
+
rewriteVtexUtilImports(ctx);
|
|
1460
|
+
|
|
1461
|
+
// 11a. Preserve root-level constants.ts (site-wide UI IDs/heights) by
|
|
1462
|
+
// moving it to src/constants.ts. The cleanup phase used to delete it
|
|
1463
|
+
// unconditionally, breaking every component that imports `~/constants`.
|
|
1464
|
+
console.log(" Moving root constants.ts → src/constants.ts...");
|
|
1465
|
+
moveRootConstantsToSrc(ctx);
|
|
1466
|
+
|
|
1467
|
+
// 11b. Rewrite legacy multi-platform minicart loader → VTEX-only re-export.
|
|
1468
|
+
// `sdk/cart/` is deleted by DIRS_TO_DELETE, leaving loaders/minicart.ts
|
|
1469
|
+
// with broken imports. Replace it with a thin re-export of the
|
|
1470
|
+
// framework's @decocms/apps/vtex/inline-loaders/minicart loader.
|
|
1471
|
+
console.log(" Rewriting loaders/minicart.ts → VTEX-only re-export...");
|
|
1472
|
+
rewriteMinicartLoader(ctx);
|
|
1473
|
+
|
|
1474
|
+
// 12. Fix useVariantPossiblities omit set
|
|
1475
|
+
console.log(" Fixing useVariantPossiblities omit set...");
|
|
1476
|
+
fixVariantOmitSet(ctx);
|
|
1477
|
+
|
|
1478
|
+
// 13. Normalize component import path casing
|
|
1479
|
+
console.log(" Normalizing component import casing...");
|
|
1480
|
+
normalizeImportCasing(ctx);
|
|
1481
|
+
|
|
1482
|
+
// 13. Fix Worker-incompatible APIs (window.setTimeout, etc.)
|
|
1483
|
+
console.log(" Fixing Worker-incompatible APIs...");
|
|
1484
|
+
fixWorkerIncompatibleApis(ctx);
|
|
1485
|
+
|
|
1486
|
+
// 14. LAZY: write only the src/lib/* shim files that the migrated
|
|
1487
|
+
// codebase actually imports. Run after every previous step so we
|
|
1488
|
+
// observe the final import graph (transforms + cleanup rewrites
|
|
1489
|
+
// + inline-stub hoisting all settled).
|
|
1490
|
+
console.log(" Writing src/lib/* shims (lazy)...");
|
|
1491
|
+
writeImportedLibShims(ctx);
|
|
1492
|
+
|
|
1493
|
+
console.log(
|
|
1494
|
+
` Deleted ${ctx.deletedFiles.length} files/dirs, moved ${ctx.movedFiles.length} files`,
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Walk `src/**\/*.{ts,tsx}` looking for `from "~/lib/<name>"` imports.
|
|
1500
|
+
* For each unique `<name>` that has a registered template in
|
|
1501
|
+
* `LIB_TEMPLATES`, write `src/lib/<name>.ts` with the template content.
|
|
1502
|
+
*
|
|
1503
|
+
* Sites that import none of the shims end up with no `src/lib/` directory
|
|
1504
|
+
* at all, instead of 11 dead files.
|
|
1505
|
+
*
|
|
1506
|
+
* Exported (rather than file-local) for unit testability — see
|
|
1507
|
+
* `phase-cleanup.test.ts`.
|
|
1508
|
+
*/
|
|
1509
|
+
export function writeImportedLibShims(ctx: MigrationContext): void {
|
|
1510
|
+
const srcRoot = path.join(ctx.sourceDir, "src");
|
|
1511
|
+
if (!fs.existsSync(srcRoot)) return;
|
|
1512
|
+
|
|
1513
|
+
const importedSpecifiers = new Set<string>();
|
|
1514
|
+
// Match `from "~/lib/<name>"` or `from '~/lib/<name>'`. We don't care
|
|
1515
|
+
// about default vs named imports here — only the path matters.
|
|
1516
|
+
const importRe = /from\s+["']~\/lib\/([^"']+?)(?:\.ts)?["']/g;
|
|
1517
|
+
|
|
1518
|
+
/** Recursively scan a directory for .ts/.tsx files and collect specifiers. */
|
|
1519
|
+
const walk = (dir: string) => {
|
|
1520
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1521
|
+
const full = path.join(dir, entry.name);
|
|
1522
|
+
if (entry.isDirectory()) {
|
|
1523
|
+
// Skip the lib dir itself so we don't count internal cross-refs
|
|
1524
|
+
// when we later add them.
|
|
1525
|
+
if (full === path.join(srcRoot, "lib")) continue;
|
|
1526
|
+
walk(full);
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
if (!entry.isFile()) continue;
|
|
1530
|
+
if (!/\.(ts|tsx)$/.test(entry.name)) continue;
|
|
1531
|
+
const content = fs.readFileSync(full, "utf-8");
|
|
1532
|
+
let m: RegExpExecArray | null;
|
|
1533
|
+
while ((m = importRe.exec(content)) !== null) {
|
|
1534
|
+
importedSpecifiers.add(m[1]);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
walk(srcRoot);
|
|
1539
|
+
|
|
1540
|
+
if (importedSpecifiers.size === 0) {
|
|
1541
|
+
log(ctx, " No ~/lib/* imports detected — no shims written.");
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const known = new Set(
|
|
1546
|
+
Object.keys(LIB_TEMPLATES).map((k) =>
|
|
1547
|
+
k.replace(/^src\/lib\//, "").replace(/\.ts$/, ""),
|
|
1548
|
+
),
|
|
1549
|
+
);
|
|
1550
|
+
const unknown: string[] = [];
|
|
1551
|
+
for (const spec of importedSpecifiers) {
|
|
1552
|
+
if (!known.has(spec)) unknown.push(spec);
|
|
1553
|
+
}
|
|
1554
|
+
if (unknown.length > 0) {
|
|
1555
|
+
log(
|
|
1556
|
+
ctx,
|
|
1557
|
+
` Warning: ~/lib/{${unknown.join(", ")}} imported but no template registered. ` +
|
|
1558
|
+
`Write the file by hand or add a template to scripts/migrate/templates/lib-utils.ts.`,
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
const toWrite = selectImportedLibTemplates(importedSpecifiers);
|
|
1563
|
+
if (Object.keys(toWrite).length === 0) return;
|
|
1564
|
+
|
|
1565
|
+
if (ctx.dryRun) {
|
|
1566
|
+
for (const relPath of Object.keys(toWrite)) {
|
|
1567
|
+
log(ctx, ` [DRY] Would write: ${relPath}`);
|
|
1568
|
+
}
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const libDir = path.join(srcRoot, "lib");
|
|
1573
|
+
if (!fs.existsSync(libDir)) {
|
|
1574
|
+
fs.mkdirSync(libDir, { recursive: true });
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
for (const [relPath, content] of Object.entries(toWrite)) {
|
|
1578
|
+
const fullPath = path.join(ctx.sourceDir, relPath);
|
|
1579
|
+
fs.writeFileSync(fullPath, content, "utf-8");
|
|
1580
|
+
log(ctx, ` Wrote: ${relPath}`);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
log(
|
|
1584
|
+
ctx,
|
|
1585
|
+
` Generated ${Object.keys(toWrite).length} src/lib/* shim(s) ` +
|
|
1586
|
+
`(out of ${Object.keys(LIB_TEMPLATES).length} available templates).`,
|
|
1587
|
+
);
|
|
1588
|
+
}
|