mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
CLI: Support dynamic import transformation
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@fumadocs/cli': minor
|
||||
---
|
||||
|
||||
Support dynamic import transformation
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@fumadocs/cli': patch
|
||||
---
|
||||
|
||||
Improve dep install UI
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as path from 'node:path';
|
||||
import type { SourceFile, StringLiteral } from 'ts-morph';
|
||||
import { SourceFile, StringLiteral, ts } from 'ts-morph';
|
||||
import type { ComponentBuilder } from '@/build/component-builder';
|
||||
import type {
|
||||
Component,
|
||||
@@ -55,10 +55,10 @@ export async function buildFile(
|
||||
/**
|
||||
* Process import paths
|
||||
*/
|
||||
async function process(
|
||||
function process(
|
||||
specifier: StringLiteral,
|
||||
getSpecifiedFile: () => SourceFile | undefined,
|
||||
): Promise<void> {
|
||||
) {
|
||||
let specifiedFile = getSpecifiedFile();
|
||||
if (!specifiedFile) return;
|
||||
|
||||
@@ -74,6 +74,17 @@ export async function buildFile(
|
||||
specifiedFile = getSpecifiedFile();
|
||||
|
||||
if (!specifiedFile) return;
|
||||
} else if (resolver.type === 'dependency') {
|
||||
const info = builder.resolveDep(resolver.name);
|
||||
|
||||
const value = onReference({
|
||||
type: 'dependency',
|
||||
name: info.name,
|
||||
version: info.version ?? '',
|
||||
isDev: info.type === 'dev',
|
||||
});
|
||||
if (value) out.imports[specifier.getLiteralValue()] = value;
|
||||
return;
|
||||
} else {
|
||||
const sub = builder.getComponentByName(
|
||||
resolver.name,
|
||||
@@ -133,7 +144,7 @@ export async function buildFile(
|
||||
const sourceFile = await builder.createSourceFile(inputPath);
|
||||
|
||||
for (const item of sourceFile.getImportDeclarations()) {
|
||||
await process(item.getModuleSpecifier(), () =>
|
||||
process(item.getModuleSpecifier(), () =>
|
||||
item.getModuleSpecifierSourceFile(),
|
||||
);
|
||||
}
|
||||
@@ -142,7 +153,25 @@ export async function buildFile(
|
||||
const specifier = item.getModuleSpecifier();
|
||||
if (!specifier) continue;
|
||||
|
||||
await process(specifier, () => item.getModuleSpecifierSourceFile());
|
||||
process(specifier, () => item.getModuleSpecifierSourceFile());
|
||||
}
|
||||
|
||||
// transform async imports
|
||||
const calls = sourceFile.getDescendantsOfKind(ts.SyntaxKind.CallExpression);
|
||||
|
||||
for (const expression of calls) {
|
||||
if (
|
||||
expression.getExpression().isKind(ts.SyntaxKind.ImportKeyword) &&
|
||||
expression.getArguments().length === 1
|
||||
) {
|
||||
const argument = expression.getArguments()[0];
|
||||
|
||||
if (!argument.isKind(ts.SyntaxKind.StringLiteral)) continue;
|
||||
|
||||
process(argument, () =>
|
||||
argument.getSymbol()?.getDeclarations()[0].getSourceFile(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
out.content = sourceFile.getFullText();
|
||||
|
||||
@@ -6,6 +6,25 @@ import {
|
||||
createComponentBuilder,
|
||||
} from './component-builder';
|
||||
|
||||
export type ImportPathMap = Record<
|
||||
string,
|
||||
| string
|
||||
| {
|
||||
type: 'component';
|
||||
name: string;
|
||||
file: string;
|
||||
|
||||
/**
|
||||
* Registry of the component, refer to the current registry if not specified
|
||||
*/
|
||||
registry?: string;
|
||||
}
|
||||
| {
|
||||
type: 'dependency';
|
||||
name: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface Component {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -25,20 +44,7 @@ export interface Component {
|
||||
/**
|
||||
* Map imported file paths
|
||||
*/
|
||||
mapImportPath?: Record<
|
||||
string,
|
||||
| string
|
||||
| {
|
||||
type: 'component';
|
||||
name: string;
|
||||
file: string;
|
||||
|
||||
/**
|
||||
* Registry of the component, refer to the current registry if not specified
|
||||
*/
|
||||
registry?: string;
|
||||
}
|
||||
>;
|
||||
mapImportPath?: ImportPathMap;
|
||||
}
|
||||
|
||||
export type NamespaceType = 'components' | 'hooks' | 'lib';
|
||||
|
||||
@@ -1,223 +1,80 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { log, confirm, isCancel, outro, spinner, intro } from '@clack/prompts';
|
||||
import { type Project } from 'ts-morph';
|
||||
import picocolors from 'picocolors';
|
||||
import { x } from 'tinyexec';
|
||||
import { createEmptyProject } from '@/utils/typescript';
|
||||
import { getPackageManager } from '@/utils/get-package-manager';
|
||||
import { type Config, defaultConfig } from '@/config';
|
||||
import { typescriptExtensions } from '@/constants';
|
||||
import { getDependencies } from '@/utils/add/get-dependencies';
|
||||
import {
|
||||
type NamespaceType,
|
||||
type OutputComponent,
|
||||
type OutputFile,
|
||||
} from '@/build';
|
||||
import { type Awaitable } from '@/commands/init';
|
||||
import { toReferencePath } from '@/utils/transform-references';
|
||||
intro,
|
||||
isCancel,
|
||||
log,
|
||||
multiselect,
|
||||
outro,
|
||||
spinner,
|
||||
} from '@clack/prompts';
|
||||
import type { OutputComponent, OutputIndex } from '@/build';
|
||||
import picocolors from 'picocolors';
|
||||
import type { Config } from '@/config';
|
||||
import { installComponent, type Resolver } from '@/utils/add/install-component';
|
||||
import { installDeps } from '@/utils/add/install-deps';
|
||||
|
||||
interface Context {
|
||||
config: Config;
|
||||
project: Project;
|
||||
export async function add(input: string[], resolver: Resolver, config: Config) {
|
||||
let target = input;
|
||||
|
||||
resolver: Resolver;
|
||||
}
|
||||
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')));
|
||||
|
||||
export type Resolver = (file: string) => Awaitable<object | undefined>;
|
||||
if (!registry) {
|
||||
log.error(`Failed to fetch '_registry.json' file from registry`);
|
||||
throw new Error(`Failed to fetch registry`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A set of downloaded files
|
||||
*/
|
||||
const downloadedFiles = new Set<string>();
|
||||
|
||||
export async function add(
|
||||
name: string,
|
||||
resolver: Resolver,
|
||||
config: Config = {},
|
||||
): Promise<void> {
|
||||
intro(
|
||||
picocolors.bold(
|
||||
picocolors.inverse(picocolors.cyanBright(`Add Component: ${name}`)),
|
||||
),
|
||||
);
|
||||
const project = createEmptyProject();
|
||||
|
||||
const result = await downloadComponent(name, {
|
||||
project,
|
||||
config,
|
||||
resolver,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
log.error(`Component: ${name} not found`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const installed = await getDependencies();
|
||||
const deps = Object.entries(result.dependencies)
|
||||
.filter(([k]) => !installed.has(k))
|
||||
.map(([k, v]) => (v.length === 0 ? k : `${k}@${v}`));
|
||||
|
||||
const devDeps = Object.entries(result.devDependencies)
|
||||
.filter(([k]) => !installed.has(k))
|
||||
.map(([k, v]) => (v.length === 0 ? k : `${k}@${v}`));
|
||||
|
||||
if (deps.length > 0 || devDeps.length > 0) {
|
||||
const manager = await getPackageManager();
|
||||
const value = await confirm({
|
||||
message: `This component requires dependencies (${[...deps, ...devDeps].join(' ')}), install them with ${manager}?`,
|
||||
const value = await multiselect({
|
||||
message: 'Select components to install',
|
||||
options: registry.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
hint: item.description,
|
||||
})),
|
||||
});
|
||||
|
||||
if (isCancel(value)) {
|
||||
outro(picocolors.bold(picocolors.greenBright('Component downloaded')));
|
||||
process.exit(0);
|
||||
outro('Ended');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
const spin = spinner();
|
||||
spin.start('Installing dependencies...');
|
||||
if (deps.length > 0) await x(manager, ['install', ...deps]);
|
||||
if (devDeps.length > 0) await x(manager, ['install', ...devDeps, '-D']);
|
||||
|
||||
spin.stop('Dependencies installed.');
|
||||
}
|
||||
target = value;
|
||||
}
|
||||
|
||||
outro(picocolors.bold(picocolors.greenBright('Component installed')));
|
||||
await install(target, resolver, config);
|
||||
}
|
||||
|
||||
const downloadedComps = new Map<string, OutputComponent>();
|
||||
async function downloadComponent(
|
||||
name: string,
|
||||
ctx: Context,
|
||||
): Promise<OutputComponent | undefined> {
|
||||
const cached = downloadedComps.get(name);
|
||||
if (cached) return cached;
|
||||
export async function install(
|
||||
target: string[],
|
||||
resolver: Resolver,
|
||||
config: Config,
|
||||
) {
|
||||
const outputs: OutputComponent[] = [];
|
||||
|
||||
const comp = (await ctx.resolver(`${name}.json`)) as
|
||||
| OutputComponent
|
||||
| undefined;
|
||||
if (!comp) return;
|
||||
|
||||
downloadedComps.set(name, comp);
|
||||
|
||||
for (const file of comp.files) {
|
||||
if (downloadedFiles.has(file.path)) continue;
|
||||
|
||||
const outPath = resolveOutputPath(file.path, ctx.config);
|
||||
const output = typescriptExtensions.includes(path.extname(file.path))
|
||||
? transformTypeScript(outPath, file, ctx)
|
||||
: file.content;
|
||||
|
||||
let canWrite = true;
|
||||
const requireOverride = await fs
|
||||
.readFile(outPath)
|
||||
.then((res) => res.toString() !== output)
|
||||
.catch(() => false);
|
||||
|
||||
if (requireOverride) {
|
||||
const value = await confirm({
|
||||
message: `Do you want to override ${outPath}?`,
|
||||
});
|
||||
|
||||
if (isCancel(value)) {
|
||||
outro('Ended');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
canWrite = value;
|
||||
}
|
||||
|
||||
if (canWrite) {
|
||||
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
||||
await fs.writeFile(outPath, output);
|
||||
log.step(`downloaded ${outPath}`);
|
||||
}
|
||||
|
||||
downloadedFiles.add(file.path);
|
||||
}
|
||||
|
||||
for (const sub of comp.subComponents) {
|
||||
const downloaded = await downloadComponent(sub, ctx);
|
||||
if (!downloaded) continue;
|
||||
|
||||
Object.assign(comp.dependencies, downloaded.dependencies);
|
||||
Object.assign(comp.devDependencies, downloaded.devDependencies);
|
||||
}
|
||||
|
||||
return comp;
|
||||
}
|
||||
|
||||
function resolveOutputPath(ref: string, config: Config): string {
|
||||
const sep = ref.indexOf(':');
|
||||
if (sep === -1) return ref;
|
||||
|
||||
const namespace = ref.slice(0, sep) as NamespaceType,
|
||||
file = ref.slice(sep + 1);
|
||||
|
||||
if (namespace === 'components') {
|
||||
return path.join(
|
||||
config.aliases?.componentsDir ?? defaultConfig.aliases.componentsDir,
|
||||
file,
|
||||
for (const name of target) {
|
||||
intro(
|
||||
picocolors.bold(
|
||||
picocolors.inverse(picocolors.cyanBright(`Add Component: ${name}`)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return path.join(
|
||||
config.aliases?.libDir ?? defaultConfig.aliases.libDir,
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
function transformTypeScript(
|
||||
filePath: string,
|
||||
file: OutputFile,
|
||||
ctx: Context,
|
||||
): string {
|
||||
const sourceFile = ctx.project.createSourceFile(filePath, file.content, {
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
for (const item of sourceFile.getImportDeclarations()) {
|
||||
const ref = item.getModuleSpecifier().getLiteralValue();
|
||||
|
||||
if (ref in file.imports) {
|
||||
const outputPath = resolveOutputPath(file.imports[ref], ctx.config);
|
||||
item
|
||||
.getModuleSpecifier()
|
||||
.setLiteralValue(toReferencePath(filePath, outputPath));
|
||||
const output = await installComponent(name, resolver, config);
|
||||
if (!output) {
|
||||
log.error(`Failed to install ${name}: not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
outro(picocolors.bold(picocolors.greenBright(`${name} installed`)));
|
||||
outputs.push(output);
|
||||
}
|
||||
|
||||
for (const item of sourceFile.getExportDeclarations()) {
|
||||
const specifier = item.getModuleSpecifier();
|
||||
if (!specifier) continue;
|
||||
const ref = specifier.getLiteralValue();
|
||||
intro(picocolors.bold('New Dependencies'));
|
||||
|
||||
if (ref in file.imports) {
|
||||
const outputPath = resolveOutputPath(file.imports[ref], ctx.config);
|
||||
await installDeps(outputs);
|
||||
|
||||
specifier.setLiteralValue(toReferencePath(filePath, outputPath));
|
||||
}
|
||||
}
|
||||
|
||||
return sourceFile.getFullText();
|
||||
}
|
||||
|
||||
export function remoteResolver(url: string): Resolver {
|
||||
return async (file) => {
|
||||
const res = await fetch(`${url}/${file}`);
|
||||
if (!res.ok) return;
|
||||
|
||||
return res.json();
|
||||
};
|
||||
}
|
||||
|
||||
export function localResolver(dir: string): Resolver {
|
||||
return async (file) => {
|
||||
return await fs
|
||||
.readFile(path.join(dir, file))
|
||||
.then((res) => JSON.parse(res.toString()) as OutputComponent)
|
||||
.catch(() => undefined);
|
||||
};
|
||||
outro(picocolors.bold(picocolors.greenBright('Successful')));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { cancel, confirm, group, intro, log, select } from '@clack/prompts';
|
||||
import {
|
||||
cancel,
|
||||
confirm,
|
||||
group,
|
||||
intro,
|
||||
log,
|
||||
outro,
|
||||
select,
|
||||
} from '@clack/prompts';
|
||||
import picocolors from 'picocolors';
|
||||
import { add, type Resolver } from '@/commands/add';
|
||||
import { type Resolver } from '@/utils/add/install-component';
|
||||
import type { Config } from '@/config';
|
||||
import { install } from '@/commands/add';
|
||||
|
||||
export async function customise(resolver: Resolver, config: Config) {
|
||||
intro(picocolors.bgBlack(picocolors.whiteBright('Customise Fumadocs UI')));
|
||||
@@ -66,26 +75,27 @@ export async function customise(resolver: Resolver, config: Config) {
|
||||
);
|
||||
|
||||
if (result.target === 'docs') {
|
||||
const targets = [];
|
||||
let pageAdded = false;
|
||||
if (result.mode === 'minimal') {
|
||||
await add('layouts/docs-min', resolver, config);
|
||||
targets.push('layouts/docs-min');
|
||||
pageAdded = true;
|
||||
} else {
|
||||
if (result.page) {
|
||||
await add('layouts/page', resolver, config);
|
||||
targets.push('layouts/page');
|
||||
pageAdded = true;
|
||||
}
|
||||
|
||||
await add(
|
||||
targets.push(
|
||||
result.mode === 'full-default' ? 'layouts/docs' : 'layouts/notebook',
|
||||
resolver,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
await install(targets, resolver, config);
|
||||
|
||||
intro(picocolors.bold('What is Next?'));
|
||||
log.info(
|
||||
[
|
||||
picocolors.bold('What is Next?'),
|
||||
'You can check the installed components in `components/layouts`.',
|
||||
picocolors.dim('---'),
|
||||
'Open your `layout.tsx` files, replace the imports of components:',
|
||||
@@ -102,11 +112,11 @@ export async function customise(resolver: Resolver, config: Config) {
|
||||
}
|
||||
|
||||
if (result.target === 'home') {
|
||||
await add('layouts/home', resolver, config);
|
||||
await install(['layouts/home'], resolver, config);
|
||||
intro(picocolors.bold('What is Next?'));
|
||||
|
||||
log.info(
|
||||
[
|
||||
picocolors.bold('What is Next?'),
|
||||
'You can check the installed components in `components/layouts`.',
|
||||
picocolors.dim('---'),
|
||||
'Open your `layout.tsx` files, replace the imports of components:',
|
||||
@@ -116,4 +126,6 @@ export async function customise(resolver: Resolver, config: Config) {
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
outro(picocolors.bold('Have fun!'));
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import * as process from 'node:process';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
intro,
|
||||
confirm,
|
||||
isCancel,
|
||||
cancel,
|
||||
spinner,
|
||||
confirm,
|
||||
intro,
|
||||
isCancel,
|
||||
log,
|
||||
note,
|
||||
spinner,
|
||||
} from '@clack/prompts';
|
||||
import picocolors from 'picocolors';
|
||||
import { x } from 'tinyexec';
|
||||
import { getPackageManager } from '@/utils/get-package-manager';
|
||||
import { exists } from '@/utils/fs';
|
||||
import { isSrc } from '@/utils/is-src';
|
||||
import { type Config } from '@/config';
|
||||
import { type Config, defaultConfig } from '@/config';
|
||||
import {
|
||||
getOutputPath,
|
||||
resolveReference,
|
||||
toReferencePath,
|
||||
transformReferences,
|
||||
} from '@/utils/transform-references';
|
||||
@@ -96,21 +96,18 @@ export async function init(plugin: Plugin, config: Config = {}): Promise<void> {
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
await transformReferences(
|
||||
sourceFile,
|
||||
{
|
||||
transformReferences(sourceFile, (specifier) => {
|
||||
const resolved = resolveReference(specifier, {
|
||||
alias: {
|
||||
type: 'append',
|
||||
dir: ctx.src ? 'src' : '',
|
||||
},
|
||||
relativeTo: path.dirname(file),
|
||||
},
|
||||
(resolved) => {
|
||||
if (resolved.type !== 'file') return;
|
||||
});
|
||||
|
||||
return toReferencePath(file, getOutputPath(resolved.path, ctx));
|
||||
},
|
||||
);
|
||||
if (resolved.type !== 'file') return;
|
||||
return toReferencePath(file, getOutputPath(resolved.path, ctx));
|
||||
});
|
||||
|
||||
await sourceFile.save();
|
||||
}
|
||||
@@ -176,3 +173,32 @@ prettier . --write`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the transformed output path of input ref.
|
||||
*
|
||||
* extension name of `ref` is optional.
|
||||
*/
|
||||
function getOutputPath(ref: string, config: Config): string {
|
||||
if (path.isAbsolute(ref)) throw new Error(`path cannot be absolute: ${ref}`);
|
||||
|
||||
if (ref === 'utils/cn' || ref === 'utils/cn.ts') {
|
||||
return config.aliases?.cn ?? defaultConfig.aliases.cn;
|
||||
}
|
||||
|
||||
if (ref.startsWith('components')) {
|
||||
return path.join(
|
||||
config.aliases?.componentsDir ?? defaultConfig.aliases.componentsDir,
|
||||
path.relative('components', ref),
|
||||
);
|
||||
}
|
||||
|
||||
if (ref.startsWith('lib') || ref.startsWith('utils')) {
|
||||
return path.join(
|
||||
config.aliases?.libDir ?? defaultConfig.aliases.libDir,
|
||||
path.relative('lib', ref),
|
||||
);
|
||||
}
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,21 @@ export async function loadConfig(file = './cli.json'): Promise<Config> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function initConfig(file = './cli.json'): Promise<void> {
|
||||
/**
|
||||
* Write new config, skip if a config already exists
|
||||
*
|
||||
* @returns true if the config is created, otherwise false
|
||||
*/
|
||||
export async function initConfig(file = './cli.json'): Promise<boolean> {
|
||||
if (
|
||||
await fs
|
||||
.stat(file)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fs.writeFile(file, JSON.stringify(defaultConfig, null, 2));
|
||||
return true;
|
||||
}
|
||||
|
||||
+12
-53
@@ -3,21 +3,13 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import picocolors from 'picocolors';
|
||||
import {
|
||||
isCancel,
|
||||
log,
|
||||
multiselect,
|
||||
outro,
|
||||
select,
|
||||
spinner,
|
||||
} from '@clack/prompts';
|
||||
import { isCancel, outro, select } from '@clack/prompts';
|
||||
import { init } from '@/commands/init';
|
||||
import {
|
||||
add,
|
||||
localResolver,
|
||||
remoteResolver,
|
||||
type Resolver,
|
||||
} from '@/commands/add';
|
||||
} from '@/utils/add/install-component';
|
||||
import { initConfig, loadConfig } from '@/config';
|
||||
import { plugins } from '@/plugins';
|
||||
import {
|
||||
@@ -26,9 +18,9 @@ import {
|
||||
treeToMdx,
|
||||
} from '@/commands/file-tree';
|
||||
import { runTree } from '@/utils/file-tree/run-tree';
|
||||
import { type OutputIndex } from '@/build';
|
||||
import packageJson from '../package.json';
|
||||
import { customise } from '@/commands/customise';
|
||||
import { add } from '@/commands/add';
|
||||
|
||||
const program = new Command().option('--config <string>');
|
||||
|
||||
@@ -37,8 +29,11 @@ program
|
||||
.description('CLI to setup Fumadocs, init a config ')
|
||||
.version(packageJson.version)
|
||||
.action(async () => {
|
||||
await initConfig();
|
||||
console.log(picocolors.green('Initialized a `./cli.json` config file.'));
|
||||
if (await initConfig()) {
|
||||
console.log(picocolors.green('Initialized a `./cli.json` config file.'));
|
||||
} else {
|
||||
console.log(picocolors.redBright('A config file already exists.'));
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
@@ -83,7 +78,8 @@ program
|
||||
});
|
||||
|
||||
const dirShortcuts: Record<string, string> = {
|
||||
':dev': 'https://fumadocs-dev.vercel.app/registry',
|
||||
':dev': 'https://preview.fumadocs.dev/registry',
|
||||
':localhost': 'http://localhost:3000/registry',
|
||||
};
|
||||
|
||||
program
|
||||
@@ -94,44 +90,7 @@ program
|
||||
.action(
|
||||
async (input: string[], options: { config?: string; dir?: string }) => {
|
||||
const resolver = getResolverFromDir(options.dir);
|
||||
let target = input;
|
||||
|
||||
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')));
|
||||
|
||||
if (!registry) {
|
||||
log.error(
|
||||
`Failed to fetch '_registry.json' file from ${options.dir ?? 'registry'}`,
|
||||
);
|
||||
throw new Error(`Failed to fetch registry`);
|
||||
}
|
||||
|
||||
const value = await multiselect({
|
||||
message: 'Select components to install',
|
||||
options: registry.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
hint: item.description,
|
||||
})),
|
||||
});
|
||||
|
||||
if (isCancel(value)) {
|
||||
outro('Ended');
|
||||
return;
|
||||
}
|
||||
|
||||
target = value as string[];
|
||||
}
|
||||
|
||||
const loadedConfig = await loadConfig(options.config);
|
||||
for (const name of target) {
|
||||
await add(name, resolver, loadedConfig);
|
||||
}
|
||||
await add(input, resolver, await loadConfig(options.config));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -180,7 +139,7 @@ program
|
||||
);
|
||||
|
||||
function getResolverFromDir(
|
||||
dir: string = 'https://fumadocs.vercel.app/registry',
|
||||
dir: string = 'https://fumadocs.dev/registry',
|
||||
): Resolver {
|
||||
if (dir in dirShortcuts) dir = dirShortcuts[dir];
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { exists } from '@/utils/fs';
|
||||
/**
|
||||
* Get dependencies from `package.json`
|
||||
*/
|
||||
export async function getDependencies(): Promise<Map<string, string>> {
|
||||
export async function getDeps(): Promise<Map<string, string>> {
|
||||
const dependencies = new Map<string, string>();
|
||||
|
||||
if (!(await exists('package.json'))) return dependencies;
|
||||
@@ -0,0 +1,164 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { confirm, isCancel, log, outro } from '@clack/prompts';
|
||||
import { type Project } from 'ts-morph';
|
||||
import { createEmptyProject } from '@/utils/typescript';
|
||||
import { type Config, defaultConfig } from '@/config';
|
||||
import { typescriptExtensions } from '@/constants';
|
||||
import {
|
||||
type NamespaceType,
|
||||
type OutputComponent,
|
||||
type OutputFile,
|
||||
} from '@/build';
|
||||
import { type Awaitable } from '@/commands/init';
|
||||
import {
|
||||
toReferencePath,
|
||||
transformReferences,
|
||||
} from '@/utils/transform-references';
|
||||
|
||||
interface Context {
|
||||
config: Config;
|
||||
project: Project;
|
||||
|
||||
resolver: Resolver;
|
||||
}
|
||||
|
||||
export type Resolver = (file: string) => Awaitable<object | undefined>;
|
||||
|
||||
/**
|
||||
* A set of downloaded files
|
||||
*/
|
||||
const downloadedFiles = new Set<string>();
|
||||
|
||||
export async function installComponent(
|
||||
name: string,
|
||||
resolver: Resolver,
|
||||
config: Config = {},
|
||||
): Promise<OutputComponent | undefined> {
|
||||
const project = createEmptyProject();
|
||||
|
||||
return downloadComponent(name, {
|
||||
project,
|
||||
config,
|
||||
resolver,
|
||||
});
|
||||
}
|
||||
|
||||
const downloadedComps = new Map<string, OutputComponent>();
|
||||
async function downloadComponent(
|
||||
name: string,
|
||||
ctx: Context,
|
||||
): Promise<OutputComponent | undefined> {
|
||||
const cached = downloadedComps.get(name);
|
||||
if (cached) return cached;
|
||||
|
||||
const comp = (await ctx.resolver(`${name}.json`)) as
|
||||
| OutputComponent
|
||||
| undefined;
|
||||
if (!comp) return;
|
||||
|
||||
downloadedComps.set(name, comp);
|
||||
|
||||
for (const file of comp.files) {
|
||||
if (downloadedFiles.has(file.path)) continue;
|
||||
|
||||
const outPath = resolveOutputPath(file.path, ctx.config);
|
||||
const output = typescriptExtensions.includes(path.extname(file.path))
|
||||
? transformTypeScript(outPath, file, ctx)
|
||||
: file.content;
|
||||
|
||||
let canWrite = true;
|
||||
const requireOverride = await fs
|
||||
.readFile(outPath)
|
||||
.then((res) => res.toString() !== output)
|
||||
.catch(() => false);
|
||||
|
||||
if (requireOverride) {
|
||||
const value = await confirm({
|
||||
message: `Do you want to override ${outPath}?`,
|
||||
});
|
||||
|
||||
if (isCancel(value)) {
|
||||
outro('Ended');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
canWrite = value;
|
||||
}
|
||||
|
||||
if (canWrite) {
|
||||
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
||||
await fs.writeFile(outPath, output);
|
||||
log.step(`downloaded ${outPath}`);
|
||||
}
|
||||
|
||||
downloadedFiles.add(file.path);
|
||||
}
|
||||
|
||||
for (const sub of comp.subComponents) {
|
||||
const downloaded = await downloadComponent(sub, ctx);
|
||||
if (!downloaded) continue;
|
||||
|
||||
Object.assign(comp.dependencies, downloaded.dependencies);
|
||||
Object.assign(comp.devDependencies, downloaded.devDependencies);
|
||||
}
|
||||
|
||||
return comp;
|
||||
}
|
||||
|
||||
function resolveOutputPath(ref: string, config: Config): string {
|
||||
const sep = ref.indexOf(':');
|
||||
if (sep === -1) return ref;
|
||||
|
||||
const namespace = ref.slice(0, sep) as NamespaceType,
|
||||
file = ref.slice(sep + 1);
|
||||
|
||||
if (namespace === 'components') {
|
||||
return path.join(
|
||||
config.aliases?.componentsDir ?? defaultConfig.aliases.componentsDir,
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
return path.join(
|
||||
config.aliases?.libDir ?? defaultConfig.aliases.libDir,
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
function transformTypeScript(
|
||||
filePath: string,
|
||||
file: OutputFile,
|
||||
ctx: Context,
|
||||
): string {
|
||||
const sourceFile = ctx.project.createSourceFile(filePath, file.content, {
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
transformReferences(sourceFile, (specifier) => {
|
||||
if (specifier in file.imports) {
|
||||
const outputPath = resolveOutputPath(file.imports[specifier], ctx.config);
|
||||
return toReferencePath(filePath, outputPath);
|
||||
}
|
||||
});
|
||||
|
||||
return sourceFile.getFullText();
|
||||
}
|
||||
|
||||
export function remoteResolver(url: string): Resolver {
|
||||
return async (file) => {
|
||||
const res = await fetch(`${url}/${file}`);
|
||||
if (!res.ok) return;
|
||||
|
||||
return res.json();
|
||||
};
|
||||
}
|
||||
|
||||
export function localResolver(dir: string): Resolver {
|
||||
return async (file) => {
|
||||
return await fs
|
||||
.readFile(path.join(dir, file))
|
||||
.then((res) => JSON.parse(res.toString()) as OutputComponent)
|
||||
.catch(() => undefined);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getPackageManager } from '@/utils/get-package-manager';
|
||||
import { confirm, isCancel, spinner } from '@clack/prompts';
|
||||
import { x } from 'tinyexec';
|
||||
import type { OutputComponent } from '@/build';
|
||||
import { getDeps } from '@/utils/add/get-deps';
|
||||
|
||||
export async function installDeps(results: OutputComponent[]) {
|
||||
const installed = await getDeps();
|
||||
const deps: Record<string, string> = {};
|
||||
const devDeps: Record<string, string> = {};
|
||||
|
||||
for (const result of results) {
|
||||
Object.assign(deps, result.dependencies);
|
||||
Object.assign(devDeps, result.devDependencies);
|
||||
}
|
||||
|
||||
const items = Object.entries(deps)
|
||||
.filter(([k]) => !installed.has(k))
|
||||
.map(([k, v]) => (v.length === 0 ? k : `${k}@${v}`));
|
||||
|
||||
const devItems = Object.entries(devDeps)
|
||||
.filter(([k]) => !installed.has(k))
|
||||
.map(([k, v]) => (v.length === 0 ? k : `${k}@${v}`));
|
||||
|
||||
if (items.length > 0 || devItems.length > 0) {
|
||||
const manager = await getPackageManager();
|
||||
const value = await confirm({
|
||||
message: `Do you want to install with ${manager}?
|
||||
${[...items, ...devItems].map((v) => `- ${v}`).join('\n')}`,
|
||||
});
|
||||
|
||||
if (isCancel(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
const spin = spinner();
|
||||
spin.start('Installing dependencies...');
|
||||
if (items.length > 0) await x(manager, ['install', ...items]);
|
||||
if (devItems.length > 0) await x(manager, ['install', ...devItems, '-D']);
|
||||
|
||||
spin.stop('Dependencies installed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import path from 'node:path';
|
||||
import { type Project } from 'ts-morph';
|
||||
import { isRelative } from '@/utils/fs';
|
||||
import {
|
||||
resolveReference,
|
||||
toReferencePath,
|
||||
transformReferences,
|
||||
} from '@/utils/transform-references';
|
||||
@@ -17,14 +18,14 @@ const transformExtensions = ['.js', '.ts', '.tsx', '.jsx'];
|
||||
export async function moveFiles(
|
||||
from: string,
|
||||
to: string,
|
||||
filter: (file: string) => boolean | Promise<boolean>,
|
||||
filter: (file: string) => boolean,
|
||||
project: Project,
|
||||
src: boolean,
|
||||
/**
|
||||
* the original directory to move files from
|
||||
*/
|
||||
originalDir = from,
|
||||
): Promise<void> {
|
||||
) {
|
||||
function isIncluded(file: string) {
|
||||
// moving files that can't be refactored will cause issues, e.g. relative paths in CSS file being overlooked
|
||||
if (!transformExtensions.includes(path.extname(file))) return false;
|
||||
@@ -56,34 +57,31 @@ export async function moveFiles(
|
||||
});
|
||||
}
|
||||
|
||||
if (!stats.isFile() || !(await isIncluded(from))) return;
|
||||
if (!stats.isFile() || !isIncluded(from)) return;
|
||||
|
||||
const content = await fs.readFile(from);
|
||||
const sourceFile = project.createSourceFile(from, content.toString(), {
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
await transformReferences(
|
||||
sourceFile,
|
||||
{
|
||||
transformReferences(sourceFile, (specifier) => {
|
||||
const resolved = resolveReference(specifier, {
|
||||
alias: {
|
||||
type: 'append',
|
||||
dir: src ? 'src' : '',
|
||||
},
|
||||
relativeTo: path.dirname(from),
|
||||
},
|
||||
async (resolved) => {
|
||||
if (resolved.type !== 'file') return;
|
||||
if (
|
||||
// ignore if the file is also moved
|
||||
isRelative(originalDir, from) &&
|
||||
(await isIncluded(resolved.path))
|
||||
)
|
||||
return;
|
||||
});
|
||||
if (resolved.type !== 'file') return;
|
||||
if (
|
||||
// ignore if the file is also moved
|
||||
isRelative(originalDir, from) &&
|
||||
isIncluded(resolved.path)
|
||||
)
|
||||
return;
|
||||
|
||||
return toReferencePath(to, resolved.path);
|
||||
},
|
||||
);
|
||||
return toReferencePath(to, resolved.path);
|
||||
});
|
||||
|
||||
await sourceFile.save();
|
||||
await fs.mkdir(path.dirname(to), { recursive: true });
|
||||
|
||||
@@ -1,66 +1,19 @@
|
||||
import path from 'node:path';
|
||||
import { type SourceFile } from 'ts-morph';
|
||||
import { type Config, defaultConfig } from '@/config';
|
||||
import { type Awaitable } from '@/commands/init';
|
||||
import { typescriptExtensions } from '@/constants';
|
||||
|
||||
/**
|
||||
* Find the transformed output path of input ref.
|
||||
*
|
||||
* extension name of `ref` is optional.
|
||||
*/
|
||||
export function getOutputPath(ref: string, config: Config): string {
|
||||
if (path.isAbsolute(ref)) throw new Error(`path cannot be absolute: ${ref}`);
|
||||
|
||||
if (ref === 'utils/cn' || ref === 'utils/cn.ts') {
|
||||
return config.aliases?.cn ?? defaultConfig.aliases.cn;
|
||||
}
|
||||
|
||||
if (ref.startsWith('components')) {
|
||||
return path.join(
|
||||
config.aliases?.componentsDir ?? defaultConfig.aliases.componentsDir,
|
||||
path.relative('components', ref),
|
||||
);
|
||||
}
|
||||
|
||||
if (ref.startsWith('lib') || ref.startsWith('utils')) {
|
||||
return path.join(
|
||||
config.aliases?.libDir ?? defaultConfig.aliases.libDir,
|
||||
path.relative('lib', ref),
|
||||
);
|
||||
}
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform references to other files (e.g. import/export from)
|
||||
*
|
||||
* @param file - source file
|
||||
* @param resolver - options to resolve references
|
||||
* @param transform - a function that transforms module specifier
|
||||
*/
|
||||
export async function transformReferences(
|
||||
export function transformReferences(
|
||||
file: SourceFile,
|
||||
resolver: ReferenceResolver,
|
||||
transform: (
|
||||
src: ResolvedImport,
|
||||
ref: string,
|
||||
) => Awaitable<string | undefined>,
|
||||
): Promise<void> {
|
||||
for (const item of file.getImportDeclarations()) {
|
||||
const ref = item.getModuleSpecifier().getLiteralValue();
|
||||
const result = await transform(resolveReference(ref, resolver), ref);
|
||||
if (!result) continue;
|
||||
|
||||
item.getModuleSpecifier().setLiteralValue(result);
|
||||
}
|
||||
|
||||
for (const item of file.getExportDeclarations()) {
|
||||
const specifier = item.getModuleSpecifier();
|
||||
if (!specifier) continue;
|
||||
const ref = specifier.getLiteralValue();
|
||||
const result = await transform(resolveReference(ref, resolver), ref);
|
||||
transform: (specifier: string) => string | undefined,
|
||||
) {
|
||||
for (const specifier of file.getImportStringLiterals()) {
|
||||
const result = transform(specifier.getLiteralValue());
|
||||
if (!result) continue;
|
||||
|
||||
specifier.setLiteralValue(result);
|
||||
|
||||
@@ -10,7 +10,11 @@ const importMap = {
|
||||
'contexts/layout.tsx': 'fumadocs-ui/contexts/layout',
|
||||
'utils/get-sidebar-tabs.tsx': 'fumadocs-ui/utils/get-sidebar-tabs',
|
||||
'utils/use-copy-button.ts': 'fumadocs-ui/utils/use-copy-button',
|
||||
};
|
||||
'icons.tsx': {
|
||||
type: 'dependency',
|
||||
name: 'lucide-react',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const srcDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user