adopt Fuma CLI (#3194)

* Adopt Fuma CLI

* mark stable

* bump deps
This commit is contained in:
Fuma Nama
2026-04-07 16:52:40 +08:00
committed by GitHub
parent eae661d762
commit e22a22fd57
70 changed files with 2969 additions and 4689 deletions
+47 -19
View File
@@ -1,24 +1,20 @@
import { type Registry } from '@fumadocs/cli/build';
import * as radixUi from '../../../../packages/radix-ui/registry';
import * as baseUi from '../../../../packages/base-ui/registry';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
import { resolveFromRemote } from '@fumadocs/cli/build';
import type { CompileOptions, Registry } from 'fuma-cli/compiler';
const baseDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../');
export const registry: Registry = {
dir: baseDir,
name: 'fumadocs',
packageJson: './package.json',
tsconfigPath: './tsconfig.json',
export const compileOptions: Partial<CompileOptions> = {
onUnknownFile(absolutePath) {
const filePath = path.relative(baseDir, absolutePath);
// source object is external
if (filePath.startsWith('lib/source/')) return false;
},
onResolve(ref) {
if (ref.type === 'unknown-specifier' && ref.specifier === 'hast') {
onParseReference(ref) {
if (ref.type === 'unknown' && ref.specifier === 'hast') {
return {
type: 'dependency',
dep: '@types/hast',
@@ -27,26 +23,57 @@ export const registry: Registry = {
}
if (ref.type === 'file') {
const file = path.relative(baseDir, ref.file);
let file = path.relative(baseDir, ref.file);
if (file === 'lib/cn.ts') {
return resolveFromRemote(radixUi.registry, 'cn', () => true)!;
return {
type: 'file',
file: path.join(radixUi.registry.dir, 'utils/cn.ts'),
};
}
file = path.relative(radixUi.registry.dir, ref.file);
if (file.startsWith('contexts/') || file.startsWith('utils/use-')) {
return {
dep: 'fumadocs-ui',
type: 'dependency',
specifier: `fumadocs-ui/${removeExtname(file)}`,
};
}
file = path.relative(baseUi.registry.dir, ref.file);
if (file.startsWith('contexts/') || file.startsWith('utils/use-')) {
return {
dep: '@fumadocs/base-ui',
type: 'dependency',
specifier: `@fumadocs/base-ui/${removeExtname(file)}`,
};
}
}
// map dep imports to actual components
if (ref.type === 'dependency' && ref.dep === 'fumadocs-ui') {
const match = /fumadocs-ui\/components\/ui\/(.*)/.exec(ref.specifier);
if (match) {
return resolveFromRemote(
radixUi.registry,
match[1],
(file) => path.basename(file.path, path.extname(file.path)) === match[1],
)!;
return {
type: 'file',
file: path.join(radixUi.registry.dir, `components/ui/${match[1]}.tsx`),
};
}
}
return ref;
},
};
export const registry: Registry = {
dir: baseDir,
name: 'fumadocs',
packageJson: './package.json',
tsconfigPath: './tsconfig.json',
subRegistries: [radixUi.registry, baseUi.registry],
components: [
{
name: 'layouts/docs-min',
@@ -174,8 +201,9 @@ export const registry: Registry = {
dependencies: {
'fumadocs-core': null,
'fumadocs-ui': null,
'lucide-react': null,
next: null,
react: null,
},
};
function removeExtname(file: string) {
return file.slice(0, -path.extname(file).length);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { ProvideLinksToolSchema } from '@/lib/inkeep/inkeep-qa-schema';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { $routeHandler } from '@fumadocs/cli/registry/macros/route-handler';
import { $routeHandler } from 'fuma-cli/macros/route-handler';
import { convertToModelMessages, streamText, type UIMessage } from 'ai';
export type InkeepUIMessage = UIMessage<
+1 -1
View File
@@ -3,7 +3,7 @@ import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage }
import { z } from 'zod';
import { source } from '@/lib/source';
import { Document, type DocumentData } from 'flexsearch';
import { $routeHandler } from '@fumadocs/cli/registry/macros/route-handler';
import { $routeHandler } from 'fuma-cli/macros/route-handler';
interface CustomDocument extends DocumentData {
url: string;
+11 -10
View File
@@ -13,11 +13,11 @@
"start": "next start"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^2.0.37",
"@ai-sdk/react": "^3.0.142",
"@ai-sdk/openai-compatible": "^2.0.40",
"@ai-sdk/react": "^3.0.151",
"@fumadocs/story": "workspace:*",
"@mixedbread/sdk": "^0.61.0",
"@openrouter/ai-sdk-provider": "^2.3.3",
"@openrouter/ai-sdk-provider": "^2.5.0",
"@orama/core": "^1.2.19",
"@orama/orama": "^3.1.18",
"@paper-design/shaders-react": "^0.0.72",
@@ -28,13 +28,14 @@
"@shikijs/rehype": "^4.0.2",
"@shikijs/transformers": "^4.0.2",
"@takumi-rs/image-response": "^0.73.1",
"ai": "^6.0.140",
"ai": "^6.0.149",
"class-variance-authority": "^0.7.1",
"d3-force": "^3.0.0",
"estree-util-build-jsx": "^3.0.1",
"estree-util-to-js": "^2.0.0",
"feed": "^5.2.0",
"flexsearch": "^0.8.212",
"fuma-cli": "^0.0.2",
"fumadocs-core": "workspace:*",
"fumadocs-docgen": "workspace:^",
"fumadocs-epub": "workspace:*",
@@ -44,14 +45,14 @@
"fumadocs-typescript": "workspace:^",
"fumadocs-ui": "workspace:*",
"hast-util-to-jsx-runtime": "^2.3.6",
"katex": "0.16.43",
"katex": "0.16.45",
"lucide-react": "^1.7.0",
"mermaid": "^11.13.0",
"mermaid": "^11.14.0",
"motion": "^12.38.0",
"next": "16.2.2",
"next-themes": "^0.4.6",
"octokit": "^5.0.5",
"oxc-transform": "^0.121.0",
"oxc-transform": "^0.124.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-force-graph-2d": "^1.29.1",
@@ -78,17 +79,17 @@
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.183.1",
"algoliasearch": "5.50.0",
"algoliasearch": "5.50.1",
"browserslist-config-baseline": "^0.5.0",
"gray-matter": "^4.0.3",
"mcp-handler": "^1.1.0",
"next-validate-link": "^1.6.4",
"postcss": "^8.5.8",
"shadcn": "4.1.1",
"shadcn": "4.1.2",
"tailwindcss": "^4.2.2",
"tailwindcss-animate": "^1.0.7",
"tinyexec": "^1.0.4",
+7 -12
View File
@@ -1,17 +1,12 @@
import { combineRegistry, RegistryCompiler, writeFumadocsRegistry } from '@fumadocs/cli/build';
import { registry } from '@/components/registry/index.js';
import * as radixUi from '../../../packages/radix-ui/registry';
import * as baseUi from '../../../packages/base-ui/registry';
import { compileOptions, registry } from '@/components/registry/index.js';
import { compile, writeRegistry } from 'fuma-cli/compiler';
export async function buildRegistry() {
const results = await Promise.all([
new RegistryCompiler(registry).compile(),
new RegistryCompiler(radixUi.registry).compile(),
new RegistryCompiler(baseUi.registry).compile(),
]);
const all = combineRegistry(...results);
await writeFumadocsRegistry(all, {
const all = await compile({
root: registry,
...compileOptions,
});
await writeRegistry(all, {
dir: 'public/registry',
});
}
+2 -2
View File
@@ -12,9 +12,9 @@
},
"dependencies": {
"@astrojs/mdx": "^5.0.3",
"@astrojs/react": "^5.0.2",
"@astrojs/react": "^5.0.3",
"@orama/orama": "^3.1.18",
"astro": "^6.1.1",
"astro": "^6.1.4",
"fumadocs-core": "workspace:*",
"fumadocs-ui": "workspace:*",
"react": "^19.2.4",
+1 -1
View File
@@ -21,7 +21,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
+2 -2
View File
@@ -11,7 +11,7 @@
"fumadocs-core": "workspace:*",
"fumadocs-ui": "workspace:*",
"github-slugger": "^2.0.0",
"html-react-parser": "^5.2.17",
"html-react-parser": "^6.0.0",
"next": "16.2.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -19,7 +19,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
+1 -1
View File
@@ -22,7 +22,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
+1 -1
View File
@@ -21,7 +21,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
+1 -1
View File
@@ -22,7 +22,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
+7 -7
View File
@@ -10,26 +10,26 @@
},
"dependencies": {
"@orama/tokenizers": "^3.1.18",
"@react-router/node": "^7.13.2",
"@react-router/serve": "^7.13.2",
"@react-router/node": "^7.14.0",
"@react-router/serve": "^7.14.0",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"isbot": "^5.1.36",
"isbot": "^5.1.37",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.2"
"react-router": "^7.14.0"
},
"devDependencies": {
"@react-router/dev": "^7.13.2",
"@react-router/dev": "^7.14.0",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.3"
"vite": "^8.0.5"
}
}
+7 -7
View File
@@ -9,26 +9,26 @@
"types:check": "react-router typegen && fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@react-router/node": "^7.13.2",
"@react-router/serve": "^7.13.2",
"@react-router/node": "^7.14.0",
"@react-router/serve": "^7.14.0",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"isbot": "^5.1.36",
"isbot": "^5.1.37",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.2"
"react-router": "^7.14.0"
},
"devDependencies": {
"@react-router/dev": "^7.13.2",
"@react-router/dev": "^7.14.0",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.3"
"vite": "^8.0.5"
}
}
+8 -8
View File
@@ -9,27 +9,27 @@
"types:check": "react-router typegen && fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@react-router/node": "^7.13.2",
"@react-router/serve": "^7.13.2",
"@react-router/node": "^7.14.0",
"@react-router/serve": "^7.14.0",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"isbot": "^5.1.36",
"isbot": "^5.1.37",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.2"
"react-router": "^7.14.0"
},
"devDependencies": {
"@react-router/dev": "^7.13.2",
"@react-router/dev": "^7.14.0",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-rsc": "^0.5.21",
"@vitejs/plugin-rsc": "^0.5.22",
"react-router-devtools": "^6.2.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.3"
"vite": "^8.0.5"
}
}
+6 -6
View File
@@ -10,28 +10,28 @@
},
"dependencies": {
"@orama/orama": "^3.1.18",
"@react-router/node": "^7.13.2",
"@react-router/node": "^7.14.0",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"isbot": "^5.1.36",
"isbot": "^5.1.37",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.2",
"react-router": "^7.14.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@react-router/dev": "^7.13.2",
"@react-router/dev": "^7.14.0",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"serve": "^14.2.6",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.3"
"vite": "^8.0.5"
}
}
+7 -7
View File
@@ -9,29 +9,29 @@
"types:check": "react-router typegen && fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@react-router/node": "^7.13.2",
"@react-router/serve": "^7.13.2",
"@react-router/node": "^7.14.0",
"@react-router/serve": "^7.14.0",
"@takumi-rs/image-response": "^0.73.1",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"isbot": "^5.1.36",
"isbot": "^5.1.37",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.2",
"react-router": "^7.14.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@react-router/dev": "^7.13.2",
"@react-router/dev": "^7.14.0",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.3"
"vite": "^8.0.5"
}
}
+4 -4
View File
@@ -11,9 +11,9 @@
},
"dependencies": {
"@orama/tokenizers": "^3.1.18",
"@tanstack/react-router": "1.168.7",
"@tanstack/react-router": "1.168.10",
"@tanstack/react-router-devtools": "1.166.11",
"@tanstack/react-start": "1.167.12",
"@tanstack/react-start": "1.167.16",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
@@ -21,12 +21,12 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"vite": "^8.0.3"
"vite": "^8.0.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
+5 -5
View File
@@ -11,26 +11,26 @@
"types:check": "fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@tanstack/react-router": "1.168.7",
"@tanstack/react-router": "1.168.10",
"@tanstack/react-router-devtools": "1.166.11",
"@tanstack/react-start": "1.167.12",
"@tanstack/react-start": "1.167.16",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"vite": "^8.0.3"
"vite": "^8.0.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"nitro": "^3.0.260311-beta",
"srvx": "^0.11.13",
"srvx": "^0.11.15",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2"
}
+4 -4
View File
@@ -11,9 +11,9 @@
"types:check": "fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@tanstack/react-router": "1.168.7",
"@tanstack/react-router": "1.168.10",
"@tanstack/react-router-devtools": "1.166.11",
"@tanstack/react-start": "1.167.12",
"@tanstack/react-start": "1.167.16",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-openapi": "workspace:*",
@@ -23,12 +23,12 @@
"react-dom": "^19.2.4",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0",
"vite": "^8.0.3"
"vite": "^8.0.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
+6 -6
View File
@@ -12,10 +12,10 @@
},
"dependencies": {
"@orama/orama": "^3.1.18",
"@tanstack/react-router": "1.168.7",
"@tanstack/react-router": "1.168.10",
"@tanstack/react-router-devtools": "1.166.11",
"@tanstack/react-start": "1.167.12",
"@tanstack/start-static-server-functions": "1.166.22",
"@tanstack/react-start": "1.167.16",
"@tanstack/start-static-server-functions": "1.166.25",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
@@ -23,18 +23,18 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"vite": "^8.0.3"
"vite": "^8.0.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"nitro": "^3.0.260311-beta",
"serve": "^14.2.6",
"srvx": "^0.11.13",
"srvx": "^0.11.15",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2"
}
+4 -4
View File
@@ -11,9 +11,9 @@
},
"dependencies": {
"@fumadocs/story": "workspace:*",
"@tanstack/react-router": "1.168.7",
"@tanstack/react-router": "1.168.10",
"@tanstack/react-router-devtools": "1.166.11",
"@tanstack/react-start": "1.167.12",
"@tanstack/react-start": "1.167.16",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
@@ -22,12 +22,12 @@
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"ts-morph": "^27.0.2",
"vite": "^8.0.3"
"vite": "^8.0.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
+4 -4
View File
@@ -11,9 +11,9 @@
"types:check": "fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@tanstack/react-router": "1.168.7",
"@tanstack/react-router": "1.168.10",
"@tanstack/react-router-devtools": "1.166.11",
"@tanstack/react-start": "1.167.12",
"@tanstack/react-start": "1.167.16",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
@@ -21,12 +21,12 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"vite": "^8.0.3"
"vite": "^8.0.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
+2 -2
View File
@@ -22,12 +22,12 @@
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vite": "^8.0.5",
"vite-tsconfig-paths": "^6.1.1"
}
}
+2 -2
View File
@@ -24,12 +24,12 @@
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vite": "^8.0.5",
"vite-tsconfig-paths": "^6.1.1"
}
}
+3 -3
View File
@@ -20,10 +20,10 @@
"@changesets/cli": "^2.30.0",
"bun": "1.3.11",
"concurrently": "^9.2.1",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"rimraf": "^6.1.3",
"turbo": "2.8.20",
"turbo": "2.9.4",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
},
+4 -3
View File
@@ -128,10 +128,11 @@
"@base-ui/react": "^1.3.0",
"@fumadocs/tailwind": "workspace:*",
"class-variance-authority": "^0.7.1",
"fuma-cli": "^0.0.2",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"react-medium-image-zoom": "^5.4.1",
"react-medium-image-zoom": "^5.4.3",
"react-remove-scroll": "^2.7.2",
"rehype-raw": "^7.0.0",
"scroll-into-view-if-needed": "^3.1.0",
@@ -144,14 +145,14 @@
"@tsdown/css": "^0.21.6",
"@types/hast": "^3.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"fumadocs-core": "workspace:*",
"shiki": "^4.0.2",
"tailwindcss": "^4.2.2",
"tsconfig": "workspace:*",
"tsdown": "0.21.6",
"tsdown": "0.21.7",
"unified": "^11.0.5"
},
"peerDependencies": {
+2 -5
View File
@@ -1,7 +1,7 @@
import { fileURLToPath } from 'node:url';
import type { Registry } from '@fumadocs/cli/build';
import * as path from 'node:path';
import { commonComponents, findSlotComponents, resolveExternal } from '../../shared/registry';
import type { Registry } from 'fuma-cli/compiler';
import { commonComponents, findSlotComponents } from '../../shared/registry';
const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../src');
@@ -14,9 +14,6 @@ export const registry: Registry = {
env: {
ui: '@fumadocs/base-ui',
},
onResolve(ref) {
return resolveExternal(ref, 'fumadocs-ui', dir) ?? ref;
},
components: [
...commonComponents,
...(await findSlotComponents(dir)),
+8 -12
View File
@@ -18,12 +18,7 @@
],
"type": "module",
"exports": {
"./build": "./dist/build/index.js",
"./config": "./dist/config.js",
"./registry/client": "./dist/registry/client.js",
"./registry/installer": "./dist/registry/installer/index.js",
"./registry/macros/route-handler": "./dist/registry/macros/route-handler.js",
"./registry/schema": "./dist/registry/schema.js",
".": "./dist/config.js",
"./package.json": "./package.json"
},
"publishConfig": {
@@ -37,10 +32,11 @@
"types:check": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^1.1.0",
"@clack/prompts": "^1.2.0",
"commander": "^14.0.3",
"fuma-cli": "^0.0.2",
"magic-string": "^0.30.21",
"oxc-parser": "^0.121.0",
"oxc-parser": "^0.124.0",
"oxc-resolver": "^11.19.1",
"package-manager-detector": "^1.6.0",
"picocolors": "^1.1.1",
@@ -48,10 +44,10 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@oxc-project/types": "^0.122.0",
"@types/node": "25.5.0",
"shadcn": "4.1.1",
"@oxc-project/types": "^0.124.0",
"@types/node": "25.5.2",
"shadcn": "4.1.2",
"tsconfig": "workspace:*",
"tsdown": "0.21.6"
"tsdown": "0.21.7"
}
}
-472
View File
@@ -1,472 +0,0 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type {
CompiledComponent,
CompiledFile,
httpSubComponent,
registryInfoSchema,
} from '@/registry/schema';
import type { z } from 'zod';
import { parse } from 'oxc-parser';
import { ResolverFactory } from 'oxc-resolver';
import MagicString from 'magic-string';
import { transformSpecifiers } from '@/utils/ast';
import { isRelative } from '@/utils/fs';
import type { DistributiveOmit } from '@/types';
import { encodeImport } from '@/registry/protocols/import';
export type OnResolve = (
reference: SourceReference,
from: { component: Component; file: ComponentFile },
) => Reference;
export interface CompiledRegistry {
name: string;
components: CompiledComponent[];
info: z.output<typeof registryInfoSchema>;
}
export type ComponentFile = DistributiveOmit<CompiledFile, 'content'> & {
path: string;
};
export interface Component {
name: string;
title?: string;
description?: string;
files: ComponentFile[];
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
/**
* Don't list the component in registry index file
*/
unlisted?: boolean;
/**
* Map imported file paths, inherit from registry if not defined.
*/
onResolve?: OnResolve;
}
export interface PackageJson {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}
export interface Registry extends Omit<z.input<typeof registryInfoSchema>, 'indexes'> {
name: string;
packageJson: string | PackageJson;
tsconfigPath: string;
components: Component[];
/**
* The directory of registry, used to resolve relative paths
*/
dir: string;
/**
* Map import paths of components
*/
onResolve?: OnResolve;
/**
* When a referenced file is not found in component files, this function is called.
* @returns file, or `false` to mark as external.
*/
onUnknownFile?: (absolutePath: string) => ComponentFile | false | undefined;
dependencies?: Record<string, string | null>;
devDependencies?: Record<string, string | null>;
}
export class RegistryCompiler {
readonly raw: Registry;
resolver!: RegistryResolver;
constructor(registry: Registry) {
this.raw = registry;
}
private async readPackageJson(): Promise<PackageJson | undefined> {
if (typeof this.raw.packageJson !== 'string') return this.raw.packageJson;
return fs
.readFile(path.join(this.raw.dir, this.raw.packageJson))
.then((res) => JSON.parse(res.toString()) as PackageJson)
.catch(() => undefined);
}
async compile(): Promise<CompiledRegistry> {
const registry = this.raw;
this.resolver = new RegistryResolver(this, await this.readPackageJson());
const output: CompiledRegistry = {
name: registry.name,
info: {
indexes: [],
unlistedIndexes: [],
env: registry.env,
variables: registry.variables,
},
components: [],
};
const builtComps = await Promise.all(
registry.components.map(async (component) => {
const compiler = new ComponentCompiler(this, component);
return [component, await compiler.build()] as [Component, CompiledComponent];
}),
);
for (const [input, comp] of builtComps) {
const arr = input.unlisted ? output.info.unlistedIndexes : output.info.indexes;
arr.push({
name: input.name,
title: input.title,
description: input.description,
});
output.components.push(comp);
}
return output;
}
}
class RegistryResolver {
private readonly deps: Record<string, string | null>;
private readonly devDeps: Record<string, string | null>;
private readonly fileToComponent = new Map<string, [Component, ComponentFile]>();
readonly oxc: ResolverFactory;
constructor(
private readonly compiler: RegistryCompiler,
packageJson: PackageJson = {},
) {
const registry = compiler.raw;
for (const comp of registry.components) {
for (const file of comp.files) {
if (this.fileToComponent.has(file.path))
console.warn(
`the same file ${file.path} exists in multiple component, you should make the shared file a separate component.`,
);
this.fileToComponent.set(file.path, [comp, file]);
}
}
this.deps = {
...packageJson?.dependencies,
...registry.dependencies,
};
this.devDeps = {
...packageJson?.devDependencies,
...registry.devDependencies,
};
// resolve anything possible
this.oxc = new ResolverFactory({
extensions: ['.js', '.jsx', '.ts', '.tsx', '.node'],
conditionNames: ['node', 'import', 'require', 'default', 'types'],
tsconfig: {
configFile: path.join(registry.dir, registry.tsconfigPath),
},
});
}
getDepFromSpecifier(specifier: string) {
return specifier.startsWith('@')
? specifier.split('/').slice(0, 2).join('/')
: specifier.split('/')[0];
}
getDepInfo(name: string):
| {
type: 'runtime' | 'dev';
name: string;
version: string | null;
}
| undefined {
if (name in this.deps)
return {
name,
type: 'runtime',
version: this.deps[name],
};
if (name in this.devDeps)
return {
name,
type: 'dev',
version: this.devDeps[name],
};
console.warn(`dep info for ${name} cannot be found`);
}
getComponentByName(name: string): Component | undefined {
return this.compiler.raw.components.find((comp) => comp.name === name);
}
getSubComponent(file: string) {
const relativeFile = path.relative(this.compiler.raw.dir, file);
const comp = this.fileToComponent.get(relativeFile);
if (!comp) return;
return {
component: comp[0],
file: comp[1],
};
}
}
export type SourceReference =
| {
type: 'file';
/**
* Absolute path
*/
file: string;
}
| {
type: 'dependency';
dep: string;
specifier: string;
}
| {
type: 'sub-component';
resolved:
| {
type: 'local';
component: Component;
file: ComponentFile;
}
| {
type: 'remote';
component: Component;
file: ComponentFile;
registryName: string;
};
}
| {
type: 'unknown-specifier';
specifier: string;
};
export type Reference =
| SourceReference
| {
type: 'custom';
specifier: string;
};
export class ComponentCompiler {
private readonly processedFiles = new Set<string>();
private readonly registry: Registry;
private readonly subComponents = new Map<string, string | z.input<typeof httpSubComponent>>();
private readonly devDependencies = new Map<string, string | null>();
private readonly dependencies = new Map<string, string | null>();
constructor(
private readonly compiler: RegistryCompiler,
private readonly component: Component,
) {
this.registry = compiler.raw;
}
async build(): Promise<CompiledComponent> {
const files = (
await Promise.all(this.component.files.map((file) => this.onBuildFile(file)))
).flat();
const dependencies = Object.fromEntries(this.dependencies);
const devDependencies = Object.fromEntries(this.devDependencies);
if (this.component.dependencies) {
Object.assign(dependencies, this.component.dependencies);
}
if (this.component.devDependencies) {
Object.assign(devDependencies, this.component.devDependencies);
}
return {
name: this.component.name,
title: this.component.title,
description: this.component.description,
files,
subComponents: Array.from(this.subComponents.values()),
dependencies,
devDependencies,
};
}
private async onBuildFile(file: ComponentFile): Promise<CompiledFile[]> {
if (this.processedFiles.has(file.path)) return [];
this.processedFiles.add(file.path);
const resolver = this.compiler.resolver;
const queue: ComponentFile[] = [];
const result = await this.buildFile(file, (reference) => {
const external = reference.type === 'dependency' && reference.dep === '@fumadocs/cli';
if (external) {
return reference.specifier;
}
if (reference.type === 'unknown-specifier') {
if (!reference.specifier.startsWith('node:')) {
console.warn(`Unknown specifier ${reference.specifier}, skipping for now`);
}
return reference.specifier;
}
if (reference.type === 'custom') return reference.specifier;
if (reference.type === 'file') {
const refFile = this.registry.onUnknownFile?.(reference.file);
if (refFile) {
queue.push(refFile);
return encodeImport(refFile);
}
if (refFile === false) return;
throw new Error(`Unknown file ${reference.file} referenced by ${file.path}`);
}
if (reference.type === 'sub-component') {
const resolved = reference.resolved;
if (resolved.component.name === this.component.name) return encodeImport(resolved.file);
if (resolved.type === 'remote') {
this.subComponents.set(`${resolved.registryName}:${resolved.component.name}`, {
type: 'http',
baseUrl: resolved.registryName,
component: resolved.component.name,
});
} else {
this.subComponents.set(resolved.component.name, resolved.component.name);
}
return encodeImport(resolved.file);
}
const dep = resolver.getDepInfo(reference.dep);
if (dep) {
const map = dep.type === 'dev' ? this.devDependencies : this.dependencies;
map.set(dep.name, dep.version);
}
return reference.specifier;
});
return [result, ...(await Promise.all(queue.map((file) => this.onBuildFile(file)))).flat()];
}
private async buildFile(
file: ComponentFile,
/**
* write references back to import specifiers
*
* keep original one if `undefined`
*/
writeReference: (reference: Reference) => string | undefined,
): Promise<CompiledFile> {
const sourceFilePath = path.join(this.registry.dir, file.path);
const astTypes: Record<string, 'js' | 'ts' | undefined> = {
'.ts': 'ts',
'.tsx': 'ts',
'.js': 'js',
'.jsx': 'js',
};
const astType = astTypes[path.extname(file.path)];
const content = (await fs.readFile(sourceFilePath)).toString();
if (!astType) {
return {
...file,
content,
};
}
const resolver = this.compiler.resolver;
const ast = await parse(sourceFilePath, content, {
astType,
});
if (ast.errors.length > 0) {
throw new Error(`failed to parse file ${sourceFilePath}: \n${ast.errors.join('\n')}`);
}
const s = new MagicString(content);
const ctx = { component: this.component, file };
// Process import paths
transformSpecifiers(ast.program, s, (specifier) => {
let resolved: Reference = {
type: 'unknown-specifier',
specifier: specifier,
};
const onResolve = this.component.onResolve ?? this.registry.onResolve;
const resolvedSpecifier = resolver.oxc.resolveFileSync(sourceFilePath, specifier);
if (resolvedSpecifier.error || !resolvedSpecifier.path) {
return writeReference(onResolve ? onResolve(resolved, ctx) : resolved);
}
resolved = {
type: 'file',
file: resolvedSpecifier.path,
};
// outside of registry dir
if (!isRelative(this.registry.dir, resolvedSpecifier.path)) {
resolved = {
type: 'dependency',
dep: resolver.getDepFromSpecifier(specifier),
specifier,
};
} else {
const sub = resolver.getSubComponent(resolvedSpecifier.path);
if (sub) {
resolved = {
type: 'sub-component',
resolved: {
type: 'local',
component: sub.component,
file: sub.file,
},
};
}
}
return writeReference(onResolve ? onResolve(resolved, ctx) : resolved);
});
return {
...file,
content: s.toString(),
};
}
}
export function resolveFromRemote(
r: Registry,
component: string,
selectFile: (file: ComponentFile) => boolean,
): Reference | undefined {
const comp = r.components.find((comp) => comp.name === component);
if (!comp) return;
const file = comp.files.find(selectFile);
if (!file) return;
return {
type: 'sub-component',
resolved: {
type: 'remote',
registryName: r.name,
component: comp,
file,
},
};
}
-91
View File
@@ -1,91 +0,0 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import picocolors from 'picocolors';
import type { CompiledRegistry } from '@/build/compiler';
export * from './compiler';
export interface MonoRegistry extends CompiledRegistry {
registries: CompiledRegistry[];
}
export function combineRegistry(
root: CompiledRegistry,
...items: CompiledRegistry[]
): MonoRegistry {
return {
...root,
info: {
...root.info,
registries: items.map((item) => item.name),
},
registries: items,
};
}
export async function writeFumadocsRegistry(
out: CompiledRegistry | MonoRegistry,
options: {
dir: string;
/**
* Remove previous outputs
*
* @defaultValue false
*/
cleanDir?: boolean;
log?: boolean;
},
): Promise<void> {
const { dir, cleanDir = false, log = true } = options;
if (cleanDir) {
await fs.rm(dir, {
recursive: true,
force: true,
});
console.log(picocolors.bold(picocolors.greenBright('Cleaned directory')));
}
async function writeInfo() {
const file = path.join(dir, '_registry.json');
const json = JSON.stringify(out.info, null, 2);
await writeFile(file, json, log);
}
const write = out.components.map(async (comp) => {
const file = path.join(dir, `${comp.name}.json`);
const json = JSON.stringify(comp, null, 2);
await writeFile(file, json, log);
});
write.push(writeInfo());
if ('registries' in out) {
for (const child of out.registries) {
write.push(
writeFumadocsRegistry(child, {
dir: path.join(dir, child.name),
log: options.log,
}),
);
}
}
await Promise.all(write);
}
async function writeFile(file: string, content: string, log = true): Promise<void> {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, content);
if (log) {
const size = (Buffer.byteLength(content) / 1024).toFixed(2);
console.log(
`${picocolors.greenBright('+')} ${path.relative(process.cwd(), file)} ${picocolors.dim(`${size} KB`)}`,
);
}
}
+62 -84
View File
@@ -8,51 +8,46 @@ import {
log,
} from '@clack/prompts';
import picocolors from 'picocolors';
import { ComponentInstaller } from '@/registry/installer';
import type { RegistryClient } from '@/registry/client';
import { UIRegistries } from '@/commands/shared';
import { pluginPreserveLayouts } from '@/registry/plugins/preserve';
import { detect } from 'package-manager-detector';
import { RegistryConnector } from 'fuma-cli/registry/connector';
import { LoadedConfig } from '@/config';
import { FumadocsComponentInstaller } from '@/registry/utils';
export async function add(input: string[], client: RegistryClient) {
const config = client.config;
let target: string[];
const installer = new ComponentInstaller(client, {
plugins: [pluginPreserveLayouts()],
});
const registry = UIRegistries[config.uiLibrary];
interface AddOption {
label: string;
value: Target;
hint?: string;
}
export interface Target {
name: string;
subRegistry?: string;
}
export async function add(input: string[], connector: RegistryConnector, config: LoadedConfig) {
let targets: Target[];
const installer = new FumadocsComponentInstaller(connector, config);
const subRegistry = UIRegistries[config.uiLibrary];
if (input.length === 0) {
const spin = spinner();
spin.start('fetching registry');
const info = await client.fetchRegistryInfo();
const options: {
label: string;
value: string;
hint?: string;
}[] = [];
for (const item of info.indexes) {
options.push({
label: item.title ?? item.name,
value: item.name,
hint: item.description,
});
}
const { indexes } = await client.createLinkedRegistryClient(registry).fetchRegistryInfo();
async function scan(subRegistry?: string): Promise<AddOption[]> {
const info = await connector.fetchRegistryInfo(subRegistry);
for (const item of indexes) {
options.push({
return info.indexes.map((item) => ({
label: item.title ?? item.name,
value: `${registry}/${item.name}`,
value: { name: item.name, subRegistry },
hint: item.description,
});
}));
}
spin.stop(picocolors.bold(picocolors.greenBright('registry fetched')));
const value = await autocompleteMultiselect({
message: 'Select components to install',
options,
options: [...(await scan()), ...(await scan(subRegistry))],
});
if (isCancel(value)) {
@@ -60,76 +55,59 @@ export async function add(input: string[], client: RegistryClient) {
return;
}
target = value;
targets = value;
} else {
target = await Promise.all(
input.map(async (item) => ((await client.hasComponent(item)) ? item : `${registry}/${item}`)),
targets = await Promise.all(
input.map(async (item) =>
(await connector.hasComponent(item)) ? { name: item } : { subRegistry, name: item },
),
);
}
await install(target, installer);
await install(targets, installer);
}
export async function install(target: string[], installer: ComponentInstaller) {
for (const name of target) {
export async function install(targets: Target[], installer: FumadocsComponentInstaller) {
for (const target of targets) {
const spin = spinner();
spin.start(picocolors.bold(picocolors.cyanBright(`Installing ${name}`)));
spin.start(picocolors.bold(picocolors.cyanBright(`Installing ${target.name}`)));
try {
await installer.install(name, {
onWarn(message) {
spin.message(message);
},
async confirmFileOverride(options) {
spin.clear();
const value = await confirm({
message: `Do you want to override ${options.path}?`,
initialValue: false,
installer.installing = { name: target.name, spin };
const deps = await installer
.install(target.name, target.subRegistry)
.then((res) => res.deps());
spin.stop(picocolors.bold(picocolors.greenBright(`${target.name} installed`)));
if (deps.hasRequired()) {
log.message();
box([...deps.dependencies, ...deps.devDependencies].join('\n'), 'New Dependencies');
const pm = (await detect())?.name ?? 'npm';
const value = await confirm({
message: `Do you want to install with ${pm}?`,
});
if (isCancel(value)) {
outro('Installation terminated');
process.exit(0);
}
if (value) {
const spin = spinner({
errorMessage: 'Failed to install dependencies',
});
if (isCancel(value)) {
outro('Installation terminated');
process.exit(0);
}
spin.start(picocolors.bold(picocolors.cyanBright(`Installing ${name}`)));
return value;
},
onFileDownloaded(options) {
spin.message(options.path);
},
});
spin.stop(picocolors.bold(picocolors.greenBright(`${name} installed`)));
spin.start('Installing dependencies');
await deps.installRequired(pm);
spin.stop('Dependencies installed');
} else {
await deps.writeRequired();
}
}
} catch (e) {
spin.error(e instanceof Error ? e.message : String(e));
process.exit(-1);
}
}
const deps = await installer.deps();
if (deps.hasRequired()) {
log.message();
box([...deps.dependencies, ...deps.devDependencies].join('\n'), 'New Dependencies');
const pm = (await detect())?.name ?? 'npm';
const value = await confirm({
message: `Do you want to install with ${pm}?`,
});
if (isCancel(value)) {
outro('Installation terminated');
process.exit(0);
}
if (value) {
const spin = spinner({
errorMessage: 'Failed to install dependencies',
});
spin.start('Installing dependencies');
await deps.installRequired(pm);
spin.stop('Dependencies installed');
} else {
await deps.writeRequired();
}
}
await installer.onEnd();
outro(picocolors.bold(picocolors.greenBright('Successful')));
}
+18 -20
View File
@@ -1,13 +1,13 @@
import { cancel, group, intro, log, outro, select } from '@clack/prompts';
import picocolors from 'picocolors';
import { install } from '@/commands/add';
import type { RegistryClient } from '@/registry/client';
import { ComponentInstaller } from '@/registry/installer';
import { install, Target } from '@/commands/add';
import { UIRegistries } from '@/commands/shared';
import { pluginPreserveLayouts } from '@/registry/plugins/preserve';
import { LoadedConfig } from '@/config';
import { RegistryConnector } from 'fuma-cli/registry/connector';
import { FumadocsComponentInstaller } from '@/registry/utils';
interface TargetInfo {
target: string[];
targets: Target[];
id: string;
print?: () => void;
}
@@ -19,14 +19,12 @@ interface SlotPrintInfo {
isPage: boolean;
}
export async function customise(client: RegistryClient) {
export async function customise(config: LoadedConfig, connector: RegistryConnector) {
intro(picocolors.bgBlack(picocolors.whiteBright('Customise Fumadocs UI')));
const config = client.config;
const installer = new ComponentInstaller(client, {
plugins: [pluginPreserveLayouts()],
});
const registry = UIRegistries[config.uiLibrary];
const info = await client.createLinkedRegistryClient(registry).fetchRegistryInfo();
const installer = new FumadocsComponentInstaller(connector, config);
const subRegistry = UIRegistries[config.uiLibrary];
const info = await connector.fetchRegistryInfo(subRegistry);
const result = await group(
{
@@ -38,7 +36,7 @@ export async function customise(client: RegistryClient) {
label: 'Docs Layout',
value: {
id: 'docs',
target: [`${registry}/layouts/docs`],
targets: [{ subRegistry, name: 'layouts/docs' }],
print() {
printLayout(
['fumadocs-ui/layouts/docs', '@/layouts/docs'],
@@ -52,7 +50,7 @@ export async function customise(client: RegistryClient) {
label: 'Notebook Layout',
value: {
id: 'notebook',
target: [`${registry}/layouts/notebook`],
targets: [{ subRegistry, name: 'layouts/notebook' }],
print() {
printLayout(
['fumadocs-ui/layouts/notebook', '@/layouts/notebook'],
@@ -66,7 +64,7 @@ export async function customise(client: RegistryClient) {
label: 'Flux Layout',
value: {
id: 'flux',
target: [`${registry}/layouts/flux`],
targets: [{ subRegistry, name: 'layouts/flux' }],
print() {
printLayout(
['fumadocs-ui/layouts/flux', '@/layouts/flux'],
@@ -80,7 +78,7 @@ export async function customise(client: RegistryClient) {
label: 'Home Layout',
value: {
id: 'home',
target: [`${registry}/layouts/home`],
targets: [{ subRegistry, name: 'layouts/home' }],
print() {
printLayout(['fumadocs-ui/layouts/home', `@/layouts/home`]);
},
@@ -106,7 +104,7 @@ export async function customise(client: RegistryClient) {
hint: 'for those who want to build their own UI from ground up',
value: {
id: 'docs-min',
target: ['layouts/docs-min'],
targets: [{ name: 'layouts/docs-min' }],
print() {
printLayout(
['fumadocs-ui/layouts/docs', '@/layouts/docs'],
@@ -128,7 +126,7 @@ export async function customise(client: RegistryClient) {
hint: "only replace a part of layout's page, useful for adjusting details",
value: {
id: index.name,
target: [`${registry}/${index.name}`],
targets: [{ subRegistry, name: index.name }],
print() {
printSlot({
at: `@/layouts/${selected.id}/page/slots/${name}`,
@@ -146,7 +144,7 @@ export async function customise(client: RegistryClient) {
hint: 'only replace a part of layout, useful for adjusting details',
value: {
id: index.name,
target: [`${registry}/${index.name}`],
targets: [{ subRegistry, name: index.name }],
print() {
printSlot({
at: `@/layouts/${selected.id}/slots/${name}`,
@@ -171,7 +169,7 @@ export async function customise(client: RegistryClient) {
);
const target = result.target as TargetInfo;
await install(target.target, installer);
await install(target.targets, installer);
target.print?.();
outro(picocolors.bold('Have fun!'));
+10 -8
View File
@@ -3,14 +3,14 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { Command } from 'commander';
import picocolors from 'picocolors';
import { createOrLoadConfig, initConfig, type LoadedConfig } from '@/config';
import { createOrLoadConfig, initConfig } from '@/config';
import { type JsonTreeNode, treeToJavaScript, treeToMdx } from '@/commands/file-tree';
import { runTree } from '@/utils/file-tree/run-tree';
import packageJson from '../package.json';
import { customise } from '@/commands/customise';
import { add } from '@/commands/add';
import { exportEpub } from '@/commands/export-epub';
import { HttpRegistryClient, LocalRegistryClient } from '@/registry/client';
import { HttpRegistryConnector, LocalRegistryConnector } from 'fuma-cli/registry/connector';
const program = new Command().option('--config <string>');
@@ -32,7 +32,8 @@ program
.description('simple way to customise layouts with Fumadocs UI')
.option('--dir <string>', 'the root url or directory to resolve registry')
.action(async (options: { config?: string; dir?: string }) => {
await customise(createClientFromDir(options.dir, await createOrLoadConfig(options.config)));
const config = await createOrLoadConfig(options.config);
await customise(config, createClientFromDir(options.dir));
});
const dirShortcuts: Record<string, string> = {
@@ -46,8 +47,9 @@ program
.argument('[components...]', 'components to download')
.option('--dir <string>', 'the root url or directory to resolve registry')
.action(async (input: string[], options: { config?: string; dir?: string }) => {
const client = createClientFromDir(options.dir, await createOrLoadConfig(options.config));
await add(input, client);
const config = await createOrLoadConfig(options.config);
const client = createClientFromDir(options.dir);
await add(input, client, config);
});
const exportCmd = program.command('export').description('export documentation to various formats');
@@ -103,12 +105,12 @@ program
},
);
function createClientFromDir(dir = 'https://fumadocs.dev/registry', config: LoadedConfig) {
function createClientFromDir(dir = 'https://fumadocs.dev/registry') {
if (dir in dirShortcuts) dir = dirShortcuts[dir];
return dir.startsWith('http://') || dir.startsWith('https://')
? new HttpRegistryClient(dir, config)
: new LocalRegistryClient(dir, config);
? new HttpRegistryConnector(dir)
: new LocalRegistryConnector(dir);
}
program.parse();
-125
View File
@@ -1,125 +0,0 @@
import {
type DownloadedRegistryInfo,
type Component,
componentSchema,
registryInfoSchema,
} from '@/registry/schema';
import path from 'node:path';
import fs from 'node:fs/promises';
import type { LoadedConfig } from '@/config';
import { createCache } from '@/utils/cache';
export interface RegistryClient {
readonly registryId: string;
readonly config: LoadedConfig;
fetchRegistryInfo: () => Promise<DownloadedRegistryInfo>;
fetchComponent: (name: string) => Promise<Component>;
hasComponent: (name: string) => Promise<boolean>;
createLinkedRegistryClient: (registryName: string) => RegistryClient;
}
const fetchCache = createCache<unknown>();
export class HttpRegistryClient implements RegistryClient {
readonly registryId: string;
constructor(
readonly baseUrl: string,
readonly config: LoadedConfig,
) {
this.registryId = baseUrl;
}
async fetchRegistryInfo(baseUrl = this.baseUrl) {
const url = new URL('_registry.json', `${baseUrl}/`);
return fetchCache.$value<DownloadedRegistryInfo>().cached(url.href, async () => {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`failed to fetch ${url.href}: ${res.statusText}`);
}
return registryInfoSchema.parse(await res.json());
});
}
async fetchComponent(name: string) {
const url = new URL(`${name}.json`, `${this.baseUrl}/`);
return fetchCache.$value<Component>().cached(url.href, async () => {
const res = await fetch(`${this.baseUrl}/${name}.json`);
if (!res.ok) {
if (res.status === 404) {
throw new Error(`component ${name} not found at ${url.href}`);
}
throw new Error(await res.text());
}
return componentSchema.parse(await res.json());
});
}
async hasComponent(name: string) {
const url = new URL(`${name}.json`, `${this.baseUrl}/`);
const res = await fetch(url, { method: 'HEAD' });
return res.ok;
}
createLinkedRegistryClient(name: string) {
return new HttpRegistryClient(`${this.baseUrl}/${name}`, this.config);
}
}
export class LocalRegistryClient implements RegistryClient {
readonly registryId: string;
private registryInfo: DownloadedRegistryInfo | undefined;
constructor(
private readonly dir: string,
readonly config: LoadedConfig,
) {
this.registryId = dir;
}
async fetchRegistryInfo(dir = this.dir) {
if (this.registryInfo) return this.registryInfo;
const filePath = path.join(dir, '_registry.json');
const out = await fs
.readFile(filePath)
.then((res) => JSON.parse(res.toString()))
.catch((e) => {
throw new Error(`failed to resolve local file "${filePath}"`, {
cause: e,
});
});
return (this.registryInfo = registryInfoSchema.parse(out));
}
async fetchComponent(name: string) {
const filePath = path.join(this.dir, `${name}.json`);
const out = await fs
.readFile(filePath)
.then((res) => JSON.parse(res.toString()))
.catch((e) => {
throw new Error(`component ${name} not found at ${filePath}`, { cause: e });
});
return componentSchema.parse(out);
}
async hasComponent(name: string) {
const filePath = path.join(this.dir, `${name}.json`);
try {
await fs.stat(filePath);
return true;
} catch {
return false;
}
}
createLinkedRegistryClient(name: string) {
return new LocalRegistryClient(path.join(this.dir, name), this.config);
}
}
@@ -1,91 +0,0 @@
import fs from 'node:fs/promises';
import { x } from 'tinyexec';
import path from 'node:path';
import { detect, type AgentName } from 'package-manager-detector';
interface PackageJsonType {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}
export async function createDeps(
cwd: string,
dependencies: Record<string, string | null>,
devDependencies: Record<string, string | null>,
) {
const packageJsonPath = path.join(cwd, 'package.json');
const packageJson = await fs
.readFile(packageJsonPath)
.then((res) => JSON.parse(res.toString()) as PackageJsonType)
.catch(() => null);
return new DependencyManager(cwd, packageJson, dependencies, devDependencies);
}
export class DependencyManager {
readonly dependencies: string[];
readonly devDependencies: string[];
constructor(
private readonly cwd: string,
private readonly packageJson: PackageJsonType | null,
dependencies: Record<string, string | null>,
devDependencies: Record<string, string | null>,
) {
const installedDeps = {
...packageJson?.dependencies,
...packageJson?.devDependencies,
};
this.dependencies = Object.entries(dependencies)
.filter(([k]) => !(k in installedDeps))
.map(([k, v]) => encodeDep(k, v));
this.devDependencies = Object.entries(devDependencies)
.filter(([k]) => !(k in installedDeps))
.map(([k, v]) => encodeDep(k, v));
}
hasRequired() {
return this.dependencies.length > 0 || this.devDependencies.length > 0;
}
async writeRequired(packageJsonPath = path.resolve(this.cwd, 'package.json')) {
if (this.packageJson === null) return false;
for (const dep of this.dependencies) {
const { name, version } = decodeDep(dep);
this.packageJson.dependencies ??= {};
this.packageJson.dependencies[name] ??= version;
}
for (const dep of this.devDependencies) {
const { name, version } = decodeDep(dep);
this.packageJson.devDependencies ??= {};
this.packageJson.devDependencies[name] ??= version;
}
await fs.writeFile(packageJsonPath, JSON.stringify(this.packageJson, null, 2));
}
async installRequired(packageManager?: AgentName) {
packageManager ??= (await detect())?.name ?? 'npm';
if (this.dependencies.length > 0) await x(packageManager, ['install', ...this.dependencies]);
if (this.devDependencies.length > 0)
await x(packageManager, ['install', ...this.devDependencies, '-D']);
}
}
function encodeDep(name: string, version: string | null): string {
return version === null || version.length === 0 ? name : `${name}@${version}`;
}
function decodeDep(dep: string) {
const idx = dep.indexOf('@', 1);
if (idx === -1) {
return { name: dep, version: 'latest' };
} else {
return { name: dep.slice(0, idx), version: dep.slice(idx + 1) };
}
}
@@ -1,351 +0,0 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import { typescriptExtensions } from '@/constants';
import { toImportSpecifier, transformSpecifiers } from '@/utils/ast';
import type { Component, File } from '@/registry/schema';
import { HttpRegistryClient, type RegistryClient } from '@/registry/client';
import { x } from 'tinyexec';
import { createDeps } from '@/registry/installer/dep-manager';
import { createCache } from '@/utils/cache';
import { parse } from 'oxc-parser';
import MagicString from 'magic-string';
import { decodeImport, encodeImport } from '../protocols/import';
import type { Awaitable } from '@/types';
import { transformRouteHandler } from '@/registry/macros/route-handler.build';
import {
addReactRouterRouteToFile,
resolveReactRouterRoute,
resolveRouteFilePath,
} from '@/utils/framework';
interface PluginContext {
installer: ComponentInstaller;
}
interface TransformContext extends PluginContext, InstallContext {
file: File;
filePath: string;
component: DownloadedComponent;
}
interface InstallContext {
io: IOInterface;
importLookup: Map<string, File>;
/** full variables of the current component. */
$variables: Record<string, unknown>;
/** the last item is always the current component. */
stack: DownloadedComponent[];
}
interface DownloadedComponent extends Component {
$subComponents: DownloadedComponent[];
$registry: RegistryClient;
}
export interface ComponentInstallerPlugin {
transform?: (file: string, context: TransformContext) => Awaitable<string>;
transformImport?: (specifier: string, context: TransformContext) => string;
/**
* transform component before install
*/
beforeInstall?: (
comp: DownloadedComponent,
context: InstallContext & PluginContext,
) => Awaitable<DownloadedComponent | undefined>;
beforeDownload?: (
context: PluginContext & {
name: string;
},
) => void | Promise<void>;
afterDownload?: (
context: PluginContext & {
name: string;
result: DownloadedComponent;
},
) => void | Promise<void>;
}
export interface IOInterface {
onWarn: (message: string) => void;
confirmFileOverride: (options: { path: string }) => Promise<boolean>;
onFileDownloaded: (options: { path: string; file: File; component: Component }) => void;
}
export interface ComponentInstallerOptions {
plugins?: ComponentInstallerPlugin[];
cwd?: string;
}
export class ComponentInstaller {
private readonly installedFiles = new Set<string>();
private readonly downloadCache = createCache<DownloadedComponent>();
private readonly cwd: string;
private readonly plugins: ComponentInstallerPlugin[];
readonly dependencies: Record<string, string | null> = {};
readonly devDependencies: Record<string, string | null> = {};
constructor(
private readonly rootClient: RegistryClient,
options: ComponentInstallerOptions = {},
) {
this.cwd = options.cwd ?? process.cwd();
this.plugins = options.plugins ?? [];
}
private async installComponent(comp: DownloadedComponent, ctx: InstallContext) {
// avoid circular refs
if (ctx.stack.indexOf(comp) !== ctx.stack.length - 1) return;
const pluginCtx = { installer: this, ...ctx };
for (const plugin of this.plugins) {
comp = (await plugin.beforeInstall?.(comp, pluginCtx)) ?? comp;
}
Object.assign(this.dependencies, comp.dependencies);
Object.assign(this.devDependencies, comp.devDependencies);
for (const file of comp.files) {
const outPath = this.resolveOutputPath(file);
if (this.installedFiles.has(outPath)) continue;
this.installedFiles.add(outPath);
const output = typescriptExtensions.includes(path.extname(outPath))
? await this.transform(file, comp, ctx)
: file.content;
const status = await fs
.readFile(outPath)
.then((res) => {
if (res.toString().trim() === output.trim()) return 'ignore';
return 'need-update';
})
.catch(() => 'write');
if (status === 'ignore') continue;
if (status === 'need-update') {
const override = await ctx.io.confirmFileOverride({ path: outPath });
if (!override) continue;
}
await fs.mkdir(path.dirname(outPath), { recursive: true });
await fs.writeFile(outPath, output);
ctx.io.onFileDownloaded({ path: outPath, file, component: comp });
}
for (const child of comp.$subComponents) {
const stack = [...ctx.stack, child];
const variables = { ...ctx.$variables };
if (child.$registry.registryId !== comp.$registry.registryId) {
const info = await child.$registry.fetchRegistryInfo();
Object.assign(variables, info.variables);
}
Object.assign(variables, child.variables);
await this.installComponent(child, { ...ctx, stack, $variables: variables });
}
}
async install(name: string, io: IOInterface) {
let downloaded: DownloadedComponent;
// detect linked registry
const rootInfo = await this.rootClient.fetchRegistryInfo();
const registry = rootInfo.registries?.find((registry) => name.startsWith(`${registry}/`));
if (registry) {
downloaded = await this.download(
name.slice(registry.length + 1),
this.rootClient.createLinkedRegistryClient(registry),
);
} else {
downloaded = await this.download(name, this.rootClient);
}
const allComponents = new Set<DownloadedComponent>();
function scan(comp: DownloadedComponent) {
if (allComponents.has(comp)) return;
allComponents.add(comp);
for (const child of comp.$subComponents) scan(child);
}
scan(downloaded);
const importLookup = new Map<string, File>();
for (const comp of allComponents) {
for (const file of comp.files) {
importLookup.set(encodeImport(file), file);
}
}
const info = await downloaded.$registry.fetchRegistryInfo();
await this.installComponent(downloaded, {
importLookup,
io,
$variables: { ...info.env, ...downloaded.variables },
stack: [downloaded],
});
}
deps() {
return createDeps(this.cwd, this.dependencies, this.devDependencies);
}
async onEnd() {
const config = this.rootClient.config;
if (config.commands.format) {
await x(config.commands.format);
}
}
/**
* download component & its sub components
*/
private async download(name: string, client: RegistryClient): Promise<DownloadedComponent> {
return this.downloadCache.cached(
JSON.stringify([client.registryId, name]),
async (presolve) => {
for (const plugin of this.plugins) {
await plugin.beforeDownload?.({
installer: this,
name,
});
}
const comp = await client.fetchComponent(name);
const result: DownloadedComponent = {
...comp,
$registry: client,
$subComponents: [],
};
// place it before downloading child components to avoid recursive downloads
presolve(result);
result.$subComponents = await Promise.all(
comp.subComponents.map((sub) => {
if (typeof sub === 'string') return this.download(sub, client);
let subClient: RegistryClient;
if (this.rootClient instanceof HttpRegistryClient) {
const baseUrl = new URL(sub.baseUrl, `${this.rootClient.baseUrl}/`).href;
subClient =
client instanceof HttpRegistryClient && client.baseUrl === baseUrl
? client
: new HttpRegistryClient(baseUrl, client.config);
} else {
subClient = new HttpRegistryClient(sub.baseUrl, client.config);
}
return this.download(sub.component, subClient);
}),
);
for (const plugin of this.plugins) {
await plugin.afterDownload?.({
installer: this,
name,
result,
});
}
return result;
},
);
}
private async transform(
file: File,
component: DownloadedComponent,
ctx: InstallContext,
): Promise<string> {
const filePath = this.resolveOutputPath(file);
const transformCtx: TransformContext = { installer: this, file, filePath, component, ...ctx };
let transformed = await this.defaultTransform(file.content, transformCtx);
for (const plugin of this.plugins) {
if (!plugin.transform) continue;
transformed = await plugin.transform(transformed, transformCtx);
}
return transformed;
}
private async defaultTransform(content: string, ctx: TransformContext) {
const { file, importLookup, filePath, $variables, io } = ctx;
const config = this.rootClient.config;
const parsed = await parse(filePath, content);
const s = new MagicString(content);
transformSpecifiers(parsed.program, s, (specifier) => {
for (const plugin of this.plugins) {
if (plugin.transformImport) {
specifier = plugin.transformImport(specifier, ctx);
}
}
if (importLookup.has(specifier)) {
let outputPath = this.resolveOutputPath(importLookup.get(specifier)!);
for (const [k, v] of Object.entries($variables)) {
if (typeof v === 'string') outputPath = outputPath.replaceAll(`<${k}>`, v);
}
return toImportSpecifier(filePath, outputPath);
}
const decoded = decodeImport(specifier);
if ('raw' in decoded) {
return decoded.raw;
}
io.onWarn(`cannot find the referenced file of ${specifier}`);
return specifier;
});
if (file.type === 'route-handler') {
transformRouteHandler(file.route, filePath, config.framework, parsed.program, s);
if (config.framework === 'react-router') {
const routesFile = path.join(this.cwd, 'app/routes.ts');
const content = await fs
.readFile(routesFile, 'utf-8')
.then((res) => res.toString())
.catch(() => null);
if (content)
await addReactRouterRouteToFile(routesFile, content, {
path: resolveReactRouterRoute(file.route),
module: path.relative(path.dirname(routesFile), filePath),
});
}
}
return s.toString();
}
private resolveOutputPath(file: File): string {
const config = this.rootClient.config;
if (file.type === 'route-handler') {
const rel = resolveRouteFilePath(file.route, config.framework, 'ts');
return path.resolve(this.cwd, config.baseDir, rel);
}
const dir = (
{
components: config.aliases.componentsDir,
ui: config.aliases.uiDir,
css: config.aliases.cssDir,
lib: config.aliases.libDir,
layout: config.aliases.layoutDir,
} as const
)[file.type];
if (file.target) {
return path.resolve(this.cwd, config.baseDir, file.target.replace('<dir>', dir));
}
return path.resolve(this.cwd, config.baseDir, dir, path.basename(file.path));
}
}
@@ -1,472 +0,0 @@
import path from 'node:path';
import MagicString from 'magic-string';
import { Visitor } from 'oxc-parser';
import type {
Argument,
CallExpression,
Expression,
ImportDeclaration,
ObjectExpression,
ParamPattern,
Program,
Statement,
} from '@oxc-project/types';
import type { Framework } from '@/config';
import type { RouteHandlerHttpMethod, StaticInfo } from './route-handler';
import { dedent, indent } from '@/utils/format';
import { collectMacroBindings } from '@/utils/ast';
const reactRouterLoaderMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
const reactRouterActionMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export type ParsedRouteInfo = StaticInfo<string, string | undefined>;
function collectRouteHandlerCalls(program: Program, locals: Set<string>): CallExpression[] {
const calls: CallExpression[] = [];
new Visitor({
CallExpression(node: CallExpression) {
if (node.callee.type !== 'Identifier') return;
if (!locals.has(node.callee.name)) return;
calls.push(node);
},
}).visit(program);
return calls;
}
function isSameCall(init: Expression | null | undefined, call: CallExpression): boolean {
if (!init || init.type !== 'CallExpression') return false;
return init.start === call.start && init.end === call.end;
}
function findStatementSpanForCall(
program: Program,
call: CallExpression,
): { start: number; end: number } | null {
for (const stmt of program.body) {
const span = statementSpanIfContainsCall(stmt, call);
if (span) return span;
}
return null;
}
function statementSpanIfContainsCall(
stmt: Statement,
call: CallExpression,
): { start: number; end: number } | null {
if (stmt.type === 'ExportNamedDeclaration' && stmt.declaration?.type === 'VariableDeclaration') {
for (const d of stmt.declaration.declarations) {
if (isSameCall(d.init, call)) return { start: stmt.start, end: stmt.end };
}
}
if (stmt.type === 'VariableDeclaration') {
for (const d of stmt.declarations) {
if (isSameCall(d.init, call)) return { start: stmt.start, end: stmt.end };
}
}
return null;
}
function objectPropertyKeyName(key: ObjectExpression['properties'][number]): string | null {
if (key.type !== 'Property') return null;
const k = key.key;
if (k.type === 'Identifier') return k.name;
if (k.type === 'Literal' && typeof k.value === 'string') return k.value;
return null;
}
/**
* Read `methods`, `params`, and optional `catchAll` from the `$routeHandler` info object literal (AST).
*/
function parseRouteInfoFromAst(info: ObjectExpression): ParsedRouteInfo {
let methods: string[] | undefined;
let params: string[] | undefined;
let catchAll: string | undefined;
for (const prop of info.properties) {
if (prop.type !== 'Property') continue;
const name = objectPropertyKeyName(prop);
if (name === 'methods') {
if (prop.value.type !== 'ArrayExpression') {
throw new Error(
'route-handler.build: `methods` in $routeHandler info must be an array literal',
);
}
const out: string[] = [];
for (const el of prop.value.elements) {
if (el == null) continue;
if (el.type === 'Literal' && typeof el.value === 'string') {
out.push(el.value);
continue;
}
throw new Error(
'route-handler.build: `methods` must be string literals (e.g. methods: ["GET", "POST"])',
);
}
methods = out;
}
if (name === 'params') {
if (prop.value.type !== 'ArrayExpression') {
throw new Error('route-handler.build: `params` must be an array literal');
}
const out: string[] = [];
for (const el of prop.value.elements) {
if (el == null) continue;
if (el.type === 'Literal' && typeof el.value === 'string') {
out.push(el.value);
continue;
}
throw new Error('route-handler.build: `params` must be string literals');
}
params = out;
}
if (name === 'catchAll') {
if (prop.value.type === 'Literal' && typeof prop.value.value === 'string') {
catchAll = prop.value.value;
continue;
}
throw new Error('route-handler.build: `catchAll` must be a string literal');
}
}
if (!methods?.length) {
throw new Error(
'route-handler.build: $routeHandler info must include a non-empty `methods` array',
);
}
if (!params) {
throw new Error(
'route-handler.build: $routeHandler info must include a `params` array literal',
);
}
return { methods: methods as RouteHandlerHttpMethod[], params, catchAll };
}
function needsRouteParams(info: ParsedRouteInfo): boolean {
return info.params.length > 0 || Boolean(info.catchAll);
}
function encodeKey(key: string): string {
if (key.includes('-')) {
return JSON.stringify(key);
}
return key;
}
function bindingNameFromParam(p: ParamPattern): string | null {
if (p.type === 'Identifier') return p.name;
return null;
}
interface HandlerInfo {
requestName: string;
paramsName: string | null;
bodyText: string;
}
function parseHandlerFromAst(s: MagicString, expr: Argument): HandlerInfo {
const isFn = expr.type === 'FunctionExpression';
const isArrow = expr.type === 'ArrowFunctionExpression';
if (!isFn && !isArrow) {
throw new Error(
'route-handler.build: second argument to $routeHandler must be a function or async arrow function',
);
}
const fn = expr;
if (!fn.async) {
throw new Error('route-handler.build: route handler must be async');
}
if (isFn && (!fn.body || fn.body.type !== 'BlockStatement')) {
throw new Error('route-handler.build: function handler must use a block body');
}
const p0 = fn.params[0];
const p1 = fn.params[1];
if (fn.params.length > 2) {
throw new Error('route-handler.build: route handler must have at most two parameters');
}
const requestName = (p0 && bindingNameFromParam(p0)) || 'request';
const paramsName = p1 ? bindingNameFromParam(p1) : null;
if (p1 && !paramsName) {
throw new Error(
'route-handler.build: unsupported parameter pattern; use a simple identifier for (request, params)',
);
}
const body = fn.body;
let bodyText: string;
if (body == null) {
throw new Error('route-handler.build: handler has no body');
} else if (body.type === 'BlockStatement') {
bodyText = s.original
.slice(body.start + 1, body.end - 1)
.replace(/^\s*?\n/, '')
.replace(/\n\s*?$/, '');
} else if (fn.type === 'ArrowFunctionExpression') {
const expr = s.original.slice(body.start, body.end);
bodyText = `return ${expr};`;
} else {
throw new Error('route-handler.build: could not extract handler body');
}
return { requestName, paramsName, bodyText };
}
function generateRequestDeclaration(framework: Framework, binding: string): string {
if (framework === 'tanstack-start') {
if (binding === 'ctx') {
throw new Error(
'route-handler.build: name the request parameter something other than `ctx` for TanStack file routes',
);
}
return `const ${binding} = ctx.request;\n`;
}
if (framework === 'react-router') {
if (binding === 'args') {
throw new Error(
'route-handler.build: name the request parameter something other than `args` for React Router resource routes',
);
}
return `const ${binding} = args.request;\n`;
}
return '';
}
function generateParamsDeclaration(
framework: Framework,
info: ParsedRouteInfo,
paramsBinding: string,
): string {
let paramsIdentifier: string;
let paramsCatchAllIdentifier: string;
switch (framework) {
// no renames
case 'next':
return `const ${paramsBinding} = await ctx.params;\n`;
case 'waku':
return `const ${paramsBinding} = context.params;\n`;
case 'react-router':
paramsIdentifier = 'args.params';
paramsCatchAllIdentifier = "args.params['*']";
break;
case 'tanstack-start':
paramsIdentifier = 'ctx.params';
paramsCatchAllIdentifier = 'ctx.params._splat';
break;
}
const parts: string[] = [];
for (const k of info.params) {
parts.push(`${encodeKey(k)}: ${paramsIdentifier}.${k}`);
}
if (info.catchAll) {
parts.push(`${encodeKey(info.catchAll)}: ${paramsCatchAllIdentifier}`);
}
return `const ${paramsBinding} = {\n${indent(parts.join(',\n'))}\n};\n`;
}
function resolveParamsBindingName(info: ParsedRouteInfo, userSecond: string | null): string | null {
if (!needsRouteParams(info)) return null;
return userSecond ?? 'params';
}
function registryRouteToTanStackCreateFileRoutePath(route: string): string {
const segments = route.split('/');
const parts: string[] = [];
for (const seg of segments) {
if (/^\[\[\.\.\.[^/\]]+\]\]$/.test(seg) || /^\[\.\.\.[^/\]]+\]$/.test(seg)) {
parts.push('$');
continue;
}
const m = /^\[([^/\]]+)\]$/.exec(seg);
if (m) {
parts.push(`$${m[1]}`);
} else {
parts.push(seg);
}
}
return `/${parts.join('/')}`;
}
/** URL path for `RouteContext` / `ApiContext` string literals (leading slash, App Router–style segments). */
function registryRouteToUrlPath(route: string): string {
const t = route.replace(/^\/+/, '').replace(/\/+$/, '');
return t ? `/${t}` : '/';
}
/**
* React Router typegen: `./+types/<segments>` relative to the route file (see `+types` next to `routes/`).
*/
function computeReactRouterTypesSpecifier(routeFilePath: string): string {
return `./+types/${path.basename(routeFilePath, path.extname(routeFilePath))}`;
}
function generateImports(framework: Framework, routeFilePath: string): string {
const lines: string[] = [];
if (framework === 'tanstack-start') {
lines.push(`import { createFileRoute } from '@tanstack/react-router';`);
}
if (framework === 'waku') {
lines.push(`import type { ApiContext } from 'waku/router';`);
}
if (framework === 'react-router') {
lines.push(`import type { Route } from '${computeReactRouterTypesSpecifier(routeFilePath)}';`);
}
return lines.length ? `${lines.join('\n')}\n` : '';
}
function generateDeclaration(
framework: Framework,
route: string,
parsedInfo: ParsedRouteInfo,
handler: HandlerInfo,
): string {
const paramsBinding = resolveParamsBindingName(parsedInfo, handler.paramsName);
let inner = dedent(handler.bodyText);
if (paramsBinding) {
inner = generateParamsDeclaration(framework, parsedInfo, paramsBinding) + inner;
}
inner = generateRequestDeclaration(framework, handler.requestName) + inner;
const urlPath = registryRouteToUrlPath(route);
const urlPathLiteral = JSON.stringify(urlPath);
switch (framework) {
case 'next': {
/** `RouteContext` is provided by Next.js typed routes / `next typegen` (global). */
const ctxType = `RouteContext<${urlPathLiteral}>`;
return parsedInfo.methods
.map(
(m) =>
`export async function ${m}(${handler.requestName}: Request, ctx: ${ctxType}) {\n${indent(inner)}\n}`,
)
.join('\n\n');
}
case 'waku': {
const ctxType = `ApiContext<${urlPathLiteral}>`;
return parsedInfo.methods
.map(
(m) =>
`export async function ${m}(${handler.requestName}: Request, context: ${ctxType}) {\n${indent(inner)}\n}`,
)
.join('\n\n');
}
case 'tanstack-start': {
const fileRoutePath = JSON.stringify(registryRouteToTanStackCreateFileRoutePath(route));
const entries = parsedInfo.methods
.map((m) => ` ${m}: async (ctx) => {\n${indent(inner, 4)}\n },`)
.join('\n');
return `export const Route = createFileRoute(${fileRoutePath})({\n server: {\n handlers: {\n${entries}\n },\n },\n});\n`;
}
case 'react-router': {
const includeLoader = parsedInfo.methods.some((x) => reactRouterLoaderMethods.has(x));
const includeAction = parsedInfo.methods.some((x) => reactRouterActionMethods.has(x));
if (!includeLoader && !includeAction) {
throw new Error(
'route-handler.build: react-router needs at least one method mapped to loader (GET/HEAD/OPTIONS) or action (POST/PUT/PATCH/DELETE)',
);
}
const parts: string[] = [];
if (includeLoader) {
parts.push(`export async function loader(args: Route.LoaderArgs) {\n${indent(inner)}\n}`);
}
if (includeAction) {
parts.push(`export async function action(args: Route.ActionArgs) {\n${indent(inner)}\n}`);
}
return `${parts.join('\n\n')}\n`;
}
}
}
function removeMacroImport(s: MagicString, importDecl: ImportDeclaration): void {
const start = importDecl.start;
let end = importDecl.end;
if (s.original[end] === '\n') end += 1;
s.remove(start, end);
}
/**
* Rewrites a module that calls `$routeHandler` into framework-native route code.
*
* Output does not import `@fumadocs/cli`.
*
* Uses framework typegen where applicable: global `RouteContext` (Next typed routes), `ApiContext` (Waku),
* inferred handler `ctx` (TanStack `createFileRoute`), `Route.LoaderArgs` / `Route.ActionArgs` (React Router `+types`).
*/
export function transformRouteHandler(
route: string,
routeFilePath: string,
framework: Framework,
program: Program,
s: MagicString,
) {
const macro = collectMacroBindings(program, '$routeHandler');
if (!macro) return;
const calls = collectRouteHandlerCalls(program, macro.locals);
if (calls.length === 0) return;
if (calls.length > 1) {
throw new Error('route-handler.build: expected exactly one $routeHandler(...) call per file');
}
const call = calls[0]!;
if (call.arguments.length !== 2) {
throw new Error('route-handler.build: $routeHandler must be called with (info, handler)');
}
const arg0 = call.arguments[0];
const arg1 = call.arguments[1];
if (arg0.type !== 'ObjectExpression') {
throw new Error(
'route-handler.build: first argument to $routeHandler must be an object literal',
);
}
const parsedInfo = parseRouteInfoFromAst(arg0);
const handler = parseHandlerFromAst(s, arg1);
const stmtSpan = findStatementSpanForCall(program, call);
if (!stmtSpan) {
throw new Error(
'route-handler.build: $routeHandler(...) must be the initializer of a const (optionally exported)',
);
}
for (const decl of macro.importDecls) {
removeMacroImport(s, decl);
}
const extraImports = generateImports(framework, routeFilePath);
if (extraImports) {
const insertPos = findInsertPositionForNewImports(program);
if (insertPos === 0) {
s.prepend(extraImports);
} else {
s.appendLeft(insertPos, `\n${extraImports}`);
}
}
s.overwrite(
stmtSpan.start,
stmtSpan.end,
generateDeclaration(framework, route, parsedInfo, handler),
);
s.trim();
}
function findInsertPositionForNewImports(program: Program): number {
const last = program.body.findLast(
(stmt): stmt is ImportDeclaration => stmt.type === 'ImportDeclaration',
);
if (last) return last.end;
return 0;
}
@@ -1,40 +0,0 @@
import type { Awaitable } from '@/types';
export type RouteHandlerHttpMethod =
| 'GET'
| 'HEAD'
| 'POST'
| 'PUT'
| 'PATCH'
| 'DELETE'
| 'OPTIONS';
export interface StaticInfo<Params extends string, CatchAll extends string | undefined> {
params: Params[];
catchAll?: CatchAll;
/**
* HTTP verbs this route implements.
*/
methods: readonly RouteHandlerHttpMethod[];
}
type RouteHandler<Params extends string, CatchAll extends string | undefined> = (
request: Request,
params: RouteHandlerParams<Params, CatchAll>,
) => Awaitable<Response>;
type RouteHandlerParams<Params extends string, CatchAll extends string | undefined> = Record<
Params,
string
> &
(CatchAll extends string ? Record<CatchAll, string[] | undefined> : unknown);
export function $routeHandler<
Params extends string,
CatchAll extends string | undefined = undefined,
>(info: StaticInfo<Params, CatchAll>, handler: RouteHandler<NoInfer<Params>, NoInfer<CatchAll>>) {
return {
info,
handler,
};
}
@@ -1,9 +1,9 @@
import type { ComponentInstallerPlugin } from '../installer';
import type { InstallerPlugin } from 'fuma-cli/registry/installer';
/**
* keep references to `fumadocs-ui/layouts/*` components as original, unless the user is installing them directly.
*/
export function pluginPreserveLayouts(): ComponentInstallerPlugin {
export function pluginPreserveLayouts(): InstallerPlugin {
const layoutNames = [
'layouts/home',
'layouts/flux',
@@ -1,32 +0,0 @@
import type { ComponentFile } from '@/build';
import type { CompiledFile } from '../schema';
export type ImportInfo =
| {
raw: string;
}
| {
target: string;
}
| {
route: string;
};
export function encodeImport(file: ComponentFile | CompiledFile): string {
if (file.type === 'route-handler') return `route-handler:${file.route}`;
return `:${file.target ?? file.path}`;
}
export function decodeImport(s: string): ImportInfo {
if (s.startsWith(':'))
return {
target: s.slice(1),
};
if (s.startsWith('route-handler:'))
return {
route: s.slice('route-handler:'.length),
};
return { raw: s };
}
-77
View File
@@ -1,77 +0,0 @@
import { z } from 'zod';
export type CompiledFile = z.input<typeof fileSchema>;
export type CompiledComponent = z.input<typeof componentSchema>;
export type CompiledRegistryInfo = z.input<typeof registryInfoSchema>;
export type DownloadedRegistryInfo = z.output<typeof registryInfoSchema>;
export type File = z.output<typeof fileSchema>;
export type Component = z.output<typeof componentSchema>;
export const indexSchema = z.object({
name: z.string(),
title: z.string().optional(),
description: z.string().optional(),
});
export const routeFileSchema = z.object({
type: z.literal('route-handler'),
route: z.string(),
content: z.string(),
});
export const fileSchema = z
.object({
type: z.literal(['components', 'lib', 'css', 'ui', 'layout']),
path: z.string(),
target: z.string().optional(),
content: z.string(),
})
.or(routeFileSchema);
export const httpSubComponent = z.object({
type: z.literal('http'),
baseUrl: z.string(),
component: z.string(),
});
export const componentSchema = z.object({
name: z.string(),
title: z.string().optional(),
description: z.string().optional(),
files: z.array(fileSchema),
dependencies: z.record(z.string(), z.string().or(z.null())),
devDependencies: z.record(z.string(), z.string().or(z.null())),
/**
* list of sub components, either local (component name) or remote (registry info & component name)
*/
subComponents: z.array(z.string().or(httpSubComponent)).default([]),
/**
* override variables for the current component & its sub components.
*
* this is powerful to customise how sub components are installed vs. installing them directly.
*/
variables: z.record(z.string(), z.unknown()).optional(),
});
export const registryInfoSchema = z.object({
/**
* define metadata for variables, variables can be referenced in the target path of component files, or in plugins.
*/
variables: z
.record(
z.string(),
z.object({
description: z.string().optional(),
}),
)
.optional(),
/**
* override variables for all components.
*/
env: z.record(z.string(), z.unknown()).optional(),
indexes: z.array(indexSchema).default([]),
unlistedIndexes: z.array(indexSchema).default([]),
registries: z.array(z.string()).optional(),
});
+51
View File
@@ -0,0 +1,51 @@
import { ComponentInstaller } from 'fuma-cli/registry/installer';
import { pluginPreserveLayouts } from './plugins/preserve';
import { RegistryConnector } from 'fuma-cli/registry/connector';
import type { LoadedConfig } from '@/config';
import { confirm, isCancel, outro, SpinnerResult } from '@clack/prompts';
import picocolors from 'picocolors';
export class FumadocsComponentInstaller extends ComponentInstaller {
installing?: {
name: string;
spin: SpinnerResult;
};
constructor(connector: RegistryConnector, config: LoadedConfig) {
super(connector, {
framework: config.framework,
outDir: {
base: config.baseDir,
components: config.aliases.componentsDir,
css: config.aliases.cssDir,
layout: config.aliases.layoutDir,
lib: config.aliases.libDir,
ui: config.aliases.uiDir,
},
io: {
onWarn: (message) => {
this.installing?.spin.message(message);
},
confirmFileOverride: async (options) => {
if (!this.installing) return true;
const { name, spin } = this.installing;
spin.clear();
const value = await confirm({
message: `Do you want to override ${options.path}?`,
initialValue: false,
});
if (isCancel(value)) {
outro('Installation terminated');
process.exit(0);
}
spin.start(picocolors.bold(picocolors.cyanBright(`Installing ${name}`)));
return value;
},
onFileDownloaded: (options) => {
this.installing?.spin.message(options.path);
},
},
plugins: [pluginPreserveLayouts()],
});
}
}
-224
View File
@@ -1,224 +0,0 @@
import fs from 'node:fs/promises';
import MagicString from 'magic-string';
import { parse } from 'oxc-parser';
import type {
Argument,
ArrayExpression,
ExportDefaultDeclarationKind,
Expression,
Program,
} from '@oxc-project/types';
import type { Framework } from '@/config';
/**
* Resolves the route file path (relative to the CLI `baseDir`) from an App Router–style route string.
*
* @param route - The route path, e.g., "api/auth/[[...slug]]".
* @param framework - Value of `framework` in CLI config.
* @param extension - Optional file extension, default is "ts".
* @returns Relative path incl. framework route root, e.g. "app/api/foo/route.ts" (next), "routes/api.foo.$.ts" (tanstack-start), or "routes/api/foo/$.ts" (react-router).
*/
export function resolveRouteFilePath(
route: string,
framework: Framework,
extension: string = 'ts',
): string {
route = route.replace(/^\/+/, '').replace(/\/+$/, '');
switch (framework) {
case 'next': {
return `app/${route}/route.${extension}`;
}
case 'tanstack-start': {
let flat = route
.replace(/\[\[\.\.\.[^/\]]+\]\]/g, '$')
.replace(/\[\.\.\.[^/\]]+\]/g, '$')
.replace(/\[([^/\]]+)\]/g, (_, p1) => `$${p1}`);
flat = flat.replaceAll('/', '.');
return `routes/${flat}.${extension}`;
}
case 'react-router': {
const rrPath = route
.replace(/\[\[\.\.\.[^/\]]+\]\]/g, 'all')
.replace(/\[\.\.\.[^/\]]+\]/g, 'all')
.replace(/\[([^/\]]+)\]/g, (_, v) => `$${v}`);
return `app/routes/${rrPath}.${extension}`;
}
case 'waku': {
const underApi = route
.replace(/\[\[\.\.\.([^\]]+)\]\]/g, '[...$1]')
.replace(/\[\.\.\.([^\]]+)\]/g, '[...$1]');
return `pages/_api/${underApi}.${extension}`;
}
default: {
const _exhaustive: never = framework;
return _exhaustive;
}
}
}
export function resolveReactRouterRoute(route: string): string {
return route
.replace(/\[\[\.\.\.[^/\]]+\]\]/g, '*')
.replace(/\[\.\.\.[^/\]]+\]/g, '*')
.replace(/\[([^/\]]+)\]/g, (_, v) => `:${v}`);
}
function tsSingleQuotedLiteral(value: string): string {
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
}
function unwrapExportDefaultToArrayExpression(
decl: ExportDefaultDeclarationKind,
): ArrayExpression | null {
if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') {
return null;
}
let expr = decl;
while (expr) {
if (expr.type === 'ArrayExpression') return expr;
if (expr.type === 'TSSatisfiesExpression' || expr.type === 'TSAsExpression') {
expr = expr.expression;
continue;
}
return null;
}
return null;
}
function parseStringArgument(expr: Argument | undefined): string | null {
if (!expr) return null;
if (expr.type === 'Literal' && typeof expr.value === 'string') return expr.value;
return null;
}
type ReactRouterRoutesItem =
| {
type: 'route';
path: string;
module: string;
expression: Expression;
}
| {
type: 'index';
module: string;
expression: Expression;
};
function parseReactRouterRoutesItem(item: Expression): ReactRouterRoutesItem | undefined {
if (!item || item.type !== 'CallExpression') return;
const callee = item.callee;
if (callee.type !== 'Identifier') return;
if (callee.name === 'route') {
const path = parseStringArgument(item.arguments[0]);
const module = parseStringArgument(item.arguments[1]);
if (path === null || module === null) return;
return {
type: 'route',
path,
module,
expression: item,
};
}
if (callee.name === 'index') {
const module = parseStringArgument(item.arguments[0]);
if (module === null) return;
return {
type: 'index',
module,
expression: item,
};
}
}
function findReactRouterRoutesArray(program: Program): ArrayExpression | null {
for (const stmt of program.body) {
if (stmt.type !== 'ExportDefaultDeclaration') continue;
if (stmt.declaration == null) continue;
return unwrapExportDefaultToArrayExpression(stmt.declaration);
}
return null;
}
export type AddReactRouterRouteToFileInput =
| { kind?: 'route'; path: string; module: string }
| { kind: 'index'; module: string };
export interface AddReactRouterRouteToFileResult {
/** False when an equivalent entry was already present. */
added: boolean;
content: string;
}
/**
* Inserts a `route(...)` or `index(...)` entry into a React Router `routes.ts` config
* (`export default [ ... ] satisfies RouteConfig`), writing the file in place.
*
* New `route` entries are placed before the first `route('*', ...)` splat (if any) so the
* catch-all remains last.
*/
export async function addReactRouterRouteToFile(
routesFilePath: string,
content: string,
spec: AddReactRouterRouteToFileInput,
write = true,
): Promise<AddReactRouterRouteToFileResult> {
const parsed = await parse(routesFilePath, content, {
lang: routesFilePath.endsWith('.tsx') ? 'tsx' : 'ts',
astType: 'ts',
});
if (parsed.errors.length > 0) {
throw new Error(
`addReactRouterRouteToFile: failed to parse ${routesFilePath}:\n${parsed.errors.map((e) => e.message).join('\n')}`,
);
}
const array = findReactRouterRoutesArray(parsed.program);
if (!array) {
throw new Error(
`addReactRouterRouteToFile: no export default array found in ${routesFilePath}`,
);
}
const elements: ReactRouterRoutesItem[] = [];
for (const v of array.elements) {
if (v && v.type !== 'SpreadElement') {
const parsed = parseReactRouterRoutesItem(v);
if (parsed) elements.push(parsed);
}
}
if (spec.kind === 'index') {
if (elements.some((element) => element.type === 'index')) {
return { added: false, content };
}
} else if (elements.some((element) => element.type === 'route' && element.path === spec.path)) {
return { added: false, content };
}
const line =
spec.kind === 'index'
? `index(${tsSingleQuotedLiteral(spec.module)})`
: `route(${tsSingleQuotedLiteral(spec.path)}, ${tsSingleQuotedLiteral(spec.module)})`;
const s = new MagicString(content);
const insertAt = elements.findLast(
(element) => element.type !== 'route' || !element.path.endsWith('*'),
);
if (!insertAt) {
s.appendRight(array.start + 1, `\n ${line},\n`);
} else {
s.appendRight(insertAt.expression.end, `,\n ${line}`);
}
const out = s.toString();
if (write) {
await fs.writeFile(routesFilePath, out, 'utf8');
}
return { added: true, content: out };
}
@@ -1,207 +0,0 @@
import { expect, test } from 'vitest';
import { addReactRouterRouteToFile } from '@/utils/framework';
/** Dry run only: never persist to disk. */
const shouldWrite = false;
const routesTs = 'app/routes.ts';
const routesTsx = 'app/routes.tsx';
const sample = `import { index, route, type RouteConfig } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('api/search', 'routes/search.ts'),
route('*', 'routes/not-found.tsx'),
] satisfies RouteConfig;
`;
test('inserts route before splat', async () => {
const r = await addReactRouterRouteToFile(
routesTs,
sample,
{ path: 'api/chat', module: 'routes/api.chat.ts' },
shouldWrite,
);
expect(r.added).toBe(true);
expect(r.content).toMatchInlineSnapshot(`
"import { index, route, type RouteConfig } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('api/search', 'routes/search.ts'),
route('api/chat', 'routes/api.chat.ts'),
route('*', 'routes/not-found.tsx'),
] satisfies RouteConfig;
"
`);
});
test('duplicate route returns added: false and original content', async () => {
const first = await addReactRouterRouteToFile(
routesTs,
sample,
{ path: 'api/chat', module: 'routes/api.chat.ts' },
shouldWrite,
);
expect(first.added).toBe(true);
const second = await addReactRouterRouteToFile(
routesTs,
first.content,
{ path: 'api/chat', module: 'routes/api.chat.ts' },
shouldWrite,
);
expect(second).toEqual({ added: false, content: first.content });
});
test('duplicate of existing route in source returns added: false', async () => {
const r = await addReactRouterRouteToFile(
routesTs,
sample,
{ path: 'api/search', module: 'routes/search.ts' },
shouldWrite,
);
expect(r).toEqual({ added: false, content: sample });
});
test('appends when no splat', async () => {
const noSplat = `import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
route('a', 'routes/a.ts'),
] satisfies RouteConfig;
`;
const r = await addReactRouterRouteToFile(
routesTs,
noSplat,
{ path: 'b', module: 'routes/b.ts' },
shouldWrite,
);
expect(r.content).toMatchInlineSnapshot(`
"import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
route('a', 'routes/a.ts'),
route('b', 'routes/b.ts'),
] satisfies RouteConfig;
"
`);
});
test('escapes single quotes in path and module', async () => {
const initial = `import { route, type RouteConfig } from '@react-router/dev/routes';
export default [] satisfies RouteConfig;
`;
const r = await addReactRouterRouteToFile(
routesTs,
initial,
{ path: "it's/x", module: "routes/x's.ts" },
shouldWrite,
);
expect(r.content).toMatchInlineSnapshot(`
"import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
route('it\\'s/x', 'routes/x\\'s.ts'),
] satisfies RouteConfig;
"
`);
});
test('inserts index route before splat', async () => {
const initial = `import { index, route, type RouteConfig } from '@react-router/dev/routes';
export default [
route('api/x', 'routes/x.ts'),
route('*', 'routes/not-found.tsx'),
] satisfies RouteConfig;
`;
const r = await addReactRouterRouteToFile(
routesTs,
initial,
{ kind: 'index', module: 'routes/landing.tsx' },
shouldWrite,
);
expect(r.added).toBe(true);
expect(r.content).toMatchInlineSnapshot(`
"import { index, route, type RouteConfig } from '@react-router/dev/routes';
export default [
route('api/x', 'routes/x.ts'),
index('routes/landing.tsx'),
route('*', 'routes/not-found.tsx'),
] satisfies RouteConfig;
"
`);
});
test('duplicate index returns added: false', async () => {
const initial = `import { index, type RouteConfig } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
] satisfies RouteConfig;
`;
expect(
await addReactRouterRouteToFile(
routesTs,
initial,
{ kind: 'index', module: 'routes/home.tsx' },
shouldWrite,
),
).toEqual({ added: false, content: initial });
});
test('unwraps export default array with as RouteConfig', async () => {
const initial = `import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
route('a', 'routes/a.ts'),
] as RouteConfig;
`;
const r = await addReactRouterRouteToFile(
routesTs,
initial,
{ path: 'b', module: 'routes/b.ts' },
shouldWrite,
);
expect(r.content).toMatchInlineSnapshot(`
"import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
route('a', 'routes/a.ts'),
route('b', 'routes/b.ts'),
] as RouteConfig;
"
`);
});
test('parses .tsx routes config path', async () => {
const initial = `import { route, type RouteConfig } from '@react-router/dev/routes';
export default [] satisfies RouteConfig;
`;
const r = await addReactRouterRouteToFile(
routesTsx,
initial,
{ path: 'x', module: 'routes/x.tsx' },
shouldWrite,
);
expect(r.content).toMatchInlineSnapshot(`
"import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
route('x', 'routes/x.tsx'),
] satisfies RouteConfig;
"
`);
});
test('throws when no export default route array', async () => {
const bad = `export const notRoutes = 1;\n`;
await expect(
addReactRouterRouteToFile(routesTs, bad, { path: 'a', module: 'routes/a.ts' }, shouldWrite),
).rejects.toThrow(/no export default array found/);
});
test('throws on parse errors', async () => {
const bad = `export default [ @@@`;
await expect(
addReactRouterRouteToFile(routesTs, bad, { path: 'a', module: 'routes/a.ts' }, shouldWrite),
).rejects.toThrow(/failed to parse/);
});
@@ -1,44 +0,0 @@
import { expect, test } from 'vitest';
import type { Framework } from '@/config';
import { resolveRouteFilePath } from '@/utils/framework';
test('next: app/ + app router route.ts path (incl. optional catch-all)', () => {
expect(resolveRouteFilePath('api/auth/[[...slug]]', 'next')).toBe(
'app/api/auth/[[...slug]]/route.ts',
);
expect(resolveRouteFilePath('api/foo/[...slug]', 'next')).toBe('app/api/foo/[...slug]/route.ts');
expect(resolveRouteFilePath('/api/foo/', 'next')).toBe('app/api/foo/route.ts');
});
test('next: custom extension', () => {
expect(resolveRouteFilePath('api/foo', 'next', 'tsx')).toBe('app/api/foo/route.tsx');
});
test('tanstack-start: routes/ + flattened $ / splat filenames', () => {
const fw = 'tanstack-start' as const satisfies Framework;
expect(resolveRouteFilePath('api/handler', fw)).toBe('routes/api.handler.ts');
expect(resolveRouteFilePath('/api/foo/', fw)).toBe('routes/api.foo.ts');
expect(resolveRouteFilePath('api/posts/[id]', fw)).toBe('routes/api.posts.$id.ts');
expect(resolveRouteFilePath('api/foo/[[...slug]]', fw)).toBe('routes/api.foo.$.ts');
expect(resolveRouteFilePath('api/foo/[...slug]', fw)).toBe('routes/api.foo.$.ts');
expect(resolveRouteFilePath('posts/[slug]/edit', fw)).toBe('routes/posts.$slug.edit.ts');
});
test('react-router: routes/ + nested path ($ dynamic, $ splat)', () => {
const fw = 'react-router' as const satisfies Framework;
expect(resolveRouteFilePath('api/handler', fw)).toBe('app/routes/api/handler.ts');
expect(resolveRouteFilePath('/api/foo/', fw)).toBe('app/routes/api/foo.ts');
expect(resolveRouteFilePath('api/posts/[id]', fw)).toBe('app/routes/api/posts/$id.ts');
expect(resolveRouteFilePath('api/foo/[[...slug]]', fw)).toBe('app/routes/api/foo/all.ts');
expect(resolveRouteFilePath('api/foo/[...slug]', fw)).toBe('app/routes/api/foo/all.ts');
expect(resolveRouteFilePath('posts/[slug]/edit', fw)).toBe('app/routes/posts/$slug/edit.ts');
});
test('waku: pages/_api/ + [...] catch-all normalization', () => {
expect(resolveRouteFilePath('api/handler', 'waku')).toBe('pages/_api/api/handler.ts');
expect(resolveRouteFilePath('api/foo/[[...slug]]', 'waku')).toBe(
'pages/_api/api/foo/[...slug].ts',
);
expect(resolveRouteFilePath('api/foo/[...slug]', 'waku')).toBe('pages/_api/api/foo/[...slug].ts');
expect(resolveRouteFilePath('api/items/[id]', 'waku')).toBe('pages/_api/api/items/[id].ts');
});
@@ -1,192 +0,0 @@
import type { Framework } from '@/config';
import { transformRouteHandler } from '@/registry/macros/route-handler.build';
import MagicString from 'magic-string';
import path from 'node:path';
import { parseSync } from 'oxc-parser';
import { expect, test } from 'vitest';
export interface BuildRouteHandlerFileOptions {
route: string;
/** Resolved absolute path for the emitted route module (used for `lang` / parse filename). */
routeFilePath: string;
framework: Framework;
/** Source text (registry component output or app source) containing a `$routeHandler` call. */
compiledContent: string;
}
export function buildRouteHandlerFromString(options: BuildRouteHandlerFileOptions): string {
const { route, routeFilePath, framework, compiledContent } = options;
const lang = path.extname(routeFilePath) === '.tsx' ? 'tsx' : 'ts';
const result = parseSync(routeFilePath, compiledContent, { lang, astType: 'ts' });
if (result.errors.length > 0) {
throw new Error(
`route-handler.build: failed to parse ${routeFilePath}:\n${result.errors.map((e) => e.message).join('\n')}`,
);
}
const program = result.program;
const s = new MagicString(options.compiledContent);
transformRouteHandler(route, routeFilePath, framework, program, s);
return s.toString();
}
const sample = `import { $routeHandler } from '@fumadocs/cli/registry/macros/route-handler';
export const handler = $routeHandler(
{ methods: ['GET', 'POST'], params: ['id'] },
async (req, p) => Response.json({ id: p.id }),
);
`;
test('next: native verb exports, inlined body, params from ctx', () => {
const out = buildRouteHandlerFromString({
route: 'api/posts/[id]',
routeFilePath: '/proj/app/api/posts/[id]/route.ts',
framework: 'next',
compiledContent: sample,
});
expect(out).toMatchInlineSnapshot(`
"export async function GET(req: Request, ctx: RouteContext<"/api/posts/[id]">) {
const p = await ctx.params;
return Response.json({ id: p.id });
}
export async function POST(req: Request, ctx: RouteContext<"/api/posts/[id]">) {
const p = await ctx.params;
return Response.json({ id: p.id });
}"
`);
});
test('next: inlines dynamic + params object when route has segments', () => {
const src = `import { $routeHandler } from '@fumadocs/cli/registry/macros/route-handler';
export const h = $routeHandler(
{ methods: ['GET'], params: ['id'], catchAll: 'rest' },
async (request, p) => Response.json({ request, p }),
);
`;
const out = buildRouteHandlerFromString({
route: 'api/items/[id]/[...rest]',
routeFilePath: '/proj/app/api/items/[id]/[...rest]/route.ts',
framework: 'next',
compiledContent: src,
});
expect(out).toMatchInlineSnapshot(`
"export async function GET(request: Request, ctx: RouteContext<"/api/items/[id]/[...rest]">) {
const p = await ctx.params;
return Response.json({ request, p });
}"
`);
});
test('tanstack-start: createFileRoute + per-verb handlers with ctx.request alias', () => {
const out = buildRouteHandlerFromString({
route: 'api/posts/[id]',
routeFilePath: '/proj/routes/api.posts.$id.ts',
framework: 'tanstack-start',
compiledContent: sample,
});
expect(out).toMatchInlineSnapshot(`
"import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute("/api/posts/$id")({
server: {
handlers: {
GET: async (ctx) => {
const req = ctx.request;
const p = {
id: ctx.params.id
};
return Response.json({ id: p.id });
},
POST: async (ctx) => {
const req = ctx.request;
const p = {
id: ctx.params.id
};
return Response.json({ id: p.id });
},
},
},
});"
`);
});
test('react-router: async loader + action, args.request alias', () => {
const out = buildRouteHandlerFromString({
route: 'api/posts/[id]',
routeFilePath: '/proj/routes/api/posts/$id.ts',
framework: 'react-router',
compiledContent: sample,
});
expect(out).toMatchInlineSnapshot(`
"import type { Route } from './+types/$id';
export async function loader(args: Route.LoaderArgs) {
const req = args.request;
const p = {
id: args.params.id
};
return Response.json({ id: p.id });
}
export async function action(args: Route.ActionArgs) {
const req = args.request;
const p = {
id: args.params.id
};
return Response.json({ id: p.id });
}"
`);
});
test('react-router: GET-only omits action', () => {
const getOnly = `import { $routeHandler } from '@fumadocs/cli/registry/macros/route-handler';
export const handler = $routeHandler(
{ methods: ['GET'], params: [] },
async () => new Response('ok'),
);
`;
const out = buildRouteHandlerFromString({
route: 'api/x',
routeFilePath: '/proj/routes/api/x.ts',
framework: 'react-router',
compiledContent: getOnly,
});
expect(out).toMatchInlineSnapshot(`
"import type { Route } from './+types/x';
export async function loader(args: Route.LoaderArgs) {
const request = args.request;
return new Response('ok');
}"
`);
});
test('no-op when there is no $routeHandler import', () => {
const src = `export const x = 1;
`;
const out = buildRouteHandlerFromString({
route: 'api/x',
routeFilePath: '/proj/app/api/x/route.ts',
framework: 'next',
compiledContent: src,
});
expect(out).toBe(src);
});
test('no-op when $routeHandler is imported but never called', () => {
const src = `import { $routeHandler } from '@fumadocs/cli/registry/macros/route-handler';
export const setup = true;
`;
const out = buildRouteHandlerFromString({
route: 'api/x',
routeFilePath: '/proj/app/api/x/route.ts',
framework: 'next',
compiledContent: src,
});
expect(out).toBe(src);
});
+1 -7
View File
@@ -4,13 +4,7 @@ import { createConfigSchema } from './src/config';
import { z } from 'zod';
export default defineConfig({
entry: [
'./src/{index,config}.ts',
'./src/registry/{client,schema}.ts',
'./src/registry/installer/index.ts',
'./src/registry/macros/route-handler.ts',
'./src/build/index.ts',
],
entry: ['./src/{index,config}.ts'],
format: 'esm',
dts: true,
fixedExtension: false,
+2 -2
View File
@@ -44,10 +44,10 @@
"devDependencies": {
"@content-collections/core": "^0.14.3",
"@content-collections/mdx": "^0.2.2",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"fumadocs-core": "workspace:*",
"tsconfig": "workspace:*",
"tsdown": "0.21.6"
"tsdown": "0.21.7"
},
"peerDependencies": {
"@content-collections/core": "0.x.x",
+2 -2
View File
@@ -33,12 +33,12 @@
"@standard-schema/spec": "^1.1.0"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"fuma-content": "^1.2.3",
"fumadocs-core": "workspace:*",
"react-dom": "^19.2.4",
"tsconfig": "workspace:*",
"tsdown": "0.21.6"
"tsdown": "0.21.7"
},
"peerDependencies": {
"fuma-content": "^1.2.3",
+6 -6
View File
@@ -136,7 +136,7 @@
"mdast-util-to-markdown": "^2.1.2",
"negotiator": "^1.0.0",
"npm-to-yarn": "^3.0.1",
"path-to-regexp": "^8.4.0",
"path-to-regexp": "^8.4.2",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-rehype": "^11.1.2",
@@ -152,24 +152,24 @@
"@mixedbread/sdk": "^0.61.0",
"@orama/core": "^1.2.19",
"@oramacloud/client": "^2.1.4",
"@tanstack/react-router": "1.168.7",
"@tanstack/react-router": "1.168.10",
"@types/estree-jsx": "^1.0.5",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/negotiator": "^0.6.4",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"algoliasearch": "5.50.0",
"algoliasearch": "5.50.1",
"flexsearch": "^0.8.212",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"react-router": "^7.13.2",
"react-router": "^7.14.0",
"remark-directive": "^4.0.0",
"remark-mdx": "^3.1.1",
"remove-markdown": "^0.6.3",
"tsconfig": "workspace:*",
"tsdown": "0.21.6",
"tsdown": "0.21.7",
"typescript": "^6.0.2",
"waku": "1.0.0-alpha.6",
"zod": "^4.3.6"
+3 -3
View File
@@ -4,14 +4,14 @@
"private": true,
"description": "Used to track dependency versions in create-fumadocs-app",
"dependencies": {
"@biomejs/biome": "^2.4.9",
"@biomejs/biome": "^2.4.10",
"@orama/core": "^1.2.19",
"@takumi-rs/image-response": "^0.73.1",
"eslint": "^10.1.0",
"eslint": "^10.2.0",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"oxlint": "^1.57.0",
"oxlint": "^1.59.0",
"react": "^19.2.4"
}
}
+3 -3
View File
@@ -42,7 +42,7 @@
"sync": "bun ./scripts/sync.ts"
},
"dependencies": {
"@clack/prompts": "^1.1.0",
"@clack/prompts": "^1.2.0",
"@commander-js/extra-typings": "^14.0.0",
"@fumadocs/cli": "workspace:*",
"commander": "^14.0.3",
@@ -52,10 +52,10 @@
},
"devDependencies": {
"@types/cross-spawn": "^6.0.6",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"tinyglobby": "^0.2.15",
"tsconfig": "workspace:*",
"tsdown": "0.21.6",
"tsdown": "0.21.7",
"typescript": "^6.0.2"
},
"engines": {
+3 -3
View File
@@ -42,7 +42,7 @@
"estree-util-to-js": "^2.0.0",
"estree-util-value-to-estree": "^3.5.0",
"npm-to-yarn": "^3.0.1",
"oxc-transform": "^0.121.0",
"oxc-transform": "^0.124.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"vfile": "^6.0.3",
@@ -53,12 +53,12 @@
"@types/estree": "^1.0.8",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"fumadocs-core": "workspace:*",
"mdast-util-mdx": "^3.0.0",
"remark": "^15.0.1",
"tsconfig": "workspace:*",
"tsdown": "0.21.6",
"tsdown": "0.21.7",
"typescript": "^6.0.2"
},
"peerDependencies": {
+2 -2
View File
@@ -45,11 +45,11 @@
},
"devDependencies": {
"@types/mdast": "^4.0.4",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"fumadocs-core": "workspace:*",
"rimraf": "^6.0.0",
"tsconfig": "workspace:*",
"tsdown": "0.21.6",
"tsdown": "0.21.7",
"typescript": "^6.0.2",
"vfile": "^6.0.3"
},
+2 -2
View File
@@ -45,13 +45,13 @@
},
"devDependencies": {
"@types/mdx": "^2.0.13",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/react": "^19.2.14",
"fumadocs-core": "workspace:*",
"react": "^19.2.4",
"tinyglobby": "^0.2.15",
"tsconfig": "workspace:*",
"tsdown": "0.21.6"
"tsdown": "0.21.7"
},
"peerDependencies": {
"@types/mdx": "*",
+6 -6
View File
@@ -70,7 +70,7 @@
"@mdx-js/mdx": "^3.1.1",
"@standard-schema/spec": "^1.1.0",
"chokidar": "^5.0.0",
"esbuild": "^0.27.3",
"esbuild": "^0.28.0",
"estree-util-value-to-estree": "^3.5.0",
"js-yaml": "^4.1.1",
"mdast-util-mdx": "^3.0.0",
@@ -91,8 +91,8 @@
"@types/js-yaml": "^4.0.9",
"@types/mdast": "^4.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/picomatch": "^4.0.2",
"@types/node": "^25.5.2",
"@types/picomatch": "^4.0.3",
"@types/react": "^19.2.14",
"fumadocs-core": "workspace:*",
"mdast-util-directive": "^3.1.0",
@@ -101,10 +101,10 @@
"remark": "^15.0.1",
"remark-directive": "^4.0.0",
"remark-mdx": "^3.1.1",
"rolldown": "1.0.0-rc.12",
"rolldown": "1.0.0-rc.13",
"tsconfig": "workspace:*",
"tsdown": "0.21.6",
"vite": "^8.0.3",
"tsdown": "0.21.7",
"vite": "^8.0.5",
"webpack": "^5.105.4"
},
"peerDependencies": {
+2 -2
View File
@@ -61,13 +61,13 @@
"@types/estree": "^1.0.8",
"@types/js-yaml": "^4.0.9",
"@types/mdast": "^4.0.4",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"fumadocs-core": "workspace:*",
"mdast-util-mdx": "^3.0.0",
"tsconfig": "workspace:*",
"tsdown": "0.21.6",
"tsdown": "0.21.7",
"typescript": "^6.0.2"
},
"peerDependencies": {
+6 -6
View File
@@ -57,8 +57,8 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@scalar/json-magic": "^0.12.4",
"@scalar/openapi-upgrader": "^0.2.2",
"@scalar/json-magic": "^0.12.5",
"@scalar/openapi-upgrader": "^0.2.4",
"ajv": "^8.18.0",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
@@ -69,7 +69,7 @@
"lucide-react": "^1.7.0",
"next-themes": "^0.4.6",
"openapi-sampler": "^1.7.2",
"react-hook-form": "^7.72.0",
"react-hook-form": "^7.72.1",
"remark": "^15.0.1",
"remark-rehype": "^11.1.2",
"tailwind-merge": "^3.5.0",
@@ -77,9 +77,9 @@
},
"devDependencies": {
"@fumadocs/tailwind": "workspace:*",
"@scalar/api-client-react": "^1.4.15",
"@scalar/api-client-react": "^1.4.20",
"@types/js-yaml": "^4.0.9",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/openapi-sampler": "^1.0.3",
"@types/react": "^19.2.14",
"fumadocs-core": "workspace:*",
@@ -88,7 +88,7 @@
"shiki": "^4.0.2",
"tailwindcss": "^4.2.2",
"tsconfig": "workspace:*",
"tsdown": "0.21.6"
"tsdown": "0.21.7"
},
"peerDependencies": {
"@scalar/api-client-react": "*",
+11 -11
View File
@@ -33,15 +33,15 @@
"types:check": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/react": "^3.0.142",
"@openrouter/ai-sdk-provider": "^2.3.3",
"@ai-sdk/react": "^3.0.151",
"@openrouter/ai-sdk-provider": "^2.5.0",
"@radix-ui/react-presence": "^1.1.5",
"@takumi-rs/image-response": "^0.73.1",
"ai": "^6.0.140",
"ai": "^6.0.149",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"commander": "^14.0.3",
"dotenv": "^17.3.1",
"dotenv": "^17.4.1",
"flexsearch": "^0.8.212",
"fumadocs-core": "workspace:*",
"fumadocs-ui": "workspace:*",
@@ -53,23 +53,23 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@hono/node-server": "^1.19.11",
"@hono/node-server": "^1.19.13",
"@hono/node-ws": "^1.3.0",
"@tailwindcss/vite": "^4.2.2",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/picomatch": "^4.0.2",
"@types/node": "^25.5.2",
"@types/picomatch": "^4.0.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/unist": "^3.0.3",
"beautiful-mermaid": "^1.1.3",
"gray-matter": "^4.0.3",
"hast-util-to-jsx-runtime": "^2.3.6",
"hono": "^4.12.9",
"hono": "^4.12.12",
"ignore": "^7.0.5",
"katex": "0.16.43",
"katex": "0.16.45",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -80,11 +80,11 @@
"remark-rehype": "^11.1.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tsdown": "0.21.6",
"tsdown": "0.21.7",
"typescript": "^6.0.2",
"unified": "^11.0.5",
"vfile": "^6.0.3",
"vite": "^8.0.3",
"vite": "^8.0.5",
"vite-tsconfig-paths": "^6.1.1"
}
}
+2 -2
View File
@@ -49,12 +49,12 @@
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"fumadocs-core": "workspace:*",
"fumadocs-ui": "workspace:*",
"tsconfig": "workspace:*",
"tsdown": "0.21.6"
"tsdown": "0.21.7"
},
"peerDependencies": {
"@types/react": "*",
+4 -3
View File
@@ -145,10 +145,11 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"fuma-cli": "^0.0.2",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"react-medium-image-zoom": "^5.4.1",
"react-medium-image-zoom": "^5.4.3",
"react-remove-scroll": "^2.7.2",
"rehype-raw": "^7.0.0",
"scroll-into-view-if-needed": "^3.1.0",
@@ -161,14 +162,14 @@
"@tsdown/css": "^0.21.6",
"@types/hast": "^3.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"fumadocs-core": "workspace:*",
"shiki": "^4.0.2",
"tailwindcss": "^4.2.2",
"tsconfig": "workspace:*",
"tsdown": "0.21.6",
"tsdown": "0.21.7",
"unified": "^11.0.5"
},
"peerDependencies": {
+2 -5
View File
@@ -1,7 +1,7 @@
import { fileURLToPath } from 'node:url';
import type { Registry } from '@fumadocs/cli/build';
import * as path from 'node:path';
import { commonComponents, findSlotComponents, resolveExternal } from '../../shared/registry';
import { commonComponents, findSlotComponents } from '../../shared/registry';
import type { Registry } from 'fuma-cli/compiler';
const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../src');
@@ -14,9 +14,6 @@ export const registry: Registry = {
env: {
ui: 'fumadocs-ui',
},
onResolve(ref) {
return resolveExternal(ref, 'fumadocs-ui', dir) ?? ref;
},
components: [
...commonComponents,
...(await findSlotComponents(dir)),
+5 -1
View File
@@ -1,9 +1,13 @@
{
"name": "shared",
"private": true,
"dependencies": {
"fuma-cli": "^0.0.2",
"tinyglobby": "^0.2.15"
},
"devDependencies": {
"@fumadocs/cli": "workspace:*",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"tsconfig": "workspace:*"
}
}
+3 -28
View File
@@ -1,32 +1,11 @@
import type { Component, Reference, SourceReference } from '@fumadocs/cli/build';
import { glob } from 'node:fs/promises';
import { glob } from 'tinyglobby';
import path from 'node:path';
/**
* externalize some contexts & hooks
*/
export function resolveExternal(
ref: SourceReference,
toPackageName: string,
toRegistryDir: string,
): Reference | undefined {
if (ref.type === 'file') {
const file = path.relative(toRegistryDir, ref.file).replaceAll(path.sep, '/');
if (file.startsWith('contexts/') || file.startsWith('utils/use-')) {
return {
dep: toPackageName,
type: 'dependency',
specifier: `${toPackageName}/${removeExtname(file)}`,
};
}
}
}
import type { Component } from 'fuma-cli/compiler';
export async function findSlotComponents(dir: string): Promise<Component[]> {
const slots: Component[] = [];
for await (const file of glob('layouts/**/slots/*', { cwd: dir })) {
for (const file of await glob('layouts/**/slots/*', { cwd: dir })) {
const relativePath = path.relative('layouts', file);
const name = relativePath
.split(path.sep)
@@ -103,7 +82,3 @@ export const commonComponents: Component[] = [
],
},
];
function removeExtname(file: string) {
return file.slice(0, -path.extname(file).length);
}
+2 -2
View File
@@ -26,10 +26,10 @@
"types:check": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/react": "^19.2.14",
"tsconfig": "workspace:*",
"tsdown": "0.21.6"
"tsdown": "0.21.7"
},
"peerDependencies": {
"@types/react": "*",
+2 -2
View File
@@ -39,14 +39,14 @@
},
"devDependencies": {
"@fastify/deepmerge": "^3.2.1",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/react": "^19.2.14",
"@types/ungap__structured-clone": "^1.2.0",
"@ungap/structured-clone": "^1.3.0",
"fumadocs-ui": "workspace:*",
"tailwindcss": "^4.2.2",
"tsconfig": "workspace:*",
"tsdown": "0.21.6"
"tsdown": "0.21.7"
},
"peerDependencies": {
"@types/react": "*",
+2 -2
View File
@@ -40,11 +40,11 @@
"devDependencies": {
"@tailwindcss/oxide": "^4.2.2",
"@types/lodash.merge": "^4.6.9",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"lodash.merge": "^4.6.2",
"tailwindcss": "^4.2.2",
"tsconfig": "workspace:*",
"tsdown": "0.21.6"
"tsdown": "0.21.7"
},
"peerDependencies": {
"tailwindcss": "^4.0.0"
+2 -2
View File
@@ -56,11 +56,11 @@
"devDependencies": {
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/react": "^19.2.14",
"fumadocs-ui": "workspace:*",
"tsconfig": "workspace:*",
"tsdown": "0.21.6",
"tsdown": "0.21.7",
"typescript": "^6.0.2"
},
"peerDependencies": {
+2 -2
View File
@@ -52,7 +52,7 @@
"@types/estree": "^1.0.8",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "25.5.0",
"@types/node": "25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"fumadocs-core": "workspace:*",
@@ -60,7 +60,7 @@
"mdast-util-mdx": "^3.0.0",
"shiki": "^4.0.2",
"tsconfig": "workspace:*",
"tsdown": "0.21.6",
"tsdown": "0.21.7",
"typescript": "^6.0.2",
"vfile": "^6.0.3"
},
+2593 -1913
View File
File diff suppressed because it is too large Load Diff