mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 05:23:40 +00:00
Core: Support custom toc settings in headings
This commit is contained in:
@@ -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],
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user