@0dep/toc 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +42 -7
- package/bin/toc.js +53 -32
- package/index.cjs +148 -70
- package/index.js +147 -70
- package/package.json +2 -2
- package/types/index.d.ts +32 -22
- package/types/index.d.ts.map +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## v1.1.0 - 2026-09-17
|
|
6
|
+
|
|
7
|
+
- duplicate slugs are numbered over the whole document, headings above the start marker included, the way GitHub does. A toc under a heading that shares its name with a later heading used to link the later one without the `-1`
|
|
8
|
+
- `findAnchors(source)` lists every link to an anchor in the document with whether it has a target, and a suggestion when a heading obviously matches
|
|
9
|
+
- the last source and its scan are cached, so the bin walks a file once instead of once per call
|
|
10
|
+
- the bin checks every link to an anchor outside the regenerated pairs and warns about the ones without a target, `-c`/`--check` makes them set exit code 1
|
|
11
|
+
- a byte order mark at the start of the file is ignored and kept, a heading or start marker on the first line used to be missed
|
|
12
|
+
|
|
5
13
|
## v1.0.1 - 2026-09-12
|
|
6
14
|
|
|
7
15
|
- `updateToc` is removed and the library no longer imports anything from Node, so it runs in the browser too. Reading and writing files is the `toc` bin's job, use `buildToc` with your own IO
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Generate a GitHub flavoured table of contents for markdown files. No dependencies, ESM and CommonJS. The library is string in, string out and runs in the browser too, the `toc` bin needs Node 20 or later.
|
|
6
6
|
|
|
7
|
-
The toc is written between `<!-- toc -->` and `<!-- /toc -->` markers, and only there. Nothing outside the markers is ever touched, nothing is inserted or guessed. Slugs match GitHub's anchors.
|
|
7
|
+
The toc is written between `<!-- toc -->` and `<!-- /toc -->` markers, and only there. Nothing outside the markers is ever touched, nothing is inserted or guessed. Slugs match GitHub's anchors, and every link to an anchor in the document is checked against them.
|
|
8
8
|
|
|
9
9
|
<!-- toc -->
|
|
10
10
|
|
|
@@ -16,12 +16,14 @@ The toc is written between `<!-- toc -->` and `<!-- /toc -->` markers, and only
|
|
|
16
16
|
- [CLI](#cli)
|
|
17
17
|
- [Options](#options-1)
|
|
18
18
|
- [Output and exit code](#output-and-exit-code)
|
|
19
|
+
- [Anchors](#anchors)
|
|
19
20
|
- [Dry run](#dry-run)
|
|
20
21
|
- [With prettier](#with-prettier)
|
|
21
22
|
- [API](#api)
|
|
22
23
|
- [`buildToc(source)`](#buildtocsource)
|
|
23
24
|
- [`renderToc(source[, fromLine[, options]])`](#rendertocsource-fromline-options)
|
|
24
25
|
- [`findMarkers(source)`](#findmarkerssource)
|
|
26
|
+
- [`findAnchors(source)`](#findanchorssource)
|
|
25
27
|
- [`slugify(text)`](#slugifytext)
|
|
26
28
|
- [`headingText(markdown)`](#headingtextmarkdown)
|
|
27
29
|
- [`TOC_START` and `TOC_END`](#toc_start-and-toc_end)
|
|
@@ -71,7 +73,7 @@ becomes
|
|
|
71
73
|
## Usage
|
|
72
74
|
```
|
|
73
75
|
|
|
74
|
-
Running it again replaces whatever is between the markers with a fresh list, so the toc can be regenerated any number of times. A file with CRLF line endings keeps them, the toc is written with the same line ending as the rest of the file.
|
|
76
|
+
Running it again replaces whatever is between the markers with a fresh list, so the toc can be regenerated any number of times. A file with CRLF line endings keeps them, the toc is written with the same line ending as the rest of the file. A byte order mark is ignored, a heading or marker on the first line is still seen, and the mark is kept when the file is written.
|
|
75
77
|
|
|
76
78
|
### Several tocs in one document
|
|
77
79
|
|
|
@@ -149,7 +151,7 @@ Anything on the start marker that is not one of the two options or a well-formed
|
|
|
149
151
|
|
|
150
152
|
A start marker pairs with the first end marker after it, a second start marker inside an open pair is treated as content and replaced with the rest of the block. The CLI reports every skipped file and pair with a warning, see below.
|
|
151
153
|
|
|
152
|
-
This README has two headings named Options, the one above and the one under CLI. The second one is linked as `#options-1`, the way GitHub numbers duplicate anchors.
|
|
154
|
+
This README has two headings named Options, the one above and the one under CLI. The second one is linked as `#options-1`, the way GitHub numbers duplicate anchors. Duplicates are counted over the whole document, headings above the start marker included, since that is what GitHub does.
|
|
153
155
|
|
|
154
156
|
## CLI
|
|
155
157
|
|
|
@@ -158,6 +160,7 @@ npx toc # updates README.md in the current directory
|
|
|
158
160
|
npx toc docs/a.md docs/b.md # several files
|
|
159
161
|
npx toc docs/a.md,docs/b.md # comma separated works too
|
|
160
162
|
npx toc --dry-run README.md # print the toc, write nothing
|
|
163
|
+
npx toc --check README.md # exit with 1 when a link to an anchor has no target
|
|
161
164
|
npx toc --help
|
|
162
165
|
```
|
|
163
166
|
|
|
@@ -168,6 +171,7 @@ Paths are resolved against the current directory.
|
|
|
168
171
|
| Option | Effect |
|
|
169
172
|
| --------------- | ----------------------------------------------------------------------------- |
|
|
170
173
|
| `-n, --dry-run` | Print the toc of every pair to stdout and the status to stderr, write nothing |
|
|
174
|
+
| `-c, --check` | Exit with 1 when a link to an anchor has no target |
|
|
171
175
|
| `-h, --help` | Show usage |
|
|
172
176
|
|
|
173
177
|
### Output and exit code
|
|
@@ -185,9 +189,21 @@ One status line per file on stdout, and one warning per skipped file or pair on
|
|
|
185
189
|
| `README.md:3: unknown TOC option foo, skipped.` | stderr | The start marker has something that is not an option |
|
|
186
190
|
| `README.md:3: TOC options collapsible and collapsed exclude each other, skipped.` | stderr | Pick one |
|
|
187
191
|
| `README.md:3: TOC attributes style need collapsible or collapsed, skipped.` | stderr | There is no summary element to put them on |
|
|
192
|
+
| `README.md:9: anchor #instal has no target.` | stderr | A link points at a heading that does not exist |
|
|
193
|
+
| `README.md:9: anchor #Install has no target, did you mean #install?` | stderr | Same, and a heading obviously matches |
|
|
188
194
|
| `README.md: ENOENT: no such file or directory, ...` | stderr | The file could not be read or written |
|
|
189
195
|
|
|
190
|
-
The exit code is 1 when a file could not be read or written
|
|
196
|
+
The exit code is 1 when a file could not be read or written, when an option is not recognised, or, with `--check`, when a link to an anchor has no target. Otherwise 0. Skipped files and pairs do not affect the exit code.
|
|
197
|
+
|
|
198
|
+
### Anchors
|
|
199
|
+
|
|
200
|
+
Every link to an anchor in the file, `[text](#slug)`, is checked against the slugs of the headings, the `id` of any html element and the `name` of an `<a>` in the file, outside fenced and indented code blocks, code spans and html comments. A bare `#`, the top of the page, and a fragment on a path or url to another document, `other.md#install`, are not anchors in this file and are left alone. Links inside a pair that is being regenerated are not checked, the new toc replaces them. A link without a target is reported with its line number, and the exit code stays 0 unless `--check` is given, so a stale link does not stop the toc from being written.
|
|
201
|
+
|
|
202
|
+
The warning ends with `did you mean` when the target is obvious: slugging the anchor as written, `#My-Heading` or `#My%20Heading` for `## My Heading`, or slugging the link text, `[My Heading](#heading)`, lands on exactly one existing target. The link itself is never rewritten, nothing outside the markers is.
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
npx toc --check docs/*.md
|
|
206
|
+
```
|
|
191
207
|
|
|
192
208
|
### Dry run
|
|
193
209
|
|
|
@@ -227,6 +243,7 @@ To regenerate the toc and format the rest of the file in one go, let a `posttoc`
|
|
|
227
243
|
- [`buildToc(source)`](#buildtocsource)
|
|
228
244
|
- [`renderToc(source[, fromLine[, options]])`](#rendertocsource-fromline-options)
|
|
229
245
|
- [`findMarkers(source)`](#findmarkerssource)
|
|
246
|
+
- [`findAnchors(source)`](#findanchorssource)
|
|
230
247
|
- [`slugify(text)`](#slugifytext)
|
|
231
248
|
- [`headingText(markdown)`](#headingtextmarkdown)
|
|
232
249
|
- [`TOC_START` and `TOC_END`](#toc_start-and-toc_end)
|
|
@@ -240,7 +257,7 @@ To regenerate the toc and format the rest of the file in one go, let a `posttoc`
|
|
|
240
257
|
<!-- /toc -->
|
|
241
258
|
|
|
242
259
|
```javascript
|
|
243
|
-
import { buildToc, renderToc, findMarkers, slugify, headingText, TOC_START, TOC_END } from '@0dep/toc';
|
|
260
|
+
import { buildToc, renderToc, findMarkers, findAnchors, slugify, headingText, TOC_START, TOC_END } from '@0dep/toc';
|
|
244
261
|
```
|
|
245
262
|
|
|
246
263
|
CommonJS works the same way with `require('@0dep/toc')`.
|
|
@@ -308,9 +325,27 @@ console.log(findMarkers('# Title\n\n<!-- toc collapsed -->\n<!-- /toc -->\n\n##
|
|
|
308
325
|
]
|
|
309
326
|
```
|
|
310
327
|
|
|
328
|
+
### `findAnchors(source)`
|
|
329
|
+
|
|
330
|
+
Returns every link to an anchor in document order as `{ line, text, anchor, valid }`: the zero based line, the link text, the anchor without the `#` and with backslash escapes resolved, and whether the document has a heading slug, an html `id` or an `<a name>` for it. Links and targets inside fenced and indented code blocks, code spans and html comments are ignored. When there is no target and exactly one heading matches the slugged anchor or the slugged link text, `suggestion` names it, and is otherwise absent.
|
|
331
|
+
|
|
332
|
+
```javascript
|
|
333
|
+
import { findAnchors } from '@0dep/toc';
|
|
334
|
+
|
|
335
|
+
console.log(findAnchors('## My Heading\n\n[a](#my-heading) [b](#My-Heading) [c](#nope)\n'));
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
```text
|
|
339
|
+
[
|
|
340
|
+
{ line: 2, text: 'a', anchor: 'my-heading', valid: true },
|
|
341
|
+
{ line: 2, text: 'b', anchor: 'My-Heading', valid: false, suggestion: 'my-heading' },
|
|
342
|
+
{ line: 2, text: 'c', anchor: 'nope', valid: false }
|
|
343
|
+
]
|
|
344
|
+
```
|
|
345
|
+
|
|
311
346
|
### `slugify(text)`
|
|
312
347
|
|
|
313
|
-
The github-slugger algorithm: lowercase, drop everything that is not a letter, number, mark, space, hyphen or underscore, then turn spaces into hyphens. Nothing is trimmed or collapsed. Duplicate slugs
|
|
348
|
+
The github-slugger algorithm: lowercase, drop everything that is not a letter, number, mark, space, hyphen or underscore, then turn spaces into hyphens. Nothing is trimmed or collapsed. Duplicate slugs get `-1`, `-2` and so on, counted over the whole document, like GitHub.
|
|
314
349
|
|
|
315
350
|
```javascript
|
|
316
351
|
import { slugify } from '@0dep/toc';
|
|
@@ -370,7 +405,7 @@ The link text is the heading's own markdown, so inline code and emphasis are kep
|
|
|
370
405
|
|
|
371
406
|
### Slugs
|
|
372
407
|
|
|
373
|
-
Slugs are built from the rendered heading text with the same algorithm as GitHub, so the anchors work on github.com and in every renderer that follows it. See `slugify` and `headingText` above.
|
|
408
|
+
Slugs are built from the rendered heading text with the same algorithm as GitHub, so the anchors work on github.com and in every renderer that follows it. Duplicates are numbered over the whole document, headings above the markers included. See `slugify` and `headingText` above.
|
|
374
409
|
|
|
375
410
|
## License
|
|
376
411
|
|
package/bin/toc.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/* eslint-disable no-console */
|
|
3
3
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
|
|
5
|
-
import { buildToc, findMarkers, renderToc } from '../index.js';
|
|
5
|
+
import { buildToc, findAnchors, findMarkers, renderToc } from '../index.js';
|
|
6
6
|
|
|
7
7
|
const USAGE = `Usage: toc [options] [file...]
|
|
8
8
|
|
|
@@ -10,7 +10,9 @@ Update the table of contents between every <!-- toc --> and <!-- /toc -->
|
|
|
10
10
|
pair in markdown files. Each pair lists the headings below its own start
|
|
11
11
|
marker. A file without markers is skipped, and so is a pair that is
|
|
12
12
|
unbalanced, has a problem on its start marker, or has no headings below
|
|
13
|
-
it, each with a warning on stderr.
|
|
13
|
+
it, each with a warning on stderr. Every link to an anchor, [text](#slug),
|
|
14
|
+
is checked against the headings and html ids in the file and a link
|
|
15
|
+
without a target gets a warning too.
|
|
14
16
|
|
|
15
17
|
Markers:
|
|
16
18
|
<!-- toc --> plain list
|
|
@@ -18,7 +20,9 @@ Markers:
|
|
|
18
20
|
<!-- toc collapsed --> same, but closed until clicked
|
|
19
21
|
<!-- toc collapsed="Contents" --> either one with a custom summary text
|
|
20
22
|
<!-- toc collapsed class="toc" --> other name="value" pairs go on <summary>
|
|
21
|
-
<!-- /toc --> end of the block
|
|
23
|
+
<!-- /toc --> end of the block
|
|
24
|
+
|
|
25
|
+
Files are resolved against the current directory and default to
|
|
22
26
|
README.md. Several files can be given as separate arguments or comma
|
|
23
27
|
separated.
|
|
24
28
|
|
|
@@ -26,13 +30,14 @@ Options:
|
|
|
26
30
|
-n, --dry-run print the toc to stdout and report on stderr, write nothing.
|
|
27
31
|
Without markers every heading is listed so the block can be
|
|
28
32
|
pasted into the document
|
|
33
|
+
-c, --check exit with 1 when a link to an anchor has no target
|
|
29
34
|
-h, --help show this help
|
|
30
35
|
`;
|
|
31
36
|
|
|
32
37
|
await main();
|
|
33
38
|
|
|
34
39
|
async function main() {
|
|
35
|
-
const { files,
|
|
40
|
+
const { files, help, unknown, ...flags } = parseArgs(process.argv.slice(2));
|
|
36
41
|
|
|
37
42
|
if (unknown) {
|
|
38
43
|
console.error(`Unknown option: ${unknown}\n\n${USAGE}`);
|
|
@@ -46,7 +51,7 @@ async function main() {
|
|
|
46
51
|
|
|
47
52
|
for (const file of files) {
|
|
48
53
|
try {
|
|
49
|
-
await processFile(file,
|
|
54
|
+
await processFile(file, flags);
|
|
50
55
|
} catch (err) {
|
|
51
56
|
console.error(`${file}: ${/** @type {Error} */ (err).message}`);
|
|
52
57
|
process.exitCode = 1;
|
|
@@ -55,26 +60,52 @@ async function main() {
|
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/**
|
|
58
|
-
*
|
|
59
|
-
* of every start marker goes to stdout instead of the file, so it can be piped, and the status line moves to
|
|
60
|
-
* stderr. A start marker that will be skipped still gets its block printed, plain when its options are broken,
|
|
61
|
-
* so the author sees the list either way.
|
|
63
|
+
* Update the toc and check the anchors of one file, or report what would happen with `dryRun`.
|
|
62
64
|
* @param {string} file
|
|
63
|
-
* @param {boolean}
|
|
65
|
+
* @param {{ dryRun: boolean, check: boolean }} flags
|
|
64
66
|
*/
|
|
65
|
-
async function processFile(file, dryRun) {
|
|
67
|
+
async function processFile(file, { dryRun, check }) {
|
|
66
68
|
const source = await readFile(file, 'utf8');
|
|
69
|
+
const status = dryRun ? console.error : console.log;
|
|
70
|
+
const warn = console.error;
|
|
71
|
+
|
|
72
|
+
const regenerated = reportMarkers(file, source, dryRun);
|
|
73
|
+
let updated = source;
|
|
74
|
+
if (regenerated.length) {
|
|
75
|
+
updated = buildToc(source);
|
|
76
|
+
if (updated === source) status(`${file}: TOC already up to date.`);
|
|
77
|
+
else status(`${file}: ${dryRun ? 'would write' : 'wrote'} TOC.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const { line, anchor, valid, suggestion } of findAnchors(source)) {
|
|
81
|
+
if (valid || regenerated.some(({ start, end }) => line >= start && line <= end)) continue;
|
|
82
|
+
if (suggestion) warn(`${file}:${line + 1}: anchor #${anchor} has no target, did you mean #${suggestion}?`);
|
|
83
|
+
else warn(`${file}:${line + 1}: anchor #${anchor} has no target.`);
|
|
84
|
+
if (check) process.exitCode = 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!dryRun && updated !== source) await writeFile(file, updated);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} file
|
|
92
|
+
* @param {string} source
|
|
93
|
+
* @param {boolean} dryRun
|
|
94
|
+
* @returns {Array<{ start: number, end: number }>}
|
|
95
|
+
*/
|
|
96
|
+
function reportMarkers(file, source, dryRun) {
|
|
67
97
|
const markers = findMarkers(source);
|
|
68
98
|
const warn = console.error;
|
|
99
|
+
/** @type {Array<{ start: number, end: number }>} */
|
|
100
|
+
const regenerated = [];
|
|
69
101
|
|
|
70
102
|
if (markers.length === 0) {
|
|
71
103
|
const toc = renderToc(source);
|
|
72
104
|
if (dryRun && toc) console.log(toc);
|
|
73
105
|
warn(`${file}: no TOC markers, skipped.`);
|
|
74
|
-
return;
|
|
106
|
+
return regenerated;
|
|
75
107
|
}
|
|
76
108
|
|
|
77
|
-
let updatable = 0;
|
|
78
109
|
for (const { start, end, options, problem } of markers) {
|
|
79
110
|
if (start === -1) {
|
|
80
111
|
warn(`${file}:${end + 1}: TOC end marker without start marker, skipped.`);
|
|
@@ -85,35 +116,25 @@ async function processFile(file, dryRun) {
|
|
|
85
116
|
if (problem) warn(`${file}:${start + 1}: ${problem}, skipped.`);
|
|
86
117
|
else if (end === -1) warn(`${file}:${start + 1}: TOC start marker without end marker, skipped.`);
|
|
87
118
|
else if (!toc) warn(`${file}:${start + 1}: no headings below TOC start marker, skipped.`);
|
|
88
|
-
else
|
|
89
|
-
}
|
|
90
|
-
if (updatable === 0) return;
|
|
91
|
-
|
|
92
|
-
const updated = buildToc(source);
|
|
93
|
-
if (updated === source) {
|
|
94
|
-
(dryRun ? console.error : console.log)(`${file}: TOC already up to date.`);
|
|
95
|
-
} else if (dryRun) {
|
|
96
|
-
console.error(`${file}: would write TOC.`);
|
|
97
|
-
} else {
|
|
98
|
-
await writeFile(file, updated);
|
|
99
|
-
console.log(`${file}: wrote TOC.`);
|
|
119
|
+
else regenerated.push({ start, end });
|
|
100
120
|
}
|
|
121
|
+
return regenerated;
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
/**
|
|
104
125
|
* @param {string[]} argv
|
|
105
|
-
* @returns {{ files: string[], dryRun: boolean, help: boolean, unknown?: string }}
|
|
126
|
+
* @returns {{ files: string[], dryRun: boolean, check: boolean, help: boolean, unknown?: string }}
|
|
106
127
|
*/
|
|
107
128
|
function parseArgs(argv) {
|
|
108
129
|
/** @type {string[]} */
|
|
109
130
|
const files = [];
|
|
110
|
-
|
|
111
|
-
let help = false;
|
|
131
|
+
const flags = { dryRun: false, check: false, help: false };
|
|
112
132
|
for (const arg of argv) {
|
|
113
|
-
if (arg === '-n' || arg === '--dry-run') dryRun = true;
|
|
114
|
-
else if (arg === '-
|
|
115
|
-
else if (arg
|
|
133
|
+
if (arg === '-n' || arg === '--dry-run') flags.dryRun = true;
|
|
134
|
+
else if (arg === '-c' || arg === '--check') flags.check = true;
|
|
135
|
+
else if (arg === '-h' || arg === '--help') flags.help = true;
|
|
136
|
+
else if (arg.startsWith('-')) return { files, ...flags, unknown: arg };
|
|
116
137
|
else files.push(...arg.split(',').filter(Boolean));
|
|
117
138
|
}
|
|
118
|
-
return { files: files.length ? files : ['README.md'],
|
|
139
|
+
return { files: files.length ? files : ['README.md'], ...flags };
|
|
119
140
|
}
|
package/index.cjs
CHANGED
|
@@ -5,18 +5,17 @@ const TOC_END = '<!-- /toc -->';
|
|
|
5
5
|
const DEFAULT_SUMMARY = 'Table of contents';
|
|
6
6
|
const KNOWN_OPTIONS = ['collapsible', 'collapsed'];
|
|
7
7
|
const NUL = String.fromCharCode(0);
|
|
8
|
+
/** @type {Map<string, ReturnType<typeof analyse>>} */
|
|
9
|
+
const cache = new Map();
|
|
10
|
+
const LINK = /\[([^\]]*)\]\(#([^)\s]+)/g;
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
|
-
*
|
|
11
|
-
* pair lists every heading below its own start marker. A pair is left alone when it is unbalanced, has a
|
|
12
|
-
* problem on its start marker, or has no headings below it, and nothing outside the pairs is ever touched.
|
|
13
|
-
* The start marker line is kept as written, options included.
|
|
13
|
+
* The markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated.
|
|
14
14
|
* @param {string} source
|
|
15
15
|
* @returns {string}
|
|
16
16
|
*/
|
|
17
17
|
function buildToc(source) {
|
|
18
|
-
const { lines, eol } =
|
|
19
|
-
const { headlines, markers } = scan(lines);
|
|
18
|
+
const { bom, lines, eol, headlines, markers } = analyse(source);
|
|
20
19
|
/** @type {string[]} */
|
|
21
20
|
const out = [];
|
|
22
21
|
let cursor = 0;
|
|
@@ -28,39 +27,42 @@ function buildToc(source) {
|
|
|
28
27
|
cursor = end + 1;
|
|
29
28
|
}
|
|
30
29
|
out.push(...lines.slice(cursor));
|
|
31
|
-
return out.join(eol);
|
|
30
|
+
return bom + out.join(eol);
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
/**
|
|
35
|
-
*
|
|
36
|
-
* block can be pasted into a document without markers. Returns an empty string when there is nothing to list.
|
|
34
|
+
* The toc block, markers included, for the headings below `fromLine`, empty when there is nothing to list.
|
|
37
35
|
* @param {string} source
|
|
38
|
-
* @param {number} [fromLine] zero based
|
|
39
|
-
* @param {TocOptions} [options]
|
|
36
|
+
* @param {number} [fromLine] zero based
|
|
37
|
+
* @param {TocOptions} [options]
|
|
40
38
|
* @returns {string}
|
|
41
39
|
*/
|
|
42
40
|
function renderToc(source, fromLine = -1, options = {}) {
|
|
43
|
-
const {
|
|
44
|
-
const listed =
|
|
41
|
+
const { eol, headlines } = analyse(source);
|
|
42
|
+
const listed = headlines.filter((h) => h.line > fromLine);
|
|
45
43
|
return listed.length === 0 ? '' : renderBlock(listed, formatMarker(options), options, eol);
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
/**
|
|
49
|
-
* Every marker pair in document order
|
|
50
|
-
* pairs with the first end marker after it. A missing side is -1: a start marker without an end marker, or an
|
|
51
|
-
* end marker with no open start marker before it. `options` holds the recognised options written on the start
|
|
52
|
-
* marker and `problem`, only present when there is one, says why the marker cannot be used.
|
|
47
|
+
* Every marker pair in document order, shared with later calls for the same source.
|
|
53
48
|
* @param {string} source
|
|
54
49
|
* @returns {Marker[]}
|
|
55
50
|
*/
|
|
56
51
|
function findMarkers(source) {
|
|
57
|
-
return
|
|
52
|
+
return analyse(source).markers;
|
|
58
53
|
}
|
|
59
54
|
|
|
60
55
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
56
|
+
* Every link to an anchor in the document, in order, shared with later calls for the same source.
|
|
57
|
+
* @param {string} source
|
|
58
|
+
* @returns {Anchor[]}
|
|
59
|
+
*/
|
|
60
|
+
function findAnchors(source) {
|
|
61
|
+
return analyse(source).anchors;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The GitHub anchor slug of a heading's rendered text.
|
|
64
66
|
* @param {string} text
|
|
65
67
|
* @returns {string}
|
|
66
68
|
*/
|
|
@@ -72,7 +74,7 @@ function slugify(text) {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/**
|
|
75
|
-
*
|
|
77
|
+
* The text GitHub renders for a heading's inline markdown.
|
|
76
78
|
* @param {string} markdown
|
|
77
79
|
* @returns {string}
|
|
78
80
|
*/
|
|
@@ -87,33 +89,51 @@ function headingText(markdown) {
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
+
* The lines and the scan of the source.
|
|
93
|
+
* @param {string} source
|
|
94
|
+
* @returns {{ bom: string, lines: string[], eol: string, headlines: Headline[], markers: Marker[], anchors: Anchor[] }}
|
|
95
|
+
*/
|
|
96
|
+
function analyse(source) {
|
|
97
|
+
let result = cache.get(source);
|
|
98
|
+
if (!result) {
|
|
99
|
+
const { bom, lines, eol } = splitLines(source);
|
|
100
|
+
result = { bom, lines, eol, ...scan(lines) };
|
|
101
|
+
cache.clear();
|
|
102
|
+
cache.set(source, result);
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The lines of the source, its byte order mark if any, and its line ending.
|
|
92
109
|
* @param {string} source
|
|
93
|
-
* @returns {{ lines: string[], eol: string }}
|
|
110
|
+
* @returns {{ bom: string, lines: string[], eol: string }}
|
|
94
111
|
*/
|
|
95
112
|
function splitLines(source) {
|
|
96
|
-
|
|
113
|
+
const bom = source.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
114
|
+
return { bom, lines: source.slice(bom.length).split(/\r?\n/), eol: source.includes('\r\n') ? '\r\n' : '\n' };
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
/**
|
|
100
|
-
*
|
|
101
|
-
* marker is a line holding nothing but the comment, indented at most three spaces like a heading, since four
|
|
102
|
-
* make an indented code block. A start marker, `<!-- toc -->` with optional options before the closing `-->`,
|
|
103
|
-
* opens a pair that the first end marker after it closes; a start marker inside an open pair is content and an end marker outside a pair is
|
|
104
|
-
* reported with start -1.
|
|
118
|
+
* The headings, marker pairs and anchor links of the lines.
|
|
105
119
|
* @param {string[]} lines
|
|
106
|
-
* @returns {{ headlines:
|
|
120
|
+
* @returns {{ headlines: Headline[], markers: Marker[], anchors: Anchor[] }}
|
|
107
121
|
*/
|
|
108
122
|
function scan(lines) {
|
|
109
|
-
/** @type {
|
|
123
|
+
/** @type {Headline[]} */
|
|
110
124
|
const headlines = [];
|
|
111
125
|
/** @type {Marker[]} */
|
|
112
126
|
const markers = [];
|
|
127
|
+
/** @type {Array<{ line: number, text: string, anchor: string }>} */
|
|
128
|
+
const links = [];
|
|
129
|
+
/** @type {Set<string>} */
|
|
130
|
+
const ids = new Set();
|
|
113
131
|
/** @type {{ char: string, length: number } | null} */
|
|
114
132
|
let fence = null;
|
|
115
133
|
/** @type {Marker | null} */
|
|
116
134
|
let open = null;
|
|
135
|
+
let paragraph = false;
|
|
136
|
+
let comment = false;
|
|
117
137
|
|
|
118
138
|
for (const [i, line] of lines.entries()) {
|
|
119
139
|
const fenceMatch = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
@@ -125,8 +145,32 @@ function scan(lines) {
|
|
|
125
145
|
}
|
|
126
146
|
if (fenceMatch && !(fenceMatch[1][0] === '`' && fenceMatch[2].includes('`'))) {
|
|
127
147
|
fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
148
|
+
paragraph = false;
|
|
128
149
|
continue;
|
|
129
150
|
}
|
|
151
|
+
if (!paragraph && /^(?: {4}|\t)/.test(line)) continue;
|
|
152
|
+
paragraph = line.trim() !== '';
|
|
153
|
+
|
|
154
|
+
const { text: inline, restore } = protect(line);
|
|
155
|
+
let visible = inline;
|
|
156
|
+
if (comment) {
|
|
157
|
+
const close = visible.indexOf('-->');
|
|
158
|
+
if (close === -1) continue;
|
|
159
|
+
visible = visible.slice(close + 3);
|
|
160
|
+
comment = false;
|
|
161
|
+
}
|
|
162
|
+
visible = visible.replace(/<!--[\s\S]*?-->/g, '');
|
|
163
|
+
const start = visible.indexOf('<!--');
|
|
164
|
+
if (start !== -1) {
|
|
165
|
+
visible = visible.slice(0, start);
|
|
166
|
+
comment = true;
|
|
167
|
+
}
|
|
168
|
+
for (const [, tag, attributes] of visible.matchAll(/<([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/g)) {
|
|
169
|
+
for (const [, name, quoted, single] of attributes.matchAll(/(?:^|\s)(id|name)=(?:"([^"]*)"|'([^']*)')/g)) {
|
|
170
|
+
if (name === 'id' || tag.toLowerCase() === 'a') ids.add(quoted ?? single);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const [, text, anchor] of visible.matchAll(LINK)) links.push({ line: i, text: restore(text), anchor: restore(anchor) });
|
|
130
174
|
|
|
131
175
|
const startMatch = /^ {0,3}<!--\s*toc(?:\s+(.*?))?\s*-->\s*$/.exec(line);
|
|
132
176
|
if (startMatch) {
|
|
@@ -142,42 +186,75 @@ function scan(lines) {
|
|
|
142
186
|
|
|
143
187
|
const atx = /^ {0,3}(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
144
188
|
if (atx) {
|
|
145
|
-
headlines.push({ line: i, level: atx[1].length, markdown: atx[2] });
|
|
189
|
+
headlines.push({ line: i, level: atx[1].length, markdown: atx[2], slug: '' });
|
|
146
190
|
continue;
|
|
147
191
|
}
|
|
148
192
|
|
|
149
193
|
const setext = /^ {0,3}(=+|-+)\s*$/.exec(line);
|
|
150
194
|
if (setext && i > 0 && isParagraphText(lines[i - 1])) {
|
|
151
|
-
headlines.push({ line: i - 1, level: setext[1][0] === '=' ? 1 : 2, markdown: lines[i - 1].trim() });
|
|
195
|
+
headlines.push({ line: i - 1, level: setext[1][0] === '=' ? 1 : 2, markdown: lines[i - 1].trim(), slug: '' });
|
|
152
196
|
}
|
|
153
197
|
}
|
|
154
|
-
|
|
198
|
+
assignSlugs(headlines);
|
|
199
|
+
return { headlines, markers, anchors: resolveAnchors(links, headlines, ids) };
|
|
155
200
|
}
|
|
156
201
|
|
|
157
202
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* lines around it so it renders as markdown, and any other attributes from the marker on the summary element.
|
|
163
|
-
* @param {Array<{ level: number, markdown: string }>} headlines
|
|
164
|
-
* @param {string} startLine the start marker as written in the document
|
|
165
|
-
* @param {TocOptions} options
|
|
166
|
-
* @param {string} [eol] line ending, LF by default
|
|
203
|
+
* @param {Array<{ line: number, text: string, anchor: string }>} links
|
|
204
|
+
* @param {Headline[]} headlines
|
|
205
|
+
* @param {Set<string>} ids
|
|
206
|
+
* @returns {Anchor[]}
|
|
167
207
|
*/
|
|
168
|
-
function
|
|
208
|
+
function resolveAnchors(links, headlines, ids) {
|
|
209
|
+
const targets = new Set([...headlines.map((h) => h.slug), ...ids]);
|
|
210
|
+
return links.map(({ line, text, anchor }) => {
|
|
211
|
+
const decoded = decodeAnchor(anchor);
|
|
212
|
+
if (targets.has(anchor) || targets.has(decoded)) return { line, text, anchor, valid: true };
|
|
213
|
+
const candidates = new Set([slugify(headingText(decoded)), slugify(headingText(text))].filter((c) => targets.has(c)));
|
|
214
|
+
if (candidates.size !== 1) return { line, text, anchor, valid: false };
|
|
215
|
+
return { line, text, anchor, valid: false, suggestion: [...candidates][0] };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @param {string} anchor */
|
|
220
|
+
function decodeAnchor(anchor) {
|
|
221
|
+
try {
|
|
222
|
+
return decodeURIComponent(anchor);
|
|
223
|
+
} catch {
|
|
224
|
+
return anchor;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Set the slug of every heading.
|
|
230
|
+
* @param {Headline[]} headlines
|
|
231
|
+
*/
|
|
232
|
+
function assignSlugs(headlines) {
|
|
169
233
|
/** @type {Record<string, number>} */
|
|
170
234
|
const occurrences = {};
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
minLevel = Math.min(minLevel, level);
|
|
174
|
-
const base = slugify(headingText(markdown));
|
|
235
|
+
for (const headline of headlines) {
|
|
236
|
+
const base = slugify(headingText(headline.markdown));
|
|
175
237
|
let slug = base;
|
|
176
238
|
while (Object.hasOwn(occurrences, slug)) {
|
|
177
239
|
occurrences[base]++;
|
|
178
240
|
slug = `${base}-${occurrences[base]}`;
|
|
179
241
|
}
|
|
180
242
|
occurrences[slug] = 0;
|
|
243
|
+
headline.slug = slug;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The toc block for the headings, between the start marker line and the end marker.
|
|
249
|
+
* @param {Headline[]} headlines
|
|
250
|
+
* @param {string} startLine
|
|
251
|
+
* @param {TocOptions} options
|
|
252
|
+
* @param {string} [eol]
|
|
253
|
+
*/
|
|
254
|
+
function renderBlock(headlines, startLine, options, eol = '\n') {
|
|
255
|
+
let minLevel = Infinity;
|
|
256
|
+
const tocLines = headlines.map(({ level, markdown, slug }) => {
|
|
257
|
+
minLevel = Math.min(minLevel, level);
|
|
181
258
|
return `${' '.repeat(level - minLevel)}- [${headingLabel(markdown)}](#${slug})`;
|
|
182
259
|
});
|
|
183
260
|
const details = options.collapsible ?? options.collapsed;
|
|
@@ -191,10 +268,7 @@ function renderBlock(headlines, startLine, options, eol = '\n') {
|
|
|
191
268
|
}
|
|
192
269
|
|
|
193
270
|
/**
|
|
194
|
-
*
|
|
195
|
-
* (`collapsed="Contents"`), and any other well-formed `name="value"` as an attribute for the summary element.
|
|
196
|
-
* Anything else, bare names that are not options, unquoted values, malformed names, is a problem, as is a
|
|
197
|
-
* combination that makes no sense. A marker with a problem is never used.
|
|
271
|
+
* The options written on a start marker, with a problem when they cannot be used.
|
|
198
272
|
* @param {string | undefined} text
|
|
199
273
|
* @returns {{ options: TocOptions, problem?: string }}
|
|
200
274
|
*/
|
|
@@ -223,7 +297,7 @@ function parseOptions(text) {
|
|
|
223
297
|
}
|
|
224
298
|
|
|
225
299
|
/**
|
|
226
|
-
* The start marker line for the
|
|
300
|
+
* The start marker line for the options.
|
|
227
301
|
* @param {TocOptions} options
|
|
228
302
|
*/
|
|
229
303
|
function formatMarker(options) {
|
|
@@ -237,15 +311,14 @@ function formatMarker(options) {
|
|
|
237
311
|
|
|
238
312
|
/**
|
|
239
313
|
* @param {Record<string, string>} attributes
|
|
240
|
-
* @returns {string[]}
|
|
314
|
+
* @returns {string[]}
|
|
241
315
|
*/
|
|
242
316
|
function formatAttributes(attributes) {
|
|
243
317
|
return Object.entries(attributes).map(([name, value]) => `${name}="${value}"`);
|
|
244
318
|
}
|
|
245
319
|
|
|
246
320
|
/**
|
|
247
|
-
* The
|
|
248
|
-
* cannot nest inside the toc link.
|
|
321
|
+
* The link text used in the toc for a heading.
|
|
249
322
|
* @param {string} markdown
|
|
250
323
|
* @returns {string}
|
|
251
324
|
*/
|
|
@@ -255,8 +328,7 @@ function headingLabel(markdown) {
|
|
|
255
328
|
}
|
|
256
329
|
|
|
257
330
|
/**
|
|
258
|
-
*
|
|
259
|
-
* (with the escaped character) by placeholders so the inline markdown passes leave them alone.
|
|
331
|
+
* The markdown with code spans and backslash escapes swapped for placeholders, and a function to put them back.
|
|
260
332
|
* @param {string} markdown
|
|
261
333
|
* @param {{ keepCodeSpans?: boolean }} [options]
|
|
262
334
|
*/
|
|
@@ -276,8 +348,7 @@ function protect(markdown, { keepCodeSpans = false } = {}) {
|
|
|
276
348
|
}
|
|
277
349
|
|
|
278
350
|
/**
|
|
279
|
-
*
|
|
280
|
-
* not only spaces, as CommonMark does.
|
|
351
|
+
* The code span content unpadded as CommonMark renders it.
|
|
281
352
|
* @param {string} code
|
|
282
353
|
*/
|
|
283
354
|
function unpadCodeSpan(code) {
|
|
@@ -291,8 +362,7 @@ function stripClosingHashes(markdown) {
|
|
|
291
362
|
}
|
|
292
363
|
|
|
293
364
|
/**
|
|
294
|
-
*
|
|
295
|
-
* the toc label since a link cannot nest inside the toc link.
|
|
365
|
+
* The text with links, images and autolinks flattened to their text or url.
|
|
296
366
|
* @param {string} text
|
|
297
367
|
*/
|
|
298
368
|
function stripLinks(text) {
|
|
@@ -310,22 +380,30 @@ function isParagraphText(line) {
|
|
|
310
380
|
}
|
|
311
381
|
|
|
312
382
|
/**
|
|
313
|
-
* Options written on a start marker
|
|
314
|
-
* `collapsed` in one that starts closed. Each is `true` for the default summary "Table of contents" or a
|
|
315
|
-
* string for a custom one. `attributes` are the other `name="value"` pairs on the marker, rendered on the
|
|
316
|
-
* summary element in the order written, only present when there are any.
|
|
383
|
+
* Options written on a start marker, each `true` or a summary text, and the other attributes for the summary element.
|
|
317
384
|
* @typedef {{ collapsible?: true | string, collapsed?: true | string, attributes?: Record<string, string> }} TocOptions
|
|
318
385
|
*/
|
|
319
386
|
|
|
320
387
|
/**
|
|
321
|
-
* A
|
|
322
|
-
*
|
|
388
|
+
* A heading with its zero based line, level, inline markdown and GitHub slug.
|
|
389
|
+
* @typedef {{ line: number, level: number, markdown: string, slug: string }} Headline
|
|
390
|
+
*/
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A link to an anchor with its zero based line, text, anchor as written, whether it has a target and, when it
|
|
394
|
+
* has none and one heading clearly matches, a suggestion.
|
|
395
|
+
* @typedef {{ line: number, text: string, anchor: string, valid: boolean, suggestion?: string }} Anchor
|
|
396
|
+
*/
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* A marker pair as zero based lines, -1 for a missing side, with its options and a problem when they cannot be used.
|
|
323
400
|
* @typedef {{ start: number, end: number, options: TocOptions, problem?: string }} Marker
|
|
324
401
|
*/
|
|
325
402
|
|
|
326
403
|
exports.TOC_END = TOC_END;
|
|
327
404
|
exports.TOC_START = TOC_START;
|
|
328
405
|
exports.buildToc = buildToc;
|
|
406
|
+
exports.findAnchors = findAnchors;
|
|
329
407
|
exports.findMarkers = findMarkers;
|
|
330
408
|
exports.headingText = headingText;
|
|
331
409
|
exports.renderToc = renderToc;
|
package/index.js
CHANGED
|
@@ -3,18 +3,17 @@ export const TOC_END = '<!-- /toc -->';
|
|
|
3
3
|
const DEFAULT_SUMMARY = 'Table of contents';
|
|
4
4
|
const KNOWN_OPTIONS = ['collapsible', 'collapsed'];
|
|
5
5
|
const NUL = String.fromCharCode(0);
|
|
6
|
+
/** @type {Map<string, ReturnType<typeof analyse>>} */
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
const LINK = /\[([^\]]*)\]\(#([^)\s]+)/g;
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
|
-
*
|
|
9
|
-
* pair lists every heading below its own start marker. A pair is left alone when it is unbalanced, has a
|
|
10
|
-
* problem on its start marker, or has no headings below it, and nothing outside the pairs is ever touched.
|
|
11
|
-
* The start marker line is kept as written, options included.
|
|
11
|
+
* The markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated.
|
|
12
12
|
* @param {string} source
|
|
13
13
|
* @returns {string}
|
|
14
14
|
*/
|
|
15
15
|
export function buildToc(source) {
|
|
16
|
-
const { lines, eol } =
|
|
17
|
-
const { headlines, markers } = scan(lines);
|
|
16
|
+
const { bom, lines, eol, headlines, markers } = analyse(source);
|
|
18
17
|
/** @type {string[]} */
|
|
19
18
|
const out = [];
|
|
20
19
|
let cursor = 0;
|
|
@@ -26,39 +25,42 @@ export function buildToc(source) {
|
|
|
26
25
|
cursor = end + 1;
|
|
27
26
|
}
|
|
28
27
|
out.push(...lines.slice(cursor));
|
|
29
|
-
return out.join(eol);
|
|
28
|
+
return bom + out.join(eol);
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
/**
|
|
33
|
-
*
|
|
34
|
-
* block can be pasted into a document without markers. Returns an empty string when there is nothing to list.
|
|
32
|
+
* The toc block, markers included, for the headings below `fromLine`, empty when there is nothing to list.
|
|
35
33
|
* @param {string} source
|
|
36
|
-
* @param {number} [fromLine] zero based
|
|
37
|
-
* @param {TocOptions} [options]
|
|
34
|
+
* @param {number} [fromLine] zero based
|
|
35
|
+
* @param {TocOptions} [options]
|
|
38
36
|
* @returns {string}
|
|
39
37
|
*/
|
|
40
38
|
export function renderToc(source, fromLine = -1, options = {}) {
|
|
41
|
-
const {
|
|
42
|
-
const listed =
|
|
39
|
+
const { eol, headlines } = analyse(source);
|
|
40
|
+
const listed = headlines.filter((h) => h.line > fromLine);
|
|
43
41
|
return listed.length === 0 ? '' : renderBlock(listed, formatMarker(options), options, eol);
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
/**
|
|
47
|
-
* Every marker pair in document order
|
|
48
|
-
* pairs with the first end marker after it. A missing side is -1: a start marker without an end marker, or an
|
|
49
|
-
* end marker with no open start marker before it. `options` holds the recognised options written on the start
|
|
50
|
-
* marker and `problem`, only present when there is one, says why the marker cannot be used.
|
|
45
|
+
* Every marker pair in document order, shared with later calls for the same source.
|
|
51
46
|
* @param {string} source
|
|
52
47
|
* @returns {Marker[]}
|
|
53
48
|
*/
|
|
54
49
|
export function findMarkers(source) {
|
|
55
|
-
return
|
|
50
|
+
return analyse(source).markers;
|
|
56
51
|
}
|
|
57
52
|
|
|
58
53
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
54
|
+
* Every link to an anchor in the document, in order, shared with later calls for the same source.
|
|
55
|
+
* @param {string} source
|
|
56
|
+
* @returns {Anchor[]}
|
|
57
|
+
*/
|
|
58
|
+
export function findAnchors(source) {
|
|
59
|
+
return analyse(source).anchors;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The GitHub anchor slug of a heading's rendered text.
|
|
62
64
|
* @param {string} text
|
|
63
65
|
* @returns {string}
|
|
64
66
|
*/
|
|
@@ -70,7 +72,7 @@ export function slugify(text) {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/**
|
|
73
|
-
*
|
|
75
|
+
* The text GitHub renders for a heading's inline markdown.
|
|
74
76
|
* @param {string} markdown
|
|
75
77
|
* @returns {string}
|
|
76
78
|
*/
|
|
@@ -85,33 +87,51 @@ export function headingText(markdown) {
|
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
+
* The lines and the scan of the source.
|
|
91
|
+
* @param {string} source
|
|
92
|
+
* @returns {{ bom: string, lines: string[], eol: string, headlines: Headline[], markers: Marker[], anchors: Anchor[] }}
|
|
93
|
+
*/
|
|
94
|
+
function analyse(source) {
|
|
95
|
+
let result = cache.get(source);
|
|
96
|
+
if (!result) {
|
|
97
|
+
const { bom, lines, eol } = splitLines(source);
|
|
98
|
+
result = { bom, lines, eol, ...scan(lines) };
|
|
99
|
+
cache.clear();
|
|
100
|
+
cache.set(source, result);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The lines of the source, its byte order mark if any, and its line ending.
|
|
90
107
|
* @param {string} source
|
|
91
|
-
* @returns {{ lines: string[], eol: string }}
|
|
108
|
+
* @returns {{ bom: string, lines: string[], eol: string }}
|
|
92
109
|
*/
|
|
93
110
|
function splitLines(source) {
|
|
94
|
-
|
|
111
|
+
const bom = source.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
112
|
+
return { bom, lines: source.slice(bom.length).split(/\r?\n/), eol: source.includes('\r\n') ? '\r\n' : '\n' };
|
|
95
113
|
}
|
|
96
114
|
|
|
97
115
|
/**
|
|
98
|
-
*
|
|
99
|
-
* marker is a line holding nothing but the comment, indented at most three spaces like a heading, since four
|
|
100
|
-
* make an indented code block. A start marker, `<!-- toc -->` with optional options before the closing `-->`,
|
|
101
|
-
* opens a pair that the first end marker after it closes; a start marker inside an open pair is content and an end marker outside a pair is
|
|
102
|
-
* reported with start -1.
|
|
116
|
+
* The headings, marker pairs and anchor links of the lines.
|
|
103
117
|
* @param {string[]} lines
|
|
104
|
-
* @returns {{ headlines:
|
|
118
|
+
* @returns {{ headlines: Headline[], markers: Marker[], anchors: Anchor[] }}
|
|
105
119
|
*/
|
|
106
120
|
function scan(lines) {
|
|
107
|
-
/** @type {
|
|
121
|
+
/** @type {Headline[]} */
|
|
108
122
|
const headlines = [];
|
|
109
123
|
/** @type {Marker[]} */
|
|
110
124
|
const markers = [];
|
|
125
|
+
/** @type {Array<{ line: number, text: string, anchor: string }>} */
|
|
126
|
+
const links = [];
|
|
127
|
+
/** @type {Set<string>} */
|
|
128
|
+
const ids = new Set();
|
|
111
129
|
/** @type {{ char: string, length: number } | null} */
|
|
112
130
|
let fence = null;
|
|
113
131
|
/** @type {Marker | null} */
|
|
114
132
|
let open = null;
|
|
133
|
+
let paragraph = false;
|
|
134
|
+
let comment = false;
|
|
115
135
|
|
|
116
136
|
for (const [i, line] of lines.entries()) {
|
|
117
137
|
const fenceMatch = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
@@ -123,8 +143,32 @@ function scan(lines) {
|
|
|
123
143
|
}
|
|
124
144
|
if (fenceMatch && !(fenceMatch[1][0] === '`' && fenceMatch[2].includes('`'))) {
|
|
125
145
|
fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
146
|
+
paragraph = false;
|
|
126
147
|
continue;
|
|
127
148
|
}
|
|
149
|
+
if (!paragraph && /^(?: {4}|\t)/.test(line)) continue;
|
|
150
|
+
paragraph = line.trim() !== '';
|
|
151
|
+
|
|
152
|
+
const { text: inline, restore } = protect(line);
|
|
153
|
+
let visible = inline;
|
|
154
|
+
if (comment) {
|
|
155
|
+
const close = visible.indexOf('-->');
|
|
156
|
+
if (close === -1) continue;
|
|
157
|
+
visible = visible.slice(close + 3);
|
|
158
|
+
comment = false;
|
|
159
|
+
}
|
|
160
|
+
visible = visible.replace(/<!--[\s\S]*?-->/g, '');
|
|
161
|
+
const start = visible.indexOf('<!--');
|
|
162
|
+
if (start !== -1) {
|
|
163
|
+
visible = visible.slice(0, start);
|
|
164
|
+
comment = true;
|
|
165
|
+
}
|
|
166
|
+
for (const [, tag, attributes] of visible.matchAll(/<([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/g)) {
|
|
167
|
+
for (const [, name, quoted, single] of attributes.matchAll(/(?:^|\s)(id|name)=(?:"([^"]*)"|'([^']*)')/g)) {
|
|
168
|
+
if (name === 'id' || tag.toLowerCase() === 'a') ids.add(quoted ?? single);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const [, text, anchor] of visible.matchAll(LINK)) links.push({ line: i, text: restore(text), anchor: restore(anchor) });
|
|
128
172
|
|
|
129
173
|
const startMatch = /^ {0,3}<!--\s*toc(?:\s+(.*?))?\s*-->\s*$/.exec(line);
|
|
130
174
|
if (startMatch) {
|
|
@@ -140,42 +184,75 @@ function scan(lines) {
|
|
|
140
184
|
|
|
141
185
|
const atx = /^ {0,3}(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
142
186
|
if (atx) {
|
|
143
|
-
headlines.push({ line: i, level: atx[1].length, markdown: atx[2] });
|
|
187
|
+
headlines.push({ line: i, level: atx[1].length, markdown: atx[2], slug: '' });
|
|
144
188
|
continue;
|
|
145
189
|
}
|
|
146
190
|
|
|
147
191
|
const setext = /^ {0,3}(=+|-+)\s*$/.exec(line);
|
|
148
192
|
if (setext && i > 0 && isParagraphText(lines[i - 1])) {
|
|
149
|
-
headlines.push({ line: i - 1, level: setext[1][0] === '=' ? 1 : 2, markdown: lines[i - 1].trim() });
|
|
193
|
+
headlines.push({ line: i - 1, level: setext[1][0] === '=' ? 1 : 2, markdown: lines[i - 1].trim(), slug: '' });
|
|
150
194
|
}
|
|
151
195
|
}
|
|
152
|
-
|
|
196
|
+
assignSlugs(headlines);
|
|
197
|
+
return { headlines, markers, anchors: resolveAnchors(links, headlines, ids) };
|
|
153
198
|
}
|
|
154
199
|
|
|
155
200
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* lines around it so it renders as markdown, and any other attributes from the marker on the summary element.
|
|
161
|
-
* @param {Array<{ level: number, markdown: string }>} headlines
|
|
162
|
-
* @param {string} startLine the start marker as written in the document
|
|
163
|
-
* @param {TocOptions} options
|
|
164
|
-
* @param {string} [eol] line ending, LF by default
|
|
201
|
+
* @param {Array<{ line: number, text: string, anchor: string }>} links
|
|
202
|
+
* @param {Headline[]} headlines
|
|
203
|
+
* @param {Set<string>} ids
|
|
204
|
+
* @returns {Anchor[]}
|
|
165
205
|
*/
|
|
166
|
-
function
|
|
206
|
+
function resolveAnchors(links, headlines, ids) {
|
|
207
|
+
const targets = new Set([...headlines.map((h) => h.slug), ...ids]);
|
|
208
|
+
return links.map(({ line, text, anchor }) => {
|
|
209
|
+
const decoded = decodeAnchor(anchor);
|
|
210
|
+
if (targets.has(anchor) || targets.has(decoded)) return { line, text, anchor, valid: true };
|
|
211
|
+
const candidates = new Set([slugify(headingText(decoded)), slugify(headingText(text))].filter((c) => targets.has(c)));
|
|
212
|
+
if (candidates.size !== 1) return { line, text, anchor, valid: false };
|
|
213
|
+
return { line, text, anchor, valid: false, suggestion: [...candidates][0] };
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @param {string} anchor */
|
|
218
|
+
function decodeAnchor(anchor) {
|
|
219
|
+
try {
|
|
220
|
+
return decodeURIComponent(anchor);
|
|
221
|
+
} catch {
|
|
222
|
+
return anchor;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Set the slug of every heading.
|
|
228
|
+
* @param {Headline[]} headlines
|
|
229
|
+
*/
|
|
230
|
+
function assignSlugs(headlines) {
|
|
167
231
|
/** @type {Record<string, number>} */
|
|
168
232
|
const occurrences = {};
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
minLevel = Math.min(minLevel, level);
|
|
172
|
-
const base = slugify(headingText(markdown));
|
|
233
|
+
for (const headline of headlines) {
|
|
234
|
+
const base = slugify(headingText(headline.markdown));
|
|
173
235
|
let slug = base;
|
|
174
236
|
while (Object.hasOwn(occurrences, slug)) {
|
|
175
237
|
occurrences[base]++;
|
|
176
238
|
slug = `${base}-${occurrences[base]}`;
|
|
177
239
|
}
|
|
178
240
|
occurrences[slug] = 0;
|
|
241
|
+
headline.slug = slug;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The toc block for the headings, between the start marker line and the end marker.
|
|
247
|
+
* @param {Headline[]} headlines
|
|
248
|
+
* @param {string} startLine
|
|
249
|
+
* @param {TocOptions} options
|
|
250
|
+
* @param {string} [eol]
|
|
251
|
+
*/
|
|
252
|
+
function renderBlock(headlines, startLine, options, eol = '\n') {
|
|
253
|
+
let minLevel = Infinity;
|
|
254
|
+
const tocLines = headlines.map(({ level, markdown, slug }) => {
|
|
255
|
+
minLevel = Math.min(minLevel, level);
|
|
179
256
|
return `${' '.repeat(level - minLevel)}- [${headingLabel(markdown)}](#${slug})`;
|
|
180
257
|
});
|
|
181
258
|
const details = options.collapsible ?? options.collapsed;
|
|
@@ -189,10 +266,7 @@ function renderBlock(headlines, startLine, options, eol = '\n') {
|
|
|
189
266
|
}
|
|
190
267
|
|
|
191
268
|
/**
|
|
192
|
-
*
|
|
193
|
-
* (`collapsed="Contents"`), and any other well-formed `name="value"` as an attribute for the summary element.
|
|
194
|
-
* Anything else, bare names that are not options, unquoted values, malformed names, is a problem, as is a
|
|
195
|
-
* combination that makes no sense. A marker with a problem is never used.
|
|
269
|
+
* The options written on a start marker, with a problem when they cannot be used.
|
|
196
270
|
* @param {string | undefined} text
|
|
197
271
|
* @returns {{ options: TocOptions, problem?: string }}
|
|
198
272
|
*/
|
|
@@ -221,7 +295,7 @@ function parseOptions(text) {
|
|
|
221
295
|
}
|
|
222
296
|
|
|
223
297
|
/**
|
|
224
|
-
* The start marker line for the
|
|
298
|
+
* The start marker line for the options.
|
|
225
299
|
* @param {TocOptions} options
|
|
226
300
|
*/
|
|
227
301
|
function formatMarker(options) {
|
|
@@ -235,15 +309,14 @@ function formatMarker(options) {
|
|
|
235
309
|
|
|
236
310
|
/**
|
|
237
311
|
* @param {Record<string, string>} attributes
|
|
238
|
-
* @returns {string[]}
|
|
312
|
+
* @returns {string[]}
|
|
239
313
|
*/
|
|
240
314
|
function formatAttributes(attributes) {
|
|
241
315
|
return Object.entries(attributes).map(([name, value]) => `${name}="${value}"`);
|
|
242
316
|
}
|
|
243
317
|
|
|
244
318
|
/**
|
|
245
|
-
* The
|
|
246
|
-
* cannot nest inside the toc link.
|
|
319
|
+
* The link text used in the toc for a heading.
|
|
247
320
|
* @param {string} markdown
|
|
248
321
|
* @returns {string}
|
|
249
322
|
*/
|
|
@@ -253,8 +326,7 @@ function headingLabel(markdown) {
|
|
|
253
326
|
}
|
|
254
327
|
|
|
255
328
|
/**
|
|
256
|
-
*
|
|
257
|
-
* (with the escaped character) by placeholders so the inline markdown passes leave them alone.
|
|
329
|
+
* The markdown with code spans and backslash escapes swapped for placeholders, and a function to put them back.
|
|
258
330
|
* @param {string} markdown
|
|
259
331
|
* @param {{ keepCodeSpans?: boolean }} [options]
|
|
260
332
|
*/
|
|
@@ -274,8 +346,7 @@ function protect(markdown, { keepCodeSpans = false } = {}) {
|
|
|
274
346
|
}
|
|
275
347
|
|
|
276
348
|
/**
|
|
277
|
-
*
|
|
278
|
-
* not only spaces, as CommonMark does.
|
|
349
|
+
* The code span content unpadded as CommonMark renders it.
|
|
279
350
|
* @param {string} code
|
|
280
351
|
*/
|
|
281
352
|
function unpadCodeSpan(code) {
|
|
@@ -289,8 +360,7 @@ function stripClosingHashes(markdown) {
|
|
|
289
360
|
}
|
|
290
361
|
|
|
291
362
|
/**
|
|
292
|
-
*
|
|
293
|
-
* the toc label since a link cannot nest inside the toc link.
|
|
363
|
+
* The text with links, images and autolinks flattened to their text or url.
|
|
294
364
|
* @param {string} text
|
|
295
365
|
*/
|
|
296
366
|
function stripLinks(text) {
|
|
@@ -308,15 +378,22 @@ function isParagraphText(line) {
|
|
|
308
378
|
}
|
|
309
379
|
|
|
310
380
|
/**
|
|
311
|
-
* Options written on a start marker
|
|
312
|
-
* `collapsed` in one that starts closed. Each is `true` for the default summary "Table of contents" or a
|
|
313
|
-
* string for a custom one. `attributes` are the other `name="value"` pairs on the marker, rendered on the
|
|
314
|
-
* summary element in the order written, only present when there are any.
|
|
381
|
+
* Options written on a start marker, each `true` or a summary text, and the other attributes for the summary element.
|
|
315
382
|
* @typedef {{ collapsible?: true | string, collapsed?: true | string, attributes?: Record<string, string> }} TocOptions
|
|
316
383
|
*/
|
|
317
384
|
|
|
318
385
|
/**
|
|
319
|
-
* A
|
|
320
|
-
*
|
|
386
|
+
* A heading with its zero based line, level, inline markdown and GitHub slug.
|
|
387
|
+
* @typedef {{ line: number, level: number, markdown: string, slug: string }} Headline
|
|
388
|
+
*/
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A link to an anchor with its zero based line, text, anchor as written, whether it has a target and, when it
|
|
392
|
+
* has none and one heading clearly matches, a suggestion.
|
|
393
|
+
* @typedef {{ line: number, text: string, anchor: string, valid: boolean, suggestion?: string }} Anchor
|
|
394
|
+
*/
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* A marker pair as zero based lines, -1 for a missing side, with its options and a problem when they cannot be used.
|
|
321
398
|
* @typedef {{ start: number, end: number, options: TocOptions, problem?: string }} Marker
|
|
322
399
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0dep/toc",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Generate a GitHub flavoured markdown table of contents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"test": "mocha",
|
|
26
26
|
"posttest": "npm run lint && npm run tsc && npm run test:md",
|
|
27
27
|
"test:md": "texample",
|
|
28
|
-
"toc": "node bin/toc.js README.md",
|
|
28
|
+
"toc": "node bin/toc.js --check README.md",
|
|
29
29
|
"posttoc": "prettier --write README.md",
|
|
30
30
|
"lint": "eslint . --cache && prettier . --check --cache",
|
|
31
31
|
"tsc": "tsc -p test",
|
package/types/index.d.ts
CHANGED
|
@@ -1,42 +1,33 @@
|
|
|
1
1
|
declare module '@0dep/toc' {
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* pair lists every heading below its own start marker. A pair is left alone when it is unbalanced, has a
|
|
5
|
-
* problem on its start marker, or has no headings below it, and nothing outside the pairs is ever touched.
|
|
6
|
-
* The start marker line is kept as written, options included.
|
|
3
|
+
* The markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated.
|
|
7
4
|
* */
|
|
8
5
|
export function buildToc(source: string): string;
|
|
9
6
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* @param fromLine zero based line number, typically the start marker's
|
|
13
|
-
* @param options rendered into the start marker, e.g. `{ collapsed: 'Contents' }`
|
|
7
|
+
* The toc block, markers included, for the headings below `fromLine`, empty when there is nothing to list.
|
|
8
|
+
* @param fromLine zero based
|
|
14
9
|
* */
|
|
15
10
|
export function renderToc(source: string, fromLine?: number, options?: TocOptions): string;
|
|
16
11
|
/**
|
|
17
|
-
* Every marker pair in document order
|
|
18
|
-
* pairs with the first end marker after it. A missing side is -1: a start marker without an end marker, or an
|
|
19
|
-
* end marker with no open start marker before it. `options` holds the recognised options written on the start
|
|
20
|
-
* marker and `problem`, only present when there is one, says why the marker cannot be used.
|
|
12
|
+
* Every marker pair in document order, shared with later calls for the same source.
|
|
21
13
|
* */
|
|
22
14
|
export function findMarkers(source: string): Marker[];
|
|
23
15
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
16
|
+
* Every link to an anchor in the document, in order, shared with later calls for the same source.
|
|
17
|
+
* */
|
|
18
|
+
export function findAnchors(source: string): Anchor[];
|
|
19
|
+
/**
|
|
20
|
+
* The GitHub anchor slug of a heading's rendered text.
|
|
27
21
|
* */
|
|
28
22
|
export function slugify(text: string): string;
|
|
29
23
|
/**
|
|
30
|
-
*
|
|
24
|
+
* The text GitHub renders for a heading's inline markdown.
|
|
31
25
|
* */
|
|
32
26
|
export function headingText(markdown: string): string;
|
|
33
27
|
export const TOC_START: "<!-- toc -->";
|
|
34
28
|
export const TOC_END: "<!-- /toc -->";
|
|
35
29
|
/**
|
|
36
|
-
* Options written on a start marker
|
|
37
|
-
* `collapsed` in one that starts closed. Each is `true` for the default summary "Table of contents" or a
|
|
38
|
-
* string for a custom one. `attributes` are the other `name="value"` pairs on the marker, rendered on the
|
|
39
|
-
* summary element in the order written, only present when there are any.
|
|
30
|
+
* Options written on a start marker, each `true` or a summary text, and the other attributes for the summary element.
|
|
40
31
|
*/
|
|
41
32
|
export type TocOptions = {
|
|
42
33
|
collapsible?: true | string;
|
|
@@ -44,8 +35,27 @@ declare module '@0dep/toc' {
|
|
|
44
35
|
attributes?: Record<string, string>;
|
|
45
36
|
};
|
|
46
37
|
/**
|
|
47
|
-
* A
|
|
48
|
-
|
|
38
|
+
* A heading with its zero based line, level, inline markdown and GitHub slug.
|
|
39
|
+
*/
|
|
40
|
+
export type Headline = {
|
|
41
|
+
line: number;
|
|
42
|
+
level: number;
|
|
43
|
+
markdown: string;
|
|
44
|
+
slug: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* A link to an anchor with its zero based line, text, anchor as written, whether it has a target and, when it
|
|
48
|
+
* has none and one heading clearly matches, a suggestion.
|
|
49
|
+
*/
|
|
50
|
+
export type Anchor = {
|
|
51
|
+
line: number;
|
|
52
|
+
text: string;
|
|
53
|
+
anchor: string;
|
|
54
|
+
valid: boolean;
|
|
55
|
+
suggestion?: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A marker pair as zero based lines, -1 for a missing side, with its options and a problem when they cannot be used.
|
|
49
59
|
*/
|
|
50
60
|
export type Marker = {
|
|
51
61
|
start: number;
|
package/types/index.d.ts.map
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
"buildToc",
|
|
6
6
|
"renderToc",
|
|
7
7
|
"findMarkers",
|
|
8
|
+
"findAnchors",
|
|
8
9
|
"slugify",
|
|
9
10
|
"headingText",
|
|
10
11
|
"TOC_START",
|
|
11
12
|
"TOC_END",
|
|
12
13
|
"TocOptions",
|
|
14
|
+
"Headline",
|
|
15
|
+
"Anchor",
|
|
13
16
|
"Marker"
|
|
14
17
|
],
|
|
15
18
|
"sources": [
|
|
@@ -18,6 +21,6 @@
|
|
|
18
21
|
"sourcesContent": [
|
|
19
22
|
null
|
|
20
23
|
],
|
|
21
|
-
"mappings": "
|
|
24
|
+
"mappings": ";;;;iBAcgBA,QAAQA;;;;;iBAuBRC,SAASA;;;;iBAWTC,WAAWA;;;;iBASXC,WAAWA;;;;iBASXC,OAAOA;;;;iBAYPC,WAAWA;cA9EdC,SAASA;cACTC,OAAOA;;;;aA4X0FC,UAAUA;;;;;;;;aAK1CC,QAAQA;;;;;;;;;;aAMYC,MAAMA;;;;;;;;;;aAKpBC,MAAMA",
|
|
22
25
|
"ignoreList": []
|
|
23
26
|
}
|