1010import { describe , expect , test } from "bun:test" ;
1111import { existsSync , readdirSync , readFileSync } from "node:fs" ;
1212import { join , posix } from "node:path" ;
13+ import { unescape as percentDecoded } from "node:querystring" ;
1314import { DecodingMode , decodeHTML } from "entities" ;
1415import { ok } from "neverthrow" ;
1516import { parse as parseYaml } from "yaml" ;
@@ -208,12 +209,50 @@ async function linkDestinations(
208209 return destinations ;
209210}
210211
211- /** An origin no real link names, under which docs/ is served for the resolution below . */
212+ /** An origin no real link names, under which the repository is served for linkTargets, and docs/ alone, at SITE_ROOT, for linksLeavingDocs . */
212213const SITE_ORIGIN = "https://docs-site.invalid" ;
213214
214215/** The path docs/ is served under, which no real link names either, so a destination that climbs out (even back into a literal "docs/") lands outside it. */
215216const SITE_ROOT = "/docs-root-7c1e/" ;
216217
218+ /** One link within the repository, as a browser resolves it against its page: the file it requests and the heading it looks up, each decoded on its own. */
219+ interface LinkTarget {
220+ /** The destination as the renderer wrote it, for a failure message. */
221+ readonly destination : string ;
222+
223+ /** The file the link requests, from the repository root, percent-decoded as a server does (a valid escape decodes, a bare % stays); the page itself for a fragment-only link. */
224+ readonly path : string ;
225+
226+ /** The heading the link looks up, percent-decoded; null for a destination with no #. */
227+ readonly fragment : string | null ;
228+ }
229+
230+ /**
231+ * The links in `markdown`, the page at `page` from the repository root, that lead within the repository, resolved as a
232+ * browser does: padding trimmed, query and #fragment split off before the path is decoded (an encoded %23 stays in
233+ * the path), and a destination on another origin (a scheme, a network path `//host/`) or one no parser accepts skipped.
234+ */
235+ async function linkTargets (
236+ markdown : string ,
237+ page : string ,
238+ attributes ?: readonly string [ ] ,
239+ ) : Promise < LinkTarget [ ] > {
240+ const base = `${ SITE_ORIGIN } /${ page } ` ;
241+ const targets : LinkTarget [ ] = [ ] ;
242+ for ( const destination of await linkDestinations ( markdown , attributes ) ) {
243+ const url = URL . parse ( destination , base ) ;
244+ if ( url === null || url . origin !== SITE_ORIGIN ) {
245+ continue ;
246+ }
247+ targets . push ( {
248+ destination,
249+ path : percentDecoded ( url . pathname . slice ( 1 ) ) ,
250+ fragment : url . hash === "" ? null : percentDecoded ( url . hash . slice ( 1 ) ) ,
251+ } ) ;
252+ }
253+ return targets ;
254+ }
255+
217256/**
218257 * Relative links in the docs/ page at `page` that resolve outside docs/.
219258 * Resolution is the WHATWG URL parser's, as a browser does it; the published site is built from docs/ alone, so a link
@@ -224,14 +263,7 @@ async function linksLeavingDocs(markdown: string, page: string): Promise<string[
224263 const lines = markdown . split ( "\n" ) ;
225264 const lineOf = ( target : string ) : string => {
226265 const name = posix . basename ( target . split ( "#" ) [ 0 ] ?? "" ) ;
227- const decoded = ( text : string ) : string => {
228- try {
229- return decodeURIComponent ( text ) ;
230- } catch {
231- return text ;
232- }
233- } ;
234- const needles = [ target , decoded ( target ) , name , decoded ( name ) ] ;
266+ const needles = [ target , percentDecoded ( target ) , name , percentDecoded ( name ) ] ;
235267 const index = needles
236268 . map ( ( needle ) => lines . findIndex ( ( line ) => line . includes ( needle ) ) )
237269 . find ( ( found ) => found !== - 1 ) ;
@@ -411,12 +443,9 @@ describe("docs/ guide pages", () => {
411443 const linked = new Set < string > ( ) ;
412444 for ( const file of linkScanFiles ( ) ) {
413445 // href only: an image or embed shows the file but takes the reader nowhere.
414- for ( const destination of await linkDestinations ( readFileSync ( file . path , "utf8" ) , [ "href" ] ) ) {
415- const path = destination . split ( "#" ) [ 0 ] ?? "" ;
416- if ( path === "" || / ^ [ a - z ] + : / . test ( path ) ) {
417- continue ;
418- }
419- const resolved = join ( file . path , ".." , decodeURIComponent ( path ) ) ;
446+ const links = await linkTargets ( readFileSync ( file . path , "utf8" ) , file . label , [ "href" ] ) ;
447+ for ( const target of links ) {
448+ const resolved = join ( ROOT , target . path ) ;
420449 if ( resolved !== file . path ) {
421450 linked . add ( resolved ) ;
422451 }
@@ -426,49 +455,34 @@ describe("docs/ guide pages", () => {
426455 expect ( orphans , "no README, guide, or root page links these docs/ pages" ) . toEqual ( [ ] ) ;
427456 } ) ;
428457
429- /** Every link target in `file` outside its fences, the external ones (a scheme, mailto:) skipped. */
430- function linkTargets ( file : { label : string ; path : string } ) : string [ ] {
431- const markdown = linesOutsideFences ( readFileSync ( file . path , "utf8" ) , file . label ) . join ( "\n" ) ;
432- return [ ...markdown . matchAll ( / \] \( ( [ ^ ) ] + ) \) / g) ]
433- . map ( ( match ) => match [ 1 ] ?? "" )
434- . filter ( ( target ) => ! / ^ [ a - z ] + : \/ \/ / . test ( target ) && ! target . startsWith ( "mailto:" ) ) ;
435- }
436-
437458 /** Why a target's #fragment matches no heading of the markdown file it names; null for a target with no fragment to check, or a good one. */
438459 function fragmentProblem (
439460 file : { label : string ; path : string } ,
440- target : string ,
461+ target : LinkTarget ,
441462 slugsOf : ( path : string ) => Set < string > ,
442463 ) : string | null {
443- const hash = target . indexOf ( "#" ) ;
444- if ( hash === - 1 ) {
464+ if ( target . fragment === null ) {
445465 return null ;
446466 }
447- const fragment = target . slice ( hash + 1 ) ;
448- const path = target . slice ( 0 , hash ) ;
449- const resolved = path === "" ? file . path : join ( file . path , ".." , path ) ;
467+ const resolved = join ( ROOT , target . path ) ;
450468 if ( ! resolved . endsWith ( ".md" ) ) {
451469 return null ; // only markdown targets have slugified headings
452470 }
453471 if ( ! existsSync ( resolved ) ) {
454- return `${ file . label } : (${ target } ) target file does not exist` ;
472+ return `${ file . label } : (${ target . destination } ) target file does not exist` ;
455473 }
456- if ( ! slugsOf ( resolved ) . has ( fragment ) ) {
457- return `${ file . label } : (${ target } ) fragment matches no heading` ;
474+ if ( ! slugsOf ( resolved ) . has ( target . fragment ) ) {
475+ return `${ file . label } : (${ target . destination } ) fragment matches no heading` ;
458476 }
459477 return null ;
460478 }
461479
462- test ( "every relative link in the guides, README, and CONTRIBUTING resolves to a real file" , ( ) => {
480+ test ( "every relative link in the guides, README, and CONTRIBUTING resolves to a real file" , async ( ) => {
463481 const broken : string [ ] = [ ] ;
464482 for ( const file of linkScanFiles ( ) ) {
465- for ( const target of linkTargets ( file ) ) {
466- const path = target . split ( "#" ) [ 0 ] ?? "" ;
467- if ( path === "" ) {
468- continue ;
469- }
470- if ( ! existsSync ( join ( file . path , ".." , path ) ) ) {
471- broken . push ( `${ file . label } : (${ target } )` ) ;
483+ for ( const target of await linkTargets ( readFileSync ( file . path , "utf8" ) , file . label ) ) {
484+ if ( ! existsSync ( join ( ROOT , target . path ) ) ) {
485+ broken . push ( `${ file . label } : (${ target . destination } )` ) ;
472486 }
473487 }
474488 }
@@ -483,7 +497,7 @@ describe("docs/ guide pages", () => {
483497 expect ( leaving ) . toEqual ( [ ] ) ;
484498 } ) ;
485499
486- test ( "every relative link with a #fragment points at a real heading" , ( ) => {
500+ test ( "every relative link with a #fragment points at a real heading" , async ( ) => {
487501 const files = linkScanFiles ( ) ;
488502 const slugCache = new Map < string , Set < string > > ( ) ;
489503 const slugsOf = ( path : string ) : Set < string > => {
@@ -496,7 +510,7 @@ describe("docs/ guide pages", () => {
496510 } ;
497511 const broken : string [ ] = [ ] ;
498512 for ( const file of files ) {
499- for ( const target of linkTargets ( file ) ) {
513+ for ( const target of await linkTargets ( readFileSync ( file . path , "utf8" ) , file . label ) ) {
500514 const problem = fragmentProblem ( file , target , slugsOf ) ;
501515 if ( problem !== null ) {
502516 broken . push ( problem ) ;
@@ -990,6 +1004,52 @@ describe("links-leaving-docs guard (mutation checks)", () => {
9901004 } ) ;
9911005} ) ;
9921006
1007+ describe ( "link target scan (mutation checks)" , ( ) => {
1008+ test ( "reads each destination as a browser does, where a regex over the source read it differently" , async ( ) => {
1009+ // A regex over the source read the first three lines, the tel: link, and the encoded forms differently; the other
1010+ // schemes pin the skip, the two bare percents the tolerant decode, %23 the split before the decode, and the last
1011+ // line the URL parser's own trimming, query split, and origin test (a network path and a non-URL scheme are external).
1012+ const link = ( destination : string , path : string , fragment : string | null ) : LinkTarget => ( {
1013+ destination,
1014+ path,
1015+ fragment,
1016+ } ) ;
1017+ const page = [
1018+ '[titled](a.md "Title") [bracketed](<a b.md>) [nested](p.md (title))' ,
1019+ "[reference][ref] [collapsed][] [shortcut]" ,
1020+ '<a href="raw.md">anchor</a> <img src="raw.png" alt="">' ,
1021+ "[phone](tel:+15550100) [mail](mailto:x@example.com) [site](https://example.com/)" ,
1022+ "Not links: `[span](code.md)` and \\[escaped](escaped.md)." ,
1023+ "[encoded](a%20b.md#frag%20x) [entity](../ent.md) [escaped](\\.\\./esc.md)" ,
1024+ "[bare percent](50%.md) [bare and escape](<50% done.md>) [encoded hash](README.md%23library) [fragment](#top)" ,
1025+ '<img src="//cdn.example/x.png" alt=""> [clone](git+ssh://git@example.com/r.git) <a href=" guide.md ">padded</a> [query](guide.md?view=1)' ,
1026+ "" ,
1027+ "[ref]: ref.md" ,
1028+ "[collapsed]: collapsed.md" ,
1029+ "[shortcut]: shortcut.md" ,
1030+ ] . join ( "\n" ) ;
1031+ expect ( await linkTargets ( page , "docs/start/page.md" ) ) . toEqual ( [
1032+ link ( "a.md" , "docs/start/a.md" , null ) ,
1033+ link ( "a%20b.md" , "docs/start/a b.md" , null ) ,
1034+ link ( "p.md" , "docs/start/p.md" , null ) ,
1035+ link ( "ref.md" , "docs/start/ref.md" , null ) ,
1036+ link ( "collapsed.md" , "docs/start/collapsed.md" , null ) ,
1037+ link ( "shortcut.md" , "docs/start/shortcut.md" , null ) ,
1038+ link ( "raw.md" , "docs/start/raw.md" , null ) ,
1039+ link ( "raw.png" , "docs/start/raw.png" , null ) ,
1040+ link ( "a%20b.md#frag%20x" , "docs/start/a b.md" , "frag x" ) ,
1041+ link ( "../ent.md" , "docs/ent.md" , null ) ,
1042+ link ( "../esc.md" , "docs/esc.md" , null ) ,
1043+ link ( "50%.md" , "docs/start/50%.md" , null ) ,
1044+ link ( "50%%20done.md" , "docs/start/50% done.md" , null ) ,
1045+ link ( "README.md%23library" , "docs/start/README.md#library" , null ) ,
1046+ link ( "#top" , "docs/start/page.md" , "top" ) ,
1047+ link ( " guide.md " , "docs/start/guide.md" , null ) ,
1048+ link ( "guide.md?view=1" , "docs/start/guide.md" , null ) ,
1049+ ] ) ;
1050+ } ) ;
1051+ } ) ;
1052+
9931053describe ( "refusal table parser (mutation checks)" , ( ) => {
9941054 const header = [ "| The layer has | The fold says |" , "|---|---|" ] ;
9951055 const row = ( has : string , says = "`the message`" ) : string => `| ${ has } | ${ says } |` ;
0 commit comments