Skip to content

Commit ce233b2

Browse files
heiskrCopilot
andauthored
Extract shared computeHeadingIds helper for anchor validation (#62401)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7bc51b35-b5bd-4cec-9948-a1664d171f4c
1 parent 74abbe8 commit ce233b2

3 files changed

Lines changed: 186 additions & 106 deletions

File tree

‎src/links/lib/heading-anchors.ts‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import GithubSlugger from 'github-slugger'
2+
3+
/**
4+
* Strip inline Markdown markup from a heading to get plain text for slug computation.
5+
* Matches what hast-util-to-string produces on a heading node after remark parsing.
6+
*
7+
* Key design decisions:
8+
* - Inline code spans (backtick) are extracted verbatim so that `<job_id>` inside them
9+
* is not incorrectly stripped by the HTML-tag regex (which is needed for octicon SVGs).
10+
* - HTML stripping only removes valid HTML element names (no underscores) to avoid stripping
11+
* angle-bracket placeholders like <job_id> that appear in code-span heading text.
12+
* - No final .trim() — trailing whitespace from stripped SVGs becomes trailing hyphens via
13+
* github-slugger, reproducing the live site's heading IDs (e.g. `allow--`).
14+
*/
15+
export function headingTextToPlain(text: string): string {
16+
// Strip HTML tags using a state machine rather than a regex so that CodeQL can verify
17+
// the stripping is complete. Tags like <script\n...> or tags with '>' in attribute values
18+
// are handled correctly. Output is only used for slug computation, never rendered as HTML.
19+
function stripHtmlTags(s: string): string {
20+
let out = ''
21+
let inTag = false
22+
for (let i = 0; i < s.length; i++) {
23+
if (!inTag && s[i] === '<') {
24+
// Peek ahead: if this looks like an underscore-containing placeholder (e.g. <job_id>),
25+
// emit the inner text instead of dropping it entirely so the slug stays correct.
26+
const close = s.indexOf('>', i + 1)
27+
if (close !== -1) {
28+
const inner = s.slice(i + 1, close)
29+
if (/^[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+$/.test(inner)) {
30+
out += inner
31+
i = close
32+
continue
33+
}
34+
}
35+
inTag = true
36+
} else if (inTag && s[i] === '>') {
37+
inTag = false
38+
// Don't emit a replacement space — surrounding whitespace in the source markdown
39+
// already provides the correct spacing for github-slugger (e.g. `allow ` from
40+
// the space before an octicon tag).
41+
} else if (!inTag) {
42+
out += s[i]
43+
}
44+
}
45+
return out
46+
}
47+
48+
// Process non-code portions: strip HTML and inline formatting markup.
49+
function processNonCode(s: string): string {
50+
return stripHtmlTags(s)
51+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images: ![alt](url) → alt
52+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links: [text](url) → text
53+
.replace(/\*\*([^*]+)\*\*/g, '$1') // bold **text**
54+
.replace(/\*([^*]+)\*/g, '$1') // italic *text*
55+
.replace(/(?<![a-zA-Z0-9_])__([^_]+)__(?![a-zA-Z0-9_])/g, '$1') // bold __text__
56+
.replace(/(?<![a-zA-Z0-9_])_([^_]+)_(?![a-zA-Z0-9_])/g, '$1') // italic _text_
57+
}
58+
59+
// Split text into alternating non-code / code-span segments.
60+
// Code spans are extracted verbatim (hast-util-to-string returns their raw text content).
61+
const parts: string[] = []
62+
let remaining = text
63+
while (remaining.length > 0) {
64+
const open = remaining.indexOf('`')
65+
if (open === -1) {
66+
parts.push(processNonCode(remaining))
67+
break
68+
}
69+
if (open > 0) parts.push(processNonCode(remaining.slice(0, open)))
70+
const close = remaining.indexOf('`', open + 1)
71+
if (close === -1) {
72+
// Unclosed backtick — treat remainder as non-code
73+
parts.push(processNonCode(remaining.slice(open)))
74+
break
75+
}
76+
parts.push(remaining.slice(open + 1, close)) // code content verbatim
77+
remaining = remaining.slice(close + 1)
78+
}
79+
return parts.join('')
80+
// Note: no .trim() — see comment above.
81+
}
82+
83+
/**
84+
* Compute the set of heading anchor IDs for a page from its Liquid-rendered markdown.
85+
*
86+
* Uses github-slugger (the same library as rehype-slug in the render pipeline) to compute
87+
* heading anchor IDs in document order, producing results that match the live site,
88+
* including the `-1`, `-2`, ... dedupe suffixes github-slugger adds for repeated headings.
89+
*
90+
* Handles ATX headings (`## Heading`), Setext headings (underlined with `===`/`---`), and
91+
* explicit `<a name="...">` / `<a id="...">` anchors embedded in the markdown.
92+
*/
93+
export function computeHeadingIds(renderedMarkdown: string): Set<string> {
94+
const slugger = new GithubSlugger()
95+
const headingIds = new Set<string>()
96+
97+
// ATX headings: ## Heading text (optional trailing ##)
98+
const ATX_HEADING_RE = /^#{1,6}\s+(.+?)(?:\s+#+)?\s*$/gm
99+
let m: RegExpExecArray | null
100+
while ((m = ATX_HEADING_RE.exec(renderedMarkdown)) !== null) {
101+
headingIds.add(slugger.slug(headingTextToPlain(m[1])))
102+
}
103+
104+
// Setext headings: text line followed by === or --- underline
105+
const SETEXT_HEADING_RE = /^([^\n]+)\n[=-]{2,}\s*$/gm
106+
while ((m = SETEXT_HEADING_RE.exec(renderedMarkdown)) !== null) {
107+
headingIds.add(slugger.slug(headingTextToPlain(m[1])))
108+
}
109+
110+
// Explicit <a name="..."> and <a id="..."> anchors embedded in the markdown.
111+
// Some pages (e.g. site-policy) use raw HTML anchors instead of headings.
112+
const NAMED_ANCHOR_RE = /<a\s[^>]*(?:name|id)="([^"]+)"[^>]*>/gi
113+
while ((m = NAMED_ANCHOR_RE.exec(renderedMarkdown)) !== null) {
114+
headingIds.add(m[1])
115+
}
116+
117+
return headingIds
118+
}

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

