@@ -12,6 +12,8 @@ import { createLogger } from '@/observability/logger'
1212import { allVersions } from '@/versions/lib/all-versions'
1313import { latestStable } from '@/versions/lib/enterprise-server-releases'
1414import { getDataByLanguage } from '@/data-directory/lib/get-data'
15+ import getRedirect from '@/redirects/lib/get-redirect'
16+ import { isArchivedVersionByPath } from '@/archives/lib/is-archived-version'
1517import type { Context , Page } from '@/types'
1618
1719const logger = createLogger ( import . meta. url )
@@ -447,8 +449,10 @@ export function checkInternalLink(
447449 }
448450 }
449451
450- // Strip language prefix and check redirects (which are stored without it)
451- const langPrefixMatch = resolved . match ( / ^ \/ [ a - z ] { 2 } \/ / )
452+ // Strip language prefix and check redirects (which are stored without it).
453+ // Match hyphenated locales too (e.g. /pt-br/, /zh-cn/) so we don't later
454+ // double-prefix them with /en.
455+ const langPrefixMatch = resolved . match ( / ^ \/ [ a - z ] { 2 } ( - [ a - z ] { 2 } ) ? \/ / )
452456 if ( langPrefixMatch ) {
453457 const withoutLang = resolved . slice ( langPrefixMatch [ 0 ] . length - 1 )
454458 if ( redirects [ withoutLang ] ) {
@@ -460,6 +464,49 @@ export function checkInternalLink(
460464 }
461465 }
462466
467+ // The path in language-prefixed form, used by the runtime resolvers below.
468+ // Avoid double-prefixing when the link already carried a language code.
469+ const withEn = langPrefixMatch ? resolved : withLang
470+
471+ // Links into deprecated/archived Enterprise Server versions (e.g.
472+ // /enterprise-server@3.7/... or the legacy /enterprise/2.1/... format) are
473+ // served by the archived enterprise versions system, which isn't loaded into
474+ // pageMap. They resolve fine at runtime, so treat them as valid rather than
475+ // broken.
476+ if ( isArchivedVersionByPath ( withEn ) . isArchived ) {
477+ return { exists : true , isRedirect : false }
478+ }
479+
480+ // Fall back to the runtime redirect resolver. It handles algorithmic
481+ // corrections (version-prefix normalization, /admin, /desktop/guides, etc.)
482+ // that the flat redirects map doesn't contain as literal keys. This mirrors
483+ // what the production server actually does, so a link that redirects in
484+ // production is reported as a redirect here instead of a false broken link.
485+ try {
486+ // Only redirects, userLanguage, and pages are read by getRedirect (and the
487+ // resolvers it delegates to), so type the object to those fields rather than
488+ // casting an arbitrary shape to the full Context.
489+ const context : Pick < Context , 'redirects' | 'userLanguage' | 'pages' > = {
490+ redirects,
491+ userLanguage : 'en' ,
492+ pages : pageMap ,
493+ }
494+ const redirect = getRedirect ( withEn , context as unknown as Context )
495+ if ( redirect ) {
496+ // getRedirect returns a language-prefixed path (e.g. /en/...); strip any
497+ // locale prefix to match the format used by the flat-map branches above,
498+ // and normalize a bare language root (e.g. /en) to /.
499+ return {
500+ exists : true ,
501+ isRedirect : true ,
502+ redirectTarget : redirect . replace ( / ^ \/ [ a - z ] { 2 } ( - [ a - z ] { 2 } ) ? (? = \/ | $ ) / , '' ) || '/' ,
503+ }
504+ }
505+ } catch {
506+ // getRedirect throws on a few fully-deprecated shapes (e.g. github-ae).
507+ // Treat those as unresolvable rather than crashing the whole check.
508+ }
509+
463510 return { exists : false , isRedirect : false }
464511}
465512
0 commit comments