@nodejs-loaders/alias
Advanced tools
| /** | ||
| * @param {FileURL} parentURL Relative to where. | ||
| * @param {string} [filename] Filename or fully resolved location of the tsconfig. | ||
| */ | ||
| export function getAliases(parentURL: FileURL, filename?: string): AliasMap; | ||
| /** | ||
| * @param {FileURL} resolvedLocus The resolved location of the tsconfig file. | ||
| */ | ||
| export function readTSConfigFile(resolvedLocus: FileURL): AliasMap; | ||
| export namespace meta { | ||
| let filename: string; | ||
| } | ||
| export type TSConfig = import("type-fest").TsConfigJson; | ||
| export type AbsoluteFilePath = import("../types.d.ts").AbsoluteFilePath; | ||
| export type FileURL = import("../types.d.ts").FileURL; | ||
| /** | ||
| * A map of resolved aliases. | ||
| */ | ||
| export type AliasMap = Map<string, string>; | ||
| //# sourceMappingURL=get-aliases-from-tsconfig.d.mts.map |
| {"version":3,"file":"get-aliases-from-tsconfig.d.mts","sourceRoot":"","sources":["get-aliases-from-tsconfig.mjs"],"names":[],"mappings":"AAgCA;;;GAGG;AACH,sCAHW,OAAO,aACP,MAAM,YA+BhB;AAGD;;GAEG;AACH,gDAFW,OAAO,YAiBjB;;;;uBA7EY,OAAO,WAAW,EAAE,YAAY;+BAChC,OAAO,eAAe,EAAE,gBAAgB;sBACxC,OAAO,eAAe,EAAE,OAAO;;;;uBAa/B,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC"} |
| 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; | ||
| } |
+14
-3
| export function resolveAliases(specifier: string, context: import("module").ResolveHookContext, nextResolve: (specifier: string, context?: Partial<import("module").ResolveHookContext>) => import("module").ResolveFnOutput | Promise<import("module").ResolveFnOutput>): import("module").ResolveFnOutput | Promise<import("module").ResolveFnOutput>; | ||
| export function readConfigFile(filename: any): Promise<void | AliasMap>; | ||
| export { resolveAlias as resolve }; | ||
| export type AliasMap = Map<string, string>; | ||
| export type TsConfigJson = import("type-fest").TsConfigJson; | ||
| export type FileURL = import("../types.d.ts").FileURL; | ||
| export type AliasInitConfig = { | ||
| /** | ||
| * The name or fully resolved location of the tsconfig. | ||
| */ | ||
| location: string | FileURL; | ||
| }; | ||
| export type ResolveHook = import("node:module").ResolveHook; | ||
| export type ResolveParams = Parameters<ResolveHook>; | ||
| export type ResolveSpecifier = ResolveParams[0]; | ||
| export type ResolveCtx = ResolveParams[1]; | ||
| declare function initialiseAlias(data: any): void | Promise<void>; | ||
| declare function resolveAlias(specifier: string, context: import("module").ResolveHookContext, nextResolve: (specifier: string, context?: Partial<import("module").ResolveHookContext>) => import("module").ResolveFnOutput | Promise<import("module").ResolveFnOutput>): import("module").ResolveFnOutput | Promise<import("module").ResolveFnOutput>; | ||
| export { initialiseAlias as initialize, resolveAlias as resolve }; | ||
| //# sourceMappingURL=alias.loader.d.mts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"alias.loader.d.mts","sourceRoot":"","sources":["alias.loader.mjs"],"names":[],"mappings":"wIAmFs5c,CAAC;AA1Cv5c,wEAcC;;uBAGY,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC;uIAyBs3c,CAAC"} | ||
| {"version":3,"file":"alias.loader.d.mts","sourceRoot":"","sources":["alias.loader.mjs"],"names":[],"mappings":"wIAgFg7c,CAAC;2BA5En6c,OAAO,WAAW,EAAE,YAAY;sBAChC,OAAO,eAAe,EAAE,OAAO;;;;;cAInC,MAAM,GAAG,OAAO;;0BAcb,OAAO,aAAa,EAAE,WAAW;4BACjC,UAAU,CAAC,WAAW,CAAC;+BACvB,aAAa,CAAC,CAAC,CAAC;yBAChB,aAAa,CAAC,CAAC,CAAC;;uIAsDm5c,CAAC"} |
+55
-58
@@ -1,22 +0,45 @@ | ||
| import { readFile } from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
| import { pathToFileURL, URL } from 'node:url'; | ||
| // oxlint-disable eslint/max-depth | ||
| import JSON5 from 'json5'; | ||
| import { getAliases, meta } from './get-aliases-from-tsconfig.mjs'; | ||
| const projectRoot = pathToFileURL(`${process.cwd()}/`); | ||
| /** @typedef {import('type-fest').TsConfigJson} TsConfigJson */ | ||
| /** @typedef {import('../types.d.ts').FileURL} FileURL */ | ||
| const aliases = await readConfigFile('tsconfig.json'); | ||
| /** | ||
| * @typedef {object} AliasInitConfig | ||
| * @prop {string | FileURL} AliasInitConfig.location The name or fully resolved location of the tsconfig. | ||
| */ | ||
| /** | ||
| * @type {import('node:module').InitializeHook} | ||
| * @param {AliasInitConfig} [config] Configuration object to customise Alias loader. | ||
| */ | ||
| function initialiseAlias(config) { | ||
| if (config == null) return; | ||
| if (!aliases) | ||
| console.warn( | ||
| 'Alias loader was registered but no "paths" were found in tsconfig.json', | ||
| 'This loader will behave as a noop (but you should probably remove it if you aren’t using it).', | ||
| ); | ||
| if (config.location) meta.filename = config.location; | ||
| } | ||
| export { initialiseAlias as initialize }; | ||
| /** | ||
| * @typedef {import('node:module').ResolveHook} ResolveHook | ||
| * @typedef {Parameters<ResolveHook>} ResolveParams | ||
| * @typedef {ResolveParams[0]} ResolveSpecifier | ||
| * @typedef {ResolveParams[1]} ResolveCtx | ||
| */ | ||
| /** | ||
| * @type {import('node:module').ResolveHook} | ||
| * @param {ResolveSpecifier} specifier The unresolved module specifier. | ||
| * @param {ResolveCtx & { parentURL?: FileURL }} ctx The ResolveHookContext. | ||
| */ | ||
| function resolveAlias(specifier, ctx, next) { | ||
| return (aliases ? resolveAliases : next)(specifier, ctx, next); | ||
| const aliases = getAliases(ctx.parentURL); | ||
| if (!aliases) return next(specifier, ctx); | ||
| return resolveAliases(specifier, { | ||
| ...ctx, | ||
| // @ts-expect-error not sure why it isn't picking up the type union | ||
| aliases, | ||
| }, next); | ||
| } | ||
@@ -27,58 +50,32 @@ export { resolveAlias as resolve }; | ||
| * @type {import('node:module').ResolveHook} | ||
| * @param {ResolveSpecifier} specifier The unresolved module specifier. | ||
| * @param {ResolveCtx & { aliases: import('./get-aliases-from-tsconfig.mjs').AliasMap }} ctx The ResolveHookContext. | ||
| */ | ||
| export function resolveAliases(specifier, ctx, next) { | ||
| // biome-ignore format: https://github.com/biomejs/biome/issues/4799 | ||
| for (const [key, dest] of /** @type {AliasMap} */ (aliases)) { | ||
| export function resolveAliases(specifier, { aliases }, next) { | ||
| for (const [key, dest] of aliases) { | ||
| if (specifier === key) { | ||
| return next(dest, ctx); | ||
| return next(dest); | ||
| } | ||
| if (specifier.startsWith(key)) { | ||
| return next(specifier.replace(key, dest), ctx); | ||
| } | ||
| } | ||
| let resolved; | ||
| // Need try/catch for the sync path (module.registerHooks) | ||
| try { resolved = next(specifier.replace(key, dest)) } | ||
| catch (err) { if (err.code !== 'ERR_MODULE_NOT_FOUND') throw err } | ||
| return next(specifier, ctx); | ||
| } | ||
| // Need the promise path for the async path (module.register) | ||
| if ('catch' in resolved && typeof resolved?.catch === 'function') { | ||
| return resolved.catch((err) => { | ||
| if (err.code !== 'ERR_MODULE_NOT_FOUND') throw err; | ||
| export function readConfigFile(filename) { | ||
| const filepath = path.join(projectRoot.pathname, filename); | ||
| return next(specifier); | ||
| }); | ||
| } | ||
| return ( | ||
| readFile(filepath) | ||
| .then((contents) => contents.toString()) | ||
| .then((contents) => JSON5.parse(contents)) | ||
| // Get the `compilerOptions.paths` object from the parsed JSON | ||
| .then((contents) => contents?.compilerOptions?.paths) | ||
| .then(buildAliasMaps) | ||
| .catch((err) => { | ||
| if (err.code !== 'ENOENT') throw err; | ||
| }) | ||
| ); | ||
| } | ||
| if (resolved) return resolved; | ||
| /** | ||
| * @typedef {Map<string, string>} AliasMap | ||
| */ | ||
| function buildAliasMaps(config) { | ||
| if (!config) return; | ||
| // biome-ignore format: https://github.com/biomejs/biome/issues/4799 | ||
| const aliases = /** @type {AliasMap} */ (new Map()); | ||
| for (const rawKey of Object.keys(config)) { | ||
| const alias = config[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, projectRoot).href; | ||
| aliases.set(key, dest); | ||
| return next(specifier); | ||
| } | ||
| } | ||
| return aliases; | ||
| return next(specifier); | ||
| } |
+1
-1
| { | ||
| "version": "2.0.1", | ||
| "version": "2.1.0", | ||
| "name": "@nodejs-loaders/alias", | ||
@@ -4,0 +4,0 @@ "description": "Extend node to support TypeScript 'paths' via customization hooks.", |
+40
-2
@@ -5,5 +5,18 @@ # Nodejs Loaders: Alias | ||
| [](https://www.npmjs.com/package/nodejs-loaders/alias) | ||
| [](https://www.npmjs.com/package/@nodejs-loaders/alias) | ||
|  | ||
| [](https://nodejs.org/download) | ||
| ## Usage | ||
| ```console | ||
| $ npm i -D @nodejs-loaders/alias | ||
| ``` | ||
| ```console | ||
| $ node --import @nodejs-loaders/alias main.js | ||
| ``` | ||
| See `README.md` in the repository's root for more details. | ||
| **Environments**: dev, test | ||
@@ -13,3 +26,3 @@ | ||
| This loader facilitates TypeScript's [`paths`](https://www.typescriptlang.org/docs/handbook/modules/reference.html#paths), handling the (important) half of work TypeScript ignores. It looks for a `tsconfig.json` in the project root (the current working directory) and builds aliases from `compilerOptions.paths` if it exists. If your tsconfig lives in a different location, create a symlink to it from your project root. | ||
| This loader facilitates TypeScript's [`paths`](https://www.typescriptlang.org/docs/handbook/modules/reference.html#paths), handling the (important) half of work TypeScript ignores. It looks for a `tsconfig.json` in the project root (the current working directory) and builds aliases from `compilerOptions.paths` if it exists. If your tsconfig lives in a different location, see [Configuration](#configuration) below. | ||
@@ -19,2 +32,18 @@ > [!CAUTION] | ||
| ## `compilerOptions.baseUrl` | ||
| In order for Alias loader to leverage `baseUrl`, there must be at least 1 path in `compilerOptions.paths`. If, for example, you wish to only facilitate absolute specifiers (relative to some base folder, like `./src`, such as is common in Next.js projects), include the following "dummy" `"paths"`: | ||
| ```json5 | ||
| { | ||
| "compilerOptions": { | ||
| "baseUrl": "./src", | ||
| "paths": { "*": ["./*"] }, // ⚠️ Effectively prepends ./src | ||
| }, | ||
| } | ||
| ``` | ||
| > [!WARN] | ||
| > If an aliased specifier successfully resolves to a "local" module, you will not be able to reach one in `node_modules`. This behaviour is consistent with Node.js and tsc, but it can still be a gotcha. | ||
| ## A simple prefix | ||
@@ -33,1 +62,10 @@ | ||
| This is a static specifier similar to a bare module specifier: `foo` → `${project_root}/src/app/foo.mts`. This may be useful when you have a commonly referenced file like config (which may conditionally not even live on the same filesystem): `import CONF from 'conf';` → `${project_root}/config.json`. | ||
| ## Configuration | ||
| The are 2 ways to configure the tsconfig alias loader uses: | ||
| * Environment variable: `TS_NODE_PROJECT` | ||
| * `node:module.register`'s options.data argument: `register(…, …, { data: import.meta.resolve(…) })`. | ||
| For both options, the value can be either a simple filename like `'tsconfig.whatever.json'` or a fully resolved location `'file:///path/to/someplace/tsconfig.whatever.json'` (or its absolute file path). |
13833
93.82%11
37.5%165
126.03%68
126.67%