@@ -21,6 +21,7 @@ import { loadUnversionedTree, loadPages, loadPageMap } from '@/frame/lib/page-da
2121import getRedirect , { splitPathByLanguage } from '@/redirects/lib/get-redirect'
2222import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version'
2323import { deprecated } from '@/versions/lib/enterprise-server-releases'
24+ import { RedirectedFragmentValidator } from '@/links/lib/validate-redirected-fragment'
2425
2526const logger = createLogger ( import . meta. url )
2627
@@ -33,6 +34,7 @@ type LinkContext = {
3334 redirects : NonNullable < Context [ 'redirects' ] >
3435 currentLanguage : string
3536 userLanguage : string
37+ fragmentValidator : RedirectedFragmentValidator
3638}
3739
3840type Replacement = {
@@ -49,11 +51,40 @@ type Warning = {
4951 column ?: number
5052}
5153
54+ // A fragment (`#anchor`) that was carried over when a redirect rewrote the link's path.
55+ // It needs validating against the destination page before we decide to keep or drop it.
56+ type CarriedFragment = {
57+ hash : string
58+ destPage ?: Page
59+ }
60+
61+ type NewHrefResult = {
62+ // The rewritten href WITHOUT the fragment when `fragment` is set (the caller decides
63+ // whether to re-append it); otherwise the full href including any fragment/search.
64+ href : string
65+ fragment ?: CarriedFragment
66+ }
67+
68+ // A link/definition replacement collected during the synchronous AST walk. Applying it is
69+ // deferred until after async fragment validation, so a carried-over anchor can be dropped.
70+ type PendingReplacement = {
71+ asMarkdown : string
72+ line : number
73+ column ?: number
74+ baseHref : string
75+ makeMarkdown : ( href : string ) => string
76+ fragment ?: CarriedFragment
77+ }
78+
5279const Options = {
5380 setAutotitle : false ,
5481 fixHref : false ,
5582 verbose : false ,
5683 strict : false ,
84+ // When a redirect rewrites a link's path, the carried-over `#anchor` is validated
85+ // against the destination page's real headings. If it exists in no applicable version
86+ // it is dropped by default. Set this to keep such fragments (warn only).
87+ keepStaleFragments : false ,
5788}
5889
5990export async function updateInternalLinks ( files : string [ ] , options = { } ) {
@@ -71,6 +102,7 @@ export async function updateInternalLinks(files: string[], options = {}) {
71102 redirects,
72103 currentLanguage : 'en' ,
73104 userLanguage : 'en' ,
105+ fragmentValidator : new RedirectedFragmentValidator ( pageMap , redirects , 'en' ) ,
74106 }
75107
76108 for ( const file of files ) {
@@ -167,31 +199,30 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
167199
168200 const lineOffset = rawContent . replace ( content , '' ) . split ( / \n / g) . length - 1
169201
202+ // Replacements are collected here during the synchronous AST walk and applied after
203+ // async fragment validation below, so a carried-over `#anchor` can be dropped when the
204+ // redirected destination page doesn't actually have that heading.
205+ const pending : PendingReplacement [ ] = [ ]
206+
170207 visit ( ast , definitionMatcher as Test , ( node : Nodes ) => {
171208 const asMarkdown = toMarkdown ( node ) . trim ( )
172209 // E.g. `[foo]: /bar`
173210 if ( opts . fixHref && content . includes ( asMarkdown ) && isDefinition ( node ) ) {
174- let newHref = node . url
175211 const { label } = node
176- const betterHref = getNewHref ( newHref , context , opts , file )
212+ const result = getNewHref ( node . url , context , opts , file )
177213 // getNewHref() might return a deliberate `undefined` if the
178214 // new href value could not be computed for some reason.
179- if ( betterHref !== undefined ) {
180- newHref = betterHref
181- }
182- const newAsMarkdown = `[${ label } ]: ${ newHref } `
183- if ( asMarkdown !== newAsMarkdown ) {
184- // Something can be improved!
185- const column = node . position ?. start . column
186- const line = node . position ?. start . line || 0 + lineOffset
187- replacements . push ( {
188- asMarkdown,
189- newAsMarkdown,
190- line,
191- column,
192- } )
193- newContent = newContent . replace ( asMarkdown , newAsMarkdown )
194- }
215+ const baseHref = result === undefined ? node . url : result . href
216+ const column = node . position ?. start . column
217+ const line = ( node . position ?. start . line ?? 0 ) + lineOffset
218+ pending . push ( {
219+ asMarkdown,
220+ line,
221+ column,
222+ baseHref,
223+ makeMarkdown : ( href ) => `[${ label } ]: ${ href } ` ,
224+ fragment : result ?. fragment ,
225+ } )
195226 }
196227 } )
197228
@@ -212,7 +243,8 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
212243 const title = node . children . map ( ( child : Nodes ) => toMarkdown ( child ) . slice ( 0 , - 1 ) ) . join ( '' )
213244
214245 let newTitle = title
215- let newHref = node . url
246+ let baseHref = node . url
247+ let fragment : CarriedFragment | undefined
216248
217249 const hasQuotesAroundLink = content . includes ( `"${ asMarkdown } ` )
218250
@@ -250,7 +282,7 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
250282 if ( xValue ) {
251283 if ( singleStartingQuote ( xValue ) ) {
252284 const column = node . position ?. start . column
253- const line = node . position ?. start . line || 0 + lineOffset
285+ const line = ( node . position ?. start . line ?? 0 ) + lineOffset
254286 warnings . push ( {
255287 warning : 'Starts with a single " inside the text' ,
256288 asMarkdown,
@@ -259,7 +291,7 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
259291 } )
260292 } else if ( isSimpleQuote ( xValue ) ) {
261293 const column = node . position ?. start . column
262- const line = node . position ?. start . line || 0 + lineOffset
294+ const line = ( node . position ?. start . line ?? 0 ) + lineOffset
263295 warnings . push ( {
264296 warning : 'Starts and ends with a " inside the text' ,
265297 asMarkdown,
@@ -271,26 +303,24 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
271303 }
272304 }
273305 if ( opts . fixHref ) {
274- const betterHref = getNewHref ( node . url , context , opts , file )
306+ const result = getNewHref ( node . url , context , opts , file )
275307 // getNewHref() might return a deliberate `undefined` if the
276308 // new href value could not be computed for some reason.
277- if ( betterHref !== undefined ) {
278- newHref = betterHref
309+ if ( result !== undefined ) {
310+ baseHref = result . href
311+ fragment = result . fragment
279312 }
280313 }
281- const newAsMarkdown = `[${ newTitle } ](${ newHref } )`
282- if ( asMarkdown !== newAsMarkdown ) {
283- // Something can be improved!
284- const column = node . position ?. start . column
285- const line = node . position ?. start . line || 0 + lineOffset
286- replacements . push ( {
287- asMarkdown,
288- newAsMarkdown,
289- line,
290- column,
291- } )
292- newContent = newContent . replace ( asMarkdown , newAsMarkdown )
293- }
314+ const column = node . position ?. start . column
315+ const line = ( node . position ?. start . line ?? 0 ) + lineOffset
316+ pending . push ( {
317+ asMarkdown,
318+ line,
319+ column,
320+ baseHref,
321+ makeMarkdown : ( href ) => `[${ newTitle } ](${ href } )` ,
322+ fragment,
323+ } )
294324 } else if ( opts . verbose ) {
295325 logger . warn ( 'Unable to find link as Markdown in the source content' , {
296326 asMarkdown,
@@ -299,6 +329,64 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
299329 }
300330 } )
301331
332+ // Resolve any carried-over fragments (async — renders the destination page), then apply
333+ // every replacement to `newContent` in document order.
334+ for ( const item of pending ) {
335+ let finalHref = item . baseHref
336+ if ( item . fragment ) {
337+ const { hash, destPage } = item . fragment
338+ const decision = await context . fragmentValidator . classify ( destPage , hash )
339+ if ( decision === 'drop' ) {
340+ if ( opts . keepStaleFragments ) {
341+ finalHref = item . baseHref + hash
342+ warnings . push ( {
343+ warning : `Stale anchor '${ hash } ' not found on the redirected destination page in any applicable version, but kept because keepStaleFragments is set` ,
344+ asMarkdown : item . asMarkdown ,
345+ line : item . line ,
346+ column : item . column ,
347+ } )
348+ } else {
349+ // Drop the fragment: leave `finalHref` as the fragment-less base href.
350+ warnings . push ( {
351+ warning : `Removed stale anchor '${ hash } ' — not found on the redirected destination page in any applicable version` ,
352+ asMarkdown : item . asMarkdown ,
353+ line : item . line ,
354+ column : item . column ,
355+ } )
356+ }
357+ } else if ( decision === 'mixed' ) {
358+ finalHref = item . baseHref + hash
359+ warnings . push ( {
360+ warning : `Anchor '${ hash } ' exists in some but not all versions of the redirected destination page; kept for manual review` ,
361+ asMarkdown : item . asMarkdown ,
362+ line : item . line ,
363+ column : item . column ,
364+ } )
365+ } else if ( decision === 'unvalidatable' ) {
366+ finalHref = item . baseHref + hash
367+ warnings . push ( {
368+ warning : `Could not validate anchor '${ hash } ' on the redirected destination page; kept unchanged` ,
369+ asMarkdown : item . asMarkdown ,
370+ line : item . line ,
371+ column : item . column ,
372+ } )
373+ } else {
374+ // 'keep' — the anchor exists on the destination in every applicable version.
375+ finalHref = item . baseHref + hash
376+ }
377+ }
378+ const newAsMarkdown = item . makeMarkdown ( finalHref )
379+ if ( item . asMarkdown !== newAsMarkdown ) {
380+ replacements . push ( {
381+ asMarkdown : item . asMarkdown ,
382+ newAsMarkdown,
383+ line : item . line ,
384+ column : item . column ,
385+ } )
386+ newContent = newContent . replace ( item . asMarkdown , newAsMarkdown )
387+ }
388+ }
389+
302390 return {
303391 data,
304392 content,
@@ -490,7 +578,7 @@ function getNewHref(
490578 strict : boolean
491579 } ,
492580 file : string ,
493- ) {
581+ ) : NewHrefResult | undefined {
494582 const { currentLanguage } = context
495583 const parsed = new URL ( href , 'https://docs.github.com' )
496584 const hash = parsed . hash
@@ -579,13 +667,31 @@ function getNewHref(
579667 }
580668 }
581669
582- if ( search ) {
583- newHref += search
670+ const base = search ? `${ newHref } ${ search } ` : newHref
671+
672+ // No fragment → nothing to validate; return the (possibly rewritten) path as-is.
673+ if ( ! hash ) {
674+ return { href : base }
584675 }
585- if ( hash ) {
586- newHref += hash
676+
677+ // The path wasn't rewritten, so the original fragment still points at the same page and
678+ // remains valid. Keep it appended, exactly as before.
679+ if ( ! redirected ) {
680+ return { href : base + hash }
587681 }
588- return newHref
682+
683+ // The path WAS rewritten by a redirect, so the carried-over fragment may be stale on the
684+ // destination page. Surface it (with the destination Page, if resolvable) so the caller
685+ // can validate it against the destination's real headings and decide keep vs. drop.
686+ const destPage = resolveDestinationPage ( context . pages , redirected )
687+ return { href : base , fragment : { hash, destPage } }
688+ }
689+
690+ // Resolve the Page a redirect points at, so its headings can be validated. `redirected` is
691+ // a full permalink-style URL from getRedirect() (e.g. `/en/get-started/foo` or
692+ // `/en/enterprise-cloud@latest/get-started/foo`), which is how the page map is keyed.
693+ function resolveDestinationPage ( pages : Record < string , Page > , redirected : string ) : Page | undefined {
694+ return pages [ redirected ] || pages [ getPathWithLanguage ( getPathWithoutLanguage ( redirected ) , 'en' ) ]
589695}
590696
591697function singleStartingQuote ( text : string ) {
0 commit comments