diff --git a/README.md b/README.md index dcf8915..cc18ea8 100644 --- a/README.md +++ b/README.md @@ -491,6 +491,40 @@ import { sanitizeFilePath } from "mlly"; console.log(sanitizeFilePath("C:\\te#st\\[...slug].jsx")); ``` +### `parseNodeModulePath` + +Parses an absolute file path in `node_modules` to three segments: + +- `dir`: Path to main directory of package +- `name`: Package name +- `subpath`: The optional package subpath + +It returns an empty object (with partial keys) if parsing fails. + +```js +import { parseNodeModulePath } from "mlly"; + +// dir: "/src/a/node_modules/" +// name: "lib" +// subpath: "./dist/index.mjs" +const { dir, name, subpath } = parseNodeModulePath( + "/src/a/node_modules/lib/dist/index.mjs" +); +``` + +### `lookupNodeModuleSubpath` + +Parses an absolute file path in `node_modules` and tries to reverse lookup (or guess) the original package exports subpath for it. + +```js +import { lookupNodeModuleSubpath } from "mlly"; + +// subpath: "./utils" +const subpath = lookupNodeModuleSubpath( + "/src/a/node_modules/lib/dist/utils.mjs" +); +``` + ## License [MIT](./LICENSE) - Made with ❤️ diff --git a/src/resolve.ts b/src/resolve.ts index 593d58a..5b1fa52 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -1,8 +1,9 @@ import { existsSync, realpathSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { joinURL } from "ufo"; -import { isAbsolute } from "pathe"; +import { isAbsolute, join, normalize } from "pathe"; import { moduleResolve } from "import-meta-resolve"; +import { PackageJson, readPackageJSON } from "pkg-types"; import { fileURLToPath, normalizeid } from "./utils"; import { pcall, BUILTIN_MODULES } from "./_utils"; @@ -148,3 +149,77 @@ export function createResolve(defaults?: ResolveOptions) { return resolve(id, { url, ...defaults }); }; } + +const NODE_MODULES_RE = /^(.+\/node_modules\/)([^/@]+|@[^/]+\/[^/]+)(\/?.*?)?$/; + +export function parseNodeModulePath(path: string) { + if (!path) { + return {}; + } + path = normalize(fileURLToPath(path)); + const match = NODE_MODULES_RE.exec(path); + if (!match) { + return {}; + } + const [, dir, name, subpath] = match; + return { + dir, + name, + subpath: subpath ? `.${subpath}` : undefined, + }; +} + +/** Reverse engineer a subpath export if possible */ +export async function lookupNodeModuleSubpath( + path: string +): Promise { + path = normalize(fileURLToPath(path)); + const { name, subpath } = parseNodeModulePath(path); + + if (!name || !subpath) { + return subpath; + } + + const { exports } = (await readPackageJSON(path).catch(() => {})) || {}; + if (exports) { + const resolvedSubpath = _findSubpath(subpath, exports); + if (resolvedSubpath) { + return resolvedSubpath; + } + } + + return subpath; +} + +// --- Internal --- + +function _findSubpath(subpath: string, exports: PackageJson["exports"]) { + if (typeof exports === "string") { + exports = { ".": exports }; + } + + if (!subpath.startsWith(".")) { + subpath = subpath.startsWith("/") ? `.${subpath}` : `./${subpath}`; + } + + if (subpath in exports) { + return subpath; + } + + const flattenedExports = _flattenExports(exports); + const [foundPath] = + flattenedExports.find(([_, resolved]) => resolved === subpath) || []; + + return foundPath; +} + +function _flattenExports( + exports: Exclude, + path?: string +) { + return Object.entries(exports).flatMap(([key, value]) => + typeof value === "string" + ? [[path ?? key, value]] + : _flattenExports(value, path ?? key) + ); +} diff --git a/test/fixture/package/node_modules/subpaths/package.json b/test/fixture/package/node_modules/subpaths/package.json new file mode 100644 index 0000000..ba1b90f --- /dev/null +++ b/test/fixture/package/node_modules/subpaths/package.json @@ -0,0 +1,7 @@ +{ + "name": "subpaths", + "version": "1.0.0", + "exports": { + "./subpath": "./lib/subpath.mjs" + } +} diff --git a/test/utils.test.ts b/test/utils.test.ts index 9193c4d..c06a05d 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,5 +1,13 @@ +import { fileURLToPath } from "node:url"; import { describe, it, expect } from "vitest"; -import { isNodeBuiltin, sanitizeFilePath, getProtocol } from "../src"; + +import { + isNodeBuiltin, + sanitizeFilePath, + getProtocol, + parseNodeModulePath, + lookupNodeModuleSubpath, +} from "../src"; describe("isNodeBuiltin", () => { const cases = { @@ -58,3 +66,80 @@ describe("getProtocol", () => { expect(getProtocol("file:///C:/src/a.ts")).to.equal("file"); }); }); + +describe("parseNodeModulePath", () => { + const tests = [ + { + input: "/foo/bar", + output: {}, + }, + { + input: "/src/a/node_modules/thing", + output: { + dir: "/src/a/node_modules/", + name: "thing", + }, + }, + { + input: "/src/a/node_modules/thing/dist/index.mjs", + output: { + dir: "/src/a/node_modules/", + name: "thing", + subpath: "./dist/index.mjs", + }, + }, + { + input: "C:\\src\\a\\node_modules\\thing\\dist\\index.mjs", + output: { + dir: "C:/src/a/node_modules/", + name: "thing", + subpath: "./dist/index.mjs", + }, + }, + ]; + for (const t of tests) { + it(t.input, () => { + expect(parseNodeModulePath(t.input)).toMatchObject(t.output); + }); + } +}); + +describe("lookupNodeModuleSubpath", () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + const r = (p: string) => new URL(p, import.meta.url).toString(); + + const tests = [ + { + name: "resolves with exports field", + input: r("fixture/package/node_modules/subpaths/lib/subpath.mjs"), + output: "./subpath", + }, + { + name: "resolves with fallback subpath guess", + input: r("fixture/package/node_modules/alien/lib/subpath.json5"), + output: "./lib/subpath.json5", + }, + { + name: "ignores invalid paths", + input: r("/foo/bar/lib/subpath.mjs"), + output: undefined, + }, + { + name: "resolves main export", + input: r("fixture/package/node_modules/subpaths/foo/bar.mjs"), + output: "./foo/bar.mjs", + }, + { + name: "resolves main export", + input: r("fixture/package/node_modules/subpaths/"), + output: "./", + }, + ]; + + for (const t of tests) { + it(t.name, async () => { + const result = await lookupNodeModuleSubpath(t.input); + expect(result).toBe(t.output); + }); + } +});