Relevant source files
The following files were used as context for generating this wiki page:
- packages/cli/src/index.ts
- packages/preview/src/cli/index.ts
- packages/cli/src/commands/add.ts
- packages/cli/src/commands/customise.ts
- packages/cli/src/commands/file-tree.ts
- packages/cli/src/commands/export-epub.ts
- packages/create-app/src/bin.ts
- packages/sanity/registry/index.ts
- packages/cli/src/commands/shared.ts
- packages/base-ui/registry/index.ts
- packages/cli/package.json
- packages/python/fumapy/init.py
- packages/core/src/source/client/index.tsx
- packages/epub/src/index.ts
- packages/radix-ui/registry/index.ts
- packages/asyncapi/src/server/index.tsx
- packages/preview/src/pages/...slugs.tsx
- packages/create-app/package.json
- packages/sanity/registry/files.component.tsx
- packages/shared/registry.ts
- packages/python/pyproject.toml
- packages/python/src/convert.ts
- packages/preview/package.json
- packages/cli/src/registry/installer.ts
- packages/cli/src/registry/plugins/preserve.ts
- packages/python/package.json
- packages/python/src/components/index.tsx
- packages/epub/package.json
- packages/shadcn/src/index.ts
- packages/local-md/src/bin.ts
Overview
The Fumadocs CLI (@fumadocs/cli) is an operational tool designed to scaffold projects, manage component installations, customize documentation layouts, and process documentation structures. Operating through the commander package, it offers commands for configuration initialization, component downloads via add, interactive layout modifications via customise, EPUB Book Exporting, and directory tree parsing into MDX or JavaScript components.
Sources: packages/cli/package.json:2-15, packages/cli/src/index.ts:15-106
CLI Architecture and Command Dispatch
Entry Point and Command Registration
The Fumadocs CLI executable is defined in packages/cli/src/index.ts and configured as a Node.js command-line tool via the bin field in packages/cli/package.json. It instantiates a root Command object from commander, reads its version metadata from packageJson.version, and registers global options such as --config <string>. When executed without arguments, the root action invokes initConfig(), which checks for or creates a ./cli.json configuration file and logs status messages using picocolors.
"bin": {
"cli": "./dist/index.js"
},Sources: packages/cli/package.json:13-15
const program = new Command().option('--config <string>');
program
.name('fumadocs')
.description('CLI to setup Fumadocs, init a config')
.version(packageJson.version)
.action(async () => {
if (await initConfig()) {
console.log(picocolors.green('Initialized a `./cli.json` config file.'));
} else {
console.log(picocolors.redBright('A config file already exists.'));
}
});Sources: packages/cli/src/index.ts:15-27
Subcommands are registered directly on the program instance or nested under parent command groups. The available CLI commands, their aliases, arguments, options, and actions are structured as follows:
| Command | Aliases | Arguments | Options | Action / Handler |
|---|---|---|---|---|
fumadocs (default) | — | — | --config <string> | Initializes ./cli.json via initConfig(). |
customise | customize | — | --dir <string>, --config <string> | Loads config and runs customise with a registry client. |
add | — | [components...] | --dir <string>, --config <string> | Downloads components using a registry client and config. |
export epub | — | — | --framework <name>, --output <path>, --scaffold-only | Exports documentation to EPUB format via exportEpub. |
tree | — | [json_or_args], [output] | --js, --no-root, --import-name <string>, --config <string> | Parses directory trees or JSON nodes into MDX or JS formats. |
Sources: packages/cli/src/index.ts:17-106
Registry Connector Resolution and Configuration
Registry source resolution is handled by createClientFromDir, which maps shortcut keywords to predefined remote URLs or distinguishes between HTTP endpoints and local filesystem paths.
const dirShortcuts: Record<string, string> = {
':preview': 'https://preview.fumadocs.dev/registry',
':dev': 'http://localhost:3000/registry',
};Sources: packages/cli/src/index.ts:39-42
The shortcut mapping supports the following expansion values:
| Shortcut | Target URL |
|---|---|
:preview | https://preview.fumadocs.dev/registry |
:dev | http://localhost:3000/registry |
Sources: packages/cli/src/index.ts:39-42
Note
If the directory parameter does not match :preview or :dev, createClientFromDir evaluates whether the string begins with http:// or https://. If it does, it instantiates an HttpRegistryConnector; otherwise, it falls back to a LocalRegistryConnector.
function createClientFromDir(dir = 'https://fumadocs.dev/registry') {
if (dir in dirShortcuts) dir = dirShortcuts[dir];
return dir.startsWith('http://') || dir.startsWith('https://')
? new HttpRegistryConnector(dir)
: new LocalRegistryConnector(dir);
}Sources: packages/cli/src/index.ts:108-114
Registry Component Installation Flow
Component Selection and Interactive Prompts
The component addition command (add) managed in packages/cli/src/commands/add.ts facilitates downloading and integrating UI elements, layouts, and integration components into a project. When invoked without explicit component arguments (input.length === 0), the CLI displays a loading spinner and fetches registry information using connector.fetchRegistryInfo(subRegistry). It constructs a list of selectable items by scanning across three distinct sources: common components (undefined sub-registry with prefix common), Sanity components (fumadocs/sanity with prefix sanity), and the UI library registry configured by config.uiLibrary (mapped via UIRegistries) with prefix ui.
const subRegistry = UIRegistries[config.uiLibrary];
if (input.length === 0) {
const spin = spinner();
spin.start('fetching registry');
async function scan(subRegistry?: string, prefix?: string): Promise<AddOption[]> {
const info = await connector.fetchRegistryInfo(subRegistry);
return info.indexes.map((item) => ({
label: `${prefix ? `${picocolors.bold(prefix)} - ` : ''}${item.title ?? 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: [
...(await scan(undefined, 'common')),
...(await scan('fumadocs/sanity', 'sanity')),
...(await scan(subRegistry, 'ui')),
].sort((a, b) => a.label.localeCompare(b.label)),
});Sources: packages/cli/src/commands/shared.ts:1-4, packages/cli/src/commands/add.ts:22-46
The UI registries mapping defines the sub-registry paths associated with supported UI libraries:
| UI Library Key | Sub-Registry Path |
|---|---|
base-ui | fumadocs/base-ui |
radix-ui | fumadocs/radix-ui |
Sources: packages/cli/src/commands/shared.ts:1-4
Presented through @clack/prompts's autocompleteMultiselect, the sorted options allow users to select multiple components simultaneously. If the user cancels the prompt via isCancel(value), the command outputs an ending message and exits gracefully. When components are provided directly as arguments via the command line, the CLI checks each item against connector.hasComponent(item) to assign the appropriate sub-registry or defaults to the configured UI registry.
if (isCancel(value)) {
outro('Ended');
return;
}
targets = value;
} else {
targets = await Promise.all(
input.map(async (item) =>
(await connector.hasComponent(item)) ? { name: item } : { subRegistry, name: item },
),
);
}Sources: packages/cli/src/commands/add.ts:48-60
Warning
If a concurrent installation is attempted while this.interactive is already populated in FumadocsComponentInstaller, an error stating cannot install while installing another component is thrown, preventing race conditions during file writing.
async installInteractive(name: string, subRegistry?: string): Promise<void> {
if (this.interactive) {
throw new Error(`cannot install while installing another component`);
}
Sources: packages/cli/src/registry/installer.ts:54-58
Installer Configuration and Layout Preservation
FumadocsComponentInstaller extends ComponentInstaller from fuma-cli/registry/installer, binding output directories to configuration aliases and registering plugins such as pluginPreserveLayouts().
export class FumadocsComponentInstaller extends ComponentInstaller {
private interactive: {
name: string;
spin: SpinnerResult;
} | null = null;
constructor(connector: RegistryConnector, config: LoadedConfig, cwd?: string) {
super(connector, {
cwd,
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.interactive?.spin.message(message);
},
confirmFileOverride: async (options) => {
if (!this.interactive) return true;
const { name, spin } = this.interactive;
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.interactive?.spin.message(options.path);
},
},
plugins: [pluginPreserveLayouts()],
});
}Sources: packages/cli/src/registry/installer.ts:9-52
The pluginPreserveLayouts plugin defined in packages/cli/src/registry/plugins/preserve.ts intercepts sub-component trees and import statements to maintain references to built-in layout packages unless a layout itself is being installed directly. The tracked layout components include:
| Layout Identifier | Mapped Package / Specifier |
|---|---|
layouts/home | fumadocs-ui/layouts/home |
layouts/flux | fumadocs-ui/layouts/flux |
layouts/notebook | fumadocs-ui/layouts/notebook |
layouts/docs | fumadocs-ui/layouts/docs |
layouts/shared | fumadocs-ui/layouts/shared |
Sources: packages/cli/src/registry/plugins/preserve.ts:7-30, packages/cli/src/registry/plugins/preserve.ts:43-50
Note
When downloading files, FumadocsComponentInstaller hooks into confirmFileOverride to prompt the user via @clack/prompts if an existing file conflicts with a downloaded registry asset, terminating the process if cancelled.
confirmFileOverride: async (options) => {
if (!this.interactive) return true;
const { name, spin } = this.interactive;
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;
},Sources: packages/cli/src/registry/installer.ts:31-45
Dependency Resolution and Package Manager Execution
After downloading target components, installInteractive evaluates required dependencies through deps.hasRequired(). If missing dependencies or devDependencies are identified, they are displayed inside a boxed prompt using @clack/prompts.
try {
this.interactive = { name, spin };
const deps = await super.install(name, subRegistry).then((res) => res.deps());
spin.stop(picocolors.bold(picocolors.greenBright(`${name} installed`)));
if (deps.hasRequired()) {
log.message();
box([...deps.dependencies, ...deps.devDependencies].join('\n'), 'New Dependencies');
const pm = (await detectPackageManager())?.name ?? 'npm';Sources: packages/cli/src/registry/installer.ts:62-70
The CLI detects the local project's package manager via detectPackageManager(), defaulting to npm if no manager is found. Users are prompted to confirm whether they want to install the packages immediately with the detected manager.
const pm = (await detectPackageManager())?.name ?? 'npm';
const value = await confirm({
message: `Do you want to install with ${pm}?`,
});
if (isCancel(value)) {
outro('Installation terminated');
process.exit(0);
}
Sources: packages/cli/src/registry/installer.ts:70-79
If confirmed, the installer starts a spinner and invokes deps.installRequired(pm) to execute the installation. If declined, it invokes deps.writeRequired() to write declarations without running the package manager.
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();
}
}Sources: packages/cli/src/registry/installer.ts:80-90
Interactive Layout and Slot Customization
Layout Selection and Customization Targets
The customise command guides users through interactive selection prompts using @clack/prompts to choose a UI layout and target parts for customization based on the configured UI library registry. The available layout targets and their identifiers are defined with specific integration hints.
| Layout Option | Identifier (id) | Target Registry Path | Description |
|---|---|---|---|
| Docs Layout | docs | layouts/docs | The default documentation layout |
| Notebook Layout | notebook | layouts/notebook | A more compact variant of the docs layout |
| Flux Layout | flux | layouts/flux | The experimental variant of the docs layout |
| Home Layout | home | layouts/home | The layout for non-documentation pages |
Sources: packages/cli/src/commands/customise.ts:31-89, packages/cli/src/commands/shared.ts:1-4
When a layout other than home is selected, users can choose between installing the entire layout, replacing and rewriting from minimal styles via layouts/docs-min, or customizing specific component slots by parsing unlisted registry indexes matching slots/${selected.id}.
return select<TargetInfo>({
message: 'Which part do you want to customize?',
options: [
{
label: 'All',
hint: 'install the entire layout',
value: selected,
},
{
label: 'Replace & rewrite from minimal styles',
hint: 'for those who want to build their own UI from ground up',
value: {
id: 'docs-min',
targets: [{ name: 'layouts/docs-min' }],
print() {
printLayout(
['fumadocs-ui/layouts/docs', '@/layouts/docs'],
['fumadocs-ui/layouts/docs/page', '@/layouts/docs/page'],
);
},
},
},
...info.unlistedIndexes.flatMap((index) => {
const prefix = `slots/${selected.id}`;
if (!index.name.startsWith(prefix)) return [];
let name = index.name.slice(prefix.length + 1);
if (name.startsWith('page/')) {
name = name.slice('page/'.length);
return {
label: `Page: ${name}`,
hint: "only replace a part of layout's page, useful for adjusting details",
value: {
id: index.name,
targets: [{ subRegistry, name: index.name }],
print() {
printSlot({
at: `@/layouts/${selected.id}/page/slots/${name}`,
layoutId: selected.id,
name,
isPage: true,
});
},
} as TargetInfo,
};
}
return {
label: `Layout: ${name}`,
hint: 'only replace a part of layout, useful for adjusting details',
value: {
id: index.name,
targets: [{ subRegistry, name: index.name }],
print() {
printSlot({
at: `@/layouts/${selected.id}/slots/${name}`,
layoutId: selected.id,
name,
isPage: false,
});
},
} as TargetInfo,
};
}),
],
});Sources: packages/cli/src/commands/customise.ts:94-160
Note
For non-home layouts, slot customization paths distinguish between page-level parts prefixed with page/ and general layout slots, generating import mapping advice for either <DocsPage /> or <DocsLayout /> components accordingly.
if (name.startsWith('page/')) {
name = name.slice('page/'.length);
return {
label: `Page: ${name}`,
hint: "only replace a part of layout's page, useful for adjusting details",
value: {
id: index.name,
targets: [{ subRegistry, name: index.name }],
print() {
printSlot({
at: `@/layouts/${selected.id}/page/slots/${name}`,
layoutId: selected.id,
name,
isPage: true,
});
},
} as TargetInfo,
};
}
return {
label: `Layout: ${name}`,
hint: 'only replace a part of layout, useful for adjusting details',
value: {
id: index.name,
targets: [{ subRegistry, name: index.name }],
print() {
printSlot({
at: `@/layouts/${selected.id}/slots/${name}`,
layoutId: selected.id,
name,
isPage: false,
});
},
} as TargetInfo,
};Sources: packages/cli/src/commands/customise.ts:121-157
File Tree Inspection and Formatting
Directory Parsing and JsonTreeNode Schema
The tree command processes directory structures by accepting either a raw JSON string argument or scanning a target directory via runTree. The underlying AST nodes are defined by the JsonTreeNode union type, supporting files, directories, reports, and links.
export type JsonTreeNode =
| {
type: 'file';
name: string;
}
| {
type: 'directory';
name: string;
contents: JsonTreeNode[];
}
| {
type: 'report';
}
| {
type: 'link';
name: string;
};Sources: packages/cli/src/commands/file-tree.ts:1-18
When parsing inputs, valid nodes are filtered against a known scanned list containing file, directory, and link node types.
Node Type (type) | Properties | Description |
|---|---|---|
file | name: string | Represents an individual file within the tree |
directory | name: string, contents: JsonTreeNode[] | Represents a folder containing nested child nodes |
link | name: string | Represents a symbolic or external link node |
report | None | Represents an execution report or diagnostic entry |
Sources: packages/cli/src/commands/file-tree.ts:1-19
Note
If a directory node contains a single nested directory child, the parser flattens the path by combining their names (e.g., item.name/child.name) to avoid rendering redundant single-child nesting levels.
if (item.type === 'directory') {
if (item.contents.length === 1 && 'name' in item.contents[0]) {
const child = item.contents[0];
return toNode({
...child,
name: `${item.name}/${child.name}`,
});
}Sources: packages/cli/src/commands/file-tree.ts:27-35
Conversion to MDX and JavaScript Structures
The treeToMdx function recursively transforms JsonTreeNode arrays into MDX component syntax utilizing <Files>, <Folder>, and <File> tags. When the noRoot option is enabled and the root input consists of a single directory, the top-level container is unwrapped so that its internal contents become the primary children.
export function treeToMdx(input: JsonTreeNode[], noRoot = false): string {
function toNode(item: JsonTreeNode): string {
if (item.type === 'file' || item.type === 'link') {
return `<File name=${JSON.stringify(item.name)} />`;
}
if (item.type === 'directory') {
if (item.contents.length === 1 && 'name' in item.contents[0]) {
const child = item.contents[0];
return toNode({
...child,
name: `${item.name}/${child.name}`,
});
}
return `<Folder name=${JSON.stringify(item.name)}>
${item.contents.map(toNode).filter(Boolean).join('\n')}
</Folder>`;
}
return '';
}
let children = input.filter((v) => scanned.includes(v.type));
if (noRoot && children.length === 1 && input[0].type === 'directory') {
children = input[0].contents;
}
return `<Files>
${children.map(toNode).filter(Boolean).join('\n')}
</Files>`;
}Sources: packages/cli/src/commands/file-tree.ts:21-54
export function treeToJavaScript(
input: JsonTreeNode[],
noRoot?: boolean,
importName = 'fumadocs-ui/components/files',
): string {
return `import { File, Files, Folder } from ${JSON.stringify(importName)}
export default (${treeToMdx(input, noRoot)})`;
}Sources: packages/cli/src/commands/file-tree.ts:56-64
Warning
The CLI automatically selects JavaScript output format if the --js flag is passed or if the output file path extension matches .js, .tsx, or .jsx; otherwise, it outputs raw MDX components.
const jsExtensions = ['.js', '.tsx', '.jsx'];
const noRoot = !root;
let nodes: JsonTreeNode[];
try {
nodes = JSON.parse(str ?? '') as JsonTreeNode[];
} catch {
nodes = await runTree(str ?? './');
}
const out =
js || (output && jsExtensions.includes(path.extname(output)))
? treeToJavaScript(nodes, noRoot, importName)
: treeToMdx(nodes, noRoot);Sources: packages/cli/src/index.ts:84-97
Registry Source Organization and Indexing
UI Library Catalog Structures
Fumadocs manages component registries across different UI packages, including fumadocs/radix-ui, fumadocs/base-ui, and fumadocs/sanity. Each registry defines an export structure containing its registry name, source directory (dir), a catalog of components with associated files and targets, and package dependencies.
export const registry: Registry = {
name: 'fumadocs/radix-ui',
dir,
components: [
...commonComponents,
...(await findSlotComponents(dir)),
// ... layout and component definitions
],
dependencies: {
'fumadocs-core': null,
'fumadocs-ui': null,
react: null,
},
};Sources: packages/radix-ui/registry/index.ts:9-345
Shared utilities and common components—such as URL handlers, class-name merging, and table-of-contents clerks—are reused across libraries through modular registry shares. Slot components are dynamically discovered using tinyglobby scanning over layout directories.
export async function findSlotComponents(dir: string): Promise<Component[]> {
const slots: Component[] = [];
for (const file of await glob('layouts/**/slots/*', { cwd: dir })) {
const relativePath = path.relative('layouts', file);
const name = relativePath
.split(path.sep)
.filter((v) => v !== 'slots')
.join('/')
.slice(0, -path.extname(relativePath).length);
slots.push({
name: `slots/${name}`,
unlisted: true,
files: [
{
path: file,
type: 'layout',
target: path.join('<dir>', relativePath),
},
],
});
}
return slots;
}Sources: packages/shared/registry.ts:5-30
Note
The order of files matters when writing import paths on the consumer's codebase, ensuring that common utilities and slot components precede layout and UI primitives in the registry array.
// in shadcn cli, the order of files matters when writing import paths on consumer's codebase
export const registry: Registry = {
name: 'fumadocs/radix-ui',
dir,
components: [
...commonComponents,
...(await findSlotComponents(dir)),Sources: packages/radix-ui/registry/index.ts:8-14
Component Manifest Definitions
Component entries within a registry specify metadata, file types, source paths, and installation targets. Unlisted components, such as low-level UI primitives (button, popover, scroll-area, collapsible) and internal layout variations (layouts/sidebar, layouts/shared, layouts/docs, layouts/flux, layouts/notebook, layouts/home), are omitted from public component listings while remaining installable by the CLI.
| Component Name | File Types | Target/Preserve Settings | Description / Notes |
|---|---|---|---|
github-info | components | Default target | A card to display GitHub repo info |
image-zoom | components, css | Default target | Zoomable Image component with styles |
layouts/shared | layout | <dir>/shared/index.tsx, client.tsx (preserveAs: layouts/shared) | Unlisted shared layout files |
layouts/docs | layout | <dir>/docs/index.tsx, client.tsx, page/index.tsx | Unlisted documentation layout files |
ai/page-actions | components | <dir>/ai/page-actions.tsx | Common page actions for AI search integration |
Sources: packages/radix-ui/registry/index.ts:36-339
Warning
Registry entries that include layout files must specify metadata with preserveAs rules to prevent incorrect path rewriting during consumer project scaffolding.
meta: {
preserveAs: 'layouts/shared',
},
},Sources: packages/radix-ui/registry/index.ts:44-47
Related
On This Page
OverviewCLI Architecture and Command DispatchEntry Point and Command RegistrationRegistry Connector Resolution and ConfigurationRegistry Component Installation FlowComponent Selection and Interactive PromptsInstaller Configuration and Layout PreservationDependency Resolution and Package Manager ExecutionInteractive Layout and Slot CustomizationLayout Selection and Customization TargetsFile Tree Inspection and FormattingDirectory Parsing and JsonTreeNode SchemaConversion to MDX and JavaScript StructuresRegistry Source Organization and IndexingUI Library Catalog StructuresComponent Manifest DefinitionsRelated