diff --git a/src/bundle.ts b/src/bundle.ts index 67e1b98c..695e5384 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -27,6 +27,7 @@ import { import type { BuildMetadata } from './types' import { TypescriptOptions, resolveTsConfig } from './typescript' import { logSizeStats } from './logging' +import { resolveWildcardExports } from './lib/wildcard' function assignDefault( options: BundleConfig, @@ -58,9 +59,10 @@ async function bundle( assignDefault(options, 'target', 'es2015') const pkg = await getPackageMeta(cwd) + const resolvedWildcardExports = await resolveWildcardExports(pkg.exports, cwd) const packageType = getPackageType(pkg) - const exportPaths = getExportPaths(pkg, cwd) + const exportPaths = getExportPaths(pkg, packageType, resolvedWildcardExports) const exportKeys = Object.keys(exportPaths).filter( (key) => key !== './package.json', ) diff --git a/src/constants.ts b/src/constants.ts index 22a7f02d..5687d1f6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -15,3 +15,5 @@ export const availableExportConventions = [ ] export const availableESExtensionsRegex = /\.(m|c)?[jt]sx?$/ export const dtsExtensionRegex = /\.d\.(m|c)?ts$/ + +export const SRC = 'src' diff --git a/src/exports.ts b/src/exports.ts index 7366e6aa..f803af5c 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -1,4 +1,3 @@ -import fs from 'fs' import { join, resolve, dirname, extname } from 'path' import type { PackageMetadata, @@ -8,7 +7,6 @@ import type { ParsedExportCondition, } from './types' import { filenameWithoutExtension } from './utils' -import { availableExtensions, availableExportConventions } from './constants' export function getTypings(pkg: PackageMetadata) { return pkg.types || pkg.typings @@ -29,23 +27,24 @@ function isExportLike(field: any): field is string | FullExportCondition { } function constructFullExportCondition( - value: string | Record, + exportCondition: string | Record, packageType: PackageType, ): FullExportCondition { const isCommonjs = packageType === 'commonjs' let result: FullExportCondition - if (typeof value === 'string') { + if (typeof exportCondition === 'string') { result = { - [isCommonjs ? 'require' : 'import']: value, + [isCommonjs ? 'require' : 'import']: exportCondition, } } else { // TODO: valid export condition, warn if it's not valid - const keys: string[] = Object.keys(value) + const keys: string[] = Object.keys(exportCondition) result = {} keys.forEach((key) => { + const condition = exportCondition[key] // Filter out nullable value - if (key in value && value[key]) { - result[key] = value[key] as string + if (key in exportCondition && condition) { + result[key] = condition } }) } @@ -64,123 +63,22 @@ function joinRelativePath(...segments: string[]) { function findExport( name: string, - value: ExportCondition, + exportCondition: ExportCondition, paths: Record, packageType: 'commonjs' | 'module', ): void { // TODO: handle export condition based on package.type - if (isExportLike(value)) { - paths[name] = constructFullExportCondition(value, packageType) + if (isExportLike(exportCondition)) { + paths[name] = constructFullExportCondition(exportCondition, packageType) return } - Object.keys(value).forEach((subpath) => { + Object.keys(exportCondition).forEach((subpath) => { const nextName = joinRelativePath(name, subpath) - const nestedValue = value[subpath] - findExport(nextName, nestedValue, paths, packageType) - }) -} - -// Should exclude all outDirs since they are readable on `fs.readdirSync` -// Example: -// { 'import': './someOutDir/index.js' } => ['someOutDir'] -// { 'import': './importDir/index.js', 'require': './requireDir/index.js' } => ['importDir', 'requireDir'] -function getOutDirs(exportsConditions: ExportCondition) { - return [ - ...new Set( - Object.values(exportsConditions) - .flatMap((value) => Object.values(value)) - .flatMap((innerValue) => - typeof innerValue === 'string' ? [innerValue] : [], - ), - ), - ] - .map((value) => value.split('/')[1]) - .filter(Boolean) -} - -function resolveWildcardEntry( - wildcardEntry: { - [key: string]: ExportCondition - }, - cwd: string, - excludes: string[], -): { - [key: string]: ExportCondition -}[] { - const dirents = fs.readdirSync(cwd, { withFileTypes: true }) - - const allowedExtensions = [ - ...availableExtensions, - ...availableExportConventions, - ].map((ext) => `.${ext}`) - - const resolvedExports = dirents.flatMap((dirent) => { - // Skip outDirs and existing ExportConditions keys - if (excludes.includes(dirent.name)) return - - if (dirent.isDirectory()) { - // Read inside src directory - if (dirent.name === 'src') { - return resolveWildcardEntry(wildcardEntry, `${cwd}/src`, excludes) - } - - const dirName = dirent.name - const hasIndexFile = fs - .readdirSync(`${cwd}/${dirName}`) - .some((file) => file.startsWith('index')) - - if (hasIndexFile) { - return { - [`./${dirName}`]: JSON.parse( - JSON.stringify(wildcardEntry).replace(/\*/g, `${dirName}/index`), - ), - } - } - } - - if (dirent.isFile()) { - const fileName = filenameWithoutExtension(dirent.name)! - // ['.'] is for index file, so skip index - if (fileName === 'index') return - if (allowedExtensions.includes(extname(dirent.name))) { - return { - [`./${fileName}`]: JSON.parse( - JSON.stringify(wildcardEntry).replace(/\*/g, fileName), - ), - } - } - } - - return + const nestedExportCondition = exportCondition[subpath] + findExport(nextName, nestedExportCondition, paths, packageType) }) - - return resolvedExports.filter(Boolean) -} - -function resolveWildcardExports( - exportsConditions: { - [key: string]: ExportCondition - }, - cwd: string, -) { - const outDirs = getOutDirs(exportsConditions) - const existingKeys = [ - ...new Set(Object.keys(exportsConditions).flatMap((key) => key.split('/'))), - ] - const excludes = [...outDirs, ...existingKeys] - - const wildcardEntry = exportsConditions['./*'] as { - [key: string]: ExportCondition - } - - const resolvedEntry = resolveWildcardEntry(wildcardEntry, cwd, excludes) - - const resolvedExports = Object.assign({}, exportsConditions, ...resolvedEntry) - delete resolvedExports['./*'] - - return resolvedExports } /** @@ -222,8 +120,8 @@ function parseExport( paths['.'] = constructFullExportCondition(exportsCondition, packageType) } else { Object.keys(exportsCondition).forEach((key: string) => { - const value = exportsCondition[key] - findExport(key, value, paths, packageType) + const exportCondition = exportsCondition[key] + findExport(key, exportCondition, paths, packageType) }) } } @@ -271,24 +169,19 @@ function parseExport( * pkg.main and pkg.module will be added to ['.'] if exists */ -export function getExportPaths(pkg: PackageMetadata, cwd: string) { +export function getExportPaths( + pkg: PackageMetadata, + pkgType?: PackageType, + resolvedWildcardExports?: ExportCondition, +) { const pathsMap: Record = {} - const packageType = getPackageType(pkg) + const packageType = pkgType ?? getPackageType(pkg) const isCjsPackage = packageType === 'commonjs' - const { exports: exportsConditions } = pkg + const exportsConditions = resolvedWildcardExports ?? pkg.exports if (exportsConditions) { - let resolvedExportsConditions = exportsConditions - - if ( - Object.keys(exportsConditions).some((key) => key === './*') && - typeof exportsConditions !== 'string' - ) { - resolvedExportsConditions = resolveWildcardExports(exportsConditions, cwd) - } - - const paths = parseExport(resolvedExportsConditions, packageType) + const paths = parseExport(exportsConditions, packageType) Object.assign(pathsMap, paths) } @@ -362,14 +255,17 @@ export function constructDefaultExportCondition( value: string | Record, packageType: PackageType, ) { - const objValue = - typeof value === 'string' - ? { - [packageType === 'commonjs' ? 'require' : 'import']: value, - types: getTypings(value as PackageMetadata), - } - : value - return constructFullExportCondition(objValue, packageType) + let exportCondition + if (typeof value === 'string') { + const types = getTypings(value as PackageMetadata) + exportCondition = { + [packageType === 'commonjs' ? 'require' : 'import']: value, + ...(types && {types}), + } + } else { + exportCondition = value + } + return constructFullExportCondition(exportCondition, packageType) } export function isEsmExportName(name: string, ext: string) { diff --git a/src/lib/wildcard.ts b/src/lib/wildcard.ts new file mode 100644 index 00000000..43aa8f8f --- /dev/null +++ b/src/lib/wildcard.ts @@ -0,0 +1,115 @@ +import fs from 'fs/promises' +import type { Dirent } from 'fs' +import path from 'path' +import { SRC } from '../constants' +import { ExportCondition } from '../types' +import { + filenameWithoutExtension, + hasAvailableExtension, + nonNullable, +} from '../utils' + +// TODO: support nested wildcard exportsCondition (e.g. './foo/*') +const getWildcardExports = ( + exportsCondition: Record, +): Record => { + return { './*': exportsCondition['./*'] } +} + +const isExportable = async ( + dirent: Dirent, + pathname: string, +): Promise => { + if (dirent.isDirectory()) { + const innerDirents = await fs.readdir(path.join(pathname, dirent.name), { + withFileTypes: true, + }) + return innerDirents.some( + ({ name }) => name.startsWith('index') && hasAvailableExtension(name), + ) + } + return ( + dirent.isFile() && + !dirent.name.startsWith('index') && + hasAvailableExtension(dirent.name) + ) +} + +async function getExportables( + cwd: string, + excludeKeys: string[], +): Promise { + const pathname = path.resolve(cwd, SRC) + const dirents = await fs.readdir(pathname, { withFileTypes: true }) + const exportables: (string | undefined)[] = await Promise.all( + dirents.map(async (dirent) => + (await isExportable(dirent, pathname)) && + !excludeKeys.includes(dirent.name) + ? dirent.name + : undefined, + ), + ) + return exportables.filter(nonNullable) +} + +function mapWildcard( + wildcardExports: Record, + exportables: string[], +): ExportCondition[] { + return exportables.map((exportable) => { + const isFile = exportable.includes('.') + const filename = isFile ? filenameWithoutExtension(exportable)! : exportable + + return { + [`./${filename}`]: Object.fromEntries( + Object.entries(wildcardExports['./*']).map(([key, value]) => [ + key, + (value as string).replace( + /\*/g, + isFile ? filename : `${filename}/index`, + ), + ]), + ), + } + }) +} + +export async function resolveWildcardExports( + exportsCondition: string | Record | undefined, + cwd: string, +): Promise { + if (!exportsCondition || typeof exportsCondition === 'string') + return undefined + + const hasWildcard = !!exportsCondition['./*'] + + if (hasWildcard) { + console.warn( + `The wildcard export "./*" is experimental and may change or be removed at any time.\n` + + 'To open an issue, please visit https://github.com/huozhi/bunchee/issues' + + '.\n', + ) + + // './foo' -> ['foo']; './foo/bar' -> ['bar'] + // will contain '*' also but it's not a problem + const excludeKeys = Object.keys(exportsCondition).map( + (key) => key.split('/').pop() as string, + ) + const exportables = await getExportables(cwd, excludeKeys) + + if (exportables.length > 0) { + const wildcardExports = getWildcardExports(exportsCondition) + const resolvedWildcardExports = mapWildcard(wildcardExports, exportables) + const resolvedExports = Object.assign( + {}, + exportsCondition, + ...resolvedWildcardExports, + ) + + delete resolvedExports['./*'] + return resolvedExports + } + } + + return undefined +} diff --git a/src/utils.ts b/src/utils.ts index 2a152e8b..c8770435 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,11 @@ import fs from 'fs/promises' import path from 'path' -import type { PackageMetadata } from './types' -import { availableExportConventions, availableExtensions } from './constants' +import { PackageMetadata } from './types' +import { + availableExportConventions, + availableExtensions, + SRC, +} from './constants' export function exit(err: string | Error) { logger.error(err) @@ -72,7 +76,6 @@ export function getExportPath( export const isNotNull = (n: T | false): n is T => Boolean(n) -const SRC = 'src' // resolve from src/ directory export function resolveSourceFile(cwd: string, filename: string) { return path.resolve(cwd, SRC, filename) } @@ -143,3 +146,9 @@ export function filenameWithoutExtension(file: string | undefined) { } export const nonNullable = (n?: T): n is T => Boolean(n) + +export const fileExtension = (file: string | undefined) => + file ? path.extname(file).slice(1) : undefined + +export const hasAvailableExtension = (filename: string): boolean => + availableExtensions.includes(path.extname(filename).slice(1)) diff --git a/test/integration.test.ts b/test/integration.test.ts index 53f77c46..53cf189e 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -239,7 +239,7 @@ const testCases: { { name: 'wildcard-exports', args: [], - async expected(dir, { stdout }) { + async expected(dir, { stdout, stderr }) { const contentsRegex = { './dist/index.js': /'index'/, './dist/layout/index.js': /'layout'/, @@ -270,6 +270,7 @@ const testCases: { log.split('\n').forEach((line: string) => { expect(rawStdout).toContain(line.trim()) }) + expect(stderr).toContain('is experimental') }, }, ] diff --git a/test/lib-unit/exports.test.ts b/test/lib-unit/exports.test.ts index 0e08b1a6..b8eebe75 100644 --- a/test/lib-unit/exports.test.ts +++ b/test/lib-unit/exports.test.ts @@ -6,8 +6,6 @@ import { getExportTypeDist, } from '../../src/exports' -const cwd = path.resolve(__dirname) - describe('lib exports', () => { describe('getExportPaths', () => { it('should handle the basic main fields paths (cjs)', () => { @@ -15,7 +13,7 @@ describe('lib exports', () => { main: './dist/index.cjs', module: './dist/index.esm.js', } - const result = getExportPaths(pkg, cwd) + const result = getExportPaths(pkg) expect(result).toEqual({ '.': { require: './dist/index.cjs', @@ -26,17 +24,14 @@ describe('lib exports', () => { it('should handle types field', () => { expect( - getExportPaths( - { - exports: { - '.': { - import: './dist/index.mjs', - types: './dist/index.d.ts', - }, + getExportPaths({ + exports: { + '.': { + import: './dist/index.mjs', + types: './dist/index.d.ts', }, }, - cwd, - ), + }), ).toEqual({ '.': { import: './dist/index.mjs', @@ -45,59 +40,6 @@ describe('lib exports', () => { }) }) - it('should handle wildcard exports', () => { - expect( - getExportPaths( - { - exports: { - '.': { - types: './dist/index.d.ts', - import: './dist/index.js', - }, - './server': { - types: './dist/server/index.d.ts', - 'react-server': './dist/server/react-server.mjs', - 'edge-light': './dist/server/edge.mjs', - import: './dist/server/index.mjs', - }, - './*': { - types: './dist/*.d.ts', - import: './dist/*.js', - }, - }, - }, - path.join(__dirname, '../integration/wildcard-exports'), - ), - ).toEqual({ - '.': { - types: './dist/index.d.ts', - import: './dist/index.js', - }, - './server': { - types: './dist/server/index.d.ts', - 'react-server': './dist/server/react-server.mjs', - 'edge-light': './dist/server/edge.mjs', - import: './dist/server/index.mjs', - }, - './button': { - types: './dist/button.d.ts', - import: './dist/button.js', - }, - './input': { - types: './dist/input.d.ts', - import: './dist/input.js', - }, - './layout': { - types: './dist/layout/index.d.ts', - import: './dist/layout/index.js', - }, - './lite': { - types: './dist/lite.d.ts', - import: './dist/lite.js', - }, - }) - }) - describe('type:module', () => { it('should handle the basic main fields paths (esm)', () => { const pkg: PackageMetadata = { @@ -105,7 +47,7 @@ describe('lib exports', () => { main: './dist/index.mjs', module: './dist/index.esm.js', } - const result = getExportPaths(pkg, cwd) + const result = getExportPaths(pkg) expect(result).toEqual({ '.': { import: './dist/index.mjs', @@ -117,18 +59,15 @@ describe('lib exports', () => { it('should handle the exports conditions', () => { expect( - getExportPaths( - { - exports: { - '.': { - require: './dist/index.cjs', - module: './dist/index.esm.js', - default: './dist/index.esm.js', - }, + getExportPaths({ + exports: { + '.': { + require: './dist/index.cjs', + module: './dist/index.esm.js', + default: './dist/index.esm.js', }, }, - cwd, - ), + }), ).toEqual({ '.': { require: './dist/index.cjs', @@ -138,17 +77,14 @@ describe('lib exports', () => { }) expect( - getExportPaths( - { - exports: { - '.': { - import: './dist/index.mjs', - require: './dist/index.cjs', - }, + getExportPaths({ + exports: { + '.': { + import: './dist/index.mjs', + require: './dist/index.cjs', }, }, - cwd, - ), + }), ).toEqual({ '.': { import: './dist/index.mjs', @@ -159,19 +95,16 @@ describe('lib exports', () => { it('should handle the mixed exports conditions', () => { expect( - getExportPaths( - { - main: './dist/index.cjs', - exports: { - '.': { - sub: { - require: './dist/index.cjs', - }, + getExportPaths({ + main: './dist/index.cjs', + exports: { + '.': { + sub: { + require: './dist/index.cjs', }, }, }, - cwd, - ), + }), ).toEqual({ '.': { require: './dist/index.cjs', @@ -182,20 +115,17 @@ describe('lib exports', () => { }) expect( - getExportPaths( - { - main: './dist/index.js', - module: './dist/index.esm.js', + getExportPaths({ + main: './dist/index.js', + module: './dist/index.esm.js', + types: './dist/index.d.ts', + exports: { types: './dist/index.d.ts', - exports: { - types: './dist/index.d.ts', - import: './dist/index.mjs', - module: './dist/index.esm.js', - require: './dist/index.js', - }, + import: './dist/index.mjs', + module: './dist/index.esm.js', + require: './dist/index.js', }, - cwd, - ), + }), ).toEqual({ '.': { types: './dist/index.d.ts', @@ -208,16 +138,13 @@ describe('lib exports', () => { it('should warn the duplicated export conditions', () => { expect( - getExportPaths( - { - main: './dist/index.js', - exports: { - import: './dist/index.mjs', - require: './dist/index.cjs', - }, + getExportPaths({ + main: './dist/index.js', + exports: { + import: './dist/index.mjs', + require: './dist/index.cjs', }, - cwd, - ), + }), ).toEqual({ '.': { import: './dist/index.mjs', @@ -232,7 +159,7 @@ describe('lib exports', () => { pkg: PackageMetadata, exportName: string = '.', ) { - const parsedExportCondition = getExportPaths(pkg, cwd) + const parsedExportCondition = getExportPaths(pkg) const parsedExport = { source: `./src/${exportName === '.' ? 'index' : exportName}.ts`, name: exportName, @@ -292,7 +219,7 @@ describe('lib exports', () => { pkg: PackageMetadata, exportName: string = '.', ) { - const parsedExportCondition = getExportPaths(pkg, cwd) + const parsedExportCondition = getExportPaths(pkg) const parsedExport = { source: `./src/${exportName === '.' ? 'index' : exportName}.ts`, name: exportName,