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 2 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])

if (toc.length === 0) {
return null
Expand Down
12 changes: 10 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,12 @@ const rehypeSNStyled = () => rehypeSN({
}]
})

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

export function SearchText ({ text }) {
return (
Expand Down Expand Up @@ -134,8 +140,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: Embed
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, '')),
depth: node.depth
})
})

return headings
}