Skip to content

Commit

Permalink
feat: auto config preset (#30)
Browse files Browse the repository at this point in the history
Co-authored-by: Pooya Parsa <pyapar@gmail.com>
  • Loading branch information
danielroe and pi0 authored Dec 10, 2021
1 parent dbc0b9c commit fe1ac94
Show file tree
Hide file tree
Showing 13 changed files with 736 additions and 71 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ jobs:
- uses: actions/setup-node@v2
with:
node-version: 16
cache: 'yarn'
cache: "yarn"
- run: yarn install
- run: yarn lint
- run: yarn build
- run: yarn test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ coverage
dist
types
.gen
.nyc_output
16 changes: 0 additions & 16 deletions build.config.ts

This file was deleted.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"unbuild": "jiti ./src/cli",
"prepack": "yarn build",
"release": "yarn test && standard-version && git push --follow-tags && npm publish",
"test": "yarn lint"
"test": "mocha -r jiti/register ./test/*.test.*"
},
"dependencies": {
"@rollup/plugin-alias": "^3.1.8",
Expand Down Expand Up @@ -53,11 +53,15 @@
},
"devDependencies": {
"@nuxtjs/eslint-config-typescript": "latest",
"@types/chai": "latest",
"@types/mkdirp": "latest",
"@types/mocha": "latest",
"@types/mri": "latest",
"@types/node": "latest",
"@types/rimraf": "latest",
"chai": "latest",
"eslint": "latest",
"mocha": "latest",
"standard-version": "latest"
}
}
179 changes: 179 additions & 0 deletions src/auto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { normalize, join } from 'pathe'
import consola from 'consola'
import chalk from 'chalk'
import type { PackageJson } from 'pkg-types'
import { listRecursively } from './utils'
import { BuildEntry, definePreset, MkdistBuildEntry } from './types'

type OutputDescriptor = { file: string, type?: 'esm' | 'cjs' }
type InferEntriesResult = { entries: BuildEntry[], cjs?: boolean, dts?: boolean }

export const autoPreset = definePreset(() => {
return {
hooks: {
'build:prepare' (ctx) {
// Disable auto if entries already provided of pkg not available
if (!ctx.pkg || ctx.options.entries.length) {
return
}
const sourceFiles = listRecursively(join(ctx.options.rootDir, 'src'))
const res = inferEntries(ctx.pkg, sourceFiles)
ctx.options.entries.push(...res.entries)
if (res.cjs) {
ctx.options.rollup.emitCJS = true
}
if (res.dts) {
ctx.options.declaration = res.dts
}
consola.info(
'Automatically detected entries:',
chalk.cyan(ctx.options.entries.map(e => chalk.bold(e.input.replace(ctx.options.rootDir + '/', '').replace(/\/$/, '/*'))).join(', ')),
chalk.gray(['esm', res.cjs && 'cjs', res.dts && 'dts'].filter(Boolean).map(tag => `[${tag}]`).join(' '))
)
}
}
}
})

/**
* @param {PackageJson} pkg The contents of a package.json file to serve as the source for inferred entries.
* @param {string | string[]} source The root directory of the project.
* - if string, `<source>/src` will be scanned for possible source files.
* - if an array of source files, these will be used directly instead of accessing fs.
*/
export function inferEntries (pkg: PackageJson, sourceFiles: string[]): InferEntriesResult {
// Come up with a list of all output files & their formats
const outputs: OutputDescriptor[] = extractExportFilenames(pkg.exports)

if (pkg.bin) {
const binaries = typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin)
for (const file of binaries) {
outputs.push({ file })
}
}
if (pkg.main) {
outputs.push({ file: pkg.main })
}
if (pkg.module) {
outputs.push({ type: 'esm', file: pkg.module })
}
if (pkg.types || pkg.typings) {
outputs.push({ file: pkg.types || pkg.typings! })
}

// Try to detect output types
const isESMPkg = pkg.type === 'module'
for (const output of outputs.filter(o => !o.type)) {
const isJS = output.file.endsWith('.js')
if ((isESMPkg && isJS) || output.file.endsWith('.mjs')) {
output.type = 'esm'
} else if ((!isESMPkg && isJS) || output.file.endsWith('.cjs')) {
output.type = 'cjs'
}
}

let cjs = false
let dts = false

