← Runnable Projects

阅读中文版本

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.

beginnerjavascriptunverified

Project

node-markdown-toc

Entry point: toc.mjs

Install

node --version

Run

node toc.mjs README.md

Test

node --test toc.test.mjs

Expected 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.