Skip to content

Commit 12f4ab3

Browse files
cojiclaude
andcommitted
fix: handle remix-flat-routes folder route pattern (route.tsx) in migration CLI
- Fix isColocatedFile() misclassifying route entry files as colocated - Fix route ID generation for route.tsx folder routes - Fix snapshot normalization for route segment - Add rewriteTypesRouteSpecifier() for +types/route imports - Add stripTsExtension() for index.ts extension imports - Add route source path exclusion in collectColocatedMappings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3eaa2d5 commit 12f4ab3

File tree

5 files changed

+140
-11
lines changed

5 files changed

+140
-11
lines changed

src/migration/fs-helpers.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ export function visitFiles(dir: string, visitor: (file: string) => void): void {
88
walkFiles(dir, visitor, { followSymlinks: false })
99
}
1010

11+
const ROUTE_ENTRY_BASENAMES = new Set(['route', 'index', '_index', '_layout'])
12+
13+
function isRouteEntryBasename(basename: string): boolean {
14+
const ext = path.extname(basename)
15+
const name = ext ? basename.slice(0, -ext.length) : basename
16+
return ROUTE_ENTRY_BASENAMES.has(name)
17+
}
18+
19+
function isRouteSegment(segment: string): boolean {
20+
if (segment === '' || segment === '.') return true
21+
if (segment.endsWith('+')) return true
22+
if (segment.startsWith('(') && segment.endsWith(')')) return true
23+
if (segment.startsWith('__')) return true
24+
if (segment.startsWith('_')) return true
25+
return false
26+
}
27+
1128
export function isColocatedFile(filename: string): boolean {
1229
const normalized = filename.replace(/\\/g, '/')
1330
const segments = normalized.split('/')
@@ -25,13 +42,32 @@ export function isColocatedFile(filename: string): boolean {
2542
return true
2643
}
2744

28-
return directorySegments.some((segment) => {
29-
if (segment === '' || segment === '.') return false
30-
if (segment.endsWith('+')) return false
31-
if (segment.startsWith('(') && segment.endsWith(')')) return false
32-
if (segment.startsWith('__')) return false
33-
return true
34-
})
45+
// Count how many "regular" (non-route) directory segments exist after the
46+
// last `+` parent. In remix-flat-routes, the first directory after a `+`
47+
// folder is a route folder (e.g. `demo+/about/route.tsx`). Files named as
48+
// route entries directly inside such a folder are route modules, not
49+
// colocated files. Deeper regular directories indicate colocated content.
50+
let lastPlusIndex = -1
51+
for (let i = directorySegments.length - 1; i >= 0; i--) {
52+
if (directorySegments[i].endsWith('+')) {
53+
lastPlusIndex = i
54+
break
55+
}
56+
}
57+
58+
const segmentsAfterPlus = directorySegments.slice(lastPlusIndex + 1)
59+
const regularSegments = segmentsAfterPlus.filter(
60+
(segment) => !isRouteSegment(segment),
61+
)
62+
63+
// If there's exactly one regular directory after the last `+` folder and
64+
// the file is a route entry (route.tsx, index.tsx, etc.), this is a folder
65+
// route — not a colocated file.
66+
if (regularSegments.length <= 1 && isRouteEntryBasename(basename)) {
67+
return false
68+
}
69+
70+
return regularSegments.length > 0
3571
}
3672

