2025-06-19 01:28:16 +09:00
import { existsSync , readFileSync , statSync } from 'node:fs'
2025-12-05 22:39:25 +11:00
import { extname , resolve } from 'node:path'
2025-07-26 18:16:10 +10:00
import { glob } from 'tinyglobby'
2025-06-19 01:28:16 +09:00
// @ts-ignore Could not find a declaration file for module 'markdown-link-extractor'.
2025-05-24 00:47:55 -06:00
import markdownLinkExtractor from 'markdown-link-extractor'
2025-12-05 22:39:25 +11:00
const errors : Array < {
file : string
link : string
resolvedPath : string
reason : string
} > = [ ]
2025-05-24 00:47:55 -06:00
function isRelativeLink ( link : string ) {
return (
2025-12-05 22:39:25 +11:00
! link . startsWith ( '/' ) &&
2025-05-24 00:47:55 -06:00
! link . startsWith ( 'http://' ) &&
! link . startsWith ( 'https://' ) &&
! link . startsWith ( '//' ) &&
! link . startsWith ( '#' ) &&
! link . startsWith ( 'mailto:' )
)
}
2025-12-05 22:39:25 +11:00
/** Remove any trailing .md */
function stripExtension ( p : string ) : string {
return p . replace ( ` ${ extname ( p ) } ` , '' )
2025-05-24 00:47:55 -06:00
}
2025-12-05 22:39:25 +11:00
function relativeLinkExists ( link : string , file : string ) : boolean {
2025-05-24 00:47:55 -06:00
// Remove hash if present
2025-12-05 22:39:25 +11:00
const linkWithoutHash = link . split ( '#' ) [ 0 ]
2025-05-24 00:47:55 -06:00
// If the link is empty after removing hash, it's not a file
2025-12-05 22:39:25 +11:00
if ( ! linkWithoutHash ) return false
2025-05-24 00:47:55 -06:00
2025-12-05 22:39:25 +11:00
// Strip the file/link extensions
const filePath = stripExtension ( file )
const linkPath = stripExtension ( linkWithoutHash )
2025-05-24 00:47:55 -06:00
// Resolve the path relative to the markdown file's directory
2025-12-05 22:39:25 +11:00
// Nav up a level to simulate how links are resolved on the web
let absPath = resolve ( filePath , '..' , linkPath )
2025-05-24 00:47:55 -06:00
// Ensure the resolved path is within /docs
const docsRoot = resolve ( 'docs' )
if ( ! absPath . startsWith ( docsRoot ) ) {
errors . push ( {
link ,
2025-12-05 22:39:25 +11:00
file ,
2025-05-24 00:47:55 -06:00
resolvedPath : absPath ,
2025-12-05 22:39:25 +11:00
reason : 'Path outside /docs' ,
2025-05-24 00:47:55 -06:00
} )
return false
}
// Check if this is an example path
const isExample = absPath . includes ( '/examples/' )
let exists = false
2025-05-24 06:48:59 +00:00
2025-05-24 00:47:55 -06:00
if ( isExample ) {
// Transform /docs/framework/{framework}/examples/ to /examples/{framework}/
2025-05-24 06:48:59 +00:00
absPath = absPath . replace (
/\/docs\/framework\/([^/]+)\/examples\// ,
'/examples/$1/' ,
)
2025-05-24 00:47:55 -06:00
// For examples, we want to check if the directory exists
exists = existsSync ( absPath ) && statSync ( absPath ) . isDirectory ( )
} else {
// For non-examples, we want to check if the .md file exists
if ( ! absPath . endsWith ( '.md' ) ) {
absPath = ` ${ absPath } .md `
}
exists = existsSync ( absPath )
}
if ( ! exists ) {
errors . push ( {
link ,
2025-12-05 22:39:25 +11:00
file ,
2025-05-24 00:47:55 -06:00
resolvedPath : absPath ,
2025-12-05 22:39:25 +11:00
reason : 'Not found' ,
2025-05-24 00:47:55 -06:00
} )
}
return exists
}
2025-12-05 22:39:25 +11:00
async function verifyMarkdownLinks() {
2025-05-24 00:47:55 -06:00
// Find all markdown files in docs directory
2025-07-26 18:16:10 +10:00
const markdownFiles = await glob ( 'docs/**/*.md' , {
2025-05-24 00:47:55 -06:00
ignore : [ '**/node_modules/**' ] ,
} )
console . log ( ` Found ${ markdownFiles . length } markdown files \ n ` )
// Process each file
for ( const file of markdownFiles ) {
const content = readFileSync ( file , 'utf-8' )
2025-12-05 22:39:25 +11:00
const links : Array < string > = markdownLinkExtractor ( content )
const relativeLinks = links . filter ( ( link : string ) = > {
return isRelativeLink ( link )
2025-05-24 00:47:55 -06:00
} )
2025-12-05 22:39:25 +11:00
if ( relativeLinks . length > 0 ) {
relativeLinks . forEach ( ( link ) = > {
relativeLinkExists ( link , file )
2025-05-24 00:47:55 -06:00
} )
}
}
if ( errors . length > 0 ) {
console . log ( ` \ n❌ Found ${ errors . length } broken links: ` )
2025-05-24 06:48:59 +00:00
errors . forEach ( ( err ) = > {
2025-05-24 00:47:55 -06:00
console . log (
2025-12-05 22:39:25 +11:00
` ${ err . file } \ n link: ${ err . link } \ n resolved: ${ err . resolvedPath } \ n why: ${ err . reason } \ n ` ,
2025-05-24 00:47:55 -06:00
)
} )
process . exit ( 1 )
} else {
console . log ( '\n✅ No broken links found!' )
}
}
2025-12-05 22:39:25 +11:00
verifyMarkdownLinks ( ) . catch ( console . error )