Files
ob248.com/node_modules/enhanced-resolve/lib/TsconfigPathsPlugin.js
2026-02-05 17:31:20 +00:00

561 lines
15 KiB
JavaScript

/*
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<TsconfigPathsMap | null>} 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<TsconfigPathsMap>} the complete tsconfig paths map
*/
async _loadTsconfigPathsMap(fileSystem, absTsconfigPath) {
/** @type {Set<string>} */
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<string>} fileDependencies the file dependencies
* @returns {Promise<Tsconfig>} 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<string>} fileDependencies the file dependencies
* @param {{ [baseUrl: string]: TsconfigPathsData }} referenceMatchMap the map to populate
* @returns {Promise<void>}
*/
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<string>} fileDependencies the file dependencies
* @returns {Promise<Tsconfig>} 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;
}
};