// Infer entries from package files
const entries: BuildEntry[] = []
for (const output of outputs) {
// Supported output file extensions are `.d.ts`, `.cjs` and `.mjs`
// But we support any file extension here in case user has extended rollup options
const outputSlug = output.file.replace(/(\*[^\\/]*|\.d\.ts|\.\w+)$/, '')
const isDir = outputSlug.endsWith('/')

// Skip top level directory
if (isDir && ['./', '/'].includes(outputSlug)) { continue }

const possiblePaths = getEntrypointPaths(outputSlug)
const input = possiblePaths.reduce<string | undefined>((source, d) => {
if (source) { return source }
const SOURCE_RE = new RegExp(`${d}${isDir ? '' : '\\.\\w+'}$`)
return sourceFiles.find(i => i.match(SOURCE_RE))?.replace(/(\.d\.ts|\.\w+)$/, '')
}, undefined)

if (!input) {
consola.warn(`could not infer entrypoint for \`${output.file}\``)
continue
}

if (output.type === 'cjs') {
cjs = true
}

const entry = entries.find(i => i.input === input) || entries[entries.push({ input }) - 1]

if (output.file.endsWith('.d.ts')) {
dts = true
}

if (isDir) {
entry.outDir = outputSlug
;(entry as MkdistBuildEntry).format = output.type
}
}

return { entries, cjs, dts }
}

export function inferExportType (condition: string, previousConditions: string[] = [], filename = ''): 'esm' | 'cjs' {
if (filename) {
if (filename.endsWith('.d.ts')) {
return 'esm'
}
if (filename.endsWith('.mjs')) {
return 'esm'
}
if (filename.endsWith('.cjs')) {
return 'cjs'
}
}
switch (condition) {
case 'import':
return 'esm'
case 'require':
return 'cjs'
default: {
if (!previousConditions.length) {
// TODO: Check against type:module for default
return 'esm'
}
const [newCondition, ...rest] = previousConditions
return inferExportType(newCondition, rest, filename)
}
}
}

export function extractExportFilenames (exports: PackageJson['exports'], conditions: string[] = []): OutputDescriptor[] {
if (!exports) { return [] }
if (typeof exports === 'string') {
return [{ file: exports, type: 'esm' }]
}
return Object.entries(exports).flatMap(
([condition, exports]) => typeof exports === 'string'
? { file: exports, type: inferExportType(condition, conditions, exports) }
: extractExportFilenames(exports, [...conditions, condition])
)
}

export const getEntrypointPaths = (path: string) => {
const segments = normalize(path).split('/')
return segments.map((_, index) => segments.slice(index).join('/')).filter(Boolean)
}

