Core: Support <auto-files /> syntax in remark-mdx-files plugin

This commit is contained in:
Fuma Nama
2025-12-31 19:02:57 +08:00
parent 7e58c8eee5
commit 446631d6e3
11 changed files with 224 additions and 48 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support `<auto-files />` syntax in `remark-mdx-files` plugin
+1 -1
View File
@@ -1,5 +1,5 @@
---
"fumadocs-openapi": patch
'fumadocs-openapi': patch
---
Fix Parameter Serialization
+1 -1
View File
@@ -1,5 +1,5 @@
---
"fumadocs-obsidian": patch
'fumadocs-obsidian': patch
---
fix slugification
+20 -8
View File
@@ -28,12 +28,20 @@ import { File, Folder, Files } from 'fumadocs-ui/components/files';
</Files>
```
### As CodeBlock
### File
You can enable [`remark-mdx-files`](/docs/headless/mdx/remark-mdx-files) to define file structure with codeblocks.
<auto-type-table cwd path="content/docs/ui/props.ts" name="FileProps" />
### Folder
<auto-type-table cwd path="content/docs/ui/props.ts" name="FolderProps" />
## Remark Plugin
You can enable [`remark-mdx-files`](/docs/headless/mdx/remark-mdx-files) for additional feature & syntax.
```tsx title="source.config.ts (Fumadocs MDX)"
import { remarkMdxFiles } from 'fumadocs-core/mdx-plugins';
import { remarkMdxFiles } from 'fumadocs-core/mdx-plugins/remark-mdx-files';
import { defineConfig } from 'fumadocs-mdx/config';
export default defineConfig({
@@ -44,7 +52,9 @@ export default defineConfig({
});
```
it will convert `files` codeblocks into MDX usage, like:
### CodeBlock Syntax
It will convert `files` codeblocks into `<Files />` component, like:
````md title="content.md"
```files
@@ -57,10 +67,12 @@ project
```
````
### File
### `<auto-files>`
<auto-type-table cwd path="content/docs/ui/props.ts" name="FileProps" />
Generate `<Files />` component from glob.
### Folder
```mdx title="content.mdx"
<auto-files dir="./my-dir" pattern="**/*.{ts,tsx}" />
<auto-type-table cwd path="content/docs/ui/props.ts" name="FolderProps" />
<auto-files dir="./my-dir" pattern="**/*.{ts,tsx}" defaultOpenAll />
```
+1
View File
@@ -136,6 +136,7 @@
"remark-rehype": "^11.1.2",
"scroll-into-view-if-needed": "^3.1.0",
"shiki": "^3.20.0",
"tinyglobby": "^0.2.15",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
+1 -1
View File
@@ -10,5 +10,5 @@ export * from './remark-code-tab';
export * from './remark-steps';
export * from './remark-npm';
export * from './codeblock-utils';
export * from './remark-mdx-files';
export { remarkMdxFiles, type RemarkMdxFilesOptions } from './remark-mdx-files';
export * from './remark-mdx-mermaid';
+144 -18
View File
@@ -2,20 +2,26 @@ import type { Root } from 'mdast';
import { visit } from 'unist-util-visit';
import type { Transformer } from 'unified';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { VFile } from 'vfile';
import path from 'node:path';
interface FileNode {
export interface FileNode {
depth: number;
type: 'file';
name: string;
}
interface FolderNode {
export interface FolderNode {
depth: number;
type: 'folder';
name: string;
children: Node[];
}
export interface ToMdxOptions {
defaultOpenAll: boolean;
}
type Node = FileNode | FolderNode;
export interface RemarkMdxFilesOptions {
@@ -23,7 +29,12 @@ export interface RemarkMdxFilesOptions {
* @defaultValue files
*/
lang?: string;
toMdx?: (node: Node) => MdxJsxFlowElement;
toMdx?: (node: Node, options: ToMdxOptions) => MdxJsxFlowElement;
}
interface AutoFilesProps extends Partial<ToMdxOptions> {
dir?: string;
patterns?: string[];
}
function parseFileTree(code: string) {
@@ -65,13 +76,13 @@ function parseFileTree(code: string) {
return stack.get(0);
}
function defaultToMDX(node: Node, depth = 0): MdxJsxFlowElement {
function defaultToMDX(node: Node, options: ToMdxOptions, depth = 0): MdxJsxFlowElement {
if (depth === 0) {
return {
type: 'mdxJsxFlowElement',
name: 'Files',
attributes: [],
children: [defaultToMDX(node, depth + 1)],
children: [defaultToMDX(node, options, depth + 1)],
};
}
@@ -88,21 +99,26 @@ function defaultToMDX(node: Node, depth = 0): MdxJsxFlowElement {
};
}
attributes.push({
type: 'mdxJsxAttribute',
name: 'defaultOpen',
value: null,
});
if (options.defaultOpenAll) {
attributes.push({
type: 'mdxJsxAttribute',
name: 'defaultOpen',
value: null,
});
}
return {
type: 'mdxJsxFlowElement',
attributes,
name: 'Folder',
children: node.children.map((item) => defaultToMDX(item, depth + 1)),
children: node.children.map((item) => defaultToMDX(item, options, depth + 1)),
};
}
/**
*
* **Files CodeBlock:**
*
* Convert codeblocks with `files` as lang, like:
*
* ```files
@@ -114,19 +130,129 @@ function defaultToMDX(node: Node, depth = 0): MdxJsxFlowElement {
* ├── package.json
* ```
*
* into MDX `<Files />` component
* into MDX `<Files />` component.
*
* **Auto Files:**
*
* Generates MDX `<Files />` component from file system.
*
* ```mdx
* <auto-files dir="scripts" pattern="my-dir/*" defaultOpenAll />
* ```
*/
export function remarkMdxFiles(options: RemarkMdxFilesOptions = {}): Transformer<Root, Root> {
const { lang = 'files', toMdx = defaultToMDX } = options;
return (tree) => {
visit(tree, 'code', (node) => {
if (node.lang !== lang || !node.value) return;
async function autoFiles(
file: VFile,
node: MdxJsxFlowElement,
{ patterns, dir, defaultOpenAll = false }: AutoFilesProps,
) {
const { glob } = await import('tinyglobby');
if (!patterns) {
file.fail('Missing `pattern` prop in <auto-files>', {
place: node.position,
});
}
const fileTree = parseFileTree(node.value);
if (!fileTree) return;
const baseDir = file.dirname ?? file.cwd;
const cwd = dir ? path.join(baseDir, dir) : baseDir;
const files = await glob(patterns, { cwd });
Object.assign(node, toMdx(buildFileTreeFromGlob(cwd, files), { defaultOpenAll }));
}
Object.assign(node, toMdx(fileTree));
return async (tree, file) => {
const queue: Promise<void>[] = [];
visit(tree, ['code', 'mdxJsxFlowElement'] as const, (node) => {
if (node.type === 'code') {
if (node.lang !== lang || !node.value) return;
const fileTree = parseFileTree(node.value);
if (!fileTree) return;
Object.assign(
node,
toMdx(fileTree, {
defaultOpenAll: true,
}),
);
return 'skip';
}
if (node.type === 'mdxJsxFlowElement') {
if (node.name !== 'auto-files') return;
const parsed: AutoFilesProps = {};
for (const attr of node.attributes) {
if (attr.type !== 'mdxJsxAttribute') continue;
const { name, value } = attr;
switch (name) {
case 'dir':
if (typeof value === 'string') parsed.dir = value;
break;
case 'pattern':
if (typeof value === 'string') {
parsed.patterns ??= [];
parsed.patterns.push(value);
}
break;
case 'defaultOpenAll':
parsed.defaultOpenAll = true;
break;
}
}
queue.push(autoFiles(file, node, parsed));
return 'skip';
}
});
await Promise.all(queue);
};
}
function buildFileTreeFromGlob(dir: string, files: string[]): Node {
const nodeMap = new Map<string, FolderNode>();
const root: FolderNode = {
depth: 0,
type: 'folder',
name: path.basename(dir),
children: [],
};
nodeMap.set('', root);
for (const file of files) {
const parts = path
.normalize(file)
.split(path.sep)
.filter((part) => part.length > 0);
let currentPath = '';
let current = root;
for (let i = 0; i < parts.length; i++) {
const name = parts[i];
const nextPath = path.join(currentPath, name);
if (i === parts.length - 1) {
// Add file node.
const fileNode: FileNode = { depth: i + 1, type: 'file', name };
current.children.push(fileNode);
} else {
// Add or retrieve folder node using the map.
let folder = nodeMap.get(nextPath);
if (!folder) {
folder = { depth: i + 1, type: 'folder', name, children: [] };
nodeMap.set(nextPath, folder);
current.children.push(folder);
}
current = folder;
currentPath = nextPath;
}
}
}
return root;
}
+10
View File
@@ -0,0 +1,10 @@
```files
project
├── src
│ ├── index.js
│ └── utils
│ └── helper.js
├── package.json
```
<auto-files dir="page-trees" pattern="**/*.json" />
+16
View File
@@ -11,3 +11,19 @@
<File name="package.json" />
</Folder>
</Files>
<Files>
<Folder name="page-trees">
<File name="basic.tree.json" />
<File name="i18n-dir.tree.json" />
<File name="i18n-no-prefix.tree.json" />
<File name="i18n.entries.json" />
<File name="i18n.tree.json" />
<File name="nested.tree.json" />
</Folder>
</Files>
+22 -19
View File
@@ -1,5 +1,5 @@
import { expect, test } from 'vitest';
import { readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { remark } from 'remark';
import {
@@ -16,7 +16,6 @@ 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';
import remarkDirective from 'remark-directive';
@@ -24,7 +23,7 @@ const cwd = path.dirname(fileURLToPath(import.meta.url));
test('Remark Heading', async () => {
const file = path.resolve(cwd, './fixtures/remark-heading.md');
const content = readFileSync(file);
const content = await fs.readFile(file);
const result = await remark().use(remarkHeading).process(content);
@@ -33,8 +32,21 @@ test('Remark Heading', async () => {
);
});
test('Remark Mdx Files', async () => {
const file = path.resolve(cwd, './fixtures/remark-mdx-files.mdx');
const content = await fs.readFile(file);
const result = await remark().use(remarkMdx).use(remarkMdxFiles).process({
path: file,
value: content,
});
await expect(String(result.value)).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-mdx-files.output.mdx'),
);
});
test('Remark Structure', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/remark-structure.md'));
const content = await fs.readFile(path.resolve(cwd, './fixtures/remark-structure.md'));
const result = await remark().use(remarkGfm).use(remarkStructure).process(content);
await expect(result.data.structuredData).toMatchFileSnapshot(
@@ -43,7 +55,7 @@ test('Remark Structure', async () => {
});
test('Remark Admonition', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/remark-admonition.md'));
const content = await fs.readFile(path.resolve(cwd, './fixtures/remark-admonition.md'));
const processor = remark().use(remarkMdx).use(remarkDirective).use(remarkDirectiveAdmonition);
let tree = processor.parse(content);
tree = await processor.run(tree);
@@ -65,7 +77,7 @@ test('Remark Steps', async () => {
test('Remark Image: With Path', async () => {
const file = path.resolve(cwd, './fixtures/remark-image.md');
const content = readFileSync(file);
const content = await fs.readFile(file);
const processor = remark()
.use(remarkImage, { publicDir: path.resolve(cwd, './fixtures') })
.use(remarkMdx);
@@ -80,7 +92,7 @@ test('Remark Image: With Path', async () => {
});
test('Remark Image: Without Import', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/remark-image.md'));
const content = await fs.readFile(path.resolve(cwd, './fixtures/remark-image.md'));
const result = await remark()
.use(remarkImage, {
publicDir: path.resolve(cwd, './fixtures'),
@@ -95,7 +107,7 @@ test('Remark Image: Without Import', async () => {
});
test('Remark Image: `publicDir` with URL', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/remark-image-public-dir.md'));
const content = await fs.readFile(path.resolve(cwd, './fixtures/remark-image-public-dir.md'));
const result = await remark()
.use(remarkImage, {
publicDir: 'https://fumadocs.dev',
@@ -109,17 +121,8 @@ test('Remark Image: `publicDir` with URL', async () => {
);
});
test('Remark MDX Files', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/remark-mdx-files.md'));
const result = await remark().use(remarkMdxFiles).use(remarkMdx).process(content);
await expect(result.value).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-mdx-files.output.mdx'),
);
});
test('converts mermaid codeblock to MDX Mermaid component', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/remark-mdx-mermaid.md'));
const content = await fs.readFile(path.resolve(cwd, './fixtures/remark-mdx-mermaid.md'));
const result = await remark().use(remarkMdxMermaid).use(remarkMdx).process(content);
await expect(result.value).toMatchFileSnapshot(
@@ -128,7 +131,7 @@ test('converts mermaid codeblock to MDX Mermaid component', async () => {
});
test('Rehype Toc', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/rehype-toc.md'));
const content = await fs.readFile(path.resolve(cwd, './fixtures/rehype-toc.md'));
const processor = createProcessor({
remarkPlugins: [remarkHeading],
+3
View File
@@ -1571,6 +1571,9 @@ importers:
shiki:
specifier: ^3.20.0
version: 3.20.0
tinyglobby:
specifier: ^0.2.15
version: 0.2.15
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0