From 82acffad7bd86f190e96f6cb370ba81fcf2499fd Mon Sep 17 00:00:00 2001 From: ed-kung Date: Sat, 7 Jun 2025 07:07:43 -0700 Subject: [PATCH 1/3] automatic toc generation in markdown --- components/table-of-contents.js | 16 ++------- components/text.js | 8 ++++- lib/remark-toc.js | 61 +++++++++++++++++++++++++++++++++ lib/toc.js | 23 +++++++++++++ 4 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 lib/remark-toc.js create mode 100644 lib/toc.js 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 48a17a3c8..c2703f7ef 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,12 @@ const rehypeSNStyled = () => rehypeSN({ }] }) -const remarkPlugins = [gfm, remarkUnicode, [remarkMath, { singleDollarTextMath: false }]] +const remarkPlugins = [ + gfm, + remarkUnicode, + [remarkMath, { singleDollarTextMath: false }], + remarkToc +] export function SearchText ({ text }) { return ( 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 +} From 0f6ef6de6cc61b5e6528ba852002b317004239cf Mon Sep 17 00:00:00 2001 From: ed-kung Date: Sat, 7 Jun 2025 15:55:08 -0700 Subject: [PATCH 2/3] don't open hash links in new tab --- components/text.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/text.js b/components/text.js index c2703f7ef..94890e59d 100644 --- a/components/text.js +++ b/components/text.js @@ -140,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 {children} + return {children} }, img: TextMediaOrLink, embed: Embed From 239ef2c7dac3e5a6d258f1d3b9e7ab1eedd76efc Mon Sep 17 00:00:00 2001 From: ed-kung Date: Sat, 21 Jun 2025 11:34:35 -0700 Subject: [PATCH 3/3] only process toc for top level items --- components/text.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/components/text.js b/components/text.js index 94890e59d..f7a48afcb 100644 --- a/components/text.js +++ b/components/text.js @@ -34,11 +34,10 @@ const rehypeSNStyled = () => rehypeSN({ }] }) -const remarkPlugins = [ +const baseRemarkPlugins = [ gfm, remarkUnicode, - [remarkMath, { singleDollarTextMath: false }], - remarkToc + [remarkMath, { singleDollarTextMath: false }] ] export function SearchText ({ text }) { @@ -55,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?