@@ -24,8 +24,6 @@ import os from 'os'
2424import { program } from 'commander'
2525import chalk from 'chalk'
2626
27- import GithubSlugger from 'github-slugger'
28-
2927import warmServer from '@/frame/lib/warm-server'
3028import { allVersions , allVersionKeys } from '@/versions/lib/all-versions'
3129import languages from '@/languages/lib/languages-server'
@@ -48,6 +46,7 @@ import { uploadArtifact } from '@/links/scripts/upload-artifact'
4846import { createReportIssue , linkReports } from '@/workflows/issue-report'
4947import github from '@/workflows/github'
5048import excludedLinks from '@/links/lib/excluded-links'
49+ import { computeHeadingIds } from '@/links/lib/heading-anchors'
5150import { getFeaturesByVersion } from '@/versions/middleware/features'
5251import type { Page , Permalink , Context } from '@/types'
5352import * 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 - z A - Z ] [ a - z A - Z 0 - 9 ] * (?: _ [ a - z A - Z 0 - 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
253- . replace ( / \[ ( [ ^ \] ] * ) \] \( [ ^ ) ] * \) / g, '$1' ) // links: [text](url) → text
254- . replace ( / \* \* ( [ ^ * ] + ) \* \* / g, '$1' ) // bold **text**
255- . replace ( / \* ( [ ^ * ] + ) \* / g, '$1' ) // italic *text*
256- . replace ( / (?< ! [ a - z A - Z 0 - 9 _ ] ) _ _ ( [ ^ _ ] + ) _ _ (? ! [ a - z A - Z 0 - 9 _ ] ) / g, '$1' ) // bold __text__
257- . replace ( / (?< ! [ a - z A - Z 0 - 9 _ ] ) _ ( [ ^ _ ] + ) _ (? ! [ a - z A - Z 0 - 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 [ ^ > ] * (?: n a m e | i d ) = " ( [ ^ " ] + ) " [ ^ > ] * > / 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 > ( )
0 commit comments