@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 ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## v0.0.1 - 2026-09-12
6
+
7
+ - initial release: `buildToc`, `updateToc`, `slugify`, `headingText`, and the `toc` bin
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
+ [![Build](https://github.com/zerodep/toc/actions/workflows/build.yaml/badge.svg)](https://github.com/zerodep/toc/actions/workflows/build.yaml)[![Build (Windows)](https://github.com/zerodep/toc/actions/workflows/build-windows.yaml/badge.svg)](https://github.com/zerodep/toc/actions/workflows/build-windows.yaml)[![Coverage Status](https://coveralls.io/repos/github/zerodep/toc/badge.svg?branch=main)](https://coveralls.io/github/zerodep/toc?branch=main)
4
+
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
+ }
@@ -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
+ }