@plugjs/plug
Version:
PlugJS Build System ===================
361 lines (301 loc) • 12.5 kB
text/typescript
/* eslint-disable no-fallthrough */
/* eslint-disable no-console */
import _fs from 'node:fs'
import { main, yargsParser } from '@plugjs/tsrun'
import { BuildFailure } from './asserts'
import { runAsync } from './async'
import { invokeTasks, isBuild } from './build'
import { $blu, $gry, $p, $red, $t, $und, $wht } from './logging/colors'
import { logLevels } from './logging/levels'
import { logOptions } from './logging/options'
import { getCurrentWorkingDirectory, resolveAbsolutePath, resolveDirectory, resolveFile } from './paths'
import { Context } from './pipe'
import type { AbsolutePath } from './paths'
import type { Build } from './types'
/* Log levels */
const { TRACE, DEBUG, INFO, NOTICE, WARN, ERROR, OFF } = logLevels
/* Extra colors */
const $bnd = (s: string): string => $blu($und(s))
const $gnd = (s: string): string => $gry($und(s))
const $wnd = (s: string): string => $wht($und(s))
/** Version injected by esbuild, defaulted in case of dynamic transpilation */
const version = typeof __version === 'string' ? __version : '0.0.0-dev'
declare const __version: string | undefined
/* ========================================================================== *
* HELP SCREEN *
* ========================================================================== */
/** Show help screen */
function help(): void {
console.log(`${$bnd('Usage:')}
${$wht('plugjs')} ${$gry('[')}--options${$gry('] [... ')}prop=val${$gry(' ...] [... ')}task${$gry(' ...]')}
${$bnd('Options:')}
${$wht(`-f --file ${$gnd('file')}`)} Specify the build file to use (default ${$wnd('./build.ts')})
${$wht(`-w --watch ${$gnd('dir')}`)} Watch for changes on the specified directory and run
${$wht('-v --verbose')} Increase logging verbosity
${$wht('-q --quiet')} Decrease logging verbosity
${$wht('-c --colors')} Force colorful output (use ${$wnd('--no-colors')} to force plain text)
${$wht('-l --list')} Only list the tasks defined by the build, nothing more!
${$wht('-h --help')} Help! You're reading it now!
${$wht(' --version')} Version! This one: ${version}!
${$bnd('Properties:')}
Any argument in the format ${$wnd('key=value')} will be interpeted as a property to
be injected in the build process (e.g. ${$wnd('mode=production')}).
${$bnd('Tasks:')}
Any other argument will be treated as a task name. If no task names are
specified, the ${$t('default')} task will be executed.
${$bnd('Watch Mode:')}
The ${$wnd('--watch')} option can be specified multiple times, and each single
directory specified will be watched for changes. Note that Plug's own
watch mode is incredibly basic, for more complex scenarios use something
more advanced like nodemon ${$gry('(')}${$gnd('https://www.npmjs.com/package/nodemon')}${$gry(')')}.
${$bnd('TypeScript module format:')}
Normally our TypeScript loader will transpile ${$wnd('.ts')} files to the type
specified in ${$wnd('package.json')}, either ${$wnd('commonjs')} (the default) or ${$wnd('module')}.
To force a specific module format use one of the following flags:
${$wht('--force-esm')} Force transpilation of ${$wnd('.ts')} files to EcmaScript modules
${$wht('--force-cjs')} Force transpilation of ${$wnd('.ts')} files to CommonJS modules
`)
}
/* ========================================================================== *
* PARSE COMMAND LINE ARGUMENTS *
* ========================================================================== */
/* Parsed and normalised command line options */
interface CommandLineOptions {
buildFile: AbsolutePath,
watchDirs: string[],
tasks: string[],
props: Record<string, string>
listOnly: boolean,
}
/** Parse `perocess.argv` and return our normalised command line options */
export function parseCommandLine(args: string[]): CommandLineOptions {
/* Yargs-parse our arguments */
const parsed = yargsParser(args, {
configuration: {
'camel-case-expansion': false,
'strip-aliased': true,
'strip-dashed': true,
},
alias: {
'verbose': [ 'v' ],
'quiet': [ 'q' ],
'colors': [ 'c' ],
'file': [ 'f' ],
'list': [ 'l' ],
'watch': [ 'w' ],
'help': [ 'h' ],
},
string: [ 'file', 'watch' ],
boolean: [ 'help', 'colors', 'list', 'force-esm', 'force-cjs', 'version' ],
count: [ 'verbose', 'quiet' ],
})
/* ======================================================================== *
* NORMALIZE YARGS ARGUMENTS *
* ======================================================================== */
/* Our options */
const tasks: string[] = []
const props: Record<string, string> = {}
const watchDirs: string[] = []
let verbosity = 0 // yargs always returns 0 for count (quiet/verbose)
let colors: boolean | undefined = undefined
let file: string | undefined = undefined
let listOnly = false
/* Switcharoo on arguments */
for (const [ key, value ] of Object.entries(parsed)) {
switch (key) {
case '_': // extra arguments
value.forEach((current: string) => {
const [ key, val ] = current.split(/=(.*)/, 2)
if (key && val) props[key] = val
else tasks.push(current)
})
break
case 'verbose': // increase verbosity
verbosity = verbosity + value
break
case 'quiet': // decrease verbosity
verbosity = verbosity - value
break
case 'file': // build file
file = value
break
case 'watch': // watch directory
if (Array.isArray(value)) watchDirs.push(...value)
else if (value) watchDirs.push(value)
break
case 'colors':
colors = !! value
break
case 'list':
listOnly = !! value
break
case 'help':
help()
process.exit(0)
case 'version':
console.log(`PlugJS ${$gry('ver.')} ${$wnd(version)}`)
process.exit(0)
default:
console.log(`Unsupported option ${$wnd(key)} (try ${$wnd('--help')})`)
process.exit(1)
}
}
/* ======================================================================== *
* LOG OPTIONS AS ENVIRONMENT VARIABLES *
* ======================================================================== */
/* Log colors, overriding our LOG_COLORS environment variable */
if (colors !== undefined) logOptions.colors = colors
/* Log level (from verbosity) overriding LOG_LEVEL */
if (verbosity) {
const levels = [ TRACE, DEBUG, INFO, NOTICE, WARN, ERROR, OFF ]
let level = levels.indexOf(logLevels.NOTICE) - verbosity
if (level >= levels.length) level = levels.length - 1
else if (level < 0) level = 0
logOptions.level = levels[level]!
}
/* ======================================================================== *
* BUILD FILE RESOLUTION *
* ======================================================================== */
/* Find our build file */
const cwd = getCurrentWorkingDirectory()
const exts = [ 'ts', 'mts', 'mjs', 'js', 'mjs', 'cjs' ]
let buildFile: AbsolutePath | undefined = undefined
if (file) {
const absolute = resolveFile(cwd, file)
if (! absolute) {
console.log(`Specified build file "${file}" was not found`)
process.exit(1)
} else {
buildFile = absolute
}
} else {
for (const ext of exts) {
const absolute = resolveFile(cwd, `build.${ext}`)
if (! absolute) continue
buildFile = absolute
break
}
}
/* Final check */
if (! buildFile) {
console.log(`${$red('Unable to find build file')} ${$wht(`./build.[${exts.join('|')}]`)}`)
process.exit(1)
}
/* ======================================================================== *
* WATCH MODE *
* ======================================================================== */
watchDirs.forEach((watchDir) => {
const absolute = resolveDirectory(cwd, watchDir)
if (! absolute) {
const path = resolveAbsolutePath(cwd, watchDir)
console.log(`Specified watch directory "${$p(path)}" was not found`)
process.exit(1)
} else {
watchDir = absolute
}
})
/* ======================================================================== *
* ALL DONE *
* ======================================================================== */
return { buildFile, watchDirs, tasks, props, listOnly }
}
/* ========================================================================== *
* MAIN ENTRY POINT *
* ========================================================================== */
main(import.meta.url, async (args: string[]): Promise<void> => {
// Parse and destructure command line
const {
buildFile,
watchDirs,
tasks,
props,
listOnly,
} = parseCommandLine(args)
// Default task if none specified
if (tasks.length === 0) tasks.push('default')
// Import and check build file
const initialContext = new Context(buildFile, '')
const maybeBuild = await runAsync(initialContext, async (): Promise<Build | void> => {
let maybeBuild = await import(buildFile)
while (maybeBuild) {
if (isBuild(maybeBuild)) return maybeBuild
maybeBuild = maybeBuild.default
}
})
// We _need_ a build
if (! isBuild(maybeBuild)) {
console.log($red('Build file did not export a proper build'))
console.log()
console.log('- If using CommonJS export your build as "module.exports"')
console.log(` e.g.: ${$wht('module.exports = build({ ... })')}`)
console.log()
console.log('- If using ESM modules export your build as "default"')
console.log(` e.g.: ${$wht('export default build({ ... })')}`)
console.log()
process.exit(1)
}
const build = maybeBuild
// List tasks
if (listOnly) {
const taskNames: string[] = []
const propNames: string[] = []
for (const [ key, value ] of Object.entries(build)) {
(typeof value === 'string' ? propNames : taskNames).push(key)
}
console.log(`\n${$gry('Outline of')} ${$p(buildFile)}`)
console.log('\nKnown tasks:\n')
for (const taskName of taskNames.sort()) {
console.log(` ${$gry('\u25a0')} ${$t(taskName)}`)
}
console.log('\nKnown properties:\n')
for (const propName of propNames.sort()) {
const value = build[propName] ?
` ${$gry('(default')} ${$und(build[propName])}${$gry(')')}` : ''
console.log(` ${$gry('\u25a1')} ${$blu(propName)}${value}`)
}
console.log()
return
}
// Watch directories
if (watchDirs.length) {
return new Promise((_, reject) => {
// filesystems change trigger a new run after 250 ms a change is detected,
// in order to give time to editors to save a bunch of files open and
// modified at the same time...
let timeout: NodeJS.Timeout | undefined = undefined
// our runner executed by the timeout
const runme = (): void => {
invokeTasks(build, tasks, props)
.then(() => {
console.log(`\n${$gry('Watching for files change...')}\n`)
}, (error) => {
if (error instanceof BuildFailure) {
console.log(`\n${$gry('Watching for files change...')}\n`)
} else {
watchers.forEach((watcher) => watcher.close())
reject(error)
}
})
.finally(() => {
timeout = undefined
})
}
// watch all directories and trigger a run after 250 milliseconds
const watchers = watchDirs.map((watchDir) => {
return _fs.watch(watchDir, { recursive: true }, () => {
if (! timeout) timeout = setTimeout(runme, 250)
})
})
// start a build immediately on first run
runme()
})
}
// Normal build (no list, no watchers)
try {
await invokeTasks(build, tasks, props)
} catch (error) {
if (!(error instanceof BuildFailure)) console.log(error)
process.exitCode = 1
}
})