Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: remove export handling from getExportPaths for wildcard matching #244

Merged
merged 20 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
)
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
172 changes: 34 additions & 138 deletions src/exports.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import fs from 'fs'
import { join, resolve, dirname, extname } from 'path'
import type {
PackageMetadata,
Expand All @@ -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
Expand All @@ -29,23 +27,24 @@ function isExportLike(field: any): field is string | FullExportCondition {
}

function constructFullExportCondition(
value: string | Record<string, string | undefined>,
exportCondition: string | Record<string, string | undefined>,
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
}
})
}
Expand All @@ -64,123 +63,22 @@ function joinRelativePath(...segments: string[]) {

function findExport(
name: string,
value: ExportCondition,
exportCondition: ExportCondition,
paths: Record<string, FullExportCondition>,
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
}

/**
Expand Down Expand Up @@ -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)
})
}
}
Expand Down Expand Up @@ -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<string, FullExportCondition> = {}
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)
}

Expand Down Expand Up @@ -362,14 +255,17 @@ export function constructDefaultExportCondition(
value: string | Record<string, string | undefined>,
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) {
Expand Down
115 changes: 115 additions & 0 deletions src/lib/wildcard.ts
Original file line number Diff line number Diff line change
@@ -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<string, ExportCondition>,
): Record<string, ExportCondition> => {
return { './*': exportsCondition['./*'] }
}

const isExportable = async (
dirent: Dirent,
pathname: string,
): Promise<boolean> => {
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<string[]> {
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<string, ExportCondition>,
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<string, ExportCondition> | undefined,
cwd: string,
): Promise<ExportCondition | undefined> {
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
}
Loading