mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
@@ -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,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<
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')));
|
||||
}
|
||||
|
||||
@@ -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!'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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()],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+2593
-1913
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user