CLI: Use plugins for create() to configure linters & app dir

This commit is contained in:
Fuma Nama
2025-10-26 16:40:36 +08:00
parent 8374193416
commit 4500ce0e71
13 changed files with 259 additions and 205 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'create-fumadocs-app': patch
---
Use plugins for `create()` to configure linters & app dir
+10
View File
@@ -16,6 +16,16 @@
"bin": "./dist/index.js",
"module": "./dist/create-app.js",
"types": "./dist/create-app.d.ts",
"exports": {
".": {
"import": "./dist/create-app.js",
"types": "./dist/create-app.d.ts"
},
"./plugins/*": {
"import": "./dist/plugins/*.js",
"types": "./dist/plugins/*.d.ts"
}
},
"files": [
"template/*",
"dist/*"
+16 -1
View File
@@ -3,7 +3,6 @@ import { versions as localVersions } from '@/versions';
import versionPkg from '../../create-app-versions/package.json';
export const sourceDir = fileURLToPath(new URL(`../`, import.meta.url).href);
export const cwd = process.cwd();
export interface TemplateInfo {
value:
@@ -13,7 +12,13 @@ export interface TemplateInfo {
| 'react-router-spa'
| 'tanstack-start';
label: string;
appDir: string;
rootProviderPath: string;
hint?: string;
/**
* rename files when copying from template
*/
rename?: (name: string) => string;
}
export const templates: TemplateInfo[] = [
@@ -21,23 +26,33 @@ export const templates: TemplateInfo[] = [
value: '+next+fuma-docs-mdx',
label: 'Next.js: Fumadocs MDX',
hint: 'recommended',
appDir: '',
rootProviderPath: 'app/layout.tsx',
},
{
value: 'waku',
label: 'Waku: Fumadocs MDX',
appDir: 'src',
rootProviderPath: 'src/components/provider.tsx',
},
{
value: 'react-router',
label: 'React Router: Fumadocs MDX (not RSC)',
appDir: 'app',
rootProviderPath: 'app/root.tsx',
},
{
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.',
appDir: 'app',
rootProviderPath: 'app/root.tsx',
},
{
value: 'tanstack-start',
label: 'Tanstack Start: Fumadocs MDX (not RSC)',
appDir: 'src',
rootProviderPath: 'src/routes/__root.tsx',
},
];
+69 -158
View File
@@ -1,10 +1,9 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import { copy, pick, tryGitInit } from '@/utils';
import { copy, tryGitInit } from '@/utils';
import type { PackageManager } from './auto-install';
import { autoInstall } from './auto-install';
import {
cwd,
depVersions,
sourceDir,
type TemplateInfo,
@@ -18,20 +17,10 @@ export interface Options {
/**
* the package manager to use
*
* @defaultValue 'npm'
*/
packageManager: PackageManager;
/**
* (Next.js only) Create files inside `src`
* @defaultValue false
*/
useSrcDir?: boolean;
/**
* (Next.js Only) Configure Lint
* @defaultValue false
*/
lint?: 'eslint' | 'biome' | false;
packageManager?: PackageManager;
installDeps?: boolean;
initializeGit?: boolean;
@@ -41,21 +30,38 @@ export interface Options {
export interface TemplatePluginContext {
template: TemplateInfo;
options: Required<Options>;
log: (message: string) => void;
/**
* output directory
*/
dest: string;
/**
* output directory for app code (e.g. under `/src`)
*/
appDir: string;
}
export type PackageJsonType = {
name?: string;
version?: string;
private?: boolean;
scripts?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
} & Record<string, unknown>;
type Awaitable<T> = T | Promise<T>;
export interface TemplatePlugin {
template?: (
this: Pick<TemplatePluginContext, 'dest'>,
info: TemplateInfo,
) => Awaitable<void | TemplateInfo>;
packageJson?: (
this: TemplatePluginContext,
packageJson: any,
) => Awaitable<void | any>;
packageJson: PackageJsonType,
) => Awaitable<void | PackageJsonType>;
afterWrite?: (this: TemplatePluginContext) => Awaitable<void>;
readme?: (
this: TemplatePluginContext,
@@ -63,100 +69,56 @@ export interface TemplatePlugin {
) => Awaitable<void | string>;
}
function defaults(options: Options): Required<Options> {
return {
...options,
plugins: options.plugins ?? [],
useSrcDir:
options.template.startsWith('+next') && options.useSrcDir === true,
lint: options.lint ?? false,
initializeGit: options.initializeGit ?? false,
installDeps: options.installDeps ?? false,
log: console.log,
};
}
function isRelative(dir: string, file: string) {
return !path.relative(dir, file).startsWith(`..${path.sep}`);
}
export async function create(createOptions: Options): Promise<void> {
const options = defaults(createOptions);
const {
outputDir,
useSrcDir,
log,
installDeps,
template,
lint,
initializeGit,
packageManager,
plugins,
} = options;
plugins = [],
packageManager = 'npm',
initializeGit = false,
installDeps = false,
log = console.log,
} = createOptions;
let template = templates.find(
(item) => item.value === createOptions.template,
)!;
for (const plugin of plugins) {
template =
(await plugin.template?.call({ dest: outputDir }, template)) ?? template;
}
const appDir = path.join(outputDir, template.appDir);
const projectName = path.basename(outputDir);
const dest = path.resolve(cwd, outputDir);
const isNext = options.template.startsWith('+next');
const pluginContext: TemplatePluginContext = {
template: templates.find((item) => item.value === template)!,
dest,
options,
template,
dest: outputDir,
log,
appDir,
};
await copy(path.join(sourceDir, `template/${template}`), dest, {
await copy(path.join(sourceDir, 'template', template.value), outputDir, {
rename(file) {
file = file.replace('example.gitignore', '.gitignore');
if (
useSrcDir &&
(path.basename(file) === 'mdx-components.tsx' ||
isRelative(path.join(dest, 'app'), file) ||
isRelative(path.join(dest, 'lib'), file))
) {
return path.join(dest, 'src', path.relative(dest, file));
}
return file;
return template.rename?.(file) ?? file;
},
});
// optional ESLint configuration
if (isNext && lint) {
await copy(path.join(sourceDir, `template/+next+${lint}`), dest);
log('Configured Linter');
}
// update tsconfig.json for src dir
if (isNext && useSrcDir) {
const tsconfigPath = path.join(dest, 'tsconfig.json');
const content = (await fs.readFile(tsconfigPath)).toString();
const config = JSON.parse(content);
if (config.compilerOptions?.paths) {
Object.assign(config.compilerOptions.paths, {
'@/*': ['./src/*'],
});
}
await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2));
}
let packageJson = await createPackageJson(projectName, dest, options);
const packageJsonPath = path.join(outputDir, 'package.json');
let packageJson = await initPackageJson(projectName, packageJsonPath);
for (const plugin of plugins) {
const result = await plugin.packageJson?.call(pluginContext, packageJson);
if (result) packageJson = result;
packageJson =
(await plugin.packageJson?.call(pluginContext, packageJson)) ??
packageJson;
}
await fs.writeFile(
path.join(dest, 'package.json'),
JSON.stringify(packageJson, null, 2),
);
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
let readme = await getReadme(dest, projectName);
const readmePath = path.join(outputDir, 'README.md');
let readme = `# ${projectName}\n\n${await fs.readFile(readmePath)}`;
for (const plugin of plugins) {
readme = (await plugin.readme?.call(pluginContext, readme)) ?? readme;
}
await fs.writeFile(path.join(dest, 'README.md'), readme);
await fs.writeFile(readmePath, readme);
for (const plugin of plugins) {
await plugin.afterWrite?.call(pluginContext);
@@ -164,33 +126,23 @@ export async function create(createOptions: Options): Promise<void> {
if (installDeps) {
try {
await autoInstall(packageManager, dest);
await autoInstall(packageManager, outputDir);
log('Installed dependencies');
} catch (err) {
log(`Failed to install dependencies: ${err}`);
}
}
if (initializeGit && (await tryGitInit(dest))) {
if (initializeGit && (await tryGitInit(outputDir))) {
log('Initialized Git repository');
}
}
async function getReadme(dest: string, projectName: string): Promise<string> {
const template = await fs
.readFile(path.join(dest, 'README.md'))
.then((res) => res.toString());
return `# ${projectName}\n\n${template}`;
}
async function createPackageJson(
async function initPackageJson(
projectName: string,
dir: string,
{ template, lint }: Required<Options>,
): Promise<object> {
const isNext = template.startsWith('+next');
function replaceWorkspaceDeps(deps: Record<string, string>) {
packageJsonPath: string,
): Promise<PackageJsonType> {
function replaceWorkspaceDeps(deps: Record<string, string> = {}) {
for (const k in deps) {
if (deps[k].startsWith('workspace:') && k in depVersions) {
deps[k] = depVersions[k as keyof typeof depVersions];
@@ -200,59 +152,18 @@ async function createPackageJson(
return deps;
}
let packageJson = JSON.parse(
await fs
.readFile(path.join(dir, 'package.json'))
.then((res) => res.toString()),
const packageJson: PackageJsonType = JSON.parse(
(await fs.readFile(packageJsonPath)).toString(),
);
packageJson = {
name: projectName,
return {
...packageJson,
name: projectName,
scripts: {
...packageJson.scripts,
postinstall: 'fumadocs-mdx',
},
dependencies: replaceWorkspaceDeps(packageJson.dependencies),
devDependencies: replaceWorkspaceDeps(packageJson.devDependencies),
};
if (isNext) {
packageJson = {
...packageJson,
scripts: {
...packageJson.scripts,
postinstall: 'fumadocs-mdx',
},
};
}
if (isNext && lint === 'biome') {
packageJson = {
...packageJson,
scripts: {
...packageJson.scripts,
lint: 'biome check',
format: 'biome format --write',
},
devDependencies: {
...packageJson.devDependencies,
...pick(depVersions, ['@biomejs/biome']),
},
};
}
if (isNext && lint === 'eslint') {
packageJson = {
...packageJson,
scripts: {
...packageJson.scripts,
lint: 'eslint',
},
devDependencies: {
...packageJson.devDependencies,
eslint: '^9',
'eslint-config-next': depVersions.next,
'@eslint/eslintrc': '^3',
},
};
}
return packageJson;
}
+19 -8
View File
@@ -19,7 +19,7 @@ import {
type PackageManager,
} from './auto-install';
import { create, type Template, TemplatePlugin } from './create-app';
import { cwd, templates } from './constants';
import { templates } from './constants';
import { program } from 'commander';
program.argument('[name]', 'the project name');
@@ -172,9 +172,7 @@ async function main(config: Options): Promise<void> {
);
const projectName = options.name.toLowerCase().replace(/\s/, '-');
const dest = path.resolve(cwd, projectName);
const destDir = await fs.readdir(dest).catch(() => null);
const destDir = await fs.readdir(projectName).catch(() => null);
if (destDir && destDir.length > 0) {
const del = await confirm({
message: `directory ${projectName} already exists, do you want to delete its files?`,
@@ -191,7 +189,7 @@ async function main(config: Options): Promise<void> {
await Promise.all(
destDir.map((item) => {
return fs.rm(path.join(dest, item), {
return fs.rm(path.join(projectName, item), {
recursive: true,
force: true,
});
@@ -206,18 +204,31 @@ async function main(config: Options): Promise<void> {
info.start(`Generating Project`);
const plugins: TemplatePlugin[] = [];
if (options.src) {
const { nextUseSrc } = await import('./plugins/next-use-src');
plugins.push(nextUseSrc());
}
if (options.search === 'orama-cloud') {
const { oramaCloud } = await import('./plugins/orama-cloud');
plugins.push(oramaCloud());
}
if (options.lint === 'eslint') {
const { eslint } = await import('./plugins/eslint');
plugins.push(eslint());
}
if (options.lint === 'biome') {
const { biome } = await import('./plugins/biome');
plugins.push(biome());
}
await create({
packageManager: manager,
template: options.template,
outputDir: dest,
outputDir: projectName,
installDeps: options.installDeps,
lint: options.lint as LintOption,
useSrcDir: options.src as SrcOption,
initializeGit: config.git ?? true,
plugins,
log: (message) => {
+27
View File
@@ -0,0 +1,27 @@
import { TemplatePlugin } from '@/create-app';
import { copy, pick } from '@/utils';
import path from 'node:path';
import { depVersions, sourceDir } from '@/constants';
export function biome(): TemplatePlugin {
return {
packageJson(packageJson) {
return {
...packageJson,
scripts: {
...packageJson.scripts,
lint: 'biome check',
format: 'biome format --write',
},
devDependencies: {
...packageJson.devDependencies,
...pick(depVersions, ['@biomejs/biome']),
},
};
},
async afterWrite() {
await copy(path.join(sourceDir, `template/+next+biome`), this.dest);
this.log('Configured Biome');
},
};
}
+28
View File
@@ -0,0 +1,28 @@
import { TemplatePlugin } from '@/create-app';
import { copy } from '@/utils';
import path from 'node:path';
import { depVersions, sourceDir } from '@/constants';
export function eslint(): TemplatePlugin {
return {
packageJson(packageJson) {
return {
...packageJson,
scripts: {
...packageJson.scripts,
lint: 'eslint',
},
devDependencies: {
...packageJson.devDependencies,
eslint: '^9',
'eslint-config-next': depVersions.next,
'@eslint/eslintrc': '^3',
},
};
},
async afterWrite() {
await copy(path.join(sourceDir, `template/+next+eslint`), this.dest);
this.log('Configured ESLint');
},
};
}
@@ -0,0 +1,46 @@
import { TemplatePlugin } from '@/create-app';
import path from 'node:path';
import fs from 'node:fs/promises';
/**
* Use `src` for app directory
*/
export function nextUseSrc(): TemplatePlugin {
return {
template(info) {
return {
...info,
appDir: 'src',
rename: (file) => {
if (
path.basename(file) === 'mdx-components.tsx' ||
isRelative(path.join(this.dest, 'app'), file) ||
isRelative(path.join(this.dest, 'lib'), file)
) {
return path.join(this.dest, 'src', path.relative(this.dest, file));
}
return file;
},
};
},
// update tsconfig.json for src dir
async afterWrite() {
const tsconfigPath = path.join(this.dest, 'tsconfig.json');
const content = (await fs.readFile(tsconfigPath)).toString();
const config = JSON.parse(content);
if (config.compilerOptions?.paths) {
Object.assign(config.compilerOptions.paths, {
'@/*': ['./src/*'],
});
}
await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2));
},
};
}
function isRelative(dir: string, file: string) {
return !path.relative(dir, file).startsWith(`..${path.sep}`);
}
+19 -20
View File
@@ -7,8 +7,8 @@ import { createSourceFile } from '@/transform/shared';
import { addTanstackPrerender } from '@/transform/tanstack-start';
import {
addReactRouterRoute,
filterReactRouterPrerenderArray,
filterReactRouterRoute,
removeReactRouterPrerenderExclude,
} from '@/transform/react-router';
import { addSearchDialog } from '@/transform/provider';
@@ -19,7 +19,7 @@ export function oramaCloud(): TemplatePlugin {
...packageJson,
scripts: {
...packageJson.scripts,
build: `${packageJson.scripts.build} && bun scripts/sync-content.ts`,
build: `${packageJson.scripts!.build} && bun scripts/sync-content.ts`,
},
dependencies: {
...packageJson.dependencies,
@@ -35,8 +35,7 @@ This project uses Orama Cloud for 3rd party search solution.
See https://fumadocs.dev/docs/headless/search/orama-cloud for integrating Orama Cloud to Fumadocs.`;
},
async afterWrite() {
const { dest, template, options } = this;
const appDir = path.join(dest, options.useSrcDir ? 'src' : '.');
const { dest, appDir, template } = this;
await copy(path.join(sourceDir, 'template/+orama-cloud/@root'), dest);
await copy(path.join(sourceDir, 'template/+orama-cloud/@app'), appDir);
@@ -48,18 +47,18 @@ See https://fumadocs.dev/docs/headless/search/orama-cloud for integrating Orama
(file) => file.save(),
),
fs
.unlink(path.join(dest, 'src/routes/api/search.ts'))
.unlink(path.join(appDir, 'routes/api/search.ts'))
.catch(() => null),
writeFile(
path.join(dest, 'src/routes/static[.]json.ts'),
path.join(appDir, 'routes/static[.]json.ts'),
route.tanstack,
),
updateRootProvider(path.join(dest, 'src/routes/__root.tsx')),
updateRootProvider(path.join(appDir, 'routes/__root.tsx')),
]);
} else if (template.value.startsWith('react-router')) {
await Promise.all([
fluent(
createSourceFile(path.join(dest, 'app/routes.ts')),
createSourceFile(path.join(appDir, 'routes.ts')),
(file) =>
filterReactRouterRoute(file, ({ path }) => path !== 'api/search'),
(file) =>
@@ -73,15 +72,20 @@ See https://fumadocs.dev/docs/headless/search/orama-cloud for integrating Orama
),
fluent(
createSourceFile(path.join(dest, 'react-router.config.ts')),
(file) => removeReactRouterPrerenderExclude(file, ['/api/search']),
(file) =>
filterReactRouterPrerenderArray(
file,
'excluded',
(v) => v !== '/api/search',
),
(file) => file.save(),
),
fs.unlink(path.join(dest, 'app/docs/search.ts')).catch(() => null),
fs.unlink(path.join(appDir, 'docs/search.ts')).catch(() => null),
writeFile(
path.join(dest, 'app/routes/static.ts'),
path.join(appDir, 'routes/static.ts'),
route['react-router'],
),
updateRootProvider(path.join(dest, 'app/root.tsx')),
updateRootProvider(path.join(appDir, 'root.tsx')),
]);
} else if (template.value.startsWith('+next')) {
await Promise.all([
@@ -93,14 +97,9 @@ See https://fumadocs.dev/docs/headless/search/orama-cloud for integrating Orama
]);
} else {
await Promise.all([
fs
.unlink(path.join(dest, 'src/pages/api/search.ts'))
.catch(() => null),
writeFile(
path.join(dest, 'src/pages/api/static.json.ts'),
route.waku,
),
updateRootProvider(path.join(dest, 'src/components/provider.tsx')),
fs.unlink(path.join(appDir, 'pages/api/search.ts')).catch(() => null),
writeFile(path.join(appDir, 'pages/api/static.json.ts'), route.waku),
updateRootProvider(path.join(appDir, 'components/provider.tsx')),
]);
}
@@ -8,25 +8,24 @@ import { getCodeValue } from '@/transform/shared';
import SyntaxKind = ts.SyntaxKind;
/**
* remove items from `excluded` in the prerender function
* filter items in a specific array initializer in the prerender function
*/
export function removeReactRouterPrerenderExclude(
export function filterReactRouterPrerenderArray(
sourceFile: SourceFile,
paths: string[],
array: 'paths' | 'excluded',
filter: (item: string) => boolean,
) {
const methodBody = getPrerenderMethod(sourceFile)?.getBody();
if (!methodBody) return;
const initializer = methodBody
.getDescendantsOfKind(SyntaxKind.VariableDeclaration)
.find((item) => item.getName() === 'excluded')
.find((item) => item.getName() === array)
?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression);
if (!initializer) return;
for (const element of initializer.getElements()) {
const value = getCodeValue(element.getText());
if (paths.includes(value)) {
if (!filter(getCodeValue(element.getText()))) {
initializer.removeElement(element);
}
}
@@ -18,6 +18,10 @@ export function addTanstackPrerender(sourceFile: SourceFile, paths: string[]) {
.getProperty('pages')
?.asKind(SyntaxKind.PropertyAssignment);
function toItem(path: string) {
return `{ path: '${path}' }`;
}
if (pagesProperty) {
const initializer = pagesProperty.getInitializerIfKindOrThrow(
SyntaxKind.ArrayLiteralExpression,
@@ -48,10 +52,6 @@ export function addTanstackPrerender(sourceFile: SourceFile, paths: string[]) {
}
}
function toItem(path: string) {
return `{ path: '${path}' }`;
}
/**
* Find the tanstackStart call expression
*/
@@ -70,9 +70,8 @@ function getTanstackStartCall(
for (const element of pluginsProperty.getElements()) {
const expression = element.asKind(SyntaxKind.CallExpression);
if (
expression &&
expression.getFirstChildByKind(SyntaxKind.Identifier)?.getText() ===
'tanstackStart'
expression?.getFirstChildByKind(SyntaxKind.Identifier)?.getText() ===
'tanstackStart'
) {
return expression;
}
+6 -2
View File
@@ -5,8 +5,8 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import {
addReactRouterRoute,
filterReactRouterPrerenderArray,
filterReactRouterRoute,
removeReactRouterPrerenderExclude,
} from '@/transform/react-router';
const project = new Project({
@@ -64,7 +64,11 @@ test('transform react router routes: filter routes', async () => {
test('transform react router config: remove exclude', async () => {
const sourceFile = await createSourceFile('fixtures/react-router-config.txt');
removeReactRouterPrerenderExclude(sourceFile, ['/api/search']);
filterReactRouterPrerenderArray(
sourceFile,
'excluded',
(v) => v !== '/api/search',
);
await expect(sourceFile.getFullText()).toMatchFileSnapshot(
'fixtures/react-router-config(remove-exclude).output.txt',
);
+2 -2
View File
@@ -22,8 +22,8 @@ writeFileSync(
console.log('Create-Fumadocs-App: versions.json updated');
export default defineConfig({
entry: ['./src/index.ts', './src/create-app.ts'],
entry: ['./src/index.ts', './src/create-app.ts', './src/plugins/*'],
format: 'esm',
target: 'node18',
target: 'node20',
dts: true,
});