3773
export function defaultTargetDir(sourceDir: string): string {

src/migration/import-rewriter.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,10 @@ function computeSpecifierReplacement(
208208

209209
const relativeReplacement = rewriteRelativeSpecifier(base, context)
210210
if (relativeReplacement) {
211-
const nextSpecifier = relativeReplacement + suffix
211+
// Also strip /index.ts(x) from relative specifiers
212+
const stripped =
213+
stripTsExtension(relativeReplacement) ?? relativeReplacement
214+
const nextSpecifier = stripped + suffix
212215
if (nextSpecifier !== specifier) {
213216
return nextSpecifier
214217
}
@@ -217,7 +220,19 @@ function computeSpecifierReplacement(
217220
let nextBase = base
218221
let changed = false
219222

220-
const aliasedReplacement = rewriteAliasedSpecifier(base, context)
223+
const typesReplacement = rewriteTypesRouteSpecifier(nextBase, context)
224+
if (typesReplacement) {
225+
nextBase = typesReplacement
226+
changed = true
227+
}
228+
229+
const extensionReplacement = stripTsExtension(nextBase)
230+
if (extensionReplacement) {
231+
nextBase = extensionReplacement
232+
changed = true
233+
}
234+
235+
const aliasedReplacement = rewriteAliasedSpecifier(nextBase, context)
221236
if (aliasedReplacement) {
222237
nextBase = aliasedReplacement
223238
changed = true
@@ -239,6 +254,54 @@ function computeSpecifierReplacement(
239254
return null
240255
}
241256

257+
/**
258+
* When a folder route (`about/route.tsx`) is converted to a flat file
259+
* (`about.tsx`), the virtual `+types/route` import must be updated to
260+
* reference the new filename (e.g. `+types/about`).
261+
*/
262+
function rewriteTypesRouteSpecifier(
263+
specifier: string,
264+
context: ImportRewriteContext,
265+
): string | null {
266+
const normalized = specifier.replace(/\\/g, '/')
267+
const typesRoutePattern = /\/\+types\/route$/
268+
if (!typesRoutePattern.test(normalized)) {
269+
return null
270+
}
271+
272+
const sourceBasename = path.basename(context.sourcePath)
273+
const targetBasename = path.basename(context.targetPath)
274+
const sourceNameWithoutExt = sourceBasename.replace(/\.[^.]+$/, '')
275+
const targetNameWithoutExt = targetBasename.replace(/\.[^.]+$/, '')
276+
277+
// Only rewrite if the source file is `route.{ext}` and the target is
278+
// different (i.e. the file was converted from a folder route to a flat file).
279+
if (sourceNameWithoutExt !== 'route' || targetNameWithoutExt === 'route') {
280+
return null
281+
}
282+
283+
return normalized.replace(
284+
typesRoutePattern,
285+
`/+types/${targetNameWithoutExt}`,
286+
)
287+
}
288+
289+
/**
290+
* Strips explicit `.ts` / `.tsx` extensions from import specifiers that
291+
* reference barrel files (e.g. `./components/index.ts` → `./components`).
292+
*/
293+
function stripTsExtension(specifier: string): string | null {
294+
const normalized = specifier.replace(/\\/g, '/')
295+
296+
// Strip /index.ts or /index.tsx suffix → import from directory
297+
const indexTsPattern = /\/index\.tsx?$/
298+
if (indexTsPattern.test(normalized)) {
299+
return normalized.replace(indexTsPattern, '')
300+
}
301+
302+
return null
303+
}
304+
242305
function splitImportSpecifier(specifier: string): {
243306
base: string
244307
suffix: string

src/migration/migrate.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,14 @@ export function migrate(
5151
})
5252

5353
const routeMappings = collectRouteMappings(routes, sourceDir, targetDir)
54-
const colocatedMappings = collectColocatedMappings(sourceDir, targetDir)
54+
const routeSourcePaths = new Set(
55+
routeMappings.map((m) => normalizeAbsolutePath(m.source)),
56+
)
57+
const colocatedMappings = collectColocatedMappings(
58+
sourceDir,
59+
targetDir,
60+
routeSourcePaths,
61+
)
5562
const mappings = [...routeMappings, ...colocatedMappings]
5663
const normalizedMapping = createNormalizedMapping(mappings)
5764
const specifierReplacements = createSpecifierReplacements(
@@ -94,6 +101,7 @@ function collectRouteMappings(
94101
function collectColocatedMappings(
95102
sourceDir: string,
96103
targetDir: string,
104+
routeSourcePaths: Set<string>,
97105
): FileMapping[] {
98106
const mappings: FileMapping[] = []
99107

@@ -103,6 +111,10 @@ function collectColocatedMappings(
103111
}
104112

105113
const sourcePath = path.resolve(sourceDir, file)
114+
if (routeSourcePaths.has(normalizeAbsolutePath(sourcePath))) {
115+
return
116+
}
117+
106118
const targetPath = path.resolve(targetDir, convertColocatedPath(file))
107119
mappings.push({ source: sourcePath, target: targetPath })
108120
})

src/migration/normalizers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ export function normalizeSnapshotRouteFilePath(filePath: string): string {
8888
continue
8989
}
9090

91+
// Treat folder route convention (`about/route.tsx`) the same as flat file
92+
// (`about.tsx`). The `route` segment is a marker, not a path component.
93+
if (segment === 'route' && result.length > 0) {
94+
continue
95+
}
96+
9197
if (segment === 'index' && result.length > 0) {
9298
const parent = result.pop()!
9399
result.push(`${parent}.index`)

src/migration/route-scanner.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,19 @@ export function scanRouteModules(
5050
const extension = path.extname(file)
5151
if (MIGRATION_ROUTE_EXTENSIONS.includes(extension)) {
5252
const relativePath = path.join(routesDirectory, file)
53-
const routeId = createRouteId(relativePath)
53+
const basename = path.basename(file, extension)
54+
// In the folder route convention, `route.tsx` inside a folder means
55+
// the folder itself is the route. Strip the `/route` segment so the
56+
// route ID matches the folder name (same as remix-flat-routes).
57+
let routeId: string
58+
if (basename === 'route') {
59+
// Use directory path directly as route ID — don't use createRouteId
60+
// here because it strips dot-segments (e.g. `.biography` from
61+
// `($lang).biography`).
62+
routeId = path.dirname(relativePath).split(path.win32.sep).join('/')
63+
} else {
64+
routeId = createRouteId(relativePath)
65+
}
5466
files[routeId] = relativePath
5567
return
5668
}

0 commit comments

Comments
 (0)