Core: Support hast nodes in toc variable

This commit is contained in:
fuma-nama
2024-09-29 00:02:51 +08:00
parent fd92e55f6b
commit e1ee8228c9
13 changed files with 334 additions and 31 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-core': minor
'fumadocs-mdx': minor
'fumadocs-ui': minor
---
Support hast nodes in `toc` variable
+2
View File
@@ -77,6 +77,7 @@
"@shikijs/rehype": "^1.20.0",
"@shikijs/transformers": "^1.20.0",
"github-slugger": "^2.0.0",
"hast-util-to-estree": "^3.1.0",
"image-size": "^1.1.1",
"negotiator": "^0.6.3",
"react-remove-scroll": "^2.6.0",
@@ -102,6 +103,7 @@
"mdast-util-mdxjs-esm": "^2.0.1",
"next": "15.0.0-canary.171",
"remark-mdx": "^3.0.1",
"remark-rehype": "^11.1.1",
"tsconfig": "workspace:*",
"unified": "^11.0.5"
},
+5 -6
View File
@@ -6,13 +6,12 @@ import type { Element, Root, RootContent } from 'hast';
export function visit(
node: RootContent | Root,
tagNames: string[],
handler: (node: Element) => void,
handler: (node: Element) => 'skip' | undefined,
): void {
if (node.type === 'element')
if (tagNames.includes(node.tagName)) {
handler(node);
return;
}
if (node.type === 'element' && tagNames.includes(node.tagName)) {
const result = handler(node);
if (result === 'skip') return;
}
if ('children' in node)
node.children.forEach((n) => {
+1
View File
@@ -7,3 +7,4 @@ export * from './remark-image';
export * from './remark-structure';
export * from './remark-heading';
export * from './remark-admonition';
export * from './rehype-toc';
+136
View File
@@ -0,0 +1,136 @@
import type { Processor, Transformer } from 'unified';
import type { Root } from 'hast';
import { toEstree } from 'hast-util-to-estree';
import type { Declaration, JSXElement } from 'estree-jsx';
import { visit } from '@/mdx-plugins/hast-utils';
export interface RehypeTocOptions {
/**
* Export generated toc as a variable
*
* @defaultValue true
*/
exportToc?: boolean;
}
export function rehypeToc(
this: Processor,
{ exportToc = true }: RehypeTocOptions = {},
): Transformer<Root, Root> {
return (tree) => {
const output: {
title: JSXElement;
url: string;
depth: number;
}[] = [];
visit(tree, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], (element) => {
const id = element.properties.id as string | undefined;
if (!id) return 'skip';
const estree = toEstree(element, {
elementAttributeNameCase: 'react',
stylePropertyNameCase: 'dom',
});
if (estree.body[0].type === 'ExpressionStatement')
output.push({
title: estree.body[0].expression as unknown as JSXElement,
depth: Number(element.tagName.slice(1)),
url: `#${id}`,
});
return 'skip';
});
const declaration: Declaration = {
type: 'VariableDeclaration',
kind: 'const',
declarations: [
{
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name: 'toc',
},
init: {
type: 'ArrayExpression',
elements: output.map((item) => ({
type: 'ObjectExpression',
properties: [
{
type: 'Property',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'depth',
},
value: {
type: 'Literal',
value: item.depth,
},
kind: 'init',
},
{
type: 'Property',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'url',
},
value: {
type: 'Literal',
value: item.url,
},
kind: 'init',
},
{
type: 'Property',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'title',
},
value: {
type: 'JSXFragment',
openingFragment: { type: 'JSXOpeningFragment' },
closingFragment: { type: 'JSXClosingFragment' },
children: item.title.children,
},
kind: 'init',
},
],
})),
},
},
],
};
tree.children.push({
type: 'mdxjsEsm',
value: '',
data: {
estree: {
type: 'Program',
body: [
exportToc
? {
type: 'ExportNamedDeclaration',
declaration,
specifiers: [],
}
: declaration,
],
sourceType: 'module',
comments: [],
},
},
});
};
}
@@ -19,22 +19,29 @@ const regex = /\s*\[#(?<slug>[^]+?)]\s*$/;
export interface RemarkHeadingOptions {
slug?: (root: Root, heading: Heading, text: string) => string;
/**
* Allow custom headings ids
*
* @defaultValue `true`
* @defaultValue true
*/
customId?: boolean;
/**
* Attach an array of `TOCItemType` to `file.data.toc`
*
* @defaultValue true
*/
generateToc?: boolean;
}
/**
* Add heading ids and extract TOC
*
* Attach an array of `TOCItemType` to `file.data.toc`
*/
export function remarkHeading({
slug: defaultSlug,
customId = true,
generateToc = true,
}: RemarkHeadingOptions = {}): Transformer<Root, Root> {
return (root, file) => {
const toc: TOCItemType[] = [];
@@ -82,15 +89,16 @@ export function remarkHeading({
heading.data.hProperties.id = id;
toc.push({
title: value,
url: `#${id}`,
depth: heading.depth,
});
if (generateToc)
toc.push({
title: value,
url: `#${id}`,
depth: heading.depth,
});
return 'skip';
});
file.data.toc = toc;
if (generateToc) file.data.toc = toc;
};
}
+25
View File
@@ -0,0 +1,25 @@
# Heading 1
Some text here
## Heading 2
Some text here
### Heading 3
Some text here
### Custom heading id [#hello-world]
Some text here
### math $$C_L$$
Some text here
### Custom heading id hello-world]
Some text here
### `code` here
+103
View File
@@ -0,0 +1,103 @@
import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const toc = [{
depth: 1,
url: "#heading-1",
title: _jsx(_Fragment, {
children: "Heading 1"
})
}, {
depth: 2,
url: "#heading-2",
title: _jsx(_Fragment, {
children: "Heading 2"
})
}, {
depth: 3,
url: "#heading-3",
title: _jsx(_Fragment, {
children: "Heading 3"
})
}, {
depth: 3,
url: "#hello-world",
title: _jsx(_Fragment, {
children: "Custom heading id"
})
}, {
depth: 3,
url: "#math-c_l",
title: _jsx(_Fragment, {
children: "math $$C_L$$"
})
}, {
depth: 3,
url: "#custom-heading-id-hello-world",
title: _jsx(_Fragment, {
children: "Custom heading id hello-world]"
})
}, {
depth: 3,
url: "#code-here",
title: _jsxs(_Fragment, {
children: [_jsx("code", {
children: "code"
}), " here"]
})
}];
function _createMdxContent(props) {
const _components = {
code: "code",
h1: "h1",
h2: "h2",
h3: "h3",
p: "p",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.h1, {
id: "heading-1",
children: "Heading 1"
}), "\n", _jsx(_components.p, {
children: "Some text here"
}), "\n", _jsx(_components.h2, {
id: "heading-2",
children: "Heading 2"
}), "\n", _jsx(_components.p, {
children: "Some text here"
}), "\n", _jsx(_components.h3, {
id: "heading-3",
children: "Heading 3"
}), "\n", _jsx(_components.p, {
children: "Some text here"
}), "\n", _jsx(_components.h3, {
id: "hello-world",
children: "Custom heading id"
}), "\n", _jsx(_components.p, {
children: "Some text here"
}), "\n", _jsx(_components.h3, {
id: "math-c_l",
children: "math $$C_L$$"
}), "\n", _jsx(_components.p, {
children: "Some text here"
}), "\n", _jsx(_components.h3, {
id: "custom-heading-id-hello-world",
children: "Custom heading id hello-world]"
}), "\n", _jsx(_components.p, {
children: "Some text here"
}), "\n", _jsxs(_components.h3, {
id: "code-here",
children: [_jsx(_components.code, {
children: "code"
}), " here"]
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
+17
View File
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
import path from 'node:path';
import { remark } from 'remark';
import {
rehypeToc,
remarkAdmonition,
remarkHeading,
remarkImage,
@@ -11,6 +12,8 @@ import {
import { fileURLToPath } from 'node:url';
import remarkMdx from 'remark-mdx';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import { createProcessor } from '@mdx-js/mdx';
const cwd = path.dirname(fileURLToPath(import.meta.url));
@@ -80,3 +83,17 @@ test('Remark Image: Without Import', async () => {
path.resolve(cwd, './fixtures/remark-image-without-import.output.mdx'),
);
});
test('Rehype Toc', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/rehype-toc.md'));
const processor = createProcessor({
remarkPlugins: [remarkHeading],
rehypePlugins: [rehypeToc],
});
const result = await processor.process({ value: content });
expect(result.value).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/rehype-toc.output.js'),
);
});
+9 -2
View File
@@ -1,6 +1,7 @@
import {
rehypeCode,
type RehypeCodeOptions,
rehypeToc,
remarkGfm,
remarkHeading,
type RemarkHeadingOptions,
@@ -58,7 +59,6 @@ export function getDefaultMDXOptions({
}: DefaultMDXOptions): ProcessorOptions {
const mdxExports = [
'structuredData',
'toc',
'frontmatter',
'lastModified',
...valueToExport,
@@ -67,7 +67,13 @@ export function getDefaultMDXOptions({
const remarkPlugins = pluginOption(
(v) => [
remarkGfm,
[remarkHeading, remarkHeadingOptions],
[
remarkHeading,
{
generateToc: false,
...remarkHeadingOptions,
},
],
remarkImageOptions !== false && [remarkImage, remarkImageOptions],
...v,
remarkStructureOptions !== false && [
@@ -83,6 +89,7 @@ export function getDefaultMDXOptions({
(v) => [
rehypeCodeOptions !== false && [rehypeCode, rehypeCodeOptions],
...v,
[rehypeToc],
],
mdxOptions.rehypePlugins,
);
@@ -78,10 +78,7 @@ export default function ClerkTOCItems({
return (
<ScrollArea className={cn('flex flex-col', isMenu && '-ms-3')}>
<ScrollViewport
className="relative min-h-0 text-sm text-fd-muted-foreground"
ref={containerRef}
>
<ScrollViewport className="relative min-h-0" ref={containerRef}>
{svg ? (
<div
className="absolute start-0 top-0 rtl:-scale-x-100"
@@ -151,7 +148,7 @@ function TOCItem({
style={{
paddingInlineStart: `${getItemOffset(item.depth)}px`,
}}
className="relative py-2 transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary"
className="prose relative py-2 text-sm text-fd-muted-foreground transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary"
>
{offset !== upperOffset ? (
<svg
+2 -2
View File
@@ -117,7 +117,7 @@ export function TOCItems({
<Primitive.ScrollProvider containerRef={containerRef}>
<div
className={cn(
'flex flex-col text-fd-muted-foreground',
'flex flex-col',
!isMenu && 'border-s border-fd-foreground/10',
)}
>
@@ -136,7 +136,7 @@ function TOCItem({ item }: { item: TOCItemType }): React.ReactElement {
<Primitive.TOCItem
href={item.url}
className={cn(
'py-1.5 transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
'prose py-1.5 text-sm text-fd-muted-foreground transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
item.depth <= 2 && 'ps-3.5',
item.depth === 3 && 'ps-6',
item.depth >= 4 && 'ps-8',
+8 -7
View File
@@ -504,6 +504,9 @@ importers:
github-slugger:
specifier: ^2.0.0
version: 2.0.0
hast-util-to-estree:
specifier: ^3.1.0
version: 3.1.0
image-size:
specifier: ^1.1.1
version: 1.1.1
@@ -581,6 +584,9 @@ importers:
remark-mdx:
specifier: ^3.0.1
version: 3.0.1
remark-rehype:
specifier: ^11.1.1
version: 11.1.1
tsconfig:
specifier: workspace:*
version: link:../tsconfig
@@ -6149,9 +6155,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
[email protected]:
resolution: {integrity: sha512-gVNZ74nqhRMiIUYWGQdosYetaKc83x8oT41a0LlV3AAFCAZwCpg4vmGkq8t34+cUhp3cnM4XDiU/7xlgK7HGrg==}
[email protected]:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
@@ -11372,9 +11375,9 @@ snapshots:
estree-util-is-identifier-name: 3.0.0
hast-util-whitespace: 3.0.0
mdast-util-mdx-expression: 2.0.0
mdast-util-mdx-jsx: 3.0.0
mdast-util-mdx-jsx: 3.1.3
mdast-util-mdxjs-esm: 2.0.1
property-information: 6.3.0
property-information: 6.5.0
space-separated-tokens: 2.0.2
style-to-object: 0.4.2
unist-util-position: 5.0.0
@@ -12946,8 +12949,6 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
[email protected]: {}
[email protected]: {}
[email protected]: {}