Core: Support remarkSteps plugin

This commit is contained in:
Fuma Nama
2025-04-13 19:54:58 +08:00
parent 82df3d5ca9
commit e1a61bfbbe
11 changed files with 278 additions and 50 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support `remarkSteps` plugin
@@ -13,13 +13,13 @@ Create a new Next.js application with `create-next-app`, and install required pa
fumadocs-ui fumadocs-core
```
### MDX Components
### 1. MDX Components
<include cwd meta='title="mdx-components.tsx"'>
../../examples/next-mdx/mdx-components.tsx
</include>
### Content Source
### 2. Content Source
Fumadocs supports different content sources, you can choose one you prefer.
@@ -30,7 +30,7 @@ There is a list of officially supported sources:
Make sure to configure the library correctly following their setup guide before continuing, we will import the source adapter using `@/lib/source.ts` in this guide.
### Root Layout
### 3. Root Layout
Wrap the entire application inside [Root Provider](/docs/ui/layouts/root-provider), and add required styles to `body`.
@@ -56,7 +56,7 @@ export default function Layout({ children }: { children: ReactNode }) {
}
```
### Styles
### 4. Styles
Setup Tailwind CSS v4 on your Next.js app, add the following to `global.css`.
@@ -71,7 +71,7 @@ Setup Tailwind CSS v4 on your Next.js app, add the following to `global.css`.
> It doesn't come with a default font, you may choose one from `next/font`.
### Layout
### 5. Layout
Create a `app/layout.config.tsx` file to put the shared options for our layouts.
@@ -97,7 +97,7 @@ Create a folder `/app/docs` for our docs, and give it a proper layout.
> `pageTree` refers to Page Tree, it should be provided by your content source.
### Page
### 6. Page
Create a catch-all route `/app/docs/[[...slug]]` for docs pages.
@@ -112,7 +112,7 @@ It may vary depending on your content source. You should configure static render
</Tabs>
### Search
### 7. Search
Use the default document search based on Orama.
@@ -122,7 +122,7 @@ Use the default document search based on Orama.
Learn more about [Document Search](/docs/headless/search).
### Done
### 8. Done
You can start the dev server and create MDX files.
+6 -2
View File
@@ -1,7 +1,7 @@
import {
defineCollections,
defineConfig,
defineDocs,
defineCollections,
frontmatterSchema,
metaSchema,
} from 'fumadocs-mdx/config';
@@ -12,7 +12,10 @@ import { fileGenerator, remarkDocGen, remarkInstall } from 'fumadocs-docgen';
import { remarkTypeScriptToJavaScript } from 'fumadocs-docgen/remark-ts2js';
import rehypeKatex from 'rehype-katex';
import { z } from 'zod';
import { rehypeCodeDefaultOptions } from 'fumadocs-core/mdx-plugins';
import {
rehypeCodeDefaultOptions,
remarkSteps,
} from 'fumadocs-core/mdx-plugins';
import { remarkAutoTypeTable } from 'fumadocs-typescript';
export const docs = defineDocs({
@@ -80,6 +83,7 @@ export default defineConfig({
],
},
remarkPlugins: [
remarkSteps,
remarkMath,
remarkAutoTypeTable,
[remarkInstall, { persist: { id: 'package-manager' } }],
+1
View File
@@ -9,3 +9,4 @@ export * from './remark-heading';
export * from './remark-admonition';
export * from './rehype-toc';
export * from './remark-code-tab';
export * from './remark-steps';
+3 -10
View File
@@ -8,12 +8,12 @@ import {
} from '@shikijs/transformers';
import type { Processor, Transformer } from 'unified';
import {
type ShikiTransformer,
type BuiltinTheme,
bundledLanguages,
type ShikiTransformer,
} from 'shiki';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { IconOptions, CodeBlockIcon } from './transformer-icon';
import type { CodeBlockIcon, IconOptions } from './transformer-icon';
import { transformerIcon } from './transformer-icon';
import {
createStyleTransformer,
@@ -42,10 +42,6 @@ const metaValues: MetaValue[] = [
name: 'tab',
regex: /tab="(?<value>[^"]+)"/,
},
{
name: 'tab',
regex: /tab/,
},
];
export const rehypeCodeDefaultOptions: RehypeCodeOptions = {
@@ -192,10 +188,7 @@ function transformerTab(): ShikiTransformer {
data: {
_codeblock: true,
},
attributes:
value !== ''
? [{ type: 'mdxJsxAttribute', name: 'value', value }]
: [],
attributes: [{ type: 'mdxJsxAttribute', name: 'value', value }],
children: root.children,
} as MdxJsxFlowElement,
],
@@ -67,39 +67,38 @@ function toTab(nodes: Code[]) {
export function remarkCodeTab(): Transformer<Root, Root> {
return (tree) => {
visit(tree, (node) => {
if (!('children' in node)) return;
if (node.type === 'mdxJsxFlowElement' && node.name === 'Tabs') return;
if ('children' in node) {
let start = -1;
let i = 0;
let start = -1;
let i = 0;
while (i < node.children.length) {
const child = node.children[i];
const isSwitcher =
child.type === 'code' && child.meta && child.meta.match(TabRegex);
while (i < node.children.length) {
const child = node.children[i];
const isSwitcher =
child.type === 'code' && child.meta && child.meta.match(TabRegex);
if (isSwitcher && start === -1) {
start = i;
}
// if switcher code blocks terminated, convert them to tabs
const isLast = i === node.children.length - 1;
if (start !== -1 && (isLast || !isSwitcher)) {
const end = isSwitcher ? i + 1 : i;
const targets = node.children.slice(start, end);
node.children.splice(
start,
end - start,
toTab(targets as Code[]) as RootContent,
);
if (isLast) break;
i = start;
start = -1;
}
i++;
if (isSwitcher && start === -1) {
start = i;
}
// if switcher code blocks terminated, convert them to tabs
const isLast = i === node.children.length - 1;
if (start !== -1 && (isLast || !isSwitcher)) {
const end = isSwitcher ? i + 1 : i;
const targets = node.children.slice(start, end);
node.children.splice(
start,
end - start,
toTab(targets as Code[]) as RootContent,
);
if (isLast) break;
i = start;
start = -1;
}
i++;
}
});
};
@@ -63,7 +63,7 @@ export function remarkHeading({
}
}
let flattened: string | undefined;
let flattened: string | null = null;
if (!id) {
flattened ??= flattenNode(heading);
@@ -0,0 +1,128 @@
import type { Transformer } from 'unified';
import type { BlockContent, Heading, Root, RootContent } from 'mdast';
import { visit } from 'unist-util-visit';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
export interface RemarkStepsOptions {
/**
* Class name for steps container
*
* @defaultValue fd-steps
*/
steps?: string;
/**
* Class name for step container
*
* @defaultValue fd-step
*/
step?: string;
}
const StepRegex = /^(\d+)\.\s(.+)$/;
/**
* Convert headings in the format of `1. Hello World` into steps.
*/
export function remarkSteps({
steps = 'fd-steps',
step = 'fd-step',
}: RemarkStepsOptions = {}): Transformer<Root, Root> {
function convertToSteps(nodes: RootContent[]): MdxJsxFlowElement {
const depth = (nodes[0] as Heading).depth;
const children: MdxJsxFlowElement[] = [];
for (const node of nodes) {
if (node.type === 'heading' && node.depth === depth) {
children.push({
type: 'mdxJsxFlowElement',
name: 'div',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'className',
value: step,
},
],
children: [node],
});
} else {
children[children.length - 1].children.push(node as BlockContent);
}
}
return {
type: 'mdxJsxFlowElement',
name: 'div',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'className',
value: steps,
},
],
data: {
_fd_step: true,
} as object,
children,
};
}
return (tree) => {
visit(tree, (parent) => {
if (!('children' in parent) || parent.type === 'heading') return;
if (parent.data && '_fd_step' in parent.data) return 'skip';
let startIdx = -1;
let lastNumber = 0;
let i = 0;
const onEnd = () => {
if (startIdx === -1) return;
// range: start index to i - 1
const item = {};
const nodes = parent.children.splice(
startIdx,
i - startIdx,
item as RootContent,
);
Object.assign(item, convertToSteps(nodes));
i = startIdx + 1;
startIdx = -1;
};
for (; i < parent.children.length; i++) {
const node = parent.children[i];
if (node.type !== 'heading') continue;
if (startIdx !== -1) {
const startDepth = (parent.children[startIdx] as Heading).depth;
if (node.depth > startDepth) continue;
else if (node.depth < startDepth) onEnd();
}
const head = node.children.filter((c) => c.type === 'text').at(0);
if (!head) {
onEnd();
continue;
}
const match = StepRegex.exec(head.value);
if (!match) {
onEnd();
continue;
}
const num = Number(match[1]);
head.value = match[2];
if (startIdx !== -1 && num !== lastNumber + 1) onEnd();
if (startIdx === -1) startIdx = i;
lastNumber = num;
}
onEnd();
});
};
}
+33
View File
@@ -0,0 +1,33 @@
# Hello
## 1. First
content
### Little Tip
content
## 2. Second
content
## 1. New: First
content
## 2. New: Second
content
## Ended
content
# 1. Big: First
content
# 2. Big: Second
content
+51
View File
@@ -0,0 +1,51 @@
# Hello
<div className="fd-steps">
<div className="fd-step">
## First
content
### Little Tip
content
</div>
<div className="fd-step">
## Second
content
</div>
</div>
<div className="fd-steps">
<div className="fd-step">
## New: First
content
</div>
<div className="fd-step">
## New: Second
content
</div>
</div>
## Ended
content
<div className="fd-steps">
<div className="fd-step">
# Big: First
content
</div>
<div className="fd-step">
# Big: Second
content
</div>
</div>
+14
View File
@@ -13,6 +13,8 @@ import { fileURLToPath } from 'node:url';
import remarkMdx from 'remark-mdx';
import remarkGfm from 'remark-gfm';
import { createProcessor } from '@mdx-js/mdx';
import * as fs from 'node:fs/promises';
import { remarkSteps } from '@/mdx-plugins/remark-steps';
const cwd = path.dirname(fileURLToPath(import.meta.url));
@@ -68,6 +70,18 @@ test('Remark Image', async () => {
);
});
test('Remark Steps', async () => {
const content = await fs.readFile(
path.resolve(cwd, './fixtures/remark-steps.md'),
);
const processor = remark().use(remarkSteps).use(remarkMdx);
const result = await processor.process(content);
await expect(result.value).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-steps.output.md'),
);
});
test('Remark Image: With Path', async () => {
const file = path.resolve(cwd, './fixtures/remark-image.md');
const content = readFileSync(file);