diff --git a/e2e/cases/output/clean-dist-path/index.test.ts b/e2e/cases/output/clean-dist-path/index.test.ts index ebde17e50c..8838e51b6d 100644 --- a/e2e/cases/output/clean-dist-path/index.test.ts +++ b/e2e/cases/output/clean-dist-path/index.test.ts @@ -6,6 +6,7 @@ import fse from 'fs-extra'; const cwd = __dirname; const testDistFile = join(cwd, 'dist/test.json'); +const testDeepDistFile = join(cwd, 'dist/foo/bar/test.json'); test('should clean dist path by default', async () => { await fse.outputFile(testDistFile, `{ "test": 1 }`); @@ -60,3 +61,26 @@ test('should allow to disable cleanDistPath', async () => { fs.rmSync(testDistFile, { force: true }); }); + +test('should allow to use `cleanDistPath.keep` to keep some files', async () => { + await fse.outputFile(testDistFile, `{ "test": 1 }`); + await fse.outputFile(testDeepDistFile, `{ "test": 1 }`); + + await build({ + cwd, + rsbuildConfig: { + output: { + cleanDistPath: { + keep: [/dist[\\/]test.json/, /dist[\\/]foo[\\/]bar[\\/]test.json/], + }, + }, + }, + }); + + expect(fs.existsSync(testDistFile)).toBeTruthy(); + expect(fs.existsSync(testDeepDistFile)).toBeTruthy(); + + await build({ cwd }); + expect(fs.existsSync(testDistFile)).toBeFalsy(); + expect(fs.existsSync(testDeepDistFile)).toBeFalsy(); +}); diff --git a/packages/core/src/helpers/fs.ts b/packages/core/src/helpers/fs.ts index a6f54cf404..d3b37ea2d8 100644 --- a/packages/core/src/helpers/fs.ts +++ b/packages/core/src/helpers/fs.ts @@ -63,18 +63,37 @@ export async function fileExistsByCompilation( }); } -export async function emptyDir(dir: string): Promise { - if (!(await pathExists(dir))) { +export async function emptyDir( + dir: string, + keep: RegExp[] = [], + checkExists = true, +): Promise { + if (checkExists && !(await pathExists(dir))) { return; } try { - for (const file of await fs.promises.readdir(dir)) { - await fs.promises.rm(path.resolve(dir, file), { - recursive: true, - force: true, - }); - } + const entries = await fs.promises.readdir(dir, { + withFileTypes: true, + }); + + await Promise.all( + entries.map(async (entry) => { + const fullPath = path.resolve(dir, entry.name); + if (keep.some((reg) => reg.test(fullPath))) { + return; + } + + if (entry.isDirectory()) { + await emptyDir(fullPath, keep, false); + if (!keep.length) { + await fs.promises.rmdir(fullPath); + } + } else { + await fs.promises.unlink(fullPath); + } + }), + ); } catch (err) { logger.debug(`Failed to empty dir: ${dir}`); logger.debug(err); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 12a5c4688e..83becdfc38 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,6 +44,8 @@ export type { Charset, ClientConfig, CliShortcut, + CleanDistPath, + CleanDistPathObject, ConfigChain, ConfigChainWithContext, ConsoleType, diff --git a/packages/core/src/plugins/cleanOutput.ts b/packages/core/src/plugins/cleanOutput.ts index f06f7b3d99..0543969230 100644 --- a/packages/core/src/plugins/cleanOutput.ts +++ b/packages/core/src/plugins/cleanOutput.ts @@ -1,8 +1,13 @@ import { join, sep } from 'node:path'; import { RSBUILD_OUTPUTS_PATH } from '../constants'; -import { color, dedupeNestedPaths, emptyDir } from '../helpers'; +import { color, emptyDir } from '../helpers'; import { logger } from '../logger'; -import type { EnvironmentContext, RsbuildPlugin } from '../types'; +import type { + CleanDistPath, + CleanDistPathObject, + EnvironmentContext, + RsbuildPlugin, +} from '../types'; const addTrailingSep = (dir: string) => (dir.endsWith(sep) ? dir : dir + sep); @@ -12,51 +17,87 @@ const isStrictSubdir = (parent: string, child: string) => { return parentDir !== childDir && childDir.startsWith(parentDir); }; +const normalizeCleanDistPath = ( + userOptions: CleanDistPath, +): CleanDistPathObject => { + const defaultOptions: CleanDistPathObject = { + enable: 'auto', + }; + + if (typeof userOptions === 'boolean' || userOptions === 'auto') { + return { + ...defaultOptions, + enable: userOptions, + }; + } + + return { + ...defaultOptions, + ...userOptions, + }; +}; + +type PathInfo = { + path: string; + keep?: RegExp[]; +}; + export const pluginCleanOutput = (): RsbuildPlugin => ({ name: 'rsbuild:clean-output', setup(api) { - // should clean rsbuild outputs, such as inspect files - const getRsbuildCleanPath = () => { + // clean Rsbuild outputs files, such as the inspected config files + const getRsbuildOutputPath = (): PathInfo | undefined => { const { rootPath, distPath } = api.context; const config = api.getNormalizedConfig(); - const cleanPath = join(distPath, RSBUILD_OUTPUTS_PATH); - - const { cleanDistPath } = config.output; + const targetPath = join(distPath, RSBUILD_OUTPUTS_PATH); + const { enable } = normalizeCleanDistPath(config.output.cleanDistPath); if ( - cleanDistPath === true || - (cleanDistPath === 'auto' && isStrictSubdir(rootPath, cleanPath)) + enable === true || + (enable === 'auto' && isStrictSubdir(rootPath, targetPath)) ) { - return cleanPath; + return { + path: targetPath, + }; } return undefined; }; - const getCleanPath = (environment: EnvironmentContext) => { + const getPathInfo = ( + environment: EnvironmentContext, + ): PathInfo | undefined => { const { rootPath } = api.context; const { config, distPath } = environment; - - let { cleanDistPath } = config.output; + const { enable, keep } = normalizeCleanDistPath( + config.output.cleanDistPath, + ); // only enable cleanDistPath when the dist path is a subdir of root path - if (cleanDistPath === 'auto') { - cleanDistPath = isStrictSubdir(rootPath, distPath); - - if (!cleanDistPath) { - logger.warn( - 'The dist path is not a subdir of root path, Rsbuild will not empty it.', - ); - logger.warn( - `Please set ${color.yellow('`output.cleanDistPath`')} config manually.`, - ); - logger.warn(`Current root path: ${color.dim(rootPath)}`); - logger.warn(`Current dist path: ${color.dim(distPath)}`); + if (enable === 'auto') { + if (isStrictSubdir(rootPath, distPath)) { + return { + path: distPath, + keep, + }; } + + logger.warn( + 'The dist path is not a subdir of root path, Rsbuild will not empty it.', + ); + logger.warn( + `Please set ${color.yellow('`output.cleanDistPath`')} config manually.`, + ); + logger.warn(`Current root path: ${color.dim(rootPath)}`); + logger.warn(`Current dist path: ${color.dim(distPath)}`); + return undefined; } - if (cleanDistPath) { - return distPath; + if (enable === true) { + return { + path: distPath, + keep, + }; } return undefined; @@ -65,21 +106,25 @@ export const pluginCleanOutput = (): RsbuildPlugin => ({ const cleanAll = async (params: { environments: Record; }) => { + // dedupe environments by distPath const environments = Object.values(params.environments).reduce< - Array - >((total, curr) => { - if (!total.find((t) => t.distPath === curr.distPath)) { - total.push(curr); + EnvironmentContext[] + >((result, curr) => { + if (!result.find((item) => item.distPath === curr.distPath)) { + result.push(curr); } - return total; + return result; }, []); - const cleanPaths = environments - .map((e) => getCleanPath(e)) - .concat(getRsbuildCleanPath()) - .filter((p): p is string => !!p); + const pathInfos: PathInfo[] = [ + ...environments.map(getPathInfo), + getRsbuildOutputPath(), + ].filter((pathInfo): pathInfo is PathInfo => !!pathInfo); - await Promise.all(dedupeNestedPaths(cleanPaths).map((p) => emptyDir(p))); + // Use `for...of` to handle nested directories correctly + for (const pathInfo of pathInfos) { + await emptyDir(pathInfo.path, pathInfo.keep); + } }; api.onBeforeBuild(async ({ isFirstCompile, environments }) => { diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 544d571cf1..f2c41ac45d 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -940,6 +940,20 @@ export type ManifestObjectConfig = { export type ManifestConfig = string | boolean | ManifestObjectConfig; +export type CleanDistPathObject = { + /** + * Whether to clean the dist path. + * @default 'auto' + */ + enable?: boolean | 'auto'; + /** + * The files to keep in the dist path. + */ + keep?: RegExp[]; +}; + +export type CleanDistPath = boolean | 'auto' | CleanDistPathObject; + export interface OutputConfig { /** * Specify build target to run in specified environment. @@ -998,7 +1012,7 @@ export interface OutputConfig { * Whether to clean all files in the dist path before starting compilation. * @default 'auto' */ - cleanDistPath?: boolean | 'auto'; + cleanDistPath?: CleanDistPath; /** * Allow to custom CSS Modules options. */ @@ -1089,6 +1103,7 @@ export interface NormalizedOutputConfig extends OutputConfig { js?: Rspack.Configuration['devtool']; css: boolean; }; + cleanDistPath: CleanDistPath; filenameHash: boolean | string; assetPrefix: string; dataUriLimit: number | NormalizedDataUriLimit;