CLI: Use smarter AST transform for modifying app configs

This commit is contained in:
Fuma Nama
2025-10-25 21:19:21 +08:00
parent e4ff66c861
commit 0ac2f8250a
24 changed files with 294 additions and 301 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'create-fumadocs-app': patch
---
Use smarter AST transform for modifying app configs
+5
View File
@@ -0,0 +1,5 @@
---
'create-fumadocs-app': patch
---
fix type info lost on `create`
@@ -5,9 +5,14 @@ import { createGetUrl, getSlugs } from 'fumadocs-core/source';
const getUrl = createGetUrl('/docs');
export default {
ssr: false,
ssr: true,
async prerender({ getStaticPaths }) {
const paths: string[] = [...getStaticPaths()];
const paths: string[] = [];
const excluded: string[] = [];
for (const path of getStaticPaths()) {
if (!excluded.includes(path)) paths.push(path);
}
for await (const entry of glob('**/*.mdx', { cwd: 'content/docs' })) {
paths.push(getUrl(getSlugs(entry)));
+4 -3
View File
@@ -8,10 +8,11 @@ export default {
ssr: true,
async prerender({ getStaticPaths }) {
const paths: string[] = [];
// ignore dynamic document search
const excluded = ['/api/search'];
for (const path of getStaticPaths()) {
// ignore dynamic document search
if (path === '/api/search') continue;
paths.push(path);
if (!excluded.includes(path)) paths.push(path);
}
for await (const entry of glob('**/*.mdx', { cwd: 'content/docs' })) {
+2 -1
View File
@@ -33,7 +33,8 @@
"@clack/prompts": "^0.11.0",
"commander": "^14.0.1",
"picocolors": "^1.1.1",
"tinyexec": "^1.0.1"
"tinyexec": "^1.0.1",
"ts-morph": "^27.0.2"
},
"devDependencies": {
"@types/cross-spawn": "^6.0.6",
+8 -10
View File
@@ -6,42 +6,40 @@ export const sourceDir = fileURLToPath(new URL(`../`, import.meta.url).href);
export const cwd = process.cwd();
export interface TemplateInfo {
value:
| '+next+fuma-docs-mdx'
| 'waku'
| 'react-router'
| 'react-router-spa'
| 'tanstack-start';
label: string;
value: string;
hint?: string;
componentsDir: string;
}
export const templates = [
export const templates: TemplateInfo[] = [
{
value: '+next+fuma-docs-mdx',
label: 'Next.js: Fumadocs MDX',
hint: 'recommended',
componentsDir: 'components',
},
{
value: 'waku',
label: 'Waku: Fumadocs MDX',
componentsDir: 'src/components',
},
{
value: 'react-router',
label: 'React Router: Fumadocs MDX (not RSC)',
componentsDir: 'app/components',
},
{
value: 'react-router-spa',
label: 'React Router SPA: Fumadocs MDX (not RSC)',
hint: 'SPA mode allows you to host the site statically, compatible with a CDN.',
componentsDir: 'app/components',
},
{
value: 'tanstack-start',
label: 'Tanstack Start: Fumadocs MDX (not RSC)',
componentsDir: 'src/components',
},
] as const satisfies TemplateInfo[];
];
export const depVersions = {
...localVersions,
+1 -1
View File
@@ -11,7 +11,7 @@ import {
templates,
} from './constants';
export type Template = (typeof templates)[number]['value'];
export type Template = TemplateInfo['value'];
export interface Options {
outputDir: string;
template: Template;
+4 -4
View File
@@ -123,6 +123,10 @@ async function main(config: Options): Promise<void> {
message: 'Configure linter?',
initialValue: false,
options: [
{
value: false,
label: 'Disabled',
},
{
value: 'eslint',
label: 'ESLint',
@@ -131,10 +135,6 @@ async function main(config: Options): Promise<void> {
value: 'biome',
label: 'Biome',
},
{
value: false,
label: 'Disabled',
},
],
});
},
@@ -3,6 +3,10 @@ import { copy, pick } from '@/utils';
import path from 'node:path';
import { depVersions, sourceDir } from '@/constants';
import fs from 'node:fs/promises';
import { createSourceFile } from '@/transform/shared';
import { addTanstackPrerender } from '@/transform/tanstack-start';
import { addReactRouterRoute } from '@/transform/react-router';
import { addSearchDialog } from '@/transform/provider';
const oramaCloud: TemplatePlugin = {
packageJson(packageJson) {
@@ -30,6 +34,41 @@ See https://fumadocs.dev/docs/headless/search/orama-cloud for integrating Orama
const appDir = path.join(dest, options.useSrcDir ? 'src' : '.');
await copy(path.join(sourceDir, 'template/+orama-cloud/@root'), dest);
await copy(path.join(sourceDir, 'template/+orama-cloud/@app'), appDir);
let providerFilePath: string;
if (template.value === 'tanstack-start') {
await fluent(
createSourceFile(path.join(dest, 'vite.config.ts')),
(file) => addTanstackPrerender(file, ['/static.json']),
(file) => file.save(),
);
providerFilePath = path.join(dest, 'src/routes/__root.tsx');
} else if (template.value.startsWith('react-router')) {
await fluent(
createSourceFile(path.join(dest, 'app/routes.ts')),
(file) =>
addReactRouterRoute(file, [
{
path: 'static.json',
entry: 'routes/static.ts',
},
]),
(file) => file.save(),
);
providerFilePath = path.join(dest, 'app/root.tsx');
} else if (template.value.startsWith('+next')) {
providerFilePath = path.join(appDir, 'app/layout.tsx');
} else {
providerFilePath = path.join(dest, 'src/components/provider.tsx');
}
await fluent(
createSourceFile(providerFilePath),
(file) => addSearchDialog(file),
(file) => file.save(),
);
const filePath = {
'+next+fuma-docs-mdx': '.next/server/app/static.json.body',
@@ -78,4 +117,15 @@ void main();
},
};
async function fluent<T>(
value: T | Promise<T>,
...actions: ((value: T) => void | Promise<void>)[]
): Promise<T> {
for (const action of actions) {
await action(await value);
}
return value;
}
export default oramaCloud;
@@ -0,0 +1,39 @@
import { SourceFile, StructureKind, SyntaxKind } from 'ts-morph';
/**
* 1. find the `<RootProvider />` call.
* 2. add `search={{ SearchDialog }}` prop.
* 3. add import of `SearchDialog`.
*
*/
export function addSearchDialog(sourceFile: SourceFile) {
const elements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement);
for (const element of elements) {
const provider = element.getFirstChildByKind(SyntaxKind.JsxOpeningElement);
if (provider?.getTagNameNode().getText() !== 'RootProvider') continue;
// Skip if search prop already exists
if (
provider
.getAttributes()
.some(
(attr) =>
attr.isKind(SyntaxKind.JsxAttribute) &&
attr.getNameNode().getText() === 'search',
)
)
continue;
provider.addAttribute({
kind: StructureKind.JsxAttribute,
name: 'search',
initializer: '{{ SearchDialog }}',
});
}
sourceFile.addImportDeclaration({
moduleSpecifier: '@/components/search',
defaultImport: 'SearchDialog',
});
}
@@ -0,0 +1,57 @@
import { MethodDeclaration, SourceFile, SyntaxKind } from 'ts-morph';
/**
* Add items to `excluded` in the prerender function
*/
export function removeReactRouterPrerender(
sourceFile: SourceFile,
paths: string[],
) {
const methodBody = getPrerenderMethod(sourceFile)?.getBody();
if (!methodBody) return;
const excludedInitializer = methodBody
.getDescendantsOfKind(SyntaxKind.VariableDeclaration)
.find((item) => item.getName() === 'excluded')
?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression);
if (!excludedInitializer) return;
for (const path of paths) {
excludedInitializer.addElement(JSON.stringify(path));
}
}
/**
* Add a new route to route config
*/
export function addReactRouterRoute(
sourceFile: SourceFile,
routes: { path: string; entry: string }[],
) {
const initializer = sourceFile
.getDefaultExportSymbol()
?.getValueDeclaration()
?.getFirstDescendantByKind(SyntaxKind.ArrayLiteralExpression);
if (!initializer) {
return;
}
for (const { path, entry } of routes) {
initializer.addElement(`route('${path}', '${entry}')`);
}
}
/**
* Find the prerender method from the config
*/
function getPrerenderMethod(sourceFile: SourceFile): MethodDeclaration | null {
return (
sourceFile
.getDefaultExportSymbol()
?.getValueDeclaration()
?.asKind(SyntaxKind.VariableDeclaration)
?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression)
?.getProperty('prerender')
?.asKind(SyntaxKind.MethodDeclaration) ?? null
);
}
@@ -0,0 +1,15 @@
import { IndentationText, Project, QuoteKind } from 'ts-morph';
import fs from 'node:fs/promises';
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
quoteKind: QuoteKind.Single,
},
});
export async function createSourceFile(path: string) {
return project.createSourceFile(path, (await fs.readFile(path)).toString(), {
overwrite: true,
});
}
@@ -0,0 +1,79 @@
import { CallExpression, SourceFile, SyntaxKind } from 'ts-morph';
/**
* Add path to the `pages` array in tanstack start vite config.
*
* If the `pages` property doesn't exist, create one.
*/
export function addTanstackPrerender(sourceFile: SourceFile, paths: string[]) {
const optionsArg = getTanstackStartCall(sourceFile)
?.getArguments()[0]
?.asKind(SyntaxKind.ObjectLiteralExpression);
if (!optionsArg) {
return;
}
const pagesProperty = optionsArg
.getProperty('pages')
?.asKind(SyntaxKind.PropertyAssignment);
if (pagesProperty) {
const initializer = pagesProperty.getInitializerIfKindOrThrow(
SyntaxKind.ArrayLiteralExpression,
);
const existingPaths = new Set<string>();
for (const element of initializer.getElements()) {
const value = element
.asKind(SyntaxKind.ObjectLiteralExpression)
?.getProperty('path')
?.asKind(SyntaxKind.PropertyAssignment)
?.getInitializer()
?.getText();
if (value) {
existingPaths.add(JSON.parse(value));
}
}
for (const path of paths) {
if (existingPaths.has(path)) continue;
initializer.addElement(toItem(path));
}
} else {
optionsArg.addProperty(
`pages: [\n${paths.map((path) => ` ${toItem(path)}`).join(',\n')}\n]`,
);
}
}
function toItem(path: string) {
return `{ path: '${path}' }`;
}
/**
* Find the tanstackStart call expression
*/
function getTanstackStartCall(
sourceFile: SourceFile,
): CallExpression | undefined {
const pluginsProperty = sourceFile
.getDefaultExportSymbol()
?.getValueDeclaration()
?.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression)
?.getProperty('plugins')
?.getFirstChildByKind(SyntaxKind.ArrayLiteralExpression);
if (!pluginsProperty) return;
for (const element of pluginsProperty.getElements()) {
const expression = element.asKind(SyntaxKind.CallExpression);
if (
expression &&
expression.getFirstChildByKind(SyntaxKind.Identifier)?.getText() ===
'tanstackStart'
) {
return expression;
}
}
}
@@ -1,18 +0,0 @@
import { RootProvider } from 'fumadocs-ui/provider/next';
import './global.css';
import { Inter } from 'next/font/google';
import SearchDialog from '@/components/search';
const inter = Inter({
subsets: ['latin'],
});
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="en" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider search={{ SearchDialog }}>{children}</RootProvider>
</body>
</html>
);
}
@@ -1,76 +0,0 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from 'react-router';
import { RootProvider } from 'fumadocs-ui/provider/react-router';
import type { Route } from './+types/root';
import './app.css';
import SearchDialog from '@/components/search';
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 className="flex flex-col min-h-screen">
<RootProvider search={{ SearchDialog }}>{children}</RootProvider>
<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>
);
}
@@ -1,8 +0,0 @@
import { index, route, type RouteConfig } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('docs/*', 'docs/page.tsx'),
route('api/search', 'docs/search.ts'),
route('static.json', 'routes/static.ts'),
] satisfies RouteConfig;
@@ -1,76 +0,0 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from 'react-router';
import { RootProvider } from 'fumadocs-ui/provider/react-router';
import type { Route } from './+types/root';
import './app.css';
import SearchDialog from '@/components/search';
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 className="flex flex-col min-h-screen">
<RootProvider search={{ SearchDialog }}>{children}</RootProvider>
<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>
);
}
@@ -1,8 +0,0 @@
import { index, route, type RouteConfig } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('docs/*', 'docs/page.tsx'),
route('api/search', 'docs/search.ts'),
route('static.json', 'routes/static.ts'),
] satisfies RouteConfig;
@@ -1,51 +0,0 @@
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from '@tanstack/react-router';
import * as React from 'react';
import appCss from '@/styles/app.css?url';
import { RootProvider } from 'fumadocs-ui/provider/tanstack';
import SearchDialog from '@/components/search';
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'Fumadocs on TanStack Start',
},
],
links: [{ rel: 'stylesheet', href: appCss }],
}),
component: RootComponent,
});
function RootComponent() {
return (
<RootDocument>
<Outlet />
</RootDocument>
);
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body className="flex flex-col min-h-screen">
<RootProvider search={{ SearchDialog }}>{children}</RootProvider>
<Scripts />
</body>
</html>
);
}
@@ -1,27 +0,0 @@
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({
prerender: {
enabled: true,
// pre-render the static file
pages: [{ path: '/static.json' }],
},
}),
react(),
],
});
@@ -1,8 +0,0 @@
'use client';
import type { ReactNode } from 'react';
import { RootProvider } from 'fumadocs-ui/provider/waku';
import SearchDialog from '@/components/search';
export function Provider({ children }: { children: ReactNode }) {
return <RootProvider search={{ SearchDialog }}>{children}</RootProvider>;
}
@@ -5,9 +5,14 @@ import { createGetUrl, getSlugs } from 'fumadocs-core/source';
const getUrl = createGetUrl('/docs');
export default {
ssr: false,
ssr: true,
async prerender({ getStaticPaths }) {
const paths: string[] = [...getStaticPaths()];
const paths: string[] = [];
const excluded: string[] = [];
for (const path of getStaticPaths()) {
if (!excluded.includes(path)) paths.push(path);
}
for await (const entry of glob('**/*.mdx', { cwd: 'content/docs' })) {
paths.push(getUrl(getSlugs(entry)));
@@ -8,10 +8,11 @@ export default {
ssr: true,
async prerender({ getStaticPaths }) {
const paths: string[] = [];
// ignore dynamic document search
const excluded = ['/api/search'];
for (const path of getStaticPaths()) {
// ignore dynamic document search
if (path === '/api/search') continue;
paths.push(path);
if (!excluded.includes(path)) paths.push(path);
}
for await (const entry of glob('**/*.mdx', { cwd: 'content/docs' })) {
+7 -3
View File
@@ -327,7 +327,7 @@ importers:
version: 0.2.2(@content-collections/[email protected]([email protected]))([email protected]([email protected]))([email protected])
'@content-collections/next':
specifier: ^0.2.8
version: 0.2.8(@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))
version: 0.2.8(@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))
'@types/mdx':
specifier: ^2.0.13
version: 2.0.13
@@ -1603,6 +1603,9 @@ importers:
tinyexec:
specifier: ^1.0.1
version: 1.0.1
ts-morph:
specifier: ^27.0.2
version: 27.0.2
devDependencies:
'@types/cross-spawn':
specifier: ^6.0.6
@@ -1636,7 +1639,7 @@ importers:
version: 0.2.2(@content-collections/[email protected]([email protected]))([email protected]([email protected]))([email protected])
'@content-collections/next':
specifier: ^0.2.8
version: 0.2.8(@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))
version: 0.2.8(@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))
'@orama/core':
specifier: ^1.2.13
version: 1.2.13
@@ -6329,6 +6332,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-YI7mFs7iWc/VsGsh2aw6eAPD2cjzn1j+LKdYVk09x1CrdTWKYIHyd+dG5iQoN9//3hCDoZj8U6vKpZzEf5UARA==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
@@ -12194,7 +12198,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@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]))':
'@content-collections/[email protected](@content-collections/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected]))':
dependencies:
'@content-collections/core': 0.11.1([email protected])
'@content-collections/integrations': 0.3.0(@content-collections/[email protected]([email protected]))