@nodejs-loaders/alias
Version:
Extend node to support TypeScript 'paths' via customization hooks.
117 lines (93 loc) • 3.42 kB
JavaScript
import { readFileSync } from 'node:fs';
import { findPackageJSON } from 'node:module';
import path from 'node:path';
import { emitWarning, env } from 'node:process';
import { pathToFileURL, URL } from 'node:url';
import JSON5 from 'json5';
/**
* @typedef {import('type-fest').TsConfigJson} TSConfig
* @typedef {import('../types.d.ts').AbsoluteFilePath} AbsoluteFilePath
* @typedef {import('../types.d.ts').FileURL} FileURL
*/
export const meta = {
filename: env.TS_NODE_PROJECT
// oxlint-disable-next-line eslint/no-nested-ternary
? env.TS_NODE_PROJECT?.startsWith('file:')
? env.TS_NODE_PROJECT
: pathToFileURL(path.resolve(env.TS_NODE_PROJECT)).href
: 'tsconfig.json',
};
/**
* @typedef {Map<string, string>} AliasMap A map of resolved aliases.
*/
/**
* @type {Map<FileURL, AliasMap>}
*/
const aliasesMap = new Map();
/**
* @param {FileURL} parentURL Relative to where.
* @param {string} [filename] Filename or fully resolved location of the tsconfig.
*/
export function getAliases(
parentURL,
filename = meta.filename,
) {
if (!parentURL) return; // "resolving" the entry-point (it's already resolved)
const tsConfigLocus = /** @type {FileURL} */ (
filename.startsWith('file:')
? filename
: pathToFileURL(findPackageJSON('./', parentURL)?.replace(
PJSON_FNAME,
path.basename(filename),
))
);
if (aliasesMap.has(tsConfigLocus)) return aliasesMap.get(tsConfigLocus);
const aliases = readTSConfigFile(tsConfigLocus);
if (aliases == null) {
emitWarning([
`Alias loader was registered but no "paths" were found in "${filename}" for "${parentURL}".`,
'This loader will behave as a noop (but you should probably remove it if you aren’t using it).',
].join(''));
}
aliasesMap.set(tsConfigLocus, aliases);
return aliases;
}
const PJSON_FNAME = 'package.json';
/**
* @param {FileURL} resolvedLocus The resolved location of the tsconfig file.
*/
export function readTSConfigFile(resolvedLocus) {
const fileURL = new URL(resolvedLocus); // URL for cross-compatibility with Windows
let contents;
try {
contents = readFileSync(fileURL, 'utf8');
} catch (err) {
if (err.code !== 'ENOENT' && err.code !== 'MODULE_NOT_FOUND') throw err;
}
if (!contents) return;
const { compilerOptions } = /** @type {TSConfig} */ (JSON5.parse(contents));
return buildAliasMaps(compilerOptions, resolvedLocus);
}
/**
* @param {TSConfig['compilerOptions']|undefined} compilerOptions The value of "compilerOptions" if it exists.
* @param {FileURL} tsConfigLocus The location of the controlling tsconfig.
*/
// oxlint-disable-next-line eslint/default-param-last
function buildAliasMaps({ baseUrl: base = './', paths } = {}, tsConfigLocus) {
if (!paths) return;
// URL() drops/overwrites the final segment of the 2nd arg when it does not end in '/' 🤪
const basePath = base.at(-1) === '/' ? base : `${base}/`;
const baseURL = new URL(basePath, tsConfigLocus);
const aliases = /** @type {AliasMap} */ (new Map());
for (const rawKey of Object.keys(paths)) {
const alias = paths[rawKey][0];
const isPrefix = rawKey.endsWith('*');
const key = isPrefix ? rawKey.slice(0, -1) /* strip '*' */ : rawKey;
const baseDest = isPrefix ? alias.slice(0, -1) /* strip '*' */ : alias;
const dest = (baseDest[0] === '/' || URL.canParse(baseDest))
? baseDest
: new URL(baseDest, baseURL).href;
aliases.set(key, dest);
}
return aliases;
}