@@ -4,6 +4,12 @@ import path from "node:path";
44import { fileURLToPath } from "node:url" ;
55
66const repoRoot = path . resolve ( path . dirname ( fileURLToPath ( import . meta. url ) ) , ".." ) ;
7+ const approvedRootDocumentation = new Set ( [
8+ "README.md" ,
9+ "CHANGELOG.md" ,
10+ "LICENSE" ,
11+ "VISION.md" ,
12+ ] . map ( ( relativePath ) => path . join ( repoRoot , relativePath ) ) ) ;
713
814const readme = readText ( "README.md" ) ;
915const readmeLinks = [
@@ -13,13 +19,30 @@ const readmeLinks = [
1319] . filter ( isRepositoryDocReference ) ;
1420
1521assert ( readmeLinks . length > 0 , "README.md has no local documentation links" ) ;
16- for ( const link of readmeLinks ) validateLocalDocLink ( link ) ;
22+ for ( const link of readmeLinks ) validateLocalDocLink ( link , repoRoot , "README.md" ) ;
1723
1824const providerLinks = inlineCodeDocLinks ( readText ( "docs/providers.md" ) ) ;
1925assert ( providerLinks . length > 0 , "docs/providers.md has no provider detail links" ) ;
20- for ( const link of providerLinks ) validateLocalDocLink ( link ) ;
26+ for ( const link of providerLinks ) validateLocalDocLink ( link , repoRoot , "docs/providers.md" ) ;
2127
22- console . log ( `documentation links OK: ${ readmeLinks . length + providerLinks . length } local links` ) ;
28+ const docsLinks = markdownFiles ( "docs" ) . flatMap ( ( relativePath ) => {
29+ const markdown = readText ( relativePath ) ;
30+ const links = [
31+ ...markdownLinks ( markdown ) ,
32+ ...markdownImageLinks ( markdown ) ,
33+ ...htmlLinks ( markdown ) ,
34+ ] . filter ( isLocalDocumentationReference ) ;
35+
36+ return links . map ( ( link ) => ( { link, relativePath } ) ) ;
37+ } ) ;
38+
39+ for ( const { link, relativePath } of docsLinks ) {
40+ validateLocalDocLink ( link , path . join ( repoRoot , path . dirname ( relativePath ) ) , relativePath ) ;
41+ }
42+
43+ console . log (
44+ `documentation links OK: ${ readmeLinks . length + providerLinks . length + docsLinks . length } local links` ,
45+ ) ;
2346
2447function readText ( relativePath ) {
2548 return fs . readFileSync ( path . join ( repoRoot , relativePath ) , "utf8" ) ;
@@ -63,13 +86,14 @@ function inlineCodeDocLinks(markdown) {
6386 } ) ;
6487}
6588
66- function validateLocalDocLink ( rawLink ) {
67- const { absolutePath, fragment } = localDocPath ( rawLink ) ;
68- assert ( fs . existsSync ( absolutePath ) , `missing documentation target: ${ rawLink } ` ) ;
89+ function validateLocalDocLink ( rawLink , baseDirectory , sourceLabel ) {
90+ const sourcePath = path . join ( repoRoot , sourceLabel ) ;
91+ const { absolutePath, fragment } = localDocPath ( rawLink , baseDirectory , sourcePath ) ;
92+ assert ( fs . existsSync ( absolutePath ) , `${ sourceLabel } : missing documentation target: ${ rawLink } ` ) ;
6993
7094 if ( path . extname ( absolutePath ) . toLowerCase ( ) !== ".md" || ! fragment ) return ;
7195 const anchors = markdownHeadingAnchors ( readText ( path . relative ( repoRoot , absolutePath ) ) ) ;
72- assert ( anchors . has ( fragment ) , `missing documentation anchor: ${ rawLink } ` ) ;
96+ assert ( anchors . has ( fragment ) , `${ sourceLabel } : missing documentation anchor: ${ rawLink } ` ) ;
7397}
7498
7599function isRepositoryDocReference ( rawLink ) {
@@ -80,20 +104,41 @@ function isRepositoryDocReference(rawLink) {
80104 return pathname === "docs" || pathname . startsWith ( "docs/" ) ;
81105}
82106
83- function localDocPath ( rawLink ) {
107+ function isLocalDocumentationReference ( rawLink ) {
108+ const parsed = parseRelativeURL ( rawLink ) ;
109+ if ( ! parsed || parsed . protocol || parsed . host ) return false ;
110+ return Boolean ( parsed . pathname || parsed . hash ) ;
111+ }
112+
113+ function localDocPath ( rawLink , baseDirectory , sourcePath ) {
84114 const parsed = parseRelativeURL ( rawLink ) ;
85- assert ( parsed && ! parsed . protocol && ! parsed . host && parsed . pathname , `invalid documentation URL: ${ rawLink } ` ) ;
115+ assert (
116+ parsed && ! parsed . protocol && ! parsed . host && ( parsed . pathname || parsed . hash ) ,
117+ `invalid documentation URL: ${ rawLink } ` ,
118+ ) ;
86119
87- const decodedPath = decodeURIComponent ( parsed . pathname ) ;
88- const absolutePath = path . resolve ( repoRoot , decodedPath ) ;
120+ const rawPath = rawLink . split ( "#" , 1 ) [ 0 ] . split ( "?" , 1 ) [ 0 ] ;
121+ const decodedPath = decodeURIComponent ( rawPath ) ;
122+ const absolutePath = decodedPath ? path . resolve ( baseDirectory , decodedPath ) : sourcePath ;
89123 const docsRoot = path . resolve ( repoRoot , "docs" ) ;
124+ const isInDocsTree = absolutePath === docsRoot || absolutePath . startsWith ( `${ docsRoot } ${ path . sep } ` ) ;
90125 assert (
91- absolutePath === docsRoot || absolutePath . startsWith ( ` ${ docsRoot } ${ path . sep } ` ) ,
92- `documentation link escapes docs root : ${ rawLink } ` ,
126+ isInDocsTree || approvedRootDocumentation . has ( absolutePath ) ,
127+ `documentation link escapes approved documentation roots : ${ rawLink } ` ,
93128 ) ;
94129 return { absolutePath, fragment : parsed . hash ? decodeURIComponent ( parsed . hash . slice ( 1 ) ) : "" } ;
95130}
96131
132+ function markdownFiles ( relativeDir ) {
133+ const dir = path . join ( repoRoot , relativeDir ) ;
134+ return fs . readdirSync ( dir , { withFileTypes : true } ) . flatMap ( ( entry ) => {
135+ if ( entry . name . startsWith ( "." ) ) return [ ] ;
136+ const relativePath = path . join ( relativeDir , entry . name ) ;
137+ if ( entry . isDirectory ( ) ) return markdownFiles ( relativePath ) ;
138+ return entry . isFile ( ) && entry . name . endsWith ( ".md" ) ? [ relativePath ] : [ ] ;
139+ } ) . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
140+ }
141+
97142function parseRelativeURL ( rawLink ) {
98143 try {
99144 const parsed = new URL ( rawLink , "relative://repo/" ) ;
0 commit comments