Skip to content

Commit ce82556

Browse files
heiskrCopilot
andauthored
Validate cross-page anchor links in the internal link checker (#62411)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7bc51b35-b5bd-4cec-9948-a1664d171f4c Copilot-Session: 3993852f-89f9-401f-a2f8-2753a3d0ad43
1 parent cc38d42 commit ce82556

5 files changed

Lines changed: 282 additions & 16 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { BrokenLink } from '@/links/lib/link-report'
2+
3+
/**
4+
* A cross-page anchor link (`/some/path#fragment`) collected during pass 1 of the
5+
* internal link checker, to be validated in pass 2 once every page's heading IDs are known.
6+
*/
7+
export interface PendingCrossPageAnchor {
8+
targetKey: string
9+
fragment: string
10+
href: string
11+
file: string
12+
line: number
13+
text?: string
14+
}
15+
16+
/**
17+
* Pass 2 of cross-page anchor validation: given the collected cross-page anchor links
18+
* and the fully-populated per-page heading ID cache, return the links whose fragment is
19+
* missing from the target page.
20+
*
21+
* A target with no cache entry is skipped, not flagged. That happens when the target
22+
* resolves to a version not covered by the current run, or the target is an autogenerated
23+
* page whose anchors resolve at runtime. Note the scheduled matrix does not run every
24+
* version, so an anchor whose target only resolves to an uncovered version is currently
25+
* not validated anywhere: closing that gap is follow-up guardrail work. Kept as a pure
26+
* function so the two-pass behavior can be tested without rendering the whole site.
27+
*
28+
* `#top` is exempt, mirroring the same-page anchor check. Per the HTML spec a browser
29+
* scrolls to the top of the document for `#top` when nothing carries that ID, so it is
30+
* always valid and never appears in a page's computed heading IDs.
31+
*/
32+
export function validateCrossPageAnchors(
33+
pendingCrossPageAnchors: PendingCrossPageAnchor[],
34+
headingIdsByPageKey: Map<string, Set<string>>,
35+
): BrokenLink[] {
36+
const broken: BrokenLink[] = []
37+
for (const anchor of pendingCrossPageAnchors) {
38+
if (anchor.fragment === 'top') continue
39+
const targetHeadingIds = headingIdsByPageKey.get(anchor.targetKey)
40+
if (!targetHeadingIds) continue
41+
if (!targetHeadingIds.has(anchor.fragment)) {
42+
broken.push({
43+
href: anchor.href,
44+
file: anchor.file,
45+
lines: [anchor.line],
46+
text: anchor.text,
47+
})
48+
}
49+
}
50+
return broken
51+
}

src/links/lib/extract-links.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ export interface ExtractedLink {
4646
isAutotitle?: boolean
4747
isImage?: boolean
4848
isAnchor?: boolean
49+
/**
50+
* The URL fragment (the part after `#`) for internal links that carry one, e.g.
51+
* `some-heading` for `/foo/bar#some-heading`. `href` keeps the fragment stripped
52+
* (so path resolution is unaffected); this field preserves it for cross-page anchor
53+
* validation. Undefined when the link has no fragment.
54+
*/
55+
fragment?: string
4956
}
5057

5158
export interface LinkExtractionResult {
@@ -126,6 +133,17 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult
126133
const imageLinks: ExtractedLink[] = []
127134
const liquidPrefixedLinks: ExtractedLink[] = []
128135

136+
// Split an internal link destination into its path and optional fragment.
137+
// href keeps the fragment stripped (so path resolution is unaffected); the
138+
// fragment is returned separately for cross-page anchor validation. An empty
139+
// fragment (a trailing bare `#`) is treated as no fragment.
140+
const splitFragment = (raw: string): { href: string; fragment?: string } => {
141+
const hashIndex = raw.indexOf('#')
142+
if (hashIndex === -1) return { href: raw }
143+
const fragment = raw.slice(hashIndex + 1)
144+
return { href: raw.slice(0, hashIndex), fragment: fragment.length ? fragment : undefined }
145+
}
146+
129147
// Strip fenced code blocks to avoid checking example/placeholder URLs
130148
// Replaces non-newline characters with spaces to preserve line numbers and positions
131149
const withoutFences = content.replace(
@@ -156,14 +174,15 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult
156174
let match
157175
while ((match = AUTOTITLE_LINK_PATTERN.exec(strippedContent)) !== null) {
158176
const { line, column } = getLineAndColumn(lineOffsets, match.index)
159-
const href = match[1].split('#')[0] // Remove anchor if present
177+
const { href, fragment } = splitFragment(match[1]) // Split off anchor if present
160178
if (href.startsWith('/')) {
161179
internalLinks.push({
162180
href,
163181
line,
164182
column,
165183
text: 'AUTOTITLE',
166184
isAutotitle: true,
185+
fragment,
167186
})
168187
}
169188
}
@@ -181,7 +200,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult
181200
const { line, column } = getLineAndColumn(lineOffsets, match.index)
182201
// Extract href from ](/path) format. The destination is captured in group 1,
183202
// which handles balanced parentheses (e.g. asset filenames like `(fr).pdf`).
184-
const href = match[1].split('#')[0]
203+
const { href, fragment } = splitFragment(match[1])
185204
const text = extractLinkText(strippedContent, match.index)
186205

187206
internalLinks.push({
@@ -190,6 +209,7 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult
190209
column,
191210
text,
192211
isAutotitle: false,
212+
fragment,
193213
})
194214
}
195215

@@ -252,12 +272,13 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult
252272
// These are distinct from inline links but point to the same targets that need validating.
253273
while ((match = LINK_DEFINITION_PATTERN.exec(strippedContent)) !== null) {
254274
const { line, column } = getLineAndColumn(lineOffsets, match.index)
255-
const href = match[1].split('#')[0]
275+
const { href, fragment } = splitFragment(match[1])
256276
internalLinks.push({
257277
href,
258278
line,
259279
column,
260280
isAutotitle: false,
281+
fragment,
261282
})
262283
}
263284

@@ -416,6 +437,34 @@ export function normalizeLinkPath(href: string): string {
416437
return normalized
417438
}
418439

440+
/**
441+
* Resolve an internal link href to the exact pageMap key of the page it lands on,
442+
* but ONLY for direct (non-redirect) hits. Returns null when the link doesn't
443+
* resolve directly to a page (redirect, archived version, or broken).
444+
*
445+
* This mirrors the two direct-hit branches of `checkInternalLink` (a bare path or a
446+
* language-prefixed path). It's used by the cross-page anchor checker to look up the
447+
* target page's precomputed heading IDs. Redirects are intentionally excluded: the
448+
* link is already reported as a redirect-to-update, and its final anchor is ambiguous.
449+
*/
450+
export function resolveInternalLinkKey(href: string, pageMap: Record<string, Page>): string | null {
451+
const normalized = normalizeLinkPath(href)
452+
453+
const latestPrefix = '/enterprise-server@latest'
454+
const stablePrefix = `/enterprise-server@${latestStable}`
455+
const resolved =
456+
normalized.startsWith(latestPrefix) || normalized.startsWith(`/en${latestPrefix}`)
457+
? normalized.replace(latestPrefix, stablePrefix)
458+
: normalized
459+
460+
if (pageMap[resolved]) return resolved
461+
462+
const withLang = `/en${resolved}`
463+
if (pageMap[withLang]) return withLang
464+
465+
return null
466+
}
467+
419468
/**
420469
* Check if a path exists in the pageMap or redirects
421470
*/

src/links/scripts/check-links-internal.ts

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import languages from '@/languages/lib/languages-server'
3030
import {
3131
normalizeLinkPath,
3232
checkInternalLink,
33+
resolveInternalLinkKey,
3334
checkAssetLink,
3435
isAssetLink,
3536
extractLinksWithLiquid,
@@ -46,6 +47,10 @@ import { uploadArtifact } from '@/links/scripts/upload-artifact'
4647
import { createReportIssue, linkReports } from '@/workflows/issue-report'
4748
import github from '@/workflows/github'
4849
import excludedLinks from '@/links/lib/excluded-links'
50+
import {
51+
validateCrossPageAnchors,
52+
type PendingCrossPageAnchor,
53+
} from '@/links/lib/cross-page-anchors'
4954
import { computeHeadingIds } from '@/links/lib/heading-anchors'
5055
import { getFeaturesByVersion } from '@/versions/middleware/features'
5156
import 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
*/
210222
function 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

Comments
 (0)