Skip to content

Commit e1a61bf

Browse files
committed
Core: Support remarkSteps plugin
1 parent 82df3d5 commit e1a61bf

File tree

11 files changed

+275
-47
lines changed

11 files changed

+275
-47
lines changed

.changeset/tricky-news-hunt.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'fumadocs-core': patch
3+
---
4+
5+
Support `remarkSteps` plugin

apps/docs/content/docs/ui/manual-installation.mdx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ Create a new Next.js application with `create-next-app`, and install required pa
1313
fumadocs-ui fumadocs-core
1414
```
1515

16-
### MDX Components
16+
### 1. MDX Components
1717

1818
<include cwd meta='title="mdx-components.tsx"'>
1919
../../examples/next-mdx/mdx-components.tsx
2020
</include>
2121

22-
### Content Source
22+
### 2. Content Source
2323

2424
Fumadocs supports different content sources, you can choose one you prefer.
2525

@@ -30,7 +30,7 @@ There is a list of officially supported sources:
3030

3131
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.
3232

33-
### Root Layout
33+
### 3. Root Layout
3434

3535
Wrap the entire application inside [Root Provider](/docs/ui/layouts/root-provider), and add required styles to `body`.
3636

@@ -56,7 +56,7 @@ export default function Layout({ children }: { children: ReactNode }) {
5656
}
5757
```
5858

59-
### Styles
59+
### 4. Styles
6060

6161
Setup Tailwind CSS v4 on your Next.js app, add the following to `global.css`.
6262

@@ -71,7 +71,7 @@ Setup Tailwind CSS v4 on your Next.js app, add the following to `global.css`.
7171

7272
> It doesn't come with a default font, you may choose one from `next/font`.
7373
74-
### Layout
74+
### 5. Layout
7575

7676
Create a `app/layout.config.tsx` file to put the shared options for our layouts.
7777

@@ -97,7 +97,7 @@ Create a folder `/app/docs` for our docs, and give it a proper layout.
9797

9898
> `pageTree` refers to Page Tree, it should be provided by your content source.
9999
100-
### Page
100+
### 6. Page
101101

102102
Create a catch-all route `/app/docs/[[...slug]]` for docs pages.
103103

@@ -112,7 +112,7 @@ It may vary depending on your content source. You should configure static render
112112

113113
</Tabs>
114114

115-
### Search
115+
### 7. Search
116116

117117
Use the default document search based on Orama.
118118

@@ -122,7 +122,7 @@ Use the default document search based on Orama.
122122

123123
Learn more about [Document Search](/docs/headless/search).
124124

125-
### Done
125+
### 8. Done
126126

127127
You can start the dev server and create MDX files.
128128

apps/docs/source.config.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
2+
defineCollections,
23
defineConfig,
34
defineDocs,
4-
defineCollections,
55
frontmatterSchema,
66
metaSchema,
77
} from 'fumadocs-mdx/config';
@@ -12,7 +12,10 @@ import { fileGenerator, remarkDocGen, remarkInstall } from 'fumadocs-docgen';
1212
import { remarkTypeScriptToJavaScript } from 'fumadocs-docgen/remark-ts2js';
1313
import rehypeKatex from 'rehype-katex';
1414
import { z } from 'zod';
15-
import { rehypeCodeDefaultOptions } from 'fumadocs-core/mdx-plugins';
15+
import {
16+
rehypeCodeDefaultOptions,
17+
remarkSteps,
18+
} from 'fumadocs-core/mdx-plugins';
1619
import { remarkAutoTypeTable } from 'fumadocs-typescript';
1720

