diff --git a/src/index.js b/src/index.js index 0d03fd9..d861db5 100644 --- a/src/index.js +++ b/src/index.js @@ -874,22 +874,67 @@ class CopyPlugin { */ let copiedResult; - try { - copiedResult = await CopyPlugin.glob( - globby, - compiler, - compilation, - logger, - cache, - concurrency, - /** @type {ObjectPattern & { context: string }} */ - (pattern), - index, - ); - } catch (error) { - compilation.errors.push(/** @type {Error} */ (error)); + const fromList = Array.isArray(pattern.from) + ? pattern.from + : [pattern.from]; - return; + if (fromList.length === 0) { + copiedResult = []; + } else if (fromList.length > 1) { + const results = []; + + for (let i = 0; i < fromList.length; i++) { + const from = fromList[i]; + const arrayPattern = { ...pattern, from }; + + try { + const result = await CopyPlugin.glob( + globby, + compiler, + compilation, + logger, + cache, + concurrency, + /** @type {ObjectPattern & { context: string }} */ + (arrayPattern), + index * 1000 + i, // Unique index for caching + ); + + if (result) { + results.push(...result); + } + } catch (error) { + compilation.errors.push(/** @type {Error} */ (error)); + + // Continue with next from in array + continue; + } + } + + copiedResult = results; + } else { + const singlePattern = { + ...pattern, + from: /** @type {string} */ (fromList[0]), + }; + + try { + copiedResult = await CopyPlugin.glob( + globby, + compiler, + compilation, + logger, + cache, + concurrency, + /** @type {ObjectPattern & { context: string }} */ + (singlePattern), + index, + ); + } catch (error) { + compilation.errors.push(/** @type {Error} */ (error)); + + return; + } } if (!copiedResult) { diff --git a/src/options.json b/src/options.json index 2e0e6d5..865f857 100644 --- a/src/options.json +++ b/src/options.json @@ -5,10 +5,21 @@ "additionalProperties": false, "properties": { "from": { - "type": "string", - "description": "Glob or path from where we copy files.", - "link": "https://github.com/webpack/copy-webpack-plugin#from", - "minLength": 1 + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ], + "description": "Glob or path (or array of paths) from where we copy files.", + "link": "https://github.com/webpack/copy-webpack-plugin#from" }, "to": { "anyOf": [ diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap index c82c171..ed6d9a4 100644 --- a/test/__snapshots__/validate-options.test.js.snap +++ b/test/__snapshots__/validate-options.test.js.snap @@ -38,9 +38,7 @@ exports[`validate options should throw an error on the "patterns" option with "[ exports[`validate options should throw an error on the "patterns" option with "[{"from":"","to":"dir","context":"context"}]" value 1`] = ` "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema. - - options.patterns[0].from should be a non-empty string. - -> Glob or path from where we copy files. - -> Read more at https://github.com/webpack/copy-webpack-plugin#from" + - options.patterns[0].from should be a non-empty string." `; exports[`validate options should throw an error on the "patterns" option with "[{"from":"dir","info":"string"}]" value 1`] = ` @@ -167,23 +165,39 @@ exports[`validate options should throw an error on the "patterns" option with "[ exports[`validate options should throw an error on the "patterns" option with "[{"from":{"glob":"**/*","dot":false},"to":"dir","context":"context"}]" value 1`] = ` "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema. - - options.patterns[0].from should be a non-empty string. - -> Glob or path from where we copy files. - -> Read more at https://github.com/webpack/copy-webpack-plugin#from" + - options.patterns[0] should be one of these: + non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, toType?, force?, priority?, info?, transform?, noErrorOnMissing? } + Details: + * options.patterns[0].from should be one of these: + non-empty string | [non-empty string, ...] + -> Glob or path (or array of paths) from where we copy files. + -> Read more at https://github.com/webpack/copy-webpack-plugin#from + Details: + * options.patterns[0].from should be a non-empty string. + * options.patterns[0].from should be an array: + [non-empty string, ...]" `; exports[`validate options should throw an error on the "patterns" option with "[{"from":true,"to":"dir","context":"context"}]" value 1`] = ` "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema. - - options.patterns[0].from should be a non-empty string. - -> Glob or path from where we copy files. - -> Read more at https://github.com/webpack/copy-webpack-plugin#from" + - options.patterns[0] should be one of these: + non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, toType?, force?, priority?, info?, transform?, noErrorOnMissing? } + Details: + * options.patterns[0].from should be one of these: + non-empty string | [non-empty string, ...] + -> Glob or path (or array of paths) from where we copy files. + -> Read more at https://github.com/webpack/copy-webpack-plugin#from + Details: + * options.patterns[0].from should be a non-empty string. + * options.patterns[0].from should be an array: + [non-empty string, ...]" `; exports[`validate options should throw an error on the "patterns" option with "[{}]" value 1`] = ` "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema. - options.patterns[0] misses the property 'from'. Should be: - non-empty string - -> Glob or path from where we copy files. + non-empty string | [non-empty string, ...] + -> Glob or path (or array of paths) from where we copy files. -> Read more at https://github.com/webpack/copy-webpack-plugin#from" `; diff --git a/test/from-array-option.test.js b/test/from-array-option.test.js new file mode 100644 index 0000000..55a44c8 --- /dev/null +++ b/test/from-array-option.test.js @@ -0,0 +1,174 @@ +import path from "node:path"; + +import { runEmit } from "./helpers/run"; + +const FIXTURES_DIR = path.join(__dirname, "fixtures"); + +const FIXTURES_DIR_NORMALIZED = FIXTURES_DIR.replaceAll("\\", "/"); + +describe("from option as array", () => { + it("should copy multiple files from array", (done) => { + runEmit({ + expectedAssetKeys: ["directoryfile.txt", "file.txt"], + patterns: [ + { + from: ["file.txt", "directory/directoryfile.txt"], + }, + ], + }) + .then(done) + .catch(done); + }); + + it("should copy files from array with absolute paths", (done) => { + runEmit({ + expectedAssetKeys: ["directoryfile.txt", "file.txt"], + patterns: [ + { + from: [ + path.join(FIXTURES_DIR, "file.txt"), + path.join(FIXTURES_DIR, "directory/directoryfile.txt"), + ], + }, + ], + }) + .then(done) + .catch(done); + }); + + it("should copy files from array to specific directory", (done) => { + runEmit({ + expectedAssetKeys: ["dist/file.txt", "dist/directoryfile.txt"], + patterns: [ + { + from: ["file.txt", "directory/directoryfile.txt"], + to: "dist", + }, + ], + }) + .then(done) + .catch(done); + }); + + it("should handle missing files with noErrorOnMissing", (done) => { + runEmit({ + expectedAssetKeys: ["file.txt"], + patterns: [ + { + from: ["file.txt", "nonexistent.txt"], + noErrorOnMissing: true, + }, + ], + }) + .then(done) + .catch(done); + }); + + it("should error on missing files by default", (done) => { + runEmit({ + expectedAssetKeys: ["file.txt"], + expectedErrors: [ + new Error( + `unable to locate '${FIXTURES_DIR_NORMALIZED}/nonexistent.txt' glob`, + ), + ], + patterns: [ + { + from: ["file.txt", "nonexistent.txt"], + }, + ], + }) + .then(done) + .catch(done); + }); + + it("should apply filter to all files in array", (done) => { + runEmit({ + expectedAssetKeys: ["file.txt"], + patterns: [ + { + from: ["file.txt", "directory/directoryfile.txt"], + filter: (resource) => path.basename(resource) === "file.txt", + }, + ], + }) + .then(done) + .catch(done); + }); + + it("should apply transform to all files in array", (done) => { + runEmit({ + expectedAssetKeys: ["directoryfile.txt", "file.txt"], + expectedAssetContent: { + "file.txt": "transformed: new", + "directoryfile.txt": "transformed: new", + }, + patterns: [ + { + from: ["file.txt", "directory/directoryfile.txt"], + transform: (content) => `transformed: ${content.toString()}`, + }, + ], + }) + .then(done) + .catch(done); + }); + + it("should handle empty array gracefully", (done) => { + runEmit({ + expectedAssetKeys: [], + patterns: [ + { + from: [], + }, + ], + }) + .then(done) + .catch(done); + }); + + it("should handle single item array (backward compatibility)", (done) => { + runEmit({ + expectedAssetKeys: ["file.txt"], + patterns: [ + { + from: ["file.txt"], + }, + ], + }) + .then(done) + .catch(done); + }); + + it("should work with filter in array", (done) => { + runEmit({ + expectedAssetKeys: ["file.txt"], + patterns: [ + { + from: ["file.txt", "directory/directoryfile.txt"], + filter: (resource) => path.basename(resource) === "file.txt", + }, + ], + }) + .then(done) + .catch(done); + }); + + it('should copy files when "from" is an array containing a directory', (done) => { + runEmit({ + expectedAssetKeys: [ + ".dottedfile", + "directoryfile.txt", + "nested/deep-nested/deepnested.txt", + "nested/nestedfile.txt", + ], + patterns: [ + { + from: ["directory"], + }, + ], + }) + .then(done) + .catch(done); + }); +});