Skip to content
zerodepPublic

About

Generate a GitHub flavoured markdown table of contents

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

@0dep/toc

BuildBuild (Windows)Coverage Status

Generate a GitHub flavoured table of contents for markdown files. No dependencies. The library is string in, string out and runs in the browser too. Node 20.19 or later for the toc bin and for require, glob patterns need Node 22.

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, every link to an anchor in the document is checked against them, and an <a> anchor that nothing links to is pointed out.

Install

npm install --save-dev @0dep/toc

Markers

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.

# My project

<!-- toc -->
<!-- /toc -->

## Install

## Usage

becomes

# My project

<!-- toc -->

- [Install](#install)
- [Usage](#usage)

<!-- /toc -->

## Install

## Usage

Running it again replaces whatever is between the markers with a fresh list, so the toc can be regenerated any number of times. A file with CRLF line endings keeps them, the toc is written with the same line ending as the rest of the file. A byte order mark is ignored, a heading or marker on the first line is still seen, and the mark is kept when the file is written.

Several tocs in one document

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.

# My project

<!-- toc -->
<!-- /toc -->

## API

<!-- toc -->
<!-- /toc -->

### get

### set

## License

The top toc lists API, get, set and License. The toc under API lists get, set and License.

Options

The start marker takes options, bare or as name="value", in any order. It is kept exactly as written, and spacing inside the comment does not matter. Anything on it that is not one of the three options or a well-formed attribute makes the pair skip with a warning rather than guess: a bare name other than the options, so a typo like collapsable is caught, an unquoted value like collapsed=yes, both details options at once, or attributes without an option to give them a summary element.

collapsible and collapsed

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 and a collapsible one under Headings.

<!-- toc collapsed -->
<!-- /toc -->

becomes

<!-- toc collapsed -->
<details>
<summary>Table of contents</summary>

- [Install](#install)
- [Usage](#usage)

</details>
<!-- /toc -->

Both take a summary text, <!-- toc collapsible="Contents" --> or <!-- toc collapsed="Contents" -->.

Summary attributes

Any other name="value" pair on the start marker is put on the summary element, in the order written:

<!-- toc collapsed="Contents" title="Click to expand" style="font-weight: bold" -->
<details>
<summary title="Click to expand" style="font-weight: bold">Contents</summary>

- [Install](#install)

</details>
<!-- /toc -->

Renderers sanitise html, so check what yours keeps. GitHub drops style, class and id but keeps title, dir, lang, role and aria-* attributes, so in the example above only the title survives there.

levels

levels limits the toc to a heading level or a range of levels, levels="2", levels="2-3", levels="-3" for everything up to ### or levels="3-" for ### and deeper. The levels are the heading's own, the number of #, wherever the marker sits, so a toc under ## API that should list the ### headings beneath it says levels="3". The list starts flat at the shallowest level listed, so a changelog with ### Added and ### Breaking under every version gets a toc of the versions alone:

<!-- toc levels="2" collapsed="Versions" -->
<details>
<summary>Versions</summary>

- [v2.0.0 - 2026-09-18](#v200---2026-09-18)
- [v1.1.0 - 2026-09-17](#v110---2026-09-17)

</details>
<!-- /toc -->

## v2.0.0 - 2026-09-18

### Breaking

A levels value that is not a level or a range between 1 and 6 is ignored with a warning, and the pair lists every level. The toc above, under Options, is a levels="4" one: without it every heading to the end of this file would be listed.

What is left alone

  • A file without markers
  • A start marker without an end marker after it
  • An end marker with no open start marker before it
  • 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
  • A pair whose start marker has a problem: an unknown option, both collapsible and collapsed, or attributes without either
  • Markers indented four spaces or more, that is an indented code block
  • 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

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.

This README has two headings named Options, the one above and the one under CLI. The second one is linked as #options-1, the way GitHub numbers duplicate anchors. Duplicates are counted over the whole document, headings above the start marker included, since that is what GitHub does.

CLI

npx toc                        # updates README.md in the current directory
npx toc docs/a.md docs/b.md    # several files
npx toc docs/a.md,docs/b.md    # comma separated works too
npx toc 'docs/**/*.md'         # glob patterns, quoted so the shell leaves them alone
npx toc --dry-run README.md    # print the toc, write nothing
npx toc --check README.md      # exit with 1 when a link to an anchor has no target
npx toc -c -s docs/*.md        # write only the anchor warnings
npx toc --help

Paths are resolved against the current directory. An argument with *, ? or [ is a glob pattern, expanded by the bin with Node's own fs.glob, so it works the same in an npm script on Windows, where the shell does not expand it, as on macOS and Linux. ** matches subdirectories, node_modules is never entered, and the matches are processed in sorted order. A pattern that matches nothing is skipped with a warning, and so is every pattern on Node 20, which has no fs.glob. A file is processed once, however many arguments name or match it. Symlinks are ignored, whether named or matched.

Options

Option Effect
-n, --dry-run Print the toc of every pair to stdout and the status to stderr, write nothing
-c, --check Exit with 1 when a link to an anchor has no target
-s, --silent Skip the status lines and the skipped warnings, keep missing files, anchor warnings and errors
-h, --help Show usage

Output and exit code

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.

Message Stream Meaning
README.md: wrote TOC. stdout At least one pair changed and the file was written
README.md: TOC already up to date. stdout Every updatable pair was already correct
README.md: no such file, skipped. stderr The file does not exist
docs/*.md: no matching file, skipped. stderr The pattern matched nothing
README.md: no TOC markers, skipped. stderr The file has no markers
README.md:3: TOC start marker without end marker, skipped. stderr Unbalanced pair
README.md:3: TOC end marker without start marker, skipped. stderr Unbalanced pair
README.md:3: no headings below TOC start marker, skipped. stderr Nothing to list for that pair
README.md:3: unknown TOC option foo, skipped. stderr The start marker has something that is not an option
README.md:3: TOC options collapsible and collapsed exclude each other, skipped. stderr Pick one
README.md:3: TOC attributes style need collapsible or collapsed, skipped. stderr There is no summary element to put them on
README.md:3: TOC option levels "x" is not a level or a range like 2-3, ignored. stderr Every level is listed instead
README.md:9: anchor #instal has no target. stderr A link points at a heading that does not exist
README.md:9: anchor #Install has no target, did you mean #install? stderr Same, and a heading obviously matches
README.md:7: anchor #top has no link. stderr An <a> anchor that no link points to
README.md: EISDIR: illegal operation on a directory, ... stderr The file could not be read or written

The exit code is 1 when a file could not be read or written, when an option is not recognised, or, with --check, when a link to an anchor has no target. Otherwise 0. Skipped files and pairs do not affect the exit code.

Anchors

Every link to an anchor in the file, [text](#slug), is checked against the slugs of the headings, the id of any html element and the name of an <a> in the file, outside fenced and indented code blocks, code spans and html comments. A bare #, the top of the page, and a fragment on a path or url to another document, other.md#install, are not anchors in this file and are left alone. Links inside a pair that is being regenerated are not checked, the new toc replaces them. A link without a target is reported with its line number, and the exit code stays 0 unless --check is given, so a stale link does not stop the toc from being written.

The warning ends with did you mean when the target is obvious: slugging the anchor as written, #My-Heading or #My%20Heading for ## My Heading, or slugging the link text, [My Heading](#heading), lands on exactly one existing target. The link itself is never rewritten, nothing outside the markers is.

The other way round, an <a> with an id or name exists only to be linked to, so one that no link in the file points to is reported as an orphan with its line number. Headings and ids on other elements are not orphans, they are there whether linked or not. An orphan is a warning only, it never sets the exit code, not even with --check, and --silent keeps it.

npx toc --check docs/*.md

With --silent the status lines and the skipped warnings are dropped, so the output is the missing files, the anchor warnings and the errors alone. In a dry run the toc blocks are still printed to stdout.

npx toc --check --silent docs/*.md

Dry run

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.

npx toc --dry-run docs/new.md > toc.md
docs/new.md: no TOC markers, skipped.

With prettier

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.

Run toc before prettier --check 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".

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:

{
  "scripts": {
    "toc": "toc README.md",
    "posttoc": "prettier --write README.md"
  }
}

API

Jump to
import { buildToc, renderToc, findMarkers, findAnchors, findOrphans, slugify, headingText, TOC_START, TOC_END } from '@0dep/toc';

CommonJS works the same way with require('@0dep/toc'), there is no separate bundle since Node 20.19 and 22.12 require ES modules natively.

buildToc(source)

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.

import { buildToc } from '@0dep/toc';

const md = buildToc('# Title\n\n<!-- toc -->\n<!-- /toc -->\n\n## Install\n\n## Usage `api`\n');
console.log(md);
# Title

<!-- toc -->

- [Install](#install)
- [Usage `api`](#usage-api)

<!-- /toc -->

## Install

## Usage `api`

renderToc(source[, fromLine[, options]])

Returns the toc block, markers included, for the headings below the zero based line fromLine, typically a start marker's line. By default every heading is listed. Returns an empty string when there is nothing to list. options are rendered into the start marker and the block, { collapsible: true }, { collapsed: 'Contents', attributes: { class: 'toc' } }, { levels: '2-3' } and so on.

import { renderToc } from '@0dep/toc';

console.log(renderToc('# Title\n\n## Install\n\n### From npm\n'));
<!-- toc -->

- [Title](#title)
  - [Install](#install)
    - [From npm](#from-npm)

<!-- /toc -->

findMarkers(source)

Returns every marker pair in document order as { start, end, options }, zero based line numbers outside fenced code blocks and the recognised options written on the start marker. A start marker pairs with the first end marker after it. A missing side is -1, a start marker without an end marker or an end marker with no open start before it. options.attributes holds the other name="value" pairs, when there are any. When the start marker cannot be used, problem says why and is otherwise absent. When an option on it is ignored, a broken levels value, warning says so.

import { findMarkers } from '@0dep/toc';

console.log(findMarkers('# Title\n\n<!-- toc collapsed -->\n<!-- /toc -->\n\n## A\n\n<!-- toc foo -->\n'));
[
  { start: 2, end: 3, options: { collapsed: true } },
  { start: 7, end: -1, options: {}, problem: 'unknown TOC option foo' }
]

findAnchors(source)

Returns every link to an anchor in document order as { line, text, anchor, valid }: the zero based line, the link text, the anchor without the # and with backslash escapes resolved, and whether the document has a heading slug, an html id or an <a name> for it. Links and targets inside fenced and indented code blocks, code spans and html comments are ignored. When there is no target and exactly one heading matches the slugged anchor or the slugged link text, suggestion names it, and is otherwise absent.

import { findAnchors } from '@0dep/toc';

console.log(findAnchors('## My Heading\n\n[a](#my-heading) [b](#My-Heading) [c](#nope)\n'));
[
  { line: 2, text: 'a', anchor: 'my-heading', valid: true },
  { line: 2, text: 'b', anchor: 'My-Heading', valid: false, suggestion: 'my-heading' },
  { line: 2, text: 'c', anchor: 'nope', valid: false }
]

findOrphans(source)

Returns every <a> with an id or name that no link in the document points to, in document order, as { line, anchor }: the zero based line and the id or name. A link counts as written or percent decoded, wherever it is in the document, a toc block included. Headings and ids on other elements are never orphans. Anchors and links inside fenced and indented code blocks, code spans and html comments are ignored.

import { findOrphans } from '@0dep/toc';

console.log(findOrphans('<a name="top"></a>\n<a name="old"></a>\n\n## One\n\n[up](#top)\n'));
[ { line: 1, anchor: 'old' } ]

slugify(text)

The github-slugger algorithm: lowercase, drop everything that is not a letter, number, mark, space, hyphen or underscore, then turn spaces into hyphens. Nothing is trimmed or collapsed. Duplicate slugs get -1, -2 and so on, counted over the whole document, like GitHub.

import { slugify } from '@0dep/toc';

console.log(slugify('Usage `api`'), slugify('Ändra värde 2 gånger'));
usage-api ändra-värde-2-gånger

headingText(markdown)

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.

import { headingText } from '@0dep/toc';

console.log(headingText('Use `foo()` with [**bold**](https://example.com) \\*text\\*'));
Use foo() with bold *text*

TOC_START and TOC_END

The marker strings, <!-- toc --> and <!-- /toc -->, for code that wants to look for or insert them.

Headings

In this section and below

And one with levels="2", which lists only the level 2 headings that follow:

What is listed

  • ATX headings, ## Heading, with up to three leading spaces and a space after the hashes, and setext headings underlined with === or ---
  • 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
  • Not headings inside fenced code blocks, backtick or tilde
  • Not a --- line under a list item, a table row or a blank line, which is a thematic break and not a setext heading

Labels

The link text is the heading's own markdown, so inline code and emphasis are kept, with two changes:

  • Links and images become their text and autolinks their url, since a link cannot nest inside the toc link
  • Optional closing hashes, ## Heading ##, are stripped

Slugs

Slugs are built from the rendered heading text with the same algorithm as GitHub, so the anchors work on github.com and in every renderer that follows it. Duplicates are numbered over the whole document, headings above the markers included. See slugify and headingText above.

License

MIT

About

Generate a GitHub flavoured markdown table of contents

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages