// Copyright 2017-2026 @pezkuwi/dev-ts authors & contributors // SPDX-License-Identifier: Apache-2.0 import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL, URL } from 'node:url'; import { CWD_URL, EXT_JS_REGEX, EXT_JSON_REGEX, EXT_TS_ARRAY, EXT_TS_REGEX } from './common.js'; import { tsAliases } from './tsconfig.js'; interface Resolved { format: 'commonjs' | 'json' | 'module'; shortCircuit?: boolean; url: string; } interface ResolverContext { parentURL?: string; } type Resolver = (specifier: string, context: ResolverContext) => Resolved | undefined; /** * @internal * * From a specified URL, extract the actual full path as well as the * directory that this path reflects (either equivalent to path or the * root of the file being referenced) */ function getParentPath (parentUrl: URL | string): { parentDir: string; parentPath: string; } { const parentPath = fileURLToPath(parentUrl); return { parentDir: fs.existsSync(parentPath) && fs.lstatSync(parentPath).isDirectory() ? parentPath : path.dirname(parentPath), parentPath }; } /** * @internal * * Resolve fully-specified imports with extensions. **/ export function resolveExtTs (specifier: string, parentUrl: URL | string): Resolved | void { // handle .ts extensions directly if (EXT_TS_REGEX.test(specifier)) { return { format: 'module', shortCircuit: true, url: new URL(specifier, parentUrl).href }; } } /** * @internal * * Resolve fully-specified imports with extensions. Here we cater for the TS * mapping of import foo from './bar.js' where only './bar.ts' exists **/ export function resolveExtJs (specifier: string, parentUrl: URL | string): Resolved | void { // handle ts imports where import *.js -> *.ts // (unlike the ts resolution, we only cater for relative paths) if (specifier.startsWith('.') && EXT_JS_REGEX.test(specifier)) { const full = fileURLToPath(new URL(specifier, parentUrl)); // when it doesn't exist, we try and see if a source replacement helps if (!fs.existsSync(full)) { const found = EXT_TS_ARRAY .map((e) => full.replace(EXT_JS_REGEX, e)) .find((f) => fs.existsSync(f) && fs.lstatSync(f).isFile()); if (found) { return { format: 'module', shortCircuit: true, url: pathToFileURL(found).href }; } } } } /** * @internal * * Resolution for Json files. Generally these would be via path aliasing. */ export function resolveExtJson (specifier: string, parentUrl: URL | string): Resolved | void { if (specifier.startsWith('.') && EXT_JSON_REGEX.test(specifier)) { const { parentDir } = getParentPath(parentUrl); const jsonPath = path.join(parentDir, specifier); if (fs.existsSync(jsonPath)) { return { // .json needs to be in 'json' format for the loader, for the // the rest (it should only be TS) we use the 'module' format format: 'json', shortCircuit: true, url: pathToFileURL(jsonPath).href }; } } } /** * @internal * * Resolve relative (extensionless) paths. * * At some point we probably might need to extend this to cater for the * ts (recommended) approach for using .js extensions inside the sources. * However, since we don't use this in the pezkuwi-js code, can kick this * down the line **/ export function resolveExtBare (specifier: string, parentUrl: URL | string): Resolved | void { if (specifier.startsWith('.')) { const { parentDir, parentPath } = getParentPath(parentUrl); const found = specifier === '.' ? ( // handle . imports for /index.ts EXT_TS_ARRAY .map((e) => path.join(parentDir, `index${e}`)) .find((f) => fs.existsSync(f)) || // handle the case where parentUrl needs an extension (generally via alias) EXT_TS_ARRAY .map((e) => `${parentPath}${e}`) .find((f) => fs.existsSync(f)) ) : ( // tests to see if this is a file (without extension) EXT_TS_ARRAY .map((e) => path.join(parentDir, `${specifier}${e}`)) .find((f) => fs.existsSync(f)) || // test to see if this is a directory EXT_TS_ARRAY .map((e) => path.join(parentDir, `${specifier}/index${e}`)) .find((f) => fs.existsSync(f)) ); if (found) { return { format: 'module', shortCircuit: true, url: pathToFileURL(found).href }; } } } /** * @internal * * Resolve anything that is not an alias **/ export function resolveNonAlias (specifier: string, parentUrl: URL | string): Resolved | void { return ( resolveExtTs(specifier, parentUrl) || resolveExtJs(specifier, parentUrl) || resolveExtJson(specifier, parentUrl) || resolveExtBare(specifier, parentUrl) ); } /** * @internal * * Resolve TS alias mappings as defined in the tsconfig.json file **/ export function resolveAlias (specifier: string, _parentUrl: URL | string, aliases = tsAliases): Resolved | void { const parts = specifier.split(/[\\/]/); const found = aliases // return a [filter, [...partIndex]] mapping .map((alias) => ({ alias, indexes: parts .map((_, i) => i) .filter((start) => ( alias.isWildcard // parts should have more entries than the wildcard ? parts.length > alias.filter.length // or the same amount in case of a non-wildcard match : parts.length === alias.filter.length ) && // match all parts of the alias alias.filter.every((f, i) => parts[start + i] && parts[start + i] === f ) ) })) // we only return the first .find(({ indexes }) => indexes.length); if (found) { // do the actual un-aliased resolution return resolveNonAlias( `./${found.alias.path.replace('*', path.join(...parts.slice(found.alias.filter.length)))}`, found.alias.url ); } } /** * Resolves a path using our logic. * * 1. First we attempt to directly resolve if .ts/.tsx extension is found * 2. Then we do relative resolves (this is for extension-less .ts files) * 3. The we try to do resolution via TS aliases * * ... finally, try the next loader in the chain */ export function resolve (specifier: string, context: ResolverContext, nextResolve: Resolver) { const parentUrl = context.parentURL || CWD_URL; return ( resolveNonAlias(specifier, parentUrl) || resolveAlias(specifier, parentUrl) || nextResolve(specifier, context) ); }