Skip to content

Commit 2ee3f11

Browse files
authored
test(library): read the public-surface Name tables through mdast instead of a hand parser (#569)
1 parent 1883231 commit 2ee3f11

1 file changed

Lines changed: 74 additions & 104 deletions

File tree

‎test/library/public-surface.test.ts‎

Lines changed: 74 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@
66
import { describe, expect, test } from "bun:test";
77
import { readFileSync } from "node:fs";
88
import { 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";
912
import { parseSync } from "oxc-parser";
13+
import { visitParents } from "unist-util-visit-parents";
1014
import { ROOT } from "../root.js";
1115

1216
const ENTRY = "src/index.ts";
1317
const INTERNAL_ENTRY = "src/internal.ts";
1418
const 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. */
1723
export 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*Name\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
*/
12294
export 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

Comments
 (0)