mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-07-28 14:43:04 +00:00
Core: Support data export mode in Rehype TOC
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user