@0dep/toc 1.0.1 → 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,38 @@
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
+
29
+ ## v1.1.0 - 2026-09-17
30
+
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`
32
+ - `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
33
+ - the last source and its scan are cached, so the bin walks a file once instead of once per call
34
+ - 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
35
+ - 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
36
+
5
37
  ## v1.0.1 - 2026-09-12
6
38
 
7
39
  - `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
@@ -2,9 +2,9 @@
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
- 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
 
@@ -12,16 +12,21 @@ 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)
18
21
  - [Output and exit code](#output-and-exit-code)
22
+ - [Anchors](#anchors)
19
23
  - [Dry run](#dry-run)
20
24
  - [With prettier](#with-prettier)
21
25
  - [API](#api)
22
26
  - [`buildToc(source)`](#buildtocsource)
23
27
  - [`renderToc(source[, fromLine[, options]])`](#rendertocsource-fromline-options)
24
28
  - [`findMarkers(source)`](#findmarkerssource)
29
+ - [`findAnchors(source)`](#findanchorssource)
25
30
  - [`slugify(text)`](#slugifytext)
26
31
  - [`headingText(markdown)`](#headingtextmarkdown)
27
32
  - [`TOC_START` and `TOC_END`](#toc_start-and-toc_end)
@@ -71,7 +76,7 @@ becomes
71
76
  ## Usage
72
77
  ```
73
78
 
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.
79
+ 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
80
 
76
81
  ### Several tocs in one document
77
82
 
@@ -99,6 +104,18 @@ The top toc lists API, get, set and License. The toc under API lists get, set an
99
104
 
100
105
  ### Options
101
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
+
102
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).
103
120
 
104
121
  ```markdown
@@ -120,7 +137,11 @@ becomes
120
137
  <!-- /toc -->
121
138
  ```
122
139
 
123
- 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:
124
145
 
125
146
  ```markdown
126
147
  <!-- toc collapsed="Contents" title="Click to expand" style="font-weight: bold" -->
@@ -133,9 +154,29 @@ Both take a summary text, `<!-- toc collapsible="Contents" -->` or `<!-- toc col
133
154
  <!-- /toc -->
134
155
  ```
135
156
 
136
- 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
137
175
 
138
- 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.
176
+ ### Breaking
177
+ ```
178
+
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.
139
180
 
140
181
  ### What is left alone
141
182
 
@@ -149,7 +190,7 @@ Anything on the start marker that is not one of the two options or a well-formed
149
190
 
150
191
  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
192
 
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.
193
+ 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
194
 
154
195
  ## CLI
155
196
 
@@ -157,18 +198,23 @@ This README has two headings named Options, the one above and the one under CLI.
157
198
  npx toc # updates README.md in the current directory
158
199
  npx toc docs/a.md docs/b.md # several files
159
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
160
202
  npx toc --dry-run README.md # print the toc, write nothing
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
161
205
  npx toc --help
162
206
  ```
163
207
 
164
- 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.
165
209
 
166
210
  ### Options
167
211
 
168
- | Option | Effect |
169
- | --------------- | ----------------------------------------------------------------------------- |
170
- | `-n, --dry-run` | Print the toc of every pair to stdout and the status to stderr, write nothing |
171
- | `-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 |
172
218
 
173
219
  ### Output and exit code
174
220
 
@@ -178,6 +224,8 @@ One status line per file on stdout, and one warning per skipped file or pair on
178
224
  | --------------------------------------------------------------------------------- | ------ | ---------------------------------------------------- |
179
225
  | `README.md: wrote TOC.` | stdout | At least one pair changed and the file was written |
180
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 |
181
229
  | `README.md: no TOC markers, skipped.` | stderr | The file has no markers |
182
230
  | `README.md:3: TOC start marker without end marker, skipped.` | stderr | Unbalanced pair |
183
231
  | `README.md:3: TOC end marker without start marker, skipped.` | stderr | Unbalanced pair |
@@ -185,9 +233,28 @@ One status line per file on stdout, and one warning per skipped file or pair on
185
233
  | `README.md:3: unknown TOC option foo, skipped.` | stderr | The start marker has something that is not an option |
186
234
  | `README.md:3: TOC options collapsible and collapsed exclude each other, skipped.` | stderr | Pick one |
187
235
  | `README.md:3: TOC attributes style need collapsible or collapsed, skipped.` | stderr | There is no summary element to put them on |
