Markdown TOC Generator
A CLI that extracts ATX headings from a Markdown file, builds GitHub-style anchors, and prints an indented table of contents with backtick-fenced headings handled.
Project
node-markdown-toc
Entry point: toc.mjs
Install
node --versionRun
node toc.mjs README.mdTest
node --test toc.test.mjsExpected output
For a README with three heading levels the CLI prints nested list items whose anchors match GitHub slug rules.Dependencies
- node >=20
What it does
Extracts ATX headings from a Markdown file, skipping fenced code blocks, and renders a nested table of contents with GitHub-style anchors.
import { readFileSync } from 'node:fs';
export function extractHeadings(markdown) {
const headings = [];
const inFence = { state: false };
const lines = markdown.split('\n');
for (const line of lines) {
if (line.trimStart().startsWith('```')) {
inFence.state = !inFence.state;
continue;
}
if (inFence.state) continue;
const m = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
if (m) headings.push({ level: m[1].length, text: m[2] });
}
return headings;
}
export function slugify(text) {
return text
.toLowerCase()
.replace(/`/g, '')
.replace(/[^a-z0-9 \-]/g, '')
.trim()
.replace(/ /g, '-');
}
export function renderToc(headings) {
return headings
.map((h) => ' '.repeat(h.level - 1) + '- [' + h.text + '](#' + slugify(h.text) + ')')
.join('\n');
}
const [, , file] = process.argv;
if (file) {
console.log(renderToc(extractHeadings(readFileSync(file, 'utf8'))));
}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { extractHeadings, slugify, renderToc } from './toc.mjs';
test('extracts ATX headings with levels, skipping fences and code', () => {
const md = [
'# Title',
'',
'```bash',
'## not a heading',
'```',
'',
'## Section One',
'### Deep',
'#### Deeper',
].join('\n');
const headings = extractHeadings(md);
assert.deepEqual(headings.map((h) => h.level), [1, 2, 3, 4]);
assert.equal(headings[1].text, 'Section One');
});
test('slugify matches GitHub anchor rules', () => {
assert.equal(slugify('Hello, World!'), 'hello-world');
assert.equal(slugify('API \`usage\` Notes'), 'api-usage-notes');
assert.equal(slugify('Trailing spaces '), 'trailing-spaces');
});
test('rendered toc nests by heading level', () => {
const toc = renderToc([
{ level: 1, text: 'Root' },
{ level: 2, text: 'Child' },
{ level: 1, text: 'Root Two' },
]);
assert.deepEqual(toc.split('\n'), [
'- [Root](#root)',
' - [Child](#child)',
'- [Root Two](#root-two)',
]);
});
Run and verify
node --test toc.test.mjs
node toc.mjs README.md
Run the tests for fence skipping and slug rules, then generate the toc for a sample README.
Limitations
Setext headings (underlined with === or —) are not recognized, and duplicate anchor disambiguation follows single-suffix rules only.