MDX: Support Tanstack Router/Start via createClientLoader

This commit is contained in:
Fuma Nama
2025-07-21 20:00:53 +08:00
parent 82ae341d2c
commit 4b205f9f3e
19 changed files with 321 additions and 246 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': patch
---
Support Tanstack Router/Start via `createClientLoader`
+2 -1
View File
@@ -19,4 +19,5 @@ yarn.lock
/playwright/.cache/
.tanstack
src/routeTree.gen.ts
src/routeTree.gen.ts
source.generated.ts
+2
View File
@@ -5,6 +5,8 @@ description: This is another page
Hello World again!
## Installation
```npm
npm i fumadocs-core fumadocs-ui
```
+3 -4
View File
@@ -9,22 +9,21 @@
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@fumadocs/mdx-remote": "workspace:*",
"@tanstack/react-router": "^1.128.8",
"@tanstack/react-router-devtools": "^1.128.8",
"@tanstack/react-start": "^1.128.8",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"gray-matter": "^4.0.3",
"lucide-static": "^0.525.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1",
"vite": "^7.0.5",
"zod": "^4.0.5"
"vite": "^7.0.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@vitejs/plugin-react": "^4.7.0",
"@types/node": "^24.0.15",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
export const docs = defineDocs({
dir: 'content',
});
export default defineConfig();
@@ -0,0 +1,28 @@
import { Link } from '@tanstack/react-router';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
export function NotFound() {
return (
<HomeLayout
nav={{
title: 'Tanstack Start',
}}
className="text-center py-32 justify-center"
>
<div className="flex flex-col items-center gap-4">
<h1 className="text-6xl font-bold text-fd-muted-foreground">404</h1>
<h2 className="text-2xl font-semibold">Page Not Found</h2>
<p className="text-fd-muted-foreground max-w-md">
The page you are looking for might have been removed, had its name
changed, or is temporarily unavailable.
</p>
<Link
to="/"
className="mt-4 px-4 py-2 rounded-lg bg-fd-primary text-fd-primary-foreground font-medium text-sm hover:opacity-90 transition-opacity"
>
Back to Home
</Link>
</div>
</HomeLayout>
);
}
+3 -53
View File
@@ -1,59 +1,9 @@
import {
loader,
type MetaData,
type PageData,
type Source,
type VirtualFile,
} from 'fumadocs-core/source';
import matter from 'gray-matter';
import * as path from 'node:path';
import { loader } from 'fumadocs-core/source';
import * as icons from 'lucide-static';
const files = Object.entries(
import.meta.glob<true, 'raw'>('/content/**/*', {
eager: true,
query: '?raw',
import: 'default',
}),
);
const virtualFiles: VirtualFile[] = files.flatMap(([file, content]) => {
const ext = path.extname(file);
const virtualPath = path.relative('content', path.join(process.cwd(), file));
if (ext === '.mdx' || ext === '.md') {
const parsed = matter(content);
return {
type: 'page',
path: virtualPath,
data: {
...parsed.data,
content: parsed.content,
},
};
}
if (ext === '.json') {
return {
type: 'meta',
path: virtualPath,
data: JSON.parse(content),
};
}
return [];
});
import { create, docs } from '../../source.generated';
export const source = loader({
source: {
files: virtualFiles,
} as Source<{
pageData: PageData & {
content: string;
};
metaData: MetaData;
}>,
source: await create.sourceAsync(docs.doc, docs.meta),
baseUrl: '/docs',
// @ts-expect-error -- string
icon(icon) {
+2
View File
@@ -1,11 +1,13 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
import { NotFound } from '~/components/not-found';
export function createRouter() {
return createTanStackRouter({
routeTree,
defaultPreload: 'intent',
scrollRestoration: true,
defaultNotFoundComponent: NotFound,
});
}
@@ -1,16 +1,8 @@
import { createServerFileRoute } from '@tanstack/react-start/server';
import { createSearchAPI } from 'fumadocs-core/search/server';
import { source } from '~/lib/source';
import { structure } from 'fumadocs-core/mdx-plugins';
import { createFromSource } from 'fumadocs-core/search/server';
const server = createSearchAPI('advanced', {
indexes: source.getPages().map((page) => ({
id: page.url,
url: page.url,
title: page.data.title!,
structuredData: structure(page.data.content),
})),
});
const server = createFromSource(source);
export const ServerRoute = createServerFileRoute('/api/search').methods({
GET: async ({ request }) => server.GET(request),
+63 -58
View File
@@ -1,27 +1,25 @@
import { createFileRoute, notFound } from '@tanstack/react-router';
import { executeMdxSync } from '@fumadocs/mdx-remote/client';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { createServerFn } from '@tanstack/react-start';
import { source } from '~/lib/source';
import type { PageTree } from 'fumadocs-core/server';
import { useMemo } from 'react';
import { docs } from '../../../source.generated';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import { createServerFn } from '@tanstack/react-start';
import { createCompiler } from '@fumadocs/mdx-remote';
import { source } from '~/lib/source';
import path from 'node:path';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { PageTree } from 'fumadocs-core/server';
import { useMemo } from 'react';
const compiler = createCompiler();
import { createClientLoader } from 'fumadocs-mdx/runtime/vite';
export const Route = createFileRoute('/docs/$')({
component: Page,
loader: async ({ params }) => {
const slugs = (params._splat ?? '').split('/');
return loader({ data: slugs });
const data = await loader({ data: params._splat?.split('/') ?? [] });
await clientLoader.preload(data.path);
return data;
},
});
@@ -34,59 +32,19 @@ const loader = createServerFn({
const page = source.getPage(slugs);
if (!page) throw notFound();
const { content, ...rest } = page.data;
const compiled = await compiler.compileFile({
path: path.resolve('content', page.path),
value: content,
});
return {
tree: source.pageTree as object,
...rest,
compiled: compiled.toString(),
path: page.path,
};
});
function Page() {
const data = Route.useLoaderData();
const {
default: MDX,
toc,
tree,
} = useMemo(() => {
const result = executeMdxSync(data.compiled);
function traverse(folder: PageTree.Folder | PageTree.Root) {
for (const node of folder.children) {
if (node.type === 'page' && typeof node.icon === 'string') {
node.icon = (
<span
dangerouslySetInnerHTML={{
__html: node.icon,
}}
/>
);
}
if (node.type === 'folder') traverse(node);
}
}
const tree = structuredClone(data.tree) as PageTree.Root;
traverse(tree);
return { ...result, tree };
}, [data]);
return (
<DocsLayout
tree={tree}
nav={{
title: 'Fumadocs Tanstack',
}}
>
const clientLoader = createClientLoader(docs.doc, {
id: 'docs',
component({ toc, frontmatter, default: MDX }) {
return (
<DocsPage toc={toc}>
<DocsTitle>{data.title}</DocsTitle>
<DocsDescription>{data.description}</DocsDescription>
<DocsTitle>{frontmatter.title}</DocsTitle>
<DocsDescription>{frontmatter.description}</DocsDescription>
<DocsBody>
<MDX
components={{
@@ -95,6 +53,53 @@ function Page() {
/>
</DocsBody>
</DocsPage>
);
},
});
function Page() {
const data = Route.useLoaderData();
const Content = clientLoader.getComponent(data.path);
const tree = useMemo(
() => transformPageTree(data.tree as PageTree.Folder),
[data.tree],
);
return (
<DocsLayout
tree={tree}
nav={{
title: 'Fumadocs Tanstack',
}}
>
<Content />
</DocsLayout>
);
}
function transformPageTree(tree: PageTree.Folder): PageTree.Folder {
function page(item: PageTree.Item) {
if (typeof item.icon !== 'string') return item;
return {
...item,
icon: (
<span
dangerouslySetInnerHTML={{
__html: item.icon,
}}
/>
),
};
}
return {
...tree,
index: tree.index ? page(tree.index) : undefined,
children: tree.children.map((item) => {
if (item.type === 'page') return page(item);
if (item.type === 'folder') return transformPageTree(item);
return item;
}),
};
}
+7 -1
View File
@@ -1,17 +1,23 @@
import react from '@vitejs/plugin-react';
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import { defineConfig } from 'vite';
import tsConfigPaths from 'vite-tsconfig-paths';
import tailwindcss from '@tailwindcss/vite';
import mdx from 'fumadocs-mdx/vite';
export default defineConfig({
server: {
port: 3000,
},
plugins: [
mdx(await import('./source.config')),
tailwindcss(),
tsConfigPaths({
projects: ['./tsconfig.json'],
}),
tanstackStart(),
tanstackStart({
customViteReactPlugin: true,
}),
react(),
],
});
-2
View File
@@ -119,13 +119,11 @@ export function defineDocs<
docs: defineCollections({
type: 'doc',
dir,
files: ['**/*.{md,mdx}'],
schema: frontmatterSchema as any,
...options?.docs,
}),
meta: defineCollections({
type: 'meta',
files: ['**/*.{json,yaml}'],
dir,
schema: metaSchema as any,
...options?.meta,
+4 -3
View File
@@ -1,7 +1,6 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { glob } from 'tinyglobby';
import { getTypeFromPath } from '@/utils/get-type-from-path';
import type { LoadedConfig } from '@/utils/config';
import type { DocCollection, MetaCollection } from '@/config';
import { validate } from '@/utils/schema';
@@ -15,6 +14,7 @@ import {
type ImportPathConfig,
toImportPath,
} from '@/utils/import-formatter';
import { getGlobPatterns, isFileSupported } from '@/utils/collections';
async function readFileWithCache(file: string): Promise<string> {
const cached = fileCache.read<string>('read-file', file);
@@ -186,16 +186,17 @@ async function getCollectionFiles(
const dirs = Array.isArray(collection.dir)
? collection.dir
: [collection.dir];
const patterns = getGlobPatterns(collection);
await Promise.all(
dirs.map(async (dir) => {
const result = await glob(collection.files ?? '**/*', {
const result = await glob(patterns, {
cwd: path.resolve(dir),
absolute: true,
});
for (const item of result) {
if (getTypeFromPath(item) !== collection.type) continue;
if (!isFileSupported(item, collection)) continue;
files.set(item, {
path: path.relative(dir, item),
+82 -13
View File
@@ -1,5 +1,5 @@
import type { TableOfContents } from 'fumadocs-core/server';
import { type FC, lazy, type LazyExoticComponent, type ReactNode } from 'react';
import { createElement, type FC, lazy, type ReactNode } from 'react';
import type { MDXProps } from 'mdx/types';
import type { StructuredData } from 'fumadocs-core/mdx-plugins';
import type { DocCollection, DocsCollection, MetaCollection } from '@/config';
@@ -175,24 +175,93 @@ export function fromConfig<Config>(): {
};
}
type ClientLoader<Props> = Record<
string,
LazyExoticComponent<(props: Props) => ReactNode>
>;
export interface ClientLoaderOptions<Frontmatter, Props> {
/**
* Loader ID (usually your collection name)
*
* The code splitting strategy of frameworks like Tanstack Start may duplicate `createClientLoader()` into different chunks.
*
* We use loader ID to share cache between multiple instances of client loader.
*
* @defaultValue ''
*/
id?: string;
export function toClientRenderer<Frontmatter, Props extends object = object>(
component: (loaded: CompiledMDXFile<Frontmatter>, props: Props) => ReactNode;
}
export interface ClientLoader<Frontmatter, Props> {
preload: (path: string) => Promise<CompiledMDXFile<Frontmatter>>;
/**
* Get a component that renders content with `React.lazy`
*/
getComponent: (path: string) => FC<Props>;
}
const loaderStore = new Map<
string,
{
preloaded: Map<string, CompiledMDXFile<any>>;
}
>();
export function createClientLoader<Frontmatter, Props = object>(
files: Record<string, () => Promise<CompiledMDXFile<Frontmatter>>>,
renderer: (loaded: CompiledMDXFile<Frontmatter>, props: Props) => ReactNode,
): ClientLoader<Props> {
const loader: ClientLoader<Props> = {};
options: ClientLoaderOptions<Frontmatter, Props>,
): ClientLoader<Frontmatter, Props> {
const { id = '', component } = options;
const store = loaderStore.get(id) ?? {
preloaded: new Map(),
};
loaderStore.set(id, store);
let renderer;
return {
async preload(path) {
const loaded = await files[path]();
store.preloaded.set(path, loaded);
return loaded;
},
getComponent(path) {
renderer ??= toClientRenderer(files, component, {
cache: store.preloaded,
});
return renderer[path];
},
};
}
export interface ClientRendererOptions<Frontmatter> {
cache?: Map<string, CompiledMDXFile<Frontmatter>>;
}
type ClientRenderer<Props> = Record<string, FC<Props>>;
export function toClientRenderer<Frontmatter, Props = object>(
files: Record<string, () => Promise<CompiledMDXFile<Frontmatter>>>,
component: (loaded: CompiledMDXFile<Frontmatter>, props: Props) => ReactNode,
options: ClientRendererOptions<Frontmatter> = {},
): ClientRenderer<Props> {
const { cache } = options;
const renderer: ClientRenderer<Props> = {};
for (const k in files) {
loader[k] = lazy(async () => {
const OnDemand = lazy(async () => {
const loaded = await files[k]();
return { default: (props) => renderer(loaded, props) };
return { default: (props) => component(loaded, props) };
});
if (cache) {
renderer[k] = (props: Props) => {
const cached = cache.get(k);
if (!cached) return createElement(OnDemand, props);
return component(cached, props);
};
} else {
renderer[k] = OnDemand;
}
}
return loader;
return renderer;
}
+28
View File
@@ -0,0 +1,28 @@
import type { DocCollection, MetaCollection } from '@/config/define';
export function getSupportedFormats(
collection: DocCollection | MetaCollection,
) {
return {
doc: ['mdx', 'md'],
meta: ['json', 'yaml'],
}[collection.type];
}
export function getGlobPatterns(
collection: MetaCollection | DocCollection,
): string[] {
if (collection.files) return collection.files;
return [`**/*.{${getSupportedFormats(collection).join(',')}}`];
}
export function isFileSupported(
filePath: string,
collection: MetaCollection | DocCollection,
) {
for (const format of getSupportedFormats(collection)) {
if (filePath.endsWith(`.${format}`)) return true;
}
return false;
}
@@ -1,11 +0,0 @@
import { extname } from 'node:path';
const docTypes = ['.mdx', '.md'];
const metaTypes = ['.json', '.yaml'];
export function getTypeFromPath(path: string): 'doc' | 'meta' | undefined {
const ext = extname(path);
if (docTypes.includes(ext)) return 'doc';
if (metaTypes.includes(ext)) return 'meta';
}
@@ -54,3 +54,10 @@ export function toImportPath(file: string, config: ImportPathConfig): string {
return importPath.replaceAll(path.sep, '/');
}
export function ident(code: string, tab: number = 1) {
return code
.split('\n')
.map((v) => ' '.repeat(tab) + v)
.join('\n');
}
+67 -78
View File
@@ -6,11 +6,17 @@ import { countLines } from '@/utils/count-lines';
import { fumaMatter } from '@/utils/fuma-matter';
import { validate, ValidationError } from '@/utils/schema';
import { z } from 'zod';
import { toImportPath } from '@/utils/import-formatter';
import type { DocCollection, DocsCollection, MetaCollection } from '@/config';
import { ident, toImportPath } from '@/utils/import-formatter';
import type {
AnyCollection,
DocCollection,
DocsCollection,
MetaCollection,
} from '@/config';
import fs from 'node:fs/promises';
import path from 'node:path';
import { load } from 'js-yaml';
import { getGlobPatterns } from '@/utils/collections';
const fileRegex = /\.(md|mdx)$/;
const onlySchema = z.literal(['frontmatter', 'all']);
@@ -58,94 +64,31 @@ export default function mdx(
`export const create = fromConfig<typeof Config>();`,
];
function filesToGlob(files: string[]) {
return files.map((file) => {
if (file.startsWith('./')) return file;
if (file.startsWith('/')) return `.${file}`;
function docs(name: string, collection: DocsCollection) {
const args = [
ident(`doc: ${generateGlob(name, collection.docs)}`),
ident(`meta: ${generateGlob(name, collection.meta)}`),
].join(',\n');
return `./${file}`;
});
return `export const ${name} = create.docs("${name}", {\n${args}\n});`;
}
function docs(name: string, dir: string, collection: DocsCollection) {
const docFiles = collection.docs.files
? filesToGlob(collection.docs.files)
: ['./**/*.{mdx,md}'];
const metaFiles = collection.meta.files
? filesToGlob(collection.meta.files)
: ['./**/*.{yaml,json}'];
return `export const ${name} = create.docs('${name}', {
doc: import.meta.glob(${JSON.stringify(docFiles)}, {
query: {
collection: '${name}',
},
base: '${dir}',
}),
meta: import.meta.glob(${JSON.stringify(metaFiles)}, {
query: {
collection: '${name}',
},
base: '${dir}',
import: 'default',
}),
});`;
function doc(name: string, collection: DocCollection) {
return `export const ${name} = create.doc("${name}", ${generateGlob(name, collection)});`;
}
function doc(name: string, dir: string, collection: DocCollection) {
const files = collection.files
? filesToGlob(collection.files)
: ['./**/*.{mdx,md}'];
return `export const ${name} = create.doc(
'${name}',
import.meta.glob(${JSON.stringify(files)}, {
query: {
collection: '${name}',
},
base: '${dir}',
}),
);`;
}
function meta(name: string, dir: string, collection: MetaCollection) {
const files = collection.files
? filesToGlob(collection.files)
: ['./**/*.{yaml,json}'];
return `export const ${name} = create.meta(
'${name}',
import.meta.glob(${JSON.stringify(files)}, {
query: {
collection: '${name}',
},
base: '${dir}',
import: 'default',
}),
);`;
function meta(name: string, collection: MetaCollection) {
return `export const ${name} = create.meta("${name}", ${generateGlob(name, collection)});`;
}
for (const [name, collection] of loaded.collections.entries()) {
let dir = collection.dir;
if (Array.isArray(dir) && dir.length === 1) {
dir = dir[0];
} else if (Array.isArray(dir)) {
throw new Error(
`[Fumadocs MDX] Vite Plugin doesn't support multiple \`dir\` for a collection at the moment.`,
);
}
if (!dir.startsWith('./') && !dir.startsWith('/')) {
dir = '/' + dir;
}
lines.push('');
if (collection.type === 'docs') {
lines.push(docs(name, dir, collection));
lines.push(docs(name, collection));
} else if (collection.type === 'meta') {
lines.push(meta(name, dir, collection));
lines.push(meta(name, collection));
} else {
lines.push(doc(name, dir, collection));
lines.push(doc(name, collection));
}
}
@@ -280,3 +223,49 @@ export default function mdx(
},
};
}
function generateGlob(
name: string,
collection: MetaCollection | DocCollection,
) {
const patterns = mapGlobPatterns(getGlobPatterns(collection));
const options: Record<string, unknown> = {
query: {
collection: name,
},
base: getGlobBase(collection),
};
if (collection.type === 'meta') {
options.import = 'default';
}
return `import.meta.glob(${JSON.stringify(patterns)}, ${JSON.stringify(options, null, 2)})`;
}
function mapGlobPatterns(patterns: string[]) {
return patterns.map((file) => {
if (file.startsWith('./')) return file;
if (file.startsWith('/')) return `.${file}`;
return `./${file}`;
});
}
function getGlobBase(collection: AnyCollection) {
let dir = collection.dir;
if (Array.isArray(dir)) {
if (dir.length !== 1)
throw new Error(
`[Fumadocs MDX] Vite Plugin doesn't support multiple \`dir\` for a collection at the moment.`,
);
dir = dir[0];
}
if (!dir.startsWith('./') && !dir.startsWith('/')) {
return '/' + dir;
}
return dir;
}
+9 -12
View File
@@ -279,7 +279,7 @@ importers:
version: 0.2.2(@content-collections/[email protected]([email protected]))([email protected])([email protected]([email protected]))([email protected])
'@content-collections/next':
specifier: ^0.2.6
version: 0.2.6(@content-collections/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))
version: 0.2.6(@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))
'@types/mdx':
specifier: ^2.0.13
version: 2.0.13
@@ -638,9 +638,6 @@ importers:
examples/tanstack-start:
dependencies:
'@fumadocs/mdx-remote':
specifier: workspace:*
version: link:../../packages/mdx-remote
'@tanstack/react-router':
specifier: ^1.128.8
version: 1.128.8([email protected]([email protected]))([email protected])
@@ -653,12 +650,12 @@ importers:
fumadocs-core:
specifier: workspace:*
version: link:../../packages/core
fumadocs-mdx:
specifier: workspace:*
version: link:../../packages/mdx
fumadocs-ui:
specifier: workspace:*
version: link:../../packages/ui
gray-matter:
specifier: ^4.0.3
version: 4.0.3
lucide-static:
specifier: ^0.525.0
version: 0.525.0
@@ -674,9 +671,6 @@ importers:
vite:
specifier: ^7.0.5
version: 7.0.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
zod:
specifier: ^4.0.5
version: 4.0.5
devDependencies:
'@tailwindcss/vite':
specifier: ^4.1.11
@@ -690,6 +684,9 @@ importers:
'@types/react-dom':
specifier: ^19.0.3
version: 19.1.6(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^4.7.0
version: 4.7.0([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
tailwindcss:
specifier: ^4.1.11
version: 4.1.11
@@ -926,7 +923,7 @@ importers:
version: 0.2.2(@content-collections/[email protected]([email protected]))([email protected])([email protected]([email protected]))([email protected])
'@content-collections/next':
specifier: ^0.2.6
version: 0.2.6(@content-collections/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))
version: 0.2.6(@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))
'@react-router/dev':
specifier: ^7.7.0
version: 7.7.0(@react-router/[email protected]([email protected]([email protected]([email protected]))([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected])([email protected])([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))([email protected])
@@ -10573,7 +10570,7 @@ snapshots:
- acorn
- supports-color
'@content-collections/[email protected](@content-collections/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))':
'@content-collections/[email protected](@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))':
dependencies:
'@content-collections/core': 0.10.0([email protected])
'@content-collections/integrations': 0.2.1(@content-collections/[email protected]([email protected]))