From b8f2e051998b9a6bd13f322a1d9707d6f74f0f39 Mon Sep 17 00:00:00 2001 From: Elecmonkey Date: Mon, 26 Oct 2026 19:42:54 +0800 Subject: [PATCH 1/7] feat(core): generate package exports from build outputs --- packages/core/src/cli/commands.ts | 5 + packages/core/src/cli/init.ts | 4 + packages/core/src/config.ts | 19 +- packages/core/src/createRslib.ts | 14 +- packages/core/src/exports/dts.ts | 45 +++ packages/core/src/exports/generate.ts | 177 ++++++++++ packages/core/src/exports/packageJson.ts | 191 ++++++++++ packages/core/src/exports/plugin.ts | 133 +++++++ packages/core/src/types/config.ts | 8 + packages/core/src/types/rslib.ts | 6 + packages/core/src/types/utils.ts | 1 + packages/core/tests/exports.test.ts | 256 ++++++++++++++ packages/core/tests/exportsDts.test.ts | 258 ++++++++++++++ .../core/tests/exportsPackageJson.test.ts | 317 +++++++++++++++++ packages/core/tests/exportsPlugin.test.ts | 103 ++++++ packages/plugin-dts/src/apiOld.ts | 82 ++--- packages/plugin-dts/src/dts.ts | 6 +- packages/plugin-dts/tests/apiOldWatch.test.ts | 114 ++++++ pnpm-lock.yaml | 22 ++ .../cli/help/__snapshots__/index.test.ts.snap | 2 + .../exports/__fixtures__/src/index.ts | 1 + .../exports/__fixtures__/src/utils/numbers.ts | 1 + .../exports/__fixtures__/tsconfig.json | 7 + .../exports/build-error/package.json | 5 + .../exports/build-error/rslib.config.ts | 11 + .../exports/build-error/src/index.ts | 3 + .../exports/bundleless/package.json | 5 + .../exports/bundleless/rslib.config.ts | 15 + .../exports/dts/bundleless/package.json | 6 + .../exports/dts/bundleless/rslib.config.ts | 15 + .../exports/dts/dist-path/package.json | 6 + .../exports/dts/dist-path/rslib.config.ts | 12 + .../exports/dts/matched/package.json | 5 + .../exports/dts/matched/rslib.config.ts | 15 + .../exports/dts/module/package.json | 6 + .../exports/dts/module/rslib.config.ts | 12 + .../exports/dts/shared/package.json | 6 + .../exports/dts/shared/rslib.config.ts | 15 + .../exports/dual-format/package.json | 5 + .../exports/dual-format/rslib.config.ts | 11 + .../integration/exports/esm-only/package.json | 5 + .../exports/esm-only/rslib.config.ts | 11 + tests/integration/exports/index.test.ts | 326 ++++++++++++++++++ .../exports/multiple-entries/package.json | 5 + .../exports/multiple-entries/rslib.config.ts | 12 + tests/integration/exports/umd/package.json | 5 + tests/integration/exports/umd/rslib.config.ts | 11 + tests/scripts/shared.ts | 8 + .../docs/en/api/javascript-api/instance.mdx | 17 + .../en/guide/advanced/release-management.mdx | 50 +++ website/docs/en/guide/basic/cli.mdx | 1 + .../docs/zh/api/javascript-api/instance.mdx | 16 + .../zh/guide/advanced/release-management.mdx | 50 +++ website/docs/zh/guide/basic/cli.mdx | 1 + 54 files changed, 2396 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/exports/dts.ts create mode 100644 packages/core/src/exports/generate.ts create mode 100644 packages/core/src/exports/packageJson.ts create mode 100644 packages/core/src/exports/plugin.ts create mode 100644 packages/core/tests/exports.test.ts create mode 100644 packages/core/tests/exportsDts.test.ts create mode 100644 packages/core/tests/exportsPackageJson.test.ts create mode 100644 packages/core/tests/exportsPlugin.test.ts create mode 100644 packages/plugin-dts/tests/apiOldWatch.test.ts create mode 100644 tests/integration/exports/__fixtures__/src/index.ts create mode 100644 tests/integration/exports/__fixtures__/src/utils/numbers.ts create mode 100644 tests/integration/exports/__fixtures__/tsconfig.json create mode 100644 tests/integration/exports/build-error/package.json create mode 100644 tests/integration/exports/build-error/rslib.config.ts create mode 100644 tests/integration/exports/build-error/src/index.ts create mode 100644 tests/integration/exports/bundleless/package.json create mode 100644 tests/integration/exports/bundleless/rslib.config.ts create mode 100644 tests/integration/exports/dts/bundleless/package.json create mode 100644 tests/integration/exports/dts/bundleless/rslib.config.ts create mode 100644 tests/integration/exports/dts/dist-path/package.json create mode 100644 tests/integration/exports/dts/dist-path/rslib.config.ts create mode 100644 tests/integration/exports/dts/matched/package.json create mode 100644 tests/integration/exports/dts/matched/rslib.config.ts create mode 100644 tests/integration/exports/dts/module/package.json create mode 100644 tests/integration/exports/dts/module/rslib.config.ts create mode 100644 tests/integration/exports/dts/shared/package.json create mode 100644 tests/integration/exports/dts/shared/rslib.config.ts create mode 100644 tests/integration/exports/dual-format/package.json create mode 100644 tests/integration/exports/dual-format/rslib.config.ts create mode 100644 tests/integration/exports/esm-only/package.json create mode 100644 tests/integration/exports/esm-only/rslib.config.ts create mode 100644 tests/integration/exports/index.test.ts create mode 100644 tests/integration/exports/multiple-entries/package.json create mode 100644 tests/integration/exports/multiple-entries/rslib.config.ts create mode 100644 tests/integration/exports/umd/package.json create mode 100644 tests/integration/exports/umd/rslib.config.ts diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 66b9f9e0d3..3b419f6533 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -34,6 +34,7 @@ export type BuildOptions = CommonOptions & { autoExtension?: boolean; autoExternal?: boolean; tsconfig?: string; + exports?: boolean; }; export type InspectOptions = CommonOptions & { @@ -136,6 +137,10 @@ export function setupCommands(argv: string[]): void { '--tsconfig ', 'use specific tsconfig (relative to project root)', ) + .option( + '--exports', + 'generate the exports field in package.json from build outputs (use --no-exports to disable)', + ) .action(async (options: BuildOptions) => { initCliAction('build', options); try { diff --git a/packages/core/src/cli/init.ts b/packages/core/src/cli/init.ts index 5bf4b9cb24..dfda6b943b 100644 --- a/packages/core/src/cli/init.ts +++ b/packages/core/src/cli/init.ts @@ -100,6 +100,10 @@ export const applyCliOptions = ( config.logLevel = options.logLevel; } + if (options.exports !== undefined) { + config.exports = options.exports; + } + for (const lib of config.lib) { if (options.format !== undefined) lib.format = options.format; if (options.bundle !== undefined) lib.bundle = options.bundle; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index d6e345352c..bb17d1d00d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1882,6 +1882,7 @@ export async function composeCreateRsbuildConfig( dev: _dev, server: _server, logLevel, + exports: _exports, ...sharedRsbuildConfig } = rslibConfig; // debug mode should always verbose logs @@ -1999,6 +2000,14 @@ export async function composeCreateRsbuildConfig( config.id = libConfig.id; } + if (userConfig.dts !== undefined && userConfig.dts !== false) { + config.dts = userConfig.dts; + } + + if (userConfig.bundle === false) { + config.bundle = false; + } + return config; }); @@ -2043,10 +2052,16 @@ export async function composeRsbuildEnvironments( return candidateId; }; - for (const { format, id, config } of rsbuildConfigWithLibInfo) { + for (const { format, id, config, dts, bundle } of rsbuildConfigWithLibInfo) { const libId = typeof id === 'string' ? id : composeDefaultId(format); environments[libId] = config; - environmentWithInfos.push({ id: libId, format, config }); + environmentWithInfos.push({ + id: libId, + format, + config, + ...(dts === undefined ? {} : { dts }), + ...(bundle === undefined ? {} : { bundle }), + }); } const conflictIds = usedIds.filter( diff --git a/packages/core/src/createRslib.ts b/packages/core/src/createRslib.ts index 496c163243..beb685cf84 100644 --- a/packages/core/src/createRslib.ts +++ b/packages/core/src/createRslib.ts @@ -8,6 +8,7 @@ import { } from '@rsbuild/core'; import util from 'node:util'; import { composeRsbuildEnvironments, pruneEnvironments } from './config'; +import { ExportsPlugin } from './exports/plugin'; import type { LoadConfigResult } from './loadConfig'; import type { Format, RslibConfig } from './types'; import type { @@ -229,7 +230,8 @@ export async function createRslib( } satisfies RsbuildPlugin); } - const { environments } = await composeRsbuildEnvironments(config); + const { environments, environmentWithInfos } = + await composeRsbuildEnvironments(config); const rsbuildInstance = await createRsbuildInstance( options, @@ -237,6 +239,16 @@ export async function createRslib( pruneEnvironments(environments, buildOptions.lib), ); + if (buildOptions.exports ?? config.exports) { + rsbuildInstance.addPlugins([ + ExportsPlugin({ + root: config.root!, + libInfos: environmentWithInfos, + watch: Boolean(buildOptions.watch), + }), + ]); + } + const buildResult = await rsbuildInstance.build({ watch: buildOptions.watch, }); diff --git a/packages/core/src/exports/dts.ts b/packages/core/src/exports/dts.ts new file mode 100644 index 0000000000..da6a54169a --- /dev/null +++ b/packages/core/src/exports/dts.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// TypeScript looks up the declaration file next to the resolved JavaScript file +// with the matching extension, and never falls back to other extensions. +const MATCHED_DECLARATION_EXTENSIONS: Record = { + '.js': '.d.ts', + '.jsx': '.d.ts', + '.mjs': '.d.mts', + '.cjs': '.d.cts', +}; + +const DECLARATION_EXTENSIONS = ['.d.ts', '.d.mts', '.d.cts']; + +export type DeclarationFile = { + /** Path relative to the package root, using forward slashes. */ + file: string; + /** Whether TypeScript can resolve it from the JavaScript file without a `types` condition. */ + matched: boolean; +}; + +/** + * Find the declaration file next to a JavaScript output, preferring the one + * with the matching extension. + */ +export function findDeclarationFile( + jsFile: string, + root: string, +): DeclarationFile | undefined { + const ext = path.extname(jsFile); + const matchedExt = MATCHED_DECLARATION_EXTENSIONS[ext]; + if (!matchedExt) return; + + const base = jsFile.slice(0, -ext.length); + const declarationExt = [ + matchedExt, + ...DECLARATION_EXTENSIONS.filter((otherExt) => otherExt !== matchedExt), + ].find((candidate) => fs.existsSync(path.join(root, base + candidate))); + + if (!declarationExt) return; + return { + file: base + declarationExt, + matched: declarationExt === matchedExt, + }; +} diff --git a/packages/core/src/exports/generate.ts b/packages/core/src/exports/generate.ts new file mode 100644 index 0000000000..500a041ef9 --- /dev/null +++ b/packages/core/src/exports/generate.ts @@ -0,0 +1,177 @@ +import path from 'node:path'; +import type { RequireKey, RsbuildConfigWithLibInfo } from '../types'; +import { normalizeSlash } from '../utils/helper'; +import type { DeclarationFile } from './dts'; + +export type CollectedEntry = { + entryName: string; + /** Path relative to the package root, or absolute for different Windows drives, using forward slashes. */ + jsFile: string; + /** The declaration file next to `jsFile`, only collected for libs that emit declaration files. */ + declaration?: DeclarationFile; +}; + +export type ExportsField = { + [key: string]: string | ExportsField; +}; + +type LibInfo = RequireKey; + +export type GenerateExportsOptions = { + libInfos: LibInfo[]; + collected: Map; +}; + +type PreparedLib = { + id: string; + format: 'esm' | 'cjs'; + dts: boolean; + entries: CollectedEntry[]; +}; + +type FormatSlot = { + target: string; + declaration?: DeclarationFile; +}; + +type EntrySlots = { + esm?: FormatSlot; + cjs?: FormatSlot; +}; + +/** Normalize entry names such as `./foo/` to `foo` before using them as subpaths. */ +function normalizeEntryName(entryName: string): string { + return normalizeSlash(entryName) + .replace(/^(\.\/)+/, '') + .replace(/\/+$/, ''); +} + +function isOutsidePackageRoot(filePath: string): boolean { + return path.isAbsolute(filePath) || filePath.startsWith('../'); +} + +function getFormatSlots(slots: EntrySlots): FormatSlot[] { + return [slots.esm, slots.cjs].filter( + (slot): slot is FormatSlot => slot !== undefined, + ); +} + +/** + * Build the value of a subpath. A `types` condition is only added when + * TypeScript cannot resolve a declaration file with the matching extension + * next to every output, and then all formats share one declaration file. + */ +function buildEntryValue(slots: EntrySlots): string | ExportsField { + const formatSlots = getFormatSlots(slots); + const value = (types?: string): string | ExportsField => { + if (slots.esm && slots.cjs) { + return { + ...(types ? { types } : {}), + import: slots.esm.target, + require: slots.cjs.target, + }; + } + const target = formatSlots[0]!.target; + return types ? { types, default: target } : target; + }; + + // Declaration files are only collected for libs that emit them. + const declarations = formatSlots.map((slot) => slot.declaration); + if (declarations.every((declaration) => declaration?.matched)) { + return value(); + } + + // Prefer the declaration file whose module format matches its own output. + // Outputs from non-TypeScript sources, such as assets, have no declaration file. + const declaration = + declarations.find((declaration) => declaration?.matched) ?? + declarations.find((declaration) => declaration !== undefined); + return value(declaration && `./${declaration.file}`); +} + +export function generateExports(options: GenerateExportsOptions): ExportsField { + const { libInfos, collected } = options; + + const preparedLibs = libInfos.flatMap( + ({ id, format, dts, bundle }): PreparedLib[] => { + // Other formats, such as UMD, are not referenced by the `exports` field. + if (format !== 'esm' && format !== 'cjs') return []; + const entries = (collected.get(id) ?? []).map((entry) => ({ + ...entry, + entryName: normalizeEntryName(entry.entryName), + })); + return [ + { + id, + format, + dts: Boolean(dts), + // In bundleless mode, every source file is an entry, so only the main + // entry is treated as public API. + entries: + bundle === false + ? entries.filter((entry) => entry.entryName === 'index') + : entries, + }, + ]; + }, + ); + + // Declaration files emitted to a separate directory cannot be resolved from the outputs. + const libsWithoutDeclarations = preparedLibs + .filter( + (lib) => + lib.dts && + lib.entries.length > 0 && + lib.entries.every((entry) => !entry.declaration), + ) + .map((lib) => lib.id); + if (libsWithoutDeclarations.length > 0) { + throw new Error( + [ + `Failed to generate the "exports" field because no declaration files were found next to the outputs of the following libs that emit declaration files: ${libsWithoutDeclarations.map((id) => `"${id}"`).join(', ')}.`, + 'Emit declaration files to the same directory as the JavaScript outputs, check "dts.distPath", and the "declarationDir" and "rootDir" options in tsconfig.', + ].join('\n'), + ); + } + + const entrySlotsByName = new Map(); + for (const lib of preparedLibs) { + for (const entry of lib.entries) { + if (isOutsidePackageRoot(entry.jsFile)) { + throw new Error( + `Failed to generate the "exports" field because the "${entry.entryName}" entry of the "${lib.id}" environment emits "${entry.jsFile}", which is outside the package root.`, + ); + } + + const { entryName } = entry; + const slots = entrySlotsByName.get(entryName) ?? {}; + const target = `./${entry.jsFile}`; + const existingTarget = slots[lib.format]?.target; + if (existingTarget && existingTarget !== target) { + throw new Error( + `Failed to generate the "exports" field because the "${entryName}" entry has multiple "${lib.format}" outputs: "${existingTarget}" and "${target}".`, + ); + } + slots[lib.format] = { target, declaration: entry.declaration }; + entrySlotsByName.set(entryName, slots); + } + } + if (entrySlotsByName.size === 0) { + throw new Error( + 'Failed to generate the "exports" field because no entries can be exported. Only the "esm" and "cjs" formats are supported, and only the "index" entry is exported in bundleless mode.', + ); + } + + const exportsField: ExportsField = {}; + // Keep the main entry before other entry subpaths, then sort the rest for stable output. + const sortedEntries = [...entrySlotsByName].sort(([a], [b]) => { + if (a === 'index') return -1; + if (b === 'index') return 1; + return a.localeCompare(b); + }); + for (const [entryName, slots] of sortedEntries) { + exportsField[entryName === 'index' ? '.' : `./${entryName}`] = + buildEntryValue(slots); + } + return exportsField; +} diff --git a/packages/core/src/exports/packageJson.ts b/packages/core/src/exports/packageJson.ts new file mode 100644 index 0000000000..9974d7b457 --- /dev/null +++ b/packages/core/src/exports/packageJson.ts @@ -0,0 +1,191 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { isObject } from '../utils/helper'; +import { logger } from '../utils/logger'; +import type { ExportsField } from './generate'; + +export type MergedExportsField = Record; + +function isSubpathMap(value: unknown): value is MergedExportsField { + return ( + isObject(value) && Object.keys(value).every((key) => key.startsWith('.')) + ); +} + +/** + * Merge the generated value of a subpath into its existing value. Generated + * format conditions are replaced as a whole. Existing types and default targets + * are kept unless this build supplies replacements, with default kept last. + */ +function mergeSubpath( + key: string, + existing: unknown, + generated: string | ExportsField, +): unknown { + if ( + existing === undefined || + existing === null || + typeof existing === 'string' || + Array.isArray(existing) + ) { + return generated; + } + + if (!isObject(existing)) { + throw new Error( + `Failed to update the "${key}" subpath in the "exports" field of package.json because its existing value is not a string or a conditions object.`, + ); + } + + const generatedValue = + typeof generated === 'string' ? { default: generated } : generated; + const replacedConditions = new Set([ + 'import', + 'require', + ...Object.keys(generatedValue), + ]); + const userKeys = Object.keys(existing).filter( + (condition) => !replacedConditions.has(condition), + ); + if (userKeys.length === 0) return generated; + + const { default: generatedDefault, ...generatedConditions } = generatedValue; + const defaultTarget = Object.hasOwn(generatedValue, 'default') + ? generatedDefault + : existing.default; + + // Generated conditions take the place of the first generated condition in the + // existing value, or go after the user conditions when there is none. + const merged: Record = {}; + let inserted = false; + for (const [condition, value] of Object.entries(existing)) { + if (replacedConditions.has(condition)) { + if (!inserted) { + Object.assign(merged, generatedConditions); + inserted = true; + } + continue; + } + if (condition !== 'default') merged[condition] = value; + } + if (!inserted) Object.assign(merged, generatedConditions); + if (defaultTarget !== undefined) merged.default = defaultTarget; + return merged; +} + +/** + * Merge the generated subpaths into the existing `exports` field. + * Subpaths that only exist in package.json are kept, subpaths that exist in + * both are merged by conditions. + */ +export function mergeExports( + existing: unknown, + generated: ExportsField, +): MergedExportsField { + if (existing === undefined) return generated; + + if (!isSubpathMap(existing)) { + throw new Error( + 'Failed to update the "exports" field in package.json because the existing value is not a subpath map. Convert it to the subpath form first, e.g. { ".": "./dist/index.js" }.', + ); + } + + const mergedSubpaths: MergedExportsField = {}; + for (const [key, value] of Object.entries(generated)) { + const merged = mergeSubpath(key, existing[key], value); + if ( + Object.hasOwn(existing, key) && + !isDeepStrictEqual(existing[key], merged) + ) { + logger.warn( + `Updated the "${key}" subpath in the "exports" field of package.json to ${JSON.stringify(merged)}.`, + ); + } + mergedSubpaths[key] = merged; + } + + // Existing subpaths keep their order, and new subpaths are appended, except + // that a newly added main subpath goes first. + return { + ...(Object.hasOwn(generated, '.') && !Object.hasOwn(existing, '.') + ? { '.': mergedSubpaths['.'] } + : {}), + ...existing, + ...mergedSubpaths, + }; +} + +function collectTargets(value: unknown): string[] { + if (typeof value === 'string') return [value]; + if (Array.isArray(value)) return value.flatMap(collectTargets); + if (isObject(value)) return Object.values(value).flatMap(collectTargets); + return []; +} + +function isPathInside(dir: string, file: string): boolean { + const relative = path.relative(dir, file); + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +/** + * Find the existing subpaths that were not generated in this build, but point + * to missing files in the output directories. They are likely left over from a + * previous build. + */ +export function findStaleSubpaths({ + existing, + generated, + root, + outputPaths, +}: { + existing: unknown; + generated: ExportsField; + root: string; + outputPaths: string[]; +}): string[] { + if (!isObject(existing)) return []; + + const isStaleTarget = (target: string) => { + if (!target.startsWith('./') || target.includes('*')) return false; + const file = path.join(root, target); + return ( + outputPaths.some((outputPath) => isPathInside(outputPath, file)) && + !fs.existsSync(file) + ); + }; + + return Object.entries(existing) + .filter( + ([key, value]) => + !Object.hasOwn(generated, key) && + collectTargets(value).some(isStaleTarget), + ) + .map(([key]) => key); +} + +/** + * Stringify package.json content with the indentation, line endings, and + * trailing newline of the original text. + */ +export function stringifyPackageJson( + content: unknown, + originalText: string, +): string { + const indent = /^([ \t]+)\S/m.exec(originalText)?.[1] ?? 2; + const eol = originalText.includes('\r\n') ? '\r\n' : '\n'; + + let text = JSON.stringify(content, null, indent); + if (eol !== '\n') { + text = text.replaceAll('\n', eol); + } + if (originalText.endsWith('\n')) { + text += eol; + } + return text; +} diff --git a/packages/core/src/exports/plugin.ts b/packages/core/src/exports/plugin.ts new file mode 100644 index 0000000000..571c8ff175 --- /dev/null +++ b/packages/core/src/exports/plugin.ts @@ -0,0 +1,133 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { RsbuildPlugin, Rspack } from '@rsbuild/core'; +import { isJavaScriptOutputFile } from '../exe/utils'; +import type { PkgJson, RequireKey, RsbuildConfigWithLibInfo } from '../types'; +import { logger } from '../utils/logger'; +import { normalizeSlash } from '../utils/helper'; +import { findDeclarationFile } from './dts'; +import { generateExports, type CollectedEntry } from './generate'; +import { + findStaleSubpaths, + mergeExports, + stringifyPackageJson, +} from './packageJson'; + +export type ExportsPluginOptions = { + root: string; + libInfos: RequireKey[]; + watch: boolean; +}; + +export const ExportsPlugin = ({ + root, + libInfos, + watch, +}: ExportsPluginOptions): RsbuildPlugin => { + // Tracks the last failure so that a recovery is reported even when + // package.json does not change. + let failed = false; + + const writeExports = (stats: Rspack.Stats | Rspack.MultiStats): boolean => { + const statsList = 'stats' in stats ? stats.stats : [stats]; + const collected = new Map(); + const outputPaths: string[] = []; + + for (const { compilation } of statsList) { + // Rsbuild names each compiler after its environment, which is the lib ID. + const libId = compilation.name!; + const outputPath = compilation.outputOptions.path!; + outputPaths.push(outputPath); + const emitsDeclarations = Boolean( + libInfos.find(({ id }) => id === libId)?.dts, + ); + + const entries: CollectedEntry[] = []; + for (const [entryName, entrypoint] of compilation.entrypoints) { + // An entry chunk emits at most one JavaScript file. Entries without one, + // such as global CSS entries in bundleless mode, are not exported. + const entryFile = [...entrypoint.getEntrypointChunk().files].find( + isJavaScriptOutputFile, + ); + if (!entryFile) continue; + const jsFile = normalizeSlash( + path.relative(root, path.join(outputPath, entryFile)), + ); + entries.push({ + entryName, + jsFile, + declaration: emitsDeclarations + ? findDeclarationFile(jsFile, root) + : undefined, + }); + } + collected.set(libId, entries); + } + + const pkgJsonPath = path.join(root, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + throw new Error( + `Failed to generate the "exports" field because "${pkgJsonPath}" does not exist.`, + ); + } + const originalText = fs.readFileSync(pkgJsonPath, 'utf8'); + // An invalid package.json already fails the build before this hook, as + // Rsbuild parses it when loading the PostCSS config. + const pkgJson: PkgJson = JSON.parse(originalText); + + const generated = generateExports({ libInfos, collected }); + + const exportsField = mergeExports(pkgJson.exports, generated); + + // Warn about subpaths that are likely left over instead of removing them. + const staleSubpaths = findStaleSubpaths({ + existing: pkgJson.exports, + generated, + root, + outputPaths, + }); + if (staleSubpaths.length > 0) { + logger.warn( + `The following subpaths in the "exports" field of package.json point to files that do not exist in the output directories: ${staleSubpaths.map((key) => `"${key}"`).join(', ')}.`, + ); + } + + const text = stringifyPackageJson( + { ...pkgJson, exports: exportsField }, + originalText, + ); + if (text === originalText) return false; + + fs.writeFileSync(pkgJsonPath, text); + logger.info('generated "exports" field in package.json.'); + return true; + }; + + return { + name: 'rslib:exports', + setup(api) { + api.onAfterBuild(({ stats }) => { + // Failed builds already report errors, so there is nothing to export. + // In watch mode, the next successful build generates it again. + if (!stats || stats.hasErrors()) return; + + try { + const written = writeExports(stats); + if (failed && !written) { + logger.info('"exports" field in package.json is up to date.'); + } + failed = false; + } catch (error) { + if (!watch) throw error; + // The build output is unaffected, so keep watching and retry on the + // next rebuild. + failed = true; + logger.error( + 'Failed to update "exports" field in package.json, will retry on the next rebuild.', + ); + logger.error(error); + } + }); + }, + }; +}; diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 6a1744cb92..65fc49c5fd 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -29,6 +29,8 @@ export type RsbuildConfigWithLibInfo = { id?: string; format: Format; config: EnvironmentConfig; + dts?: Dts; + bundle?: false; }; export type RsbuildConfigEntry = NonNullable< @@ -569,4 +571,10 @@ export interface RslibConfig extends RsbuildConfig, SharedLibConfig { * @inheritdoc */ output?: RslibOutputConfig; + /** + * Whether to generate the `exports` field in `package.json` from the build outputs. + * Can be overridden by the `exports` option of `build`. + * @default false + */ + exports?: boolean; } diff --git a/packages/core/src/types/rslib.ts b/packages/core/src/types/rslib.ts index a7f81ac6f9..106c71b118 100644 --- a/packages/core/src/types/rslib.ts +++ b/packages/core/src/types/rslib.ts @@ -22,6 +22,12 @@ export type BuildOptions = CommonOptions & { * @default false */ watch?: boolean; + /** + * Whether to generate the `exports` field in `package.json` from the build outputs. + * Overrides the `exports` config when specified. + * @default the value of the `exports` config + */ + exports?: boolean; }; export type InspectConfigOptions = CommonOptions & { diff --git a/packages/core/src/types/utils.ts b/packages/core/src/types/utils.ts index 8ee51672eb..4027460232 100644 --- a/packages/core/src/types/utils.ts +++ b/packages/core/src/types/utils.ts @@ -7,6 +7,7 @@ export type PkgJson = { devDependencies?: Record; optionalDependencies?: Record; engines?: Record; + exports?: unknown; }; export type DeepRequired = Required<{ diff --git a/packages/core/tests/exports.test.ts b/packages/core/tests/exports.test.ts new file mode 100644 index 0000000000..4691876ee0 --- /dev/null +++ b/packages/core/tests/exports.test.ts @@ -0,0 +1,256 @@ +import path from 'node:path'; +import { describe, expect, it, rs } from '@rstest/core'; +import type { + CollectedEntry, + GenerateExportsOptions, +} from '../src/exports/generate'; +import { generateExports } from '../src/exports/generate'; +import type { RequireKey, RsbuildConfigWithLibInfo } from '../src/types'; + +rs.mock('rslog'); + +type LibInfo = RequireKey; + +const createLibInfo = (id: string, format: LibInfo['format']): LibInfo => ({ + id, + format, + config: {} as unknown as LibInfo['config'], +}); + +const createCollected = ( + entries: Record, +): Map => + new Map( + Object.entries(entries).map(([id, list]) => [ + id, + list.map(([entryName, jsFile]) => ({ entryName, jsFile })), + ]), + ); + +describe('generateExports', () => { + it('should generate import and require conditions for esm and cjs entries', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + cjs: [['index', 'dist/cjs/index.js']], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + }); + }); + + it('should generate a string value when only one format exists', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + it('should expose non-index entries as subpaths', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [['foo', 'dist/esm/foo.mjs']], + }), + }; + + expect(generateExports(options)).toEqual({ + './foo': './dist/esm/foo.mjs', + }); + }); + + it('should normalize leading "./" and trailing "/" in entry names', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], + collected: createCollected({ + esm: [ + ['./index', 'dist/esm/index.mjs'], + ['./foo/', 'dist/esm/foo.mjs'], + ], + cjs: [ + ['index', 'dist/cjs/index.js'], + ['foo', 'dist/cjs/foo.js'], + ], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + './foo': { + import: './dist/esm/foo.mjs', + require: './dist/cjs/foo.js', + }, + }); + }); + + it('should only expose the index entry in bundleless mode', () => { + const options: GenerateExportsOptions = { + libInfos: [{ ...createLibInfo('esm', 'esm'), bundle: false }], + collected: createCollected({ + esm: [ + ['index', 'dist/esm/index.mjs'], + ['utils/numbers', 'dist/esm/utils/numbers.mjs'], + ['assets/logo', 'dist/esm/assets/logo.mjs'], + ], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + it('should keep the index entry first and sort other entries', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [ + ['foo', 'dist/esm/foo.mjs'], + ['utils/numbers', 'dist/esm/utils/numbers.mjs'], + ['index', 'dist/esm/index.mjs'], + ['bar', 'dist/esm/bar.mjs'], + ], + }), + }; + + const exportsField = generateExports(options); + + expect(Object.keys(exportsField!)).toEqual([ + '.', + './bar', + './foo', + './utils/numbers', + ]); + expect(exportsField).toEqual({ + '.': './dist/esm/index.mjs', + './bar': './dist/esm/bar.mjs', + './foo': './dist/esm/foo.mjs', + './utils/numbers': './dist/esm/utils/numbers.mjs', + }); + }); + + it('should skip unsupported formats and keep supported ones', () => { + const options: GenerateExportsOptions = { + libInfos: [ + createLibInfo('esm', 'esm'), + createLibInfo('umd', 'umd'), + createLibInfo('mf', 'mf'), + ], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + umd: [['index', 'dist/umd/index.js']], + mf: [['index', 'dist/mf/index.js']], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + it('should throw when all formats are unsupported', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('umd', 'umd')], + collected: createCollected({ + umd: [['index', 'dist/umd/index.js']], + }), + }; + + expect(() => generateExports(options)).toThrow( + 'no entries can be exported', + ); + }); + + it('should throw when no JavaScript entries are generated', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [], + }), + }; + + expect(() => generateExports(options)).toThrow( + 'no entries can be exported', + ); + }); + + it('should skip libs without collected entries', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + it('should throw when an entry is outside the package root', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [['index', '../outside/index.mjs']], + }), + }; + + expect(() => generateExports(options)).toThrow( + 'which is outside the package root', + ); + + const absoluteOptions: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [['index', path.resolve(__dirname, 'outside/index.mjs')]], + }), + }; + + expect(() => generateExports(absoluteOptions)).toThrow( + 'which is outside the package root', + ); + }); + + it('should throw when an entry has multiple outputs of the same format', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm'), createLibInfo('esm2', 'esm')], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + esm2: [['index', 'dist/esm2/index.mjs']], + }), + }; + + expect(() => generateExports(options)).toThrow( + 'the "index" entry has multiple "esm" outputs', + ); + }); + + it('should allow duplicate identical outputs of the same format', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm'), createLibInfo('esm2', 'esm')], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + esm2: [['index', 'dist/esm/index.mjs']], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); +}); diff --git a/packages/core/tests/exportsDts.test.ts b/packages/core/tests/exportsDts.test.ts new file mode 100644 index 0000000000..7f268b7b23 --- /dev/null +++ b/packages/core/tests/exportsDts.test.ts @@ -0,0 +1,258 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, rs } from '@rstest/core'; +import { type DeclarationFile, findDeclarationFile } from '../src/exports/dts'; +import { type CollectedEntry, generateExports } from '../src/exports/generate'; +import type { RequireKey, RsbuildConfigWithLibInfo } from '../src/types'; + +rs.mock('rslog'); + +type LibInfo = RequireKey; + +const findWithFiles = async (jsFile: string, files: string[]) => { + const root = await mkdtemp(path.join(tmpdir(), 'rslib-exports-dts-')); + try { + await mkdir(path.join(root, 'dist')); + for (const file of files) { + await writeFile(path.join(root, file), ''); + } + return findDeclarationFile(jsFile, root); + } finally { + await rm(root, { recursive: true, force: true }); + } +}; + +describe('findDeclarationFile', () => { + it.each([ + ['dist/index.js', 'dist/index.d.ts'], + ['dist/index.jsx', 'dist/index.d.ts'], + ['dist/index.mjs', 'dist/index.d.mts'], + ['dist/index.cjs', 'dist/index.d.cts'], + ])('should find the matched declaration file of %s', async (jsFile, file) => { + expect(await findWithFiles(jsFile, [file])).toEqual({ + file, + matched: true, + }); + }); + + it('should prefer the matched declaration file', async () => { + expect( + await findWithFiles('dist/index.mjs', [ + 'dist/index.d.ts', + 'dist/index.d.mts', + ]), + ).toEqual({ file: 'dist/index.d.mts', matched: true }); + }); + + it('should fall back to a declaration file with another extension', async () => { + expect(await findWithFiles('dist/index.cjs', ['dist/index.d.ts'])).toEqual({ + file: 'dist/index.d.ts', + matched: false, + }); + }); + + it('should return undefined when no declaration file exists', async () => { + expect(await findWithFiles('dist/index.js', [])).toBeUndefined(); + }); +}); + +describe('generateExports with declaration files', () => { + const createLibInfo = ( + id: string, + format: LibInfo['format'], + dts?: LibInfo['dts'], + ): LibInfo => ({ + id, + format, + config: {} as unknown as LibInfo['config'], + ...(dts === undefined ? {} : { dts }), + }); + + const entry = ( + jsFile: string, + declaration?: DeclarationFile, + ): CollectedEntry => ({ entryName: 'index', jsFile, declaration }); + + it('should not add a types condition when every output has a matched declaration file', () => { + expect( + generateExports({ + libInfos: [ + createLibInfo('esm', 'esm', { autoExtension: true }), + createLibInfo('cjs', 'cjs', { autoExtension: true }), + ], + collected: new Map([ + [ + 'esm', + [ + entry('dist/esm/index.mjs', { + file: 'dist/esm/index.d.mts', + matched: true, + }), + ], + ], + [ + 'cjs', + [ + entry('dist/cjs/index.js', { + file: 'dist/cjs/index.d.ts', + matched: true, + }), + ], + ], + ]), + }), + ).toEqual({ + '.': { import: './dist/esm/index.mjs', require: './dist/cjs/index.js' }, + }); + }); + + it('should share the matched declaration file when another output has none', () => { + const exportsField = generateExports({ + libInfos: [ + createLibInfo('esm', 'esm', true), + createLibInfo('cjs', 'cjs', true), + ], + collected: new Map([ + [ + 'esm', + [ + entry('dist/esm/index.js', { + file: 'dist/esm/index.d.ts', + matched: true, + }), + ], + ], + [ + 'cjs', + [ + entry('dist/cjs/index.cjs', { + file: 'dist/cjs/index.d.ts', + matched: false, + }), + ], + ], + ]), + }); + + expect(Object.keys(exportsField!['.']!)).toEqual([ + 'types', + 'import', + 'require', + ]); + expect(exportsField).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + require: './dist/cjs/index.cjs', + }, + }); + }); + + it('should share the declaration file of the only lib that emits declaration files', () => { + expect( + generateExports({ + libInfos: [ + createLibInfo('esm', 'esm', true), + createLibInfo('cjs', 'cjs'), + ], + collected: new Map([ + [ + 'esm', + [ + entry('dist/index.mjs', { + file: 'dist/index.d.ts', + matched: false, + }), + ], + ], + ['cjs', [entry('dist/index.js')]], + ]), + }), + ).toEqual({ + '.': { + types: './dist/index.d.ts', + import: './dist/index.mjs', + require: './dist/index.js', + }, + }); + }); + + it('should use the default condition with a single format', () => { + expect( + generateExports({ + libInfos: [createLibInfo('esm', 'esm', true)], + collected: new Map([ + [ + 'esm', + [ + entry('dist/index.mjs', { + file: 'dist/index.d.ts', + matched: false, + }), + ], + ], + ]), + }), + ).toEqual({ + '.': { types: './dist/index.d.ts', default: './dist/index.mjs' }, + }); + }); + + it('should throw when a lib has no declaration file next to any output', () => { + expect(() => + generateExports({ + libInfos: [createLibInfo('esm', 'esm', { distPath: './dist/types' })], + collected: new Map([['esm', [entry('dist/index.js')]]]), + }), + ).toThrow( + 'no declaration files were found next to the outputs of the following libs that emit declaration files: "esm"', + ); + }); + + it('should not add a types condition to subpaths without declaration files', () => { + expect( + generateExports({ + libInfos: [createLibInfo('esm', 'esm', true)], + collected: new Map([ + [ + 'esm', + [ + entry('dist/index.js', { + file: 'dist/index.d.ts', + matched: true, + }), + { entryName: 'assets/logo', jsFile: 'dist/assets/logo.js' }, + ], + ], + ]), + }), + ).toEqual({ + '.': './dist/index.js', + './assets/logo': './dist/assets/logo.js', + }); + }); + + it('should throw for a bundleless lib without the index entry', () => { + expect(() => + generateExports({ + libInfos: [{ ...createLibInfo('esm', 'esm', true), bundle: false }], + collected: new Map([ + [ + 'esm', + [{ entryName: 'utils/numbers', jsFile: 'dist/utils/numbers.js' }], + ], + ]), + }), + ).toThrow('no entries can be exported'); + }); + + it('should not check libs without declaration files', () => { + expect( + generateExports({ + libInfos: [createLibInfo('esm', 'esm')], + collected: new Map([['esm', [entry('dist/index.js')]]]), + }), + ).toEqual({ '.': './dist/index.js' }); + }); +}); diff --git a/packages/core/tests/exportsPackageJson.test.ts b/packages/core/tests/exportsPackageJson.test.ts new file mode 100644 index 0000000000..c4b06a9249 --- /dev/null +++ b/packages/core/tests/exportsPackageJson.test.ts @@ -0,0 +1,317 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, rs } from '@rstest/core'; +import { + findStaleSubpaths, + mergeExports, + stringifyPackageJson, +} from '../src/exports/packageJson'; +import { logger } from '../src/utils/logger'; + +rs.mock('rslog'); + +describe('mergeExports', () => { + it('should return the generated field when there is no existing field', () => { + expect(mergeExports(undefined, { '.': './dist/index.mjs' })).toEqual({ + '.': './dist/index.mjs', + }); + }); + + it('should keep existing subpaths and overwrite conflicting ones in place', () => { + const merged = mergeExports( + { + './style.css': './dist/style.css', + '.': './dist/old.mjs', + './package.json': './package.json', + }, + { '.': './dist/index.mjs', './utils': './dist/utils.mjs' }, + ); + + expect(Object.keys(merged)).toEqual([ + './style.css', + '.', + './package.json', + './utils', + ]); + expect(merged).toEqual({ + './style.css': './dist/style.css', + '.': './dist/index.mjs', + './package.json': './package.json', + './utils': './dist/utils.mjs', + }); + }); + + it('should put a newly added main subpath first', () => { + const merged = mergeExports( + { './package.json': './package.json' }, + { '.': './dist/index.mjs' }, + ); + + expect(Object.keys(merged)).toEqual(['.', './package.json']); + }); + + it('should not add a main subpath when none is generated', () => { + const merged = mergeExports( + { './style.css': './dist/style.css' }, + { './utils': './dist/utils.mjs' }, + ); + + expect(Object.keys(merged)).toEqual(['./style.css', './utils']); + }); + + it('should warn only when an existing subpath is overwritten with a different value', () => { + const warn = rs.spyOn(logger, 'warn').mockImplementation(() => {}); + + mergeExports( + { + '.': { import: './dist/index.mjs', require: './dist/index.js' }, + './utils': './dist/old-utils.mjs', + }, + { + '.': { import: './dist/index.mjs', require: './dist/index.js' }, + './utils': './dist/utils.mjs', + }, + ); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]![0]).toContain('"./utils"'); + warn.mockRestore(); + }); + + it('should keep user conditions in place and replace generated conditions', () => { + const merged = mergeExports( + { + '.': { + '@acme/source': './src/index.ts', + import: './dist/esm/old.mjs', + browser: './dist/browser.js', + require: './dist/cjs/old.js', + }, + }, + { + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + }, + ); + + expect(Object.keys(merged['.'] as object)).toEqual([ + '@acme/source', + 'types', + 'import', + 'require', + 'browser', + ]); + expect(merged['.']).toEqual({ + '@acme/source': './src/index.ts', + types: './dist/esm/index.d.ts', + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + browser: './dist/browser.js', + }); + }); + + it('should describe the merged value and warn once when it changes', () => { + const warn = rs.spyOn(logger, 'warn').mockImplementation(() => {}); + const generated = { '.': { import: './dist/index.mjs' } }; + const merged = mergeExports( + { + '.': { + '@acme/source': './src/index.ts', + import: './dist/old.mjs', + }, + }, + generated, + ); + + expect(warn).toHaveBeenCalledWith( + 'Updated the "." subpath in the "exports" field of package.json to {"@acme/source":"./src/index.ts","import":"./dist/index.mjs"}.', + ); + mergeExports(merged, generated); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it('should drop stale generated conditions when switching to a single format', () => { + expect( + mergeExports( + { + '.': { + '@acme/source': './src/index.ts', + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + './utils': { import: './dist/utils.mjs', require: './dist/utils.js' }, + }, + { '.': './dist/esm/index.mjs', './utils': './dist/utils.mjs' }, + ), + ).toEqual({ + '.': { + '@acme/source': './src/index.ts', + default: './dist/esm/index.mjs', + }, + './utils': './dist/utils.mjs', + }); + }); + + it('should keep default last when merging user conditions', () => { + const merged = mergeExports( + { '.': { default: './dist/old.js', node: './dist/node.js' } }, + { '.': { types: './dist/index.d.ts', default: './dist/index.js' } }, + ); + + expect(Object.keys(merged['.'] as object)).toEqual([ + 'types', + 'node', + 'default', + ]); + }); + + it('should preserve external types and fallback targets when only formats are generated', () => { + const merged = mergeExports( + { + '.': { + browser: './dist/browser.js', + types: './types/index.d.ts', + import: './dist/old.mjs', + require: './dist/old.cjs', + default: './dist/umd.js', + }, + }, + { '.': { import: './dist/index.mjs', require: './dist/index.cjs' } }, + ); + + expect(merged['.']).toEqual({ + browser: './dist/browser.js', + types: './types/index.d.ts', + import: './dist/index.mjs', + require: './dist/index.cjs', + default: './dist/umd.js', + }); + expect(Object.keys(merged['.'] as object).at(-1)).toBe('default'); + }); + + it('should preserve external types when generating a single output', () => { + expect( + mergeExports( + { '.': { types: './types/index.d.ts', default: './dist/old.js' } }, + { '.': './dist/index.js' }, + ), + ).toEqual({ + '.': { types: './types/index.d.ts', default: './dist/index.js' }, + }); + }); + + it('should replace types when the build generates a declaration target', () => { + expect( + mergeExports( + { '.': { types: './types/index.d.ts', default: './dist/old.js' } }, + { '.': { types: './dist/index.d.ts', default: './dist/index.js' } }, + ), + ).toEqual({ + '.': { types: './dist/index.d.ts', default: './dist/index.js' }, + }); + }); + + it.each([ + ['an array', ['./dist/index.mjs']], + ['null', null], + ])('should replace an existing %s subpath and warn', (_, value) => { + const warn = rs.spyOn(logger, 'warn').mockImplementation(() => {}); + + expect(mergeExports({ '.': value }, { '.': './dist/index.mjs' })).toEqual({ + '.': './dist/index.mjs', + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]![0]).toContain('"."'); + warn.mockRestore(); + }); + + it('should preserve array and null subpaths that are not generated', () => { + expect( + mergeExports( + { './fallback': ['./dist/fallback.mjs'], './internal': null }, + { '.': './dist/index.mjs' }, + ), + ).toEqual({ + '.': './dist/index.mjs', + './fallback': ['./dist/fallback.mjs'], + './internal': null, + }); + }); + + it.each([ + ['a string', './dist/index.mjs'], + ['an object of conditions', { import: './dist/index.mjs' }], + ['an array', ['./dist/index.mjs']], + ['null', null], + ])('should throw when the existing field is %s', (_, existing) => { + expect(() => mergeExports(existing, { '.': './dist/index.mjs' })).toThrow( + 'is not a subpath map', + ); + }); +}); + +describe('findStaleSubpaths', () => { + it('should find existing subpaths that point to missing files in the output directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'rslib-exports-stale-')); + + try { + await mkdir(join(root, 'dist')); + await writeFile(join(root, 'dist/style.css'), ''); + + expect( + findStaleSubpaths({ + existing: { + '.': './dist/old.mjs', + './old': { import: './dist/old.mjs', require: './dist/old.js' }, + './style.css': './dist/style.css', + './src': './src/missing.ts', + './*': './dist/*.mjs', + }, + generated: { '.': './dist/index.mjs' }, + root, + outputPaths: [join(root, 'dist')], + }), + ).toEqual(['./old']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('should return an empty list when there is no existing field', () => { + expect( + findStaleSubpaths({ + existing: undefined, + generated: { '.': './dist/index.mjs' }, + root: '/pkg', + outputPaths: ['/pkg/dist'], + }), + ).toEqual([]); + }); +}); + +describe('stringifyPackageJson', () => { + const content = { name: 'pkg', exports: { '.': './dist/index.mjs' } }; + + it('should use two spaces when the original text has no indentation', () => { + expect(stringifyPackageJson(content, '{}')).toBe( + JSON.stringify(content, null, 2), + ); + }); + + it('should preserve tab indentation and the trailing newline', () => { + expect(stringifyPackageJson(content, '{\n\t"name": "pkg"\n}\n')).toBe( + `${JSON.stringify(content, null, '\t')}\n`, + ); + }); + + it('should preserve four-space indentation and CRLF line endings', () => { + expect( + stringifyPackageJson(content, '{\r\n "name": "pkg"\r\n}\r\n'), + ).toBe(`${JSON.stringify(content, null, 4).replaceAll('\n', '\r\n')}\r\n`); + }); +}); diff --git a/packages/core/tests/exportsPlugin.test.ts b/packages/core/tests/exportsPlugin.test.ts new file mode 100644 index 0000000000..2165ad61d0 --- /dev/null +++ b/packages/core/tests/exportsPlugin.test.ts @@ -0,0 +1,103 @@ +import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core'; +import { afterEach, describe, expect, it, rs } from '@rstest/core'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ExportsPlugin } from '../src/exports/plugin'; +import { logger } from '../src/utils/logger'; + +rs.mock('../src/utils/logger', () => ({ + logger: { info: rs.fn(), warn: rs.fn(), error: rs.fn() }, +})); + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function createFixture(watch: boolean) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rslib-exports-plugin-')); + roots.push(root); + const pkgPath = path.join(root, 'package.json'); + const callbacks: Array<(params: { stats: Rspack.Stats }) => void> = []; + ExportsPlugin({ + root, + libInfos: [{ id: 'esm', format: 'esm', config: {} }], + watch, + }).setup({ + onAfterBuild: (callback: (typeof callbacks)[number]) => { + callbacks.push(callback); + }, + } as unknown as RsbuildPluginAPI); + + const stats = () => { + const errors: Error[] = []; + return { + hasErrors: () => errors.length > 0, + compilation: { + name: 'esm', + errors, + outputOptions: { path: path.join(root, 'dist') }, + entrypoints: new Map([ + [ + 'index', + { getEntrypointChunk: () => ({ files: new Set(['index.js']) }) }, + ], + ]), + }, + } as unknown as Rspack.Stats; + }; + return { pkgPath, run: callbacks[0]!, stats }; +} + +describe('exports build failures', () => { + it('reports watch failures without failing the build and recovers on the next build', () => { + const { pkgPath, run, stats } = createFixture(true); + const original = JSON.stringify({ exports: './old.js' }); + fs.writeFileSync(pkgPath, original); + const failed = stats(); + + expect(() => run({ stats: failed })).not.toThrow(); + expect(failed.hasErrors()).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('subpath map'), + }), + ); + expect(fs.readFileSync(pkgPath, 'utf8')).toBe(original); + + fs.writeFileSync(pkgPath, '{}'); + run({ stats: stats() }); + expect(JSON.parse(fs.readFileSync(pkgPath, 'utf8')).exports).toEqual({ + '.': './dist/index.js', + }); + }); + + it('reports recovery once when package.json is already up to date', () => { + const { pkgPath, run, stats } = createFixture(true); + fs.writeFileSync(pkgPath, JSON.stringify({ exports: './old.js' })); + run({ stats: stats() }); + + const upToDate = `${JSON.stringify({ exports: { '.': './dist/index.js' } }, null, 2)}\n`; + fs.writeFileSync(pkgPath, upToDate); + run({ stats: stats() }); + run({ stats: stats() }); + expect(fs.readFileSync(pkgPath, 'utf8')).toBe(upToDate); + const recoveryLogs = rs + .mocked(logger.info) + .mock.calls.filter( + ([message]) => + message === '"exports" field in package.json is up to date.', + ); + expect(recoveryLogs).toHaveLength(1); + }); + + it('throws exports errors in a one-off build', () => { + const { pkgPath, run, stats } = createFixture(false); + fs.writeFileSync(pkgPath, JSON.stringify({ exports: './old.js' })); + expect(() => run({ stats: stats() })).toThrow('subpath map'); + }); +}); diff --git a/packages/plugin-dts/src/apiOld.ts b/packages/plugin-dts/src/apiOld.ts index bcfff9cd1f..cc49b3d99b 100644 --- a/packages/plugin-dts/src/apiOld.ts +++ b/packages/plugin-dts/src/apiOld.ts @@ -109,7 +109,7 @@ async function handleDiagnosticsAndProcessFiles( export async function emitDtsWithOldApi( options: EmitDtsOptions, - onComplete: (isSuccess: boolean) => void, + onComplete: (isSuccess: boolean) => void | Promise, bundle = false, isWatch = false, build = false, @@ -155,7 +155,17 @@ export async function emitDtsWithOldApi( ); }; - const reportWatchStatusChanged: WatchStatusReporter = async ( + let resolveFirstBuild!: () => void; + let rejectFirstBuild!: (error: unknown) => void; + const firstBuild = isWatch + ? new Promise((resolve, reject) => { + resolveFirstBuild = resolve; + rejectFirstBuild = reject; + }) + : undefined; + let watchTask = Promise.resolve(); + + const reportWatchStatusChanged: WatchStatusReporter = ( diagnostic: Diagnostic, _newLine: string, _options: CompilerOptions, @@ -172,43 +182,35 @@ export async function emitDtsWithOldApi( logger.info(logPrefixTsc, message); } - // 6194: 0 errors or 2+ errors! - if (diagnostic.code === 6194) { - if (errorCount === 0 || !errorCount) { - logger.info(logPrefixTsc, message); - onComplete(true); - } else { - logger.error(logPrefixTsc, message); - } - await processDtsFiles( - bundle, - cwd, - declarationDir, - dtsExtension, - redirect, - configPath, - rootDir, - paths, - banner, - footer, - ); - } - - // 6193: 1 error - if (diagnostic.code === 6193) { - logger.error(logPrefixTsc, message); - await processDtsFiles( - bundle, - cwd, - declarationDir, - dtsExtension, - redirect, - configPath, - rootDir, - paths, - banner, - footer, - ); + // TypeScript does not await status reporters. Track post-processing ourselves + // so the initial IPC success is sent after the final declarations are ready. + if (diagnostic.code === 6194 || diagnostic.code === 6193) { + const success = diagnostic.code === 6194 && !errorCount; + watchTask = watchTask + .then(async () => { + await processDtsFiles( + bundle, + cwd, + declarationDir, + dtsExtension, + redirect, + configPath, + rootDir, + paths, + banner, + footer, + ); + await onComplete(success); + if (!success) { + throw new Error(message); + } + logger.info(logPrefixTsc, message); + resolveFirstBuild(); + }) + .catch((error: unknown) => { + rejectFirstBuild(error); + logger.error(error); + }); } }; @@ -451,5 +453,7 @@ export async function emitDtsWithOldApi( solutionBuilder.build(); } + + await firstBuild; } } diff --git a/packages/plugin-dts/src/dts.ts b/packages/plugin-dts/src/dts.ts index edd7a8b212..cae0589a31 100644 --- a/packages/plugin-dts/src/dts.ts +++ b/packages/plugin-dts/src/dts.ts @@ -231,11 +231,7 @@ export async function generateDts(data: DtsGenOptions): Promise { const onComplete = async (isSuccess: boolean) => { if (isSuccess) { - try { - await bundleDtsIfNeeded(data, preparedDtsContext); - } catch (e) { - logger.error(e); - } + await bundleDtsIfNeeded(data, preparedDtsContext); } }; diff --git a/packages/plugin-dts/tests/apiOldWatch.test.ts b/packages/plugin-dts/tests/apiOldWatch.test.ts new file mode 100644 index 0000000000..71c285f847 --- /dev/null +++ b/packages/plugin-dts/tests/apiOldWatch.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, rs } from '@rstest/core'; +import type { WatchStatusReporter } from 'typescript6-api'; +import { emitDtsWithOldApi } from '../src/apiOld'; + +const mocks = rs.hoisted(() => ({ + reportStatus: undefined as WatchStatusReporter | undefined, + processDtsFiles: rs.fn(), + error: rs.fn(), +})); + +rs.mock('@rsbuild/core', () => ({ + logger: { info: rs.fn(), error: mocks.error }, +})); + +rs.mock('../src/utils', () => ({ + color: { dim: (value: string) => value }, + getTimeCost: rs.fn(), + renameDtsFile: rs.fn(), + updateDeclarationMapContent: rs.fn(), + processDtsFiles: mocks.processDtsFiles, + loadTypescript: () => ({ + sys: { getCurrentDirectory: () => '/project', newLine: '\n' }, + flattenDiagnosticMessageText: (message: string) => message, + createWatchCompilerHost: (...args: unknown[]) => { + mocks.reportStatus = args[5] as WatchStatusReporter; + return {}; + }, + createWatchProgram: () => {}, + }), +})); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const options = { + configPath: '/project/tsconfig.json', + tsConfigResult: { options: {}, fileNames: [], errors: [] }, + declarationDir: '/project/dist', + name: 'esm', + dtsExtension: '.d.ts', + rootDir: '/project/src', + cwd: '/project', + paths: {}, + redirect: {}, +}; + +function reportSuccess() { + mocks.reportStatus!( + { code: 6194, messageText: 'Found 0 errors', category: 3 }, + '\n', + {}, + 0, + ); +} + +describe('tsc watch completion', () => { + beforeEach(() => { + mocks.processDtsFiles.mockReset(); + mocks.error.mockReset(); + }); + + it('waits for post-processing and declaration bundling before resolving', async () => { + const postprocess = deferred(); + const bundle = deferred(); + mocks.processDtsFiles.mockReturnValue(postprocess.promise); + const onComplete = rs.fn(() => bundle.promise); + let completed = false; + const task = emitDtsWithOldApi(options, onComplete, true, true).then(() => { + completed = true; + }); + + reportSuccess(); + await Promise.resolve(); + expect(mocks.processDtsFiles).toHaveBeenCalledTimes(1); + expect(onComplete).not.toHaveBeenCalled(); + expect(completed).toBe(false); + + postprocess.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(onComplete).toHaveBeenCalledWith(true); + expect(completed).toBe(false); + + bundle.resolve(); + await task; + expect(completed).toBe(true); + }); + + it('reports an initial post-processing failure to the parent task', async () => { + const error = new Error('declaration processing failed'); + mocks.processDtsFiles.mockRejectedValue(error); + const task = emitDtsWithOldApi(options, rs.fn(), false, true); + reportSuccess(); + await expect(task).rejects.toThrow('declaration processing failed'); + }); + + it('reports an initial bundling failure to the parent task', async () => { + const task = emitDtsWithOldApi( + options, + async () => { + throw new Error('declaration bundling failed'); + }, + true, + true, + ); + reportSuccess(); + await expect(task).rejects.toThrow('declaration bundling failed'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae7858c81e..22d635654c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1461,6 +1461,28 @@ importers: tests/integration/exe/multi-target: {} + tests/integration/exports/build-error: {} + + tests/integration/exports/bundleless: {} + + tests/integration/exports/dts/bundleless: {} + + tests/integration/exports/dts/dist-path: {} + + tests/integration/exports/dts/matched: {} + + tests/integration/exports/dts/module: {} + + tests/integration/exports/dts/shared: {} + + tests/integration/exports/dual-format: {} + + tests/integration/exports/esm-only: {} + + tests/integration/exports/multiple-entries: {} + + tests/integration/exports/umd: {} + tests/integration/extension-alias: {} tests/integration/external-helpers/config-override: diff --git a/tests/integration/cli/help/__snapshots__/index.test.ts.snap b/tests/integration/cli/help/__snapshots__/index.test.ts.snap index 6377d4b77f..62906e5e8e 100644 --- a/tests/integration/cli/help/__snapshots__/index.test.ts.snap +++ b/tests/integration/cli/help/__snapshots__/index.test.ts.snap @@ -21,6 +21,7 @@ Options: --auto-extension control automatic extension redirect (use --no-auto-extension to disable) --auto-external control automatic dependency externalization (use --no-auto-external to disable) --tsconfig use specific tsconfig (relative to project root) + --exports generate the exports field in package.json from build outputs (use --no-exports to disable) -v, --version Display version number -c, --config specify the configuration file, can be a relative or absolute path --config-loader Set the config file loader (auto | jiti | native) (default: auto) @@ -101,6 +102,7 @@ Options: --auto-extension control automatic extension redirect (use --no-auto-extension to disable) --auto-external control automatic dependency externalization (use --no-auto-external to disable) --tsconfig use specific tsconfig (relative to project root) + --exports generate the exports field in package.json from build outputs (use --no-exports to disable) -v, --version Display version number -c, --config specify the configuration file, can be a relative or absolute path --config-loader Set the config file loader (auto | jiti | native) (default: auto) diff --git a/tests/integration/exports/__fixtures__/src/index.ts b/tests/integration/exports/__fixtures__/src/index.ts new file mode 100644 index 0000000000..f7ef6a6c87 --- /dev/null +++ b/tests/integration/exports/__fixtures__/src/index.ts @@ -0,0 +1 @@ +export const index = 'index'; diff --git a/tests/integration/exports/__fixtures__/src/utils/numbers.ts b/tests/integration/exports/__fixtures__/src/utils/numbers.ts new file mode 100644 index 0000000000..98c490e9c1 --- /dev/null +++ b/tests/integration/exports/__fixtures__/src/utils/numbers.ts @@ -0,0 +1 @@ +export const numbers = 'numbers'; diff --git a/tests/integration/exports/__fixtures__/tsconfig.json b/tests/integration/exports/__fixtures__/tsconfig.json new file mode 100644 index 0000000000..0facab787a --- /dev/null +++ b/tests/integration/exports/__fixtures__/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@rslib/tsconfig/base", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/tests/integration/exports/build-error/package.json b/tests/integration/exports/build-error/package.json new file mode 100644 index 0000000000..76e2e4f398 --- /dev/null +++ b/tests/integration/exports/build-error/package.json @@ -0,0 +1,5 @@ +{ + "name": "exports-build-error-test", + "version": "1.0.0", + "private": true +} diff --git a/tests/integration/exports/build-error/rslib.config.ts b/tests/integration/exports/build-error/rslib.config.ts new file mode 100644 index 0000000000..a79eb97f05 --- /dev/null +++ b/tests/integration/exports/build-error/rslib.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig()], + source: { + entry: { + index: './src/index.ts', + }, + }, +}); diff --git a/tests/integration/exports/build-error/src/index.ts b/tests/integration/exports/build-error/src/index.ts new file mode 100644 index 0000000000..2fb78053a3 --- /dev/null +++ b/tests/integration/exports/build-error/src/index.ts @@ -0,0 +1,3 @@ +import { missing } from './missing'; + +export const index = missing; diff --git a/tests/integration/exports/bundleless/package.json b/tests/integration/exports/bundleless/package.json new file mode 100644 index 0000000000..d83e38342f --- /dev/null +++ b/tests/integration/exports/bundleless/package.json @@ -0,0 +1,5 @@ +{ + "name": "exports-bundleless-test", + "version": "1.0.0", + "private": true +} diff --git a/tests/integration/exports/bundleless/rslib.config.ts b/tests/integration/exports/bundleless/rslib.config.ts new file mode 100644 index 0000000000..b7fe858bf2 --- /dev/null +++ b/tests/integration/exports/bundleless/rslib.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [ + generateBundleEsmConfig({ + bundle: false, + }), + ], + source: { + entry: { + index: ['../__fixtures__/src/**'], + }, + }, +}); diff --git a/tests/integration/exports/dts/bundleless/package.json b/tests/integration/exports/dts/bundleless/package.json new file mode 100644 index 0000000000..5650470bdd --- /dev/null +++ b/tests/integration/exports/dts/bundleless/package.json @@ -0,0 +1,6 @@ +{ + "name": "exports-dts-bundleless-test", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/tests/integration/exports/dts/bundleless/rslib.config.ts b/tests/integration/exports/dts/bundleless/rslib.config.ts new file mode 100644 index 0000000000..f0bef2e2cb --- /dev/null +++ b/tests/integration/exports/dts/bundleless/rslib.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [ + generateBundleEsmConfig({ bundle: false, dts: true }), + generateBundleCjsConfig({ bundle: false, dts: true }), + ], + source: { + entry: { + index: ['../../__fixtures__/src/**'], + }, + tsconfigPath: '../../__fixtures__/tsconfig.json', + }, +}); diff --git a/tests/integration/exports/dts/dist-path/package.json b/tests/integration/exports/dts/dist-path/package.json new file mode 100644 index 0000000000..170cd7e358 --- /dev/null +++ b/tests/integration/exports/dts/dist-path/package.json @@ -0,0 +1,6 @@ +{ + "name": "exports-dts-dist-path-test", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/tests/integration/exports/dts/dist-path/rslib.config.ts b/tests/integration/exports/dts/dist-path/rslib.config.ts new file mode 100644 index 0000000000..2f70f4a0f2 --- /dev/null +++ b/tests/integration/exports/dts/dist-path/rslib.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig({ dts: { distPath: './dist/types' } })], + source: { + entry: { + index: '../../__fixtures__/src/index.ts', + }, + tsconfigPath: '../../__fixtures__/tsconfig.json', + }, +}); diff --git a/tests/integration/exports/dts/matched/package.json b/tests/integration/exports/dts/matched/package.json new file mode 100644 index 0000000000..1cac2c101f --- /dev/null +++ b/tests/integration/exports/dts/matched/package.json @@ -0,0 +1,5 @@ +{ + "name": "exports-dts-matched-test", + "version": "1.0.0", + "private": true +} diff --git a/tests/integration/exports/dts/matched/rslib.config.ts b/tests/integration/exports/dts/matched/rslib.config.ts new file mode 100644 index 0000000000..23156e22b7 --- /dev/null +++ b/tests/integration/exports/dts/matched/rslib.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [ + generateBundleEsmConfig({ dts: { autoExtension: true } }), + generateBundleCjsConfig({ dts: { autoExtension: true } }), + ], + source: { + entry: { + index: '../../__fixtures__/src/index.ts', + }, + tsconfigPath: '../../__fixtures__/tsconfig.json', + }, +}); diff --git a/tests/integration/exports/dts/module/package.json b/tests/integration/exports/dts/module/package.json new file mode 100644 index 0000000000..2d8aaf83c5 --- /dev/null +++ b/tests/integration/exports/dts/module/package.json @@ -0,0 +1,6 @@ +{ + "name": "exports-dts-module-test", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/tests/integration/exports/dts/module/rslib.config.ts b/tests/integration/exports/dts/module/rslib.config.ts new file mode 100644 index 0000000000..c590e4f40d --- /dev/null +++ b/tests/integration/exports/dts/module/rslib.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig({ dts: true })], + source: { + entry: { + index: '../../__fixtures__/src/index.ts', + }, + tsconfigPath: '../../__fixtures__/tsconfig.json', + }, +}); diff --git a/tests/integration/exports/dts/shared/package.json b/tests/integration/exports/dts/shared/package.json new file mode 100644 index 0000000000..8baf40428c --- /dev/null +++ b/tests/integration/exports/dts/shared/package.json @@ -0,0 +1,6 @@ +{ + "name": "exports-dts-shared-test", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/tests/integration/exports/dts/shared/rslib.config.ts b/tests/integration/exports/dts/shared/rslib.config.ts new file mode 100644 index 0000000000..14201b2a88 --- /dev/null +++ b/tests/integration/exports/dts/shared/rslib.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [ + generateBundleEsmConfig({ dts: true }), + generateBundleCjsConfig({ dts: true }), + ], + source: { + entry: { + index: '../../__fixtures__/src/index.ts', + }, + tsconfigPath: '../../__fixtures__/tsconfig.json', + }, +}); diff --git a/tests/integration/exports/dual-format/package.json b/tests/integration/exports/dual-format/package.json new file mode 100644 index 0000000000..0a19681c5d --- /dev/null +++ b/tests/integration/exports/dual-format/package.json @@ -0,0 +1,5 @@ +{ + "name": "exports-dual-format-test", + "version": "1.0.0", + "private": true +} diff --git a/tests/integration/exports/dual-format/rslib.config.ts b/tests/integration/exports/dual-format/rslib.config.ts new file mode 100644 index 0000000000..8bbf72faac --- /dev/null +++ b/tests/integration/exports/dual-format/rslib.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig(), generateBundleCjsConfig()], + source: { + entry: { + index: '../__fixtures__/src/index.ts', + }, + }, +}); diff --git a/tests/integration/exports/esm-only/package.json b/tests/integration/exports/esm-only/package.json new file mode 100644 index 0000000000..fcf15437d0 --- /dev/null +++ b/tests/integration/exports/esm-only/package.json @@ -0,0 +1,5 @@ +{ + "name": "exports-esm-only-test", + "version": "1.0.0", + "private": true +} diff --git a/tests/integration/exports/esm-only/rslib.config.ts b/tests/integration/exports/esm-only/rslib.config.ts new file mode 100644 index 0000000000..f47cc32a42 --- /dev/null +++ b/tests/integration/exports/esm-only/rslib.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig()], + source: { + entry: { + index: '../__fixtures__/src/index.ts', + }, + }, +}); diff --git a/tests/integration/exports/index.test.ts b/tests/integration/exports/index.test.ts new file mode 100644 index 0000000000..99ba34ea53 --- /dev/null +++ b/tests/integration/exports/index.test.ts @@ -0,0 +1,326 @@ +import { createRslib } from '@rslib/core'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, describe, expect, test } from '@rstest/core'; +import { + buildAndGetResults, + expectBuildToFail, + expectPoll, + rslibBuild, +} from 'test-helper'; + +const fixtures = [ + 'dual-format', + 'esm-only', + 'multiple-entries', + 'bundleless', + 'umd', + 'build-error', + 'dts/matched', + 'dts/shared', + 'dts/dist-path', + 'dts/module', + 'dts/bundleless', +]; + +// The "exports" field is written back to the fixture package.json after +// building, keep a backup and restore it once the tests are done. +const packageJsonContents = new Map( + fixtures.map((fixture) => [ + fixture, + readFileSync(join(__dirname, fixture, 'package.json'), 'utf8'), + ]), +); + +afterAll(() => { + for (const [fixture, content] of packageJsonContents) { + writeFileSync(join(__dirname, fixture, 'package.json'), content); + } +}); + +const readFixtureExports = (fixture: string) => + JSON.parse(readFileSync(join(__dirname, fixture, 'package.json'), 'utf8')) + .exports; + +describe('exports', () => { + test('should generate import and require conditions when building esm and cjs formats', async () => { + const fixturePath = join(__dirname, 'dual-format'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dual-format')).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + }); + }); + + test('should generate a string value when building a single format', async () => { + const fixturePath = join(__dirname, 'esm-only'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('esm-only')).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + test('should generate exports in watch mode', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + const packageJsonPath = join(fixturePath, 'package.json'); + writeFileSync(packageJsonPath, packageJsonContents.get(fixture)!); + + const rslib = await createRslib({ + cwd: fixturePath, + config: { + lib: [{ format: 'esm', output: { distPath: 'dist/esm' } }], + source: { entry: { index: '../__fixtures__/src/index.ts' } }, + exports: true, + logLevel: 'silent', + }, + }); + const buildResult = await rslib.build({ watch: true }); + + try { + await expectPoll(() => + readFileSync(packageJsonPath, 'utf8').includes('"exports"'), + ).toBeTruthy(); + expect(readFixtureExports(fixture)).toEqual({ + '.': './dist/esm/index.mjs', + }); + } finally { + await buildResult.close(); + } + }); + + test('should read the exports option from the config file', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + writeFileSync( + join(fixturePath, 'package.json'), + packageJsonContents.get(fixture)!, + ); + + await rslibBuild({ + cwd: fixturePath, + modifyConfig: (config) => { + config.exports = true; + }, + }); + + expect(readFixtureExports(fixture)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + test('should let the build option override the exports config', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + writeFileSync( + join(fixturePath, 'package.json'), + packageJsonContents.get(fixture)!, + ); + + await rslibBuild({ + cwd: fixturePath, + modifyConfig: (config) => { + config.exports = true; + }, + exports: false, + }); + + expect(readFixtureExports(fixture)).toBeUndefined(); + }); + + test('should keep user conditions of an existing subpath', async () => { + const fixture = 'dual-format'; + const fixturePath = join(__dirname, fixture); + writeFileSync( + join(fixturePath, 'package.json'), + `${JSON.stringify( + { + name: 'exports-dual-format-test', + exports: { + '.': { + '@acme/source': './src/index.ts', + import: './dist/esm/old.mjs', + require: './dist/cjs/old.js', + }, + }, + }, + null, + 2, + )}\n`, + ); + + await buildAndGetResults({ fixturePath, exports: true }); + + const exports = readFixtureExports(fixture); + expect(Object.keys(exports['.'])).toEqual([ + '@acme/source', + 'import', + 'require', + ]); + expect(exports).toEqual({ + '.': { + '@acme/source': './src/index.ts', + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + }); + }); + + test('should merge into existing exports and preserve the formatting', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + const packageJsonPath = join(fixturePath, 'package.json'); + writeFileSync( + packageJsonPath, + [ + '{', + '\t"name": "exports-esm-only-test",', + '\t"exports": {', + '\t\t"./package.json": "./package.json",', + '\t\t".": "./dist/esm/old.mjs"', + '\t}', + '}', + '', + ].join('\n'), + ); + + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFileSync(packageJsonPath, 'utf8')).toBe( + [ + '{', + '\t"name": "exports-esm-only-test",', + '\t"exports": {', + '\t\t"./package.json": "./package.json",', + '\t\t".": "./dist/esm/index.mjs"', + '\t}', + '}', + '', + ].join('\n'), + ); + }); + + test('should fail when the existing exports is not a subpath map', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + const packageJsonPath = join(fixturePath, 'package.json'); + const packageJson = `${JSON.stringify( + { name: 'exports-esm-only-test', exports: './index.js' }, + null, + 2, + )}\n`; + writeFileSync(packageJsonPath, packageJson); + + const error = await expectBuildToFail( + buildAndGetResults({ fixturePath, exports: true }), + ); + + expect(error.message).toContain('is not a subpath map'); + expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson); + }); + + test('should expose each entry as a subpath', async () => { + const fixturePath = join(__dirname, 'multiple-entries'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('multiple-entries')).toEqual({ + '.': './dist/esm/index.mjs', + './utils/numbers': './dist/esm/utils/numbers.mjs', + }); + }); + + test('should only expose the main entry in bundleless mode', async () => { + const fixturePath = join(__dirname, 'bundleless'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('bundleless')).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + test('should fail when building only the umd format', async () => { + const fixture = 'umd'; + const fixturePath = join(__dirname, fixture); + const error = await expectBuildToFail( + buildAndGetResults({ fixturePath, exports: true }), + ); + + expect(error.message).toContain('no entries can be exported'); + expect(readFileSync(join(fixturePath, 'package.json'), 'utf8')).toBe( + packageJsonContents.get(fixture), + ); + }); + + test('should not generate exports when the build fails', async () => { + const fixturePath = join(__dirname, 'build-error'); + await expectBuildToFail(buildAndGetResults({ fixturePath, exports: true })); + + expect(readFixtureExports('build-error')).toBeUndefined(); + }); + + test('should generate exports when declaration files match the outputs', async () => { + const fixturePath = join(__dirname, 'dts/matched'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dts/matched')).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + }); + }); + + test('should not add types conditions when the declaration file matches a single format output', async () => { + const fixturePath = join(__dirname, 'dts/module'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dts/module')).toEqual({ + '.': './dist/esm/index.js', + }); + }); + + test('should share one declaration file when the extensions do not match', async () => { + const fixturePath = join(__dirname, 'dts/shared'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dts/shared')).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + require: './dist/cjs/index.cjs', + }, + }); + }); + + test('should share one declaration file for the main entry in bundleless mode', async () => { + const fixturePath = join(__dirname, 'dts/bundleless'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dts/bundleless')).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + require: './dist/cjs/index.cjs', + }, + }); + }); + + test('should fail when declaration files are not emitted next to the outputs', async () => { + const fixture = 'dts/dist-path'; + const fixturePath = join(__dirname, fixture); + const error = await expectBuildToFail( + buildAndGetResults({ fixturePath, exports: true }), + ); + + expect(error.message).toContain( + 'no declaration files were found next to the outputs of the following libs that emit declaration files: "esm"', + ); + expect(readFileSync(join(fixturePath, 'package.json'), 'utf8')).toBe( + packageJsonContents.get(fixture), + ); + }); +}); diff --git a/tests/integration/exports/multiple-entries/package.json b/tests/integration/exports/multiple-entries/package.json new file mode 100644 index 0000000000..a0d184502e --- /dev/null +++ b/tests/integration/exports/multiple-entries/package.json @@ -0,0 +1,5 @@ +{ + "name": "exports-multiple-entries-test", + "version": "1.0.0", + "private": true +} diff --git a/tests/integration/exports/multiple-entries/rslib.config.ts b/tests/integration/exports/multiple-entries/rslib.config.ts new file mode 100644 index 0000000000..f581107036 --- /dev/null +++ b/tests/integration/exports/multiple-entries/rslib.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig()], + source: { + entry: { + index: '../__fixtures__/src/index.ts', + 'utils/numbers': '../__fixtures__/src/utils/numbers.ts', + }, + }, +}); diff --git a/tests/integration/exports/umd/package.json b/tests/integration/exports/umd/package.json new file mode 100644 index 0000000000..166706732c --- /dev/null +++ b/tests/integration/exports/umd/package.json @@ -0,0 +1,5 @@ +{ + "name": "exports-umd-test", + "version": "1.0.0", + "private": true +} diff --git a/tests/integration/exports/umd/rslib.config.ts b/tests/integration/exports/umd/rslib.config.ts new file mode 100644 index 0000000000..2c0f5dbcdb --- /dev/null +++ b/tests/integration/exports/umd/rslib.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleUmdConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleUmdConfig()], + source: { + entry: { + index: '../__fixtures__/src/index.ts', + }, + }, +}); diff --git a/tests/scripts/shared.ts b/tests/scripts/shared.ts index 238643bfd6..35895ce1ce 100644 --- a/tests/scripts/shared.ts +++ b/tests/scripts/shared.ts @@ -270,11 +270,13 @@ export async function rslibBuild({ path, modifyConfig, lib, + exports, }: { cwd: string; path?: string; modifyConfig?: (config: RslibConfig) => void; lib?: string[]; + exports?: boolean; }) { const result = await loadConfig({ cwd, @@ -289,6 +291,7 @@ export async function rslibBuild({ }); const buildResult = await rslib.build({ lib, + exports, }); return { rslib, buildResult, rslibConfig }; } @@ -299,6 +302,7 @@ export async function buildAndGetResults(options: { type: 'all'; lib?: string[]; logLevel?: LogLevel; + exports?: boolean; }): Promise<{ js: BuildResult; dts: BuildResult; @@ -310,6 +314,7 @@ export async function buildAndGetResults(options: { type?: 'js' | 'dts' | 'css'; lib?: string[]; logLevel?: LogLevel; + exports?: boolean; }): Promise; export async function buildAndGetResults({ fixturePath, @@ -317,18 +322,21 @@ export async function buildAndGetResults({ type = 'js', lib, logLevel = 'warn', + exports, }: { fixturePath: string; configPath?: string; type?: 'js' | 'dts' | 'css' | 'all'; lib?: string[]; logLevel?: LogLevel; + exports?: boolean; }) { const { rslib, buildResult, rslibConfig } = await rslibBuild({ cwd: fixturePath, path: configPath, modifyConfig: updateConfigForTest(logLevel), lib, + exports, }); const { origin: { bundlerConfigs, rsbuildConfig }, diff --git a/website/docs/en/api/javascript-api/instance.mdx b/website/docs/en/api/javascript-api/instance.mdx index 56f8c51148..d19864e94d 100644 --- a/website/docs/en/api/javascript-api/instance.mdx +++ b/website/docs/en/api/javascript-api/instance.mdx @@ -24,6 +24,13 @@ type BuildOptions = { * @default false */ watch?: boolean; + /** + * Whether to generate the `exports` field in `package.json` from the build outputs. + * Overrides the `exports` option in the configuration file. + * + * @default the `exports` option in the configuration file + */ + exports?: boolean; }; function Build(options?: BuildOptions): Promise<{ @@ -85,6 +92,16 @@ await rslib.build({ }); ``` +### Generate exports + +To generate the `exports` field in `package.json` after the build, set the `exports` option to `true`. See [Generate exports](/guide/advanced/release-management#generate-exports) for the rules. + +```ts +await rslib.build({ + exports: true, +}); +``` + ### Close build `rslib.build()` returns a `close()` method that stops the build process. diff --git a/website/docs/en/guide/advanced/release-management.mdx b/website/docs/en/guide/advanced/release-management.mdx index 0e2f2559c7..a2a5a40266 100644 --- a/website/docs/en/guide/advanced/release-management.mdx +++ b/website/docs/en/guide/advanced/release-management.mdx @@ -46,6 +46,56 @@ Pay particular attention to the following fields: Also make sure that the package does not set `private: true`. It is also recommended to add `description`, `license`, and `repository` so users can understand and locate the project on npm. +### Generate exports + +Instead of maintaining the `exports` field by hand, you can run `rslib build --exports`, or set `exports: true` in the configuration file, to generate it from the build outputs after a successful build. `--no-exports` disables it for a single build. The rules are as follows: + +- Only outputs in the `esm` and `cjs` formats are exported. +- The `index` entry is exported as `.`, and other entries are exported by their entry names, such as `./utils/numbers`. +- In bundleless mode, only the `index` entry in the root of [lib.outBase](/config/lib/out-base) is exported. +- When an entry has both `esm` and `cjs` outputs, the value uses the `import` and `require` conditions. When it has only one format, the value is the output path. + +For example, building `esm` and `cjs` libs with an `index` entry generates: + +```json title="package.json" +{ + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } +} +``` + +The result is merged into the existing `exports` field. For matching subpaths, generated `import` and `require` conditions replace the previous format conditions. Existing `types` and `default` targets are kept unless this build generates replacements. Other conditions, such as `browser` or a custom source condition, and other subpaths are kept. A warning is shown when an existing subpath changes or points to a missing output. The original indentation and line endings are preserved, and the file is not written when nothing changes. + +In watch mode, the `exports` field is generated again after each successful build. Writing `package.json` triggers one more build, which stops once the field no longer changes. + +#### Declaration files + +For libs with [dts](/config/lib/dts) enabled, Rslib checks the declaration files next to the outputs: + +- When the outputs of every format have declaration files with the matching extension next to them (`.d.ts` for `.js`, `.d.mts` for `.mjs`, and `.d.cts` for `.cjs`), no `types` condition is added, as TypeScript finds them on its own. +- Otherwise, a `types` condition shared by all formats is added, preferring the declaration file with the matching extension. +- When none of the outputs of a lib have declaration files next to them, the build fails, such as when declaration files are emitted to a separate [dts.distPath](/config/lib/dts#dtsdistpath). + +For example, in a package with `"type": "module"`, when both the `esm` and `cjs` libs enable `dts: true`, the `cjs` output `index.cjs` only has `index.d.ts` next to it instead of the matching `index.d.cts`, so the following is generated: + +```json title="package.json" +{ + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } +} +``` + +To emit declaration files with the matching extension for every format, enable [dts.autoExtension](/config/lib/dts#dtsautoextension) and output each format to a separate directory. + ## pnpm version management pnpm provides [versioning and publishing features](https://pnpm.io/versioning) for recording changes, updating package versions, generating changelogs, synchronizing dependency versions between workspace packages, and publishing npm packages. diff --git a/website/docs/en/guide/basic/cli.mdx b/website/docs/en/guide/basic/cli.mdx index 1b2400e554..3fd4388adf 100644 --- a/website/docs/en/guide/basic/cli.mdx +++ b/website/docs/en/guide/basic/cli.mdx @@ -65,6 +65,7 @@ Options: --auto-extension control automatic extension redirect (use --no-auto-extension to disable) --auto-external control automatic dependency externalization (use --no-auto-external to disable) --tsconfig use specific tsconfig (relative to project root) + --exports generate the exports field in package.json from build outputs (use --no-exports to disable) ``` :::note diff --git a/website/docs/zh/api/javascript-api/instance.mdx b/website/docs/zh/api/javascript-api/instance.mdx index 7c00233002..aae074589b 100644 --- a/website/docs/zh/api/javascript-api/instance.mdx +++ b/website/docs/zh/api/javascript-api/instance.mdx @@ -24,6 +24,12 @@ type BuildOptions = { * @default false */ watch?: boolean; + /** + * 是否根据构建产物生成 package.json 中的 exports 字段,会覆盖配置文件中的 exports 选项 + * + * @default 配置文件中的 exports 选项 + */ + exports?: boolean; }; function Build(options?: BuildOptions): Promise<{ @@ -85,6 +91,16 @@ await rslib.build({ }); ``` +### 生成 exports \{#generate-exports} + +如果需要在构建后生成 `package.json` 中的 `exports` 字段,可以将 `exports` 参数设置为 `true`,生成规则可以参考 [生成 exports](/guide/advanced/release-management#generate-exports)。 + +```ts +await rslib.build({ + exports: true, +}); +``` + ### 结束构建 `rslib.build()` 返回一个 `close()` 方法,用于结束本次构建。 diff --git a/website/docs/zh/guide/advanced/release-management.mdx b/website/docs/zh/guide/advanced/release-management.mdx index 3b41e76bf0..5b2df9b860 100644 --- a/website/docs/zh/guide/advanced/release-management.mdx +++ b/website/docs/zh/guide/advanced/release-management.mdx @@ -46,6 +46,56 @@ description: '介绍 Rslib 项目的 npm 包版本管理与发布实践,包括 此外,需要确保包没有设置 `private: true`,并建议补充 `description`、`license`、`repository` 等信息,方便用户在 npm 上了解和定位项目。 +### 生成 exports \{#generate-exports} + +除了手动维护 `exports` 字段,也可以执行 `rslib build --exports`,或在配置文件中设置 `exports: true`,在构建成功后根据产物自动生成。`--no-exports` 可以在单次构建中关闭生成。生成规则如下: + +- 只导出 `esm` 和 `cjs` 格式的产物。 +- 入口 `index` 导出为 `.`,其他入口以入口名导出,例如 `./utils/numbers`。 +- bundleless 模式下只导出 [lib.outBase](/config/lib/out-base) 根目录下的 `index` 入口。 +- 同一入口同时有 `esm` 和 `cjs` 产物时,值为 `import` 和 `require` 条件;只有一种格式时,值为产物路径。 + +例如,构建包含 `index` 入口的 `esm` 和 `cjs` 产物时会生成: + +```json title="package.json" +{ + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } +} +``` + +生成结果会合并到已有的 `exports` 字段中。同名子路径中的 `import`、`require` 条件按本轮格式更新;已有的 `types` 和 `default` 目标在本轮生成替代值时更新,否则保留。其他条件(如 `browser` 或自定义源码条件)和其他子路径保持不变。已有子路径发生变化或指向缺失产物时,会输出警告。写入时保留原有缩进和换行符,内容没有变化时跳过写入。 + +在 watch 模式下,每次构建成功后都会重新生成 `exports` 字段。写入 `package.json` 会再触发一次构建,字段不再变化后即停止。 + +#### 类型声明文件 \{#declaration-files} + +对于开启了 [dts](/config/lib/dts) 的 lib,Rslib 会检查产物旁的类型声明文件: + +- 每种格式的产物旁都有扩展名对应的类型声明文件时(`.js` 对应 `.d.ts`,`.mjs` 对应 `.d.mts`,`.cjs` 对应 `.d.cts`),不添加 `types` 条件,TypeScript 会自行找到它们。 +- 否则添加一个所有格式共用的 `types` 条件,优先指向扩展名对应的类型声明文件。 +- 某个 lib 的所有产物旁都没有类型声明文件时,构建失败,例如类型声明文件输出到了单独的 [dts.distPath](/config/lib/dts#dtsdistpath)。 + +例如,在设置了 `"type": "module"` 的包中,`esm` 和 `cjs` 都开启 `dts: true` 时,`cjs` 产物 `index.cjs` 旁只有 `index.d.ts`,不是扩展名对应的 `index.d.cts`,因此会生成: + +```json title="package.json" +{ + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } +} +``` + +如需每种格式都有扩展名对应的类型声明文件,可以开启 [dts.autoExtension](/config/lib/dts#dtsautoextension),并将各格式输出到不同的目录。 + ## pnpm 版本管理 \{#pnpm-version-management} pnpm 提供了 [发布管理功能](https://pnpm.io/versioning),支持记录变更、更新包版本、生成 changelog、同步更新 workspace 包之间的依赖版本,以及发布 npm 包。 diff --git a/website/docs/zh/guide/basic/cli.mdx b/website/docs/zh/guide/basic/cli.mdx index aa777ecb97..12015ec29b 100644 --- a/website/docs/zh/guide/basic/cli.mdx +++ b/website/docs/zh/guide/basic/cli.mdx @@ -65,6 +65,7 @@ Options: --auto-extension 控制自动补全扩展名重定向(使用 --no-auto-extension 禁用) --auto-external 控制自动外部化依赖(使用 --no-auto-external 禁用) --tsconfig 使用指定的 tsconfig(相对于项目根目录) + --exports 根据构建产物生成 package.json 中的 exports 字段(使用 --no-exports 关闭) ``` :::note From e502553e31fe84710fac6a2e1885a8d04dd36fec Mon Sep 17 00:00:00 2001 From: Elecmonkey Date: Tue, 27 Oct 2026 15:09:48 +0800 Subject: [PATCH 2/7] refactor(core): do not generate exports in watch mode --- packages/core/src/createRslib.ts | 6 +- packages/core/src/exports/plugin.ts | 30 +---- packages/core/tests/exportsPlugin.test.ts | 82 +++---------- packages/plugin-dts/src/apiOld.ts | 82 ++++++------- packages/plugin-dts/src/dts.ts | 6 +- packages/plugin-dts/tests/apiOldWatch.test.ts | 114 ------------------ tests/integration/exports/index.test.ts | 26 ++-- .../en/guide/advanced/release-management.mdx | 2 +- .../zh/guide/advanced/release-management.mdx | 2 +- 9 files changed, 88 insertions(+), 262 deletions(-) delete mode 100644 packages/plugin-dts/tests/apiOldWatch.test.ts diff --git a/packages/core/src/createRslib.ts b/packages/core/src/createRslib.ts index beb685cf84..3b069c3fa1 100644 --- a/packages/core/src/createRslib.ts +++ b/packages/core/src/createRslib.ts @@ -239,12 +239,14 @@ export async function createRslib( pruneEnvironments(environments, buildOptions.lib), ); - if (buildOptions.exports ?? config.exports) { + // The `exports` field describes the published package, so it is only + // generated by one-off builds. In watch mode, declaration files may still + // be in progress when the build finishes. + if ((buildOptions.exports ?? config.exports) && !buildOptions.watch) { rsbuildInstance.addPlugins([ ExportsPlugin({ root: config.root!, libInfos: environmentWithInfos, - watch: Boolean(buildOptions.watch), }), ]); } diff --git a/packages/core/src/exports/plugin.ts b/packages/core/src/exports/plugin.ts index 571c8ff175..8d14636a8a 100644 --- a/packages/core/src/exports/plugin.ts +++ b/packages/core/src/exports/plugin.ts @@ -16,19 +16,13 @@ import { export type ExportsPluginOptions = { root: string; libInfos: RequireKey[]; - watch: boolean; }; export const ExportsPlugin = ({ root, libInfos, - watch, }: ExportsPluginOptions): RsbuildPlugin => { - // Tracks the last failure so that a recovery is reported even when - // package.json does not change. - let failed = false; - - const writeExports = (stats: Rspack.Stats | Rspack.MultiStats): boolean => { + const writeExports = (stats: Rspack.Stats | Rspack.MultiStats) => { const statsList = 'stats' in stats ? stats.stats : [stats]; const collected = new Map(); const outputPaths: string[] = []; @@ -96,11 +90,10 @@ export const ExportsPlugin = ({ { ...pkgJson, exports: exportsField }, originalText, ); - if (text === originalText) return false; + if (text === originalText) return; fs.writeFileSync(pkgJsonPath, text); logger.info('generated "exports" field in package.json.'); - return true; }; return { @@ -108,25 +101,8 @@ export const ExportsPlugin = ({ setup(api) { api.onAfterBuild(({ stats }) => { // Failed builds already report errors, so there is nothing to export. - // In watch mode, the next successful build generates it again. if (!stats || stats.hasErrors()) return; - - try { - const written = writeExports(stats); - if (failed && !written) { - logger.info('"exports" field in package.json is up to date.'); - } - failed = false; - } catch (error) { - if (!watch) throw error; - // The build output is unaffected, so keep watching and retry on the - // next rebuild. - failed = true; - logger.error( - 'Failed to update "exports" field in package.json, will retry on the next rebuild.', - ); - logger.error(error); - } + writeExports(stats); }); }, }; diff --git a/packages/core/tests/exportsPlugin.test.ts b/packages/core/tests/exportsPlugin.test.ts index 2165ad61d0..339d519897 100644 --- a/packages/core/tests/exportsPlugin.test.ts +++ b/packages/core/tests/exportsPlugin.test.ts @@ -4,7 +4,6 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { ExportsPlugin } from '../src/exports/plugin'; -import { logger } from '../src/utils/logger'; rs.mock('../src/utils/logger', () => ({ logger: { info: rs.fn(), warn: rs.fn(), error: rs.fn() }, @@ -18,7 +17,7 @@ afterEach(() => { } }); -function createFixture(watch: boolean) { +function createFixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rslib-exports-plugin-')); roots.push(root); const pkgPath = path.join(root, 'package.json'); @@ -26,78 +25,33 @@ function createFixture(watch: boolean) { ExportsPlugin({ root, libInfos: [{ id: 'esm', format: 'esm', config: {} }], - watch, }).setup({ onAfterBuild: (callback: (typeof callbacks)[number]) => { callbacks.push(callback); }, } as unknown as RsbuildPluginAPI); - const stats = () => { - const errors: Error[] = []; - return { - hasErrors: () => errors.length > 0, - compilation: { - name: 'esm', - errors, - outputOptions: { path: path.join(root, 'dist') }, - entrypoints: new Map([ - [ - 'index', - { getEntrypointChunk: () => ({ files: new Set(['index.js']) }) }, - ], - ]), - }, - } as unknown as Rspack.Stats; - }; + const stats = { + hasErrors: () => false, + compilation: { + name: 'esm', + errors: [], + outputOptions: { path: path.join(root, 'dist') }, + entrypoints: new Map([ + [ + 'index', + { getEntrypointChunk: () => ({ files: new Set(['index.js']) }) }, + ], + ]), + }, + } as unknown as Rspack.Stats; return { pkgPath, run: callbacks[0]!, stats }; } describe('exports build failures', () => { - it('reports watch failures without failing the build and recovers on the next build', () => { - const { pkgPath, run, stats } = createFixture(true); - const original = JSON.stringify({ exports: './old.js' }); - fs.writeFileSync(pkgPath, original); - const failed = stats(); - - expect(() => run({ stats: failed })).not.toThrow(); - expect(failed.hasErrors()).toBe(false); - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('subpath map'), - }), - ); - expect(fs.readFileSync(pkgPath, 'utf8')).toBe(original); - - fs.writeFileSync(pkgPath, '{}'); - run({ stats: stats() }); - expect(JSON.parse(fs.readFileSync(pkgPath, 'utf8')).exports).toEqual({ - '.': './dist/index.js', - }); - }); - - it('reports recovery once when package.json is already up to date', () => { - const { pkgPath, run, stats } = createFixture(true); - fs.writeFileSync(pkgPath, JSON.stringify({ exports: './old.js' })); - run({ stats: stats() }); - - const upToDate = `${JSON.stringify({ exports: { '.': './dist/index.js' } }, null, 2)}\n`; - fs.writeFileSync(pkgPath, upToDate); - run({ stats: stats() }); - run({ stats: stats() }); - expect(fs.readFileSync(pkgPath, 'utf8')).toBe(upToDate); - const recoveryLogs = rs - .mocked(logger.info) - .mock.calls.filter( - ([message]) => - message === '"exports" field in package.json is up to date.', - ); - expect(recoveryLogs).toHaveLength(1); - }); - - it('throws exports errors in a one-off build', () => { - const { pkgPath, run, stats } = createFixture(false); + it('throws exports errors', () => { + const { pkgPath, run, stats } = createFixture(); fs.writeFileSync(pkgPath, JSON.stringify({ exports: './old.js' })); - expect(() => run({ stats: stats() })).toThrow('subpath map'); + expect(() => run({ stats })).toThrow('subpath map'); }); }); diff --git a/packages/plugin-dts/src/apiOld.ts b/packages/plugin-dts/src/apiOld.ts index cc49b3d99b..bcfff9cd1f 100644 --- a/packages/plugin-dts/src/apiOld.ts +++ b/packages/plugin-dts/src/apiOld.ts @@ -109,7 +109,7 @@ async function handleDiagnosticsAndProcessFiles( export async function emitDtsWithOldApi( options: EmitDtsOptions, - onComplete: (isSuccess: boolean) => void | Promise, + onComplete: (isSuccess: boolean) => void, bundle = false, isWatch = false, build = false, @@ -155,17 +155,7 @@ export async function emitDtsWithOldApi( ); }; - let resolveFirstBuild!: () => void; - let rejectFirstBuild!: (error: unknown) => void; - const firstBuild = isWatch - ? new Promise((resolve, reject) => { - resolveFirstBuild = resolve; - rejectFirstBuild = reject; - }) - : undefined; - let watchTask = Promise.resolve(); - - const reportWatchStatusChanged: WatchStatusReporter = ( + const reportWatchStatusChanged: WatchStatusReporter = async ( diagnostic: Diagnostic, _newLine: string, _options: CompilerOptions, @@ -182,35 +172,43 @@ export async function emitDtsWithOldApi( logger.info(logPrefixTsc, message); } - // TypeScript does not await status reporters. Track post-processing ourselves - // so the initial IPC success is sent after the final declarations are ready. - if (diagnostic.code === 6194 || diagnostic.code === 6193) { - const success = diagnostic.code === 6194 && !errorCount; - watchTask = watchTask - .then(async () => { - await processDtsFiles( - bundle, - cwd, - declarationDir, - dtsExtension, - redirect, - configPath, - rootDir, - paths, - banner, - footer, - ); - await onComplete(success); - if (!success) { - throw new Error(message); - } - logger.info(logPrefixTsc, message); - resolveFirstBuild(); - }) - .catch((error: unknown) => { - rejectFirstBuild(error); - logger.error(error); - }); + // 6194: 0 errors or 2+ errors! + if (diagnostic.code === 6194) { + if (errorCount === 0 || !errorCount) { + logger.info(logPrefixTsc, message); + onComplete(true); + } else { + logger.error(logPrefixTsc, message); + } + await processDtsFiles( + bundle, + cwd, + declarationDir, + dtsExtension, + redirect, + configPath, + rootDir, + paths, + banner, + footer, + ); + } + + // 6193: 1 error + if (diagnostic.code === 6193) { + logger.error(logPrefixTsc, message); + await processDtsFiles( + bundle, + cwd, + declarationDir, + dtsExtension, + redirect, + configPath, + rootDir, + paths, + banner, + footer, + ); } }; @@ -453,7 +451,5 @@ export async function emitDtsWithOldApi( solutionBuilder.build(); } - - await firstBuild; } } diff --git a/packages/plugin-dts/src/dts.ts b/packages/plugin-dts/src/dts.ts index cae0589a31..edd7a8b212 100644 --- a/packages/plugin-dts/src/dts.ts +++ b/packages/plugin-dts/src/dts.ts @@ -231,7 +231,11 @@ export async function generateDts(data: DtsGenOptions): Promise { const onComplete = async (isSuccess: boolean) => { if (isSuccess) { - await bundleDtsIfNeeded(data, preparedDtsContext); + try { + await bundleDtsIfNeeded(data, preparedDtsContext); + } catch (e) { + logger.error(e); + } } }; diff --git a/packages/plugin-dts/tests/apiOldWatch.test.ts b/packages/plugin-dts/tests/apiOldWatch.test.ts deleted file mode 100644 index 71c285f847..0000000000 --- a/packages/plugin-dts/tests/apiOldWatch.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, rs } from '@rstest/core'; -import type { WatchStatusReporter } from 'typescript6-api'; -import { emitDtsWithOldApi } from '../src/apiOld'; - -const mocks = rs.hoisted(() => ({ - reportStatus: undefined as WatchStatusReporter | undefined, - processDtsFiles: rs.fn(), - error: rs.fn(), -})); - -rs.mock('@rsbuild/core', () => ({ - logger: { info: rs.fn(), error: mocks.error }, -})); - -rs.mock('../src/utils', () => ({ - color: { dim: (value: string) => value }, - getTimeCost: rs.fn(), - renameDtsFile: rs.fn(), - updateDeclarationMapContent: rs.fn(), - processDtsFiles: mocks.processDtsFiles, - loadTypescript: () => ({ - sys: { getCurrentDirectory: () => '/project', newLine: '\n' }, - flattenDiagnosticMessageText: (message: string) => message, - createWatchCompilerHost: (...args: unknown[]) => { - mocks.reportStatus = args[5] as WatchStatusReporter; - return {}; - }, - createWatchProgram: () => {}, - }), -})); - -function deferred() { - let resolve!: () => void; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - -const options = { - configPath: '/project/tsconfig.json', - tsConfigResult: { options: {}, fileNames: [], errors: [] }, - declarationDir: '/project/dist', - name: 'esm', - dtsExtension: '.d.ts', - rootDir: '/project/src', - cwd: '/project', - paths: {}, - redirect: {}, -}; - -function reportSuccess() { - mocks.reportStatus!( - { code: 6194, messageText: 'Found 0 errors', category: 3 }, - '\n', - {}, - 0, - ); -} - -describe('tsc watch completion', () => { - beforeEach(() => { - mocks.processDtsFiles.mockReset(); - mocks.error.mockReset(); - }); - - it('waits for post-processing and declaration bundling before resolving', async () => { - const postprocess = deferred(); - const bundle = deferred(); - mocks.processDtsFiles.mockReturnValue(postprocess.promise); - const onComplete = rs.fn(() => bundle.promise); - let completed = false; - const task = emitDtsWithOldApi(options, onComplete, true, true).then(() => { - completed = true; - }); - - reportSuccess(); - await Promise.resolve(); - expect(mocks.processDtsFiles).toHaveBeenCalledTimes(1); - expect(onComplete).not.toHaveBeenCalled(); - expect(completed).toBe(false); - - postprocess.resolve(); - await Promise.resolve(); - await Promise.resolve(); - expect(onComplete).toHaveBeenCalledWith(true); - expect(completed).toBe(false); - - bundle.resolve(); - await task; - expect(completed).toBe(true); - }); - - it('reports an initial post-processing failure to the parent task', async () => { - const error = new Error('declaration processing failed'); - mocks.processDtsFiles.mockRejectedValue(error); - const task = emitDtsWithOldApi(options, rs.fn(), false, true); - reportSuccess(); - await expect(task).rejects.toThrow('declaration processing failed'); - }); - - it('reports an initial bundling failure to the parent task', async () => { - const task = emitDtsWithOldApi( - options, - async () => { - throw new Error('declaration bundling failed'); - }, - true, - true, - ); - reportSuccess(); - await expect(task).rejects.toThrow('declaration bundling failed'); - }); -}); diff --git a/tests/integration/exports/index.test.ts b/tests/integration/exports/index.test.ts index 99ba34ea53..42ada3b551 100644 --- a/tests/integration/exports/index.test.ts +++ b/tests/integration/exports/index.test.ts @@ -5,7 +5,6 @@ import { afterAll, describe, expect, test } from '@rstest/core'; import { buildAndGetResults, expectBuildToFail, - expectPoll, rslibBuild, } from 'test-helper'; @@ -64,12 +63,17 @@ describe('exports', () => { }); }); - test('should generate exports in watch mode', async () => { + test('should not generate exports in watch mode', async () => { const fixture = 'esm-only'; const fixturePath = join(__dirname, fixture); const packageJsonPath = join(fixturePath, 'package.json'); - writeFileSync(packageJsonPath, packageJsonContents.get(fixture)!); + const original = packageJsonContents.get(fixture)!; + writeFileSync(packageJsonPath, original); + let onFirstBuild!: () => void; + const firstBuild = new Promise((resolve) => { + onFirstBuild = resolve; + }); const rslib = await createRslib({ cwd: fixturePath, config: { @@ -77,17 +81,21 @@ describe('exports', () => { source: { entry: { index: '../__fixtures__/src/index.ts' } }, exports: true, logLevel: 'silent', + plugins: [ + { + name: 'test:first-build', + setup(api) { + api.onAfterBuild(() => onFirstBuild()); + }, + }, + ], }, }); const buildResult = await rslib.build({ watch: true }); try { - await expectPoll(() => - readFileSync(packageJsonPath, 'utf8').includes('"exports"'), - ).toBeTruthy(); - expect(readFixtureExports(fixture)).toEqual({ - '.': './dist/esm/index.mjs', - }); + await firstBuild; + expect(readFileSync(packageJsonPath, 'utf8')).toBe(original); } finally { await buildResult.close(); } diff --git a/website/docs/en/guide/advanced/release-management.mdx b/website/docs/en/guide/advanced/release-management.mdx index a2a5a40266..cefde13248 100644 --- a/website/docs/en/guide/advanced/release-management.mdx +++ b/website/docs/en/guide/advanced/release-management.mdx @@ -70,7 +70,7 @@ For example, building `esm` and `cjs` libs with an `index` entry generates: The result is merged into the existing `exports` field. For matching subpaths, generated `import` and `require` conditions replace the previous format conditions. Existing `types` and `default` targets are kept unless this build generates replacements. Other conditions, such as `browser` or a custom source condition, and other subpaths are kept. A warning is shown when an existing subpath changes or points to a missing output. The original indentation and line endings are preserved, and the file is not written when nothing changes. -In watch mode, the `exports` field is generated again after each successful build. Writing `package.json` triggers one more build, which stops once the field no longer changes. +The `exports` field is not generated in watch mode. Run `rslib build` to update it after the entries change. #### Declaration files diff --git a/website/docs/zh/guide/advanced/release-management.mdx b/website/docs/zh/guide/advanced/release-management.mdx index 5b2df9b860..3b794cf387 100644 --- a/website/docs/zh/guide/advanced/release-management.mdx +++ b/website/docs/zh/guide/advanced/release-management.mdx @@ -70,7 +70,7 @@ description: '介绍 Rslib 项目的 npm 包版本管理与发布实践,包括 生成结果会合并到已有的 `exports` 字段中。同名子路径中的 `import`、`require` 条件按本轮格式更新;已有的 `types` 和 `default` 目标在本轮生成替代值时更新,否则保留。其他条件(如 `browser` 或自定义源码条件)和其他子路径保持不变。已有子路径发生变化或指向缺失产物时,会输出警告。写入时保留原有缩进和换行符,内容没有变化时跳过写入。 -在 watch 模式下,每次构建成功后都会重新生成 `exports` 字段。写入 `package.json` 会再触发一次构建,字段不再变化后即停止。 +watch 模式下不会生成 `exports` 字段。入口变化后,执行 `rslib build` 即可更新。 #### 类型声明文件 \{#declaration-files} From 88429ef2ae9e233b7bed13618065a3f2d8ed20bc Mon Sep 17 00:00:00 2001 From: Elecmonkey Date: Tue, 27 Oct 2026 15:24:30 +0800 Subject: [PATCH 3/7] refactor(core): simplify exports generation and skip it with --lib --- packages/core/src/cli/init.ts | 4 --- packages/core/src/config.ts | 10 ++------ packages/core/src/createRslib.ts | 10 +++++--- packages/core/src/exports/dts.ts | 4 +-- packages/core/src/exports/packageJson.ts | 6 ++--- tests/integration/exports/index.test.ts | 25 +++++++++++++++++++ .../en/guide/advanced/release-management.mdx | 2 +- .../zh/guide/advanced/release-management.mdx | 2 +- 8 files changed, 40 insertions(+), 23 deletions(-) diff --git a/packages/core/src/cli/init.ts b/packages/core/src/cli/init.ts index dfda6b943b..5bf4b9cb24 100644 --- a/packages/core/src/cli/init.ts +++ b/packages/core/src/cli/init.ts @@ -100,10 +100,6 @@ export const applyCliOptions = ( config.logLevel = options.logLevel; } - if (options.exports !== undefined) { - config.exports = options.exports; - } - for (const lib of config.lib) { if (options.format !== undefined) lib.format = options.format; if (options.bundle !== undefined) lib.bundle = options.bundle; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index bb17d1d00d..3630302a32 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -2000,7 +2000,7 @@ export async function composeCreateRsbuildConfig( config.id = libConfig.id; } - if (userConfig.dts !== undefined && userConfig.dts !== false) { + if (userConfig.dts) { config.dts = userConfig.dts; } @@ -2055,13 +2055,7 @@ export async function composeRsbuildEnvironments( for (const { format, id, config, dts, bundle } of rsbuildConfigWithLibInfo) { const libId = typeof id === 'string' ? id : composeDefaultId(format); environments[libId] = config; - environmentWithInfos.push({ - id: libId, - format, - config, - ...(dts === undefined ? {} : { dts }), - ...(bundle === undefined ? {} : { bundle }), - }); + environmentWithInfos.push({ id: libId, format, config, dts, bundle }); } const conflictIds = usedIds.filter( diff --git a/packages/core/src/createRslib.ts b/packages/core/src/createRslib.ts index 3b069c3fa1..c2f9848d65 100644 --- a/packages/core/src/createRslib.ts +++ b/packages/core/src/createRslib.ts @@ -240,9 +240,13 @@ export async function createRslib( ); // The `exports` field describes the published package, so it is only - // generated by one-off builds. In watch mode, declaration files may still - // be in progress when the build finishes. - if ((buildOptions.exports ?? config.exports) && !buildOptions.watch) { + // generated by one-off builds of all libs. In watch mode, declaration files + // may still be in progress when the build finishes. + if ( + (buildOptions.exports ?? config.exports) && + !buildOptions.watch && + !buildOptions.lib?.length + ) { rsbuildInstance.addPlugins([ ExportsPlugin({ root: config.root!, diff --git a/packages/core/src/exports/dts.ts b/packages/core/src/exports/dts.ts index da6a54169a..403d0185ea 100644 --- a/packages/core/src/exports/dts.ts +++ b/packages/core/src/exports/dts.ts @@ -28,8 +28,8 @@ export function findDeclarationFile( root: string, ): DeclarationFile | undefined { const ext = path.extname(jsFile); - const matchedExt = MATCHED_DECLARATION_EXTENSIONS[ext]; - if (!matchedExt) return; + // Callers only pass JavaScript outputs, whose extensions are all listed. + const matchedExt = MATCHED_DECLARATION_EXTENSIONS[ext]!; const base = jsFile.slice(0, -ext.length); const declarationExt = [ diff --git a/packages/core/src/exports/packageJson.ts b/packages/core/src/exports/packageJson.ts index 9974d7b457..fc0721150b 100644 --- a/packages/core/src/exports/packageJson.ts +++ b/packages/core/src/exports/packageJson.ts @@ -50,10 +50,8 @@ function mergeSubpath( ); if (userKeys.length === 0) return generated; - const { default: generatedDefault, ...generatedConditions } = generatedValue; - const defaultTarget = Object.hasOwn(generatedValue, 'default') - ? generatedDefault - : existing.default; + const { default: defaultTarget = existing.default, ...generatedConditions } = + generatedValue; // Generated conditions take the place of the first generated condition in the // existing value, or go after the user conditions when there is none. diff --git a/tests/integration/exports/index.test.ts b/tests/integration/exports/index.test.ts index 42ada3b551..0a99d56ede 100644 --- a/tests/integration/exports/index.test.ts +++ b/tests/integration/exports/index.test.ts @@ -101,6 +101,31 @@ describe('exports', () => { } }); + test('should not generate exports when building some libs', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + const packageJsonPath = join(fixturePath, 'package.json'); + const original = packageJsonContents.get(fixture)!; + writeFileSync(packageJsonPath, original); + + const rslib = await createRslib({ + cwd: fixturePath, + config: { + lib: [ + { id: 'esm', format: 'esm', output: { distPath: 'dist/esm' } }, + { id: 'cjs', format: 'cjs', output: { distPath: 'dist/cjs' } }, + ], + source: { entry: { index: '../__fixtures__/src/index.ts' } }, + exports: true, + logLevel: 'silent', + }, + }); + const buildResult = await rslib.build({ lib: ['esm'] }); + await buildResult.close(); + + expect(readFileSync(packageJsonPath, 'utf8')).toBe(original); + }); + test('should read the exports option from the config file', async () => { const fixture = 'esm-only'; const fixturePath = join(__dirname, fixture); diff --git a/website/docs/en/guide/advanced/release-management.mdx b/website/docs/en/guide/advanced/release-management.mdx index cefde13248..c8ca018f97 100644 --- a/website/docs/en/guide/advanced/release-management.mdx +++ b/website/docs/en/guide/advanced/release-management.mdx @@ -70,7 +70,7 @@ For example, building `esm` and `cjs` libs with an `index` entry generates: The result is merged into the existing `exports` field. For matching subpaths, generated `import` and `require` conditions replace the previous format conditions. Existing `types` and `default` targets are kept unless this build generates replacements. Other conditions, such as `browser` or a custom source condition, and other subpaths are kept. A warning is shown when an existing subpath changes or points to a missing output. The original indentation and line endings are preserved, and the file is not written when nothing changes. -The `exports` field is not generated in watch mode. Run `rslib build` to update it after the entries change. +The `exports` field is not generated in watch mode, or when only some libs are built with `--lib`. Run `rslib build` to update it after the entries change. #### Declaration files diff --git a/website/docs/zh/guide/advanced/release-management.mdx b/website/docs/zh/guide/advanced/release-management.mdx index 3b794cf387..d29b746cc2 100644 --- a/website/docs/zh/guide/advanced/release-management.mdx +++ b/website/docs/zh/guide/advanced/release-management.mdx @@ -70,7 +70,7 @@ description: '介绍 Rslib 项目的 npm 包版本管理与发布实践,包括 生成结果会合并到已有的 `exports` 字段中。同名子路径中的 `import`、`require` 条件按本轮格式更新;已有的 `types` 和 `default` 目标在本轮生成替代值时更新,否则保留。其他条件(如 `browser` 或自定义源码条件)和其他子路径保持不变。已有子路径发生变化或指向缺失产物时,会输出警告。写入时保留原有缩进和换行符,内容没有变化时跳过写入。 -watch 模式下不会生成 `exports` 字段。入口变化后,执行 `rslib build` 即可更新。 +watch 模式下,或通过 `--lib` 只构建部分 lib 时,不会生成 `exports` 字段。入口变化后,执行 `rslib build` 即可更新。 #### 类型声明文件 \{#declaration-files} From 774cfb666dc2646ede275cd15e2b8e26f2b0e83c Mon Sep 17 00:00:00 2001 From: Elecmonkey Date: Tue, 27 Oct 2026 17:48:10 +0800 Subject: [PATCH 4/7] feat(core): generate exports with entry selection, CSS, and source options --- packages/core/package.json | 2 + packages/core/src/createRslib.ts | 6 +- packages/core/src/exports/dts.ts | 42 +- packages/core/src/exports/generate.ts | 321 ++++++++--- packages/core/src/exports/packageJson.ts | 169 ------ packages/core/src/exports/plugin.ts | 164 ++++-- packages/core/src/index.ts | 5 + packages/core/src/mergeConfig.ts | 38 +- packages/core/src/types/config.ts | 51 +- packages/core/src/types/utils.ts | 1 + packages/core/tests/config.test.ts | 44 +- packages/core/tests/exports.test.ts | 525 ++++++++++++------ packages/core/tests/exportsDts.test.ts | 250 +++++---- .../core/tests/exportsPackageJson.test.ts | 297 +--------- packages/core/tests/exportsPlugin.test.ts | 57 -- pnpm-lock.yaml | 17 + pnpm-workspace.yaml | 2 + tests/integration/exports/css/package.json | 5 + tests/integration/exports/css/rslib.config.ts | 15 + tests/integration/exports/css/src/index.css | 3 + tests/integration/exports/css/src/index.ts | 3 + tests/integration/exports/css/src/theme.css | 3 + .../integration/exports/include/package.json | 5 + .../exports/include/rslib.config.ts | 14 + tests/integration/exports/index.test.ts | 220 +++++--- tests/integration/exports/source/package.json | 5 + .../exports/source/rslib.config.ts | 11 + tests/integration/exports/source/src/index.ts | 1 + .../en/guide/advanced/release-management.mdx | 108 +++- .../zh/guide/advanced/release-management.mdx | 116 +++- 30 files changed, 1457 insertions(+), 1043 deletions(-) delete mode 100644 packages/core/tests/exportsPlugin.test.ts create mode 100644 tests/integration/exports/css/package.json create mode 100644 tests/integration/exports/css/rslib.config.ts create mode 100644 tests/integration/exports/css/src/index.css create mode 100644 tests/integration/exports/css/src/index.ts create mode 100644 tests/integration/exports/css/src/theme.css create mode 100644 tests/integration/exports/include/package.json create mode 100644 tests/integration/exports/include/rslib.config.ts create mode 100644 tests/integration/exports/source/package.json create mode 100644 tests/integration/exports/source/rslib.config.ts create mode 100644 tests/integration/exports/source/src/index.ts diff --git a/packages/core/package.json b/packages/core/package.json index d2f4f66aac..8bc3f9acc5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -47,12 +47,14 @@ "@module-federation/rsbuild-plugin": "catalog:", "@rslib/tsconfig": "workspace:*", "@types/fs-extra": "catalog:", + "@types/picomatch": "catalog:", "@types/semver": "catalog:", "cac": "catalog:", "fs-extra": "catalog:", "get-tsconfig": "catalog:", "memfs": "catalog:", "path-serializer": "catalog:", + "picomatch": "catalog:", "rsbuild-plugin-publint": "catalog:", "rslib": "catalog:", "rslog": "catalog:", diff --git a/packages/core/src/createRslib.ts b/packages/core/src/createRslib.ts index c2f9848d65..d48a151060 100644 --- a/packages/core/src/createRslib.ts +++ b/packages/core/src/createRslib.ts @@ -8,7 +8,6 @@ import { } from '@rsbuild/core'; import util from 'node:util'; import { composeRsbuildEnvironments, pruneEnvironments } from './config'; -import { ExportsPlugin } from './exports/plugin'; import type { LoadConfigResult } from './loadConfig'; import type { Format, RslibConfig } from './types'; import type { @@ -243,14 +242,17 @@ export async function createRslib( // generated by one-off builds of all libs. In watch mode, declaration files // may still be in progress when the build finishes. if ( - (buildOptions.exports ?? config.exports) && + (buildOptions.exports ?? Boolean(config.exports)) && !buildOptions.watch && !buildOptions.lib?.length ) { + // Loaded on demand to keep it and its dependencies out of the startup. + const { ExportsPlugin } = await import('./exports/plugin'); rsbuildInstance.addPlugins([ ExportsPlugin({ root: config.root!, libInfos: environmentWithInfos, + options: typeof config.exports === 'object' ? config.exports : {}, }), ]); } diff --git a/packages/core/src/exports/dts.ts b/packages/core/src/exports/dts.ts index 403d0185ea..b83bb7f3b7 100644 --- a/packages/core/src/exports/dts.ts +++ b/packages/core/src/exports/dts.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { normalizeSlash } from '../utils/helper'; // TypeScript looks up the declaration file next to the resolved JavaScript file // with the matching extension, and never falls back to other extensions. @@ -20,26 +21,47 @@ export type DeclarationFile = { }; /** - * Find the declaration file next to a JavaScript output, preferring the one - * with the matching extension. + * Find the declaration file of a JavaScript output, preferring the one with the + * matching extension. It is looked up next to the output, or at the same + * relative path under `dtsDistPath` when declaration files are emitted to + * another directory, where TypeScript cannot resolve it on its own. */ -export function findDeclarationFile( - jsFile: string, - root: string, -): DeclarationFile | undefined { +export function findDeclarationFile({ + jsFile, + root, + outputPath, + dtsDistPath, +}: { + /** Path relative to the package root. */ + jsFile: string; + root: string; + /** Absolute path of the JavaScript output directory. */ + outputPath: string; + /** Absolute path of `dts.distPath`. */ + dtsDistPath?: string; +}): DeclarationFile | undefined { const ext = path.extname(jsFile); // Callers only pass JavaScript outputs, whose extensions are all listed. const matchedExt = MATCHED_DECLARATION_EXTENSIONS[ext]!; - const base = jsFile.slice(0, -ext.length); + const jsPath = path.join(root, jsFile); + const nextToOutput = + dtsDistPath === undefined || + path.resolve(dtsDistPath) === path.resolve(outputPath); + const base = ( + nextToOutput + ? jsPath + : path.join(dtsDistPath, path.relative(outputPath, jsPath)) + ).slice(0, -ext.length); + const declarationExt = [ matchedExt, ...DECLARATION_EXTENSIONS.filter((otherExt) => otherExt !== matchedExt), - ].find((candidate) => fs.existsSync(path.join(root, base + candidate))); + ].find((candidate) => fs.existsSync(base + candidate)); if (!declarationExt) return; return { - file: base + declarationExt, - matched: declarationExt === matchedExt, + file: normalizeSlash(path.relative(root, base + declarationExt)), + matched: nextToOutput && declarationExt === matchedExt, }; } diff --git a/packages/core/src/exports/generate.ts b/packages/core/src/exports/generate.ts index 500a041ef9..f40665f484 100644 --- a/packages/core/src/exports/generate.ts +++ b/packages/core/src/exports/generate.ts @@ -1,18 +1,36 @@ import path from 'node:path'; -import type { RequireKey, RsbuildConfigWithLibInfo } from '../types'; +import picomatch from 'picomatch'; +import type { + ExportsField, + ExportsOptions, + ExportsValue, + RequireKey, + RsbuildConfigWithLibInfo, +} from '../types'; import { normalizeSlash } from '../utils/helper'; +import { logger } from '../utils/logger'; import type { DeclarationFile } from './dts'; export type CollectedEntry = { entryName: string; - /** Path relative to the package root, or absolute for different Windows drives, using forward slashes. */ - jsFile: string; - /** The declaration file next to `jsFile`, only collected for libs that emit declaration files. */ + /** + * Paths below are relative to the package root, or absolute for different + * Windows drives, using forward slashes. + * + * The JavaScript output, absent for entries whose modules are all CSS files, + * as their JavaScript output is empty. + */ + jsFile?: string; + /** The CSS output, only collected in bundle mode. */ + css?: { + file: string; + /** Path relative to the output directory, which names the CSS subpath. */ + name: string; + }; + /** The declaration file of `jsFile`, only collected for libs that emit declaration files. */ declaration?: DeclarationFile; -}; - -export type ExportsField = { - [key: string]: string | ExportsField; + /** The modules of the entry. */ + sourceFiles: string[]; }; type LibInfo = RequireKey; @@ -20,36 +38,51 @@ type LibInfo = RequireKey; export type GenerateExportsOptions = { libInfos: LibInfo[]; collected: Map; + options: Pick; +}; + +export type GeneratedExports = { + exports: ExportsField; + /** The exports of the build outputs when `source` is `true`. */ + publishExports?: ExportsField; }; +type Format = 'esm' | 'cjs'; + type PreparedLib = { id: string; - format: 'esm' | 'cjs'; + format: Format; dts: boolean; + /** Whether declaration errors fail the build, see `dts.abortOnError`. */ + abortOnError: boolean; entries: CollectedEntry[]; }; type FormatSlot = { + libId: string; target: string; declaration?: DeclarationFile; + sourceFiles: string[]; }; -type EntrySlots = { - esm?: FormatSlot; - cjs?: FormatSlot; +type EntrySlots = Partial> & { + css?: { format: Format; target: string; subpath: string }; }; -/** Normalize entry names such as `./foo/` to `foo` before using them as subpaths. */ +/** Normalize entry names such as `./foo` to `foo` before matching and naming. */ function normalizeEntryName(entryName: string): string { - return normalizeSlash(entryName) - .replace(/^(\.\/)+/, '') - .replace(/\/+$/, ''); + return normalizeSlash(entryName).replace(/^(\.\/)+/, ''); } function isOutsidePackageRoot(filePath: string): boolean { return path.isAbsolute(filePath) || filePath.startsWith('../'); } +function getJsSubpath(entryName: string): string { + if (entryName === 'index') return '.'; + return `./${entryName.replace(/\/index$/, '')}`; +} + function getFormatSlots(slots: EntrySlots): FormatSlot[] { return [slots.esm, slots.cjs].filter( (slot): slot is FormatSlot => slot !== undefined, @@ -57,13 +90,13 @@ function getFormatSlots(slots: EntrySlots): FormatSlot[] { } /** - * Build the value of a subpath. A `types` condition is only added when - * TypeScript cannot resolve a declaration file with the matching extension + * Build the value of a JavaScript subpath. A `types` condition is only added + * when TypeScript cannot resolve a declaration file with the matching extension * next to every output, and then all formats share one declaration file. */ -function buildEntryValue(slots: EntrySlots): string | ExportsField { +function buildJsValue(slots: EntrySlots): ExportsValue { const formatSlots = getFormatSlots(slots); - const value = (types?: string): string | ExportsField => { + const value = (types?: string): ExportsValue => { if (slots.esm && slots.cjs) { return { ...(types ? { types } : {}), @@ -89,47 +122,117 @@ function buildEntryValue(slots: EntrySlots): string | ExportsField { return value(declaration && `./${declaration.file}`); } -export function generateExports(options: GenerateExportsOptions): ExportsField { - const { libInfos, collected } = options; +function getSourceFile(subpath: string, slots: EntrySlots): string { + const sourceFiles = new Set(); + for (const slot of getFormatSlots(slots)) { + if (slot.sourceFiles.length !== 1) { + throw new Error( + `Failed to generate the "exports" field because the "${subpath}" subpath of the "${slot.libId}" lib is built from ${slot.sourceFiles.length} modules, while "exports.source" requires an entry with a single module.`, + ); + } + const sourceFile = slot.sourceFiles[0]!; + if (isOutsidePackageRoot(sourceFile)) { + throw new Error( + `Failed to generate the "exports" field because the source file "${sourceFile}" of the "${subpath}" subpath is outside the package root.`, + ); + } + sourceFiles.add(`./${sourceFile}`); + } + if (sourceFiles.size > 1) { + throw new Error( + `Failed to generate the "exports" field because the "${subpath}" subpath has different source files in different formats: ${[ + ...sourceFiles, + ] + .map((file) => `"${file}"`) + .join(', ')}.`, + ); + } + return [...sourceFiles][0]!; +} + +function addSourceCondition( + value: ExportsValue, + condition: string, + sourceFile: string, +): ExportsValue { + return typeof value === 'string' + ? { [condition]: sourceFile, default: value } + : { [condition]: sourceFile, ...(value as Record) }; +} + +function sortSubpaths(entries: [string, T][]): [string, T][] { + // Keep the main subpath first, then sort the rest by code units, as + // locale-aware comparison may differ between machines. + return entries.sort(([a], [b]) => { + if (a === '.') return -1; + if (b === '.') return 1; + return a < b ? -1 : a > b ? 1 : 0; + }); +} + +export function generateExports({ + libInfos, + collected, + options, +}: GenerateExportsOptions): GeneratedExports { + const exclude = options.exclude?.length + ? picomatch(options.exclude) + : () => false; const preparedLibs = libInfos.flatMap( ({ id, format, dts, bundle }): PreparedLib[] => { // Other formats, such as UMD, are not referenced by the `exports` field. if (format !== 'esm' && format !== 'cjs') return []; - const entries = (collected.get(id) ?? []).map((entry) => ({ - ...entry, - entryName: normalizeEntryName(entry.entryName), - })); + // In bundleless mode, every source file is an entry, so only the main + // entry is treated as public API by default. + const include = picomatch( + options.include ?? (bundle === false ? ['index'] : ['**']), + ); + const entries = collected + .get(id)! + .map((entry) => ({ + ...entry, + entryName: normalizeEntryName(entry.entryName), + })) + .filter(({ entryName }) => include(entryName) && !exclude(entryName)); return [ { id, format, dts: Boolean(dts), - // In bundleless mode, every source file is an entry, so only the main - // entry is treated as public API. - entries: - bundle === false - ? entries.filter((entry) => entry.entryName === 'index') - : entries, + abortOnError: typeof dts !== 'object' || dts.abortOnError !== false, + entries, }, ]; }, ); - // Declaration files emitted to a separate directory cannot be resolved from the outputs. - const libsWithoutDeclarations = preparedLibs - .filter( - (lib) => - lib.dts && - lib.entries.length > 0 && - lib.entries.every((entry) => !entry.declaration), - ) - .map((lib) => lib.id); - if (libsWithoutDeclarations.length > 0) { + const libsWithoutDeclarations = preparedLibs.filter((lib) => { + const jsEntries = lib.entries.filter((entry) => entry.jsFile); + return ( + lib.dts && + jsEntries.length > 0 && + jsEntries.every((entry) => !entry.declaration) + ); + }); + // With `abortOnError: false`, declaration generation may fail without failing + // the build, so the exports are generated without `types` instead. + const skippedLibs = libsWithoutDeclarations + .filter((lib) => !lib.abortOnError) + .map((lib) => `"${lib.id}"`); + if (skippedLibs.length > 0) { + logger.warn( + `No declaration files were found for the outputs of the following libs, so no "types" condition is generated for them: ${skippedLibs.join(', ')}.`, + ); + } + const failedLibs = libsWithoutDeclarations + .filter((lib) => lib.abortOnError) + .map((lib) => `"${lib.id}"`); + if (failedLibs.length > 0) { throw new Error( [ - `Failed to generate the "exports" field because no declaration files were found next to the outputs of the following libs that emit declaration files: ${libsWithoutDeclarations.map((id) => `"${id}"`).join(', ')}.`, - 'Emit declaration files to the same directory as the JavaScript outputs, check "dts.distPath", and the "declarationDir" and "rootDir" options in tsconfig.', + `Failed to generate the "exports" field because no declaration files were found for the outputs of the following libs that emit declaration files: ${failedLibs.join(', ')}.`, + 'Emit declaration files next to the JavaScript outputs or to "dts.distPath", and check the "declarationDir" and "rootDir" options in tsconfig.', ].join('\n'), ); } @@ -137,41 +240,121 @@ export function generateExports(options: GenerateExportsOptions): ExportsField { const entrySlotsByName = new Map(); for (const lib of preparedLibs) { for (const entry of lib.entries) { - if (isOutsidePackageRoot(entry.jsFile)) { - throw new Error( - `Failed to generate the "exports" field because the "${entry.entryName}" entry of the "${lib.id}" environment emits "${entry.jsFile}", which is outside the package root.`, - ); + const { entryName } = entry; + for (const file of [entry.jsFile, entry.css?.file]) { + if (file && isOutsidePackageRoot(file)) { + throw new Error( + `Failed to generate the "exports" field because the "${entryName}" entry of the "${lib.id}" lib emits "${file}", which is outside the package root.`, + ); + } } - const { entryName } = entry; const slots = entrySlotsByName.get(entryName) ?? {}; - const target = `./${entry.jsFile}`; - const existingTarget = slots[lib.format]?.target; - if (existingTarget && existingTarget !== target) { - throw new Error( - `Failed to generate the "exports" field because the "${entryName}" entry has multiple "${lib.format}" outputs: "${existingTarget}" and "${target}".`, - ); + if (entry.jsFile) { + const target = `./${entry.jsFile}`; + const existingTarget = slots[lib.format]?.target; + if (existingTarget && existingTarget !== target) { + throw new Error( + `Failed to generate the "exports" field because the "${entryName}" entry has multiple "${lib.format}" outputs: "${existingTarget}" and "${target}".`, + ); + } + slots[lib.format] = { + libId: lib.id, + target, + declaration: entry.declaration, + sourceFiles: entry.sourceFiles, + }; + } + if (entry.css) { + const target = `./${entry.css.file}`; + // ESM and CJS outputs share the same styles, prefer the ESM one. + if ( + !slots.css || + (slots.css.format === 'cjs' && lib.format === 'esm') + ) { + slots.css = { + format: lib.format, + target, + subpath: `./${entry.css.name}`, + }; + } else if ( + slots.css.format === lib.format && + slots.css.target !== target + ) { + throw new Error( + `Failed to generate the "exports" field because the "${entryName}" entry has multiple "${lib.format}" CSS outputs: "${slots.css.target}" and "${target}".`, + ); + } } - slots[lib.format] = { target, declaration: entry.declaration }; entrySlotsByName.set(entryName, slots); } } - if (entrySlotsByName.size === 0) { + + const jsSubpaths: [string, EntrySlots][] = []; + const cssSubpaths: [string, string][] = []; + const entryNameBySubpath = new Map(); + const addSubpath = (subpath: string, entryName: string) => { + const existing = entryNameBySubpath.get(subpath); + if (existing !== undefined) { + throw new Error( + `Failed to generate the "exports" field because the "${existing}" and "${entryName}" entries are both exported as the "${subpath}" subpath.`, + ); + } + entryNameBySubpath.set(subpath, entryName); + }; + for (const [entryName, slots] of entrySlotsByName) { + if (getFormatSlots(slots).length > 0) { + const subpath = getJsSubpath(entryName); + addSubpath(subpath, entryName); + jsSubpaths.push([subpath, slots]); + } + if (slots.css) { + addSubpath(slots.css.subpath, entryName); + cssSubpaths.push([slots.css.subpath, slots.css.target]); + } + } + if (entryNameBySubpath.size === 0) { throw new Error( - 'Failed to generate the "exports" field because no entries can be exported. Only the "esm" and "cjs" formats are supported, and only the "index" entry is exported in bundleless mode.', + 'Failed to generate the "exports" field because no entries can be exported. Only entries of the "esm" and "cjs" formats that match "exports.include" and do not match "exports.exclude" are exported, and only the "index" entry is included by default in bundleless mode.', ); } - const exportsField: ExportsField = {}; - // Keep the main entry before other entry subpaths, then sort the rest for stable output. - const sortedEntries = [...entrySlotsByName].sort(([a], [b]) => { - if (a === 'index') return -1; - if (b === 'index') return 1; - return a.localeCompare(b); - }); - for (const [entryName, slots] of sortedEntries) { - exportsField[entryName === 'index' ? '.' : `./${entryName}`] = - buildEntryValue(slots); + const buildExports = ( + jsValue: (subpath: string, slots: EntrySlots) => ExportsValue, + withCss: boolean, + ): ExportsField => + Object.fromEntries( + sortSubpaths([ + ...jsSubpaths.map(([subpath, slots]): [string, ExportsValue] => [ + subpath, + jsValue(subpath, slots), + ]), + ...(withCss ? cssSubpaths : []), + ]), + ); + + const { source } = options; + const outputExports = buildExports((_, slots) => buildJsValue(slots), true); + if (source === true) { + // Source files import their own styles, so only JavaScript subpaths point + // to source files, and the outputs are published instead. + return { + exports: buildExports(getSourceFile, false), + publishExports: outputExports, + }; + } + if (typeof source === 'string') { + return { + exports: buildExports( + (subpath, slots) => + addSourceCondition( + buildJsValue(slots), + source, + getSourceFile(subpath, slots), + ), + true, + ), + }; } - return exportsField; + return { exports: outputExports }; } diff --git a/packages/core/src/exports/packageJson.ts b/packages/core/src/exports/packageJson.ts index fc0721150b..a9bc2ebd39 100644 --- a/packages/core/src/exports/packageJson.ts +++ b/packages/core/src/exports/packageJson.ts @@ -1,172 +1,3 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { isDeepStrictEqual } from 'node:util'; -import { isObject } from '../utils/helper'; -import { logger } from '../utils/logger'; -import type { ExportsField } from './generate'; - -export type MergedExportsField = Record; - -function isSubpathMap(value: unknown): value is MergedExportsField { - return ( - isObject(value) && Object.keys(value).every((key) => key.startsWith('.')) - ); -} - -/** - * Merge the generated value of a subpath into its existing value. Generated - * format conditions are replaced as a whole. Existing types and default targets - * are kept unless this build supplies replacements, with default kept last. - */ -function mergeSubpath( - key: string, - existing: unknown, - generated: string | ExportsField, -): unknown { - if ( - existing === undefined || - existing === null || - typeof existing === 'string' || - Array.isArray(existing) - ) { - return generated; - } - - if (!isObject(existing)) { - throw new Error( - `Failed to update the "${key}" subpath in the "exports" field of package.json because its existing value is not a string or a conditions object.`, - ); - } - - const generatedValue = - typeof generated === 'string' ? { default: generated } : generated; - const replacedConditions = new Set([ - 'import', - 'require', - ...Object.keys(generatedValue), - ]); - const userKeys = Object.keys(existing).filter( - (condition) => !replacedConditions.has(condition), - ); - if (userKeys.length === 0) return generated; - - const { default: defaultTarget = existing.default, ...generatedConditions } = - generatedValue; - - // Generated conditions take the place of the first generated condition in the - // existing value, or go after the user conditions when there is none. - const merged: Record = {}; - let inserted = false; - for (const [condition, value] of Object.entries(existing)) { - if (replacedConditions.has(condition)) { - if (!inserted) { - Object.assign(merged, generatedConditions); - inserted = true; - } - continue; - } - if (condition !== 'default') merged[condition] = value; - } - if (!inserted) Object.assign(merged, generatedConditions); - if (defaultTarget !== undefined) merged.default = defaultTarget; - return merged; -} - -/** - * Merge the generated subpaths into the existing `exports` field. - * Subpaths that only exist in package.json are kept, subpaths that exist in - * both are merged by conditions. - */ -export function mergeExports( - existing: unknown, - generated: ExportsField, -): MergedExportsField { - if (existing === undefined) return generated; - - if (!isSubpathMap(existing)) { - throw new Error( - 'Failed to update the "exports" field in package.json because the existing value is not a subpath map. Convert it to the subpath form first, e.g. { ".": "./dist/index.js" }.', - ); - } - - const mergedSubpaths: MergedExportsField = {}; - for (const [key, value] of Object.entries(generated)) { - const merged = mergeSubpath(key, existing[key], value); - if ( - Object.hasOwn(existing, key) && - !isDeepStrictEqual(existing[key], merged) - ) { - logger.warn( - `Updated the "${key}" subpath in the "exports" field of package.json to ${JSON.stringify(merged)}.`, - ); - } - mergedSubpaths[key] = merged; - } - - // Existing subpaths keep their order, and new subpaths are appended, except - // that a newly added main subpath goes first. - return { - ...(Object.hasOwn(generated, '.') && !Object.hasOwn(existing, '.') - ? { '.': mergedSubpaths['.'] } - : {}), - ...existing, - ...mergedSubpaths, - }; -} - -function collectTargets(value: unknown): string[] { - if (typeof value === 'string') return [value]; - if (Array.isArray(value)) return value.flatMap(collectTargets); - if (isObject(value)) return Object.values(value).flatMap(collectTargets); - return []; -} - -function isPathInside(dir: string, file: string): boolean { - const relative = path.relative(dir, file); - return ( - relative !== '' && - relative !== '..' && - !relative.startsWith(`..${path.sep}`) && - !path.isAbsolute(relative) - ); -} - -/** - * Find the existing subpaths that were not generated in this build, but point - * to missing files in the output directories. They are likely left over from a - * previous build. - */ -export function findStaleSubpaths({ - existing, - generated, - root, - outputPaths, -}: { - existing: unknown; - generated: ExportsField; - root: string; - outputPaths: string[]; -}): string[] { - if (!isObject(existing)) return []; - - const isStaleTarget = (target: string) => { - if (!target.startsWith('./') || target.includes('*')) return false; - const file = path.join(root, target); - return ( - outputPaths.some((outputPath) => isPathInside(outputPath, file)) && - !fs.existsSync(file) - ); - }; - - return Object.entries(existing) - .filter( - ([key, value]) => - !Object.hasOwn(generated, key) && - collectTargets(value).some(isStaleTarget), - ) - .map(([key]) => key); -} - /** * Stringify package.json content with the indentation, line endings, and * trailing newline of the original text. diff --git a/packages/core/src/exports/plugin.ts b/packages/core/src/exports/plugin.ts index 8d14636a8a..63c907a2d7 100644 --- a/packages/core/src/exports/plugin.ts +++ b/packages/core/src/exports/plugin.ts @@ -1,63 +1,112 @@ import fs from 'node:fs'; import path from 'node:path'; import type { RsbuildPlugin, Rspack } from '@rsbuild/core'; +import { isCssFile } from '../css/utils'; import { isJavaScriptOutputFile } from '../exe/utils'; -import type { PkgJson, RequireKey, RsbuildConfigWithLibInfo } from '../types'; +import type { + ExportsField, + ExportsOptions, + PkgJson, + RequireKey, + RsbuildConfigWithLibInfo, +} from '../types'; import { logger } from '../utils/logger'; import { normalizeSlash } from '../utils/helper'; import { findDeclarationFile } from './dts'; -import { generateExports, type CollectedEntry } from './generate'; -import { - findStaleSubpaths, - mergeExports, - stringifyPackageJson, -} from './packageJson'; +import { type CollectedEntry, generateExports } from './generate'; +import { stringifyPackageJson } from './packageJson'; export type ExportsPluginOptions = { root: string; libInfos: RequireKey[]; + options: ExportsOptions; }; +async function customizeExports( + exports: ExportsField, + { extra, transform }: ExportsOptions, + context: { pkg: PkgJson; isPublish: boolean }, +): Promise { + let result: ExportsField = { ...exports, ...extra }; + for (const fn of [transform ?? []].flat()) { + result = await fn(result, context); + } + return result; +} + export const ExportsPlugin = ({ root, libInfos, + options, }: ExportsPluginOptions): RsbuildPlugin => { - const writeExports = (stats: Rspack.Stats | Rspack.MultiStats) => { + const toRelative = (file: string) => + normalizeSlash(path.relative(root, file)); + + const collect = (compilation: Rspack.Compilation): CollectedEntry[] => { + const libInfo = libInfos.find(({ id }) => id === compilation.name)!; + const outputPath = compilation.outputOptions.path!; + const { dts } = libInfo; + const dtsDistPath = + typeof dts === 'object' && dts.distPath + ? path.resolve(root, dts.distPath) + : undefined; + + const entries: CollectedEntry[] = []; + for (const [entryName, entrypoint] of compilation.entrypoints) { + const sourceFiles = compilation.entries + .get(entryName)! + .dependencies.flatMap((dependency) => { + const resource = ( + compilation.moduleGraph.getModule(dependency) as + { resource?: string } | undefined + )?.resource; + return resource ? [toRelative(resource.split('?')[0]!)] : []; + }); + // The JavaScript output of an entry built only from CSS files is empty. + const cssOnly = sourceFiles.length > 0 && sourceFiles.every(isCssFile); + // An entry chunk emits at most one JavaScript file and one CSS file. + // Reading them from the entry chunk skips the separate runtime chunk. + const files = [...entrypoint.getEntrypointChunk().files]; + const jsOutput = cssOnly ? undefined : files.find(isJavaScriptOutputFile); + // Bundleless outputs import their own styles, so CSS is only exported in + // bundle mode. + const cssOutput = + libInfo.bundle === false ? undefined : files.find(isCssFile); + + const jsFile = jsOutput && toRelative(path.join(outputPath, jsOutput)); + entries.push({ + entryName, + jsFile, + css: cssOutput + ? { + file: toRelative(path.join(outputPath, cssOutput)), + name: normalizeSlash(cssOutput), + } + : undefined, + declaration: + jsFile && dts + ? findDeclarationFile({ jsFile, root, outputPath, dtsDistPath }) + : undefined, + sourceFiles, + }); + } + return entries; + }; + + const writeExports = async (stats: Rspack.Stats | Rspack.MultiStats) => { const statsList = 'stats' in stats ? stats.stats : [stats]; const collected = new Map(); - const outputPaths: string[] = []; - for (const { compilation } of statsList) { // Rsbuild names each compiler after its environment, which is the lib ID. - const libId = compilation.name!; - const outputPath = compilation.outputOptions.path!; - outputPaths.push(outputPath); - const emitsDeclarations = Boolean( - libInfos.find(({ id }) => id === libId)?.dts, - ); - - const entries: CollectedEntry[] = []; - for (const [entryName, entrypoint] of compilation.entrypoints) { - // An entry chunk emits at most one JavaScript file. Entries without one, - // such as global CSS entries in bundleless mode, are not exported. - const entryFile = [...entrypoint.getEntrypointChunk().files].find( - isJavaScriptOutputFile, - ); - if (!entryFile) continue; - const jsFile = normalizeSlash( - path.relative(root, path.join(outputPath, entryFile)), - ); - entries.push({ - entryName, - jsFile, - declaration: emitsDeclarations - ? findDeclarationFile(jsFile, root) - : undefined, - }); - } - collected.set(libId, entries); + collected.set(compilation.name!, collect(compilation)); } + const { exports, publishExports } = generateExports({ + libInfos, + collected, + options, + }); + const pkgJsonPath = path.join(root, 'package.json'); if (!fs.existsSync(pkgJsonPath)) { throw new Error( @@ -67,29 +116,26 @@ export const ExportsPlugin = ({ const originalText = fs.readFileSync(pkgJsonPath, 'utf8'); // An invalid package.json already fails the build before this hook, as // Rsbuild parses it when loading the PostCSS config. - const pkgJson: PkgJson = JSON.parse(originalText); - - const generated = generateExports({ libInfos, collected }); - - const exportsField = mergeExports(pkgJson.exports, generated); + const pkg: PkgJson = JSON.parse(originalText); - // Warn about subpaths that are likely left over instead of removing them. - const staleSubpaths = findStaleSubpaths({ - existing: pkgJson.exports, - generated, - root, - outputPaths, - }); - if (staleSubpaths.length > 0) { - logger.warn( - `The following subpaths in the "exports" field of package.json point to files that do not exist in the output directories: ${staleSubpaths.map((key) => `"${key}"`).join(', ')}.`, - ); + const content: PkgJson = { + ...pkg, + exports: await customizeExports(exports, options, { + pkg, + isPublish: false, + }), + }; + if (publishExports) { + content.publishConfig = { + ...pkg.publishConfig, + exports: await customizeExports(publishExports, options, { + pkg, + isPublish: true, + }), + }; } - const text = stringifyPackageJson( - { ...pkgJson, exports: exportsField }, - originalText, - ); + const text = stringifyPackageJson(content, originalText); if (text === originalText) return; fs.writeFileSync(pkgJsonPath, text); @@ -99,10 +145,10 @@ export const ExportsPlugin = ({ return { name: 'rslib:exports', setup(api) { - api.onAfterBuild(({ stats }) => { + api.onAfterBuild(async ({ stats }) => { // Failed builds already report errors, so there is nothing to export. if (!stats || stats.hasErrors()) return; - writeExports(stats); + await writeExports(stats); }); }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a5e626b16..c375a360e5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,11 @@ export type { CreateRslibOptions, Dts, ExeOptions, + ExportsField, + ExportsOptions, + ExportsTransform, + ExportsTransformContext, + ExportsValue, Format, InspectConfigOptions, InspectConfigResult, diff --git a/packages/core/src/mergeConfig.ts b/packages/core/src/mergeConfig.ts index c21d50b3d8..cec11b9949 100644 --- a/packages/core/src/mergeConfig.ts +++ b/packages/core/src/mergeConfig.ts @@ -38,6 +38,30 @@ function mergeLibConfigs( return [...mergedById.values(), ...itemsWithoutId]; } +/** + * Merge multiple `exports` options. `true` and an options object enable it with + * the options, `extra` is merged by subpath, as each subpath is replaced as a + * whole, and `transform` functions are called in order. + */ +function mergeExportsConfigs( + ...exportsConfigs: RslibConfig['exports'][] +): RslibConfig['exports'] { + return exportsConfigs.reduce((merged, config) => { + if (config === undefined) return merged; + if (config === true) return typeof merged === 'object' ? merged : true; + if (config === false || typeof merged !== 'object') return config; + + const result = { ...merged, ...config }; + if (merged.extra && config.extra) { + result.extra = { ...merged.extra, ...config.extra }; + } + if (merged.transform && config.transform) { + result.transform = [merged.transform, config.transform].flat(); + } + return result; + }, undefined); +} + /** * Merge multiple Rslib configurations into a single configuration. * @@ -46,7 +70,8 @@ function mergeLibConfigs( * - Items without `id` are appended to the result array * - The order of items with `id` is preserved based on their first occurrence * - * Other configuration fields are merged using `mergeRsbuildConfig`. + * The `exports` field is merged by `mergeExportsConfigs`, and other + * configuration fields are merged using `mergeRsbuildConfig`. */ export function mergeRslibConfig( ...originalConfigs: (RslibConfig | undefined)[] @@ -62,9 +87,9 @@ export function mergeRslibConfig( // Extract lib arrays from all configs const libArrays = definedConfigs.map((config) => config.lib); - // Create configs without lib for merging with mergeRsbuildConfig + // Create configs without lib and exports for merging with mergeRsbuildConfig const configsWithoutLib = definedConfigs.map((config) => { - const { lib: _, ...rest } = config; + const { lib: _lib, exports: _exports, ...rest } = config; return rest; }); @@ -77,5 +102,12 @@ export function mergeRslibConfig( mergedConfig.lib = mergedLib; } + const mergedExports = mergeExportsConfigs( + ...definedConfigs.map((config) => config.exports), + ); + if (mergedExports !== undefined) { + mergedConfig.exports = mergedExports; + } + return mergedConfig; } diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 65fc49c5fd..ef288849bf 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -4,7 +4,7 @@ import type { RsbuildConfig, Rspack, } from '@rsbuild/core'; -import type { GetAsyncFunctionFromUnion } from './utils'; +import type { GetAsyncFunctionFromUnion, PkgJson } from './utils'; export type Format = 'esm' | 'cjs' | 'umd' | 'mf' | 'iife'; @@ -576,5 +576,52 @@ export interface RslibConfig extends RsbuildConfig, SharedLibConfig { * Can be overridden by the `exports` option of `build`. * @default false */ - exports?: boolean; + exports?: boolean | ExportsOptions; } + +export type ExportsValue = + string | null | ExportsValue[] | { [condition: string]: ExportsValue }; + +export type ExportsField = Record; + +export type ExportsTransformContext = { + /** The content of package.json before writing. */ + pkg: PkgJson; + /** Whether the result is written to `publishConfig.exports`. */ + isPublish: boolean; +}; + +export type ExportsTransform = ( + exports: ExportsField, + context: ExportsTransformContext, +) => ExportsField | Promise; + +export type ExportsOptions = { + /** + * Glob patterns of entry names to export. + * @default `['**']` in bundle mode, `['index']` in bundleless mode + */ + include?: string[]; + /** + * Glob patterns of entry names to exclude from `include`. + */ + exclude?: string[]; + /** + * - `true`: point the top-level exports to source files, and write the + * exports of the build outputs to `publishConfig.exports`. + * - string: add a condition with this name pointing to the source file to + * each JavaScript subpath. + * @default false + */ + source?: boolean | string; + /** + * Subpaths added to the generated exports. A subpath with the same key as a + * generated one replaces it as a whole. + */ + extra?: ExportsField; + /** + * Receives the generated exports with `extra` applied, and returns the final + * exports. An array of functions is called in order. + */ + transform?: ExportsTransform | ExportsTransform[]; +}; diff --git a/packages/core/src/types/utils.ts b/packages/core/src/types/utils.ts index 4027460232..1a8807d92c 100644 --- a/packages/core/src/types/utils.ts +++ b/packages/core/src/types/utils.ts @@ -8,6 +8,7 @@ export type PkgJson = { optionalDependencies?: Record; engines?: Record; exports?: unknown; + publishConfig?: Record; }; export type DeepRequired = Required<{ diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts index a7e3e3d629..47d0dc44c7 100644 --- a/packages/core/tests/config.test.ts +++ b/packages/core/tests/config.test.ts @@ -14,7 +14,11 @@ import { import { createRslib } from '../src/createRslib'; import { loadConfig } from '../src/loadConfig'; import { mergeRslibConfig } from '../src/mergeConfig'; -import type { RslibConfig } from '../src/types/config'; +import type { + ExportsField, + ExportsOptions, + RslibConfig, +} from '../src/types/config'; import { normalizeSlash } from '../src/utils/helper'; import { logger } from '../src/utils/logger'; @@ -400,6 +404,44 @@ describe('Should merge Rslib config correctly', () => { } `); }); + + test('merge exports options', () => { + const transform1 = (exports: ExportsField) => exports; + const transform2 = (exports: ExportsField) => exports; + const options: ExportsOptions = { + include: ['index'], + extra: { './a': { browser: './a.js', default: './a.cjs' } }, + transform: transform1, + }; + + expect(mergeRslibConfig({ exports: options }, { exports: true })).toEqual({ + exports: options, + }); + expect(mergeRslibConfig({ exports: true }, { exports: options })).toEqual({ + exports: options, + }); + expect(mergeRslibConfig({ exports: options }, { exports: false })).toEqual({ + exports: false, + }); + expect( + mergeRslibConfig( + { exports: options }, + { + exports: { + include: ['*/index'], + extra: { './a': './a.js', './b': './b.js' }, + transform: transform2, + }, + }, + ), + ).toEqual({ + exports: { + include: ['*/index'], + extra: { './a': './a.js', './b': './b.js' }, + transform: [transform1, transform2], + }, + }); + }); }); describe('CLI options', () => { diff --git a/packages/core/tests/exports.test.ts b/packages/core/tests/exports.test.ts index 4691876ee0..87a993a960 100644 --- a/packages/core/tests/exports.test.ts +++ b/packages/core/tests/exports.test.ts @@ -11,246 +11,427 @@ rs.mock('rslog'); type LibInfo = RequireKey; -const createLibInfo = (id: string, format: LibInfo['format']): LibInfo => ({ +const createLibInfo = ( + id: string, + format: LibInfo['format'], + info: Partial = {}, +): LibInfo => ({ id, format, config: {} as unknown as LibInfo['config'], + ...info, }); -const createCollected = ( - entries: Record, -): Map => - new Map( - Object.entries(entries).map(([id, list]) => [ - id, - list.map(([entryName, jsFile]) => ({ entryName, jsFile })), - ]), - ); +const entry = ( + entryName: string, + jsFile?: string, + info: Partial = {}, +): CollectedEntry => ({ entryName, jsFile, sourceFiles: [], ...info }); + +/** A CSS output named by its path relative to an output directory under `dist`. */ +const css = ( + file: string, + name = file.replace(/^dist\/(esm\/|cjs\/)?/, ''), +) => ({ + file, + name, +}); + +const generate = ( + libInfos: LibInfo[], + collected: Record, + options: GenerateExportsOptions['options'] = {}, +) => + generateExports({ + libInfos, + collected: new Map(Object.entries(collected)), + options, + }); describe('generateExports', () => { it('should generate import and require conditions for esm and cjs entries', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], - collected: createCollected({ - esm: [['index', 'dist/esm/index.mjs']], - cjs: [['index', 'dist/cjs/index.js']], + expect( + generate([createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], { + esm: [entry('index', 'dist/esm/index.mjs')], + cjs: [entry('index', 'dist/cjs/index.js')], }), - }; - - expect(generateExports(options)).toEqual({ - '.': { - import: './dist/esm/index.mjs', - require: './dist/cjs/index.js', + ).toEqual({ + exports: { + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, }, }); }); it('should generate a string value when only one format exists', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm')], - collected: createCollected({ - esm: [['index', 'dist/esm/index.mjs']], - }), - }; - - expect(generateExports(options)).toEqual({ - '.': './dist/esm/index.mjs', - }); + expect( + generate([createLibInfo('esm', 'esm')], { + esm: [entry('index', 'dist/esm/index.mjs')], + }).exports, + ).toEqual({ '.': './dist/esm/index.mjs' }); }); - it('should expose non-index entries as subpaths', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm')], - collected: createCollected({ - esm: [['foo', 'dist/esm/foo.mjs']], - }), - }; - - expect(generateExports(options)).toEqual({ - './foo': './dist/esm/foo.mjs', + it('should name subpaths after entries and strip trailing "/index"', () => { + expect( + generate([createLibInfo('esm', 'esm')], { + esm: [ + entry('index', 'dist/index.mjs'), + entry('foo', 'dist/foo.mjs'), + entry('button/index', 'dist/button/index.mjs'), + entry('utils/numbers', 'dist/utils/numbers.mjs'), + ], + }).exports, + ).toEqual({ + '.': './dist/index.mjs', + './button': './dist/button/index.mjs', + './foo': './dist/foo.mjs', + './utils/numbers': './dist/utils/numbers.mjs', }); }); - it('should normalize leading "./" and trailing "/" in entry names', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], - collected: createCollected({ + it('should normalize leading "./" in entry names', () => { + expect( + generate([createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], { esm: [ - ['./index', 'dist/esm/index.mjs'], - ['./foo/', 'dist/esm/foo.mjs'], + entry('./index', 'dist/esm/index.mjs'), + entry('./foo/index', 'dist/esm/foo/index.mjs'), ], cjs: [ - ['index', 'dist/cjs/index.js'], - ['foo', 'dist/cjs/foo.js'], + entry('index', 'dist/cjs/index.js'), + entry('foo/index', 'dist/cjs/foo/index.js'), ], - }), - }; - - expect(generateExports(options)).toEqual({ + }).exports, + ).toEqual({ '.': { import: './dist/esm/index.mjs', require: './dist/cjs/index.js', }, './foo': { - import: './dist/esm/foo.mjs', - require: './dist/cjs/foo.js', + import: './dist/esm/foo/index.mjs', + require: './dist/cjs/foo/index.js', }, }); }); - it('should only expose the index entry in bundleless mode', () => { - const options: GenerateExportsOptions = { - libInfos: [{ ...createLibInfo('esm', 'esm'), bundle: false }], - collected: createCollected({ + it('should throw when entries are exported as the same subpath', () => { + expect(() => + generate([createLibInfo('esm', 'esm')], { esm: [ - ['index', 'dist/esm/index.mjs'], - ['utils/numbers', 'dist/esm/utils/numbers.mjs'], - ['assets/logo', 'dist/esm/assets/logo.mjs'], + entry('foo', 'dist/foo.mjs'), + entry('foo/index', 'dist/foo/index.mjs'), ], }), - }; - - expect(generateExports(options)).toEqual({ - '.': './dist/esm/index.mjs', - }); + ).toThrow( + 'the "foo" and "foo/index" entries are both exported as the "./foo" subpath', + ); }); - it('should keep the index entry first and sort other entries', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm')], - collected: createCollected({ - esm: [ - ['foo', 'dist/esm/foo.mjs'], - ['utils/numbers', 'dist/esm/utils/numbers.mjs'], - ['index', 'dist/esm/index.mjs'], - ['bar', 'dist/esm/bar.mjs'], - ], - }), - }; - - const exportsField = generateExports(options); - - expect(Object.keys(exportsField!)).toEqual([ - '.', - './bar', - './foo', - './utils/numbers', - ]); - expect(exportsField).toEqual({ - '.': './dist/esm/index.mjs', - './bar': './dist/esm/bar.mjs', - './foo': './dist/esm/foo.mjs', - './utils/numbers': './dist/esm/utils/numbers.mjs', + it('should keep the main subpath first and sort others by code units', () => { + const { exports } = generate([createLibInfo('esm', 'esm')], { + esm: [ + entry('foo', 'dist/foo.mjs'), + entry('Bar', 'dist/Bar.mjs'), + entry('index', 'dist/index.mjs'), + entry('bar', 'dist/bar.mjs'), + ], }); + + expect(Object.keys(exports)).toEqual(['.', './Bar', './bar', './foo']); }); it('should skip unsupported formats and keep supported ones', () => { - const options: GenerateExportsOptions = { - libInfos: [ - createLibInfo('esm', 'esm'), - createLibInfo('umd', 'umd'), - createLibInfo('mf', 'mf'), - ], - collected: createCollected({ - esm: [['index', 'dist/esm/index.mjs']], - umd: [['index', 'dist/umd/index.js']], - mf: [['index', 'dist/mf/index.js']], - }), - }; - - expect(generateExports(options)).toEqual({ - '.': './dist/esm/index.mjs', - }); + expect( + generate( + [ + createLibInfo('esm', 'esm'), + createLibInfo('umd', 'umd'), + createLibInfo('mf', 'mf'), + ], + { + esm: [entry('index', 'dist/esm/index.mjs')], + umd: [entry('index', 'dist/umd/index.js')], + mf: [entry('index', 'dist/mf/index.js')], + }, + ).exports, + ).toEqual({ '.': './dist/esm/index.mjs' }); }); - it('should throw when all formats are unsupported', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('umd', 'umd')], - collected: createCollected({ - umd: [['index', 'dist/umd/index.js']], + it('should throw when no entries can be exported', () => { + expect(() => + generate([createLibInfo('umd', 'umd')], { + umd: [entry('index', 'dist/umd/index.js')], }), - }; + ).toThrow('no entries can be exported'); - expect(() => generateExports(options)).toThrow( + expect(() => generate([createLibInfo('esm', 'esm')], { esm: [] })).toThrow( 'no entries can be exported', ); }); - it('should throw when no JavaScript entries are generated', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm')], - collected: createCollected({ - esm: [], + it('should throw when an output is outside the package root', () => { + expect(() => + generate([createLibInfo('esm', 'esm')], { + esm: [entry('index', '../outside/index.mjs')], }), - }; + ).toThrow('which is outside the package root'); - expect(() => generateExports(options)).toThrow( - 'no entries can be exported', - ); + expect(() => + generate([createLibInfo('esm', 'esm')], { + esm: [entry('index', path.resolve(__dirname, 'outside/index.mjs'))], + }), + ).toThrow('which is outside the package root'); }); - it('should skip libs without collected entries', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], - collected: createCollected({ - esm: [['index', 'dist/esm/index.mjs']], + it('should throw when an entry has multiple outputs of the same format', () => { + expect(() => + generate([createLibInfo('esm', 'esm'), createLibInfo('esm2', 'esm')], { + esm: [entry('index', 'dist/esm/index.mjs')], + esm2: [entry('index', 'dist/esm2/index.mjs')], }), - }; + ).toThrow('the "index" entry has multiple "esm" outputs'); + }); + + it('should allow duplicate identical outputs of the same format', () => { + expect( + generate([createLibInfo('esm', 'esm'), createLibInfo('esm2', 'esm')], { + esm: [entry('index', 'dist/esm/index.mjs')], + esm2: [entry('index', 'dist/esm/index.mjs')], + }).exports, + ).toEqual({ '.': './dist/esm/index.mjs' }); + }); +}); - expect(generateExports(options)).toEqual({ - '.': './dist/esm/index.mjs', +describe('generateExports with include and exclude', () => { + const bundleless = createLibInfo('esm', 'esm', { bundle: false }); + const entries = [ + entry('index', 'dist/index.js'), + entry('button/index', 'dist/button/index.js'), + entry('button/utils', 'dist/button/utils.js'), + entry('internal/index', 'dist/internal/index.js'), + ]; + + it('should only export the index entry in bundleless mode by default', () => { + expect(generate([bundleless], { esm: entries }).exports).toEqual({ + '.': './dist/index.js', }); }); - it('should throw when an entry is outside the package root', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm')], - collected: createCollected({ - esm: [['index', '../outside/index.mjs']], - }), - }; + it('should export all entries in bundle mode by default', () => { + expect( + Object.keys( + generate([createLibInfo('esm', 'esm')], { esm: entries }).exports, + ), + ).toEqual(['.', './button', './button/utils', './internal']); + }); - expect(() => generateExports(options)).toThrow( - 'which is outside the package root', - ); + it('should select entries with glob patterns', () => { + expect( + generate( + [bundleless], + { esm: entries }, + { include: ['index', '*/index'], exclude: ['internal/**'] }, + ).exports, + ).toEqual({ + '.': './dist/index.js', + './button': './dist/button/index.js', + }); + }); - const absoluteOptions: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm')], - collected: createCollected({ - esm: [['index', path.resolve(__dirname, 'outside/index.mjs')]], - }), - }; + it('should throw when no entries match', () => { + expect(() => + generate([bundleless], { esm: entries }, { include: ['missing'] }), + ).toThrow('no entries can be exported'); + }); +}); - expect(() => generateExports(absoluteOptions)).toThrow( - 'which is outside the package root', - ); +describe('generateExports with CSS', () => { + it('should export the CSS of entries and prefer the esm output', () => { + expect( + generate([createLibInfo('cjs', 'cjs'), createLibInfo('esm', 'esm')], { + cjs: [ + entry('index', 'dist/cjs/index.cjs', { + css: css('dist/cjs/index.css'), + }), + ], + esm: [ + entry('index', 'dist/esm/index.js', { + css: css('dist/esm/index.css'), + }), + entry('button/index', 'dist/esm/button/index.js', { + css: css('dist/esm/button/index.css'), + }), + ], + }).exports, + ).toEqual({ + '.': { + import: './dist/esm/index.js', + require: './dist/cjs/index.cjs', + }, + './button': './dist/esm/button/index.js', + './button/index.css': './dist/esm/button/index.css', + './index.css': './dist/esm/index.css', + }); }); - it('should throw when an entry has multiple outputs of the same format', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm'), createLibInfo('esm2', 'esm')], - collected: createCollected({ - esm: [['index', 'dist/esm/index.mjs']], - esm2: [['index', 'dist/esm2/index.mjs']], - }), - }; + it('should name CSS subpaths after the output files', () => { + expect( + generate([createLibInfo('cjs', 'cjs'), createLibInfo('esm', 'esm')], { + cjs: [ + entry('index', 'dist/cjs/index.cjs', { + css: css('dist/cjs/static/index.css', 'static/index.css'), + }), + ], + esm: [ + entry('index', 'dist/esm/index.js', { + css: css( + 'dist/esm/css/style.a1b2c3d4.css', + 'css/style.a1b2c3d4.css', + ), + }), + ], + }).exports, + ).toEqual({ + '.': { + import: './dist/esm/index.js', + require: './dist/cjs/index.cjs', + }, + './css/style.a1b2c3d4.css': './dist/esm/css/style.a1b2c3d4.css', + }); + }); - expect(() => generateExports(options)).toThrow( - 'the "index" entry has multiple "esm" outputs', - ); + it('should only export the CSS of CSS-only entries', () => { + expect( + generate([createLibInfo('esm', 'esm')], { + esm: [ + entry('index', 'dist/index.js'), + entry('theme', undefined, { css: css('dist/theme.css') }), + ], + }).exports, + ).toEqual({ + '.': './dist/index.js', + './theme.css': './dist/theme.css', + }); }); - it('should allow duplicate identical outputs of the same format', () => { - const options: GenerateExportsOptions = { - libInfos: [createLibInfo('esm', 'esm'), createLibInfo('esm2', 'esm')], - collected: createCollected({ - esm: [['index', 'dist/esm/index.mjs']], - esm2: [['index', 'dist/esm/index.mjs']], + it('should throw when a CSS subpath is taken by another entry', () => { + expect(() => + generate([createLibInfo('esm', 'esm')], { + esm: [ + entry('theme', 'dist/theme.js', { css: css('dist/theme.css') }), + entry('theme.css', 'dist/theme.css.js'), + ], }), - }; + ).toThrow('are both exported as the "./theme.css" subpath'); + }); +}); - expect(generateExports(options)).toEqual({ - '.': './dist/esm/index.mjs', +describe('generateExports with source', () => { + const libInfos = [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')]; + const collected = { + esm: [ + entry('index', 'dist/esm/index.js', { + css: css('dist/esm/index.css'), + sourceFiles: ['src/index.ts'], + }), + ], + cjs: [ + entry('index', 'dist/cjs/index.cjs', { sourceFiles: ['src/index.ts'] }), + ], + }; + const outputExports = { + '.': { + import: './dist/esm/index.js', + require: './dist/cjs/index.cjs', + }, + './index.css': './dist/esm/index.css', + }; + + it('should point to source files and publish the outputs', () => { + expect(generate(libInfos, collected, { source: true })).toEqual({ + exports: { '.': './src/index.ts' }, + publishExports: outputExports, }); }); + + it('should add a source condition to JavaScript subpaths', () => { + expect(generate(libInfos, collected, { source: '@acme/source' })).toEqual({ + exports: { + '.': { + '@acme/source': './src/index.ts', + import: './dist/esm/index.js', + require: './dist/cjs/index.cjs', + }, + './index.css': './dist/esm/index.css', + }, + }); + + expect( + generate( + [createLibInfo('esm', 'esm')], + { + esm: [ + entry('index', 'dist/index.js', { sourceFiles: ['src/index.ts'] }), + ], + }, + { source: '@acme/source' }, + ).exports, + ).toEqual({ + '.': { '@acme/source': './src/index.ts', default: './dist/index.js' }, + }); + }); + + it('should throw when an entry is built from multiple modules', () => { + expect(() => + generate( + [createLibInfo('esm', 'esm')], + { + esm: [ + entry('index', 'dist/index.js', { + sourceFiles: ['src/index.ts', 'src/polyfill.ts'], + }), + ], + }, + { source: true }, + ), + ).toThrow('is built from 2 modules'); + }); + + it('should throw when a source file is outside the package root', () => { + expect(() => + generate( + [createLibInfo('esm', 'esm')], + { + esm: [ + entry('index', 'dist/index.js', { + sourceFiles: ['../src/index.ts'], + }), + ], + }, + { source: true }, + ), + ).toThrow('is outside the package root'); + }); + + it('should throw when formats have different source files', () => { + expect(() => + generate( + libInfos, + { + esm: [ + entry('index', 'dist/esm/index.js', { + sourceFiles: ['src/index.ts'], + }), + ], + cjs: [ + entry('index', 'dist/cjs/index.cjs', { + sourceFiles: ['src/index.cjs.ts'], + }), + ], + }, + { source: true }, + ), + ).toThrow('has different source files in different formats'); + }); }); diff --git a/packages/core/tests/exportsDts.test.ts b/packages/core/tests/exportsDts.test.ts index 7f268b7b23..461f6c8a75 100644 --- a/packages/core/tests/exportsDts.test.ts +++ b/packages/core/tests/exportsDts.test.ts @@ -10,14 +10,23 @@ rs.mock('rslog'); type LibInfo = RequireKey; -const findWithFiles = async (jsFile: string, files: string[]) => { +const findWithFiles = async ( + jsFile: string, + files: string[], + dtsDistPath?: string, +) => { const root = await mkdtemp(path.join(tmpdir(), 'rslib-exports-dts-')); try { - await mkdir(path.join(root, 'dist')); for (const file of files) { + await mkdir(path.dirname(path.join(root, file)), { recursive: true }); await writeFile(path.join(root, file), ''); } - return findDeclarationFile(jsFile, root); + return findDeclarationFile({ + jsFile, + root, + outputPath: path.join(root, 'dist'), + dtsDistPath: dtsDistPath && path.join(root, dtsDistPath), + }); } finally { await rm(root, { recursive: true, force: true }); } @@ -55,6 +64,22 @@ describe('findDeclarationFile', () => { it('should return undefined when no declaration file exists', async () => { expect(await findWithFiles('dist/index.js', [])).toBeUndefined(); }); + + it('should find the declaration file in dts.distPath as unmatched', async () => { + expect( + await findWithFiles( + 'dist/utils/numbers.js', + ['dist/types/utils/numbers.d.ts'], + 'dist/types', + ), + ).toEqual({ file: 'dist/types/utils/numbers.d.ts', matched: false }); + }); + + it('should look up next to the output when dts.distPath is the output directory', async () => { + expect( + await findWithFiles('dist/index.js', ['dist/index.d.ts'], 'dist'), + ).toEqual({ file: 'dist/index.d.ts', matched: true }); + }); }); describe('generateExports with declaration files', () => { @@ -72,70 +97,70 @@ describe('generateExports with declaration files', () => { const entry = ( jsFile: string, declaration?: DeclarationFile, - ): CollectedEntry => ({ entryName: 'index', jsFile, declaration }); + ): CollectedEntry => ({ + entryName: 'index', + jsFile, + declaration, + sourceFiles: [], + }); + + const generate = ( + libInfos: LibInfo[], + collected: Record, + ) => + generateExports({ + libInfos, + collected: new Map(Object.entries(collected)), + options: {}, + }).exports; it('should not add a types condition when every output has a matched declaration file', () => { expect( - generateExports({ - libInfos: [ + generate( + [ createLibInfo('esm', 'esm', { autoExtension: true }), createLibInfo('cjs', 'cjs', { autoExtension: true }), ], - collected: new Map([ - [ - 'esm', - [ - entry('dist/esm/index.mjs', { - file: 'dist/esm/index.d.mts', - matched: true, - }), - ], + { + esm: [ + entry('dist/esm/index.mjs', { + file: 'dist/esm/index.d.mts', + matched: true, + }), ], - [ - 'cjs', - [ - entry('dist/cjs/index.js', { - file: 'dist/cjs/index.d.ts', - matched: true, - }), - ], + cjs: [ + entry('dist/cjs/index.js', { + file: 'dist/cjs/index.d.ts', + matched: true, + }), ], - ]), - }), + }, + ), ).toEqual({ '.': { import: './dist/esm/index.mjs', require: './dist/cjs/index.js' }, }); }); it('should share the matched declaration file when another output has none', () => { - const exportsField = generateExports({ - libInfos: [ - createLibInfo('esm', 'esm', true), - createLibInfo('cjs', 'cjs', true), - ], - collected: new Map([ - [ - 'esm', - [ - entry('dist/esm/index.js', { - file: 'dist/esm/index.d.ts', - matched: true, - }), - ], + const exportsField = generate( + [createLibInfo('esm', 'esm', true), createLibInfo('cjs', 'cjs', true)], + { + esm: [ + entry('dist/esm/index.js', { + file: 'dist/esm/index.d.ts', + matched: true, + }), ], - [ - 'cjs', - [ - entry('dist/cjs/index.cjs', { - file: 'dist/cjs/index.d.ts', - matched: false, - }), - ], + cjs: [ + entry('dist/cjs/index.cjs', { + file: 'dist/cjs/index.d.ts', + matched: false, + }), ], - ]), - }); + }, + ); - expect(Object.keys(exportsField!['.']!)).toEqual([ + expect(Object.keys(exportsField['.']!)).toEqual([ 'types', 'import', 'require', @@ -151,24 +176,18 @@ describe('generateExports with declaration files', () => { it('should share the declaration file of the only lib that emits declaration files', () => { expect( - generateExports({ - libInfos: [ - createLibInfo('esm', 'esm', true), - createLibInfo('cjs', 'cjs'), - ], - collected: new Map([ - [ - 'esm', - [ - entry('dist/index.mjs', { - file: 'dist/index.d.ts', - matched: false, - }), - ], + generate( + [createLibInfo('esm', 'esm', true), createLibInfo('cjs', 'cjs')], + { + esm: [ + entry('dist/index.mjs', { + file: 'dist/index.d.ts', + matched: false, + }), ], - ['cjs', [entry('dist/index.js')]], - ]), - }), + cjs: [entry('dist/index.js')], + }, + ), ).toEqual({ '.': { types: './dist/index.d.ts', @@ -180,52 +199,66 @@ describe('generateExports with declaration files', () => { it('should use the default condition with a single format', () => { expect( - generateExports({ - libInfos: [createLibInfo('esm', 'esm', true)], - collected: new Map([ - [ - 'esm', - [ - entry('dist/index.mjs', { - file: 'dist/index.d.ts', - matched: false, - }), - ], - ], - ]), + generate([createLibInfo('esm', 'esm', true)], { + esm: [ + entry('dist/index.mjs', { + file: 'dist/index.d.ts', + matched: false, + }), + ], }), ).toEqual({ '.': { types: './dist/index.d.ts', default: './dist/index.mjs' }, }); }); - it('should throw when a lib has no declaration file next to any output', () => { + it('should add a types condition for declaration files in dts.distPath', () => { + expect( + generate([createLibInfo('esm', 'esm', { distPath: './dist/types' })], { + esm: [ + entry('dist/esm/index.js', { + file: 'dist/types/index.d.ts', + matched: false, + }), + ], + }), + ).toEqual({ + '.': { types: './dist/types/index.d.ts', default: './dist/esm/index.js' }, + }); + }); + + it('should throw when a lib has no declaration file for any output', () => { expect(() => - generateExports({ - libInfos: [createLibInfo('esm', 'esm', { distPath: './dist/types' })], - collected: new Map([['esm', [entry('dist/index.js')]]]), + generate([createLibInfo('esm', 'esm', true)], { + esm: [entry('dist/index.js')], }), ).toThrow( - 'no declaration files were found next to the outputs of the following libs that emit declaration files: "esm"', + 'no declaration files were found for the outputs of the following libs that emit declaration files: "esm"', ); }); + it('should skip the types condition without declaration files when abortOnError is disabled', () => { + expect( + generate([createLibInfo('esm', 'esm', { abortOnError: false })], { + esm: [entry('dist/index.js')], + }), + ).toEqual({ '.': './dist/index.js' }); + }); + it('should not add a types condition to subpaths without declaration files', () => { expect( - generateExports({ - libInfos: [createLibInfo('esm', 'esm', true)], - collected: new Map([ - [ - 'esm', - [ - entry('dist/index.js', { - file: 'dist/index.d.ts', - matched: true, - }), - { entryName: 'assets/logo', jsFile: 'dist/assets/logo.js' }, - ], - ], - ]), + generate([createLibInfo('esm', 'esm', true)], { + esm: [ + entry('dist/index.js', { + file: 'dist/index.d.ts', + matched: true, + }), + { + entryName: 'assets/logo', + jsFile: 'dist/assets/logo.js', + sourceFiles: [], + }, + ], }), ).toEqual({ '.': './dist/index.js', @@ -233,25 +266,10 @@ describe('generateExports with declaration files', () => { }); }); - it('should throw for a bundleless lib without the index entry', () => { - expect(() => - generateExports({ - libInfos: [{ ...createLibInfo('esm', 'esm', true), bundle: false }], - collected: new Map([ - [ - 'esm', - [{ entryName: 'utils/numbers', jsFile: 'dist/utils/numbers.js' }], - ], - ]), - }), - ).toThrow('no entries can be exported'); - }); - it('should not check libs without declaration files', () => { expect( - generateExports({ - libInfos: [createLibInfo('esm', 'esm')], - collected: new Map([['esm', [entry('dist/index.js')]]]), + generate([createLibInfo('esm', 'esm')], { + esm: [entry('dist/index.js')], }), ).toEqual({ '.': './dist/index.js' }); }); diff --git a/packages/core/tests/exportsPackageJson.test.ts b/packages/core/tests/exportsPackageJson.test.ts index c4b06a9249..96a7fde3cd 100644 --- a/packages/core/tests/exportsPackageJson.test.ts +++ b/packages/core/tests/exportsPackageJson.test.ts @@ -1,298 +1,5 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { describe, expect, it, rs } from '@rstest/core'; -import { - findStaleSubpaths, - mergeExports, - stringifyPackageJson, -} from '../src/exports/packageJson'; -import { logger } from '../src/utils/logger'; - -rs.mock('rslog'); - -describe('mergeExports', () => { - it('should return the generated field when there is no existing field', () => { - expect(mergeExports(undefined, { '.': './dist/index.mjs' })).toEqual({ - '.': './dist/index.mjs', - }); - }); - - it('should keep existing subpaths and overwrite conflicting ones in place', () => { - const merged = mergeExports( - { - './style.css': './dist/style.css', - '.': './dist/old.mjs', - './package.json': './package.json', - }, - { '.': './dist/index.mjs', './utils': './dist/utils.mjs' }, - ); - - expect(Object.keys(merged)).toEqual([ - './style.css', - '.', - './package.json', - './utils', - ]); - expect(merged).toEqual({ - './style.css': './dist/style.css', - '.': './dist/index.mjs', - './package.json': './package.json', - './utils': './dist/utils.mjs', - }); - }); - - it('should put a newly added main subpath first', () => { - const merged = mergeExports( - { './package.json': './package.json' }, - { '.': './dist/index.mjs' }, - ); - - expect(Object.keys(merged)).toEqual(['.', './package.json']); - }); - - it('should not add a main subpath when none is generated', () => { - const merged = mergeExports( - { './style.css': './dist/style.css' }, - { './utils': './dist/utils.mjs' }, - ); - - expect(Object.keys(merged)).toEqual(['./style.css', './utils']); - }); - - it('should warn only when an existing subpath is overwritten with a different value', () => { - const warn = rs.spyOn(logger, 'warn').mockImplementation(() => {}); - - mergeExports( - { - '.': { import: './dist/index.mjs', require: './dist/index.js' }, - './utils': './dist/old-utils.mjs', - }, - { - '.': { import: './dist/index.mjs', require: './dist/index.js' }, - './utils': './dist/utils.mjs', - }, - ); - - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0]![0]).toContain('"./utils"'); - warn.mockRestore(); - }); - - it('should keep user conditions in place and replace generated conditions', () => { - const merged = mergeExports( - { - '.': { - '@acme/source': './src/index.ts', - import: './dist/esm/old.mjs', - browser: './dist/browser.js', - require: './dist/cjs/old.js', - }, - }, - { - '.': { - types: './dist/esm/index.d.ts', - import: './dist/esm/index.mjs', - require: './dist/cjs/index.js', - }, - }, - ); - - expect(Object.keys(merged['.'] as object)).toEqual([ - '@acme/source', - 'types', - 'import', - 'require', - 'browser', - ]); - expect(merged['.']).toEqual({ - '@acme/source': './src/index.ts', - types: './dist/esm/index.d.ts', - import: './dist/esm/index.mjs', - require: './dist/cjs/index.js', - browser: './dist/browser.js', - }); - }); - - it('should describe the merged value and warn once when it changes', () => { - const warn = rs.spyOn(logger, 'warn').mockImplementation(() => {}); - const generated = { '.': { import: './dist/index.mjs' } }; - const merged = mergeExports( - { - '.': { - '@acme/source': './src/index.ts', - import: './dist/old.mjs', - }, - }, - generated, - ); - - expect(warn).toHaveBeenCalledWith( - 'Updated the "." subpath in the "exports" field of package.json to {"@acme/source":"./src/index.ts","import":"./dist/index.mjs"}.', - ); - mergeExports(merged, generated); - expect(warn).toHaveBeenCalledTimes(1); - warn.mockRestore(); - }); - - it('should drop stale generated conditions when switching to a single format', () => { - expect( - mergeExports( - { - '.': { - '@acme/source': './src/index.ts', - import: './dist/esm/index.mjs', - require: './dist/cjs/index.js', - }, - './utils': { import: './dist/utils.mjs', require: './dist/utils.js' }, - }, - { '.': './dist/esm/index.mjs', './utils': './dist/utils.mjs' }, - ), - ).toEqual({ - '.': { - '@acme/source': './src/index.ts', - default: './dist/esm/index.mjs', - }, - './utils': './dist/utils.mjs', - }); - }); - - it('should keep default last when merging user conditions', () => { - const merged = mergeExports( - { '.': { default: './dist/old.js', node: './dist/node.js' } }, - { '.': { types: './dist/index.d.ts', default: './dist/index.js' } }, - ); - - expect(Object.keys(merged['.'] as object)).toEqual([ - 'types', - 'node', - 'default', - ]); - }); - - it('should preserve external types and fallback targets when only formats are generated', () => { - const merged = mergeExports( - { - '.': { - browser: './dist/browser.js', - types: './types/index.d.ts', - import: './dist/old.mjs', - require: './dist/old.cjs', - default: './dist/umd.js', - }, - }, - { '.': { import: './dist/index.mjs', require: './dist/index.cjs' } }, - ); - - expect(merged['.']).toEqual({ - browser: './dist/browser.js', - types: './types/index.d.ts', - import: './dist/index.mjs', - require: './dist/index.cjs', - default: './dist/umd.js', - }); - expect(Object.keys(merged['.'] as object).at(-1)).toBe('default'); - }); - - it('should preserve external types when generating a single output', () => { - expect( - mergeExports( - { '.': { types: './types/index.d.ts', default: './dist/old.js' } }, - { '.': './dist/index.js' }, - ), - ).toEqual({ - '.': { types: './types/index.d.ts', default: './dist/index.js' }, - }); - }); - - it('should replace types when the build generates a declaration target', () => { - expect( - mergeExports( - { '.': { types: './types/index.d.ts', default: './dist/old.js' } }, - { '.': { types: './dist/index.d.ts', default: './dist/index.js' } }, - ), - ).toEqual({ - '.': { types: './dist/index.d.ts', default: './dist/index.js' }, - }); - }); - - it.each([ - ['an array', ['./dist/index.mjs']], - ['null', null], - ])('should replace an existing %s subpath and warn', (_, value) => { - const warn = rs.spyOn(logger, 'warn').mockImplementation(() => {}); - - expect(mergeExports({ '.': value }, { '.': './dist/index.mjs' })).toEqual({ - '.': './dist/index.mjs', - }); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0]![0]).toContain('"."'); - warn.mockRestore(); - }); - - it('should preserve array and null subpaths that are not generated', () => { - expect( - mergeExports( - { './fallback': ['./dist/fallback.mjs'], './internal': null }, - { '.': './dist/index.mjs' }, - ), - ).toEqual({ - '.': './dist/index.mjs', - './fallback': ['./dist/fallback.mjs'], - './internal': null, - }); - }); - - it.each([ - ['a string', './dist/index.mjs'], - ['an object of conditions', { import: './dist/index.mjs' }], - ['an array', ['./dist/index.mjs']], - ['null', null], - ])('should throw when the existing field is %s', (_, existing) => { - expect(() => mergeExports(existing, { '.': './dist/index.mjs' })).toThrow( - 'is not a subpath map', - ); - }); -}); - -describe('findStaleSubpaths', () => { - it('should find existing subpaths that point to missing files in the output directories', async () => { - const root = await mkdtemp(join(tmpdir(), 'rslib-exports-stale-')); - - try { - await mkdir(join(root, 'dist')); - await writeFile(join(root, 'dist/style.css'), ''); - - expect( - findStaleSubpaths({ - existing: { - '.': './dist/old.mjs', - './old': { import: './dist/old.mjs', require: './dist/old.js' }, - './style.css': './dist/style.css', - './src': './src/missing.ts', - './*': './dist/*.mjs', - }, - generated: { '.': './dist/index.mjs' }, - root, - outputPaths: [join(root, 'dist')], - }), - ).toEqual(['./old']); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it('should return an empty list when there is no existing field', () => { - expect( - findStaleSubpaths({ - existing: undefined, - generated: { '.': './dist/index.mjs' }, - root: '/pkg', - outputPaths: ['/pkg/dist'], - }), - ).toEqual([]); - }); -}); +import { describe, expect, it } from '@rstest/core'; +import { stringifyPackageJson } from '../src/exports/packageJson'; describe('stringifyPackageJson', () => { const content = { name: 'pkg', exports: { '.': './dist/index.mjs' } }; diff --git a/packages/core/tests/exportsPlugin.test.ts b/packages/core/tests/exportsPlugin.test.ts deleted file mode 100644 index 339d519897..0000000000 --- a/packages/core/tests/exportsPlugin.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core'; -import { afterEach, describe, expect, it, rs } from '@rstest/core'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { ExportsPlugin } from '../src/exports/plugin'; - -rs.mock('../src/utils/logger', () => ({ - logger: { info: rs.fn(), warn: rs.fn(), error: rs.fn() }, -})); - -const roots: string[] = []; - -afterEach(() => { - for (const root of roots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -function createFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rslib-exports-plugin-')); - roots.push(root); - const pkgPath = path.join(root, 'package.json'); - const callbacks: Array<(params: { stats: Rspack.Stats }) => void> = []; - ExportsPlugin({ - root, - libInfos: [{ id: 'esm', format: 'esm', config: {} }], - }).setup({ - onAfterBuild: (callback: (typeof callbacks)[number]) => { - callbacks.push(callback); - }, - } as unknown as RsbuildPluginAPI); - - const stats = { - hasErrors: () => false, - compilation: { - name: 'esm', - errors: [], - outputOptions: { path: path.join(root, 'dist') }, - entrypoints: new Map([ - [ - 'index', - { getEntrypointChunk: () => ({ files: new Set(['index.js']) }) }, - ], - ]), - }, - } as unknown as Rspack.Stats; - return { pkgPath, run: callbacks[0]!, stats }; -} - -describe('exports build failures', () => { - it('throws exports errors', () => { - const { pkgPath, run, stats } = createFixture(); - fs.writeFileSync(pkgPath, JSON.stringify({ exports: './old.js' })); - expect(() => run({ stats })).toThrow('subpath map'); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22d635654c..7b3429cbe0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,9 @@ catalogs: '@types/node': specifier: ^24.19.2 version: 24.19.2 + '@types/picomatch': + specifier: ^4.0.3 + version: 4.0.3 '@types/react': specifier: ^19.3.0 version: 19.3.0 @@ -360,6 +363,9 @@ catalogs: path-serializer: specifier: 0.7.0 version: 0.7.0 + picomatch: + specifier: ^4.0.5 + version: 4.0.5 postcss-alias: specifier: 2.0.0 version: 2.0.0 @@ -805,6 +811,9 @@ importers: '@types/fs-extra': specifier: 'catalog:' version: 11.0.4 + '@types/picomatch': + specifier: 'catalog:' + version: 4.0.3 '@types/semver': specifier: 'catalog:' version: 7.8.0 @@ -823,6 +832,9 @@ importers: path-serializer: specifier: 'catalog:' version: 0.7.0 + picomatch: + specifier: 'catalog:' + version: 4.0.5 rsbuild-plugin-publint: specifier: 'catalog:' version: 1.0.0(@rsbuild/core@2.3.0) @@ -4401,6 +4413,9 @@ packages: '@types/node@24.19.2': resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/qs@6.9.15': resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==} @@ -10957,6 +10972,8 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/picomatch@4.0.3': {} + '@types/qs@6.9.15': {} '@types/range-parser@1.2.7': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4a7311d298..68ba9b19a1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -59,6 +59,7 @@ catalog: '@types/lodash': '^4.17.27' '@types/lodash.merge': '^4.6.9' '@types/node': '^24.19.2' + '@types/picomatch': '^4.0.3' '@types/react': '^19.3.0' '@types/react-dom': '^19.3.0' '@types/semver': '^7.8.0' @@ -79,6 +80,7 @@ catalog: 'memfs': '^4.80.0' 'ora': '9.4.1' 'path-serializer': '0.7.0' + 'picomatch': '^4.0.5' 'postcss-alias': '2.0.0' 'preact': '^11.1.0' 'prettier': '^3.9.10' diff --git a/tests/integration/exports/css/package.json b/tests/integration/exports/css/package.json new file mode 100644 index 0000000000..3e050a35ff --- /dev/null +++ b/tests/integration/exports/css/package.json @@ -0,0 +1,5 @@ +{ + "name": "exports-css-test", + "version": "1.0.0", + "private": true +} diff --git a/tests/integration/exports/css/rslib.config.ts b/tests/integration/exports/css/rslib.config.ts new file mode 100644 index 0000000000..0e8401876b --- /dev/null +++ b/tests/integration/exports/css/rslib.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig(), generateBundleCjsConfig()], + source: { + entry: { + index: './src/index.ts', + theme: './src/theme.css', + }, + }, + output: { + target: 'web', + }, +}); diff --git a/tests/integration/exports/css/src/index.css b/tests/integration/exports/css/src/index.css new file mode 100644 index 0000000000..27cc739e60 --- /dev/null +++ b/tests/integration/exports/css/src/index.css @@ -0,0 +1,3 @@ +.index { + color: red; +} diff --git a/tests/integration/exports/css/src/index.ts b/tests/integration/exports/css/src/index.ts new file mode 100644 index 0000000000..5ad674e3c9 --- /dev/null +++ b/tests/integration/exports/css/src/index.ts @@ -0,0 +1,3 @@ +import './index.css'; + +export const index = 'index'; diff --git a/tests/integration/exports/css/src/theme.css b/tests/integration/exports/css/src/theme.css new file mode 100644 index 0000000000..f2def49565 --- /dev/null +++ b/tests/integration/exports/css/src/theme.css @@ -0,0 +1,3 @@ +.theme { + color: blue; +} diff --git a/tests/integration/exports/include/package.json b/tests/integration/exports/include/package.json new file mode 100644 index 0000000000..cf065b4141 --- /dev/null +++ b/tests/integration/exports/include/package.json @@ -0,0 +1,5 @@ +{ + "name": "exports-include-test", + "version": "1.0.0", + "private": true +} diff --git a/tests/integration/exports/include/rslib.config.ts b/tests/integration/exports/include/rslib.config.ts new file mode 100644 index 0000000000..af37b27a43 --- /dev/null +++ b/tests/integration/exports/include/rslib.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig({ bundle: false })], + source: { + entry: { + index: ['../__fixtures__/src/**'], + }, + }, + exports: { + include: ['index', 'utils/*'], + }, +}); diff --git a/tests/integration/exports/index.test.ts b/tests/integration/exports/index.test.ts index 0a99d56ede..e81faa0f8a 100644 --- a/tests/integration/exports/index.test.ts +++ b/tests/integration/exports/index.test.ts @@ -2,11 +2,7 @@ import { createRslib } from '@rslib/core'; import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterAll, describe, expect, test } from '@rstest/core'; -import { - buildAndGetResults, - expectBuildToFail, - rslibBuild, -} from 'test-helper'; +import { buildAndGetResults, expectBuildToFail, rslibBuild } from 'test-helper'; const fixtures = [ 'dual-format', @@ -20,6 +16,9 @@ const fixtures = [ 'dts/dist-path', 'dts/module', 'dts/bundleless', + 'include', + 'css', + 'source', ]; // The "exports" field is written back to the fixture package.json after @@ -165,45 +164,7 @@ describe('exports', () => { expect(readFixtureExports(fixture)).toBeUndefined(); }); - test('should keep user conditions of an existing subpath', async () => { - const fixture = 'dual-format'; - const fixturePath = join(__dirname, fixture); - writeFileSync( - join(fixturePath, 'package.json'), - `${JSON.stringify( - { - name: 'exports-dual-format-test', - exports: { - '.': { - '@acme/source': './src/index.ts', - import: './dist/esm/old.mjs', - require: './dist/cjs/old.js', - }, - }, - }, - null, - 2, - )}\n`, - ); - - await buildAndGetResults({ fixturePath, exports: true }); - - const exports = readFixtureExports(fixture); - expect(Object.keys(exports['.'])).toEqual([ - '@acme/source', - 'import', - 'require', - ]); - expect(exports).toEqual({ - '.': { - '@acme/source': './src/index.ts', - import: './dist/esm/index.mjs', - require: './dist/cjs/index.js', - }, - }); - }); - - test('should merge into existing exports and preserve the formatting', async () => { + test('should overwrite existing exports and preserve the formatting', async () => { const fixture = 'esm-only'; const fixturePath = join(__dirname, fixture); const packageJsonPath = join(fixturePath, 'package.json'); @@ -214,7 +175,10 @@ describe('exports', () => { '\t"name": "exports-esm-only-test",', '\t"exports": {', '\t\t"./package.json": "./package.json",', - '\t\t".": "./dist/esm/old.mjs"', + '\t\t".": {', + '\t\t\t"@acme/source": "./src/index.ts",', + '\t\t\t"default": "./dist/esm/old.mjs"', + '\t\t}', '\t}', '}', '', @@ -228,7 +192,6 @@ describe('exports', () => { '{', '\t"name": "exports-esm-only-test",', '\t"exports": {', - '\t\t"./package.json": "./package.json",', '\t\t".": "./dist/esm/index.mjs"', '\t}', '}', @@ -237,23 +200,66 @@ describe('exports', () => { ); }); - test('should fail when the existing exports is not a subpath map', async () => { + test('should overwrite existing exports in any form', async () => { const fixture = 'esm-only'; const fixturePath = join(__dirname, fixture); - const packageJsonPath = join(fixturePath, 'package.json'); - const packageJson = `${JSON.stringify( - { name: 'exports-esm-only-test', exports: './index.js' }, - null, - 2, - )}\n`; - writeFileSync(packageJsonPath, packageJson); + writeFileSync( + join(fixturePath, 'package.json'), + JSON.stringify({ name: 'exports-esm-only-test', exports: './index.js' }), + ); - const error = await expectBuildToFail( - buildAndGetResults({ fixturePath, exports: true }), + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports(fixture)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + test('should apply extra and transform to the generated exports', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + writeFileSync( + join(fixturePath, 'package.json'), + packageJsonContents.get(fixture)!, ); - expect(error.message).toContain('is not a subpath map'); - expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson); + const contexts: unknown[] = []; + await rslibBuild({ + cwd: fixturePath, + modifyConfig: (config) => { + config.exports = { + extra: { + '.': './dist/esm/replaced.mjs', + './package.json': './package.json', + }, + transform: [ + async (exports, context) => { + contexts.push(context); + return { ...exports, './worker': './dist/esm/worker.mjs' }; + }, + (exports) => ({ + ...exports, + '.': { browser: './dist/browser.js', default: exports['.']! }, + }), + ], + }; + }, + }); + + expect(readFixtureExports(fixture)).toEqual({ + '.': { + browser: './dist/browser.js', + default: './dist/esm/replaced.mjs', + }, + './package.json': './package.json', + './worker': './dist/esm/worker.mjs', + }); + expect(contexts).toEqual([ + { + pkg: JSON.parse(packageJsonContents.get(fixture)!), + isPublish: false, + }, + ]); }); test('should expose each entry as a subpath', async () => { @@ -342,18 +348,100 @@ describe('exports', () => { }); }); - test('should fail when declaration files are not emitted next to the outputs', async () => { - const fixture = 'dts/dist-path'; + test('should add a types condition for declaration files in dts.distPath', async () => { + const fixturePath = join(__dirname, 'dts/dist-path'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dts/dist-path')).toEqual({ + '.': { + types: './dist/types/index.d.ts', + default: './dist/esm/index.js', + }, + }); + }); + + test('should select bundleless entries with include', async () => { + const fixturePath = join(__dirname, 'include'); + await buildAndGetResults({ fixturePath }); + + expect(readFixtureExports('include')).toEqual({ + '.': './dist/esm/index.mjs', + './utils/numbers': './dist/esm/utils/numbers.mjs', + }); + }); + + test('should export the CSS of entries in bundle mode', async () => { + const fixturePath = join(__dirname, 'css'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('css')).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + './index.css': './dist/esm/index.css', + './theme.css': './dist/esm/theme.css', + }); + }); + + test('should point exports to source files and publish the outputs', async () => { + const fixture = 'source'; const fixturePath = join(__dirname, fixture); - const error = await expectBuildToFail( - buildAndGetResults({ fixturePath, exports: true }), + writeFileSync( + join(fixturePath, 'package.json'), + packageJsonContents.get(fixture)!, ); - expect(error.message).toContain( - 'no declaration files were found next to the outputs of the following libs that emit declaration files: "esm"', + const contexts: unknown[] = []; + await rslibBuild({ + cwd: fixturePath, + modifyConfig: (config) => { + config.exports = { + source: true, + transform: (exports, { isPublish }) => { + contexts.push(isPublish); + return exports; + }, + }; + }, + }); + + const pkg = JSON.parse( + readFileSync(join(fixturePath, 'package.json'), 'utf8'), ); - expect(readFileSync(join(fixturePath, 'package.json'), 'utf8')).toBe( - packageJsonContents.get(fixture), + expect(pkg.exports).toEqual({ '.': './src/index.ts' }); + expect(pkg.publishConfig).toEqual({ + exports: { + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + }, + }); + expect(contexts).toEqual([false, true]); + }); + + test('should add a source condition', async () => { + const fixture = 'source'; + const fixturePath = join(__dirname, fixture); + writeFileSync( + join(fixturePath, 'package.json'), + packageJsonContents.get(fixture)!, ); + + await rslibBuild({ + cwd: fixturePath, + modifyConfig: (config) => { + config.exports = { source: '@acme/source' }; + }, + }); + + expect(readFixtureExports(fixture)).toEqual({ + '.': { + '@acme/source': './src/index.ts', + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + }); }); }); diff --git a/tests/integration/exports/source/package.json b/tests/integration/exports/source/package.json new file mode 100644 index 0000000000..72014a9a15 --- /dev/null +++ b/tests/integration/exports/source/package.json @@ -0,0 +1,5 @@ +{ + "name": "exports-source-test", + "version": "1.0.0", + "private": true +} diff --git a/tests/integration/exports/source/rslib.config.ts b/tests/integration/exports/source/rslib.config.ts new file mode 100644 index 0000000000..a1f544775d --- /dev/null +++ b/tests/integration/exports/source/rslib.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig(), generateBundleCjsConfig()], + source: { + entry: { + index: './src/index.ts', + }, + }, +}); diff --git a/tests/integration/exports/source/src/index.ts b/tests/integration/exports/source/src/index.ts new file mode 100644 index 0000000000..f7ef6a6c87 --- /dev/null +++ b/tests/integration/exports/source/src/index.ts @@ -0,0 +1 @@ +export const index = 'index'; diff --git a/website/docs/en/guide/advanced/release-management.mdx b/website/docs/en/guide/advanced/release-management.mdx index c8ca018f97..f600b68d86 100644 --- a/website/docs/en/guide/advanced/release-management.mdx +++ b/website/docs/en/guide/advanced/release-management.mdx @@ -48,12 +48,24 @@ Also make sure that the package does not set `private: true`. It is also recomme ### Generate exports -Instead of maintaining the `exports` field by hand, you can run `rslib build --exports`, or set `exports: true` in the configuration file, to generate it from the build outputs after a successful build. `--no-exports` disables it for a single build. The rules are as follows: +Instead of maintaining the `exports` field by hand, you can run `rslib build --exports`, or set `exports` in the configuration file, to generate it from the build outputs after a successful build. `--no-exports` disables it for a single build. + +```ts title="rslib.config.ts" +export default defineConfig({ + exports: true, +}); +``` + +Rslib owns the `exports` field: the generated result replaces the existing value as a whole. Add subpaths and conditions that are not generated with [extra and transform](#customize-exports). The original indentation and line endings are preserved, and the file is not written when nothing changes. + +The `exports` field is not generated in watch mode, or when only some libs are built with `--lib`. Run `rslib build` to update it after the entries change. + +The rules are as follows: - Only outputs in the `esm` and `cjs` formats are exported. -- The `index` entry is exported as `.`, and other entries are exported by their entry names, such as `./utils/numbers`. -- In bundleless mode, only the `index` entry in the root of [lib.outBase](/config/lib/out-base) is exported. -- When an entry has both `esm` and `cjs` outputs, the value uses the `import` and `require` conditions. When it has only one format, the value is the output path. +- The `index` entry is exported as `.`, an entry such as `button/index` is exported as `./button`, and other entries are exported by their names, such as `./utils/numbers`. +- When an entry has both `esm` and `cjs` outputs, the value uses the `import` and `require` conditions. When it has only one format, the value is the output path, which applies to every condition, so an ESM-only package can also be loaded by `require(esm)`. +- The `.` subpath comes first, and the other subpaths are sorted. For example, building `esm` and `cjs` libs with an `index` entry generates: @@ -68,17 +80,39 @@ For example, building `esm` and `cjs` libs with an `index` entry generates: } ``` -The result is merged into the existing `exports` field. For matching subpaths, generated `import` and `require` conditions replace the previous format conditions. Existing `types` and `default` targets are kept unless this build generates replacements. Other conditions, such as `browser` or a custom source condition, and other subpaths are kept. A warning is shown when an existing subpath changes or points to a missing output. The original indentation and line endings are preserved, and the file is not written when nothing changes. +#### Select entries -The `exports` field is not generated in watch mode, or when only some libs are built with `--lib`. Run `rslib build` to update it after the entries change. +`exports.include` and `exports.exclude` select the exported entries with glob patterns matched against entry names. By default, all entries are exported in bundle mode, and only the `index` entry in the root of [lib.outBase](/config/lib/out-base) is exported in bundleless mode, where every source file is an entry. + +For example, to export the main entry and the `index` entry of each component directory in a bundleless component library: + +```ts title="rslib.config.ts" +export default defineConfig({ + lib: [{ format: 'esm', bundle: false }], + exports: { + include: ['index', '*/index'], + exclude: ['internal/**'], + }, +}); +``` + +```json title="package.json" +{ + "exports": { + ".": "./dist/index.js", + "./button": "./dist/button/index.js", + "./input": "./dist/input/index.js" + } +} +``` #### Declaration files -For libs with [dts](/config/lib/dts) enabled, Rslib checks the declaration files next to the outputs: +For libs with [dts](/config/lib/dts) enabled, Rslib looks up the declaration file of each output, next to the output, or under [dts.distPath](/config/lib/dts#dtsdistpath) when it is set: - When the outputs of every format have declaration files with the matching extension next to them (`.d.ts` for `.js`, `.d.mts` for `.mjs`, and `.d.cts` for `.cjs`), no `types` condition is added, as TypeScript finds them on its own. -- Otherwise, a `types` condition shared by all formats is added, preferring the declaration file with the matching extension. -- When none of the outputs of a lib have declaration files next to them, the build fails, such as when declaration files are emitted to a separate [dts.distPath](/config/lib/dts#dtsdistpath). +- Otherwise, a `types` condition shared by all formats is added, preferring the declaration file with the matching extension. Declaration files under a separate `dts.distPath` always need it. +- When no declaration files are found for the outputs of a lib, the build fails. For example, in a package with `"type": "module"`, when both the `esm` and `cjs` libs enable `dts: true`, the `cjs` output `index.cjs` only has `index.d.ts` next to it instead of the matching `index.d.cts`, so the following is generated: @@ -96,6 +130,62 @@ For example, in a package with `"type": "module"`, when both the `esm` and `cjs` To emit declaration files with the matching extension for every format, enable [dts.autoExtension](/config/lib/dts#dtsautoextension) and output each format to a separate directory. +#### CSS + +In bundle mode, the CSS emitted for an entry is exported, because the JavaScript output does not import it. The subpath is the path of the CSS file relative to the output directory, such as `./index.css` by default. When both the `esm` and `cjs` libs emit it, the `esm` one is used. An entry built only from CSS files only exports its CSS. + +```json title="package.json" +{ + "exports": { + ".": "./dist/index.js", + "./index.css": "./dist/index.css" + } +} +``` + +In bundleless mode, CSS is not exported, as the JavaScript outputs import their own styles. + +#### Source files + +`exports.source` exports the source files of the entries for development: + +- `true`: the top-level `exports` points to the source files, and the exports of the build outputs are written to `publishConfig.exports`, which replaces `exports` when packing and publishing with pnpm or Yarn. npm does not support `publishConfig.exports`. +- A string: a condition with this name pointing to the source file is added to each JavaScript subpath, which can be used with the [customConditions](https://www.typescriptlang.org/tsconfig/#customConditions) option of TypeScript, or `@rsbuild/plugin-source-build`. + +For example, `source: '@acme/source'` generates: + +```json title="package.json" +{ + "exports": { + ".": { + "@acme/source": "./src/index.ts", + "default": "./dist/index.js" + } + } +} +``` + +The source file of an entry must be a single module inside the package root. + +#### Customize exports + +- `exports.extra` adds subpaths to the generated exports. A subpath with the same key as a generated one replaces it as a whole. +- `exports.transform` receives the exports with `extra` applied, and returns the final exports. It can be async, or an array of functions called in order. Its second argument provides the content of `package.json` as `pkg`, and `isPublish`, which is `true` when the result is written to `publishConfig.exports`. + +```ts title="rslib.config.ts" +export default defineConfig({ + exports: { + extra: { + './package.json': './package.json', + }, + transform: (exports) => ({ + ...exports, + '.': { browser: './dist/browser.js', default: exports['.'] }, + }), + }, +}); +``` + ## pnpm version management pnpm provides [versioning and publishing features](https://pnpm.io/versioning) for recording changes, updating package versions, generating changelogs, synchronizing dependency versions between workspace packages, and publishing npm packages. diff --git a/website/docs/zh/guide/advanced/release-management.mdx b/website/docs/zh/guide/advanced/release-management.mdx index d29b746cc2..91b5941d42 100644 --- a/website/docs/zh/guide/advanced/release-management.mdx +++ b/website/docs/zh/guide/advanced/release-management.mdx @@ -48,14 +48,26 @@ description: '介绍 Rslib 项目的 npm 包版本管理与发布实践,包括 ### 生成 exports \{#generate-exports} -除了手动维护 `exports` 字段,也可以执行 `rslib build --exports`,或在配置文件中设置 `exports: true`,在构建成功后根据产物自动生成。`--no-exports` 可以在单次构建中关闭生成。生成规则如下: +除了手动维护 `exports` 字段,也可以执行 `rslib build --exports`,或在配置文件中设置 `exports`,在构建成功后根据产物自动生成。`--no-exports` 可以在单次构建中关闭生成。 + +```ts title="rslib.config.ts" +export default defineConfig({ + exports: true, +}); +``` + +Rslib 会接管 `exports` 字段:生成结果整体替换已有的值。不在生成结果中的子路径和条件,可以通过 [extra 和 transform](#customize-exports) 添加。写入时保留原有缩进和换行符,内容没有变化时跳过写入。 + +watch 模式下,或通过 `--lib` 只构建部分 lib 时,不会生成 `exports` 字段。入口变化后,执行 `rslib build` 即可更新。 + +生成规则如下: - 只导出 `esm` 和 `cjs` 格式的产物。 -- 入口 `index` 导出为 `.`,其他入口以入口名导出,例如 `./utils/numbers`。 -- bundleless 模式下只导出 [lib.outBase](/config/lib/out-base) 根目录下的 `index` 入口。 -- 同一入口同时有 `esm` 和 `cjs` 产物时,值为 `import` 和 `require` 条件;只有一种格式时,值为产物路径。 +- `index` 入口导出为 `.`,`button/index` 这类入口导出为 `./button`,其他入口按入口名导出,如 `./utils/numbers`。 +- 同一入口同时有 `esm` 和 `cjs` 产物时,使用 `import`、`require` 条件;只有一种格式时,直接写产物路径,对所有条件生效,因此只发布 ESM 的包也能被 `require(esm)` 加载。 +- `.` 排在最前,其余子路径按顺序排列。 -例如,构建包含 `index` 入口的 `esm` 和 `cjs` 产物时会生成: +例如,构建带有 `index` 入口的 `esm` 和 `cjs` lib 会生成: ```json title="package.json" { @@ -68,19 +80,41 @@ description: '介绍 Rslib 项目的 npm 包版本管理与发布实践,包括 } ``` -生成结果会合并到已有的 `exports` 字段中。同名子路径中的 `import`、`require` 条件按本轮格式更新;已有的 `types` 和 `default` 目标在本轮生成替代值时更新,否则保留。其他条件(如 `browser` 或自定义源码条件)和其他子路径保持不变。已有子路径发生变化或指向缺失产物时,会输出警告。写入时保留原有缩进和换行符,内容没有变化时跳过写入。 +#### 选择入口 \{#select-entries} -watch 模式下,或通过 `--lib` 只构建部分 lib 时,不会生成 `exports` 字段。入口变化后,执行 `rslib build` 即可更新。 +`exports.include` 和 `exports.exclude` 通过匹配入口名的 glob 选择导出的入口。默认情况下,bundle 模式导出所有入口;bundleless 模式下每个源文件都是入口,只导出 [lib.outBase](/config/lib/out-base) 根目录下的 `index` 入口。 + +例如,在 bundleless 组件库中导出主入口和每个组件目录的 `index` 入口: + +```ts title="rslib.config.ts" +export default defineConfig({ + lib: [{ format: 'esm', bundle: false }], + exports: { + include: ['index', '*/index'], + exclude: ['internal/**'], + }, +}); +``` + +```json title="package.json" +{ + "exports": { + ".": "./dist/index.js", + "./button": "./dist/button/index.js", + "./input": "./dist/input/index.js" + } +} +``` #### 类型声明文件 \{#declaration-files} -对于开启了 [dts](/config/lib/dts) 的 lib,Rslib 会检查产物旁的类型声明文件: +对开启了 [dts](/config/lib/dts) 的 lib,Rslib 会为每个产物查找类型声明文件:默认在产物旁边查找,设置了 [dts.distPath](/config/lib/dts#dtsdistpath) 时在该目录中查找。 -- 每种格式的产物旁都有扩展名对应的类型声明文件时(`.js` 对应 `.d.ts`,`.mjs` 对应 `.d.mts`,`.cjs` 对应 `.d.cts`),不添加 `types` 条件,TypeScript 会自行找到它们。 -- 否则添加一个所有格式共用的 `types` 条件,优先指向扩展名对应的类型声明文件。 -- 某个 lib 的所有产物旁都没有类型声明文件时,构建失败,例如类型声明文件输出到了单独的 [dts.distPath](/config/lib/dts#dtsdistpath)。 +- 各格式产物旁都有扩展名对应的类型声明文件时(`.js` 对应 `.d.ts`,`.mjs` 对应 `.d.mts`,`.cjs` 对应 `.d.cts`),不添加 `types` 条件,TypeScript 可以自行找到。 +- 否则添加一个各格式共用的 `types` 条件,优先选择扩展名对应的类型声明文件。位于单独 `dts.distPath` 中的类型声明文件总是需要该条件。 +- 某个 lib 的产物都找不到类型声明文件时,构建失败。 -例如,在设置了 `"type": "module"` 的包中,`esm` 和 `cjs` 都开启 `dts: true` 时,`cjs` 产物 `index.cjs` 旁只有 `index.d.ts`,不是扩展名对应的 `index.d.cts`,因此会生成: +例如,在 `"type": "module"` 的包中,`esm` 和 `cjs` lib 都开启 `dts: true` 时,`cjs` 产物 `index.cjs` 旁只有 `index.d.ts`,没有对应的 `index.d.cts`,会生成: ```json title="package.json" { @@ -94,7 +128,63 @@ watch 模式下,或通过 `--lib` 只构建部分 lib 时,不会生成 `expo } ``` -如需每种格式都有扩展名对应的类型声明文件,可以开启 [dts.autoExtension](/config/lib/dts#dtsautoextension),并将各格式输出到不同的目录。 +如果需要为每种格式生成扩展名对应的类型声明文件,可以开启 [dts.autoExtension](/config/lib/dts#dtsautoextension),并将各格式输出到不同目录。 + +#### CSS \{#css} + +bundle 模式下,入口产出的 CSS 会被导出,因为 JS 产物不会导入它。子路径为 CSS 文件相对输出目录的路径,默认如 `./index.css`。`esm` 和 `cjs` lib 都产出该 CSS 时,使用 `esm` 的产物。只由 CSS 文件构成的入口只导出 CSS。 + +```json title="package.json" +{ + "exports": { + ".": "./dist/index.js", + "./index.css": "./dist/index.css" + } +} +``` + +bundleless 模式下不导出 CSS,JS 产物会自行导入对应的样式。 + +#### 源码 \{#source-files} + +`exports.source` 用于在开发时导出入口的源文件: + +- `true`:顶层 `exports` 指向源文件,产物的导出写入 `publishConfig.exports`。使用 pnpm 或 Yarn 打包、发布时,`publishConfig.exports` 会替换 `exports`。npm 不支持 `publishConfig.exports`。 +- 字符串:在每个 JS 子路径中添加同名条件,指向源文件,可以配合 TypeScript 的 [customConditions](https://www.typescriptlang.org/tsconfig/#customConditions) 选项或 `@rsbuild/plugin-source-build` 使用。 + +例如,`source: '@acme/source'` 会生成: + +```json title="package.json" +{ + "exports": { + ".": { + "@acme/source": "./src/index.ts", + "default": "./dist/index.js" + } + } +} +``` + +入口的源文件必须是包根目录下的单个模块。 + +#### 自定义 exports \{#customize-exports} + +- `exports.extra` 向生成结果添加子路径。与生成的子路径同名时,整体替换该子路径。 +- `exports.transform` 接收应用 `extra` 后的结果,返回最终的 exports。它可以是异步函数,也可以是按顺序调用的函数数组。第二个参数提供 `package.json` 的内容 `pkg`,以及 `isPublish`,结果写入 `publishConfig.exports` 时为 `true`。 + +```ts title="rslib.config.ts" +export default defineConfig({ + exports: { + extra: { + './package.json': './package.json', + }, + transform: (exports) => ({ + ...exports, + '.': { browser: './dist/browser.js', default: exports['.'] }, + }), + }, +}); +``` ## pnpm 版本管理 \{#pnpm-version-management} From c799e1a36e95d26f0711d2a4d724ff0853c2aa6f Mon Sep 17 00:00:00 2001 From: Elecmonkey Date: Tue, 27 Oct 2026 18:26:13 +0800 Subject: [PATCH 5/7] fix(core): resolve entry modules for exports source and tighten exports tests --- packages/core/src/exports/generate.ts | 27 +++++++++-------- packages/core/src/exports/plugin.ts | 10 +++++-- packages/core/src/mergeConfig.ts | 10 +++++-- packages/core/src/types/config.ts | 4 +-- packages/core/tests/config.test.ts | 30 +++++++++++++++++++ packages/core/tests/exports.test.ts | 8 +++++ tests/integration/exports/index.test.ts | 18 ++++++++--- tests/integration/exports/source/package.json | 5 +++- .../exports/source/rslib.config.ts | 4 +++ tests/integration/exports/source/src/index.ts | 4 ++- tests/integration/exports/source/src/utils.ts | 1 + 11 files changed, 95 insertions(+), 26 deletions(-) create mode 100644 tests/integration/exports/source/src/utils.ts diff --git a/packages/core/src/exports/generate.ts b/packages/core/src/exports/generate.ts index f40665f484..d682cd3420 100644 --- a/packages/core/src/exports/generate.ts +++ b/packages/core/src/exports/generate.ts @@ -11,12 +11,13 @@ import { normalizeSlash } from '../utils/helper'; import { logger } from '../utils/logger'; import type { DeclarationFile } from './dts'; +/** + * Paths are relative to the package root, or absolute for different Windows + * drives, using forward slashes. + */ export type CollectedEntry = { entryName: string; /** - * Paths below are relative to the package root, or absolute for different - * Windows drives, using forward slashes. - * * The JavaScript output, absent for entries whose modules are all CSS files, * as their JavaScript output is empty. */ @@ -94,9 +95,9 @@ function getFormatSlots(slots: EntrySlots): FormatSlot[] { * when TypeScript cannot resolve a declaration file with the matching extension * next to every output, and then all formats share one declaration file. */ -function buildJsValue(slots: EntrySlots): ExportsValue { +function buildJsValue(slots: EntrySlots): string | Record { const formatSlots = getFormatSlots(slots); - const value = (types?: string): ExportsValue => { + const value = (types?: string): string | Record => { if (slots.esm && slots.cjs) { return { ...(types ? { types } : {}), @@ -151,16 +152,18 @@ function getSourceFile(subpath: string, slots: EntrySlots): string { } function addSourceCondition( - value: ExportsValue, + value: string | Record, condition: string, sourceFile: string, -): ExportsValue { +): Record { return typeof value === 'string' ? { [condition]: sourceFile, default: value } - : { [condition]: sourceFile, ...(value as Record) }; + : { [condition]: sourceFile, ...value }; } -function sortSubpaths(entries: [string, T][]): [string, T][] { +function sortSubpaths( + entries: [string, ExportsValue][], +): [string, ExportsValue][] { // Keep the main subpath first, then sort the rest by code units, as // locale-aware comparison may differ between machines. return entries.sort(([a], [b]) => { @@ -175,9 +178,7 @@ export function generateExports({ collected, options, }: GenerateExportsOptions): GeneratedExports { - const exclude = options.exclude?.length - ? picomatch(options.exclude) - : () => false; + const exclude = picomatch(options.exclude ?? []); const preparedLibs = libInfos.flatMap( ({ id, format, dts, bundle }): PreparedLib[] => { @@ -324,7 +325,7 @@ export function generateExports({ withCss: boolean, ): ExportsField => Object.fromEntries( - sortSubpaths([ + sortSubpaths([ ...jsSubpaths.map(([subpath, slots]): [string, ExportsValue] => [ subpath, jsValue(subpath, slots), diff --git a/packages/core/src/exports/plugin.ts b/packages/core/src/exports/plugin.ts index 63c907a2d7..28dafa91fb 100644 --- a/packages/core/src/exports/plugin.ts +++ b/packages/core/src/exports/plugin.ts @@ -6,6 +6,7 @@ import { isJavaScriptOutputFile } from '../exe/utils'; import type { ExportsField, ExportsOptions, + ExportsTransformContext, PkgJson, RequireKey, RsbuildConfigWithLibInfo, @@ -25,7 +26,7 @@ export type ExportsPluginOptions = { async function customizeExports( exports: ExportsField, { extra, transform }: ExportsOptions, - context: { pkg: PkgJson; isPublish: boolean }, + context: ExportsTransformContext, ): Promise { let result: ExportsField = { ...exports, ...extra }; for (const fn of [transform ?? []].flat()) { @@ -56,9 +57,12 @@ export const ExportsPlugin = ({ const sourceFiles = compilation.entries .get(entryName)! .dependencies.flatMap((dependency) => { + // The module of a dependency may be a concatenated module without a + // resource, while the resolved module is the original one. const resource = ( - compilation.moduleGraph.getModule(dependency) as - { resource?: string } | undefined + compilation.moduleGraph.getResolvedModule(dependency) as { + resource?: string; + } | null )?.resource; return resource ? [toRelative(resource.split('?')[0]!)] : []; }); diff --git a/packages/core/src/mergeConfig.ts b/packages/core/src/mergeConfig.ts index cec11b9949..a9ba358fe0 100644 --- a/packages/core/src/mergeConfig.ts +++ b/packages/core/src/mergeConfig.ts @@ -1,5 +1,5 @@ import { mergeRsbuildConfig } from '@rsbuild/core'; -import type { LibConfig, RslibConfig } from './types'; +import type { ExportsOptions, LibConfig, RslibConfig } from './types'; /** * Merge multiple lib arrays, merging items with the same id. @@ -51,7 +51,13 @@ function mergeExportsConfigs( if (config === true) return typeof merged === 'object' ? merged : true; if (config === false || typeof merged !== 'object') return config; - const result = { ...merged, ...config }; + // Like `mergeRsbuildConfig`, `undefined` values do not override others. + const result: ExportsOptions = { + ...merged, + ...Object.fromEntries( + Object.entries(config).filter(([, value]) => value !== undefined), + ), + }; if (merged.extra && config.extra) { result.extra = { ...merged.extra, ...config.extra }; } diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index ef288849bf..d3c4b51e1f 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -4,7 +4,7 @@ import type { RsbuildConfig, Rspack, } from '@rsbuild/core'; -import type { GetAsyncFunctionFromUnion, PkgJson } from './utils'; +import type { GetAsyncFunctionFromUnion } from './utils'; export type Format = 'esm' | 'cjs' | 'umd' | 'mf' | 'iife'; @@ -586,7 +586,7 @@ export type ExportsField = Record; export type ExportsTransformContext = { /** The content of package.json before writing. */ - pkg: PkgJson; + pkg: Record; /** Whether the result is written to `publishConfig.exports`. */ isPublish: boolean; }; diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts index 47d0dc44c7..487b377e83 100644 --- a/packages/core/tests/config.test.ts +++ b/packages/core/tests/config.test.ts @@ -442,6 +442,36 @@ describe('Should merge Rslib config correctly', () => { }, }); }); + + test('merge exports options with booleans and partial options', () => { + const transform = (exports: ExportsField) => exports; + const extra = { './package.json': './package.json' }; + + expect( + mergeRslibConfig({ exports: false }, { exports: { extra } }), + ).toEqual({ exports: { extra } }); + expect(mergeRslibConfig({ exports: false }, { exports: true })).toEqual({ + exports: true, + }); + expect( + mergeRslibConfig( + { exports: { extra } }, + { exports: { transform, include: ['index'] } }, + ), + ).toEqual({ exports: { extra, transform, include: ['index'] } }); + expect( + mergeRslibConfig( + { exports: { extra, transform, include: ['index'] } }, + { + exports: { + extra: undefined, + transform: undefined, + include: undefined, + }, + }, + ), + ).toEqual({ exports: { extra, transform, include: ['index'] } }); + }); }); describe('CLI options', () => { diff --git a/packages/core/tests/exports.test.ts b/packages/core/tests/exports.test.ts index 87a993a960..618717df25 100644 --- a/packages/core/tests/exports.test.ts +++ b/packages/core/tests/exports.test.ts @@ -356,6 +356,14 @@ describe('generateExports with source', () => { }); it('should add a source condition to JavaScript subpaths', () => { + const { exports } = generate(libInfos, collected, { + source: '@acme/source', + }); + expect(Object.keys(exports['.']!)).toEqual([ + '@acme/source', + 'import', + 'require', + ]); expect(generate(libInfos, collected, { source: '@acme/source' })).toEqual({ exports: { '.': { diff --git a/tests/integration/exports/index.test.ts b/tests/integration/exports/index.test.ts index e81faa0f8a..629048bd31 100644 --- a/tests/integration/exports/index.test.ts +++ b/tests/integration/exports/index.test.ts @@ -84,7 +84,8 @@ describe('exports', () => { { name: 'test:first-build', setup(api) { - api.onAfterBuild(() => onFirstBuild()); + // Run after the exports plugin to observe its writes. + api.onAfterBuild({ order: 'post', handler: onFirstBuild }); }, }, ], @@ -125,7 +126,7 @@ describe('exports', () => { expect(readFileSync(packageJsonPath, 'utf8')).toBe(original); }); - test('should read the exports option from the config file', async () => { + test('should enable exports with the exports option in the config', async () => { const fixture = 'esm-only'; const fixturePath = join(__dirname, fixture); writeFileSync( @@ -246,7 +247,9 @@ describe('exports', () => { }, }); - expect(readFixtureExports(fixture)).toEqual({ + const exports = readFixtureExports(fixture); + expect(Object.keys(exports)).toEqual(['.', './package.json', './worker']); + expect(exports).toEqual({ '.': { browser: './dist/browser.js', default: './dist/esm/replaced.mjs', @@ -411,6 +414,7 @@ describe('exports', () => { ); expect(pkg.exports).toEqual({ '.': './src/index.ts' }); expect(pkg.publishConfig).toEqual({ + access: 'public', exports: { '.': { import: './dist/esm/index.mjs', @@ -436,7 +440,13 @@ describe('exports', () => { }, }); - expect(readFixtureExports(fixture)).toEqual({ + const exports = readFixtureExports(fixture); + expect(Object.keys(exports['.'])).toEqual([ + '@acme/source', + 'import', + 'require', + ]); + expect(exports).toEqual({ '.': { '@acme/source': './src/index.ts', import: './dist/esm/index.mjs', diff --git a/tests/integration/exports/source/package.json b/tests/integration/exports/source/package.json index 72014a9a15..25725d73be 100644 --- a/tests/integration/exports/source/package.json +++ b/tests/integration/exports/source/package.json @@ -1,5 +1,8 @@ { "name": "exports-source-test", "version": "1.0.0", - "private": true + "private": true, + "publishConfig": { + "access": "public" + } } diff --git a/tests/integration/exports/source/rslib.config.ts b/tests/integration/exports/source/rslib.config.ts index a1f544775d..4293a74ed2 100644 --- a/tests/integration/exports/source/rslib.config.ts +++ b/tests/integration/exports/source/rslib.config.ts @@ -8,4 +8,8 @@ export default defineConfig({ index: './src/index.ts', }, }, + output: { + // The web target concatenates the imported module into the CJS entry. + target: 'web', + }, }); diff --git a/tests/integration/exports/source/src/index.ts b/tests/integration/exports/source/src/index.ts index f7ef6a6c87..118141357f 100644 --- a/tests/integration/exports/source/src/index.ts +++ b/tests/integration/exports/source/src/index.ts @@ -1 +1,3 @@ -export const index = 'index'; +import { add } from './utils'; + +export const sum = (a: number, b: number): number => add(a, b); diff --git a/tests/integration/exports/source/src/utils.ts b/tests/integration/exports/source/src/utils.ts new file mode 100644 index 0000000000..da7ff17a4d --- /dev/null +++ b/tests/integration/exports/source/src/utils.ts @@ -0,0 +1 @@ +export const add = (a: number, b: number): number => a + b; From 585dab985259b6090111eb4d5771fa5ddc9fae15 Mon Sep 17 00:00:00 2001 From: Elecmonkey Date: Wed, 28 Oct 2026 11:14:02 +0800 Subject: [PATCH 6/7] refactor(core): merge exports extra and transform into custom --- packages/core/src/exports/plugin.ts | 13 +++-- packages/core/src/index.ts | 5 +- packages/core/src/mergeConfig.ts | 14 ++--- packages/core/src/types/config.ts | 21 ++++---- packages/core/tests/config.test.ts | 38 +++++--------- tests/integration/exports/index.test.ts | 51 ++++++++++++------- .../en/guide/advanced/release-management.mdx | 14 ++--- .../zh/guide/advanced/release-management.mdx | 14 ++--- 8 files changed, 84 insertions(+), 86 deletions(-) diff --git a/packages/core/src/exports/plugin.ts b/packages/core/src/exports/plugin.ts index 28dafa91fb..1018127444 100644 --- a/packages/core/src/exports/plugin.ts +++ b/packages/core/src/exports/plugin.ts @@ -6,7 +6,7 @@ import { isJavaScriptOutputFile } from '../exe/utils'; import type { ExportsField, ExportsOptions, - ExportsTransformContext, + ExportsCustomContext, PkgJson, RequireKey, RsbuildConfigWithLibInfo, @@ -25,14 +25,13 @@ export type ExportsPluginOptions = { async function customizeExports( exports: ExportsField, - { extra, transform }: ExportsOptions, - context: ExportsTransformContext, + { custom }: ExportsOptions, + context: ExportsCustomContext, ): Promise { - let result: ExportsField = { ...exports, ...extra }; - for (const fn of [transform ?? []].flat()) { - result = await fn(result, context); + if (typeof custom === 'function') { + return custom(exports, context); } - return result; + return { ...exports, ...custom }; } export const ExportsPlugin = ({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c375a360e5..b292503293 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,9 +32,10 @@ export type { Dts, ExeOptions, ExportsField, + ExportsCustom, + ExportsCustomContext, + ExportsCustomFunction, ExportsOptions, - ExportsTransform, - ExportsTransformContext, ExportsValue, Format, InspectConfigOptions, diff --git a/packages/core/src/mergeConfig.ts b/packages/core/src/mergeConfig.ts index a9ba358fe0..d434046dc9 100644 --- a/packages/core/src/mergeConfig.ts +++ b/packages/core/src/mergeConfig.ts @@ -40,8 +40,7 @@ function mergeLibConfigs( /** * Merge multiple `exports` options. `true` and an options object enable it with - * the options, `extra` is merged by subpath, as each subpath is replaced as a - * whole, and `transform` functions are called in order. + * the options, and each option is overridden by the later one. */ function mergeExportsConfigs( ...exportsConfigs: RslibConfig['exports'][] @@ -52,19 +51,12 @@ function mergeExportsConfigs( if (config === false || typeof merged !== 'object') return config; // Like `mergeRsbuildConfig`, `undefined` values do not override others. - const result: ExportsOptions = { + return { ...merged, ...Object.fromEntries( Object.entries(config).filter(([, value]) => value !== undefined), ), - }; - if (merged.extra && config.extra) { - result.extra = { ...merged.extra, ...config.extra }; - } - if (merged.transform && config.transform) { - result.transform = [merged.transform, config.transform].flat(); - } - return result; + } as ExportsOptions; }, undefined); } diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index d3c4b51e1f..e93fa654c8 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -584,18 +584,20 @@ export type ExportsValue = export type ExportsField = Record; -export type ExportsTransformContext = { +export type ExportsCustomContext = { /** The content of package.json before writing. */ pkg: Record; /** Whether the result is written to `publishConfig.exports`. */ isPublish: boolean; }; -export type ExportsTransform = ( +export type ExportsCustomFunction = ( exports: ExportsField, - context: ExportsTransformContext, + context: ExportsCustomContext, ) => ExportsField | Promise; +export type ExportsCustom = ExportsField | ExportsCustomFunction; + export type ExportsOptions = { /** * Glob patterns of entry names to export. @@ -615,13 +617,10 @@ export type ExportsOptions = { */ source?: boolean | string; /** - * Subpaths added to the generated exports. A subpath with the same key as a - * generated one replaces it as a whole. - */ - extra?: ExportsField; - /** - * Receives the generated exports with `extra` applied, and returns the final - * exports. An array of functions is called in order. + * Customize the generated exports. + * - object: subpaths added to the exports. A subpath with the same key as a + * generated one replaces it as a whole. + * - function: receives the exports and returns the final exports. */ - transform?: ExportsTransform | ExportsTransform[]; + custom?: ExportsCustom; }; diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts index 487b377e83..a6e83b52ff 100644 --- a/packages/core/tests/config.test.ts +++ b/packages/core/tests/config.test.ts @@ -406,12 +406,11 @@ describe('Should merge Rslib config correctly', () => { }); test('merge exports options', () => { - const transform1 = (exports: ExportsField) => exports; - const transform2 = (exports: ExportsField) => exports; + const fn = (exports: ExportsField) => exports; + const extra = { './a': { browser: './a.js', default: './a.cjs' } }; const options: ExportsOptions = { include: ['index'], - extra: { './a': { browser: './a.js', default: './a.cjs' } }, - transform: transform1, + custom: extra, }; expect(mergeRslibConfig({ exports: options }, { exports: true })).toEqual({ @@ -429,48 +428,39 @@ describe('Should merge Rslib config correctly', () => { { exports: { include: ['*/index'], - extra: { './a': './a.js', './b': './b.js' }, - transform: transform2, + custom: fn, }, }, ), ).toEqual({ exports: { include: ['*/index'], - extra: { './a': './a.js', './b': './b.js' }, - transform: [transform1, transform2], + custom: fn, }, }); }); test('merge exports options with booleans and partial options', () => { - const transform = (exports: ExportsField) => exports; - const extra = { './package.json': './package.json' }; + const custom = { './package.json': './package.json' }; expect( - mergeRslibConfig({ exports: false }, { exports: { extra } }), - ).toEqual({ exports: { extra } }); + mergeRslibConfig({ exports: false }, { exports: { custom } }), + ).toEqual({ exports: { custom } }); expect(mergeRslibConfig({ exports: false }, { exports: true })).toEqual({ exports: true, }); expect( mergeRslibConfig( - { exports: { extra } }, - { exports: { transform, include: ['index'] } }, + { exports: { custom } }, + { exports: { include: ['index'] } }, ), - ).toEqual({ exports: { extra, transform, include: ['index'] } }); + ).toEqual({ exports: { custom, include: ['index'] } }); expect( mergeRslibConfig( - { exports: { extra, transform, include: ['index'] } }, - { - exports: { - extra: undefined, - transform: undefined, - include: undefined, - }, - }, + { exports: { custom, include: ['index'] } }, + { exports: { custom: undefined, include: undefined } }, ), - ).toEqual({ exports: { extra, transform, include: ['index'] } }); + ).toEqual({ exports: { custom, include: ['index'] } }); }); }); diff --git a/tests/integration/exports/index.test.ts b/tests/integration/exports/index.test.ts index 629048bd31..cbe72cfaa8 100644 --- a/tests/integration/exports/index.test.ts +++ b/tests/integration/exports/index.test.ts @@ -216,7 +216,7 @@ describe('exports', () => { }); }); - test('should apply extra and transform to the generated exports', async () => { + test('should apply an object custom to the generated exports', async () => { const fixture = 'esm-only'; const fixturePath = join(__dirname, fixture); writeFileSync( @@ -224,37 +224,54 @@ describe('exports', () => { packageJsonContents.get(fixture)!, ); - const contexts: unknown[] = []; await rslibBuild({ cwd: fixturePath, modifyConfig: (config) => { config.exports = { - extra: { + custom: { '.': './dist/esm/replaced.mjs', './package.json': './package.json', }, - transform: [ - async (exports, context) => { - contexts.push(context); - return { ...exports, './worker': './dist/esm/worker.mjs' }; - }, - (exports) => ({ + }; + }, + }); + + expect(readFixtureExports(fixture)).toEqual({ + '.': './dist/esm/replaced.mjs', + './package.json': './package.json', + }); + }); + + test('should apply an async function custom to the generated exports', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + writeFileSync( + join(fixturePath, 'package.json'), + packageJsonContents.get(fixture)!, + ); + + const contexts: unknown[] = []; + await rslibBuild({ + cwd: fixturePath, + modifyConfig: (config) => { + config.exports = { + custom: async (exports, context) => { + contexts.push(context); + return { ...exports, '.': { browser: './dist/browser.js', default: exports['.']! }, - }), - ], + './worker': './dist/esm/worker.mjs', + }; + }, }; }, }); - const exports = readFixtureExports(fixture); - expect(Object.keys(exports)).toEqual(['.', './package.json', './worker']); - expect(exports).toEqual({ + expect(readFixtureExports(fixture)).toEqual({ '.': { browser: './dist/browser.js', - default: './dist/esm/replaced.mjs', + default: './dist/esm/index.mjs', }, - './package.json': './package.json', './worker': './dist/esm/worker.mjs', }); expect(contexts).toEqual([ @@ -401,7 +418,7 @@ describe('exports', () => { modifyConfig: (config) => { config.exports = { source: true, - transform: (exports, { isPublish }) => { + custom: (exports, { isPublish }) => { contexts.push(isPublish); return exports; }, diff --git a/website/docs/en/guide/advanced/release-management.mdx b/website/docs/en/guide/advanced/release-management.mdx index f600b68d86..9017b6c3da 100644 --- a/website/docs/en/guide/advanced/release-management.mdx +++ b/website/docs/en/guide/advanced/release-management.mdx @@ -56,7 +56,7 @@ export default defineConfig({ }); ``` -Rslib owns the `exports` field: the generated result replaces the existing value as a whole. Add subpaths and conditions that are not generated with [extra and transform](#customize-exports). The original indentation and line endings are preserved, and the file is not written when nothing changes. +Rslib owns the `exports` field: the generated result replaces the existing value as a whole. Add subpaths and conditions that are not generated with [custom](#customize-exports). The original indentation and line endings are preserved, and the file is not written when nothing changes. The `exports` field is not generated in watch mode, or when only some libs are built with `--lib`. Run `rslib build` to update it after the entries change. @@ -169,18 +169,18 @@ The source file of an entry must be a single module inside the package root. #### Customize exports -- `exports.extra` adds subpaths to the generated exports. A subpath with the same key as a generated one replaces it as a whole. -- `exports.transform` receives the exports with `extra` applied, and returns the final exports. It can be async, or an array of functions called in order. Its second argument provides the content of `package.json` as `pkg`, and `isPublish`, which is `true` when the result is written to `publishConfig.exports`. +`exports.custom` customizes the generated exports. It can be an object or a function. + +- An object adds subpaths to the exports. A subpath with the same key as a generated one replaces it as a whole. +- A function receives the exports and returns the final exports. It can be async. Its second argument provides the content of `package.json` as `pkg`, and `isPublish`, which is `true` when the result is written to `publishConfig.exports`. ```ts title="rslib.config.ts" export default defineConfig({ exports: { - extra: { - './package.json': './package.json', - }, - transform: (exports) => ({ + custom: (exports) => ({ ...exports, '.': { browser: './dist/browser.js', default: exports['.'] }, + './package.json': './package.json', }), }, }); diff --git a/website/docs/zh/guide/advanced/release-management.mdx b/website/docs/zh/guide/advanced/release-management.mdx index 91b5941d42..ba3efefa9d 100644 --- a/website/docs/zh/guide/advanced/release-management.mdx +++ b/website/docs/zh/guide/advanced/release-management.mdx @@ -56,7 +56,7 @@ export default defineConfig({ }); ``` -Rslib 会接管 `exports` 字段:生成结果整体替换已有的值。不在生成结果中的子路径和条件,可以通过 [extra 和 transform](#customize-exports) 添加。写入时保留原有缩进和换行符,内容没有变化时跳过写入。 +Rslib 会接管 `exports` 字段:生成结果整体替换已有的值。不在生成结果中的子路径和条件,可以通过 [custom](#customize-exports) 添加。写入时保留原有缩进和换行符,内容没有变化时跳过写入。 watch 模式下,或通过 `--lib` 只构建部分 lib 时,不会生成 `exports` 字段。入口变化后,执行 `rslib build` 即可更新。 @@ -169,18 +169,18 @@ bundleless 模式下不导出 CSS,JS 产物会自行导入对应的样式。 #### 自定义 exports \{#customize-exports} -- `exports.extra` 向生成结果添加子路径。与生成的子路径同名时,整体替换该子路径。 -- `exports.transform` 接收应用 `extra` 后的结果,返回最终的 exports。它可以是异步函数,也可以是按顺序调用的函数数组。第二个参数提供 `package.json` 的内容 `pkg`,以及 `isPublish`,结果写入 `publishConfig.exports` 时为 `true`。 +`exports.custom` 用于自定义生成的 exports,可以是对象或函数。 + +- 对象向生成结果添加子路径。与生成的子路径同名时,整体替换该子路径。 +- 函数接收生成结果,返回最终的 exports,可以是异步函数。第二个参数提供 `package.json` 的内容 `pkg`,以及 `isPublish`,结果写入 `publishConfig.exports` 时为 `true`。 ```ts title="rslib.config.ts" export default defineConfig({ exports: { - extra: { - './package.json': './package.json', - }, - transform: (exports) => ({ + custom: (exports) => ({ ...exports, '.': { browser: './dist/browser.js', default: exports['.'] }, + './package.json': './package.json', }), }, }); From 802b12c143795bf140d05a35b9ebab3dc513f650 Mon Sep 17 00:00:00 2001 From: Elecmonkey Date: Thu, 29 Oct 2026 19:17:50 +0800 Subject: [PATCH 7/7] feat(core): match exports include and exclude against output files --- packages/core/src/exports/generate.ts | 75 ++++++++++++------ packages/core/src/exports/plugin.ts | 18 ++++- packages/core/src/types/config.ts | 9 ++- packages/core/tests/exports.test.ts | 79 ++++++++++++++++--- packages/core/tests/exportsDts.test.ts | 4 +- tests/integration/exports/index.test.ts | 24 ++++++ .../en/guide/advanced/release-management.mdx | 2 +- .../zh/guide/advanced/release-management.mdx | 2 +- 8 files changed, 168 insertions(+), 45 deletions(-) diff --git a/packages/core/src/exports/generate.ts b/packages/core/src/exports/generate.ts index d682cd3420..19ebbb41b1 100644 --- a/packages/core/src/exports/generate.ts +++ b/packages/core/src/exports/generate.ts @@ -15,20 +15,22 @@ import type { DeclarationFile } from './dts'; * Paths are relative to the package root, or absolute for different Windows * drives, using forward slashes. */ +export type CollectedOutput = { + file: string; + /** Path relative to the output directory, matched by `include` and `exclude`. */ + name: string; +}; + export type CollectedEntry = { entryName: string; /** * The JavaScript output, absent for entries whose modules are all CSS files, * as their JavaScript output is empty. */ - jsFile?: string; + js?: CollectedOutput; /** The CSS output, only collected in bundle mode. */ - css?: { - file: string; - /** Path relative to the output directory, which names the CSS subpath. */ - name: string; - }; - /** The declaration file of `jsFile`, only collected for libs that emit declaration files. */ + css?: CollectedOutput; + /** The declaration file of `js`, only collected for libs that emit declaration files. */ declaration?: DeclarationFile; /** The modules of the entry. */ sourceFiles: string[]; @@ -70,6 +72,18 @@ type EntrySlots = Partial> & { css?: { format: Format; target: string; subpath: string }; }; +// Extensions of the outputs that `include` and `exclude` match without them. +const OUTPUT_EXTENSION = /\.(?:js|mjs|cjs|css)$/; + +/** + * Match an output path relative to the output directory, either as it is or + * without its extension. + */ +function createOutputMatcher(patterns: string[]): (name: string) => boolean { + const isMatch = picomatch(patterns); + return (name) => isMatch(name) || isMatch(name.replace(OUTPUT_EXTENSION, '')); +} + /** Normalize entry names such as `./foo` to `foo` before matching and naming. */ function normalizeEntryName(entryName: string): string { return normalizeSlash(entryName).replace(/^(\.\/)+/, ''); @@ -178,24 +192,35 @@ export function generateExports({ collected, options, }: GenerateExportsOptions): GeneratedExports { - const exclude = picomatch(options.exclude ?? []); + const exclude = createOutputMatcher(options.exclude ?? []); const preparedLibs = libInfos.flatMap( ({ id, format, dts, bundle }): PreparedLib[] => { // Other formats, such as UMD, are not referenced by the `exports` field. if (format !== 'esm' && format !== 'cjs') return []; - // In bundleless mode, every source file is an entry, so only the main - // entry is treated as public API by default. - const include = picomatch( - options.include ?? (bundle === false ? ['index'] : ['**']), - ); - const entries = collected - .get(id)! - .map((entry) => ({ - ...entry, - entryName: normalizeEntryName(entry.entryName), - })) - .filter(({ entryName }) => include(entryName) && !exclude(entryName)); + const include = options.include && createOutputMatcher(options.include); + // Outputs are selected one by one, so an entry can export its JavaScript + // without its CSS. In bundleless mode, every source file is an entry, so + // only the main entry is treated as public API by default. + const isSelected = (entryName: string, name: string) => + (include ? include(name) : bundle !== false || entryName === 'index') && + !exclude(name); + const entries = collected.get(id)!.flatMap((collectedEntry) => { + const entryName = normalizeEntryName(collectedEntry.entryName); + const { js, css } = collectedEntry; + const hasJs = js !== undefined && isSelected(entryName, js.name); + const hasCss = css !== undefined && isSelected(entryName, css.name); + if (!hasJs && !hasCss) return []; + return [ + { + ...collectedEntry, + entryName, + js: hasJs ? js : undefined, + declaration: hasJs ? collectedEntry.declaration : undefined, + css: hasCss ? css : undefined, + }, + ]; + }); return [ { id, @@ -209,7 +234,7 @@ export function generateExports({ ); const libsWithoutDeclarations = preparedLibs.filter((lib) => { - const jsEntries = lib.entries.filter((entry) => entry.jsFile); + const jsEntries = lib.entries.filter((entry) => entry.js); return ( lib.dts && jsEntries.length > 0 && @@ -242,7 +267,7 @@ export function generateExports({ for (const lib of preparedLibs) { for (const entry of lib.entries) { const { entryName } = entry; - for (const file of [entry.jsFile, entry.css?.file]) { + for (const file of [entry.js?.file, entry.css?.file]) { if (file && isOutsidePackageRoot(file)) { throw new Error( `Failed to generate the "exports" field because the "${entryName}" entry of the "${lib.id}" lib emits "${file}", which is outside the package root.`, @@ -251,8 +276,8 @@ export function generateExports({ } const slots = entrySlotsByName.get(entryName) ?? {}; - if (entry.jsFile) { - const target = `./${entry.jsFile}`; + if (entry.js) { + const target = `./${entry.js.file}`; const existingTarget = slots[lib.format]?.target; if (existingTarget && existingTarget !== target) { throw new Error( @@ -316,7 +341,7 @@ export function generateExports({ } if (entryNameBySubpath.size === 0) { throw new Error( - 'Failed to generate the "exports" field because no entries can be exported. Only entries of the "esm" and "cjs" formats that match "exports.include" and do not match "exports.exclude" are exported, and only the "index" entry is included by default in bundleless mode.', + 'Failed to generate the "exports" field because no entries can be exported. Only outputs of the "esm" and "cjs" formats that match "exports.include" and do not match "exports.exclude" are exported, and only the "index" entry is included by default in bundleless mode.', ); } diff --git a/packages/core/src/exports/plugin.ts b/packages/core/src/exports/plugin.ts index 1018127444..fb0d04181f 100644 --- a/packages/core/src/exports/plugin.ts +++ b/packages/core/src/exports/plugin.ts @@ -76,10 +76,15 @@ export const ExportsPlugin = ({ const cssOutput = libInfo.bundle === false ? undefined : files.find(isCssFile); - const jsFile = jsOutput && toRelative(path.join(outputPath, jsOutput)); + const js = jsOutput + ? { + file: toRelative(path.join(outputPath, jsOutput)), + name: normalizeSlash(jsOutput), + } + : undefined; entries.push({ entryName, - jsFile, + js, css: cssOutput ? { file: toRelative(path.join(outputPath, cssOutput)), @@ -87,8 +92,13 @@ export const ExportsPlugin = ({ } : undefined, declaration: - jsFile && dts - ? findDeclarationFile({ jsFile, root, outputPath, dtsDistPath }) + js && dts + ? findDeclarationFile({ + jsFile: js.file, + root, + outputPath, + dtsDistPath, + }) : undefined, sourceFiles, }); diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index e93fa654c8..00fefa37ab 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -600,12 +600,15 @@ export type ExportsCustom = ExportsField | ExportsCustomFunction; export type ExportsOptions = { /** - * Glob patterns of entry names to export. - * @default `['**']` in bundle mode, `['index']` in bundleless mode + * Glob patterns of the outputs to export, matched against the paths of the + * JavaScript and CSS outputs relative to the output directory of each lib, + * with or without their extensions. + * @default all entries in bundle mode, the `index` entry in bundleless mode */ include?: string[]; /** - * Glob patterns of entry names to exclude from `include`. + * Glob patterns of the outputs to exclude from `include`, matched in the same + * way. */ exclude?: string[]; /** diff --git a/packages/core/tests/exports.test.ts b/packages/core/tests/exports.test.ts index 618717df25..a0a5113508 100644 --- a/packages/core/tests/exports.test.ts +++ b/packages/core/tests/exports.test.ts @@ -22,21 +22,22 @@ const createLibInfo = ( ...info, }); +/** The path of an output relative to an output directory under `dist`. */ +const outputName = (file: string) => file.replace(/^dist\/(esm\/|cjs\/)?/, ''); + const entry = ( entryName: string, jsFile?: string, info: Partial = {}, -): CollectedEntry => ({ entryName, jsFile, sourceFiles: [], ...info }); - -/** A CSS output named by its path relative to an output directory under `dist`. */ -const css = ( - file: string, - name = file.replace(/^dist\/(esm\/|cjs\/)?/, ''), -) => ({ - file, - name, +): CollectedEntry => ({ + entryName, + js: jsFile ? { file: jsFile, name: outputName(jsFile) } : undefined, + sourceFiles: [], + ...info, }); +const css = (file: string, name = outputName(file)) => ({ file, name }); + const generate = ( libInfos: LibInfo[], collected: Record, @@ -239,6 +240,66 @@ describe('generateExports with include and exclude', () => { }); }); + it('should match outputs with or without their extensions', () => { + const libs = [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')]; + const collected = { + esm: [entry('index', 'dist/esm/index.js')], + cjs: [entry('index', 'dist/cjs/index.cjs')], + }; + expect(generate(libs, collected, { include: ['index'] }).exports).toEqual({ + '.': { import: './dist/esm/index.js', require: './dist/cjs/index.cjs' }, + }); + expect( + generate(libs, collected, { include: ['index.js', 'index.cjs'] }).exports, + ).toEqual({ + '.': { import: './dist/esm/index.js', require: './dist/cjs/index.cjs' }, + }); + expect( + generate(libs, collected, { include: ['index.js'] }).exports, + ).toEqual({ '.': './dist/esm/index.js' }); + expect( + generate(libs, collected, { exclude: ['index.cjs'] }).exports, + ).toEqual({ '.': './dist/esm/index.js' }); + }); + + it('should select the JavaScript and CSS of an entry separately', () => { + const libs = [createLibInfo('esm', 'esm')]; + const collected = { + esm: [entry('index', 'dist/index.js', { css: css('dist/index.css') })], + }; + expect(generate(libs, collected).exports).toEqual({ + '.': './dist/index.js', + './index.css': './dist/index.css', + }); + expect( + generate(libs, collected, { include: ['index.js'] }).exports, + ).toEqual({ '.': './dist/index.js' }); + expect( + generate(libs, collected, { include: ['index.css'] }).exports, + ).toEqual({ './index.css': './dist/index.css' }); + expect(generate(libs, collected, { exclude: ['*.css'] }).exports).toEqual({ + '.': './dist/index.js', + }); + }); + + it('should match hashed outputs and keep the default of bundleless mode', () => { + expect( + generate( + [createLibInfo('esm', 'esm')], + { esm: [entry('index', 'dist/index.cfdc9c6e75.js')] }, + { include: ['index.*'] }, + ).exports, + ).toEqual({ '.': './dist/index.cfdc9c6e75.js' }); + expect( + generate([bundleless], { + esm: [ + entry('index', 'dist/index.cfdc9c6e75.js'), + entry('button/index', 'dist/button/index.acf131b86e.js'), + ], + }).exports, + ).toEqual({ '.': './dist/index.cfdc9c6e75.js' }); + }); + it('should throw when no entries match', () => { expect(() => generate([bundleless], { esm: entries }, { include: ['missing'] }), diff --git a/packages/core/tests/exportsDts.test.ts b/packages/core/tests/exportsDts.test.ts index 461f6c8a75..ce734b450b 100644 --- a/packages/core/tests/exportsDts.test.ts +++ b/packages/core/tests/exportsDts.test.ts @@ -99,7 +99,7 @@ describe('generateExports with declaration files', () => { declaration?: DeclarationFile, ): CollectedEntry => ({ entryName: 'index', - jsFile, + js: { file: jsFile, name: jsFile.replace(/^dist\/(esm\/|cjs\/)?/, '') }, declaration, sourceFiles: [], }); @@ -255,7 +255,7 @@ describe('generateExports with declaration files', () => { }), { entryName: 'assets/logo', - jsFile: 'dist/assets/logo.js', + js: { file: 'dist/assets/logo.js', name: 'assets/logo.js' }, sourceFiles: [], }, ], diff --git a/tests/integration/exports/index.test.ts b/tests/integration/exports/index.test.ts index cbe72cfaa8..72868dbab0 100644 --- a/tests/integration/exports/index.test.ts +++ b/tests/integration/exports/index.test.ts @@ -404,6 +404,30 @@ describe('exports', () => { }); }); + test('should select the JavaScript and CSS outputs separately', async () => { + const fixture = 'css'; + const fixturePath = join(__dirname, fixture); + writeFileSync( + join(fixturePath, 'package.json'), + packageJsonContents.get(fixture)!, + ); + + await rslibBuild({ + cwd: fixturePath, + modifyConfig: (config) => { + config.exports = { include: ['index.{js,mjs}', 'theme'] }; + }, + }); + + expect(readFixtureExports(fixture)).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + './theme.css': './dist/esm/theme.css', + }); + }); + test('should point exports to source files and publish the outputs', async () => { const fixture = 'source'; const fixturePath = join(__dirname, fixture); diff --git a/website/docs/en/guide/advanced/release-management.mdx b/website/docs/en/guide/advanced/release-management.mdx index 9017b6c3da..a560ef7daf 100644 --- a/website/docs/en/guide/advanced/release-management.mdx +++ b/website/docs/en/guide/advanced/release-management.mdx @@ -82,7 +82,7 @@ For example, building `esm` and `cjs` libs with an `index` entry generates: #### Select entries -`exports.include` and `exports.exclude` select the exported entries with glob patterns matched against entry names. By default, all entries are exported in bundle mode, and only the `index` entry in the root of [lib.outBase](/config/lib/out-base) is exported in bundleless mode, where every source file is an entry. +`exports.include` and `exports.exclude` select the exported outputs with glob patterns. A pattern is matched against the path of each JavaScript or CSS output relative to the output directory of its lib, such as `utils/index.js` or `index.css`, with or without the extension. Writing `index` matches `index.js`, `index.cjs` and `index.css`, while `index.css` matches only the CSS. By default, all entries are exported in bundle mode, and only the `index` entry in the root of [lib.outBase](/config/lib/out-base) is exported in bundleless mode, where every source file is an entry. For example, to export the main entry and the `index` entry of each component directory in a bundleless component library: diff --git a/website/docs/zh/guide/advanced/release-management.mdx b/website/docs/zh/guide/advanced/release-management.mdx index ba3efefa9d..770c2b2c6c 100644 --- a/website/docs/zh/guide/advanced/release-management.mdx +++ b/website/docs/zh/guide/advanced/release-management.mdx @@ -82,7 +82,7 @@ watch 模式下,或通过 `--lib` 只构建部分 lib 时,不会生成 `expo #### 选择入口 \{#select-entries} -`exports.include` 和 `exports.exclude` 通过匹配入口名的 glob 选择导出的入口。默认情况下,bundle 模式导出所有入口;bundleless 模式下每个源文件都是入口,只导出 [lib.outBase](/config/lib/out-base) 根目录下的 `index` 入口。 +`exports.include` 和 `exports.exclude` 通过 glob 选择导出的产物。模式匹配的是每个 JS 或 CSS 产物相对其 lib 输出目录的路径,如 `utils/index.js`、`index.css`,可以带扩展名,也可以省略。写 `index` 会匹配 `index.js`、`index.cjs` 和 `index.css`,写 `index.css` 则只匹配 CSS。默认情况下,bundle 模式导出所有入口;bundleless 模式下每个源文件都是入口,只导出 [lib.outBase](/config/lib/out-base) 根目录下的 `index` 入口。 例如,在 bundleless 组件库中导出主入口和每个组件目录的 `index` 入口: