diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 66b9f9e0d..855620058 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', + ) .action(async (options: BuildOptions) => { initCliAction('build', options); try { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c7e7a61c0..6b67d1e13 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1999,6 +1999,14 @@ export async function composeCreateRsbuildConfig( config.id = libConfig.id; } + if (userConfig.dts !== undefined && userConfig.dts !== false) { + config.dts = userConfig.dts; + } + + if (userConfig.bundle === false) { + config.bundle = false; + } + return config; }); @@ -2043,10 +2051,16 @@ export async function composeRsbuildEnvironments( return candidateId; }; - for (const { format, id, config } of rsbuildConfigWithLibInfo) { + for (const { format, id, config, dts, bundle } of rsbuildConfigWithLibInfo) { const libId = typeof id === 'string' ? id : composeDefaultId(format); environments[libId] = config; - environmentWithInfos.push({ id: libId, format, config }); + environmentWithInfos.push({ + id: libId, + format, + config, + ...(dts === undefined ? {} : { dts }), + ...(bundle === undefined ? {} : { bundle }), + }); } const conflictIds = usedIds.filter( diff --git a/packages/core/src/createRslib.ts b/packages/core/src/createRslib.ts index 496c16324..47065905f 100644 --- a/packages/core/src/createRslib.ts +++ b/packages/core/src/createRslib.ts @@ -8,6 +8,7 @@ import { } from '@rsbuild/core'; import util from 'node:util'; import { composeRsbuildEnvironments, pruneEnvironments } from './config'; +import { ExportsPlugin } from './exports/plugin'; import type { LoadConfigResult } from './loadConfig'; import type { Format, RslibConfig } from './types'; import type { @@ -209,6 +210,12 @@ export async function createRslib( setNodeEnv('production'); } + if (buildOptions.exports && buildOptions.watch) { + throw new Error( + 'The "exports" option cannot be used in watch mode because writing package.json can trigger another rebuild.', + ); + } + if (buildOptions.watch) { config.plugins = config.plugins || []; config.plugins.push({ @@ -229,7 +236,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 +245,12 @@ export async function createRslib( pruneEnvironments(environments, buildOptions.lib), ); + if (buildOptions.exports) { + rsbuildInstance.addPlugins([ + ExportsPlugin({ root: config.root!, libInfos: environmentWithInfos }), + ]); + } + 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 000000000..da6a54169 --- /dev/null +++ b/packages/core/src/exports/dts.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// TypeScript looks up the declaration file next to the resolved JavaScript file +// with the matching extension, and never falls back to other extensions. +const MATCHED_DECLARATION_EXTENSIONS: Record = { + '.js': '.d.ts', + '.jsx': '.d.ts', + '.mjs': '.d.mts', + '.cjs': '.d.cts', +}; + +const DECLARATION_EXTENSIONS = ['.d.ts', '.d.mts', '.d.cts']; + +export type DeclarationFile = { + /** Path relative to the package root, using forward slashes. */ + file: string; + /** Whether TypeScript can resolve it from the JavaScript file without a `types` condition. */ + matched: boolean; +}; + +/** + * Find the declaration file next to a JavaScript output, preferring the one + * with the matching extension. + */ +export function findDeclarationFile( + jsFile: string, + root: string, +): DeclarationFile | undefined { + const ext = path.extname(jsFile); + const matchedExt = MATCHED_DECLARATION_EXTENSIONS[ext]; + if (!matchedExt) return; + + const base = jsFile.slice(0, -ext.length); + const declarationExt = [ + matchedExt, + ...DECLARATION_EXTENSIONS.filter((otherExt) => otherExt !== matchedExt), + ].find((candidate) => fs.existsSync(path.join(root, base + candidate))); + + if (!declarationExt) return; + return { + file: base + declarationExt, + matched: declarationExt === matchedExt, + }; +} diff --git a/packages/core/src/exports/generate.ts b/packages/core/src/exports/generate.ts new file mode 100644 index 000000000..500a041ef --- /dev/null +++ b/packages/core/src/exports/generate.ts @@ -0,0 +1,177 @@ +import path from 'node:path'; +import type { RequireKey, RsbuildConfigWithLibInfo } from '../types'; +import { normalizeSlash } from '../utils/helper'; +import type { DeclarationFile } from './dts'; + +export type CollectedEntry = { + entryName: string; + /** Path relative to the package root, or absolute for different Windows drives, using forward slashes. */ + jsFile: string; + /** The declaration file next to `jsFile`, only collected for libs that emit declaration files. */ + declaration?: DeclarationFile; +}; + +export type ExportsField = { + [key: string]: string | ExportsField; +}; + +type LibInfo = RequireKey; + +export type GenerateExportsOptions = { + libInfos: LibInfo[]; + collected: Map; +}; + +type PreparedLib = { + id: string; + format: 'esm' | 'cjs'; + dts: boolean; + entries: CollectedEntry[]; +}; + +type FormatSlot = { + target: string; + declaration?: DeclarationFile; +}; + +type EntrySlots = { + esm?: FormatSlot; + cjs?: FormatSlot; +}; + +/** Normalize entry names such as `./foo/` to `foo` before using them as subpaths. */ +function normalizeEntryName(entryName: string): string { + return normalizeSlash(entryName) + .replace(/^(\.\/)+/, '') + .replace(/\/+$/, ''); +} + +function isOutsidePackageRoot(filePath: string): boolean { + return path.isAbsolute(filePath) || filePath.startsWith('../'); +} + +function getFormatSlots(slots: EntrySlots): FormatSlot[] { + return [slots.esm, slots.cjs].filter( + (slot): slot is FormatSlot => slot !== undefined, + ); +} + +/** + * Build the value of a subpath. A `types` condition is only added when + * TypeScript cannot resolve a declaration file with the matching extension + * next to every output, and then all formats share one declaration file. + */ +function buildEntryValue(slots: EntrySlots): string | ExportsField { + const formatSlots = getFormatSlots(slots); + const value = (types?: string): string | ExportsField => { + if (slots.esm && slots.cjs) { + return { + ...(types ? { types } : {}), + import: slots.esm.target, + require: slots.cjs.target, + }; + } + const target = formatSlots[0]!.target; + return types ? { types, default: target } : target; + }; + + // Declaration files are only collected for libs that emit them. + const declarations = formatSlots.map((slot) => slot.declaration); + if (declarations.every((declaration) => declaration?.matched)) { + return value(); + } + + // Prefer the declaration file whose module format matches its own output. + // Outputs from non-TypeScript sources, such as assets, have no declaration file. + const declaration = + declarations.find((declaration) => declaration?.matched) ?? + declarations.find((declaration) => declaration !== undefined); + return value(declaration && `./${declaration.file}`); +} + +export function generateExports(options: GenerateExportsOptions): ExportsField { + const { libInfos, collected } = options; + + const preparedLibs = libInfos.flatMap( + ({ id, format, dts, bundle }): PreparedLib[] => { + // Other formats, such as UMD, are not referenced by the `exports` field. + if (format !== 'esm' && format !== 'cjs') return []; + const entries = (collected.get(id) ?? []).map((entry) => ({ + ...entry, + entryName: normalizeEntryName(entry.entryName), + })); + return [ + { + id, + format, + dts: Boolean(dts), + // In bundleless mode, every source file is an entry, so only the main + // entry is treated as public API. + entries: + bundle === false + ? entries.filter((entry) => entry.entryName === 'index') + : entries, + }, + ]; + }, + ); + + // Declaration files emitted to a separate directory cannot be resolved from the outputs. + const libsWithoutDeclarations = preparedLibs + .filter( + (lib) => + lib.dts && + lib.entries.length > 0 && + lib.entries.every((entry) => !entry.declaration), + ) + .map((lib) => lib.id); + if (libsWithoutDeclarations.length > 0) { + throw new Error( + [ + `Failed to generate the "exports" field because no declaration files were found next to the outputs of the following libs that emit declaration files: ${libsWithoutDeclarations.map((id) => `"${id}"`).join(', ')}.`, + 'Emit declaration files to the same directory as the JavaScript outputs, check "dts.distPath", and the "declarationDir" and "rootDir" options in tsconfig.', + ].join('\n'), + ); + } + + const entrySlotsByName = new Map(); + for (const lib of preparedLibs) { + for (const entry of lib.entries) { + if (isOutsidePackageRoot(entry.jsFile)) { + throw new Error( + `Failed to generate the "exports" field because the "${entry.entryName}" entry of the "${lib.id}" environment emits "${entry.jsFile}", which is outside the package root.`, + ); + } + + const { entryName } = entry; + const slots = entrySlotsByName.get(entryName) ?? {}; + const target = `./${entry.jsFile}`; + const existingTarget = slots[lib.format]?.target; + if (existingTarget && existingTarget !== target) { + throw new Error( + `Failed to generate the "exports" field because the "${entryName}" entry has multiple "${lib.format}" outputs: "${existingTarget}" and "${target}".`, + ); + } + slots[lib.format] = { target, declaration: entry.declaration }; + entrySlotsByName.set(entryName, slots); + } + } + if (entrySlotsByName.size === 0) { + throw new Error( + 'Failed to generate the "exports" field because no entries can be exported. Only the "esm" and "cjs" formats are supported, and only the "index" entry is exported in bundleless mode.', + ); + } + + const exportsField: ExportsField = {}; + // Keep the main entry before other entry subpaths, then sort the rest for stable output. + const sortedEntries = [...entrySlotsByName].sort(([a], [b]) => { + if (a === 'index') return -1; + if (b === 'index') return 1; + return a.localeCompare(b); + }); + for (const [entryName, slots] of sortedEntries) { + exportsField[entryName === 'index' ? '.' : `./${entryName}`] = + buildEntryValue(slots); + } + return exportsField; +} diff --git a/packages/core/src/exports/packageJson.ts b/packages/core/src/exports/packageJson.ts new file mode 100644 index 000000000..32865ec0a --- /dev/null +++ b/packages/core/src/exports/packageJson.ts @@ -0,0 +1,127 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { isObject } from '../utils/helper'; +import { logger } from '../utils/logger'; +import type { ExportsField } from './generate'; + +export type MergedExportsField = Record; + +function isSubpathMap(value: unknown): value is MergedExportsField { + return ( + isObject(value) && Object.keys(value).every((key) => key.startsWith('.')) + ); +} + +/** + * Merge the generated subpaths into the existing `exports` field. + * Subpaths that only exist in package.json are kept, subpaths that exist in + * both are overwritten by the generated value. + */ +export function mergeExports( + existing: unknown, + generated: ExportsField, +): MergedExportsField { + if (existing === undefined) return generated; + + if (!isSubpathMap(existing)) { + throw new Error( + 'Failed to update the "exports" field in package.json because the existing value is not a subpath map. Convert it to the subpath form first, e.g. { ".": "./dist/index.js" }.', + ); + } + + for (const [key, value] of Object.entries(generated)) { + if ( + Object.hasOwn(existing, key) && + !isDeepStrictEqual(existing[key], value) + ) { + logger.warn( + `Overwrote the "${key}" subpath in the "exports" field of package.json with ${JSON.stringify(value)}.`, + ); + } + } + + // Existing subpaths keep their order, and new subpaths are appended, except + // that a newly added main subpath goes first. + return { + ...(Object.hasOwn(generated, '.') && !Object.hasOwn(existing, '.') + ? { '.': generated['.'] } + : {}), + ...existing, + ...generated, + }; +} + +function collectTargets(value: unknown): string[] { + if (typeof value === 'string') return [value]; + if (Array.isArray(value)) return value.flatMap(collectTargets); + if (isObject(value)) return Object.values(value).flatMap(collectTargets); + return []; +} + +function isPathInside(dir: string, file: string): boolean { + const relative = path.relative(dir, file); + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +/** + * Find the existing subpaths that were not generated in this build, but point + * to missing files in the output directories. They are likely left over from a + * previous build. + */ +export function findStaleSubpaths({ + existing, + generated, + root, + outputPaths, +}: { + existing: unknown; + generated: ExportsField; + root: string; + outputPaths: string[]; +}): string[] { + if (!isObject(existing)) return []; + + const isStaleTarget = (target: string) => { + if (!target.startsWith('./') || target.includes('*')) return false; + const file = path.join(root, target); + return ( + outputPaths.some((outputPath) => isPathInside(outputPath, file)) && + !fs.existsSync(file) + ); + }; + + return Object.entries(existing) + .filter( + ([key, value]) => + !Object.hasOwn(generated, key) && + collectTargets(value).some(isStaleTarget), + ) + .map(([key]) => key); +} + +/** + * Stringify package.json content with the indentation, line endings, and + * trailing newline of the original text. + */ +export function stringifyPackageJson( + content: unknown, + originalText: string, +): string { + const indent = /^([ \t]+)\S/m.exec(originalText)?.[1] ?? 2; + const eol = originalText.includes('\r\n') ? '\r\n' : '\n'; + + let text = JSON.stringify(content, null, indent); + if (eol !== '\n') { + text = text.replaceAll('\n', eol); + } + if (originalText.endsWith('\n')) { + text += eol; + } + return text; +} diff --git a/packages/core/src/exports/plugin.ts b/packages/core/src/exports/plugin.ts new file mode 100644 index 000000000..499abf71b --- /dev/null +++ b/packages/core/src/exports/plugin.ts @@ -0,0 +1,106 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { RsbuildPlugin } from '@rsbuild/core'; +import { isJavaScriptOutputFile } from '../exe/utils'; +import type { PkgJson, RequireKey, RsbuildConfigWithLibInfo } from '../types'; +import { logger } from '../utils/logger'; +import { normalizeSlash } from '../utils/helper'; +import { findDeclarationFile } from './dts'; +import { generateExports, type CollectedEntry } from './generate'; +import { + findStaleSubpaths, + mergeExports, + stringifyPackageJson, +} from './packageJson'; + +export type ExportsPluginOptions = { + root: string; + libInfos: RequireKey[]; +}; + +export const ExportsPlugin = ({ + root, + libInfos, +}: ExportsPluginOptions): RsbuildPlugin => { + return { + name: 'rslib:exports', + setup(api) { + api.onAfterBuild(({ stats }) => { + // Failed builds already exit with errors, so there is nothing to export. + if (!stats || stats.hasErrors()) return; + + const statsList = 'stats' in stats ? stats.stats : [stats]; + const collected = new Map(); + const outputPaths: string[] = []; + + for (const { compilation } of statsList) { + // Rsbuild names each compiler after its environment, which is the lib ID. + const libId = compilation.name!; + const outputPath = compilation.outputOptions.path!; + outputPaths.push(outputPath); + const emitsDeclarations = Boolean( + libInfos.find(({ id }) => id === libId)?.dts, + ); + + const entries: CollectedEntry[] = []; + for (const [entryName, entrypoint] of compilation.entrypoints) { + // An entry chunk emits at most one JavaScript file. Entries without one, + // such as global CSS entries in bundleless mode, are not exported. + const entryFile = [...entrypoint.getEntrypointChunk().files].find( + isJavaScriptOutputFile, + ); + if (!entryFile) continue; + const jsFile = normalizeSlash( + path.relative(root, path.join(outputPath, entryFile)), + ); + entries.push({ + entryName, + jsFile, + declaration: emitsDeclarations + ? findDeclarationFile(jsFile, root) + : undefined, + }); + } + collected.set(libId, entries); + } + + const pkgJsonPath = path.join(root, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + throw new Error( + `Failed to generate the "exports" field because "${pkgJsonPath}" does not exist.`, + ); + } + const originalText = fs.readFileSync(pkgJsonPath, 'utf8'); + // An invalid package.json already fails the build before this hook, as + // Rsbuild parses it when loading the PostCSS config. + const pkgJson: PkgJson = JSON.parse(originalText); + + const generated = generateExports({ libInfos, collected }); + + const exportsField = mergeExports(pkgJson.exports, generated); + + // Warn about subpaths that are likely left over instead of removing them. + const staleSubpaths = findStaleSubpaths({ + existing: pkgJson.exports, + generated, + root, + outputPaths, + }); + if (staleSubpaths.length > 0) { + logger.warn( + `The following subpaths in the "exports" field of package.json point to files that do not exist in the output directories: ${staleSubpaths.map((key) => `"${key}"`).join(', ')}.`, + ); + } + + const text = stringifyPackageJson( + { ...pkgJson, exports: exportsField }, + originalText, + ); + if (text === originalText) return; + + fs.writeFileSync(pkgJsonPath, text); + logger.info('generated "exports" field in package.json.'); + }); + }, + }; +}; diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 6a1744cb9..3dd1443dd 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< diff --git a/packages/core/src/types/rslib.ts b/packages/core/src/types/rslib.ts index a7f81ac6f..cc0b433b2 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. + * Not supported in watch mode. + * @default false + */ + exports?: boolean; }; export type InspectConfigOptions = CommonOptions & { diff --git a/packages/core/src/types/utils.ts b/packages/core/src/types/utils.ts index 8ee51672e..402746023 100644 --- a/packages/core/src/types/utils.ts +++ b/packages/core/src/types/utils.ts @@ -7,6 +7,7 @@ export type PkgJson = { devDependencies?: Record; optionalDependencies?: Record; engines?: Record; + exports?: unknown; }; export type DeepRequired = Required<{ diff --git a/packages/core/tests/exports.test.ts b/packages/core/tests/exports.test.ts new file mode 100644 index 000000000..4691876ee --- /dev/null +++ b/packages/core/tests/exports.test.ts @@ -0,0 +1,256 @@ +import path from 'node:path'; +import { describe, expect, it, rs } from '@rstest/core'; +import type { + CollectedEntry, + GenerateExportsOptions, +} from '../src/exports/generate'; +import { generateExports } from '../src/exports/generate'; +import type { RequireKey, RsbuildConfigWithLibInfo } from '../src/types'; + +rs.mock('rslog'); + +type LibInfo = RequireKey; + +const createLibInfo = (id: string, format: LibInfo['format']): LibInfo => ({ + id, + format, + config: {} as unknown as LibInfo['config'], +}); + +const createCollected = ( + entries: Record, +): Map => + new Map( + Object.entries(entries).map(([id, list]) => [ + id, + list.map(([entryName, jsFile]) => ({ entryName, jsFile })), + ]), + ); + +describe('generateExports', () => { + it('should generate import and require conditions for esm and cjs entries', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + cjs: [['index', 'dist/cjs/index.js']], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + }); + }); + + it('should generate a string value when only one format exists', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + it('should expose non-index entries as subpaths', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [['foo', 'dist/esm/foo.mjs']], + }), + }; + + expect(generateExports(options)).toEqual({ + './foo': './dist/esm/foo.mjs', + }); + }); + + it('should normalize leading "./" and trailing "/" in entry names', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], + collected: createCollected({ + esm: [ + ['./index', 'dist/esm/index.mjs'], + ['./foo/', 'dist/esm/foo.mjs'], + ], + cjs: [ + ['index', 'dist/cjs/index.js'], + ['foo', 'dist/cjs/foo.js'], + ], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + './foo': { + import: './dist/esm/foo.mjs', + require: './dist/cjs/foo.js', + }, + }); + }); + + it('should only expose the index entry in bundleless mode', () => { + const options: GenerateExportsOptions = { + libInfos: [{ ...createLibInfo('esm', 'esm'), bundle: false }], + collected: createCollected({ + esm: [ + ['index', 'dist/esm/index.mjs'], + ['utils/numbers', 'dist/esm/utils/numbers.mjs'], + ['assets/logo', 'dist/esm/assets/logo.mjs'], + ], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + it('should keep the index entry first and sort other entries', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [ + ['foo', 'dist/esm/foo.mjs'], + ['utils/numbers', 'dist/esm/utils/numbers.mjs'], + ['index', 'dist/esm/index.mjs'], + ['bar', 'dist/esm/bar.mjs'], + ], + }), + }; + + const exportsField = generateExports(options); + + expect(Object.keys(exportsField!)).toEqual([ + '.', + './bar', + './foo', + './utils/numbers', + ]); + expect(exportsField).toEqual({ + '.': './dist/esm/index.mjs', + './bar': './dist/esm/bar.mjs', + './foo': './dist/esm/foo.mjs', + './utils/numbers': './dist/esm/utils/numbers.mjs', + }); + }); + + it('should skip unsupported formats and keep supported ones', () => { + const options: GenerateExportsOptions = { + libInfos: [ + createLibInfo('esm', 'esm'), + createLibInfo('umd', 'umd'), + createLibInfo('mf', 'mf'), + ], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + umd: [['index', 'dist/umd/index.js']], + mf: [['index', 'dist/mf/index.js']], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + it('should throw when all formats are unsupported', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('umd', 'umd')], + collected: createCollected({ + umd: [['index', 'dist/umd/index.js']], + }), + }; + + expect(() => generateExports(options)).toThrow( + 'no entries can be exported', + ); + }); + + it('should throw when no JavaScript entries are generated', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [], + }), + }; + + expect(() => generateExports(options)).toThrow( + 'no entries can be exported', + ); + }); + + it('should skip libs without collected entries', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm'), createLibInfo('cjs', 'cjs')], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + it('should throw when an entry is outside the package root', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [['index', '../outside/index.mjs']], + }), + }; + + expect(() => generateExports(options)).toThrow( + 'which is outside the package root', + ); + + const absoluteOptions: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm')], + collected: createCollected({ + esm: [['index', path.resolve(__dirname, 'outside/index.mjs')]], + }), + }; + + expect(() => generateExports(absoluteOptions)).toThrow( + 'which is outside the package root', + ); + }); + + it('should throw when an entry has multiple outputs of the same format', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm'), createLibInfo('esm2', 'esm')], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + esm2: [['index', 'dist/esm2/index.mjs']], + }), + }; + + expect(() => generateExports(options)).toThrow( + 'the "index" entry has multiple "esm" outputs', + ); + }); + + it('should allow duplicate identical outputs of the same format', () => { + const options: GenerateExportsOptions = { + libInfos: [createLibInfo('esm', 'esm'), createLibInfo('esm2', 'esm')], + collected: createCollected({ + esm: [['index', 'dist/esm/index.mjs']], + esm2: [['index', 'dist/esm/index.mjs']], + }), + }; + + expect(generateExports(options)).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); +}); diff --git a/packages/core/tests/exportsDts.test.ts b/packages/core/tests/exportsDts.test.ts new file mode 100644 index 000000000..7f268b7b2 --- /dev/null +++ b/packages/core/tests/exportsDts.test.ts @@ -0,0 +1,258 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, rs } from '@rstest/core'; +import { type DeclarationFile, findDeclarationFile } from '../src/exports/dts'; +import { type CollectedEntry, generateExports } from '../src/exports/generate'; +import type { RequireKey, RsbuildConfigWithLibInfo } from '../src/types'; + +rs.mock('rslog'); + +type LibInfo = RequireKey; + +const findWithFiles = async (jsFile: string, files: string[]) => { + const root = await mkdtemp(path.join(tmpdir(), 'rslib-exports-dts-')); + try { + await mkdir(path.join(root, 'dist')); + for (const file of files) { + await writeFile(path.join(root, file), ''); + } + return findDeclarationFile(jsFile, root); + } finally { + await rm(root, { recursive: true, force: true }); + } +}; + +describe('findDeclarationFile', () => { + it.each([ + ['dist/index.js', 'dist/index.d.ts'], + ['dist/index.jsx', 'dist/index.d.ts'], + ['dist/index.mjs', 'dist/index.d.mts'], + ['dist/index.cjs', 'dist/index.d.cts'], + ])('should find the matched declaration file of %s', async (jsFile, file) => { + expect(await findWithFiles(jsFile, [file])).toEqual({ + file, + matched: true, + }); + }); + + it('should prefer the matched declaration file', async () => { + expect( + await findWithFiles('dist/index.mjs', [ + 'dist/index.d.ts', + 'dist/index.d.mts', + ]), + ).toEqual({ file: 'dist/index.d.mts', matched: true }); + }); + + it('should fall back to a declaration file with another extension', async () => { + expect(await findWithFiles('dist/index.cjs', ['dist/index.d.ts'])).toEqual({ + file: 'dist/index.d.ts', + matched: false, + }); + }); + + it('should return undefined when no declaration file exists', async () => { + expect(await findWithFiles('dist/index.js', [])).toBeUndefined(); + }); +}); + +describe('generateExports with declaration files', () => { + const createLibInfo = ( + id: string, + format: LibInfo['format'], + dts?: LibInfo['dts'], + ): LibInfo => ({ + id, + format, + config: {} as unknown as LibInfo['config'], + ...(dts === undefined ? {} : { dts }), + }); + + const entry = ( + jsFile: string, + declaration?: DeclarationFile, + ): CollectedEntry => ({ entryName: 'index', jsFile, declaration }); + + it('should not add a types condition when every output has a matched declaration file', () => { + expect( + generateExports({ + libInfos: [ + createLibInfo('esm', 'esm', { autoExtension: true }), + createLibInfo('cjs', 'cjs', { autoExtension: true }), + ], + collected: new Map([ + [ + 'esm', + [ + entry('dist/esm/index.mjs', { + file: 'dist/esm/index.d.mts', + matched: true, + }), + ], + ], + [ + 'cjs', + [ + entry('dist/cjs/index.js', { + file: 'dist/cjs/index.d.ts', + matched: true, + }), + ], + ], + ]), + }), + ).toEqual({ + '.': { import: './dist/esm/index.mjs', require: './dist/cjs/index.js' }, + }); + }); + + it('should share the matched declaration file when another output has none', () => { + const exportsField = generateExports({ + libInfos: [ + createLibInfo('esm', 'esm', true), + createLibInfo('cjs', 'cjs', true), + ], + collected: new Map([ + [ + 'esm', + [ + entry('dist/esm/index.js', { + file: 'dist/esm/index.d.ts', + matched: true, + }), + ], + ], + [ + 'cjs', + [ + entry('dist/cjs/index.cjs', { + file: 'dist/cjs/index.d.ts', + matched: false, + }), + ], + ], + ]), + }); + + expect(Object.keys(exportsField!['.']!)).toEqual([ + 'types', + 'import', + 'require', + ]); + expect(exportsField).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + require: './dist/cjs/index.cjs', + }, + }); + }); + + it('should share the declaration file of the only lib that emits declaration files', () => { + expect( + generateExports({ + libInfos: [ + createLibInfo('esm', 'esm', true), + createLibInfo('cjs', 'cjs'), + ], + collected: new Map([ + [ + 'esm', + [ + entry('dist/index.mjs', { + file: 'dist/index.d.ts', + matched: false, + }), + ], + ], + ['cjs', [entry('dist/index.js')]], + ]), + }), + ).toEqual({ + '.': { + types: './dist/index.d.ts', + import: './dist/index.mjs', + require: './dist/index.js', + }, + }); + }); + + it('should use the default condition with a single format', () => { + expect( + generateExports({ + libInfos: [createLibInfo('esm', 'esm', true)], + collected: new Map([ + [ + 'esm', + [ + entry('dist/index.mjs', { + file: 'dist/index.d.ts', + matched: false, + }), + ], + ], + ]), + }), + ).toEqual({ + '.': { types: './dist/index.d.ts', default: './dist/index.mjs' }, + }); + }); + + it('should throw when a lib has no declaration file next to any output', () => { + expect(() => + generateExports({ + libInfos: [createLibInfo('esm', 'esm', { distPath: './dist/types' })], + collected: new Map([['esm', [entry('dist/index.js')]]]), + }), + ).toThrow( + 'no declaration files were found next to the outputs of the following libs that emit declaration files: "esm"', + ); + }); + + it('should not add a types condition to subpaths without declaration files', () => { + expect( + generateExports({ + libInfos: [createLibInfo('esm', 'esm', true)], + collected: new Map([ + [ + 'esm', + [ + entry('dist/index.js', { + file: 'dist/index.d.ts', + matched: true, + }), + { entryName: 'assets/logo', jsFile: 'dist/assets/logo.js' }, + ], + ], + ]), + }), + ).toEqual({ + '.': './dist/index.js', + './assets/logo': './dist/assets/logo.js', + }); + }); + + it('should throw for a bundleless lib without the index entry', () => { + expect(() => + generateExports({ + libInfos: [{ ...createLibInfo('esm', 'esm', true), bundle: false }], + collected: new Map([ + [ + 'esm', + [{ entryName: 'utils/numbers', jsFile: 'dist/utils/numbers.js' }], + ], + ]), + }), + ).toThrow('no entries can be exported'); + }); + + it('should not check libs without declaration files', () => { + expect( + generateExports({ + libInfos: [createLibInfo('esm', 'esm')], + collected: new Map([['esm', [entry('dist/index.js')]]]), + }), + ).toEqual({ '.': './dist/index.js' }); + }); +}); diff --git a/packages/core/tests/exportsPackageJson.test.ts b/packages/core/tests/exportsPackageJson.test.ts new file mode 100644 index 000000000..eecce7358 --- /dev/null +++ b/packages/core/tests/exportsPackageJson.test.ts @@ -0,0 +1,153 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, rs } from '@rstest/core'; +import { + findStaleSubpaths, + mergeExports, + stringifyPackageJson, +} from '../src/exports/packageJson'; +import { logger } from '../src/utils/logger'; + +rs.mock('rslog'); + +describe('mergeExports', () => { + it('should return the generated field when there is no existing field', () => { + expect(mergeExports(undefined, { '.': './dist/index.mjs' })).toEqual({ + '.': './dist/index.mjs', + }); + }); + + it('should keep existing subpaths and overwrite conflicting ones in place', () => { + const merged = mergeExports( + { + './style.css': './dist/style.css', + '.': './dist/old.mjs', + './package.json': './package.json', + }, + { '.': './dist/index.mjs', './utils': './dist/utils.mjs' }, + ); + + expect(Object.keys(merged)).toEqual([ + './style.css', + '.', + './package.json', + './utils', + ]); + expect(merged).toEqual({ + './style.css': './dist/style.css', + '.': './dist/index.mjs', + './package.json': './package.json', + './utils': './dist/utils.mjs', + }); + }); + + it('should put a newly added main subpath first', () => { + const merged = mergeExports( + { './package.json': './package.json' }, + { '.': './dist/index.mjs' }, + ); + + expect(Object.keys(merged)).toEqual(['.', './package.json']); + }); + + it('should not add a main subpath when none is generated', () => { + const merged = mergeExports( + { './style.css': './dist/style.css' }, + { './utils': './dist/utils.mjs' }, + ); + + expect(Object.keys(merged)).toEqual(['./style.css', './utils']); + }); + + it('should warn only when an existing subpath is overwritten with a different value', () => { + const warn = rs.spyOn(logger, 'warn').mockImplementation(() => {}); + + mergeExports( + { + '.': { import: './dist/index.mjs', require: './dist/index.js' }, + './utils': './dist/old-utils.mjs', + }, + { + '.': { import: './dist/index.mjs', require: './dist/index.js' }, + './utils': './dist/utils.mjs', + }, + ); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]![0]).toContain('"./utils"'); + warn.mockRestore(); + }); + + it.each([ + ['a string', './dist/index.mjs'], + ['an object of conditions', { import: './dist/index.mjs' }], + ['an array', ['./dist/index.mjs']], + ['null', null], + ])('should throw when the existing field is %s', (_, existing) => { + expect(() => mergeExports(existing, { '.': './dist/index.mjs' })).toThrow( + 'is not a subpath map', + ); + }); +}); + +describe('findStaleSubpaths', () => { + it('should find existing subpaths that point to missing files in the output directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'rslib-exports-stale-')); + + try { + await mkdir(join(root, 'dist')); + await writeFile(join(root, 'dist/style.css'), ''); + + expect( + findStaleSubpaths({ + existing: { + '.': './dist/old.mjs', + './old': { import: './dist/old.mjs', require: './dist/old.js' }, + './style.css': './dist/style.css', + './src': './src/missing.ts', + './*': './dist/*.mjs', + }, + generated: { '.': './dist/index.mjs' }, + root, + outputPaths: [join(root, 'dist')], + }), + ).toEqual(['./old']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('should return an empty list when there is no existing field', () => { + expect( + findStaleSubpaths({ + existing: undefined, + generated: { '.': './dist/index.mjs' }, + root: '/pkg', + outputPaths: ['/pkg/dist'], + }), + ).toEqual([]); + }); +}); + +describe('stringifyPackageJson', () => { + const content = { name: 'pkg', exports: { '.': './dist/index.mjs' } }; + + it('should use two spaces when the original text has no indentation', () => { + expect(stringifyPackageJson(content, '{}')).toBe( + JSON.stringify(content, null, 2), + ); + }); + + it('should preserve tab indentation and the trailing newline', () => { + expect(stringifyPackageJson(content, '{\n\t"name": "pkg"\n}\n')).toBe( + `${JSON.stringify(content, null, '\t')}\n`, + ); + }); + + it('should preserve four-space indentation and CRLF line endings', () => { + expect( + stringifyPackageJson(content, '{\r\n "name": "pkg"\r\n}\r\n'), + ).toBe(`${JSON.stringify(content, null, 4).replaceAll('\n', '\r\n')}\r\n`); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81c194a68..5bf646533 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1376,6 +1376,28 @@ importers: tests/integration/exe/multi-target: {} + tests/integration/exports/build-error: {} + + tests/integration/exports/bundleless: {} + + tests/integration/exports/dts/bundleless: {} + + tests/integration/exports/dts/dist-path: {} + + tests/integration/exports/dts/matched: {} + + tests/integration/exports/dts/module: {} + + tests/integration/exports/dts/shared: {} + + tests/integration/exports/dual-format: {} + + tests/integration/exports/esm-only: {} + + tests/integration/exports/multiple-entries: {} + + tests/integration/exports/umd: {} + tests/integration/extension-alias: {} tests/integration/external-helpers/config-override: diff --git a/tests/integration/cli/help/__snapshots__/index.test.ts.snap b/tests/integration/cli/help/__snapshots__/index.test.ts.snap index 6377d4b77..40121b08b 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 -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 -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 000000000..f7ef6a6c8 --- /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 000000000..98c490e9c --- /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 000000000..0facab787 --- /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 000000000..76e2e4f39 --- /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 000000000..a79eb97f0 --- /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 000000000..2fb78053a --- /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 000000000..d83e38342 --- /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 000000000..b7fe858bf --- /dev/null +++ b/tests/integration/exports/bundleless/rslib.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [ + generateBundleEsmConfig({ + bundle: false, + }), + ], + source: { + entry: { + index: ['../__fixtures__/src/**'], + }, + }, +}); diff --git a/tests/integration/exports/dts/bundleless/package.json b/tests/integration/exports/dts/bundleless/package.json new file mode 100644 index 000000000..5650470bd --- /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 000000000..f0bef2e2c --- /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 000000000..170cd7e35 --- /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 000000000..2f70f4a0f --- /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 000000000..1cac2c101 --- /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 000000000..23156e22b --- /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 000000000..2d8aaf83c --- /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 000000000..c590e4f40 --- /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 000000000..8baf40428 --- /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 000000000..14201b2a8 --- /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 000000000..0a19681c5 --- /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 000000000..8bbf72faa --- /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 000000000..fcf15437d --- /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 000000000..f47cc32a4 --- /dev/null +++ b/tests/integration/exports/esm-only/rslib.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig()], + source: { + entry: { + index: '../__fixtures__/src/index.ts', + }, + }, +}); diff --git a/tests/integration/exports/index.test.ts b/tests/integration/exports/index.test.ts new file mode 100644 index 000000000..ba63ade41 --- /dev/null +++ b/tests/integration/exports/index.test.ts @@ -0,0 +1,237 @@ +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 } from 'test-helper'; + +const fixtures = [ + 'dual-format', + 'esm-only', + 'multiple-entries', + 'bundleless', + 'umd', + 'build-error', + 'dts/matched', + 'dts/shared', + 'dts/dist-path', + 'dts/module', + 'dts/bundleless', +]; + +// The "exports" field is written back to the fixture package.json after +// building, keep a backup and restore it once the tests are done. +const packageJsonContents = new Map( + fixtures.map((fixture) => [ + fixture, + readFileSync(join(__dirname, fixture, 'package.json'), 'utf8'), + ]), +); + +afterAll(() => { + for (const [fixture, content] of packageJsonContents) { + writeFileSync(join(__dirname, fixture, 'package.json'), content); + } +}); + +const readFixtureExports = (fixture: string) => + JSON.parse(readFileSync(join(__dirname, fixture, 'package.json'), 'utf8')) + .exports; + +describe('exports', () => { + test('should generate import and require conditions when building esm and cjs formats', async () => { + const fixturePath = join(__dirname, 'dual-format'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dual-format')).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + }); + }); + + test('should generate a string value when building a single format', async () => { + const fixturePath = join(__dirname, 'esm-only'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('esm-only')).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + test('should throw in watch mode', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + const packageJsonPath = join(fixturePath, 'package.json'); + const originalPackageJson = packageJsonContents.get(fixture)!; + writeFileSync(packageJsonPath, originalPackageJson); + + const rslib = await createRslib({ + cwd: fixturePath, + config: { + lib: [{ format: 'esm' }], + logLevel: 'silent', + }, + }); + const error = await expectBuildToFail( + rslib.build({ watch: true, exports: true }), + ); + + expect(error.message).toContain('cannot be used in watch mode'); + expect(readFileSync(packageJsonPath, 'utf8')).toBe(originalPackageJson); + }); + + test('should merge into existing exports and preserve the formatting', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + const packageJsonPath = join(fixturePath, 'package.json'); + writeFileSync( + packageJsonPath, + [ + '{', + '\t"name": "exports-esm-only-test",', + '\t"exports": {', + '\t\t"./package.json": "./package.json",', + '\t\t".": "./dist/esm/old.mjs"', + '\t}', + '}', + '', + ].join('\n'), + ); + + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFileSync(packageJsonPath, 'utf8')).toBe( + [ + '{', + '\t"name": "exports-esm-only-test",', + '\t"exports": {', + '\t\t"./package.json": "./package.json",', + '\t\t".": "./dist/esm/index.mjs"', + '\t}', + '}', + '', + ].join('\n'), + ); + }); + + test('should fail when the existing exports is not a subpath map', async () => { + const fixture = 'esm-only'; + const fixturePath = join(__dirname, fixture); + const packageJsonPath = join(fixturePath, 'package.json'); + const packageJson = `${JSON.stringify( + { name: 'exports-esm-only-test', exports: './index.js' }, + null, + 2, + )}\n`; + writeFileSync(packageJsonPath, packageJson); + + const error = await expectBuildToFail( + buildAndGetResults({ fixturePath, exports: true }), + ); + + expect(error.message).toContain('is not a subpath map'); + expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJson); + }); + + test('should expose each entry as a subpath', async () => { + const fixturePath = join(__dirname, 'multiple-entries'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('multiple-entries')).toEqual({ + '.': './dist/esm/index.mjs', + './utils/numbers': './dist/esm/utils/numbers.mjs', + }); + }); + + test('should only expose the main entry in bundleless mode', async () => { + const fixturePath = join(__dirname, 'bundleless'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('bundleless')).toEqual({ + '.': './dist/esm/index.mjs', + }); + }); + + test('should fail when building only the umd format', async () => { + const fixture = 'umd'; + const fixturePath = join(__dirname, fixture); + const error = await expectBuildToFail( + buildAndGetResults({ fixturePath, exports: true }), + ); + + expect(error.message).toContain('no entries can be exported'); + expect(readFileSync(join(fixturePath, 'package.json'), 'utf8')).toBe( + packageJsonContents.get(fixture), + ); + }); + + test('should not generate exports when the build fails', async () => { + const fixturePath = join(__dirname, 'build-error'); + await expectBuildToFail(buildAndGetResults({ fixturePath, exports: true })); + + expect(readFixtureExports('build-error')).toBeUndefined(); + }); + + test('should generate exports when declaration files match the outputs', async () => { + const fixturePath = join(__dirname, 'dts/matched'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dts/matched')).toEqual({ + '.': { + import: './dist/esm/index.mjs', + require: './dist/cjs/index.js', + }, + }); + }); + + test('should not add types conditions when the declaration file matches a single format output', async () => { + const fixturePath = join(__dirname, 'dts/module'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dts/module')).toEqual({ + '.': './dist/esm/index.js', + }); + }); + + test('should share one declaration file when the extensions do not match', async () => { + const fixturePath = join(__dirname, 'dts/shared'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dts/shared')).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + require: './dist/cjs/index.cjs', + }, + }); + }); + + test('should share one declaration file for the main entry in bundleless mode', async () => { + const fixturePath = join(__dirname, 'dts/bundleless'); + await buildAndGetResults({ fixturePath, exports: true }); + + expect(readFixtureExports('dts/bundleless')).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + require: './dist/cjs/index.cjs', + }, + }); + }); + + test('should fail when declaration files are not emitted next to the outputs', async () => { + const fixture = 'dts/dist-path'; + const fixturePath = join(__dirname, fixture); + const error = await expectBuildToFail( + buildAndGetResults({ fixturePath, exports: true }), + ); + + expect(error.message).toContain( + 'no declaration files were found next to the outputs of the following libs that emit declaration files: "esm"', + ); + expect(readFileSync(join(fixturePath, 'package.json'), 'utf8')).toBe( + packageJsonContents.get(fixture), + ); + }); +}); diff --git a/tests/integration/exports/multiple-entries/package.json b/tests/integration/exports/multiple-entries/package.json new file mode 100644 index 000000000..a0d184502 --- /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 000000000..f58110703 --- /dev/null +++ b/tests/integration/exports/multiple-entries/rslib.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig()], + source: { + entry: { + index: '../__fixtures__/src/index.ts', + 'utils/numbers': '../__fixtures__/src/utils/numbers.ts', + }, + }, +}); diff --git a/tests/integration/exports/umd/package.json b/tests/integration/exports/umd/package.json new file mode 100644 index 000000000..166706732 --- /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 000000000..2c0f5dbcd --- /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 eebed9b9c..b27889c9d 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 56f8c5114..efb2abb4b 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. + * Not supported in watch mode. + * + * @default false + */ + 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 0e2f2559c..b43e27f6c 100644 --- a/website/docs/en/guide/advanced/release-management.mdx +++ b/website/docs/en/guide/advanced/release-management.mdx @@ -46,6 +46,56 @@ Pay particular attention to the following fields: Also make sure that the package does not set `private: true`. It is also recommended to add `description`, `license`, and `repository` so users can understand and locate the project on npm. +### Generate exports + +Instead of maintaining the `exports` field by hand, you can run `rslib build --exports` to generate it from the build outputs after a successful build. The rules are as follows: + +- Only outputs in the `esm` and `cjs` formats are exported. +- The `index` entry is exported as `.`, and other entries are exported by their entry names, such as `./utils/numbers`. +- In bundleless mode, only the `index` entry in the root of [lib.outBase](/config/lib/out-base) is exported. +- When an entry has both `esm` and `cjs` outputs, the value uses the `import` and `require` conditions. When it has only one format, the value is the output path. + +For example, building `esm` and `cjs` libs with an `index` entry generates: + +```json title="package.json" +{ + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } +} +``` + +The result is merged into the existing `exports` field: subpaths with the same name are overwritten, and other subpaths, including those added manually, are kept. A warning is shown when the value of an existing subpath changes, or when an existing subpath points to an output that no longer exists. The original indentation and line endings of the file are preserved, and the file is not written when nothing changes. + +`--exports` cannot be used together with watch mode. + +#### Declaration files + +For libs with [dts](/config/lib/dts) enabled, Rslib checks the declaration files next to the outputs: + +- When the outputs of every format have declaration files with the matching extension next to them (`.d.ts` for `.js`, `.d.mts` for `.mjs`, and `.d.cts` for `.cjs`), no `types` condition is added, as TypeScript finds them on its own. +- Otherwise, a `types` condition shared by all formats is added, preferring the declaration file with the matching extension. +- When none of the outputs of a lib have declaration files next to them, the build fails, such as when declaration files are emitted to a separate [dts.distPath](/config/lib/dts#dtsdistpath). + +For example, in a package with `"type": "module"`, when both the `esm` and `cjs` libs enable `dts: true`, the `cjs` output `index.cjs` only has `index.d.ts` next to it instead of the matching `index.d.cts`, so the following is generated: + +```json title="package.json" +{ + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } +} +``` + +To emit declaration files with the matching extension for every format, enable [dts.autoExtension](/config/lib/dts#dtsautoextension) and output each format to a separate directory. + ## pnpm version management pnpm provides [versioning and publishing features](https://pnpm.io/versioning) for recording changes, updating package versions, generating changelogs, synchronizing dependency versions between workspace packages, and publishing npm packages. diff --git a/website/docs/en/guide/basic/cli.mdx b/website/docs/en/guide/basic/cli.mdx index 1b2400e55..0a394fde7 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 ``` :::note diff --git a/website/docs/zh/api/javascript-api/instance.mdx b/website/docs/zh/api/javascript-api/instance.mdx index 7c0023300..0c8ac4549 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 字段,不支持 watch 模式 + * + * @default false + */ + 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 3b41e76bf..1d76dd4f4 100644 --- a/website/docs/zh/guide/advanced/release-management.mdx +++ b/website/docs/zh/guide/advanced/release-management.mdx @@ -46,6 +46,56 @@ description: '介绍 Rslib 项目的 npm 包版本管理与发布实践,包括 此外,需要确保包没有设置 `private: true`,并建议补充 `description`、`license`、`repository` 等信息,方便用户在 npm 上了解和定位项目。 +### 生成 exports \{#generate-exports} + +除了手动维护 `exports` 字段,也可以执行 `rslib build --exports`,在构建成功后根据产物自动生成。生成规则如下: + +- 只导出 `esm` 和 `cjs` 格式的产物。 +- 入口 `index` 导出为 `.`,其他入口以入口名导出,例如 `./utils/numbers`。 +- bundleless 模式下只导出 [lib.outBase](/config/lib/out-base) 根目录下的 `index` 入口。 +- 同一入口同时有 `esm` 和 `cjs` 产物时,值为 `import` 和 `require` 条件;只有一种格式时,值为产物路径。 + +例如,构建包含 `index` 入口的 `esm` 和 `cjs` 产物时会生成: + +```json title="package.json" +{ + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } +} +``` + +生成结果会合并到已有的 `exports` 字段中:同名子路径会被覆盖,其他子路径(包括手动添加的)保持不变。同名子路径的值发生变化,或已有子路径指向的产物已不存在时,会输出警告。写入时会保留文件原有的缩进和换行符,内容没有变化时不会写入。 + +`--exports` 不能与 watch 模式同时使用。 + +#### 类型声明文件 \{#declaration-files} + +对于开启了 [dts](/config/lib/dts) 的 lib,Rslib 会检查产物旁的类型声明文件: + +- 每种格式的产物旁都有扩展名对应的类型声明文件时(`.js` 对应 `.d.ts`,`.mjs` 对应 `.d.mts`,`.cjs` 对应 `.d.cts`),不添加 `types` 条件,TypeScript 会自行找到它们。 +- 否则添加一个所有格式共用的 `types` 条件,优先指向扩展名对应的类型声明文件。 +- 某个 lib 的所有产物旁都没有类型声明文件时,构建失败,例如类型声明文件输出到了单独的 [dts.distPath](/config/lib/dts#dtsdistpath)。 + +例如,在设置了 `"type": "module"` 的包中,`esm` 和 `cjs` 都开启 `dts: true` 时,`cjs` 产物 `index.cjs` 旁只有 `index.d.ts`,不是扩展名对应的 `index.d.cts`,因此会生成: + +```json title="package.json" +{ + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } +} +``` + +如需每种格式都有扩展名对应的类型声明文件,可以开启 [dts.autoExtension](/config/lib/dts#dtsautoextension),并将各格式输出到不同的目录。 + ## pnpm 版本管理 \{#pnpm-version-management} pnpm 提供了 [发布管理功能](https://pnpm.io/versioning),支持记录变更、更新包版本、生成 changelog、同步更新 workspace 包之间的依赖版本,以及发布 npm 包。 diff --git a/website/docs/zh/guide/basic/cli.mdx b/website/docs/zh/guide/basic/cli.mdx index aa777ecb9..938605b65 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 字段 ``` :::note