Lines changed: 2 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ import os from 'os'
2424
import { program } from 'commander'
2525
import chalk from 'chalk'
2626

27-
import GithubSlugger from 'github-slugger'
28-
2927
import warmServer from '@/frame/lib/warm-server'
3028
import { allVersions, allVersionKeys } from '@/versions/lib/all-versions'
3129
import languages from '@/languages/lib/languages-server'
@@ -48,6 +46,7 @@ import { uploadArtifact } from '@/links/scripts/upload-artifact'
4846
import { createReportIssue, linkReports } from '@/workflows/issue-report'
4947
import github from '@/workflows/github'
5048
import excludedLinks from '@/links/lib/excluded-links'
49+
import { computeHeadingIds } from '@/links/lib/heading-anchors'
5150
import { getFeaturesByVersion } from '@/versions/middleware/features'
5251
import type { Page, Permalink, Context } from '@/types'
5352
import * as coreLib from '@actions/core'
@@ -201,86 +200,6 @@ async function getLinksFromMarkdown(
201200
})
202201
}
203202

204-
/**
205-
* Strip inline Markdown markup from a heading to get plain text for slug computation.
206-
* Matches what hast-util-to-string produces on a heading node after remark parsing.
207-
*
208-
* Key design decisions:
209-
* - Inline code spans (backtick) are extracted verbatim so that `<job_id>` inside them
210-
* is not incorrectly stripped by the HTML-tag regex (which is needed for octicon SVGs).
211-
* - HTML stripping only removes valid HTML element names (no underscores) to avoid stripping
212-
* angle-bracket placeholders like <job_id> that appear in code-span heading text.
213-
* - No final .trim() — trailing whitespace from stripped SVGs becomes trailing hyphens via
214-
* github-slugger, reproducing the live site's heading IDs (e.g. `allow--`).
215-
*/
216-
function headingTextToPlain(text: string): string {
217-
// Strip HTML tags using a state machine rather than a regex so that CodeQL can verify
218-
// the stripping is complete. Tags like <script\n...> or tags with '>' in attribute values
219-
// are handled correctly. Output is only used for slug computation, never rendered as HTML.
220-
function stripHtmlTags(s: string): string {
221-
let out = ''
222-
let inTag = false
223-
for (let i = 0; i < s.length; i++) {
224-
if (!inTag && s[i] === '<') {
225-
// Peek ahead: if this looks like an underscore-containing placeholder (e.g. <job_id>),
226-
// emit the inner text instead of dropping it entirely so the slug stays correct.
227-
const close = s.indexOf('>', i + 1)
228-
if (close !== -1) {
229-
const inner = s.slice(i + 1, close)
230-
if (/^[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+$/.test(inner)) {
231-
out += inner
232-
i = close
233-
continue
234-
}
235-
}
236-
inTag = true
237-
} else if (inTag && s[i] === '>') {
238-
inTag = false
239-
// Don't emit a replacement space — surrounding whitespace in the source markdown
240-
// already provides the correct spacing for github-slugger (e.g. `allow ` from
241-
// the space before an octicon tag).
242-
} else if (!inTag) {
243-
out += s[i]
244-
}
245-
}
246-
return out
247-
}
248-
249-
// Process non-code portions: strip HTML and inline formatting markup.
250-
function processNonCode(s: string): string {
251-
return stripHtmlTags(s)
252-
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images: ![alt](url) → alt
253-
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links: [text](url) → text
254-
.replace(/\*\*([^*]+)\*\*/g, '$1') // bold **text**
255-
.replace(/\*([^*]+)\*/g, '$1') // italic *text*
256-
.replace(/(?<![a-zA-Z0-9_])__([^_]+)__(?![a-zA-Z0-9_])/g, '$1') // bold __text__
257-
.replace(/(?<![a-zA-Z0-9_])_([^_]+)_(?![a-zA-Z0-9_])/g, '$1') // italic _text_
258-
}
259-
260-
// Split text into alternating non-code / code-span segments.
261-
// Code spans are extracted verbatim (hast-util-to-string returns their raw text content).
262-
const parts: string[] = []
263-
let remaining = text
264-
while (remaining.length > 0) {
265-
const open = remaining.indexOf('`')
266-
if (open === -1) {
267-
parts.push(processNonCode(remaining))
268-
break
269-
}
270-
if (open > 0) parts.push(processNonCode(remaining.slice(0, open)))
271-
const close = remaining.indexOf('`', open + 1)
272-
if (close === -1) {
273-
// Unclosed backtick — treat remainder as non-code
274-
parts.push(processNonCode(remaining.slice(open)))
275-
break
276-
}
277-
parts.push(remaining.slice(open + 1, close)) // code content verbatim
278-
remaining = remaining.slice(close + 1)
279-
}
280-
return parts.join('')
281-
// Note: no .trim() — see comment above.
282-
}
283-
284203
/**
285204
* Check anchor links on a page using fast heading ID computation from Liquid-rendered
286205
* markdown. Avoids the expensive full HTML render previously used.
@@ -299,30 +218,7 @@ function checkAnchorsFromHeadings(
299218
const fmOffset = getFrontmatterLineOffset(page.fullPath)
300219

301220
// Compute heading anchor IDs from the Liquid-rendered markdown in document order.
302-
// github-slugger deduplicates identical headings with -1, -2, ... suffixes,
303-
// matching the behaviour of rehype-slug in the full render pipeline.
304-
const slugger = new GithubSlugger()
305-
const headingIds = new Set<string>()
306-
307-
// ATX headings: ## Heading text (optional trailing ##)
308-
const ATX_HEADING_RE = /^#{1,6}\s+(.+?)(?:\s+#+)?\s*$/gm
309-
let m: RegExpExecArray | null
310-
while ((m = ATX_HEADING_RE.exec(renderedMarkdown)) !== null) {
311-
headingIds.add(slugger.slug(headingTextToPlain(m[1])))
312-
}
313-
314-
// Setext headings: text line followed by === or --- underline
315-
const SETEXT_HEADING_RE = /^([^\n]+)\n[=-]{2,}\s*$/gm
316-
while ((m = SETEXT_HEADING_RE.exec(renderedMarkdown)) !== null) {
317-
headingIds.add(slugger.slug(headingTextToPlain(m[1])))
318-
}
319-
320-
// Explicit <a name="..."> and <a id="..."> anchors embedded in the markdown.
321-
// Some pages (e.g. site-policy) use raw HTML anchors instead of headings.
322-
const NAMED_ANCHOR_RE = /<a\s[^>]*(?:name|id)="([^"]+)"[^>]*>/gi
323-
while ((m = NAMED_ANCHOR_RE.exec(renderedMarkdown)) !== null) {
324-
headingIds.add(m[1])
325-
}
221+
const headingIds = computeHeadingIds(renderedMarkdown)
326222

327223
// Build line-number map from the raw (pre-Liquid) source for accurate file line numbers.
328224
const anchorLineMap = new Map<string, number>()

‎src/links/tests/heading-anchors.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { computeHeadingIds, headingTextToPlain } from '../lib/heading-anchors'
4+
5+
describe('headingTextToPlain', () => {
6+
test('leaves plain text unchanged', () => {
7+
expect(headingTextToPlain('Configuring proxy settings')).toBe('Configuring proxy settings')
8+
})
9+
10+
test('strips inline markdown formatting', () => {
11+
expect(headingTextToPlain('**Bold** and *italic* text')).toBe('Bold and italic text')
12+
expect(headingTextToPlain('__Bold__ and _italic_ text')).toBe('Bold and italic text')
13+
})
14+
15+
test('extracts link and image text', () => {
16+
expect(headingTextToPlain('See [the guide](/actions) here')).toBe('See the guide here')
17+
expect(headingTextToPlain('![alt text](/img.png) caption')).toBe('alt text caption')
18+
})
19+
20+
test('preserves inline code spans verbatim, including angle-bracket placeholders', () => {
21+
expect(headingTextToPlain('Use `<job_id>` in your workflow')).toBe(
22+
'Use <job_id> in your workflow',
23+
)
24+
})
25+
26+
test('strips HTML/octicon tags but keeps underscore placeholders outside code', () => {
27+
expect(headingTextToPlain('Allow <svg class="octicon"></svg>')).toBe('Allow ')
28+
expect(headingTextToPlain('Set <job_id> here')).toBe('Set job_id here')
29+
})
30+
})
31+
32+
describe('computeHeadingIds', () => {
33+
test('slugs ATX headings', () => {
34+
const ids = computeHeadingIds(
35+
'## Configuring proxy settings for Copilot\n\ntext\n\n### Usage limits and policy',
36+
)
37+
expect(ids.has('configuring-proxy-settings-for-copilot')).toBe(true)
38+
expect(ids.has('usage-limits-and-policy')).toBe(true)
39+
})
40+
41+
test('slugs Setext headings', () => {
42+
const ids = computeHeadingIds('Introduction\n============\n\nDetails\n-------')
43+
expect(ids.has('introduction')).toBe(true)
44+
expect(ids.has('details')).toBe(true)
45+
})
46+
47+
test('dedupes repeated headings with github-slugger suffixes', () => {
48+
const ids = computeHeadingIds('## Overview\n\n## Overview')
49+
expect(ids.has('overview')).toBe(true)
50+
expect(ids.has('overview-1')).toBe(true)
51+
})
52+
53+
test('captures explicit <a name>/<a id> anchors', () => {
54+
const ids = computeHeadingIds('<a name="legacy-anchor"></a>\n\n<a id="another-anchor"></a>')
55+
expect(ids.has('legacy-anchor')).toBe(true)
56+
expect(ids.has('another-anchor')).toBe(true)
57+
})
58+
59+
test('reproduces Liquid-rendered heading with variable already expanded', () => {
60+
// After Liquid render, the variable is expanded to plain text; the full slug includes it.
61+
const ids = computeHeadingIds(
62+
'## Disabling or enabling Copilot coding agent in your repositories',
63+
)
64+
expect(ids.has('disabling-or-enabling-copilot-coding-agent-in-your-repositories')).toBe(true)
65+
})
66+
})

0 commit comments

Comments
 (0)