diff --git a/components/table-of-contents.js b/components/table-of-contents.js index 59bd4039d..c7a3d4e53 100644 --- a/components/table-of-contents.js +++ b/components/table-of-contents.js @@ -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 diff --git a/components/text.js b/components/text.js index 654fa3caf..c3e64e7ff 100644 --- a/components/text.js +++ b/components/text.js @@ -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? @@ -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 {children} + return {children} }, img: TextMediaOrLink, embed: (props) => diff --git a/lib/remark-toc.js b/lib/remark-toc.js new file mode 100644 index 000000000..c1492c90a --- /dev/null +++ b/lib/remark-toc.js @@ -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 +} diff --git a/lib/toc.js b/lib/toc.js new file mode 100644 index 000000000..b56ebdd44 --- /dev/null +++ b/lib/toc.js @@ -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 +}