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..6c56f3e6 100644 --- a/README.md +++ b/README.md @@ -85,33 +85,36 @@ myResolver.resolve( #### Resolver Options -| Field | Default | Description | -| ---------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| alias | [] | A list of module alias configurations or an object which maps key to value | -| aliasFields | [] | A list of alias fields in description files | -| extensionAlias | {} | An object which maps extension to extension aliases | -| cachePredicate | function() { return true }; | A function which decides whether a request should be cached or not. An object is passed to the function with `path` and `request` properties. | -| cacheWithContext | true | If unsafe cache is enabled, includes `request.context` in the cache key | -| conditionNames | [] | A list of exports field condition names | -| descriptionFiles | ["package.json"] | A list of description files to read from | -| enforceExtension | false | Enforce that a extension from extensions must be used | -| exportsFields | ["exports"] | A list of exports fields in description files | -| extensions | [".js", ".json", ".node"] | A list of extensions which should be tried for files | -| fallback | [] | Same as `alias`, but only used if default resolving fails | -| fileSystem | | The file system which should be used | -| fullySpecified | false | Request passed to resolve is already fully specified and extensions or main files are not resolved for it (they are still resolved for internal requests) | -| mainFields | ["main"] | A list of main fields in description files | -| mainFiles | ["index"] | A list of main files in directories | -| modules | ["node_modules"] | A list of directories to resolve modules from, can be absolute path or folder name | -| plugins | [] | A list of additional resolve plugins which should be applied | -| resolver | undefined | A prepared Resolver to which the plugins are attached | -| resolveToContext | false | Resolve to a context instead of a file | -| preferRelative | false | Prefer to resolve module requests as relative request and fallback to resolving as module | -| preferAbsolute | false | Prefer to resolve server-relative urls as absolute paths before falling back to resolve in roots | -| restrictions | [] | A list of resolve restrictions | -| roots | [] | A list of root paths | -| symlinks | true | Whether to resolve symlinks to their symlinked location | -| unsafeCache | false | Use this cache object to unsafely cache the successful requests | +| Field | Default | Description | +| ------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| alias | [] | A list of module alias configurations or an object which maps key to value | +| aliasFields | [] | A list of alias fields in description files | +| extensionAlias | {} | An object which maps extension to extension aliases | +| cachePredicate | function() { return true }; | A function which decides whether a request should be cached or not. An object is passed to the function with `path` and `request` properties. | +| cacheWithContext | true | If unsafe cache is enabled, includes `request.context` in the cache key | +| conditionNames | [] | A list of exports field condition names | +| descriptionFiles | ["package.json"] | A list of description files to read from | +| enforceExtension | false | Enforce that a extension from extensions must be used | +| exportsFields | ["exports"] | A list of exports fields in description files | +| extensions | [".js", ".json", ".node"] | A list of extensions which should be tried for files | +| fallback | [] | Same as `alias`, but only used if default resolving fails | +| fileSystem | | The file system which should be used | +| fullySpecified | false | Request passed to resolve is already fully specified and extensions or main files are not resolved for it (they are still resolved for internal requests) | +| mainFields | ["main"] | A list of main fields in description files | +| mainFiles | ["index"] | A list of main files in directories | +| modules | ["node_modules"] | A list of directories to resolve modules from, can be absolute path or folder name | +| plugins | [] | A list of additional resolve plugins which should be applied | +| resolver | undefined | A prepared Resolver to which the plugins are attached | +| resolveToContext | false | Resolve to a context instead of a file | +| preferRelative | false | Prefer to resolve module requests as relative request and fallback to resolving as module | +| preferAbsolute | false | Prefer to resolve server-relative urls as absolute paths before falling back to resolve in roots | +| restrictions | [] | A list of resolve restrictions | +| roots | [] | A list of root paths | +| symlinks | true | Whether to resolve symlinks to their symlinked location | +| tsconfig | false | TypeScript config for paths mapping. Can be `false` (disabled), a string path to `tsconfig.json`, or an object with `configFile` and `references` options. | +| tsconfig.configFile | tsconfig.json | Path to the tsconfig.json file | +| tsconfig.references | [] | Project references. `'auto'` to load from tsconfig, or an array of paths to referenced projects | +| 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..fd0beb8d 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,20 @@ const { // eslint-disable-next-line jsdoc/require-property /** @typedef {object} Context */ +/** + * @typedef {object} TsconfigPathsMap + * @property {TsconfigPathsData} main main tsconfig paths data + * @property {string} mainContext main tsconfig base URL (absolute path) + * @property {{[baseUrl: string]: TsconfigPathsData}} refs referenced tsconfig paths data mapped by baseUrl + * @property {Set} fileDependencies file dependencies + */ + +/** + * @typedef {object} TsconfigPathsData + * @property {Array} alias tsconfig file data + * @property {Array} modules tsconfig file data + */ + /** * @typedef {object} BaseResolveRequest * @property {string | false} path path @@ -299,6 +313,7 @@ const { * @property {string=} descriptionFilePath description file path * @property {string=} descriptionFileRoot description file root * @property {JsonObject=} descriptionFileData description file data + * @property {TsconfigPathsMap|null|undefined=} tsconfigPathsMap tsconfig paths map * @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..da85f351 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"); @@ -54,6 +55,12 @@ const { PathType, getType } = require("./util/path"); /** @typedef {false | 0 | "" | null | undefined} Falsy */ /** @typedef {{apply: (resolver: Resolver) => void} | ((this: Resolver, resolver: Resolver) => void) | Falsy} Plugin */ +/** + * @typedef {object} TsconfigOptions + * @property {string=} configFile A relative path to the tsconfig file based on cwd, or an absolute path of tsconfig file + * @property {string[] | 'auto'=} references References to other tsconfig files. 'auto' inherits from TypeScript config, or an array of relative/absolute paths + */ + /** * @typedef {object} UserResolveOptions * @property {(AliasOptions | AliasOptionEntry[])=} alias A list of module alias configurations or an object which maps key to value @@ -84,6 +91,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|TsconfigOptions=} tsconfig TypeScript config file path or config object with configFile and references */ /** @@ -115,6 +123,7 @@ const { PathType, getType } = require("./util/path"); * @property {Set} restrictions restrictions * @property {boolean} preferRelative prefer relative * @property {boolean} preferAbsolute prefer absolute + * @property {string|boolean|TsconfigOptions} tsconfig tsconfig file path or config object */ /** @@ -294,6 +303,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 +343,7 @@ module.exports.createResolver = function createResolver(options) { resolver: customResolver, restrictions, roots, + tsconfig, } = normalizedOptions; const plugins = [...userPlugins]; @@ -415,11 +427,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..5503b5ae --- /dev/null +++ b/lib/TsconfigPathsPlugin.js @@ -0,0 +1,560 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Natsu @xiaoxiaojx +*/ + +"use strict"; + +const { aliasResolveHandler } = require("./AliasUtils"); +const { modulesResolveHandler } = require("./ModulesUtils"); +const { readJson } = require("./util/fs"); +const { + PathType: _PathType, + cachedDirname: dirname, + cachedJoin: join, + isSubPath, + 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 {import("./Resolver").TsconfigPathsMap} TsconfigPathsMap */ +/** @typedef {import("./ResolverFactory").TsconfigOptions} TsconfigOptions */ + +/** + * @typedef {object} TsconfigCompilerOptions + * @property {string=} baseUrl Base URL for resolving paths + * @property {{[key: string]: string[]}=} paths TypeScript paths mapping + */ + +/** + * @typedef {object} TsconfigReference + * @property {string} path Path to the referenced project + */ + +/** + * @typedef {object} Tsconfig + * @property {TsconfigCompilerOptions=} compilerOptions Compiler options + * @property {string|string[]=} extends Extended configuration paths + * @property {TsconfigReference[]=} references Project references + */ + +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), + }, + }; +} + +/** + * Substitute ${configDir} template variable in path + * @param {string} pathValue the path value + * @param {string} configDir the config directory + * @returns {string} the path with substituted template + */ +function substituteConfigDir(pathValue, configDir) { + return pathValue.replace(/\$\{configDir\}/g, configDir); +} + +/** + * Convert tsconfig paths to resolver options + * @param {string} configDir Config file directory + * @param {{[key: string]: string[]}} paths TypeScript paths mapping + * @param {string=} baseUrl Base URL for resolving paths (relative to configDir) + * @returns {TsconfigPathsData} the resolver options + */ +function tsconfigPathsToResolveOptions(configDir, paths, baseUrl) { + // Calculate absolute base URL + const absoluteBaseUrl = !baseUrl ? configDir : join(configDir, 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]; + // Substitute ${configDir} in path mappings + const absolutePaths = mappings.map((mapping) => { + const substituted = substituteConfigDir(mapping, configDir); + return join(absoluteBaseUrl, substituted); + }); + + if (absolutePaths.length > 0) { + if (pattern === "*") { + modules.push( + ...absolutePaths + .map((dir) => { + if (/[/\\]\*$/.test(dir)) { + return dir.replace(/[/\\]\*$/, ""); + } + return ""; + }) + .filter(Boolean), + ); + } else { + alias.push({ name: pattern, alias: absolutePaths }); + } + } + } + + if (absoluteBaseUrl && !modules.includes(absoluteBaseUrl)) { + modules.push(absoluteBaseUrl); + } + + return { + alias, + modules, + }; +} + +/** + * Get the base context for the current project + * @param {string} context the context + * @param {string=} baseUrl base URL for resolving paths + * @returns {string} the base context + */ +function getAbsoluteBaseUrl(context, baseUrl) { + return !baseUrl ? context : join(context, baseUrl); +} + +module.exports = class TsconfigPathsPlugin { + /** + * @param {true | string | TsconfigOptions} configFileOrOptions tsconfig file path or options object + */ + constructor(configFileOrOptions) { + if ( + typeof configFileOrOptions === "object" && + configFileOrOptions !== null + ) { + // Options object format + this.configFile = configFileOrOptions.configFile || DEFAULT_CONFIG_FILE; + /** @type {string[] | "auto"} */ + if (Array.isArray(configFileOrOptions.references)) { + /** @type {TsconfigReference[]|"auto"} */ + this.references = configFileOrOptions.references.map((ref) => ({ + path: ref, + })); + } else if (configFileOrOptions.references === "auto") { + this.references = "auto"; + } else { + this.references = []; + } + } else { + this.configFile = + configFileOrOptions === true + ? DEFAULT_CONFIG_FILE + : /** @type {string} */ (configFileOrOptions); + /** @type {TsconfigReference[]|"auto"} */ + this.references = []; + } + } + + /** + * @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 tsconfigPathsMap = await this._getTsconfigPathsMap( + resolver, + request, + resolveContext, + ); + + if (!tsconfigPathsMap) return callback(); + + const selectedData = this._selectPathsDataForContext( + request.path, + tsconfigPathsMap, + ); + + if (!selectedData) return callback(); + + aliasResolveHandler( + resolver, + selectedData.alias, + aliasTarget, + request, + resolveContext, + callback, + ); + } catch (err) { + callback(/** @type {Error} */ (err)); + } + }, + ); + + resolver + .getHook("raw-module") + .tapAsync( + "TsconfigPathsPlugin", + async (request, resolveContext, callback) => { + try { + const tsconfigPathsMap = await this._getTsconfigPathsMap( + resolver, + request, + resolveContext, + ); + + if (!tsconfigPathsMap) return callback(); + + const selectedData = this._selectPathsDataForContext( + request.path, + tsconfigPathsMap, + ); + + if (!selectedData) return callback(); + + modulesResolveHandler( + resolver, + selectedData.modules, + moduleTarget, + request, + resolveContext, + callback, + ); + } catch (err) { + callback(/** @type {Error} */ (err)); + } + }, + ); + } + + /** + * Get TsconfigPathsMap for the request (with caching) + * @param {Resolver} resolver the resolver + * @param {ResolveRequest} request the request + * @param {ResolveContext} resolveContext the resolve context + * @returns {Promise} the tsconfig paths map or null + */ + async _getTsconfigPathsMap(resolver, request, resolveContext) { + if (typeof request.tsconfigPathsMap === "undefined") { + try { + const absTsconfigPath = join( + request.path || process.cwd(), + this.configFile, + ); + const result = await this._loadTsconfigPathsMap( + resolver.fileSystem, + absTsconfigPath, + ); + + request.tsconfigPathsMap = result; + } catch (err) { + request.tsconfigPathsMap = null; + throw err; + } + } + + if (!request.tsconfigPathsMap) { + return null; + } + + for (const fileDependency of request.tsconfigPathsMap.fileDependencies) { + if (resolveContext.fileDependencies) { + resolveContext.fileDependencies.add(fileDependency); + } + } + return request.tsconfigPathsMap; + } + + /** + * Load tsconfig.json and build complete TsconfigPathsMap + * Includes main project paths and all referenced projects + * @param {FileSystem} fileSystem the file system + * @param {string} absTsconfigPath absolute path to tsconfig.json + * @returns {Promise} the complete tsconfig paths map + */ + async _loadTsconfigPathsMap(fileSystem, absTsconfigPath) { + /** @type {Set} */ + const fileDependencies = new Set(); + const config = await this._loadTsconfig( + fileSystem, + absTsconfigPath, + fileDependencies, + ); + + const compilerOptions = config.compilerOptions || {}; + const mainContext = dirname(absTsconfigPath); + + const main = tsconfigPathsToResolveOptions( + mainContext, + compilerOptions.paths || {}, + compilerOptions.baseUrl, + ); + /** @type {{[baseUrl: string]: TsconfigPathsData}} */ + const refs = {}; + + let referencesToUse = null; + if (this.references === "auto") { + referencesToUse = config.references; + } else if (Array.isArray(this.references)) { + referencesToUse = this.references; + } + + if (Array.isArray(referencesToUse)) { + await this._loadTsconfigReferences( + fileSystem, + mainContext, + referencesToUse, + fileDependencies, + refs, + ); + } + + return { main, mainContext, refs, fileDependencies }; + } + + /** + * Select the correct TsconfigPathsData based on request.path (context-aware) + * Matches the behavior of tsconfig-paths-webpack-plugin + * @param {string | false} requestPath the request path + * @param {TsconfigPathsMap} tsconfigPathsMap the tsconfig paths map + * @returns {TsconfigPathsData | null} the selected paths data + */ + _selectPathsDataForContext(requestPath, tsconfigPathsMap) { + const { main, mainContext, refs } = tsconfigPathsMap; + if (!requestPath) { + return main; + } + + // Combine main and refs into a single map: context path -> TsconfigPathsData + const allContexts = { + [mainContext]: main, + ...refs, + }; + + let longestMatch = null; + let longestMatchLength = 0; + + for (const [context, data] of Object.entries(allContexts)) { + if (context === requestPath) { + return data; + } + if ( + isSubPath(context, requestPath) && + context.length > longestMatchLength + ) { + longestMatch = data; + longestMatchLength = context.length; + } + } + + if (longestMatch) { + return longestMatch; + } + + return null; + } + + /** + * 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, + ) { + const currentDir = dirname(configFilePath); + + // Substitute ${configDir} in extends path + extendedConfigValue = substituteConfigDir(extendedConfigValue, currentDir); + + if ( + typeof extendedConfigValue === "string" && + !extendedConfigValue.includes(".json") + ) { + extendedConfigValue += ".json"; + } + + let extendedConfigPath = join(currentDir, extendedConfigValue); + + 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 }; + + if (compilerOptions.baseUrl) { + const extendsDir = dirname(extendedConfigValue); + compilerOptions.baseUrl = getAbsoluteBaseUrl( + extendsDir, + compilerOptions.baseUrl, + ); + } + + delete config.references; + + return /** @type {Tsconfig} */ (config); + } + + /** + * Load referenced tsconfig projects and store in referenceMatchMap + * Simple implementation matching tsconfig-paths-webpack-plugin: + * Just load each reference and store independently + * @param {FileSystem} fileSystem the file system + * @param {string} context the context + * @param {TsconfigReference[]} references array of references + * @param {Set} fileDependencies the file dependencies + * @param {{[baseUrl: string]: TsconfigPathsData}} referenceMatchMap the map to populate + * @returns {Promise} + */ + async _loadTsconfigReferences( + fileSystem, + context, + references, + fileDependencies, + referenceMatchMap, + ) { + for (const ref of references) { + // Substitute ${configDir} in reference path + const refPath = substituteConfigDir(ref.path, context); + const refConfigPath = join(join(context, refPath), DEFAULT_CONFIG_FILE); + + try { + const refConfig = await this._loadTsconfig( + fileSystem, + refConfigPath, + fileDependencies, + ); + + if (refConfig.compilerOptions && refConfig.compilerOptions.paths) { + const refContext = dirname(refConfigPath); + + referenceMatchMap[refContext] = tsconfigPathsToResolveOptions( + refContext, + refConfig.compilerOptions.paths || {}, + refConfig.compilerOptions.baseUrl, + ); + } + + if (this.references === "auto" && Array.isArray(refConfig.references)) { + await this._loadTsconfigReferences( + fileSystem, + dirname(refConfigPath), + refConfig.references, + fileDependencies, + referenceMatchMap, + ); + } + } catch (_err) { + continue; + } + } + } + + /** + * Load tsconfig.json with extends support + * @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 config = await readJson(fileSystem, configFilePath); + fileDependencies.add(configFilePath); + + let result = config; + + const extendedConfig = config.extends; + if (extendedConfig) { + let base; + + if (Array.isArray(extendedConfig)) { + base = {}; + for (const extendedConfigElement of extendedConfig) { + const extendedTsconfig = await this._loadTsconfigFromExtends( + fileSystem, + configFilePath, + extendedConfigElement, + fileDependencies, + ); + base = mergeTsconfigs(base, extendedTsconfig); + } + } else { + base = await this._loadTsconfigFromExtends( + fileSystem, + configFilePath, + extendedConfig, + fileDependencies, + ); + } + + result = /** @type {Tsconfig} */ (mergeTsconfigs(base, config)); + } + + return result; + } +}; diff --git a/lib/index.js b/lib/index.js index 9b101430..d708a406 100644 --- a/lib/index.js +++ b/lib/index.js @@ -219,6 +219,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/fs.js b/lib/util/fs.js new file mode 100644 index 00000000..41a3c854 --- /dev/null +++ b/lib/util/fs.js @@ -0,0 +1,38 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Natsu @xiaoxiaojx +*/ + +"use strict"; + +/** @typedef {import("../Resolver").FileSystem} FileSystem */ + +/** + * Read and parse JSON file + * @template T + * @param {FileSystem} fileSystem the file system + * @param {string} jsonFilePath absolute path to JSON file + * @returns {Promise} parsed JSON content + */ +async function readJson(fileSystem, jsonFilePath) { + const { readJson } = fileSystem; + if (readJson) { + return new Promise((resolve, reject) => { + readJson(jsonFilePath, (err, content) => { + if (err) return reject(err); + resolve(/** @type {T} */ (content)); + }); + }); + } + + const buf = await new Promise((resolve, reject) => { + fileSystem.readFile(jsonFilePath, (err, data) => { + if (err) return reject(err); + resolve(data); + }); + }); + + return JSON.parse(/** @type {string} */ (buf.toString())); +} + +module.exports.readJson = readJson; diff --git a/lib/util/path.js b/lib/util/path.js index af340469..b07d736f 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,10 +206,45 @@ 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; +}; + +/** + * Check if childPath is a subdirectory of parentPath + * @param {string} parentPath parent directory path + * @param {string} childPath child path to check + * @returns {boolean} true if childPath is under parentPath + */ +const isSubPath = (parentPath, childPath) => { + // Ensure parentPath ends with a separator to avoid false matches + // e.g., "/app" shouldn't match "/app-other" + const parentWithSlash = + parentPath.endsWith("/") || parentPath.endsWith("\\") + ? parentPath + : normalize(`${parentPath}/`); + + return childPath.startsWith(parentWithSlash); +}; + 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.isSubPath = isSubPath; module.exports.join = join; module.exports.normalize = normalize; 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..36539757 --- /dev/null +++ b/test/fixtures/tsconfig-paths/base/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "outDir": "./js_out", + "baseUrl": ".", + "paths": { + "@components/*": ["${configDir}/src/utils/*", "${configDir}/src/components/*"], + "@utils/*": ["./src/utils/*"], + "foo": ["${configDir}/src/mapped/foo"], + "foo/*": ["${configDir}/src/mapped/bar/*"], + "bar/*": ["./src/mapped/bar/*"], + "refs/*": ["${configDir}/src/refs/*"], + "*/old-file": ["${configDir}/src/components/new-file"], + "longest/*": ["${configDir}/src/mapped/longest/four.ts", "${configDir}/src/mapped/longest/two.ts"], + "longest/bar": ["${configDir}/src/mapped/longest/three.ts"], + "*": ["${configDir}/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..52202a6c --- /dev/null +++ b/test/fixtures/tsconfig-paths/extends-base/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "${configDir}/../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/references-project/packages/app/src/components/Button.ts b/test/fixtures/tsconfig-paths/references-project/packages/app/src/components/Button.ts new file mode 100644 index 00000000..9ef47e71 --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/packages/app/src/components/Button.ts @@ -0,0 +1,3 @@ +// Button component +export const Button = "Button"; + diff --git a/test/fixtures/tsconfig-paths/references-project/packages/app/src/index.ts b/test/fixtures/tsconfig-paths/references-project/packages/app/src/index.ts new file mode 100644 index 00000000..955de72f --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/packages/app/src/index.ts @@ -0,0 +1,4 @@ +export function appMain() { + return "app main"; +} + diff --git a/test/fixtures/tsconfig-paths/references-project/packages/app/tsconfig.json b/test/fixtures/tsconfig-paths/references-project/packages/app/tsconfig.json new file mode 100644 index 00000000..3b7a8dd8 --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/packages/app/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@app/*": ["${configDir}/src/*"] + }, + "composite": true, + "outDir": "./dist" + }, + "references": [{ "path": "${configDir}/../shared" }] +} + diff --git a/test/fixtures/tsconfig-paths/references-project/packages/shared/index.ts b/test/fixtures/tsconfig-paths/references-project/packages/shared/index.ts new file mode 100644 index 00000000..eba9a670 --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/packages/shared/index.ts @@ -0,0 +1,2 @@ +import { helper } from "utils/helper"; +export { helper }; \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/references-project/packages/shared/src/components/Input.ts b/test/fixtures/tsconfig-paths/references-project/packages/shared/src/components/Input.ts new file mode 100644 index 00000000..4efbe485 --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/packages/shared/src/components/Input.ts @@ -0,0 +1,3 @@ +// Input component +export const Input = "Input"; + diff --git a/test/fixtures/tsconfig-paths/references-project/packages/shared/src/index.ts b/test/fixtures/tsconfig-paths/references-project/packages/shared/src/index.ts new file mode 100644 index 00000000..f42f1d91 --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +import { helper } from "@shared/helper"; +export { helper }; \ No newline at end of file diff --git a/test/fixtures/tsconfig-paths/references-project/packages/shared/src/utils/helper.ts b/test/fixtures/tsconfig-paths/references-project/packages/shared/src/utils/helper.ts new file mode 100644 index 00000000..8fd0ce34 --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/packages/shared/src/utils/helper.ts @@ -0,0 +1,4 @@ +export function helper() { + return "helper from shared package"; +} + diff --git a/test/fixtures/tsconfig-paths/references-project/packages/shared/tsconfig.json b/test/fixtures/tsconfig-paths/references-project/packages/shared/tsconfig.json new file mode 100644 index 00000000..1ca7ae54 --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/packages/shared/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": "./src", + "paths": { + "@shared/*": ["${configDir}/src/utils/*"] + }, + "composite": true, + "outDir": "./dist" + }, + "references": [{ "path": "${configDir}/../utils" }] +} + diff --git a/test/fixtures/tsconfig-paths/references-project/packages/utils/src/core/date.ts b/test/fixtures/tsconfig-paths/references-project/packages/utils/src/core/date.ts new file mode 100644 index 00000000..a6ebd8c9 --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/packages/utils/src/core/date.ts @@ -0,0 +1,4 @@ +export function formatDate(date: Date): string { + return date.toISOString(); +} + diff --git a/test/fixtures/tsconfig-paths/references-project/packages/utils/tsconfig.json b/test/fixtures/tsconfig-paths/references-project/packages/utils/tsconfig.json new file mode 100644 index 00000000..df326733 --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/packages/utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": "./src", + "paths": { + "@utils/*": ["${configDir}/src/core/*"] + }, + "composite": true, + "outDir": "./dist" + } +} + diff --git a/test/fixtures/tsconfig-paths/references-project/src/main.ts b/test/fixtures/tsconfig-paths/references-project/src/main.ts new file mode 100644 index 00000000..e379e96f --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/src/main.ts @@ -0,0 +1,4 @@ +export function rootMain() { + return "root main"; +} + diff --git a/test/fixtures/tsconfig-paths/references-project/tsconfig.json b/test/fixtures/tsconfig-paths/references-project/tsconfig.json new file mode 100644 index 00000000..5256c465 --- /dev/null +++ b/test/fixtures/tsconfig-paths/references-project/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@root/*": ["${configDir}/src/*"] + } + }, + "references": [ + { "path": "${configDir}/packages/shared" }, + { "path": "./packages/app" } + ] +} + diff --git a/test/tsconfig-paths.test.js b/test/tsconfig-paths.test.js new file mode 100644 index 00000000..1b4afc8f --- /dev/null +++ b/test/tsconfig-paths.test.js @@ -0,0 +1,925 @@ +"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 extendsExampleDir = path.resolve( + __dirname, + "fixtures", + "tsconfig-paths", + "extends-base", +); +const extendsNpmDir = path.resolve( + __dirname, + "fixtures", + "tsconfig-paths", + "extends-npm", +); +const referencesProjectDir = path.resolve( + __dirname, + "fixtures", + "tsconfig-paths", + "references-project", +); + +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"), + ); + 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(); + }, + ); + }); + + describe("Path wildcard patterns", () => { + it("resolves 'foo/*' wildcard pattern", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(baseExampleDir, "tsconfig.json"), + }); + + resolver.resolve({}, baseExampleDir, "foo/file1", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result for foo")); + expect(result).toEqual( + path.join(baseExampleDir, "src", "mapped", "bar", "file1.ts"), + ); + done(); + }); + }); + + it("resolves '*' catch-all pattern to src/mapped/star/*", (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, + "star-bar/index", + {}, + (err, resultStar) => { + if (err) return done(err); + if (!resultStar) return done(new Error("No result for star/*")); + expect(resultStar).toEqual( + path.join( + baseExampleDir, + "src", + "mapped", + "star", + "star-bar", + "index.ts", + ), + ); + done(); + }, + ); + }); + + it("resolves package with mainFields", (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, + "main-field-package", + {}, + (err, result) => { + if (err) return done(err); + if (!result) { + return done(new Error("No result for main-field-package")); + } + expect(result).toEqual( + path.join( + baseExampleDir, + "src", + "mapped", + "star", + "main-field-package", + "node.ts", + ), + ); + done(); + }, + ); + }); + + it("resolves package with browser field", (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, + "browser-field-package", + {}, + (err, result) => { + if (err) return done(err); + if (!result) { + return done(new Error("No result for browser-field-package")); + } + expect(result).toEqual( + path.join( + baseExampleDir, + "src", + "mapped", + "star", + "browser-field-package", + "browser.ts", + ), + ); + done(); + }, + ); + }); + + it("resolves package with default index.ts", (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, + "no-main-field-package", + {}, + (err, result) => { + if (err) return done(err); + if (!result) { + return done(new Error("No result for no-main-field-package")); + } + expect(result).toEqual( + path.join( + baseExampleDir, + "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(); + }, + ); + }); + + // eslint-disable-next-line no-template-curly-in-string + describe("${configDir} template variable support", () => { + // eslint-disable-next-line no-template-curly-in-string + it("should substitute ${configDir} in path mappings", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(baseExampleDir, "tsconfig.json"), + }); + + // The base tsconfig.json now uses ${configDir}/src/components/* + 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(); + }, + ); + }); + + // eslint-disable-next-line no-template-curly-in-string + it("should substitute ${configDir} in multiple path patterns", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(baseExampleDir, "tsconfig.json"), + }); + + // Test @utils/* pattern + resolver.resolve({}, baseExampleDir, "@utils/date", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(baseExampleDir, "src", "utils", "date.ts"), + ); + + // Test exact pattern 'foo' + resolver.resolve({}, baseExampleDir, "foo", {}, (err2, result2) => { + if (err2) return done(err2); + if (!result2) return done(new Error("No result for foo")); + expect(result2).toEqual( + path.join(baseExampleDir, "src", "mapped", "foo", "index.ts"), + ); + done(); + }); + }); + }); + + // eslint-disable-next-line no-template-curly-in-string + it("should substitute ${configDir} in referenced projects", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + references: "auto", + }, + }); + + // app's tsconfig uses ${configDir}/src/* + resolver.resolve({}, appDir, "@app/index", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual(path.join(appDir, "src", "index.ts")); + done(); + }); + }); + + // eslint-disable-next-line no-template-curly-in-string + it("should substitute ${configDir} in extends field", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: path.join(extendsExampleDir, "tsconfig.json"), + }); + + // extendsExampleDir uses ${configDir}/../base/tsconfig in extends + 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(); + }, + ); + }); + + // eslint-disable-next-line no-template-curly-in-string + it("should substitute ${configDir} in references field", (done) => { + const sharedDir = path.join(referencesProjectDir, "packages", "shared"); + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(referencesProjectDir, "tsconfig.json"), + references: "auto", + }, + }); + + // root tsconfig uses ${configDir}/packages/shared in references + resolver.resolve({}, sharedDir, "@shared/helper", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(sharedDir, "src", "utils", "helper.ts"), + ); + done(); + }); + }); + }); + + describe("TypeScript Project References", () => { + it("should support tsconfig object format with configFile", (done) => { + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(baseExampleDir, "tsconfig.json"), + references: "auto", + }, + }); + + 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("should resolve own paths (without cross-project references)", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + references: "auto", + }, + }); + + // app's own @app/* paths should work + resolver.resolve({}, appDir, "@app/index", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual(path.join(appDir, "src", "index.ts")); + + // @shared/* from app context should fail (not in app's paths) + resolver.resolve({}, appDir, "@shared/utils/helper", {}, (err2) => { + expect(err2).toBeInstanceOf(Error); + done(); + }); + }); + }); + + it("should resolve self-references within a referenced project", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const sharedDir = path.join(referencesProjectDir, "packages", "shared"); + + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + references: "auto", + }, + }); + + // When resolving from sharedDir, @shared/* should work (self-reference) + resolver.resolve({}, sharedDir, "@shared/helper", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(sharedDir, "src", "utils", "helper.ts"), + ); + done(); + }); + }); + + it("should support explicit references array", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const sharedSrcDir = path.join( + referencesProjectDir, + "packages", + "shared", + "src", + ); + + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + references: ["../shared"], + }, + }); + + // Self-reference should still work with explicit references + resolver.resolve( + {}, + sharedSrcDir, + "@shared/helper", + {}, + (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual(path.join(sharedSrcDir, "utils", "helper.ts")); + done(); + }, + ); + }); + + it("should not load references when references option is omitted", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + // references is not specified - should not load any references + }, + }); + + // @shared/* should fail because references are not loaded + resolver.resolve({}, appDir, "@shared/utils/helper", {}, (err) => { + expect(err).toBeInstanceOf(Error); + done(); + }); + }); + + it("should handle nested references (when a referenced project has its own references)", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const utilsSrcDir = path.join( + referencesProjectDir, + "packages", + "utils", + "src", + ); + + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + references: "auto", + }, + }); + + // utils has @utils/* paths, and shared references utils + // When resolving from utils context, @utils/* should work + resolver.resolve({}, utilsSrcDir, "@utils/date", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual(path.join(utilsSrcDir, "core", "date.ts")); + done(); + }); + }); + + describe("modules resolution with references", () => { + it("should resolve modules from main project's baseUrl", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + references: "auto", + }, + }); + + // Should resolve relative to app's baseUrl (app root) + resolver.resolve( + {}, + appDir, + "src/components/Button", + {}, + (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(appDir, "src", "components", "Button.ts"), + ); + done(); + }, + ); + }); + + it("should resolve modules from referenced project's baseUrl (self-reference)", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const sharedSrcDir = path.join( + referencesProjectDir, + "packages", + "shared", + "src", + ); + + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + references: "auto", + }, + }); + + // When resolving from shared/src, should use shared's baseUrl (shared/src) // cspell:disable-line + resolver.resolve( + {}, + sharedSrcDir, + "utils/helper", + {}, + (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(sharedSrcDir, "utils", "helper.ts"), + ); + done(); + }, + ); + }); + + it("should resolve components from referenced project's baseUrl", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const sharedSrcDir = path.join( + referencesProjectDir, + "packages", + "shared", + "src", + ); + + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + references: "auto", + }, + }); + + // Resolve components from shared project's baseUrl + resolver.resolve( + {}, + sharedSrcDir, + "components/Input", + {}, + (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(sharedSrcDir, "components", "Input.ts"), + ); + done(); + }, + ); + }); + + it("should use correct baseUrl based on request context", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const sharedDir = path.join(referencesProjectDir, "packages", "shared"); + + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + references: "auto", + }, + }); + + // From app context, 'src/index' should resolve to app/src/index + resolver.resolve({}, appDir, "src/index", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result from app")); + expect(result).toEqual(path.join(appDir, "src", "index.ts")); + + // From shared context, 'utils/helper' should resolve to shared/src/utils/helper + resolver.resolve( + {}, + path.join(sharedDir, "src"), + "utils/helper", + {}, + (err2, result2) => { + if (err2) return done(err2); + if (!result2) return done(new Error("No result from shared")); + expect(result2).toEqual( + path.join(sharedDir, "src", "utils", "helper.ts"), + ); + done(); + }, + ); + }); + }); + + it("should support explicit references with modules resolution", (done) => { + const appDir = path.join(referencesProjectDir, "packages", "app"); + const sharedSrcDir = path.join( + referencesProjectDir, + "packages", + "shared", + "src", + ); + + const resolver = ResolverFactory.createResolver({ + fileSystem, + extensions: [".ts", ".tsx"], + mainFields: ["browser", "main"], + mainFiles: ["index"], + tsconfig: { + configFile: path.join(appDir, "tsconfig.json"), + references: ["../shared"], + }, + }); + + // Explicit references should also support modules resolution + resolver.resolve( + {}, + sharedSrcDir, + "utils/helper", + {}, + (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual( + path.join(sharedSrcDir, "utils", "helper.ts"), + ); + done(); + }, + ); + }); + }); + }); +}); diff --git a/types.d.ts b/types.d.ts index 6953b2c8..c784566f 100644 --- a/types.d.ts +++ b/types.d.ts @@ -51,6 +51,11 @@ declare interface BaseResolveRequest { */ descriptionFileData?: JsonObject; + /** + * tsconfig paths map + */ + tsconfigPathsMap?: null | TsconfigPathsMap; + /** * relative path */ @@ -1266,6 +1271,11 @@ declare interface ResolveOptionsResolverFactoryObject_1 { * prefer absolute */ preferAbsolute: boolean; + + /** + * tsconfig file path or config object + */ + tsconfig: string | boolean | TsconfigOptions; } 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; + + /** + * TypeScript config file path or config object with configFile and references + */ + tsconfig?: string | boolean | TsconfigOptions; } type ResolveRequest = BaseResolveRequest & Partial; declare abstract class Resolver { @@ -1569,6 +1584,61 @@ declare interface SyncFileSystem { */ realpathSync?: RealPathSync; } +declare interface TsconfigOptions { + /** + * A relative path to the tsconfig file based on cwd, or an absolute path of tsconfig file + */ + configFile?: string; + + /** + * References to other tsconfig files. 'auto' inherits from TypeScript config, or an array of relative/absolute paths + */ + references?: string[] | "auto"; +} +declare interface TsconfigPathsData { + /** + * tsconfig file data + */ + alias: AliasOption[]; + + /** + * tsconfig file data + */ + modules: string[]; +} +declare interface TsconfigPathsMap { + /** + * main tsconfig paths data + */ + main: TsconfigPathsData; + + /** + * main tsconfig base URL (absolute path) + */ + mainContext: string; + + /** + * referenced tsconfig paths data mapped by baseUrl + */ + refs: { [index: string]: TsconfigPathsData }; + + /** + * file dependencies + */ + fileDependencies: Set; +} +declare class TsconfigPathsPlugin { + constructor(configFileOrOptions: string | true | TsconfigOptions); + configFile: string; + references: "auto" | TsconfigReference[]; + apply(resolver: Resolver): void; +} +declare interface TsconfigReference { + /** + * Path to the referenced project + */ + path: string; +} declare interface URL_url extends URL_Import {} declare interface WriteOnlySet { add: (item: T) => void; @@ -1640,6 +1710,7 @@ declare namespace exports { CachedInputFileSystem, CloneBasenamePlugin, LogInfoPlugin, + TsconfigPathsPlugin, ResolveOptionsOptionalFS, BaseFileSystem, PnpApi,