Core: Support custom toc settings in headings

This commit is contained in:
Fuma Nama
2025-02-09 21:52:14 +08:00
parent bc4a99c9e2
commit 08236e18a7
7 changed files with 99 additions and 58 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support custom toc settings in headings
@@ -45,7 +45,7 @@ An array of `TOCItemType`.
<AutoTypeTable path="./content/docs/headless/props.ts" name="TOCItemType" />
## Rehype Toc
## Rehype TOC
Export table of contents (an array of `TOCItemType`), it allows JSX nodes which is not possible with a Remark plugin.
@@ -57,7 +57,7 @@ Export table of contents (an array of `TOCItemType`), it allows JSX nodes which
import { rehypeToc } from 'fumadocs-core/mdx-plugins';
export default {
remarkPlugins: [rehypeToc],
rehypePlugins: [rehypeToc],
};
```
+30 -3
View File
@@ -174,18 +174,45 @@ You can specify the type of callout.
See [all MDX components and available options](/docs/ui/mdx).
## Heading Anchors
## Headings
An anchor is automatically applied to all headings, it sanitizes invalid characters like spaces. (e.g. `Hello World` to `hello-world`)
An anchor is automatically applied to each heading, it sanitizes invalid characters like spaces. (e.g. `Hello World` to `hello-world`)
```md
# Hello `World`
```
### TOC Settings
The table of contents (TOC) will be generated based on headings, you can also customise the effects of headings:
```md
# Heading [!toc]
This heading will be hidden from TOC.
# Another Heading [toc]
This heading will **only** be visible in TOC, you can use it to add additional TOC items.
Like headings rendered in a React component:
<MyComp />
```
### Custom Anchor
You can add `[#slug]` to customise heading anchors.
```mdx
```md
# heading [#my-heading-id]
```
You can also chain it with TOC settings like:
```md
# heading [toc] [#my-heading-id]
```
To link people to a specific heading, add the heading id to hash fragment: `/page#my-heading-id`.
## Frontmatter
+25 -1
View File
@@ -1,5 +1,5 @@
import type { Processor, Transformer } from 'unified';
import type { Root } from 'hast';
import type { Root, RootContent } from 'hast';
import { toEstree } from 'hast-util-to-estree';
import type { Declaration, JSXElement } from 'estree-jsx';
import { visit } from '@/mdx-plugins/hast-utils';
@@ -13,6 +13,9 @@ export interface RehypeTocOptions {
exportToc?: boolean;
}
const TocOnlyTag = '[toc]';
const NoTocTag = '[!toc]';
export function rehypeToc(
this: Processor,
{ exportToc = true }: RehypeTocOptions = {},
@@ -27,6 +30,20 @@ export function rehypeToc(
visit(tree, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], (element) => {
const id = element.properties.id as string | undefined;
if (!id) 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 estree = toEstree(element, {
elementAttributeNameCase: 'react',
@@ -40,6 +57,13 @@ export function rehypeToc(
url: `#${id}`,
});
if (isTocOnly) {
Object.assign(element, {
type: 'comment',
value: '',
} satisfies RootContent);
}
return 'skip';
});
+12 -26
View File
@@ -47,54 +47,40 @@ export function remarkHeading({
const toc: TOCItemType[] = [];
slugger.reset();
// Fumadocs OpenAPI Generated TOC
if (file.data.frontmatter) {
const frontmatter = file.data.frontmatter as {
_openapi?: {
toc?: TOCItemType[];
};
};
if (frontmatter._openapi?.toc) {
toc.push(...frontmatter._openapi.toc);
}
}
visit(root, 'heading', (heading) => {
heading.data ||= {};
heading.data.hProperties ||= {};
let id = heading.data.hProperties.id;
const lastNode = heading.children.at(-1);
if (
!heading.data.hProperties.id &&
lastNode?.type === 'text' &&
customId
) {
if (!id && lastNode?.type === 'text' && customId) {
const match = regex.exec(lastNode.value);
if (match?.[1]) {
heading.data.hProperties.id = match[1];
id = match[1];
lastNode.value = lastNode.value.slice(0, match.index);
}
}
const value = flattenNode(heading);
let id = heading.data.hProperties.id;
let flattened: string | undefined;
if (!id) {
flattened ??= flattenNode(heading);
id = defaultSlug
? defaultSlug(root, heading, value)
: slugger.slug(value);
? defaultSlug(root, heading, flattened)
: slugger.slug(flattened);
}
heading.data.hProperties.id = id;
if (generateToc)
if (generateToc) {
toc.push({
title: value,
title: flattened ?? flattenNode(heading),
url: `#${id}`,
depth: heading.depth,
});
}
return 'skip';
});
+24 -25
View File
@@ -1,4 +1,4 @@
import path from 'node:path';
import * as path from 'node:path';
import type { Root } from 'mdast';
import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
@@ -168,33 +168,32 @@ export function remarkImage({
});
await Promise.all(promises);
if (importsToInject.length === 0) return;
if (importsToInject.length > 0) {
const imports = importsToInject.map(
({ variableName, importPath }) =>
({
type: 'mdxjsEsm',
data: {
estree: {
body: [
{
type: 'ImportDeclaration',
source: { type: 'Literal', value: importPath },
specifiers: [
{
type: 'ImportDefaultSpecifier',
local: { type: 'Identifier', name: variableName },
},
],
},
],
},
const imports = importsToInject.map(
({ variableName, importPath }) =>
({
type: 'mdxjsEsm',
data: {
estree: {
body: [
{
type: 'ImportDeclaration',
source: { type: 'Literal', value: importPath },
specifiers: [
{
type: 'ImportDefaultSpecifier',
local: { type: 'Identifier', name: variableName },
},
],
},
],
},
}) as MdxjsEsm,
);
},
}) as MdxjsEsm,
);
tree.children.unshift(...imports);
}
tree.children.unshift(...imports);
};
}
+1 -1
View File
@@ -199,7 +199,7 @@ export function SidebarFooter(props: HTMLAttributes<HTMLDivElement>) {
<div
{...props}
className={cn(
'flex flex-col border-t ps-4 p-3 empty:hidden',
'flex flex-col border-t ps-4 pe-3 py-3 empty:hidden',
props.className,
)}
>