CLI: Preserve layout imports for slots

This commit is contained in:
Fuma Nama
2026-03-19 23:08:33 +08:00
parent 42bf43e0ca
commit 0ddaa8ae34
9 changed files with 273 additions and 156 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@fumadocs/cli': minor
---
Preserve layout imports for slots
-2
View File
@@ -276,8 +276,6 @@ export class ComponentCompiler {
this.registry = compiler.raw;
}
// 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
private toImportPath(file: ComponentFile): string {
let filePath = file.target ?? file.path;
+4 -1
View File
@@ -11,11 +11,14 @@ import picocolors from 'picocolors';
import { ComponentInstaller } from '@/registry/installer';
import type { RegistryClient } from '@/registry/client';
import { UIRegistries } from '@/commands/shared';
import { pluginPreserveLayouts } from '@/registry/plugins/preserve';
export async function add(input: string[], client: RegistryClient) {
const config = client.config;
let target: string[];
const installer = new ComponentInstaller(client);
const installer = new ComponentInstaller(client, {
plugins: [pluginPreserveLayouts()],
});
const registry = UIRegistries[config.uiLibrary];
if (input.length === 0) {
+4 -1
View File
@@ -4,6 +4,7 @@ import { install } from '@/commands/add';
import type { RegistryClient } from '@/registry/client';
import { ComponentInstaller } from '@/registry/installer';
import { UIRegistries } from '@/commands/shared';
import { pluginPreserveLayouts } from '@/registry/plugins/preserve';
interface TargetInfo {
target: string[];
@@ -13,7 +14,9 @@ interface TargetInfo {
export async function customise(client: RegistryClient) {
intro(picocolors.bgBlack(picocolors.whiteBright('Customise Fumadocs UI')));
const config = client.config;
const installer = new ComponentInstaller(client);
const installer = new ComponentInstaller(client, {
plugins: [pluginPreserveLayouts()],
});
const registry = UIRegistries[config.uiLibrary];
const result = await group(
+193 -110
View File
@@ -10,27 +10,70 @@ import { createCache } from '@/utils/cache';
import { parse, type ParseResult } from 'oxc-parser';
import MagicString from 'magic-string';
interface DownloadedComponent extends Omit<Component, 'subComponents'> {
variables?: Record<string, unknown>;
interface PluginContext {
installer: ComponentInstaller;
}
interface TransformContext extends PluginContext, InstallContext {
s: MagicString;
parsed: ParseResult;
file: File;
component: DownloadedComponent;
}
interface InstallContext {
pathToFile: Map<string, File>;
io: IOInterface;
/** full variables of the current component. */
$variables: Record<string, unknown>;
/** the last item is always the current component. */
stack: DownloadedComponent[];
}
interface DownloadedComponent extends Component {
$subComponents: DownloadedComponent[];
$registry: RegistryClient;
}
type Awaitable<T> = T | Promise<T>;
export interface ComponentInstallerPlugin {
/**
* transform file downloaded from components
* transform file before writing (before default transformation)
*/
transformFile?: (context: {
s: MagicString;
parsed: ParseResult;
beforeTransform?: (context: TransformContext) => Awaitable<void>;
file: File;
component: DownloadedComponent;
}) => void | Promise<void>;
/**
* transform file before writing (after default transformation)
*/
afterTransform?: (context: TransformContext) => Awaitable<void>;
/**
* transform component before install
*/
beforeInstall?: (
comp: DownloadedComponent,
context: InstallContext & PluginContext,
) => Awaitable<DownloadedComponent | undefined>;
beforeDownload?: (
context: PluginContext & {
name: string;
},
) => void | Promise<void>;
afterDownload?: (
context: PluginContext & {
name: string;
result: DownloadedComponent;
},
) => void | Promise<void>;
}
export interface IOInterface {
onWarn: (message: string) => void;
confirmFileOverride: (options: { path: string }) => Promise<boolean>;
onFileDownloaded: (options: { path: string; file: File; component: DownloadedComponent }) => void;
onFileDownloaded: (options: { path: string; file: File; component: Component }) => void;
}
export interface ComponentInstallerOptions {
@@ -40,7 +83,7 @@ export interface ComponentInstallerOptions {
export class ComponentInstaller {
private readonly installedFiles = new Set<string>();
private readonly downloadCache = createCache<DownloadedComponent[]>();
private readonly downloadCache = createCache<DownloadedComponent>();
private readonly cwd: string;
private readonly plugins: ComponentInstallerPlugin[];
readonly dependencies: Record<string, string | null> = {};
@@ -54,58 +97,97 @@ export class ComponentInstaller {
this.plugins = options.plugins ?? [];
}
private async installComponent(comp: DownloadedComponent, ctx: InstallContext) {
// avoid circular refs
if (ctx.stack.indexOf(comp) !== ctx.stack.length - 1) return;
const pluginCtx = { installer: this, ...ctx };
for (const plugin of this.plugins) {
comp = (await plugin.beforeInstall?.(comp, pluginCtx)) ?? comp;
}
Object.assign(this.dependencies, comp.dependencies);
Object.assign(this.devDependencies, comp.devDependencies);
for (const file of comp.files) {
const outPath = this.resolveOutputPath(file);
if (this.installedFiles.has(outPath)) continue;
this.installedFiles.add(outPath);
const output = typescriptExtensions.includes(path.extname(outPath))
? await this.transform(file, comp, ctx)
: file.content;
const status = await fs
.readFile(outPath)
.then((res) => {
if (res.toString().trim() === output.trim()) return 'ignore';
return 'need-update';
})
.catch(() => 'write');
if (status === 'ignore') continue;
if (status === 'need-update') {
const override = await ctx.io.confirmFileOverride({ path: outPath });
if (!override) continue;
}
await fs.mkdir(path.dirname(outPath), { recursive: true });
await fs.writeFile(outPath, output);
ctx.io.onFileDownloaded({ path: outPath, file, component: comp });
}
for (const child of comp.$subComponents) {
const stack = [...ctx.stack, child];
const variables = { ...ctx.$variables };
if (child.$registry.registryId !== comp.$registry.registryId) {
const info = await child.$registry.fetchRegistryInfo();
Object.assign(variables, info.variables);
}
Object.assign(variables, child.variables);
await this.installComponent(child, { ...ctx, stack, $variables: variables });
}
}
async install(name: string, io: IOInterface) {
let downloaded: DownloadedComponent[];
let downloaded: DownloadedComponent;
// detect linked registry
const info = await this.rootClient.fetchRegistryInfo();
const rootInfo = await this.rootClient.fetchRegistryInfo();
const registry = rootInfo.registries?.find((registry) => name.startsWith(`${registry}/`));
for (const registry of info.registries ?? []) {
if (name.startsWith(`${registry}/`)) {
downloaded = await this.download(
name.slice(registry.length + 1),
this.rootClient.createLinkedRegistryClient(registry),
);
break;
}
if (registry) {
downloaded = await this.download(
name.slice(registry.length + 1),
this.rootClient.createLinkedRegistryClient(registry),
);
} else {
downloaded = await this.download(name, this.rootClient);
}
downloaded ??= await this.download(name, this.rootClient);
const allComponents = new Set<DownloadedComponent>();
function scan(comp: DownloadedComponent) {
if (allComponents.has(comp)) return;
for (const item of downloaded) {
Object.assign(this.dependencies, item.dependencies);
Object.assign(this.devDependencies, item.devDependencies);
allComponents.add(comp);
for (const child of comp.$subComponents) scan(child);
}
for (const comp of downloaded) {
for (const file of comp.files) {
const outPath = this.resolveOutputPath(file);
if (this.installedFiles.has(outPath)) continue;
this.installedFiles.add(outPath);
scan(downloaded);
const output = typescriptExtensions.includes(path.extname(outPath))
? await this.transform(io, name, file, comp, downloaded)
: file.content;
const status = await fs
.readFile(outPath)
.then((res) => {
if (res.toString().trim() === output.trim()) return 'ignore';
return 'need-update';
})
.catch(() => 'write');
if (status === 'ignore') continue;
if (status === 'need-update') {
const override = await io.confirmFileOverride({ path: outPath });
if (!override) continue;
}
await fs.mkdir(path.dirname(outPath), { recursive: true });
await fs.writeFile(outPath, output);
io.onFileDownloaded({ path: outPath, file, component: comp });
}
const pathToFile = new Map<string, File>();
for (const comp of allComponents) {
for (const file of comp.files) pathToFile.set(file.target ?? file.path, file);
}
const info = await downloaded.$registry.fetchRegistryInfo();
await this.installComponent(downloaded, {
pathToFile,
io,
$variables: { ...info.env, ...downloaded.variables },
stack: [downloaded],
});
}
async deps() {
@@ -122,84 +204,90 @@ export class ComponentInstaller {
}
/**
* return a list of components, merged with child components & variables.
* download component & its sub components
*/
private async download(
name: string,
client: RegistryClient,
contextVariables?: Record<string, unknown>,
): Promise<DownloadedComponent[]> {
const hash = `${client.registryId} ${name}`;
const info = await client.fetchRegistryInfo();
const variables = { ...contextVariables, ...info.env };
for (const [k, v] of Object.entries(info.variables ?? {})) {
variables[k] ??= v.default;
}
private async download(name: string, client: RegistryClient): Promise<DownloadedComponent> {
return this.downloadCache.cached(
JSON.stringify([client.registryId, name]),
async (presolve) => {
for (const plugin of this.plugins) {
await plugin.beforeDownload?.({
installer: this,
name,
});
}
const out = await this.downloadCache.cached(hash, async (presolve) => {
const comp = await client.fetchComponent(name);
const result: DownloadedComponent[] = [comp];
// place it before downloading child components to avoid recursive downloads
presolve(result);
const comp = await client.fetchComponent(name);
const result: DownloadedComponent = {
...comp,
$registry: client,
$subComponents: [],
};
// place it before downloading child components to avoid recursive downloads
presolve(result);
const child = await Promise.all(
comp.subComponents.map((sub) => {
if (typeof sub === 'string') return this.download(sub, client);
const baseUrl =
this.rootClient instanceof HttpRegistryClient
? new URL(sub.baseUrl, `${this.rootClient.baseUrl}/`).href
: sub.baseUrl;
result.$subComponents = await Promise.all(
comp.subComponents.map((sub) => {
if (typeof sub === 'string') return this.download(sub, client);
return this.download(
sub.component,
new HttpRegistryClient(baseUrl, client.config),
variables,
);
}),
);
for (const sub of child) result.push(...sub);
return result;
});
let subClient: RegistryClient;
if (this.rootClient instanceof HttpRegistryClient) {
const baseUrl = new URL(sub.baseUrl, `${this.rootClient.baseUrl}/`).href;
subClient =
client instanceof HttpRegistryClient && client.baseUrl === baseUrl
? client
: new HttpRegistryClient(baseUrl, client.config);
} else {
subClient = new HttpRegistryClient(sub.baseUrl, client.config);
}
return out.map((file) => ({ ...file, variables }));
return this.download(sub.component, subClient);
}),
);
for (const plugin of this.plugins) {
await plugin.afterDownload?.({
installer: this,
name,
result,
});
}
return result;
},
);
}
private readonly pathToFileCache = createCache<Map<string, File>>();
private async transform(
io: IOInterface,
taskId: string,
file: File,
component: DownloadedComponent,
allComponents: DownloadedComponent[],
ctx: InstallContext,
): Promise<string> {
const filePath = this.resolveOutputPath(file);
const parsed = await parse(filePath, file.content);
const s = new MagicString(file.content);
const transformCtx: TransformContext = { installer: this, s, file, component, parsed, ...ctx };
for (const plugin of this.plugins) {
await plugin.beforeTransform?.(transformCtx);
}
// transform alias
const prefix = '@/';
const variables = Object.entries(component.variables ?? {});
const pathToFile = await this.pathToFileCache.cached(taskId, () => {
const map = new Map<string, File>();
for (const comp of allComponents) {
for (const file of comp.files) map.set(file.target ?? file.path, file);
}
return map;
});
transformSpecifiers(parsed.program, s, (specifier) => {
for (const [k, v] of variables) {
specifier = specifier.replaceAll(`<${k}>`, v as string);
for (const [k, v] of Object.entries(ctx.$variables)) {
if (typeof v === 'string') specifier = specifier.replaceAll(`<${k}>`, v);
}
if (specifier.startsWith(prefix)) {
const lookup = specifier.substring(prefix.length);
const target = pathToFile.get(lookup);
const target = ctx.pathToFile.get(lookup);
if (target) {
specifier = toImportSpecifier(filePath, this.resolveOutputPath(target));
} else {
io.onWarn(`cannot find the referenced file of ${specifier}`);
ctx.io.onWarn(`cannot find the referenced file of ${specifier}`);
}
}
@@ -207,12 +295,7 @@ export class ComponentInstaller {
});
for (const plugin of this.plugins) {
await plugin.transformFile?.({
s,
parsed,
file,
component,
});
await plugin.afterTransform?.(transformCtx);
}
return s.toString();
@@ -0,0 +1,56 @@
import { transformSpecifiers } from '@/utils/ast';
import type { ComponentInstallerPlugin } from '../installer';
/**
* keep references to `fumadocs-ui/layouts/*` components as original, unless the user is installing them direclty.
*/
export function pluginPreserveLayouts(): ComponentInstallerPlugin {
const layoutNames = [
'layouts/home',
'layouts/flux',
'layouts/notebook',
'layouts/docs',
'layouts/shared',
];
// original specifier -> new specifier
const layoutComps: Record<string, string> = {
'@/<dir>/layout/home/index.tsx': 'layouts/home',
'@/<dir>/layout/shared/index.tsx': 'layouts/shared',
'@/<dir>/layout/shared/client.tsx': 'layouts/shared',
'@/<dir>/layout/notebook/index.tsx': 'layouts/notebook',
'@/<dir>/layout/notebook/client.tsx': 'layouts/notebook',
'@/<dir>/layout/notebook/page/index.tsx': 'layouts/notebook/page',
'@/<dir>/layout/notebook/page/client.tsx': 'layouts/notebook/page',
'@/<dir>/layout/docs/index.tsx': 'layouts/docs',
'@/<dir>/layout/docs/client.tsx': 'layouts/docs',
'@/<dir>/layout/docs/page/index.tsx': 'layouts/docs/page',
'@/<dir>/layout/docs/page/client.tsx': 'layouts/docs/page',
'@/<dir>/layout/flux/index.tsx': 'layouts/flux',
'@/<dir>/layout/flux/page/index.tsx': 'layouts/flux/page',
'@/<dir>/layout/flux/page/client.tsx': 'layouts/flux/page',
};
const layoutNameSet = new Set(layoutNames);
return {
beforeInstall(comp, { stack }) {
const isDirectInstall = layoutNameSet.has(stack[0].name);
if (isDirectInstall) return;
return {
...comp,
$subComponents: comp.$subComponents.filter((child) => !layoutNameSet.has(child.name)),
};
},
beforeTransform({ parsed, s, stack }) {
const isDirectInstall = layoutNameSet.has(stack[0].name);
if (isDirectInstall) return;
transformSpecifiers(parsed.program, s, (specifier) => {
// skip if unrelated to layout component
if (!(specifier in layoutComps)) return specifier;
return `fumadocs-ui/${layoutComps[specifier]}`;
});
},
};
}
+9 -3
View File
@@ -40,23 +40,29 @@ export const componentSchema = z.object({
* list of sub components, either local (component name) or remote (registry info & component name)
*/
subComponents: z.array(z.string().or(httpSubComponent)).default([]),
/**
* override variables for the current component & its sub components.
*
* this is powerful to customise how sub components are installed vs. installing them directly.
*/
variables: z.record(z.string(), z.unknown()).optional(),
});
export const registryInfoSchema = z.object({
/**
* define used variables, variables can be referenced in the import specifiers of component files.
* define metadata for variables, variables can be referenced in the target path of component files, or in plugins.
*/
variables: z
.record(
z.string(),
z.object({
description: z.string().optional(),
default: z.unknown().optional(),
}),
)
.optional(),
/**
* provide variables to sub components
* override variables for all components.
*/
env: z.record(z.string(), z.unknown()).optional(),
indexes: z.array(indexSchema).default([]),
+1 -2
View File
@@ -1,5 +1,5 @@
import type { StructuredData } from '@/mdx-plugins';
import { type AnyObject, type OramaCloud } from '@orama/core';
import type { AnyObject, OramaCloud } from '@orama/core';
export interface SyncOptions {
/**
@@ -129,7 +129,6 @@ function toIndex(page: OramaDocument): OramaIndex[] {
id: `${page.id}-${(id++).toString()}`,
title: page.title,
url: page.url,
// TODO: explicit declare enums
page_id: page.id,
tag: page.tag,
section,
+1 -37
View File
@@ -10,42 +10,6 @@ export function resolveExternal(
toPackageName: string,
toRegistryDir: string,
): Reference | undefined {
// TODO: this will cause the installed slots to reference `fumadocs-ui` even if the layout & context itself are already installed locally.
// need change from CLI side.
if (ref.type === 'sub-component' && ref.resolved.type === 'local') {
let specifier: string | undefined;
switch (ref.resolved.component.name) {
case 'layouts/home':
specifier = '/layouts/home';
break;
case 'layouts/docs':
specifier = ref.resolved.file.path.includes('page')
? '/layouts/docs/page'
: '/layouts/docs';
break;
case 'layouts/notebook':
specifier = ref.resolved.file.path.includes('page')
? '/layouts/notebook/page'
: '/layouts/notebook';
break;
case 'layouts/flux':
specifier = ref.resolved.file.path.includes('page')
? '/layouts/flux/page'
: '/layouts/flux';
break;
case 'layouts/shared':
specifier = '/layouts/shared';
break;
}
if (specifier)
return {
type: 'dependency',
dep: toPackageName,
specifier: toPackageName + specifier,
};
}
if (ref.type === 'file') {
const file = path.relative(toRegistryDir, ref.file).replaceAll(path.sep, '/');
@@ -72,7 +36,7 @@ export async function findSlotComponents(dir: string): Promise<Component[]> {
{
path: file,
type: 'components',
target: path.join('<dir>/layout', file),
target: path.join('<dir>/layout', name),
},
],
});