@@ -30,6 +30,7 @@ import languages from '@/languages/lib/languages-server'
3030import {
3131 normalizeLinkPath ,
3232 checkInternalLink ,
33+ resolveInternalLinkKey ,
3334 checkAssetLink ,
3435 isAssetLink ,
3536 extractLinksWithLiquid ,
@@ -46,6 +47,10 @@ import { uploadArtifact } from '@/links/scripts/upload-artifact'
4647import { createReportIssue , linkReports } from '@/workflows/issue-report'
4748import github from '@/workflows/github'
4849import excludedLinks from '@/links/lib/excluded-links'
50+ import {
51+ validateCrossPageAnchors ,
52+ type PendingCrossPageAnchor ,
53+ } from '@/links/lib/cross-page-anchors'
4954import { computeHeadingIds } from '@/links/lib/heading-anchors'
5055import { getFeaturesByVersion } from '@/versions/middleware/features'
5156import type { Page , Permalink , Context } from '@/types'
@@ -119,7 +124,7 @@ async function getLinksFromMarkdown(
119124 context : Context ,
120125 precomputedRawResult ?: LinkExtractionResult ,
121126 prerenderedResult ?: LinkExtractionResult ,
122- ) : Promise < { href : string ; text : string | undefined ; line : number } [ ] > {
127+ ) : Promise < { href : string ; text : string | undefined ; line : number ; fragment ?: string } [ ] > {
123128 const fmOffset = getFrontmatterLineOffset ( page . fullPath )
124129
125130 // Build a map of raw-markdown line numbers per href, plus a parallel index
@@ -189,14 +194,18 @@ async function getLinksFromMarkdown(
189194 // extractLinksWithLiquid already catches Liquid render failures internally and
190195 // falls back to raw extraction with a warning, so no outer try/catch is needed.
191196 const renderedResult = prerenderedResult ?? ( await extractLinksWithLiquid ( page . markdown , context ) )
192- const renderedLinks = renderedResult . internalLinks . map ( ( l ) => ( { href : l . href , text : l . text } ) )
197+ const renderedLinks = renderedResult . internalLinks . map ( ( l ) => ( {
198+ href : l . href ,
199+ text : l . text ,
200+ fragment : l . fragment ,
201+ } ) )
193202
194203 return renderedLinks . map ( ( link ) => {
195204 const lines = rawLinesByHref . get ( link . href )
196205 const idx = rawLinesIndex . get ( link . href ) ?? 0
197206 const line = lines && idx < lines . length ? lines [ idx ] : 0
198207 rawLinesIndex . set ( link . href , idx + 1 )
199- return { href : link . href , text : link . text , line }
208+ return { href : link . href , text : link . text , line, fragment : link . fragment }
200209 } )
201210}
202211
@@ -206,20 +215,18 @@ async function getLinksFromMarkdown(
206215 *
207216 * Uses github-slugger (the same library as rehype-slug in the render pipeline) to compute
208217 * heading anchor IDs, producing results that match the live site.
218+ *
219+ * `headingIds` is precomputed once per page in checkPage and shared with the cross-page
220+ * anchor cache, so this function only checks same-page (`#fragment`) links here.
209221 */
210222function checkAnchorsFromHeadings (
211223 page : Page ,
212224 rawResult : LinkExtractionResult ,
213225 renderedResult : LinkExtractionResult ,
214- renderedMarkdown : string ,
226+ headingIds : Set < string > ,
215227) : BrokenLink [ ] {
216- if ( page . autogenerated ) return [ ]
217-
218228 const fmOffset = getFrontmatterLineOffset ( page . fullPath )
219229
220- // Compute heading anchor IDs from the Liquid-rendered markdown in document order.
221- const headingIds = computeHeadingIds ( renderedMarkdown )
222-
223230 // Build line-number map from the raw (pre-Liquid) source for accurate file line numbers.
224231 const anchorLineMap = new Map < string , number > ( )
225232 for ( const link of rawResult . anchorLinks ) {
@@ -259,9 +266,16 @@ async function checkPage(
259266 pageMap : Record < string , Page > ,
260267 redirects : Record < string , string > ,
261268 options : { checkAnchors : boolean } ,
262- ) : Promise < { brokenLinks : BrokenLink [ ] ; redirectLinks : BrokenLink [ ] ; linksChecked : number } > {
269+ ) : Promise < {
270+ brokenLinks : BrokenLink [ ]
271+ redirectLinks : BrokenLink [ ]
272+ linksChecked : number
273+ headingIds : Set < string > | null
274+ crossPageAnchors : PendingCrossPageAnchor [ ]
275+ } > {
263276 const brokenLinks : BrokenLink [ ] = [ ]
264277 const redirectLinks : BrokenLink [ ] = [ ]
278+ const crossPageAnchors : PendingCrossPageAnchor [ ] = [ ]
265279
266280 const rawMarkdownLinks = extractLinksFromMarkdown ( page . markdown )
267281
@@ -272,6 +286,15 @@ async function checkPage(
272286 pageContext ,
273287 )
274288
289+ // Compute this page's heading anchor IDs once from the Liquid-rendered markdown.
290+ // Autogenerated pages (REST/GraphQL/webhooks) derive their anchors from OpenAPI
291+ // operation IDs, not markdown headings, so we can't compute them here — leave them
292+ // out of the cache so links into them are never flagged (they resolve at runtime).
293+ // Skip the work entirely when anchor checking is disabled: nothing downstream reads
294+ // the heading cache in that mode.
295+ const headingIds =
296+ options . checkAnchors && ! page . autogenerated ? computeHeadingIds ( renderedMarkdown ) : null
297+
275298 const links = await getLinksFromMarkdown ( page , pageContext , rawMarkdownLinks , renderedLinkResult )
276299
277300 for ( const link of links ) {
@@ -309,20 +332,35 @@ async function checkPage(
309332 isRedirect : true ,
310333 redirectTarget : result . redirectTarget ,
311334 } )
335+ } else if ( options . checkAnchors && link . fragment ) {
336+ // Direct (non-redirect) hit with a fragment: defer a cross-page anchor check.
337+ // We can't validate it now because the target page may not have been rendered
338+ // yet, so collect it and validate after the whole version finishes.
339+ const targetKey = resolveInternalLinkKey ( link . href , pageMap )
340+ if ( targetKey ) {
341+ crossPageAnchors . push ( {
342+ targetKey,
343+ fragment : link . fragment ,
344+ href : `${ link . href } #${ link . fragment } ` ,
345+ file : page . relativePath ,
346+ line : link . line ,
347+ text : link . text ,
348+ } )
349+ }
312350 }
313351 }
314352
315- if ( options . checkAnchors ) {
353+ if ( options . checkAnchors && headingIds ) {
316354 const anchorFlaws = checkAnchorsFromHeadings (
317355 page ,
318356 rawMarkdownLinks ,
319357 renderedLinkResult ,
320- renderedMarkdown ,
358+ headingIds ,
321359 )
322360 brokenLinks . push ( ...anchorFlaws )
323361 }
324362
325- return { brokenLinks, redirectLinks, linksChecked : links . length }
363+ return { brokenLinks, redirectLinks, linksChecked : links . length , headingIds , crossPageAnchors }
326364}
327365
328366/**
@@ -370,6 +408,17 @@ async function checkVersion(
370408 let totalPagesChecked = 0
371409 let totalLinksChecked = 0
372410
411+ // Cross-page anchor validation is a two-pass process within the version:
412+ // pass 1 — render every page, caching its heading IDs and collecting the
413+ // cross-page anchor links it contains (target may not be rendered yet)
414+ // pass 2 — after all pages are rendered, validate each collected anchor against
415+ // the now-complete heading cache
416+ // The cache is keyed by pageMap key (lang + version + path). A link whose target
417+ // resolves to a different version isn't in this run's cache and is skipped here;
418+ // it's validated when the workflow runs the checker for that target version.
419+ const headingIdsByPageKey = new Map < string , Set < string > > ( )
420+ const pendingCrossPageAnchors : PendingCrossPageAnchor [ ] = [ ]
421+
373422 // Bounded concurrency: process up to `options.concurrency` pages simultaneously.
374423 // All workers drain from the same shared iterator — no page is processed twice.
375424 const queue = relevantPages . entries ( )
@@ -389,6 +438,10 @@ async function checkVersion(
389438 // between await points cannot interleave with another worker's pushes.
390439 allBrokenLinks . push ( ...result . brokenLinks )
391440 allRedirectLinks . push ( ...result . redirectLinks )
441+ if ( result . headingIds ) headingIdsByPageKey . set ( permalink . href , result . headingIds )
442+ if ( result . crossPageAnchors . length ) {
443+ pendingCrossPageAnchors . push ( ...result . crossPageAnchors )
444+ }
392445 totalPagesChecked ++
393446 totalLinksChecked += result . linksChecked
394447
@@ -401,6 +454,11 @@ async function checkVersion(
401454 // Launch `concurrency` workers that all drain from the same shared queue iterator.
402455 await Promise . all ( Array . from ( { length : options . concurrency } , worker ) )
403456
457+ // Pass 2: validate cross-page anchors now that every page's headings are cached.
458+ if ( options . checkAnchors ) {
459+ allBrokenLinks . push ( ...validateCrossPageAnchors ( pendingCrossPageAnchors , headingIdsByPageKey ) )
460+ }
461+
404462 return {
405463 brokenLinks : allBrokenLinks ,
406464 redirectLinks : allRedirectLinks ,
0 commit comments