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:
parent
68bd96a65c
commit
4998041d73
@ -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
|
||||
|
@ -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
61
lib/remark-toc.js
Normal 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
23
lib/toc.js
Normal 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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user