Skip to content

Commit

Permalink
feat: add parseNodeModulePath and lookupNodeModuleSubpath utils (#89
Browse files Browse the repository at this point in the history
)

Co-authored-by: Pooya Parsa <pooya@pi0.io>
  • Loading branch information
danielroe and pi0 committed Jun 20, 2023
1 parent acae578 commit 70a22ad
Show file tree
Hide file tree
Showing 4 changed files with 203 additions and 2 deletions.
34 changes: 34 additions & 0 deletions README.md
Expand Up @@ -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 ❤️
77 changes: 76 additions & 1 deletion 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";

Check warning on line 4 in src/resolve.ts

View workflow job for this annotation

GitHub Actions / ci

'join' is defined but never used
import { moduleResolve } from "import-meta-resolve";
import { PackageJson, readPackageJSON } from "pkg-types";
import { fileURLToPath, normalizeid } from "./utils";
import { pcall, BUILTIN_MODULES } from "./_utils";

Expand Down Expand Up @@ -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<string | undefined> {
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) || [];

Check warning on line 211 in src/resolve.ts

View workflow job for this annotation

GitHub Actions / ci

'_' is defined but never used

return foundPath;
}

function _flattenExports(
exports: Exclude<PackageJson["exports"], string>,
path?: string
) {
return Object.entries(exports).flatMap(([key, value]) =>
typeof value === "string"
? [[path ?? key, value]]
: _flattenExports(value, path ?? key)
);
}
7 changes: 7 additions & 0 deletions test/fixture/package/node_modules/subpaths/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 86 additions & 1 deletion test/utils.test.ts
@@ -1,5 +1,13 @@
import { fileURLToPath } from "node:url";

Check warning on line 1 in test/utils.test.ts

View workflow job for this annotation

GitHub Actions / ci

'fileURLToPath' is defined but never used
import { describe, it, expect } from "vitest";
import { isNodeBuiltin, sanitizeFilePath, getProtocol } from "../src";

import {
isNodeBuiltin,
sanitizeFilePath,
getProtocol,
parseNodeModulePath,
lookupNodeModuleSubpath,
} from "../src";

describe("isNodeBuiltin", () => {
const cases = {
Expand Down Expand Up @@ -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);
});
}
});

0 comments on commit 70a22ad

Please sign in to comment.