@@ -47,12 +47,12 @@ function filePathToUrls(filePath: string): {isDeveloperDocs: boolean; urls: stri
4747 // Handle index files
4848 if ( slug . endsWith ( '/index' ) ) {
4949 slug = slug . replace ( / \/ i n d e x $ / , '' ) ;
50- // Return both with and without trailing slash
51- return { isDeveloperDocs, urls : [ `/${ slug } /` , `/ ${ slug } ` ] } ;
50+ // Return canonical URL with trailing slash (Next.js has trailingSlash: true)
51+ return { isDeveloperDocs, urls : [ `/${ slug } /` ] } ;
5252 }
5353
54- // Return URL path
55- return { isDeveloperDocs, urls : [ `/${ slug } ` , `/ ${ slug } /`] } ;
54+ // Return canonical URL with trailing slash (Next.js has trailingSlash: true)
55+ return { isDeveloperDocs, urls : [ `/${ slug } /` ] } ;
5656}
5757
5858/**
@@ -109,18 +109,15 @@ function detectRenamedFiles(): RenamedFile[] {
109109 ) ;
110110 }
111111
112- // Create entries for all URL variants
113- for ( const oldUrl of oldPathInfo . urls ) {
114- for ( const newUrl of newPathInfo . urls ) {
115- renamedFiles . push ( {
116- oldPath,
117- newPath,
118- oldUrl,
119- newUrl,
120- isDeveloperDocs : oldPathInfo . isDeveloperDocs ,
121- } ) ;
122- }
123- }
112+ // Create entry with canonical URL (Next.js normalizes to trailing slash)
113+ // Since trailingSlash: true is set, we only need one redirect per file pair
114+ renamedFiles . push ( {
115+ oldPath,
116+ newPath,
117+ oldUrl : oldPathInfo . urls [ 0 ] , // Canonical URL (with trailing slash)
118+ newUrl : newPathInfo . urls [ 0 ] , // Canonical URL (with trailing slash)
119+ isDeveloperDocs : oldPathInfo . isDeveloperDocs ,
120+ } ) ;
124121 }
125122
126123 return renamedFiles ;
@@ -130,6 +127,31 @@ function detectRenamedFiles(): RenamedFile[] {
130127 }
131128}
132129
130+ /**
131+ * Checks if a quote at the given index is escaped by counting consecutive backslashes.
132+ * A quote is escaped (part of the string) if there's an odd number of backslashes before it.
133+ * A quote is not escaped (ends the string) if there's an even number (including zero) of backslashes before it.
134+ *
135+ * Examples:
136+ * - "text\" - 1 backslash (odd) → escaped
137+ * - "text\\" - 2 backslashes (even) → not escaped
138+ * - "text\\\" - 3 backslashes (odd) → escaped
139+ */
140+ function isEscapedQuote ( content : string , index : number ) : boolean {
141+ if ( index === 0 ) return false ;
142+
143+ // Count consecutive backslashes before this position
144+ let backslashCount = 0 ;
145+ let pos = index - 1 ;
146+ while ( pos >= 0 && content [ pos ] === '\\' ) {
147+ backslashCount ++ ;
148+ pos -- ;
149+ }
150+
151+ // Quote is escaped if there's an odd number of backslashes
152+ return backslashCount % 2 === 1 ;
153+ }
154+
133155/**
134156 * Parses redirects.js to extract redirect entries
135157 * This uses regex-based parsing since redirects.js is a JavaScript file
@@ -163,13 +185,12 @@ function parseRedirectsJs(filePath: string): {
163185
164186 while ( i < content . length ) {
165187 const char = content [ i ] ;
166- const prevChar = i > 0 ? content [ i - 1 ] : '' ;
167188
168189 // Handle string literals
169- if ( ! inString && ( char === '"' || char === "'" ) && prevChar !== '\\' ) {
190+ if ( ! inString && ( char === '"' || char === "'" ) && ! isEscapedQuote ( content , i ) ) {
170191 inString = true ;
171192 stringChar = char ;
172- } else if ( inString && char === stringChar && prevChar !== '\\' ) {
193+ } else if ( inString && char === stringChar && ! isEscapedQuote ( content , i ) ) {
173194 inString = false ;
174195 }
175196
@@ -203,12 +224,11 @@ function parseRedirectsJs(filePath: string): {
203224
204225 while ( i < content . length ) {
205226 const char = content [ i ] ;
206- const prevChar = i > 0 ? content [ i - 1 ] : '' ;
207227
208- if ( ! inString && ( char === '"' || char === "'" ) && prevChar !== '\\' ) {
228+ if ( ! inString && ( char === '"' || char === "'" ) && ! isEscapedQuote ( content , i ) ) {
209229 inString = true ;
210230 stringChar = char ;
211- } else if ( inString && char === stringChar && prevChar !== '\\' ) {
231+ } else if ( inString && char === stringChar && ! isEscapedQuote ( content , i ) ) {
212232 inString = false ;
213233 }
214234
@@ -265,6 +285,20 @@ function extractRedirectsFromArray(arrayContent: string): Redirect[] {
265285/**
266286 * Checks if a redirect matches the expected old → new URL pattern
267287 * Handles path parameters like :path*, :platform, etc.
288+ *
289+ * Important considerations for :path*:
290+ * 1. If a redirect uses :path* (e.g., /old/:path* -> /new/:path*), it matches
291+ * any path under /old/ and redirects to the same path under /new/
292+ * 2. For a file rename, we need to verify that the redirect correctly maps
293+ * the old URL to the new URL
294+ * 3. If the redirect destination doesn't match where the file actually moved,
295+ * we need a specific redirect
296+ *
297+ * Examples:
298+ * - Redirect: /sdk/basics/:path* -> /sdk/processes/basics/:path*
299+ * - File: /sdk/basics/old.mdx -> /sdk/processes/basics/old.mdx ✅ Covered
300+ * - File: /sdk/basics/old.mdx -> /sdk/basics/new.mdx ❌ Needs specific redirect
301+ * - File: /sdk/basics/old.mdx -> /sdk/other/new.mdx ❌ Needs specific redirect
268302 */
269303function redirectMatches ( redirect : Redirect , oldUrl : string , newUrl : string ) : boolean {
270304 // Simple exact match first
@@ -273,29 +307,42 @@ function redirectMatches(redirect: Redirect, oldUrl: string, newUrl: string): bo
273307 }
274308
275309 // Handle path parameters - convert patterns to regex
310+ // :path* matches zero or more path segments (including nested paths)
311+ // :param matches a single path segment
276312 const sourcePattern = redirect . source
277- . replace ( / : \w + \* / g, '.*' )
278- . replace ( / : \w + / g, '[^/]+' ) ;
313+ . replace ( / : \w + \* / g, '.*' ) // :path* -> .* (matches any chars including slashes)
314+ . replace ( / : \w + / g, '[^/]+' ) ; // :param -> [^/]+ (matches non-slash chars)
279315 const sourceRegex = new RegExp ( `^${ sourcePattern } $` ) ;
280316
281317 // Check if oldUrl matches the source pattern
282- if ( sourceRegex . test ( oldUrl ) ) {
283- // For destinations with path parameters, check if newUrl matches
284- const destPattern = redirect . destination
285- . replace ( / : \w + \* / g, '.*' )
286- . replace ( / : \w + / g, '[^/]+' ) ;
287- const destRegex = new RegExp ( `^${ destPattern } $` ) ;
288-
289- // If destination has no params, exact match
290- if ( ! redirect . destination . includes ( ':' ) ) {
291- return redirect . destination === newUrl ;
292- }
318+ if ( ! sourceRegex . test ( oldUrl ) ) {
319+ return false ;
320+ }
321+
322+ // Old URL matches the source pattern, now check if destination matches new URL
323+ const destPattern = redirect . destination
324+ . replace ( / : \w + \* / g, '.*' )
325+ . replace ( / : \w + / g, '[^/]+' ) ;
326+ const destRegex = new RegExp ( `^${ destPattern } $` ) ;
293327
294- // If destination has params, check if pattern matches
295- return destRegex . test ( newUrl ) ;
328+ // If destination has no path parameters, require exact match
329+ if ( ! redirect . destination . includes ( ':' ) ) {
330+ return redirect . destination === newUrl ;
296331 }
297332
298- return false ;
333+ // If destination has path parameters, check if newUrl matches the pattern
334+ // This handles cases like:
335+ // - /old/:path* -> /new/:path* where /old/file/ -> /new/file/ ✅
336+ // - /old/:path* -> /new/ where /old/file/ -> /new/ ✅
337+ // - /old/:path* -> /new/:path* where /old/file/ -> /other/file/ ❌
338+ //
339+ // Note: Next.js redirects preserve parameter values (e.g., /platforms/:platform/old
340+ // with request /platforms/javascript/old redirects to /platforms/javascript/new).
341+ // Our pattern matching doesn't extract and resolve parameter values, so we might
342+ // have false positives in edge cases where parameters differ between old and new URLs.
343+ // However, this is rare in practice (most renames preserve parameter values), and
344+ // the pattern match is a good heuristic that a redirect exists.
345+ return destRegex . test ( newUrl ) ;
299346}
300347
301348/**
@@ -397,9 +444,14 @@ function validateRedirects(): MissingRedirect[] {
397444 ) ;
398445
399446 if ( ! hasRedirect ) {
400- // Check if it's a duplicate (already reported for a different URL variant)
447+ // Check if this file pair has already been reported
448+ // Since we only generate one URL variant per file (canonical with trailing slash),
449+ // we can deduplicate by file paths
401450 const alreadyReported = missingRedirects . some (
402- mr => mr . oldPath === renamedFile . oldPath && mr . newPath === renamedFile . newPath
451+ mr =>
452+ mr . oldPath === renamedFile . oldPath &&
453+ mr . newPath === renamedFile . newPath &&
454+ mr . isDeveloperDocs === renamedFile . isDeveloperDocs
403455 ) ;
404456
405457 if ( ! alreadyReported ) {
0 commit comments