CLI: Support framework-agnostic route handler

This commit is contained in:
Fuma Nama
2026-03-31 20:25:03 +08:00
parent 9f62bc69b8
commit 3ff78c9183
25 changed files with 1152 additions and 192 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@fumadocs/cli': patch
---
Support framework-agnostic route handler
+4 -2
View File
@@ -1,3 +1,5 @@
export const runtime = 'edge';
import { handler } from '@/lib/inkeep/route';
export { POST } from '@/lib/inkeep/server';
export function POST(req: Request) {
return handler.handler(req, {});
}
+1 -1
View File
@@ -21,7 +21,7 @@ import type { z } from 'zod';
import { DefaultChatTransport } from 'ai';
import { Markdown } from '../markdown';
import { Presence } from '@radix-ui/react-presence';
import type { InkeepUIMessage } from '@/lib/inkeep/server';
import type { InkeepUIMessage } from '@/lib/inkeep/route';
const Context = createContext<{
open: boolean;
+1 -1
View File
@@ -18,7 +18,7 @@ import { useChat, type UseChatHelpers } from '@ai-sdk/react';
import { DefaultChatTransport, type Tool, type UIToolInvocation } from 'ai';
import { Markdown } from '../markdown';
import { Presence } from '@radix-ui/react-presence';
import type { ChatUIMessage, SearchTool } from '@/lib/openrouter/server';
import type { ChatUIMessage, SearchTool } from '@/lib/openrouter/route';
const Context = createContext<{
open: boolean;
+6 -6
View File
@@ -100,9 +100,9 @@ export const registry: Registry = {
target: '<dir>/ai/search.tsx',
},
{
type: 'route',
path: 'lib/openrouter/server.ts',
target: 'app/api/chat/route.ts',
type: 'route-handler',
route: 'api/chat',
path: 'lib/openrouter/route.ts',
},
],
dependencies: {
@@ -130,9 +130,9 @@ export const registry: Registry = {
target: '<dir>/ai/search.tsx',
},
{
type: 'route',
path: 'lib/inkeep/server.ts',
target: 'app/api/chat/route.ts',
type: 'route-handler',
route: 'api/chat',
path: 'lib/inkeep/route.ts',
},
{
type: 'lib',
+51
View File
@@ -0,0 +1,51 @@
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 { convertToModelMessages, streamText, type UIMessage } from 'ai';
export type InkeepUIMessage = UIMessage<
never,
{
client: {
location: string;
};
}
>;
const openai = createOpenAICompatible({
name: 'inkeep',
apiKey: process.env.INKEEP_API_KEY,
baseURL: 'https://api.inkeep.com/v1',
});
export const handler = $routeHandler(
{
methods: ['POST'],
params: [],
},
async (req) => {
const reqJson = await req.json();
const result = streamText({
model: openai('inkeep-qa-sonnet-4'),
tools: {
provideLinks: {
inputSchema: ProvideLinksToolSchema,
},
},
messages: await convertToModelMessages<InkeepUIMessage>(reqJson.messages, {
ignoreIncompleteToolCalls: true,
convertDataPart(part) {
if (part.type === 'data-client')
return {
type: 'text',
text: `[Client Context: ${JSON.stringify(part.data)}]`,
};
},
}),
toolChoice: 'auto',
});
return result.toUIMessageStreamResponse();
},
);
-46
View File
@@ -1,46 +0,0 @@
import { ProvideLinksToolSchema } from '@/lib/inkeep/inkeep-qa-schema';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { convertToModelMessages, streamText, UIMessage } from 'ai';
export const runtime = 'edge';
export type InkeepUIMessage = UIMessage<
never,
{
client: {
location: string;
};
}
>;
const openai = createOpenAICompatible({
name: 'inkeep',
apiKey: process.env.INKEEP_API_KEY,
baseURL: 'https://api.inkeep.com/v1',
});
export async function POST(req: Request) {
const reqJson = await req.json();
const result = streamText({
model: openai('inkeep-qa-sonnet-4'),
tools: {
provideLinks: {
inputSchema: ProvideLinksToolSchema,
},
},
messages: await convertToModelMessages<InkeepUIMessage>(reqJson.messages, {
ignoreIncompleteToolCalls: true,
convertDataPart(part) {
if (part.type === 'data-client')
return {
type: 'text',
text: `[Client Context: ${JSON.stringify(part.data)}]`,
};
},
}),
toolChoice: 'auto',
});
return result.toUIMessageStreamResponse();
}
@@ -3,6 +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';
interface CustomDocument extends DocumentData {
url: string;
@@ -72,32 +73,38 @@ const systemPrompt = [
'If you cannot find the answer in search results, say you do not know and suggest a better search query.',
].join('\n');
export async function POST(req: Request) {
const reqJson = await req.json();
export const handler = $routeHandler(
{
methods: ['POST'],
params: [],
},
async (req) => {
const reqJson = await req.json();
const result = streamText({
model: openrouter.chat(process.env.OPENROUTER_MODEL ?? 'anthropic/claude-3.5-sonnet'),
stopWhen: stepCountIs(5),
tools: {
search: searchTool,
},
messages: [
{ role: 'system', content: systemPrompt },
...(await convertToModelMessages<ChatUIMessage>(reqJson.messages ?? [], {
convertDataPart(part) {
if (part.type === 'data-client')
return {
type: 'text',
text: `[Client Context: ${JSON.stringify(part.data)}]`,
};
},
})),
],
toolChoice: 'auto',
});
const result = streamText({
model: openrouter.chat(process.env.OPENROUTER_MODEL ?? 'anthropic/claude-3.5-sonnet'),
stopWhen: stepCountIs(5),
tools: {
search: searchTool,
},
messages: [
{ role: 'system', content: systemPrompt },
...(await convertToModelMessages<ChatUIMessage>(reqJson.messages ?? [], {
convertDataPart(part) {
if (part.type === 'data-client')
return {
type: 'text',
text: `[Client Context: ${JSON.stringify(part.data)}]`,
};
},
})),
],
toolChoice: 'auto',
});
return result.toUIMessageStreamResponse();
}
return result.toUIMessageStreamResponse();
},
);
export type SearchTool = typeof searchTool;
+1
View File
@@ -22,6 +22,7 @@
"./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",
"./package.json": "./package.json"
},
+11 -24
View File
@@ -4,7 +4,6 @@ import type {
CompiledComponent,
CompiledFile,
httpSubComponent,
NamespaceType,
registryInfoSchema,
} from '@/registry/schema';
import type { z } from 'zod';
@@ -13,6 +12,8 @@ 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,
@@ -25,11 +26,9 @@ export interface CompiledRegistry {
info: z.output<typeof registryInfoSchema>;
}
export interface ComponentFile {
type: NamespaceType;
export type ComponentFile = DistributiveOmit<CompiledFile, 'content'> & {
path: string;
target?: string;
}
};
export interface Component {
name: string;
@@ -276,14 +275,6 @@ export class ComponentCompiler {
this.registry = compiler.raw;
}
private toImportPath(file: ComponentFile): string {
let filePath = file.target ?? file.path;
if (filePath.startsWith('./')) filePath = filePath.slice(2);
return `@/${filePath.replaceAll(path.sep, '/')}`;
}
async build(): Promise<CompiledComponent> {
const files = (
await Promise.all(this.component.files.map((file) => this.onBuildFile(file)))
@@ -330,7 +321,7 @@ export class ComponentCompiler {
const refFile = this.registry.onUnknownFile?.(reference.file);
if (refFile) {
queue.push(refFile);
return this.toImportPath(refFile);
return encodeImport(refFile);
}
if (refFile === false) return;
@@ -340,8 +331,7 @@ export class ComponentCompiler {
if (reference.type === 'sub-component') {
const resolved = reference.resolved;
if (resolved.component.name === this.component.name)
return this.toImportPath(resolved.file);
if (resolved.component.name === this.component.name) return encodeImport(resolved.file);
if (resolved.type === 'remote') {
this.subComponents.set(`${resolved.registryName}:${resolved.component.name}`, {
@@ -353,7 +343,7 @@ export class ComponentCompiler {
this.subComponents.set(resolved.component.name, resolved.component.name);
}
return this.toImportPath(resolved.file);
return encodeImport(resolved.file);
}
const dep = resolver.getDepInfo(reference.dep);
@@ -387,13 +377,12 @@ export class ComponentCompiler {
const astType = astTypes[path.extname(file.path)];
const content = (await fs.readFile(sourceFilePath)).toString();
if (!astType)
if (!astType) {
return {
...file,
content,
path: file.path,
type: file.type,
target: file.target,
};
}
const resolver = this.compiler.resolver;
@@ -449,10 +438,8 @@ export class ComponentCompiler {
});
return {
...file,
content: s.toString(),
type: file.type,
path: file.path,
target: file.target,
};
}
}
+34 -20
View File
@@ -1,8 +1,11 @@
import fs from 'node:fs/promises';
import { isSrc } from '@/utils/is-src';
import { z } from 'zod';
import { existsSync, readFileSync } from 'node:fs';
export function createConfigSchema(isSrc: boolean) {
const frameworks = ['next', 'waku', 'react-router', 'tanstack-start'] as const;
export type Framework = (typeof frameworks)[number];
export function createConfigSchema() {
const defaultAliases = {
uiDir: './components/ui',
componentsDir: './components',
@@ -12,14 +15,7 @@ export function createConfigSchema(isSrc: boolean) {
};
return z.object({
$schema: z
.string()
.default(
isSrc
? 'node_modules/@fumadocs/cli/dist/schema/src.json'
: 'node_modules/@fumadocs/cli/dist/schema/default.json',
)
.optional(),
$schema: z.string().default('node_modules/@fumadocs/cli/dist/schema.json').optional(),
aliases: z
.object({
uiDir: z.string().default(defaultAliases.uiDir),
@@ -30,8 +26,11 @@ export function createConfigSchema(isSrc: boolean) {
})
.default(defaultAliases),
baseDir: z.string().default(isSrc ? 'src' : ''),
baseDir: z.string().default(() => (existsSync('./src') ? 'src' : '')),
uiLibrary: z.enum(['radix-ui', 'base-ui']).default('radix-ui'),
framework: z.literal(frameworks).default(() => {
return detectFrameworkFromPackageJson() ?? 'next';
}),
commands: z
.object({
@@ -44,6 +43,25 @@ export function createConfigSchema(isSrc: boolean) {
});
}
function detectFrameworkFromPackageJson(pkgPath = './package.json'): Framework | undefined {
try {
const pkgRaw = readFileSync(pkgPath, 'utf-8');
const pkg = JSON.parse(pkgRaw);
const deps = {
...pkg.dependencies,
...pkg.devDependencies,
};
if (deps['next']) return 'next';
if (deps['waku']) return 'waku';
if (deps['react-router'] || deps['react-router-dom']) return 'react-router';
if (deps['@tanstack/react-start']) return 'tanstack-start';
} catch {
return;
}
}
type ConfigSchema = ReturnType<typeof createConfigSchema>;
export type ConfigInput = z.input<ConfigSchema>;
@@ -54,8 +72,7 @@ export async function createOrLoadConfig(file = './cli.json'): Promise<LoadedCon
if (inited) return inited;
const content = (await fs.readFile(file)).toString();
const src = await isSrc();
const configSchema = createConfigSchema(src);
const configSchema = createConfigSchema();
return configSchema.parse(JSON.parse(content));
}
@@ -65,10 +82,7 @@ export async function createOrLoadConfig(file = './cli.json'): Promise<LoadedCon
*
* @returns the created config, `undefined` if not created
*/
export async function initConfig(
file = './cli.json',
src?: boolean,
): Promise<LoadedConfig | undefined> {
export async function initConfig(file = './cli.json'): Promise<LoadedConfig | undefined> {
if (
await fs
.stat(file)
@@ -78,11 +92,11 @@ export async function initConfig(
return;
}
const defaultConfig = await getDefaultConfig(src);
const defaultConfig = await getDefaultConfig();
await fs.writeFile(file, JSON.stringify(defaultConfig, null, 2));
return defaultConfig;
}
export async function getDefaultConfig(src?: boolean) {
return createConfigSchema(src ?? (await isSrc())).parse({} satisfies ConfigInput);
export async function getDefaultConfig() {
return createConfigSchema().parse({} satisfies ConfigInput);
}
+36 -20
View File
@@ -9,6 +9,10 @@ import { DependencyManager } from '@/registry/installer/dep-manager';
import { createCache } from '@/utils/cache';
import { parse, type ParseResult } from 'oxc-parser';
import MagicString from 'magic-string';
import { decodeImport, encodeImport } from '../protocols/import';
import type { Awaitable } from '@/types';
import { buildRouteHandlerFile } from '@/registry/macros/route-handler.build';
import { resolveRouteFilePath } from '@/utils/framework';
interface PluginContext {
installer: ComponentInstaller;
@@ -22,8 +26,8 @@ interface TransformContext extends PluginContext, InstallContext {
}
interface InstallContext {
pathToFile: Map<string, File>;
io: IOInterface;
importLookup: Map<string, File>;
/** full variables of the current component. */
$variables: Record<string, unknown>;
/** the last item is always the current component. */
@@ -35,8 +39,6 @@ interface DownloadedComponent extends Component {
$registry: RegistryClient;
}
type Awaitable<T> = T | Promise<T>;
export interface ComponentInstallerPlugin {
/**
* transform file before writing (before default transformation)
@@ -176,14 +178,16 @@ export class ComponentInstaller {
scan(downloaded);
const pathToFile = new Map<string, File>();
const importLookup = new Map<string, File>();
for (const comp of allComponents) {
for (const file of comp.files) pathToFile.set(file.target ?? file.path, file);
for (const file of comp.files) {
importLookup.set(encodeImport(file), file);
}
}
const info = await downloaded.$registry.fetchRegistryInfo();
await this.installComponent(downloaded, {
pathToFile,
importLookup,
io,
$variables: { ...info.env, ...downloaded.variables },
stack: [downloaded],
@@ -272,25 +276,33 @@ export class ComponentInstaller {
await plugin.beforeTransform?.(transformCtx);
}
// transform alias
const prefix = '@/';
if (file.type === 'route-handler') {
buildRouteHandlerFile(
file.route,
filePath,
this.rootClient.config.framework,
parsed.program,
s,
);
}
transformSpecifiers(parsed.program, s, (specifier) => {
for (const [k, v] of Object.entries(ctx.$variables)) {
if (typeof v === 'string') specifier = specifier.replaceAll(`<${k}>`, v);
}
if (ctx.importLookup.has(specifier)) {
let outputPath = this.resolveOutputPath(ctx.importLookup.get(specifier)!);
if (specifier.startsWith(prefix)) {
const lookup = specifier.substring(prefix.length);
const target = ctx.pathToFile.get(lookup);
if (target) {
specifier = toImportSpecifier(filePath, this.resolveOutputPath(target));
} else {
ctx.io.onWarn(`cannot find the referenced file of ${specifier}`);
for (const [k, v] of Object.entries(ctx.$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;
}
ctx.io.onWarn(`cannot find the referenced file of ${specifier}`);
return specifier;
});
@@ -303,6 +315,11 @@ export class ComponentInstaller {
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,
@@ -310,7 +327,6 @@ export class ComponentInstaller {
css: config.aliases.cssDir,
lib: config.aliases.libDir,
layout: config.aliases.layoutDir,
route: './',
} as const
)[file.type];
if (file.target) {
@@ -0,0 +1,545 @@
import path from 'node:path';
import MagicString from 'magic-string';
import { Visitor } from 'oxc-parser';
import type {
ArrowFunctionExpression,
CallExpression,
Expression,
Function as AstFunction,
ImportDeclaration,
ImportDeclarationSpecifier,
ModuleExportName,
ObjectExpression,
ParamPattern,
Program,
Statement,
} from '@oxc-project/types';
import type { Framework } from '@/config';
import type { RouteHandlerHttpMethod, StaticInfo } from './route-handler';
const reactRouterLoaderMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
const reactRouterActionMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export type ParsedRouteInfo = StaticInfo<string, string | undefined>;
function moduleExportNameString(name: ModuleExportName): string {
if (name.type === 'Identifier') return name.name;
if (name.type === 'Literal') return String(name.value);
return '';
}
function getImportedBinding(
spec: ImportDeclarationSpecifier,
): { imported: string; local: string } | null {
if (spec.type === 'ImportSpecifier') {
return {
imported: moduleExportNameString(spec.imported),
local: spec.local.name,
};
}
if (spec.type === 'ImportDefaultSpecifier') {
return { imported: 'default', local: spec.local.name };
}
return null;
}
function collectMacroBindings(
program: Program,
): { importDecls: ImportDeclaration[]; locals: Set<string> } | null {
const locals = new Set<string>();
const importDecls: ImportDeclaration[] = [];
const seenDecl = new Set<ImportDeclaration>();
new Visitor({
ImportDeclaration(node: ImportDeclaration) {
for (const spec of node.specifiers) {
const b = getImportedBinding(spec);
if (!b) continue;
if (b.imported === '$routeHandler' || b.imported === 'default') {
locals.add(b.local);
if (!seenDecl.has(node)) {
seenDecl.add(node);
importDecls.push(node);
}
}
}
},
}).visit(program);
if (locals.size === 0) return null;
return { importDecls, locals };
}
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).
*/
export 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;
}
function parseHandlerFromAst(
s: MagicString,
expr: Expression,
): { requestName: string; paramsName: string | null; bodyText: string } {
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 as ArrowFunctionExpression | (AstFunction & { type: 'FunctionExpression' });
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.slice(body.start + 1, body.end - 1).trim();
} else if (fn.type === 'ArrowFunctionExpression') {
const expr = s.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 buildParamsObjectLiteral(
framework: Framework,
info: ParsedRouteInfo,
paramsIdentifier: string,
): string {
const parts: string[] = [];
for (const k of info.params) {
parts.push(`${encodeKey(k)}: ${paramsIdentifier}.${k}`);
}
if (info.catchAll) {
let value: string;
switch (framework) {
case 'react-router':
value = `${paramsIdentifier}['*']`;
break;
case 'tanstack-start':
value = `${paramsIdentifier}._splat`;
break;
default:
value = `${paramsIdentifier}.${info.catchAll}`;
}
parts.push(`${encodeKey(info.catchAll)}: ${value}`);
}
return `{\n${indentBlock(parts.join(',\n'), 2)}\n}`;
}
function requestAliasPrefix(framework: Framework, handler: { requestName: string }): string {
if (framework === 'tanstack-start') {
if (handler.requestName === 'ctx') {
throw new Error(
'route-handler.build: name the request parameter something other than `ctx` for TanStack file routes',
);
}
return `const ${handler.requestName} = ctx.request;\n`;
}
if (framework === 'react-router') {
if (handler.requestName === 'args') {
throw new Error(
'route-handler.build: name the request parameter something other than `args` for React Router resource routes',
);
}
return `const ${handler.requestName} = args.request;\n`;
}
return '';
}
function buildRouteSetupBlock(
framework: Framework,
info: ParsedRouteInfo,
paramsBinding: string,
): string {
if (!needsRouteParams(info)) return '';
let paramsIdentifier: string;
switch (framework) {
case 'next':
paramsIdentifier = '(await ctx.params)';
break;
case 'react-router':
paramsIdentifier = 'args.params';
break;
case 'tanstack-start':
paramsIdentifier = 'ctx.params';
break;
case 'waku':
paramsIdentifier = 'context.params';
break;
}
return `const ${paramsBinding} = ${buildParamsObjectLiteral(framework, info, paramsIdentifier)};\n`;
}
function resolveParamsBindingName(info: ParsedRouteInfo, userSecond: string | null): string | null {
if (!needsRouteParams(info)) return null;
return userSecond ?? 'params';
}
function registryRouteToTanStackCreateFileRoutePath(route: string): string {
const trimmed = route.replace(/^\/+/, '').replace(/\/+$/, '');
const segments = trimmed.split('/');
const parts = segments.map((seg) => {
if (/^\[\[\.\.\.[^/\]]+\]\]$/.test(seg) || /^\[\.\.\.[^/\]]+\]$/.test(seg)) return '$';
const m = /^\[([^/\]]+)\]$/.exec(seg);
if (m) return `$${m[1]}`;
return 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 {
const normalized = routeFilePath.replace(/\\/g, '/');
const parts = normalized.split('/');
const routesIdx = parts.lastIndexOf('routes');
const ext = path.extname(routeFilePath);
const base = path.basename(routeFilePath, ext);
if (routesIdx >= 0) {
const dirSegments = parts.slice(routesIdx + 1, -1);
return `./+types/${[...dirSegments, base].join('.')}`;
}
return `./+types/${base}`;
}
function frameworkGeneratedImports(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 indentBlock(text: string, spaces: number): string {
const pad = ' '.repeat(spaces);
return text
.split('\n')
.map((line) => (line.length ? pad + line : line))
.join('\n');
}
function generateDeclaration(
framework: Framework,
route: string,
parsedInfo: ParsedRouteInfo,
handler: { requestName: string; paramsName: string | null; bodyText: string },
): string {
const paramsBinding = resolveParamsBindingName(parsedInfo, handler.paramsName);
const alias = requestAliasPrefix(framework, handler);
const setupBlock =
paramsBinding != null ? buildRouteSetupBlock(framework, parsedInfo, paramsBinding) : '';
const inner = alias + setupBlock + handler.bodyText;
const bodyIndented = indentBlock(inner, 2);
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${bodyIndented}\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${bodyIndented}\n}`,
)
.join('\n\n');
}
case 'tanstack-start': {
const fileRoutePath = JSON.stringify(registryRouteToTanStackCreateFileRoutePath(route));
const entries = parsedInfo.methods
.map((m) => ` ${m}: async (ctx) => {\n${indentBlock(inner, 8)}\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${bodyIndented}\n}`);
}
if (includeAction) {
parts.push(`export async function action(args: Route.ActionArgs) {\n${bodyIndented}\n}`);
}
return `${parts.join('\n\n')}\n`;
}
default: {
const _exhaustive: never = framework;
return _exhaustive;
}
}
}
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 buildRouteHandlerFile(
route: string,
routeFilePath: string,
framework: Framework,
program: Program,
s: MagicString,
) {
const macro = collectMacroBindings(program);
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] as Expression;
const arg1 = call.arguments[1] as Expression;
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)',
);
}
const extraImports = frameworkGeneratedImports(framework, routeFilePath);
if (extraImports) {
const insertPos = findInsertPositionForNewImports(program, macro.importDecls);
if (insertPos === 0) {
s.prepend(extraImports);
} else {
s.appendLeft(insertPos, `\n${extraImports}`);
}
}
for (const decl of [...macro.importDecls].sort((a, b) => b.start - a.start)) {
removeMacroImport(s, decl);
}
s.overwrite(
stmtSpan.start,
stmtSpan.end,
generateDeclaration(framework, route, parsedInfo, handler),
);
}
function findInsertPositionForNewImports(
program: Program,
skipImports: readonly ImportDeclaration[],
): number {
const skip = new Set(skipImports);
const others = program.body.filter(
(stmt): stmt is ImportDeclaration => stmt.type === 'ImportDeclaration' && !skip.has(stmt),
);
if (others.length === 0) return 0;
return others[others.length - 1]!.end;
}
@@ -0,0 +1,43 @@
import type { Awaitable } from '@/types';
const routeHandlerHttpMethods = [
'GET',
'HEAD',
'POST',
'PUT',
'PATCH',
'DELETE',
'OPTIONS',
] as const;
export type RouteHandlerHttpMethod = (typeof routeHandlerHttpMethods)[number];
export interface StaticInfo<Params extends string, CatchAll extends string | undefined> {
params: Params[];
catchAll?: CatchAll;
/**
* HTTP verbs this route implements.
*/
methods: readonly RouteHandlerHttpMethod[];
}
export type RouteHandler<Params extends string, CatchAll extends string | undefined> = (
request: Request,
params: RouteHandlerParams<Params, CatchAll>,
) => Awaitable<Response>;
export 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,
};
}
@@ -0,0 +1,32 @@
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 };
}
+12 -7
View File
@@ -1,6 +1,5 @@
import { z } from 'zod';
export type NamespaceType = (typeof namespaces)[number];
export type CompiledFile = z.input<typeof fileSchema>;
export type CompiledComponent = z.input<typeof componentSchema>;
export type CompiledRegistryInfo = z.input<typeof registryInfoSchema>;
@@ -8,21 +7,27 @@ export type DownloadedRegistryInfo = z.output<typeof registryInfoSchema>;
export type File = z.output<typeof fileSchema>;
export type Component = z.output<typeof componentSchema>;
export const namespaces = ['components', 'lib', 'css', 'route', 'ui', 'layout'] as const;
export const indexSchema = z.object({
name: z.string(),
title: z.string().optional(),
description: z.string().optional(),
});
export const fileSchema = z.object({
type: z.literal(namespaces),
path: z.string(),
target: 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(),
+2
View File
@@ -0,0 +1,2 @@
export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
export type Awaitable<T> = T | Promise<T>;
+52
View File
@@ -0,0 +1,52 @@
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('/', '.');
if (flat.startsWith('.')) flat = flat.slice(1);
return `routes/${flat}.${extension}`;
}
case 'react-router': {
const rrPath = route
.split('/')
.map((seg) => {
if (/^\[\[\.\.\.[^/\]]+\]\]$/.test(seg) || /^\[\.\.\.[^/\]]+\]$/.test(seg)) return '$';
return seg.replace(/^\[([^/\]]+)\]$/, (_, v) => `$${v}`);
})
.join('/');
return `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;
}
}
}
-5
View File
@@ -1,5 +0,0 @@
import { exists } from '@/utils/fs';
export async function isSrc(): Promise<boolean> {
return exists('./src');
}
@@ -0,0 +1,44 @@
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('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('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');
});
@@ -0,0 +1,208 @@
import type { Framework } from '@/config';
import { buildRouteHandlerFile } 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);
buildRouteHandlerFile(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 = {
id: (await ctx.params).id
};
return Response.json({ id: p.id });
}
export async function POST(req: Request, ctx: RouteContext<"/api/posts/[id]">) {
const p = {
id: (await ctx.params).id
};
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 = {
id: (await ctx.params).id,
rest: (await ctx.params).rest
};
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/api.posts.$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/api.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);
});
+3 -10
View File
@@ -8,6 +8,7 @@ export default defineConfig({
'./src/{index,config}.ts',
'./src/registry/{client,schema}.ts',
'./src/registry/installer/index.ts',
'./src/registry/macros/route-handler.ts',
'./src/build/index.ts',
],
format: 'esm',
@@ -25,17 +26,9 @@ export default defineConfig({
console.log('JSON schema generated');
await fs.mkdir('dist/schema', { recursive: true });
await fs.writeFile(
'dist/schema/src.json',
'dist/schema.json',
JSON.stringify(
z.toJSONSchema(createConfigSchema(true), {
io: 'input',
}),
),
);
await fs.writeFile(
'dist/schema/default.json',
JSON.stringify(
z.toJSONSchema(createConfigSchema(false), {
z.toJSONSchema(createConfigSchema(), {
io: 'input',
}),
),
+1 -1
View File
@@ -13,7 +13,7 @@ export function resolveExternal(
if (ref.type === 'file') {
const file = path.relative(toRegistryDir, ref.file).replaceAll(path.sep, '/');
if (/^contexts\//.test(file) || /^utils\/use-/.test(file)) {
if (file.startsWith('contexts/') || file.startsWith('utils/use-')) {
return {
dep: toPackageName,
type: 'dependency',
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "tsconfig/base.json",
"compilerOptions": {
"module": "esnext",
"target": "esnext"
"target": "esnext",
"types": ["@types/node"]
},
"exclude": ["node_modules", "dist", "eslint.config.mjs"]
}
+27 -24
View File
@@ -145,7 +145,7 @@ importers:
version: 12.38.0([email protected]([email protected]))([email protected])
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
next-themes:
specifier: ^0.4.6
version: 0.4.6([email protected]([email protected]))([email protected])
@@ -251,7 +251,7 @@ importers:
version: 4.0.3
mcp-handler:
specifier: ^1.1.0
version: 1.1.0(@modelcontextprotocol/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))
version: 1.1.0(@modelcontextprotocol/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))
next-validate-link:
specifier: ^1.6.4
version: 1.6.4
@@ -345,7 +345,7 @@ importers:
version: 1.7.0([email protected])
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -394,7 +394,7 @@ importers:
version: link:../../packages/radix-ui
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -440,7 +440,7 @@ importers:
version: link:../../packages/radix-ui
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -477,7 +477,7 @@ importers:
version: 5.2.17(@types/[email protected])([email protected])
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -526,7 +526,7 @@ importers:
version: 1.7.0([email protected])
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -575,7 +575,7 @@ importers:
version: link:../../packages/radix-ui
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -624,7 +624,7 @@ importers:
version: 1.7.0([email protected])
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -679,7 +679,7 @@ importers:
version: link:../../packages/radix-ui
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -725,7 +725,7 @@ importers:
version: link:../../packages/radix-ui
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -777,7 +777,7 @@ importers:
version: link:../../packages/radix-ui
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -1577,6 +1577,9 @@ importers:
specifier: 1.0.0-alpha.6
version: 1.0.0-alpha.6(@types/[email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]([email protected])))([email protected])([email protected])([email protected])([email protected])
devDependencies:
'@fumadocs/cli':
specifier: workspace:^
version: link:../../packages/cli
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
@@ -1682,7 +1685,7 @@ importers:
version: 12.38.0([email protected]([email protected]))([email protected])
next:
specifier: 16.x.x
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
next-themes:
specifier: ^0.4.6
version: 0.4.6([email protected]([email protected]))([email protected])
@@ -1978,7 +1981,7 @@ importers:
version: 1.7.0([email protected])
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react-router:
specifier: ^7.13.2
version: 7.13.2([email protected]([email protected]))([email protected])
@@ -2276,7 +2279,7 @@ importers:
version: 3.1.0
next:
specifier: 16.2.1
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
@@ -2794,7 +2797,7 @@ importers:
version: 12.38.0([email protected]([email protected]))([email protected])
next:
specifier: 16.x.x
version: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
version: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
next-themes:
specifier: ^0.4.6
version: 0.4.6([email protected]([email protected]))([email protected])
@@ -7647,6 +7650,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-AvXWYFO6j/ZQ7bhGm4X6eilq2JHsDVC90ZM32k2B7/srhC2gs3Sdki1QTbwrdRCo8o7eT+167vcB1yzOvPdbjA==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
@@ -13136,7 +13140,7 @@ snapshots:
dependencies:
'@content-collections/core': 0.14.3([email protected])
'@content-collections/integrations': 0.5.0(@content-collections/[email protected]([email protected]))
next: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
next: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
'@dimforge/[email protected]': {}
@@ -18457,7 +18461,7 @@ snapshots:
optionalDependencies:
'@types/mdast': 4.0.4
'@types/mdx': 2.0.13
next: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
next: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react: 19.2.4
vite: 8.0.3(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
vue: 3.5.31([email protected])
@@ -19270,14 +19274,14 @@ snapshots:
[email protected]: {}
[email protected](@modelcontextprotocol/[email protected]([email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])):
[email protected](@modelcontextprotocol/[email protected]([email protected]))([email protected](@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])):
dependencies:
'@modelcontextprotocol/sdk': 1.28.0([email protected])
chalk: 5.6.2
commander: 11.1.0
redis: 4.7.1
optionalDependencies:
next: 16.2.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
next: 16.2.1(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
[email protected]:
dependencies:
@@ -20019,7 +20023,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]):
[email protected](@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@next/env': 16.2.1
'@swc/helpers': 0.5.15
@@ -20028,7 +20032,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4([email protected])
styled-jsx: 5.1.6(@babel/[email protected])([email protected])([email protected])
styled-jsx: 5.1.6([email protected])([email protected])
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.1
'@next/swc-darwin-x64': 16.2.1
@@ -21661,12 +21665,11 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
[email protected](@babel/[email protected])([email protected])([email protected]):
[email protected]([email protected])([email protected]):
dependencies:
client-only: 0.0.1
react: 19.2.4
optionalDependencies:
'@babel/core': 7.29.0
babel-plugin-macros: 3.1.0
[email protected]: {}