Core & MDX: Support customising heading id in headings

This commit is contained in:
fuma-nama
2024-05-12 16:33:53 +08:00
parent 77da932b8a
commit 5653d5d326
11 changed files with 141 additions and 37 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"fumadocs-core": patch
"fumadocs-mdx": patch
---
Support customising heading id in headings
+6
View File
@@ -0,0 +1,6 @@
---
"fumadocs-core": patch
"fumadocs-mdx": patch
---
Support custom heading slugger
+1
View File
@@ -21,6 +21,7 @@ dist
/build
# misc
.idea
.DS_Store
*.pem
+3 -1
View File
@@ -10,7 +10,9 @@ export default function Layout({
}): React.ReactElement {
return (
<Body>
<DocsLayout {...layoutOptions}>{children}</DocsLayout>
<DocsLayout {...layoutOptions}>
{children}
</DocsLayout>
</Body>
);
}
+1 -1
View File
@@ -74,7 +74,7 @@ export type AutoTypeTableProps = ComponentPropsWithoutRef<typeof AutoTypeTable>;
export interface AutoTypeTableExample {
/**
* markdown syntax like links, `code` are supported.
* Markdown syntax like links, `code` are supported.
*
* See https://fumadocs.vercel.app/docs/ui/components/type-table
*/
@@ -1,5 +1,5 @@
import Slugger from 'github-slugger';
import type { Root } from 'mdast';
import type { Heading, Root } from 'mdast';
import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import type { TOCItemType } from '@/server/get-toc';
@@ -15,12 +15,18 @@ declare module 'mdast' {
}
}
export interface RemarkHeadingOptions {
slug?: (root: Root, heading: Heading, text: string) => string;
}
/**
* Add heading ids and extract TOC
*
* Attach an array of `TOCItemType` to `vfile.data.toc`
* Attach an array of `TOCItemType` to `file.data.toc`
*/
export function remarkHeading(): Transformer<Root, Root> {
export function remarkHeading(
options: RemarkHeadingOptions = {},
): Transformer<Root, Root> {
return (node, file) => {
const toc: TOCItemType[] = [];
slugger.reset();
@@ -30,7 +36,9 @@ export function remarkHeading(): Transformer<Root, Root> {
heading.data.hProperties ||= {};
const text = flattenNode(heading);
const id = slugger.slug(heading.data.hProperties.id ?? text);
const id =
heading.data.hProperties.id ??
(options.slug ? options.slug(node, heading, text) : defaultSlug(text));
heading.data.hProperties.id = id;
@@ -46,3 +54,15 @@ export function remarkHeading(): Transformer<Root, Root> {
file.data.toc = toc;
};
}
function defaultSlug(text: string): string {
// match {slug}, while escape `\{slug}`
if (text.endsWith('}')) {
const start = text.lastIndexOf('{');
if (start !== -1 && text[start - 1] !== '\\')
return text.substring(start + 1, text.length - 1);
}
return slugger.slug(text);
}
+12
View File
@@ -9,3 +9,15 @@ Some text here
### Heading 3
Some text here
### Custom heading id {hello-world}
Some text here
### Custom heading id \\{hello-world}
Some text here
### Custom heading id hello-world}
Some text here
+32 -1
View File
@@ -1 +1,32 @@
[{"title":"Heading 1","url":"#heading-1","depth":1},{"title":"Heading 2","url":"#heading-2","depth":2},{"title":"Heading 3","url":"#heading-3","depth":3}]
[
{
"depth": 1,
"title": "Heading 1",
"url": "#heading-1",
},
{
"depth": 2,
"title": "Heading 2",
"url": "#heading-2",
},
{
"depth": 3,
"title": "Heading 3",
"url": "#heading-3",
},
{
"depth": 3,
"title": "Custom heading id {hello-world}",
"url": "#hello-world",
},
{
"depth": 3,
"title": "Custom heading id \{hello-world}",
"url": "#custom-heading-id-hello-world",
},
{
"depth": 3,
"title": "Custom heading id hello-world}",
"url": "#custom-heading-id-hello-world-1",
},
]
+26 -1
View File
@@ -1 +1,26 @@
{"contents":[{"heading":"heading-1","content":"Some text here"},{"heading":"heading-2","content":"Some text here"}],"headings":[{"id":"heading-1","content":"Heading 1"},{"id":"heading-2","content":"Heading 2"},{"id":"heading-3","content":"Heading 3"}]}
{
"contents": [
{
"content": "Some text here",
"heading": "heading-1",
},
{
"content": "Some text here",
"heading": "heading-2",
},
],
"headings": [
{
"content": "Heading 1",
"id": "heading-1",
},
{
"content": "Heading 2",
"id": "heading-2",
},
{
"content": "Heading 3",
"id": "heading-3",
},
],
}
+2 -2
View File
@@ -13,7 +13,7 @@ test('Remark Heading', async () => {
const result = await remark().use(remarkHeading).process(content);
expect(JSON.stringify(result.data.toc)).toMatchFileSnapshot(
expect(result.data.toc).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-heading.output.json'),
);
});
@@ -24,7 +24,7 @@ test('Remark Structure', async () => {
);
const result = await remark().use(remarkStructure).process(content);
expect(JSON.stringify(result.data.structuredData)).toMatchFileSnapshot(
expect(result.data.structuredData).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-structure.output.json'),
);
});
+28 -27
View File
@@ -8,8 +8,9 @@ import {
type RehypeCodeOptions,
remarkImage,
type RemarkImageOptions,
type RemarkHeadingOptions,
} from 'fumadocs-core/mdx-plugins';
import type { Pluggable, PluggableList } from 'unified';
import type { Pluggable } from 'unified';
import type { Configuration } from 'webpack';
import { MapWebpackPlugin } from './webpack-plugins/map-plugin';
import remarkMdxExport from './mdx-plugins/remark-exports';
@@ -32,11 +33,12 @@ type MDXOptions = Omit<
*/
valueToExport?: string[];
remarkHeadingOptions?: RemarkHeadingOptions;
remarkImageOptions?: RemarkImageOptions | false;
rehypeCodeOptions?: RehypeCodeOptions | false;
};
type ResolvePlugins = PluggableList | ((v: PluggableList) => PluggableList);
type ResolvePlugins = Pluggable[] | ((v: Pluggable[]) => Pluggable[]);
export interface CreateMDXOptions {
cwd?: string;
@@ -68,10 +70,12 @@ export interface CreateMDXOptions {
}
function pluginOption(
def: (v: PluggableList) => PluggableList,
def: (v: Pluggable[]) => (Pluggable | false)[],
options: ResolvePlugins = [],
): PluggableList {
const list = def(Array.isArray(options) ? options : []);
): Pluggable[] {
const list = def(Array.isArray(options) ? options : []).filter(
Boolean,
) as Pluggable[];
if (typeof options === 'function') {
return options(list);
@@ -84,6 +88,7 @@ function getMDXLoaderOptions({
valueToExport = [],
rehypeCodeOptions,
remarkImageOptions,
remarkHeadingOptions,
...mdxOptions
}: MDXOptions): MDXLoaderOptions {
const mdxExports = [
@@ -94,29 +99,25 @@ function getMDXLoaderOptions({
...valueToExport,
];
const remarkPlugins = pluginOption((v) => {
const plugins: Pluggable[] = [remarkGfm, remarkHeading];
const remarkPlugins = pluginOption(
(v) => [
remarkGfm,
[remarkHeading, remarkHeadingOptions],
remarkImageOptions !== false && [remarkImage, remarkImageOptions],
...v,
remarkStructure,
[remarkMdxExport, { values: mdxExports }],
],
mdxOptions.remarkPlugins,
);
if (remarkImageOptions !== false)
plugins.push([remarkImage, remarkImageOptions]);
plugins.push(...v, remarkStructure, [
remarkMdxExport,
{ values: mdxExports },
]);
return plugins;
}, mdxOptions.remarkPlugins);
const rehypePlugins: PluggableList = pluginOption((v) => {
const plugins: Pluggable[] = [...v];
if (rehypeCodeOptions !== false) {
plugins.unshift([rehypeCode, rehypeCodeOptions]);
}
return plugins;
}, mdxOptions.rehypePlugins);
const rehypePlugins = pluginOption(
(v) => [
rehypeCodeOptions !== false && [rehypeCode, rehypeCodeOptions],
...v,
],
mdxOptions.rehypePlugins,
);
return {
providerImportSource: 'next-mdx-import-source-file',