mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core: Support hast nodes in toc variable
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'fumadocs-core': minor
|
||||
'fumadocs-mdx': minor
|
||||
'fumadocs-ui': minor
|
||||
---
|
||||
|
||||
Support hast nodes in `toc` variable
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './remark-image';
|
||||
export * from './remark-structure';
|
||||
export * from './remark-heading';
|
||||
export * from './remark-admonition';
|
||||
export * from './rehype-toc';
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Generated
+8
-7
@@ -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]: {}
|
||||
|
||||
Reference in New Issue
Block a user