CLI: Support dynamic import transformation

This commit is contained in:
Fuma Nama
2025-05-19 19:48:54 +08:00
parent efc18c6135
commit ba35933d4d
15 changed files with 448 additions and 370 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@fumadocs/cli': minor
---
Support dynamic import transformation
+5
View File
@@ -0,0 +1,5 @@
---
'@fumadocs/cli': patch
---
Improve dep install UI
+34 -5
View File
@@ -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();
+20 -14
View File
@@ -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';
+57 -200
View File
@@ -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')));
}
+22 -10
View File
@@ -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!'));
}
+41 -15
View File
@@ -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;
}
+16 -1
View File
@@ -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
View File
@@ -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];
@@ -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.');
}
}
}
+16 -18
View File
@@ -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 });
+5 -52
View File
@@ -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);
+5 -1
View File
@@ -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)), '../');