Skip to content

Automatically generate table of contents in text #2213

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 2 additions & 14 deletions components/table-of-contents.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,16 @@ import React, { useMemo, useState } from 'react'
import Dropdown from 'react-bootstrap/Dropdown'
import FormControl from 'react-bootstrap/FormControl'
import TocIcon from '@/svgs/list-unordered.svg'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { visit } from 'unist-util-visit'
import { toString } from 'mdast-util-to-string'
import { slug } from 'github-slugger'
import { useRouter } from 'next/router'
import { extractHeadings } from '@/lib/toc'

export default function Toc ({ text }) {
const router = useRouter()
if (!text || text.length === 0) {
return null
}

const toc = useMemo(() => {
const tree = fromMarkdown(text)
const toc = []
visit(tree, 'heading', (node, position, parent) => {
const str = toString(node)
toc.push({ heading: str, slug: slug(str.replace(/[^\w\-\s]+/gi, '')), depth: node.depth })
})

return toc
}, [text])
const toc = useMemo(() => extractHeadings(text), [text])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that we don't give IDs to nodes inside comments, only on full posts:

h1: ({ node, id, ...props }) => <h1 id={topLevel ? id : undefined} {...props} />,

Because of this, the table of contents won't work if used in comments. I personally think that the ToC doesn't make that much sense in comments, so we can just disable {:toc} for them with topLevel awareness. What do you think? ^^

Copy link
Contributor Author

@ed-kung ed-kung Jun 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, it wouldn't make sense to use Toc in comments, so I changed it so that remarkToc is only processed for topLevel items


if (toc.length === 0) {
return null
Expand Down
14 changes: 12 additions & 2 deletions components/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import rehypeSN from '@/lib/rehype-sn'
import remarkUnicode from '@/lib/remark-unicode'
import Embed from './embed'
import remarkMath from 'remark-math'
import remarkToc from '@/lib/remark-toc'

const rehypeSNStyled = () => rehypeSN({
stylers: [{
Expand All @@ -33,7 +34,11 @@ const rehypeSNStyled = () => rehypeSN({
}]
})

const remarkPlugins = [gfm, remarkUnicode, [remarkMath, { singleDollarTextMath: false }]]
const baseRemarkPlugins = [
gfm,
remarkUnicode,
[remarkMath, { singleDollarTextMath: false }]
]

export function SearchText ({ text }) {
return (
Expand All @@ -49,6 +54,9 @@ export function SearchText ({ text }) {

// this is one of the slowest components to render
export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
// include remarkToc if topLevel
const remarkPlugins = topLevel ? [...baseRemarkPlugins, remarkToc] : baseRemarkPlugins

// would the text overflow on the current screen size?
const [overflowing, setOverflowing] = useState(false)
// should we show the full text?
Expand Down Expand Up @@ -134,8 +142,10 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child
return href
}

const isHashLink = href?.startsWith('#')

// eslint-disable-next-line
return <Link id={props.id} target='_blank' rel={rel} href={href}>{children}</Link>
return <Link id={props.id} target={isHashLink ? undefined : '_blank'} rel={rel} href={href}>{children}</Link>
},
img: TextMediaOrLink,
embed: (props) => <Embed {...props} topLevel={topLevel} />
Expand Down
61 changes: 61 additions & 0 deletions lib/remark-toc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { SKIP, visit } from 'unist-util-visit'
import { extractHeadings } from './toc'

export default function remarkToc () {
return function transformer (tree) {
const headings = extractHeadings(tree)

visit(tree, 'paragraph', (node, index, parent) => {
if (
node.children?.length === 1 &&
node.children[0].type === 'text' &&
node.children[0].value.trim() === '{:toc}'
) {
parent.children.splice(index, 1, buildToc(headings))
return [SKIP, index]
}
})
}
}

function buildToc (headings) {
const root = { type: 'list', ordered: false, spread: false, children: [] }
const stack = [{ depth: 0, node: root }] // holds the current chain of parents

for (const { heading, slug, depth } of headings) {
// walk up the stack to find the parent of the current heading
while (stack.length && depth <= stack[stack.length - 1].depth) {
stack.pop()
}
let parent = stack[stack.length - 1].node

// if the parent is a li, gets its child ul
if (parent.type === 'listItem') {
let ul = parent.children.find(c => c.type === 'list')
if (!ul) {
ul = { type: 'list', ordered: false, spread: false, children: [] }
parent.children.push(ul)
}
parent = ul
}

// build the li from the current heading
const listItem = {
type: 'listItem',
spread: false,
children: [{
type: 'paragraph',
children: [{
type: 'link',
url: `#${slug}`,
children: [{ type: 'text', value: heading }]
}]
}]
}

parent.children.push(listItem)
stack.push({ depth, node: listItem })
}

return root
}
23 changes: 23 additions & 0 deletions lib/toc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { fromMarkdown } from 'mdast-util-from-markdown'
import { visit } from 'unist-util-visit'
import { toString } from 'mdast-util-to-string'
import { slug } from 'github-slugger'

export function extractHeadings (markdownOrTree) {
const tree = typeof markdownOrTree === 'string'
? fromMarkdown(markdownOrTree)
: markdownOrTree

const headings = []

visit(tree, 'heading', node => {
const str = toString(node)
headings.push({
heading: str,
slug: slug(str.replace(/[^\w\-\s]+/gi, '')),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note for the future, unrelated to review:
it seems that we don't handle duplicate headings, because we use slug instead of the GithubSlugger class.
Probably because it's faster as GithubSlugger would have instead tracked headings in memory and checked every heading against the previous ones to count 🤔.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use GithubSlugger because of #1405

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, will leave this as is for now

depth: node.depth
})
})

return headings
}