@@ -758,6 +758,80 @@ function slugifyHeading(text: string): string {
758758 . replace ( / [ ^ a - z 0 - 9 - ] / g, "" ) ;
759759}
760760
761+ interface RenderedFile {
762+ slug : string ;
763+ content : string ;
764+ }
765+
766+ // Post-render guard: every intra-page (`#anchor`) and cross-file
767+ // (`./slug.md#anchor`) markdown link must point at a heading that
768+ // actually exists. Heading ids are derived with `slugifyHeading`, so
769+ // they match the ids Starlight emits. This catches stale or mistyped
770+ // anchors — e.g. JSDoc links written against an older anchor scheme —
771+ // before they ship as dead links. Fails the build on any miss,
772+ // consistent with the unmapped-symbol gate in `main()`. External
773+ // (`http(s):`/`mailto:`) links and non-`.md` relative paths are out of
774+ // scope and skipped.
775+ function validateAnchors ( files : RenderedFile [ ] ) : void {
776+ // Strip fenced code blocks first: a ```ts``` declaration is not a
777+ // heading, and signatures must never be scanned for links.
778+ const stripFences = ( content : string ) : string =>
779+ content . replace ( / ` ` ` [ \s \S ] * ?` ` ` / g, "" ) ;
780+
781+ const anchorsBySlug = new Map < string , Set < string > > ( ) ;
782+ for ( const { slug, content } of files ) {
783+ const ids = new Set < string > ( ) ;
784+ for ( const m of stripFences ( content ) . matchAll ( / ^ # { 1 , 6 } \s + ( .+ ?) \s * $ / gm) ) {
785+ ids . add ( slugifyHeading ( m [ 1 ] ) ) ;
786+ }
787+ anchorsBySlug . set ( slug , ids ) ;
788+ }
789+
790+ const problems : string [ ] = [ ] ;
791+ for ( const { slug, content } of files ) {
792+ for ( const m of stripFences ( content ) . matchAll ( / \[ [ ^ \] ] * \] \( ( [ ^ ) ] + ) \) / g) ) {
793+ const url = m [ 1 ] . trim ( ) ;
794+ if ( / ^ ( h t t p s ? : | m a i l t o : ) / . test ( url ) ) continue ;
795+
796+ if ( url . startsWith ( "#" ) ) {
797+ const anchor = url . slice ( 1 ) ;
798+ if ( anchorsBySlug . get ( slug ) ?. has ( anchor ) !== true ) {
799+ problems . push (
800+ `${ slug } .md: ${ m [ 0 ] } → no heading with id "#${ anchor } " on this page` ,
801+ ) ;
802+ }
803+ continue ;
804+ }
805+
806+ const cross = url . match ( / ^ \. ? \/ ? ( [ \w - ] + ) \. m d (?: # ( .+ ) ) ? $ / ) ;
807+ if ( cross !== null ) {
808+ const targetSlug = cross [ 1 ] ;
809+ const targetAnchor = cross [ 2 ] ;
810+ const targetIds = anchorsBySlug . get ( targetSlug ) ;
811+ if ( targetIds === undefined ) {
812+ problems . push (
813+ `${ slug } .md: ${ m [ 0 ] } → links to unknown file "${ targetSlug } .md"` ,
814+ ) ;
815+ } else if ( targetAnchor !== undefined && ! targetIds . has ( targetAnchor ) ) {
816+ problems . push (
817+ `${ slug } .md: ${ m [ 0 ] } → no heading with id "#${ targetAnchor } " in ${ targetSlug } .md` ,
818+ ) ;
819+ }
820+ }
821+ }
822+ }
823+
824+ if ( problems . length > 0 ) {
825+ console . error (
826+ `[error] ${ problems . length } dead anchor link(s) in generated reference docs:` ,
827+ ) ;
828+ for ( const p of problems ) {
829+ console . error ( ` [dead-anchor] ${ p } ` ) ;
830+ }
831+ process . exit ( 1 ) ;
832+ }
833+ }
834+
761835interface ResolvedLink {
762836 bucket : BucketName ;
763837 slug : string ;
@@ -1254,6 +1328,7 @@ function main(): void {
12541328 currentLinkResolver = buildLinkResolver ( collected ) ;
12551329
12561330 mkdirSync ( OUTPUT_DIR , { recursive : true } ) ;
1331+ const rendered : RenderedFile [ ] = [ ] ;
12571332 let totalSymbols = 0 ;
12581333 for ( const [ bucket , slug ] of Object . entries ( BUCKET_TO_SLUG ) as [
12591334 BucketName ,
@@ -1264,10 +1339,18 @@ function main(): void {
12641339 throw new Error ( `Internal: bucket "${ bucket } " not pre-populated` ) ;
12651340 }
12661341 const content = renderFile ( bucket , list , DOCS_SOURCE_REF ) ;
1267- writeFileSync ( join ( OUTPUT_DIR , ` ${ slug } .md` ) , content , "utf8" ) ;
1342+ rendered . push ( { slug, content } ) ;
12681343 console . log ( ` ${ slug } .md: ${ list . length } symbol(s)` ) ;
12691344 totalSymbols += list . length ;
12701345 }
1346+
1347+ // Guard before writing: a dead intra-page or cross-file anchor must
1348+ // fail the build rather than ship (mirrors the unmapped-symbol gate
1349+ // above). Only write once every link is known to resolve.
1350+ validateAnchors ( rendered ) ;
1351+ for ( const { slug, content } of rendered ) {
1352+ writeFileSync ( join ( OUTPUT_DIR , `${ slug } .md` ) , content , "utf8" ) ;
1353+ }
12711354 currentLinkResolver = undefined ;
12721355 currentRenderBucket = undefined ;
12731356 console . log (
0 commit comments