@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 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 or when an option is not recognised, otherwise 0. Skipped files and pairs do not affect the exit code.
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 in one toc get `-1`, `-2` and so on, like GitHub.
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 Files are resolved against the current directory and default to
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, dryRun, help, unknown } = parseArgs(process.argv.slice(2));
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, dryRun);
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
- * Status lines go to stdout, warnings to stderr with the marker's line number. With `dryRun` the rendered toc
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} dryRun
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 updatable++;
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
- let dryRun = false;
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 === '-h' || arg === '--help') help = true;
115
- else if (arg.startsWith('-')) return { files, dryRun, help, unknown: 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'], dryRun, help };
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
- * Return the markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated. Each
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 } = splitLines(source);
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
- * Return the toc block, markers included, for the headings below `fromLine`, by default every heading so the
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 line number, typically the start marker's
39
- * @param {TocOptions} [options] rendered into the start marker, e.g. `{ collapsed: 'Contents' }`
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 { lines, eol } = splitLines(source);
44
- const listed = scan(lines).headlines.filter((h) => h.line > fromLine);
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 as zero based line numbers, outside fenced code blocks. A start marker
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 scan(splitLines(source).lines).markers;
52
+ return analyse(source).markers;
58
53
  }
59
54
 
60
55
  /**
61
- * Slug a heading's rendered text the way github-slugger does: lowercase, drop everything that is not a
62
- * letter, number, mark, space, hyphen or underscore, then turn each space into a hyphen. Nothing is trimmed
63
- * or collapsed.
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
- * Reduce a heading's inline markdown to the text GitHub renders and slugs.
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
- * Split the source into lines and remember its line ending, CRLF when the source has any, so the toc is
91
- * written the way the rest of the file is and a Windows authored file does not end up with mixed endings.
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
- return { lines: source.split(/\r?\n/), eol: source.includes('\r\n') ? '\r\n' : '\n' };
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
- * Scan the lines for every marker pair and every ATX and setext heading, all outside fenced code blocks. A
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: Array<{ line: number, level: number, markdown: string }>, markers: Marker[] }}
120
+ * @returns {{ headlines: Headline[], markers: Marker[], anchors: Anchor[] }}
107
121
  */
108
122
  function scan(lines) {
109
- /** @type {Array<{ line: number, level: number, markdown: string }>} */
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
- return { headlines, markers };
198
+ assignSlugs(headlines);
199
+ return { headlines, markers, anchors: resolveAnchors(links, headlines, ids) };
155
200
  }
156
201
 
157
202
  /**
158
- * The toc block for the given headings, wrapped in the given start marker line and the end marker. Indentation
159
- * is relative to the shallowest level listed so far, so the list never starts indented, which markdown would
160
- * render flat anyway, and duplicate slugs get github style `-1`, `-2` suffixes. With
161
- * `collapsible` or `collapsed` the list goes inside a details element, open or closed to start with, blank
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 renderBlock(headlines, startLine, options, eol = '\n') {
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
- let minLevel = Infinity;
172
- const tocLines = headlines.map(({ level, markdown }) => {
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
- * Parse what is written on a start marker: the options as bare names (`collapsed`) or quoted values
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 given options, the inverse of `parseOptions`: options first, then attributes.
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[]} `name="value"` in the order given
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 label used in the toc: the heading's own markdown, except that links become their text since a link
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
- * Replace code spans (with their content, or the whole span when `keepCodeSpans` is set) and backslash escapes
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
- * Strip one leading and one trailing space from code span content when both are present and the content is
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
- * Links, images and reference links become their text, autolinks their url. Used for both the slug text and
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. `collapsible` wraps the list in a details element that starts open,
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 marker pair. `start` and `end` are zero based line numbers, -1 when that side is missing. `problem` is
322
- * only present when the start marker cannot be used: an unknown option or an impossible combination.
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
- * Return the markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated. Each
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 } = splitLines(source);
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
- * Return the toc block, markers included, for the headings below `fromLine`, by default every heading so the
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 line number, typically the start marker's
37
- * @param {TocOptions} [options] rendered into the start marker, e.g. `{ collapsed: 'Contents' }`
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 { lines, eol } = splitLines(source);
42
- const listed = scan(lines).headlines.filter((h) => h.line > fromLine);
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 as zero based line numbers, outside fenced code blocks. A start marker
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 scan(splitLines(source).lines).markers;
50
+ return analyse(source).markers;
56
51
  }
57
52
 
58
53
  /**
59
- * Slug a heading's rendered text the way github-slugger does: lowercase, drop everything that is not a
60
- * letter, number, mark, space, hyphen or underscore, then turn each space into a hyphen. Nothing is trimmed
61
- * or collapsed.
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
- * Reduce a heading's inline markdown to the text GitHub renders and slugs.
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
- * Split the source into lines and remember its line ending, CRLF when the source has any, so the toc is
89
- * written the way the rest of the file is and a Windows authored file does not end up with mixed endings.
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
- return { lines: source.split(/\r?\n/), eol: source.includes('\r\n') ? '\r\n' : '\n' };
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
- * Scan the lines for every marker pair and every ATX and setext heading, all outside fenced code blocks. A
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: Array<{ line: number, level: number, markdown: string }>, markers: Marker[] }}
118
+ * @returns {{ headlines: Headline[], markers: Marker[], anchors: Anchor[] }}
105
119
  */
