Repository: webpack/enhanced-resolve
Pull request #351 (2022-06-27 09:19:28)

add extension alias
add new option extensionAlias which maps extension to extension alias

related to https://github.com/TypeStrong/ts-loader/issues/1383#issuecomment-968075478


Patch:
diff --git a/README.md b/README.md
index ad512e5..644305c 100644
--- a/README.md
+++ b/README.md
@@ -77,9 +77,10 @@ myResolver.resolve({}, lookupStartPath, request, resolveContext, (
 #### Resolver Options
 
 | Field            | Default                     | Description                                                                                                                                               |
-| ---------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+|------------------|-----------------------------| --------------------------------------------------------------------------------------------------------------------------------------------------------- |
 | alias            | []                          | A list of module alias configurations or an object which maps key to value                                                                                |
 | aliasFields      | []                          | A list of alias fields in description files                                                                                                               |
+| extensionAlias   | {}                          | An object which maps extension to extension aliases                                                                                                       |
 | cachePredicate   | function() { return true }; | A function which decides whether a request should be cached or not. An object is passed to the function with `path` and `request` properties.             |
 | cacheWithContext | true                        | If unsafe cache is enabled, includes `request.context` in the cache key                                                                                   |
 | conditionNames   | ["node"]                    | A list of exports field condition names                                                                                                                   |
@@ -142,7 +143,7 @@ enhanced-resolve will try to resolve requests containing `#` as path and as frag
 ## Tests
 
 ```javascript
-npm test
+yarn test
 ```
 
 [![Build Status](https://secure.travis-ci.org/webpack/enhanced-resolve.png?branch=main)](http://travis-ci.org/webpack/enhanced-resolve)
diff --git a/lib/ExtensionAliasPlugin.js b/lib/ExtensionAliasPlugin.js
new file mode 100644
index 0000000..76ce9b4
--- /dev/null
+++ b/lib/ExtensionAliasPlugin.js
@@ -0,0 +1,68 @@
+/*
+	MIT License http://www.opensource.org/licenses/mit-license.php
+	Author Ivan Kopeykin @vankop
+*/
+
+"use strict";
+
+const forEachBail = require("./forEachBail");
+
+/** @typedef {import("./Resolver")} Resolver */
+/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */
+/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
+/** @typedef {{ alias: string|string[], extension: string }} ExtensionAliasOption */
+
+module.exports = class ExtensionAliasPlugin {
+	/**
+	 * @param {string | ResolveStepHook} source source
+	 * @param {ExtensionAliasOption} options options
+	 * @param {string | ResolveStepHook} target target
+	 */
+	constructor(source, options, target) {
+		this.source = source;
+		this.options = options;
+		this.target = target;
+	}
+
+	/**
+	 * @param {Resolver} resolver the resolver
+	 * @returns {void}
+	 */
+	apply(resolver) {
+		const target = resolver.ensureHook(this.target);
+		const { extension, alias } = this.options;
+		resolver
+			.getHook(this.source)
+			.tapAsync("ExtensionAliasPlugin", (request, resolveContext, callback) => {
+				const requestPath = request.request;
+				if (!requestPath || !requestPath.endsWith(extension)) return callback();
+				const resolve = (alias, callback) => {
+					resolver.doResolve(
+						target,
+						{
+							...request,
+							request: `${requestPath.slice(0, -extension.length)}${alias}`,
+							fullySpecified: true
+						},
+						`aliased from extension alias with mapping '${extension}' to '${alias}'`,
+						resolveContext,
+						callback
+					);
+				};
+
+				const stoppingCallback = (err, result) => {
+					if (err) return callback(err);
+					if (result) return callback(null, result);
+					// Don't allow other aliasing or raw request
+					return callback(null, null);
+				};
+				if (typeof alias === "string") {
+					resolve(alias, stoppingCallback);
+				} else if (alias.length > 1) {
+					forEachBail(alias, resolve, stoppingCallback);
+				} else {
+					resolve(alias[0], stoppingCallback);
+				}
+			});
+	}
+};
diff --git a/lib/ResolverFactory.js b/lib/ResolverFactory.js
index 7a290de..6af60a5 100644
--- a/lib/ResolverFactory.js
+++ b/lib/ResolverFactory.js
@@ -18,6 +18,7 @@ const ConditionalPlugin = require("./ConditionalPlugin");
 const DescriptionFilePlugin = require("./DescriptionFilePlugin");
 const DirectoryExistsPlugin = require("./DirectoryExistsPlugin");
 const ExportsFieldPlugin = require("./ExportsFieldPlugin");
+const ExtensionAliasPlugin = require("./ExtensionAliasPlugin");
 const FileExistsPlugin = require("./FileExistsPlugin");
 const ImportsFieldPlugin = require("./ImportsFieldPlugin");
 const JoinRequestPartPlugin = require("./JoinRequestPartPlugin");
@@ -38,6 +39,7 @@ const UnsafeCachePlugin = require("./UnsafeCachePlugin");
 const UseFilePlugin = require("./UseFilePlugin");
 
 /** @typedef {import("./AliasPlugin").AliasOption} AliasOptionEntry */
+/** @typedef {import("./ExtensionAliasPlugin").ExtensionAliasOption} ExtensionAliasOption */
 /** @typedef {import("./PnpPlugin").PnpApiImpl} PnpApi */
 /** @typedef {import("./Resolver").FileSystem} FileSystem */
 /** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */
@@ -45,12 +47,14 @@ const UseFilePlugin = require("./UseFilePlugin");
 
 /** @typedef {string|string[]|false} AliasOptionNewRequest */
 /** @typedef {{[k: string]: AliasOptionNewRequest}} AliasOptions */
+/** @typedef {{[k: string]: string|string[] }} ExtensionAliasOptions */
 /** @typedef {{apply: function(Resolver): void} | function(this: Resolver, Resolver): void} Plugin */
 
 /**
  * @typedef {Object} UserResolveOptions
  * @property {(AliasOptions | AliasOptionEntry[])=} alias A list of module alias configurations or an object which maps key to value
  * @property {(AliasOptions | AliasOptionEntry[])=} fallback A list of module alias configurations or an object which maps key to value, applied only after modules option
+ * @property {ExtensionAliasOptions=} extensionAlias An object which maps extension to extension aliases
  * @property {(string | string[])[]=} aliasFields A list of alias fields in description files
  * @property {(function(ResolveRequest): boolean)=} cachePredicate A function which decides whether a request should be cached or not. An object is passed with at least `path` and `request` properties.
  * @property {boolean=} cacheWithContext Whether or not the unsafeCache should include request context as part of the cache key.
@@ -83,6 +87,7 @@ const UseFilePlugin = require("./UseFilePlugin");
  * @property {AliasOptionEntry[]} alias
  * @property {AliasOptionEntry[]} fallback
  * @property {Set<string | string[]>} aliasFields
+ * @property {ExtensionAliasOption[]} extensionAlias
  * @property {(function(ResolveRequest): boolean)} cachePredicate
  * @property {boolean} cacheWithContext
  * @property {Set<string>} conditionNames A list of exports field condition names.
@@ -197,6 +202,14 @@ function createOptions(options) {
 					: false
 				: options.enforceExtension,
 		extensions: new Set(options.extensions || [".js", ".json", ".node"]),
+		extensionAlias: options.extensionAlias
+			? Object.keys(options.extensionAlias).map(k => ({
+					extension: k,
+					alias: /** @type {ExtensionAliasOptions} */ (options.extensionAlias)[
+						k
+					]
+			  }))
+			: [],
 		fileSystem: options.useSyncFileSystemCalls
 			? new SyncAsyncFileSystemDecorator(
 					/** @type {SyncFileSystem} */ (
@@ -251,6 +264,7 @@ exports.createResolver = function (options) {
 		descriptionFiles,
 		enforceExtension,
 		exportsFields,
+		extensionAlias,
 		importsFields,
 		extensions,
 		fileSystem,
@@ -283,6 +297,8 @@ exports.createResolver = function (options) {
 	resolver.ensureHook("newInternalResolve");
 	resolver.ensureHook("parsedResolve");
 	resolver.ensureHook("describedResolve");
+	resolver.ensureHook("rawResolve");
+	resolver.ensureHook("normalResolve");
 	resolver.ensureHook("internal");
 	resolver.ensureHook("rawModule");
 	resolver.ensureHook("module");
@@ -342,21 +358,28 @@ exports.createResolver = function (options) {
 	plugins.push(new NextPlugin("after-parsed-resolve", "described-resolve"));
 
 	// described-resolve
-	plugins.push(new NextPlugin("described-resolve", "normal-resolve"));
+	plugins.push(new NextPlugin("described-resolve", "raw-resolve"));
 	if (fallback.length > 0) {
 		plugins.push(
 			new AliasPlugin("described-resolve", fallback, "internal-resolve")
 		);
 	}
 
-	// normal-resolve
-	if (alias.length > 0)
-		plugins.push(new AliasPlugin("normal-resolve", alias, "internal-resolve"));
+	// raw-resolve
+	if (alias.length > 0) {
+		plugins.push(new AliasPlugin("raw-resolve", alias, "internal-resolve"));
+	}
 	aliasFields.forEach(item => {
-		plugins.push(
-			new AliasFieldPlugin("normal-resolve", item, "internal-resolve")
-		);
+		plugins.push(new AliasFieldPlugin("raw-resolve", item, "internal-resolve"));
 	});
+	extensionAlias.forEach(item =>
+		plugins.push(
+			new ExtensionAliasPlugin("raw-resolve", item, "normal-resolve")
+		)
+	);
+	plugins.push(new NextPlugin("raw-resolve", "normal-resolve"));
+
+	// normal-resolve
 	if (preferRelative) {
 		plugins.push(new JoinRequestPlugin("after-normal-resolve", "relative"));
 	}
diff --git a/test/fixtures/extension-alias/dir/index.js b/test/fixtures/extension-alias/dir/index.js
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/extension-alias/dir/index.ts b/test/fixtures/extension-alias/dir/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/extension-alias/dir2/index.js b/test/fixtures/extension-alias/dir2/index.js
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/extension-alias/dir2/index.mts b/test/fixtures/extension-alias/dir2/index.mts
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/extension-alias/index.js b/test/fixtures/extension-alias/index.js
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/extension-alias/index.mjs b/test/fixtures/extension-alias/index.mjs
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/extension-alias/index.mts.js b/test/fixtures/extension-alias/index.mts.js
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/extension-alias/index.ts b/test/fixtures/extension-alias/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/types.d.ts b/types.d.ts
index c418c89..0143653 100644
--- a/types.d.ts
+++ b/types.d.ts
@@ -91,6 +91,13 @@ declare class CloneBasenamePlugin {
 	target: any;
 	apply(resolver: Resolver): void;
 }
+declare interface ExtensionAliasOption {
+	alias: string | string[];
+	extension: string;
+}
+declare interface ExtensionAliasOptions {
+	[index: string]: string | string[];
+}
 declare interface FileSystem {
 	readFile: {
 		(arg0: string, arg1: FileSystemCallback<string | Buffer>): void;
@@ -214,6 +221,7 @@ declare interface ResolveOptions {
 	alias: AliasOption[];
 	fallback: AliasOption[];
 	aliasFields: Set<string | string[]>;
+	extensionAlias: ExtensionAliasOption[];
 	cachePredicate: (arg0: ResolveRequest) => boolean;
 	cacheWithContext: boolean;
 
@@ -322,6 +330,11 @@ declare interface UserResolveOptions {
 	 */
 	fallback?: AliasOptions | AliasOption[];
 
+	/**
+	 * An object which maps extension to extension aliases
+	 */
+	extensionAlias?: ExtensionAliasOptions;
+
 	/**
 	 * A list of alias fields in description files
 	 */


Test patch:
diff --git a/test/extension-alias.js b/test/extension-alias.js
new file mode 100644
index 0000000..0ebca62
--- /dev/null
+++ b/test/extension-alias.js
@@ -0,0 +1,89 @@
+const path = require("path");
+const fs = require("fs");
+const should = require("should");
+
+const CachedInputFileSystem = require("../lib/CachedInputFileSystem");
+const ResolverFactory = require("../lib/ResolverFactory");
+
+/** @typedef {import("../lib/util/entrypoints").ImportsField} ImportsField */
+
+describe("extension-alias", () => {
+	const fixture = path.resolve(__dirname, "fixtures", "extension-alias");
+	const nodeFileSystem = new CachedInputFileSystem(fs, 4000);
+
+	const resolver = ResolverFactory.createResolver({
+		extensions: [".js"],
+		fileSystem: nodeFileSystem,
+		mainFiles: ["index.js"],
+		extensionAlias: {
+			".js": [".ts", ".js"],
+			".mjs": ".mts"
+		}
+	});
+
+	it("should alias fully specified file", done => {
+		resolver.resolve({}, fixture, "./index.js", {}, (err, result) => {
+			if (err) return done(err);
+			should(result).be.eql(path.resolve(fixture, "index.ts"));
+			done();
+		});
+	});
+
+	it("should alias fully specified file when there are two alternatives", done => {
+		resolver.resolve({}, fixture, "./dir/index.js", {}, (err, result) => {
+			if (err) return done(err);
+			should(result).be.eql(path.resolve(fixture, "dir", "index.ts"));
+			done();
+		});
+	});
+
+	it("should also allow the second alternative", done => {
+		resolver.resolve({}, fixture, "./dir2/index.js", {}, (err, result) => {
+			if (err) return done(err);
+			should(result).be.eql(path.resolve(fixture, "dir2", "index.js"));
+			done();
+		});
+	});
+
+	it("should support alias option without an array", done => {
+		resolver.resolve({}, fixture, "./dir2/index.mjs", {}, (err, result) => {
+			if (err) return done(err);
+			should(result).be.eql(path.resolve(fixture, "dir2", "index.mts"));
+			done();
+		});
+	});
+
+	it("should not allow to fallback to the original extension or add extensions", done => {
+		resolver.resolve({}, fixture, "./index.mjs", {}, (err, result) => {
+			should(err).be.instanceOf(Error);
+			done();
+		});
+	});
+
+	describe("should not apply extension alias to extensions or mainFiles field", () => {
+		const resolver = ResolverFactory.createResolver({
+			extensions: [".js"],
+			fileSystem: nodeFileSystem,
+			mainFiles: ["index.js"],
+			extensionAlias: {
+				".js": []
+			}
+		});
+
+		it("directory", done => {
+			resolver.resolve({}, fixture, "./dir2", {}, (err, result) => {
+				if (err) return done(err);
+				should(result).be.eql(path.resolve(fixture, "dir2", "index.js"));
+				done();
+			});
+		});
+
+		it("file", done => {
+			resolver.resolve({}, fixture, "./dir2/index", {}, (err, result) => {
+				if (err) return done(err);
+				should(result).be.eql(path.resolve(fixture, "dir2", "index.js"));
+				done();
+			});
+		});
+	});
+});