188
- | `README.md: ENOENT: no such file or directory, ...` | stderr | The file could not be read or written |
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 |
237
+ | `README.md:9: anchor #instal has no target.` | stderr | A link points at a heading that does not exist |
238
+ | `README.md:9: anchor #Install has no target, did you mean #install?` | stderr | Same, and a heading obviously matches |
239
+ | `README.md: EISDIR: illegal operation on a directory, ...` | stderr | The file could not be read or written |
240
+
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.
189
242
 
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.
243
+ ### Anchors
244
+
245
+ 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.
246
+
247
+ 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.
248
+
249
+ ```sh
250
+ npx toc --check docs/*.md
251
+ ```
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
+ ```
191
258
 
192
259
  ### Dry run
193
260
 
@@ -227,6 +294,7 @@ To regenerate the toc and format the rest of the file in one go, let a `posttoc`
227
294
  - [`buildToc(source)`](#buildtocsource)
228
295
  - [`renderToc(source[, fromLine[, options]])`](#rendertocsource-fromline-options)
229
296
  - [`findMarkers(source)`](#findmarkerssource)
297
+ - [`findAnchors(source)`](#findanchorssource)
230
298
  - [`slugify(text)`](#slugifytext)
231
299
  - [`headingText(markdown)`](#headingtextmarkdown)
232
300
  - [`TOC_START` and `TOC_END`](#toc_start-and-toc_end)
@@ -240,10 +308,10 @@ To regenerate the toc and format the rest of the file in one go, let a `posttoc`
240
308
  <!-- /toc -->
241
309
 
242
310
  ```javascript
243
- import { buildToc, renderToc, findMarkers, slugify, headingText, TOC_START, TOC_END } from '@0dep/toc';
311
+ import { buildToc, renderToc, findMarkers, findAnchors, slugify, headingText, TOC_START, TOC_END } from '@0dep/toc';
244
312
  ```
245
313
 
246
- 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.
247
315
 
248
316
  ### `buildToc(source)`
249
317
 
@@ -273,7 +341,7 @@ console.log(md);
273
341
 
274
342
  ### `renderToc(source[, fromLine[, options]])`
275
343
 
276
- 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.
277
345
 
278
346
  ```javascript
279
347
  import { renderToc } from '@0dep/toc';
@@ -293,7 +361,7 @@ console.log(renderToc('# Title\n\n## Install\n\n### From npm\n'));
293
361
 
294
362
  ### `findMarkers(source)`
295
363
 
296
- 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.
297
365
 
298
366
  ```javascript
299
367
  import { findMarkers } from '@0dep/toc';
@@ -308,9 +376,27 @@ console.log(findMarkers('# Title\n\n<!-- toc collapsed -->\n<!-- /toc -->\n\n##
308
376
  ]
309
377
  ```
310
378
 
379
+ ### `findAnchors(source)`
380
+
381
+ 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.
382
+
383
+ ```javascript
384
+ import { findAnchors } from '@0dep/toc';
385
+
386
+ console.log(findAnchors('## My Heading\n\n[a](#my-heading) [b](#My-Heading) [c](#nope)\n'));
387
+ ```
388
+
389
+ ```text
390
+ [
391
+ { line: 2, text: 'a', anchor: 'my-heading', valid: true },
392
+ { line: 2, text: 'b', anchor: 'My-Heading', valid: false, suggestion: 'my-heading' },
393
+ { line: 2, text: 'c', anchor: 'nope', valid: false }
394
+ ]
395
+ ```
396
+
311
397
  ### `slugify(text)`
312
398
 
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.
399
+ 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
400
 
315
401
  ```javascript
316
402
  import { slugify } from '@0dep/toc';
@@ -354,6 +440,14 @@ The marker strings, `<!-- toc -->` and `<!-- /toc -->`, for code that wants to l
354
440
  </details>
355
441
  <!-- /toc -->
356
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
+
357
451
  ### What is listed
358
452
 
359
453
  - ATX headings, `## Heading`, with up to three leading spaces and a space after the hashes, and setext headings underlined with `===` or `---`
@@ -370,7 +464,7 @@ The link text is the heading's own markdown, so inline code and emphasis are kep
370
464
 
371
465
  ### Slugs
372
466
 
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.
467
+ 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
468
 
375
469
  ## License
376
470
 
package/bin/toc.js CHANGED
@@ -1,16 +1,19 @@
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
- import { buildToc, findMarkers, renderToc } from '../index.js';
6
+ import { buildToc, findAnchors, findMarkers, renderToc } from '../index.js';
6
7
 
7
8
  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.
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),
15
+ is checked against the headings and html ids in the file and a link
16
+ without a target gets a warning too.
14
17
 
15
18
  Markers:
16
19
  <!-- toc --> plain list
@@ -18,21 +21,30 @@ Markers:
18
21
  <!-- toc collapsed --> same, but closed until clicked
19
22
  <!-- toc collapsed="Contents" --> either one with a custom summary text
20
23
  <!-- 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
24
+ <!-- toc levels="2-3" --> only these heading levels, also "2", "-3", "3-"
25
+ <!-- /toc --> end of the block
26
+
27
+ Files are resolved against the current directory and default to
22
28
  README.md. Several files can be given as separate arguments or comma
23
- 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.
24
33
 
25
34
  Options:
26
35
  -n, --dry-run print the toc to stdout and report on stderr, write nothing.
27
36
  Without markers every heading is listed so the block can be
28
37
  pasted into the document
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
29
41
  -h, --help show this help
