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/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/config.ts b/packages/core/src/config.ts index d6e345352c..3630302a32 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) { + config.dts = userConfig.dts; + } + + if (userConfig.bundle === false) { + config.bundle = false; + } + return config; }); @@ -2043,10 +2052,10 @@ 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, bundle }); } const conflictIds = usedIds.filter( diff --git a/packages/core/src/createRslib.ts b/packages/core/src/createRslib.ts index 496c163243..d48a151060 100644 --- a/packages/core/src/createRslib.ts +++ b/packages/core/src/createRslib.ts @@ -229,7 +229,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 +238,25 @@ export async function createRslib( pruneEnvironments(environments, buildOptions.lib), ); + // The `exports` field describes the published package, so it is only + // 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 ?? 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 : {}, + }), + ]); + } + 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..b83bb7f3b7 --- /dev/null +++ b/packages/core/src/exports/dts.ts @@ -0,0 +1,67 @@ +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. +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 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, + 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 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(base + candidate)); + + if (!declarationExt) return; + return { + 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 new file mode 100644 index 0000000000..19ebbb41b1 --- /dev/null +++ b/packages/core/src/exports/generate.ts @@ -0,0 +1,386 @@ +import path from 'node:path'; +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'; + +/** + * 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. + */ + js?: CollectedOutput; + /** The CSS output, only collected in bundle mode. */ + css?: CollectedOutput; + /** The declaration file of `js`, only collected for libs that emit declaration files. */ + declaration?: DeclarationFile; + /** The modules of the entry. */ + sourceFiles: string[]; +}; + +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: 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 = 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(/^(\.\/)+/, ''); +} + +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, + ); +} + +/** + * 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 buildJsValue(slots: EntrySlots): string | Record { + const formatSlots = getFormatSlots(slots); + const value = (types?: string): string | Record => { + 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}`); +} + +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: string | Record, + condition: string, + sourceFile: string, +): Record { + return typeof value === 'string' + ? { [condition]: sourceFile, default: value } + : { [condition]: sourceFile, ...value }; +} + +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]) => { + 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 = 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 []; + 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, + format, + dts: Boolean(dts), + abortOnError: typeof dts !== 'object' || dts.abortOnError !== false, + entries, + }, + ]; + }, + ); + + const libsWithoutDeclarations = preparedLibs.filter((lib) => { + const jsEntries = lib.entries.filter((entry) => entry.js); + 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 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'), + ); + } + + const entrySlotsByName = new Map(); + for (const lib of preparedLibs) { + for (const entry of lib.entries) { + const { entryName } = entry; + 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.`, + ); + } + } + + const slots = entrySlotsByName.get(entryName) ?? {}; + if (entry.js) { + const target = `./${entry.js.file}`; + 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}".`, + ); + } + } + entrySlotsByName.set(entryName, slots); + } + } + + 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 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.', + ); + } + + 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 { exports: outputExports }; +} diff --git a/packages/core/src/exports/packageJson.ts b/packages/core/src/exports/packageJson.ts new file mode 100644 index 0000000000..a9bc2ebd39 --- /dev/null +++ b/packages/core/src/exports/packageJson.ts @@ -0,0 +1,20 @@ +/** + * 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..fb0d04181f --- /dev/null +++ b/packages/core/src/exports/plugin.ts @@ -0,0 +1,168 @@ +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 { + ExportsField, + ExportsOptions, + ExportsCustomContext, + PkgJson, + RequireKey, + RsbuildConfigWithLibInfo, +} from '../types'; +import { logger } from '../utils/logger'; +import { normalizeSlash } from '../utils/helper'; +import { findDeclarationFile } from './dts'; +import { type CollectedEntry, generateExports } from './generate'; +import { stringifyPackageJson } from './packageJson'; + +export type ExportsPluginOptions = { + root: string; + libInfos: RequireKey[]; + options: ExportsOptions; +}; + +async function customizeExports( + exports: ExportsField, + { custom }: ExportsOptions, + context: ExportsCustomContext, +): Promise { + if (typeof custom === 'function') { + return custom(exports, context); + } + return { ...exports, ...custom }; +} + +export const ExportsPlugin = ({ + root, + libInfos, + options, +}: ExportsPluginOptions): RsbuildPlugin => { + 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) => { + // 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.getResolvedModule(dependency) as { + resource?: string; + } | null + )?.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 js = jsOutput + ? { + file: toRelative(path.join(outputPath, jsOutput)), + name: normalizeSlash(jsOutput), + } + : undefined; + entries.push({ + entryName, + js, + css: cssOutput + ? { + file: toRelative(path.join(outputPath, cssOutput)), + name: normalizeSlash(cssOutput), + } + : undefined, + declaration: + js && dts + ? findDeclarationFile({ + jsFile: js.file, + 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(); + for (const { compilation } of statsList) { + // Rsbuild names each compiler after its environment, which is the lib ID. + 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( + `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 pkg: PkgJson = JSON.parse(originalText); + + 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(content, originalText); + if (text === originalText) return; + + fs.writeFileSync(pkgJsonPath, text); + logger.info('generated "exports" field in package.json.'); + }; + + return { + name: 'rslib:exports', + setup(api) { + api.onAfterBuild(async ({ stats }) => { + // Failed builds already report errors, so there is nothing to export. + if (!stats || stats.hasErrors()) return; + await writeExports(stats); + }); + }, + }; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a5e626b16..b292503293 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,12 @@ export type { CreateRslibOptions, Dts, ExeOptions, + ExportsField, + ExportsCustom, + ExportsCustomContext, + ExportsCustomFunction, + ExportsOptions, + ExportsValue, Format, InspectConfigOptions, InspectConfigResult, diff --git a/packages/core/src/mergeConfig.ts b/packages/core/src/mergeConfig.ts index c21d50b3d8..d434046dc9 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. @@ -38,6 +38,28 @@ function mergeLibConfigs( return [...mergedById.values(), ...itemsWithoutId]; } +/** + * Merge multiple `exports` options. `true` and an options object enable it with + * the options, and each option is overridden by the later one. + */ +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; + + // Like `mergeRsbuildConfig`, `undefined` values do not override others. + return { + ...merged, + ...Object.fromEntries( + Object.entries(config).filter(([, value]) => value !== undefined), + ), + } as ExportsOptions; + }, undefined); +} + /** * Merge multiple Rslib configurations into a single configuration. * @@ -46,7 +68,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 +85,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 +100,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 6a1744cb92..00fefa37ab 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,59 @@ 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 | ExportsOptions; } + +export type ExportsValue = + string | null | ExportsValue[] | { [condition: string]: ExportsValue }; + +export type ExportsField = Record; + +export type ExportsCustomContext = { + /** The content of package.json before writing. */ + pkg: Record; + /** Whether the result is written to `publishConfig.exports`. */ + isPublish: boolean; +}; + +export type ExportsCustomFunction = ( + exports: ExportsField, + context: ExportsCustomContext, +) => ExportsField | Promise; + +export type ExportsCustom = ExportsField | ExportsCustomFunction; + +export type ExportsOptions = { + /** + * 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 the outputs to exclude from `include`, matched in the same + * way. + */ + 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; + /** + * 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. + */ + custom?: ExportsCustom; +}; 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..1a8807d92c 100644 --- a/packages/core/src/types/utils.ts +++ b/packages/core/src/types/utils.ts @@ -7,6 +7,8 @@ export type PkgJson = { devDependencies?: Record; 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..a6e83b52ff 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,64 @@ describe('Should merge Rslib config correctly', () => { } `); }); + + test('merge exports options', () => { + const fn = (exports: ExportsField) => exports; + const extra = { './a': { browser: './a.js', default: './a.cjs' } }; + const options: ExportsOptions = { + include: ['index'], + custom: extra, + }; + + 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'], + custom: fn, + }, + }, + ), + ).toEqual({ + exports: { + include: ['*/index'], + custom: fn, + }, + }); + }); + + test('merge exports options with booleans and partial options', () => { + const custom = { './package.json': './package.json' }; + + expect( + mergeRslibConfig({ exports: false }, { exports: { custom } }), + ).toEqual({ exports: { custom } }); + expect(mergeRslibConfig({ exports: false }, { exports: true })).toEqual({ + exports: true, + }); + expect( + mergeRslibConfig( + { exports: { custom } }, + { exports: { include: ['index'] } }, + ), + ).toEqual({ exports: { custom, include: ['index'] } }); + expect( + mergeRslibConfig( + { exports: { custom, include: ['index'] } }, + { exports: { custom: undefined, include: undefined } }, + ), + ).toEqual({ exports: { custom, include: ['index'] } }); + }); }); describe('CLI options', () => { diff --git a/packages/core/tests/exports.test.ts b/packages/core/tests/exports.test.ts new file mode 100644 index 0000000000..a0a5113508 --- /dev/null +++ b/packages/core/tests/exports.test.ts @@ -0,0 +1,506 @@ +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'], + info: Partial = {}, +): LibInfo => ({ + id, + format, + config: {} as unknown as LibInfo['config'], + ...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, + 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, + 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', () => { + expect( + generate([createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], { + esm: [entry('index', 'dist/esm/index.mjs')], + cjs: [entry('index', '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', () => { + expect( + generate([createLibInfo('esm', 'esm')], { + esm: [entry('index', 'dist/esm/index.mjs')], + }).exports, + ).toEqual({ '.': './dist/esm/index.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 "./" in entry names', () => { + expect( + generate([createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], { + esm: [ + entry('./index', 'dist/esm/index.mjs'), + entry('./foo/index', 'dist/esm/foo/index.mjs'), + ], + cjs: [ + entry('index', 'dist/cjs/index.js'), + entry('foo/index', 'dist/cjs/foo/index.js'), + ], + }).exports, + ).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + './foo': { + import: './dist/esm/foo/index.mjs', + require: './dist/cjs/foo/index.js', + }, + }); + }); + + it('should throw when entries are exported as the same subpath', () => { + expect(() => + generate([createLibInfo('esm', 'esm')], { + esm: [ + entry('foo', 'dist/foo.mjs'), + entry('foo/index', 'dist/foo/index.mjs'), + ], + }), + ).toThrow( + 'the "foo" and "foo/index" entries are both exported as the "./foo" subpath', + ); + }); + + 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', () => { + 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 no entries can be exported', () => { + expect(() => + generate([createLibInfo('umd', 'umd')], { + umd: [entry('index', 'dist/umd/index.js')], + }), + ).toThrow('no entries can be exported'); + + expect(() => generate([createLibInfo('esm', 'esm')], { esm: [] })).toThrow( + 'no entries can be exported', + ); + }); + + 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(() => + generate([createLibInfo('esm', 'esm')], { + esm: [entry('index', path.resolve(__dirname, 'outside/index.mjs'))], + }), + ).toThrow('which is outside the package root'); + }); + + 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' }); + }); +}); + +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 export all entries in bundle mode by default', () => { + expect( + Object.keys( + generate([createLibInfo('esm', 'esm')], { esm: entries }).exports, + ), + ).toEqual(['.', './button', './button/utils', './internal']); + }); + + 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', + }); + }); + + 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'] }), + ).toThrow('no entries can be exported'); + }); +}); + +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 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', + }); + }); + + 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 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'); + }); +}); + +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', () => { + 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: { + '.': { + '@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 new file mode 100644 index 0000000000..ce734b450b --- /dev/null +++ b/packages/core/tests/exportsDts.test.ts @@ -0,0 +1,276 @@ +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[], + dtsDistPath?: string, +) => { + const root = await mkdtemp(path.join(tmpdir(), 'rslib-exports-dts-')); + try { + 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, + outputPath: path.join(root, 'dist'), + dtsDistPath: dtsDistPath && path.join(root, dtsDistPath), + }); + } 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(); + }); + + 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', () => { + 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', + js: { file: jsFile, name: jsFile.replace(/^dist\/(esm\/|cjs\/)?/, '') }, + 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( + generate( + [ + createLibInfo('esm', 'esm', { autoExtension: true }), + createLibInfo('cjs', 'cjs', { autoExtension: 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, + }), + ], + }, + ), + ).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 = 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, + }), + ], + }, + ); + + 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( + 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')], + }, + ), + ).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( + 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 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(() => + generate([createLibInfo('esm', 'esm', true)], { + esm: [entry('dist/index.js')], + }), + ).toThrow( + '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( + generate([createLibInfo('esm', 'esm', true)], { + esm: [ + entry('dist/index.js', { + file: 'dist/index.d.ts', + matched: true, + }), + { + entryName: 'assets/logo', + js: { file: 'dist/assets/logo.js', name: 'assets/logo.js' }, + sourceFiles: [], + }, + ], + }), + ).toEqual({ + '.': './dist/index.js', + './assets/logo': './dist/assets/logo.js', + }); + }); + + it('should not check libs without declaration files', () => { + expect( + 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 new file mode 100644 index 0000000000..96a7fde3cd --- /dev/null +++ b/packages/core/tests/exportsPackageJson.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from '@rstest/core'; +import { stringifyPackageJson } from '../src/exports/packageJson'; + +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/pnpm-lock.yaml b/pnpm-lock.yaml index ae7858c81e..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) @@ -1461,6 +1473,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: @@ -4379,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==} @@ -10935,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/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/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/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/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 new file mode 100644 index 0000000000..72868dbab0 --- /dev/null +++ b/tests/integration/exports/index.test.ts @@ -0,0 +1,498 @@ +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'; + +const fixtures = [ + 'dual-format', + 'esm-only', + 'multiple-entries', + 'bundleless', + 'umd', + 'build-error', + 'dts/matched', + 'dts/shared', + 'dts/dist-path', + 'dts/module', + 'dts/bundleless', + 'include', + 'css', + 'source', +]; + +// 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 not generate exports in watch mode', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + const packageJsonPath = join(fixturePath, 'package.json'); + 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: { + lib: [{ format: 'esm', output: { distPath: 'dist/esm' } }], + source: { entry: { index: '../__fixtures__/src/index.ts' } }, + exports: true, + logLevel: 'silent', + plugins: [ + { + name: 'test:first-build', + setup(api) { + // Run after the exports plugin to observe its writes. + api.onAfterBuild({ order: 'post', handler: onFirstBuild }); + }, + }, + ], + }, + }); + const buildResult = await rslib.build({ watch: true }); + + try { + await firstBuild; + expect(readFileSync(packageJsonPath, 'utf8')).toBe(original); + } finally { + await buildResult.close(); + } + }); + + 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 enable exports with the exports option in the 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; + }, + }); + + 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 overwrite 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".": {', + '\t\t\t"@acme/source": "./src/index.ts",', + '\t\t\t"default": "./dist/esm/old.mjs"', + '\t\t}', + '\t}', + '}', + '', + ].join('\n'), + ); + + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFileSync(packageJsonPath, 'utf8')).toBe( + [ + '{', + '\t"name": "exports-esm-only-test",', + '\t"exports": {', + '\t\t".": "./dist/esm/index.mjs"', + '\t}', + '}', + '', + ].join('\n'), + ); + }); + + test('should overwrite existing exports in any form', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + writeFileSync( + join(fixturePath, 'package.json'), + JSON.stringify({ name: 'exports-esm-only-test', exports: './index.js' }), + ); + + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports(fixture)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + test('should apply an object custom to the generated exports', 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 = { + custom: { + '.': './dist/esm/replaced.mjs', + './package.json': './package.json', + }, + }; + }, + }); + + 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', + }; + }, + }; + }, + }); + + expect(readFixtureExports(fixture)).toEqual({ + '.': { + browser: './dist/browser.js', + default: './dist/esm/index.mjs', + }, + './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 () => { + 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 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 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); + writeFileSync( + join(fixturePath, 'package.json'), + packageJsonContents.get(fixture)!, + ); + + const contexts: unknown[] = []; + await rslibBuild({ + cwd: fixturePath, + modifyConfig: (config) => { + config.exports = { + source: true, + custom: (exports, { isPublish }) => { + contexts.push(isPublish); + return exports; + }, + }; + }, + }); + + const pkg = JSON.parse( + readFileSync(join(fixturePath, 'package.json'), 'utf8'), + ); + expect(pkg.exports).toEqual({ '.': './src/index.ts' }); + expect(pkg.publishConfig).toEqual({ + access: 'public', + 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' }; + }, + }); + + 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', + }, + }); + }); +}); 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/source/package.json b/tests/integration/exports/source/package.json new file mode 100644 index 0000000000..25725d73be --- /dev/null +++ b/tests/integration/exports/source/package.json @@ -0,0 +1,8 @@ +{ + "name": "exports-source-test", + "version": "1.0.0", + "private": true, + "publishConfig": { + "access": "public" + } +} diff --git a/tests/integration/exports/source/rslib.config.ts b/tests/integration/exports/source/rslib.config.ts new file mode 100644 index 0000000000..4293a74ed2 --- /dev/null +++ b/tests/integration/exports/source/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', + }, + }, + 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 new file mode 100644 index 0000000000..118141357f --- /dev/null +++ b/tests/integration/exports/source/src/index.ts @@ -0,0 +1,3 @@ +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; 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..a560ef7daf 100644 --- a/website/docs/en/guide/advanced/release-management.mdx +++ b/website/docs/en/guide/advanced/release-management.mdx @@ -46,6 +46,146 @@ 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` 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 [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. + +The rules are as follows: + +- Only outputs in the `esm` and `cjs` formats are exported. +- 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: + +```json title="package.json" +{ + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } +} +``` + +#### Select entries + +`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: + +```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 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. 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: + +```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. + +#### 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.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: { + custom: (exports) => ({ + ...exports, + '.': { browser: './dist/browser.js', default: exports['.'] }, + './package.json': './package.json', + }), + }, +}); +``` + ## 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..770c2b2c6c 100644 --- a/website/docs/zh/guide/advanced/release-management.mdx +++ b/website/docs/zh/guide/advanced/release-management.mdx @@ -46,6 +46,146 @@ description: '介绍 Rslib 项目的 npm 包版本管理与发布实践,包括 此外,需要确保包没有设置 `private: true`,并建议补充 `description`、`license`、`repository` 等信息,方便用户在 npm 上了解和定位项目。 +### 生成 exports \{#generate-exports} + +除了手动维护 `exports` 字段,也可以执行 `rslib build --exports`,或在配置文件中设置 `exports`,在构建成功后根据产物自动生成。`--no-exports` 可以在单次构建中关闭生成。 + +```ts title="rslib.config.ts" +export default defineConfig({ + exports: true, +}); +``` + +Rslib 会接管 `exports` 字段:生成结果整体替换已有的值。不在生成结果中的子路径和条件,可以通过 [custom](#customize-exports) 添加。写入时保留原有缩进和换行符,内容没有变化时跳过写入。 + +watch 模式下,或通过 `--lib` 只构建部分 lib 时,不会生成 `exports` 字段。入口变化后,执行 `rslib build` 即可更新。 + +生成规则如下: + +- 只导出 `esm` 和 `cjs` 格式的产物。 +- `index` 入口导出为 `.`,`button/index` 这类入口导出为 `./button`,其他入口按入口名导出,如 `./utils/numbers`。 +- 同一入口同时有 `esm` 和 `cjs` 产物时,使用 `import`、`require` 条件;只有一种格式时,直接写产物路径,对所有条件生效,因此只发布 ESM 的包也能被 `require(esm)` 加载。 +- `.` 排在最前,其余子路径按顺序排列。 + +例如,构建带有 `index` 入口的 `esm` 和 `cjs` lib 会生成: + +```json title="package.json" +{ + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } +} +``` + +#### 选择入口 \{#select-entries} + +`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` 入口: + +```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.distPath](/config/lib/dts#dtsdistpath) 时在该目录中查找。 + +- 各格式产物旁都有扩展名对应的类型声明文件时(`.js` 对应 `.d.ts`,`.mjs` 对应 `.d.mts`,`.cjs` 对应 `.d.cts`),不添加 `types` 条件,TypeScript 可以自行找到。 +- 否则添加一个各格式共用的 `types` 条件,优先选择扩展名对应的类型声明文件。位于单独 `dts.distPath` 中的类型声明文件总是需要该条件。 +- 某个 lib 的产物都找不到类型声明文件时,构建失败。 + +例如,在 `"type": "module"` 的包中,`esm` 和 `cjs` lib 都开启 `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),并将各格式输出到不同目录。 + +#### 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.custom` 用于自定义生成的 exports,可以是对象或函数。 + +- 对象向生成结果添加子路径。与生成的子路径同名时,整体替换该子路径。 +- 函数接收生成结果,返回最终的 exports,可以是异步函数。第二个参数提供 `package.json` 的内容 `pkg`,以及 `isPublish`,结果写入 `publishConfig.exports` 时为 `true`。 + +```ts title="rslib.config.ts" +export default defineConfig({ + exports: { + custom: (exports) => ({ + ...exports, + '.': { browser: './dist/browser.js', default: exports['.'] }, + './package.json': './package.json', + }), + }, +}); +``` + ## 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