| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746 |
- /*
- MIT License http://www.opensource.org/licenses/mit-license.php
- Author Natsu @xiaoxiaojx
- */
- "use strict";
- const { aliasResolveHandler, compileAliasOptions } = require("./AliasUtils");
- const { modulesResolveHandler } = require("./ModulesUtils");
- const { readJson } = require("./util/fs");
- const { PathType: _PathType, 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";
- const READ_JSON_OPTIONS = { stripComments: true };
- // Trailing `/*` or `\*` segment of a tsconfig `paths` mapping (e.g.
- // `./src/*` → `./src`). Hoisted so we don't allocate a fresh regex per
- // path entry on every tsconfig load — and so the same regex object can be
- // reused for the matching `test` + `replace` pair below.
- const WILDCARD_TAIL_RE = /[/\\]\*$/;
- /**
- * @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 prefixLength;
- }
- /**
- * 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) {
- // eslint-disable-next-line no-template-curly-in-string
- if (!pathValue.includes("${configDir}")) return pathValue;
- 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 {Resolver} resolver resolver instance
- * @param {string=} baseUrl Base URL for resolving paths (relative to configDir)
- * @returns {TsconfigPathsData} the resolver options
- */
- function tsconfigPathsToResolveOptions(configDir, paths, resolver, baseUrl) {
- // Calculate absolute base URL
- const absoluteBaseUrl = !baseUrl
- ? configDir
- : resolver.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 resolver.join(absoluteBaseUrl, substituted);
- });
- if (absolutePaths.length > 0) {
- if (pattern === "*") {
- // Pull `dir/*` entries directly into `modules` with their
- // trailing wildcard stripped, skipping anything else. The
- // previous `.map(...).filter(Boolean)` form allocated two
- // throwaway arrays plus a spread iterator per `*` mapping.
- for (let j = 0; j < absolutePaths.length; j++) {
- const dir = absolutePaths[j];
- if (WILDCARD_TAIL_RE.test(dir)) {
- modules.push(dir.replace(WILDCARD_TAIL_RE, ""));
- }
- }
- } else {
- alias.push({ name: pattern, alias: absolutePaths });
- }
- }
- }
- if (absoluteBaseUrl && !modules.includes(absoluteBaseUrl)) {
- modules.push(absoluteBaseUrl);
- }
- return {
- alias: compileAliasOptions(resolver, alias),
- modules,
- };
- }
- /**
- * Get the base context for the current project
- * @param {string} context the context
- * @param {Resolver} resolver resolver instance
- * @param {string=} baseUrl base URL for resolving paths
- * @returns {string} the base context
- */
- function getAbsoluteBaseUrl(context, resolver, baseUrl) {
- return !baseUrl ? context : resolver.join(context, baseUrl);
- }
- /**
- * @param {TsconfigPathsData} main main paths data
- * @param {string} mainContext main context
- * @param {{ [baseUrl: string]: TsconfigPathsData }} refs references map
- * @param {Set<string>} fileDependencies file dependencies
- * @returns {TsconfigPathsMap} the tsconfig paths map
- */
- function buildTsconfigPathsMap(main, mainContext, refs, fileDependencies) {
- const allContexts = /** @type {{ [context: string]: TsconfigPathsData }} */ ({
- [mainContext]: main,
- ...refs,
- });
- // Precompute the key list once per tsconfig load. `_selectPathsDataForContext`
- // runs per resolve and otherwise would call `Object.entries(allContexts)`
- // each time, allocating a fresh [key, value][] array.
- const contextList = Object.keys(allContexts);
- return {
- main,
- mainContext,
- refs,
- allContexts,
- contextList,
- fileDependencies,
- };
- }
- 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
- const { configFile } = configFileOrOptions;
- /** @type {boolean} */
- this.isAutoConfigFile = typeof configFile !== "string";
- /** @type {string} */
- this.configFile = this.isAutoConfigFile
- ? DEFAULT_CONFIG_FILE
- : /** @type {string} */ (configFile);
- /** @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 = [];
- }
- /** @type {string | undefined} */
- this.baseUrl = configFileOrOptions.baseUrl;
- } else {
- /** @type {boolean} */
- this.isAutoConfigFile = configFileOrOptions === true;
- /** @type {string} */
- this.configFile = this.isAutoConfigFile
- ? DEFAULT_CONFIG_FILE
- : /** @type {string} */ (configFileOrOptions);
- /** @type {TsconfigReference[] | "auto"} */
- this.references = [];
- /** @type {string | undefined} */
- this.baseUrl = undefined;
- }
- }
- /**
- * @param {Resolver} resolver the resolver
- * @returns {void}
- */
- apply(resolver) {
- const aliasTarget = resolver.ensureHook("internal-resolve");
- const moduleTarget = resolver.ensureHook("module");
- resolver
- .getHook("raw-resolve")
- .tapAsync("TsconfigPathsPlugin", (request, resolveContext, callback) => {
- this._getTsconfigPathsMap(
- resolver,
- request,
- resolveContext,
- (err, tsconfigPathsMap) => {
- if (err) return callback(err);
- if (!tsconfigPathsMap) return callback();
- const selectedData = this._selectPathsDataForContext(
- request.path,
- tsconfigPathsMap,
- );
- if (!selectedData) return callback();
- aliasResolveHandler(
- resolver,
- selectedData.alias,
- aliasTarget,
- request,
- resolveContext,
- callback,
- );
- },
- );
- });
- resolver
- .getHook("raw-module")
- .tapAsync("TsconfigPathsPlugin", (request, resolveContext, callback) => {
- this._getTsconfigPathsMap(
- resolver,
- request,
- resolveContext,
- (err, tsconfigPathsMap) => {
- if (err) return callback(err);
- if (!tsconfigPathsMap) return callback();
- const selectedData = this._selectPathsDataForContext(
- request.path,
- tsconfigPathsMap,
- );
- if (!selectedData) return callback();
- modulesResolveHandler(
- resolver,
- selectedData.modules,
- moduleTarget,
- request,
- resolveContext,
- callback,
- );
- },
- );
- });
- }
- /**
- * Get TsconfigPathsMap for the request (with caching)
- * @param {Resolver} resolver the resolver
- * @param {ResolveRequest} request the request
- * @param {ResolveContext} resolveContext the resolve context
- * @param {(err: Error | null, result?: TsconfigPathsMap | null) => void} callback the callback
- * @returns {void}
- */
- _getTsconfigPathsMap(resolver, request, resolveContext, callback) {
- if (typeof request.tsconfigPathsMap !== "undefined") {
- const cached = request.tsconfigPathsMap;
- if (!cached) return callback(null, null);
- if (resolveContext.fileDependencies) {
- for (const fileDependency of cached.fileDependencies) {
- resolveContext.fileDependencies.add(fileDependency);
- }
- }
- return callback(null, cached);
- }
- const absTsconfigPath = resolver.join(
- request.path || process.cwd(),
- this.configFile,
- );
- this._loadTsconfigPathsMap(resolver, absTsconfigPath, (err, result) => {
- if (err) {
- request.tsconfigPathsMap = null;
- if (
- this.isAutoConfigFile &&
- /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
- ) {
- return callback(null, null);
- }
- return callback(err);
- }
- const map = /** @type {TsconfigPathsMap} */ (result);
- request.tsconfigPathsMap = map;
- if (resolveContext.fileDependencies) {
- for (const fileDependency of map.fileDependencies) {
- resolveContext.fileDependencies.add(fileDependency);
- }
- }
- callback(null, map);
- });
- }
- /**
- * Load tsconfig.json and build complete TsconfigPathsMap
- * Includes main project paths and all referenced projects
- * @param {Resolver} resolver the resolver
- * @param {string} absTsconfigPath absolute path to tsconfig.json
- * @param {(err: Error | null, result?: TsconfigPathsMap) => void} callback the callback
- * @returns {void}
- */
- _loadTsconfigPathsMap(resolver, absTsconfigPath, callback) {
- /** @type {Set<string>} */
- const fileDependencies = new Set();
- this._loadTsconfig(
- resolver,
- absTsconfigPath,
- fileDependencies,
- undefined,
- (err, config) => {
- if (err) return callback(err);
- const cfg = /** @type {Tsconfig} */ (config);
- const compilerOptions = cfg.compilerOptions || {};
- const mainContext = resolver.dirname(absTsconfigPath);
- const baseUrl =
- this.baseUrl !== undefined ? this.baseUrl : compilerOptions.baseUrl;
- const main = tsconfigPathsToResolveOptions(
- mainContext,
- compilerOptions.paths || {},
- resolver,
- baseUrl,
- );
- /** @type {{ [baseUrl: string]: TsconfigPathsData }} */
- const refs = {};
- let referencesToUse = null;
- if (this.references === "auto") {
- referencesToUse = cfg.references;
- } else if (Array.isArray(this.references)) {
- referencesToUse = this.references;
- }
- if (!Array.isArray(referencesToUse)) {
- return callback(
- null,
- buildTsconfigPathsMap(main, mainContext, refs, fileDependencies),
- );
- }
- this._loadTsconfigReferences(
- resolver,
- mainContext,
- referencesToUse,
- fileDependencies,
- refs,
- (refErr) => {
- if (refErr) return callback(refErr);
- callback(
- null,
- buildTsconfigPathsMap(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, allContexts, contextList } = tsconfigPathsMap;
- if (!requestPath) {
- return main;
- }
- let longestMatchContext = null;
- let longestMatchLength = 0;
- // Iterate the pre-computed key list (the previous
- // `Object.entries(allContexts)` form allocated a fresh
- // `[key, value][]` per resolve). Defer the `allContexts[context]`
- // lookup to after we know the context actually matches — non-matches
- // are the common case and don't need the property access.
- for (let i = 0; i < contextList.length; i++) {
- const context = contextList[i];
- if (context === requestPath) {
- return allContexts[context];
- }
- // Cheap integer-compare gate first: a context can only beat the
- // current longest match if its own length is strictly greater.
- // Skipping `isSubPath` (a `startsWith` + char-code probe) when the
- // length already disqualifies the candidate avoids the per-resolve
- // scan over every shorter context.
- if (
- context.length > longestMatchLength &&
- isSubPath(context, requestPath)
- ) {
- longestMatchContext = context;
- longestMatchLength = context.length;
- }
- }
- return longestMatchContext === null
- ? null
- : allContexts[longestMatchContext];
- }
- /**
- * Load tsconfig from extends path
- * @param {Resolver} resolver the resolver
- * @param {string} configFilePath current config file path
- * @param {string} extendedConfigValue extends value
- * @param {Set<string>} fileDependencies the file dependencies
- * @param {Set<string>} visitedConfigPaths config paths being loaded (for circular extends detection)
- * @param {(err: Error | null, result?: Tsconfig) => void} callback callback
- * @returns {void}
- */
- _loadTsconfigFromExtends(
- resolver,
- configFilePath,
- extendedConfigValue,
- fileDependencies,
- visitedConfigPaths,
- callback,
- ) {
- const { fileSystem } = resolver;
- const currentDir = resolver.dirname(configFilePath);
- // Substitute ${configDir} in extends path
- extendedConfigValue = substituteConfigDir(extendedConfigValue, currentDir);
- // Remember the original value before potentially appending .json
- const originalExtendedConfigValue = extendedConfigValue;
- if (
- typeof extendedConfigValue === "string" &&
- !extendedConfigValue.includes(".json")
- ) {
- extendedConfigValue += ".json";
- }
- const initialExtendedConfigPath = resolver.join(
- currentDir,
- extendedConfigValue,
- );
- fileSystem.stat(initialExtendedConfigPath, (existsErr) => {
- let extendedConfigPath = initialExtendedConfigPath;
- if (existsErr) {
- // Handle scoped package extends like "@scope/name" (no sub-path):
- // "@scope/name" should resolve to node_modules/@scope/name/tsconfig.json,
- // not node_modules/@scope/name.json
- // See: test/fixtures/tsconfig-paths/extends-pkg-entry/
- if (
- typeof originalExtendedConfigValue === "string" &&
- originalExtendedConfigValue.startsWith("@") &&
- originalExtendedConfigValue.split("/").length === 2
- ) {
- extendedConfigPath = resolver.join(
- currentDir,
- normalize(
- `node_modules/${originalExtendedConfigValue}/${DEFAULT_CONFIG_FILE}`,
- ),
- );
- } else if (extendedConfigValue.includes("/")) {
- // Handle package sub-path extends like "react/tsconfig":
- // "react/tsconfig" resolves to node_modules/react/tsconfig.json
- // See: test/fixtures/tsconfig-paths/extends-npm/
- extendedConfigPath = resolver.join(
- currentDir,
- normalize(`node_modules/${extendedConfigValue}`),
- );
- } else if (
- !originalExtendedConfigValue.startsWith(".") &&
- !originalExtendedConfigValue.startsWith("/")
- ) {
- // Handle unscoped package extends like "my-base-config" (no sub-path):
- // "my-base-config" should resolve to node_modules/my-base-config/tsconfig.json
- extendedConfigPath = resolver.join(
- currentDir,
- normalize(
- `node_modules/${originalExtendedConfigValue}/${DEFAULT_CONFIG_FILE}`,
- ),
- );
- }
- }
- this._loadTsconfig(
- resolver,
- extendedConfigPath,
- fileDependencies,
- visitedConfigPaths,
- (err, config) => {
- if (err) return callback(err);
- const cfg = /** @type {Tsconfig} */ (config);
- const compilerOptions = cfg.compilerOptions || {
- baseUrl: undefined,
- };
- if (compilerOptions.baseUrl) {
- const extendedConfigDir = resolver.dirname(extendedConfigPath);
- compilerOptions.baseUrl = getAbsoluteBaseUrl(
- extendedConfigDir,
- resolver,
- compilerOptions.baseUrl,
- );
- }
- delete cfg.references;
- callback(null, cfg);
- },
- );
- });
- }
- /**
- * Load referenced tsconfig projects and store in referenceMatchMap
- * Simple implementation matching tsconfig-paths-webpack-plugin:
- * Just load each reference and store independently
- * @param {Resolver} resolver the resolver
- * @param {string} context the context
- * @param {TsconfigReference[]} references array of references
- * @param {Set<string>} fileDependencies the file dependencies
- * @param {{ [baseUrl: string]: TsconfigPathsData }} referenceMatchMap the map to populate
- * @param {(err: Error | null) => void} callback callback
- * @param {Set<string>=} visitedRefPaths visited reference config paths (for circular reference detection)
- * @returns {void}
- */
- _loadTsconfigReferences(
- resolver,
- context,
- references,
- fileDependencies,
- referenceMatchMap,
- callback,
- visitedRefPaths,
- ) {
- if (references.length === 0) return callback(null);
- const visited = visitedRefPaths || new Set();
- let pending = references.length;
- const finishOne = () => {
- if (--pending === 0) callback(null);
- };
- for (const ref of references) {
- const refPath = substituteConfigDir(ref.path, context);
- const refConfigPath = resolver.join(
- resolver.join(context, refPath),
- DEFAULT_CONFIG_FILE,
- );
- if (visited.has(refConfigPath)) {
- finishOne();
- continue;
- }
- visited.add(refConfigPath);
- this._loadTsconfig(
- resolver,
- refConfigPath,
- fileDependencies,
- undefined,
- (err, refConfig) => {
- // Failures are swallowed to match tsconfig-paths-webpack-plugin:
- // a broken reference must not abort the main project's resolution.
- if (err) return finishOne();
- const cfg = /** @type {Tsconfig} */ (refConfig);
- if (cfg.compilerOptions && cfg.compilerOptions.paths) {
- const refContext = resolver.dirname(refConfigPath);
- referenceMatchMap[refContext] = tsconfigPathsToResolveOptions(
- refContext,
- cfg.compilerOptions.paths || {},
- resolver,
- cfg.compilerOptions.baseUrl,
- );
- }
- if (this.references === "auto" && Array.isArray(cfg.references)) {
- this._loadTsconfigReferences(
- resolver,
- resolver.dirname(refConfigPath),
- cfg.references,
- fileDependencies,
- referenceMatchMap,
- finishOne,
- visited,
- );
- } else {
- finishOne();
- }
- },
- );
- }
- }
- /**
- * Load tsconfig.json with extends support
- * @param {Resolver} resolver the resolver
- * @param {string} configFilePath absolute path to tsconfig.json
- * @param {Set<string>} fileDependencies the file dependencies
- * @param {Set<string> | undefined} visitedConfigPaths config paths being loaded (for circular extends detection)
- * @param {(err: Error | null, result?: Tsconfig) => void} callback callback
- * @returns {void}
- */
- _loadTsconfig(
- resolver,
- configFilePath,
- fileDependencies,
- visitedConfigPaths,
- callback,
- ) {
- const visited = visitedConfigPaths || new Set();
- if (visited.has(configFilePath)) {
- return callback(null, /** @type {Tsconfig} */ ({}));
- }
- visited.add(configFilePath);
- readJson(
- resolver.fileSystem,
- configFilePath,
- READ_JSON_OPTIONS,
- (err, parsed) => {
- if (err) return callback(/** @type {Error} */ (err));
- const config = /** @type {Tsconfig} */ (parsed);
- fileDependencies.add(configFilePath);
- const extendedConfig = config.extends;
- if (!extendedConfig) return callback(null, config);
- if (!Array.isArray(extendedConfig)) {
- this._loadTsconfigFromExtends(
- resolver,
- configFilePath,
- extendedConfig,
- fileDependencies,
- visited,
- (extErr, extendedTsconfig) => {
- if (extErr) return callback(extErr);
- callback(
- null,
- mergeTsconfigs(
- /** @type {Tsconfig} */ (extendedTsconfig),
- config,
- ),
- );
- },
- );
- return;
- }
- /** @type {Tsconfig} */
- let base = {};
- let i = 0;
- const next = () => {
- if (i >= extendedConfig.length) {
- return callback(null, mergeTsconfigs(base, config));
- }
- this._loadTsconfigFromExtends(
- resolver,
- configFilePath,
- extendedConfig[i++],
- fileDependencies,
- visited,
- (extErr, extendedTsconfig) => {
- if (extErr) return callback(extErr);
- base = mergeTsconfigs(
- base,
- /** @type {Tsconfig} */ (extendedTsconfig),
- );
- next();
- },
- );
- };
- next();
- },
- );
- }
- };
|