Skip to content

Commit 47c8009

Browse files
heiskrCopilot
andauthored
fix: validate carried-over anchors when update-internal-links rewrites a path (#62414)
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 ce82556 commit 47c8009

5 files changed

Lines changed: 463 additions & 43 deletions

File tree

src/links/lib/extract-links.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,20 @@ async function getCachedRenderLiquid(): Promise<RenderLiquidModule> {
350350
return _renderLiquid
351351
}
352352

353+
/**
354+
* Render Liquid templates in content and return the rendered markdown.
355+
*
356+
* Unlike `extractLinksWithLiquid` and `renderAndExtractLinks`, a render failure is NOT
357+
* swallowed: it propagates to the caller. Use this when falling back to the raw, unrendered
358+
* markdown would be worse than no result at all — for example when the rendered headings
359+
* drive a destructive edit, where unrendered `{% data %}` in a heading would silently
360+
* produce the wrong anchor IDs.
361+
*/
362+
export async function renderMarkdownLiquid(content: string, context: Context): Promise<string> {
363+
const renderLiquid = await getCachedRenderLiquid()
364+
return renderLiquid(content, context)
365+
}
366+
353367
/**
354368
* Render Liquid templates in content and extract links
355369
*

src/links/lib/update-internal-links.ts

Lines changed: 149 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { loadUnversionedTree, loadPages, loadPageMap } from '@/frame/lib/page-da
2121
import getRedirect, { splitPathByLanguage } from '@/redirects/lib/get-redirect'
2222
import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version'
2323
import { deprecated } from '@/versions/lib/enterprise-server-releases'
24+
import { RedirectedFragmentValidator } from '@/links/lib/validate-redirected-fragment'
2425

2526
const 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

3840
type 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+
5279
const 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

5990
export 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

591697
function singleStartingQuote(text: string) {

0 commit comments

Comments
 (0)