Automatically generate table of contents in text (#2213)

* automatic toc generation in markdown

* don't open hash links in new tab

* only process toc for top level items

---------

Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
Edward Kung 2025-08-26 07:42:01 -07:00 committed by GitHub
parent 68bd96a65c
commit 4998041d73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 95 additions and 15 deletions

View File

@ -2,11 +2,8 @@ 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()
@ -14,16 +11,7 @@ export default function Toc ({ text }) {
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])
if (toc.length === 0) {
return null

View File

@ -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: [{
@ -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 (
@ -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?

61
lib/remark-toc.js Normal file
View File

@ -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
lib/toc.js Normal file
View File

@ -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, '')),
depth: node.depth
})
})
return headings
}