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

feat: auto config preset #30

Merged
merged 9 commits into from
Dec 10, 2021
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
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