diff --git a/.eslintrc.js b/.eslintrc.js index 3268c29..d7b488a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,6 +5,9 @@ module.exports = { 'object-curly-newline': ['error', { multiline: true, consistent: true }], semi: ['error', 'never'] }, + ignorePatterns: [ + 'index.d.ts' + ], env: { jest: true } diff --git a/index.d.ts b/index.d.ts index eaff965..092e946 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,62 +1,76 @@ -import rollup from 'rollup'; -import fs from 'fs-extra'; -import globby from 'globby'; - -interface Target extends globby.GlobbyOptions { - /** - * Path or glob of what to copy. - */ - readonly src: string | readonly string[]; - - /** - * One or more destinations where to copy. - */ - readonly dest: string | readonly string[]; - - /** - * Change destination file or folder name. - */ - readonly rename?: string | Function; - - /** - * Modify file contents. - */ - readonly transform?: Function; +import rollup from 'rollup' +import fs from 'fs-extra' +import globby from 'globby' + + +export interface Target extends globby.GlobbyOptions { + /** + * Path or glob of what to copy. + */ + readonly src: string | readonly string[] + + /** + * One or more destinations where to copy. + */ + readonly dest: string | readonly string[] + + /** + * Change destination file or folder name. + */ + readonly rename?: string | ((fileName: string, fileExt: string) => string) + /** + * Modify file contents. + */ + readonly transform?: ( + content: string | ArrayBuffer, + srcPath: string, + destPath: string + ) => string | ArrayBuffer } -interface CopyOptions extends globby.GlobbyOptions, fs.CopyOptions { - /** - * Copy items once. Useful in watch mode. - * @default false - */ - readonly copyOnce?: boolean; - - /** - * Remove the directory structure of copied files. - * @default true - */ - readonly flatten?: boolean; - - /** - * Rollup hook the plugin should use. - * @default 'buildEnd' - */ - readonly hook?: string; - - /** - * Array of targets to copy. - * @default [] - */ - readonly targets?: readonly Target[]; - - /** - * Output copied items to console. - * @default false - */ - readonly verbose?: boolean; + +export interface CopyOptions extends globby.GlobbyOptions, fs.CopyOptions { + /** + * Copy items once. Useful in watch mode. + * @default false + */ + readonly copyOnce?: boolean + + /** + * Remove the directory structure of copied files. + * @default true + */ + readonly flatten?: boolean + + /** + * Rollup hook the plugin should use. + * @default 'buildEnd' + */ + readonly hook?: string + + /** + * Rollup hook the `this.addWatchFile` should call, only be used in hooks + * during the build phase, and must be processed earlier than hook + * @default 'buildStart' + * @see https://rollupjs.org/guide/en/#thisaddwatchfileid-string--void + */ + readonly watchStart?: 'buildStart' | 'load' | 'resolveId' | 'transform' | string + + /** + * Array of targets to copy. + * @default [] + */ + readonly targets?: readonly Target[] + + /** + * Output copied items to console. + * @default false + */ + readonly verbose?: boolean } + /** * Copy files and folders using Rollup */ -export default function copy(options?: CopyOptions): rollup.Plugin; +export default function copy(options?: CopyOptions): rollup.Plugin diff --git a/readme.md b/readme.md index 9bf965b..7928f62 100644 --- a/readme.md +++ b/readme.md @@ -142,7 +142,12 @@ copy({ targets: [{ src: 'src/index.html', dest: 'dist/public', - transform: (contents) => contents.toString().replace('__SCRIPT__', 'app.js') + transform: (contents, srcPath, destPath) => ( + contents.toString() + .replace('__SCRIPT__', 'app.js') + .replace('__SOURCE_FILE_PATH__', srcPath) + .replace('__TARGET_FILE_NAME__', path.basename(srcPath)) + ) }] }) ``` @@ -173,6 +178,21 @@ copy({ }) ``` +#### watchHook + +Type: `string` | Default: `buildStart` + +[Rollup hook](https://rollupjs.org/guide/en/#hooks) the [this.addWatchFile](https://rollupjs.org/guide/en/#thisaddwatchfileid-string--void) should call. By default, `addWatchFile` called on each rollup.rollup build. +Only be used in hooks during the build phase, i.e. in `buildStart`, `load`, `resolveId`, and `transform`. + +```js +copy({ + targets: [{ src: 'assets/*', dest: 'dist/public' }], + watchHook: 'resolveId' +}) +``` + + #### copyOnce Type: `boolean` | Default: `false` diff --git a/src/index.js b/src/index.js index a720841..6f0c463 100644 --- a/src/index.js +++ b/src/index.js @@ -6,25 +6,43 @@ import isObject from 'is-plain-object' import globby from 'globby' import { bold, green, yellow } from 'colorette' + function stringify(value) { return util.inspect(value, { breakLength: Infinity }) } + async function isFile(filePath) { const fileStats = await fs.stat(filePath) - return fileStats.isFile() } -function renameTarget(target, rename) { - const parsedPath = path.parse(target) - return typeof rename === 'string' - ? rename - : rename(parsedPath.name, parsedPath.ext.replace('.', '')) +/** + * @param {string} targetFilePath + * @param {string|(fileName: string, fileExt: string): string} rename + */ + +function renameTarget(targetFilePath, rename) { + const parsedPath = path.parse(targetFilePath) + if (typeof rename === 'string') return rename + return rename(parsedPath.name, parsedPath.ext.replace(/^(\.)?/, '')) } -async function generateCopyTarget(src, dest, { flatten, rename, transform }) { + +/** + * @param {string} src + * @param {string} dest + * @param {boolean} options.flatten + * @param {string|((fileName: string, fileExt: string) => string)} options.rename + * @param {( + * content: string|ArrayBuffer, + * srcPath: string, + * destPath: string + * ): string|ArrayBuffer} options.transform + */ +async function generateCopyTarget(src, dest, options) { + const { flatten, rename, transform } = options if (transform && !await isFile(src)) { throw new Error(`"transform" option works only on files: '${src}' must be a file`) } @@ -34,107 +52,164 @@ async function generateCopyTarget(src, dest, { flatten, rename, transform }) { ? dest : dir.replace(dir.split('/')[0], dest) - return { + const destFilePath = path.join(destinationFolder, rename ? renameTarget(base, rename) : base) + const result = { src, - dest: path.join(destinationFolder, rename ? renameTarget(base, rename) : base), - ...(transform && { contents: await transform(await fs.readFile(src)) }), - renamed: rename, - transformed: transform + dest: destFilePath, + renamed: Boolean(rename), + transformed: false } + + if (transform) { + result.contents = await transform(await fs.readFile(src), src, destFilePath) + result.transformed = true + } + return result } + export default function copy(options = {}) { const { copyOnce = false, flatten = true, hook = 'buildEnd', + watchHook = 'buildStart', targets = [], - verbose = false, + verbose: shouldBeVerbose = false, ...restPluginOptions } = options - let copied = false - - return { - name: 'copy', - [hook]: async () => { - if (copyOnce && copied) { - return + const log = { + /** + * print verbose messages + * @param {string|() => string} message + */ + verbose(message) { + if (!shouldBeVerbose) return + if (typeof message === 'function') { + // eslint-disable-next-line no-param-reassign + message = message() } + console.log(message) + } + } - const copyTargets = [] + let copied = false + let copyTargets = [] - if (Array.isArray(targets) && targets.length) { - for (const target of targets) { - if (!isObject(target)) { - throw new Error(`${stringify(target)} target must be an object`) - } + async function collectAndWatchingTargets() { + const self = this + if (copyOnce && copied) { + return + } - const { dest, rename, src, transform, ...restTargetOptions } = target + // Recollect copyTargets + copyTargets = [] + if (Array.isArray(targets) && targets.length) { + for (const target of targets) { + if (!isObject(target)) { + throw new Error(`${stringify(target)} target must be an object`) + } - if (!src || !dest) { - throw new Error(`${stringify(target)} target must have "src" and "dest" properties`) - } + const { dest, rename, src, transform, ...restTargetOptions } = target - if (rename && typeof rename !== 'string' && typeof rename !== 'function') { - throw new Error(`${stringify(target)} target's "rename" property must be a string or a function`) - } + if (!src || !dest) { + throw new Error(`${stringify(target)} target must have "src" and "dest" properties`) + } - const matchedPaths = await globby(src, { - expandDirectories: false, - onlyFiles: false, - ...restPluginOptions, - ...restTargetOptions - }) - - if (matchedPaths.length) { - for (const matchedPath of matchedPaths) { - const generatedCopyTargets = Array.isArray(dest) - ? await Promise.all(dest.map((destination) => generateCopyTarget( - matchedPath, - destination, - { flatten, rename, transform } - ))) - : [await generateCopyTarget(matchedPath, dest, { flatten, rename, transform })] - - copyTargets.push(...generatedCopyTargets) - } + if (rename && typeof rename !== 'string' && typeof rename !== 'function') { + throw new Error(`${stringify(target)} target's "rename" property must be a string or a function`) + } + + const matchedPaths = await globby(src, { + expandDirectories: false, + onlyFiles: false, + ...restPluginOptions, + ...restTargetOptions + }) + + if (matchedPaths.length) { + for (const matchedPath of matchedPaths) { + const destinations = Array.isArray(dest) ? dest : [dest] + const generatedCopyTargets = await Promise.all( + destinations.map((destination) => generateCopyTarget( + matchedPath, + destination, + { flatten, rename, transform } + )) + ) + copyTargets.push(...generatedCopyTargets) } } } + } - if (copyTargets.length) { - if (verbose) { - console.log(green('copied:')) - } + /** + * Watching source files + */ + for (const target of copyTargets) { + const srcPath = path.resolve(target.src) + self.addWatchFile(srcPath) + } + } - for (const copyTarget of copyTargets) { - const { contents, dest, src, transformed } = copyTarget + /** + * Do copy operation + */ + async function handleCopy() { + if (copyOnce && copied) { + return + } - if (transformed) { - await fs.outputFile(dest, contents, restPluginOptions) - } else { - await fs.copy(src, dest, restPluginOptions) - } + if (copyTargets.length) { + log.verbose(green('copied:')) - if (verbose) { - let message = green(` ${bold(src)} → ${bold(dest)}`) - const flags = Object.entries(copyTarget) - .filter(([key, value]) => ['renamed', 'transformed'].includes(key) && value) - .map(([key]) => key.charAt(0).toUpperCase()) + for (const copyTarget of copyTargets) { + const { contents, dest, src, transformed } = copyTarget + + if (transformed) { + await fs.outputFile(dest, contents, restPluginOptions) + } else { + await fs.copy(src, dest, restPluginOptions) + } - if (flags.length) { - message = `${message} ${yellow(`[${flags.join(', ')}]`)}` - } + log.verbose(() => { + let message = green(` ${bold(src)} → ${bold(dest)}`) + const flags = Object.entries(copyTarget) + .filter(([key, value]) => ['renamed', 'transformed'].includes(key) && value) + .map(([key]) => key.charAt(0).toUpperCase()) - console.log(message) + if (flags.length) { + message = `${message} ${yellow(`[${flags.join(', ')}]`)}` } - } - } else if (verbose) { - console.log(yellow('no items to copy')) + + return message + }) } + } else { + log.verbose(yellow('no items to copy')) + } + + copied = true + } - copied = true + const plugin = { + name: 'copy', + async [watchHook](...args) { + const self = this + await collectAndWatchingTargets.call(self, ...args) + + /** + * Merge handleCopy and collectAndWatchingTargets + */ + if (hook === watchHook) { + await handleCopy.call(self, ...args) + } } } + + if (hook !== watchHook) { + plugin[hook] = handleCopy + } + return plugin }