diff --git a/packages/rstack/src/cli/commands.ts b/packages/rstack/src/cli/commands.ts index 07ed732e..ee6e24a1 100644 --- a/packages/rstack/src/cli/commands.ts +++ b/packages/rstack/src/cli/commands.ts @@ -1,5 +1,5 @@ import { join, resolve } from 'node:path'; -import { getConfigState } from '../config.ts'; +import { getConfigState, getRstackPluginRuntime, loadRstackConfig } from '../config.ts'; import { insertConfigArg, parseArgs, parseCliArgs } from './args.ts'; import { hasHelpFlag, printCommandHelp } from './help.ts'; @@ -163,16 +163,13 @@ async function runCheckCLI(args: string[]): Promise { } export async function setupCommands(): Promise { + const state = getConfigState(); + delete state.configPath; + delete state.invocation; + const { args, configPath } = parseCliArgs(process.argv.slice(2)); const command = args[0]; - // Resolved for every command so that a relative `--config` path always means - // the same file: it is anchored to the directory the CLI was invoked in, even - // when the config is later loaded from another directory. The motivating case - // is `rs fmt --lsp`, which loads the config from the LSP workspace root the - // client reports, and that root need not be the process working directory. - getConfigState().configPath = configPath === undefined ? undefined : resolve(configPath); - if (!command || command === '-h' || command === '--help') { return printCommandHelp('root'); } @@ -182,6 +179,17 @@ export async function setupCommands(): Promise { return; } + // Anchor a relative `--config` to the directory the CLI was invoked in, + // even when a command later loads it from another directory (for example, + // an LSP workspace root). + state.configPath = configPath === undefined ? undefined : resolve(configPath); + state.invocation = { + cwd: process.cwd(), + command, + args: args.slice(1), + configFilePath: null, + }; + if (command === 'lib') { await runRslibCLI(args.slice(1)); return; @@ -239,5 +247,12 @@ export async function setupCommands(): Promise { return; } + const loaded = await loadRstackConfig(); + const runtime = await getRstackPluginRuntime(loaded); + + if (await runtime.runCommand(command, state.invocation.args)) { + return; + } + throw new Error(`Unknown command: ${command}`); } diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index 0bf3bc52..425b4c9f 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -1,11 +1,15 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { resolve } from 'node:path'; import { loadConfig } from '@rstackjs/load-config'; +import { logger } from 'rslog'; import type { RsbuildConfigDefinition } from '@rsbuild/core'; import type { RslibConfigDefinition } from '@rslib/core'; import type { RslintConfig } from '@rslint/core'; import type { UserConfig, UserConfigAsyncFn } from '@rspress/core'; import type { RstestConfigExport } from '@rstest/core'; import type { FmtConfigDefinition } from './fmt/types.ts'; +import type { RstackConfigMap, RstackPlugins } from './plugin.ts'; +import { createPluginRuntime, type RstackPluginRuntime } from './pluginRuntime.ts'; import type { StagedConfig } from './staged.ts'; export type RslintConfigDefinition = RslintConfig | (() => Promise); @@ -27,10 +31,14 @@ export type Configs = { export type LoadedRstackConfig = { configs: Configs; + plugins: RstackPlugins; filePath: string | null; dependencies: string[]; }; +const loadedPluginRuntimes = new WeakMap>(); +const loadedConfigDirectories = new WeakMap(); + export type LoadRstackConfigOptions = { /** * The path to the Rstack config file, can be a relative or absolute path. @@ -48,9 +56,18 @@ export type LoadRstackConfigOptions = { type ConfigSession = { configs: Configs; + plugins: RstackPlugins; + pluginsDefined: boolean; active: boolean; }; +export type RstackInvocation = { + cwd: string; + command: string; + args: string[]; + configFilePath: string | null; +}; + type ConfigState = { /** * Config file path from the global `--config` flag. Always absolute: the CLI @@ -58,6 +75,7 @@ type ConfigState = { * (`loadRstackConfig` may be called with an LSP workspace root as `cwd`). */ configPath?: string; + invocation?: RstackInvocation; }; declare global { @@ -80,7 +98,7 @@ const getConfigSessionStorage = (): AsyncLocalStorage => { export const getConfigState = (): ConfigState => { // The CLI and its internal tool config can also be loaded as separate module - // instances. Keep only the CLI config path in its own global state. + // instances. Keep CLI invocation state in its own global state. if (!globalThis.__rstackCliState) { globalThis.__rstackCliState = {}; } @@ -88,7 +106,43 @@ export const getConfigState = (): ConfigState => { return globalThis.__rstackCliState; }; +export const getRstackPluginRuntime = ( + config: LoadedRstackConfig, +): Promise => { + const existingRuntime = loadedPluginRuntimes.get(config); + if (existingRuntime) { + return existingRuntime; + } + + const invocation = getConfigState().invocation; + const runtime = createPluginRuntime({ + plugins: config.plugins, + context: { + cwd: invocation?.cwd ?? loadedConfigDirectories.get(config) ?? process.cwd(), + command: invocation?.command ?? 'programmatic', + args: invocation?.args ?? [], + configFilePath: config.filePath, + }, + logger, + }); + loadedPluginRuntimes.set(config, runtime); + return runtime; +}; + +export const applyRstackConfigModifiers = async ( + loaded: LoadedRstackConfig, + kind: K, + config: RstackConfigMap[K], +): Promise => + (await getRstackPluginRuntime(loaded)).applyConfigModifiers(kind, config); + type Define = { + /** + * Registers plugins that extend the Rstack CLI. + * + * @see {@link https://rstack.rs/plugins | Rstack plugin guide} + */ + plugins: (plugins: RstackPlugins) => void; /** * Defines the Rsbuild config for the app. * @@ -152,20 +206,37 @@ type Define = { staged: (config: StagedConfig) => void; }; -const setConfig = (type: T, config: Configs[T]): void => { +const getActiveConfigSession = (type: string): ConfigSession => { const session = getConfigSessionStorage().getStore(); if (!session?.active) { throw new Error(`The "${type}" config must be defined while loading an Rstack config.`); } + return session; +}; + +const setConfig = (type: T, config: Configs[T]): void => { + const session = getActiveConfigSession(type); + if (type in session.configs) { throw new Error(`The "${type}" config has already been defined.`); } session.configs[type] = config; }; +const setPlugins = (plugins: RstackPlugins): void => { + const session = getActiveConfigSession('plugins'); + + if (session.pluginsDefined) { + throw new Error('The "plugins" config has already been defined.'); + } + session.plugins = plugins; + session.pluginsDefined = true; +}; + export const define: Define = { + plugins: setPlugins, app: (config) => setConfig('app', config), lib: (config) => setConfig('lib', config), doc: (config) => setConfig('doc', config), @@ -187,10 +258,12 @@ export const loadRstackConfig = async ({ const configPath = configFilePath ?? state.configPath; const session: ConfigSession = { configs: {}, + plugins: [], + pluginsDefined: false, active: true, }; - return getConfigSessionStorage().run(session, async () => { + const loadedConfig = await getConfigSessionStorage().run(session, async () => { try { const { filePath, dependencies } = await loadConfig({ loader: 'native', @@ -209,14 +282,26 @@ export const loadRstackConfig = async ({ }), }); - return { + if (state.invocation) { + state.invocation.configFilePath = filePath; + } + + const result = { configs: session.configs, + plugins: session.plugins, filePath, dependencies, }; + loadedConfigDirectories.set(result, resolve(cwd ?? process.cwd())); + return result; } finally { session.active = false; session.configs = {}; + session.plugins = []; + session.pluginsDefined = false; } }); + + await getRstackPluginRuntime(loadedConfig); + return loadedConfig; }; diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index 6bd314f2..c0e604e5 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -3,10 +3,10 @@ import { performance } from 'node:perf_hooks'; import { color, logger } from 'rslog'; import { parseArgs } from '../cli/args.ts'; import { printCommandHelp } from '../cli/help.ts'; -import { loadRstackConfig } from '../config.ts'; +import { applyRstackConfigModifiers, loadRstackConfig } from '../config.ts'; import { ensureProjectCacheDir } from '../projectCache.ts'; import { fmtCacheFileName } from './cacheStore.ts'; -import { resolveFmtConfig } from './config.ts'; +import { resolveFmtConfig, resolveFmtConfigDefinition } from './config.ts'; import { discoverFmtFiles } from './discovery.ts'; import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts'; import { runFmtFiles } from './runner.ts'; @@ -236,11 +236,16 @@ const logFmtResult = ( }; const loadFmtConfig = async (cwd: string): Promise => { - const { configs, filePath } = await loadRstackConfig({ cwd }); + const loaded = await loadRstackConfig({ cwd }); + const config = await applyRstackConfigModifiers( + loaded, + 'fmt', + await resolveFmtConfigDefinition(loaded.configs.fmt), + ); return resolveFmtConfig({ - definition: configs.fmt, - configFilePath: filePath, + definition: config, + configFilePath: loaded.filePath, cwd, }); }; diff --git a/packages/rstack/src/fmt/config.ts b/packages/rstack/src/fmt/config.ts index ee0a5123..60c90f7c 100644 --- a/packages/rstack/src/fmt/config.ts +++ b/packages/rstack/src/fmt/config.ts @@ -135,16 +135,20 @@ const createOptionsResolver = (config: ResolvedFmtConfig): FmtOptionsResolver => }; /** Resolves a formatter config definition and its project root. */ +const resolveFmtConfigDefinition = async ( + definition: FmtConfigDefinition | undefined, +): Promise => (typeof definition === 'function' ? await definition() : definition) ?? {}; + const resolveFmtConfig = async ({ definition, configFilePath, cwd, }: ResolveFmtConfigOptions): Promise => { - const config = typeof definition === 'function' ? await definition() : definition; + const config = await resolveFmtConfigDefinition(definition); const rootPath = configFilePath ? dirname(configFilePath) : cwd; return normalizeFmtConfig(config, rootPath); }; -export { createOptionsResolver, normalizeFmtConfig, resolveFmtConfig }; +export { createOptionsResolver, normalizeFmtConfig, resolveFmtConfig, resolveFmtConfigDefinition }; export type { FmtOptionsResolver }; diff --git a/packages/rstack/src/index.ts b/packages/rstack/src/index.ts index 85410493..56cbc566 100644 --- a/packages/rstack/src/index.ts +++ b/packages/rstack/src/index.ts @@ -1,2 +1,13 @@ export { define } from './config.ts'; export { runCLI } from './cli/index.ts'; +export type { FmtConfig } from './fmt/types.ts'; +export type { + RstackCommand, + RstackConfigMap, + RstackLogger, + RstackPlugin, + RstackPluginAPI, + RstackPluginContext, + RstackPlugins, +} from './plugin.ts'; +export type { StagedConfig } from './staged.ts'; diff --git a/packages/rstack/src/plugin.ts b/packages/rstack/src/plugin.ts new file mode 100644 index 00000000..fd4024ab --- /dev/null +++ b/packages/rstack/src/plugin.ts @@ -0,0 +1,65 @@ +import type { RsbuildConfig } from '@rsbuild/core'; +import type { RslibConfig } from '@rslib/core'; +import type { RslintConfig } from '@rslint/core'; +import type { UserConfig as RspressConfig } from '@rspress/core'; +import type { RstestConfig } from '@rstest/core'; +import type { FmtConfig } from './fmt/types.ts'; +import type { StagedConfig } from './staged.ts'; + +export type RstackConfigMap = { + app: RsbuildConfig; + lib: RslibConfig; + doc: RspressConfig; + test: RstestConfig; + lint: RslintConfig; + fmt: FmtConfig; + staged: StagedConfig; +}; + +export type RstackPluginContext = Readonly<{ + cwd: string; + command: string; + args: readonly string[]; + configFilePath: string | null; +}>; + +export interface RstackLogger { + debug(message?: unknown, ...args: unknown[]): void; + info(message?: unknown, ...args: unknown[]): void; + warn(message?: unknown, ...args: unknown[]): void; + error(message?: unknown, ...args: unknown[]): void; + success(message?: unknown, ...args: unknown[]): void; +} + +export type RstackCommand = { + name: string; + handler: (args: readonly string[]) => void | Promise; +}; + +export type RstackPluginAPI = { + readonly context: RstackPluginContext; + readonly logger: RstackLogger; + + addCommand: (command: RstackCommand) => void; + + modifyConfig: ( + kind: K, + handler: ( + config: RstackConfigMap[K], + ) => void | RstackConfigMap[K] | Promise, + ) => void; +}; + +export type RstackPlugin = { + name: string; + setup(api: RstackPluginAPI): void | Promise; +}; + +export type RstackPlugins = Array< + | RstackPlugin + | false + | null + | undefined + | Promise + | RstackPlugins +>; diff --git a/packages/rstack/src/pluginRuntime.ts b/packages/rstack/src/pluginRuntime.ts new file mode 100644 index 00000000..eb4a173a --- /dev/null +++ b/packages/rstack/src/pluginRuntime.ts @@ -0,0 +1,202 @@ +import type { + RstackCommand, + RstackConfigMap, + RstackLogger, + RstackPlugin, + RstackPluginContext, + RstackPlugins, +} from './plugin.ts'; + +export type RstackConfigModifier = ( + config: RstackConfigMap[K], +) => void | RstackConfigMap[K] | Promise; + +type RstackConfigModifierRegistry = { + [K in keyof RstackConfigMap]: RstackConfigModifier[]; +}; + +export type RstackPluginRuntime = { + readonly context: RstackPluginContext; + hasConfigModifier(kind: keyof RstackConfigMap): boolean; + runCommand(name: string, args: readonly string[]): Promise; + applyConfigModifiers( + kind: K, + config: RstackConfigMap[K], + ): Promise; +}; + +export type CreatePluginRuntimeOptions = { + plugins: RstackPlugins; + context: RstackPluginContext; + logger: RstackLogger; + reservedCommands?: Iterable; +}; + +const validCommandName = /^[a-z][a-z0-9-]*$/u; + +const builtInCommandNames = [ + 'dev', + 'build', + 'preview', + 'lib', + 'doc', + 'test', + 'lint', + 'check', + 'fmt', + 'format', + 'staged', + 'setup', +]; + +const hasValidName = (name: unknown): name is string => + typeof name === 'string' && name.length > 0 && name.trim() === name && !/\s/u.test(name); + +const flattenPlugins = async (plugins: RstackPlugins): Promise => { + if (!Array.isArray(plugins)) { + throw new Error('Invalid Rstack plugins. Expected an array.'); + } + + const result: RstackPlugin[] = []; + + const visit = async (value: RstackPlugins[number]): Promise => { + const resolved = await value; + + if (resolved === false || resolved === null || resolved === undefined) { + return; + } + + if (Array.isArray(resolved)) { + for (const plugin of resolved) { + await visit(plugin); + } + return; + } + + result.push(resolved); + }; + + for (const plugin of plugins) { + await visit(plugin); + } + + return result; +}; + +const assertPlugin = (plugin: RstackPlugin): void => { + if (!plugin || typeof plugin !== 'object' || Array.isArray(plugin)) { + throw new Error('Invalid Rstack plugin. Expected a plugin object.'); + } + + if (!hasValidName(plugin.name)) { + throw new Error( + 'Invalid Rstack plugin name. Plugin names must be non-empty strings without whitespace.', + ); + } + + if (typeof plugin.setup !== 'function') { + throw new Error(`Invalid Rstack plugin "${plugin.name}". Expected a setup function.`); + } +}; + +const createModifierRegistry = (): RstackConfigModifierRegistry => ({ + app: [], + lib: [], + doc: [], + test: [], + lint: [], + fmt: [], + staged: [], +}); + +export const createPluginRuntime = async ({ + plugins, + context, + logger, + reservedCommands = builtInCommandNames, +}: CreatePluginRuntimeOptions): Promise => { + const commands = new Map(); + const configModifiers = createModifierRegistry(); + const pluginNames = new Set(); + const reservedCommandNames = new Set(reservedCommands); + + const addCommand = (command: RstackCommand): void => { + if (!command || typeof command !== 'object' || Array.isArray(command)) { + throw new Error('Invalid Rstack command. Expected a command object.'); + } + + if (!validCommandName.test(command.name)) { + throw new Error( + 'Invalid Rstack command name. Command names must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.', + ); + } + + if (typeof command.handler !== 'function') { + throw new Error(`Invalid Rstack command "${command.name}". Expected a handler function.`); + } + + if (reservedCommandNames.has(command.name)) { + throw new Error(`Rstack command "${command.name}" conflicts with a built-in command.`); + } + + if (commands.has(command.name)) { + throw new Error(`Duplicate Rstack command: "${command.name}".`); + } + + commands.set(command.name, command.handler); + }; + + const runtime: RstackPluginRuntime = { + context, + hasConfigModifier(kind) { + return configModifiers[kind].length > 0; + }, + async runCommand(name, args) { + const handler = commands.get(name); + if (!handler) { + return false; + } + await handler(args); + return true; + }, + async applyConfigModifiers(kind, config) { + let current = config; + for (const modifier of configModifiers[kind] as RstackConfigModifier[]) { + const result = await modifier(current); + if (result !== undefined) { + current = result; + } + } + return current; + }, + }; + + const resolvedPlugins = await flattenPlugins(plugins); + for (const plugin of resolvedPlugins) { + assertPlugin(plugin); + + if (pluginNames.has(plugin.name)) { + throw new Error(`Duplicate Rstack plugin: "${plugin.name}".`); + } + pluginNames.add(plugin.name); + } + + for (const plugin of resolvedPlugins) { + await plugin.setup({ + context, + logger, + addCommand, + modifyConfig(kind, handler) { + if (!Object.hasOwn(configModifiers, kind)) { + throw new Error(`Invalid Rstack config kind: "${String(kind)}".`); + } + if (typeof handler !== 'function') { + throw new Error(`Invalid Rstack ${kind} config modifier. Expected a function.`); + } + (configModifiers[kind] as RstackConfigModifier[]).push(handler); + }, + }); + } + + return runtime; +}; diff --git a/packages/rstack/src/rsbuildConfig.ts b/packages/rstack/src/rsbuildConfig.ts index 01385c44..569f6ccb 100644 --- a/packages/rstack/src/rsbuildConfig.ts +++ b/packages/rstack/src/rsbuildConfig.ts @@ -1,5 +1,5 @@ import type { ConfigParams, RsbuildConfigDefinition, WatchFiles } from '@rsbuild/core'; -import { loadRstackConfig, type Configs } from './config.ts'; +import { applyRstackConfigModifiers, loadRstackConfig, type Configs } from './config.ts'; const resolveRsbuildConfig = async (configs: Configs, params: ConfigParams) => { const appConfig = configs.app; @@ -13,8 +13,13 @@ const resolveRsbuildConfig = async (configs: Configs, params: ConfigParams) => { }; const loadRsbuildConfig: RsbuildConfigDefinition = async (params) => { - const { configs, filePath, dependencies } = await loadRstackConfig(); - const config = await resolveRsbuildConfig(configs, params); + const loaded = await loadRstackConfig(); + const { configs, filePath, dependencies } = loaded; + const config = await applyRstackConfigModifiers( + loaded, + 'app', + await resolveRsbuildConfig(configs, params), + ); if (!filePath) { return config; diff --git a/packages/rstack/src/rslibConfig.ts b/packages/rstack/src/rslibConfig.ts index b7468aa4..5117b53c 100644 --- a/packages/rstack/src/rslibConfig.ts +++ b/packages/rstack/src/rslibConfig.ts @@ -1,6 +1,6 @@ import type { WatchFiles } from '@rsbuild/core'; import type { ConfigParams, RslibConfig, RslibConfigDefinition } from '@rslib/core'; -import { loadRstackConfig, type Configs } from './config.ts'; +import { applyRstackConfigModifiers, loadRstackConfig, type Configs } from './config.ts'; const resolveRslibConfig = async (configs: Configs, params: ConfigParams): Promise => { const libConfig = configs.lib; @@ -14,16 +14,20 @@ const resolveRslibConfig = async (configs: Configs, params: ConfigParams): Promi }; const loadRslibConfig = (async (params: ConfigParams) => { - const { configs, filePath, dependencies } = await loadRstackConfig(); - const config = await resolveRslibConfig(configs, params); + const loaded = await loadRstackConfig(); + const config = await applyRstackConfigModifiers( + loaded, + 'lib', + await resolveRslibConfig(loaded.configs, params), + ); - if (!filePath) { + if (!loaded.filePath) { return config; } const watchFiles = config.dev?.watchFiles; const watchConfig: WatchFiles = { - paths: [filePath, ...dependencies], + paths: [loaded.filePath, ...loaded.dependencies], type: 'restart', }; diff --git a/packages/rstack/src/rslintConfig.ts b/packages/rstack/src/rslintConfig.ts index 50f13c20..2adf3a22 100644 --- a/packages/rstack/src/rslintConfig.ts +++ b/packages/rstack/src/rslintConfig.ts @@ -1,7 +1,8 @@ -import { loadRstackConfig } from './config.ts'; +import { applyRstackConfigModifiers, loadRstackConfig } from './config.ts'; import type { RslintConfig } from '@rslint/core'; -const { configs } = await loadRstackConfig(); +const loaded = await loadRstackConfig(); +const { configs } = loaded; const lintDefinition = configs.lint ?? []; let lintConfig: RslintConfig; @@ -13,4 +14,10 @@ if (typeof lintDefinition === 'function') { lintConfig = lintDefinition; } -export default lintConfig; +const modifiedLintConfig: RslintConfig = await applyRstackConfigModifiers( + loaded, + 'lint', + lintConfig, +); + +export default modifiedLintConfig; diff --git a/packages/rstack/src/rspressConfig.ts b/packages/rstack/src/rspressConfig.ts index 0bf6a60d..6c0eec19 100644 --- a/packages/rstack/src/rspressConfig.ts +++ b/packages/rstack/src/rspressConfig.ts @@ -1,6 +1,6 @@ import type { WatchFiles } from '@rsbuild/core'; import type { UserConfig } from '@rspress/core'; -import { loadRstackConfig, type Configs } from './config.ts'; +import { applyRstackConfigModifiers, loadRstackConfig, type Configs } from './config.ts'; const resolveRspressConfig = async (configs: Configs): Promise => { const docConfig = configs.doc; @@ -14,16 +14,20 @@ const resolveRspressConfig = async (configs: Configs): Promise => { }; export default async (): Promise => { - const { configs, filePath, dependencies } = await loadRstackConfig(); - const config = await resolveRspressConfig(configs); + const loaded = await loadRstackConfig(); + const config = await applyRstackConfigModifiers( + loaded, + 'doc', + await resolveRspressConfig(loaded.configs), + ); - if (!filePath) { + if (!loaded.filePath) { return config; } const watchFiles = config.builderConfig?.dev?.watchFiles; const watchConfig: WatchFiles = { - paths: [filePath, ...dependencies], + paths: [loaded.filePath, ...loaded.dependencies], type: 'restart', }; diff --git a/packages/rstack/src/rstestConfig.ts b/packages/rstack/src/rstestConfig.ts index b28d61d2..97bad605 100644 --- a/packages/rstack/src/rstestConfig.ts +++ b/packages/rstack/src/rstestConfig.ts @@ -1,33 +1,50 @@ import type { ConfigParams } from '@rsbuild/core'; import type { RstestConfig, RstestConfigExport } from '@rstest/core'; -import { loadRstackConfig, type Configs } from './config.ts'; +import { + applyRstackConfigModifiers, + getRstackPluginRuntime, + loadRstackConfig, + type Configs, + type LoadedRstackConfig, +} from './config.ts'; const resolveAutomaticExtends = async ( - configs: Configs, + loaded: LoadedRstackConfig, params: ConfigParams, ): Promise => { // Prefer the app when both app and lib are defined. Merging both adapters can // introduce conflicting runtime, resolve, and source transform settings. - const appConfig = configs.app; - if (appConfig) { + const appConfig = loaded.configs.app; + const runtime = await getRstackPluginRuntime(loaded); + if (appConfig || runtime.hasConfigModifier('app')) { const { withRsbuildConfig } = await import( /* rspackChunkName: 'adapterRsbuild' */ '@rstest/adapter-rsbuild' ); - const config = typeof appConfig === 'function' ? await appConfig(params) : appConfig; + const resolvedConfig = typeof appConfig === 'function' ? await appConfig(params) : appConfig; + const config = await applyRstackConfigModifiers( + loaded, + 'app', + resolvedConfig === undefined ? {} : resolvedConfig, + ); return withRsbuildConfig({ config, }); } - const libConfig = configs.lib; - if (libConfig) { + const libConfig = loaded.configs.lib; + if (libConfig || runtime.hasConfigModifier('lib')) { const { withRslibConfig } = await import( /* rspackChunkName: 'adapterRslib' */ '@rstest/adapter-rslib' ); - const config = typeof libConfig === 'function' ? await libConfig(params) : libConfig; + const resolvedConfig = typeof libConfig === 'function' ? await libConfig(params) : libConfig; + const config = await applyRstackConfigModifiers( + loaded, + 'lib', + resolvedConfig === undefined ? {} : resolvedConfig, + ); return withRslibConfig({ config, @@ -51,13 +68,17 @@ const injectExtends = ( }; }; -const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params: ConfigParams) => { +const extendsConfig = async ( + loaded: LoadedRstackConfig, + testConfig: RstestConfig, + params: ConfigParams, +) => { if ('extends' in testConfig) { return testConfig; } if (testConfig.projects === undefined) { - const automaticExtends = await resolveAutomaticExtends(configs, params); + const automaticExtends = await resolveAutomaticExtends(loaded, params); return injectExtends(testConfig, automaticExtends); } @@ -68,7 +89,7 @@ const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params: return testConfig; } - const automaticExtends = await resolveAutomaticExtends(configs, params); + const automaticExtends = await resolveAutomaticExtends(loaded, params); return { ...testConfig, @@ -90,9 +111,13 @@ const resolveRstestConfig = async (configs: Configs) => { }; const loadRstestConfig = (async (params: ConfigParams) => { - const { configs } = await loadRstackConfig(); - const testConfig = await resolveRstestConfig(configs); - return extendsConfig(configs, testConfig, params); + const loaded = await loadRstackConfig(); + const configWithAutomaticExtends = await extendsConfig( + loaded, + await resolveRstestConfig(loaded.configs), + params, + ); + return applyRstackConfigModifiers(loaded, 'test', configWithAutomaticExtends); }) as RstestConfigExport; export default loadRstestConfig; diff --git a/packages/rstack/src/staged.ts b/packages/rstack/src/staged.ts index 4f6c78c2..aff6fb7f 100644 --- a/packages/rstack/src/staged.ts +++ b/packages/rstack/src/staged.ts @@ -1,7 +1,7 @@ import lintStaged from 'lint-staged'; import { parseArgs } from './cli/args.ts'; import { printCommandHelp } from './cli/help.ts'; -import { loadRstackConfig } from './config.ts'; +import { getRstackPluginRuntime, loadRstackConfig } from './config.ts'; export type StagedSyncTaskGenerator = (stagedFileNames: readonly string[]) => string | string[]; @@ -44,13 +44,14 @@ export async function runStagedCLI(args: string[]): Promise { return; } - const { configs } = await loadRstackConfig(); - const stagedConfig = configs.staged; - if (!stagedConfig) { + const loaded = await loadRstackConfig(); + const runtime = await getRstackPluginRuntime(loaded); + if (!loaded.configs.staged && !runtime.hasConfigModifier('staged')) { throw new Error( 'No define.staged config found. Add define.staged({ "*": "your-command" }) to rstack config file', ); } + const stagedConfig = await runtime.applyConfigModifiers('staged', loaded.configs.staged ?? {}); // Let child commands detect that they are running through `rs staged`. process.env.RSTACK_STAGED = '1'; diff --git a/packages/rstack/tests/cli/plugin-commands/broken.config.ts b/packages/rstack/tests/cli/plugin-commands/broken.config.ts new file mode 100644 index 00000000..6564093d --- /dev/null +++ b/packages/rstack/tests/cli/plugin-commands/broken.config.ts @@ -0,0 +1 @@ +throw new Error('root help and version must not load configuration'); diff --git a/packages/rstack/tests/cli/plugin-commands/index.test.ts b/packages/rstack/tests/cli/plugin-commands/index.test.ts new file mode 100644 index 00000000..90b0aec0 --- /dev/null +++ b/packages/rstack/tests/cli/plugin-commands/index.test.ts @@ -0,0 +1,47 @@ +import path from 'node:path'; +import { test } from '#test-helpers'; + +for (const configArg of [ + '--config ./rstack.config.ts', + '--config=./rstack.config.ts', + '-c ./rstack.config.ts', + '-c./rstack.config.ts', +]) { + test(`dispatches a plugin command after removing ${configArg} from its raw arguments`, ({ + cwd, + execCli, + expect, + }) => { + const output = execCli(`plugin-command first ${configArg} second`); + + expect(JSON.parse(output)).toEqual({ + args: ['first', 'second'], + context: { + cwd, + command: 'plugin-command', + args: ['first', 'second'], + configFilePath: path.join(cwd, 'rstack.config.ts'), + }, + }); + }); +} + +test('awaits asynchronous plugin command handlers', ({ execCli, expect }) => { + expect(execCli('async-command')).toBe('async handler completed\n'); +}); + +test('preserves plugin command failures', ({ execCli, expect }) => { + try { + execCli('throws-command'); + } catch (error) { + expect((error as { stderr?: Buffer }).stderr?.toString()).toContain('plugin command failure'); + return; + } + + throw new Error('Expected the plugin command to fail.'); +}); + +test('keeps root help and version config-free', ({ execCli, expect }) => { + expect(execCli('--config ./broken.config.ts --help')).toContain('$ rs'); + expect(execCli('-c./broken.config.ts --version')).toMatch(/^Rstack v/u); +}); diff --git a/packages/rstack/tests/cli/plugin-commands/rstack.config.ts b/packages/rstack/tests/cli/plugin-commands/rstack.config.ts new file mode 100644 index 00000000..64b90a9e --- /dev/null +++ b/packages/rstack/tests/cli/plugin-commands/rstack.config.ts @@ -0,0 +1,28 @@ +import { define } from 'rstack'; + +define.plugins([ + { + name: 'plugin-command-fixture', + setup({ addCommand, context }) { + addCommand({ + name: 'plugin-command', + handler(args) { + console.log(JSON.stringify({ args, context })); + }, + }); + addCommand({ + name: 'async-command', + async handler() { + await Promise.resolve(); + console.log('async handler completed'); + }, + }); + addCommand({ + name: 'throws-command', + handler() { + throw new Error('plugin command failure'); + }, + }); + }, + }, +]); diff --git a/packages/rstack/tests/cli/plugin-commands/state.test.ts b/packages/rstack/tests/cli/plugin-commands/state.test.ts new file mode 100644 index 00000000..535a2b22 --- /dev/null +++ b/packages/rstack/tests/cli/plugin-commands/state.test.ts @@ -0,0 +1,29 @@ +import { afterEach, expect, test } from 'rstack/test'; +import { setupCommands } from '../../../src/cli/commands.ts'; +import { getConfigState } from '../../../src/config.ts'; + +const state = getConfigState(); +const originalArgv = process.argv; + +afterEach(() => { + process.argv = originalArgv; + delete (globalThis as { RSTACK_VERSION?: string }).RSTACK_VERSION; + delete state.configPath; + delete state.invocation; +}); + +test('clears prior invocation state for config-free root help', async () => { + state.configPath = '/prior/rstack.config.ts'; + state.invocation = { + cwd: '/prior', + command: 'build', + args: [], + configFilePath: '/prior/rstack.config.ts', + }; + process.argv = ['node', 'rs', '--config', './ignored.config.ts', '--help']; + (globalThis as { RSTACK_VERSION?: string }).RSTACK_VERSION = 'test'; + + await setupCommands(); + + expect(state).toEqual({}); +}); diff --git a/packages/rstack/tests/cli/staged/index.test.ts b/packages/rstack/tests/cli/staged/index.test.ts index b43a928b..11db3a01 100644 --- a/packages/rstack/tests/cli/staged/index.test.ts +++ b/packages/rstack/tests/cli/staged/index.test.ts @@ -1,7 +1,7 @@ import lintStaged from 'lint-staged'; -import { afterEach, beforeEach, rs } from 'rstack/test'; +import { afterEach, beforeEach, expect, rs } from 'rstack/test'; import { normalizeHelpOutput, test } from '#test-helpers'; -import { loadRstackConfig } from '../../../src/config.ts'; +import { getRstackPluginRuntime, loadRstackConfig } from '../../../src/config.ts'; import { runStagedCLI, type StagedConfig } from '../../../src/staged.ts'; rs.mock('lint-staged'); @@ -9,6 +9,7 @@ rs.mock('../../../src/config.ts'); const mocks = { lintStaged: rs.mocked(lintStaged), + getRstackPluginRuntime: rs.mocked(getRstackPluginRuntime), loadRstackConfig: rs.mocked(loadRstackConfig), }; @@ -16,15 +17,53 @@ const stagedConfig: StagedConfig = { '*.txt': 'echo test', }; +const noStagedModifiers = { + hasConfigModifier: () => false, + applyConfigModifiers: (_kind: 'staged', config: StagedConfig) => Promise.resolve(config), +}; + beforeEach(() => { delete process.env.RSTACK_STAGED; rs.resetAllMocks(); mocks.lintStaged.mockResolvedValue(true); + mocks.getRstackPluginRuntime.mockResolvedValue(noStagedModifiers as never); mocks.loadRstackConfig.mockResolvedValue({ configs: { staged: stagedConfig }, + plugins: [], + filePath: null, + dependencies: [], + }); +}); + +test('should preserve the missing staged config error when no plugin contributes one', async () => { + mocks.loadRstackConfig.mockResolvedValue({ + configs: {}, + plugins: [], + filePath: null, + dependencies: [], + }); + + await expect(runStagedCLI([])).rejects.toThrow('No define.staged config found'); +}); + +test('should accept a staged config supplied only by a plugin modifier', async ({ expect }) => { + const modifierConfig: StagedConfig = { '*.ts': 'echo plugin' }; + mocks.loadRstackConfig.mockResolvedValue({ + configs: {}, + plugins: [], filePath: null, dependencies: [], }); + mocks.getRstackPluginRuntime.mockResolvedValue({ + hasConfigModifier: () => true, + applyConfigModifiers: () => Promise.resolve(modifierConfig), + } as never); + + await runStagedCLI([]); + + expect(mocks.lintStaged).toHaveBeenCalledWith( + expect.objectContaining({ config: modifierConfig }), + ); }); afterEach(() => { diff --git a/packages/rstack/tests/config/load-config/duplicate-plugins.config.ts b/packages/rstack/tests/config/load-config/duplicate-plugins.config.ts new file mode 100644 index 00000000..bd3d289c --- /dev/null +++ b/packages/rstack/tests/config/load-config/duplicate-plugins.config.ts @@ -0,0 +1,4 @@ +import { define } from 'rstack'; + +define.plugins([]); +define.plugins([]); diff --git a/packages/rstack/tests/config/load-config/index.test.ts b/packages/rstack/tests/config/load-config/index.test.ts index a738f80e..23bca411 100644 --- a/packages/rstack/tests/config/load-config/index.test.ts +++ b/packages/rstack/tests/config/load-config/index.test.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { afterEach, expect, test } from 'rstack/test'; -import { getConfigState, loadRstackConfig } from '../../../src/config.ts'; +import { define, getConfigState, loadRstackConfig } from '../../../src/config.ts'; type Deferred = ReturnType>; @@ -35,8 +35,9 @@ test('should discard a config session after loading fails', async () => { await expect(loadRstackConfig()).rejects.toThrow('test config error'); - const { configs } = await loadConfigFile('explicit.config.ts'); + const { configs, plugins } = await loadConfigFile('explicit.config.ts'); expect(configs).toEqual({ app: {} }); + expect(plugins).toEqual([]); }); test('should prefer an explicit config path over the state config path', async () => { @@ -81,10 +82,12 @@ test('should isolate parallel config sessions across top-level await', async () app: { root: 'first' }, test: {}, }); + expect(firstResult.plugins).toMatchObject([{ name: 'first' }]); expect(secondResult.configs).toEqual({ app: { root: 'second' }, lib: {}, }); + expect(secondResult.plugins).toMatchObject([{ name: 'second' }]); }); test('should keep a running session intact when another config fails', async () => { @@ -108,3 +111,24 @@ test('should reject duplicate definitions within the same session', async () => 'The "app" config has already been defined.', ); }); + +test('should capture nested and asynchronous plugin definitions', async () => { + const { plugins } = await loadConfigFile('plugins.config.ts'); + + expect(plugins).toHaveLength(3); + expect(plugins[0]).toMatchObject({ name: 'first' }); + await expect(plugins[1]).resolves.toMatchObject({ name: 'second' }); + await expect(plugins[2]).resolves.toEqual([false, expect.objectContaining({ name: 'third' })]); +}); + +test('should reject duplicate plugin definitions within the same session', async () => { + await expect(loadConfigFile('duplicate-plugins.config.ts')).rejects.toThrow( + 'The "plugins" config has already been defined.', + ); +}); + +test('should reject plugin definitions outside config loading', () => { + expect(() => define.plugins([])).toThrow( + 'The "plugins" config must be defined while loading an Rstack config.', + ); +}); diff --git a/packages/rstack/tests/config/load-config/parallel-first.config.ts b/packages/rstack/tests/config/load-config/parallel-first.config.ts index 4d68eed0..fc7ccf91 100644 --- a/packages/rstack/tests/config/load-config/parallel-first.config.ts +++ b/packages/rstack/tests/config/load-config/parallel-first.config.ts @@ -3,6 +3,7 @@ import { define } from 'rstack'; const hooks = globalThis.__rstackConfigTestHooks!; define.app({ root: 'first' }); +define.plugins([{ name: 'first', setup() {} }]); hooks.ready.resolve(); await hooks.release.promise; diff --git a/packages/rstack/tests/config/load-config/parallel-second.config.ts b/packages/rstack/tests/config/load-config/parallel-second.config.ts index f5d6bcb7..4d66a4c4 100644 --- a/packages/rstack/tests/config/load-config/parallel-second.config.ts +++ b/packages/rstack/tests/config/load-config/parallel-second.config.ts @@ -2,3 +2,4 @@ import { define } from 'rstack'; define.app({ root: 'second' }); define.lib({}); +define.plugins([{ name: 'second', setup() {} }]); diff --git a/packages/rstack/tests/config/load-config/plugins.config.ts b/packages/rstack/tests/config/load-config/plugins.config.ts new file mode 100644 index 00000000..dfee7b49 --- /dev/null +++ b/packages/rstack/tests/config/load-config/plugins.config.ts @@ -0,0 +1,7 @@ +import { define } from 'rstack'; + +define.plugins([ + { name: 'first', setup() {} }, + Promise.resolve({ name: 'second', setup() {} }), + Promise.resolve([false, { name: 'third', setup() {} }]), +]); diff --git a/packages/rstack/tests/config/load-config/rstack.config.ts b/packages/rstack/tests/config/load-config/rstack.config.ts index 7720867d..b77da116 100644 --- a/packages/rstack/tests/config/load-config/rstack.config.ts +++ b/packages/rstack/tests/config/load-config/rstack.config.ts @@ -1,4 +1,5 @@ import { define } from 'rstack'; define.app({}); +define.plugins([{ name: 'discarded', setup() {} }]); throw new Error('test config error'); diff --git a/packages/rstack/tests/config/plugin-modifiers/adapters.test.ts b/packages/rstack/tests/config/plugin-modifiers/adapters.test.ts new file mode 100644 index 00000000..b899cbf7 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/adapters.test.ts @@ -0,0 +1,95 @@ +import path from 'node:path'; +import { afterEach, expect, test } from 'rstack/test'; +import { getConfigState } from '../../../src/config.ts'; +import loadRsbuildConfig from '../../../src/rsbuildConfig.ts'; +import loadRslibConfig from '../../../src/rslibConfig.ts'; +import loadRspressConfig from '../../../src/rspressConfig.ts'; +import loadRstestConfig from '../../../src/rstestConfig.ts'; + +declare global { + // rslint-disable-next-line no-var + var __rstackPluginModifierSetups: number | undefined; +} + +const state = getConfigState(); +const loadAppConfig = loadRsbuildConfig as (params: never) => Promise; +const loadLibConfig = loadRslibConfig as (params: never) => Promise; +const loadTestConfig = loadRstestConfig as (params: never) => Promise; +const configPath = path.join(import.meta.dirname, 'rstack.config.ts'); +const factoryOrderConfigPath = path.join(import.meta.dirname, 'factory-order-rstack.config.ts'); +const explicitConfigPath = path.join(import.meta.dirname, 'explicit-rstack.config.ts'); +const projectsExplicitConfigPath = path.join( + import.meta.dirname, + 'projects-explicit-rstack.config.ts', +); +const testModifierExtendsConfigPath = path.join( + import.meta.dirname, + 'test-modifier-extends-rstack.config.ts', +); + +afterEach(() => { + delete state.configPath; + delete globalThis.__rstackPluginModifierSetups; + delete globalThis.__rstackExplicitAppModifierCalls; + delete globalThis.__rstackAllProjectsExplicitAppModifierCalls; + delete globalThis.__rstackTestModifierExtendsAppCalls; +}); + +test('constructs automatic Rstest extends before applying test modifiers', async () => { + state.configPath = testModifierExtendsConfigPath; + + await expect(loadTestConfig({} as never)).resolves.toMatchObject({ + extends: { root: 'test-modifier' }, + }); + expect(globalThis.__rstackTestModifierExtendsAppCalls).toBe(1); +}); + +test.each([ + ['app', () => loadAppConfig({} as never)], + ['lib', () => loadLibConfig({} as never)], + ['doc', () => loadRspressConfig()], + ['test', () => loadTestConfig({} as never)], +])('initializes plugins before resolving the %s config factory', async (_kind, loadConfig) => { + state.configPath = factoryOrderConfigPath; + + await expect(loadConfig()).resolves.toBeDefined(); +}); + +test('does not apply app modifiers when all Rstest projects explicitly extend configs', async () => { + state.configPath = projectsExplicitConfigPath; + + await expect(loadTestConfig({} as never)).resolves.toMatchObject({ + reporters: ['dot'], + projects: [{ name: 'explicit', extends: {} }], + }); + expect(globalThis.__rstackAllProjectsExplicitAppModifierCalls).toBeUndefined(); +}); + +test('does not apply app modifiers for explicit Rstest extends', async () => { + state.configPath = explicitConfigPath; + + await expect(loadTestConfig({} as never)).resolves.toMatchObject({ + extends: {}, + reporters: ['dot'], + }); + expect(globalThis.__rstackExplicitAppModifierCalls).toBeUndefined(); +}); + +test('uses app, lib, and doc modifiers when their user configs are absent', async () => { + state.configPath = configPath; + + await expect(loadAppConfig({} as never)).resolves.toMatchObject({ root: 'app-1' }); + await expect(loadLibConfig({} as never)).resolves.toMatchObject({ root: 'lib-2' }); + await expect(loadRspressConfig()).resolves.toMatchObject({ root: 'doc-3' }); +}); + +test('uses plugin-provided app config for automatic Rstest extends and keeps test config native', async () => { + state.configPath = configPath; + + const config = (await loadTestConfig({} as never)) as { extends?: unknown }; + expect(config).toMatchObject({ + name: 'test-1', + reporters: ['dot'], + }); + expect(config.extends).toBeDefined(); +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/explicit-rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/explicit-rstack.config.ts new file mode 100644 index 00000000..2beea713 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/explicit-rstack.config.ts @@ -0,0 +1,25 @@ +import { define } from 'rstack'; + +declare global { + // rslint-disable-next-line no-var + var __rstackExplicitAppModifierCalls: number | undefined; +} + +define.plugins([ + { + name: 'explicit-extends', + setup({ modifyConfig }) { + modifyConfig('app', (config) => { + globalThis.__rstackExplicitAppModifierCalls = + (globalThis.__rstackExplicitAppModifierCalls ?? 0) + 1; + return config; + }); + modifyConfig('test', (config) => ({ ...config, reporters: ['dot'] })); + }, + }, +]); + +define.app({}); +define.test({ + extends: {}, +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts new file mode 100644 index 00000000..4f915988 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts @@ -0,0 +1,26 @@ +import { define } from 'rstack'; + +let setupComplete = false; + +const createConfig = () => { + if (!setupComplete) { + throw new Error('plugin setup must run before config factories'); + } + return {}; +}; + +define.plugins([ + { + name: 'factory-order', + setup() { + setupComplete = true; + }, + }, +]); + +define.app(createConfig); +define.lib(createConfig); +define.doc(() => Promise.resolve(createConfig())); +define.test(createConfig); +define.lint(() => [createConfig()]); +define.fmt(createConfig); diff --git a/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts b/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts new file mode 100644 index 00000000..a726d990 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from 'rstack/test'; +import { setupFmtTest } from '../../cli/fmt/helpers.ts'; + +const { readProjectFile, runFmt, writeProjectFile } = setupFmtTest(); + +test('applies fmt modifiers after resolving the native config definition', () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +let setupComplete = false; + +define.plugins([ + { + name: 'fmt-modifier', + setup({ modifyConfig }) { + setupComplete = true; + modifyConfig('fmt', async (config) => ({ ...config, singleQuote: true })); + }, + }, +]); + +define.fmt(() => { + if (!setupComplete) { + throw new Error('plugin setup must run before config factories'); + } + return {}; +}); +`, + ); + writeProjectFile('index.ts', 'const message="hello"'); + + const result = runFmt(['index.ts']); + + expect(result.status).toBe(0); + expect(readProjectFile('index.ts')).toBe("const message = 'hello';\n"); +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/index.test.ts b/packages/rstack/tests/config/plugin-modifiers/index.test.ts new file mode 100644 index 00000000..b16163f7 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/index.test.ts @@ -0,0 +1,100 @@ +import path from 'node:path'; +import { afterEach, expect, test } from 'rstack/test'; +import { + applyRstackConfigModifiers, + getConfigState, + loadRstackConfig, +} from '../../../src/config.ts'; + +declare global { + // rslint-disable-next-line no-var + var __rstackPluginModifierSetups: number | undefined; +} + +const state = getConfigState(); +const configFilePath = path.join(import.meta.dirname, 'rstack.config.ts'); + +afterEach(() => { + delete state.invocation; + delete globalThis.__rstackPluginModifierSetups; + delete globalThis.__rstackPluginModifierContext; + delete globalThis.__rstackPluginModifierError; +}); + +test('propagates modifier errors', async () => { + const loaded = await loadRstackConfig({ configFilePath }); + globalThis.__rstackPluginModifierError = true; + + await expect(applyRstackConfigModifiers(loaded, 'app', {})).rejects.toThrow( + 'plugin modifier error', + ); +}); + +test('applies typed modifiers to native defaults in registration order', async () => { + const loaded = await loadRstackConfig({ configFilePath }); + + await expect(applyRstackConfigModifiers(loaded, 'app', {})).resolves.toEqual({ + setup: 1, + root: 'app-1', + }); + await expect(applyRstackConfigModifiers(loaded, 'lib', {})).resolves.toEqual({ + root: 'lib-1', + }); + await expect(applyRstackConfigModifiers(loaded, 'doc', {})).resolves.toEqual({ + root: 'doc-1', + }); + await expect(applyRstackConfigModifiers(loaded, 'test', {})).resolves.toEqual({ + name: 'test-1', + reporters: ['dot'], + }); + await expect(applyRstackConfigModifiers(loaded, 'lint', [])).resolves.toEqual([ + { name: 'lint-1' }, + ]); + await expect(applyRstackConfigModifiers(loaded, 'fmt', {})).resolves.toEqual({ + singleQuote: true, + }); + await expect(applyRstackConfigModifiers(loaded, 'staged', {})).resolves.toEqual({ + '*.ts': 'echo staged-1', + }); +}); + +test('initializes plugins again for each fresh config load', async () => { + state.invocation = { + cwd: '/invocation', + command: 'build', + args: ['--watch'], + configFilePath: null, + }; + + const first = await loadRstackConfig({ configFilePath }); + const second = await loadRstackConfig({ configFilePath }); + + await expect(applyRstackConfigModifiers(first, 'app', {})).resolves.toMatchObject({ + root: 'app-1', + }); + await expect(applyRstackConfigModifiers(second, 'app', {})).resolves.toMatchObject({ + root: 'app-2', + }); + expect(globalThis.__rstackPluginModifierContext).toEqual({ + cwd: '/invocation', + command: 'build', + args: ['--watch'], + configFilePath, + }); +}); + +test('uses the programmatic config cwd in plugin context', async () => { + const loaded = await loadRstackConfig({ + configFilePath, + cwd: import.meta.dirname, + }); + + await applyRstackConfigModifiers(loaded, 'app', {}); + + expect(globalThis.__rstackPluginModifierContext).toEqual({ + cwd: import.meta.dirname, + command: 'programmatic', + args: [], + configFilePath, + }); +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/lint.test.ts b/packages/rstack/tests/config/plugin-modifiers/lint.test.ts new file mode 100644 index 00000000..765624d1 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/lint.test.ts @@ -0,0 +1,17 @@ +import path from 'node:path'; +import { afterEach, expect, test } from 'rstack/test'; +import { getConfigState } from '../../../src/config.ts'; + +const state = getConfigState(); + +afterEach(() => { + delete state.configPath; +}); + +test('initializes plugins before resolving the lint config factory', async () => { + state.configPath = path.join(import.meta.dirname, 'factory-order-rstack.config.ts'); + + await expect(import('../../../src/rslintConfig.ts')).resolves.toMatchObject({ + default: [{}], + }); +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/projects-explicit-rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/projects-explicit-rstack.config.ts new file mode 100644 index 00000000..9cbdcf1c --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/projects-explicit-rstack.config.ts @@ -0,0 +1,25 @@ +import { define } from 'rstack'; + +declare global { + // rslint-disable-next-line no-var + var __rstackAllProjectsExplicitAppModifierCalls: number | undefined; +} + +define.plugins([ + { + name: 'all-projects-explicit', + setup({ modifyConfig }) { + modifyConfig('app', (config) => { + globalThis.__rstackAllProjectsExplicitAppModifierCalls = + (globalThis.__rstackAllProjectsExplicitAppModifierCalls ?? 0) + 1; + return config; + }); + modifyConfig('test', (config) => ({ ...config, reporters: ['dot'] })); + }, + }, +]); + +define.app({}); +define.test({ + projects: [{ name: 'explicit', extends: {} }], +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/rstack.config.ts new file mode 100644 index 00000000..484616bd --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/rstack.config.ts @@ -0,0 +1,49 @@ +import { define } from 'rstack'; + +declare global { + // rslint-disable-next-line no-var + var __rstackPluginModifierSetups: number | undefined; + // rslint-disable-next-line no-var + var __rstackPluginModifierContext: + | { cwd: string; command: string; args: readonly string[]; configFilePath: string | null } + | undefined; + // rslint-disable-next-line no-var + var __rstackPluginModifierError: boolean | undefined; +} + +define.plugins([ + { + name: 'config-modifiers', + setup({ context, modifyConfig }) { + globalThis.__rstackPluginModifierSetups = (globalThis.__rstackPluginModifierSetups ?? 0) + 1; + globalThis.__rstackPluginModifierContext = context; + const setup = globalThis.__rstackPluginModifierSetups; + + modifyConfig('app', (config) => { + (config as { setup?: number }).setup = setup; + }); + modifyConfig('app', (config) => + Promise.resolve({ + ...config, + root: `app-${(config as { setup?: number }).setup}`, + }), + ); + modifyConfig('app', () => { + if (globalThis.__rstackPluginModifierError) { + throw new Error('plugin modifier error'); + } + }); + + modifyConfig('lib', (config) => ({ ...config, root: `lib-${setup}` })); + modifyConfig('doc', (config) => ({ ...config, root: `doc-${setup}` })); + modifyConfig('test', (config) => ({ + ...config, + name: `test-${setup}`, + reporters: ['dot'], + })); + modifyConfig('lint', () => [{ name: `lint-${setup}` }] as never); + modifyConfig('fmt', (config) => ({ ...config, singleQuote: true })); + modifyConfig('staged', () => Promise.resolve({ '*.ts': `echo staged-${setup}` })); + }, + }, +]); diff --git a/packages/rstack/tests/config/plugin-modifiers/test-modifier-extends-rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/test-modifier-extends-rstack.config.ts new file mode 100644 index 00000000..9d515911 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/test-modifier-extends-rstack.config.ts @@ -0,0 +1,25 @@ +import { define } from 'rstack'; + +declare global { + // rslint-disable-next-line no-var + var __rstackTestModifierExtendsAppCalls: number | undefined; +} + +define.plugins([ + { + name: 'test-modifier-extends', + setup({ modifyConfig }) { + modifyConfig('app', (config) => { + globalThis.__rstackTestModifierExtendsAppCalls = + (globalThis.__rstackTestModifierExtendsAppCalls ?? 0) + 1; + return { ...config, root: 'automatic' }; + }); + modifyConfig('test', (config) => ({ + ...config, + extends: { root: 'test-modifier' }, + })); + }, + }, +]); + +define.test({}); diff --git a/packages/rstack/tests/pluginRuntime.test.ts b/packages/rstack/tests/pluginRuntime.test.ts new file mode 100644 index 00000000..50a8c987 --- /dev/null +++ b/packages/rstack/tests/pluginRuntime.test.ts @@ -0,0 +1,268 @@ +import { expect, test } from 'rstack/test'; +import type { RstackPlugins } from '../src/plugin.ts'; +import { createPluginRuntime } from '../src/pluginRuntime.ts'; + +const logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + success() {}, +}; + +test('flattens nested asynchronous plugins in declaration order and initializes them sequentially', async () => { + const events: string[] = []; + const runtime = await createPluginRuntime({ + plugins: [ + { + name: 'first', + async setup() { + events.push('first:start'); + await Promise.resolve(); + events.push('first:end'); + }, + }, + false, + Promise.resolve([ + { + name: 'second', + setup() { + events.push('second'); + }, + }, + [ + undefined, + { + name: 'third', + setup() { + events.push('third'); + }, + }, + ], + ]), + ], + context: { + cwd: '/project', + command: 'plugin-command', + args: ['--raw'], + configFilePath: '/project/rstack.config.ts', + }, + logger, + }); + + expect(events).toEqual(['first:start', 'first:end', 'second', 'third']); + expect(runtime.context).toEqual({ + cwd: '/project', + command: 'plugin-command', + args: ['--raw'], + configFilePath: '/project/rstack.config.ts', + }); +}); + +test('rejects invalid plugin objects and duplicate plugin names', async () => { + const options = { + context: { + cwd: '/project', + command: 'command', + args: [], + configFilePath: null, + }, + logger, + }; + + await expect( + createPluginRuntime({ + ...options, + plugins: [true] as unknown as RstackPlugins, + }), + ).rejects.toThrow('Invalid Rstack plugin'); + await expect(createPluginRuntime({ ...options, plugins: {} as RstackPlugins })).rejects.toThrow( + 'Invalid Rstack plugins', + ); + await expect( + createPluginRuntime({ + ...options, + plugins: [{ name: 'not valid', setup() {} }], + }), + ).rejects.toThrow('Invalid Rstack plugin name'); + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { name: 'duplicate', setup() {} }, + { name: 'duplicate', setup() {} }, + ], + }), + ).rejects.toThrow('Duplicate Rstack plugin'); +}); + +test('rejects invalid config modifier registrations', async () => { + const options = { + context: { + cwd: '/project', + command: 'command', + args: [], + configFilePath: null, + }, + logger, + }; + + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { + name: 'invalid-kind', + setup({ modifyConfig }) { + modifyConfig('unknown' as never, () => {}); + }, + }, + ], + }), + ).rejects.toThrow('Invalid Rstack config kind'); + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { + name: 'invalid-handler', + setup({ modifyConfig }) { + modifyConfig('app', undefined as never); + }, + }, + ], + }), + ).rejects.toThrow('Invalid Rstack app config modifier'); +}); + +test('validates every plugin before setup begins', async () => { + let setupRan = false; + + await expect( + createPluginRuntime({ + plugins: [ + { + name: 'valid', + setup() { + setupRan = true; + }, + }, + { name: 'invalid' } as never, + ], + context: { + cwd: '/project', + command: 'command', + args: [], + configFilePath: null, + }, + logger, + }), + ).rejects.toThrow('Expected a setup function'); + expect(setupRan).toBe(false); +}); + +test('rejects invalid, duplicate, and built-in command names', async () => { + const options = { + context: { + cwd: '/project', + command: 'command', + args: [], + configFilePath: null, + }, + logger, + }; + + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { + name: 'invalid-command', + setup({ addCommand }) { + addCommand({ name: 'not valid', handler() {} }); + }, + }, + ], + }), + ).rejects.toThrow('Invalid Rstack command name'); + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { + name: 'duplicate-command', + setup({ addCommand }) { + addCommand({ name: 'plugin-command', handler() {} }); + addCommand({ name: 'plugin-command', handler() {} }); + }, + }, + ], + }), + ).rejects.toThrow('Duplicate Rstack command'); + for (const name of [ + 'dev', + 'build', + 'preview', + 'lib', + 'doc', + 'test', + 'lint', + 'check', + 'fmt', + 'format', + 'staged', + 'setup', + ]) { + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { + name: `built-in-${name}`, + setup({ addCommand }) { + addCommand({ name, handler() {} }); + }, + }, + ], + }), + ).rejects.toThrow('conflicts with a built-in command'); + } +}); + +test('runs config modifiers after setup and keeps each modifier result', async () => { + const runtime = await createPluginRuntime({ + plugins: [ + { + name: 'modifier', + setup({ modifyConfig }) { + modifyConfig('app', (config) => { + config.root = '/first'; + }); + modifyConfig('app', (config) => ({ + ...config, + root: `${config.root}/second`, + })); + modifyConfig('app', (config) => + Promise.resolve({ + ...config, + root: `${config.root}/third`, + }), + ); + }, + }, + ], + context: { + cwd: '/project', + command: 'command', + args: [], + configFilePath: null, + }, + logger, + }); + + expect(runtime.hasConfigModifier('app')).toBe(true); + expect(runtime.hasConfigModifier('lib')).toBe(false); + await expect(runtime.applyConfigModifiers('app', {})).resolves.toMatchObject({ + root: '/first/second/third', + }); +}); diff --git a/packages/rstack/tests/types/resolution-bundler/index.ts b/packages/rstack/tests/types/resolution-bundler/index.ts index 8853c64e..242bb345 100644 --- a/packages/rstack/tests/types/resolution-bundler/index.ts +++ b/packages/rstack/tests/types/resolution-bundler/index.ts @@ -2,7 +2,14 @@ import 'rstack/test/globals'; import 'rstack/test/importMeta'; import 'rstack/types'; -import { define } from 'rstack'; +import { + define, + type FmtConfig, + type RstackConfigMap, + type RstackPlugin, + type RstackPlugins, + type StagedConfig, +} from 'rstack'; import { createRsbuild, defineConfig as defineAppConfig } from 'rstack/app'; import { loadRstackConfig, @@ -20,9 +27,25 @@ const lintConfig = defineLintConfig([]); const loadOptions: LoadRstackConfigOptions = { configFilePath: 'rstack.config.ts' }; const loadedConfig: Promise = loadRstackConfig(loadOptions); const configs: Configs = {}; +const plugin: RstackPlugin = { + name: 'example', + setup(api) { + api.addCommand({ name: 'example', handler: () => Promise.resolve() }); + api.modifyConfig('app', (config) => config); + api.modifyConfig('test', (config) => Promise.resolve(config)); + api.logger.info(api.context.command); + }, +}; +const plugins: RstackPlugins = [plugin, false, Promise.resolve([undefined, plugin])]; +const fmtConfig: FmtConfig = {}; +const stagedConfig: StagedConfig = {}; +const appConfigFromMap: RstackConfigMap['app'] = appConfig; void loadedConfig; void configs; +void fmtConfig; +void stagedConfig; +void appConfigFromMap; void createRsbuild({ config: appConfig }); define.app(appConfig); @@ -32,6 +55,7 @@ define.lint(({ js, ts }) => [js.configs.recommended, ts.configs.recommendedTypeC define.doc({}); define.test({}); define.staged({}); +define.plugins(plugins); importedTest('exposes the Rstest APIs', () => { importedExpect(true).toBe(true); diff --git a/packages/rstack/tests/types/resolution-nodenext/index.ts b/packages/rstack/tests/types/resolution-nodenext/index.ts index 8853c64e..242bb345 100644 --- a/packages/rstack/tests/types/resolution-nodenext/index.ts +++ b/packages/rstack/tests/types/resolution-nodenext/index.ts @@ -2,7 +2,14 @@ import 'rstack/test/globals'; import 'rstack/test/importMeta'; import 'rstack/types'; -import { define } from 'rstack'; +import { + define, + type FmtConfig, + type RstackConfigMap, + type RstackPlugin, + type RstackPlugins, + type StagedConfig, +} from 'rstack'; import { createRsbuild, defineConfig as defineAppConfig } from 'rstack/app'; import { loadRstackConfig, @@ -20,9 +27,25 @@ const lintConfig = defineLintConfig([]); const loadOptions: LoadRstackConfigOptions = { configFilePath: 'rstack.config.ts' }; const loadedConfig: Promise = loadRstackConfig(loadOptions); const configs: Configs = {}; +const plugin: RstackPlugin = { + name: 'example', + setup(api) { + api.addCommand({ name: 'example', handler: () => Promise.resolve() }); + api.modifyConfig('app', (config) => config); + api.modifyConfig('test', (config) => Promise.resolve(config)); + api.logger.info(api.context.command); + }, +}; +const plugins: RstackPlugins = [plugin, false, Promise.resolve([undefined, plugin])]; +const fmtConfig: FmtConfig = {}; +const stagedConfig: StagedConfig = {}; +const appConfigFromMap: RstackConfigMap['app'] = appConfig; void loadedConfig; void configs; +void fmtConfig; +void stagedConfig; +void appConfigFromMap; void createRsbuild({ config: appConfig }); define.app(appConfig); @@ -32,6 +55,7 @@ define.lint(({ js, ts }) => [js.configs.recommended, ts.configs.recommendedTypeC define.doc({}); define.test({}); define.staged({}); +define.plugins(plugins); importedTest('exposes the Rstest APIs', () => { importedExpect(true).toBe(true); diff --git a/website/docs/en/guide/_meta.json b/website/docs/en/guide/_meta.json index 630bde07..359c36d3 100644 --- a/website/docs/en/guide/_meta.json +++ b/website/docs/en/guide/_meta.json @@ -13,6 +13,11 @@ "name": "configuration", "label": "Configuration" }, + { + "type": "file", + "name": "plugins", + "label": "Plugins" + }, { "type": "file", "name": "ai", diff --git a/website/docs/en/guide/api-reference.mdx b/website/docs/en/guide/api-reference.mdx index dd2eba08..a3b5ea1a 100644 --- a/website/docs/en/guide/api-reference.mdx +++ b/website/docs/en/guide/api-reference.mdx @@ -6,7 +6,7 @@ Rstack CLI provides a unified configuration API and re-exports the public APIs o | Import path | Contents | Use case | | ------------------------ | ------------------------------------------------- | --------------------------------------- | -| `rstack` | Rstack CLI configuration API | Register tool configurations | +| `rstack` | Rstack CLI configuration and plugin APIs | Configure and extend Rstack CLI | | `rstack/app` | Public APIs from `@rsbuild/core` | Build applications and extend Rsbuild | | `rstack/lib` | Public APIs from `@rslib/core` | Build libraries and extend Rslib | | `rstack/test` | Public APIs from `@rstest/core` | Write tests and configure test projects | @@ -19,7 +19,9 @@ Rstack CLI provides a unified configuration API and re-exports the public APIs o ### `define` -Import `define` from `rstack` to register tool configurations in `rstack.config.ts`; see [Configuration APIs](./configuration#configuration-apis) for details. +Import `define` from `rstack` to register tool configurations and plugins in `rstack.config.ts`; see [Configuration APIs](./configuration#configuration-apis) and [Plugins](./plugins) for details. + +The main entry point also exports the public plugin types, including `RstackPlugin`, `RstackPluginAPI`, `RstackPluginContext`, `RstackConfigMap`, `FmtConfig`, and `StagedConfig`. ## Re-exports diff --git a/website/docs/en/guide/configuration.mdx b/website/docs/en/guide/configuration.mdx index 9b3d8283..3e353a66 100644 --- a/website/docs/en/guide/configuration.mdx +++ b/website/docs/en/guide/configuration.mdx @@ -29,7 +29,7 @@ define.fmt({ }); ``` -The configuration file does not require a default export. Each `define.*()` API can be called at most once; defining the same configuration type more than once throws an error. +The configuration file does not require a default export. Each `define.*()` API can be called at most once; defining the same configuration type more than once throws an error. Use [`define.plugins()`](./plugins#registering-plugins) when an external package needs to extend Rstack itself or contribute to several tool configurations. By default, Rstack CLI looks for a file with one of the following names: @@ -65,15 +65,20 @@ define.app(async () => { Configuration options follow the formats of the underlying tools. When using APIs and helpers that Rstack CLI re-exports, prefer the `rstack/app`, `rstack/lib`, `rstack/test`, and `rstack/lint` entry points. -| API | Tool | Commands | -| ----------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| [`define.app()`](#define-app) | [Rsbuild](https://rsbuild.rs/config/) | [`rs dev`](./cli/dev), [`rs build`](./cli/build), [`rs preview`](./cli/preview) | -| [`define.lib()`](#define-lib) | [Rslib](https://rslib.rs/config/) | [`rs lib`](./cli/lib) | -| [`define.doc()`](#define-doc) | [Rspress](https://rspress.rs/api/config/config-basic) | [`rs doc`](./cli/doc) | -| [`define.test()`](#define-test) | [Rstest](https://rstest.rs/config/) | [`rs test`](./cli/test) | -| [`define.lint()`](#define-lint) | [Rslint](https://rslint.rs/config/) | [`rs lint`](./cli/lint) | -| [`define.fmt()`](#define-fmt) | [Prettier](https://prettier.io/docs/options) | [`rs fmt`](./cli/fmt) | -| [`define.staged()`](#define-staged) | [lint-staged](https://github.com/lint-staged/lint-staged#configuration) | [`rs staged`](./cli/staged) | +| API | Tool | Commands | +| ------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [`define.app()`](#define-app) | [Rsbuild](https://rsbuild.rs/config/) | [`rs dev`](./cli/dev), [`rs build`](./cli/build), [`rs preview`](./cli/preview) | +| [`define.lib()`](#define-lib) | [Rslib](https://rslib.rs/config/) | [`rs lib`](./cli/lib) | +| [`define.doc()`](#define-doc) | [Rspress](https://rspress.rs/api/config/config-basic) | [`rs doc`](./cli/doc) | +| [`define.test()`](#define-test) | [Rstest](https://rstest.rs/config/) | [`rs test`](./cli/test) | +| [`define.lint()`](#define-lint) | [Rslint](https://rslint.rs/config/) | [`rs lint`](./cli/lint) | +| [`define.fmt()`](#define-fmt) | [Prettier](https://prettier.io/docs/options) | [`rs fmt`](./cli/fmt) | +| [`define.staged()`](#define-staged) | [lint-staged](https://github.com/lint-staged/lint-staged#configuration) | [`rs staged`](./cli/staged) | +| [`define.plugins()`](#define-plugins) | Rstack CLI | Plugin commands and configured tool commands | + +### `define.plugins()` \{#define-plugins} + +Registers explicit Rstack CLI plugins. Plugins can add top-level commands and transform the native configurations listed below. See [Plugins](./plugins) for the API, lifecycle, and examples. ### `define.app()` \{#define-app} diff --git a/website/docs/en/guide/plugins.mdx b/website/docs/en/guide/plugins.mdx new file mode 100644 index 00000000..c08ef3b5 --- /dev/null +++ b/website/docs/en/guide/plugins.mdx @@ -0,0 +1,194 @@ +# Plugins \{#plugins} + +Rstack plugins extend the Rstack CLI as a whole. A plugin can register a new `rs` command and transform the native configuration passed to any tool that Rstack orchestrates. + +This API complements the extension systems of the underlying tools. Use an Rsbuild or Rslib plugin for build hooks, an Rstest reporter for test results, an Rslint plugin or its programmatic API for lint behavior, and an Rspress plugin for documentation hooks. Use an Rstack plugin when one package needs to register a command or contribute to several of those tools from the shared Rstack configuration. + +## Registering plugins \{#registering-plugins} + +Register plugins explicitly with `define.plugins()` in `rstack.config.ts`: + +```ts title="rstack.config.ts" +import { pluginAcme } from '@acme/rstack-plugin'; +import { define } from 'rstack'; + +define.plugins([pluginAcme()]); +``` + +Rstack does not scan dependencies or discover plugins by package name. Plugin entries may be nested, asynchronous, or conditional; falsy entries are ignored: + +```ts title="rstack.config.ts" +import { define } from 'rstack'; + +define.plugins([ + import('@acme/rstack-plugin').then(({ pluginAcme }) => pluginAcme()), + process.env.CI ? import('@acme/rstack-ci').then(({ pluginCI }) => pluginCI()) : false, +]); +``` + +Like the other `define.*()` APIs, `define.plugins()` can be called at most once per configuration file. + +## Creating a plugin \{#creating-a-plugin} + +A plugin has a unique name and a `setup` function. The setup function receives the Rstack plugin API: + +```ts title="src/pluginAcme.ts" +import type { RstackPlugin } from 'rstack'; + +export const pluginAcme = (): RstackPlugin => ({ + name: 'acme', + setup(api) { + api.addCommand({ + name: 'acme-info', + async handler(args) { + await printWorkspaceInfo(api.context.cwd, args); + }, + }); + + api.modifyConfig('app', (config) => ({ + ...config, + source: { + ...config.source, + define: { + ...config.source?.define, + ACME_CHANNEL: JSON.stringify('stable'), + }, + }, + })); + + api.modifyConfig('test', (config) => ({ + ...config, + reporters: [...(config.reporters ?? []), createAcmeReporter()], + })); + }, +}); +``` + +The package owns `printWorkspaceInfo` and `createAcmeReporter`; Rstack only provides registration and configuration composition. `RstackPlugin`, `RstackPluginAPI`, `RstackPluginContext`, `RstackConfigMap`, `FmtConfig`, `StagedConfig`, and the other supporting types are exported from `rstack`. + +## Plugin lifecycle \{#plugin-lifecycle} + +For a command that loads project configuration, Rstack performs these steps in order: + +1. Load `rstack.config.*` and capture the registered tool configurations and plugins. +2. Flatten plugin entries in declaration order. +3. Validate every plugin, then run each `setup` function sequentially. +4. Resolve the native configuration factory required by the selected command. +5. Run matching configuration modifiers sequentially. +6. Apply Rstack's internal configuration and start the underlying tool. + +A modifier may mutate its input, return a replacement, or return a promise. When it returns `undefined`, Rstack keeps the current configuration. Errors from setup, command handlers, and modifiers stop the invocation. + +Root help and version output do not load project configuration. Other commands initialize plugins only when they load a configuration. + +## Registering commands \{#registering-commands} + +Use `addCommand()` to add a top-level `rs` command: + +```ts +import type { RstackPlugin } from 'rstack'; + +export const pluginInspect = (): RstackPlugin => ({ + name: 'inspect', + setup({ addCommand, context, logger }) { + addCommand({ + name: 'inspect-workspace', + async handler(args) { + logger.info(`Inspecting ${context.cwd}`); + await inspectWorkspace(args); + }, + }); + }, +}); +``` + +Command names use lowercase kebab case. A plugin cannot replace a built-in command or alias, and duplicate command names are rejected. + +The command handler receives the arguments after the command name. Global `--config` or `-c` options parsed by Rstack are removed first. The plugin owns command-specific parsing and help output. + +The read-only `context` exposes: + +- `cwd`: the working directory where Rstack was invoked. +- `command`: the selected command name. +- `args`: the same command arguments available during setup. +- `configFilePath`: the resolved Rstack configuration path, or `null` when no file was found. + +## Modifying tool configurations \{#modifying-tool-configurations} + +Use `modifyConfig()` to contribute directly to a tool's native configuration. The `kind` selects the configuration type and keeps the handler typed. + +### Configuration kinds \{#configuration-kinds} + +| Kind | Native configuration | Initialized by | +| -------- | -------------------- | ---------------------------------- | +| `app` | Rsbuild | `rs dev`, `rs build`, `rs preview` | +| `lib` | Rslib | `rs lib` | +| `doc` | Rspress | `rs doc` | +| `test` | Rstest | `rs test` | +| `lint` | Rslint | `rs lint` | +| `fmt` | Rstack/Prettier | `rs fmt` | +| `staged` | lint-staged | `rs staged` | + +Tool configuration factories resolve before their matching modifiers. Existing command-line overlays still run in the underlying tool afterward, so explicit CLI options keep their normal precedence. + +### Mutation and replacement \{#mutation-and-replacement} + +Modifiers run in registration order. Both mutation and replacement are supported: + +```ts +setup({ modifyConfig }) { + modifyConfig('app', (config) => { + config.plugins ??= []; + config.plugins.push(createRsbuildPlugin()); + }); + + modifyConfig('lint', async (config) => [ + ...config, + await createRslintConfig(), + ]); +} +``` + +When no `define.*()` value exists, modifiers receive the native empty configuration: an empty array for `lint` and an empty object for the other kinds. `staged` keeps its missing-configuration error unless a staged modifier is registered. + +### Rstest inheritance \{#rstest-inheritance} + +When Rstest uses Rstack's automatic application or library inheritance, Rstack applies the matching `app` or `lib` modifiers before constructing the Rstest `extends` value. It then applies `test` modifiers after automatic inheritance. This lets one plugin contribute native build configuration and an Rstest reporter without applying either contribution twice. + +Explicit Rstest `extends` values continue to opt out of automatic application or library inheritance. + +## Underlying tool extensions \{#underlying-tool-extensions} + +Configuration modifiers do not replace native extension APIs. They contribute native values before the tool starts: + +- Add Rsbuild or Rslib plugins through the `plugins` field and use their hook APIs for build lifecycle work. +- Add Rstest reporters through `reporters`, or use Rstest's programmatic API when a separate process needs structured test results. +- Add Rslint flat-config entries and plugins through `lint`, or use the Rslint programmatic API for direct lint execution. +- Add Rspress plugins through the Rspress configuration. +- Add Prettier plugins through `fmt` and lint-staged tasks through `staged`. + +Build hooks, test results, lint results, and documentation lifecycle events remain owned by the underlying tools. Rstack does not normalize those results into a cross-tool lifecycle. + +## Configuration loading and reloads \{#configuration-loading-and-reloads} + +Plugins initialize once for each loaded Rstack configuration. Rsbuild's configuration watch entries are added after plugin modifiers; when Rsbuild reloads a changed configuration, Rstack loads it again and initializes a fresh plugin set. + +Registration is local to the selected configuration file. Rstack does not merge plugins from another workspace root or a parent configuration. + +## Compatibility \{#compatibility} + +The exported Rstack plugin types and setup contract follow Rstack's semantic-versioning policy. Plugin packages should declare a peer dependency range for the Rstack versions they support. + +Configuration values remain native to the integrated tool versions. A plugin that uses a particular Rsbuild, Rslib, Rstest, Rslint, Rspress, Prettier, or lint-staged feature should also follow that tool's compatibility guidance. + +## Non-goals \{#non-goals} + +The initial SPI intentionally does not provide: + +- automatic package discovery or scanning; +- replacement of built-in commands; +- global before/after command hooks or middleware; +- plugin ordering descriptors or a plugin-to-plugin service registry; +- a cross-tool result or event abstraction. + +Use the native tool APIs for domain-specific lifecycle and result processing. diff --git a/website/docs/zh/guide/_meta.json b/website/docs/zh/guide/_meta.json index 518da9ca..b5f44f84 100644 --- a/website/docs/zh/guide/_meta.json +++ b/website/docs/zh/guide/_meta.json @@ -13,6 +13,11 @@ "name": "configuration", "label": "配置" }, + { + "type": "file", + "name": "plugins", + "label": "插件" + }, { "type": "file", "name": "ai", diff --git a/website/docs/zh/guide/api-reference.mdx b/website/docs/zh/guide/api-reference.mdx index d61ebb1b..5b516db8 100644 --- a/website/docs/zh/guide/api-reference.mdx +++ b/website/docs/zh/guide/api-reference.mdx @@ -6,7 +6,7 @@ Rstack CLI 提供统一的配置 API,并通过专用子路径重导出 Rsbuild | 导入路径 | 内容 | 使用场景 | | ------------------------ | ----------------------------------------- | ------------------------ | -| `rstack` | Rstack CLI 配置 API | 注册各项工具配置 | +| `rstack` | Rstack CLI 配置与插件 API | 配置并扩展 Rstack CLI | | `rstack/app` | `@rsbuild/core` 的公开 API | 构建应用及扩展 Rsbuild | | `rstack/lib` | `@rslib/core` 的公开 API | 构建库及扩展 Rslib | | `rstack/test` | `@rstest/core` 的公开 API | 编写测试及配置测试项目 | @@ -19,7 +19,9 @@ Rstack CLI 提供统一的配置 API,并通过专用子路径重导出 Rsbuild ### `define` -从 `rstack` 导入 `define`,用于在 `rstack.config.ts` 中注册各项工具配置;详细用法请参阅[配置 API](./configuration#configuration-apis)。 +从 `rstack` 导入 `define`,用于在 `rstack.config.ts` 中注册各项工具配置和插件;详细用法请参阅[配置 API](./configuration#configuration-apis)和[插件](./plugins)。 + +主入口还会导出公开插件类型,包括 `RstackPlugin`、`RstackPluginAPI`、`RstackPluginContext`、`RstackConfigMap`、`FmtConfig` 和 `StagedConfig`。 ## 重导出 \{#re-exports} diff --git a/website/docs/zh/guide/configuration.mdx b/website/docs/zh/guide/configuration.mdx index dc3d8fde..6955e569 100644 --- a/website/docs/zh/guide/configuration.mdx +++ b/website/docs/zh/guide/configuration.mdx @@ -29,7 +29,7 @@ define.fmt({ }); ``` -配置文件无需默认导出。每个 `define.*()` API 最多调用一次;重复定义同一类型的配置会抛出错误。 +配置文件无需默认导出。每个 `define.*()` API 最多调用一次;重复定义同一类型的配置会抛出错误。当外部包需要扩展 Rstack 本身或同时贡献多项工具配置时,请使用 [`define.plugins()`](./plugins#registering-plugins)。 Rstack CLI 默认会查找使用以下任一文件名的配置文件: @@ -65,15 +65,20 @@ define.app(async () => { 各 API 沿用底层工具的配置格式。使用 Rstack CLI 已重导出的 API 和辅助函数时,推荐从 `rstack/app`、`rstack/lib`、`rstack/test` 和 `rstack/lint` 入口导入。 -| API | 底层工具 | 对应命令 | -| ----------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| [`define.app()`](#define-app) | [Rsbuild](https://rsbuild.rs/zh/config/) | [`rs dev`](./cli/dev)、[`rs build`](./cli/build)、[`rs preview`](./cli/preview) | -| [`define.lib()`](#define-lib) | [Rslib](https://rslib.rs/zh/config/) | [`rs lib`](./cli/lib) | -| [`define.doc()`](#define-doc) | [Rspress](https://rspress.rs/zh/api/config/config-basic) | [`rs doc`](./cli/doc) | -| [`define.test()`](#define-test) | [Rstest](https://rstest.rs/zh/config/) | [`rs test`](./cli/test) | -| [`define.lint()`](#define-lint) | [Rslint](https://rslint.rs/config/) | [`rs lint`](./cli/lint) | -| [`define.fmt()`](#define-fmt) | [Prettier](https://prettier.io/docs/options) | [`rs fmt`](./cli/fmt) | -| [`define.staged()`](#define-staged) | [lint-staged](https://github.com/lint-staged/lint-staged#configuration) | [`rs staged`](./cli/staged) | +| API | 底层工具 | 对应命令 | +| ------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [`define.app()`](#define-app) | [Rsbuild](https://rsbuild.rs/zh/config/) | [`rs dev`](./cli/dev)、[`rs build`](./cli/build)、[`rs preview`](./cli/preview) | +| [`define.lib()`](#define-lib) | [Rslib](https://rslib.rs/zh/config/) | [`rs lib`](./cli/lib) | +| [`define.doc()`](#define-doc) | [Rspress](https://rspress.rs/zh/api/config/config-basic) | [`rs doc`](./cli/doc) | +| [`define.test()`](#define-test) | [Rstest](https://rstest.rs/zh/config/) | [`rs test`](./cli/test) | +| [`define.lint()`](#define-lint) | [Rslint](https://rslint.rs/config/) | [`rs lint`](./cli/lint) | +| [`define.fmt()`](#define-fmt) | [Prettier](https://prettier.io/docs/options) | [`rs fmt`](./cli/fmt) | +| [`define.staged()`](#define-staged) | [lint-staged](https://github.com/lint-staged/lint-staged#configuration) | [`rs staged`](./cli/staged) | +| [`define.plugins()`](#define-plugins) | Rstack CLI | 插件命令及已配置的工具命令 | + +### `define.plugins()` \{#define-plugins} + +显式注册 Rstack CLI 插件。插件可以添加顶层命令,也可以转换下方列出的原生工具配置。API、生命周期和示例请参阅[插件](./plugins)指南。 ### `define.app()` \{#define-app} diff --git a/website/docs/zh/guide/plugins.mdx b/website/docs/zh/guide/plugins.mdx new file mode 100644 index 00000000..84b7f2b8 --- /dev/null +++ b/website/docs/zh/guide/plugins.mdx @@ -0,0 +1,194 @@ +# 插件 \{#plugins} + +Rstack 插件用于扩展整个 Rstack CLI。插件可以注册新的 `rs` 命令,也可以转换 Rstack 所编排工具的原生配置。 + +该 API 是底层工具扩展机制的补充。构建生命周期请使用 Rsbuild 或 Rslib 插件,测试结果请使用 Rstest reporter,代码检查行为请使用 Rslint 插件或其编程式 API,文档生命周期请使用 Rspress 插件。当一个包需要从统一的 Rstack 配置中注册命令,或同时为多个工具提供配置时,请使用 Rstack 插件。 + +## 注册插件 \{#registering-plugins} + +在 `rstack.config.ts` 中通过 `define.plugins()` 显式注册插件: + +```ts title="rstack.config.ts" +import { pluginAcme } from '@acme/rstack-plugin'; +import { define } from 'rstack'; + +define.plugins([pluginAcme()]); +``` + +Rstack 不会扫描依赖,也不会根据包名自动发现插件。插件条目支持嵌套、异步和条件形式;假值条目会被忽略: + +```ts title="rstack.config.ts" +import { define } from 'rstack'; + +define.plugins([ + import('@acme/rstack-plugin').then(({ pluginAcme }) => pluginAcme()), + process.env.CI ? import('@acme/rstack-ci').then(({ pluginCI }) => pluginCI()) : false, +]); +``` + +与其他 `define.*()` API 相同,每份配置文件最多调用一次 `define.plugins()`。 + +## 创建插件 \{#creating-a-plugin} + +插件包含唯一名称和一个 `setup` 函数。setup 函数会接收 Rstack 插件 API: + +```ts title="src/pluginAcme.ts" +import type { RstackPlugin } from 'rstack'; + +export const pluginAcme = (): RstackPlugin => ({ + name: 'acme', + setup(api) { + api.addCommand({ + name: 'acme-info', + async handler(args) { + await printWorkspaceInfo(api.context.cwd, args); + }, + }); + + api.modifyConfig('app', (config) => ({ + ...config, + source: { + ...config.source, + define: { + ...config.source?.define, + ACME_CHANNEL: JSON.stringify('stable'), + }, + }, + })); + + api.modifyConfig('test', (config) => ({ + ...config, + reporters: [...(config.reporters ?? []), createAcmeReporter()], + })); + }, +}); +``` + +`printWorkspaceInfo` 和 `createAcmeReporter` 由插件包自身实现;Rstack 只负责注册与配置组合。`RstackPlugin`、`RstackPluginAPI`、`RstackPluginContext`、`RstackConfigMap`、`FmtConfig`、`StagedConfig` 及其他相关类型均从 `rstack` 导出。 + +## 插件生命周期 \{#plugin-lifecycle} + +对于需要加载项目配置的命令,Rstack 按以下顺序执行: + +1. 加载 `rstack.config.*`,并捕获已注册的工具配置和插件。 +2. 按声明顺序展开插件条目。 +3. 验证全部插件,然后依次执行每个 `setup` 函数。 +4. 解析当前命令所需的原生配置函数。 +5. 依次执行匹配的配置修改器。 +6. 应用 Rstack 内部配置,并启动底层工具。 + +修改器可以修改输入对象、返回替换值,也可以返回 Promise。返回 `undefined` 时,Rstack 会保留当前配置。setup、命令处理器或修改器抛出的错误都会终止本次调用。 + +根帮助和版本输出不会加载项目配置。其他命令仅在加载配置时初始化插件。 + +## 注册命令 \{#registering-commands} + +使用 `addCommand()` 添加顶层 `rs` 命令: + +```ts +import type { RstackPlugin } from 'rstack'; + +export const pluginInspect = (): RstackPlugin => ({ + name: 'inspect', + setup({ addCommand, context, logger }) { + addCommand({ + name: 'inspect-workspace', + async handler(args) { + logger.info(`Inspecting ${context.cwd}`); + await inspectWorkspace(args); + }, + }); + }, +}); +``` + +命令名使用小写 kebab-case。插件不能替换内置命令或别名,重复的命令名也会被拒绝。 + +命令处理器接收命令名之后的参数。Rstack 会先移除已经解析的全局 `--config` 或 `-c` 选项。插件负责命令专属的参数解析和帮助输出。 + +只读的 `context` 提供: + +- `cwd`:调用 Rstack 时的工作目录。 +- `command`:当前选中的命令名。 +- `args`:setup 阶段可用的同一组命令参数。 +- `configFilePath`:解析后的 Rstack 配置路径;未找到配置文件时为 `null`。 + +## 修改工具配置 \{#modifying-tool-configurations} + +使用 `modifyConfig()` 直接贡献底层工具的原生配置。`kind` 用于选择配置类型,并为处理器保留对应的类型信息。 + +### 配置类型 \{#configuration-kinds} + +| Kind | 原生配置 | 初始化命令 | +| -------- | --------------- | ---------------------------------- | +| `app` | Rsbuild | `rs dev`、`rs build`、`rs preview` | +| `lib` | Rslib | `rs lib` | +| `doc` | Rspress | `rs doc` | +| `test` | Rstest | `rs test` | +| `lint` | Rslint | `rs lint` | +| `fmt` | Rstack/Prettier | `rs fmt` | +| `staged` | lint-staged | `rs staged` | + +匹配的工具配置函数会先解析,再执行对应的修改器。底层工具现有的命令行覆盖仍会在之后执行,因此显式 CLI 选项保持原有优先级。 + +### 修改与替换 \{#mutation-and-replacement} + +修改器按注册顺序执行,同时支持修改和替换: + +```ts +setup({ modifyConfig }) { + modifyConfig('app', (config) => { + config.plugins ??= []; + config.plugins.push(createRsbuildPlugin()); + }); + + modifyConfig('lint', async (config) => [ + ...config, + await createRslintConfig(), + ]); +} +``` + +未定义对应的 `define.*()` 值时,修改器会收到原生的空配置:`lint` 为一个空数组,其他类型为空对象。`staged` 仍会保留缺少配置的错误,除非注册了 staged 修改器。 + +### Rstest 继承 \{#rstest-inheritance} + +当 Rstest 使用 Rstack 的应用或库自动继承时,Rstack 会先应用匹配的 `app` 或 `lib` 修改器,再构造 Rstest 的 `extends` 值,最后在自动继承之后应用 `test` 修改器。因此,同一个插件既可以贡献原生构建配置,也可以贡献 Rstest reporter,而不会重复应用任一配置。 + +显式设置 Rstest `extends` 时,仍会退出应用或库的自动继承。 + +## 底层工具扩展 \{#underlying-tool-extensions} + +配置修改器不会替代原生扩展 API。它们只是在工具启动前贡献原生配置值: + +- 通过 `plugins` 字段添加 Rsbuild 或 Rslib 插件,并使用其 hook API 处理构建生命周期。 +- 通过 `reporters` 添加 Rstest reporter;独立进程需要结构化测试结果时,请使用 Rstest 编程式 API。 +- 通过 `lint` 添加 Rslint flat config 条目和插件;需要直接执行代码检查时,请使用 Rslint 编程式 API。 +- 通过 Rspress 配置添加 Rspress 插件。 +- 通过 `fmt` 添加 Prettier 插件,通过 `staged` 添加 lint-staged 任务。 + +构建 hook、测试结果、代码检查结果和文档生命周期事件仍由底层工具负责。Rstack 不会将这些结果规范化为跨工具生命周期。 + +## 配置加载与重载 \{#configuration-loading-and-reloads} + +每次加载 Rstack 配置时,插件只初始化一次。Rsbuild 的配置监听条目会在插件修改器之后添加;Rsbuild 因配置变更而重载时,Rstack 会重新加载配置并初始化一组新的插件。 + +注册只作用于当前选中的配置文件。Rstack 不会合并其他 workspace 根目录或父级配置中的插件。 + +## 兼容性 \{#compatibility} + +导出的 Rstack 插件类型和 setup 契约遵循 Rstack 的语义化版本策略。插件包应为其支持的 Rstack 版本声明 peer dependency 范围。 + +配置值仍是集成工具版本的原生类型。插件使用特定 Rsbuild、Rslib、Rstest、Rslint、Rspress、Prettier 或 lint-staged 功能时,也应遵循对应工具的兼容性说明。 + +## 非目标 \{#non-goals} + +初始 SPI 刻意不提供: + +- 自动发现或扫描依赖包; +- 替换内置命令; +- 全局命令前后 hook 或中间件; +- 插件排序描述符或插件间服务注册表; +- 跨工具的结果或事件抽象。 + +领域专属的生命周期和结果处理应使用底层工具的原生 API。