106
120
  function scan(lines) {
107
- /** @type {Array<{ line: number, level: number, markdown: string }>} */
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
- return { headlines, markers };
196
+ assignSlugs(headlines);
197
+ return { headlines, markers, anchors: resolveAnchors(links, headlines, ids) };
153
198
  }
154
199
 
155
200
  /**
156
- * The toc block for the given headings, wrapped in the given start marker line and the end marker. Indentation
157
- * is relative to the shallowest level listed so far, so the list never starts indented, which markdown would
158
- * render flat anyway, and duplicate slugs get github style `-1`, `-2` suffixes. With
159
- * `collapsible` or `collapsed` the list goes inside a details element, open or closed to start with, blank
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 renderBlock(headlines, startLine, options, eol = '\n') {
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
- let minLevel = Infinity;
170
- const tocLines = headlines.map(({ level, markdown }) => {
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
- * Parse what is written on a start marker: the options as bare names (`collapsed`) or quoted values
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 given options, the inverse of `parseOptions`: options first, then attributes.
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[]} `name="value"` in the order given
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 label used in the toc: the heading's own markdown, except that links become their text since a link
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
- * Replace code spans (with their content, or the whole span when `keepCodeSpans` is set) and backslash escapes
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
- * Strip one leading and one trailing space from code span content when both are present and the content is
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
- * Links, images and reference links become their text, autolinks their url. Used for both the slug text and
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. `collapsible` wraps the list in a details element that starts open,
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 marker pair. `start` and `end` are zero based line numbers, -1 when that side is missing. `problem` is
320
- * only present when the start marker cannot be used: an unknown option or an impossible combination.
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.1",
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
- * Return the markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated. Each
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
- * Return the toc block, markers included, for the headings below `fromLine`, by default every heading so the
11
- * block can be pasted into a document without markers. Returns an empty string when there is nothing to list.
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 as zero based line numbers, outside fenced code blocks. A start marker
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
- * Slug a heading's rendered text the way github-slugger does: lowercase, drop everything that is not a
25
- * letter, number, mark, space, hyphen or underscore, then turn each space into a hyphen. Nothing is trimmed
26
- * or collapsed.
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
- * Reduce a heading's inline markdown to the text GitHub renders and slugs.
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. `collapsible` wraps the list in a details element that starts open,
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 marker pair. `start` and `end` are zero based line numbers, -1 when that side is missing. `problem` is
48
- * only present when the start marker cannot be used: an unknown option or an impossible combination.
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;
@@ -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": ";;;;;;;iBAcgBA,QAAQA;;;;;;;iBAyBRC,SAASA;;;;;;;iBAcTC,WAAWA;;;;;;iBAWXC,OAAOA;;;;iBAYPC,WAAWA;cA5EdC,SAASA;cACTC,OAAOA;;;;;;;aAyT0FC,UAAUA;;;;;;;;;aAMpCC,MAAMA",
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
  }