@@ -11,6 +11,7 @@ import path from 'path'
1111import { createLogger } from '@/observability/logger'
1212import { allVersions } from '@/versions/lib/all-versions'
1313import { latestStable } from '@/versions/lib/enterprise-server-releases'
14+ import removeFPTFromPath from '@/versions/lib/remove-fpt-from-path'
1415import { getDataByLanguage } from '@/data-directory/lib/get-data'
1516import getRedirect from '@/redirects/lib/get-redirect'
1617import { isArchivedVersionByPath } from '@/archives/lib/is-archived-version'
@@ -461,7 +462,12 @@ export function normalizeLinkPath(href: string): string {
461462 * target page's precomputed heading IDs. Redirects are intentionally excluded: the
462463 * link is already reported as a redirect-to-update, and its final anchor is ambiguous.
463464 */
464- export function resolveInternalLinkKey ( href : string , pageMap : Record < string , Page > ) : string | null {
465+ export function resolveInternalLinkKey (
466+ href : string ,
467+ pageMap : Record < string , Page > ,
468+ version ?: string ,
469+ language = 'en' ,
470+ ) : string | null {
465471 const normalized = normalizeLinkPath ( href )
466472
467473 const latestPrefix = '/enterprise-server@latest'
@@ -473,19 +479,62 @@ export function resolveInternalLinkKey(href: string, pageMap: Record<string, Pag
473479
474480 if ( pageMap [ resolved ] ) return resolved
475481
476- const withLang = `/en${ resolved } `
482+ // Try the version being checked before the bare `/en` fallback, matching the order
483+ // `checkInternalLink` uses. A page that applies to both FPT and GHES has a key for
484+ // each, and the `/en` key would otherwise win even during a GHES run. The heading
485+ // cache is keyed by that version's permalink, so the anchor would then be looked up
486+ // under the wrong key and silently skipped.
487+ const versioned = versionedPageKey ( resolved , version , language )
488+ if ( versioned && pageMap [ versioned . key ] ) return versioned . key
489+
490+ const withLang = `/${ language } ${ resolved } `
477491 if ( pageMap [ withLang ] ) return withLang
478492
479493 return null
480494}
481495
496+ /**
497+ * Build the pageMap key for a versionless path inside a specific version, or return
498+ * null when the path shouldn't be resolved that way.
499+ *
500+ * pageMap keys look like `/en/enterprise-server@3.21/admin/foo`, with FPT omitting the
501+ * version segment entirely. A path that already carries its own version or language
502+ * prefix is left alone: it means something specific and shouldn't be reinterpreted as
503+ * relative to the version being checked.
504+ *
505+ * Returns the language-prefixed key for pageMap lookups and the language-stripped form,
506+ * which is how the redirect table stores paths.
507+ */
508+ function versionedPageKey (
509+ resolved : string ,
510+ version : string | undefined ,
511+ language : string ,
512+ ) : { key : string ; withoutLanguage : string } | null {
513+ if ( ! version ) return null
514+ // Already version-qualified, e.g. /enterprise-cloud@latest/..., so not a versionless link.
515+ if ( / ^ \/ [ a - z - ] + @ / . test ( resolved ) ) return null
516+ // Already language-qualified, e.g. /en/..., handled by the direct pageMap lookup above.
517+ if ( / ^ \/ [ a - z ] { 2 } ( - [ a - z ] { 2 } ) ? ( \/ | $ ) / . test ( resolved ) ) return null
518+
519+ const withoutLanguage = removeFPTFromPath ( path . posix . join ( '/' , version , resolved ) )
520+ return { key : `/${ language } ${ withoutLanguage } ` , withoutLanguage }
521+ }
522+
482523/**
483524 * Check if a path exists in the pageMap or redirects
525+ *
526+ * When `version` is supplied, a versionless link is first resolved inside that version,
527+ * mirroring what the runtime does when rendering the page. Without it, a versionless
528+ * link on a non-FPT page never matches a permalink (only FPT pages have versionless
529+ * permalinks), so it falls through to the versionless-fallback redirect and gets
530+ * misreported as a link that needs updating.
484531 */
485532export function checkInternalLink (
486533 href : string ,
487534 pageMap : Record < string , Page > ,
488535 redirects : Record < string , string > ,
536+ version ?: string ,
537+ language = 'en' ,
489538) : { exists : boolean ; isRedirect : boolean ; redirectTarget ?: string } {
490539 const normalized = normalizeLinkPath ( href )
491540
@@ -503,6 +552,24 @@ export function checkInternalLink(
503552 return { exists : true , isRedirect : false }
504553 }
505554
555+ // A versionless link resolves within the version currently being checked. This has to
556+ // come before the redirect lookups on the versionless form: that form exists in the
557+ // redirect table as a fallback and would otherwise shadow a page that really exists
558+ // in this version.
559+ const versioned = versionedPageKey ( resolved , version , language )
560+ if ( versioned ) {
561+ // Mirror runtime precedence: the redirect middleware runs before a page is served,
562+ // so a redirect on the effective versioned URL wins over the page itself. A
563+ // self-redirect is a no-op and doesn't count.
564+ const versionedRedirect = redirects [ versioned . withoutLanguage ]
565+ if ( versionedRedirect && versionedRedirect !== versioned . withoutLanguage ) {
566+ return { exists : true , isRedirect : true , redirectTarget : versionedRedirect }
567+ }
568+ if ( pageMap [ versioned . key ] ) {
569+ return { exists : true , isRedirect : false }
570+ }
571+ }
572+
506573 // Check if it's a redirect
507574 if ( redirects [ resolved ] ) {
508575 return {
0 commit comments