Core: Support data export mode in Rehype TOC

This commit is contained in:
Fuma Nama
2026-03-03 19:56:43 +08:00
parent 4d05c4e09c
commit 5f687b6a0e
12 changed files with 189 additions and 159 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
[rehype-toc] Support `data` export mode
@@ -1,20 +0,0 @@
import type { Element, Root, RootContent } from 'hast';
/**
* Visit a node with filtered tag names
*/
export function visit(
node: RootContent | Root,
tagNames: string[],
handler: (node: Element) => 'skip' | undefined,
): void {
if (node.type === 'element' && tagNames.includes(node.tagName)) {
const result = handler(node);
if (result === 'skip') return;
}
if ('children' in node)
node.children.forEach((n) => {
visit(n, tagNames, handler);
});
}
+8 -3
View File
@@ -1,5 +1,7 @@
import type { RootContent } from 'mdast';
import { valueToEstree } from 'estree-util-value-to-estree';
import type { Expression } from 'estree-jsx';
import type { MdxjsEsm } from 'mdast-util-mdx';
export function flattenNode(node: RootContent): string {
if ('children' in node) return node.children.map((child) => flattenNode(child)).join('');
@@ -9,7 +11,11 @@ export function flattenNode(node: RootContent): string {
return '';
}
export function toMdxExport(name: string, value: unknown): RootContent {
export function toMdxExport(name: string, value: unknown): MdxjsEsm {
return toMdxExportRaw(name, valueToEstree(value));
}
export function toMdxExportRaw(name: string, expression: Expression): MdxjsEsm {
return {
type: 'mdxjsEsm',
value: '',
@@ -22,7 +28,6 @@ export function toMdxExport(name: string, value: unknown): RootContent {
type: 'ExportNamedDeclaration',
attributes: [],
specifiers: [],
source: null,
declaration: {
type: 'VariableDeclaration',
kind: 'let',
@@ -33,7 +38,7 @@ export function toMdxExport(name: string, value: unknown): RootContent {
type: 'Identifier',
name,
},
init: valueToEstree(value),
init: expression,
},
],
},
+150 -124
View File
@@ -1,157 +1,183 @@
import type { Processor, Transformer } from 'unified';
import type { Root, RootContent } from 'hast';
import type { Root, Element } from 'hast';
import { toEstree } from 'hast-util-to-estree';
import type { Declaration, JSXElement } from 'estree-jsx';
import { visit } from '@/mdx-plugins/hast-utils';
import type { JSXElement } from 'estree-jsx';
import { visit } from 'unist-util-visit';
import { toMdxExportRaw } from './mdast-utils';
export interface RehypeTocOptions {
/**
* Export generated toc as a variable
* Export the generated toc.
*
* @defaultValue true
* - `true` (default): as an ESM export named `toc`.
* - `false`: disable the plugin.
*/
exportToc?: boolean;
exportToc?:
| boolean
| {
/**
* generate to `file.data.rehypeToc`.
*/
as: 'data';
}
| {
/**
* generate as an ESM export.
*/
as: 'esm';
name: string;
};
}
declare module 'vfile' {
interface DataMap {
/**
* [Fumadocs: rehype-toc] output data.
*/
rehypeToc?: RehypeTOCItemType[];
}
}
export interface RehypeTOCItemType {
/**
* the original heading tag
*/
title: Element;
url: string;
depth: number;
}
const TocOnlyTag = '[toc]';
const NoTocTag = '[!toc]';
const HeadingTags = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
export function rehypeToc(
this: Processor,
{ exportToc = true }: RehypeTocOptions = {},
): Transformer<Root, Root> {
return (tree) => {
const output: {
title: JSXElement;
url: string;
depth: number;
}[] = [];
if (exportToc === true) {
exportToc = { as: 'esm', name: 'toc' };
}
if (exportToc === false) {
return () => undefined;
}
return (tree, file) => {
const items: RehypeTOCItemType[] = [];
visit(tree, 'element', (element, idx, parent) => {
if (!HeadingTags.has(element.tagName) || element.children.length === 0) return;
visit(tree, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], (element) => {
const id = element.properties.id;
if (typeof id !== 'string') return 'skip';
let isTocOnly = false;
const last = element.children.at(-1);
if (last?.type === 'text' && last.value.endsWith(TocOnlyTag)) {
isTocOnly = true;
last.value = last.value.substring(0, last.value.length - TocOnlyTag.length).trimEnd();
} else if (last?.type === 'text' && last.value.endsWith(NoTocTag)) {
last.value = last.value.substring(0, last.value.length - NoTocTag.length).trimEnd();
return 'skip';
const last = element.children[element.children.length - 1];
if (last.type === 'text') {
if (last.value.endsWith(NoTocTag)) {
last.value = last.value.substring(0, last.value.length - NoTocTag.length).trimEnd();
return 'skip';
}
if (last.value.endsWith(TocOnlyTag)) {
isTocOnly = true;
last.value = last.value.substring(0, last.value.length - TocOnlyTag.length).trimEnd();
}
}
const estree = toEstree(element, {
elementAttributeNameCase: 'react',
stylePropertyNameCase: 'dom',
items.push({
title: element,
depth: Number(element.tagName[1]),
url: `#${id}`,
});
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}`,
});
if (isTocOnly) {
Object.assign(element, {
type: 'comment',
value: '',
} satisfies RootContent);
if (isTocOnly && parent && typeof idx === 'number') {
parent.children.splice(idx, 1);
}
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',
},
],
})),
},
},
],
};
if (exportToc.as === 'esm') {
const esmItems: {
title: JSXElement;
url: string;
depth: number;
}[] = [];
for (const item of items) {
const root = toEstree(item.title, {
elementAttributeNameCase: 'react',
stylePropertyNameCase: 'dom',
}).body[0];
tree.children.push({
type: 'mdxjsEsm',
value: '',
data: {
estree: {
type: 'Program',
body: [
exportToc
? {
type: 'ExportNamedDeclaration',
declaration,
attributes: [],
specifiers: [],
}
: declaration,
],
sourceType: 'module',
comments: [],
},
},
} satisfies RootContent);
if (root.type === 'ExpressionStatement' && root.expression.type === 'JSXElement') {
esmItems.push({
...item,
title: root.expression,
});
}
}
tree.children.push(
toMdxExportRaw(exportToc.name, {
type: 'ArrayExpression',
elements: esmItems.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',
},
],
})),
}),
);
} else {
file.data.rehypeToc = items;
}
};
}
@@ -5,8 +5,6 @@ import { visit } from 'unist-util-visit';
import type { TOCItemType } from '@/toc';
import { flattenNode } from '@/mdx-plugins/mdast-utils';
const slugger = new Slugger();
declare module 'mdast' {
export interface HeadingData extends Data {
hProperties?: {
@@ -35,17 +33,33 @@ export interface RemarkHeadingOptions {
generateToc?: boolean;
}
declare module 'vfile' {
interface DataMap {
/**
* [Fumadocs: remark-heading] output data.
*/
toc?: TOCItemType[];
}
}
/**
* Add heading ids and extract TOC
*/
export function remarkHeading({
slug: defaultSlug,
slug,
customId = true,
generateToc = true,
}: RemarkHeadingOptions = {}): Transformer<Root, Root> {
let slugger: Slugger | undefined;
if (!slug) {
slugger = new Slugger();
slug = (_root, _heading, text) => slugger!.slug(text);
}
return (root, file) => {
const toc: TOCItemType[] = [];
slugger.reset();
slugger?.reset();
visit(root, 'heading', (heading) => {
heading.data ||= {};
@@ -66,7 +80,7 @@ export function remarkHeading({
if (!props.id) {
flattened ??= flattenNode(heading);
props.id = defaultSlug ? defaultSlug(root, heading, flattened) : slugger.slug(flattened);
props.id = slug(root, heading, flattened);
}
if (generateToc) {
+1 -1
View File
@@ -1,5 +1,5 @@
import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const toc = [{
export let toc = [{
depth: 1,
url: "#heading-1",
title: _jsx(_Fragment, {
+1 -1
View File
@@ -1,6 +1,6 @@
"use strict";
const {Fragment: _Fragment, jsx: _jsx, jsxs: _jsxs} = arguments[0];
const toc = [{
let toc = [{
depth: 2,
url: "#you-found-me",
title: _jsxs(_Fragment, {
+1 -1
View File
@@ -1,6 +1,6 @@
"use strict";
const {Fragment: _Fragment, jsxDEV: _jsxDEV} = arguments[0];
const toc = [{
let toc = [{
depth: 2,
url: "#you-found-me",
title: _jsxDEV(_Fragment, {
+1 -1
View File
@@ -1,6 +1,6 @@
"use strict";
const {Fragment: _Fragment, jsx: _jsx, jsxs: _jsxs} = arguments[0];
const toc = [{
let toc = [{
depth: 2,
url: "#you-found-me",
title: _jsxs(_Fragment, {
+1 -1
View File
@@ -1,6 +1,6 @@
"use strict";
const {Fragment: _Fragment, jsx: _jsx, jsxs: _jsxs} = arguments[0];
const toc = [];
let toc = [];
function _createMdxContent(props) {
const _components = {
code: "code",
+1 -1
View File
@@ -1,6 +1,6 @@
"use strict";
const {Fragment: _Fragment, jsxDEV: _jsxDEV} = arguments[0];
const toc = [];
let toc = [];
function _createMdxContent(props) {
const _components = {
code: "code",
+1 -1
View File
@@ -1,6 +1,6 @@
"use strict";
const {Fragment: _Fragment, jsx: _jsx, jsxs: _jsxs} = arguments[0];
const toc = [];
let toc = [];
function _createMdxContent(props) {
const _components = {
code: "code",