@0dep/toc 1.1.0 → 2.0.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,30 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ <!-- toc levels="2" -->
6
+
7
+ - [v2.0.0 - 2026-09-18](#v200---2026-09-18)
8
+ - [v1.1.0 - 2026-09-17](#v110---2026-09-17)
9
+ - [v1.0.1 - 2026-09-12](#v101---2026-09-12)
10
+ - [v1.0.0 - 2026-09-12](#v100---2026-09-12)
11
+ - [v0.0.1 - 2026-09-12](#v001---2026-09-12)
12
+
13
+ <!-- /toc -->
14
+
15
+ ## v2.0.0 - 2026-09-18
16
+
17
+ ### Breaking
18
+
19
+ - Node 22.12 or later. The bin expands glob patterns with `fs.promises.glob`, and `require('@0dep/toc')` loads `index.js` itself, so the CommonJS bundle `index.cjs` is gone
20
+ - `types/index.d.ts` is emitted by `tsc` as a plain module declaration instead of the `declare module '@0dep/toc'` block dts-buddy wrote, with a declaration map back to `index.js`
21
+
22
+ ### Added
23
+
24
+ - `levels="2"`, `levels="2-3"`, `levels="-3"` or `levels="3-"` on the start marker limits the toc to those heading levels, so a changelog can list its versions alone. A broken value is ignored with a warning, `findMarkers` reports it as `warning`. `renderToc` takes `{ levels: '2-3' }`
25
+ - an argument with `*`, `?` or `[` is a glob pattern, `**` matches subdirectories and `node_modules` is never entered, so `toc 'docs/**/*.md'` works the same in an npm script on Windows as on macOS and Linux. A pattern that matches nothing is skipped with a warning, and a file is processed once however many arguments name or match it
26
+ - `-s`/`--silent` drops the status lines and the skipped warnings from the bin, so only anchor warnings and errors are written
27
+ - a file that does not exist is skipped with a warning and no longer sets the exit code, like a file without markers. `--silent` keeps that warning
28
+
5
29
  ## v1.1.0 - 2026-09-17
6
30
 
7
31
  - 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`
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Build](https://github.com/zerodep/toc/actions/workflows/build.yaml/badge.svg)](https://github.com/zerodep/toc/actions/workflows/build.yaml)[![Build (Windows)](https://github.com/zerodep/toc/actions/workflows/build-windows.yaml/badge.svg)](https://github.com/zerodep/toc/actions/workflows/build-windows.yaml)[![Coverage Status](https://coveralls.io/repos/github/zerodep/toc/badge.svg?branch=main)](https://coveralls.io/github/zerodep/toc?branch=main)
4
4
 
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.
5
+ Generate a GitHub flavoured table of contents for markdown files. No dependencies. The library is string in, string out and runs in the browser too. Node 22.12 or later for the `toc` bin and for `require`.
6
6
 
7
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
 
@@ -12,6 +12,9 @@ The toc is written between `<!-- toc -->` and `<!-- /toc -->` markers, and only
12
12
  - [Markers](#markers)
13
13
  - [Several tocs in one document](#several-tocs-in-one-document)
14
14
  - [Options](#options)
15
+ - [`collapsible` and `collapsed`](#collapsible-and-collapsed)
16
+ - [Summary attributes](#summary-attributes)
17
+ - [`levels`](#levels)
15
18
  - [What is left alone](#what-is-left-alone)
16
19
  - [CLI](#cli)
17
20
  - [Options](#options-1)
@@ -101,6 +104,18 @@ The top toc lists API, get, set and License. The toc under API lists get, set an
101
104
 
102
105
  ### Options
103
106
 
107
+ The start marker takes options, bare or as `name="value"`, in any order. It is kept exactly as written, and spacing inside the comment does not matter. Anything on it that is not one of the three options or a well-formed attribute makes the pair skip with a warning rather than guess: a bare name other than the options, so a typo like `collapsable` is caught, an unquoted value like `collapsed=yes`, both details options at once, or attributes without an option to give them a summary element.
108
+
109
+ <!-- toc levels="4" -->
110
+
111
+ - [`collapsible` and `collapsed`](#collapsible-and-collapsed)
112
+ - [Summary attributes](#summary-attributes)
113
+ - [`levels`](#levels)
114
+
115
+ <!-- /toc -->
116
+
117
+ #### `collapsible` and `collapsed`
118
+
104
119
  GitHub and most other renderers support the html details element in markdown. Ask for it on the start marker and the list is wrapped in one. `collapsible` starts open and can be folded away, `collapsed` starts folded until the reader clicks the summary. This README has a `collapsed` one under [API](#api) and a `collapsible` one under [Headings](#headings).
105
120
 
106
121
  ```markdown
@@ -122,7 +137,11 @@ becomes
122
137
  <!-- /toc -->
123
138
  ```
124
139
 
125
- Both take a summary text, `<!-- toc collapsible="Contents" -->` or `<!-- toc collapsed="Contents" -->`. Any other `name="value"` pair on the start marker is put on the summary element, in the order written:
140
+ Both take a summary text, `<!-- toc collapsible="Contents" -->` or `<!-- toc collapsed="Contents" -->`.
141
+
142
+ #### Summary attributes
143
+
144
+ Any other `name="value"` pair on the start marker is put on the summary element, in the order written:
126
145
 
127
146
  ```markdown
128
147
  <!-- toc collapsed="Contents" title="Click to expand" style="font-weight: bold" -->
@@ -135,9 +154,29 @@ Both take a summary text, `<!-- toc collapsible="Contents" -->` or `<!-- toc col
135
154
  <!-- /toc -->
136
155
  ```
137
156
 
138
- Renderers sanitise html, so check what yours keeps. GitHub drops `style`, `class` and `id` but keeps `title`, `dir`, `lang`, `role` and `aria-*` attributes, so in the example above only the `title` survives there. The start marker itself is kept exactly as written, and spacing inside the comment does not matter.
157
+ Renderers sanitise html, so check what yours keeps. GitHub drops `style`, `class` and `id` but keeps `title`, `dir`, `lang`, `role` and `aria-*` attributes, so in the example above only the `title` survives there.
158
+
159
+ #### `levels`
160
+
161
+ `levels` limits the toc to a heading level or a range of levels, `levels="2"`, `levels="2-3"`, `levels="-3"` for everything up to `###` or `levels="3-"` for `###` and deeper. The levels are the heading's own, the number of `#`, wherever the marker sits, so a toc under `## API` that should list the `###` headings beneath it says `levels="3"`. The list starts flat at the shallowest level listed, so a changelog with `### Added` and `### Breaking` under every version gets a toc of the versions alone:
162
+
163
+ ```markdown
164
+ <!-- toc levels="2" collapsed="Versions" -->
165
+ <details>
166
+ <summary>Versions</summary>
167
+
168
+ - [v2.0.0 - 2026-09-18](#v200---2026-09-18)
169
+ - [v1.1.0 - 2026-09-17](#v110---2026-09-17)
170
+
171
+ </details>
172
+ <!-- /toc -->
173
+
174
+ ## v2.0.0 - 2026-09-18
175
+
176
+ ### Breaking
177
+ ```
139
178
 
140
- Anything on the start marker that is not one of the two options or a well-formed attribute makes the pair skip with a warning rather than guess: a bare name other than the two options, so a typo like `collapsable` is caught, an unquoted value like `collapsed=yes`, both options at once, or attributes without an option to give them a summary element.
179
+ A `levels` value that is not a level or a range between 1 and 6 is ignored with a warning, and the pair lists every level. The toc above, under Options, is a `levels="4"` one: without it every heading to the end of this file would be listed.
141
180
 
142
181
  ### What is left alone
143
182
 
@@ -159,20 +198,23 @@ This README has two headings named Options, the one above and the one under CLI.
159
198
  npx toc # updates README.md in the current directory
160
199
  npx toc docs/a.md docs/b.md # several files
161
200
  npx toc docs/a.md,docs/b.md # comma separated works too
201
+ npx toc 'docs/**/*.md' # glob patterns, quoted so the shell leaves them alone
162
202
  npx toc --dry-run README.md # print the toc, write nothing
163
203
  npx toc --check README.md # exit with 1 when a link to an anchor has no target
204
+ npx toc -c -s docs/*.md # write only the anchor warnings
164
205
  npx toc --help
165
206
  ```
166
207
 
167
- Paths are resolved against the current directory.
208
+ Paths are resolved against the current directory. An argument with `*`, `?` or `[` is a glob pattern, expanded by the bin with Node's own `fs.glob`, so it works the same in an npm script on Windows, where the shell does not expand it, as on macOS and Linux. `**` matches subdirectories, `node_modules` is never entered, and the matches are processed in sorted order. A pattern that matches nothing is skipped with a warning. A file is processed once, however many arguments name or match it.
168
209
 
169
210
  ### Options
170
211
 
171
- | Option | Effect |
172
- | --------------- | ----------------------------------------------------------------------------- |
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 |
175
- | `-h, --help` | Show usage |
212
+ | Option | Effect |
213
+ | --------------- | ---------------------------------------------------------------------------------------------- |
214
+ | `-n, --dry-run` | Print the toc of every pair to stdout and the status to stderr, write nothing |
215
+ | `-c, --check` | Exit with 1 when a link to an anchor has no target |
216
+ | `-s, --silent` | Skip the status lines and the skipped warnings, keep missing files, anchor warnings and errors |
217
+ | `-h, --help` | Show usage |
176
218
 
177
219
  ### Output and exit code
178
220
 
@@ -182,6 +224,8 @@ One status line per file on stdout, and one warning per skipped file or pair on
182
224
  | --------------------------------------------------------------------------------- | ------ | ---------------------------------------------------- |
183
225
  | `README.md: wrote TOC.` | stdout | At least one pair changed and the file was written |
184
226
  | `README.md: TOC already up to date.` | stdout | Every updatable pair was already correct |
227
+ | `README.md: no such file, skipped.` | stderr | The file does not exist |
228
+ | `docs/*.md: no matching file, skipped.` | stderr | The pattern matched nothing |
185
229
  | `README.md: no TOC markers, skipped.` | stderr | The file has no markers |
186
230
  | `README.md:3: TOC start marker without end marker, skipped.` | stderr | Unbalanced pair |
187
231
  | `README.md:3: TOC end marker without start marker, skipped.` | stderr | Unbalanced pair |
@@ -189,9 +233,10 @@ One status line per file on stdout, and one warning per skipped file or pair on
189
233
  | `README.md:3: unknown TOC option foo, skipped.` | stderr | The start marker has something that is not an option |
190
234
  | `README.md:3: TOC options collapsible and collapsed exclude each other, skipped.` | stderr | Pick one |
191
235
  | `README.md:3: TOC attributes style need collapsible or collapsed, skipped.` | stderr | There is no summary element to put them on |
236
+ | `README.md:3: TOC option levels "x" is not a level or a range like 2-3, ignored.` | stderr | Every level is listed instead |
192
237
  | `README.md:9: anchor #instal has no target.` | stderr | A link points at a heading that does not exist |
193
238
  | `README.md:9: anchor #Install has no target, did you mean #install?` | stderr | Same, and a heading obviously matches |
194
- | `README.md: ENOENT: no such file or directory, ...` | stderr | The file could not be read or written |
239
+ | `README.md: EISDIR: illegal operation on a directory, ...` | stderr | The file could not be read or written |
195
240
 
196
241
  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
242
 
@@ -205,6 +250,12 @@ The warning ends with `did you mean` when the target is obvious: slugging the an
205
250
  npx toc --check docs/*.md
206
251
  ```
207
252
 
253
+ With `--silent` the status lines and the skipped warnings are dropped, so the output is the missing files, the anchor warnings and the errors alone. In a dry run the toc blocks are still printed to stdout.
254
+
255
+ ```sh
256
+ npx toc --check --silent docs/*.md
257
+ ```
258
+
208
259
  ### Dry run
209
260
 
210
261
  With `--dry-run` nothing is written. The rendered block of every start marker goes to stdout so it can be piped or redirected, and the status line and warnings go to stderr. A start marker that would be skipped, unbalanced or with a problem, still gets its block printed, plain when its options are broken, next to the warning. A file without markers gets a block with every heading, so the output can be pasted into the document as a starting point.
@@ -260,7 +311,7 @@ To regenerate the toc and format the rest of the file in one go, let a `posttoc`
260
311
  import { buildToc, renderToc, findMarkers, findAnchors, slugify, headingText, TOC_START, TOC_END } from '@0dep/toc';
261
312
  ```
262
313
 
263
- CommonJS works the same way with `require('@0dep/toc')`.
314
+ CommonJS works the same way with `require('@0dep/toc')`, there is no separate bundle since Node 22.12 requires ES modules natively.
264
315
 
265
316
  ### `buildToc(source)`
266
317
 
@@ -290,7 +341,7 @@ console.log(md);
290
341
 
291
342
  ### `renderToc(source[, fromLine[, options]])`
292
343
 
293
- Returns the toc block, markers included, for the headings below the zero based line `fromLine`, typically a start marker's line. By default every heading is listed. Returns an empty string when there is nothing to list. `options` are rendered into the start marker and the block, `{ collapsible: true }`, `{ collapsed: 'Contents', attributes: { class: 'toc' } }` and so on.
344
+ Returns the toc block, markers included, for the headings below the zero based line `fromLine`, typically a start marker's line. By default every heading is listed. Returns an empty string when there is nothing to list. `options` are rendered into the start marker and the block, `{ collapsible: true }`, `{ collapsed: 'Contents', attributes: { class: 'toc' } }`, `{ levels: '2-3' }` and so on.
294
345
 
295
346
  ```javascript
296
347
  import { renderToc } from '@0dep/toc';
@@ -310,7 +361,7 @@ console.log(renderToc('# Title\n\n## Install\n\n### From npm\n'));
310
361
 
311
362
  ### `findMarkers(source)`
312
363
 
313
- Returns every marker pair in document order as `{ start, end, options }`, zero based line numbers outside fenced code blocks and the recognised options written on the start marker. A start marker pairs with the first end marker after it. A missing side is `-1`, a start marker without an end marker or an end marker with no open start before it. `options.attributes` holds the other `name="value"` pairs, when there are any. When the start marker cannot be used, `problem` says why and is otherwise absent.
364
+ Returns every marker pair in document order as `{ start, end, options }`, zero based line numbers outside fenced code blocks and the recognised options written on the start marker. A start marker pairs with the first end marker after it. A missing side is `-1`, a start marker without an end marker or an end marker with no open start before it. `options.attributes` holds the other `name="value"` pairs, when there are any. When the start marker cannot be used, `problem` says why and is otherwise absent. When an option on it is ignored, a broken `levels` value, `warning` says so.
314
365
 
315
366
  ```javascript
316
367
  import { findMarkers } from '@0dep/toc';
@@ -389,6 +440,14 @@ The marker strings, `<!-- toc -->` and `<!-- /toc -->`, for code that wants to l
389
440
  </details>
390
441
  <!-- /toc -->
391
442
 
443
+ And one with `levels="2"`, which lists only the level 2 headings that follow:
444
+
445
+ <!-- toc levels="2" -->
446
+
447
+ - [License](#license)
448
+
449
+ <!-- /toc -->
450
+
392
451
  ### What is listed
393
452
 
394
453
  - ATX headings, `## Heading`, with up to three leading spaces and a space after the hashes, and setext headings underlined with `===` or `---`
package/bin/toc.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /* eslint-disable no-console */
3
- import { readFile, writeFile } from 'node:fs/promises';
3
+ import { glob, readFile, writeFile } from 'node:fs/promises';
4
+ import { basename, resolve } from 'node:path';
4
5
 
5
6
  import { buildToc, findAnchors, findMarkers, renderToc } from '../index.js';
6
7
 
@@ -8,9 +9,9 @@ const USAGE = `Usage: toc [options] [file...]
8
9
 
9
10
  Update the table of contents between every <!-- toc --> and <!-- /toc -->
10
11
  pair in markdown files. Each pair lists the headings below its own start
11
- marker. A file without markers is skipped, and so is a pair that is
12
- unbalanced, has a problem on its start marker, or has no headings below
13
- it, each with a warning on stderr. Every link to an anchor, [text](#slug),
12
+ marker. A file that does not exist or has no markers is skipped, and so
13
+ is a pair that is unbalanced, has a problem on its start marker, or has
14
+ no headings below it, each with a warning on stderr. Every link to an anchor, [text](#slug),
14
15
  is checked against the headings and html ids in the file and a link
15
16
  without a target gets a warning too.
16
17
 
@@ -20,17 +21,23 @@ Markers:
20
21
  <!-- toc collapsed --> same, but closed until clicked
21
22
  <!-- toc collapsed="Contents" --> either one with a custom summary text
22
23
  <!-- toc collapsed class="toc" --> other name="value" pairs go on <summary>
24
+ <!-- toc levels="2-3" --> only these heading levels, also "2", "-3", "3-"
23
25
  <!-- /toc --> end of the block
24
26
 
25
27
  Files are resolved against the current directory and default to
26
28
  README.md. Several files can be given as separate arguments or comma
27
- separated.
29
+ separated. An argument with *, ? or [ is a glob pattern, ** matches
30
+ subdirectories, node_modules is never entered. A pattern that matches
31
+ nothing is skipped with a warning. A file is processed once, however
32
+ many arguments name or match it.
28
33
 
29
34
  Options:
30
35
  -n, --dry-run print the toc to stdout and report on stderr, write nothing.
31
36
  Without markers every heading is listed so the block can be
32
37
  pasted into the document
33
38
  -c, --check exit with 1 when a link to an anchor has no target
39
+ -s, --silent skip the status lines and the skipped warnings, only missing
40
+ files, anchor warnings and errors are written
34
41
  -h, --help show this help
35
42
  `;
36
43
 
@@ -49,27 +56,51 @@ async function main() {
49
56
  return;
50
57
  }
51
58
 
52
- for (const file of files) {
53
- try {
54
- await processFile(file, flags);
55
- } catch (err) {
56
- console.error(`${file}: ${/** @type {Error} */ (err).message}`);
57
- process.exitCode = 1;
59
+ const seen = new Set();
60
+ for (const arg of files) {
61
+ const matches = await expand(arg);
62
+ if (!matches.length) console.error(`${arg}: no matching file, skipped.`);
63
+ for (const file of matches) {
64
+ const key = resolve(file);
65
+ if (seen.has(key)) continue;
66
+ seen.add(key);
67
+ try {
68
+ await processFile(file, flags);
69
+ } catch (err) {
70
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
71
+ console.error(`${file}: no such file, skipped.`);
72
+ continue;
73
+ }
74
+ console.error(`${file}: ${/** @type {Error} */ (err).message}`);
75
+ process.exitCode = 1;
76
+ }
58
77
  }
59
78
  }
60
79
  }
61
80
 
81
+ /**
82
+ * Expand a glob pattern to the sorted matching files, a plain path is returned as is.
83
+ * @param {string} arg
84
+ * @returns {Promise<string[]>}
85
+ */
86
+ async function expand(arg) {
87
+ if (!/[*?[]/.test(arg)) return [arg];
88
+ const matches = [];
89
+ for await (const file of glob(arg, { exclude: (path) => basename(path) === 'node_modules' })) matches.push(file);
90
+ return matches.sort();
91
+ }
92
+
62
93
  /**
63
94
  * Update the toc and check the anchors of one file, or report what would happen with `dryRun`.
64
95
  * @param {string} file
65
- * @param {{ dryRun: boolean, check: boolean }} flags
96
+ * @param {{ dryRun: boolean, check: boolean, silent: boolean }} flags
66
97
  */
67
- async function processFile(file, { dryRun, check }) {
98
+ async function processFile(file, { dryRun, check, silent }) {
68
99
  const source = await readFile(file, 'utf8');
69
- const status = dryRun ? console.error : console.log;
100
+ const status = silent ? noop : dryRun ? console.error : console.log;
70
101
  const warn = console.error;
71
102
 
72
- const regenerated = reportMarkers(file, source, dryRun);
103
+ const regenerated = reportMarkers(file, source, dryRun, silent);
73
104
  let updated = source;
74
105
  if (regenerated.length) {
75
106
  updated = buildToc(source);
@@ -91,11 +122,12 @@ async function processFile(file, { dryRun, check }) {
91
122
  * @param {string} file
92
123
  * @param {string} source
93
124
  * @param {boolean} dryRun
125
+ * @param {boolean} silent
94
126
  * @returns {Array<{ start: number, end: number }>}
95
127
  */
96
- function reportMarkers(file, source, dryRun) {
128
+ function reportMarkers(file, source, dryRun, silent) {
97
129
  const markers = findMarkers(source);
98
- const warn = console.error;
130
+ const warn = silent ? noop : console.error;
99
131
  /** @type {Array<{ start: number, end: number }>} */
100
132
  const regenerated = [];
101
133
 
@@ -106,11 +138,12 @@ function reportMarkers(file, source, dryRun) {
106
138
  return regenerated;
107
139
  }
108
140
 
109
- for (const { start, end, options, problem } of markers) {
141
+ for (const { start, end, options, problem, warning } of markers) {
110
142
  if (start === -1) {
111
143
  warn(`${file}:${end + 1}: TOC end marker without start marker, skipped.`);
112
144
  continue;
113
145
  }
146
+ if (warning) warn(`${file}:${start + 1}: ${warning}.`);
114
147
  const toc = renderToc(source, start, problem ? {} : options);
115
148
  if (dryRun && toc) console.log(toc);
116
149
  if (problem) warn(`${file}:${start + 1}: ${problem}, skipped.`);
@@ -123,18 +156,21 @@ function reportMarkers(file, source, dryRun) {
123
156
 
124
157
  /**
125
158
  * @param {string[]} argv
126
- * @returns {{ files: string[], dryRun: boolean, check: boolean, help: boolean, unknown?: string }}
159
+ * @returns {{ files: string[], dryRun: boolean, check: boolean, silent: boolean, help: boolean, unknown?: string }}
127
160
  */
128
161
  function parseArgs(argv) {
129
162
  /** @type {string[]} */
130
163
  const files = [];
131
- const flags = { dryRun: false, check: false, help: false };
164
+ const flags = { dryRun: false, check: false, silent: false, help: false };
132
165
  for (const arg of argv) {
133
166
  if (arg === '-n' || arg === '--dry-run') flags.dryRun = true;
134
167
  else if (arg === '-c' || arg === '--check') flags.check = true;
168
+ else if (arg === '-s' || arg === '--silent') flags.silent = true;
135
169
  else if (arg === '-h' || arg === '--help') flags.help = true;
136
170
  else if (arg.startsWith('-')) return { files, ...flags, unknown: arg };
137
171
  else files.push(...arg.split(',').filter(Boolean));
138
172
  }
139
173
  return { files: files.length ? files : ['README.md'], ...flags };
140
174
  }
175
+
176
+ function noop() {}
package/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export const TOC_START = '<!-- toc -->';
2
2
  export const TOC_END = '<!-- /toc -->';
3
3
  const DEFAULT_SUMMARY = 'Table of contents';
4
- const KNOWN_OPTIONS = ['collapsible', 'collapsed'];
4
+ const KNOWN_OPTIONS = ['collapsible', 'collapsed', 'levels'];
5
+ const LEVELS = /^(?:([1-6])|([1-6])-([1-6])|([1-6])-|-([1-6]))$/;
5
6
  const NUL = String.fromCharCode(0);
6
7
  /** @type {Map<string, ReturnType<typeof analyse>>} */
7
8
  const cache = new Map();
@@ -19,7 +20,7 @@ export function buildToc(source) {
19
20
  let cursor = 0;
20
21
  for (const { start, end, options, problem } of markers) {
21
22
  if (start === -1 || end === -1 || problem) continue;
22
- const listed = headlines.filter((h) => h.line > start);
23
+ const listed = headingsBelow(headlines, start, options);
23
24
  if (listed.length === 0) continue;
24
25
  out.push(...lines.slice(cursor, start), renderBlock(listed, lines[start], options, eol));
25
26
  cursor = end + 1;
@@ -37,7 +38,7 @@ export function buildToc(source) {
37
38
  */
38
39
  export function renderToc(source, fromLine = -1, options = {}) {
39
40
  const { eol, headlines } = analyse(source);
40
- const listed = headlines.filter((h) => h.line > fromLine);
41
+ const listed = headingsBelow(headlines, fromLine, options);
41
42
  return listed.length === 0 ? '' : renderBlock(listed, formatMarker(options), options, eol);
42
43
  }
43
44
 
@@ -242,6 +243,33 @@ function assignSlugs(headlines) {
242
243
  }
243
244
  }
244
245
 
246
+ /**
247
+ * The headings after `fromLine` within the levels the options allow.
248
+ * @param {Headline[]} headlines
249
+ * @param {number} fromLine
250
+ * @param {TocOptions} options
251
+ * @returns {Headline[]}
252
+ */
253
+ function headingsBelow(headlines, fromLine, options) {
254
+ const [min, max] = levelRange(options.levels) ?? [1, 6];
255
+ return headlines.filter((h) => h.line > fromLine && h.level >= min && h.level <= max);
256
+ }
257
+
258
+ /**
259
+ * The lowest and highest heading level a levels option allows, every level when absent, undefined when broken.
260
+ * @param {string} [levels]
261
+ * @returns {[number, number] | undefined}
262
+ */
263
+ function levelRange(levels) {
264
+ if (levels === undefined) return [1, 6];
265
+ const match = LEVELS.exec(levels);
266
+ if (!match) return undefined;
267
+ const [, only, from, to, fromOpen, toOpen] = match;
268
+ const min = Number(only ?? from ?? fromOpen ?? 1);
269
+ const max = Number(only ?? to ?? toOpen ?? 6);
270
+ return min <= max ? [min, max] : undefined;
271
+ }
272
+
245
273
  /**
246
274
  * The toc block for the headings, between the start marker line and the end marker.
247
275
  * @param {Headline[]} headlines
@@ -266,9 +294,9 @@ function renderBlock(headlines, startLine, options, eol = '\n') {
266
294
  }
267
295
 
268
296
  /**
269
- * The options written on a start marker, with a problem when they cannot be used.
297
+ * The options written on a start marker, with a problem when they cannot be used and a warning when one is ignored.
270
298
  * @param {string | undefined} text
271
- * @returns {{ options: TocOptions, problem?: string }}
299
+ * @returns {{ options: TocOptions, problem?: string, warning?: string }}
272
300
  */
273
301
  function parseOptions(text) {
274
302
  /** @type {TocOptions} */
@@ -278,20 +306,25 @@ function parseOptions(text) {
278
306
  /** @type {string[]} */
279
307
  const unknown = [];
280
308
  for (const [token, name, value] of (text ?? '').matchAll(/([^\s="]+)(?:="([^"]*)")?(?=\s|$)|\S+/g)) {
281
- if (name && KNOWN_OPTIONS.includes(name)) options[/** @type {'collapsible' | 'collapsed'} */ (name)] = value ?? true;
309
+ if (name === 'levels') options.levels = value ?? '';
310
+ else if (name && KNOWN_OPTIONS.includes(name)) options[/** @type {'collapsible' | 'collapsed'} */ (name)] = value ?? true;
282
311
  else if (name && value !== undefined && /^[a-zA-Z][\w:.-]*$/.test(name)) attributes[name] = value;
283
312
  else unknown.push(token);
284
313
  }
285
314
  const names = Object.keys(attributes);
286
315
  if (names.length) options.attributes = attributes;
287
- if (unknown.length) return { options, problem: `unknown TOC option ${unknown.join(' ')}` };
288
- if (options.collapsible !== undefined && options.collapsed !== undefined) {
289
- return { options, problem: 'TOC options collapsible and collapsed exclude each other' };
316
+ /** @type {{ options: TocOptions, problem?: string, warning?: string }} */
317
+ const parsed = { options };
318
+ if (options.levels !== undefined && !levelRange(options.levels)) {
319
+ parsed.warning = `TOC option levels "${options.levels}" is not a level or a range like 2-3, ignored`;
290
320
  }
291
- if (names.length && options.collapsible === undefined && options.collapsed === undefined) {
292
- return { options, problem: `TOC attributes ${names.join(', ')} need collapsible or collapsed` };
321
+ if (unknown.length) parsed.problem = `unknown TOC option ${unknown.join(' ')}`;
322
+ else if (options.collapsible !== undefined && options.collapsed !== undefined) {
323
+ parsed.problem = 'TOC options collapsible and collapsed exclude each other';
324
+ } else if (names.length && options.collapsible === undefined && options.collapsed === undefined) {
325
+ parsed.problem = `TOC attributes ${names.join(', ')} need collapsible or collapsed`;
293
326
  }
294
- return { options };
327
+ return parsed;
295
328
  }
296
329
 
297
330
  /**
@@ -379,7 +412,7 @@ function isParagraphText(line) {
379
412
 
380
413
  /**
381
414
  * Options written on a start marker, each `true` or a summary text, and the other attributes for the summary element.
382
- * @typedef {{ collapsible?: true | string, collapsed?: true | string, attributes?: Record<string, string> }} TocOptions
415
+ * @typedef {{ collapsible?: true | string, collapsed?: true | string, levels?: string, attributes?: Record<string, string> }} TocOptions
383
416
  */
384
417
 
385
418
  /**
@@ -394,6 +427,6 @@ function isParagraphText(line) {
394
427
  */
395
428
 
396
429
  /**
397
- * A marker pair as zero based lines, -1 for a missing side, with its options and a problem when they cannot be used.
398
- * @typedef {{ start: number, end: number, options: TocOptions, problem?: string }} Marker
430
+ * A marker pair as zero based lines, -1 for a missing side, with its options, a problem when they cannot be used and a warning when one is ignored.
431
+ * @typedef {{ start: number, end: number, options: TocOptions, problem?: string, warning?: string }} Marker
399
432
  */
package/package.json CHANGED
@@ -1,36 +1,34 @@
1
1
  {
2
2
  "name": "@0dep/toc",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Generate a GitHub flavoured markdown table of contents",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "module": "./index.js",
8
- "main": "./index.cjs",
9
8
  "types": "./types/index.d.ts",
10
9
  "exports": {
11
10
  ".": {
12
11
  "types": "./types/index.d.ts",
13
- "require": "./index.cjs",
14
- "import": "./index.js"
12
+ "default": "./index.js"
15
13
  }
16
14
  },
17
15
  "bin": {
18
16
  "toc": "bin/toc.js"
19
17
  },
20
18
  "engines": {
21
- "node": ">=20"
19
+ "node": ">=22.12"
22
20
  },
23
21
  "scripts": {
24
- "pretest": "npm run toc && npm run dist",
22
+ "pretest": "npm run toc && npm run types",
25
23
  "test": "mocha",
26
24
  "posttest": "npm run lint && npm run tsc && npm run test:md",
27
25
  "test:md": "texample",
28
- "toc": "node bin/toc.js --check README.md",
29
- "posttoc": "prettier --write README.md",
26
+ "toc": "node bin/toc.js --check README.md CHANGELOG.md",
27
+ "posttoc": "prettier --write README.md CHANGELOG.md",
30
28
  "lint": "eslint . --cache && prettier . --check --cache",
31
29
  "tsc": "tsc -p test",
32
- "dist": "rollup -c && dts-buddy",
33
- "prepack": "npm run dist",
30
+ "types": "tsc",
31
+ "prepack": "npm run types",
34
32
  "cov:html": "c8 -r html -r text mocha",
35
33
  "test:lcov": "c8 -r lcov mocha && npm run lint"
36
34
  },
@@ -46,7 +44,8 @@
46
44
  "markdown",
47
45
  "readme",
48
46
  "github",
49
- "slug"
47
+ "slug",
48
+ "glob"
50
49
  ],
51
50
  "author": {
52
51
  "name": "Zerodep AB",
@@ -63,26 +62,22 @@
63
62
  "license": "MIT",
64
63
  "devDependencies": {
65
64
  "@eslint/js": "^10.0.1",
66
- "@rollup/plugin-commonjs": "^29.0.3",
67
65
  "@types/chai": "^5.2.3",
68
66
  "@types/mocha": "^10.0.10",
69
67
  "@types/node": "^22.20.2",
70
68
  "c8": "^12.0.0",
71
69
  "chai": "^6.2.2",
72
- "dts-buddy": "^0.8.3",
73
70
  "eslint": "^10.10.0",
74
71
  "globals": "^17.12.0",
75
72
  "mocha": "^12.0.0",
76
73
  "prettier": "^3.9.6",
77
- "rollup": "^4.63.1",
78
74
  "texample": "^1.0.2",
79
75
  "typescript": "^6.0.3"
80
76
  },
81
77
  "files": [
82
78
  "bin/",
83
79
  "index.js",
84
- "index.cjs",
85
- "types/index.d.ts*",
80
+ "types/",
86
81
  "CHANGELOG.md"
87
82
  ]
88
83
  }
package/types/index.d.ts CHANGED
@@ -1,70 +1,80 @@
1
- declare module '@0dep/toc' {
2
- /**
3
- * The markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated.
4
- * */
5
- export function buildToc(source: string): string;
6
- /**
7
- * The toc block, markers included, for the headings below `fromLine`, empty when there is nothing to list.
8
- * @param fromLine zero based
9
- * */
10
- export function renderToc(source: string, fromLine?: number, options?: TocOptions): string;
11
- /**
12
- * Every marker pair in document order, shared with later calls for the same source.
13
- * */
14
- export function findMarkers(source: string): Marker[];
15
- /**
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.
21
- * */
22
- export function slugify(text: string): string;
23
- /**
24
- * The text GitHub renders for a heading's inline markdown.
25
- * */
26
- export function headingText(markdown: string): string;
27
- export const TOC_START: "<!-- toc -->";
28
- export const TOC_END: "<!-- /toc -->";
29
- /**
30
- * Options written on a start marker, each `true` or a summary text, and the other attributes for the summary element.
31
- */
32
- export type TocOptions = {
33
- collapsible?: true | string;
34
- collapsed?: true | string;
35
- attributes?: Record<string, string>;
36
- };
37
- /**
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.
59
- */
60
- export type Marker = {
61
- start: number;
62
- end: number;
63
- options: TocOptions;
64
- problem?: string;
65
- };
66
-
67
- export {};
68
- }
69
-
1
+ /**
2
+ * The markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated.
3
+ * @param {string} source
4
+ * @returns {string}
5
+ */
6
+ export function buildToc(source: string): string;
7
+ /**
8
+ * The toc block, markers included, for the headings below `fromLine`, empty when there is nothing to list.
9
+ * @param {string} source
10
+ * @param {number} [fromLine] zero based
11
+ * @param {TocOptions} [options]
12
+ * @returns {string}
13
+ */
14
+ export function renderToc(source: string, fromLine?: number, options?: TocOptions): string;
15
+ /**
16
+ * Every marker pair in document order, shared with later calls for the same source.
17
+ * @param {string} source
18
+ * @returns {Marker[]}
19
+ */
20
+ export function findMarkers(source: string): Marker[];
21
+ /**
22
+ * Every link to an anchor in the document, in order, shared with later calls for the same source.
23
+ * @param {string} source
24
+ * @returns {Anchor[]}
25
+ */
26
+ export function findAnchors(source: string): Anchor[];
27
+ /**
28
+ * The GitHub anchor slug of a heading's rendered text.
29
+ * @param {string} text
30
+ * @returns {string}
31
+ */
32
+ export function slugify(text: string): string;
33
+ /**
34
+ * The text GitHub renders for a heading's inline markdown.
35
+ * @param {string} markdown
36
+ * @returns {string}
37
+ */
38
+ export function headingText(markdown: string): string;
39
+ export const TOC_START: "<!-- toc -->";
40
+ export const TOC_END: "<!-- /toc -->";
41
+ /**
42
+ * Options written on a start marker, each `true` or a summary text, and the other attributes for the summary element.
43
+ */
44
+ export type TocOptions = {
45
+ collapsible?: true | string;
46
+ collapsed?: true | string;
47
+ levels?: string;
48
+ attributes?: Record<string, string>;
49
+ };
50
+ /**
51
+ * A heading with its zero based line, level, inline markdown and GitHub slug.
52
+ */
53
+ export type Headline = {
54
+ line: number;
55
+ level: number;
56
+ markdown: string;
57
+ slug: string;
58
+ };
59
+ /**
60
+ * A link to an anchor with its zero based line, text, anchor as written, whether it has a target and, when it
61
+ * has none and one heading clearly matches, a suggestion.
62
+ */
63
+ export type Anchor = {
64
+ line: number;
65
+ text: string;
66
+ anchor: string;
67
+ valid: boolean;
68
+ suggestion?: string;
69
+ };
70
+ /**
71
+ * A marker pair as zero based lines, -1 for a missing side, with its options, a problem when they cannot be used and a warning when one is ignored.
72
+ */
73
+ export type Marker = {
74
+ start: number;
75
+ end: number;
76
+ options: TocOptions;
77
+ problem?: string;
78
+ warning?: string;
79
+ };
70
80
  //# sourceMappingURL=index.d.ts.map
@@ -1,26 +1 @@
1
- {
2
- "version": 3,
3
- "file": "index.d.ts",
4
- "names": [
5
- "buildToc",
6
- "renderToc",
7
- "findMarkers",
8
- "findAnchors",
9
- "slugify",
10
- "headingText",
11
- "TOC_START",
12
- "TOC_END",
13
- "TocOptions",
14
- "Headline",
15
- "Anchor",
16
- "Marker"
17
- ],
18
- "sources": [
19
- "../index.js"
20
- ],
21
- "sourcesContent": [
22
- null
23
- ],
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",
25
- "ignoreList": []
26
- }
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.js"],"names":[],"mappings":"AAUA;;;;GAIG;AACH,iCAHW,MAAM,GACJ,MAAM,CAgBlB;AAED;;;;;;GAMG;AACH,kCALW,MAAM,aACN,MAAM,YACN,UAAU,GACR,MAAM,CAMlB;AAED;;;;GAIG;AACH,oCAHW,MAAM,GACJ,MAAM,EAAE,CAIpB;AAED;;;;GAIG;AACH,oCAHW,MAAM,GACJ,MAAM,EAAE,CAIpB;AAED;;;;GAIG;AACH,8BAHW,MAAM,GACJ,MAAM,CAOlB;AAED;;;;GAIG;AACH,sCAHW,MAAM,GACJ,MAAM,CAUlB;AAvFD,wBAAyB,cAAc,CAAC;AACxC,sBAAuB,eAAe,CAAC;;;;yBA6Z1B;IAAE,WAAW,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE;;;;uBAKhH;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE;;;;;qBAM/D;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE;;;;qBAKnF;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,UAAU,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE"}
package/index.cjs DELETED
@@ -1,410 +0,0 @@
1
- 'use strict';
2
-
3
- const TOC_START = '<!-- toc -->';
4
- const TOC_END = '<!-- /toc -->';
5
- const DEFAULT_SUMMARY = 'Table of contents';
6
- const KNOWN_OPTIONS = ['collapsible', 'collapsed'];
7
- const NUL = String.fromCharCode(0);
8
- /** @type {Map<string, ReturnType<typeof analyse>>} */
9
- const cache = new Map();
10
- const LINK = /\[([^\]]*)\]\(#([^)\s]+)/g;
11
-
12
- /**
13
- * The markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated.
14
- * @param {string} source
15
- * @returns {string}
16
- */
17
- function buildToc(source) {
18
- const { bom, lines, eol, headlines, markers } = analyse(source);
19
- /** @type {string[]} */
20
- const out = [];
21
- let cursor = 0;
22
- for (const { start, end, options, problem } of markers) {
23
- if (start === -1 || end === -1 || problem) continue;
24
- const listed = headlines.filter((h) => h.line > start);
25
- if (listed.length === 0) continue;
26
- out.push(...lines.slice(cursor, start), renderBlock(listed, lines[start], options, eol));
27
- cursor = end + 1;
28
- }
29
- out.push(...lines.slice(cursor));
30
- return bom + out.join(eol);
31
- }
32
-
33
- /**
34
- * The toc block, markers included, for the headings below `fromLine`, empty when there is nothing to list.
35
- * @param {string} source
36
- * @param {number} [fromLine] zero based
37
- * @param {TocOptions} [options]
38
- * @returns {string}
39
- */
40
- function renderToc(source, fromLine = -1, options = {}) {
41
- const { eol, headlines } = analyse(source);
42
- const listed = headlines.filter((h) => h.line > fromLine);
43
- return listed.length === 0 ? '' : renderBlock(listed, formatMarker(options), options, eol);
44
- }
45
-
46
- /**
47
- * Every marker pair in document order, shared with later calls for the same source.
48
- * @param {string} source
49
- * @returns {Marker[]}
50
- */
51
- function findMarkers(source) {
52
- return analyse(source).markers;
53
- }
54
-
55
- /**
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.
66
- * @param {string} text
67
- * @returns {string}
68
- */
69
- function slugify(text) {
70
- return text
71
- .toLowerCase()
72
- .replace(/[^\p{L}\p{N}\p{M} _-]/gu, '')
73
- .replace(/ /g, '-');
74
- }
75
-
76
- /**
77
- * The text GitHub renders for a heading's inline markdown.
78
- * @param {string} markdown
79
- * @returns {string}
80
- */
81
- function headingText(markdown) {
82
- const { text, restore } = protect(stripClosingHashes(markdown));
83
- const rendered = stripLinks(text)
84
- .replace(/<\/?[a-zA-Z][^>]*>/g, '')
85
- .replace(/~~(?=\S)(.+?)(?<=\S)~~/g, '$1')
86
- .replace(/\*+(?=\S)|(?<=\S)\*+/g, '')
87
- .replace(/(?<![\p{L}\p{N}_])_+(?=\S)|(?<=\S)_+(?![\p{L}\p{N}_])/gu, '');
88
- return restore(rendered).trim();
89
- }
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.
109
- * @param {string} source
110
- * @returns {{ bom: string, lines: string[], eol: string }}
111
- */
112
- function splitLines(source) {
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' };
115
- }
116
-
117
- /**
118
- * The headings, marker pairs and anchor links of the lines.
119
- * @param {string[]} lines
120
- * @returns {{ headlines: Headline[], markers: Marker[], anchors: Anchor[] }}
121
- */
122
- function scan(lines) {
123
- /** @type {Headline[]} */
124
- const headlines = [];
125
- /** @type {Marker[]} */
126
- const markers = [];
127
- /** @type {Array<{ line: number, text: string, anchor: string }>} */
128
- const links = [];
129
- /** @type {Set<string>} */
130
- const ids = new Set();
131
- /** @type {{ char: string, length: number } | null} */
132
- let fence = null;
133
- /** @type {Marker | null} */
134
- let open = null;
135
- let paragraph = false;
136
- let comment = false;
137
-
138
- for (const [i, line] of lines.entries()) {
139
- const fenceMatch = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
140
- if (fence) {
141
- if (fenceMatch && fenceMatch[1][0] === fence.char && fenceMatch[1].length >= fence.length && !fenceMatch[2].trim()) {
142
- fence = null;
143
- }
144
- continue;
145
- }
146
- if (fenceMatch && !(fenceMatch[1][0] === '`' && fenceMatch[2].includes('`'))) {
147
- fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
148
- paragraph = false;
149
- continue;
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) });
174
-
175
- const startMatch = /^ {0,3}<!--\s*toc(?:\s+(.*?))?\s*-->\s*$/.exec(line);
176
- if (startMatch) {
177
- if (!open) markers.push((open = { start: i, end: -1, ...parseOptions(startMatch[1]) }));
178
- continue;
179
- }
180
- if (/^ {0,3}<!--\s*\/toc\s*-->\s*$/.test(line)) {
181
- if (open) open.end = i;
182
- else markers.push({ start: -1, end: i, options: {} });
183
- open = null;
184
- continue;
185
- }
186
-
187
- const atx = /^ {0,3}(#{1,6})\s+(.+?)\s*$/.exec(line);
188
- if (atx) {
189
- headlines.push({ line: i, level: atx[1].length, markdown: atx[2], slug: '' });
190
- continue;
191
- }
192
-
193
- const setext = /^ {0,3}(=+|-+)\s*$/.exec(line);
194
- if (setext && i > 0 && isParagraphText(lines[i - 1])) {
195
- headlines.push({ line: i - 1, level: setext[1][0] === '=' ? 1 : 2, markdown: lines[i - 1].trim(), slug: '' });
196
- }
197
- }
198
- assignSlugs(headlines);
199
- return { headlines, markers, anchors: resolveAnchors(links, headlines, ids) };
200
- }
201
-
202
- /**
203
- * @param {Array<{ line: number, text: string, anchor: string }>} links
204
- * @param {Headline[]} headlines
205
- * @param {Set<string>} ids
206
- * @returns {Anchor[]}
207
- */
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) {
233
- /** @type {Record<string, number>} */
234
- const occurrences = {};
235
- for (const headline of headlines) {
236
- const base = slugify(headingText(headline.markdown));
237
- let slug = base;
238
- while (Object.hasOwn(occurrences, slug)) {
239
- occurrences[base]++;
240
- slug = `${base}-${occurrences[base]}`;
241
- }
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);
258
- return `${' '.repeat(level - minLevel)}- [${headingLabel(markdown)}](#${slug})`;
259
- });
260
- const details = options.collapsible ?? options.collapsed;
261
- if (details === undefined) return [startLine, '', ...tocLines, '', TOC_END].join(eol);
262
- const summary = details === true ? DEFAULT_SUMMARY : details;
263
- const tag = options.collapsible === undefined ? '<details>' : '<details open>';
264
- const attrs = formatAttributes(options.attributes ?? {})
265
- .map((a) => ` ${a}`)
266
- .join('');
267
- return [startLine, tag, `<summary${attrs}>${summary}</summary>`, '', ...tocLines, '', '</details>', TOC_END].join(eol);
268
- }
269
-
270
- /**
271
- * The options written on a start marker, with a problem when they cannot be used.
272
- * @param {string | undefined} text
273
- * @returns {{ options: TocOptions, problem?: string }}
274
- */
275
- function parseOptions(text) {
276
- /** @type {TocOptions} */
277
- const options = {};
278
- /** @type {Record<string, string>} */
279
- const attributes = {};
280
- /** @type {string[]} */
281
- const unknown = [];
282
- for (const [token, name, value] of (text ?? '').matchAll(/([^\s="]+)(?:="([^"]*)")?(?=\s|$)|\S+/g)) {
283
- if (name && KNOWN_OPTIONS.includes(name)) options[/** @type {'collapsible' | 'collapsed'} */ (name)] = value ?? true;
284
- else if (name && value !== undefined && /^[a-zA-Z][\w:.-]*$/.test(name)) attributes[name] = value;
285
- else unknown.push(token);
286
- }
287
- const names = Object.keys(attributes);
288
- if (names.length) options.attributes = attributes;
289
- if (unknown.length) return { options, problem: `unknown TOC option ${unknown.join(' ')}` };
290
- if (options.collapsible !== undefined && options.collapsed !== undefined) {
291
- return { options, problem: 'TOC options collapsible and collapsed exclude each other' };
292
- }
293
- if (names.length && options.collapsible === undefined && options.collapsed === undefined) {
294
- return { options, problem: `TOC attributes ${names.join(', ')} need collapsible or collapsed` };
295
- }
296
- return { options };
297
- }
298
-
299
- /**
300
- * The start marker line for the options.
301
- * @param {TocOptions} options
302
- */
303
- function formatMarker(options) {
304
- const { attributes = {}, ...named } = options;
305
- const parts = [
306
- ...Object.entries(named).map(([name, value]) => (value === true ? name : `${name}="${value}"`)),
307
- ...formatAttributes(attributes),
308
- ];
309
- return parts.length ? `<!-- toc ${parts.join(' ')} -->` : TOC_START;
310
- }
311
-
312
- /**
313
- * @param {Record<string, string>} attributes
314
- * @returns {string[]}
315
- */
316
- function formatAttributes(attributes) {
317
- return Object.entries(attributes).map(([name, value]) => `${name}="${value}"`);
318
- }
319
-
320
- /**
321
- * The link text used in the toc for a heading.
322
- * @param {string} markdown
323
- * @returns {string}
324
- */
325
- function headingLabel(markdown) {
326
- const { text, restore } = protect(stripClosingHashes(markdown), { keepCodeSpans: true });
327
- return restore(stripLinks(text)).trim();
328
- }
329
-
330
- /**
331
- * The markdown with code spans and backslash escapes swapped for placeholders, and a function to put them back.
332
- * @param {string} markdown
333
- * @param {{ keepCodeSpans?: boolean }} [options]
334
- */
335
- function protect(markdown, { keepCodeSpans = false } = {}) {
336
- /** @type {string[]} */
337
- const kept = [];
338
- /** @param {string} value */
339
- const keep = (value) => `${NUL}${kept.push(value) - 1}${NUL}`;
340
- const text = markdown
341
- .replace(/(`+)(.+?)\1(?!`)/g, (span, _ticks, code) => keep(keepCodeSpans ? span : unpadCodeSpan(code)))
342
- .replace(/\\([!-/:-@[-`{-~])/g, (_, char) => keep(char));
343
- return {
344
- text,
345
- /** @param {string} value */
346
- restore: (value) => value.replace(new RegExp(`${NUL}(\\d+)${NUL}`, 'g'), (_, i) => kept[Number(i)]),
347
- };
348
- }
349
-
350
- /**
351
- * The code span content unpadded as CommonMark renders it.
352
- * @param {string} code
353
- */
354
- function unpadCodeSpan(code) {
355
- if (code.length > 2 && code.startsWith(' ') && code.endsWith(' ') && code.trim() !== '') return code.slice(1, -1);
356
- return code;
357
- }
358
-
359
- /** @param {string} markdown */
360
- function stripClosingHashes(markdown) {
361
- return markdown.replace(/(^|\s+)#+\s*$/, '');
362
- }
363
-
364
- /**
365
- * The text with links, images and autolinks flattened to their text or url.
366
- * @param {string} text
367
- */
368
- function stripLinks(text) {
369
- return text
370
- .replace(/<(https?:\/\/[^>\s]+|mailto:[^>\s]+)>/g, '$1')
371
- .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
372
- .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
373
- .replace(/\[([^\]]*)\]\[[^\]]*\]/g, '$1');
374
- }
375
-
376
- /** @param {string} line */
377
- function isParagraphText(line) {
378
- const trimmed = line.trim();
379
- return trimmed !== '' && !/^(#|>|[-*+]\s|\d+[.)]\s|\||[-=]+\s*$|```|~~~|<!--)/.test(trimmed);
380
- }
381
-
382
- /**
383
- * Options written on a start marker, each `true` or a summary text, and the other attributes for the summary element.
384
- * @typedef {{ collapsible?: true | string, collapsed?: true | string, attributes?: Record<string, string> }} TocOptions
385
- */
386
-
387
- /**
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.
400
- * @typedef {{ start: number, end: number, options: TocOptions, problem?: string }} Marker
401
- */
402
-
403
- exports.TOC_END = TOC_END;
404
- exports.TOC_START = TOC_START;
405
- exports.buildToc = buildToc;
406
- exports.findAnchors = findAnchors;
407
- exports.findMarkers = findMarkers;
408
- exports.headingText = headingText;
409
- exports.renderToc = renderToc;
410
- exports.slugify = slugify;