Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/__mocks__/rslog.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ module.exports = {
logger: {
warn: () => {},
override: () => {},
debug: () => {},
},
};
65 changes: 59 additions & 6 deletions packages/core/src/cli/commands.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { LogLevel, RsbuildMode } from '@rsbuild/core';
import cac, { type CAC } from 'cac';
import type { ConfigLoader } from '../config';
import type { Format } from '../types/config';
import { logger } from '../utils/logger';
import { build } from './build';
import { init } from './init';
import { initConfig } from './initConfig';
import { inspect } from './inspect';
import { startMFDevServer } from './mf';
import { watchFilesForRestart } from './restart';
Expand All @@ -16,6 +17,19 @@ export type CommonOptions = {
lib?: string[];
configLoader?: ConfigLoader;
logLevel?: LogLevel;
format?: Format;
entry?: string[];
distPath?: string;
bundle?: boolean;
syntax?: string;
target?: string;
dts?: boolean;
external?: string[];
minify?: boolean;
clean?: boolean;
autoExtension?: boolean;
autoExternal?: boolean;
tsconfig?: string;
};

export type BuildOptions = CommonOptions & {
Expand Down Expand Up @@ -84,13 +98,52 @@ export function runCli(): void {

buildCommand
.option('-w, --watch', 'turn on watch mode, watch for changes and rebuild')
.option(
'--entry <entry>',
'set entry file or pattern (repeatable) (e.g. --entry="src/*" or --entry="main=src/main.ts")',
{
type: [String],
default: [],
},
)
.option('--dist-path <dir>', 'set output directory')
.option('--bundle', 'enable bundle mode (use --no-bundle to disable)')
.option(
'--format <format>',
'specify the output format (esm | cjs | umd | mf | iife)',
)
.option(
'--syntax <syntax>',
'set build syntax target (e.g. --syntax=es2018 or --syntax=["node 14", "Chrome 103"])',
)
.option('--target <target>', 'set runtime target (web | node)')
.option('--dts', 'emit declaration files (use --no-dts to disable)')
.option('--external <pkg>', 'add package to externals (repeatable)', {
type: [String],
default: [],
})
.option('--minify', 'minify output (use --no-minify to disable)')
.option(
'--clean',
'clean dist directory before build (use --no-clean to disable)',
)
.option(
'--auto-extension',
'control automatic extension redirect (use --no-auto-extension to disable)',
)
.option(
'--auto-external',
'control automatic dependency externalization (use --no-auto-external to disable)',
)
.option(
'--tsconfig <path>',
'use specific tsconfig (relative to project root)',
)
.action(async (options: BuildOptions) => {
try {
const cliBuild = async () => {
const { config, watchFiles } = await init(options);

const { config, watchFiles } = await initConfig(options);
await build(config, options);

if (options.watch) {
watchFilesForRestart(watchFiles, async () => {
await cliBuild();
Expand Down Expand Up @@ -120,7 +173,7 @@ export function runCli(): void {
.action(async (options: InspectOptions) => {
try {
// TODO: inspect should output Rslib's config
const { config } = await init(options);
const { config } = await initConfig(options);
await inspect(config, {
lib: options.lib,
mode: options.mode,
Expand All @@ -137,7 +190,7 @@ export function runCli(): void {
mfDevCommand.action(async (options: CommonOptions) => {
try {
const cliMfDev = async () => {
const { config, watchFiles } = await init(options);
const { config, watchFiles } = await initConfig(options);
await startMFDevServer(config, {
lib: options.lib,
});
Expand Down
56 changes: 0 additions & 56 deletions packages/core/src/cli/init.ts

This file was deleted.

162 changes: 162 additions & 0 deletions packages/core/src/cli/initConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import path from 'node:path';
import util from 'node:util';
import { loadEnv, type RsbuildEntry } from '@rsbuild/core';
import { loadConfig } from '../config';
import type {
EcmaScriptVersion,
RsbuildConfigOutputTarget,
RslibConfig,
Syntax,
} from '../types';
import { getAbsolutePath } from '../utils/helper';
import { logger } from '../utils/logger';
import type { CommonOptions } from './commands';
import { onBeforeRestart } from './restart';

const getEnvDir = (cwd: string, envDir?: string) => {
if (envDir) {
return path.isAbsolute(envDir) ? envDir : path.resolve(cwd, envDir);
}
return cwd;
};

export const parseEntryOption = (
entries?: string[],
): Record<string, string> | undefined => {
if (!entries || entries.length === 0) {
return undefined;
}

const parsed: Record<string, string> = {};
let unnamedIndex = 0;

for (const rawEntry of entries) {
const value = rawEntry?.trim();
if (!value) {
continue;
}

const equalIndex = value.indexOf('=');
if (equalIndex > -1) {
const name = value.slice(0, equalIndex).trim();
const entryPath = value.slice(equalIndex + 1).trim();
if (name && entryPath) {
parsed[name] = entryPath;
continue;
}
}

unnamedIndex += 1;
const key = unnamedIndex === 1 ? 'index' : `entry${unnamedIndex}`;
parsed[key] = value;
}

return Object.keys(parsed).length === 0 ? undefined : parsed;
};

export const parseSyntaxOption = (syntax?: string): Syntax | undefined => {
if (!syntax) {
return undefined;
}

const trimmed = syntax.trim();
if (!trimmed) {
return undefined;
}

if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed;
}
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
throw new Error(
`Failed to parse --syntax option "${trimmed}" as JSON array: ${reason}`,
);
}
}

return trimmed as EcmaScriptVersion;
};

const applyCliOptions = (
config: RslibConfig,
options: CommonOptions,
root: string,
): void => {
if (options.root) config.root = root;
if (options.logLevel) config.logLevel = options.logLevel;

for (const lib of config.lib) {
if (options.format !== undefined) lib.format = options.format;
if (options.bundle !== undefined) lib.bundle = options.bundle;
if (options.dts !== undefined) lib.dts = options.dts;
if (options.autoExtension !== undefined)
lib.autoExtension = options.autoExtension;
Copy link
Preview

Copilot AI Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assignment of options.autoExtension to lib.autoExtension should maintain type consistency. Based on the test snapshot showing autoExtension: 'false' as a string, consider ensuring boolean values are properly handled or document the expected type behavior.

Suggested change
lib.autoExtension = options.autoExtension;
lib.autoExtension =
typeof options.autoExtension === 'string'
? options.autoExtension === 'true' || options.autoExtension === '1'
: Boolean(options.autoExtension);

Copilot uses AI. Check for mistakes.

if (options.autoExternal !== undefined)
lib.autoExternal = options.autoExternal;
if (options.tsconfig !== undefined) {
lib.source ||= {};
lib.source.tsconfigPath = options.tsconfig;
}
const entry = parseEntryOption(options.entry);
if (entry !== undefined) {
lib.source ||= {};
lib.source.entry = entry as RsbuildEntry;
}
const syntax = parseSyntaxOption(options.syntax);
if (syntax !== undefined) lib.syntax = syntax;
const output = lib.output ?? {};
if (options.target !== undefined)
output.target = options.target as RsbuildConfigOutputTarget;
if (options.minify !== undefined) output.minify = options.minify;
if (options.clean !== undefined) output.cleanDistPath = options.clean;
const externals = options.external?.filter(Boolean) ?? [];
if (externals.length > 0) output.externals = externals;
if (options.distPath) {
output.distPath ??= {};
output.distPath.root = options.distPath;
}
Copy link
Preview

Copilot AI Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assignment to output.distPath should be updated to maintain the existing structure. The code assigns to lib.output but then modifies a local output variable without reassigning it back to lib.output.

Suggested change
}
}
lib.output = output;

Copilot uses AI. Check for mistakes.

}
};

export async function initConfig(options: CommonOptions): Promise<{
config: RslibConfig;
configFilePath: string;
watchFiles: string[];
}> {
const cwd = process.cwd();
const root = options.root ? getAbsolutePath(cwd, options.root) : cwd;
const envs = loadEnv({
cwd: getEnvDir(root, options.envDir),
mode: options.envMode,
});

onBeforeRestart(envs.cleanup);

const { content: config, filePath: configFilePath } = await loadConfig({
cwd: root,
path: options.config,
envMode: options.envMode,
loader: options.configLoader,
});

config.source ||= {};
config.source.define = {
...envs.publicVars,
...config.source.define,
};

applyCliOptions(config, options, root);

logger.debug('Rslib config used to generate Rsbuild environments:');
logger.debug(`\n${util.inspect(config, { depth: null, colors: true })}`);

return {
config,
configFilePath,
watchFiles: [configFilePath, ...envs.filePaths],
};
}
52 changes: 52 additions & 0 deletions packages/core/tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, test } from '@rstest/core';
import { parseEntryOption, parseSyntaxOption } from '../src/cli/initConfig';

describe('parseEntryOption', () => {
test('returns undefined when entries are missing or empty', () => {
expect(parseEntryOption()).toBeUndefined();
expect(parseEntryOption([])).toBeUndefined();
expect(parseEntryOption(['', ' '])).toBeUndefined();
});

test('parses named and positional entries with trimming', () => {
const result = parseEntryOption([
' main = ./src/main.ts ',
' ./src/utils.ts ',
'entry=./src/entry.ts',
'./src/extra.ts',
]);

expect(result).toEqual({
main: './src/main.ts',
index: './src/utils.ts',
entry: './src/entry.ts',
entry2: './src/extra.ts',
});
});
});

describe('parseSyntaxOption', () => {
test('returns undefined for missing or whitespace values', () => {
expect(parseSyntaxOption()).toBeUndefined();
expect(parseSyntaxOption('')).toBeUndefined();
expect(parseSyntaxOption(' ')).toBeUndefined();
});

test('returns the trimmed ECMAScript version when not a JSON array', () => {
expect(parseSyntaxOption(' es2020 ')).toBe('es2020');
});

test('parses JSON array syntax', () => {
expect(parseSyntaxOption('["chrome 120", "firefox 115"]')).toEqual([
'chrome 120',
'firefox 115',
]);
});

test('throws descriptive error when JSON parsing fails', () => {
const parseInvalidSyntax = () => parseSyntaxOption('[invalid');
expect(parseInvalidSyntax).toThrowError(
/Failed to parse --syntax option "\[inv.*JSON array/,
);
});
});
Loading
Loading