@0dep/toc 0.0.1
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 +7 -0
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/bin/toc.js +119 -0
- package/index.cjs +332 -0
- package/index.js +322 -0
- package/package.json +88 -0
- package/types/index.d.ts +60 -0
- package/types/index.d.ts.map +23 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerodep
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# @0dep/toc
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
Generate a GitHub flavoured table of contents for markdown files. No dependencies, ESM and CommonJS. The library is string in, string out and runs in the browser too, the `toc` bin needs Node 20 or later.
|
|
6
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
<!-- toc -->
|
|
10
|
+
|
|
11
|
+
- [Install](#install)
|
|
12
|
+
- [Markers](#markers)
|
|
13
|
+
- [Several tocs in one document](#several-tocs-in-one-document)
|
|
14
|
+
- [Options](#options)
|
|
15
|
+
- [What is left alone](#what-is-left-alone)
|
|
16
|
+
- [CLI](#cli)
|
|
17
|
+
- [Options](#options-1)
|
|
18
|
+
- [Output and exit code](#output-and-exit-code)
|
|
19
|
+
- [Dry run](#dry-run)
|
|
20
|
+
- [Keep the toc fresh with npm test](#keep-the-toc-fresh-with-npm-test)
|
|
21
|
+
- [With prettier](#with-prettier)
|
|
22
|
+
- [API](#api)
|
|
23
|
+
- [`buildToc(source)`](#buildtocsource)
|
|
24
|
+
- [`renderToc(source[, fromLine[, options]])`](#rendertocsource-fromline-options)
|
|
25
|
+
- [`findMarkers(source)`](#findmarkerssource)
|
|
26
|
+
- [`slugify(text)`](#slugifytext)
|
|
27
|
+
- [`headingText(markdown)`](#headingtextmarkdown)
|
|
28
|
+
- [`TOC_START` and `TOC_END`](#toc_start-and-toc_end)
|
|
29
|
+
- [Headings](#headings)
|
|
30
|
+
- [What is listed](#what-is-listed)
|
|
31
|
+
- [Labels](#labels)
|
|
32
|
+
- [Slugs](#slugs)
|
|
33
|
+
- [License](#license)
|
|
34
|
+
|
|
35
|
+
<!-- /toc -->
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npm install --save-dev @0dep/toc
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Markers
|
|
44
|
+
|
|
45
|
+
Put the pair where the toc should go, on their own lines. Every heading below the start marker is listed, nothing above it is, so the document title stays out of the toc when the markers sit under it.
|
|
46
|
+
|
|
47
|
+
```markdown
|
|
48
|
+
# My project
|
|
49
|
+
|
|
50
|
+
<!-- toc -->
|
|
51
|
+
<!-- /toc -->
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
becomes
|
|
59
|
+
|
|
60
|
+
```markdown
|
|
61
|
+
# My project
|
|
62
|
+
|
|
63
|
+
<!-- toc -->
|
|
64
|
+
|
|
65
|
+
- [Install](#install)
|
|
66
|
+
- [Usage](#usage)
|
|
67
|
+
|
|
68
|
+
<!-- /toc -->
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Running it again replaces whatever is between the markers with a fresh list, so the toc can be regenerated any number of times. A file with CRLF line endings keeps them, the toc is written with the same line ending as the rest of the file.
|
|
76
|
+
|
|
77
|
+
### Several tocs in one document
|
|
78
|
+
|
|
79
|
+
A document can have any number of marker pairs, for example a full toc at the top and one per section. Each pair lists every heading below its own start marker, and a later pair never shortens an earlier toc.
|
|
80
|
+
|
|
81
|
+
```markdown
|
|
82
|
+
# My project
|
|
83
|
+
|
|
84
|
+
<!-- toc -->
|
|
85
|
+
<!-- /toc -->
|
|
86
|
+
|
|
87
|
+
## API
|
|
88
|
+
|
|
89
|
+
<!-- toc -->
|
|
90
|
+
<!-- /toc -->
|
|
91
|
+
|
|
92
|
+
### get
|
|
93
|
+
|
|
94
|
+
### set
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The top toc lists API, get, set and License. The toc under API lists get, set and License.
|
|
100
|
+
|
|
101
|
+
### Options
|
|
102
|
+
|
|
103
|
+
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).
|
|
104
|
+
|
|
105
|
+
```markdown
|
|
106
|
+
<!-- toc collapsed -->
|
|
107
|
+
<!-- /toc -->
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
becomes
|
|
111
|
+
|
|
112
|
+
```markdown
|
|
113
|
+
<!-- toc collapsed -->
|
|
114
|
+
<details>
|
|
115
|
+
<summary>Table of contents</summary>
|
|
116
|
+
|
|
117
|
+
- [Install](#install)
|
|
118
|
+
- [Usage](#usage)
|
|
119
|
+
|
|
120
|
+
</details>
|
|
121
|
+
<!-- /toc -->
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
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:
|
|
125
|
+
|
|
126
|
+
```markdown
|
|
127
|
+
<!-- toc collapsed="Contents" class="toc" style="font-weight: bold" -->
|
|
128
|
+
<details>
|
|
129
|
+
<summary class="toc" style="font-weight: bold">Contents</summary>
|
|
130
|
+
|
|
131
|
+
- [Install](#install)
|
|
132
|
+
|
|
133
|
+
</details>
|
|
134
|
+
<!-- /toc -->
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Renderers sanitise html, GitHub for one drops `style`, so check what yours keeps. The start marker itself is kept exactly as written, and spacing inside the comment does not matter.
|
|
138
|
+
|
|
139
|
+
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.
|
|
140
|
+
|
|
141
|
+
### What is left alone
|
|
142
|
+
|
|
143
|
+
- A file without markers
|
|
144
|
+
- A start marker without an end marker after it
|
|
145
|
+
- An end marker with no open start marker before it
|
|
146
|
+
- A pair with no headings below its start marker, since a toc that lists nothing is more likely a misplaced marker than an empty document
|
|
147
|
+
- A pair whose start marker has a problem: an unknown option, both `collapsible` and `collapsed`, or attributes without either
|
|
148
|
+
- Markers indented four spaces or more, that is an indented code block
|
|
149
|
+
- Markers and headings inside fenced code blocks, so this README can show marker examples. A marker in inline code or in the middle of a sentence is not a marker either, only a line holding nothing but the comment
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
This README has two headings named Options, the one above and the one under CLI. The second one is linked as `#options-1`, the way GitHub numbers duplicate anchors.
|
|
154
|
+
|
|
155
|
+
## CLI
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
npx toc # updates README.md in the current directory
|
|
159
|
+
npx toc docs/a.md docs/b.md # several files
|
|
160
|
+
npx toc docs/a.md,docs/b.md # comma separated works too
|
|
161
|
+
npx toc --dry-run README.md # print the toc, write nothing
|
|
162
|
+
npx toc --help
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Paths are resolved against the current directory.
|
|
166
|
+
|
|
167
|
+
### Options
|
|
168
|
+
|
|
169
|
+
| Option | Effect |
|
|
170
|
+
| --------------- | ----------------------------------------------------------------------------- |
|
|
171
|
+
| `-n, --dry-run` | Print the toc of every pair to stdout and the status to stderr, write nothing |
|
|
172
|
+
| `-h, --help` | Show usage |
|
|
173
|
+
|
|
174
|
+
### Output and exit code
|
|
175
|
+
|
|
176
|
+
One status line per file on stdout, and one warning per skipped file or pair on stderr with the marker's line number so it can be opened from a terminal.
|
|
177
|
+
|
|
178
|
+
| Message | Stream | Meaning |
|
|
179
|
+
| --------------------------------------------------------------------------------- | ------ | ---------------------------------------------------- |
|
|
180
|
+
| `README.md: wrote TOC.` | stdout | At least one pair changed and the file was written |
|
|
181
|
+
| `README.md: TOC already up to date.` | stdout | Every updatable pair was already correct |
|
|
182
|
+
| `README.md: no TOC markers, skipped.` | stderr | The file has no markers |
|
|
183
|
+
| `README.md:3: TOC start marker without end marker, skipped.` | stderr | Unbalanced pair |
|
|
184
|
+
| `README.md:3: TOC end marker without start marker, skipped.` | stderr | Unbalanced pair |
|
|
185
|
+
| `README.md:3: no headings below TOC start marker, skipped.` | stderr | Nothing to list for that pair |
|
|
186
|
+
| `README.md:3: unknown TOC option foo, skipped.` | stderr | The start marker has something that is not an option |
|
|
187
|
+
| `README.md:3: TOC options collapsible and collapsed exclude each other, skipped.` | stderr | Pick one |
|
|
188
|
+
| `README.md:3: TOC attributes style need collapsible or collapsed, skipped.` | stderr | There is no summary element to put them on |
|
|
189
|
+
| `README.md: ENOENT: no such file or directory, ...` | stderr | The file could not be read or written |
|
|
190
|
+
|
|
191
|
+
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.
|
|
192
|
+
|
|
193
|
+
### Dry run
|
|
194
|
+
|
|
195
|
+
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.
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
npx toc --dry-run docs/new.md > toc.md
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
docs/new.md: no TOC markers, skipped.
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Keep the toc fresh with npm test
|
|
206
|
+
|
|
207
|
+
A typical setup regenerates the toc before the tests run and lints afterwards, this README is maintained that way:
|
|
208
|
+
|
|
209
|
+
```json
|
|
210
|
+
{
|
|
211
|
+
"scripts": {
|
|
212
|
+
"pretest": "toc README.md",
|
|
213
|
+
"test": "mocha",
|
|
214
|
+
"posttest": "prettier . --check"
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### With prettier
|
|
220
|
+
|
|
221
|
+
The generated block is written the way prettier formats markdown, so the two do not fight: `-` bullets, two spaces per nesting level, a blank line before and after the list, and html on lines of its own. Running `prettier --write` over a generated toc changes nothing, and running `toc` over a prettier formatted toc changes nothing either. This README passes both.
|
|
222
|
+
|
|
223
|
+
Run `toc` before `prettier --check`, as in the scripts above, so a stale toc is regenerated rather than reported as a formatting error. The order does not matter for the content: prettier leaves headings as written, setext underlines and closing hashes included, and it never breaks a line inside a link, so toc entries survive even `proseWrap: "always"`.
|
|
224
|
+
|
|
225
|
+
To regenerate the toc and format the rest of the file in one go, let a `posttoc` script run prettier with `--write` after `toc`. Prettier rewrites the prose and leaves the generated block as it is, so a second `toc` run reports the file up to date:
|
|
226
|
+
|
|
227
|
+
```json
|
|
228
|
+
{
|
|
229
|
+
"scripts": {
|
|
230
|
+
"toc": "toc README.md",
|
|
231
|
+
"posttoc": "prettier --write README.md"
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## API
|
|
237
|
+
|
|
238
|
+
<!-- toc collapsed="Jump to" -->
|
|
239
|
+
<details>
|
|
240
|
+
<summary>Jump to</summary>
|
|
241
|
+
|
|
242
|
+
- [`buildToc(source)`](#buildtocsource)
|
|
243
|
+
- [`renderToc(source[, fromLine[, options]])`](#rendertocsource-fromline-options)
|
|
244
|
+
- [`findMarkers(source)`](#findmarkerssource)
|
|
245
|
+
- [`slugify(text)`](#slugifytext)
|
|
246
|
+
- [`headingText(markdown)`](#headingtextmarkdown)
|
|
247
|
+
- [`TOC_START` and `TOC_END`](#toc_start-and-toc_end)
|
|
248
|
+
- [Headings](#headings)
|
|
249
|
+
- [What is listed](#what-is-listed)
|
|
250
|
+
- [Labels](#labels)
|
|
251
|
+
- [Slugs](#slugs)
|
|
252
|
+
- [License](#license)
|
|
253
|
+
|
|
254
|
+
</details>
|
|
255
|
+
<!-- /toc -->
|
|
256
|
+
|
|
257
|
+
```javascript
|
|
258
|
+
import { buildToc, renderToc, findMarkers, slugify, headingText, TOC_START, TOC_END } from '@0dep/toc';
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
CommonJS works the same way with `require('@0dep/toc')`.
|
|
262
|
+
|
|
263
|
+
### `buildToc(source)`
|
|
264
|
+
|
|
265
|
+
Returns the markdown `source` with the toc between every marker pair regenerated. A pair that is unbalanced, has a problem on its start marker, or has no headings below it is left alone, and the source is returned unchanged when nothing is updated. The start marker line is kept as written.
|
|
266
|
+
|
|
267
|
+
```javascript
|
|
268
|
+
import { buildToc } from '@0dep/toc';
|
|
269
|
+
|
|
270
|
+
const md = buildToc('# Title\n\n<!-- toc -->\n<!-- /toc -->\n\n## Install\n\n## Usage `api`\n');
|
|
271
|
+
console.log(md);
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
```text
|
|
275
|
+
# Title
|
|
276
|
+
|
|
277
|
+
<!-- toc -->
|
|
278
|
+
|
|
279
|
+
- [Install](#install)
|
|
280
|
+
- [Usage `api`](#usage-api)
|
|
281
|
+
|
|
282
|
+
<!-- /toc -->
|
|
283
|
+
|
|
284
|
+
## Install
|
|
285
|
+
|
|
286
|
+
## Usage `api`
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### `renderToc(source[, fromLine[, options]])`
|
|
290
|
+
|
|
291
|
+
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.
|
|
292
|
+
|
|
293
|
+
```javascript
|
|
294
|
+
import { renderToc } from '@0dep/toc';
|
|
295
|
+
|
|
296
|
+
console.log(renderToc('# Title\n\n## Install\n\n### From npm\n'));
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
```text
|
|
300
|
+
<!-- toc -->
|
|
301
|
+
|
|
302
|
+
- [Title](#title)
|
|
303
|
+
- [Install](#install)
|
|
304
|
+
- [From npm](#from-npm)
|
|
305
|
+
|
|
306
|
+
<!-- /toc -->
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### `findMarkers(source)`
|
|
310
|
+
|
|
311
|
+
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.
|
|
312
|
+
|
|
313
|
+
```javascript
|
|
314
|
+
import { findMarkers } from '@0dep/toc';
|
|
315
|
+
|
|
316
|
+
console.log(findMarkers('# Title\n\n<!-- toc collapsed -->\n<!-- /toc -->\n\n## A\n\n<!-- toc foo -->\n'));
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
```text
|
|
320
|
+
[
|
|
321
|
+
{ start: 2, end: 3, options: { collapsed: true } },
|
|
322
|
+
{ start: 7, end: -1, options: {}, problem: 'unknown TOC option foo' }
|
|
323
|
+
]
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### `slugify(text)`
|
|
327
|
+
|
|
328
|
+
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.
|
|
329
|
+
|
|
330
|
+
```javascript
|
|
331
|
+
import { slugify } from '@0dep/toc';
|
|
332
|
+
|
|
333
|
+
console.log(slugify('Usage `api`'), slugify('Ändra värde 2 gånger'));
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```text
|
|
337
|
+
usage-api ändra-värde-2-gånger
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### `headingText(markdown)`
|
|
341
|
+
|
|
342
|
+
Reduces a heading's inline markdown to the text GitHub renders and slugs: links and images become their text, emphasis, strikethrough and html tags are removed, autolinks become their url, code span content is kept without the backticks and backslash escapes are resolved.
|
|
343
|
+
|
|
344
|
+
```javascript
|
|
345
|
+
import { headingText } from '@0dep/toc';
|
|
346
|
+
|
|
347
|
+
console.log(headingText('Use `foo()` with [**bold**](https://example.com) \\*text\\*'));
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
```text
|
|
351
|
+
Use foo() with bold *text*
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### `TOC_START` and `TOC_END`
|
|
355
|
+
|
|
356
|
+
The marker strings, `<!-- toc -->` and `<!-- /toc -->`, for code that wants to look for or insert them.
|
|
357
|
+
|
|
358
|
+
## Headings
|
|
359
|
+
|
|
360
|
+
<!-- toc collapsible="In this section and below" -->
|
|
361
|
+
<details open>
|
|
362
|
+
<summary>In this section and below</summary>
|
|
363
|
+
|
|
364
|
+
- [What is listed](#what-is-listed)
|
|
365
|
+
- [Labels](#labels)
|
|
366
|
+
- [Slugs](#slugs)
|
|
367
|
+
- [License](#license)
|
|
368
|
+
|
|
369
|
+
</details>
|
|
370
|
+
<!-- /toc -->
|
|
371
|
+
|
|
372
|
+
### What is listed
|
|
373
|
+
|
|
374
|
+
- ATX headings, `## Heading`, with up to three leading spaces and a space after the hashes, and setext headings underlined with `===` or `---`
|
|
375
|
+
- Every level, 1 through 6, below the start marker. Nesting is relative to the shallowest level listed so far, so a toc under a level 2 heading that lists level 3 headings starts flush left, and the level 2 headings that follow join them at the top level
|
|
376
|
+
- Not headings inside fenced code blocks, backtick or tilde
|
|
377
|
+
- Not a `---` line under a list item, a table row or a blank line, which is a thematic break and not a setext heading
|
|
378
|
+
|
|
379
|
+
### Labels
|
|
380
|
+
|
|
381
|
+
The link text is the heading's own markdown, so inline code and emphasis are kept, with two changes:
|
|
382
|
+
|
|
383
|
+
- Links and images become their text and autolinks their url, since a link cannot nest inside the toc link
|
|
384
|
+
- Optional closing hashes, `## Heading ##`, are stripped
|
|
385
|
+
|
|
386
|
+
### Slugs
|
|
387
|
+
|
|
388
|
+
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.
|
|
389
|
+
|
|
390
|
+
## License
|
|
391
|
+
|
|
392
|
+
MIT
|
package/bin/toc.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable no-console */
|
|
3
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
import { buildToc, findMarkers, renderToc } from '../index.js';
|
|
6
|
+
|
|
7
|
+
const USAGE = `Usage: toc [options] [file...]
|
|
8
|
+
|
|
9
|
+
Update the table of contents between every <!-- toc --> and <!-- /toc -->
|
|
10
|
+
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.
|
|
14
|
+
|
|
15
|
+
Markers:
|
|
16
|
+
<!-- toc --> plain list
|
|
17
|
+
<!-- toc collapsible --> list inside a <details> element, open
|
|
18
|
+
<!-- toc collapsed --> same, but closed until clicked
|
|
19
|
+
<!-- toc collapsed="Contents" --> either one with a custom summary text
|
|
20
|
+
<!-- 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
|
|
22
|
+
README.md. Several files can be given as separate arguments or comma
|
|
23
|
+
separated.
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-n, --dry-run print the toc to stdout and report on stderr, write nothing.
|
|
27
|
+
Without markers every heading is listed so the block can be
|
|
28
|
+
pasted into the document
|
|
29
|
+
-h, --help show this help
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
await main();
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
const { files, dryRun, help, unknown } = parseArgs(process.argv.slice(2));
|
|
36
|
+
|
|
37
|
+
if (unknown) {
|
|
38
|
+
console.error(`Unknown option: ${unknown}\n\n${USAGE}`);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (help) {
|
|
43
|
+
console.log(USAGE);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
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;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
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.
|
|
62
|
+
* @param {string} file
|
|
63
|
+
* @param {boolean} dryRun
|
|
64
|
+
*/
|
|
65
|
+
async function processFile(file, dryRun) {
|
|
66
|
+
const source = await readFile(file, 'utf8');
|
|
67
|
+
const markers = findMarkers(source);
|
|
68
|
+
const warn = console.error;
|
|
69
|
+
|
|
70
|
+
if (markers.length === 0) {
|
|
71
|
+
const toc = renderToc(source);
|
|
72
|
+
if (dryRun && toc) console.log(toc);
|
|
73
|
+
warn(`${file}: no TOC markers, skipped.`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let updatable = 0;
|
|
78
|
+
for (const { start, end, options, problem } of markers) {
|
|
79
|
+
if (start === -1) {
|
|
80
|
+
warn(`${file}:${end + 1}: TOC end marker without start marker, skipped.`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const toc = renderToc(source, start, problem ? {} : options);
|
|
84
|
+
if (dryRun && toc) console.log(toc);
|
|
85
|
+
if (problem) warn(`${file}:${start + 1}: ${problem}, skipped.`);
|
|
86
|
+
else if (end === -1) warn(`${file}:${start + 1}: TOC start marker without end marker, skipped.`);
|
|
87
|
+
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.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string[]} argv
|
|
105
|
+
* @returns {{ files: string[], dryRun: boolean, help: boolean, unknown?: string }}
|
|
106
|
+
*/
|
|
107
|
+
function parseArgs(argv) {
|
|
108
|
+
/** @type {string[]} */
|
|
109
|
+
const files = [];
|
|
110
|
+
let dryRun = false;
|
|
111
|
+
let help = false;
|
|
112
|
+
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 };
|
|
116
|
+
else files.push(...arg.split(',').filter(Boolean));
|
|
117
|
+
}
|
|
118
|
+
return { files: files.length ? files : ['README.md'], dryRun, help };
|
|
119
|
+
}
|
package/index.cjs
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
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
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Return the markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated. Each
|
|
11
|
+
* pair lists every heading below its own start marker. A pair is left alone when it is unbalanced, has a
|
|
12
|
+
* problem on its start marker, or has no headings below it, and nothing outside the pairs is ever touched.
|
|
13
|
+
* The start marker line is kept as written, options included.
|
|
14
|
+
* @param {string} source
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function buildToc(source) {
|
|
18
|
+
const { lines, eol } = splitLines(source);
|
|
19
|
+
const { headlines, markers } = scan(lines);
|
|
20
|
+
/** @type {string[]} */
|
|
21
|
+
const out = [];
|
|
22
|
+
let cursor = 0;
|
|
23
|
+
for (const { start, end, options, problem } of markers) {
|
|
24
|
+
if (start === -1 || end === -1 || problem) continue;
|
|
25
|
+
const listed = headlines.filter((h) => h.line > start);
|
|
26
|
+
if (listed.length === 0) continue;
|
|
27
|
+
out.push(...lines.slice(cursor, start), renderBlock(listed, lines[start], options, eol));
|
|
28
|
+
cursor = end + 1;
|
|
29
|
+
}
|
|
30
|
+
out.push(...lines.slice(cursor));
|
|
31
|
+
return out.join(eol);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return the toc block, markers included, for the headings below `fromLine`, by default every heading so the
|
|
36
|
+
* block can be pasted into a document without markers. Returns an empty string when there is nothing to list.
|
|
37
|
+
* @param {string} source
|
|
38
|
+
* @param {number} [fromLine] zero based line number, typically the start marker's
|
|
39
|
+
* @param {TocOptions} [options] rendered into the start marker, e.g. `{ collapsed: 'Contents' }`
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function renderToc(source, fromLine = -1, options = {}) {
|
|
43
|
+
const { lines, eol } = splitLines(source);
|
|
44
|
+
const listed = scan(lines).headlines.filter((h) => h.line > fromLine);
|
|
45
|
+
return listed.length === 0 ? '' : renderBlock(listed, formatMarker(options), options, eol);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every marker pair in document order as zero based line numbers, outside fenced code blocks. A start marker
|
|
50
|
+
* pairs with the first end marker after it. A missing side is -1: a start marker without an end marker, or an
|
|
51
|
+
* end marker with no open start marker before it. `options` holds the recognised options written on the start
|
|
52
|
+
* marker and `problem`, only present when there is one, says why the marker cannot be used.
|
|
53
|
+
* @param {string} source
|
|
54
|
+
* @returns {Marker[]}
|
|
55
|
+
*/
|
|
56
|
+
function findMarkers(source) {
|
|
57
|
+
return scan(splitLines(source).lines).markers;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Slug a heading's rendered text the way github-slugger does: lowercase, drop everything that is not a
|
|
62
|
+
* letter, number, mark, space, hyphen or underscore, then turn each space into a hyphen. Nothing is trimmed
|
|
63
|
+
* or collapsed.
|
|
64
|
+
* @param {string} text
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function slugify(text) {
|
|
68
|
+
return text
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^\p{L}\p{N}\p{M} _-]/gu, '')
|
|
71
|
+
.replace(/ /g, '-');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Reduce a heading's inline markdown to the text GitHub renders and slugs.
|
|
76
|
+
* @param {string} markdown
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
function headingText(markdown) {
|
|
80
|
+
const { text, restore } = protect(stripClosingHashes(markdown));
|
|
81
|
+
const rendered = stripLinks(text)
|
|
82
|
+
.replace(/<\/?[a-zA-Z][^>]*>/g, '')
|
|
83
|
+
.replace(/~~(?=\S)(.+?)(?<=\S)~~/g, '$1')
|
|
84
|
+
.replace(/\*+(?=\S)|(?<=\S)\*+/g, '')
|
|
85
|
+
.replace(/(?<![\p{L}\p{N}_])_+(?=\S)|(?<=\S)_+(?![\p{L}\p{N}_])/gu, '');
|
|
86
|
+
return restore(rendered).trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Split the source into lines and remember its line ending, CRLF when the source has any, so the toc is
|
|
91
|
+
* written the way the rest of the file is and a Windows authored file does not end up with mixed endings.
|
|
92
|
+
* @param {string} source
|
|
93
|
+
* @returns {{ lines: string[], eol: string }}
|
|
94
|
+
*/
|
|
95
|
+
function splitLines(source) {
|
|
96
|
+
return { lines: source.split(/\r?\n/), eol: source.includes('\r\n') ? '\r\n' : '\n' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Scan the lines for every marker pair and every ATX and setext heading, all outside fenced code blocks. A
|
|
101
|
+
* marker is a line holding nothing but the comment, indented at most three spaces like a heading, since four
|
|
102
|
+
* make an indented code block. A start marker, `<!-- toc -->` with optional options before the closing `-->`,
|
|
103
|
+
* opens a pair that the first end marker after it closes; a start marker inside an open pair is content and an end marker outside a pair is
|
|
104
|
+
* reported with start -1.
|
|
105
|
+
* @param {string[]} lines
|
|
106
|
+
* @returns {{ headlines: Array<{ line: number, level: number, markdown: string }>, markers: Marker[] }}
|
|
107
|
+
*/
|
|
108
|
+
function scan(lines) {
|
|
109
|
+
/** @type {Array<{ line: number, level: number, markdown: string }>} */
|
|
110
|
+
const headlines = [];
|
|
111
|
+
/** @type {Marker[]} */
|
|
112
|
+
const markers = [];
|
|
113
|
+
/** @type {{ char: string, length: number } | null} */
|
|
114
|
+
let fence = null;
|
|
115
|
+
/** @type {Marker | null} */
|
|
116
|
+
let open = null;
|
|
117
|
+
|
|
118
|
+
for (const [i, line] of lines.entries()) {
|
|
119
|
+
const fenceMatch = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
120
|
+
if (fence) {
|
|
121
|
+
if (fenceMatch && fenceMatch[1][0] === fence.char && fenceMatch[1].length >= fence.length && !fenceMatch[2].trim()) {
|
|
122
|
+
fence = null;
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (fenceMatch && !(fenceMatch[1][0] === '`' && fenceMatch[2].includes('`'))) {
|
|
127
|
+
fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const startMatch = /^ {0,3}<!--\s*toc(?:\s+(.*?))?\s*-->\s*$/.exec(line);
|
|
132
|
+
if (startMatch) {
|
|
133
|
+
if (!open) markers.push((open = { start: i, end: -1, ...parseOptions(startMatch[1]) }));
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (/^ {0,3}<!--\s*\/toc\s*-->\s*$/.test(line)) {
|
|
137
|
+
if (open) open.end = i;
|
|
138
|
+
else markers.push({ start: -1, end: i, options: {} });
|
|
139
|
+
open = null;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const atx = /^ {0,3}(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
144
|
+
if (atx) {
|
|
145
|
+
headlines.push({ line: i, level: atx[1].length, markdown: atx[2] });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const setext = /^ {0,3}(=+|-+)\s*$/.exec(line);
|
|
150
|
+
if (setext && i > 0 && isParagraphText(lines[i - 1])) {
|
|
151
|
+
headlines.push({ line: i - 1, level: setext[1][0] === '=' ? 1 : 2, markdown: lines[i - 1].trim() });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { headlines, markers };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The toc block for the given headings, wrapped in the given start marker line and the end marker. Indentation
|
|
159
|
+
* is relative to the shallowest level listed so far, so the list never starts indented, which markdown would
|
|
160
|
+
* render flat anyway, and duplicate slugs get github style `-1`, `-2` suffixes. With
|
|
161
|
+
* `collapsible` or `collapsed` the list goes inside a details element, open or closed to start with, blank
|
|
162
|
+
* lines around it so it renders as markdown, and any other attributes from the marker on the summary element.
|
|
163
|
+
* @param {Array<{ level: number, markdown: string }>} headlines
|
|
164
|
+
* @param {string} startLine the start marker as written in the document
|
|
165
|
+
* @param {TocOptions} options
|
|
166
|
+
* @param {string} [eol] line ending, LF by default
|
|
167
|
+
*/
|
|
168
|
+
function renderBlock(headlines, startLine, options, eol = '\n') {
|
|
169
|
+
/** @type {Record<string, number>} */
|
|
170
|
+
const occurrences = {};
|
|
171
|
+
let minLevel = Infinity;
|
|
172
|
+
const tocLines = headlines.map(({ level, markdown }) => {
|
|
173
|
+
minLevel = Math.min(minLevel, level);
|
|
174
|
+
const base = slugify(headingText(markdown));
|
|
175
|
+
let slug = base;
|
|
176
|
+
while (Object.hasOwn(occurrences, slug)) {
|
|
177
|
+
occurrences[base]++;
|
|
178
|
+
slug = `${base}-${occurrences[base]}`;
|
|
179
|
+
}
|
|
180
|
+
occurrences[slug] = 0;
|
|
181
|
+
return `${' '.repeat(level - minLevel)}- [${headingLabel(markdown)}](#${slug})`;
|
|
182
|
+
});
|
|
183
|
+
const details = options.collapsible ?? options.collapsed;
|
|
184
|
+
if (details === undefined) return [startLine, '', ...tocLines, '', TOC_END].join(eol);
|
|
185
|
+
const summary = details === true ? DEFAULT_SUMMARY : details;
|
|
186
|
+
const tag = options.collapsible === undefined ? '<details>' : '<details open>';
|
|
187
|
+
const attrs = formatAttributes(options.attributes ?? {})
|
|
188
|
+
.map((a) => ` ${a}`)
|
|
189
|
+
.join('');
|
|
190
|
+
return [startLine, tag, `<summary${attrs}>${summary}</summary>`, '', ...tocLines, '', '</details>', TOC_END].join(eol);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parse what is written on a start marker: the options as bare names (`collapsed`) or quoted values
|
|
195
|
+
* (`collapsed="Contents"`), and any other well-formed `name="value"` as an attribute for the summary element.
|
|
196
|
+
* Anything else, bare names that are not options, unquoted values, malformed names, is a problem, as is a
|
|
197
|
+
* combination that makes no sense. A marker with a problem is never used.
|
|
198
|
+
* @param {string | undefined} text
|
|
199
|
+
* @returns {{ options: TocOptions, problem?: string }}
|
|
200
|
+
*/
|
|
201
|
+
function parseOptions(text) {
|
|
202
|
+
/** @type {TocOptions} */
|
|
203
|
+
const options = {};
|
|
204
|
+
/** @type {Record<string, string>} */
|
|
205
|
+
const attributes = {};
|
|
206
|
+
/** @type {string[]} */
|
|
207
|
+
const unknown = [];
|
|
208
|
+
for (const [token, name, value] of (text ?? '').matchAll(/([^\s="]+)(?:="([^"]*)")?(?=\s|$)|\S+/g)) {
|
|
209
|
+
if (name && KNOWN_OPTIONS.includes(name)) options[/** @type {'collapsible' | 'collapsed'} */ (name)] = value ?? true;
|
|
210
|
+
else if (name && value !== undefined && /^[a-zA-Z][\w:.-]*$/.test(name)) attributes[name] = value;
|
|
211
|
+
else unknown.push(token);
|
|
212
|
+
}
|
|
213
|
+
const names = Object.keys(attributes);
|
|
214
|
+
if (names.length) options.attributes = attributes;
|
|
215
|
+
if (unknown.length) return { options, problem: `unknown TOC option ${unknown.join(' ')}` };
|
|
216
|
+
if (options.collapsible !== undefined && options.collapsed !== undefined) {
|
|
217
|
+
return { options, problem: 'TOC options collapsible and collapsed exclude each other' };
|
|
218
|
+
}
|
|
219
|
+
if (names.length && options.collapsible === undefined && options.collapsed === undefined) {
|
|
220
|
+
return { options, problem: `TOC attributes ${names.join(', ')} need collapsible or collapsed` };
|
|
221
|
+
}
|
|
222
|
+
return { options };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The start marker line for the given options, the inverse of `parseOptions`: options first, then attributes.
|
|
227
|
+
* @param {TocOptions} options
|
|
228
|
+
*/
|
|
229
|
+
function formatMarker(options) {
|
|
230
|
+
const { attributes = {}, ...named } = options;
|
|
231
|
+
const parts = [
|
|
232
|
+
...Object.entries(named).map(([name, value]) => (value === true ? name : `${name}="${value}"`)),
|
|
233
|
+
...formatAttributes(attributes),
|
|
234
|
+
];
|
|
235
|
+
return parts.length ? `<!-- toc ${parts.join(' ')} -->` : TOC_START;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @param {Record<string, string>} attributes
|
|
240
|
+
* @returns {string[]} `name="value"` in the order given
|
|
241
|
+
*/
|
|
242
|
+
function formatAttributes(attributes) {
|
|
243
|
+
return Object.entries(attributes).map(([name, value]) => `${name}="${value}"`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The label used in the toc: the heading's own markdown, except that links become their text since a link
|
|
248
|
+
* cannot nest inside the toc link.
|
|
249
|
+
* @param {string} markdown
|
|
250
|
+
* @returns {string}
|
|
251
|
+
*/
|
|
252
|
+
function headingLabel(markdown) {
|
|
253
|
+
const { text, restore } = protect(stripClosingHashes(markdown), { keepCodeSpans: true });
|
|
254
|
+
return restore(stripLinks(text)).trim();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Replace code spans (with their content, or the whole span when `keepCodeSpans` is set) and backslash escapes
|
|
259
|
+
* (with the escaped character) by placeholders so the inline markdown passes leave them alone.
|
|
260
|
+
* @param {string} markdown
|
|
261
|
+
* @param {{ keepCodeSpans?: boolean }} [options]
|
|
262
|
+
*/
|
|
263
|
+
function protect(markdown, { keepCodeSpans = false } = {}) {
|
|
264
|
+
/** @type {string[]} */
|
|
265
|
+
const kept = [];
|
|
266
|
+
/** @param {string} value */
|
|
267
|
+
const keep = (value) => `${NUL}${kept.push(value) - 1}${NUL}`;
|
|
268
|
+
const text = markdown
|
|
269
|
+
.replace(/(`+)(.+?)\1(?!`)/g, (span, _ticks, code) => keep(keepCodeSpans ? span : unpadCodeSpan(code)))
|
|
270
|
+
.replace(/\\([!-/:-@[-`{-~])/g, (_, char) => keep(char));
|
|
271
|
+
return {
|
|
272
|
+
text,
|
|
273
|
+
/** @param {string} value */
|
|
274
|
+
restore: (value) => value.replace(new RegExp(`${NUL}(\\d+)${NUL}`, 'g'), (_, i) => kept[Number(i)]),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Strip one leading and one trailing space from code span content when both are present and the content is
|
|
280
|
+
* not only spaces, as CommonMark does.
|
|
281
|
+
* @param {string} code
|
|
282
|
+
*/
|
|
283
|
+
function unpadCodeSpan(code) {
|
|
284
|
+
if (code.length > 2 && code.startsWith(' ') && code.endsWith(' ') && code.trim() !== '') return code.slice(1, -1);
|
|
285
|
+
return code;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** @param {string} markdown */
|
|
289
|
+
function stripClosingHashes(markdown) {
|
|
290
|
+
return markdown.replace(/(^|\s+)#+\s*$/, '');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Links, images and reference links become their text, autolinks their url. Used for both the slug text and
|
|
295
|
+
* the toc label since a link cannot nest inside the toc link.
|
|
296
|
+
* @param {string} text
|
|
297
|
+
*/
|
|
298
|
+
function stripLinks(text) {
|
|
299
|
+
return text
|
|
300
|
+
.replace(/<(https?:\/\/[^>\s]+|mailto:[^>\s]+)>/g, '$1')
|
|
301
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
302
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
303
|
+
.replace(/\[([^\]]*)\]\[[^\]]*\]/g, '$1');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** @param {string} line */
|
|
307
|
+
function isParagraphText(line) {
|
|
308
|
+
const trimmed = line.trim();
|
|
309
|
+
return trimmed !== '' && !/^(#|>|[-*+]\s|\d+[.)]\s|\||[-=]+\s*$|```|~~~|<!--)/.test(trimmed);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Options written on a start marker. `collapsible` wraps the list in a details element that starts open,
|
|
314
|
+
* `collapsed` in one that starts closed. Each is `true` for the default summary "Table of contents" or a
|
|
315
|
+
* string for a custom one. `attributes` are the other `name="value"` pairs on the marker, rendered on the
|
|
316
|
+
* summary element in the order written, only present when there are any.
|
|
317
|
+
* @typedef {{ collapsible?: true | string, collapsed?: true | string, attributes?: Record<string, string> }} TocOptions
|
|
318
|
+
*/
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A marker pair. `start` and `end` are zero based line numbers, -1 when that side is missing. `problem` is
|
|
322
|
+
* only present when the start marker cannot be used: an unknown option or an impossible combination.
|
|
323
|
+
* @typedef {{ start: number, end: number, options: TocOptions, problem?: string }} Marker
|
|
324
|
+
*/
|
|
325
|
+
|
|
326
|
+
exports.TOC_END = TOC_END;
|
|
327
|
+
exports.TOC_START = TOC_START;
|
|
328
|
+
exports.buildToc = buildToc;
|
|
329
|
+
exports.findMarkers = findMarkers;
|
|
330
|
+
exports.headingText = headingText;
|
|
331
|
+
exports.renderToc = renderToc;
|
|
332
|
+
exports.slugify = slugify;
|
package/index.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
export const TOC_START = '<!-- toc -->';
|
|
2
|
+
export const TOC_END = '<!-- /toc -->';
|
|
3
|
+
const DEFAULT_SUMMARY = 'Table of contents';
|
|
4
|
+
const KNOWN_OPTIONS = ['collapsible', 'collapsed'];
|
|
5
|
+
const NUL = String.fromCharCode(0);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Return the markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated. Each
|
|
9
|
+
* pair lists every heading below its own start marker. A pair is left alone when it is unbalanced, has a
|
|
10
|
+
* problem on its start marker, or has no headings below it, and nothing outside the pairs is ever touched.
|
|
11
|
+
* The start marker line is kept as written, options included.
|
|
12
|
+
* @param {string} source
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function buildToc(source) {
|
|
16
|
+
const { lines, eol } = splitLines(source);
|
|
17
|
+
const { headlines, markers } = scan(lines);
|
|
18
|
+
/** @type {string[]} */
|
|
19
|
+
const out = [];
|
|
20
|
+
let cursor = 0;
|
|
21
|
+
for (const { start, end, options, problem } of markers) {
|
|
22
|
+
if (start === -1 || end === -1 || problem) continue;
|
|
23
|
+
const listed = headlines.filter((h) => h.line > start);
|
|
24
|
+
if (listed.length === 0) continue;
|
|
25
|
+
out.push(...lines.slice(cursor, start), renderBlock(listed, lines[start], options, eol));
|
|
26
|
+
cursor = end + 1;
|
|
27
|
+
}
|
|
28
|
+
out.push(...lines.slice(cursor));
|
|
29
|
+
return out.join(eol);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Return the toc block, markers included, for the headings below `fromLine`, by default every heading so the
|
|
34
|
+
* block can be pasted into a document without markers. Returns an empty string when there is nothing to list.
|
|
35
|
+
* @param {string} source
|
|
36
|
+
* @param {number} [fromLine] zero based line number, typically the start marker's
|
|
37
|
+
* @param {TocOptions} [options] rendered into the start marker, e.g. `{ collapsed: 'Contents' }`
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function renderToc(source, fromLine = -1, options = {}) {
|
|
41
|
+
const { lines, eol } = splitLines(source);
|
|
42
|
+
const listed = scan(lines).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 as zero based line numbers, outside fenced code blocks. A start marker
|
|
48
|
+
* pairs with the first end marker after it. A missing side is -1: a start marker without an end marker, or an
|
|
49
|
+
* end marker with no open start marker before it. `options` holds the recognised options written on the start
|
|
50
|
+
* marker and `problem`, only present when there is one, says why the marker cannot be used.
|
|
51
|
+
* @param {string} source
|
|
52
|
+
* @returns {Marker[]}
|
|
53
|
+
*/
|
|
54
|
+
export function findMarkers(source) {
|
|
55
|
+
return scan(splitLines(source).lines).markers;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Slug a heading's rendered text the way github-slugger does: lowercase, drop everything that is not a
|
|
60
|
+
* letter, number, mark, space, hyphen or underscore, then turn each space into a hyphen. Nothing is trimmed
|
|
61
|
+
* or collapsed.
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function slugify(text) {
|
|
66
|
+
return text
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/[^\p{L}\p{N}\p{M} _-]/gu, '')
|
|
69
|
+
.replace(/ /g, '-');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reduce a heading's inline markdown to the text GitHub renders and slugs.
|
|
74
|
+
* @param {string} markdown
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function headingText(markdown) {
|
|
78
|
+
const { text, restore } = protect(stripClosingHashes(markdown));
|
|
79
|
+
const rendered = stripLinks(text)
|
|
80
|
+
.replace(/<\/?[a-zA-Z][^>]*>/g, '')
|
|
81
|
+
.replace(/~~(?=\S)(.+?)(?<=\S)~~/g, '$1')
|
|
82
|
+
.replace(/\*+(?=\S)|(?<=\S)\*+/g, '')
|
|
83
|
+
.replace(/(?<![\p{L}\p{N}_])_+(?=\S)|(?<=\S)_+(?![\p{L}\p{N}_])/gu, '');
|
|
84
|
+
return restore(rendered).trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Split the source into lines and remember its line ending, CRLF when the source has any, so the toc is
|
|
89
|
+
* written the way the rest of the file is and a Windows authored file does not end up with mixed endings.
|
|
90
|
+
* @param {string} source
|
|
91
|
+
* @returns {{ lines: string[], eol: string }}
|
|
92
|
+
*/
|
|
93
|
+
function splitLines(source) {
|
|
94
|
+
return { lines: source.split(/\r?\n/), eol: source.includes('\r\n') ? '\r\n' : '\n' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Scan the lines for every marker pair and every ATX and setext heading, all outside fenced code blocks. A
|
|
99
|
+
* marker is a line holding nothing but the comment, indented at most three spaces like a heading, since four
|
|
100
|
+
* make an indented code block. A start marker, `<!-- toc -->` with optional options before the closing `-->`,
|
|
101
|
+
* opens a pair that the first end marker after it closes; a start marker inside an open pair is content and an end marker outside a pair is
|
|
102
|
+
* reported with start -1.
|
|
103
|
+
* @param {string[]} lines
|
|
104
|
+
* @returns {{ headlines: Array<{ line: number, level: number, markdown: string }>, markers: Marker[] }}
|
|
105
|
+
*/
|
|
106
|
+
function scan(lines) {
|
|
107
|
+
/** @type {Array<{ line: number, level: number, markdown: string }>} */
|
|
108
|
+
const headlines = [];
|
|
109
|
+
/** @type {Marker[]} */
|
|
110
|
+
const markers = [];
|
|
111
|
+
/** @type {{ char: string, length: number } | null} */
|
|
112
|
+
let fence = null;
|
|
113
|
+
/** @type {Marker | null} */
|
|
114
|
+
let open = null;
|
|
115
|
+
|
|
116
|
+
for (const [i, line] of lines.entries()) {
|
|
117
|
+
const fenceMatch = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
118
|
+
if (fence) {
|
|
119
|
+
if (fenceMatch && fenceMatch[1][0] === fence.char && fenceMatch[1].length >= fence.length && !fenceMatch[2].trim()) {
|
|
120
|
+
fence = null;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (fenceMatch && !(fenceMatch[1][0] === '`' && fenceMatch[2].includes('`'))) {
|
|
125
|
+
fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const startMatch = /^ {0,3}<!--\s*toc(?:\s+(.*?))?\s*-->\s*$/.exec(line);
|
|
130
|
+
if (startMatch) {
|
|
131
|
+
if (!open) markers.push((open = { start: i, end: -1, ...parseOptions(startMatch[1]) }));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (/^ {0,3}<!--\s*\/toc\s*-->\s*$/.test(line)) {
|
|
135
|
+
if (open) open.end = i;
|
|
136
|
+
else markers.push({ start: -1, end: i, options: {} });
|
|
137
|
+
open = null;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const atx = /^ {0,3}(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
142
|
+
if (atx) {
|
|
143
|
+
headlines.push({ line: i, level: atx[1].length, markdown: atx[2] });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const setext = /^ {0,3}(=+|-+)\s*$/.exec(line);
|
|
148
|
+
if (setext && i > 0 && isParagraphText(lines[i - 1])) {
|
|
149
|
+
headlines.push({ line: i - 1, level: setext[1][0] === '=' ? 1 : 2, markdown: lines[i - 1].trim() });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { headlines, markers };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The toc block for the given headings, wrapped in the given start marker line and the end marker. Indentation
|
|
157
|
+
* is relative to the shallowest level listed so far, so the list never starts indented, which markdown would
|
|
158
|
+
* render flat anyway, and duplicate slugs get github style `-1`, `-2` suffixes. With
|
|
159
|
+
* `collapsible` or `collapsed` the list goes inside a details element, open or closed to start with, blank
|
|
160
|
+
* lines around it so it renders as markdown, and any other attributes from the marker on the summary element.
|
|
161
|
+
* @param {Array<{ level: number, markdown: string }>} headlines
|
|
162
|
+
* @param {string} startLine the start marker as written in the document
|
|
163
|
+
* @param {TocOptions} options
|
|
164
|
+
* @param {string} [eol] line ending, LF by default
|
|
165
|
+
*/
|
|
166
|
+
function renderBlock(headlines, startLine, options, eol = '\n') {
|
|
167
|
+
/** @type {Record<string, number>} */
|
|
168
|
+
const occurrences = {};
|
|
169
|
+
let minLevel = Infinity;
|
|
170
|
+
const tocLines = headlines.map(({ level, markdown }) => {
|
|
171
|
+
minLevel = Math.min(minLevel, level);
|
|
172
|
+
const base = slugify(headingText(markdown));
|
|
173
|
+
let slug = base;
|
|
174
|
+
while (Object.hasOwn(occurrences, slug)) {
|
|
175
|
+
occurrences[base]++;
|
|
176
|
+
slug = `${base}-${occurrences[base]}`;
|
|
177
|
+
}
|
|
178
|
+
occurrences[slug] = 0;
|
|
179
|
+
return `${' '.repeat(level - minLevel)}- [${headingLabel(markdown)}](#${slug})`;
|
|
180
|
+
});
|
|
181
|
+
const details = options.collapsible ?? options.collapsed;
|
|
182
|
+
if (details === undefined) return [startLine, '', ...tocLines, '', TOC_END].join(eol);
|
|
183
|
+
const summary = details === true ? DEFAULT_SUMMARY : details;
|
|
184
|
+
const tag = options.collapsible === undefined ? '<details>' : '<details open>';
|
|
185
|
+
const attrs = formatAttributes(options.attributes ?? {})
|
|
186
|
+
.map((a) => ` ${a}`)
|
|
187
|
+
.join('');
|
|
188
|
+
return [startLine, tag, `<summary${attrs}>${summary}</summary>`, '', ...tocLines, '', '</details>', TOC_END].join(eol);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Parse what is written on a start marker: the options as bare names (`collapsed`) or quoted values
|
|
193
|
+
* (`collapsed="Contents"`), and any other well-formed `name="value"` as an attribute for the summary element.
|
|
194
|
+
* Anything else, bare names that are not options, unquoted values, malformed names, is a problem, as is a
|
|
195
|
+
* combination that makes no sense. A marker with a problem is never used.
|
|
196
|
+
* @param {string | undefined} text
|
|
197
|
+
* @returns {{ options: TocOptions, problem?: string }}
|
|
198
|
+
*/
|
|
199
|
+
function parseOptions(text) {
|
|
200
|
+
/** @type {TocOptions} */
|
|
201
|
+
const options = {};
|
|
202
|
+
/** @type {Record<string, string>} */
|
|
203
|
+
const attributes = {};
|
|
204
|
+
/** @type {string[]} */
|
|
205
|
+
const unknown = [];
|
|
206
|
+
for (const [token, name, value] of (text ?? '').matchAll(/([^\s="]+)(?:="([^"]*)")?(?=\s|$)|\S+/g)) {
|
|
207
|
+
if (name && KNOWN_OPTIONS.includes(name)) options[/** @type {'collapsible' | 'collapsed'} */ (name)] = value ?? true;
|
|
208
|
+
else if (name && value !== undefined && /^[a-zA-Z][\w:.-]*$/.test(name)) attributes[name] = value;
|
|
209
|
+
else unknown.push(token);
|
|
210
|
+
}
|
|
211
|
+
const names = Object.keys(attributes);
|
|
212
|
+
if (names.length) options.attributes = attributes;
|
|
213
|
+
if (unknown.length) return { options, problem: `unknown TOC option ${unknown.join(' ')}` };
|
|
214
|
+
if (options.collapsible !== undefined && options.collapsed !== undefined) {
|
|
215
|
+
return { options, problem: 'TOC options collapsible and collapsed exclude each other' };
|
|
216
|
+
}
|
|
217
|
+
if (names.length && options.collapsible === undefined && options.collapsed === undefined) {
|
|
218
|
+
return { options, problem: `TOC attributes ${names.join(', ')} need collapsible or collapsed` };
|
|
219
|
+
}
|
|
220
|
+
return { options };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The start marker line for the given options, the inverse of `parseOptions`: options first, then attributes.
|
|
225
|
+
* @param {TocOptions} options
|
|
226
|
+
*/
|
|
227
|
+
function formatMarker(options) {
|
|
228
|
+
const { attributes = {}, ...named } = options;
|
|
229
|
+
const parts = [
|
|
230
|
+
...Object.entries(named).map(([name, value]) => (value === true ? name : `${name}="${value}"`)),
|
|
231
|
+
...formatAttributes(attributes),
|
|
232
|
+
];
|
|
233
|
+
return parts.length ? `<!-- toc ${parts.join(' ')} -->` : TOC_START;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {Record<string, string>} attributes
|
|
238
|
+
* @returns {string[]} `name="value"` in the order given
|
|
239
|
+
*/
|
|
240
|
+
function formatAttributes(attributes) {
|
|
241
|
+
return Object.entries(attributes).map(([name, value]) => `${name}="${value}"`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The label used in the toc: the heading's own markdown, except that links become their text since a link
|
|
246
|
+
* cannot nest inside the toc link.
|
|
247
|
+
* @param {string} markdown
|
|
248
|
+
* @returns {string}
|
|
249
|
+
*/
|
|
250
|
+
function headingLabel(markdown) {
|
|
251
|
+
const { text, restore } = protect(stripClosingHashes(markdown), { keepCodeSpans: true });
|
|
252
|
+
return restore(stripLinks(text)).trim();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Replace code spans (with their content, or the whole span when `keepCodeSpans` is set) and backslash escapes
|
|
257
|
+
* (with the escaped character) by placeholders so the inline markdown passes leave them alone.
|
|
258
|
+
* @param {string} markdown
|
|
259
|
+
* @param {{ keepCodeSpans?: boolean }} [options]
|
|
260
|
+
*/
|
|
261
|
+
function protect(markdown, { keepCodeSpans = false } = {}) {
|
|
262
|
+
/** @type {string[]} */
|
|
263
|
+
const kept = [];
|
|
264
|
+
/** @param {string} value */
|
|
265
|
+
const keep = (value) => `${NUL}${kept.push(value) - 1}${NUL}`;
|
|
266
|
+
const text = markdown
|
|
267
|
+
.replace(/(`+)(.+?)\1(?!`)/g, (span, _ticks, code) => keep(keepCodeSpans ? span : unpadCodeSpan(code)))
|
|
268
|
+
.replace(/\\([!-/:-@[-`{-~])/g, (_, char) => keep(char));
|
|
269
|
+
return {
|
|
270
|
+
text,
|
|
271
|
+
/** @param {string} value */
|
|
272
|
+
restore: (value) => value.replace(new RegExp(`${NUL}(\\d+)${NUL}`, 'g'), (_, i) => kept[Number(i)]),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Strip one leading and one trailing space from code span content when both are present and the content is
|
|
278
|
+
* not only spaces, as CommonMark does.
|
|
279
|
+
* @param {string} code
|
|
280
|
+
*/
|
|
281
|
+
function unpadCodeSpan(code) {
|
|
282
|
+
if (code.length > 2 && code.startsWith(' ') && code.endsWith(' ') && code.trim() !== '') return code.slice(1, -1);
|
|
283
|
+
return code;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** @param {string} markdown */
|
|
287
|
+
function stripClosingHashes(markdown) {
|
|
288
|
+
return markdown.replace(/(^|\s+)#+\s*$/, '');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Links, images and reference links become their text, autolinks their url. Used for both the slug text and
|
|
293
|
+
* the toc label since a link cannot nest inside the toc link.
|
|
294
|
+
* @param {string} text
|
|
295
|
+
*/
|
|
296
|
+
function stripLinks(text) {
|
|
297
|
+
return text
|
|
298
|
+
.replace(/<(https?:\/\/[^>\s]+|mailto:[^>\s]+)>/g, '$1')
|
|
299
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
300
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
301
|
+
.replace(/\[([^\]]*)\]\[[^\]]*\]/g, '$1');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** @param {string} line */
|
|
305
|
+
function isParagraphText(line) {
|
|
306
|
+
const trimmed = line.trim();
|
|
307
|
+
return trimmed !== '' && !/^(#|>|[-*+]\s|\d+[.)]\s|\||[-=]+\s*$|```|~~~|<!--)/.test(trimmed);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Options written on a start marker. `collapsible` wraps the list in a details element that starts open,
|
|
312
|
+
* `collapsed` in one that starts closed. Each is `true` for the default summary "Table of contents" or a
|
|
313
|
+
* string for a custom one. `attributes` are the other `name="value"` pairs on the marker, rendered on the
|
|
314
|
+
* summary element in the order written, only present when there are any.
|
|
315
|
+
* @typedef {{ collapsible?: true | string, collapsed?: true | string, attributes?: Record<string, string> }} TocOptions
|
|
316
|
+
*/
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* A marker pair. `start` and `end` are zero based line numbers, -1 when that side is missing. `problem` is
|
|
320
|
+
* only present when the start marker cannot be used: an unknown option or an impossible combination.
|
|
321
|
+
* @typedef {{ start: number, end: number, options: TocOptions, problem?: string }} Marker
|
|
322
|
+
*/
|
package/package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@0dep/toc",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Generate a GitHub flavoured markdown table of contents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"module": "./index.js",
|
|
8
|
+
"main": "./index.cjs",
|
|
9
|
+
"types": "./types/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./types/index.d.ts",
|
|
13
|
+
"require": "./index.cjs",
|
|
14
|
+
"import": "./index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"toc": "bin/toc.js"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"pretest": "npm run toc && npm run dist",
|
|
25
|
+
"test": "mocha",
|
|
26
|
+
"posttest": "npm run lint && npm run tsc && npm run test:md",
|
|
27
|
+
"test:md": "texample",
|
|
28
|
+
"toc": "node bin/toc.js README.md",
|
|
29
|
+
"posttoc": "prettier --write README.md",
|
|
30
|
+
"lint": "eslint . --cache && prettier . --check --cache",
|
|
31
|
+
"tsc": "tsc -p test",
|
|
32
|
+
"dist": "rollup -c && dts-buddy",
|
|
33
|
+
"prepack": "npm run dist",
|
|
34
|
+
"cov:html": "c8 -r html -r text mocha",
|
|
35
|
+
"test:lcov": "c8 -r lcov mocha && npm run lint"
|
|
36
|
+
},
|
|
37
|
+
"c8": {
|
|
38
|
+
"include": [
|
|
39
|
+
"index.js",
|
|
40
|
+
"bin/"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"toc",
|
|
45
|
+
"table of contents",
|
|
46
|
+
"markdown",
|
|
47
|
+
"readme",
|
|
48
|
+
"github",
|
|
49
|
+
"slug"
|
|
50
|
+
],
|
|
51
|
+
"author": {
|
|
52
|
+
"name": "Zerodep AB",
|
|
53
|
+
"url": "https://0dep.se"
|
|
54
|
+
},
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://github.com/zerodep/toc.git"
|
|
58
|
+
},
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/zerodep/toc/issues"
|
|
61
|
+
},
|
|
62
|
+
"homepage": "https://0dep.se/tools",
|
|
63
|
+
"license": "MIT",
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@eslint/js": "^10.0.1",
|
|
66
|
+
"@rollup/plugin-commonjs": "^29.0.3",
|
|
67
|
+
"@types/chai": "^5.2.3",
|
|
68
|
+
"@types/mocha": "^10.0.10",
|
|
69
|
+
"@types/node": "^22.20.2",
|
|
70
|
+
"c8": "^12.0.0",
|
|
71
|
+
"chai": "^6.2.2",
|
|
72
|
+
"dts-buddy": "^0.8.3",
|
|
73
|
+
"eslint": "^10.10.0",
|
|
74
|
+
"globals": "^17.12.0",
|
|
75
|
+
"mocha": "^12.0.0",
|
|
76
|
+
"prettier": "^3.9.6",
|
|
77
|
+
"rollup": "^4.63.1",
|
|
78
|
+
"texample": "^1.0.2",
|
|
79
|
+
"typescript": "^6.0.3"
|
|
80
|
+
},
|
|
81
|
+
"files": [
|
|
82
|
+
"bin/",
|
|
83
|
+
"index.js",
|
|
84
|
+
"index.cjs",
|
|
85
|
+
"types/index.d.ts*",
|
|
86
|
+
"CHANGELOG.md"
|
|
87
|
+
]
|
|
88
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
declare module '@0dep/toc' {
|
|
2
|
+
/**
|
|
3
|
+
* Return the markdown with the toc between every `<!-- toc -->` and `<!-- /toc -->` pair regenerated. Each
|
|
4
|
+
* pair lists every heading below its own start marker. A pair is left alone when it is unbalanced, has a
|
|
5
|
+
* problem on its start marker, or has no headings below it, and nothing outside the pairs is ever touched.
|
|
6
|
+
* The start marker line is kept as written, options included.
|
|
7
|
+
* */
|
|
8
|
+
export function buildToc(source: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Return the toc block, markers included, for the headings below `fromLine`, by default every heading so the
|
|
11
|
+
* block can be pasted into a document without markers. Returns an empty string when there is nothing to list.
|
|
12
|
+
* @param fromLine zero based line number, typically the start marker's
|
|
13
|
+
* @param options rendered into the start marker, e.g. `{ collapsed: 'Contents' }`
|
|
14
|
+
* */
|
|
15
|
+
export function renderToc(source: string, fromLine?: number, options?: TocOptions): string;
|
|
16
|
+
/**
|
|
17
|
+
* Every marker pair in document order as zero based line numbers, outside fenced code blocks. A start marker
|
|
18
|
+
* pairs with the first end marker after it. A missing side is -1: a start marker without an end marker, or an
|
|
19
|
+
* end marker with no open start marker before it. `options` holds the recognised options written on the start
|
|
20
|
+
* marker and `problem`, only present when there is one, says why the marker cannot be used.
|
|
21
|
+
* */
|
|
22
|
+
export function findMarkers(source: string): Marker[];
|
|
23
|
+
/**
|
|
24
|
+
* Slug a heading's rendered text the way github-slugger does: lowercase, drop everything that is not a
|
|
25
|
+
* letter, number, mark, space, hyphen or underscore, then turn each space into a hyphen. Nothing is trimmed
|
|
26
|
+
* or collapsed.
|
|
27
|
+
* */
|
|
28
|
+
export function slugify(text: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Reduce a heading's inline markdown to the text GitHub renders and slugs.
|
|
31
|
+
* */
|
|
32
|
+
export function headingText(markdown: string): string;
|
|
33
|
+
export const TOC_START: "<!-- toc -->";
|
|
34
|
+
export const TOC_END: "<!-- /toc -->";
|
|
35
|
+
/**
|
|
36
|
+
* Options written on a start marker. `collapsible` wraps the list in a details element that starts open,
|
|
37
|
+
* `collapsed` in one that starts closed. Each is `true` for the default summary "Table of contents" or a
|
|
38
|
+
* string for a custom one. `attributes` are the other `name="value"` pairs on the marker, rendered on the
|
|
39
|
+
* summary element in the order written, only present when there are any.
|
|
40
|
+
*/
|
|
41
|
+
export type TocOptions = {
|
|
42
|
+
collapsible?: true | string;
|
|
43
|
+
collapsed?: true | string;
|
|
44
|
+
attributes?: Record<string, string>;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* A marker pair. `start` and `end` are zero based line numbers, -1 when that side is missing. `problem` is
|
|
48
|
+
* only present when the start marker cannot be used: an unknown option or an impossible combination.
|
|
49
|
+
*/
|
|
50
|
+
export type Marker = {
|
|
51
|
+
start: number;
|
|
52
|
+
end: number;
|
|
53
|
+
options: TocOptions;
|
|
54
|
+
problem?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"file": "index.d.ts",
|
|
4
|
+
"names": [
|
|
5
|
+
"buildToc",
|
|
6
|
+
"renderToc",
|
|
7
|
+
"findMarkers",
|
|
8
|
+
"slugify",
|
|
9
|
+
"headingText",
|
|
10
|
+
"TOC_START",
|
|
11
|
+
"TOC_END",
|
|
12
|
+
"TocOptions",
|
|
13
|
+
"Marker"
|
|
14
|
+
],
|
|
15
|
+
"sources": [
|
|
16
|
+
"../index.js"
|
|
17
|
+
],
|
|
18
|
+
"sourcesContent": [
|
|
19
|
+
null
|
|
20
|
+
],
|
|
21
|
+
"mappings": ";;;;;;;iBAcgBA,QAAQA;;;;;;;iBAyBRC,SAASA;;;;;;;iBAcTC,WAAWA;;;;;;iBAWXC,OAAOA;;;;iBAYPC,WAAWA;cA5EdC,SAASA;cACTC,OAAOA;;;;;;;aAyT0FC,UAAUA;;;;;;;;;aAMpCC,MAAMA",
|
|
22
|
+
"ignoreList": []
|
|
23
|
+
}
|