@plugjs/plug
Version:
PlugJS Build System ===================
175 lines (148 loc) • 6.62 kB
text/typescript
import { EOL } from 'node:os'
import { sep } from 'node:path'
import { assert } from '../asserts'
import { Files } from '../files'
import { readFile, writeFile } from '../fs'
import { $p } from '../logging'
import { assertRelativeChildPath, getAbsoluteParent } from '../paths'
import { install } from '../pipe'
import type { Context, PipeParameters, Plug } from '../pipe'
/** Options for our `exports` plug. */
export interface ExportsOptions {
/** The `package.json` file used as the input for processing */
packageJson?: string
/** The `package.json` file to be written including the matching exports */
outputPackageJson?: string
/** The extension for CommonJS modules (default: `.cjs` or `.js`) */
cjsExtension?: string
/** The extension for EcmaScript modules (default: `.mjs` or `.js`) */
esmExtension?: string
}
declare module '../index' {
export interface Pipe {
/** Include the files piped into this task as `exports` in `package.json` */
exports(options?: ExportsOptions): Pipe
}
}
/* ========================================================================== *
* INSTALLATION / IMPLEMENTATION *
* ========================================================================== */
type ExportsDeclaration = {
[ name in string ]? : {
[ type in 'require' | 'import' ]? : {
[ kind in 'types' | 'default' ]? : string
}
}
}
install('exports', class Exports implements Plug<Files> {
private readonly _packageJson: string
private readonly _outputPackageJson: string
private readonly _cjsExtension?: string
private readonly _esmExtension?: string
constructor(...args: PipeParameters<'exports'>) {
const options = args[0] || {}
const {
packageJson = 'package.json',
outputPackageJson = packageJson,
cjsExtension,
esmExtension,
} = options
this._packageJson = packageJson
this._outputPackageJson = outputPackageJson
this._cjsExtension = cjsExtension
this._esmExtension = esmExtension
}
async pipe(files: Files, context: Context): Promise<Files> {
// read up our package.json, we need it to figure out the default `type`
const incomingFile = context.resolve(this._packageJson)
const incomingData = await readFile(incomingFile, 'utf8')
const packageData = JSON.parse(incomingData)
// exports must be relative to the _output_ package.json
const outgoingFile = context.resolve(this._outputPackageJson)
const outgoingDirectory = getAbsoluteParent(outgoingFile)
// type here determines the extension of commonjs or ecmascript modules
const type =
packageData.type === 'module' ? 'module' :
packageData.type === 'commonjs' ? 'commonjs' :
packageData.type == null ? 'commonjs' :
undefined
assert(type, `Unknown module type "${packageData.type}" in ${$p(incomingFile)}`)
context.log.debug(`Package file ${$p(incomingFile)} declares module type "${type}"`)
const cjsExtension = this._cjsExtension || (type === 'commonjs' ? '.js' : '.cjs')
const esmExtension = this._esmExtension || (type === 'module' ? '.js' : '.mjs')
// reject when commonjs and ecmascript modules have the same extension
assert(cjsExtension !== esmExtension, `CommonJS and EcmaScript modules both resolve to same extension "${cjsExtension}"`)
const exports: ExportsDeclaration = {}
function addExport(
name: string,
type: 'require' | 'import',
kind: 'types' | 'default',
file: string,
): void {
if (! exports[name]) exports[name] = {}
if (! exports[name]![type]) exports[name]![type] = {}
exports[name]![type]![kind] = file
}
// all extensions to match in the incoming files
const exts = [ '.d.mts', '.d.cts', '.d.ts', cjsExtension, esmExtension ]
// look up all the files we were piped in
for (const [ name, absolute ] of files.pathMappings()) {
const relative = assertRelativeChildPath(outgoingDirectory, absolute)
for (const ext of exts) {
if (! relative.endsWith(ext)) continue
const base = `.${sep}${name.slice(0, -ext.length)}`
const exp = base.endsWith(`${sep}index`) ? base.slice(0, -6) : base
switch (ext) {
case cjsExtension:
addExport(exp, 'require', 'default', `.${sep}${relative}`)
break
case esmExtension:
addExport(exp, 'import', 'default', `.${sep}${relative}`)
break
case '.d.cts':
addExport(exp, 'require', 'types', `.${sep}${relative}`)
break
case '.d.mts':
addExport(exp, 'import', 'types', `.${sep}${relative}`)
break
case '.d.ts':
addExport(exp, 'require', 'types', `.${sep}${relative}`)
addExport(exp, 'import', 'types', `.${sep}${relative}`)
break
}
}
}
// if we have a "." export, inject the "main", "module" and "types" fields
if ('.' in exports) {
const rootExport = exports['.']
packageData['main'] = rootExport?.require?.default
packageData['module'] = rootExport?.import?.default
packageData['types'] = packageData['type'] === 'module' ?
rootExport?.import?.types : rootExport?.require?.types
}
// correctly order the exports record (e.g. types comes before default)
packageData['exports'] = Object.keys(exports).sort().reduce((obj, name) => {
const current = exports[name]
if (! current) return obj
// json serialization will scrub all undefined... here we export the types
// only if the "default" export is available, or we scrub the whole thing!
obj[name] = current.require?.default || current.import?.default ? {
require: current.require?.default ? {
types: current.require.types || undefined,
default: current.require.default || undefined,
} : undefined,
import: current.import?.default ? {
types: current.import.types || undefined,
default: current.import.default || undefined,
} : undefined,
} : undefined
return obj
}, {} as ExportsDeclaration)
// convert back our package data into a json and write it
const outgoingData = JSON.stringify(packageData, null, 2)
context.log.info(`Writing new ${$p(outgoingFile)}`, outgoingData)
await writeFile(outgoingFile, outgoingData + EOL, 'utf8')
// return a `Files` instance with our `package.json` in there
return Files.builder(getAbsoluteParent(outgoingFile)).add(outgoingFile).build()
}
})