diff --git a/dts.snapshot.json b/dts.snapshot.json index 0e90ee262..7c8230686 100644 --- a/dts.snapshot.json +++ b/dts.snapshot.json @@ -1,7 +1,7 @@ { "config-!~{00d}~.d.mts": { "defineConfig": "declare function defineConfig(_: UserConfigExport): UserConfigExport", - "mergeConfig": "declare function mergeConfig(_: InlineConfig, _: InlineConfig): InlineConfig", + "mergeConfig": "declare function mergeConfig(_: InlineConfig, ...overrides: InlineConfig[]): InlineConfig", "resolveUserConfig": "declare function resolveUserConfig(_: UserConfig, _: InlineConfig): Promise" }, "config.d.mts": { @@ -128,7 +128,7 @@ "DepsPlugin": "declare function DepsPlugin(_: ResolvedConfig, _: TsdownBundle): Plugin", "DevtoolsOptions": "interface DevtoolsOptions extends NonNullable {\n ui?: boolean | Partial\n clean?: boolean\n}", "ExeOptions": "interface ExeOptions extends ExeExtensionOptions {\n seaConfig?: Omit\n fileName?: string | ((_: RolldownChunk) => string)\n outDir?: string\n}", - "ExportsOptions": "interface ExportsOptions {\n devExports?: boolean | string\n packageJson?: boolean\n all?: boolean\n exclude?: (RegExp | string)[]\n legacy?: boolean\n customExports?: Record | ((_: Record, _: { pkg: PackageJson; chunks: ChunksByFormat; isPublish: boolean }) => Awaitable>)\n inlinedDependencies?: boolean\n}", + "ExportsOptions": "interface ExportsOptions {\n devExports?: boolean | string\n packageJson?: boolean\n all?: boolean\n exclude?: (RegExp | string)[]\n legacy?: boolean\n customExports?: Record | ((_: Record, _: { pkg: PackageJson; chunks: ChunksByFormat; isPublish: boolean }) => Awaitable>)\n inlinedDependencies?: boolean\n bin?: boolean | string | Record\n}", "Format": "type Format = ModuleFormat", "InlineConfig": "interface InlineConfig extends UserConfig {\n config?: boolean | string\n configLoader?: 'auto' | 'native' | 'unrun'\n filter?: RegExp | Arrayable\n}", "NoExternalFn": "type NoExternalFn = (_: string, _: string | undefined) => boolean | null | undefined | void", diff --git a/packages/create-tsdown/tsdown.config.ts b/packages/create-tsdown/tsdown.config.ts index d9d903dc4..49e00a194 100644 --- a/packages/create-tsdown/tsdown.config.ts +++ b/packages/create-tsdown/tsdown.config.ts @@ -2,4 +2,7 @@ import type { UserConfig } from '../../src/config.ts' export default { entry: ['./src/{index,run}.ts'], + exports: { + bin: true, + }, } satisfies UserConfig diff --git a/packages/migrate/tsdown.config.ts b/packages/migrate/tsdown.config.ts index 0a675fd7f..8bddd39ef 100644 --- a/packages/migrate/tsdown.config.ts +++ b/packages/migrate/tsdown.config.ts @@ -3,4 +3,7 @@ import type { UserConfig } from '../../src/config.ts' export default { name: 'migrate', entry: ['./src/{index,run}.ts'], + exports: { + bin: true, + }, } satisfies UserConfig diff --git a/src/config/options.ts b/src/config/options.ts index 4178f4877..be13f6809 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -361,17 +361,21 @@ const defu = createDefu((obj, key, value) => { export function mergeConfig( defaults: UserConfig, - overrides: UserConfig, + ...overrides: UserConfig[] ): UserConfig export function mergeConfig( defaults: InlineConfig, - overrides: InlineConfig, + ...overrides: InlineConfig[] ): InlineConfig export function mergeConfig( defaults: InlineConfig, - overrides: InlineConfig, + ...overrides: InlineConfig[] ): InlineConfig { - return defu(overrides, defaults) + return defu( + // @ts-expect-error - defu does not handle overloads well + ...overrides.toReversed(), + defaults, + ) } export async function mergeUserOptions( diff --git a/src/config/workspace.ts b/src/config/workspace.ts index 74ef72192..5cebc84fc 100644 --- a/src/config/workspace.ts +++ b/src/config/workspace.ts @@ -4,6 +4,7 @@ import { createDebug } from 'obug' import { glob } from 'tinyglobby' import { slash } from '../utils/general.ts' import { loadConfigFile } from './file.ts' +import { mergeConfig } from './options.ts' import type { InlineConfig, UserConfig } from './types.ts' const debug = createDebug('tsdown:config:workspace') @@ -19,7 +20,7 @@ export async function resolveWorkspace( config: UserConfig, inlineConfig: InlineConfig, ): Promise<{ configs: UserConfig[]; files?: string[] }> { - const normalized: UserConfig = { ...config, ...inlineConfig } + const normalized = mergeConfig(config, inlineConfig) const rootCwd = normalized.cwd || process.cwd() let { workspace } = normalized @@ -82,9 +83,7 @@ export async function resolveWorkspace( } else { debug('no workspace config file found in %s', cwd) } - return configs.map( - (config): UserConfig => ({ ...normalized, ...config }), - ) + return configs.map((config) => mergeConfig(normalized, config)) }), ) ).flat() diff --git a/src/features/pkg/exports.test.ts b/src/features/pkg/exports.test.ts index 2cdfe16ca..ff9b61d8f 100644 --- a/src/features/pkg/exports.test.ts +++ b/src/features/pkg/exports.test.ts @@ -18,12 +18,15 @@ function generateExports( options: { exports?: ResolvedConfig['exports'] css?: ResolvedConfig['css'] + logger?: ResolvedConfig['logger'] } = {}, + pkg = FAKE_PACKAGE_JSON, ) { - return _generateExports(FAKE_PACKAGE_JSON, chunks, { + return _generateExports(pkg, chunks, { exports: {}, logger: globalLogger, css: DEFAULT_CSS_OPTIONS, + cwd, ...options, }) } @@ -33,6 +36,7 @@ describe('generateExports', () => { const results = generateExports() await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { "./package.json": "./package.json", }, @@ -54,6 +58,7 @@ describe('generateExports', () => { }) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./main.js", "./package.json": "./package.json", @@ -73,6 +78,7 @@ describe('generateExports', () => { }) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./foo": "./foo.js", @@ -93,6 +99,7 @@ describe('generateExports', () => { }) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./foo": "./foo/index.js", @@ -113,6 +120,7 @@ describe('generateExports', () => { }) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { "./bar": "./bar.js", "./foo": "./foo.js", @@ -134,6 +142,7 @@ describe('generateExports', () => { }) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": { "import": "./foo.js", @@ -157,6 +166,7 @@ describe('generateExports', () => { }) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": { "import": "./foo.js", @@ -180,6 +190,7 @@ describe('generateExports', () => { }) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": { "import": "./index.mjs", @@ -236,6 +247,7 @@ describe('generateExports', () => { ) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./SRC/index.js", "./package.json": "./package.json", @@ -330,6 +342,7 @@ describe('generateExports', () => { await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./foo": "./foo.js", @@ -356,6 +369,7 @@ describe('generateExports', () => { await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./foo": "./foo.js", @@ -380,6 +394,7 @@ describe('generateExports', () => { await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { "./package.json": "./package.json", }, @@ -401,6 +416,7 @@ describe('generateExports', () => { ) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": { "import": "./index.js", @@ -428,6 +444,7 @@ describe('generateExports', () => { ) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./*": "./*", @@ -456,6 +473,7 @@ describe('generateExports', () => { ) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./*": "./*", @@ -483,6 +501,7 @@ describe('generateExports', () => { ) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./*": "./*", @@ -509,6 +528,7 @@ describe('generateExports', () => { }) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./bar/baz": "./bar/baz.js", @@ -547,6 +567,7 @@ describe('generateExports', () => { }) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": { "import": "./index.js", @@ -581,6 +602,7 @@ describe('generateExports', () => { ) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./package.json": "./package.json", @@ -607,6 +629,7 @@ describe('generateExports', () => { ) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./package.json": "./package.json", @@ -630,6 +653,7 @@ describe('generateExports', () => { ) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./custom.css": "./custom.css", @@ -654,6 +678,7 @@ describe('generateExports', () => { ) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": "./index.js", "./package.json": "./package.json", @@ -668,6 +693,179 @@ describe('generateExports', () => { `) }) + test('bin: true with single shebang entry', async ({ expect }) => { + const results = generateExports( + { + es: [ + genChunk( + 'cli.js', + true, + undefined, + '#!/usr/bin/env node\nconsole.log("hello")', + ), + ], + }, + { exports: { bin: true } }, + ) + await expect(results).resolves.toMatchObject({ + bin: { 'fake-pkg': './cli.js' }, + }) + }) + + test('bin: true with no shebangs warns', async ({ expect }) => { + const warnings: string[] = [] + const logger = { + ...globalLogger, + warn: (...msgs: any[]) => { + warnings.push(msgs.join(' ')) + }, + } + const results = await generateExports( + { es: [genChunk('index.js', true, undefined, 'console.log("hello")')] }, + { exports: { bin: true }, logger }, + ) + expect(results.bin).toBeUndefined() + expect( + warnings.some((w) => w.includes('no entry chunks with shebangs')), + ).toBe(true) + }) + + test('bin: true with multiple shebangs throws', async ({ expect }) => { + await expect( + generateExports( + { + es: [ + genChunk('cli.js', true, undefined, '#!/usr/bin/env node\n'), + genChunk('tool.js', true, undefined, '#!/usr/bin/env node\n'), + ], + }, + { exports: { bin: true } }, + ), + ).rejects.toThrow('Multiple entry chunks with shebangs found') + }) + + test('bin: true prefers ESM over CJS', async ({ expect }) => { + const facadeId = path.resolve('./SRC/cli.js') + const results = generateExports( + { + es: [genChunk('cli.mjs', true, facadeId, '#!/usr/bin/env node\n')], + cjs: [genChunk('cli.cjs', true, facadeId, '#!/usr/bin/env node\n')], + }, + { exports: { bin: true } }, + ) + await expect(results).resolves.toMatchObject({ + bin: { 'fake-pkg': './cli.mjs' }, + }) + }) + + test('bin: string form', async ({ expect }) => { + const results = generateExports( + { + es: [ + genChunk( + 'cli.js', + true, + path.resolve('./src/cli.ts'), + '#!/usr/bin/env node\n', + ), + ], + }, + { exports: { bin: './src/cli.ts' } }, + ) + await expect(results).resolves.toMatchObject({ + bin: { 'fake-pkg': './cli.js' }, + }) + }) + + test('bin: string form warns without shebang', async ({ expect }) => { + const warnings: string[] = [] + const logger = { + ...globalLogger, + warn: (...msgs: any[]) => { + warnings.push(msgs.join(' ')) + }, + } + const results = await generateExports( + { + es: [ + genChunk( + 'cli.js', + true, + path.resolve('./src/cli.ts'), + 'console.log("hello")', + ), + ], + }, + { exports: { bin: './src/cli.ts' }, logger }, + ) + expect(results.bin).toEqual({ 'fake-pkg': './cli.js' }) + expect( + warnings.some((w) => w.includes('does not contain a shebang line')), + ).toBe(true) + }) + + test('bin: string form throws when no matching chunk', async ({ expect }) => { + await expect( + generateExports( + { es: [genChunk('index.js')] }, + { exports: { bin: './src/cli.ts' } }, + ), + ).rejects.toThrow( + 'Could not find output chunk for bin entry "./src/cli.ts"', + ) + }) + + test('bin: object form', async ({ expect }) => { + const results = generateExports( + { + es: [ + genChunk( + 'cli.js', + true, + path.resolve('./src/cli.ts'), + '#!/usr/bin/env node\n', + ), + genChunk( + 'tool.js', + true, + path.resolve('./src/tool.ts'), + '#!/usr/bin/env node\n', + ), + ], + }, + { + exports: { + bin: { + mycli: './src/cli.ts', + mytool: './src/tool.ts', + }, + }, + }, + ) + await expect(results).resolves.toMatchObject({ + bin: { + mycli: './cli.js', + mytool: './tool.js', + }, + }) + }) + + test('bin: scoped package name', async ({ expect }) => { + const results = generateExports( + { + es: [genChunk('cli.js', true, undefined, '#!/usr/bin/env node\n')], + }, + { exports: { bin: true } }, + { + name: '@scope/my-tool', + packageJsonPath: path.join(cwd, 'package.json'), + }, + ) + await expect(results).resolves.toMatchObject({ + bin: { 'my-tool': './cli.js' }, + }) + }) + test('generate css publish exports', async ({ expect }) => { const results = generateExports( { es: [genChunk('index.js'), genAsset('style.css')] }, @@ -680,6 +878,7 @@ describe('generateExports', () => { ) await expect(results).resolves.toMatchInlineSnapshot(` { + "bin": undefined, "exports": { ".": { "default": "./index.js", @@ -702,7 +901,12 @@ describe('generateExports', () => { }) }) -function genChunk(fileName: string, isEntry = true, facadeModuleId?: string) { +function genChunk( + fileName: string, + isEntry = true, + facadeModuleId?: string, + code = '', +) { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { type: 'chunk', @@ -710,6 +914,7 @@ function genChunk(fileName: string, isEntry = true, facadeModuleId?: string) { isEntry, facadeModuleId: facadeModuleId ?? path.resolve(`./SRC/${fileName}`), outDir: cwd, + code, } as RolldownChunk } diff --git a/src/features/pkg/exports.ts b/src/features/pkg/exports.ts index 6c382cd66..a6c1fe867 100644 --- a/src/features/pkg/exports.ts +++ b/src/features/pkg/exports.ts @@ -10,6 +10,7 @@ import type { RolldownChunk, RolldownCodeChunk, } from '../../utils/chunks.ts' +import type { Logger } from '../../utils/logger.ts' import type { Awaitable } from '../../utils/types.ts' import type { PackageJson } from 'pkg-types' @@ -94,6 +95,23 @@ export interface ExportsOptions { * @see {@link https://github.com/e18e/ecosystem-issues/issues/237} */ inlinedDependencies?: boolean + + /** + * Auto-generate the `bin` field in package.json. + * + * - `true`: Auto-detect entry chunks with shebangs. Uses package name (without scope) as bin name. + * Errors if multiple shebang entries are found. + * - `string`: Source file path to use as the bin entry. Bin name defaults to package name (without scope). + * - `Record`: Map of bin command names to source file paths. + * + * @example + * bin: true + * @example + * bin: './src/cli.ts' + * @example + * bin: { tool: './src/cli-tool.ts' } + */ + bin?: boolean | string | Record } export async function writeExports( @@ -104,7 +122,7 @@ export async function writeExports( typeAssert(options.pkg) const { pkg } = options - const { publishExports, ...generated } = await generateExports( + const { publishExports, bin, ...generated } = await generateExports( pkg, chunks, options, @@ -114,6 +132,7 @@ export async function writeExports( const updatedPkg = { ...pkg, ...generated, + ...(bin === undefined ? {} : { bin }), packageJsonPath: undefined, } @@ -146,13 +165,14 @@ function shouldExclude( export async function generateExports( pkg: PackageJson, chunks: ChunksByFormat, - options: Pick, + options: Pick, inlinedDeps?: Record, ): Promise<{ main: string | undefined module: string | undefined types: string | undefined exports: Record + bin?: string | Record inlinedDependencies?: Record publishExports?: Record }> { @@ -166,9 +186,11 @@ export async function generateExports( customExports, legacy, inlinedDependencies: emitInlinedDeps = true, + bin, }, css, logger, + cwd, } = options const pkgRoot = path.dirname(pkg.packageJsonPath) @@ -312,6 +334,7 @@ export async function generateExports( module: legacy ? module || pkg.module : undefined, types: legacy ? cjsTypes || esmTypes || pkg.types : pkg.types, exports, + bin: generateBin(bin, pkg, chunks, pkgRoot, logger, cwd), inlinedDependencies: emitInlinedDeps ? inlinedDeps : undefined, publishExports, } @@ -402,6 +425,101 @@ export function hasExportsTypes(pkg?: PackageJson): boolean { return false } +const RE_SHEBANG = /^#!.*/ + +function generateBin( + bin: ExportsOptions['bin'], + pkg: PackageJson, + chunks: ChunksByFormat, + pkgRoot: string, + logger: Logger, + cwd: string, +): string | Record | undefined { + if (!bin) return + + if (bin === true || typeof bin === 'string') { + if (!pkg.name) + throw new Error( + 'Package name is required when using string form for `bin`', + ) + + const binName = pkg.name[0] === '@' ? pkg.name.split('/', 2)[1] : pkg.name + + if (bin === true) { + let detected: string | undefined + const seen = new Set() + + for (const format of ['es', 'cjs'] as const) { + const formatChunks = chunks[format] + if (!formatChunks) continue + for (const chunk of formatChunks) { + if (chunk.type !== 'chunk' || !chunk.isEntry || !chunk.facadeModuleId) + continue + if (!RE_SHEBANG.test(chunk.code)) continue + if (seen.has(chunk.facadeModuleId)) continue + seen.add(chunk.facadeModuleId) + + if (detected) { + throw new Error( + 'Multiple entry chunks with shebangs found. Use `exports.bin: { name: "./src/file.ts" }` to specify which one to use.', + ) + } + detected = join(pkgRoot, chunk.outDir, slash(chunk.fileName)) + } + } + + if (detected == null) { + logger.warn( + '`exports.bin` is true but no entry chunks with shebangs were found', + ) + return + } + return { [binName]: detected } + } + + if (typeof bin === 'string') { + const match = findChunkBySource(bin) + if (!match) { + throw new Error(`Could not find output chunk for bin entry "${bin}"`) + } + return { [binName]: match } + } + } + + // Object form: { commandName: './src/file.ts' } + const result: Record = {} + for (const [cmdName, sourcePath] of Object.entries(bin)) { + const match = findChunkBySource(sourcePath) + if (!match) { + throw new Error( + `Could not find output chunk for bin entry "${cmdName}": "${sourcePath}"`, + ) + } + result[cmdName] = match + } + return result + + function findChunkBySource(sourcePath: string): string | undefined { + const resolved = path.resolve(cwd, sourcePath) + + for (const format of ['es', 'cjs'] as const) { + const formatChunks = chunks[format] + if (!formatChunks) continue + for (const chunk of formatChunks) { + if (chunk.type !== 'chunk' || !chunk.isEntry) continue + if (chunk.facadeModuleId !== resolved) continue + + if (!RE_SHEBANG.test(chunk.code)) { + logger.warn( + `Bin entry "${sourcePath}" does not contain a shebang line`, + ) + } + return join(pkgRoot, chunk.outDir, slash(chunk.fileName)) + } + } + } +} + function getExportName( chunk: RolldownCodeChunk, ): [name: string, normalizedName: string, isDts: boolean] { diff --git a/tsdown.config.ts b/tsdown.config.ts index ebffa7856..6e05883fa 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -41,6 +41,7 @@ export default defineConfig([ customExports: { './client': './client.d.ts', }, + bin: true, }, plugins: [ RequireCJS(),