Repository: webpack-contrib/copy-webpack-plugin
Pull request #563 (2020-12-07 11:55:21)

feat: added type `function` for `to` option
<!--
  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**
- [x] new **feature**
- [ ] **code refactor**
- [x] **test update** <!-- if bug or feature is checked, this should be too -->
- [ ] **typo fix**
- [x] **metadata update**

### Motivation / Use-Case

Added type `function` for `to` option

### Breaking Changes

No

### Additional Info

No



codecov[bot]: # [Codecov](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/563?src=pr&el=h1) Report
> Merging [#563](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/563?src=pr&el=desc) (0ddc407) into [master](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/commit/71676457b5257ada0440aca4aea70ba1fceeaac0?el=desc) (7167645) will **increase** coverage by `1.07%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/563/graphs/tree.svg?width=650&height=150&src=pr&token=fEoU9iBFGL)](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/563?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #563      +/-   ##
==========================================
+ Coverage   91.11%   92.18%   +1.07%     
==========================================
  Files           3        3              
  Lines         315      320       +5     
  Branches       96       99       +3     
==========================================
+ Hits          287      295       +8     
+ Misses         28       25       -3     
```


| [Impacted Files](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/563?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/index.js](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/563/diff?src=pr&el=tree#diff-c3JjL2luZGV4Lmpz) | `92.15% <100.00%> (+1.12%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/563?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/563?src=pr&el=footer). Last update [7167645...0ddc407](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/563?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.md b/README.md
index e388ef8..f0f575c 100644
--- a/README.md
+++ b/README.md
@@ -79,7 +79,7 @@ module.exports = {
 |                  Name                   |            Type             |                     Default                     | Description                                                                                           |
 | :-------------------------------------: | :-------------------------: | :---------------------------------------------: | :---------------------------------------------------------------------------------------------------- |
 |             [`from`](#from)             |         `{String}`          |                   `undefined`                   | Glob or path from where we сopy files.                                                                |
-|               [`to`](#to)               |         `{String}`          |            `compiler.options.output`            | Output path.                                                                                          |
+|               [`to`](#to)               |    `{String\|Function}`     |            `compiler.options.output`            | Output path.                                                                                          |
 |          [`context`](#context)          |         `{String}`          | `options.context \|\| compiler.options.context` | A path that determines how to interpret the `from` path.                                              |
 |      [`globOptions`](#globoptions)      |         `{Object}`          |                   `undefined`                   | [Options][glob-options] passed to the glob pattern matching library including `ignore` option.        |
 |           [`filter`](#filter)           |        `{Function}`         |                   `undefined`                   | Allows to filter copied assets.                                                                       |
@@ -174,9 +174,11 @@ More [`examples`](#examples)
 
 #### `to`
 
-Type: `String`
+Type: `String|Function`
 Default: `compiler.options.output`
 
+##### String
+
 Output path.
 
 > ⚠️ Don't use directly `\\` in `to` (i.e `path\to\dest`) option because on UNIX the backslash is a valid character inside a path component, i.e., it's not a separator.
@@ -208,6 +210,53 @@ module.exports = {
 };
 ```
 
+##### Function
+
+Allows to modify the writing path.
+
+> ⚠️ Don't return directly `\\` in `to` (i.e `path\to\newFile`) option because on UNIX the backslash is a valid character inside a path component, i.e., it's not a separator.
+> On Windows, the forward slash and the backward slash are both separators.
+> Instead please use `/` or `path` methods.
+
+**webpack.config.js**
+
+```js
+module.exports = {
+  plugins: [
+    new CopyPlugin({
+      patterns: [
+        {
+          from: "src/*.png",
+          to({ context, absoluteFilename }) {
+            return "dest/newPath";
+          },
+        },
+      ],
+    }),
+  ],
+};
+```
+
+**webpack.config.js**
+
+```js
+module.exports = {
+  plugins: [
+    new CopyPlugin({
+      patterns: [
+        {
+          from: "src/*.png",
+          to: "dest/",
+          to({ context, absoluteFilename }) {
+            return Promise.resolve("dest/newPath");
+          },
+        },
+      ],
+    }),
+  ],
+};
+```
+
 #### `context`
 
 Type: `String`
diff --git a/src/index.js b/src/index.js
index 1b9bc43..f46255b 100644
--- a/src/index.js
+++ b/src/index.js
@@ -102,9 +102,6 @@ class CopyPlugin {
 
     pattern.fromOrigin = pattern.from;
     pattern.from = path.normalize(pattern.from);
-    pattern.to = path.normalize(
-      typeof pattern.to !== "undefined" ? pattern.to : ""
-    );
     pattern.compilerContext = compiler.context;
     pattern.context = path.normalize(
       typeof pattern.context !== "undefined"
@@ -115,26 +112,9 @@ class CopyPlugin {
     );
 
     logger.log(
-      `starting to process a pattern from '${pattern.from}' using '${pattern.context}' context to '${pattern.to}'...`
+      `starting to process a pattern from '${pattern.from}' using '${pattern.context}' context`
     );
 
-    const isToDirectory =
-      path.extname(pattern.to) === "" || pattern.to.slice(-1) === path.sep;
-
-    switch (true) {
-      // if toType already exists
-      case !!pattern.toType:
-        break;
-      case template.test(pattern.to):
-        pattern.toType = "template";
-        break;
-      case isToDirectory:
-        pattern.toType = "dir";
-        break;
-      default:
-        pattern.toType = "file";
-    }
-
     if (path.isAbsolute(pattern.from)) {
       pattern.absoluteFrom = pattern.from;
     } else {
@@ -310,33 +290,64 @@ class CopyPlugin {
       return;
     }
 
-    const files = filteredPaths.map((item) => {
-      const from = item.path;
+    const files = await Promise.all(
+      filteredPaths.map(async (item) => {
+        const from = item.path;
 
-      logger.debug(`found '${from}'`);
+        logger.debug(`found '${from}'`);
 
-      // `globby`/`fast-glob` return the relative path when the path contains special characters on windows
-      const absoluteFilename = path.resolve(pattern.context, from);
-      const relativeFrom = pattern.flatten
-        ? path.basename(absoluteFilename)
-        : path.relative(pattern.context, absoluteFilename);
-      let filename =
-        pattern.toType === "dir"
-          ? path.join(pattern.to, relativeFrom)
-          : pattern.to;
+        // `globby`/`fast-glob` return the relative path when the path contains special characters on windows
+        const absoluteFilename = path.resolve(pattern.context, from);
 
-      if (path.isAbsolute(filename)) {
-        filename = path.relative(compiler.options.output.path, filename);
-      }
+        pattern.to =
+          typeof pattern.to !== "function"
+            ? path.normalize(
+                typeof pattern.to !== "undefined" ? pattern.to : ""
+              )
+            : await pattern.to({ context: pattern.context, absoluteFilename });
+
+        const isToDirectory =
+          path.extname(pattern.to) === "" || pattern.to.slice(-1) === path.sep;
+
+        switch (true) {
+          // if toType already exists
+          case !!pattern.toType:
+            break;
+          case template.test(pattern.to):
+            pattern.toType = "template";
+            break;
+          case isToDirectory:
+            pattern.toType = "dir";
+            break;
+          default:
+            pattern.toType = "file";
+        }
 
-      logger.log(`determined that '${from}' should write to '${filename}'`);
+        logger.log(
+          `'to' option '${pattern.to}' determinated as '${pattern.toType}'`
+        );
 
-      const sourceFilename = normalizePath(
-        path.relative(pattern.compilerContext, absoluteFilename)
-      );
+        const relativeFrom = pattern.flatten
+          ? path.basename(absoluteFilename)
+          : path.relative(pattern.context, absoluteFilename);
+        let filename =
+          pattern.toType === "dir"
+            ? path.join(pattern.to, relativeFrom)
+            : pattern.to;
 
-      return { absoluteFilename, sourceFilename, filename };
-    });
+        if (path.isAbsolute(filename)) {
+          filename = path.relative(compiler.options.output.path, filename);
+        }
+
+        logger.log(`determined that '${from}' should write to '${filename}'`);
+
+        const sourceFilename = normalizePath(
+          path.relative(pattern.compilerContext, absoluteFilename)
+        );
+
+        return { absoluteFilename, sourceFilename, filename };
+      })
+    );
 
     let assets;
 
diff --git a/src/options.json b/src/options.json
index 76d620b..0e3ed49 100644
--- a/src/options.json
+++ b/src/options.json
@@ -9,7 +9,14 @@
           "minLength": 1
         },
         "to": {
-          "type": "string"
+          "anyOf": [
+            {
+              "type": "string"
+            },
+            {
+              "instanceof": "Function"
+            }
+          ]
         },
         "context": {
           "type": "string"


Test patch:
diff --git a/test/CopyPlugin.test.js b/test/CopyPlugin.test.js
index aed3455..ca57881 100644
--- a/test/CopyPlugin.test.js
+++ b/test/CopyPlugin.test.js
@@ -1246,5 +1246,46 @@ describe("CopyPlugin", () => {
         .then(done)
         .catch(done);
     });
+
+    it("should logging when 'to' is a function", (done) => {
+      const expectedAssetKeys = ["newFile.txt"];
+
+      run({
+        patterns: [
+          {
+            from: "file.txt",
+            to() {
+              return "newFile.txt";
+            },
+          },
+        ],
+      })
+        .then(({ compiler, stats }) => {
+          const root = path.resolve(__dirname).replace(/\\/g, "/");
+          const logs = stats.compilation.logging
+            .get("copy-webpack-plugin")
+            .map((entry) =>
+              entry.args[0].replace(/\\/g, "/").split(root).join(".")
+            )
+            // TODO remove after drop webpack@4
+            .filter(
+              (item) =>
+                !item.startsWith("created snapshot") &&
+                !item.startsWith("creating snapshot") &&
+                !item.startsWith("getting cache") &&
+                !item.startsWith("missed cache") &&
+                !item.startsWith("stored cache") &&
+                !item.startsWith("storing cache")
+            )
+            .sort();
+
+          expect(
+            Array.from(Object.keys(readAssets(compiler, stats))).sort()
+          ).toEqual(expectedAssetKeys);
+          expect({ logs }).toMatchSnapshot("logs");
+        })
+        .then(done)
+        .catch(done);
+    });
   });
 });
diff --git a/test/__snapshots__/CopyPlugin.test.js.snap b/test/__snapshots__/CopyPlugin.test.js.snap
index 6f28f0a..7d099d7 100644
--- a/test/__snapshots__/CopyPlugin.test.js.snap
+++ b/test/__snapshots__/CopyPlugin.test.js.snap
@@ -175,6 +175,10 @@ exports[`CopyPlugin cache should work with the "transform" option: warnings 2`]
 exports[`CopyPlugin logging should logging when "from" is a directory: logs 1`] = `
 Object {
   "logs": Array [
+    "'to' option '.' determinated as 'dir'",
+    "'to' option '.' determinated as 'dir'",
+    "'to' option '.' determinated as 'dir'",
+    "'to' option '.' determinated as 'dir'",
     "added './fixtures/directory' as a context dependency",
     "added './fixtures/directory/.dottedfile' as a file dependency",
     "added './fixtures/directory/directoryfile.txt' as a file dependency",
@@ -202,7 +206,7 @@ Object {
     "reading './fixtures/directory/nested/deep-nested/deepnested.txt'...",
     "reading './fixtures/directory/nested/nestedfile.txt'...",
     "starting to add additional assets...",
-    "starting to process a pattern from 'directory' using './fixtures' context to '.'...",
+    "starting to process a pattern from 'directory' using './fixtures' context",
     "writing '.dottedfile' from './fixtures/directory/.dottedfile' to compilation assets...",
     "writing 'directoryfile.txt' from './fixtures/directory/directoryfile.txt' to compilation assets...",
     "writing 'nested/deep-nested/deepnested.txt' from './fixtures/directory/nested/deep-nested/deepnested.txt' to compilation assets...",
@@ -218,6 +222,7 @@ Object {
 exports[`CopyPlugin logging should logging when "from" is a file: logs 1`] = `
 Object {
   "logs": Array [
+    "'to' option '.' determinated as 'dir'",
     "added './fixtures/file.txt' as a file dependency",
     "begin globbing './fixtures/file.txt'...",
     "determined './fixtures/file.txt' is a file",
@@ -229,7 +234,7 @@ Object {
     "read './fixtures/file.txt'",
     "reading './fixtures/file.txt'...",
     "starting to add additional assets...",
-    "starting to process a pattern from 'file.txt' using './fixtures' context to '.'...",
+    "starting to process a pattern from 'file.txt' using './fixtures' context",
     "writing 'file.txt' from './fixtures/file.txt' to compilation assets...",
     "written 'file.txt' from './fixtures/file.txt' to compilation assets",
   ],
@@ -239,6 +244,9 @@ Object {
 exports[`CopyPlugin logging should logging when "from" is a glob: logs 1`] = `
 Object {
   "logs": Array [
+    "'to' option '.' determinated as 'dir'",
+    "'to' option '.' determinated as 'dir'",
+    "'to' option '.' determinated as 'dir'",
     "added './fixtures/directory' as a context dependency",
     "added './fixtures/directory/directoryfile.txt' as a file dependency",
     "added './fixtures/directory/nested/deep-nested/deepnested.txt' as a file dependency",
@@ -260,7 +268,7 @@ Object {
     "reading './fixtures/directory/nested/deep-nested/deepnested.txt'...",
     "reading './fixtures/directory/nested/nestedfile.txt'...",
     "starting to add additional assets...",
-    "starting to process a pattern from 'directory/**' using './fixtures' context to '.'...",
+    "starting to process a pattern from 'directory/**' using './fixtures' context",
     "writing 'directory/directoryfile.txt' from './fixtures/directory/directoryfile.txt' to compilation assets...",
     "writing 'directory/nested/deep-nested/deepnested.txt' from './fixtures/directory/nested/deep-nested/deepnested.txt' to compilation assets...",
     "writing 'directory/nested/nestedfile.txt' from './fixtures/directory/nested/nestedfile.txt' to compilation assets...",
@@ -271,6 +279,28 @@ Object {
 }
 `;
 
+exports[`CopyPlugin logging should logging when 'to' is a function: logs 1`] = `
+Object {
+  "logs": Array [
+    "'to' option 'newFile.txt' determinated as 'file'",
+    "added './fixtures/file.txt' as a file dependency",
+    "begin globbing './fixtures/file.txt'...",
+    "determined './fixtures/file.txt' is a file",
+    "determined that './fixtures/file.txt' should write to 'newFile.txt'",
+    "finished to adding additional assets",
+    "finished to process a pattern from 'file.txt' using './fixtures' context to 'newFile.txt'",
+    "found './fixtures/file.txt'",
+    "getting stats for './fixtures/file.txt'...",
+    "read './fixtures/file.txt'",
+    "reading './fixtures/file.txt'...",
+    "starting to add additional assets...",
+    "starting to process a pattern from 'file.txt' using './fixtures' context",
+    "writing 'newFile.txt' from './fixtures/file.txt' to compilation assets...",
+    "written 'newFile.txt' from './fixtures/file.txt' to compilation assets",
+  ],
+}
+`;
+
 exports[`CopyPlugin stats should work have assets info: assets 1`] = `
 Object {
   ".dottedfile": "dottedfile contents
diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap
index 3ee7e2d..992c663 100644
--- a/test/__snapshots__/validate-options.test.js.snap
+++ b/test/__snapshots__/validate-options.test.js.snap
@@ -95,7 +95,14 @@ 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":"test.txt","to":true,"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].to should be a string."
+ - options.patterns[0] should be one of these:
+   non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }
+   Details:
+    * options.patterns[0].to should be one of these:
+      string | function
+      Details:
+       * options.patterns[0].to should be a string.
+       * options.patterns[0].to should be an instance of function."
 `;
 
 exports[`validate options should throw an error on the "patterns" option with "[{"from":{"glob":"**/*","dot":false},"to":"dir","context":"context"}]" value 1`] = `
diff --git a/test/to-option.test.js b/test/to-option.test.js
index a00272e..bf3f7d9 100644
--- a/test/to-option.test.js
+++ b/test/to-option.test.js
@@ -478,4 +478,285 @@ describe("to option", () => {
         .catch(done);
     });
   });
+
+  describe("to option as function", () => {
+    it('should transform target path when "from" is a file', (done) => {
+      runEmit({
+        expectedAssetKeys: ["subdir/test.txt"],
+        patterns: [
+          {
+            from: "file.txt",
+            to({ context, absoluteFilename }) {
+              expect(absoluteFilename).toBe(
+                path.join(FIXTURES_DIR, "file.txt")
+              );
+
+              const targetPath = path.relative(context, absoluteFilename);
+
+              return targetPath.replace("file.txt", "subdir/test.txt");
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it('should transform target path of every when "from" is a directory', (done) => {
+      runEmit({
+        expectedAssetKeys: [
+          "../.dottedfile",
+          "../deepnested.txt",
+          "../directoryfile.txt",
+          "../nestedfile.txt",
+        ],
+        patterns: [
+          {
+            from: "directory",
+            toType: "file",
+            to({ context, absoluteFilename }) {
+              expect(
+                absoluteFilename.includes(path.join(FIXTURES_DIR, "directory"))
+              ).toBe(true);
+
+              const targetPath = path.relative(context, absoluteFilename);
+
+              return path.resolve(__dirname, path.basename(targetPath));
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it('should transform target path of every file when "from" is a glob', (done) => {
+      runEmit({
+        expectedAssetKeys: [
+          "../deepnested.txt.tst",
+          "../directoryfile.txt.tst",
+          "../nestedfile.txt.tst",
+        ],
+        patterns: [
+          {
+            from: "directory/**/*",
+            to({ context, absoluteFilename }) {
+              expect(absoluteFilename.includes(FIXTURES_DIR)).toBe(true);
+
+              const targetPath = path.relative(context, absoluteFilename);
+
+              return path.resolve(
+                __dirname,
+                `${path.basename(targetPath)}.tst`
+              );
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it("should transform target path when function return Promise", (done) => {
+      runEmit({
+        expectedAssetKeys: ["../file.txt"],
+        patterns: [
+          {
+            from: "file.txt",
+            to({ context, absoluteFilename }) {
+              expect(absoluteFilename.includes(FIXTURES_DIR)).toBe(true);
+
+              const targetPath = path.relative(context, absoluteFilename);
+
+              return new Promise((resolve) => {
+                resolve(path.resolve(__dirname, path.basename(targetPath)));
+              });
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it("should transform target path when async function used", (done) => {
+      runEmit({
+        expectedAssetKeys: ["../file.txt"],
+        patterns: [
+          {
+            from: "file.txt",
+            async to({ context, absoluteFilename }) {
+              expect(absoluteFilename.includes(FIXTURES_DIR)).toBe(true);
+
+              const targetPath = path.relative(context, absoluteFilename);
+
+              const newPath = await new Promise((resolve) => {
+                resolve(path.resolve(__dirname, path.basename(targetPath)));
+              });
+
+              return newPath;
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it("should warn when function throw error", (done) => {
+      runEmit({
+        expectedAssetKeys: [],
+        expectedErrors: [new Error("a failure happened")],
+        patterns: [
+          {
+            from: "file.txt",
+            to() {
+              throw new Error("a failure happened");
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it("should warn when Promise was rejected", (done) => {
+      runEmit({
+        expectedAssetKeys: [],
+        expectedErrors: [new Error("a failure happened")],
+        patterns: [
+          {
+            from: "file.txt",
+            to() {
+              return new Promise((resolve, reject) => {
+                return reject(new Error("a failure happened"));
+              });
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it("should warn when async function throw error", (done) => {
+      runEmit({
+        expectedAssetKeys: [],
+        expectedErrors: [new Error("a failure happened")],
+        patterns: [
+          {
+            from: "file.txt",
+            async to() {
+              await new Promise((resolve, reject) => {
+                reject(new Error("a failure happened"));
+              });
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it("should transform target path of every file in glob after applying template", (done) => {
+      runEmit({
+        expectedAssetKeys: [
+          "transformed/directory/directoryfile-5d7817.txt",
+          "transformed/directory/nested/deep-nested/deepnested-31d6cf.txt",
+          "transformed/directory/nested/nestedfile-31d6cf.txt",
+        ],
+        patterns: [
+          {
+            from: "directory/**/*",
+            to({ absoluteFilename }) {
+              expect(absoluteFilename.includes(FIXTURES_DIR)).toBe(true);
+
+              return "transformed/[path][name]-[hash:6].[ext]";
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it("should move files", (done) => {
+      runEmit({
+        expectedAssetKeys: ["txt"],
+        patterns: [
+          {
+            from: "directory/nested/deep-nested",
+            toType: "file",
+            to({ absoluteFilename }) {
+              const mathes = absoluteFilename.match(/\.([^.]*)$/);
+              const [, res] = mathes;
+              const target = res;
+
+              return target;
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it("should move files to a non-root directory", (done) => {
+      runEmit({
+        expectedAssetKeys: ["nested/txt"],
+        patterns: [
+          {
+            from: "directory/nested/deep-nested",
+            toType: "file",
+            to({ absoluteFilename }) {
+              const mathes = absoluteFilename.match(/\.([^.]*)$/);
+              const [, res] = mathes;
+              const target = `nested/${res}`;
+
+              return target;
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+
+    it("should move files", (done) => {
+      runEmit({
+        expectedAssetKeys: [
+          "deep-nested-deepnested.txt",
+          "directoryfile.txt",
+          "nested-nestedfile.txt",
+        ],
+        patterns: [
+          {
+            from: "**/*",
+            context: "directory",
+            to({ context, absoluteFilename }) {
+              const targetPath = path.relative(context, absoluteFilename);
+              const pathSegments = path.parse(targetPath);
+              const result = [];
+
+              if (pathSegments.root) {
+                result.push(pathSegments.root);
+              }
+
+              if (pathSegments.dir) {
+                result.push(pathSegments.dir.split(path.sep).pop());
+              }
+
+              if (pathSegments.base) {
+                result.push(pathSegments.base);
+              }
+
+              return result.join("-");
+            },
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+  });
 });
diff --git a/test/validate-options.test.js b/test/validate-options.test.js
index a2c3881..9686377 100644
--- a/test/validate-options.test.js
+++ b/test/validate-options.test.js
@@ -29,6 +29,12 @@ describe("validate options", () => {
             to: "dir",
           },
         ],
+        [
+          {
+            from: "test.txt",
+            to: () => {},
+          },
+        ],
         [
           {
             from: "test.txt",