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
+}