1821
export const docs = defineDocs({
@@ -80,6 +83,7 @@ export default defineConfig({
8083
],
8184
},
8285
remarkPlugins: [
86+
remarkSteps,
8387
remarkMath,
8488
remarkAutoTypeTable,
8589
[remarkInstall, { persist: { id: 'package-manager' } }],

packages/core/src/mdx-plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from './remark-heading';
99
export * from './remark-admonition';
1010
export * from './rehype-toc';
1111
export * from './remark-code-tab';
12+
export * from './remark-steps';

packages/core/src/mdx-plugins/rehype-code.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import {
88
} from '@shikijs/transformers';
99
import type { Processor, Transformer } from 'unified';
1010
import {
11-
type ShikiTransformer,
1211
type BuiltinTheme,
1312
bundledLanguages,
13+
type ShikiTransformer,
1414
} from 'shiki';
1515
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
16-
import type { IconOptions, CodeBlockIcon } from './transformer-icon';
16+
import type { CodeBlockIcon, IconOptions } from './transformer-icon';
1717
import { transformerIcon } from './transformer-icon';
1818
import {
1919
createStyleTransformer,
@@ -42,10 +42,6 @@ const metaValues: MetaValue[] = [
4242
name: 'tab',
4343
regex: /tab="(?<value>[^"]+)"/,
4444
},
45-
{
46-
name: 'tab',
47-
regex: /tab/,
48-
},
4945
];
5046

5147
export const rehypeCodeDefaultOptions: RehypeCodeOptions = {
@@ -192,10 +188,7 @@ function transformerTab(): ShikiTransformer {
192188
data: {
193189
_codeblock: true,
194190
},
195-
attributes:
196-
value !== ''
197-
? [{ type: 'mdxJsxAttribute', name: 'value', value }]
198-
: [],
191+
attributes: [{ type: 'mdxJsxAttribute', name: 'value', value }],
199192
children: root.children,
200193
} as MdxJsxFlowElement,
201194
],

packages/core/src/mdx-plugins/remark-code-tab.ts

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -67,39 +67,38 @@ function toTab(nodes: Code[]) {
6767
export function remarkCodeTab(): Transformer<Root, Root> {
6868
return (tree) => {
6969
visit(tree, (node) => {
70+
if (!('children' in node)) return;
7071
if (node.type === 'mdxJsxFlowElement' && node.name === 'Tabs') return;
71-
if ('children' in node) {
72-
let start = -1;
73-
let i = 0;
72+
let start = -1;
73+
let i = 0;
7474

75-
while (i < node.children.length) {
76-
const child = node.children[i];
77-
const isSwitcher =
78-
child.type === 'code' && child.meta && child.meta.match(TabRegex);
75+
while (i < node.children.length) {
76+
const child = node.children[i];
77+
const isSwitcher =
78+
child.type === 'code' && child.meta && child.meta.match(TabRegex);
7979

80-
if (isSwitcher && start === -1) {
81-
start = i;
82-
}
83-
84-
// if switcher code blocks terminated, convert them to tabs
85-
const isLast = i === node.children.length - 1;
86-
if (start !== -1 && (isLast || !isSwitcher)) {
87-
const end = isSwitcher ? i + 1 : i;
88-
const targets = node.children.slice(start, end);
80+
if (isSwitcher && start === -1) {
81+
start = i;
82+
}
8983

90-
node.children.splice(
91-
start,
92-
end - start,
93-
toTab(targets as Code[]) as RootContent,
94-
);
84+
// if switcher code blocks terminated, convert them to tabs
85+
const isLast = i === node.children.length - 1;
86+
if (start !== -1 && (isLast || !isSwitcher)) {
87+
const end = isSwitcher ? i + 1 : i;
88+
const targets = node.children.slice(start, end);
9589

96-
if (isLast) break;
97-
i = start;
98-
start = -1;
99-
}
90+
node.children.splice(
91+
start,
92+
end - start,
93+
toTab(targets as Code[]) as RootContent,
94+
);
10095

101-
i++;
96+
if (isLast) break;
97+
i = start;
98+
start = -1;
10299
}
100+
101+
i++;
103102
}
104103
});
105104
};

packages/core/src/mdx-plugins/remark-heading.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export function remarkHeading({
6363
}
6464
}
6565

66-
let flattened: string | undefined;
66+
let flattened: string | null = null;
6767
if (!id) {
6868
flattened ??= flattenNode(heading);
6969

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { Transformer } from 'unified';
2+
import type { BlockContent, Heading, Root, RootContent } from 'mdast';
3+
import { visit } from 'unist-util-visit';
4+
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
5+
6+
export interface RemarkStepsOptions {
7+
/**
8+
* Class name for steps container
9+
*
10+
* @defaultValue fd-steps
11+
*/
12+
steps?: string;
13+
14+
/**
15+
* Class name for step container
16+
*
17+
* @defaultValue fd-step
18+
*/
19+
step?: string;
20+
}
21+
22+
const StepRegex = /^(\d+)\.\s(.+)$/;
23+
24+
/**
25+
* Convert headings in the format of `1. Hello World` into steps.
26+
*/
27+
export function remarkSteps({
28+
steps = 'fd-steps',
29+
step = 'fd-step',
30+
}: RemarkStepsOptions = {}): Transformer<Root, Root> {
31+
function convertToSteps(nodes: RootContent[]): MdxJsxFlowElement {
32+
const depth = (nodes[0] as Heading).depth;
33+
const children: MdxJsxFlowElement[] = [];
34+
35+
for (const node of nodes) {
36+
if (node.type === 'heading' && node.depth === depth) {
37+
children.push({
38+
type: 'mdxJsxFlowElement',
39+
name: 'div',
40+
attributes: [
41+
{
42+
type: 'mdxJsxAttribute',
43+
name: 'className',
44+
value: step,
45+
},
46+
],
47+
children: [node],
48+
});
49+
} else {
50+
children[children.length - 1].children.push(node as BlockContent);
51+
}
52+
}
53+
54+
return {
55+
type: 'mdxJsxFlowElement',
56+
name: 'div',
57+
attributes: [
58+
{
59+
type: 'mdxJsxAttribute',
60+
name: 'className',
61+
value: steps,
62+
},
63+
],
64+
data: {
65+
_fd_step: true,
66+
} as object,
67+
children,
68+
};
69+
}
70+
71+
return (tree) => {
72+
visit(tree, (parent) => {
73+
if (!('children' in parent) || parent.type === 'heading') return;
74+
if (parent.data && '_fd_step' in parent.data) return 'skip';
75+
76+
let startIdx = -1;
77+
let lastNumber = 0;
78+
let i = 0;
79+
80+
const onEnd = () => {
81+
if (startIdx === -1) return;
82+
// range: start index to i - 1
83+
const item = {};
84+
const nodes = parent.children.splice(
85+
startIdx,
86+
i - startIdx,
87+
item as RootContent,
88+
);
89+
Object.assign(item, convertToSteps(nodes));
90+
i = startIdx + 1;
91+
startIdx = -1;
92+
};
93+
94+
for (; i < parent.children.length; i++) {
95+
const node = parent.children[i];
96+
97+
if (node.type !== 'heading') continue;
98+
if (startIdx !== -1) {
99+
const startDepth = (parent.children[startIdx] as Heading).depth;
100+
101+
if (node.depth > startDepth) continue;
102+
else if (node.depth < startDepth) onEnd();
103+
}
104+
105+
const head = node.children.filter((c) => c.type === 'text').at(0);
106+
if (!head) {
107+
onEnd();
108+
continue;
109+
}
110+
111+
const match = StepRegex.exec(head.value);
112+
if (!match) {
113+
onEnd();
114+
continue;
115+
}
116+
117+
const num = Number(match[1]);
118+
head.value = match[2];
119+
120+
if (startIdx !== -1 && num !== lastNumber + 1) onEnd();
121+
if (startIdx === -1) startIdx = i;
122+
lastNumber = num;
123+
}
124+
125+
onEnd();
126+
});
127+
};
128+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Hello
2+
3+
## 1. First
4+
5+
content
6+
7+
### Little Tip
8+
9+
content
10+
11+
## 2. Second
12+
13+
content
14+
15+
## 1. New: First
16+
17+
content
18+
19+
## 2. New: Second
20+
21+
content
22+
23+
## Ended
24+
25+
content
26+
27+
# 1. Big: First
28+
29+
content
30+
31+
# 2. Big: Second
32+
33+
content

0 commit comments

Comments
 (0)