66import { describe , expect , test } from "bun:test" ;
77import { readFileSync } from "node:fs" ;
88import { join } from "node:path" ;
9+ import { fromMarkdown } from "mdast-util-from-markdown" ;
10+ import { gfmTableFromMarkdown } from "mdast-util-gfm-table" ;
11+ import { gfmTable } from "micromark-extension-gfm-table" ;
912import { parseSync } from "oxc-parser" ;
13+ import { visitParents } from "unist-util-visit-parents" ;
1014import { ROOT } from "../root.js" ;
1115
1216const ENTRY = "src/index.ts" ;
1317const INTERNAL_ENTRY = "src/internal.ts" ;
1418const PAGE = "docs/reference/library.md" ;
1519
20+ const API_HEADING = "## The API by group" ;
21+
1622/** Every name an entry exports, runtime and type-only alike, sorted; a star re-export has no names to pin and is refused. */
1723export function exportedNames (
1824 entry : string ,
@@ -32,124 +38,75 @@ export function exportedNames(
3238 . sort ( ) ;
3339}
3440
35- /** The heading the API tables sit under, up to the next second-level heading. */
36- const API_HEADING = "## The API by group" ;
37-
38- /** The header row of an API table, as GFM reads it (the leading pipe and the padding optional); the knob table's header is `| Knob |`. */
39- const NAME_TABLE_HEADER = / ^ \| ? \s * N a m e \s * \| / ;
41+ type Root = ReturnType < typeof fromMarkdown > ;
42+ type Block = Root [ "children" ] [ number ] ;
43+ type Table = Extract < Block , { type : "table" } > ;
44+ type TableRow = Table [ "children" ] [ number ] ;
4045
41- /** A table row's first cell, when it is one code span: `| \`name\` | kind | says |`. */
42- const NAME_ROW = / ^ \| ? \s * ` ( [ ^ ` \s ] + ) ` \s * \| / ;
43-
44- /** The header's delimiter row: dashes per column, the colons, the outer pipes, and the padding optional. */
45- const DELIMITER_ROW = / ^ \| ? \s * : ? - + : ? \s * ( \| \s * : ? - + : ? \s * ) * \| ? \s * $ / ;
46-
47- /** A line as GFM reads it: its blockquote depth and the text under the markers. */
48- interface QuotedLine {
49- /** How many `>` markers (each with any spaces after it) open the line; nested markers stack. */
50- readonly depth : number ;
51-
52- /** The text under the markers, trimmed. */
53- readonly text : string ;
54- }
55-
56- /** The line `raw` as GFM reads it. */
57- function quotedLine ( raw : string ) : QuotedLine {
58- let text = raw . trim ( ) ;
59- let depth = 0 ;
60- for ( let marker = text . match ( / ^ > \s * / ) ; marker ; marker = text . match ( / ^ > \s * / ) ) {
61- depth ++ ;
62- text = text . slice ( marker [ 0 ] . length ) ;
46+ function sourceOf ( node : Block | TableRow , markdown : string ) : string {
47+ const start = node . position ?. start . offset ;
48+ const end = node . position ?. end . offset ;
49+ if ( start === undefined || end === undefined ) {
50+ throw new Error ( `BUG: mdast-util-from-markdown left a ${ node . type } node without a position` ) ;
6351 }
64- return { depth , text : text . trim ( ) } ;
52+ return markdown . slice ( start , end ) ;
6553}
6654
67- /**
68- * The lines of `section` outside its fences, each with its offset; a fence opener is yielded as a blank line, which
69- * ends a table as the opener does. The open fence is its mark and the quote depth it opened at. A closer is the whole
70- * line at that depth; a `>` line inside a fence at depth 0 is fence text, and so is a table. A line below the fence's
71- * depth ends the blockquote, and the fence with it.
72- */
73- function * outsideFences (
74- section : readonly QuotedLine [ ] ,
75- ) : Generator < { offset : number ; text : string } > {
76- let fence : { mark : string ; depth : number } | null = null ;
77- for ( const [ offset , { depth, text } ] of section . entries ( ) ) {
78- // A backtick fence's info string carries no backtick, so a triple-backtick inline span is not an opener.
79- const opener = text . match ( / ^ ( ` { 3 , } ) (? ! [ ^ ` ] * ` ) | ^ ( ~ { 3 , } ) / ) ?. [ 0 ] ;
80- if ( fence !== null && depth < fence . depth ) {
81- fence = null ;
82- }
83- if ( fence !== null ) {
84- if ( depth === fence . depth && text . startsWith ( fence . mark ) && / ^ ( ` + | ~ + ) $ / . test ( text ) ) {
85- fence = null ;
86- }
87- continue ;
88- }
89- if ( opener !== undefined ) {
90- fence = { mark : opener , depth } ;
91- yield { offset, text : "" } ;
92- continue ;
93- }
94- yield { offset, text } ;
55+ /** The blocks under "## The API by group" up to the next second-level heading of the page's own outline; a heading inside a blockquote or a code block is content. */
56+ function apiSection ( root : Root , markdown : string ) : Block [ ] {
57+ const blocks = root . children ;
58+ const start = blocks . findIndex (
59+ ( block ) =>
60+ block . type === "heading" && block . depth === 2 && sourceOf ( block , markdown ) === API_HEADING ,
61+ ) ;
62+ if ( start === - 1 ) {
63+ throw new Error ( `${ PAGE } has no "${ API_HEADING } " heading` ) ;
9564 }
65+ const rest = blocks . slice ( start + 1 ) ;
66+ const end = rest . findIndex ( ( block ) => block . type === "heading" && block . depth === 2 ) ;
67+ return end === - 1 ? rest : rest . slice ( 0 , end ) ;
9668}
9769
98- /** Whether the line at `offset` is a Name table's header: the header row with the delimiter row under it. */
99- function opensNameTable ( section : readonly QuotedLine [ ] , offset : number ) : boolean {
100- return (
101- NAME_TABLE_HEADER . test ( section [ offset ] ?. text ?? "" ) &&
102- DELIMITER_ROW . test ( section [ offset + 1 ] ?. text ?? "" )
103- ) ;
70+ /** Whether `header` opens a Name table: its first cell is the plain text `Name`; the knob table's is `Knob`. */
71+ function isNameHeader ( header : TableRow | undefined ) : boolean {
72+ const cell = header ?. children [ 0 ] ;
73+ const [ only ] = cell ?. children ?? [ ] ;
74+ return cell ?. children . length === 1 && only ?. type === "text" && only . value === "Name" ;
10475}
10576
106- /** Whether the line at `offset` is the delimiter row under a Name table's header. */
107- function isNameTableDelimiter ( section : readonly QuotedLine [ ] , offset : number ) : boolean {
108- return (
109- offset > 0 &&
110- NAME_TABLE_HEADER . test ( section [ offset - 1 ] ?. text ?? "" ) &&
111- DELIMITER_ROW . test ( section [ offset ] ?. text ?? "" )
112- ) ;
77+ /** The name a Name table's body row documents: its first cell as one code span; any other first cell is refused by row, so a misspelled row cannot read as absent. */
78+ function nameOf ( row : TableRow , markdown : string ) : string {
79+ const [ cell ] = row . children ;
80+ const [ only ] = cell ?. children ?? [ ] ;
81+ if ( cell ?. children . length !== 1 || only ?. type !== "inlineCode" ) {
82+ throw new Error (
83+ `${ PAGE } :${ row . position ?. start . line } : a Name table row must start with one code span, got: ${ sourceOf ( row , markdown ) } ` ,
84+ ) ;
85+ }
86+ return only . value ;
11387}
11488
11589/**
116- * The names the page documents: the first column of every `| Name |` table between "## The API by group" and the
117- * next second-level heading, sorted. A table starts where a Name header is followed by a delimiter row and runs to
118- * the next blank line, whatever its rows' leading whitespace or pipes, so every line in that span is a body row:
119- * one whose first cell is not one code span is refused, so a misspelled row cannot read as absent, and a duplicate
120- * row is kept, so the equality below reports it.
90+ * The names the page documents: the first column of every `| Name |` table under "## The API by group", sorted, with
91+ * the page parsed as GitHub renders it (a table inside a blockquote or a list item counts; one inside a fence, an
92+ * indented code block, or an HTML block is shown text). A duplicate row is kept, so the equality below reports it.
12193 */
12294export function documentedNames ( markdown : string ) : string [ ] {
123- const lines = markdown . split ( "\n" ) ;
124- const start = lines . indexOf ( API_HEADING ) ;
125- if ( start < 0 ) {
126- throw new Error ( `${ PAGE } has no "${ API_HEADING } " heading` ) ;
127- }
128- const section = lines . slice ( start + 1 ) . map ( quotedLine ) ;
95+ const root = fromMarkdown ( markdown , {
96+ extensions : [ gfmTable ( ) ] ,
97+ mdastExtensions : [ gfmTableFromMarkdown ( ) ] ,
98+ } ) ;
12999 const names : string [ ] = [ ] ;
130- let inNameTable = false ;
131- for ( const { offset, text : line } of outsideFences ( section ) ) {
132- if ( line . startsWith ( "## " ) ) {
133- break ;
134- }
135- if ( line === "" ) {
136- inNameTable = false ;
137- continue ;
138- }
139- if ( ! inNameTable ) {
140- inNameTable = opensNameTable ( section , offset ) ;
141- continue ;
142- }
143- if ( isNameTableDelimiter ( section , offset ) ) {
144- continue ;
145- }
146- const match = line . match ( NAME_ROW ) ;
147- if ( match ?. [ 1 ] === undefined ) {
148- throw new Error (
149- `${ PAGE } :${ start + 2 + offset } : a Name table row must start with one code span, got: ${ line } ` ,
150- ) ;
151- }
152- names . push ( match [ 1 ] ) ;
100+ for ( const block of apiSection ( root , markdown ) ) {
101+ visitParents ( block , "table" , ( table ) => {
102+ const [ header , ...rows ] = table . children ;
103+ if ( ! isNameHeader ( header ) ) {
104+ return ;
105+ }
106+ for ( const row of rows ) {
107+ names . push ( nameOf ( row , markdown ) ) ;
108+ }
109+ } ) ;
153110 }
154111 return names . sort ( ) ;
155112}
@@ -182,12 +139,15 @@ describe("the public entry", () => {
182139 expect ( documentedNames ( afterSection ) ) . toEqual ( documentedNames ( page ) ) ;
183140 } ) ;
184141
185- // A fence shows its text; a table inside one renders as code, so it is not a documented name.
142+ // Shown text (a fence, four-space indented code, an HTML block) renders as code or raw HTML, so a table inside it is
143+ // not a documented name.
186144 const FENCED_TABLE =
187145 "| Name | Kind | Says |\n|---|---|---|\n| `notExported` | function | Fenced |" ;
188146 test . each ( [
189147 [ "a backtick fence" , `\`\`\`md\n${ FENCED_TABLE } \n\`\`\`` ] ,
190148 [ "a tilde fence" , `~~~\n${ FENCED_TABLE } \n~~~` ] ,
149+ [ "an indented code block" , FENCED_TABLE . replaceAll ( / ^ / gm, " " ) ] ,
150+ [ "an HTML block" , `<div>\n${ FENCED_TABLE } \n</div>` ] ,
191151 [ "a fence whose body opens another fence" , `\`\`\`md\n\`\`\`ts\n${ FENCED_TABLE } \n\n\`\`\`` ] ,
192152 [
193153 "a fence inside a blockquote" ,
@@ -245,6 +205,16 @@ describe("the public entry", () => {
245205 "### Io\n" ,
246206 "### Io\n\n```an inline `code` span```\n\n| Name | Kind | Says |\n|---|---|---|\n| `notExported` | function | Extra |\n\n" ,
247207 ] ,
208+ [
209+ "a table whose rows run into a heading, which ends the table" ,
210+ "### Io\n" ,
211+ "### Io\n\n| Name | Kind | Says |\n|---|---|---|\n| `notExported` | function | Extra |\n#### Not a row\n\n" ,
212+ ] ,
213+ [
214+ "a table after a quoted second-level heading, which is content and not the section's end" ,
215+ "### Io\n" ,
216+ "### Io\n\n> ## Quoted\n\n| Name | Kind | Says |\n|---|---|---|\n| `notExported` | function | Extra |\n\n" ,
217+ ] ,
248218 ] ) ( "%s is read as a documented name (negative control)" , ( _form , anchor , replacement ) => {
249219 const variant = page . replace ( anchor , replacement ) ;
250220 expect ( variant ) . not . toBe ( page ) ;
0 commit comments