export const getEntrypointFilenames = (path: string, supportedExtensions = ['.ts', '.mjs', '.cjs', '.js', '.json']) => {
if (path.startsWith('./')) { path = path.slice(2) }

const filenames = getEntrypointPaths(path).flatMap((path) => {
const basefile = path.replace(/\.\w+$/, '')
return [
basefile,
`${basefile}/index`
]
})

filenames.push('index')

return filenames.flatMap(name => supportedExtensions.map(ext => `${name}${ext}`))
}
15 changes: 8 additions & 7 deletions src/build.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import Module from 'module'
import { resolve, basename } from 'pathe'
import type { PackageJson } from 'pkg-types'
import chalk from 'chalk'
import consola from 'consola'
import defu from 'defu'
import { createHooks } from 'hookable'
import prettyBytes from 'pretty-bytes'
import mkdirp from 'mkdirp'
import { dumpObject, rmdir, tryRequire } from './utils'
import { dumpObject, rmdir, tryRequire, resolvePreset } from './utils'
import type { BuildContext, BuildConfig, BuildOptions } from './types'
import { validateDependencies } from './validate'
import { rollupBuild } from './builder/rollup'
Expand All @@ -19,13 +20,10 @@ export async function build (rootDir: string, stub: boolean, inputConfig: BuildC

// Read build.config and package.json
const buildConfig: BuildConfig = tryRequire('./build.config', rootDir) || {}
const pkg = tryRequire('./package.json', rootDir)
const pkg: PackageJson & Record<'unbuild' | 'build', BuildConfig> = tryRequire('./package.json', rootDir)

// Resolve preset
let preset = buildConfig.preset || pkg.unbuild?.preset || pkg.build?.preset || inputConfig.preset || {}
if (typeof preset === 'string') {
preset = tryRequire(preset, rootDir)
}
const preset = resolvePreset(buildConfig.preset || pkg.unbuild?.preset || pkg.build?.preset || inputConfig.preset || 'auto', rootDir)

// Merge options
const options = defu(buildConfig, pkg.unbuild || pkg.build, inputConfig, preset, <BuildOptions>{
Expand All @@ -40,7 +38,7 @@ export async function build (rootDir: string, stub: boolean, inputConfig: BuildC
devDependencies: [],
peerDependencies: [],
rollup: {
emitCJS: true,
emitCJS: false,
cjsBridge: false,
inlineDependencies: false
}
Expand Down Expand Up @@ -69,6 +67,9 @@ export async function build (rootDir: string, stub: boolean, inputConfig: BuildC
ctx.hooks.addHooks(buildConfig.hooks)
}

// Allow prepare and extending context
await ctx.hooks.callHook('build:prepare', ctx)

// Normalize entries
options.entries = options.entries.map(entry =>
typeof entry === 'string' ? { input: entry } : entry
Expand Down
14 changes: 11 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable no-use-before-define */
import type { PackageJson } from 'pkg-types'
import type { Hookable } from 'hookable'
import type { RollupOptions, RollupBuild } from 'rollup'
Expand Down Expand Up @@ -54,13 +55,15 @@ export interface BuildContext {
pkg: PackageJson,
buildEntries: { path: string, bytes?: number, exports?: string[], chunks?: string[] }[]
usedImports: Set<string>
hooks: Hookable<BuildHooks> // eslint-disable-line no-use-before-define
hooks: Hookable<BuildHooks>
}

export type BuildPreset = BuildConfig | (() => BuildConfig)

export interface BuildConfig extends Partial<Omit<BuildOptions, 'entries'>> {
entries?: (BuildEntry | string)[]
preset?: string | BuildConfig
hooks?: Partial<BuildHooks> // eslint-disable-line no-use-before-define
preset?: string | BuildPreset
hooks?: Partial<BuildHooks>
}

export interface UntypedOutput { fileName: string, contents: string }
Expand All @@ -72,6 +75,7 @@ export interface UntypedOutputs {
}

export interface BuildHooks {
'build:prepare': (ctx: BuildContext) => void | Promise<void>
'build:before': (ctx: BuildContext) => void | Promise<void>
'build:done': (ctx: BuildContext) => void | Promise<void>

Expand All @@ -96,3 +100,7 @@ export interface BuildHooks {
export function defineBuildConfig (config: BuildConfig): BuildConfig {
return config
}

export function definePreset (preset: BuildPreset): BuildPreset {
return preset
}
35 changes: 34 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import fsp from 'fs/promises'
import { promisify } from 'util'
import { dirname } from 'pathe'
import { readdirSync, statSync } from 'fs'
import { dirname, resolve } from 'pathe'
import mkdirp from 'mkdirp'
import _rimraf from 'rimraf'
import jiti from 'jiti'
import { autoPreset } from './auto'
import type { BuildPreset, BuildConfig } from './types'

export async function ensuredir (path: string) {
await mkdirp(dirname(path))
Expand Down Expand Up @@ -33,6 +36,24 @@ export async function rmdir (dir: string) {
await rimraf(dir)
}

export function listRecursively (path: string) {
const filenames = new Set<string>()
const walk = (path: string) => {
const files = readdirSync(path)
for (const file of files) {
const fullPath = resolve(path, file)
if (statSync(fullPath).isDirectory()) {
filenames.add(fullPath + '/')
walk(fullPath)
} else {
filenames.add(fullPath)
}
}
}
walk(path)
return Array.from(filenames)
}

export function tryRequire (id: string, rootDir: string = process.cwd()) {
const _require = jiti(rootDir, { interopDefault: true })
try {
Expand All @@ -56,3 +77,15 @@ export function tryResolve (id: string, rootDir: string = process.cwd()) {
return id
}
}

export function resolvePreset (preset: string | BuildPreset, rootDir: string): BuildConfig {
if (preset === 'auto') {
preset = autoPreset
} else if (typeof preset === 'string') {
preset = tryRequire(preset, rootDir) || {}
}
if (typeof preset === 'function') {
preset = preset()
}
return preset as BuildConfig
}
Loading

0 comments on commit fe1ac94

Please sign in to comment.