CLI: build for both Shadcn & Fumadocs registry

This commit is contained in:
Fuma Nama
2025-08-15 23:00:17 +08:00
parent 9b0b3d7a55
commit 3f6e948ba3
20 changed files with 180 additions and 218 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@fumadocs/cli': major
---
Redesign installer & fumadocs registry schema
+2 -1
View File
@@ -2,4 +2,5 @@ out
content/docs/openapi/**/*.mdx
!content/docs/openapi/index.mdx
public/r
public/r
public/registry
+20 -9
View File
@@ -1,15 +1,26 @@
import { build, writeShadcnRegistry } from '@fumadocs/cli/build';
import {
build,
combineRegistry,
writeFumadocsRegistry,
writeShadcnRegistry,
} from '@fumadocs/cli/build';
import { registry } from '@/components/registry.mjs';
import * as ui from '../../../packages/ui/src/_registry';
export async function buildRegistry() {
await writeShadcnRegistry(await build(registry), {
dir: 'public/r',
baseUrl: 'http://localhost:3000',
});
const [mainRegistry, uiRegistry] = await Promise.all([
build(registry),
build(ui.registry),
]);
const all = combineRegistry(mainRegistry, uiRegistry);
await writeShadcnRegistry(await build(ui.registry), {
dir: 'public/r',
baseUrl: 'http://localhost:3000',
});
await Promise.all([
writeFumadocsRegistry(all, {
dir: 'public/registry',
}),
writeShadcnRegistry(all, {
dir: 'public/r',
baseUrl: 'http://localhost:3000',
}),
]);
}
@@ -4,7 +4,7 @@ import {
DocsDescription,
DocsPage,
DocsTitle,
} from '@/components/layout/page';
} from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/mdx-components';
+1 -1
View File
@@ -1,5 +1,5 @@
import { source } from '@/lib/source';
import { DocsLayout } from '@/components/layout/docs';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
import { baseOptions } from '@/app/layout.config';
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+1 -18
View File
@@ -8,29 +8,12 @@
"start": "next start"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^1.0.5",
"@ai-sdk/react": "^2.0.10",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-presence": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.2.9",
"ai": "^5.0.10",
"class-variance-authority": "^0.7.1",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"hast-util-to-jsx-runtime": "^2.3.6",
"lucide-react": "^0.539.0",
"next": "15.4.6",
"next-themes": "^0.4.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-rehype": "^11.1.2",
"tailwind-merge": "^3.3.1",
"zod": "^4.0.17"
"react-dom": "19.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
+1 -1
View File
@@ -38,12 +38,12 @@
"commander": "^14.0.0",
"package-manager-detector": "^1.3.0",
"picocolors": "^1.1.1",
"shadcn": "2.9.3-canary.0",
"tinyexec": "^1.0.1",
"ts-morph": "^26.0.0",
"zod": "^4.0.17"
},
"devDependencies": {
"shadcn": "2.9.3-canary.0",
"@types/node": "24.2.1",
"eslint-config-custom": "workspace:*",
"tsconfig": "workspace:*",
+3 -2
View File
@@ -1,7 +1,8 @@
import * as path from 'node:path';
import { SourceFile, StringLiteral, ts } from 'ts-morph';
import { ComponentBuilder } from '@/build/component-builder';
import { Component, ComponentFile, OutputFile } from '@/build/build-registry';
import type { ComponentBuilder } from '@/build/component-builder';
import type { Component, ComponentFile } from '@/build/build-registry';
import type { OutputFile } from '@/registry/schema';
export type SourceReference =
| {
+10 -42
View File
@@ -6,6 +6,12 @@ import {
createComponentBuilder,
} from './component-builder';
import { validateOutput } from '@/build/validate';
import type {
NamespaceType,
Output,
OutputComponent,
OutputFile,
} from '@/registry/schema';
export type OnResolve = (reference: SourceReference) => Reference;
@@ -32,14 +38,6 @@ export interface Component {
onResolve?: OnResolve;
}
export type NamespaceType =
| 'components'
| 'lib'
| 'css'
| 'route'
| 'ui'
| 'block';
export interface PackageJson {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
@@ -69,37 +67,6 @@ export interface Registry {
devDependencies?: Record<string, string | null>;
}
export interface Output {
name: string;
index: OutputIndex[];
components: OutputComponent[];
}
export interface OutputIndex {
name: string;
title?: string;
description?: string;
}
export interface OutputFile {
type: NamespaceType;
path: string;
target?: string;
content: string;
}
export interface OutputComponent {
name: string;
title?: string;
description?: string;
files: OutputFile[];
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
subComponents: string[];
}
export async function build(registry: Registry): Promise<Output> {
const output: Output = {
name: registry.name,
@@ -141,8 +108,8 @@ export async function build(registry: Registry): Promise<Output> {
async function buildComponent(component: Component, builder: ComponentBuilder) {
const processedFiles = new Set<string>();
const subComponents = new Set<string>();
const devDependencies = new Map<string, string>();
const dependencies = new Map<string, string>();
const devDependencies = new Map<string, string | null>();
const dependencies = new Map<string, string | null>();
// see https://github.com/shadcn-ui/ui/blob/396275e46a58333caa1fa0a991bd9bc5237d2ee3/packages/shadcn/src/utils/updaters/update-files.ts#L585
// to hit the fast-path step, we need to import `target` path first because it's detected from `fileSet`, a set of output file paths
@@ -185,8 +152,9 @@ async function buildComponent(component: Component, builder: ComponentBuilder) {
const dep = builder.getDepInfo(reference.dep);
if (dep) {
const map = dep.type === 'dev' ? devDependencies : dependencies;
map.set(dep.name, dep.version ?? '');
map.set(dep.name, dep.version);
}
return reference.specifier;
});
+1 -1
View File
@@ -1,9 +1,9 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import picocolors from 'picocolors';
import { type Output } from '@/build/build-registry';
import { toShadcnRegistry } from '@/build/shadcn';
import { validateOutput } from '@/build/validate';
import type { Output } from '@/registry/schema';
export * from './build-registry';
export * from './component-builder';
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Output, OutputComponent } from '@/build/build-registry';
import type { Output, OutputComponent } from '@/registry/schema';
import type { Registry as ShadcnRegistry, RegistryItem } from 'shadcn/registry';
function mapDeps(deps: Record<string, string | null>) {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Output, OutputComponent } from '@/build/build-registry';
import type { Output, OutputComponent } from '@/registry/schema';
export function validateOutput(registry: Output) {
function validateComponent(
+14 -10
View File
@@ -6,7 +6,6 @@ import {
outro,
spinner,
} from '@clack/prompts';
import type { OutputIndex } from '@/build';
import picocolors from 'picocolors';
import {
type ComponentInstaller,
@@ -15,6 +14,7 @@ import {
} from '@/utils/add/install-component';
import { installDeps } from '@/utils/add/install-deps';
import type { LoadedConfig } from '@/config';
import { validateRegistryIndex } from '@/registry/client';
export async function add(
input: string[],
@@ -30,19 +30,22 @@ export async function add(
if (input.length === 0) {
const spin = spinner();
spin.start('fetching registry');
const registry = (await resolver('_registry.json')) as
| OutputIndex[]
| undefined;
spin.stop(picocolors.bold(picocolors.greenBright('registry fetched')));
const indexes = validateRegistryIndex(
await resolver('_registry.json').then((res) => {
if (!res) {
log.error(`Failed to fetch '_registry.json' file from registry`);
process.exit(1);
}
if (!registry) {
log.error(`Failed to fetch '_registry.json' file from registry`);
throw new Error(`Failed to fetch registry`);
}
return res;
}),
);
spin.stop(picocolors.bold(picocolors.greenBright('registry fetched')));
const value = await multiselect({
message: 'Select components to install',
options: registry.map((item) => ({
options: indexes.map((item) => ({
label: item.title,
value: item.name,
hint: item.description,
@@ -80,6 +83,7 @@ export async function install(target: string[], installer: ComponentInstaller) {
outro(picocolors.bold(picocolors.greenBright(`${name} installed`)));
} catch (e) {
log.error(String(e));
throw e;
}
}
+24 -28
View File
@@ -99,40 +99,36 @@ export async function customise(resolver: Resolver, config: LoadedConfig) {
}
await install(targets, installer);
const maps: [string, string][] = [
['fumadocs-ui/layouts/docs', '@/components/layout/docs'],
];
intro(picocolors.bold('What is Next?'));
log.info(
[
'You can check the installed components in `components/layouts`.',
picocolors.dim('---'),
'Open your `layout.tsx` files, replace the imports of components:',
picocolors.greenBright(
'`fumadocs-ui/layouts/docs` -> `@/components/layouts/docs`',
),
pageAdded
? picocolors.greenBright(
'`fumadocs-ui/page` -> `@/components/layouts/page`',
)
: '',
].join('\n'),
);
if (pageAdded) {
maps.push(['fumadocs-ui/page', '@/components/layout/page']);
}
printNext(...maps);
}
if (result.target === 'home') {
await install(['layouts/home'], installer);
intro(picocolors.bold('What is Next?'));
log.info(
[
'You can check the installed components in `components/layouts`.',
picocolors.dim('---'),
'Open your `layout.tsx` files, replace the imports of components:',
picocolors.greenBright(
'`fumadocs-ui/layouts/home` -> `@/components/layouts/home`',
),
].join('\n'),
);
printNext(['fumadocs-ui/layouts/home', `@/components/layout/home`]);
}
outro(picocolors.bold('Have fun!'));
}
function printNext(...maps: [from: string, to: string][]) {
intro(picocolors.bold('What is Next?'));
log.info(
[
'You can check the installed components in `components`.',
picocolors.dim('---'),
'Open your `layout.tsx` files, replace the imports of components:',
...maps.map(([from, to]) =>
picocolors.greenBright(`"${from}" -> "${to}"`),
),
].join('\n'),
);
}
+5 -5
View File
@@ -5,11 +5,11 @@ import { z } from 'zod';
function createConfigSchema(isSrc: boolean) {
const defaultAliases = {
uiDir: './components',
componentsDir: './components/ui',
blockDir: './lib',
cssDir: './components',
libDir: './styles',
uiDir: './components/ui',
componentsDir: './components',
blockDir: './components',
cssDir: './styles',
libDir: './lib',
};
return z.object({
+15
View File
@@ -0,0 +1,15 @@
import {
componentSchema,
indexSchema,
type OutputComponent,
type OutputIndex,
} from '@/registry/schema';
import { z } from 'zod';
export function validateRegistryIndex(indexes: unknown): OutputIndex[] {
return z.array(indexSchema).parse(indexes);
}
export function validateRegistryComponent(component: unknown): OutputComponent {
return componentSchema.parse(component);
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod';
export type Output = z.infer<typeof rootSchema>;
export type NamespaceType = (typeof namespaces)[number];
export type OutputIndex = z.infer<typeof indexSchema>;
export type OutputFile = z.infer<typeof fileSchema>;
export type OutputComponent = z.infer<typeof componentSchema>;
export const namespaces = [
'components',
'lib',
'css',
'route',
'ui',
'block',
] 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(),
content: 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())),
subComponents: z.array(z.string()).default([]),
});
export const rootSchema = z.object({
name: z.string(),
index: z.array(indexSchema),
components: z.array(componentSchema),
});
+15 -10
View File
@@ -4,14 +4,14 @@ import { confirm, isCancel, log, outro } from '@clack/prompts';
import { createEmptyProject } from '@/utils/typescript';
import { type LoadedConfig } from '@/config';
import { typescriptExtensions } from '@/constants';
import { type OutputComponent, type OutputFile } from '@/build';
import {
toImportSpecifier,
transformReferences,
} from '@/utils/transform-references';
import type { OutputComponent, OutputFile } from '@/registry/schema';
import { validateRegistryComponent } from '@/registry/client';
type Awaitable<T> = T | Promise<T>;
export type Resolver = (file: string) => Awaitable<object | undefined>;
export type Resolver = (file: string) => Promise<unknown | undefined>;
type DownloadedComponents = Omit<OutputComponent, 'subComponents'>[];
@@ -71,8 +71,8 @@ export function createComponentInstaller(options: {
const status = await fs
.readFile(outPath)
.then((res) => {
if (res.toString() !== output) return 'need-update';
return 'ignore';
if (res.toString() === output) return 'ignore';
return 'need-update';
})
.catch(() => 'write');
@@ -110,12 +110,17 @@ export function createComponentInstaller(options: {
const cached = downloadedComps.get(name);
if (cached) return cached;
const comp = (await resolver(`${name}.json`)) as
| OutputComponent
| undefined;
if (!comp) throw new Error(`component ${name} not found`);
const comp = validateRegistryComponent(
await resolver(`${name}.json`).then((res) => {
if (!res) {
log.error(`component ${name} not found`);
process.exit(1);
}
const result: DownloadedComponents = [];
return res;
}),
);
const result: DownloadedComponents = [comp];
// place it before downloading child components to avoid recursive downloads
downloadedComps.set(name, result);
+15 -66
View File
@@ -133,7 +133,7 @@ importers:
version: 11.9.0
next:
specifier: 15.4.6
version: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
version: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
next-themes:
specifier: ^0.4.6
version: 0.4.6([email protected]([email protected]))([email protected])
@@ -272,7 +272,7 @@ importers:
version: link:../../packages/ui
next:
specifier: ^15.4.6
version: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
version: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: 19.1.1
version: 19.1.1
@@ -318,7 +318,7 @@ importers:
version: link:../../packages/ui
next:
specifier: 15.4.6
version: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
version: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: 19.1.1
version: 19.1.1
@@ -341,33 +341,6 @@ importers:
examples/next-mdx:
dependencies:
'@ai-sdk/openai-compatible':
specifier: ^1.0.5
version: 1.0.5([email protected])
'@ai-sdk/react':
specifier: ^2.0.10
version: 2.0.10([email protected])([email protected])
'@radix-ui/react-collapsible':
specifier: ^1.1.11
version: 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-dialog':
specifier: ^1.1.14
version: 1.1.14(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-popover':
specifier: ^1.1.14
version: 1.1.14(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-presence':
specifier: ^1.1.4
version: 1.1.4(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-scroll-area':
specifier: ^1.2.9
version: 1.2.9(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
ai:
specifier: ^5.0.10
version: 5.0.10([email protected])
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
fumadocs-core:
specifier: workspace:*
version: link:../../packages/core
@@ -377,39 +350,15 @@ importers:
fumadocs-ui:
specifier: workspace:*
version: link:../../packages/ui
hast-util-to-jsx-runtime:
specifier: ^2.3.6
version: 2.3.6
lucide-react:
specifier: ^0.539.0
version: 0.539.0([email protected])
next:
specifier: 15.4.6
version: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
next-themes:
specifier: ^0.4.6
version: 0.4.6([email protected]([email protected]))([email protected])
version: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: 19.1.1
version: 19.1.1
react-dom:
specifier: 19.1.1
version: 19.1.1([email protected])
remark:
specifier: ^15.0.1
version: 15.0.1
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
remark-rehype:
specifier: ^11.1.2
version: 11.1.2
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
zod:
specifier: ^4.0.17
version: 4.0.17
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.1.11
@@ -449,7 +398,7 @@ importers:
version: link:../../packages/ui
next:
specifier: 15.4.6
version: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
version: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: 19.1.1
version: 19.1.1
@@ -501,7 +450,7 @@ importers:
version: link:../../packages/ui
next:
specifier: 15.4.6
version: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
version: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.1.1
version: 19.1.1
@@ -742,7 +691,7 @@ importers:
version: link:../../packages/ui
next:
specifier: 15.4.6
version: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
version: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: 19.1.1
version: 19.1.1
@@ -942,9 +891,6 @@ importers:
picocolors:
specifier: ^1.1.1
version: 1.1.1
shadcn:
specifier: 2.9.3-canary.0
version: 2.9.3-canary.0(@types/[email protected])([email protected])
tinyexec:
specifier: ^1.0.1
version: 1.0.1
@@ -961,6 +907,9 @@ importers:
eslint-config-custom:
specifier: workspace:*
version: link:../eslint-config-custom
shadcn:
specifier: 2.9.3-canary.0
version: 2.9.3-canary.0(@types/[email protected])([email protected])
tsconfig:
specifier: workspace:*
version: link:../tsconfig
@@ -1102,7 +1051,7 @@ importers:
version: 2.0.1
next:
specifier: ^15.4.6
version: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
version: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
react-router:
specifier: ^7.8.0
version: 7.8.0([email protected]([email protected]))([email protected])
@@ -1383,7 +1332,7 @@ importers:
version: 3.2.0
next:
specifier: ^15.4.6
version: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
version: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.1.1
version: 19.1.1
@@ -1799,7 +1748,7 @@ importers:
version: link:../eslint-config-custom
next:
specifier: 15.4.6
version: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
version: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
tailwindcss:
specifier: ^4.1.11
version: 4.1.11
@@ -11554,7 +11503,7 @@ snapshots:
dependencies:
'@content-collections/core': 0.10.0([email protected])
'@content-collections/integrations': 0.2.1(@content-collections/[email protected]([email protected]))
next: 15.4.6(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
next: 15.4.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
'@dabh/[email protected]':
dependencies:
@@ -18974,7 +18923,7 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
[email protected](@opentelemetry/[email protected])([email protected]([email protected]))([email protected]):
[email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@next/env': 15.4.6
'@swc/helpers': 0.5.15