Include h1 headings in markdown table of contents

Previously h1 headings were excluded, treating them as a document-level root.
Now h1 entries appear as TOC items alongside h2/h3, giving users a more
complete and consistent navigation outline.
This commit is contained in:
Jinjing
2026-06-07 16:37:09 -07:00
parent 9b7e0e661a
commit ed7da6fbdc
2 changed files with 31 additions and 28 deletions
@@ -5,54 +5,57 @@ import {
} from './markdown-table-of-contents'
describe('markdown table of contents', () => {
it('builds a nested h2-h3 outline excluding h1', () => {
it('builds a nested h1-h3 outline', () => {
const toc = buildMarkdownTableOfContents('# Intro\n\n## Setup\n\n### Install\n\n## Usage')
expect(toc).toEqual([
{
id: 'setup',
level: 2,
title: 'Setup',
id: 'intro',
level: 1,
title: 'Intro',
children: [
{
id: 'install',
level: 3,
title: 'Install',
id: 'setup',
level: 2,
title: 'Setup',
children: [
{
id: 'install',
level: 3,
title: 'Install',
children: []
}
]
},
{
id: 'usage',
level: 2,
title: 'Usage',
children: []
}
]
},
{
id: 'usage',
level: 2,
title: 'Usage',
children: []
}
])
})
it('skips front matter, h1, and unsupported heading depths', () => {
const toc = buildMarkdownTableOfContents(
'---\ntitle: Doc\n---\n# Hidden H1\n## Visible\n#### Hidden H4'
)
it('skips front matter and unsupported heading depths', () => {
const toc = buildMarkdownTableOfContents('---\ntitle: Doc\n---\n# Visible\n#### Hidden')
expect(toc.map((item) => item.title)).toEqual(['Visible'])
})
it('skips headings inside fenced code blocks', () => {
const toc = buildMarkdownTableOfContents(
'# Hidden H1\n\n## Setup\n\n```sh\n# not a heading\n```\n\n## Real'
)
const toc = buildMarkdownTableOfContents('# Install\n\n```sh\n# not a heading\n```\n\n## Real')
expect(toc.map((item) => item.title)).toEqual(['Setup', 'Real'])
expect(toc[0].children.map((item) => item.title)).toEqual(['Real'])
})
it('includes rendered markdown heading forms', () => {
const toc = buildMarkdownTableOfContents(
'# Hidden H1\n\n ## Indented\n\nSetext *Title*\n---\n\n### https://example.com'
'# Intro\n\n ## Indented\n\nSetext *Title*\n---\n\n### https://example.com'
)
expect(toc).toEqual([
expect(toc[0].children).toEqual([
{
id: 'indented',
level: 2,
@@ -76,13 +79,13 @@ describe('markdown table of contents', () => {
})
it('uses GitHub-compatible duplicate slugs', () => {
const toc = buildMarkdownTableOfContents('## Repeat\n## Repeat')
const toc = buildMarkdownTableOfContents('# Repeat\n# Repeat')
expect(toc.map((item) => item.id)).toEqual(['repeat', 'repeat-1'])
})
it('decodes HTML entities before slugging headings', () => {
const toc = buildMarkdownTableOfContents('## A & B')
const toc = buildMarkdownTableOfContents('# A & B')
expect(toc[0]).toMatchObject({
id: 'a--b',
@@ -4,7 +4,7 @@ import remarkParse from 'remark-parse'
import { unified } from 'unified'
import { MarkdownHeadingSlugger } from './markdown-heading-slug'
export type MarkdownTocLevel = 2 | 3
export type MarkdownTocLevel = 1 | 2 | 3
export type MarkdownTocItem = {
children: MarkdownTocItem[]
@@ -23,7 +23,7 @@ const htmlEntitiesForToc = new Map([
])
function isMarkdownTocLevel(value: number): value is MarkdownTocLevel {
return value === 2 || value === 3
return value === 1 || value === 2 || value === 3
}
// Scoped local fork of the tiny entities@6.0.1 surface Orca used here.
@@ -99,7 +99,7 @@ function markdownAstNodeToText(node: MarkdownAstNode): string {
export function buildMarkdownTableOfContents(markdown: string): MarkdownTocItem[] {
const slugger = new MarkdownHeadingSlugger()
const root: MarkdownTocItem = { id: 'toc-root', level: 1, title: '', children: [] }
const root = { id: 'toc-root', level: 1 as const, title: '', children: [] }
const stack: MarkdownTocItem[] = [root]
// Why: parsing Markdown keeps the TOC aligned with rendered setext/GFM/entity