@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 +32 -0
- package/README.md +114 -20
- package/bin/toc.js +100 -43
- package/index.js +192 -82
- package/package.json +11 -16
- package/types/index.d.ts +79 -59
- package/types/index.d.ts.map +1 -23
- package/index.cjs +0 -332
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
|
[](https://github.com/zerodep/toc/actions/workflows/build.yaml)[](https://github.com/zerodep/toc/actions/workflows/build-windows.yaml)[](https://coveralls.io/github/zerodep/toc?branch=main)
|
|
4
4
|
|
|
5
|
-
Generate a GitHub flavoured table of contents for markdown files. No dependencies
|
|
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" -->`.
|
|
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.
|
|
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
|
-
|
|
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
|
-
| `-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
12
|
-
unbalanced, has a problem on its start marker, or has
|
|
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
|
-
<!--
|
|
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,
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 === '-
|
|
115
|
-
else if (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'],
|
|
173
|
+
return { files: files.length ? files : ['README.md'], ...flags };
|
|
119
174
|
}
|
|
175
|
+
|
|
176
|
+
function noop() {}
|