30
42
  `;
31
43
 
32
44
  await main();
33
45
 
34
46
  async function main() {
35
- const { files, dryRun, help, unknown } = parseArgs(process.argv.slice(2));
47
+ const { files, help, unknown, ...flags } = parseArgs(process.argv.slice(2));
36
48
 
37
49
  if (unknown) {
38
50
  console.error(`Unknown option: ${unknown}\n\n${USAGE}`);
@@ -44,76 +56,121 @@ async function main() {
44
56
  return;
45
57
  }
46
58
 
47
- for (const file of files) {
48
- try {
49
- await processFile(file, dryRun);
50
- } catch (err) {
51
- console.error(`${file}: ${/** @type {Error} */ (err).message}`);
52
- 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
+ }
53
77
  }
54
78
  }
55
79
  }
56
80
 
57
81
  /**
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.
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
+
93
+ /**
94
+ * Update the toc and check the anchors of one file, or report what would happen with `dryRun`.
62
95
  * @param {string} file
63
- * @param {boolean} dryRun
96
+ * @param {{ dryRun: boolean, check: boolean, silent: boolean }} flags
64
97
  */
65
- async function processFile(file, dryRun) {
98
+ async function processFile(file, { dryRun, check, silent }) {
66
99
  const source = await readFile(file, 'utf8');
67
- const markers = findMarkers(source);
100
+ const status = silent ? noop : dryRun ? console.error : console.log;
68
101
  const warn = console.error;
69
102
 
103
+ const regenerated = reportMarkers(file, source, dryRun, silent);
104
+ let updated = source;
105
+ if (regenerated.length) {
106
+ updated = buildToc(source);
107
+ if (updated === source) status(`${file}: TOC already up to date.`);
108
+ else status(`${file}: ${dryRun ? 'would write' : 'wrote'} TOC.`);
109
+ }
110
+
111
+ for (const { line, anchor, valid, suggestion } of findAnchors(source)) {
112
+ if (valid || regenerated.some(({ start, end }) => line >= start && line <= end)) continue;
113
+ if (suggestion) warn(`${file}:${line + 1}: anchor #${anchor} has no target, did you mean #${suggestion}?`);
114
+ else warn(`${file}:${line + 1}: anchor #${anchor} has no target.`);
115
+ if (check) process.exitCode = 1;
116
+ }
117
+
118
+ if (!dryRun && updated !== source) await writeFile(file, updated);
119
+ }
120
+
121
+ /**
122
+ * @param {string} file
123
+ * @param {string} source
124
+ * @param {boolean} dryRun
125
+ * @param {boolean} silent
126
+ * @returns {Array<{ start: number, end: number }>}
127
+ */
128
+ function reportMarkers(file, source, dryRun, silent) {
129
+ const markers = findMarkers(source);
130
+ const warn = silent ? noop : console.error;
131
+ /** @type {Array<{ start: number, end: number }>} */
132
+ const regenerated = [];
133
+
70
134
  if (markers.length === 0) {
71
135
  const toc = renderToc(source);
72
136
  if (dryRun && toc) console.log(toc);
73
137
  warn(`${file}: no TOC markers, skipped.`);
74
- return;
138
+ return regenerated;
75
139
  }
76
140
 
77
- let updatable = 0;
78
- for (const { start, end, options, problem } of markers) {
141
+ for (const { start, end, options, problem, warning } of markers) {
79
142
  if (start === -1) {
80
143
  warn(`${file}:${end + 1}: TOC end marker without start marker, skipped.`);
81
144
  continue;
82
145
  }
146
+ if (warning) warn(`${file}:${start + 1}: ${warning}.`);
83
147
  const toc = renderToc(source, start, problem ? {} : options);
84
148
  if (dryRun && toc) console.log(toc);
85
149
  if (problem) warn(`${file}:${start + 1}: ${problem}, skipped.`);
86
150
  else if (end === -1) warn(`${file}:${start + 1}: TOC start marker without end marker, skipped.`);
87
151
  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.`);
152
+ else regenerated.push({ start, end });
100
153
  }
154
+ return regenerated;
101
155
  }
102
156
 
103
157
  /**
104
158
  * @param {string[]} argv
105
- * @returns {{ files: string[], dryRun: boolean, help: boolean, unknown?: string }}
159
+ * @returns {{ files: string[], dryRun: boolean, check: boolean, silent: boolean, help: boolean, unknown?: string }}
106
160
  */
107
161
  function parseArgs(argv) {
108
162
  /** @type {string[]} */
109
163
  const files = [];
110
- let dryRun = false;
111
- let help = false;
164
+ const flags = { dryRun: false, check: false, silent: false, help: false };
112
165
  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 };
166
+ if (arg === '-n' || arg === '--dry-run') flags.dryRun = true;
167
+ else if (arg === '-c' || arg === '--check') flags.check = true;
168
+ else if (arg === '-s' || arg === '--silent') flags.silent = true;
169
+ else if (arg === '-h' || arg === '--help') flags.help = true;
170
+ else if (arg.startsWith('-')) return { files, ...flags, unknown: arg };
116
171
  else files.push(...arg.split(',').filter(Boolean));
117
172
  }
118
- return { files: files.length ? files : ['README.md'], dryRun, help };
173
+ return { files: files.length ? files : ['README.md'], ...flags };
119
174
  }
175
+
176
+ function noop() {}