Repository: webpack-contrib/copy-webpack-plugin (js)

Issue:
Copy files with hashed name?
Hello all,

I think it would be a nice feature to have the ability to hash the filenames of the copied files. Currently when I build with Webpack I copy my locales json files ( translations) into the dist folder.

These will be cached in user´s browsers and if I update them, users will not notice as they are cached.

Please, let me know your thoughts about this.

Thank you and regards.


Pull request description:
docs: example for placeholders in `to`
<!--
  HOLY CRAP a Pull Request. We ❤️ those!

  If you remove or skip this template, you'll make the 🐼 sad and the mighty god
  of Github will appear and pile-drive the close button from a great height
  while making animal noises.

  Please place an x (no spaces!) in all [ ] that apply
-->

This PR contains a:

- [ ] **bugfix**
- [ ] new **feature**
- [ ] **code refactor**
- [ ] **test update** <!-- if bug or feature is checked, this should be too -->
- [ ] **typo fix**
- [x] **metadata update**

### Motivation / Use-Case

fixes #411

### Breaking Changes

No

### Additional Info

No

Patch:
diff --git a/README.md b/README.md
index 1008ea4..3379fa7 100644
--- a/README.md
+++ b/README.md
@@ -60,23 +60,23 @@ module.exports = {
 
 ### Patterns
 
-|               Name                |         Type          |                     Default                     | Description                                                                                           |
-| :-------------------------------: | :-------------------: | :---------------------------------------------: | :---------------------------------------------------------------------------------------------------- |
-|          [`from`](#from)          |  `{String\|Object}`   |                   `undefined`                   | Glob or path from where we сopy files.                                                                |
-|            [`to`](#to)            |      `{String}`       |            `compiler.options.output`            | Output path.                                                                                          |
-|       [`context`](#context)       |      `{String}`       | `options.context \|\| compiler.options.context` | A path that determines how to interpret the `from` path.                                              |
-|        [`toType`](#totype)        |      `{String}`       |                   `undefined`                   | Determinate what is `to` option - directory, file or template.                                        |
-|          [`test`](#test)          |      `{RegExp}`       |                   `undefined`                   | Pattern for extracting elements to be used in `to` templates.                                         |
-|         [`force`](#force)         |      `{Boolean}`      |                     `false`                     | Overwrites files already in `compilation.assets` (usually added by other plugins/loaders).            |
-|        [`ignore`](#ignore)        |       `{Array}`       |                      `[]`                       | Globs to ignore files.                                                                                |
-|       [`flatten`](#flatten)       |      `{Boolean}`      |                     `false`                     | Removes all directory references and only copies file names.                                          |
-|         [`cache`](#cache)         |  `{Boolean\|Object}`  |                     `false`                     | Enable `transform` caching. You can use `{ cache: { key: 'my-cache-key' } }` to invalidate the cache. |
-|     [`transform`](#transform)     | `{Function\|Promise}` |                   `undefined`                   | Allows to modify the file contents.                                                                   |
-| [`transformPath`](#transformpath) | `{Function\|Promise}` |                   `undefined`                   | Allows to modify the writing path.                                                                    |
+|               Name                |        Type         |                     Default                     | Description                                                                                           |
+| :-------------------------------: | :-----------------: | :---------------------------------------------: | :---------------------------------------------------------------------------------------------------- |
+|          [`from`](#from)          | `{String\|Object}`  |                   `undefined`                   | Glob or path from where we сopy files.                                                                |
+|            [`to`](#to)            |     `{String}`      |            `compiler.options.output`            | Output path.                                                                                          |
+|       [`context`](#context)       |     `{String}`      | `options.context \|\| compiler.options.context` | A path that determines how to interpret the `from` path.                                              |
+|        [`toType`](#totype)        |     `{String}`      |                   `undefined`                   | Determinate what is `to` option - directory, file or template.                                        |
+|          [`test`](#test)          | `{String\|RegExp}`  |                   `undefined`                   | Pattern for extracting elements to be used in `to` templates.                                         |
+|         [`force`](#force)         |     `{Boolean}`     |                     `false`                     | Overwrites files already in `compilation.assets` (usually added by other plugins/loaders).            |
+|        [`ignore`](#ignore)        |      `{Array}`      |                      `[]`                       | Globs to ignore files.                                                                                |
+|       [`flatten`](#flatten)       |     `{Boolean}`     |                     `false`                     | Removes all directory references and only copies file names.                                          |
+|         [`cache`](#cache)         | `{Boolean\|Object}` |                     `false`                     | Enable `transform` caching. You can use `{ cache: { key: 'my-cache-key' } }` to invalidate the cache. |
+|     [`transform`](#transform)     |    `{Function}`     |                   `undefined`                   | Allows to modify the file contents.                                                                   |
+| [`transformPath`](#transformpath) |    `{Function}`     |                   `undefined`                   | Allows to modify the writing path.                                                                    |
 
 #### `from`
 
-Type: `String\|Object`
+Type: `String|Object`
 Default: `undefined`
 
 Glob or path from where we сopy files.
@@ -99,7 +99,6 @@ module.exports = {
       'relative/path/to/dir',
       '/absolute/path/to/dir',
       '**/*',
-      { glob: '**/*', dot: false },
       {
         from: '**/*',
         globOptions: {
@@ -136,6 +135,10 @@ module.exports = {
         from: '**/*',
         to: '/absolute/path/to/dest/',
       },
+      {
+        from: '**/*',
+        to: '[path][name].[contenthash].[ext]',
+      },
     ]),
   ],
 };
@@ -240,7 +243,7 @@ module.exports = {
 
 #### `test`
 
-Type: `RegExp`
+Type: `string|RegExp`
 Default: `undefined`
 
 Pattern for extracting elements to be used in `to` templates.
@@ -367,13 +370,11 @@ module.exports = {
 
 #### `transform`
 
-Type: `Function|Promise`
+Type: `Function`
 Default: `undefined`
 
 Allows to modify the file contents.
 
-##### `{Function}`
-
 **webpack.config.js**
 
 ```js
@@ -392,8 +393,6 @@ module.exports = {
 };
 ```
 
-##### `{Promise}`
-
 **webpack.config.js**
 
 ```js
@@ -414,7 +413,7 @@ module.exports = {
 
 #### `transformPath`
 
-Type: `Function|Promise`
+Type: `Function`
 Default: `undefined`
 
 Allows to modify the writing path.
@@ -423,8 +422,6 @@ Allows to modify the writing path.
 > On Windows, the forward slash and the backward slash are both separators.
 > Instead please use `/` or `path` methods.
 
-##### `{Function}`
-
 **webpack.config.js**
 
 ```js
@@ -443,8 +440,6 @@ module.exports = {
 };
 ```
 
-##### `{Promise}`
-
 **webpack.config.js**
 
 ```js
diff --git a/src/index.js b/src/index.js
index b48a68a..f72b480 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,16 +1,16 @@
 import path from 'path';
 
+import validateOptions from 'schema-utils';
 import log from 'webpack-log';
 
+import schema from './options.json';
 import preProcessPattern from './preProcessPattern';
 import processPattern from './processPattern';
 import postProcessPattern from './postProcessPattern';
 
 class CopyPlugin {
   constructor(patterns = [], options = {}) {
-    if (!Array.isArray(patterns)) {
-      throw new Error('[copy-webpack-plugin] patterns must be an array');
-    }
+    validateOptions(schema, patterns, this.constructor.name);
 
     this.patterns = patterns;
     this.options = options;
diff --git a/src/options.json b/src/options.json
new file mode 100644
index 0000000..cc3109a
--- /dev/null
+++ b/src/options.json
@@ -0,0 +1,81 @@
+{
+  "definitions": {
+    "ObjectPattern": {
+      "type": "object",
+      "properties": {
+        "from": {
+          "anyOf": [
+            {
+              "type": "string",
+              "minLength": 1
+            },
+            {
+              "type": "object"
+            }
+          ]
+        },
+        "to": {
+          "type": "string"
+        },
+        "context": {
+          "type": "string"
+        },
+        "toType": {
+          "enum": ["dir", "file", "template"]
+        },
+        "test": {
+          "anyOf": [
+            {
+              "type": "string"
+            },
+            {
+              "instanceof": "RegExp"
+            }
+          ]
+        },
+        "force": {
+          "type": "boolean"
+        },
+        "ignore": {
+          "type": "array"
+        },
+        "flatten": {
+          "type": "boolean"
+        },
+        "cache": {
+          "anyOf": [
+            {
+              "type": "boolean"
+            },
+            {
+              "type": "object"
+            }
+          ]
+        },
+        "transform": {
+          "instanceof": "Function"
+        },
+        "transformPath": {
+          "instanceof": "Function"
+        }
+      },
+      "required": ["from"]
+    },
+    "StringPattern": {
+      "type": "string",
+      "minLength": 1
+    }
+  },
+  "type": "array",
+  "minItems": 1,
+  "items": {
+    "anyOf": [
+      {
+        "$ref": "#/definitions/StringPattern"
+      },
+      {
+        "$ref": "#/definitions/ObjectPattern"
+      }
+    ]
+  }
+}
diff --git a/src/preProcessPattern.js b/src/preProcessPattern.js
index c731918..4d79f6f 100644
--- a/src/preProcessPattern.js
+++ b/src/preProcessPattern.js
@@ -25,14 +25,6 @@ export default function preProcessPattern(globalRef, pattern) {
       ? { from: pattern }
       : Object.assign({}, pattern);
 
-  if (pattern.from === '') {
-    const message = 'path "from" cannot be empty string';
-
-    logger.error(message);
-
-    compilation.errors.push(new Error(message));
-  }
-
   pattern.to = pattern.to || '';
   pattern.context = pattern.context || context;
 


Test patch:
diff --git a/test/CopyPlugin.test.js b/test/CopyPlugin.test.js
index 69a9815..5f25b63 100644
--- a/test/CopyPlugin.test.js
+++ b/test/CopyPlugin.test.js
@@ -1,7 +1,5 @@
 import path from 'path';
 
-import CopyPlugin from '../src/index';
-
 import { MockCompiler } from './utils/mocks';
 import { run, runEmit, runChange } from './utils/run';
 
@@ -341,77 +339,6 @@ describe('apply function', () => {
     });
   });
 
-  describe('errors', () => {
-    it('should not throw an error if no patterns are passed', (done) => {
-      runEmit({
-        expectedAssetKeys: [],
-        patterns: undefined, // eslint-disable-line no-undefined
-      })
-        .then(done)
-        .catch(done);
-    });
-
-    it('should throw an error if the patterns are an object', () => {
-      const createPluginWithObject = () => {
-        // eslint-disable-next-line no-new
-        new CopyPlugin({});
-      };
-
-      expect(createPluginWithObject).toThrow(Error);
-    });
-
-    it('should throw an error if the patterns are null', () => {
-      const createPluginWithNull = () => {
-        // eslint-disable-next-line no-new
-        new CopyPlugin(null);
-      };
-
-      expect(createPluginWithNull).toThrow(Error);
-    });
-
-    it('should throw an error if the "from" path is an empty string', () => {
-      const createPluginWithNull = () => {
-        // eslint-disable-next-line no-new
-        new CopyPlugin({
-          from: '',
-        });
-      };
-
-      expect(createPluginWithNull).toThrow(Error);
-    });
-
-    it('should warn when pattern is empty', (done) => {
-      runEmit({
-        expectedAssetKeys: [
-          '.file.txt',
-          '[!]/hello.txt',
-          '[special?directory]/(special-*file).txt',
-          '[special?directory]/directoryfile.txt',
-          '[special?directory]/nested/nestedfile.txt',
-          'binextension.bin',
-          'dir (86)/file.txt',
-          'dir (86)/nesteddir/deepnesteddir/deepnesteddir.txt',
-          'dir (86)/nesteddir/nestedfile.txt',
-          'directory/.dottedfile',
-          'directory/directoryfile.txt',
-          'directory/nested/deep-nested/deepnested.txt',
-          'directory/nested/nestedfile.txt',
-          'file.txt',
-          'file.txt.gz',
-          'noextension',
-        ],
-        expectedErrors: [new Error(`path "from" cannot be empty string`)],
-        patterns: [
-          {
-            from: '',
-          },
-        ],
-      })
-        .then(done)
-        .catch(done);
-    });
-  });
-
   describe('dev server', () => {
     it('should work with absolute to if outpath is defined with webpack-dev-server', (done) => {
       runEmit({
diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap
new file mode 100644
index 0000000..b5f2747
--- /dev/null
+++ b/test/__snapshots__/validate-options.test.js.snap
@@ -0,0 +1,163 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`validate options should throw an error on the "patterns" option with "" value 1`] = `
+"CopyPlugin Invalid Options
+
+options should be array
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[""]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should NOT be shorter than 1 characters
+options.0 should be object
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"","to":"dir","context":"context"}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.from should NOT be shorter than 1 characters
+options.0.from should be object
+options.0.from should match some schema in anyOf
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context","flatten":"true"}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.flatten should be boolean
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context","force":"true"}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.force should be boolean
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context","ignore":true}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.ignore should be array
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context","test":true}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.test should be string
+options.0.test should pass \\"instanceof\\" keyword validation
+options.0.test should match some schema in anyOf
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context","toType":"foo"}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.toType should be equal to one of the allowed values
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context","transform":true}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.transform should pass \\"instanceof\\" keyword validation
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context","transformPath":true}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.transformPath should pass \\"instanceof\\" keyword validation
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context"}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.cache should be boolean
+options.0.cache should be object
+options.0.cache should match some schema in anyOf
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":true}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.context should be string
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":true,"context":"context"}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.to should be string
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":true,"to":"dir","context":"context"}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0.from should be string
+options.0.from should be object
+options.0.from should match some schema in anyOf
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{}]" value 1`] = `
+"CopyPlugin Invalid Options
+
+options.0 should be string
+options.0 should have required property 'from'
+options.0 should match some schema in anyOf
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "{}" value 1`] = `
+"CopyPlugin Invalid Options
+
+options should be array
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "true" value 1`] = `
+"CopyPlugin Invalid Options
+
+options should be array
+"
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "true" value 2`] = `
+"CopyPlugin Invalid Options
+
+options should be array
+"
+`;
diff --git a/test/validate-options.test.js b/test/validate-options.test.js
new file mode 100644
index 0000000..0afaaa0
--- /dev/null
+++ b/test/validate-options.test.js
@@ -0,0 +1,263 @@
+import CopyPlugin from '../src/index';
+
+// Todo remove after dorp node@6 support
+if (!Object.entries) {
+  Object.entries = function entries(obj) {
+    const ownProps = Object.keys(obj);
+    let i = ownProps.length;
+    const resArray = new Array(i);
+
+    // eslint-disable-next-line no-plusplus
+    while (i--) {
+      resArray[i] = [ownProps[i], obj[ownProps[i]]];
+    }
+
+    return resArray;
+  };
+}
+
+describe('validate options', () => {
+  const tests = {
+    patterns: {
+      success: [
+        ['test.txt'],
+        ['test.txt', 'test-other.txt'],
+        [
+          'test.txt',
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            context: 'context',
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            toType: 'file',
+            test: /test/,
+            force: true,
+            ignore: ['ignore-1', 'ignore-2'],
+            flatten: true,
+            cache: true,
+            transform: () => {},
+            transformPath: () => {},
+          },
+        ],
+        [
+          {
+            from: {
+              glob: '**/*',
+              dot: false,
+            },
+            to: 'dir',
+            context: 'context',
+            globOptions: {
+              dot: false,
+            },
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            globOptions: {
+              dot: false,
+            },
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            test: 'test',
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            test: /test/,
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            cache: {
+              foo: 'bar',
+            },
+          },
+        ],
+      ],
+      failure: [
+        true,
+        'true',
+        '',
+        {},
+        [''],
+        [{}],
+        [
+          {
+            from: '',
+            to: 'dir',
+            context: 'context',
+          },
+        ],
+        [
+          {
+            from: true,
+            to: 'dir',
+            context: 'context',
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: true,
+            context: 'context',
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: true,
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            toType: 'foo',
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            test: true,
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            force: 'true',
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            ignore: true,
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            flatten: 'true',
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            cache: () => {},
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            transform: true,
+          },
+        ],
+        [
+          {
+            from: 'test.txt',
+            to: 'dir',
+            context: 'context',
+            transformPath: true,
+          },
+        ],
+      ],
+    },
+  };
+
+  function stringifyValue(value) {
+    if (
+      Array.isArray(value) ||
+      (value && typeof value === 'object' && value.constructor === Object)
+    ) {
+      return JSON.stringify(value);
+    }
+
+    return value;
+  }
+
+  async function createTestCase(key, value, type) {
+    it(`should ${
+      type === 'success' ? 'successfully validate' : 'throw an error on'
+    } the "${key}" option with "${stringifyValue(value)}" value`, async () => {
+      let error;
+
+      try {
+        // eslint-disable-next-line no-new
+        new CopyPlugin(key === 'patterns' ? value : { [key]: value });
+      } catch (errorFromPlugin) {
+        if (errorFromPlugin.name !== 'ValidationError') {
+          throw errorFromPlugin;
+        }
+
+        error = errorFromPlugin;
+      } finally {
+        if (type === 'success') {
+          expect(error).toBeUndefined();
+        } else if (type === 'failure') {
+          expect(() => {
+            throw error;
+          }).toThrowErrorMatchingSnapshot();
+        }
+      }
+    });
+  }
+
+  for (const [key, values] of Object.entries(tests)) {
+    for (const type of Object.keys(values)) {
+      for (const value of values[type]) {
+        createTestCase(key, value, type);
+      }
+    }
+  }
+});