From 50aa0ca3d7bbe82e2d4b6161f66835ea443e9598 Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Fri, 12 Sep 2025 01:43:42 +0800 Subject: [PATCH 01/12] feat: add TsconfigPathsPlugin --- .cspell.json | 5 +- README.md | 1 + lib/AliasPlugin.js | 145 +------ lib/AliasUtils.js | 172 ++++++++ lib/ModulesInHierarchicalDirectoriesPlugin.js | 58 +-- lib/ModulesUtils.js | 83 ++++ lib/Resolver.js | 9 +- lib/ResolverFactory.js | 9 +- lib/TsconfigPathsPlugin.js | 127 ++++++ lib/TsconfigPathsUtils.js | 299 ++++++++++++++ lib/index.js | 4 + test/alias.test.js | 1 + test/browserField.test.js | 1 + test/fallback.test.js | 1 + .../base/src/components/button.ts | 3 + .../base/src/components/new-file.ts | 1 + .../fixtures/tsconfig-paths/base/src/index.ts | 23 ++ .../base/src/mapped/bar/file1.ts | 1 + .../base/src/mapped/foo/index.ts | 1 + .../base/src/mapped/longest/one.ts | 1 + .../base/src/mapped/longest/three.ts | 1 + .../base/src/mapped/longest/two.ts | 1 + .../star/browser-field-package/browser.ts | 1 + .../mapped/star/browser-field-package/node.ts | 1 + .../star/browser-field-package/package.json | 5 + .../mapped/star/main-field-package/node.ts | 1 + .../star/main-field-package/package.json | 4 + .../star/no-main-field-package/index.ts | 1 + .../star/no-main-field-package/package.json | 3 + .../base/src/mapped/star/star-bar/index.ts | 1 + .../tsconfig-paths/base/src/refs/index.ts | 1 + .../tsconfig-paths/base/src/utils/date.ts | 3 + .../tsconfig-paths/base/tsconfig.json | 20 + .../extends-base/src/components/button.ts | 3 + .../tsconfig-paths/extends-base/src/index.ts | 8 + .../extends-base/src/utils/date.ts | 3 + .../tsconfig-paths/extends-base/tsconfig.json | 6 + test/fixtures/tsconfig-paths/pkg/src/index.ts | 16 + .../pkg/src/mapped/bar/file1.ts | 1 + .../pkg/src/mapped/foo/index.ts | 1 + .../star/browser-field-package/browser.ts | 1 + .../mapped/star/browser-field-package/node.ts | 1 + .../star/browser-field-package/package.json | 5 + .../mapped/star/main-field-package/node.ts | 1 + .../star/main-field-package/package.json | 4 + .../star/no-main-field-package/index.ts | 1 + .../star/no-main-field-package/package.json | 3 + .../pkg/src/mapped/star/star-bar/index.ts | 1 + .../tsconfig-paths/pkg/src/refs/index.ts | 1 + .../fixtures/tsconfig-paths/pkg/tsconfig.json | 13 + test/fullSpecified.test.js | 2 + test/resolve.test.js | 4 + test/symlink.test.js | 2 + test/tsconfig-paths.test.js | 390 ++++++++++++++++++ types.d.ts | 42 ++ 55 files changed, 1306 insertions(+), 191 deletions(-) create mode 100644 lib/AliasUtils.js create mode 100644 lib/ModulesUtils.js create mode 100644 lib/TsconfigPathsPlugin.js create mode 100644 lib/TsconfigPathsUtils.js create mode 100644 test/fixtures/tsconfig-paths/base/src/components/button.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/components/new-file.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/index.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/bar/file1.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/foo/index.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/longest/one.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/longest/three.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/longest/two.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/browser.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/node.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/package.json create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/star/main-field-package/node.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/star/main-field-package/package.json create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/star/no-main-field-package/index.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/star/no-main-field-package/package.json create mode 100644 test/fixtures/tsconfig-paths/base/src/mapped/star/star-bar/index.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/refs/index.ts create mode 100644 test/fixtures/tsconfig-paths/base/src/utils/date.ts create mode 100644 test/fixtures/tsconfig-paths/base/tsconfig.json create mode 100644 test/fixtures/tsconfig-paths/extends-base/src/components/button.ts create mode 100644 test/fixtures/tsconfig-paths/extends-base/src/index.ts create mode 100644 test/fixtures/tsconfig-paths/extends-base/src/utils/date.ts create mode 100644 test/fixtures/tsconfig-paths/extends-base/tsconfig.json create mode 100644 test/fixtures/tsconfig-paths/pkg/src/index.ts create mode 100644 test/fixtures/tsconfig-paths/pkg/src/mapped/bar/file1.ts create mode 100644 test/fixtures/tsconfig-paths/pkg/src/mapped/foo/index.ts create mode 100644 test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/browser.ts create mode 100644 test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/node.ts create mode 100644 test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/package.json create mode 100644 test/fixtures/tsconfig-paths/pkg/src/mapped/star/main-field-package/node.ts create mode 100644 test/fixtures/tsconfig-paths/pkg/src/mapped/star/main-field-package/package.json create mode 100644 test/fixtures/tsconfig-paths/pkg/src/mapped/star/no-main-field-package/index.ts create mode 100644 test/fixtures/tsconfig-paths/pkg/src/mapped/star/no-main-field-package/package.json create mode 100644 test/fixtures/tsconfig-paths/pkg/src/mapped/star/star-bar/index.ts create mode 100644 test/fixtures/tsconfig-paths/pkg/src/refs/index.ts create mode 100644 test/fixtures/tsconfig-paths/pkg/tsconfig.json create mode 100644 test/tsconfig-paths.test.js diff --git a/.cspell.json b/.cspell.json index 38717337..0db2a984 100644 --- a/.cspell.json +++ b/.cspell.json @@ -34,7 +34,10 @@ "zipp", "zippi", "zizizi", - "codecov" + "codecov", + "xiaoxiaojx", + "Natsu", + "tsconfigs" ], "ignorePaths": ["package.json", "yarn.lock", "coverage", "*.log"] } diff --git a/README.md b/README.md index 8a6efb2d..f7ace52a 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ myResolver.resolve( | restrictions | [] | A list of resolve restrictions | | roots | [] | A list of root paths | | symlinks | true | Whether to resolve symlinks to their symlinked location | +| tsconfig | true | Path to tsconfig.json for paths mapping. Default true loads tsconfig.json, or pass a string path. | | unsafeCache | false | Use this cache object to unsafely cache the successful requests | ## Plugins diff --git a/lib/AliasPlugin.js b/lib/AliasPlugin.js index 03dbbb15..4bc6e188 100644 --- a/lib/AliasPlugin.js +++ b/lib/AliasPlugin.js @@ -5,15 +5,13 @@ "use strict"; -const forEachBail = require("./forEachBail"); -const { PathType, getType } = require("./util/path"); - /** @typedef {import("./Resolver")} Resolver */ -/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */ /** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */ /** @typedef {string | Array | false} Alias */ /** @typedef {{alias: Alias, name: string, onlyModule?: boolean}} AliasOption */ +const { aliasResolveHandler } = require("./AliasUtils"); + module.exports = class AliasPlugin { /** * @param {string | ResolveStepHook} source source @@ -32,143 +30,16 @@ module.exports = class AliasPlugin { */ apply(resolver) { const target = resolver.ensureHook(this.target); - /** - * @param {string} maybeAbsolutePath path - * @returns {null|string} absolute path with slash ending - */ - const getAbsolutePathWithSlashEnding = (maybeAbsolutePath) => { - const type = getType(maybeAbsolutePath); - if (type === PathType.AbsolutePosix || type === PathType.AbsoluteWin) { - return resolver.join(maybeAbsolutePath, "_").slice(0, -1); - } - return null; - }; - /** - * @param {string} path path - * @param {string} maybeSubPath sub path - * @returns {boolean} true, if path is sub path - */ - const isSubPath = (path, maybeSubPath) => { - const absolutePath = getAbsolutePathWithSlashEnding(maybeSubPath); - if (!absolutePath) return false; - return path.startsWith(absolutePath); - }; + resolver .getHook(this.source) .tapAsync("AliasPlugin", (request, resolveContext, callback) => { - const innerRequest = request.request || request.path; - if (!innerRequest) return callback(); - - forEachBail( + aliasResolveHandler( + resolver, this.options, - (item, callback) => { - /** @type {boolean} */ - let shouldStop = false; - - const matchRequest = - innerRequest === item.name || - (!item.onlyModule && - (request.request - ? innerRequest.startsWith(`${item.name}/`) - : isSubPath(innerRequest, item.name))); - - const splitName = item.name.split("*"); - const matchWildcard = !item.onlyModule && splitName.length === 2; - - if (matchRequest || matchWildcard) { - /** - * @param {Alias} alias alias - * @param {(err?: null|Error, result?: null|ResolveRequest) => void} callback callback - * @returns {void} - */ - const resolveWithAlias = (alias, callback) => { - if (alias === false) { - /** @type {ResolveRequest} */ - const ignoreObj = { - ...request, - path: false, - }; - if (typeof resolveContext.yield === "function") { - resolveContext.yield(ignoreObj); - return callback(null, null); - } - return callback(null, ignoreObj); - } - - let newRequestStr; - - const [prefix, suffix] = splitName; - if ( - matchWildcard && - innerRequest.startsWith(prefix) && - innerRequest.endsWith(suffix) - ) { - const match = innerRequest.slice( - prefix.length, - innerRequest.length - suffix.length, - ); - newRequestStr = alias.toString().replace("*", match); - } - - if ( - matchRequest && - innerRequest !== alias && - !innerRequest.startsWith(`${alias}/`) - ) { - /** @type {string} */ - const remainingRequest = innerRequest.slice(item.name.length); - newRequestStr = alias + remainingRequest; - } - - if (newRequestStr !== undefined) { - shouldStop = true; - /** @type {ResolveRequest} */ - const obj = { - ...request, - request: newRequestStr, - fullySpecified: false, - }; - return resolver.doResolve( - target, - obj, - `aliased with mapping '${item.name}': '${alias}' to '${newRequestStr}'`, - resolveContext, - (err, result) => { - if (err) return callback(err); - if (result) return callback(null, result); - return callback(); - }, - ); - } - return callback(); - }; - - /** - * @param {(null | Error)=} err error - * @param {(null | ResolveRequest)=} result result - * @returns {void} - */ - const stoppingCallback = (err, result) => { - if (err) return callback(err); - - if (result) return callback(null, result); - // Don't allow other aliasing or raw request - if (shouldStop) return callback(null, null); - return callback(); - }; - - if (Array.isArray(item.alias)) { - return forEachBail( - item.alias, - resolveWithAlias, - stoppingCallback, - ); - } - return resolveWithAlias(item.alias, stoppingCallback); - } - - return callback(); - }, + target, + request, + resolveContext, callback, ); }); diff --git a/lib/AliasUtils.js b/lib/AliasUtils.js new file mode 100644 index 00000000..df04040f --- /dev/null +++ b/lib/AliasUtils.js @@ -0,0 +1,172 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Tobias Koppers @sokra +*/ + +"use strict"; + +const forEachBail = require("./forEachBail"); +const { PathType, getType } = require("./util/path"); + +/** @typedef {import("./Resolver")} Resolver */ +/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */ +/** @typedef {import("./Resolver").ResolveContext} ResolveContext */ +/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */ +/** @typedef {import("./Resolver").ResolveCallback} ResolveCallback */ +/** @typedef {string | Array | false} Alias */ +/** @typedef {{alias: Alias, name: string, onlyModule?: boolean}} AliasOption */ + +/** @typedef {(err?: null | Error, result?: null | ResolveRequest) => void} InnerCallback */ +/** + * @param {Resolver} resolver resolver + * @param {Array} options options + * @param {ResolveStepHook} target target + * @param {ResolveRequest} request request + * @param {ResolveContext} resolveContext resolve context + * @param {InnerCallback} callback callback + * @returns {void} + */ +function aliasResolveHandler( + resolver, + options, + target, + request, + resolveContext, + callback, +) { + const innerRequest = request.request || request.path; + if (!innerRequest) return callback(); + + /** + * @param {string} maybeAbsolutePath path + * @returns {null|string} absolute path with slash ending + */ + const getAbsolutePathWithSlashEnding = (maybeAbsolutePath) => { + const type = getType(maybeAbsolutePath); + if (type === PathType.AbsolutePosix || type === PathType.AbsoluteWin) { + return resolver.join(maybeAbsolutePath, "_").slice(0, -1); + } + return null; + }; + /** + * @param {string} path path + * @param {string} maybeSubPath sub path + * @returns {boolean} true, if path is sub path + */ + const isSubPath = (path, maybeSubPath) => { + const absolutePath = getAbsolutePathWithSlashEnding(maybeSubPath); + if (!absolutePath) return false; + return path.startsWith(absolutePath); + }; + + forEachBail( + options, + (item, callback) => { + /** @type {boolean} */ + let shouldStop = false; + + const matchRequest = + innerRequest === item.name || + (!item.onlyModule && + (request.request + ? innerRequest.startsWith(`${item.name}/`) + : isSubPath(innerRequest, item.name))); + + const splitName = item.name.split("*"); + const matchWildcard = !item.onlyModule && splitName.length === 2; + + if (matchRequest || matchWildcard) { + /** + * @param {Alias} alias alias + * @param {(err?: null|Error, result?: null|ResolveRequest) => void} callback callback + * @returns {void} + */ + const resolveWithAlias = (alias, callback) => { + if (alias === false) { + /** @type {ResolveRequest} */ + const ignoreObj = { + ...request, + path: false, + }; + if (typeof resolveContext.yield === "function") { + resolveContext.yield(ignoreObj); + return callback(null, null); + } + return callback(null, ignoreObj); + } + + let newRequestStr; + + const [prefix, suffix] = splitName; + if ( + matchWildcard && + innerRequest.startsWith(prefix) && + innerRequest.endsWith(suffix) + ) { + const match = innerRequest.slice( + prefix.length, + innerRequest.length - suffix.length, + ); + newRequestStr = alias.toString().replace("*", match); + } + + if ( + matchRequest && + innerRequest !== alias && + !innerRequest.startsWith(`${alias}/`) + ) { + /** @type {string} */ + const remainingRequest = innerRequest.slice(item.name.length); + newRequestStr = alias + remainingRequest; + } + + if (newRequestStr !== undefined) { + shouldStop = true; + /** @type {ResolveRequest} */ + const obj = { + ...request, + request: newRequestStr, + fullySpecified: false, + }; + return resolver.doResolve( + target, + obj, + `aliased with mapping '${item.name}': '${alias}' to '${newRequestStr}'`, + resolveContext, + (err, result) => { + if (err) return callback(err); + if (result) return callback(null, result); + return callback(); + }, + ); + } + return callback(); + }; + + /** + * @param {(null | Error)=} err error + * @param {(null | ResolveRequest)=} result result + * @returns {void} + */ + const stoppingCallback = (err, result) => { + if (err) return callback(err); + + if (result) return callback(null, result); + // Don't allow other aliasing or raw request + if (shouldStop) return callback(null, null); + return callback(); + }; + + if (Array.isArray(item.alias)) { + return forEachBail(item.alias, resolveWithAlias, stoppingCallback); + } + return resolveWithAlias(item.alias, stoppingCallback); + } + + return callback(); + }, + callback, + ); +} + +module.exports.aliasResolveHandler = aliasResolveHandler; diff --git a/lib/ModulesInHierarchicalDirectoriesPlugin.js b/lib/ModulesInHierarchicalDirectoriesPlugin.js index 8ed78cdb..a2c9dfe1 100644 --- a/lib/ModulesInHierarchicalDirectoriesPlugin.js +++ b/lib/ModulesInHierarchicalDirectoriesPlugin.js @@ -5,11 +5,9 @@ "use strict"; -const forEachBail = require("./forEachBail"); -const getPaths = require("./getPaths"); +const { modulesResolveHandler } = require("./ModulesUtils"); /** @typedef {import("./Resolver")} Resolver */ -/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */ /** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */ module.exports = class ModulesInHierarchicalDirectoriesPlugin { @@ -35,54 +33,12 @@ module.exports = class ModulesInHierarchicalDirectoriesPlugin { .tapAsync( "ModulesInHierarchicalDirectoriesPlugin", (request, resolveContext, callback) => { - const fs = resolver.fileSystem; - const addrs = getPaths(/** @type {string} */ (request.path)) - .paths.map((path) => - this.directories.map((directory) => - resolver.join(path, directory), - ), - ) - .reduce((array, path) => { - array.push(...path); - return array; - }, []); - forEachBail( - addrs, - /** - * @param {string} addr addr - * @param {(err?: null|Error, result?: null|ResolveRequest) => void} callback callback - * @returns {void} - */ - (addr, callback) => { - fs.stat(addr, (err, stat) => { - if (!err && stat && stat.isDirectory()) { - /** @type {ResolveRequest} */ - const obj = { - ...request, - path: addr, - request: `./${request.request}`, - module: false, - }; - const message = `looking for modules in ${addr}`; - return resolver.doResolve( - target, - obj, - message, - resolveContext, - callback, - ); - } - if (resolveContext.log) { - resolveContext.log( - `${addr} doesn't exist or is not a directory`, - ); - } - if (resolveContext.missingDependencies) { - resolveContext.missingDependencies.add(addr); - } - return callback(); - }); - }, + modulesResolveHandler( + resolver, + this.directories, + target, + request, + resolveContext, callback, ); }, diff --git a/lib/ModulesUtils.js b/lib/ModulesUtils.js new file mode 100644 index 00000000..2860f9f9 --- /dev/null +++ b/lib/ModulesUtils.js @@ -0,0 +1,83 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Tobias Koppers @sokra +*/ + +"use strict"; + +const forEachBail = require("./forEachBail"); +const getPaths = require("./getPaths"); + +/** @typedef {import("./Resolver")} Resolver */ +/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */ +/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */ +/** @typedef {import("./Resolver").ResolveContext} ResolveContext */ +/** @typedef {(err?: null | Error, result?: null | ResolveRequest) => void} InnerCallback */ +/** + * @param {Resolver} resolver resolver + * @param {Array} directories directories + * @param {ResolveStepHook} target target + * @param {ResolveRequest} request request + * @param {ResolveContext} resolveContext resolve context + * @param {InnerCallback} callback callback + * @returns {void} + */ +function modulesResolveHandler( + resolver, + directories, + target, + request, + resolveContext, + callback, +) { + const fs = resolver.fileSystem; + const addrs = getPaths(/** @type {string} */ (request.path)) + .paths.map((path) => + directories.map((directory) => resolver.join(path, directory)), + ) + .reduce((array, path) => { + array.push(...path); + return array; + }, []); + forEachBail( + addrs, + /** + * @param {string} addr addr + * @param {(err?: null|Error, result?: null|ResolveRequest) => void} callback callback + * @returns {void} + */ + (addr, callback) => { + fs.stat(addr, (err, stat) => { + if (!err && stat && stat.isDirectory()) { + /** @type {ResolveRequest} */ + const obj = { + ...request, + path: addr, + request: `./${request.request}`, + module: false, + }; + const message = `looking for modules in ${addr}`; + return resolver.doResolve( + target, + obj, + message, + resolveContext, + callback, + ); + } + if (resolveContext.log) { + resolveContext.log(`${addr} doesn't exist or is not a directory`); + } + if (resolveContext.missingDependencies) { + resolveContext.missingDependencies.add(addr); + } + return callback(); + }); + }, + callback, + ); +} + +module.exports = { + modulesResolveHandler, +}; diff --git a/lib/Resolver.js b/lib/Resolver.js index 8267ac2b..3f88b8e9 100644 --- a/lib/Resolver.js +++ b/lib/Resolver.js @@ -16,7 +16,7 @@ const { } = require("./util/path"); /** @typedef {import("./ResolverFactory").ResolveOptions} ResolveOptions */ - +/** @typedef {import("./AliasUtils").AliasOption} AliasOption */ /** @typedef {Error & { details?: string }} ErrorWithDetail */ /** @typedef {(err: ErrorWithDetail | null, res?: string | false, req?: ResolveRequest) => void} ResolveCallback */ @@ -292,6 +292,13 @@ const { // eslint-disable-next-line jsdoc/require-property /** @typedef {object} Context */ +/** + * @typedef {object} TsconfigPathsData + * @property {Array} alias tsconfig file data + * @property {Array} modules tsconfig file data + * @property {Set} fileDependencies file dependencies + */ + /** * @typedef {object} BaseResolveRequest * @property {string | false} path path diff --git a/lib/ResolverFactory.js b/lib/ResolverFactory.js index 266dd695..c0337f30 100644 --- a/lib/ResolverFactory.js +++ b/lib/ResolverFactory.js @@ -34,6 +34,7 @@ const SelfReferencePlugin = require("./SelfReferencePlugin"); const SymlinkPlugin = require("./SymlinkPlugin"); const SyncAsyncFileSystemDecorator = require("./SyncAsyncFileSystemDecorator"); const TryNextPlugin = require("./TryNextPlugin"); +const TsconfigPathsPlugin = require("./TsconfigPathsPlugin"); const UnsafeCachePlugin = require("./UnsafeCachePlugin"); const UseFilePlugin = require("./UseFilePlugin"); const { PathType, getType } = require("./util/path"); @@ -84,6 +85,7 @@ const { PathType, getType } = require("./util/path"); * @property {boolean=} useSyncFileSystemCalls Use only the sync constraints of the file system calls * @property {boolean=} preferRelative Prefer to resolve module requests as relative requests before falling back to modules * @property {boolean=} preferAbsolute Prefer to resolve server-relative urls as absolute paths before falling back to resolve in roots + * @property {string|boolean=} tsconfig tsconfig file path */ /** @@ -115,6 +117,7 @@ const { PathType, getType } = require("./util/path"); * @property {Set} restrictions restrictions * @property {boolean} preferRelative prefer relative * @property {boolean} preferAbsolute prefer absolute + * @property {string|boolean=} tsconfig tsconfig file path */ /** @@ -294,6 +297,7 @@ function createOptions(options) { preferRelative: options.preferRelative || false, preferAbsolute: options.preferAbsolute || false, restrictions: new Set(options.restrictions), + tsconfig: typeof options.tsconfig === "undefined" ? true : options.tsconfig, }; } @@ -332,6 +336,7 @@ module.exports.createResolver = function createResolver(options) { resolver: customResolver, restrictions, roots, + tsconfig, } = normalizedOptions; const plugins = [...userPlugins]; @@ -415,11 +420,13 @@ module.exports.createResolver = function createResolver(options) { new AliasPlugin("described-resolve", fallback, "internal-resolve"), ); } - // raw-resolve if (alias.length > 0) { plugins.push(new AliasPlugin("raw-resolve", alias, "internal-resolve")); } + if (tsconfig) { + plugins.push(new TsconfigPathsPlugin(tsconfig)); + } for (const item of aliasFields) { plugins.push(new AliasFieldPlugin("raw-resolve", item, "internal-resolve")); } diff --git a/lib/TsconfigPathsPlugin.js b/lib/TsconfigPathsPlugin.js new file mode 100644 index 00000000..2e1fe818 --- /dev/null +++ b/lib/TsconfigPathsPlugin.js @@ -0,0 +1,127 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Natsu @xiaoxiaojx +*/ + +"use strict"; + +const { aliasResolveHandler } = require("./AliasUtils"); +const { modulesResolveHandler } = require("./ModulesUtils"); + +const TsconfigPathsUtils = require("./TsconfigPathsUtils"); + +/** @typedef {import("./Resolver")} Resolver */ +/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */ +/** @typedef {import("./AliasUtils").AliasOption} AliasOption */ +/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */ +/** @typedef {import("./Resolver").ResolveContext} ResolveContext */ +/** @typedef {import("./Resolver").TsconfigPathsData} TsconfigPathsData */ + +const DEFAULT_CONFIG_FILE = "tsconfig.json"; + +module.exports = class TsconfigPathsPlugin { + /** + * @param {true | string} configFile tsconfig file path + */ + constructor(configFile) { + this.configFile = + configFile === true + ? DEFAULT_CONFIG_FILE + : /** @type {string} */ (configFile); + /** @type {TsconfigPathsData|undefined|null} */ + this.tsconfigPathsData = undefined; + } + + /** + * @param {Resolver} resolver the resolver + * @returns {void} + */ + apply(resolver) { + const aliasTarget = resolver.ensureHook("internal-resolve"); + const moduleTarget = resolver.ensureHook("module"); + + resolver + .getHook("raw-resolve") + .tapAsync( + "TsconfigPathsPlugin", + async (request, resolveContext, callback) => { + try { + const tsconfigPathsData = await this.loadTsconfigPathsData( + resolver, + request, + resolveContext, + ); + + if (!tsconfigPathsData) return callback(); + + aliasResolveHandler( + resolver, + tsconfigPathsData.alias, + aliasTarget, + request, + resolveContext, + callback, + ); + } catch (err) { + callback(/** @type {Error} */ (err)); + } + }, + ); + + resolver + .getHook("raw-module") + .tapAsync( + "TsconfigPathsPlugin", + async (request, resolveContext, callback) => { + try { + const tsconfigPathsData = await this.loadTsconfigPathsData( + resolver, + request, + resolveContext, + ); + + if (!tsconfigPathsData) return callback(); + + modulesResolveHandler( + resolver, + tsconfigPathsData.modules, + moduleTarget, + request, + resolveContext, + callback, + ); + } catch (err) { + callback(/** @type {Error} */ (err)); + } + }, + ); + } + + /** + * Pre-process request to load tsconfig.json and convert paths to AliasPlugin format + * @param {Resolver} resolver the resolver + * @param {ResolveRequest} request the request + * @param {ResolveContext} resolveContext the resolve context + * @returns {Promise} the pre-processed request + */ + async loadTsconfigPathsData(resolver, request, resolveContext) { + if (typeof this.tsconfigPathsData === "undefined") { + this.tsconfigPathsData = await TsconfigPathsUtils.loadTsconfigPathsData( + resolver.fileSystem, + this.configFile, + request.path || undefined, + ); + } + + if (!this.tsconfigPathsData) { + return null; + } + + for (const fileDependency of this.tsconfigPathsData.fileDependencies) { + if (resolveContext.fileDependencies) { + resolveContext.fileDependencies.add(fileDependency); + } + } + return this.tsconfigPathsData; + } +}; diff --git a/lib/TsconfigPathsUtils.js b/lib/TsconfigPathsUtils.js new file mode 100644 index 00000000..92b3936c --- /dev/null +++ b/lib/TsconfigPathsUtils.js @@ -0,0 +1,299 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Natsu @xiaoxiaojx +*/ + +"use strict"; + +const path = require("path"); +const { join, normalize } = require("./util/path"); + +/** @typedef {import("./Resolver")} Resolver */ +/** @typedef {import("./Resolver").FileSystem} FileSystem */ +/** @typedef {import("./AliasUtils").AliasOption} AliasOption */ +/** @typedef {import("./Resolver").TsconfigPathsData} TsconfigPathsData */ + +/** + * @typedef {object} TsconfigCompilerOptions + * @property {string} baseUrl Base URL for resolving paths + * @property {{[key: string]: string[]}} paths TypeScript paths mapping + */ + +/** + * @typedef {object} Tsconfig + * @property {TsconfigCompilerOptions=} compilerOptions Compiler options + * @property {string|string[]=} extends Extended configuration paths + */ + +/** + * @param {string} pattern Path pattern + * @returns {number} Length of the prefix + */ +function getPrefixLength(pattern) { + const prefixLength = pattern.indexOf("*"); + if (prefixLength === -1) { + return pattern.length; + } + return pattern.slice(0, Math.max(0, prefixLength)).length; +} + +/** + * Sort path patterns. + * If a module name can be matched with multiple patterns then pattern with the longest prefix will be picked. + * @param {string[]} arr Array of path patterns + * @returns {string[]} Array of path patterns sorted by longest prefix + */ +function sortByLongestPrefix(arr) { + return [...arr].sort((a, b) => getPrefixLength(b) - getPrefixLength(a)); +} + +/** + * Merge two tsconfig objects + * @param {Tsconfig} base base config + * @param {Tsconfig} config config to merge + * @returns {Tsconfig} merged config + */ +function mergeTsconfigs(base, config) { + base = base || {}; + config = config || {}; + + return { + ...base, + ...config, + compilerOptions: { + .../** @type {TsconfigCompilerOptions} */ (base.compilerOptions), + .../** @type {TsconfigCompilerOptions} */ (config.compilerOptions), + }, + }; +} + +/** + * Load tsconfig from extends path + * @param {FileSystem} fileSystem the file system + * @param {string} configFilePath current config file path + * @param {string} extendedConfigValue extends value + * @param {Set} fileDependencies the file dependencies + * @returns {Promise} the extended tsconfig + */ +async function loadTsconfigFromExtends( + fileSystem, + configFilePath, + extendedConfigValue, + fileDependencies, +) { + // Add .json extension if not present + if ( + typeof extendedConfigValue === "string" && + !extendedConfigValue.includes(".json") + ) { + extendedConfigValue += ".json"; + } + + const currentDir = path.dirname(configFilePath); + let extendedConfigPath = join(currentDir, extendedConfigValue); + + // Check if file exists, if not try node_modules + const exists = await new Promise((resolve) => { + fileSystem.readFile(extendedConfigPath, (err) => { + resolve(!err); + }); + }); + if ( + !exists && + extendedConfigValue.includes("/") && + extendedConfigValue.includes(".") + ) { + extendedConfigPath = join( + currentDir, + normalize(`node_modules/${extendedConfigValue}`), + ); + } + + const config = + // eslint-disable-next-line no-use-before-define + (await loadTsconfig(fileSystem, extendedConfigPath, fileDependencies)) || + {}; + const compilerOptions = config.compilerOptions || { baseUrl: undefined }; + + // baseUrl should be interpreted as relative to extendedConfigPath, + // but we need to update it so it is relative to the original tsconfig being loaded + if (compilerOptions.baseUrl) { + const extendsDir = path.dirname(extendedConfigValue); + compilerOptions.baseUrl = join(extendsDir, compilerOptions.baseUrl); + } + + return /** @type {Tsconfig} */ (config); +} + +/** + * Load tsconfig.json with extends support (similar to tsconfig-paths) + * @param {FileSystem} fileSystem the file system + * @param {string} configFilePath absolute path to tsconfig.json + * @param {Set} fileDependencies the file dependencies + * @returns {Promise} the merged tsconfig + */ +async function loadTsconfig(fileSystem, configFilePath, fileDependencies) { + try { + const data = await new Promise((resolve, reject) => { + fileSystem.readFile(configFilePath, "utf8", (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + fileDependencies.add(configFilePath); + let config; + try { + config = JSON.parse(/** @type {string} */ (data)); + } catch (err) { + throw new Error( + `${configFilePath} is malformed ${/** @type {Error} */ (err).message}`, + ); + } + + const extendedConfig = config.extends; + if (extendedConfig) { + let base; + + if (Array.isArray(extendedConfig)) { + // Handle multiple extends (array) + base = {}; + for (const extendedConfigElement of extendedConfig) { + const extendedTsconfig = await loadTsconfigFromExtends( + fileSystem, + configFilePath, + extendedConfigElement, + fileDependencies, + ); + base = mergeTsconfigs(base, extendedTsconfig); + } + } else { + // Handle single extends (string) + base = await loadTsconfigFromExtends( + fileSystem, + configFilePath, + extendedConfig, + fileDependencies, + ); + } + + return /** @type {Tsconfig} */ (mergeTsconfigs(base, config)); + } + return config; + } catch (_e) { + return null; + } +} + +/** + * Read tsconfig.json and return normalized compiler options + * @param {FileSystem} fileSystem the file system + * @param {string} absTsconfigPath absolute path to tsconfig.json + * @returns {Promise<{options: TsconfigCompilerOptions, fileDependencies: Set}|null>} the normalized compiler options + */ +async function readTsconfigCompilerOptions(fileSystem, absTsconfigPath) { + try { + /** @type {Set} */ + const fileDependencies = new Set(); + const config = await loadTsconfig( + fileSystem, + absTsconfigPath, + fileDependencies, + ); + if (!config) return null; + + const compilerOptions = config.compilerOptions || { + baseUrl: undefined, + paths: undefined, + }; + let { baseUrl } = compilerOptions; + + baseUrl = !baseUrl + ? path.dirname(absTsconfigPath) + : join(path.dirname(absTsconfigPath), baseUrl); + + const paths = compilerOptions.paths || {}; + return { options: { baseUrl, paths }, fileDependencies }; + } catch (_e) { + return null; + } +} + +/** + * Convert tsconfig paths to resolver options + * @param {{[key: string]: string[]}} paths TypeScript paths mapping + * @param {string} baseUrl Base URL for resolving paths + * @returns {Omit} the resolver options + */ +function tsconfigPathsToResolveOptions(paths, baseUrl) { + /** @type {string[]} */ + const sortedKeys = sortByLongestPrefix(Object.keys(paths)); + /** @type {AliasOption[]} */ + const alias = []; + /** @type {string[]} */ + const modules = []; + + for (const pattern of sortedKeys) { + const mappings = paths[pattern]; + const aliasTargets = mappings.map((mapping) => join(baseUrl, mapping)); + + if (aliasTargets.length > 0) { + if (pattern === "*") { + // Handle "*" pattern - extract modules from wildcard targets + modules.push( + ...aliasTargets + .map((dir) => { + if (/[/\\]\*$/.test(dir)) { + return dir.replace(/[/\\]\*$/, ""); + } + return ""; + }) + .filter(Boolean), + ); + } else { + // Handle regular patterns - add as alias + alias.push({ name: pattern, alias: aliasTargets }); + } + } + } + + return { + alias, + modules, + }; +} + +/** + * Load tsconfig.json (and referenced tsconfigs) and convert paths to AliasPlugin format + * @param {FileSystem} fileSystem the file system + * @param {string} configFile path to tsconfig.json file + * @param {string=} context the resolve context path + * @returns {Promise} Array of alias options for AliasPlugin + */ +async function loadTsconfigPathsData( + fileSystem, + configFile, + context = process.cwd(), +) { + try { + const absTsconfigPath = join(context, configFile); + const result = await readTsconfigCompilerOptions( + fileSystem, + absTsconfigPath, + ); + if (!result) return null; + const { options: compilerOptions, fileDependencies } = result; + if (!compilerOptions) return null; + + return { + ...tsconfigPathsToResolveOptions( + compilerOptions.paths, + compilerOptions.baseUrl, + ), + fileDependencies, + }; + } catch (_e) { + return null; + } +} + +module.exports.loadTsconfigPathsData = loadTsconfigPathsData; diff --git a/lib/index.js b/lib/index.js index 9b101430..264f906a 100644 --- a/lib/index.js +++ b/lib/index.js @@ -94,6 +94,7 @@ const getSyncResolver = memoize(() => extensions: [".js", ".json", ".node"], useSyncFileSystemCalls: true, fileSystem: getNodeFileSystem(), + tsconfig: false, }), ); @@ -219,6 +220,9 @@ module.exports = mergeExports(resolve, { get LogInfoPlugin() { return require("./LogInfoPlugin"); }, + get TsconfigPathsPlugin() { + return require("./TsconfigPathsPlugin"); + }, get forEachBail() { return require("./forEachBail"); }, diff --git a/test/alias.test.js b/test/alias.test.js index ba8a67cb..8c1a82dc 100644 --- a/test/alias.test.js +++ b/test/alias.test.js @@ -52,6 +52,7 @@ describe("alias", () => { }, modules: "/", useSyncFileSystemCalls: true, + tsconfig: false, // @ts-expect-error for tests fileSystem, }); diff --git a/test/browserField.test.js b/test/browserField.test.js index d858194d..3d19d511 100644 --- a/test/browserField.test.js +++ b/test/browserField.test.js @@ -27,6 +27,7 @@ describe("browserField", () => { ], useSyncFileSystemCalls: true, fileSystem: fs, + tsconfig: false, }); }); diff --git a/test/fallback.test.js b/test/fallback.test.js index be0aab9e..6fa80c6c 100644 --- a/test/fallback.test.js +++ b/test/fallback.test.js @@ -40,6 +40,7 @@ describe("fallback", () => { }, modules: "/", useSyncFileSystemCalls: true, + tsconfig: false, // @ts-expect-error for test fileSystem, }); diff --git a/test/fixtures/tsconfig-paths/base/src/components/button.ts b/test/fixtures/tsconfig-paths/base/src/components/button.ts new file mode 100644 index 00000000..3e4d185e --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/components/button.ts @@ -0,0 +1,3 @@ +export function button() { + return "button"; +} \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/base/src/components/new-file.ts b/test/fixtures/tsconfig-paths/base/src/components/new-file.ts new file mode 100644 index 00000000..9e68acde --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/components/new-file.ts @@ -0,0 +1 @@ +export const newFile = "new-file"; \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/base/src/index.ts b/test/fixtures/tsconfig-paths/base/src/index.ts new file mode 100644 index 00000000..98e62d76 --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/index.ts @@ -0,0 +1,23 @@ +import * as foo from "foo"; +import * as file1 from "foo/file1"; + +import * as bar from "bar/file1"; +import * as myStar from "star-bar"; +import * as longest from "longest/bar"; +import * as packagedBrowser from "browser-field-package"; +import * as packagedMain from "main-field-package"; +import * as packagedIndex from "no-main-field-package"; +import * as newFile from "utils/old-file"; + +console.log( + "HELLO WORLD!", + foo.message, + bar.message, + file1, + longest, + myStar.message, + packagedBrowser.message, + packagedMain.message, + packagedIndex.message, + newFile +); diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/bar/file1.ts b/test/fixtures/tsconfig-paths/base/src/mapped/bar/file1.ts new file mode 100644 index 00000000..6b9cedfb --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/bar/file1.ts @@ -0,0 +1 @@ +export const message = "bar"; diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/foo/index.ts b/test/fixtures/tsconfig-paths/base/src/mapped/foo/index.ts new file mode 100644 index 00000000..9135bce1 --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/foo/index.ts @@ -0,0 +1 @@ +export const message = "HELLO!"; diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/longest/one.ts b/test/fixtures/tsconfig-paths/base/src/mapped/longest/one.ts new file mode 100644 index 00000000..5208d40e --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/longest/one.ts @@ -0,0 +1 @@ +export const a = 1 \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/longest/three.ts b/test/fixtures/tsconfig-paths/base/src/mapped/longest/three.ts new file mode 100644 index 00000000..5208d40e --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/longest/three.ts @@ -0,0 +1 @@ +export const a = 1 \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/longest/two.ts b/test/fixtures/tsconfig-paths/base/src/mapped/longest/two.ts new file mode 100644 index 00000000..5208d40e --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/longest/two.ts @@ -0,0 +1 @@ +export const a = 1 \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/browser.ts b/test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/browser.ts new file mode 100644 index 00000000..411a3cc9 --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/browser.ts @@ -0,0 +1 @@ +export const message = "browser"; diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/node.ts b/test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/node.ts new file mode 100644 index 00000000..f5354589 --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/node.ts @@ -0,0 +1 @@ +export const message = "node"; diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/package.json b/test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/package.json new file mode 100644 index 00000000..f0166577 --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/star/browser-field-package/package.json @@ -0,0 +1,5 @@ +{ + "name": "browser-field", + "main": "node.ts", + "browser": "browser.ts" +} diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/star/main-field-package/node.ts b/test/fixtures/tsconfig-paths/base/src/mapped/star/main-field-package/node.ts new file mode 100644 index 00000000..f5354589 --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/star/main-field-package/node.ts @@ -0,0 +1 @@ +export const message = "node"; diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/star/main-field-package/package.json b/test/fixtures/tsconfig-paths/base/src/mapped/star/main-field-package/package.json new file mode 100644 index 00000000..6dea5949 --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/star/main-field-package/package.json @@ -0,0 +1,4 @@ +{ + "name": "main-field", + "main": "node.ts" +} diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/star/no-main-field-package/index.ts b/test/fixtures/tsconfig-paths/base/src/mapped/star/no-main-field-package/index.ts new file mode 100644 index 00000000..f7bc95cd --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/star/no-main-field-package/index.ts @@ -0,0 +1 @@ +export const message = "index"; diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/star/no-main-field-package/package.json b/test/fixtures/tsconfig-paths/base/src/mapped/star/no-main-field-package/package.json new file mode 100644 index 00000000..7077904c --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/star/no-main-field-package/package.json @@ -0,0 +1,3 @@ +{ + "name": "no-main-field" +} diff --git a/test/fixtures/tsconfig-paths/base/src/mapped/star/star-bar/index.ts b/test/fixtures/tsconfig-paths/base/src/mapped/star/star-bar/index.ts new file mode 100644 index 00000000..1a9c8b42 --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/mapped/star/star-bar/index.ts @@ -0,0 +1 @@ +export const message = "Hello Star!"; diff --git a/test/fixtures/tsconfig-paths/base/src/refs/index.ts b/test/fixtures/tsconfig-paths/base/src/refs/index.ts new file mode 100644 index 00000000..41f6c80b --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/refs/index.ts @@ -0,0 +1 @@ +export const message = "HELLO WORLD!"; \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/base/src/utils/date.ts b/test/fixtures/tsconfig-paths/base/src/utils/date.ts new file mode 100644 index 00000000..0bbfedaf --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/src/utils/date.ts @@ -0,0 +1,3 @@ +export function date() { + return "date"; +} \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/base/tsconfig.json b/test/fixtures/tsconfig-paths/base/tsconfig.json new file mode 100644 index 00000000..7b4690a2 --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "outDir": "./js_out", + "baseUrl": ".", + "paths": { + "@components/*": ["./src/utils/*", "./src/components/*"], + "@utils/*": ["./src/utils/*"], + "foo": ["./src/mapped/foo"], + "bar/*": ["./src/mapped/bar/*"], + "refs/*": ["./src/refs/*"], + "*/old-file": ["./src/components/new-file"], + "longest/*": ["./src/mapped/longest/four.ts", "./src/mapped/longest/two.ts"], + "longest/bar": ["./src/mapped/longest/three.ts"], + "*": ["./src/mapped/star/*"] + }, + "composite": true + } +} diff --git a/test/fixtures/tsconfig-paths/extends-base/src/components/button.ts b/test/fixtures/tsconfig-paths/extends-base/src/components/button.ts new file mode 100644 index 00000000..3e4d185e --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-base/src/components/button.ts @@ -0,0 +1,3 @@ +export function button() { + return "button"; +} \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/extends-base/src/index.ts b/test/fixtures/tsconfig-paths/extends-base/src/index.ts new file mode 100644 index 00000000..99393a21 --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-base/src/index.ts @@ -0,0 +1,8 @@ +import * as button from "@components/button"; +import * as date from "@utils/date"; + +console.log( + "HELLO WORLD!", + button, + date, +); diff --git a/test/fixtures/tsconfig-paths/extends-base/src/utils/date.ts b/test/fixtures/tsconfig-paths/extends-base/src/utils/date.ts new file mode 100644 index 00000000..0bbfedaf --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-base/src/utils/date.ts @@ -0,0 +1,3 @@ +export function date() { + return "date"; +} \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/extends-base/tsconfig.json b/test/fixtures/tsconfig-paths/extends-base/tsconfig.json new file mode 100644 index 00000000..60b3e116 --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-base/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../base/tsconfig.json", + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/test/fixtures/tsconfig-paths/pkg/src/index.ts b/test/fixtures/tsconfig-paths/pkg/src/index.ts new file mode 100644 index 00000000..5d07941c --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/index.ts @@ -0,0 +1,16 @@ +import * as myStar from "star-bar"; +import * as packagedBrowser from "browser-field-package"; +import * as packagedMain from "main-field-package"; +import * as packagedIndex from "no-main-field-package"; +import * as starBar from "star-bar/index"; +// import { message } from "refs/index"; + +console.log( + "HELLO WORLD!", + myStar.message, + packagedBrowser.message, + packagedMain.message, + packagedIndex.message, + starBar, + // message +); diff --git a/test/fixtures/tsconfig-paths/pkg/src/mapped/bar/file1.ts b/test/fixtures/tsconfig-paths/pkg/src/mapped/bar/file1.ts new file mode 100644 index 00000000..8f20433a --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/mapped/bar/file1.ts @@ -0,0 +1 @@ +export const message = "fizz"; diff --git a/test/fixtures/tsconfig-paths/pkg/src/mapped/foo/index.ts b/test/fixtures/tsconfig-paths/pkg/src/mapped/foo/index.ts new file mode 100644 index 00000000..37cffa85 --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/mapped/foo/index.ts @@ -0,0 +1 @@ +export const message = "GOODBYE!"; diff --git a/test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/browser.ts b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/browser.ts new file mode 100644 index 00000000..411a3cc9 --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/browser.ts @@ -0,0 +1 @@ +export const message = "browser"; diff --git a/test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/node.ts b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/node.ts new file mode 100644 index 00000000..f5354589 --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/node.ts @@ -0,0 +1 @@ +export const message = "node"; diff --git a/test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/package.json b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/package.json new file mode 100644 index 00000000..f0166577 --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/browser-field-package/package.json @@ -0,0 +1,5 @@ +{ + "name": "browser-field", + "main": "node.ts", + "browser": "browser.ts" +} diff --git a/test/fixtures/tsconfig-paths/pkg/src/mapped/star/main-field-package/node.ts b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/main-field-package/node.ts new file mode 100644 index 00000000..f5354589 --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/main-field-package/node.ts @@ -0,0 +1 @@ +export const message = "node"; diff --git a/test/fixtures/tsconfig-paths/pkg/src/mapped/star/main-field-package/package.json b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/main-field-package/package.json new file mode 100644 index 00000000..6dea5949 --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/main-field-package/package.json @@ -0,0 +1,4 @@ +{ + "name": "main-field", + "main": "node.ts" +} diff --git a/test/fixtures/tsconfig-paths/pkg/src/mapped/star/no-main-field-package/index.ts b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/no-main-field-package/index.ts new file mode 100644 index 00000000..f7bc95cd --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/no-main-field-package/index.ts @@ -0,0 +1 @@ +export const message = "index"; diff --git a/test/fixtures/tsconfig-paths/pkg/src/mapped/star/no-main-field-package/package.json b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/no-main-field-package/package.json new file mode 100644 index 00000000..7077904c --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/no-main-field-package/package.json @@ -0,0 +1,3 @@ +{ + "name": "no-main-field" +} diff --git a/test/fixtures/tsconfig-paths/pkg/src/mapped/star/star-bar/index.ts b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/star-bar/index.ts new file mode 100644 index 00000000..1a9c8b42 --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/mapped/star/star-bar/index.ts @@ -0,0 +1 @@ +export const message = "Hello Star!"; diff --git a/test/fixtures/tsconfig-paths/pkg/src/refs/index.ts b/test/fixtures/tsconfig-paths/pkg/src/refs/index.ts new file mode 100644 index 00000000..41f6c80b --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/src/refs/index.ts @@ -0,0 +1 @@ +export const message = "HELLO WORLD!"; \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/pkg/tsconfig.json b/test/fixtures/tsconfig-paths/pkg/tsconfig.json new file mode 100644 index 00000000..50dc4368 --- /dev/null +++ b/test/fixtures/tsconfig-paths/pkg/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "outDir": "./js_out", + "baseUrl": ".", + "paths": { + "foo/*": ["src/mapped/bar/*"], + "bar/*": ["src/mapped/foo/*"], + "*": ["./src/mapped/star/*"] + } + } +} diff --git a/test/fullSpecified.test.js b/test/fullSpecified.test.js index c8c666b3..15fb895f 100644 --- a/test/fullSpecified.test.js +++ b/test/fullSpecified.test.js @@ -37,6 +37,7 @@ describe("fullSpecified", () => { aliasFields: ["browser"], fullySpecified: true, useSyncFileSystemCalls: true, + tsconfig: false, // @ts-expect-error for test fileSystem, }); @@ -49,6 +50,7 @@ describe("fullSpecified", () => { fullySpecified: true, resolveToContext: true, useSyncFileSystemCalls: true, + tsconfig: false, // @ts-expect-error for test fileSystem, }); diff --git a/test/resolve.test.js b/test/resolve.test.js index d7c86561..ff355f10 100644 --- a/test/resolve.test.js +++ b/test/resolve.test.js @@ -8,20 +8,24 @@ const fixtures = path.join(__dirname, "fixtures"); const asyncContextResolve = resolve.create({ extensions: [".js", ".json", ".node"], resolveToContext: true, + tsconfig: false, }); const syncContextResolve = resolve.create.sync({ extensions: [".js", ".json", ".node"], resolveToContext: true, + tsconfig: false, }); const issue238Resolve = resolve.create({ extensions: [".js", ".jsx", ".ts", ".tsx"], modules: ["src/a", "src/b", "src/common", "node_modules"], + tsconfig: false, }); const preferRelativeResolve = resolve.create({ preferRelative: true, + tsconfig: false, }); /** diff --git a/test/symlink.test.js b/test/symlink.test.js index cba9a7ae..2429484f 100644 --- a/test/symlink.test.js +++ b/test/symlink.test.js @@ -104,9 +104,11 @@ describe("symlink", () => { const resolveWithoutSymlinks = resolve.create({ symlinks: false, + tsconfig: false, }); const resolveSyncWithoutSymlinks = resolve.create.sync({ symlinks: false, + tsconfig: false, }); for (const pathToIt of [ diff --git a/test/tsconfig-paths.test.js b/test/tsconfig-paths.test.js new file mode 100644 index 00000000..41ad26cf --- /dev/null +++ b/test/tsconfig-paths.test.js @@ -0,0 +1,390 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const { ResolverFactory } = require("../"); +const CachedInputFileSystem = require("../lib/CachedInputFileSystem"); + +const fileSystem = new CachedInputFileSystem(fs, 4000); + +const baseExampleDir = path.resolve( + __dirname, + "fixtures", + "tsconfig-paths", + "base", +); +const pkgExampleDir = path.resolve( + __dirname, + "fixtures", + "tsconfig-paths", + "pkg", +); +const extendsExampleDir = path.resolve( + __dirname, + "fixtures", + "tsconfig-paths", + "extends-base", +); + +describe("TsconfigPathsPlugin", () => { + it("resolves exact mapped path '@components/*' via tsconfig option (example)", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(baseExampleDir, "tsconfig.json"), + useSyncFileSystemCalls: true, + }); + + resolver.resolve( + {}, + baseExampleDir, + "@components/button", + {}, + (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(baseExampleDir, "src", "components", "button.ts"), + ); + done(); + }, + ); + }); + + it("when multiple patterns match a module specifier, the pattern with the longest matching prefix before any * token is used:", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(baseExampleDir, "tsconfig.json"), + }); + + resolver.resolve({}, baseExampleDir, "longest/bar", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(baseExampleDir, "src", "mapped", "longest", "three.ts"), + ); + done(); + }); + }); + + it("resolves exact mapped path 'foo' via tsconfig option (example)", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(baseExampleDir, "tsconfig.json"), + useSyncFileSystemCalls: true, + }); + + resolver.resolve({}, baseExampleDir, "foo", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(baseExampleDir, "src", "mapped", "foo", "index.ts"), + ); + done(); + }); + }); + + it("resolves wildcard mapped path 'bar/*' via tsconfig option (example)", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(baseExampleDir, "tsconfig.json"), + useSyncFileSystemCalls: true, + }); + + resolver.resolve({}, baseExampleDir, "bar/file1", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(baseExampleDir, "src", "mapped", "bar", "file1.ts"), + ); + done(); + }); + }); + + it("resolves wildcard mapped path '*/old-file' to specific file via tsconfig option (example)", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(baseExampleDir, "tsconfig.json"), + useSyncFileSystemCalls: true, + }); + + resolver.resolve( + {}, + baseExampleDir, + "utils/old-file", + {}, + (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(baseExampleDir, "src", "components", "new-file.ts"), + ); + done(); + }, + ); + }); + + it("falls through when no mapping exists (example)", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(baseExampleDir, "tsconfig.json"), + useSyncFileSystemCalls: true, + }); + + resolver.resolve( + {}, + baseExampleDir, + "does-not-exist", + {}, + (err, result) => { + expect(err).toBeInstanceOf(Error); + expect(result).toBeUndefined(); + done(); + }, + ); + }); + + it("resolves '@components/*' using extends from extendsExampleDir project", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(extendsExampleDir, "tsconfig.json"), + }); + resolver.resolve( + {}, + extendsExampleDir, + "@components/button", + {}, + (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(extendsExampleDir, "src", "components", "button.ts"), + ); + done(); + }, + ); + }); + + it("resolves '@utils/*' using extends from extendsExampleDir project", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + }); + + resolver.resolve( + {}, + extendsExampleDir, + "@utils/date", + {}, + (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(extendsExampleDir, "src", "utils", "date.ts"), + ); + done(); + }, + ); + }); + + it("resolves 'foo' and 'bar' using pkg from pkgExampleDir project", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(pkgExampleDir, "tsconfig.json"), + }); + + // 'foo' is mapped in pkgExampleDir to src/mapped/bar (within pkgExampleDir) + resolver.resolve({}, pkgExampleDir, "foo/file1", {}, (err, resultFoo) => { + if (err) return done(err); + if (!resultFoo) return done(new Error("No result for foo")); + expect(resultFoo).toEqual( + path.join(pkgExampleDir, "src", "mapped", "bar", "file1.ts"), + ); + done(); + }); + }); + + it("resolves 'foo' using pkg from pkgExampleDir project", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(pkgExampleDir, "tsconfig.json"), + useSyncFileSystemCalls: true, + }); + + // 'foo' is mapped in pkgExampleDir to src/mapped/bar (within pkgExampleDir) + resolver.resolve({}, pkgExampleDir, "foo/file1", {}, (err, resultFoo) => { + if (err) return done(err); + if (!resultFoo) return done(new Error("No result for foo")); + expect(resultFoo).toEqual( + path.join(pkgExampleDir, "src", "mapped", "bar", "file1.ts"), + ); + done(); + }); + }); + + it("* pattern resolves to src/mapped/star/* in pkgExampleDir project", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(pkgExampleDir, "tsconfig.json"), + useSyncFileSystemCalls: true, + }); + + // 'star/*' maps to src/mapped/star/* in pkgExampleDir + resolver.resolve( + {}, + pkgExampleDir, + "star-bar/index", + {}, + (err2, resultStar) => { + if (err2) return done(err2); + if (!resultStar) return done(new Error("No result for star/*")); + expect(resultStar).toEqual( + path.join( + pkgExampleDir, + "src", + "mapped", + "star", + "star-bar", + "index.ts", + ), + ); + done(); + }, + ); + }); + + it("main-field-package resolves to src/mapped/star/main-field-package/index.ts in pkgExampleDir project", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(pkgExampleDir, "tsconfig.json"), + useSyncFileSystemCalls: true, + }); + + resolver.resolve( + {}, + pkgExampleDir, + "main-field-package", + {}, + (err2, resultStar) => { + if (err2) return done(err2); + if (!resultStar) { + return done(new Error("No result for main-field-package")); + } + expect(resultStar).toEqual( + path.join( + pkgExampleDir, + "src", + "mapped", + "star", + "main-field-package", + "node.ts", + ), + ); + done(); + }, + ); + }); + + it("browser-field-package resolves to src/mapped/star/browser-field-package/browser.ts in pkgExampleDir project", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(pkgExampleDir, "tsconfig.json"), + useSyncFileSystemCalls: true, + }); + resolver.resolve( + {}, + pkgExampleDir, + "browser-field-package", + {}, + (err2, resultStar) => { + if (err2) return done(err2); + if (!resultStar) { + return done(new Error("No result for browser-field-package")); + } + expect(resultStar).toEqual( + path.join( + pkgExampleDir, + "src", + "mapped", + "star", + "browser-field-package", + "browser.ts", + ), + ); + done(); + }, + ); + }); + + it("no-main-field-package resolves to src/mapped/star/no-main-field-package/index.ts in pkgExampleDir project", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(pkgExampleDir, "tsconfig.json"), + useSyncFileSystemCalls: true, + }); + resolver.resolve( + {}, + pkgExampleDir, + "no-main-field-package", + {}, + (err2, resultStar) => { + if (err2) return done(err2); + if (!resultStar) { + return done(new Error("No result for no-main-field-package")); + } + expect(resultStar).toEqual( + path.join( + pkgExampleDir, + "src", + "mapped", + "star", + "no-main-field-package", + "index.ts", + ), + ); + done(); + }, + ); + }); +}); diff --git a/types.d.ts b/types.d.ts index 6953b2c8..e5744cda 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1266,6 +1266,11 @@ declare interface ResolveOptionsResolverFactoryObject_1 { * prefer absolute */ preferAbsolute: boolean; + + /** + * tsconfig file path + */ + tsconfig?: string | boolean; } declare interface ResolveOptionsResolverFactoryObject_2 { /** @@ -1411,6 +1416,11 @@ declare interface ResolveOptionsResolverFactoryObject_2 { * Prefer to resolve server-relative urls as absolute paths before falling back to resolve in roots */ preferAbsolute?: boolean; + + /** + * tsconfig file path + */ + tsconfig?: string | boolean; } type ResolveRequest = BaseResolveRequest & Partial; declare abstract class Resolver { @@ -1569,6 +1579,37 @@ declare interface SyncFileSystem { */ realpathSync?: RealPathSync; } +declare interface TsconfigPathsData { + /** + * tsconfig file data + */ + alias: AliasOption[]; + + /** + * tsconfig file data + */ + modules: string[]; + + /** + * file dependencies + */ + fileDependencies: Set; +} +declare class TsconfigPathsPlugin { + constructor(configFile: string | true); + configFile: string; + tsconfigPathsData?: null | TsconfigPathsData; + apply(resolver: Resolver): void; + + /** + * Pre-process request to load tsconfig.json and convert paths to AliasPlugin format + */ + loadTsconfigPathsData( + resolver: Resolver, + request: ResolveRequest, + resolveContext: ResolveContext, + ): Promise; +} declare interface URL_url extends URL_Import {} declare interface WriteOnlySet { add: (item: T) => void; @@ -1640,6 +1681,7 @@ declare namespace exports { CachedInputFileSystem, CloneBasenamePlugin, LogInfoPlugin, + TsconfigPathsPlugin, ResolveOptionsOptionalFS, BaseFileSystem, PnpApi, From 2a2e19397cb7aab7d3308a3e6f54685e70866428 Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sat, 4 Oct 2025 17:06:05 +0800 Subject: [PATCH 02/12] update --- lib/ResolverFactory.js | 3 +- lib/TsconfigPathsPlugin.js | 294 ++++++++++++++++++++++++++++++++++- lib/TsconfigPathsUtils.js | 299 ------------------------------------ lib/util/path.js | 29 ++++ test/alias.test.js | 1 - test/browserField.test.js | 1 - test/fallback.test.js | 1 - test/fullSpecified.test.js | 2 - test/resolve.test.js | 4 - test/symlink.test.js | 2 - test/tsconfig-paths.test.js | 1 + types.d.ts | 52 +++++++ 12 files changed, 371 insertions(+), 318 deletions(-) delete mode 100644 lib/TsconfigPathsUtils.js diff --git a/lib/ResolverFactory.js b/lib/ResolverFactory.js index c0337f30..cc1b0420 100644 --- a/lib/ResolverFactory.js +++ b/lib/ResolverFactory.js @@ -297,7 +297,8 @@ function createOptions(options) { preferRelative: options.preferRelative || false, preferAbsolute: options.preferAbsolute || false, restrictions: new Set(options.restrictions), - tsconfig: typeof options.tsconfig === "undefined" ? true : options.tsconfig, + tsconfig: + typeof options.tsconfig === "undefined" ? false : options.tsconfig, }; } diff --git a/lib/TsconfigPathsPlugin.js b/lib/TsconfigPathsPlugin.js index 2e1fe818..a3373198 100644 --- a/lib/TsconfigPathsPlugin.js +++ b/lib/TsconfigPathsPlugin.js @@ -7,18 +7,120 @@ const { aliasResolveHandler } = require("./AliasUtils"); const { modulesResolveHandler } = require("./ModulesUtils"); - -const TsconfigPathsUtils = require("./TsconfigPathsUtils"); +const { + cachedDirname: dirname, + cachedJoin: join, + normalize, +} = require("./util/path"); /** @typedef {import("./Resolver")} Resolver */ /** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */ /** @typedef {import("./AliasUtils").AliasOption} AliasOption */ /** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */ /** @typedef {import("./Resolver").ResolveContext} ResolveContext */ +/** @typedef {import("./Resolver").FileSystem} FileSystem */ /** @typedef {import("./Resolver").TsconfigPathsData} TsconfigPathsData */ +/** + * @typedef {object} TsconfigCompilerOptions + * @property {string} baseUrl Base URL for resolving paths + * @property {{[key: string]: string[]}} paths TypeScript paths mapping + */ + +/** + * @typedef {object} Tsconfig + * @property {TsconfigCompilerOptions=} compilerOptions Compiler options + * @property {string|string[]=} extends Extended configuration paths + */ + const DEFAULT_CONFIG_FILE = "tsconfig.json"; +/** + * @param {string} pattern Path pattern + * @returns {number} Length of the prefix + */ +function getPrefixLength(pattern) { + const prefixLength = pattern.indexOf("*"); + if (prefixLength === -1) { + return pattern.length; + } + return pattern.slice(0, Math.max(0, prefixLength)).length; +} + +/** + * Sort path patterns. + * If a module name can be matched with multiple patterns then pattern with the longest prefix will be picked. + * @param {string[]} arr Array of path patterns + * @returns {string[]} Array of path patterns sorted by longest prefix + */ +function sortByLongestPrefix(arr) { + return [...arr].sort((a, b) => getPrefixLength(b) - getPrefixLength(a)); +} + +/** + * Merge two tsconfig objects + * @param {Tsconfig} base base config + * @param {Tsconfig} config config to merge + * @returns {Tsconfig} merged config + */ +function mergeTsconfigs(base, config) { + base = base || {}; + config = config || {}; + + return { + ...base, + ...config, + compilerOptions: { + .../** @type {TsconfigCompilerOptions} */ (base.compilerOptions), + .../** @type {TsconfigCompilerOptions} */ (config.compilerOptions), + }, + }; +} + +/** + * Convert tsconfig paths to resolver options + * @param {{[key: string]: string[]}} paths TypeScript paths mapping + * @param {string} baseUrl Base URL for resolving paths + * @returns {Omit} the resolver options + */ +function tsconfigPathsToResolveOptions(paths, baseUrl) { + /** @type {string[]} */ + const sortedKeys = sortByLongestPrefix(Object.keys(paths)); + /** @type {AliasOption[]} */ + const alias = []; + /** @type {string[]} */ + const modules = []; + + for (const pattern of sortedKeys) { + const mappings = paths[pattern]; + const aliasTargets = mappings.map((mapping) => join(baseUrl, mapping)); + + if (aliasTargets.length > 0) { + if (pattern === "*") { + // Handle "*" pattern - extract modules from wildcard targets + modules.push( + ...aliasTargets + .map((dir) => { + if (/[/\\]\*$/.test(dir)) { + return dir.replace(/[/\\]\*$/, ""); + } + return ""; + }) + .filter(Boolean), + ); + } else { + // Handle regular patterns - add as alias + alias.push({ name: pattern, alias: aliasTargets }); + } + } + } + + return { + alias, + modules, + }; +} + module.exports = class TsconfigPathsPlugin { /** * @param {true | string} configFile tsconfig file path @@ -97,6 +199,161 @@ module.exports = class TsconfigPathsPlugin { ); } + /** + * Load tsconfig from extends path + * @param {FileSystem} fileSystem the file system + * @param {string} configFilePath current config file path + * @param {string} extendedConfigValue extends value + * @param {Set} fileDependencies the file dependencies + * @returns {Promise} the extended tsconfig + */ + async loadTsconfigFromExtends( + fileSystem, + configFilePath, + extendedConfigValue, + fileDependencies, + ) { + // Add .json extension if not present + if ( + typeof extendedConfigValue === "string" && + !extendedConfigValue.includes(".json") + ) { + extendedConfigValue += ".json"; + } + + const currentDir = dirname(configFilePath); + let extendedConfigPath = join(currentDir, extendedConfigValue); + + // Check if file exists, if not try node_modules + const exists = await new Promise((resolve) => { + fileSystem.readFile(extendedConfigPath, (err) => { + resolve(!err); + }); + }); + if ( + !exists && + extendedConfigValue.includes("/") && + extendedConfigValue.includes(".") + ) { + extendedConfigPath = join( + currentDir, + normalize(`node_modules/${extendedConfigValue}`), + ); + } + + const config = + (await this.loadTsconfig( + fileSystem, + extendedConfigPath, + fileDependencies, + )) || {}; + const compilerOptions = config.compilerOptions || { baseUrl: undefined }; + + // baseUrl should be interpreted as relative to extendedConfigPath, + // but we need to update it so it is relative to the original tsconfig being loaded + if (compilerOptions.baseUrl) { + const extendsDir = dirname(extendedConfigValue); + compilerOptions.baseUrl = join(extendsDir, compilerOptions.baseUrl); + } + + return /** @type {Tsconfig} */ (config); + } + + /** + * Load tsconfig.json with extends support (similar to tsconfig-paths) + * @param {FileSystem} fileSystem the file system + * @param {string} configFilePath absolute path to tsconfig.json + * @param {Set} fileDependencies the file dependencies + * @returns {Promise} the merged tsconfig + */ + async loadTsconfig(fileSystem, configFilePath, fileDependencies) { + try { + const data = await new Promise((resolve, reject) => { + fileSystem.readFile(configFilePath, "utf8", (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + fileDependencies.add(configFilePath); + let config; + try { + config = JSON.parse(/** @type {string} */ (data)); + } catch (err) { + throw new Error( + `${configFilePath} is malformed ${ + /** @type {Error} */ (err).message + }`, + ); + } + + const extendedConfig = config.extends; + if (extendedConfig) { + let base; + + if (Array.isArray(extendedConfig)) { + // Handle multiple extends (array) + base = {}; + for (const extendedConfigElement of extendedConfig) { + const extendedTsconfig = await this.loadTsconfigFromExtends( + fileSystem, + configFilePath, + extendedConfigElement, + fileDependencies, + ); + base = mergeTsconfigs(base, extendedTsconfig); + } + } else { + // Handle single extends (string) + base = await this.loadTsconfigFromExtends( + fileSystem, + configFilePath, + extendedConfig, + fileDependencies, + ); + } + + return /** @type {Tsconfig} */ (mergeTsconfigs(base, config)); + } + return config; + } catch (_e) { + return null; + } + } + + /** + * Read tsconfig.json and return normalized compiler options + * @param {FileSystem} fileSystem the file system + * @param {string} absTsconfigPath absolute path to tsconfig.json + * @returns {Promise<{options: TsconfigCompilerOptions, fileDependencies: Set}|null>} the normalized compiler options + */ + async readTsconfigCompilerOptions(fileSystem, absTsconfigPath) { + try { + /** @type {Set} */ + const fileDependencies = new Set(); + const config = await this.loadTsconfig( + fileSystem, + absTsconfigPath, + fileDependencies, + ); + if (!config) return null; + + const compilerOptions = config.compilerOptions || { + baseUrl: undefined, + paths: undefined, + }; + let { baseUrl } = compilerOptions; + + baseUrl = !baseUrl + ? dirname(absTsconfigPath) + : join(dirname(absTsconfigPath), baseUrl); + + const paths = compilerOptions.paths || {}; + return { options: { baseUrl, paths }, fileDependencies }; + } catch (_e) { + return null; + } + } + /** * Pre-process request to load tsconfig.json and convert paths to AliasPlugin format * @param {Resolver} resolver the resolver @@ -106,11 +363,34 @@ module.exports = class TsconfigPathsPlugin { */ async loadTsconfigPathsData(resolver, request, resolveContext) { if (typeof this.tsconfigPathsData === "undefined") { - this.tsconfigPathsData = await TsconfigPathsUtils.loadTsconfigPathsData( - resolver.fileSystem, - this.configFile, - request.path || undefined, - ); + try { + const absTsconfigPath = join( + request.path || process.cwd(), + this.configFile, + ); + const result = await this.readTsconfigCompilerOptions( + resolver.fileSystem, + absTsconfigPath, + ); + if (!result) { + this.tsconfigPathsData = null; + } else { + const { options: compilerOptions, fileDependencies } = result; + if (!compilerOptions) { + this.tsconfigPathsData = null; + } else { + this.tsconfigPathsData = { + ...tsconfigPathsToResolveOptions( + compilerOptions.paths, + compilerOptions.baseUrl, + ), + fileDependencies, + }; + } + } + } catch (_e) { + this.tsconfigPathsData = null; + } } if (!this.tsconfigPathsData) { diff --git a/lib/TsconfigPathsUtils.js b/lib/TsconfigPathsUtils.js deleted file mode 100644 index 92b3936c..00000000 --- a/lib/TsconfigPathsUtils.js +++ /dev/null @@ -1,299 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php - Author Natsu @xiaoxiaojx -*/ - -"use strict"; - -const path = require("path"); -const { join, normalize } = require("./util/path"); - -/** @typedef {import("./Resolver")} Resolver */ -/** @typedef {import("./Resolver").FileSystem} FileSystem */ -/** @typedef {import("./AliasUtils").AliasOption} AliasOption */ -/** @typedef {import("./Resolver").TsconfigPathsData} TsconfigPathsData */ - -/** - * @typedef {object} TsconfigCompilerOptions - * @property {string} baseUrl Base URL for resolving paths - * @property {{[key: string]: string[]}} paths TypeScript paths mapping - */ - -/** - * @typedef {object} Tsconfig - * @property {TsconfigCompilerOptions=} compilerOptions Compiler options - * @property {string|string[]=} extends Extended configuration paths - */ - -/** - * @param {string} pattern Path pattern - * @returns {number} Length of the prefix - */ -function getPrefixLength(pattern) { - const prefixLength = pattern.indexOf("*"); - if (prefixLength === -1) { - return pattern.length; - } - return pattern.slice(0, Math.max(0, prefixLength)).length; -} - -/** - * Sort path patterns. - * If a module name can be matched with multiple patterns then pattern with the longest prefix will be picked. - * @param {string[]} arr Array of path patterns - * @returns {string[]} Array of path patterns sorted by longest prefix - */ -function sortByLongestPrefix(arr) { - return [...arr].sort((a, b) => getPrefixLength(b) - getPrefixLength(a)); -} - -/** - * Merge two tsconfig objects - * @param {Tsconfig} base base config - * @param {Tsconfig} config config to merge - * @returns {Tsconfig} merged config - */ -function mergeTsconfigs(base, config) { - base = base || {}; - config = config || {}; - - return { - ...base, - ...config, - compilerOptions: { - .../** @type {TsconfigCompilerOptions} */ (base.compilerOptions), - .../** @type {TsconfigCompilerOptions} */ (config.compilerOptions), - }, - }; -} - -/** - * Load tsconfig from extends path - * @param {FileSystem} fileSystem the file system - * @param {string} configFilePath current config file path - * @param {string} extendedConfigValue extends value - * @param {Set} fileDependencies the file dependencies - * @returns {Promise} the extended tsconfig - */ -async function loadTsconfigFromExtends( - fileSystem, - configFilePath, - extendedConfigValue, - fileDependencies, -) { - // Add .json extension if not present - if ( - typeof extendedConfigValue === "string" && - !extendedConfigValue.includes(".json") - ) { - extendedConfigValue += ".json"; - } - - const currentDir = path.dirname(configFilePath); - let extendedConfigPath = join(currentDir, extendedConfigValue); - - // Check if file exists, if not try node_modules - const exists = await new Promise((resolve) => { - fileSystem.readFile(extendedConfigPath, (err) => { - resolve(!err); - }); - }); - if ( - !exists && - extendedConfigValue.includes("/") && - extendedConfigValue.includes(".") - ) { - extendedConfigPath = join( - currentDir, - normalize(`node_modules/${extendedConfigValue}`), - ); - } - - const config = - // eslint-disable-next-line no-use-before-define - (await loadTsconfig(fileSystem, extendedConfigPath, fileDependencies)) || - {}; - const compilerOptions = config.compilerOptions || { baseUrl: undefined }; - - // baseUrl should be interpreted as relative to extendedConfigPath, - // but we need to update it so it is relative to the original tsconfig being loaded - if (compilerOptions.baseUrl) { - const extendsDir = path.dirname(extendedConfigValue); - compilerOptions.baseUrl = join(extendsDir, compilerOptions.baseUrl); - } - - return /** @type {Tsconfig} */ (config); -} - -/** - * Load tsconfig.json with extends support (similar to tsconfig-paths) - * @param {FileSystem} fileSystem the file system - * @param {string} configFilePath absolute path to tsconfig.json - * @param {Set} fileDependencies the file dependencies - * @returns {Promise} the merged tsconfig - */ -async function loadTsconfig(fileSystem, configFilePath, fileDependencies) { - try { - const data = await new Promise((resolve, reject) => { - fileSystem.readFile(configFilePath, "utf8", (err, data) => { - if (err) reject(err); - else resolve(data); - }); - }); - fileDependencies.add(configFilePath); - let config; - try { - config = JSON.parse(/** @type {string} */ (data)); - } catch (err) { - throw new Error( - `${configFilePath} is malformed ${/** @type {Error} */ (err).message}`, - ); - } - - const extendedConfig = config.extends; - if (extendedConfig) { - let base; - - if (Array.isArray(extendedConfig)) { - // Handle multiple extends (array) - base = {}; - for (const extendedConfigElement of extendedConfig) { - const extendedTsconfig = await loadTsconfigFromExtends( - fileSystem, - configFilePath, - extendedConfigElement, - fileDependencies, - ); - base = mergeTsconfigs(base, extendedTsconfig); - } - } else { - // Handle single extends (string) - base = await loadTsconfigFromExtends( - fileSystem, - configFilePath, - extendedConfig, - fileDependencies, - ); - } - - return /** @type {Tsconfig} */ (mergeTsconfigs(base, config)); - } - return config; - } catch (_e) { - return null; - } -} - -/** - * Read tsconfig.json and return normalized compiler options - * @param {FileSystem} fileSystem the file system - * @param {string} absTsconfigPath absolute path to tsconfig.json - * @returns {Promise<{options: TsconfigCompilerOptions, fileDependencies: Set}|null>} the normalized compiler options - */ -async function readTsconfigCompilerOptions(fileSystem, absTsconfigPath) { - try { - /** @type {Set} */ - const fileDependencies = new Set(); - const config = await loadTsconfig( - fileSystem, - absTsconfigPath, - fileDependencies, - ); - if (!config) return null; - - const compilerOptions = config.compilerOptions || { - baseUrl: undefined, - paths: undefined, - }; - let { baseUrl } = compilerOptions; - - baseUrl = !baseUrl - ? path.dirname(absTsconfigPath) - : join(path.dirname(absTsconfigPath), baseUrl); - - const paths = compilerOptions.paths || {}; - return { options: { baseUrl, paths }, fileDependencies }; - } catch (_e) { - return null; - } -} - -/** - * Convert tsconfig paths to resolver options - * @param {{[key: string]: string[]}} paths TypeScript paths mapping - * @param {string} baseUrl Base URL for resolving paths - * @returns {Omit} the resolver options - */ -function tsconfigPathsToResolveOptions(paths, baseUrl) { - /** @type {string[]} */ - const sortedKeys = sortByLongestPrefix(Object.keys(paths)); - /** @type {AliasOption[]} */ - const alias = []; - /** @type {string[]} */ - const modules = []; - - for (const pattern of sortedKeys) { - const mappings = paths[pattern]; - const aliasTargets = mappings.map((mapping) => join(baseUrl, mapping)); - - if (aliasTargets.length > 0) { - if (pattern === "*") { - // Handle "*" pattern - extract modules from wildcard targets - modules.push( - ...aliasTargets - .map((dir) => { - if (/[/\\]\*$/.test(dir)) { - return dir.replace(/[/\\]\*$/, ""); - } - return ""; - }) - .filter(Boolean), - ); - } else { - // Handle regular patterns - add as alias - alias.push({ name: pattern, alias: aliasTargets }); - } - } - } - - return { - alias, - modules, - }; -} - -/** - * Load tsconfig.json (and referenced tsconfigs) and convert paths to AliasPlugin format - * @param {FileSystem} fileSystem the file system - * @param {string} configFile path to tsconfig.json file - * @param {string=} context the resolve context path - * @returns {Promise} Array of alias options for AliasPlugin - */ -async function loadTsconfigPathsData( - fileSystem, - configFile, - context = process.cwd(), -) { - try { - const absTsconfigPath = join(context, configFile); - const result = await readTsconfigCompilerOptions( - fileSystem, - absTsconfigPath, - ); - if (!result) return null; - const { options: compilerOptions, fileDependencies } = result; - if (!compilerOptions) return null; - - return { - ...tsconfigPathsToResolveOptions( - compilerOptions.paths, - compilerOptions.baseUrl, - ), - fileDependencies, - }; - } catch (_e) { - return null; - } -} - -module.exports.loadTsconfigPathsData = loadTsconfigPathsData; diff --git a/lib/util/path.js b/lib/util/path.js index af340469..6408d885 100644 --- a/lib/util/path.js +++ b/lib/util/path.js @@ -171,6 +171,18 @@ const join = (rootPath, request) => { return posixNormalize(rootPath); }; +/** + * @param {string} maybePath a path + * @returns {string} the directory name + */ +const dirname = (maybePath) => { + switch (getType(maybePath)) { + case PathType.AbsoluteWin: + return path.win32.dirname(maybePath); + } + return path.posix.dirname(maybePath); +}; + /** @type {Map>} */ const joinCache = new Map(); @@ -194,9 +206,26 @@ const cachedJoin = (rootPath, request) => { return cacheEntry; }; +/** @type {Map} */ +const dirnameCache = new Map(); + +/** + * @param {string} maybePath a path + * @returns {string} the directory name + */ +const cachedDirname = (maybePath) => { + const cacheEntry = dirnameCache.get(maybePath); + if (cacheEntry !== undefined) return cacheEntry; + const result = dirname(maybePath); + dirnameCache.set(maybePath, result); + return result; +}; + module.exports.PathType = PathType; +module.exports.cachedDirname = cachedDirname; module.exports.cachedJoin = cachedJoin; module.exports.deprecatedInvalidSegmentRegEx = deprecatedInvalidSegmentRegEx; +module.exports.dirname = dirname; module.exports.getType = getType; module.exports.invalidSegmentRegEx = invalidSegmentRegEx; module.exports.join = join; diff --git a/test/alias.test.js b/test/alias.test.js index 8c1a82dc..ba8a67cb 100644 --- a/test/alias.test.js +++ b/test/alias.test.js @@ -52,7 +52,6 @@ describe("alias", () => { }, modules: "/", useSyncFileSystemCalls: true, - tsconfig: false, // @ts-expect-error for tests fileSystem, }); diff --git a/test/browserField.test.js b/test/browserField.test.js index 3d19d511..d858194d 100644 --- a/test/browserField.test.js +++ b/test/browserField.test.js @@ -27,7 +27,6 @@ describe("browserField", () => { ], useSyncFileSystemCalls: true, fileSystem: fs, - tsconfig: false, }); }); diff --git a/test/fallback.test.js b/test/fallback.test.js index 6fa80c6c..be0aab9e 100644 --- a/test/fallback.test.js +++ b/test/fallback.test.js @@ -40,7 +40,6 @@ describe("fallback", () => { }, modules: "/", useSyncFileSystemCalls: true, - tsconfig: false, // @ts-expect-error for test fileSystem, }); diff --git a/test/fullSpecified.test.js b/test/fullSpecified.test.js index 15fb895f..c8c666b3 100644 --- a/test/fullSpecified.test.js +++ b/test/fullSpecified.test.js @@ -37,7 +37,6 @@ describe("fullSpecified", () => { aliasFields: ["browser"], fullySpecified: true, useSyncFileSystemCalls: true, - tsconfig: false, // @ts-expect-error for test fileSystem, }); @@ -50,7 +49,6 @@ describe("fullSpecified", () => { fullySpecified: true, resolveToContext: true, useSyncFileSystemCalls: true, - tsconfig: false, // @ts-expect-error for test fileSystem, }); diff --git a/test/resolve.test.js b/test/resolve.test.js index ff355f10..d7c86561 100644 --- a/test/resolve.test.js +++ b/test/resolve.test.js @@ -8,24 +8,20 @@ const fixtures = path.join(__dirname, "fixtures"); const asyncContextResolve = resolve.create({ extensions: [".js", ".json", ".node"], resolveToContext: true, - tsconfig: false, }); const syncContextResolve = resolve.create.sync({ extensions: [".js", ".json", ".node"], resolveToContext: true, - tsconfig: false, }); const issue238Resolve = resolve.create({ extensions: [".js", ".jsx", ".ts", ".tsx"], modules: ["src/a", "src/b", "src/common", "node_modules"], - tsconfig: false, }); const preferRelativeResolve = resolve.create({ preferRelative: true, - tsconfig: false, }); /** diff --git a/test/symlink.test.js b/test/symlink.test.js index 2429484f..cba9a7ae 100644 --- a/test/symlink.test.js +++ b/test/symlink.test.js @@ -104,11 +104,9 @@ describe("symlink", () => { const resolveWithoutSymlinks = resolve.create({ symlinks: false, - tsconfig: false, }); const resolveSyncWithoutSymlinks = resolve.create.sync({ symlinks: false, - tsconfig: false, }); for (const pathToIt of [ diff --git a/test/tsconfig-paths.test.js b/test/tsconfig-paths.test.js index 41ad26cf..246c291e 100644 --- a/test/tsconfig-paths.test.js +++ b/test/tsconfig-paths.test.js @@ -192,6 +192,7 @@ describe("TsconfigPathsPlugin", () => { extensions: [".ts", ".tsx"], mainFields: ["browser", "main"], mainFiles: ["index"], + tsconfig: true, }); resolver.resolve( diff --git a/types.d.ts b/types.d.ts index e5744cda..02e30726 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1579,6 +1579,28 @@ declare interface SyncFileSystem { */ realpathSync?: RealPathSync; } +declare interface Tsconfig { + /** + * Compiler options + */ + compilerOptions?: TsconfigCompilerOptions; + + /** + * Extended configuration paths + */ + extends?: string | string[]; +} +declare interface TsconfigCompilerOptions { + /** + * Base URL for resolving paths + */ + baseUrl: string; + + /** + * TypeScript paths mapping + */ + paths: { [index: string]: string[] }; +} declare interface TsconfigPathsData { /** * tsconfig file data @@ -1601,6 +1623,36 @@ declare class TsconfigPathsPlugin { tsconfigPathsData?: null | TsconfigPathsData; apply(resolver: Resolver): void; + /** + * Load tsconfig from extends path + */ + loadTsconfigFromExtends( + fileSystem: FileSystem, + configFilePath: string, + extendedConfigValue: string, + fileDependencies: Set, + ): Promise; + + /** + * Load tsconfig.json with extends support (similar to tsconfig-paths) + */ + loadTsconfig( + fileSystem: FileSystem, + configFilePath: string, + fileDependencies: Set, + ): Promise; + + /** + * Read tsconfig.json and return normalized compiler options + */ + readTsconfigCompilerOptions( + fileSystem: FileSystem, + absTsconfigPath: string, + ): Promise; + }>; + /** * Pre-process request to load tsconfig.json and convert paths to AliasPlugin format */ From 2935f723f5b78c363b9b10f55767a008db07f621 Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sun, 5 Oct 2025 11:54:28 +0800 Subject: [PATCH 03/12] update --- lib/Resolver.js | 1 + lib/TsconfigPathsPlugin.js | 18 ++++++++---------- types.d.ts | 6 +++++- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/Resolver.js b/lib/Resolver.js index 3f88b8e9..c0a1c5f1 100644 --- a/lib/Resolver.js +++ b/lib/Resolver.js @@ -306,6 +306,7 @@ const { * @property {string=} descriptionFilePath description file path * @property {string=} descriptionFileRoot description file root * @property {JsonObject=} descriptionFileData description file data + * @property {TsconfigPathsData|null|undefined=} tsconfigPathsData tsconfig paths data * @property {string=} relativePath relative path * @property {boolean=} ignoreSymlinks true when need to ignore symlinks, otherwise false * @property {boolean=} fullySpecified true when full specified, otherwise false diff --git a/lib/TsconfigPathsPlugin.js b/lib/TsconfigPathsPlugin.js index a3373198..668d6711 100644 --- a/lib/TsconfigPathsPlugin.js +++ b/lib/TsconfigPathsPlugin.js @@ -130,8 +130,6 @@ module.exports = class TsconfigPathsPlugin { configFile === true ? DEFAULT_CONFIG_FILE : /** @type {string} */ (configFile); - /** @type {TsconfigPathsData|undefined|null} */ - this.tsconfigPathsData = undefined; } /** @@ -362,7 +360,7 @@ module.exports = class TsconfigPathsPlugin { * @returns {Promise} the pre-processed request */ async loadTsconfigPathsData(resolver, request, resolveContext) { - if (typeof this.tsconfigPathsData === "undefined") { + if (typeof request.tsconfigPathsData === "undefined") { try { const absTsconfigPath = join( request.path || process.cwd(), @@ -373,13 +371,13 @@ module.exports = class TsconfigPathsPlugin { absTsconfigPath, ); if (!result) { - this.tsconfigPathsData = null; + request.tsconfigPathsData = null; } else { const { options: compilerOptions, fileDependencies } = result; if (!compilerOptions) { - this.tsconfigPathsData = null; + request.tsconfigPathsData = null; } else { - this.tsconfigPathsData = { + request.tsconfigPathsData = { ...tsconfigPathsToResolveOptions( compilerOptions.paths, compilerOptions.baseUrl, @@ -389,19 +387,19 @@ module.exports = class TsconfigPathsPlugin { } } } catch (_e) { - this.tsconfigPathsData = null; + request.tsconfigPathsData = null; } } - if (!this.tsconfigPathsData) { + if (!request.tsconfigPathsData) { return null; } - for (const fileDependency of this.tsconfigPathsData.fileDependencies) { + for (const fileDependency of request.tsconfigPathsData.fileDependencies) { if (resolveContext.fileDependencies) { resolveContext.fileDependencies.add(fileDependency); } } - return this.tsconfigPathsData; + return request.tsconfigPathsData; } }; diff --git a/types.d.ts b/types.d.ts index 02e30726..1aa56860 100644 --- a/types.d.ts +++ b/types.d.ts @@ -51,6 +51,11 @@ declare interface BaseResolveRequest { */ descriptionFileData?: JsonObject; + /** + * tsconfig paths data + */ + tsconfigPathsData?: null | TsconfigPathsData; + /** * relative path */ @@ -1620,7 +1625,6 @@ declare interface TsconfigPathsData { declare class TsconfigPathsPlugin { constructor(configFile: string | true); configFile: string; - tsconfigPathsData?: null | TsconfigPathsData; apply(resolver: Resolver): void; /** From b1e4c7e2e1098dbb4cc01863dd1bb41653f9e35c Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sun, 5 Oct 2025 12:52:18 +0800 Subject: [PATCH 04/12] update --- .cspell.json | 4 +- lib/TsconfigPathsPlugin.js | 17 +- .../tsconfig-paths-utils.test.js.snap | 759 +++++++++++++++++ test/tsconfig-paths-utils.test.js | 781 ++++++++++++++++++ types.d.ts | 4 +- 5 files changed, 1556 insertions(+), 9 deletions(-) create mode 100644 test/__snapshots__/tsconfig-paths-utils.test.js.snap create mode 100644 test/tsconfig-paths-utils.test.js diff --git a/.cspell.json b/.cspell.json index 0db2a984..76571bdc 100644 --- a/.cspell.json +++ b/.cspell.json @@ -37,7 +37,9 @@ "codecov", "xiaoxiaojx", "Natsu", - "tsconfigs" + "tsconfigs", + "preact", + "compat" ], "ignorePaths": ["package.json", "yarn.lock", "coverage", "*.log"] } diff --git a/lib/TsconfigPathsPlugin.js b/lib/TsconfigPathsPlugin.js index 668d6711..0cf61710 100644 --- a/lib/TsconfigPathsPlugin.js +++ b/lib/TsconfigPathsPlugin.js @@ -23,8 +23,8 @@ const { /** * @typedef {object} TsconfigCompilerOptions - * @property {string} baseUrl Base URL for resolving paths - * @property {{[key: string]: string[]}} paths TypeScript paths mapping + * @property {string=} baseUrl Base URL for resolving paths + * @property {{[key: string]: string[]}=} paths TypeScript paths mapping */ /** @@ -59,8 +59,8 @@ function sortByLongestPrefix(arr) { /** * Merge two tsconfig objects - * @param {Tsconfig} base base config - * @param {Tsconfig} config config to merge + * @param {Tsconfig|null} base base config + * @param {Tsconfig|null} config config to merge * @returns {Tsconfig} merged config */ function mergeTsconfigs(base, config) { @@ -379,8 +379,8 @@ module.exports = class TsconfigPathsPlugin { } else { request.tsconfigPathsData = { ...tsconfigPathsToResolveOptions( - compilerOptions.paths, - compilerOptions.baseUrl, + compilerOptions.paths || {}, + /** @type {string} */ (compilerOptions.baseUrl), ), fileDependencies, }; @@ -403,3 +403,8 @@ module.exports = class TsconfigPathsPlugin { return request.tsconfigPathsData; } }; + +module.exports._getPrefixLength = getPrefixLength; +module.exports._mergeTsconfigs = mergeTsconfigs; +module.exports._sortByLongestPrefix = sortByLongestPrefix; +module.exports._tsconfigPathsToResolveOptions = tsconfigPathsToResolveOptions; diff --git a/test/__snapshots__/tsconfig-paths-utils.test.js.snap b/test/__snapshots__/tsconfig-paths-utils.test.js.snap new file mode 100644 index 00000000..18d0b590 --- /dev/null +++ b/test/__snapshots__/tsconfig-paths-utils.test.js.snap @@ -0,0 +1,759 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for Angular project paths 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/angular-project/src/environments/*", + ], + "name": "@environments/*", + }, + Object { + "alias": Array [ + "/angular-project/src/app/shared/*", + ], + "name": "@shared/*", + }, + Object { + "alias": Array [ + "/angular-project/src/assets/*", + ], + "name": "@assets/*", + }, + Object { + "alias": Array [ + "/angular-project/src/app/core/*", + ], + "name": "@core/*", + }, + Object { + "alias": Array [ + "/angular-project/src/app/*", + ], + "name": "@app/*", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for Next.js default paths 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/webpack-app/components/*", + ], + "name": "@/components/*", + }, + Object { + "alias": Array [ + "/webpack-app/styles/*", + ], + "name": "@/styles/*", + }, + Object { + "alias": Array [ + "/webpack-app/lib/*", + ], + "name": "@/lib/*", + }, + Object { + "alias": Array [ + "/webpack-app/*", + ], + "name": "@/*", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for Vue.js project paths 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/vue-project/src/components/*", + ], + "name": "@components/*", + }, + Object { + "alias": Array [ + "/vue-project/src/router/*", + ], + "name": "@router/*", + }, + Object { + "alias": Array [ + "/vue-project/src/views/*", + ], + "name": "@views/*", + }, + Object { + "alias": Array [ + "/vue-project/src/store/*", + ], + "name": "@store/*", + }, + Object { + "alias": Array [ + "/vue-project/src/api/*", + ], + "name": "@api/*", + }, + Object { + "alias": Array [ + "/vue-project/src/*", + ], + "name": "@/*", + }, + Object { + "alias": Array [ + "/vue-project/src/*", + ], + "name": "~/*", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for complex enterprise application 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/enterprise-app/src/shared/components/*", + ], + "name": "@shared/components/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/shared/constants/*", + ], + "name": "@shared/constants/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/shared/services/*", + ], + "name": "@shared/services/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/infrastructure/*", + ], + "name": "@infrastructure/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/shared/utils/*", + ], + "name": "@shared/utils/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/shared/types/*", + ], + "name": "@shared/types/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/presentation/*", + ], + "name": "@presentation/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/core/config/*", + ], + "name": "@core/config/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/core/auth/*", + ], + "name": "@core/auth/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/features/*", + ], + "name": "@features/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/core/api/*", + ], + "name": "@core/api/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/modules/*", + ], + "name": "@modules/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/domain/*", + ], + "name": "@domain/*", + }, + Object { + "alias": Array [ + "/enterprise-app/src/app/*", + ], + "name": "@app/*", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for cross-platform React Native project 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/react-native-app/src/components/*", + ], + "name": "@components/*", + }, + Object { + "alias": Array [ + "/react-native-app/src/navigation/*", + ], + "name": "@navigation/*", + }, + Object { + "alias": Array [ + "/react-native-app/src/services/*", + ], + "name": "@services/*", + }, + Object { + "alias": Array [ + "/react-native-app/src/screens/*", + ], + "name": "@screens/*", + }, + Object { + "alias": Array [ + "/react-native-app/src/android/*", + ], + "name": "@android/*", + }, + Object { + "alias": Array [ + "/react-native-app/src/assets/*", + ], + "name": "@assets/*", + }, + Object { + "alias": Array [ + "/react-native-app/src/hooks/*", + ], + "name": "@hooks/*", + }, + Object { + "alias": Array [ + "/react-native-app/src/utils/*", + ], + "name": "@utils/*", + }, + Object { + "alias": Array [ + "/react-native-app/src/ios/*", + ], + "name": "@ios/*", + }, + Object { + "alias": Array [ + "/react-native-app/src/web/*", + ], + "name": "@web/*", + }, + Object { + "alias": Array [ + "/react-native-app/src/*", + ], + "name": "@/*", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for design system library 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/design-system/src/components/molecules/*", + ], + "name": "@components/molecules/*", + }, + Object { + "alias": Array [ + "/design-system/src/components/organisms/*", + ], + "name": "@components/organisms/*", + }, + Object { + "alias": Array [ + "/design-system/src/components/templates/*", + ], + "name": "@components/templates/*", + }, + Object { + "alias": Array [ + "/design-system/src/components/atoms/*", + ], + "name": "@components/atoms/*", + }, + Object { + "alias": Array [ + "/design-system/src/components/*", + ], + "name": "@components/*", + }, + Object { + "alias": Array [ + "/design-system/src/tokens/*", + ], + "name": "@tokens/*", + }, + Object { + "alias": Array [ + "/design-system/src/hooks/*", + ], + "name": "@hooks/*", + }, + Object { + "alias": Array [ + "/design-system/src/utils/*", + ], + "name": "@utils/*", + }, + Object { + "alias": Array [ + "/design-system/src/theme/*", + ], + "name": "@theme/*", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for fullstack project with server and client 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/fullstack-app/shared/constants/index", + ], + "name": "@common/constants", + }, + Object { + "alias": Array [ + "/fullstack-app/shared/types/index", + ], + "name": "@common/types", + }, + Object { + "alias": Array [ + "/fullstack-app/shared/utils/index", + ], + "name": "@common/utils", + }, + Object { + "alias": Array [ + "/fullstack-app/client/src/*", + ], + "name": "@client/*", + }, + Object { + "alias": Array [ + "/fullstack-app/server/src/*", + ], + "name": "@server/*", + }, + Object { + "alias": Array [ + "/fullstack-app/shared/*", + ], + "name": "@shared/*", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for legacy Node.js project 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/node-project/src/controllers/*", + ], + "name": "controllers/*", + }, + Object { + "alias": Array [ + "/node-project/src/middleware/*", + ], + "name": "middleware/*", + }, + Object { + "alias": Array [ + "/node-project/src/services/*", + ], + "name": "services/*", + }, + Object { + "alias": Array [ + "/node-project/config/*", + ], + "name": "config/*", + }, + Object { + "alias": Array [ + "/node-project/src/models/*", + ], + "name": "models/*", + }, + Object { + "alias": Array [ + "/node-project/src/utils/*", + ], + "name": "utils/*", + }, + Object { + "alias": Array [ + "/node-project/src/*", + ], + "name": "app/*", + }, + Object { + "alias": Array [ + "/node-project/lib/*", + ], + "name": "lib/*", + }, + ], + "modules": Array [ + "/node-project/node_modules", + "/node-project/src/types", + ], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for library with multiple fallbacks 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/library/src/internal/*", + "/library/lib/internal/*", + ], + "name": "@internal/*", + }, + Object { + "alias": Array [ + "/library/src/lib/*", + "/library/lib/*", + "/shared/lib/*", + "/library/node_modules/@internal/lib/*", + ], + "name": "@lib/*", + }, + ], + "modules": Array [ + "/library/node_modules", + "/library/src/types", + ], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for micro-frontend architecture 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/micro-frontend/remotes/app1/src/*", + ], + "name": "@remote/app1/*", + }, + Object { + "alias": Array [ + "/micro-frontend/remotes/app2/src/*", + ], + "name": "@remote/app2/*", + }, + Object { + "alias": Array [ + "/micro-frontend/remotes/app1/src/index", + ], + "name": "@remote/app1", + }, + Object { + "alias": Array [ + "/micro-frontend/remotes/app2/src/index", + ], + "name": "@remote/app2", + }, + Object { + "alias": Array [ + "/micro-frontend/federation/*", + ], + "name": "@federation/*", + }, + Object { + "alias": Array [ + "/micro-frontend/shared/*", + ], + "name": "@shared/*", + }, + Object { + "alias": Array [ + "/micro-frontend/src/*", + ], + "name": "@host/*", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for monorepo workspace configuration 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/monorepo/packages/shared/src/*", + ], + "name": "@workspace/shared/*", + }, + Object { + "alias": Array [ + "/monorepo/packages/shared/src/index.ts", + ], + "name": "@workspace/shared", + }, + Object { + "alias": Array [ + "/monorepo/packages/utils/src/*", + ], + "name": "@workspace/utils/*", + }, + Object { + "alias": Array [ + "/monorepo/packages/core/src/*", + ], + "name": "@workspace/core/*", + }, + Object { + "alias": Array [ + "/monorepo/packages/utils/src/index.ts", + ], + "name": "@workspace/utils", + }, + Object { + "alias": Array [ + "/monorepo/packages/core/src/index.ts", + ], + "name": "@workspace/core", + }, + Object { + "alias": Array [ + "/monorepo/packages/ui/src/*", + ], + "name": "@workspace/ui/*", + }, + Object { + "alias": Array [ + "/monorepo/packages/ui/src/index.ts", + ], + "name": "@workspace/ui", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for nested path patterns with specificity 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/nested-project/src/ui/components/forms/inputs/*", + ], + "name": "@ui/components/forms/inputs/*", + }, + Object { + "alias": Array [ + "/nested-project/src/business/user/profile/*", + ], + "name": "@business/user/profile/*", + }, + Object { + "alias": Array [ + "/nested-project/src/ui/components/forms/*", + ], + "name": "@ui/components/forms/*", + }, + Object { + "alias": Array [ + "/nested-project/src/ui/components/*", + ], + "name": "@ui/components/*", + }, + Object { + "alias": Array [ + "/nested-project/src/business/user/*", + ], + "name": "@business/user/*", + }, + Object { + "alias": Array [ + "/nested-project/src/business/*", + ], + "name": "@business/*", + }, + Object { + "alias": Array [ + "/nested-project/src/ui/*", + ], + "name": "@ui/*", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for package with custom module resolution 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/custom-resolution/src/internal/*", + ], + "name": "#internal/*", + }, + Object { + "alias": Array [ + "/custom-resolution/node_modules/preact/compat", + ], + "name": "react-dom", + }, + Object { + "alias": Array [ + "/custom-resolution/node_modules/lodash-es/*", + ], + "name": "lodash/*", + }, + Object { + "alias": Array [ + "/custom-resolution/node_modules/lodash-es/lodash", + ], + "name": "lodash", + }, + Object { + "alias": Array [ + "/custom-resolution/node_modules/preact/compat", + ], + "name": "react", + }, + Object { + "alias": Array [ + "/custom-resolution/src/*", + ], + "name": "~/*", + }, + ], + "modules": Array [ + "/custom-resolution/src/custom-modules", + "/custom-resolution/node_modules", + ], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for testing with mocks and fixtures 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/project-with-tests/test/utils/*", + ], + "name": "@test-utils/*", + }, + Object { + "alias": Array [ + "/project-with-tests/test/fixtures/*", + ], + "name": "@fixtures/*", + }, + Object { + "alias": Array [ + "/project-with-tests/test/mocks/*", + "/project-with-tests/__mocks__/*", + ], + "name": "@mocks/*", + }, + Object { + "alias": Array [ + "/project-with-tests/test/*", + ], + "name": "@test/*", + }, + Object { + "alias": Array [ + "/project-with-tests/src/*", + ], + "name": "@src/*", + }, + ], + "modules": Array [], +} +`; + +exports[`TsconfigPathsPlugin Utils _tsconfigPathsToResolveOptions snapshot tests should match snapshot for typical React project paths 1`] = ` +Object { + "alias": Array [ + Object { + "alias": Array [ + "/project/src/components/*", + ], + "name": "@components/*", + }, + Object { + "alias": Array [ + "/project/src/styles/*", + ], + "name": "@styles/*", + }, + Object { + "alias": Array [ + "/project/src/hooks/*", + ], + "name": "@hooks/*", + }, + Object { + "alias": Array [ + "/project/src/utils/*", + ], + "name": "@utils/*", + }, + Object { + "alias": Array [ + "/project/src/*", + ], + "name": "@/*", + }, + ], + "modules": Array [], +} +`; diff --git a/test/tsconfig-paths-utils.test.js b/test/tsconfig-paths-utils.test.js new file mode 100644 index 00000000..1f820783 --- /dev/null +++ b/test/tsconfig-paths-utils.test.js @@ -0,0 +1,781 @@ +"use strict"; + +/** @typedef {import("../lib/TsconfigPathsPlugin").Tsconfig} Tsconfig */ + +const TsconfigPathsPlugin = require("../lib/TsconfigPathsPlugin"); + +const { + _getPrefixLength, + _sortByLongestPrefix, + _mergeTsconfigs, + _tsconfigPathsToResolveOptions, +} = TsconfigPathsPlugin; + +describe("TsconfigPathsPlugin Utils", () => { + describe("_tsconfigPathsToResolveOptions", () => { + it("should convert simple path mappings to alias options", () => { + const paths = { + "@components/*": ["src/components/*"], + "@utils/*": ["src/utils/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toEqual([ + { name: "@components/*", alias: ["/project/src/components/*"] }, + { name: "@utils/*", alias: ["/project/src/utils/*"] }, + ]); + expect(result.modules).toEqual([]); + }); + + it("should handle exact path mappings", () => { + const paths = { + foo: ["src/mapped/foo"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toEqual([ + { name: "foo", alias: ["/project/src/mapped/foo"] }, + ]); + }); + + it("should handle wildcard '*' pattern as modules", () => { + const paths = { + "*": ["src/mapped/star/*", "node_modules/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toEqual([]); + expect(result.modules).toEqual([ + "/project/src/mapped/star", + "/project/node_modules", + ]); + }); + + it("should handle multiple mappings for same pattern", () => { + const paths = { + "@lib/*": ["src/lib/*", "vendor/lib/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toEqual([ + { + name: "@lib/*", + alias: ["/project/src/lib/*", "/project/vendor/lib/*"], + }, + ]); + }); + + it("should sort by longest prefix", () => { + const paths = { + "*": ["src/*"], + "longest/*": ["src/longest/*"], + "longest/bar/*": ["src/longest/bar/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias[0].name).toBe("longest/bar/*"); + expect(result.alias[1].name).toBe("longest/*"); + }); + + it("should handle mixed exact and wildcard patterns", () => { + const paths = { + foo: ["src/foo"], + "bar/*": ["src/bar/*"], + "*": ["src/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toContainEqual({ + name: "foo", + alias: ["/project/src/foo"], + }); + expect(result.alias).toContainEqual({ + name: "bar/*", + alias: ["/project/src/bar/*"], + }); + expect(result.modules).toEqual(["/project/src"]); + }); + + it("should filter out non-wildcard targets from modules", () => { + const paths = { + "*": ["src/*", "lib/no-wildcard", "vendor/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.modules).toEqual(["/project/src", "/project/vendor"]); + expect(result.modules).not.toContain("/project/lib/no-wildcard"); + }); + + it("should handle empty paths", () => { + const paths = {}; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions( + /** @type {{[key: string]: string[]}} */ (paths), + baseUrl, + ); + + expect(result.alias).toEqual([]); + expect(result.modules).toEqual([]); + }); + + it("should handle Windows-style paths", () => { + const paths = { + "@components/*": ["src\\components\\*"], + }; + const baseUrl = "C:\\project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias[0].alias[0]).toContain("components"); + }); + + // Real-world tsconfig.json scenarios + it("should handle monorepo package references", () => { + const paths = { + "@workspace/ui/*": ["packages/ui/src/*"], + "@workspace/shared/*": ["packages/shared/src/*"], + "@workspace/utils": ["packages/utils/src/index"], + }; + const baseUrl = "/monorepo"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toContainEqual({ + name: "@workspace/ui/*", + alias: ["/monorepo/packages/ui/src/*"], + }); + expect(result.alias).toContainEqual({ + name: "@workspace/shared/*", + alias: ["/monorepo/packages/shared/src/*"], + }); + expect(result.alias).toContainEqual({ + name: "@workspace/utils", + alias: ["/monorepo/packages/utils/src/index"], + }); + }); + + it("should handle fallback paths (multiple targets for same pattern)", () => { + const paths = { + "@lib/*": [ + "src/lib/*", + "../../common/lib/*", + "node_modules/@company/lib/*", + ], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toEqual([ + { + name: "@lib/*", + alias: [ + "/project/src/lib/*", + "/common/lib/*", + "/project/node_modules/@company/lib/*", + ], + }, + ]); + }); + + it("should handle scoped package aliases", () => { + const paths = { + "@app/*": ["src/*"], + "@shared/*": ["../shared/*"], + "@core/*": ["../../core/src/*"], + }; + const baseUrl = "/workspace/apps/web"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toContainEqual({ + name: "@app/*", + alias: ["/workspace/apps/web/src/*"], + }); + expect(result.alias).toContainEqual({ + name: "@shared/*", + alias: ["/workspace/apps/shared/*"], + }); + expect(result.alias).toContainEqual({ + name: "@core/*", + alias: ["/workspace/core/src/*"], + }); + }); + + it("should handle specific file mappings (non-wildcard)", () => { + const paths = { + jquery: ["node_modules/jquery/dist/jquery"], + lodash: ["node_modules/lodash-es/lodash"], + "react-native": ["node_modules/react-native-web"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toContainEqual({ + name: "jquery", + alias: ["/project/node_modules/jquery/dist/jquery"], + }); + expect(result.alias).toContainEqual({ + name: "lodash", + alias: ["/project/node_modules/lodash-es/lodash"], + }); + expect(result.alias).toContainEqual({ + name: "react-native", + alias: ["/project/node_modules/react-native-web"], + }); + }); + + it("should handle wildcard suffix patterns", () => { + const paths = { + "*/utils": ["src/common/utils"], + "*/helpers": ["src/shared/helpers"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toContainEqual({ + name: "*/utils", + alias: ["/project/src/common/utils"], + }); + expect(result.alias).toContainEqual({ + name: "*/helpers", + alias: ["/project/src/shared/helpers"], + }); + }); + + it("should handle mixed relative and absolute-like paths", () => { + const paths = { + "~/*": ["src/*"], + "@/*": ["./src/*"], + "#/*": ["../common/*"], + }; + const baseUrl = "/project/app"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toContainEqual({ + name: "~/*", + alias: ["/project/app/src/*"], + }); + expect(result.alias).toContainEqual({ + name: "@/*", + alias: ["/project/app/src/*"], + }); + expect(result.alias).toContainEqual({ + name: "#/*", + alias: ["/project/common/*"], + }); + }); + + it("should handle deep nested path patterns", () => { + const paths = { + "@components/ui/buttons/*": ["src/ui/components/buttons/*"], + "@components/ui/*": ["src/ui/components/*"], + "@components/*": ["src/components/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + // Should be sorted by longest prefix + expect(result.alias[0].name).toBe("@components/ui/buttons/*"); + expect(result.alias[1].name).toBe("@components/ui/*"); + expect(result.alias[2].name).toBe("@components/*"); + }); + + it("should handle legacy module resolution patterns", () => { + const paths = { + "*": ["node_modules/*", "src/types/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toEqual([]); + expect(result.modules).toEqual([ + "/project/node_modules", + "/project/src/types", + ]); + }); + + it("should handle empty array targets", () => { + const paths = { + "@deprecated/*": [], + "@active/*": ["src/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + // Empty arrays should be handled gracefully + expect( + result.alias.find((a) => a.name === "@deprecated/*"), + ).toBeUndefined(); + expect(result.alias).toContainEqual({ + name: "@active/*", + alias: ["/project/src/*"], + }); + }); + + it("should handle complex multi-pattern configuration", () => { + const paths = { + "@app/*": ["src/*"], + "@lib/*": ["lib/*", "vendor/lib/*"], + "@test/*": ["test/*"], + "@types/*": ["types/*", "node_modules/@types/*"], + "*": ["node_modules/*", "src/vendor/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toHaveLength(4); // * is handled as modules + expect(result.modules).toEqual([ + "/project/node_modules", + "/project/src/vendor", + ]); + expect(result.alias).toContainEqual({ + name: "@lib/*", + alias: ["/project/lib/*", "/project/vendor/lib/*"], + }); + }); + + it("should handle paths with special characters", () => { + const paths = { + "@my-app/*": ["src/*"], + "@my_lib/*": ["lib/*"], + "@123/*": ["packages/123/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toContainEqual({ + name: "@my-app/*", + alias: ["/project/src/*"], + }); + expect(result.alias).toContainEqual({ + name: "@my_lib/*", + alias: ["/project/lib/*"], + }); + expect(result.alias).toContainEqual({ + name: "@123/*", + alias: ["/project/packages/123/*"], + }); + }); + + it("should handle TypeScript built-in lib overrides", () => { + const paths = { + "typescript/lib/*": ["custom-typings/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toEqual([ + { + name: "typescript/lib/*", + alias: ["/project/custom-typings/*"], + }, + ]); + }); + + it("should handle root-level module mappings", () => { + const paths = { + react: ["custom/react"], + "react-dom": ["custom/react-dom"], + "react/*": ["custom/react/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + + expect(result.alias).toContainEqual({ + name: "react", + alias: ["/project/custom/react"], + }); + expect(result.alias).toContainEqual({ + name: "react-dom", + alias: ["/project/custom/react-dom"], + }); + expect(result.alias).toContainEqual({ + name: "react/*", + alias: ["/project/custom/react/*"], + }); + }); + + // Snapshot tests for various tsconfig paths configurations + describe("snapshot tests", () => { + it("should match snapshot for typical React project paths", () => { + const paths = { + "@/*": ["src/*"], + "@components/*": ["src/components/*"], + "@hooks/*": ["src/hooks/*"], + "@utils/*": ["src/utils/*"], + "@styles/*": ["src/styles/*"], + }; + const baseUrl = "/project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + // cspell:disable-next-line + it("should match snapshot for Next.js default paths", () => { + const paths = { + "@/*": ["./*"], + "@/components/*": ["components/*"], + "@/lib/*": ["lib/*"], + "@/styles/*": ["styles/*"], + }; + const baseUrl = "/webpack-app"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for monorepo workspace configuration", () => { + const paths = { + "@workspace/ui": ["packages/ui/src/index.ts"], + "@workspace/ui/*": ["packages/ui/src/*"], + "@workspace/core": ["packages/core/src/index.ts"], + "@workspace/core/*": ["packages/core/src/*"], + "@workspace/shared": ["packages/shared/src/index.ts"], + "@workspace/shared/*": ["packages/shared/src/*"], + "@workspace/utils": ["packages/utils/src/index.ts"], + "@workspace/utils/*": ["packages/utils/src/*"], + }; + const baseUrl = "/monorepo"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for Angular project paths", () => { + const paths = { + "@app/*": ["src/app/*"], + "@core/*": ["src/app/core/*"], + "@shared/*": ["src/app/shared/*"], + "@environments/*": ["src/environments/*"], + "@assets/*": ["src/assets/*"], + }; + const baseUrl = "/angular-project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for Vue.js project paths", () => { + const paths = { + "@/*": ["src/*"], + "~/*": ["src/*"], + "@components/*": ["src/components/*"], + "@views/*": ["src/views/*"], + "@store/*": ["src/store/*"], + "@router/*": ["src/router/*"], + "@api/*": ["src/api/*"], + }; + const baseUrl = "/vue-project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for library with multiple fallbacks", () => { + const paths = { + "*": ["node_modules/*", "src/types/*", "global.d.ts"], + "@lib/*": [ + "src/lib/*", + "lib/*", + "../../shared/lib/*", + "node_modules/@internal/lib/*", + ], + "@internal/*": ["src/internal/*", "lib/internal/*"], + }; + const baseUrl = "/library"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for complex enterprise application", () => { + const paths = { + "@app/*": ["src/app/*"], + "@modules/*": ["src/modules/*"], + "@features/*": ["src/features/*"], + "@shared/components/*": ["src/shared/components/*"], + "@shared/services/*": ["src/shared/services/*"], + "@shared/utils/*": ["src/shared/utils/*"], + "@shared/types/*": ["src/shared/types/*"], + "@shared/constants/*": ["src/shared/constants/*"], + "@core/auth/*": ["src/core/auth/*"], + "@core/api/*": ["src/core/api/*"], + "@core/config/*": ["src/core/config/*"], + "@domain/*": ["src/domain/*"], + "@infrastructure/*": ["src/infrastructure/*"], + "@presentation/*": ["src/presentation/*"], + }; + const baseUrl = "/enterprise-app"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for legacy Node.js project", () => { + const paths = { + "*": ["node_modules/*", "src/types/*"], + "app/*": ["src/*"], + "lib/*": ["lib/*"], + "config/*": ["config/*"], + "utils/*": ["src/utils/*"], + "controllers/*": ["src/controllers/*"], + "models/*": ["src/models/*"], + "services/*": ["src/services/*"], + "middleware/*": ["src/middleware/*"], + }; + const baseUrl = "/node-project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for testing with mocks and fixtures", () => { + const paths = { + "@src/*": ["src/*"], + "@test/*": ["test/*"], + "@mocks/*": ["test/mocks/*", "__mocks__/*"], + "@fixtures/*": ["test/fixtures/*"], + "@test-utils/*": ["test/utils/*"], + }; + const baseUrl = "/project-with-tests"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for micro-frontend architecture", () => { + const paths = { + "@host/*": ["src/*"], + "@remote/app1": ["remotes/app1/src/index"], + "@remote/app1/*": ["remotes/app1/src/*"], + "@remote/app2": ["remotes/app2/src/index"], + "@remote/app2/*": ["remotes/app2/src/*"], + "@shared/*": ["shared/*"], + "@federation/*": ["federation/*"], + }; + const baseUrl = "/micro-frontend"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for fullstack project with server and client", () => { + const paths = { + "@client/*": ["client/src/*"], + "@server/*": ["server/src/*"], + "@shared/*": ["shared/*"], + "@common/types": ["shared/types/index"], + "@common/utils": ["shared/utils/index"], + "@common/constants": ["shared/constants/index"], + }; + const baseUrl = "/fullstack-app"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for design system library", () => { + const paths = { + "@components/*": ["src/components/*"], + "@components/atoms/*": ["src/components/atoms/*"], + "@components/molecules/*": ["src/components/molecules/*"], + "@components/organisms/*": ["src/components/organisms/*"], + "@components/templates/*": ["src/components/templates/*"], + "@tokens/*": ["src/tokens/*"], + "@hooks/*": ["src/hooks/*"], + "@utils/*": ["src/utils/*"], + "@theme/*": ["src/theme/*"], + }; + const baseUrl = "/design-system"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for cross-platform React Native project", () => { + const paths = { + "@/*": ["src/*"], + "@components/*": ["src/components/*"], + "@screens/*": ["src/screens/*"], + "@navigation/*": ["src/navigation/*"], + "@hooks/*": ["src/hooks/*"], + "@utils/*": ["src/utils/*"], + "@services/*": ["src/services/*"], + "@assets/*": ["src/assets/*"], + "@ios/*": ["src/ios/*"], + "@android/*": ["src/android/*"], + "@web/*": ["src/web/*"], + }; + const baseUrl = "/react-native-app"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for nested path patterns with specificity", () => { + const paths = { + "@ui/components/forms/inputs/*": ["src/ui/components/forms/inputs/*"], + "@ui/components/forms/*": ["src/ui/components/forms/*"], + "@ui/components/*": ["src/ui/components/*"], + "@ui/*": ["src/ui/*"], + "@business/user/profile/*": ["src/business/user/profile/*"], + "@business/user/*": ["src/business/user/*"], + "@business/*": ["src/business/*"], + }; + const baseUrl = "/nested-project"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + + it("should match snapshot for package with custom module resolution", () => { + const paths = { + "*": ["src/custom-modules/*", "node_modules/*"], + "~/*": ["src/*"], + "#internal/*": ["src/internal/*"], + lodash: ["node_modules/lodash-es/lodash"], + "lodash/*": ["node_modules/lodash-es/*"], + react: ["node_modules/preact/compat"], + "react-dom": ["node_modules/preact/compat"], + }; + const baseUrl = "/custom-resolution"; + const result = _tsconfigPathsToResolveOptions(paths, baseUrl); + expect(result).toMatchSnapshot(); + }); + }); + }); + + describe("_getPrefixLength", () => { + it("should return full length for patterns without wildcard", () => { + expect(_getPrefixLength("@components/button")).toBe(18); + expect(_getPrefixLength("foo")).toBe(3); + }); + + it("should return length before wildcard", () => { + expect(_getPrefixLength("@components/*")).toBe(12); + expect(_getPrefixLength("bar/*")).toBe(4); + expect(_getPrefixLength("*/old-file")).toBe(0); + }); + + it("should handle wildcard pattern", () => { + expect(_getPrefixLength("*")).toBe(0); + }); + + it("should handle patterns with multiple segments", () => { + expect(_getPrefixLength("longest/bar/*")).toBe(12); + expect(_getPrefixLength("longest/*")).toBe(8); + }); + }); + + describe("_sortByLongestPrefix", () => { + it("should sort patterns by longest prefix first", () => { + const patterns = ["bar/*", "longest/bar/*", "longest/*", "*"]; + const sorted = _sortByLongestPrefix(patterns); + expect(sorted).toEqual(["longest/bar/*", "longest/*", "bar/*", "*"]); + }); + + it("should handle patterns without wildcards", () => { + const patterns = ["foo", "@components/button", "bar"]; + const sorted = _sortByLongestPrefix(patterns); + expect(sorted).toEqual(["@components/button", "foo", "bar"]); + }); + + it("should handle mixed patterns", () => { + const patterns = ["*", "@utils/*", "longest/bar/baz/*", "foo"]; + const sorted = _sortByLongestPrefix(patterns); + expect(sorted).toEqual(["longest/bar/baz/*", "@utils/*", "foo", "*"]); + }); + + it("should not mutate original array", () => { + const patterns = ["bar/*", "foo", "*"]; + const original = [...patterns]; + _sortByLongestPrefix(patterns); + expect(patterns).toEqual(original); + }); + }); + + describe("_mergeTsconfigs", () => { + it("should merge two tsconfig objects", () => { + const base = { + compilerOptions: { + baseUrl: "./src", + paths: { + "@utils/*": ["utils/*"], + }, + }, + }; + const config = { + compilerOptions: { + paths: { + "@components/*": ["components/*"], + }, + }, + }; + const merged = _mergeTsconfigs(base, /** @type {Tsconfig} */ (config)); + expect(merged).toEqual({ + compilerOptions: { + baseUrl: "./src", + paths: { + "@components/*": ["components/*"], + }, + }, + }); + }); + + it("should handle undefined/null base", () => { + const config = { + compilerOptions: { + baseUrl: "./src", + }, + }; + const merged = _mergeTsconfigs(null, /** @type {Tsconfig} */ (config)); + expect(merged).toEqual(config); + }); + + it("should handle undefined/null config", () => { + const base = { + compilerOptions: { + baseUrl: "./src", + }, + }; + const merged = _mergeTsconfigs(base, null); + expect(merged).toEqual(base); + }); + + it("should override base with config properties", () => { + const base = { + compilerOptions: { + baseUrl: "./base", + target: "ES5", + }, + include: ["base/**/*"], + }; + const config = { + compilerOptions: { + baseUrl: "./src", + module: "commonjs", + }, + exclude: ["node_modules"], + }; + const merged = _mergeTsconfigs( + /** @type {Tsconfig} */ (base), + /** @type {Tsconfig} */ (config), + ); + // @ts-expect-error + expect(/** @type {Tsconfig} */ (merged).compilerOptions.baseUrl).toBe( + "./src", + ); + // @ts-expect-error + expect(/** @type {Tsconfig} */ (merged).compilerOptions.module).toBe( + "commonjs", + ); + // @ts-expect-error + expect(/** @type {Tsconfig} */ (merged).exclude).toEqual([ + "node_modules", + ]); + }); + + it("should handle empty compilerOptions", () => { + const base = {}; + const config = { + compilerOptions: { + baseUrl: "./src", + }, + }; + const merged = _mergeTsconfigs(base, config); + // @ts-expect-error + expect(merged.compilerOptions.baseUrl).toBe("./src"); + }); + }); +}); diff --git a/types.d.ts b/types.d.ts index 1aa56860..7fc15895 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1599,12 +1599,12 @@ declare interface TsconfigCompilerOptions { /** * Base URL for resolving paths */ - baseUrl: string; + baseUrl?: string; /** * TypeScript paths mapping */ - paths: { [index: string]: string[] }; + paths?: { [index: string]: string[] }; } declare interface TsconfigPathsData { /** From a233f9c5817d87108c2e506e13fe03e9f0ca00b8 Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sun, 5 Oct 2025 15:13:11 +0800 Subject: [PATCH 05/12] update --- test/__snapshots__/tsconfig-paths-utils.test.js.snap | 12 ------------ test/tsconfig-paths-utils.test.js | 2 -- 2 files changed, 14 deletions(-) diff --git a/test/__snapshots__/tsconfig-paths-utils.test.js.snap b/test/__snapshots__/tsconfig-paths-utils.test.js.snap index 18d0b590..58dfa669 100644 --- a/test/__snapshots__/tsconfig-paths-utils.test.js.snap +++ b/test/__snapshots__/tsconfig-paths-utils.test.js.snap @@ -221,12 +221,6 @@ Object { ], "name": "@components/*", }, - Object { - "alias": Array [ - "/react-native-app/src/navigation/*", - ], - "name": "@navigation/*", - }, Object { "alias": Array [ "/react-native-app/src/services/*", @@ -251,12 +245,6 @@ Object { ], "name": "@assets/*", }, - Object { - "alias": Array [ - "/react-native-app/src/hooks/*", - ], - "name": "@hooks/*", - }, Object { "alias": Array [ "/react-native-app/src/utils/*", diff --git a/test/tsconfig-paths-utils.test.js b/test/tsconfig-paths-utils.test.js index 1f820783..279d059c 100644 --- a/test/tsconfig-paths-utils.test.js +++ b/test/tsconfig-paths-utils.test.js @@ -590,8 +590,6 @@ describe("TsconfigPathsPlugin Utils", () => { "@/*": ["src/*"], "@components/*": ["src/components/*"], "@screens/*": ["src/screens/*"], - "@navigation/*": ["src/navigation/*"], - "@hooks/*": ["src/hooks/*"], "@utils/*": ["src/utils/*"], "@services/*": ["src/services/*"], "@assets/*": ["src/assets/*"], From a0dbc45d7e9ab7bbe7e5e137fb6c3d3d0a1ac23c Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sun, 5 Oct 2025 15:32:39 +0800 Subject: [PATCH 06/12] update --- test/__snapshots__/tsconfig-paths-utils.test.js.snap | 12 ------------ test/tsconfig-paths-utils.test.js | 2 -- 2 files changed, 14 deletions(-) diff --git a/test/__snapshots__/tsconfig-paths-utils.test.js.snap b/test/__snapshots__/tsconfig-paths-utils.test.js.snap index 58dfa669..697d270b 100644 --- a/test/__snapshots__/tsconfig-paths-utils.test.js.snap +++ b/test/__snapshots__/tsconfig-paths-utils.test.js.snap @@ -159,12 +159,6 @@ Object { ], "name": "@shared/types/*", }, - Object { - "alias": Array [ - "/enterprise-app/src/presentation/*", - ], - "name": "@presentation/*", - }, Object { "alias": Array [ "/enterprise-app/src/core/config/*", @@ -183,12 +177,6 @@ Object { ], "name": "@features/*", }, - Object { - "alias": Array [ - "/enterprise-app/src/core/api/*", - ], - "name": "@core/api/*", - }, Object { "alias": Array [ "/enterprise-app/src/modules/*", diff --git a/test/tsconfig-paths-utils.test.js b/test/tsconfig-paths-utils.test.js index 279d059c..85336283 100644 --- a/test/tsconfig-paths-utils.test.js +++ b/test/tsconfig-paths-utils.test.js @@ -498,11 +498,9 @@ describe("TsconfigPathsPlugin Utils", () => { "@shared/types/*": ["src/shared/types/*"], "@shared/constants/*": ["src/shared/constants/*"], "@core/auth/*": ["src/core/auth/*"], - "@core/api/*": ["src/core/api/*"], "@core/config/*": ["src/core/config/*"], "@domain/*": ["src/domain/*"], "@infrastructure/*": ["src/infrastructure/*"], - "@presentation/*": ["src/presentation/*"], }; const baseUrl = "/enterprise-app"; const result = _tsconfigPathsToResolveOptions(paths, baseUrl); From a83383bf7fee0f1782b7145912407078b247bac7 Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sun, 5 Oct 2025 15:36:45 +0800 Subject: [PATCH 07/12] update --- test/tsconfig-paths-utils.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/tsconfig-paths-utils.test.js b/test/tsconfig-paths-utils.test.js index 85336283..af32fd5d 100644 --- a/test/tsconfig-paths-utils.test.js +++ b/test/tsconfig-paths-utils.test.js @@ -495,7 +495,6 @@ describe("TsconfigPathsPlugin Utils", () => { "@shared/components/*": ["src/shared/components/*"], "@shared/services/*": ["src/shared/services/*"], "@shared/utils/*": ["src/shared/utils/*"], - "@shared/types/*": ["src/shared/types/*"], "@shared/constants/*": ["src/shared/constants/*"], "@core/auth/*": ["src/core/auth/*"], "@core/config/*": ["src/core/config/*"], From f88b641d77abf76706891788cf05ea93e77b8f65 Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sun, 5 Oct 2025 15:42:13 +0800 Subject: [PATCH 08/12] update --- test/__snapshots__/tsconfig-paths-utils.test.js.snap | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/__snapshots__/tsconfig-paths-utils.test.js.snap b/test/__snapshots__/tsconfig-paths-utils.test.js.snap index 697d270b..c298c54e 100644 --- a/test/__snapshots__/tsconfig-paths-utils.test.js.snap +++ b/test/__snapshots__/tsconfig-paths-utils.test.js.snap @@ -153,12 +153,6 @@ Object { ], "name": "@shared/utils/*", }, - Object { - "alias": Array [ - "/enterprise-app/src/shared/types/*", - ], - "name": "@shared/types/*", - }, Object { "alias": Array [ "/enterprise-app/src/core/config/*", From 4c8ab80f7c89eea2698a1f44979a629c9a5cfcc2 Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sun, 5 Oct 2025 16:10:54 +0800 Subject: [PATCH 09/12] update --- lib/TsconfigPathsPlugin.js | 160 ++++++++---------- .../tsconfig-paths/extends-base/tsconfig.json | 2 +- .../malformed-json/tsconfig.json | 10 ++ test/tsconfig-paths.test.js | 30 ++++ types.d.ts | 4 +- 5 files changed, 112 insertions(+), 94 deletions(-) create mode 100644 test/fixtures/tsconfig-paths/malformed-json/tsconfig.json diff --git a/lib/TsconfigPathsPlugin.js b/lib/TsconfigPathsPlugin.js index 0cf61710..3d47e0c1 100644 --- a/lib/TsconfigPathsPlugin.js +++ b/lib/TsconfigPathsPlugin.js @@ -262,94 +262,80 @@ module.exports = class TsconfigPathsPlugin { * @param {FileSystem} fileSystem the file system * @param {string} configFilePath absolute path to tsconfig.json * @param {Set} fileDependencies the file dependencies - * @returns {Promise} the merged tsconfig + * @returns {Promise} the merged tsconfig */ async loadTsconfig(fileSystem, configFilePath, fileDependencies) { - try { - const data = await new Promise((resolve, reject) => { - fileSystem.readFile(configFilePath, "utf8", (err, data) => { - if (err) reject(err); - else resolve(data); - }); + const data = await new Promise((resolve, reject) => { + fileSystem.readFile(configFilePath, "utf8", (err, data) => { + if (err) reject(err); + else resolve(data); }); - fileDependencies.add(configFilePath); - let config; - try { - config = JSON.parse(/** @type {string} */ (data)); - } catch (err) { - throw new Error( - `${configFilePath} is malformed ${ - /** @type {Error} */ (err).message - }`, - ); - } + }); + fileDependencies.add(configFilePath); + let config; + try { + config = JSON.parse(/** @type {string} */ (data)); + } catch (err) { + throw new Error( + `${configFilePath} is malformed ${/** @type {Error} */ (err).message}`, + ); + } - const extendedConfig = config.extends; - if (extendedConfig) { - let base; - - if (Array.isArray(extendedConfig)) { - // Handle multiple extends (array) - base = {}; - for (const extendedConfigElement of extendedConfig) { - const extendedTsconfig = await this.loadTsconfigFromExtends( - fileSystem, - configFilePath, - extendedConfigElement, - fileDependencies, - ); - base = mergeTsconfigs(base, extendedTsconfig); - } - } else { - // Handle single extends (string) - base = await this.loadTsconfigFromExtends( + const extendedConfig = config.extends; + if (extendedConfig) { + let base; + + if (Array.isArray(extendedConfig)) { + // Handle multiple extends (array) + base = {}; + for (const extendedConfigElement of extendedConfig) { + const extendedTsconfig = await this.loadTsconfigFromExtends( fileSystem, configFilePath, - extendedConfig, + extendedConfigElement, fileDependencies, ); + base = mergeTsconfigs(base, extendedTsconfig); } - - return /** @type {Tsconfig} */ (mergeTsconfigs(base, config)); + } else { + // Handle single extends (string) + base = await this.loadTsconfigFromExtends( + fileSystem, + configFilePath, + extendedConfig, + fileDependencies, + ); } - return config; - } catch (_e) { - return null; + + return /** @type {Tsconfig} */ (mergeTsconfigs(base, config)); } + return config; } /** * Read tsconfig.json and return normalized compiler options * @param {FileSystem} fileSystem the file system * @param {string} absTsconfigPath absolute path to tsconfig.json - * @returns {Promise<{options: TsconfigCompilerOptions, fileDependencies: Set}|null>} the normalized compiler options + * @returns {Promise<{options: TsconfigCompilerOptions, fileDependencies: Set}>} the normalized compiler options */ async readTsconfigCompilerOptions(fileSystem, absTsconfigPath) { - try { - /** @type {Set} */ - const fileDependencies = new Set(); - const config = await this.loadTsconfig( - fileSystem, - absTsconfigPath, - fileDependencies, - ); - if (!config) return null; - - const compilerOptions = config.compilerOptions || { - baseUrl: undefined, - paths: undefined, - }; - let { baseUrl } = compilerOptions; - - baseUrl = !baseUrl - ? dirname(absTsconfigPath) - : join(dirname(absTsconfigPath), baseUrl); - - const paths = compilerOptions.paths || {}; - return { options: { baseUrl, paths }, fileDependencies }; - } catch (_e) { - return null; - } + /** @type {Set} */ + const fileDependencies = new Set(); + const config = await this.loadTsconfig( + fileSystem, + absTsconfigPath, + fileDependencies, + ); + + const compilerOptions = config.compilerOptions || {}; + let { baseUrl } = compilerOptions; + + baseUrl = !baseUrl + ? dirname(absTsconfigPath) + : join(dirname(absTsconfigPath), baseUrl); + + const paths = compilerOptions.paths || {}; + return { options: { baseUrl, paths }, fileDependencies }; } /** @@ -370,32 +356,24 @@ module.exports = class TsconfigPathsPlugin { resolver.fileSystem, absTsconfigPath, ); - if (!result) { - request.tsconfigPathsData = null; - } else { - const { options: compilerOptions, fileDependencies } = result; - if (!compilerOptions) { - request.tsconfigPathsData = null; - } else { - request.tsconfigPathsData = { - ...tsconfigPathsToResolveOptions( - compilerOptions.paths || {}, - /** @type {string} */ (compilerOptions.baseUrl), - ), - fileDependencies, - }; - } - } - } catch (_e) { + const { options: compilerOptions, fileDependencies } = result; + + request.tsconfigPathsData = { + ...tsconfigPathsToResolveOptions( + compilerOptions.paths || {}, + /** @type {string} */ (compilerOptions.baseUrl), + ), + fileDependencies, + }; + } catch (err) { request.tsconfigPathsData = null; + throw err; } } - if (!request.tsconfigPathsData) { - return null; - } - - for (const fileDependency of request.tsconfigPathsData.fileDependencies) { + for (const fileDependency of /** @type {TsconfigPathsData} */ ( + request.tsconfigPathsData + ).fileDependencies) { if (resolveContext.fileDependencies) { resolveContext.fileDependencies.add(fileDependency); } diff --git a/test/fixtures/tsconfig-paths/extends-base/tsconfig.json b/test/fixtures/tsconfig-paths/extends-base/tsconfig.json index 60b3e116..1926542a 100644 --- a/test/fixtures/tsconfig-paths/extends-base/tsconfig.json +++ b/test/fixtures/tsconfig-paths/extends-base/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../base/tsconfig.json", + "extends": "../base/tsconfig", "compilerOptions": { "baseUrl": "." } diff --git a/test/fixtures/tsconfig-paths/malformed-json/tsconfig.json b/test/fixtures/tsconfig-paths/malformed-json/tsconfig.json new file mode 100644 index 00000000..fb2f3a4e --- /dev/null +++ b/test/fixtures/tsconfig-paths/malformed-json/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": "./src", + "paths": { + "@components/*": ["components/*"], + // This is a comment which makes JSON invalid + "@utils/*": ["utils/*" + } + } +} diff --git a/test/tsconfig-paths.test.js b/test/tsconfig-paths.test.js index 246c291e..2d73eb18 100644 --- a/test/tsconfig-paths.test.js +++ b/test/tsconfig-paths.test.js @@ -388,4 +388,34 @@ describe("TsconfigPathsPlugin", () => { }, ); }); + + it("should handle malformed tsconfig.json gracefully", (done) => { + const malformedExampleDir = path.resolve( + __dirname, + "fixtures", + "tsconfig-paths", + "malformed-json", + ); + + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(malformedExampleDir, "tsconfig.json"), + }); + + // Should fail to resolve because the malformed tsconfig should be ignored + resolver.resolve( + {}, + malformedExampleDir, + "@components/button", + {}, + (err, result) => { + expect(err).toBeInstanceOf(Error); + expect(result).toBeUndefined(); + done(); + }, + ); + }); }); diff --git a/types.d.ts b/types.d.ts index 7fc15895..f4c1c5ad 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1644,7 +1644,7 @@ declare class TsconfigPathsPlugin { fileSystem: FileSystem, configFilePath: string, fileDependencies: Set, - ): Promise; + ): Promise; /** * Read tsconfig.json and return normalized compiler options @@ -1652,7 +1652,7 @@ declare class TsconfigPathsPlugin { readTsconfigCompilerOptions( fileSystem: FileSystem, absTsconfigPath: string, - ): Promise; }>; From eb2ba51fe8be52b85cacee7d0ca3edb78281143d Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sun, 5 Oct 2025 16:22:23 +0800 Subject: [PATCH 10/12] update --- lib/TsconfigPathsPlugin.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/TsconfigPathsPlugin.js b/lib/TsconfigPathsPlugin.js index 3d47e0c1..2f244347 100644 --- a/lib/TsconfigPathsPlugin.js +++ b/lib/TsconfigPathsPlugin.js @@ -93,13 +93,13 @@ function tsconfigPathsToResolveOptions(paths, baseUrl) { for (const pattern of sortedKeys) { const mappings = paths[pattern]; - const aliasTargets = mappings.map((mapping) => join(baseUrl, mapping)); + const absolutePaths = mappings.map((mapping) => join(baseUrl, mapping)); - if (aliasTargets.length > 0) { + if (absolutePaths.length > 0) { if (pattern === "*") { // Handle "*" pattern - extract modules from wildcard targets modules.push( - ...aliasTargets + ...absolutePaths .map((dir) => { if (/[/\\]\*$/.test(dir)) { return dir.replace(/[/\\]\*$/, ""); @@ -110,7 +110,7 @@ function tsconfigPathsToResolveOptions(paths, baseUrl) { ); } else { // Handle regular patterns - add as alias - alias.push({ name: pattern, alias: aliasTargets }); + alias.push({ name: pattern, alias: absolutePaths }); } } } @@ -371,9 +371,11 @@ module.exports = class TsconfigPathsPlugin { } } - for (const fileDependency of /** @type {TsconfigPathsData} */ ( - request.tsconfigPathsData - ).fileDependencies) { + if (!request.tsconfigPathsData) { + return null; + } + + for (const fileDependency of request.tsconfigPathsData.fileDependencies) { if (resolveContext.fileDependencies) { resolveContext.fileDependencies.add(fileDependency); } From 6da874f3b749a157174b4bbc40db36849d18c1ad Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sun, 5 Oct 2025 16:32:15 +0800 Subject: [PATCH 11/12] update --- lib/TsconfigPathsPlugin.js | 11 +++---- .../node_modules/react/package.json | 8 +++++ .../node_modules/react/tsconfig.json | 20 ++++++++++++ .../extends-npm/src/components/button.ts | 3 ++ .../tsconfig-paths/extends-npm/src/index.ts | 8 +++++ .../extends-npm/src/utils/date.ts | 3 ++ .../tsconfig-paths/extends-npm/tsconfig.json | 6 ++++ test/tsconfig-paths.test.js | 31 +++++++++++++++++++ 8 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/tsconfig-paths/extends-npm/node_modules/react/package.json create mode 100644 test/fixtures/tsconfig-paths/extends-npm/node_modules/react/tsconfig.json create mode 100644 test/fixtures/tsconfig-paths/extends-npm/src/components/button.ts create mode 100644 test/fixtures/tsconfig-paths/extends-npm/src/index.ts create mode 100644 test/fixtures/tsconfig-paths/extends-npm/src/utils/date.ts create mode 100644 test/fixtures/tsconfig-paths/extends-npm/tsconfig.json diff --git a/lib/TsconfigPathsPlugin.js b/lib/TsconfigPathsPlugin.js index 2f244347..224381c2 100644 --- a/lib/TsconfigPathsPlugin.js +++ b/lib/TsconfigPathsPlugin.js @@ -239,12 +239,11 @@ module.exports = class TsconfigPathsPlugin { ); } - const config = - (await this.loadTsconfig( - fileSystem, - extendedConfigPath, - fileDependencies, - )) || {}; + const config = await this.loadTsconfig( + fileSystem, + extendedConfigPath, + fileDependencies, + ); const compilerOptions = config.compilerOptions || { baseUrl: undefined }; // baseUrl should be interpreted as relative to extendedConfigPath, diff --git a/test/fixtures/tsconfig-paths/extends-npm/node_modules/react/package.json b/test/fixtures/tsconfig-paths/extends-npm/node_modules/react/package.json new file mode 100644 index 00000000..2c573a9d --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-npm/node_modules/react/package.json @@ -0,0 +1,8 @@ +{ + "name": "react", + "version": "18.3.1", + "main": "index.js", + "scripts": { + "build": "tsc" + } +} \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/extends-npm/node_modules/react/tsconfig.json b/test/fixtures/tsconfig-paths/extends-npm/node_modules/react/tsconfig.json new file mode 100644 index 00000000..7b4690a2 --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-npm/node_modules/react/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "outDir": "./js_out", + "baseUrl": ".", + "paths": { + "@components/*": ["./src/utils/*", "./src/components/*"], + "@utils/*": ["./src/utils/*"], + "foo": ["./src/mapped/foo"], + "bar/*": ["./src/mapped/bar/*"], + "refs/*": ["./src/refs/*"], + "*/old-file": ["./src/components/new-file"], + "longest/*": ["./src/mapped/longest/four.ts", "./src/mapped/longest/two.ts"], + "longest/bar": ["./src/mapped/longest/three.ts"], + "*": ["./src/mapped/star/*"] + }, + "composite": true + } +} diff --git a/test/fixtures/tsconfig-paths/extends-npm/src/components/button.ts b/test/fixtures/tsconfig-paths/extends-npm/src/components/button.ts new file mode 100644 index 00000000..3e4d185e --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-npm/src/components/button.ts @@ -0,0 +1,3 @@ +export function button() { + return "button"; +} \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/extends-npm/src/index.ts b/test/fixtures/tsconfig-paths/extends-npm/src/index.ts new file mode 100644 index 00000000..99393a21 --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-npm/src/index.ts @@ -0,0 +1,8 @@ +import * as button from "@components/button"; +import * as date from "@utils/date"; + +console.log( + "HELLO WORLD!", + button, + date, +); diff --git a/test/fixtures/tsconfig-paths/extends-npm/src/utils/date.ts b/test/fixtures/tsconfig-paths/extends-npm/src/utils/date.ts new file mode 100644 index 00000000..0bbfedaf --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-npm/src/utils/date.ts @@ -0,0 +1,3 @@ +export function date() { + return "date"; +} \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/extends-npm/tsconfig.json b/test/fixtures/tsconfig-paths/extends-npm/tsconfig.json new file mode 100644 index 00000000..5cb961c2 --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-npm/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": ["react/tsconfig"], + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/test/tsconfig-paths.test.js b/test/tsconfig-paths.test.js index 2d73eb18..b7bdcca1 100644 --- a/test/tsconfig-paths.test.js +++ b/test/tsconfig-paths.test.js @@ -26,6 +26,12 @@ const extendsExampleDir = path.resolve( "tsconfig-paths", "extends-base", ); +const extendsNpmDir = path.resolve( + __dirname, + "fixtures", + "tsconfig-paths", + "extends-npm", +); describe("TsconfigPathsPlugin", () => { it("resolves exact mapped path '@components/*' via tsconfig option (example)", (done) => { @@ -389,6 +395,31 @@ describe("TsconfigPathsPlugin", () => { ); }); + it("should resolve paths when extending from npm package (node_modules)", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(extendsNpmDir, "tsconfig.json"), + }); + + // Should resolve @components/* from the extended npm package config + resolver.resolve( + {}, + extendsNpmDir, + "@components/button", + {}, + (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + // Should resolve to utils or components based on the paths in react/tsconfig.json + expect(result).toMatch(/src[\\/](utils|components)[\\/]button\.ts$/); + done(); + }, + ); + }); + it("should handle malformed tsconfig.json gracefully", (done) => { const malformedExampleDir = path.resolve( __dirname, From b880a924160c982e4f19421c30ade237deb26613 Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Sun, 5 Oct 2025 16:35:37 +0800 Subject: [PATCH 12/12] update --- lib/TsconfigPathsPlugin.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/TsconfigPathsPlugin.js b/lib/TsconfigPathsPlugin.js index 224381c2..b47eefb4 100644 --- a/lib/TsconfigPathsPlugin.js +++ b/lib/TsconfigPathsPlugin.js @@ -228,11 +228,7 @@ module.exports = class TsconfigPathsPlugin { resolve(!err); }); }); - if ( - !exists && - extendedConfigValue.includes("/") && - extendedConfigValue.includes(".") - ) { + if (!exists && extendedConfigValue.includes("/")) { extendedConfigPath = join( currentDir, normalize(`node_modules/${extendedConfigValue}`),