CLI: Add React Router example

This commit is contained in:
Fuma Nama
2025-03-29 16:54:11 +08:00
parent e63012ca80
commit 372843d005
41 changed files with 553 additions and 92 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'create-fumadocs-app': patch
---
Add React Router example
+5
View File
@@ -0,0 +1,5 @@
---
'create-fumadocs-app': patch
---
Remove Tailwind CSS option, enable by default
+1 -1
View File
@@ -73,7 +73,7 @@ bun create fumadocs-app
</Tabs>
It will initialize a new fumadocs app. Now you can start hacking!
It will ask you the framework and content source to use, a new fumadocs app should be initialized. Now you can start hacking!
<Callout title='From Existing Codebase?'>
+1 -1
View File
@@ -6,7 +6,7 @@ import {
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import { source } from '~/source';
import { source } from '@/source';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { executeMdxSync } from '@fumadocs/mdx-remote/client';
import type { PageTree } from 'fumadocs-core/server';
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Route } from './+types/search';
import { createSearchAPI } from 'fumadocs-core/search/server';
import { source } from '~/source';
import { source } from '@/source';
import { structure } from 'fumadocs-core/mdx-plugins';
const server = createSearchAPI('advanced', {
+1 -1
View File
@@ -15,7 +15,7 @@
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
"@/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
+12 -1
View File
@@ -17,7 +17,18 @@
"postcss": "^8.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.8.2",
"@react-router/dev": "^7.4.0",
"@tailwindcss/vite": "^4.0.17",
"react-router-devtools": "^1.1.7",
"tailwindcss": "^4.0.17",
"typescript": "^5.8.2"
"vite": "^6.2.3",
"vite-tsconfig-paths": "^5.1.4",
"@react-router/node": "^7.4.0",
"@react-router/serve": "^7.4.0",
"gray-matter": "^4.0.3",
"isbot": "^5.1.17",
"react-router": "^7.4.0",
"shiki": "^3.2.1"
}
}
+129 -74
View File
@@ -7,7 +7,7 @@ import type { PackageManager } from './auto-install';
import { autoInstall } from './auto-install';
import { sourceDir, cwd } from './constants';
export type Template = 'content-collections' | 'fuma-docs-mdx';
export type Template = 'content-collections' | 'fuma-docs-mdx' | 'react-router';
export interface Options {
outputDir: string;
@@ -30,6 +30,9 @@ export async function create(options: Options): Promise<void> {
} = options;
const projectName = path.basename(options.outputDir);
const dest = path.resolve(cwd, options.outputDir);
const isNext =
options.template === 'content-collections' ||
options.template === 'fuma-docs-mdx';
function defaultRename(file: string): string {
file = file.replace('example.gitignore', '.gitignore');
@@ -38,18 +41,28 @@ export async function create(options: Options): Promise<void> {
return file;
}
for (const dir of ['app', 'lib']) {
const relative = path.relative(path.join(dest, dir), file);
if (isNext) {
for (const dir of ['app', 'lib']) {
const relative = path.relative(path.join(dest, dir), file);
if (!relative.startsWith(`..${path.sep}`)) {
return path.join(dest, 'src', dir, relative);
}
}
} else if (options.template === 'react-router') {
const relative = path.relative(path.join(dest, 'app'), file);
if (!relative.startsWith(`..${path.sep}`)) {
return path.join(dest, 'src', dir, relative);
return path.join(dest, 'src', relative);
}
}
return file;
}
await copy(path.join(sourceDir, `template/+shared`), dest, defaultRename);
if (isNext) {
await copy(path.join(sourceDir, `template/+next`), dest, defaultRename);
}
await copy(
path.join(sourceDir, `template/${options.template}`),
@@ -57,20 +70,34 @@ export async function create(options: Options): Promise<void> {
defaultRename,
);
if (options.tailwindcss) {
if (isNext && options.tailwindcss) {
await copy(
path.join(sourceDir, `template/+tailwindcss`),
path.join(sourceDir, `template/+next+tailwindcss`),
dest,
defaultRename,
);
if (options.useSrcDir) {
const cssPath = path.join(dest, 'src/app/global.css');
await fs.writeFile(
cssPath,
(await fs.readFile(cssPath)).toString().replace('../', '../../'),
);
}
log('Configured Tailwind CSS');
}
if (options.eslint) {
await copy(path.join(sourceDir, `template/+eslint`), dest, defaultRename);
if (isNext && options.eslint) {
await copy(
path.join(sourceDir, `template/+next+eslint`),
dest,
defaultRename,
);
log('Configured ESLint');
}
// update tsconfig.json for src dir
if (options.useSrcDir) {
const tsconfigPath = path.join(dest, 'tsconfig.json');
const content = (await fs.readFile(tsconfigPath)).toString();
@@ -84,19 +111,13 @@ export async function create(options: Options): Promise<void> {
}
await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2));
if (options.tailwindcss) {
const cssPath = path.join(dest, 'src/app/global.css');
await fs.writeFile(
cssPath,
(await fs.readFile(cssPath)).toString().replace('../', '../../'),
);
}
}
const packageJson = createPackageJson(projectName, options);
await fs.writeFile(path.join(dest, 'package.json'), packageJson);
await fs.writeFile(
path.join(dest, 'package.json'),
JSON.stringify(packageJson, null, 2),
);
const readMe = await getReadme(dest, projectName);
await fs.writeFile(path.join(dest, 'README.md'), readMe);
@@ -140,8 +161,51 @@ async function copy(
}
}
function createPackageJson(projectName: string, options: Options): string {
const packageJson = {
function createPackageJson(projectName: string, options: Options): object {
if (options.template === 'react-router') {
return {
name: projectName,
private: true,
type: 'module',
scripts: {
build: 'react-router build',
dev: 'react-router dev',
start: 'react-router-serve ./build/server/index.js',
typecheck: 'react-router typegen && tsc',
},
dependencies: {
...pick(localVersions, [
'@fumadocs/mdx-remote',
'fumadocs-core',
'fumadocs-ui',
]),
...pick(versionPkg.dependencies, [
'@react-router/node',
'@react-router/serve',
'gray-matter',
'isbot',
'react',
'react-dom',
'react-router',
'shiki',
]),
},
devDependencies: pick(versionPkg.dependencies, [
'@react-router/dev',
'@tailwindcss/vite',
'@types/node',
'@types/react',
'@types/react-dom',
'react-router-devtools',
'tailwindcss',
'typescript',
'vite',
'vite-tsconfig-paths',
]),
};
}
return {
name: projectName,
version: '0.0.0',
private: true,
@@ -149,63 +213,54 @@ function createPackageJson(projectName: string, options: Options): string {
build: 'next build',
dev: 'next dev',
start: 'next start',
} as Record<string, string>,
...(options.template === 'fuma-docs-mdx'
? {
postinstall: 'fumadocs-mdx',
}
: null),
},
dependencies: {
...pick(versionPkg.dependencies, ['next', 'react', 'react-dom']),
...pick(localVersions, ['fumadocs-ui', 'fumadocs-core']),
} as Record<string, string>,
devDependencies: pick(versionPkg.dependencies, [
'@types/node',
'@types/react',
'@types/react-dom',
'typescript',
]) as Record<string, string>,
...(options.template === 'content-collections'
? {
...pick(versionPkg.dependencies, [
'@content-collections/mdx',
'@content-collections/core',
'@content-collections/next',
]),
...pick(localVersions, ['@fumadocs/content-collections']),
}
: null),
...(options.template === 'fuma-docs-mdx'
? pick(localVersions, ['fumadocs-mdx'])
: null),
},
devDependencies: {
...pick(versionPkg.dependencies, [
'@types/node',
'@types/react',
'@types/react-dom',
'typescript',
]),
...(options.tailwindcss
? pick(versionPkg.dependencies, [
'@tailwindcss/postcss',
'tailwindcss',
'postcss',
])
: null),
...(options.template === 'fuma-docs-mdx'
? pick(versionPkg.dependencies, ['@types/mdx'])
: null),
...(options.eslint
? {
eslint: '^8',
'eslint-config-next': versionPkg.dependencies.next,
}
: null),
},
};
if (options.template === 'content-collections') {
Object.assign(
packageJson.dependencies,
pick(versionPkg.dependencies, [
'@content-collections/mdx',
'@content-collections/core',
'@content-collections/next',
]),
pick(localVersions, ['@fumadocs/content-collections']),
);
}
if (options.template === 'fuma-docs-mdx') {
packageJson.scripts.postinstall = 'fumadocs-mdx';
Object.assign(
packageJson.dependencies,
pick(localVersions, ['fumadocs-mdx']),
);
Object.assign(
packageJson.devDependencies,
pick(versionPkg.dependencies, ['@types/mdx']),
);
}
if (options.tailwindcss) {
Object.assign(
packageJson.devDependencies,
pick(versionPkg.dependencies, [
'@tailwindcss/postcss',
'tailwindcss',
'postcss',
]),
);
}
if (options.eslint) {
Object.assign(packageJson.devDependencies, {
eslint: '^8',
'eslint-config-next': versionPkg.dependencies.next,
});
}
return JSON.stringify(packageJson, undefined, 2);
}
function pick<T extends object, K extends keyof T>(
+22 -13
View File
@@ -32,25 +32,34 @@ async function main(): Promise<void> {
}),
template: () =>
select({
message: 'Choose a content source',
message: 'Choose a template',
initialValue: 'fuma-docs-mdx' as Template,
options: [
{
value: 'fuma-docs-mdx',
label: 'Fumadocs MDX',
label: 'Next.js: Fumadocs MDX',
hint: 'recommended',
},
{ value: 'content-collections', label: 'Content Collections' },
{
value: 'content-collections',
label: 'Next.js: Content Collections',
},
{
value: 'react-router',
label: 'React Router: MDX Remote',
},
],
}),
src: () =>
confirm({ message: 'Use `/src` directory?', initialValue: false }),
tailwindcss: () => confirm({ message: 'Use Tailwind CSS for styling?' }),
eslint: () =>
confirm({
eslint: (v) => {
if (v.results.template === 'react-router') return;
return confirm({
message: 'Add default ESLint configuration?',
initialValue: false,
}),
});
},
installDeps: () =>
confirm({
message: `Do you want to install packages automatically? (detected as ${manager})`,
@@ -83,12 +92,12 @@ async function main(): Promise<void> {
info.start(`Deleting files in ${projectName}`);
await Promise.all(
destDir.map((item) =>
fs.rm(item, {
destDir.map((item) => {
return fs.rm(path.join(dest, item), {
recursive: true,
force: true,
}),
),
});
}),
);
info.stop(`Deleted files in ${projectName}`);
@@ -100,11 +109,11 @@ async function main(): Promise<void> {
await create({
packageManager: manager,
tailwindcss: options.tailwindcss,
tailwindcss: true,
template: options.template,
outputDir: dest,
installDeps: options.installDeps,
eslint: options.eslint,
eslint: options.eslint === true,
useSrcDir: options.src,
log: (message) => {
+1
View File
@@ -2,5 +2,6 @@ export const versions: {
'fumadocs-core': string;
'fumadocs-ui': string;
'fumadocs-mdx': string;
'@fumadocs/mdx-remote': string;
'@fumadocs/content-collections': string;
};
@@ -0,0 +1,12 @@
This is a React Router application generated with
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
Run development server:
```bash
npm run dev
# or
pnpm dev
# or
yarn dev
```
@@ -0,0 +1,6 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
/* path of `fumadocs-ui` relative to the CSS file */
@source '../node_modules/fumadocs-ui/dist/**/*.js';
@@ -0,0 +1,63 @@
import type { Route } from './+types/page';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import { source } from '@/source';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { executeMdxSync } from '@fumadocs/mdx-remote/client';
import type { PageTree } from 'fumadocs-core/server';
import { createCompiler } from '@fumadocs/mdx-remote';
export function meta({}: Route.MetaArgs) {
return [
{ title: 'New React Router App' },
{ name: 'description', content: 'Welcome to React Router!' },
];
}
const compiler = createCompiler({
development: false,
});
export async function loader({ params }: Route.LoaderArgs) {
const slugs = params['*'].split('/').filter((v) => v.length > 0);
const page = source.getPage(slugs);
if (!page) throw new Error('Not found');
const compiled = String(
await compiler.compileFile({
value: page.data.content,
}),
);
return {
page,
compiled,
tree: source.pageTree,
};
}
export default function Page(props: Route.ComponentProps) {
const { page, compiled, tree } = props.loaderData;
const { default: Mdx, toc } = executeMdxSync(compiled);
return (
<DocsLayout
nav={{
title: 'React Router',
}}
tree={tree as PageTree.Root}
>
<DocsPage toc={toc}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<Mdx components={defaultMdxComponents} />
</DocsBody>
</DocsPage>
</DocsLayout>
);
}
@@ -0,0 +1,18 @@
import type { Route } from './+types/search';
import { createSearchAPI } from 'fumadocs-core/search/server';
import { source } from '@/source';
import { structure } from 'fumadocs-core/mdx-plugins';
const server = createSearchAPI('advanced', {
indexes: source.getPages().map((page) => ({
id: page.url,
url: page.url,
title: page.data.title ?? '',
description: page.data.description,
structuredData: structure(page.data.content),
})),
});
export async function loader({ request }: Route.LoaderArgs) {
return server.GET(request);
}
@@ -0,0 +1,78 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from 'react-router';
import { RootProvider } from 'fumadocs-ui/provider/base';
import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
import type { Route } from './+types/root';
import './app.css';
export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<ReactRouterProvider>
<RootProvider>{children}</RootProvider>
</ReactRouterProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details =
error.status === 404
? 'The requested page could not be found.'
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
@@ -0,0 +1,7 @@
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('docs/*', 'docs/page.tsx'),
route('api/search', 'docs/search.ts'),
] satisfies RouteConfig;
@@ -0,0 +1,34 @@
import type { Route } from './+types/home';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { Link } from 'react-router';
export function meta({}: Route.MetaArgs) {
return [
{ title: 'New React Router App' },
{ name: 'description', content: 'Welcome to React Router!' },
];
}
export default function Home() {
return (
<HomeLayout
className="text-center"
nav={{
title: 'React Router',
}}
>
<div className="py-12">
<h1 className="text-xl font-bold mb-2">Fumadocs on React Router.</h1>
<p className="text-fd-muted-foreground mb-8">
The truly flexible docs framework on React.js.
</p>
<Link
className="text-sm bg-fd-primary text-fd-primary-foreground rounded-full font-medium px-4 py-2.5"
to="/docs"
>
Open Docs
</Link>
</div>
</HomeLayout>
);
}
@@ -0,0 +1,60 @@
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';
const files = Object.entries(
import.meta.glob<true, 'raw'>('/content/docs/**/*', {
eager: true,
query: '?raw',
import: 'default',
}),
);
const virtualFiles: VirtualFile[] = files.flatMap(([file, content]) => {
const ext = path.extname(file);
const virtualPath = path.relative(
'content/docs',
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 [];
});
export const source = loader({
source: {
files: virtualFiles,
} as Source<{
pageData: PageData & {
content: string;
};
metaData: MetaData;
}>,
baseUrl: '/docs',
});
@@ -0,0 +1,32 @@
---
title: Hello World
description: |
Your first `document`
You'll love it!
---
Hey there! Fumadocs is the docs framework that also works on React Router!
## Heading
Hello World
<Cards>
<Card title="Learn more about React Router" href="https://reactrouter.com" />
<Card title="Learn more about Fumadocs" href="https://fumadocs.vercel.app" />
</Cards>
```ts
console.log('I love React!');
```
### Heading
#### Heading
| Head | Description |
| ------------------------------- | ----------------------------------- |
| `hello` | Hello World |
| very **important** | Hey |
| _Surprisingly_ | Fumadocs |
| very long text that looks weird | hello world hello world hello world |
@@ -0,0 +1,8 @@
---
title: Fumadocs
description: You can just be minimal.
---
## Overview
Fumadocs is a docs framework.
@@ -0,0 +1,6 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,9 @@
import type { Config } from '@react-router/dev/config';
import { source } from './app/source';
export default {
ssr: true,
async prerender({ getStaticPaths }) {
return [...getStaticPaths(), ...source.getPages().map((page) => page.url)];
},
} satisfies Config;
@@ -0,0 +1,27 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"@/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}
@@ -0,0 +1,13 @@
import { reactRouter } from '@react-router/dev/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
build: {
rollupOptions: {
external: ['shiki'],
},
},
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
});
+2
View File
@@ -3,12 +3,14 @@ import { defineConfig } from 'tsup';
import corePkg from '../core/package.json';
import uiPkg from '../ui/package.json';
import mdxPkg from '../mdx/package.json';
import mdxRemotePkg from '../mdx-remote/package.json';
import contentCollectionsPkg from '../content-collections/package.json';
const versions = {
'fumadocs-core': corePkg.version,
'fumadocs-ui': uiPkg.version,
'fumadocs-mdx': mdxPkg.version,
'@fumadocs/mdx-remote': mdxRemotePkg.version,
'@fumadocs/content-collections': contentCollectionsPkg.version,
};