diff --git a/package.json b/package.json index 4d67cb8..1e70db2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@types/fs-extra": "^8.0.0", + "chokidar": "^3.3.0", "colorette": "^1.1.0", "fs-extra": "^8.1.0", "globby": "10.0.1", diff --git a/src/index.js b/src/index.js index e0deeaf..b23f4bc 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ import fs from 'fs-extra' import isObject from 'is-plain-object' import globby from 'globby' import { bold, green, yellow } from 'colorette' +import chokidar from 'chokidar' function stringify(value) { return util.inspect(value, { breakLength: Infinity }) @@ -27,6 +28,63 @@ function generateCopyTarget(src, dest, rename) { } } +function generateCopyTargets(src, dest, rename) { + return Array.isArray(dest) + ? dest.map(destination => generateCopyTarget(src, destination, rename)) + : [generateCopyTarget(src, dest, rename)] +} + +async function copyFiles(copyTargets, verbose, copyOptions) { + if (Array.isArray(copyTargets) && copyTargets.length) { + if (verbose) { + console.log(green('copied:')) + } + + for (const { src, dest } of copyTargets) { + await fs.copy(src, dest, copyOptions) + + if (verbose) { + console.log(green(` ${bold(src)} → ${bold(dest)}`)) + } + } + } else if (verbose) { + console.log(yellow('no items to copy')) + } +} + +function watchFiles(targets, verbose, copyOptions) { + return targets.map(({ src, dest, rename }) => { + async function onChange(matchedPath) { + const copyTargets = generateCopyTargets(matchedPath, dest, rename) + await copyFiles(copyTargets, verbose, copyOptions) + } + + return chokidar.watch(src, { ignoreInitial: true }) + .on('change', onChange) + .on('add', onChange) + }) +} + +function verifyTargets(targets) { + if (Array.isArray(targets) && targets.length) { + for (const target of targets) { + if (!isObject(target)) { + throw new Error(`${stringify(target)} target must be an object`) + } + + const { src, dest, rename } = target + + if (!src || !dest) { + throw new Error(`${stringify(target)} target must have "src" and "dest" properties`) + } + + if (rename && typeof rename !== 'string' && typeof rename !== 'function') { + throw new Error(`${stringify(target)} target's "rename" property must be a string or a function`) + } + } + } +} + export default function copy(options = {}) { const { copyOnce = false, @@ -37,6 +95,9 @@ export default function copy(options = {}) { } = options let copied = false + let watchers = [] + + verifyTargets(targets) return { name: 'copy', @@ -49,20 +110,8 @@ export default function copy(options = {}) { if (Array.isArray(targets) && targets.length) { for (const target of targets) { - if (!isObject(target)) { - throw new Error(`${stringify(target)} target must be an object`) - } - const { src, dest, rename, ...restTargetOptions } = target - if (!src || !dest) { - throw new Error(`${stringify(target)} target must have "src" and "dest" properties`) - } - - 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, @@ -72,33 +121,22 @@ export default function copy(options = {}) { if (matchedPaths.length) { matchedPaths.forEach((matchedPath) => { - const generatedCopyTargets = Array.isArray(dest) - ? dest.map(destination => generateCopyTarget(matchedPath, destination, rename)) - : [generateCopyTarget(matchedPath, dest, rename)] - - copyTargets.push(...generatedCopyTargets) + copyTargets.push(...generateCopyTargets(matchedPath, dest, rename)) }) } } } - if (copyTargets.length) { - if (verbose) { - console.log(green('copied:')) - } - - for (const { src, dest } of copyTargets) { - await fs.copy(src, dest, restPluginOptions) + await copyFiles(copyTargets, verbose, restPluginOptions) - if (verbose) { - console.log(green(` ${bold(src)} → ${bold(dest)}`)) - } - } - } else if (verbose) { - console.log(yellow('no items to copy')) + if (!copied && !copyOnce && process.env.ROLLUP_WATCH === 'true') { + watchers = watchFiles(targets, verbose, restPluginOptions) } copied = true + }, + _closeWatchers: async () => { // For unit tests + await Promise.all(watchers.map(watcher => watcher.close())) } } } diff --git a/tests/index.test.js b/tests/index.test.js index b5f08df..1a3a592 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -2,6 +2,7 @@ import { rollup, watch } from 'rollup' import fs from 'fs-extra' import replace from 'replace-in-file' import { bold, yellow, green } from 'colorette' +import { join } from 'path' import copy from '../src' process.chdir(`${__dirname}/fixtures`) @@ -16,12 +17,14 @@ afterEach(async () => { }) async function build(options) { + const copyPlugin = copy(options) await rollup({ input: 'src/index.js', plugins: [ - copy(options) + copyPlugin ] }) + return copyPlugin } describe('Copy', () => { @@ -230,6 +233,137 @@ describe('Copy', () => { }) }) +describe('Watching', () => { + test('Does not watch target files when watch mode disabled', async () => { + await build({ + targets: [ + { src: 'src/assets/asset-1.js', dest: 'dist' } + ] + }) + + expect(await fs.pathExists('dist/asset-1.js')).toBe(true) + await fs.remove('dist') + expect(await fs.pathExists('dist/asset-1.js')).toBe(false) + + await replace({ + files: 'src/assets/asset-1.js', + from: 'asset1', + to: 'assetX' + }) + + await sleep(1000) + + expect(await fs.pathExists('dist/asset-1.js')).toBe(false) + + await replace({ + files: 'src/assets/asset-1.js', + from: 'assetX', + to: 'asset1' + }) + }) + + test('Does not watch target files when watch mode and copyOnce enabled', async () => { + process.env.ROLLUP_WATCH = 'true' + await build({ + targets: [ + { src: 'src/assets/asset-1.js', dest: 'dist' } + ], + copyOnce: true + }) + delete process.env.ROLLUP_WATCH + + expect(await fs.pathExists('dist/asset-1.js')).toBe(true) + await fs.remove('dist') + expect(await fs.pathExists('dist/asset-1.js')).toBe(false) + + await replace({ + files: 'src/assets/asset-1.js', + from: 'asset1', + to: 'assetX' + }) + + await sleep(1000) + + expect(await fs.pathExists('dist/asset-1.js')).toBe(false) + + await replace({ + files: 'src/assets/asset-1.js', + from: 'assetX', + to: 'asset1' + }) + }) + + test('Watches target files when watch mode enabled', async () => { + process.env.ROLLUP_WATCH = 'true' + const copyPlugin = await build({ + targets: [ + { src: 'src/assets/asset-1.js', dest: 'dist' } + ] + }) + delete process.env.ROLLUP_WATCH + + expect(await fs.pathExists('dist/asset-1.js')).toBe(true) + await fs.remove('dist') + expect(await fs.pathExists('dist/asset-1.js')).toBe(false) + + await replace({ + files: 'src/assets/asset-1.js', + from: 'asset1', + to: 'assetX' + }) + + await sleep(1000) + + expect(await fs.pathExists('dist/asset-1.js')).toBe(true) + + // eslint-disable-next-line no-underscore-dangle + await copyPlugin._closeWatchers() + + await replace({ + files: 'src/assets/asset-1.js', + from: 'assetX', + to: 'asset1' + }) + }) + + test('Watches and copies multiple targets from same file', async () => { + process.env.ROLLUP_WATCH = 'true' + const copyPlugin = await build({ + targets: [ + { src: 'src/assets/asset-1.js', dest: 'dist' }, + { src: 'src/assets/asset-1.js', dest: 'dist/2' } + ] + }) + delete process.env.ROLLUP_WATCH + + expect(await fs.pathExists('dist/asset-1.js')).toBe(true) + expect(await fs.pathExists('dist/2/asset-1.js')).toBe(true) + await fs.remove('dist') + expect(await fs.pathExists('dist/asset-1.js')).toBe(false) + expect(await fs.pathExists('dist/2/asset-1.js')).toBe(false) + + await replace({ + files: 'src/assets/asset-1.js', + from: 'asset1', + to: 'assetX' + }) + + await sleep(1000) + + expect(await fs.pathExists('dist/asset-1.js')).toBe(true) + expect(await fs.pathExists('dist/2/asset-1.js')).toBe(true) + + // eslint-disable-next-line no-underscore-dangle + await copyPlugin._closeWatchers() + + await replace({ + files: 'src/assets/asset-1.js', + from: 'assetX', + to: 'asset1' + }) + }) +}) + describe('Options', () => { /* eslint-disable no-console */ test('Verbose', async () => { @@ -251,16 +385,16 @@ describe('Options', () => { expect(console.log).toHaveBeenCalledTimes(5) expect(console.log).toHaveBeenCalledWith(green('copied:')) expect(console.log).toHaveBeenCalledWith( - green(` ${bold('src/assets/asset-1.js')} → ${bold('dist/asset-1.js')}`) + green(` ${bold('src/assets/asset-1.js')} → ${bold(join('dist', 'asset-1.js'))}`) ) expect(console.log).toHaveBeenCalledWith( - green(` ${bold('src/assets/css/css-1.css')} → ${bold('dist/css-1.css')}`) + green(` ${bold('src/assets/css/css-1.css')} → ${bold(join('dist', 'css-1.css'))}`) ) expect(console.log).toHaveBeenCalledWith( - green(` ${bold('src/assets/css/css-2.css')} → ${bold('dist/css-2.css')}`) + green(` ${bold('src/assets/css/css-2.css')} → ${bold(join('dist', 'css-2.css'))}`) ) expect(console.log).toHaveBeenCalledWith( - green(` ${bold('src/assets/scss')} → ${bold('dist/scss')}`) + green(` ${bold('src/assets/scss')} → ${bold(join('dist', 'scss'))}`) ) }) diff --git a/yarn.lock b/yarn.lock index 89fd7bf..7ca447c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1002,6 +1002,14 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" +anymatch@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" + integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + aproba@^1.0.3: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" @@ -1182,6 +1190,11 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +binary-extensions@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" + integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -1206,7 +1219,7 @@ braces@^2.3.1: split-string "^3.0.2" to-regex "^3.0.1" -braces@^3.0.1: +braces@^3.0.1, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -1309,6 +1322,21 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +chokidar@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6" + integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.2.0" + optionalDependencies: + fsevents "~2.1.1" + chownr@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.2.tgz#a18f1e0b269c8a6a5d3c86eb298beb14c3dd7bf6" @@ -2126,6 +2154,11 @@ fsevents@^1.2.7: nan "^2.12.1" node-pre-gyp "^0.12.0" +fsevents@~2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.1.tgz#74c64e21df71721845d0c44fe54b7f56b82995a9" + integrity sha512-4FRPXWETxtigtJW/gxzEDsX1LVbPAM93VleB83kZB+ellqbHMkyt2aJfuzNLRvFPnGi6bcE5SvfxgbXPeKteJw== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -2186,6 +2219,13 @@ glob-parent@^5.0.0: dependencies: is-glob "^4.0.1" +glob-parent@~5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.0.tgz#5f4c1d1e748d30cd73ad2944b3577a81b081e8c2" + integrity sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw== + dependencies: + is-glob "^4.0.1" + glob@^7.1.1, glob@^7.1.2, glob@^7.1.3: version "7.1.4" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" @@ -2446,6 +2486,13 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" @@ -2534,7 +2581,7 @@ is-generator-fn@^2.0.0: resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== -is-glob@^4.0.1: +is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== @@ -3547,6 +3594,11 @@ normalize-path@^2.1.1: dependencies: remove-trailing-separator "^1.0.1" +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + npm-bundled@^1.0.1: version "1.0.6" resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd" @@ -3870,6 +3922,11 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +picomatch@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.1.0.tgz#0fd042f568d08b1ad9ff2d3ec0f0bfb3cb80e177" + integrity sha512-uhnEDzAbrcJ8R3g2fANnSuXZMBtkpSjxTTgn2LeSiQlfmq72enQJWdQllXW24MBLYnA1SBD2vfvx2o0Zw3Ielw== + picomatch@^2.0.5: version "2.0.7" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.0.7.tgz#514169d8c7cd0bdbeecc8a2609e34a7163de69f6" @@ -4049,6 +4106,13 @@ readable-stream@^2.0.1, readable-stream@^2.0.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readdirp@~3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839" + integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ== + dependencies: + picomatch "^2.0.4" + realpath-native@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c"