diff --git a/.cspell.json b/.cspell.json index 38717337..76571bdc 100644 --- a/.cspell.json +++ b/.cspell.json @@ -34,7 +34,12 @@ "zipp", "zippi", "zizizi", - "codecov" + "codecov", + "xiaoxiaojx", + "Natsu", + "tsconfigs", + "preact", + "compat" ], "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..c0a1c5f1 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 @@ -299,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/ResolverFactory.js b/lib/ResolverFactory.js index 266dd695..cc1b0420 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,8 @@ function createOptions(options) { preferRelative: options.preferRelative || false, preferAbsolute: options.preferAbsolute || false, restrictions: new Set(options.restrictions), + tsconfig: + typeof options.tsconfig === "undefined" ? false : options.tsconfig, }; } @@ -332,6 +337,7 @@ module.exports.createResolver = function createResolver(options) { resolver: customResolver, restrictions, roots, + tsconfig, } = normalizedOptions; const plugins = [...userPlugins]; @@ -415,11 +421,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..b47eefb4 --- /dev/null +++ b/lib/TsconfigPathsPlugin.js @@ -0,0 +1,385 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Natsu @xiaoxiaojx +*/ + +"use strict"; + +const { aliasResolveHandler } = require("./AliasUtils"); +const { modulesResolveHandler } = require("./ModulesUtils"); +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|null} base base config + * @param {Tsconfig|null} 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 absolutePaths = mappings.map((mapping) => join(baseUrl, mapping)); + + if (absolutePaths.length > 0) { + if (pattern === "*") { + // Handle "*" pattern - extract modules from wildcard targets + modules.push( + ...absolutePaths + .map((dir) => { + if (/[/\\]\*$/.test(dir)) { + return dir.replace(/[/\\]\*$/, ""); + } + return ""; + }) + .filter(Boolean), + ); + } else { + // Handle regular patterns - add as alias + alias.push({ name: pattern, alias: absolutePaths }); + } + } + } + + return { + alias, + modules, + }; +} + +module.exports = class TsconfigPathsPlugin { + /** + * @param {true | string} configFile tsconfig file path + */ + constructor(configFile) { + this.configFile = + configFile === true + ? DEFAULT_CONFIG_FILE + : /** @type {string} */ (configFile); + } + + /** + * @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)); + } + }, + ); + } + + /** + * 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("/")) { + 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) { + 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; + } + + /** + * 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}>} the normalized compiler options + */ + async readTsconfigCompilerOptions(fileSystem, absTsconfigPath) { + /** @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 }; + } + + /** + * 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 request.tsconfigPathsData === "undefined") { + try { + const absTsconfigPath = join( + request.path || process.cwd(), + this.configFile, + ); + const result = await this.readTsconfigCompilerOptions( + resolver.fileSystem, + absTsconfigPath, + ); + 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) { + if (resolveContext.fileDependencies) { + resolveContext.fileDependencies.add(fileDependency); + } + } + return request.tsconfigPathsData; + } +}; + +module.exports._getPrefixLength = getPrefixLength; +module.exports._mergeTsconfigs = mergeTsconfigs; +module.exports._sortByLongestPrefix = sortByLongestPrefix; +module.exports._tsconfigPathsToResolveOptions = tsconfigPathsToResolveOptions; 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/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/__snapshots__/tsconfig-paths-utils.test.js.snap b/test/__snapshots__/tsconfig-paths-utils.test.js.snap new file mode 100644 index 00000000..c298c54e --- /dev/null +++ b/test/__snapshots__/tsconfig-paths-utils.test.js.snap @@ -0,0 +1,729 @@ +// 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/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/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/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/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/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..1926542a --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-base/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../base/tsconfig", + "compilerOptions": { + "baseUrl": "." + } +} 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/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/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/tsconfig-paths-utils.test.js b/test/tsconfig-paths-utils.test.js new file mode 100644 index 00000000..af32fd5d --- /dev/null +++ b/test/tsconfig-paths-utils.test.js @@ -0,0 +1,776 @@ +"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/constants/*": ["src/shared/constants/*"], + "@core/auth/*": ["src/core/auth/*"], + "@core/config/*": ["src/core/config/*"], + "@domain/*": ["src/domain/*"], + "@infrastructure/*": ["src/infrastructure/*"], + }; + 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/*"], + "@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/test/tsconfig-paths.test.js b/test/tsconfig-paths.test.js new file mode 100644 index 00000000..b7bdcca1 --- /dev/null +++ b/test/tsconfig-paths.test.js @@ -0,0 +1,452 @@ +"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", +); +const extendsNpmDir = path.resolve( + __dirname, + "fixtures", + "tsconfig-paths", + "extends-npm", +); + +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"], + tsconfig: true, + }); + + 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(); + }, + ); + }); + + 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, + "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 6953b2c8..f4c1c5ad 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 */ @@ -1266,6 +1271,11 @@ declare interface ResolveOptionsResolverFactoryObject_1 { * prefer absolute */ preferAbsolute: boolean; + + /** + * tsconfig file path + */ + tsconfig?: string | boolean; } declare interface ResolveOptionsResolverFactoryObject_2 { /** @@ -1411,6 +1421,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 +1584,88 @@ 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 + */ + alias: AliasOption[]; + + /** + * tsconfig file data + */ + modules: string[]; + + /** + * file dependencies + */ + fileDependencies: Set; +} +declare class TsconfigPathsPlugin { + constructor(configFile: string | true); + configFile: string; + 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<{ + options: TsconfigCompilerOptions; + fileDependencies: Set; + }>; + + /** + * 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 +1737,7 @@ declare namespace exports { CachedInputFileSystem, CloneBasenamePlugin, LogInfoPlugin, + TsconfigPathsPlugin, ResolveOptionsOptionalFS, BaseFileSystem, PnpApi,