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 Dropdown from 'react-bootstrap/Dropdown'
|
||||||
import FormControl from 'react-bootstrap/FormControl'
|
import FormControl from 'react-bootstrap/FormControl'
|
||||||
import TocIcon from '@/svgs/list-unordered.svg'
|
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 { useRouter } from 'next/router'
|
||||||
|
import { extractHeadings } from '@/lib/toc'
|
||||||
|
|
||||||
export default function Toc ({ text }) {
|
export default function Toc ({ text }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -14,16 +11,7 @@ export default function Toc ({ text }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toc = useMemo(() => {
|
const toc = useMemo(() => extractHeadings(text), [text])
|
||||||
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])
|
|
||||||
|
|
||||||
if (toc.length === 0) {
|
if (toc.length === 0) {
|
||||||
return null
|
return null
|
||||||
|
@ -20,6 +20,7 @@ import rehypeSN from '@/lib/rehype-sn'
|
|||||||
import remarkUnicode from '@/lib/remark-unicode'
|
import remarkUnicode from '@/lib/remark-unicode'
|
||||||
import Embed from './embed'
|
import Embed from './embed'
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
|
import remarkToc from '@/lib/remark-toc'
|
||||||
|
|
||||||
const rehypeSNStyled = () => rehypeSN({
|
const rehypeSNStyled = () => rehypeSN({
|
||||||
stylers: [{
|
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 }) {
|
export function SearchText ({ text }) {
|
||||||
return (
|
return (
|
||||||
@ -49,6 +54,9 @@ export function SearchText ({ text }) {
|
|||||||
|
|
||||||
// this is one of the slowest components to render
|
// this is one of the slowest components to render
|
||||||
export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
|
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?
|
// would the text overflow on the current screen size?
|
||||||
const [overflowing, setOverflowing] = useState(false)
|
const [overflowing, setOverflowing] = useState(false)
|
||||||
// should we show the full text?
|
// 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