diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a482ac2..1068065 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - nodejs: [10, 12, 14, 16, 18] + # Node 10.x not supported by tsm & bundt + nodejs: [12, 14, 16, 18] steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 @@ -34,9 +35,16 @@ jobs: npm install npm install -g nyc + - name: Type Check + run: npm run types + - name: Test w/ Coverage run: nyc --include=src npm test + - name: Compile + if: matrix.nodejs >= 18 + run: npm run build + - name: Report if: matrix.nodejs >= 18 run: | diff --git a/index.d.ts b/index.d.ts index 8fb9ec4..269dfb0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,21 +1,100 @@ export type Options = { + /** + * When true, adds the "browser" conditions. + * Otherwise the "node" condition is enabled. + * @default false + */ browser?: boolean; + /** + * Any custom conditions to match. + * @note Array order does not matter. Priority is determined by the key-order of conditions defined within a package's imports/exports mapping. + * @default [] + */ conditions?: readonly string[]; + /** + * When true, adds the "require" condition. + * Otherwise the "import" condition is enabled. + * @default false + */ require?: boolean; - unsafe?: false; -} | { - conditions?: readonly string[]; - unsafe?: true; + /** + * Prevents "require", "import", "browser", and/or "node" conditions from being added automatically. + * When enabled, only `options.conditions` are added alongside the "default" condition. + * @important Enabling this deviates from Node.js default behavior. + * @default false + */ + unsafe?: boolean; } -export function resolve<T=any>(pkg: T, entry: string, options?: Options): string | void; - -export type BrowserFiles = Record<string, string | false>; +export function resolve<T=Package>(pkg: T, entry?: string, options?: Options): Imports.Output | Exports.Output | void; +export function imports<T=Package>(pkg: T, entry?: string, options?: Options): Imports.Output | void; +export function exports<T=Package>(pkg: T, target: string, options?: Options): Exports.Output | void; -export function legacy<T=any>(pkg: T, options: { browser: true, fields?: readonly string[] }): BrowserFiles | string | void; -export function legacy<T=any>(pkg: T, options: { browser: string, fields?: readonly string[] }): string | false | void; -export function legacy<T=any>(pkg: T, options: { browser: false, fields?: readonly string[] }): string | void; -export function legacy<T=any>(pkg: T, options?: { +export function legacy<T=Package>(pkg: T, options: { browser: true, fields?: readonly string[] }): Browser | void; +export function legacy<T=Package>(pkg: T, options: { browser: string, fields?: readonly string[] }): string | false | void; +export function legacy<T=Package>(pkg: T, options: { browser: false, fields?: readonly string[] }): string | void; +export function legacy<T=Package>(pkg: T, options?: { browser?: boolean | string; fields?: readonly string[]; -}): BrowserFiles | string | false | void; +}): Browser | string; + +// --- + +/** + * A resolve condition + * @example "node", "default", "production" + */ +export type Condition = string; + +/** An internal file path */ +export type Path = `./${string}`; + +export type Imports = { + [entry: Imports.Entry]: Imports.Value; +} + +export namespace Imports { + export type Entry = `#${string}`; + + type External = string; + + /** strings are dependency names OR internal paths */ + export type Value = External | Path | null | { + [c: Condition]: Value; + } | Value[]; + + + export type Output = Array<External|Path>; +} + +export type Exports = Path | { + [path: Exports.Entry]: Exports.Value; + [cond: Condition]: Exports.Value; +} + +export namespace Exports { + /** Allows "." and "./{name}" */ + export type Entry = `.${string}`; + + /** strings must be internal paths */ + export type Value = Path | null | { + [c: Condition]: Value; + } | Value[]; + + export type Output = Path[]; +} + +export type Package = { + name: string; + version?: string; + module?: string; + main?: string; + imports?: Imports; + exports?: Exports; + browser?: Browser; + [key: string]: any; +} + +export type Browser = string[] | string | { + [file: Path | string]: string | false; +} diff --git a/package.json b/package.json index 45b2011..8fdacf9 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "version": "1.1.1", + "version": "2.0.0-next.0", "name": "resolve.exports", "repository": "lukeed/resolve.exports", - "description": "A tiny (813b), correct, general-purpose, and configurable \"exports\" resolver without file-system reliance", + "description": "A tiny (952b), correct, general-purpose, and configurable \"exports\" and \"imports\" resolver without file-system reliance", "module": "dist/index.mjs", "main": "dist/index.js", "types": "index.d.ts", @@ -16,8 +16,9 @@ "node": ">=10" }, "scripts": { - "build": "bundt", - "test": "uvu -r esm test" + "build": "bundt -m", + "types": "tsc --noEmit", + "test": "uvu -r tsm test" }, "files": [ "*.d.ts", @@ -41,8 +42,9 @@ "resolve" ], "devDependencies": { - "bundt": "1.1.2", - "esm": "3.2.25", - "uvu": "0.5.1" + "bundt": "next", + "tsm": "2.3.0", + "typescript": "4.9.4", + "uvu": "0.5.4" } } diff --git a/readme.md b/readme.md index 715c8b6..c00b33b 100644 --- a/readme.md +++ b/readme.md @@ -1,29 +1,15 @@ # resolve.exports [](https://github.com/lukeed/resolve.exports/actions) [](https://codecov.io/gh/lukeed/resolve.exports) -> A tiny (813b), correct, general-purpose, and configurable `"exports"` resolver without file-system reliance +> A tiny (952b), correct, general-purpose, and configurable `"exports"` and `"imports"` resolver without file-system reliance ***Why?*** Hopefully, this module may serve as a reference point (and/or be used directly) so that the varying tools and bundlers within the ecosystem can share a common approach with one another **as well as** with the native Node.js implementation. -With the push for ESM, we must be _very_ careful and avoid fragmentation. If we, as a community, begin propagating different _dialects_ of `"exports"` resolution, then we're headed for deep trouble. It will make supporting (and using) `"exports"` nearly impossible, which may force its abandonment and along with it, its benefits. +With the push for ESM, we must be _very_ careful and avoid fragmentation. If we, as a community, begin propagating different _dialects_ of the resolution algorithm, then we're headed for deep trouble. It will make supporting (and using) `"exports"` nearly impossible, which may force its abandonment and along with it, its benefits. Let's have nice things. -***TODO*** - -- [x] exports string -- [x] exports object (single entry) -- [x] exports object (multi entry) -- [x] nested / recursive conditions -- [x] exports arrayable -- [x] directory mapping (`./foobar/` => `/foobar/`) -- [x] directory mapping (`./foobar/*` => `./other/*.js`) -- [x] directory mapping w/ conditions -- [x] directory mapping w/ nested conditions -- [x] legacy fields (`main` vs `module` vs ...) -- [x] legacy "browser" files object - ## Install ```sh @@ -35,12 +21,22 @@ $ npm install resolve.exports > Please see [`/test/`](/test) for examples. ```js -import { resolve, legacy } from 'resolve.exports'; +import * as resolve from 'resolve.exports'; -const contents = { +// package.json contents +const pkg = { "name": "foobar", "module": "dist/module.mjs", "main": "dist/require.js", + "imports": { + "#hash": { + "import": { + "browser": "./hash/web.mjs", + "node": "./hash/node.mjs", + }, + "default": "./hash/detect.js" + } + }, "exports": { ".": { "import": "./dist/module.mjs", @@ -57,64 +53,158 @@ const contents = { } }; -// Assumes `.` as default entry -// Assumes `import` as default condition -resolve(contents); //=> "./dist/module.mjs" +// --- +// Exports +// --- -// entry: nullish === "foobar" === "." -resolve(contents, 'foobar'); //=> "./dist/module.mjs" -resolve(contents, '.'); //=> "./dist/module.mjs" +// entry: "foobar" === "." === default +// conditions: ["default", "import", "node"] +resolve.exports(pkg); +resolve.exports(pkg, '.'); +resolve.exports(pkg, 'foobar'); +//=> ["./dist/module.mjs"] // entry: "foobar/lite" === "./lite" -resolve(contents, 'foobar/lite'); //=> "./lite/module.mjs" -resolve(contents, './lite'); //=> "./lite/module.mjs" +// conditions: ["default", "import", "node"] +resolve.exports(pkg, 'foobar/lite'); +resolve.exports(pkg, './lite'); +//=> ["./lite/module.mjs"] -// Assume `require` usage -resolve(contents, 'foobar', { require: true }); //=> "./dist/require.js" -resolve(contents, './lite', { require: true }); //=> "./lite/require.js" +// Enable `require` condition +// conditions: ["default", "require", "node"] +resolve.exports(pkg, 'foobar', { require: true }); //=> ["./dist/require.js"] +resolve.exports(pkg, './lite', { require: true }); //=> ["./lite/require.js"] -// Throws "Missing <entry> export in <name> package" Error -resolve(contents, 'foobar/hello'); -resolve(contents, './hello/world'); +// Throws "Missing <entry> specifier in <name> package" Error +resolve.exports(pkg, 'foobar/hello'); +resolve.exports(pkg, './hello/world'); // Add custom condition(s) -resolve(contents, 'foobar/lite', { +// conditions: ["default", "worker", "import", "node"] +resolve.exports(pkg, 'foobar/lite', { conditions: ['worker'] -}); // => "./lite/worker.node.js" +}); //=> ["./lite/worker.node.js"] // Toggle "browser" condition -resolve(contents, 'foobar/lite', { +// conditions: ["default", "worker", "import", "browser"] +resolve.exports(pkg, 'foobar/lite', { conditions: ['worker'], browser: true -}); // => "./lite/worker.browser.js" +}); //=> ["./lite/worker.browser.js"] + +// Disable non-"default" condition activate +// NOTE: breaks from Node.js default behavior +// conditions: ["default", "custom"] +resolve.exports(pkg, 'foobar/lite', { + conditions: ['custom'], + unsafe: true, +}); +//=> Error: No known conditions for "./lite" specifier in "foobar" package + +// --- +// Imports +// --- + +// conditions: ["default", "import", "node"] +resolve.imports(pkg, '#hash'); +resolve.imports(pkg, 'foobar/#hash'); +//=> ["./hash/node.mjs"] + +// conditions: ["default", "import", "browser"] +resolve.imports(pkg, '#hash', { browser: true }); +resolve.imports(pkg, 'foobar/#hash'); +//=> ["./hash/web.mjs"] + +// conditions: ["default"] +resolve.imports(pkg, '#hash', { unsafe: true }); +resolve.imports(pkg, 'foobar/#hash'); +//=> ["./hash/detect.mjs"] + +resolve.imports(pkg, '#hello/world'); +resolve.imports(pkg, 'foobar/#hello/world'); +//=> Error: Missing "#hello/world" specifier in "foobar" package // --- // Legacy // --- // prefer "module" > "main" (default) -legacy(contents); //=> "dist/module.mjs" +resolve.legacy(pkg); //=> "dist/module.mjs" // customize fields order -legacy(contents, { +resolve.legacy(pkg, { fields: ['main', 'module'] }); //=> "dist/require.js" ``` ## API +The [`resolve()`](#resolvepkg-entry-options), [`exports()`](#exportspkg-entry-options), and [`imports()`](#importspkg-target-options) functions share similar API signatures: + +```ts +export function resolve(pkg: Package, entry?: string, options?: Options): string[] | undefined; +export function exports(pkg: Package, entry?: string, options?: Options): string[] | undefined; +export function imports(pkg: Package, target: string, options?: Options): string[] | undefined; +// ^ not optional! +``` + +All three: +* accept a `package.json` file's contents as a JSON object +* accept a target/entry identifier +* may accept an [Options](#options) object +* return `string[]`, `string`, or `undefined` + +The only difference is that `imports()` must accept a target identifier as there can be no inferred default. + +See below for further API descriptions. + +> **Note:** There is also a [Legacy Resolver API](#legacy-resolver) + +--- + ### resolve(pkg, entry?, options?) -Returns: `string` or `undefined` +Returns: `string[]` or `undefined` + +A convenience helper which automatically reroutes to [`exports()`](#exportspkg-entry-options) or [`imports()`](#importspkg-target-options) depending on the `entry` value. + +When unspecified, `entry` defaults to the `"."` identifier, which means that `exports()` will be invoked. + +```js +import * as r from 'resolve.exports'; + +let pkg = { + name: 'foobar', + // ... +}; + +r.resolve(pkg); +//~> r.exports(pkg, '.'); + +r.resolve(pkg, 'foobar'); +//~> r.exports(pkg, '.'); + +r.resolve(pkg, 'foobar/subpath'); +//~> r.exports(pkg, './subpath'); + +r.resolve(pkg, '#hash/md5'); +//~> r.imports(pkg, '#hash/md5'); + +r.resolve(pkg, 'foobar/#hash/md5'); +//~> r.imports(pkg, '#hash/md5'); +``` + +### exports(pkg, entry?, options?) +Returns: `string[]` or `undefined` Traverse the `"exports"` within the contents of a `package.json` file. <br> If the contents _does not_ contain an `"exports"` map, then `undefined` will be returned. -Successful resolutions will always result in a string value. This will be the value of the resolved mapping itself – which means that the output is a relative file path. +Successful resolutions will always result in a `string` or `string[]` value. This will be the value of the resolved mapping itself – which means that the output is a relative file path. This function may throw an Error if: * the requested `entry` cannot be resolved (aka, not defined in the `"exports"` map) -* an `entry` _was_ resolved but no known conditions were found (see [`options.conditions`](#optionsconditions)) +* an `entry` _is_ defined but no known conditions were matched (see [`options.conditions`](#optionsconditions)) #### pkg Type: `object` <br> @@ -149,61 +239,105 @@ Assume we have a module named "foobar" and whose `pkg` contains `"name": "foobar | `'lite'` | `'./lite'` | value was not relative & did not have `pkg.name` prefix | +### imports(pkg, target, options?) +Returns: `string[]` or `undefined` + +Traverse the `"imports"` within the contents of a `package.json` file. <br> +If the contents _does not_ contain an `"imports"` map, then `undefined` will be returned. + +Successful resolutions will always result in a `string` or `string[]` value. This will be the value of the resolved mapping itself – which means that the output is a relative file path. + +This function may throw an Error if: + +* the requested `target` cannot be resolved (aka, not defined in the `"imports"` map) +* an `target` _is_ defined but no known conditions were matched (see [`options.conditions`](#optionsconditions)) + +#### pkg +Type: `object` <br> +Required: `true` + +The `package.json` contents. + +#### target +Type: `string` <br> +Required: `true` + +The target import identifier; for example, `#hash` or `#hash/md5`. + +Import specifiers _must_ begin with the `#` character, as required by the resolution specification. However, if `target` begins with the package name (determined by the `pkg.name` value), then `resolve.exports` will trim it from the `target` identifier. For example, `"foobar/#hash/md5"` will be treated as `"#hash/md5"` for the `"foobar"` package. + +## Options + +The [`resolve()`](#resolvepkg-entry-options), [`imports()`](#importspkg-target-options), and [`exports()`](#exportspkg-entry-options) functions share these options. All properties are optional and you are not required to pass an `options` argument. + +Collectively, the `options` are used to assemble a list of [conditions](https://nodejs.org/docs/latest-v18.x/api/packages.html#conditional-exports) that should be activated while resolving your target(s). + +> **Note:** Although the Node.js documentation primarily showcases conditions alongside `"exports"` usage, they also apply to `"imports"` maps too. _([example](https://nodejs.org/docs/latest-v18.x/api/packages.html#subpath-imports))_ + #### options.require Type: `boolean` <br> Default: `false` -When truthy, the `"require"` field is added to the list of allowed/known conditions. - -When falsey, the `"import"` field is added to the list of allowed/known conditions instead. +When truthy, the `"require"` field is added to the list of allowed/known conditions. <br> +Otherwise the `"import"` field is added instead. #### options.browser Type: `boolean` <br> Default: `false` -When truthy, the `"browser"` field is added to the list of allowed/known conditions. +When truthy, the `"browser"` field is added to the list of allowed/known conditions. <br> +Otherwise the `"node"` field is added instead. #### options.conditions Type: `string[]` <br> Default: `[]` -Provide a list of additional/custom conditions that should be accepted when seen. +A list of additional/custom conditions that should be accepted when seen. > **Important:** The order specified within `options.conditions` does not matter. <br>The matching order/priority is **always** determined by the `"exports"` map's key order. For example, you may choose to accept a `"production"` condition in certain environments. Given the following `pkg` content: ```js -const contents = { - // ... +const pkg = { + // package.json ... "exports": { - "worker": "./index.worker.js", - "require": "./index.require.js", - "production": "./index.prod.js", - "import": "./index.import.mjs", + "worker": "./$worker.js", + "require": "./$require.js", + "production": "./$production.js", + "import": "./$import.mjs", } }; -resolve(contents, '.'); -//=> "./index.import.mjs" +resolve.exports(pkg, '.'); +// Conditions: ["default", "import", "node"] +//=> ["./$import.mjs"] -resolve(contents, '.', { +resolve.exports(pkg, '.', { conditions: ['production'] -}); //=> "./index.prod.js" +}); +// Conditions: ["default", "production", "import", "node"] +//=> ["./$production.js"] -resolve(contents, '.', { +resolve.exports(pkg, '.', { conditions: ['production'], require: true, -}); //=> "./index.require.js" +}); +// Conditions: ["default", "production", "require", "node"] +//=> ["./$require.js"] -resolve(contents, '.', { +resolve.exports(pkg, '.', { conditions: ['production', 'worker'], require: true, -}); //=> "./index.worker.js" +}); +// Conditions: ["default", "production", "worker", "require", "node"] +//=> ["./$worker.js"] -resolve(contents, '.', { +resolve.exports(pkg, '.', { conditions: ['production', 'worker'] -}); //=> "./index.worker.js" +}); +// Conditions: ["default", "production", "worker", "import", "node"] +//=> ["./$worker.js"] ``` #### options.unsafe @@ -215,38 +349,39 @@ Default: `false` When enabled, this option will ignore **all other options** except [`options.conditions`](#optionsconditions). This is because, when enabled, `options.unsafe` **does not** assume or provide any default conditions except the `"default"` condition. ```js -resolve(contents); +resolve.exports(pkg, '.'); //=> Conditions: ["default", "import", "node"] -resolve(contents, { unsafe: true }); +resolve.exports(pkg, '.', { unsafe: true }); //=> Conditions: ["default"] -resolve(contents, { unsafe: true, require: true, browser: true }); +resolve.exports(pkg, '.', { unsafe: true, require: true, browser: true }); //=> Conditions: ["default"] ``` In other words, this means that trying to use `options.require` or `options.browser` alongside `options.unsafe` will have no effect. In order to enable these conditions, you must provide them manually into the `options.conditions` list: ```js -resolve(contents, { +resolve.exports(pkg, '.', { unsafe: true, conditions: ["require"] }); //=> Conditions: ["default", "require"] -resolve(contents, { +resolve.exports(pkg, '.', { unsafe: true, conditions: ["browser", "require", "custom123"] }); //=> Conditions: ["default", "browser", "require", "custom123"] ``` +## Legacy Resolver + +Also included is a "legacy" method for resolving non-`"exports"` package fields. This may be used as a fallback method when for when no `"exports"` mapping is defined. In other words, it's completely optional (and tree-shakeable). ### legacy(pkg, options?) Returns: `string` or `undefined` -Also included is a "legacy" method for resolving non-`"exports"` package fields. This may be used as a fallback method when for when no `"exports"` mapping is defined. In other words, it's completely optional (and tree-shakeable). - You may customize the field priority via [`options.fields`](#optionsfields). When a field is found, its value is returned _as written_. <br> @@ -278,36 +413,39 @@ A list of fields to accept. The order of the array determines the priority/impor By default, the `legacy()` method will accept any `"module"` and/or "main" fields if they are defined. However, if both fields are defined, then "module" will be returned. ```js -const contents = { +import { legacy } from 'resolve.exports'; + +// package.json +const pkg = { "name": "...", "worker": "worker.js", "module": "module.mjs", "browser": "browser.js", "main": "main.js", -} +}; -legacy(contents); +legacy(pkg); // fields = [module, main] //=> "module.mjs" -legacy(contents, { browser: true }); +legacy(pkg, { browser: true }); // fields = [browser, module, main] //=> "browser.mjs" -legacy(contents, { +legacy(pkg, { fields: ['missing', 'worker', 'module', 'main'] }); // fields = [missing, worker, module, main] //=> "worker.js" -legacy(contents, { +legacy(pkg, { fields: ['missing', 'worker', 'module', 'main'], browser: true, }); // fields = [browser, missing, worker, module, main] //=> "browser.js" -legacy(contents, { +legacy(pkg, { fields: ['module', 'browser', 'main'], browser: true, }); diff --git a/src/index.js b/src/index.js deleted file mode 100644 index b27095d..0000000 --- a/src/index.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @param {object} exports - * @param {Set<string>} keys - */ -function loop(exports, keys) { - if (typeof exports === 'string') { - return exports; - } - - if (exports) { - let idx, tmp; - if (Array.isArray(exports)) { - for (idx=0; idx < exports.length; idx++) { - if (tmp = loop(exports[idx], keys)) return tmp; - } - } else { - for (idx in exports) { - if (keys.has(idx)) { - return loop(exports[idx], keys); - } - } - } - } -} - -/** - * @param {string} name The package name - * @param {string} entry The target entry, eg "." - * @param {number} [condition] Unmatched condition? - */ -function bail(name, entry, condition) { - throw new Error( - condition - ? `No known conditions for "${entry}" entry in "${name}" package` - : `Missing "${entry}" export in "${name}" package` - ); -} - -/** - * @param {string} name the package name - * @param {string} entry the target path/import - */ -function toName(name, entry) { - return entry === name ? '.' - : entry[0] === '.' ? entry - : entry.replace(new RegExp('^' + name + '\/'), './'); -} - -/** - * @param {object} pkg package.json contents - * @param {string} [entry] entry name or import path - * @param {object} [options] - * @param {boolean} [options.browser] - * @param {boolean} [options.require] - * @param {string[]} [options.conditions] - * @param {boolean} [options.unsafe] - */ -export function resolve(pkg, entry='.', options={}) { - let { name, exports } = pkg; - - if (exports) { - let { browser, require, unsafe, conditions=[] } = options; - - let target = toName(name, entry); - if (target !== '.' && !target.startsWith('./')) { - target = './' + target; // ".ini" => "./.ini" - } - - if (typeof exports === 'string') { - return target === '.' ? exports : bail(name, target); - } - - let allows = new Set(['default', ...conditions]); - unsafe || allows.add(require ? 'require' : 'import'); - unsafe || allows.add(browser ? 'browser' : 'node'); - - let key, m, k, kv, tmp, isSingle=false; - - for (key in exports) { - isSingle = key[0] !== '.'; - break; - } - - if (isSingle) { - return target === '.' - ? loop(exports, allows) || bail(name, target, 1) - : bail(name, target); - } - - if (tmp = exports[target]) { - return loop(tmp, allows) || bail(name, target, 1); - } - - if (target !== '.') { - for (key in exports) { - if (k && key.length < k.length) { - // do not allow "./" to match if already matched "./foo*" key - } else if (key[key.length - 1] === '/' && target.startsWith(key)) { - kv = target.substring(key.length); - k = key; - } else { - tmp = key.indexOf('*', 2); - if (!!~tmp) { - m = RegExp( - '^\.\/' + key.substring(2, tmp) + '(.*)' + key.substring(1+tmp) - ).exec(target); - - if (m && m[1]) { - kv = m[1]; - k = key; - } - } - } - } - - if (k && kv) { - // must have value - tmp = loop(exports[k], allows); - if (!tmp) return bail(name, target); - - return tmp.includes('*') - ? tmp.replace(/[*]/g, kv) - : tmp + kv; - } - } - - return bail(name, target); - } -} - -/** - * @param {object} pkg - * @param {object} [options] - * @param {string|boolean} [options.browser] - * @param {string[]} [options.fields] - */ -export function legacy(pkg, options={}) { - let i=0, value, - browser = options.browser, - fields = options.fields || ['module', 'main']; - - if (browser && !fields.includes('browser')) { - fields.unshift('browser'); - } - - for (; i < fields.length; i++) { - if (value = pkg[fields[i]]) { - if (typeof value == 'string') { - // - } else if (typeof value == 'object' && fields[i] == 'browser') { - if (typeof browser == 'string') { - value = value[browser=toName(pkg.name, browser)]; - if (value == null) return browser; - } - } else { - continue; - } - - return typeof value == 'string' - ? ('./' + value.replace(/^\.?\//, '')) - : value; - } - } -} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..eec7440 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,35 @@ +import { toEntry, walk } from './utils'; +import type * as t from 'resolve.exports'; + +export { legacy } from './legacy'; + +export function exports(pkg: t.Package, input?: string, options?: t.Options): string[] | void { + let map = pkg.exports, + k: string; + + if (map) { + if (typeof map === 'string') { + map = { '.': map }; + } else for (k in map) { + // convert {conditions} to "."={condtions} + if (k[0] !== '.') map = { '.': map }; + break; + } + + return walk(pkg.name, map, input||'.', options); + } +} + +export function imports(pkg: t.Package, input: string, options?: t.Options): string[] | void { + if (pkg.imports) return walk(pkg.name, pkg.imports, input, options); +} + +export function resolve(pkg: t.Package, input?: string, options?: t.Options): string[] | void { + // let entry = input && input !== '.' + // ? toEntry(pkg.name, input) + // : '.'; + input = toEntry(pkg.name, input || '.'); + return input[0] === '#' + ? imports(pkg, input, options) + : exports(pkg, input, options); +} diff --git a/src/legacy.ts b/src/legacy.ts new file mode 100644 index 0000000..b19328e --- /dev/null +++ b/src/legacy.ts @@ -0,0 +1,46 @@ +import * as $ from './utils'; +import type * as t from 'resolve.exports'; + +type LegacyOptions = { + fields?: string[]; + browser?: string | boolean; +} + +type BrowserObject = { + [file: string]: string | undefined; +} + +export function legacy(pkg: t.Package, options: LegacyOptions = {}): t.Path | t.Browser | void { + let i=0, + value: string | t.Browser | undefined, + browser = options.browser, + fields = options.fields || ['module', 'main'], + isSTRING = typeof browser == 'string'; + + if (browser && !fields.includes('browser')) { + fields.unshift('browser'); + // "module-a" -> "module-a" + // "./path/file.js" -> "./path/file.js" + // "foobar/path/file.js" -> "./path/file.js" + if (isSTRING) browser = $.toEntry(pkg.name, browser as string, true); + } + + for (; i < fields.length; i++) { + if (value = pkg[fields[i]]) { + if (typeof value == 'string') { + // + } else if (typeof value == 'object' && fields[i] == 'browser') { + if (isSTRING) { + value = (value as BrowserObject)[browser as string]; + if (value == null) return browser as string; + } + } else { + continue; + } + + return typeof value == 'string' + ? ('./' + value.replace(/^\.?\//, '')) as t.Path + : value; + } + } +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..36897dc --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,136 @@ +import type * as t from 'resolve.exports'; + +export type Entry = t.Exports.Entry | t.Imports.Entry; +export type Value = t.Exports.Value | t.Imports.Value; +export type Mapping = Record<Entry, Value>; + +export function throws(name: string, entry: Entry, condition?: number): never { + throw new Error( + condition + ? `No known conditions for "${entry}" specifier in "${name}" package` + : `Missing "${entry}" specifier in "${name}" package` + ); +} + +export function conditions(options: t.Options): Set<t.Condition> { + let out = new Set([ 'default', ...options.conditions || [] ]); + options.unsafe || out.add(options.require ? 'require' : 'import'); + options.unsafe || out.add(options.browser ? 'browser' : 'node'); + return out; +} + +export function walk(name: string, mapping: Mapping, input: string, options?: t.Options): string[] { + let entry = toEntry(name, input); + let c = conditions(options || {}); + + let m: Value|void = mapping[entry]; + let v: string[]|void, replace: string|void; + + if (m === void 0) { + // loop for longest key match + let match: RegExpExecArray|null; + let longest: Entry|undefined; + let tmp: string|number; + let key: Entry; + + for (key in mapping) { + if (longest && key.length < longest.length) { + // do not allow "./" to match if already matched "./foo*" key + } else if (key[key.length - 1] === '/' && entry.startsWith(key)) { + replace = entry.substring(key.length); + longest = key; + } else if (key.length > 1) { + tmp = key.indexOf('*', 2); + + if (!!~tmp) { + match = RegExp( + '^' + key.substring(0, tmp) + '(.*)' + key.substring(1+tmp) + ).exec(entry); + + if (match && match[1]) { + replace = match[1]; + longest = key; + } + } + } + } + + m = mapping[longest!]; + } + + if (!m) { + // missing export + throws(name, entry); + } + + v = loop(m, c); + + // unknown condition(s) + if (!v) throws(name, entry, 1); + if (replace) injects(v, replace); + + return v; +} + +/** @note: mutates! */ +export function injects(items: string[], value: string): void { + let i=0, len=items.length, rgx=/[*]/g, tmp: string; + + for (; i < len; i++) { + items[i] = rgx.test(tmp = items[i]) + ? tmp.replace(rgx, value) + : (tmp+value); + } +} + +/** + * @param name package name + * @param ident entry identifier + * @param externals allow non-path (external) result + * @see https://esbench.com/bench/59fa3e6799634800a0349382 + */ +export function toEntry(name: string, ident: string, externals?: false): Entry; +export function toEntry(name: string, ident: string, externals: true): Entry | string; +export function toEntry(name: string, ident: string, externals?: boolean): Entry | string { + if (name === ident || ident === '.') return '.'; + + let root = name+'/', len = root.length; + let bool = ident.slice(0, len) === root; + + let output = bool ? ident.slice(len) : ident; + if (output[0] === '#') return output as t.Imports.Entry; + + return (bool || !externals) + ? (output.slice(0,2) === './' ? output : './' + output) as t.Path + : output as string | t.Exports.Entry; +} + +export function loop(m: Value, keys: Set<t.Condition>, result?: Set<string>): string[] | void { + if (m) { + if (typeof m === 'string') { + if (result) result.add(m); + return [m]; + } + + let + idx: number | string, + arr: Set<string>; + + if (Array.isArray(m)) { + arr = result || new Set; + + for (idx=0; idx < m.length; idx++) { + loop(m[idx], keys, arr); + } + + // return if initialized set + if (!result && arr.size) { + return [...arr]; + } + } else for (idx in m) { + if (keys.has(idx)) { + return loop(m[idx], keys, result); + } + } + } +} diff --git a/test/index.ts b/test/index.ts new file mode 100644 index 0000000..e8f7f7b --- /dev/null +++ b/test/index.ts @@ -0,0 +1,1442 @@ +import * as uvu from 'uvu'; +import * as assert from 'uvu/assert'; +import * as lib from '../src/index'; + +import type * as t from 'resolve.exports'; + +type Package = t.Package; +type Entry = t.Exports.Entry | t.Imports.Entry; +type Options = t.Options; + +function pass(pkg: Package, expects: string|string[], entry?: string, options?: Options) { + let out = lib.resolve(pkg, entry, options); + if (typeof expects === 'string') { + assert.ok(Array.isArray(out)); + assert.is(out[0], expects); + assert.is(out.length, 1); + } else { + // Array | null | undefined + assert.equal(out, expects); + } +} + +function fail(pkg: Package, target: Entry, entry?: string, options?: Options) { + try { + lib.resolve(pkg, entry, options); + assert.unreachable(); + } catch (err) { + assert.instance(err, Error); + assert.is((err as Error).message, `Missing "${target}" specifier in "${pkg.name}" package`); + } +} + +function describe( + name: string, + cb: (it: uvu.Test) => void +) { + let t = uvu.suite(name); + cb(t); + t.run(); +} + +// --- + +describe('$.resolve', it => { + it('should be a function', () => { + assert.type(lib.resolve, 'function'); + }); + + it('should return nothing if no maps', () => { + let output = lib.resolve({ + "name": "foobar" + }); + assert.is(output, undefined); + }); + + it('should default to `$.exports` handler', () => { + let pkg: Package = { + "name": "foobar", + "exports": "./hello.mjs" + }; + + let output = lib.resolve(pkg); + assert.equal(output, ['./hello.mjs']); + + output = lib.resolve(pkg, '.'); + assert.equal(output, ['./hello.mjs']); + + try { + lib.resolve(pkg, './other'); + assert.unreachable(); + } catch (err) { + assert.instance(err, Error); + assert.is((err as Error).message, `Missing "./other" specifier in "foobar" package`); + } + }); + + it('should run `$.imports` if given #ident', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#foo": "./foo.mjs" + } + }; + + let output = lib.resolve(pkg, '#foo'); + assert.equal(output, ['./foo.mjs']); + + output = lib.resolve(pkg, 'foobar/#foo'); + assert.equal(output, ['./foo.mjs']); + + try { + lib.resolve(pkg, '#bar'); + assert.unreachable(); + } catch (err) { + assert.instance(err, Error); + assert.is((err as Error).message, `Missing "#bar" specifier in "foobar" package`); + } + }); + + it('should run `$.export` if given "external" identifier', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + ".": "./foo.mjs" + } + }; + + try { + lib.resolve(pkg, 'external'); + assert.unreachable(); + } catch (err) { + assert.instance(err, Error); + // IMPORTANT: treats "external" as "./external" + assert.is((err as Error).message, `Missing "./external" specifier in "foobar" package`); + } + }); + + it('should run `$.export` if given "external/subpath" identifier', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + ".": "./foo.mjs" + } + }; + + try { + lib.resolve(pkg, 'external/subpath'); + assert.unreachable(); + } catch (err) { + assert.instance(err, Error); + // IMPORTANT: treats "external/subpath" as "./external/subpath" + assert.is((err as Error).message, `Missing "./external/subpath" specifier in "foobar" package`); + } + }); +}); + +describe('$.imports', it => { + it('should be a function', () => { + assert.type(lib.imports, 'function'); + }); + + it('should return nothing if no "imports" map', () => { + let pkg: Package = { + "name": "foobar" + }; + + let output = lib.imports(pkg, '#any'); + assert.is(output, undefined); + }); + + it('imports["#foo"] = string', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#foo": "./$import", + "#bar": "module-a", + } + }; + + pass(pkg, './$import', '#foo'); + pass(pkg, './$import', 'foobar/#foo'); + + pass(pkg, 'module-a', '#bar'); + pass(pkg, 'module-a', 'foobar/#bar'); + + fail(pkg, '#other', 'foobar/#other'); + }); + + it('imports["#foo"] = object', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#foo": { + "import": "./$import", + "require": "./$require", + } + } + }; + + pass(pkg, './$import', '#foo'); + pass(pkg, './$import', 'foobar/#foo'); + + fail(pkg, '#other', 'foobar/#other'); + }); + + it('nested conditions :: subpath', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#lite": { + "node": { + "import": "./$node.import", + "require": "./$node.require" + }, + "browser": { + "import": "./$browser.import", + "require": "./$browser.require" + }, + } + } + }; + + pass(pkg, './$node.import', 'foobar/#lite'); + pass(pkg, './$node.require', 'foobar/#lite', { require: true }); + + pass(pkg, './$browser.import', 'foobar/#lite', { browser: true }); + pass(pkg, './$browser.require', 'foobar/#lite', { browser: true, require: true }); + }); + + it('nested conditions :: subpath :: inverse', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#lite": { + "import": { + "browser": "./$browser.import", + "node": "./$node.import", + }, + "require": { + "browser": "./$browser.require", + "node": "./$node.require", + } + } + } + }; + + pass(pkg, './$node.import', 'foobar/#lite'); + pass(pkg, './$node.require', 'foobar/#lite', { require: true }); + + pass(pkg, './$browser.import', 'foobar/#lite', { browser: true }); + pass(pkg, './$browser.require', 'foobar/#lite', { browser: true, require: true }); + }); + + // https://nodejs.org/api/packages.html#packages_subpath_folder_mappings + it('imports["#key/*"]', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#key/*": "./cheese/*.mjs" + } + }; + + pass(pkg, './cheese/hello.mjs', 'foobar/#key/hello'); + pass(pkg, './cheese/hello/world.mjs', '#key/hello/world'); + + // evaluate as defined, not wrong + pass(pkg, './cheese/hello.js.mjs', '#key/hello.js'); + pass(pkg, './cheese/hello.js.mjs', 'foobar/#key/hello.js'); + pass(pkg, './cheese/hello/world.js.mjs', '#key/hello/world.js'); + }); + + it('imports["#key/dir*"]', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#key/dir*": "./cheese/*.mjs" + } + }; + + pass(pkg, './cheese/test.mjs', '#key/dirtest'); + pass(pkg, './cheese/test.mjs', 'foobar/#key/dirtest'); + + pass(pkg, './cheese/test/wheel.mjs', '#key/dirtest/wheel'); + pass(pkg, './cheese/test/wheel.mjs', 'foobar/#key/dirtest/wheel'); + }); + + // https://github.com/lukeed/resolve.exports/issues/9 + it('imports["#key/dir*"] :: repeat "*" value', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#key/dir*": "./*sub/dir*/file.js" + } + }; + + pass(pkg, './testsub/dirtest/file.js', '#key/dirtest'); + pass(pkg, './testsub/dirtest/file.js', 'foobar/#key/dirtest'); + + pass(pkg, './test/innersub/dirtest/inner/file.js', '#key/dirtest/inner'); + pass(pkg, './test/innersub/dirtest/inner/file.js', 'foobar/#key/dirtest/inner'); + }); + + /** + * @deprecated Documentation-only deprecation in Node 14.13 + * @deprecated Runtime deprecation in Node 16.0 + * @removed Removed in Node 18.0 + * @see https://nodejs.org/docs/latest-v16.x/api/packages.html#subpath-folder-mappings + */ + it('imports["#features/"]', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#features/": "./features/" + } + }; + + pass(pkg, './features/', '#features/'); + pass(pkg, './features/', 'foobar/#features/'); + + pass(pkg, './features/hello.js', 'foobar/#features/hello.js'); + + fail(pkg, '#features', '#features'); + fail(pkg, '#features', 'foobar/#features'); + }); + + it('imports["#features/"] :: conditions', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#features/": { + "browser": { + "import": "./browser.import/", + "require": "./browser.require/", + }, + "import": "./import/", + "require": "./require/", + }, + } + }; + + // import + pass(pkg, './import/', '#features/'); + pass(pkg, './import/', 'foobar/#features/'); + + pass(pkg, './import/hello.js', '#features/hello.js'); + pass(pkg, './import/hello.js', 'foobar/#features/hello.js'); + + // require + pass(pkg, './require/', '#features/', { require: true }); + pass(pkg, './require/', 'foobar/#features/', { require: true }); + + pass(pkg, './require/hello.js', '#features/hello.js', { require: true }); + pass(pkg, './require/hello.js', 'foobar/#features/hello.js', { require: true }); + + // require + browser + pass(pkg, './browser.require/', '#features/', { browser: true, require: true }); + pass(pkg, './browser.require/', 'foobar/#features/', { browser: true, require: true }); + + pass(pkg, './browser.require/hello.js', '#features/hello.js', { browser: true, require: true }); + pass(pkg, './browser.require/hello.js', 'foobar/#features/hello.js', { browser: true, require: true }); + }); + + // https://nodejs.org/api/packages.html#packages_subpath_folder_mappings + it('imports["#features/*"]', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#features/*": "./features/*.js", + } + }; + + fail(pkg, '#features', '#features'); + fail(pkg, '#features', 'foobar/#features'); + + fail(pkg, '#features/', '#features/'); + fail(pkg, '#features/', 'foobar/#features/'); + + pass(pkg, './features/a.js', 'foobar/#features/a'); + pass(pkg, './features/ab.js', 'foobar/#features/ab'); + pass(pkg, './features/abc.js', 'foobar/#features/abc'); + + pass(pkg, './features/hello.js', 'foobar/#features/hello'); + pass(pkg, './features/foo/bar.js', 'foobar/#features/foo/bar'); + + // Valid: Pattern trailers allow any exact substrings to be matched + pass(pkg, './features/hello.js.js', 'foobar/#features/hello.js'); + pass(pkg, './features/foo/bar.js.js', 'foobar/#features/foo/bar.js'); + }); + + // https://nodejs.org/api/packages.html#packages_subpath_folder_mappings + it('exports["#fooba*"] :: with "#foo*" key', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#fooba*": "./features/*.js", + "#foo*": "./" + } + }; + + pass(pkg, './features/r.js', '#foobar'); + pass(pkg, './features/r.js', 'foobar/#foobar'); + + pass(pkg, './features/r/hello.js', 'foobar/#foobar/hello'); + pass(pkg, './features/r/foo/bar.js', 'foobar/#foobar/foo/bar'); + + // Valid: Pattern trailers allow any exact substrings to be matched + pass(pkg, './features/r/hello.js.js', 'foobar/#foobar/hello.js'); + pass(pkg, './features/r/foo/bar.js.js', 'foobar/#foobar/foo/bar.js'); + }); + + // https://github.com/lukeed/resolve.exports/issues/7 + it('imports["#fooba*"] :: with "#foo*" key first', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#foo*": "./", + "#fooba*": "./features/*.js" + } + }; + + pass(pkg, './features/r.js', '#foobar'); + pass(pkg, './features/r.js', 'foobar/#foobar'); + + pass(pkg, './features/r/hello.js', 'foobar/#foobar/hello'); + pass(pkg, './features/r/foo/bar.js', 'foobar/#foobar/foo/bar'); + + // Valid: Pattern trailers allow any exact substrings to be matched + pass(pkg, './features/r/hello.js.js', 'foobar/#foobar/hello.js'); + pass(pkg, './features/r/foo/bar.js.js', 'foobar/#foobar/foo/bar.js'); + }); + + // https://github.com/lukeed/resolve.exports/issues/16 + it('imports["#features/*"] :: with `null` internals', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#features/*": "./src/features/*.js", + "#features/internal/*": null + } + }; + + pass(pkg, './src/features/hello.js', '#features/hello'); + pass(pkg, './src/features/hello.js', 'foobar/#features/hello'); + + pass(pkg, './src/features/foo/bar.js', '#features/foo/bar'); + pass(pkg, './src/features/foo/bar.js', 'foobar/#features/foo/bar'); + + // TODO? Native throws `ERR_PACKAGE_PATH_NOT_EXPORTED` + // Currently throwing `Missing "%s" specifier in "$s" package` + fail(pkg, '#features/internal/hello', '#features/internal/hello'); + fail(pkg, '#features/internal/foo/bar', '#features/internal/foo/bar'); + }); + + // https://github.com/lukeed/resolve.exports/issues/16 + it('imports["#features/*"] :: with `null` internals first', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#features/internal/*": null, + "#features/*": "./src/features/*.js", + } + }; + + pass(pkg, './src/features/hello.js', '#features/hello'); + pass(pkg, './src/features/hello.js', 'foobar/#features/hello'); + + pass(pkg, './src/features/foo/bar.js', '#features/foo/bar'); + pass(pkg, './src/features/foo/bar.js', 'foobar/#features/foo/bar'); + + // TODO? Native throws `ERR_PACKAGE_PATH_NOT_EXPORTED` + // Currently throwing `Missing "%s" specifier in "$s" package` + fail(pkg, '#features/internal/hello', '#features/internal/hello'); + fail(pkg, '#features/internal/foo/bar', '#features/internal/foo/bar'); + }); + + // https://nodejs.org/docs/latest-v18.x/api/packages.html#package-entry-points + it('imports["#features/*"] :: with "#features/*.js" key', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#features/*": "./features/*.js", + "#features/*.js": "./features/*.js", + } + }; + + fail(pkg, '#features', '#features'); + fail(pkg, '#features', 'foobar/#features'); + + fail(pkg, '#features/', '#features/'); + fail(pkg, '#features/', 'foobar/#features/'); + + pass(pkg, './features/a.js', 'foobar/#features/a'); + pass(pkg, './features/ab.js', 'foobar/#features/ab'); + pass(pkg, './features/abc.js', 'foobar/#features/abc'); + + pass(pkg, './features/hello.js', 'foobar/#features/hello'); + pass(pkg, './features/hello.js', 'foobar/#features/hello.js'); + + pass(pkg, './features/foo/bar.js', 'foobar/#features/foo/bar'); + pass(pkg, './features/foo/bar.js', 'foobar/#features/foo/bar.js'); + }); + + it('imports["#features/*"] :: conditions', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#features/*": { + "browser": { + "import": "./browser.import/*.mjs", + "require": "./browser.require/*.js", + }, + "import": "./import/*.mjs", + "require": "./require/*.js", + }, + } + }; + + // import + fail(pkg, '#features/', '#features/'); // no file + fail(pkg, '#features/', 'foobar/#features/'); // no file + + pass(pkg, './import/hello.mjs', '#features/hello'); + pass(pkg, './import/hello.mjs', 'foobar/#features/hello'); + + // require + fail(pkg, '#features/', '#features/', { require: true }); // no file + fail(pkg, '#features/', 'foobar/#features/', { require: true }); // no file + + pass(pkg, './require/hello.js', '#features/hello', { require: true }); + pass(pkg, './require/hello.js', 'foobar/#features/hello', { require: true }); + + // require + browser + fail(pkg, '#features/', '#features/', { browser: true, require: true }); // no file + fail(pkg, '#features/', 'foobar/#features/', { browser: true, require: true }); // no file + + pass(pkg, './browser.require/hello.js', '#features/hello', { browser: true, require: true }); + pass(pkg, './browser.require/hello.js', 'foobar/#features/hello', { browser: true, require: true }); + }); + + it('should handle mixed path/conditions', () => { + let pkg: Package = { + "name": "foobar", + "imports": { + "#foo": [ + { + "require": "./$foo.require" + }, + "./$foo.string" + ] + } + }; + + // TODO? if len==1 then single? + pass(pkg, ['./$foo.string'], '#foo'); + pass(pkg, ['./$foo.string'], 'foobar/#foo'); + + pass(pkg, ['./$foo.require', './$foo.string'], '#foo', { require: true }); + pass(pkg, ['./$foo.require', './$foo.string'], 'foobar/#foo', { require: true }); + }); +}); + +describe('$.exports', it => { + it('should be a function', () => { + assert.type(lib.exports, 'function'); + }); + + it('should return nothing if no "exports" map', () => { + let pkg: Package = { + "name": "foobar" + }; + + let output = lib.exports(pkg, '#any'); + assert.is(output, undefined); + }); + + it('should default to "." target input', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + ".": "./hello.mjs" + } + }; + + let output = lib.exports(pkg); + assert.equal(output, ['./hello.mjs']); + + output = lib.exports(pkg, '.'); + assert.equal(output, ['./hello.mjs']); + + output = lib.exports(pkg, 'foobar'); + assert.equal(output, ['./hello.mjs']); + }); + + it('exports=string', () => { + let pkg: Package = { + "name": "foobar", + "exports": "./$string", + }; + + pass(pkg, './$string'); + pass(pkg, './$string', '.'); + pass(pkg, './$string', 'foobar'); + + fail(pkg, './other', 'other'); + fail(pkg, './other', 'foobar/other'); + fail(pkg, './hello', './hello'); + }); + + it('exports = { self }', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "import": "./$import", + "require": "./$require", + } + }; + + pass(pkg, './$import'); + pass(pkg, './$import', '.'); + pass(pkg, './$import', 'foobar'); + + fail(pkg, './other', 'other'); + fail(pkg, './other', 'foobar/other'); + fail(pkg, './hello', './hello'); + }); + + it('exports["."] = string', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + ".": "./$self", + } + }; + + pass(pkg, './$self'); + pass(pkg, './$self', '.'); + pass(pkg, './$self', 'foobar'); + + fail(pkg, './other', 'other'); + fail(pkg, './other', 'foobar/other'); + fail(pkg, './hello', './hello'); + }); + + it('exports["."] = object', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + ".": { + "import": "./$import", + "require": "./$require", + } + } + }; + + pass(pkg, './$import'); + pass(pkg, './$import', '.'); + pass(pkg, './$import', 'foobar'); + + fail(pkg, './other', 'other'); + fail(pkg, './other', 'foobar/other'); + fail(pkg, './hello', './hello'); + }); + + it('exports["./foo"] = string', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./foo": "./$import", + } + }; + + pass(pkg, './$import', './foo'); + pass(pkg, './$import', 'foobar/foo'); + + fail(pkg, '.'); + fail(pkg, '.', 'foobar'); + fail(pkg, './other', 'foobar/other'); + }); + + it('exports["./foo"] = object', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./foo": { + "import": "./$import", + "require": "./$require", + } + } + }; + + pass(pkg, './$import', './foo'); + pass(pkg, './$import', 'foobar/foo'); + + fail(pkg, '.'); + fail(pkg, '.', 'foobar'); + fail(pkg, './other', 'foobar/other'); + }); + + // https://nodejs.org/api/packages.html#packages_nested_conditions + it('nested conditions', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "node": { + "import": "./$node.import", + "require": "./$node.require" + }, + "default": "./$default", + } + }; + + pass(pkg, './$node.import'); + pass(pkg, './$node.import', 'foobar'); + + // browser => no "node" key + pass(pkg, './$default', '.', { browser: true }); + pass(pkg, './$default', 'foobar', { browser: true }); + + fail(pkg, './hello', './hello'); + fail(pkg, './other', 'foobar/other'); + fail(pkg, './other', 'other'); + }); + + it('nested conditions :: subpath', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./lite": { + "node": { + "import": "./$node.import", + "require": "./$node.require" + }, + "browser": { + "import": "./$browser.import", + "require": "./$browser.require" + }, + } + } + }; + + pass(pkg, './$node.import', 'foobar/lite'); + pass(pkg, './$node.require', 'foobar/lite', { require: true }); + + pass(pkg, './$browser.import', 'foobar/lite', { browser: true }); + pass(pkg, './$browser.require', 'foobar/lite', { browser: true, require: true }); + }); + + it('nested conditions :: subpath :: inverse', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./lite": { + "import": { + "browser": "./$browser.import", + "node": "./$node.import", + }, + "require": { + "browser": "./$browser.require", + "node": "./$node.require", + } + } + } + }; + + pass(pkg, './$node.import', 'foobar/lite'); + pass(pkg, './$node.require', 'foobar/lite', { require: true }); + + pass(pkg, './$browser.import', 'foobar/lite', { browser: true }); + pass(pkg, './$browser.require', 'foobar/lite', { browser: true, require: true }); + }); + + // https://nodejs.org/api/packages.html#packages_subpath_folder_mappings + it('exports["./"]', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + ".": { + "require": "./$require", + "import": "./$import" + }, + "./package.json": "./package.json", + "./": "./" + } + }; + + pass(pkg, './$import'); + pass(pkg, './$import', 'foobar'); + pass(pkg, './$require', 'foobar', { require: true }); + + pass(pkg, './package.json', 'package.json'); + pass(pkg, './package.json', 'foobar/package.json'); + pass(pkg, './package.json', './package.json'); + + // "loose" / everything exposed + pass(pkg, './hello.js', 'hello.js'); + pass(pkg, './hello.js', 'foobar/hello.js'); + pass(pkg, './hello/world.js', './hello/world.js'); + }); + + it('exports["./"] :: w/o "." key', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./package.json": "./package.json", + "./": "./" + } + }; + + fail(pkg, '.', "."); + fail(pkg, '.', "foobar"); + + pass(pkg, './package.json', 'package.json'); + pass(pkg, './package.json', 'foobar/package.json'); + pass(pkg, './package.json', './package.json'); + + // "loose" / everything exposed + pass(pkg, './hello.js', 'hello.js'); + pass(pkg, './hello.js', 'foobar/hello.js'); + pass(pkg, './hello/world.js', './hello/world.js'); + }); + + // https://nodejs.org/api/packages.html#packages_subpath_folder_mappings + it('exports["./*"]', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./*": "./cheese/*.mjs" + } + }; + + fail(pkg, '.', "."); + fail(pkg, '.', "foobar"); + + pass(pkg, './cheese/hello.mjs', 'hello'); + pass(pkg, './cheese/hello.mjs', 'foobar/hello'); + pass(pkg, './cheese/hello/world.mjs', './hello/world'); + + // evaluate as defined, not wrong + pass(pkg, './cheese/hello.js.mjs', 'hello.js'); + pass(pkg, './cheese/hello.js.mjs', 'foobar/hello.js'); + pass(pkg, './cheese/hello/world.js.mjs', './hello/world.js'); + }); + + it('exports["./dir*"]', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./dir*": "./cheese/*.mjs" + } + }; + + fail(pkg, '.', "."); + fail(pkg, '.', "foobar"); + + pass(pkg, './cheese/test.mjs', 'dirtest'); + pass(pkg, './cheese/test.mjs', 'foobar/dirtest'); + + pass(pkg, './cheese/test/wheel.mjs', 'dirtest/wheel'); + pass(pkg, './cheese/test/wheel.mjs', 'foobar/dirtest/wheel'); + }); + + // https://github.com/lukeed/resolve.exports/issues/9 + it('exports["./dir*"] :: repeat "*" value', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./dir*": "./*sub/dir*/file.js" + } + }; + + fail(pkg, '.', "."); + fail(pkg, '.', "foobar"); + + pass(pkg, './testsub/dirtest/file.js', 'dirtest'); + pass(pkg, './testsub/dirtest/file.js', 'foobar/dirtest'); + + pass(pkg, './test/innersub/dirtest/inner/file.js', 'dirtest/inner'); + pass(pkg, './test/innersub/dirtest/inner/file.js', 'foobar/dirtest/inner'); + }); + + it('exports["./dir*"] :: share "name" start', () => { + let pkg: Package = { + "name": "director", + "exports": { + "./dir*": "./*sub/dir*/file.js" + } + }; + + fail(pkg, '.', "."); + fail(pkg, '.', "director"); + + pass(pkg, './testsub/dirtest/file.js', 'dirtest'); + pass(pkg, './testsub/dirtest/file.js', 'director/dirtest'); + + pass(pkg, './test/innersub/dirtest/inner/file.js', 'dirtest/inner'); + pass(pkg, './test/innersub/dirtest/inner/file.js', 'director/dirtest/inner'); + }); + + /** + * @deprecated Documentation-only deprecation in Node 14.13 + * @deprecated Runtime deprecation in Node 16.0 + * @removed Removed in Node 18.0 + * @see https://nodejs.org/docs/latest-v16.x/api/packages.html#subpath-folder-mappings + */ + it('exports["./features/"]', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./features/": "./features/" + } + }; + + pass(pkg, './features/', 'features/'); + pass(pkg, './features/', 'foobar/features/'); + + pass(pkg, './features/hello.js', 'foobar/features/hello.js'); + + fail(pkg, './features', 'features'); + fail(pkg, './features', 'foobar/features'); + + fail(pkg, './package.json', 'package.json'); + fail(pkg, './package.json', 'foobar/package.json'); + fail(pkg, './package.json', './package.json'); + }); + + // https://nodejs.org/api/packages.html#packages_subpath_folder_mappings + it('exports["./features/"] :: with "./" key', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./features/": "./features/", + "./package.json": "./package.json", + "./": "./" + } + }; + + pass(pkg, './features', 'features'); // via "./" + pass(pkg, './features', 'foobar/features'); // via "./" + + pass(pkg, './features/', 'features/'); // via "./features/" + pass(pkg, './features/', 'foobar/features/'); // via "./features/" + + pass(pkg, './features/hello.js', 'foobar/features/hello.js'); + + pass(pkg, './package.json', 'package.json'); + pass(pkg, './package.json', 'foobar/package.json'); + pass(pkg, './package.json', './package.json'); + + // Does NOT hit "./" (match Node) + fail(pkg, '.', '.'); + fail(pkg, '.', 'foobar'); + }); + + it('exports["./features/"] :: conditions', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./features/": { + "browser": { + "import": "./browser.import/", + "require": "./browser.require/", + }, + "import": "./import/", + "require": "./require/", + }, + } + }; + + // import + pass(pkg, './import/', 'features/'); + pass(pkg, './import/', 'foobar/features/'); + + pass(pkg, './import/hello.js', './features/hello.js'); + pass(pkg, './import/hello.js', 'foobar/features/hello.js'); + + // require + pass(pkg, './require/', 'features/', { require: true }); + pass(pkg, './require/', 'foobar/features/', { require: true }); + + pass(pkg, './require/hello.js', './features/hello.js', { require: true }); + pass(pkg, './require/hello.js', 'foobar/features/hello.js', { require: true }); + + // require + browser + pass(pkg, './browser.require/', 'features/', { browser: true, require: true }); + pass(pkg, './browser.require/', 'foobar/features/', { browser: true, require: true }); + + pass(pkg, './browser.require/hello.js', './features/hello.js', { browser: true, require: true }); + pass(pkg, './browser.require/hello.js', 'foobar/features/hello.js', { browser: true, require: true }); + }); + + // https://nodejs.org/api/packages.html#packages_subpath_folder_mappings + it('exports["./features/*"]', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./features/*": "./features/*.js", + } + }; + + fail(pkg, './features', 'features'); + fail(pkg, './features', 'foobar/features'); + + fail(pkg, './features/', 'features/'); + fail(pkg, './features/', 'foobar/features/'); + + pass(pkg, './features/a.js', 'foobar/features/a'); + pass(pkg, './features/ab.js', 'foobar/features/ab'); + pass(pkg, './features/abc.js', 'foobar/features/abc'); + + pass(pkg, './features/hello.js', 'foobar/features/hello'); + pass(pkg, './features/foo/bar.js', 'foobar/features/foo/bar'); + + // Valid: Pattern trailers allow any exact substrings to be matched + pass(pkg, './features/hello.js.js', 'foobar/features/hello.js'); + pass(pkg, './features/foo/bar.js.js', 'foobar/features/foo/bar.js'); + + fail(pkg, './package.json', 'package.json'); + fail(pkg, './package.json', 'foobar/package.json'); + fail(pkg, './package.json', './package.json'); + }); + + // https://nodejs.org/api/packages.html#packages_subpath_folder_mappings + it('exports["./features/*"] :: with "./" key', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./features/*": "./features/*.js", + "./": "./" + } + }; + + pass(pkg, './features', 'features'); // via "./" + pass(pkg, './features', 'foobar/features'); // via "./" + + pass(pkg, './features/', 'features/'); // via "./" + pass(pkg, './features/', 'foobar/features/'); // via "./" + + pass(pkg, './features/hello.js', 'foobar/features/hello'); + pass(pkg, './features/foo/bar.js', 'foobar/features/foo/bar'); + + // Valid: Pattern trailers allow any exact substrings to be matched + pass(pkg, './features/hello.js.js', 'foobar/features/hello.js'); + pass(pkg, './features/foo/bar.js.js', 'foobar/features/foo/bar.js'); + + pass(pkg, './package.json', 'package.json'); + pass(pkg, './package.json', 'foobar/package.json'); + pass(pkg, './package.json', './package.json'); + + // Does NOT hit "./" (match Node) + fail(pkg, '.', '.'); + fail(pkg, '.', 'foobar'); + }); + + // https://github.com/lukeed/resolve.exports/issues/7 + it('exports["./features/*"] :: with "./" key first', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./": "./", + "./features/*": "./features/*.js" + } + }; + + pass(pkg, './features', 'features'); // via "./" + pass(pkg, './features', 'foobar/features'); // via "./" + + pass(pkg, './features/', 'features/'); // via "./" + pass(pkg, './features/', 'foobar/features/'); // via "./" + + pass(pkg, './features/hello.js', 'foobar/features/hello'); + pass(pkg, './features/foo/bar.js', 'foobar/features/foo/bar'); + + // Valid: Pattern trailers allow any exact substrings to be matched + pass(pkg, './features/hello.js.js', 'foobar/features/hello.js'); + pass(pkg, './features/foo/bar.js.js', 'foobar/features/foo/bar.js'); + + pass(pkg, './package.json', 'package.json'); + pass(pkg, './package.json', 'foobar/package.json'); + pass(pkg, './package.json', './package.json'); + + // Does NOT hit "./" (match Node) + fail(pkg, '.', '.'); + fail(pkg, '.', 'foobar'); + }); + + // https://github.com/lukeed/resolve.exports/issues/16 + it('exports["./features/*"] :: with `null` internals', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./features/*": "./src/features/*.js", + "./features/internal/*": null + } + }; + + pass(pkg, './src/features/hello.js', 'features/hello'); + pass(pkg, './src/features/hello.js', 'foobar/features/hello'); + + pass(pkg, './src/features/foo/bar.js', 'features/foo/bar'); + pass(pkg, './src/features/foo/bar.js', 'foobar/features/foo/bar'); + + // TODO? Native throws `ERR_PACKAGE_PATH_NOT_EXPORTED` + // Currently throwing `Missing "%s" specifier in "$s" package` + fail(pkg, './features/internal/hello', 'features/internal/hello'); + fail(pkg, './features/internal/foo/bar', 'features/internal/foo/bar'); + }); + + // https://github.com/lukeed/resolve.exports/issues/16 + it('exports["./features/*"] :: with `null` internals first', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./features/internal/*": null, + "./features/*": "./src/features/*.js", + } + }; + + pass(pkg, './src/features/hello.js', 'features/hello'); + pass(pkg, './src/features/hello.js', 'foobar/features/hello'); + + pass(pkg, './src/features/foo/bar.js', 'features/foo/bar'); + pass(pkg, './src/features/foo/bar.js', 'foobar/features/foo/bar'); + + // TODO? Native throws `ERR_PACKAGE_PATH_NOT_EXPORTED` + // Currently throwing `Missing "%s" specifier in "$s" package` + fail(pkg, './features/internal/hello', 'features/internal/hello'); + fail(pkg, './features/internal/foo/bar', 'features/internal/foo/bar'); + }); + + // https://nodejs.org/docs/latest-v18.x/api/packages.html#package-entry-points + it('exports["./features/*"] :: with "./features/*.js" key', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./features/*": "./features/*.js", + "./features/*.js": "./features/*.js", + } + }; + + fail(pkg, './features', 'features'); + fail(pkg, './features', 'foobar/features'); + + fail(pkg, './features/', 'features/'); + fail(pkg, './features/', 'foobar/features/'); + + pass(pkg, './features/a.js', 'foobar/features/a'); + pass(pkg, './features/ab.js', 'foobar/features/ab'); + pass(pkg, './features/abc.js', 'foobar/features/abc'); + + pass(pkg, './features/hello.js', 'foobar/features/hello'); + pass(pkg, './features/hello.js', 'foobar/features/hello.js'); + + pass(pkg, './features/foo/bar.js', 'foobar/features/foo/bar'); + pass(pkg, './features/foo/bar.js', 'foobar/features/foo/bar.js'); + + fail(pkg, './package.json', 'package.json'); + fail(pkg, './package.json', 'foobar/package.json'); + fail(pkg, './package.json', './package.json'); + }); + + it('exports["./features/*"] :: conditions', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + "./features/*": { + "browser": { + "import": "./browser.import/*.mjs", + "require": "./browser.require/*.js", + }, + "import": "./import/*.mjs", + "require": "./require/*.js", + }, + } + }; + + // import + fail(pkg, './features/', 'features/'); // no file + fail(pkg, './features/', 'foobar/features/'); // no file + + pass(pkg, './import/hello.mjs', './features/hello'); + pass(pkg, './import/hello.mjs', 'foobar/features/hello'); + + // require + fail(pkg, './features/', 'features/', { require: true }); // no file + fail(pkg, './features/', 'foobar/features/', { require: true }); // no file + + pass(pkg, './require/hello.js', './features/hello', { require: true }); + pass(pkg, './require/hello.js', 'foobar/features/hello', { require: true }); + + // require + browser + fail(pkg, './features/', 'features/', { browser: true, require: true }); // no file + fail(pkg, './features/', 'foobar/features/', { browser: true, require: true }); // no file + + pass(pkg, './browser.require/hello.js', './features/hello', { browser: true, require: true }); + pass(pkg, './browser.require/hello.js', 'foobar/features/hello', { browser: true, require: true }); + }); + + it('should handle mixed path/conditions', () => { + let pkg: Package = { + "name": "foobar", + "exports": { + ".": [ + { + "import": "./$root.import", + }, + "./$root.string" + ], + "./foo": [ + { + "require": "./$foo.require" + }, + "./$foo.string" + ] + } + } + + pass(pkg, ['./$root.import', './$root.string']); + pass(pkg, ['./$root.import', './$root.string'], 'foobar'); + + // TODO? if len==1 then single? + pass(pkg, ['./$foo.string'], 'foo'); + pass(pkg, ['./$foo.string'], 'foobar/foo'); + pass(pkg, ['./$foo.string'], './foo'); + + pass(pkg, ['./$foo.require', './$foo.string'], 'foo', { require: true }); + pass(pkg, ['./$foo.require', './$foo.string'], 'foobar/foo', { require: true }); + pass(pkg, ['./$foo.require', './$foo.string'], './foo', { require: true }); + }); + + it('should handle file with leading dot', () => { + let pkg: Package = { + "version": "2.41.0", + "name": "aws-cdk-lib", + "exports": { + ".": "./index.js", + "./package.json": "./package.json", + "./.jsii": "./.jsii", + "./.warnings.jsii.js": "./.warnings.jsii.js", + "./alexa-ask": "./alexa-ask/index.js" + } + }; + + pass(pkg, "./.warnings.jsii.js", ".warnings.jsii.js"); + }); +}); + +describe('options.requires', it => { + let pkg: Package = { + "name": "r", + "exports": { + "require": "./$require", + "import": "./$import", + } + }; + + it('should ignore "require" keys by default', () => { + pass(pkg, './$import'); + }); + + it('should use "require" key when defined first', () => { + pass(pkg, './$require', '.', { require: true }); + }); + + it('should ignore "import" key when enabled', () => { + let pkg: Package = { + "name": "r", + "exports": { + "import": "./$import", + "require": "./$require", + } + }; + pass(pkg, './$require', '.', { require: true }); + pass(pkg, './$import', '.'); + }); + + it('should match "default" if "require" is after', () => { + let pkg: Package = { + "name": "r", + "exports": { + "default": "./$default", + "require": "./$require", + } + }; + pass(pkg, './$default', '.', { require: true }); + }); +}); + +describe('options.browser', it => { + let pkg: Package = { + "name": "b", + "exports": { + "browser": "./$browser", + "node": "./$node", + } + }; + + it('should ignore "browser" keys by default', () => { + pass(pkg, './$node'); + }); + + it('should use "browser" key when defined first', () => { + pass(pkg, './$browser', '.', { browser: true }); + }); + + it('should ignore "node" key when enabled', () => { + let pkg: Package = { + "name": "b", + "exports": { + "node": "./$node", + "import": "./$import", + "browser": "./$browser", + } + }; + + // import defined before browser + pass(pkg, './$import', '.', { browser: true }); + }); +}); + +describe('options.conditions', it => { + const pkg: Package = { + "name": "c", + "exports": { + "production": "./$prod", + "development": "./$dev", + "default": "./$default", + } + }; + + it('should ignore unknown conditions by default', () => { + pass(pkg, './$default'); + }); + + it('should recognize custom field(s) when specified', () => { + pass(pkg, './$dev', '.', { + conditions: ['development'] + }); + + pass(pkg, './$prod', '.', { + conditions: ['development', 'production'] + }); + }); + + it('should throw an error if no known conditions', () => { + let ctx: Package = { + "name": "hello", + "exports": { + // @ts-ignore + ...pkg.exports + }, + }; + + // @ts-ignore + delete ctx.exports.default; + + try { + lib.resolve(ctx); + assert.unreachable(); + } catch (err) { + assert.instance(err, Error); + assert.is((err as Error).message, `No known conditions for "." specifier in "hello" package`); + } + }); +}); + +describe('options.unsafe', it => { + let pkg: Package = { + "name": "unsafe", + "exports": { + ".": { + "production": "./$prod", + "development": "./$dev", + "default": "./$default", + }, + "./spec/type": { + "import": "./$import", + "require": "./$require", + "default": "./$default" + }, + "./spec/env": { + "worker": { + "default": "./$worker" + }, + "browser": "./$browser", + "node": "./$node", + "default": "./$default" + } + } + }; + + it('should ignore unknown conditions by default', () => { + pass(pkg, './$default', '.', { + unsafe: true, + }); + }); + + it('should ignore "import" and "require" conditions by default', () => { + pass(pkg, './$default', './spec/type', { + unsafe: true, + }); + + pass(pkg, './$default', './spec/type', { + unsafe: true, + require: true, + }); + }); + + it('should ignore "node" and "browser" conditions by default', () => { + pass(pkg, './$default', './spec/type', { + unsafe: true, + }); + + pass(pkg, './$default', './spec/type', { + unsafe: true, + browser: true, + }); + }); + + it('should respect/accept any custom condition(s) when specified', () => { + // root, dev only + pass(pkg, './$dev', '.', { + unsafe: true, + conditions: ['development'] + }); + + // root, defined order + pass(pkg, './$prod', '.', { + unsafe: true, + conditions: ['development', 'production'] + }); + + // import vs require, defined order + pass(pkg, './$require', './spec/type', { + unsafe: true, + conditions: ['require'] + }); + + // import vs require, defined order + pass(pkg, './$import', './spec/type', { + unsafe: true, + conditions: ['import', 'require'] + }); + + // import vs require, defined order + pass(pkg, './$node', './spec/env', { + unsafe: true, + conditions: ['node'] + }); + + // import vs require, defined order + pass(pkg, './$browser', './spec/env', { + unsafe: true, + conditions: ['browser', 'node'] + }); + + // import vs require, defined order + pass(pkg, './$worker', './spec/env', { + unsafe: true, + conditions: ['browser', 'node', 'worker'] + }); + }); +}); diff --git a/test/legacy.js b/test/legacy.js deleted file mode 100644 index 224c539..0000000 --- a/test/legacy.js +++ /dev/null @@ -1,223 +0,0 @@ -import { suite } from 'uvu'; -import * as assert from 'uvu/assert'; -import * as $exports from '../src'; - -const legacy = suite('$.legacy'); - -legacy('should be a function', () => { - assert.type($exports.legacy, 'function'); -}); - -legacy('should prefer "module" > "main" entry', () => { - let pkg = { - "name": "foobar", - "module": "build/module.js", - "main": "build/main.js", - }; - - let output = $exports.legacy(pkg); - assert.is(output, './build/module.js'); -}); - -legacy('should read "main" field', () => { - let pkg = { - "name": "foobar", - "main": "build/main.js", - }; - - let output = $exports.legacy(pkg); - assert.is(output, './build/main.js'); -}); - -legacy('should return nothing when no fields', () => { - let pkg = { - "name": "foobar" - }; - - let output = $exports.legacy(pkg); - assert.is(output, undefined); -}); - -legacy('should ignore boolean-type field values', () => { - let pkg = { - "module": true, - "main": "main.js" - }; - - let output = $exports.legacy(pkg); - assert.is(output, './main.js'); -}); - -legacy.run(); - -// --- - -const fields = suite('options.fields', { - "name": "foobar", - "module": "build/module.js", - "browser": "build/browser.js", - "custom": "build/custom.js", - "main": "build/main.js", -}); - -fields('should customize field search order', pkg => { - let output = $exports.legacy(pkg); - assert.is(output, './build/module.js', 'default: module'); - - output = $exports.legacy(pkg, { fields: ['main'] }); - assert.is(output, './build/main.js', 'custom: main only'); - - output = $exports.legacy(pkg, { fields: ['custom', 'main', 'module'] }); - assert.is(output, './build/custom.js', 'custom: custom > main > module'); -}); - -fields('should return first *resolved* field', pkg => { - let output = $exports.legacy(pkg, { - fields: ['howdy', 'partner', 'hello', 'world', 'main'] - }); - - assert.is(output, './build/main.js'); -}); - -fields.run(); - -// --- - -const browser = suite('options.browser', { - "name": "foobar", - "module": "build/module.js", - "browser": "build/browser.js", - "unpkg": "build/unpkg.js", - "main": "build/main.js", -}); - -browser('should prioritize "browser" field when defined', pkg => { - let output = $exports.legacy(pkg); - assert.is(output, './build/module.js'); - - output = $exports.legacy(pkg, { browser: true }); - assert.is(output, './build/browser.js'); -}); - -browser('should respect existing "browser" order in custom fields', pkg => { - let output = $exports.legacy(pkg, { - fields: ['main', 'browser'], - browser: true, - }); - - assert.is(output, './build/main.js'); -}); - -// https://github.com/defunctzombie/package-browser-field-spec -browser('should resolve object format', () => { - let pkg = { - "name": "foobar", - "browser": { - "module-a": "./shims/module-a.js", - "./server/only.js": "./shims/client-only.js" - } - }; - - assert.is( - $exports.legacy(pkg, { browser: 'module-a' }), - './shims/module-a.js' - ); - - assert.is( - $exports.legacy(pkg, { browser: './server/only.js' }), - './shims/client-only.js' - ); - - assert.is( - $exports.legacy(pkg, { browser: 'foobar/server/only.js' }), - './shims/client-only.js' - ); -}); - -browser('should allow object format to "ignore" modules/files :: string', () => { - let pkg = { - "name": "foobar", - "browser": { - "module-a": false, - "./foo.js": false, - } - }; - - assert.is( - $exports.legacy(pkg, { browser: 'module-a' }), - false - ); - - assert.is( - $exports.legacy(pkg, { browser: './foo.js' }), - false - ); - - assert.is( - $exports.legacy(pkg, { browser: 'foobar/foo.js' }), - false - ); -}); - -browser('should return the `browser` string (entry) if no custom mapping :: string', () => { - let pkg = { - "name": "foobar", - "browser": { - // - } - }; - - assert.is( - $exports.legacy(pkg, { - browser: './hello.js' - }), - './hello.js' - ); - - assert.is( - $exports.legacy(pkg, { - browser: 'foobar/hello.js' - }), - './hello.js' - ); -}); - -browser('should return the full "browser" object :: true', () => { - let pkg = { - "name": "foobar", - "browser": { - "./other.js": "./world.js" - } - }; - - let output = $exports.legacy(pkg, { - browser: true - }); - - assert.equal(output, pkg.browser); -}); - -browser('still ensures string output is made relative', () => { - let pkg = { - "name": "foobar", - "browser": { - "./foo.js": 'bar.js', - } - }; - - assert.is( - $exports.legacy(pkg, { - browser: './foo.js' - }), - './bar.js' - ); - - assert.is( - $exports.legacy(pkg, { - browser: 'foobar/foo.js' - }), - './bar.js' - ); -}); - -browser.run(); diff --git a/test/legacy.ts b/test/legacy.ts new file mode 100644 index 0000000..d923abd --- /dev/null +++ b/test/legacy.ts @@ -0,0 +1,228 @@ +import * as uvu from 'uvu'; +import * as assert from 'uvu/assert'; +import { legacy } from '../src/legacy'; + +import type { Package } from 'resolve.exports'; + +function describe( + name: string, + cb: (it: uvu.Test) => void +) { + let t = uvu.suite(name); + cb(t); + t.run(); +} + +describe('lib.legacy', it => { + it('should be a function', () => { + assert.type(legacy, 'function'); + }); + + it('should prefer "module" > "main" entry', () => { + let pkg: Package = { + "name": "foobar", + "module": "build/module.js", + "main": "build/main.js", + }; + + let output = legacy(pkg); + assert.is(output, './build/module.js'); + }); + + it('should read "main" field', () => { + let pkg: Package = { + "name": "foobar", + "main": "build/main.js", + }; + + let output = legacy(pkg); + assert.is(output, './build/main.js'); + }); + + it('should return nothing when no fields', () => { + let pkg: Package = { + "name": "foobar" + }; + + let output = legacy(pkg); + assert.is(output, undefined); + }); + + it('should ignore boolean-type field values', () => { + let pkg = { + "module": true, + "main": "main.js" + }; + + let output = legacy(pkg as any); + assert.is(output, './main.js'); + }); +}); + +describe('options.fields', it => { + let pkg: Package = { + "name": "foobar", + "module": "build/module.js", + "browser": "build/browser.js", + "custom": "build/custom.js", + "main": "build/main.js", + }; + + it('should customize field search order', () => { + let output = legacy(pkg); + assert.is(output, './build/module.js', 'default: module'); + + output = legacy(pkg, { fields: ['main'] }); + assert.is(output, './build/main.js', 'custom: main only'); + + output = legacy(pkg, { fields: ['custom', 'main', 'module'] }); + assert.is(output, './build/custom.js', 'custom: custom > main > module'); + }); + + it('should return first *resolved* field', () => { + let output = legacy(pkg, { + fields: ['howdy', 'partner', 'hello', 'world', 'main'] + }); + + assert.is(output, './build/main.js'); + }); +}); + +describe('options.browser', it => { + let pkg: Package = { + "name": "foobar", + "module": "build/module.js", + "browser": "build/browser.js", + "unpkg": "build/unpkg.js", + "main": "build/main.js", + }; + + it('should prioritize "browser" field when defined', () => { + let output = legacy(pkg); + assert.is(output, './build/module.js'); + + output = legacy(pkg, { browser: true }); + assert.is(output, './build/browser.js'); + }); + + it('should respect existing "browser" order in custom fields', () => { + let output = legacy(pkg, { + fields: ['main', 'browser'], + browser: true, + }); + + assert.is(output, './build/main.js'); + }); + + // https://github.com/defunctzombie/package-browser-field-spec + it('should resolve object format', () => { + let pkg: Package = { + "name": "foobar", + "browser": { + "module-a": "./shims/module-a.js", + "./server/only.js": "./shims/client-only.js" + } + }; + + assert.is( + legacy(pkg, { browser: 'module-a' }), + './shims/module-a.js' + ); + + assert.is( + legacy(pkg, { browser: './server/only.js' }), + './shims/client-only.js' + ); + + assert.is( + legacy(pkg, { browser: 'foobar/server/only.js' }), + './shims/client-only.js' + ); + }); + + it('should allow object format to "ignore" modules/files :: string', () => { + let pkg: Package = { + "name": "foobar", + "browser": { + "module-a": false, + "./foo.js": false, + } + }; + + assert.is( + legacy(pkg, { browser: 'module-a' }), + false + ); + + assert.is( + legacy(pkg, { browser: './foo.js' }), + false + ); + + assert.is( + legacy(pkg, { browser: 'foobar/foo.js' }), + false + ); + }); + + it('should return the `browser` string (entry) if no custom mapping :: string', () => { + let pkg: Package = { + "name": "foobar", + "browser": { + // + } + }; + + assert.is( + legacy(pkg, { + browser: './hello.js' + }), + './hello.js' + ); + + assert.is( + legacy(pkg, { + browser: 'foobar/hello.js' + }), + './hello.js' + ); + }); + + it('should return the full "browser" object :: true', () => { + let pkg: Package = { + "name": "foobar", + "browser": { + "./other.js": "./world.js" + } + }; + + let output = legacy(pkg, { + browser: true + }); + + assert.equal(output, pkg.browser); + }); + + it('still ensures string output is made relative', () => { + let pkg: Package = { + "name": "foobar", + "browser": { + "./foo.js": "bar.js", + } + } as any; + + assert.is( + legacy(pkg, { + browser: './foo.js' + }), + './bar.js' + ); + + assert.is( + legacy(pkg, { + browser: 'foobar/foo.js' + }), + './bar.js' + ); + }); +}); diff --git a/test/resolve.js b/test/resolve.js deleted file mode 100644 index 0cb2963..0000000 --- a/test/resolve.js +++ /dev/null @@ -1,894 +0,0 @@ -import { suite } from 'uvu'; -import * as assert from 'uvu/assert'; -import * as $exports from '../src'; - -function pass(pkg, expects, ...args) { - let out = $exports.resolve(pkg, ...args); - assert.is(out, expects); -} - -function fail(pkg, target, ...args) { - try { - $exports.resolve(pkg, ...args); - assert.unreachable(); - } catch (err) { - assert.instance(err, Error); - assert.is(err.message, `Missing "${target}" export in "${pkg.name}" package`); - } -} - -// --- - -const resolve = suite('$.resolve'); - -resolve('should be a function', () => { - assert.type($exports.resolve, 'function'); -}); - -resolve('exports=string', () => { - let pkg = { - "name": "foobar", - "exports": "$string", - }; - - pass(pkg, '$string'); - pass(pkg, '$string', '.'); - pass(pkg, '$string', 'foobar'); - - fail(pkg, './other', 'other'); - fail(pkg, './other', 'foobar/other'); - fail(pkg, './hello', './hello'); -}); - -resolve('exports = { self }', () => { - let pkg = { - "name": "foobar", - "exports": { - "import": "$import", - "require": "$require", - } - }; - - pass(pkg, '$import'); - pass(pkg, '$import', '.'); - pass(pkg, '$import', 'foobar'); - - fail(pkg, './other', 'other'); - fail(pkg, './other', 'foobar/other'); - fail(pkg, './hello', './hello'); -}); - -resolve('exports["."] = string', () => { - let pkg = { - "name": "foobar", - "exports": { - ".": "$self", - } - }; - - pass(pkg, '$self'); - pass(pkg, '$self', '.'); - pass(pkg, '$self', 'foobar'); - - fail(pkg, './other', 'other'); - fail(pkg, './other', 'foobar/other'); - fail(pkg, './hello', './hello'); -}); - -resolve('exports["."] = object', () => { - let pkg = { - "name": "foobar", - "exports": { - ".": { - "import": "$import", - "require": "$require", - } - } - }; - - pass(pkg, '$import'); - pass(pkg, '$import', '.'); - pass(pkg, '$import', 'foobar'); - - fail(pkg, './other', 'other'); - fail(pkg, './other', 'foobar/other'); - fail(pkg, './hello', './hello'); -}); - -resolve('exports["./foo"] = string', () => { - let pkg = { - "name": "foobar", - "exports": { - "./foo": "$import", - } - }; - - pass(pkg, '$import', './foo'); - pass(pkg, '$import', 'foobar/foo'); - - fail(pkg, '.'); - fail(pkg, '.', 'foobar'); - fail(pkg, './other', 'foobar/other'); -}); - -resolve('exports["./foo"] = object', () => { - let pkg = { - "name": "foobar", - "exports": { - "./foo": { - "import": "$import", - "require": "$require", - } - } - }; - - pass(pkg, '$import', './foo'); - pass(pkg, '$import', 'foobar/foo'); - - fail(pkg, '.'); - fail(pkg, '.', 'foobar'); - fail(pkg, './other', 'foobar/other'); -}); - -// https://nodejs.org/api/packages.html#packages_nested_conditions -resolve('nested conditions', () => { - let pkg = { - "name": "foobar", - "exports": { - "node": { - "import": "$node.import", - "require": "$node.require" - }, - "default": "$default", - } - }; - - pass(pkg, '$node.import'); - pass(pkg, '$node.import', 'foobar'); - - // browser => no "node" key - pass(pkg, '$default', '.', { browser: true }); - pass(pkg, '$default', 'foobar', { browser: true }); - - fail(pkg, './hello', './hello'); - fail(pkg, './other', 'foobar/other'); - fail(pkg, './other', 'other'); -}); - -resolve('nested conditions :: subpath', () => { - let pkg = { - "name": "foobar", - "exports": { - "./lite": { - "node": { - "import": "$node.import", - "require": "$node.require" - }, - "browser": { - "import": "$browser.import", - "require": "$browser.require" - }, - } - } - }; - - pass(pkg, '$node.import', 'foobar/lite'); - pass(pkg, '$node.require', 'foobar/lite', { require: true }); - - pass(pkg, '$browser.import', 'foobar/lite', { browser: true }); - pass(pkg, '$browser.require', 'foobar/lite', { browser: true, require: true }); -}); - -resolve('nested conditions :: subpath :: inverse', () => { - let pkg = { - "name": "foobar", - "exports": { - "./lite": { - "import": { - "browser": "$browser.import", - "node": "$node.import", - }, - "require": { - "browser": "$browser.require", - "node": "$node.require", - } - } - } - }; - - pass(pkg, '$node.import', 'foobar/lite'); - pass(pkg, '$node.require', 'foobar/lite', { require: true }); - - pass(pkg, '$browser.import', 'foobar/lite', { browser: true }); - pass(pkg, '$browser.require', 'foobar/lite', { browser: true, require: true }); -}); - -// https://nodejs.org/api/packages.html#packages_subpath_folder_mappings -resolve('exports["./"]', () => { - let pkg = { - "name": "foobar", - "exports": { - ".": { - "require": "$require", - "import": "$import" - }, - "./package.json": "./package.json", - "./": "./" - } - }; - - pass(pkg, '$import'); - pass(pkg, '$import', 'foobar'); - pass(pkg, '$require', 'foobar', { require: true }); - - pass(pkg, './package.json', 'package.json'); - pass(pkg, './package.json', 'foobar/package.json'); - pass(pkg, './package.json', './package.json'); - - // "loose" / everything exposed - pass(pkg, './hello.js', 'hello.js'); - pass(pkg, './hello.js', 'foobar/hello.js'); - pass(pkg, './hello/world.js', './hello/world.js'); -}); - -resolve('exports["./"] :: w/o "." key', () => { - let pkg = { - "name": "foobar", - "exports": { - "./package.json": "./package.json", - "./": "./" - } - }; - - fail(pkg, '.', "."); - fail(pkg, '.', "foobar"); - - pass(pkg, './package.json', 'package.json'); - pass(pkg, './package.json', 'foobar/package.json'); - pass(pkg, './package.json', './package.json'); - - // "loose" / everything exposed - pass(pkg, './hello.js', 'hello.js'); - pass(pkg, './hello.js', 'foobar/hello.js'); - pass(pkg, './hello/world.js', './hello/world.js'); -}); - -// https://nodejs.org/api/packages.html#packages_subpath_folder_mappings -resolve('exports["./*"]', () => { - let pkg = { - "name": "foobar", - "exports": { - "./*": "./cheese/*.mjs" - } - }; - - fail(pkg, '.', "."); - fail(pkg, '.', "foobar"); - - pass(pkg, './cheese/hello.mjs', 'hello'); - pass(pkg, './cheese/hello.mjs', 'foobar/hello'); - pass(pkg, './cheese/hello/world.mjs', './hello/world'); - - // evaluate as defined, not wrong - pass(pkg, './cheese/hello.js.mjs', 'hello.js'); - pass(pkg, './cheese/hello.js.mjs', 'foobar/hello.js'); - pass(pkg, './cheese/hello/world.js.mjs', './hello/world.js'); -}); - -resolve('exports["./dir*"]', () => { - let pkg = { - "name": "foobar", - "exports": { - "./dir*": "./cheese/*.mjs" - } - }; - - fail(pkg, '.', "."); - fail(pkg, '.', "foobar"); - - pass(pkg, './cheese/test.mjs', 'dirtest'); - pass(pkg, './cheese/test.mjs', 'foobar/dirtest'); - - pass(pkg, './cheese/test/wheel.mjs', 'dirtest/wheel'); - pass(pkg, './cheese/test/wheel.mjs', 'foobar/dirtest/wheel'); -}); - -// https://github.com/lukeed/resolve.exports/issues/9 -resolve('exports["./dir*"] :: repeat "*" value', () => { - let pkg = { - "name": "foobar", - "exports": { - "./dir*": "./*sub/dir*/file.js" - } - }; - - fail(pkg, '.', "."); - fail(pkg, '.', "foobar"); - - pass(pkg, './testsub/dirtest/file.js', 'dirtest'); - pass(pkg, './testsub/dirtest/file.js', 'foobar/dirtest'); - - pass(pkg, './test/innersub/dirtest/inner/file.js', 'dirtest/inner'); - pass(pkg, './test/innersub/dirtest/inner/file.js', 'foobar/dirtest/inner'); -}); - -resolve('exports["./dir*"] :: share "name" start', () => { - let pkg = { - "name": "director", - "exports": { - "./dir*": "./*sub/dir*/file.js" - } - }; - - fail(pkg, '.', "."); - fail(pkg, '.', "director"); - - pass(pkg, './testsub/dirtest/file.js', 'dirtest'); - pass(pkg, './testsub/dirtest/file.js', 'director/dirtest'); - - pass(pkg, './test/innersub/dirtest/inner/file.js', 'dirtest/inner'); - pass(pkg, './test/innersub/dirtest/inner/file.js', 'director/dirtest/inner'); -}); - -/** - * @deprecated Documentation-only deprecation in Node 14.13 - * @deprecated Runtime deprecation in Node 16.0 - * @removed Removed in Node 18.0 - * @see https://nodejs.org/docs/latest-v16.x/api/packages.html#subpath-folder-mappings - */ -resolve('exports["./features/"]', () => { - let pkg = { - "name": "foobar", - "exports": { - "./features/": "./features/" - } - }; - - pass(pkg, './features/', 'features/'); - pass(pkg, './features/', 'foobar/features/'); - - pass(pkg, './features/hello.js', 'foobar/features/hello.js'); - - fail(pkg, './features', 'features'); - fail(pkg, './features', 'foobar/features'); - - fail(pkg, './package.json', 'package.json'); - fail(pkg, './package.json', 'foobar/package.json'); - fail(pkg, './package.json', './package.json'); -}); - -// https://nodejs.org/api/packages.html#packages_subpath_folder_mappings -resolve('exports["./features/"] :: with "./" key', () => { - let pkg = { - "name": "foobar", - "exports": { - "./features/": "./features/", - "./package.json": "./package.json", - "./": "./" - } - }; - - pass(pkg, './features', 'features'); // via "./" - pass(pkg, './features', 'foobar/features'); // via "./" - - pass(pkg, './features/', 'features/'); // via "./features/" - pass(pkg, './features/', 'foobar/features/'); // via "./features/" - - pass(pkg, './features/hello.js', 'foobar/features/hello.js'); - - pass(pkg, './package.json', 'package.json'); - pass(pkg, './package.json', 'foobar/package.json'); - pass(pkg, './package.json', './package.json'); - - // Does NOT hit "./" (match Node) - fail(pkg, '.', '.'); - fail(pkg, '.', 'foobar'); -}); - -resolve('exports["./features/"] :: conditions', () => { - let pkg = { - "name": "foobar", - "exports": { - "./features/": { - "browser": { - "import": "./browser.import/", - "require": "./browser.require/", - }, - "import": "./import/", - "require": "./require/", - }, - } - }; - - // import - pass(pkg, './import/', 'features/'); - pass(pkg, './import/', 'foobar/features/'); - - pass(pkg, './import/hello.js', './features/hello.js'); - pass(pkg, './import/hello.js', 'foobar/features/hello.js'); - - // require - pass(pkg, './require/', 'features/', { require: true }); - pass(pkg, './require/', 'foobar/features/', { require: true }); - - pass(pkg, './require/hello.js', './features/hello.js', { require: true }); - pass(pkg, './require/hello.js', 'foobar/features/hello.js', { require: true }); - - // require + browser - pass(pkg, './browser.require/', 'features/', { browser: true, require: true }); - pass(pkg, './browser.require/', 'foobar/features/', { browser: true, require: true }); - - pass(pkg, './browser.require/hello.js', './features/hello.js', { browser: true, require: true }); - pass(pkg, './browser.require/hello.js', 'foobar/features/hello.js', { browser: true, require: true }); -}); - -// https://nodejs.org/api/packages.html#packages_subpath_folder_mappings -resolve('exports["./features/*"]', () => { - let pkg = { - "name": "foobar", - "exports": { - "./features/*": "./features/*.js", - } - }; - - fail(pkg, './features', 'features'); - fail(pkg, './features', 'foobar/features'); - - fail(pkg, './features/', 'features/'); - fail(pkg, './features/', 'foobar/features/'); - - pass(pkg, './features/a.js', 'foobar/features/a'); - pass(pkg, './features/ab.js', 'foobar/features/ab'); - pass(pkg, './features/abc.js', 'foobar/features/abc'); - - pass(pkg, './features/hello.js', 'foobar/features/hello'); - pass(pkg, './features/foo/bar.js', 'foobar/features/foo/bar'); - - // Valid: Pattern trailers allow any exact substrings to be matched - pass(pkg, './features/hello.js.js', 'foobar/features/hello.js'); - pass(pkg, './features/foo/bar.js.js', 'foobar/features/foo/bar.js'); - - fail(pkg, './package.json', 'package.json'); - fail(pkg, './package.json', 'foobar/package.json'); - fail(pkg, './package.json', './package.json'); -}); - -// https://nodejs.org/api/packages.html#packages_subpath_folder_mappings -resolve('exports["./features/*"] :: with "./" key', () => { - let pkg = { - "name": "foobar", - "exports": { - "./features/*": "./features/*.js", - "./": "./" - } - }; - - pass(pkg, './features', 'features'); // via "./" - pass(pkg, './features', 'foobar/features'); // via "./" - - pass(pkg, './features/', 'features/'); // via "./" - pass(pkg, './features/', 'foobar/features/'); // via "./" - - pass(pkg, './features/hello.js', 'foobar/features/hello'); - pass(pkg, './features/foo/bar.js', 'foobar/features/foo/bar'); - - // Valid: Pattern trailers allow any exact substrings to be matched - pass(pkg, './features/hello.js.js', 'foobar/features/hello.js'); - pass(pkg, './features/foo/bar.js.js', 'foobar/features/foo/bar.js'); - - pass(pkg, './package.json', 'package.json'); - pass(pkg, './package.json', 'foobar/package.json'); - pass(pkg, './package.json', './package.json'); - - // Does NOT hit "./" (match Node) - fail(pkg, '.', '.'); - fail(pkg, '.', 'foobar'); -}); - -// https://github.com/lukeed/resolve.exports/issues/7 -resolve('exports["./features/*"] :: with "./" key first', () => { - let pkg = { - "name": "foobar", - "exports": { - "./": "./", - "./features/*": "./features/*.js" - } - }; - - pass(pkg, './features', 'features'); // via "./" - pass(pkg, './features', 'foobar/features'); // via "./" - - pass(pkg, './features/', 'features/'); // via "./" - pass(pkg, './features/', 'foobar/features/'); // via "./" - - pass(pkg, './features/hello.js', 'foobar/features/hello'); - pass(pkg, './features/foo/bar.js', 'foobar/features/foo/bar'); - - // Valid: Pattern trailers allow any exact substrings to be matched - pass(pkg, './features/hello.js.js', 'foobar/features/hello.js'); - pass(pkg, './features/foo/bar.js.js', 'foobar/features/foo/bar.js'); - - pass(pkg, './package.json', 'package.json'); - pass(pkg, './package.json', 'foobar/package.json'); - pass(pkg, './package.json', './package.json'); - - // Does NOT hit "./" (match Node) - fail(pkg, '.', '.'); - fail(pkg, '.', 'foobar'); -}); - -// https://github.com/lukeed/resolve.exports/issues/16 -resolve('exports["./features/*"] :: with `null` internals', () => { - let pkg = { - "name": "foobar", - "exports": { - "./features/*": "./src/features/*.js", - "./features/internal/*": null - } - }; - - pass(pkg, './src/features/hello.js', 'features/hello'); - pass(pkg, './src/features/hello.js', 'foobar/features/hello'); - - pass(pkg, './src/features/foo/bar.js', 'features/foo/bar'); - pass(pkg, './src/features/foo/bar.js', 'foobar/features/foo/bar'); - - // TODO? Native throws `ERR_PACKAGE_PATH_NOT_EXPORTED` - // Currently throwing `Missing "%s" export in "$s" package` - fail(pkg, './features/internal/hello', 'features/internal/hello'); - fail(pkg, './features/internal/foo/bar', 'features/internal/foo/bar'); -}); - -// https://github.com/lukeed/resolve.exports/issues/16 -resolve('exports["./features/*"] :: with `null` internals first', () => { - let pkg = { - "name": "foobar", - "exports": { - "./features/internal/*": null, - "./features/*": "./src/features/*.js", - } - }; - - pass(pkg, './src/features/hello.js', 'features/hello'); - pass(pkg, './src/features/hello.js', 'foobar/features/hello'); - - pass(pkg, './src/features/foo/bar.js', 'features/foo/bar'); - pass(pkg, './src/features/foo/bar.js', 'foobar/features/foo/bar'); - - // TODO? Native throws `ERR_PACKAGE_PATH_NOT_EXPORTED` - // Currently throwing `Missing "%s" export in "$s" package` - fail(pkg, './features/internal/hello', 'features/internal/hello'); - fail(pkg, './features/internal/foo/bar', 'features/internal/foo/bar'); -}); - -// https://nodejs.org/docs/latest-v18.x/api/packages.html#package-entry-points -resolve('exports["./features/*"] :: with "./features/*.js" key', () => { - let pkg = { - "name": "foobar", - "exports": { - "./features/*": "./features/*.js", - "./features/*.js": "./features/*.js", - } - }; - - fail(pkg, './features', 'features'); - fail(pkg, './features', 'foobar/features'); - - fail(pkg, './features/', 'features/'); - fail(pkg, './features/', 'foobar/features/'); - - pass(pkg, './features/a.js', 'foobar/features/a'); - pass(pkg, './features/ab.js', 'foobar/features/ab'); - pass(pkg, './features/abc.js', 'foobar/features/abc'); - - pass(pkg, './features/hello.js', 'foobar/features/hello'); - pass(pkg, './features/hello.js', 'foobar/features/hello.js'); - - pass(pkg, './features/foo/bar.js', 'foobar/features/foo/bar'); - pass(pkg, './features/foo/bar.js', 'foobar/features/foo/bar.js'); - - fail(pkg, './package.json', 'package.json'); - fail(pkg, './package.json', 'foobar/package.json'); - fail(pkg, './package.json', './package.json'); -}); - -resolve('exports["./features/*"] :: conditions', () => { - let pkg = { - "name": "foobar", - "exports": { - "./features/*": { - "browser": { - "import": "./browser.import/*.mjs", - "require": "./browser.require/*.js", - }, - "import": "./import/*.mjs", - "require": "./require/*.js", - }, - } - }; - - // import - fail(pkg, './features/', 'features/'); // no file - fail(pkg, './features/', 'foobar/features/'); // no file - - pass(pkg, './import/hello.mjs', './features/hello'); - pass(pkg, './import/hello.mjs', 'foobar/features/hello'); - - // require - fail(pkg, './features/', 'features/', { require: true }); // no file - fail(pkg, './features/', 'foobar/features/', { require: true }); // no file - - pass(pkg, './require/hello.js', './features/hello', { require: true }); - pass(pkg, './require/hello.js', 'foobar/features/hello', { require: true }); - - // require + browser - fail(pkg, './features/', 'features/', { browser: true, require: true }); // no file - fail(pkg, './features/', 'foobar/features/', { browser: true, require: true }); // no file - - pass(pkg, './browser.require/hello.js', './features/hello', { browser: true, require: true }); - pass(pkg, './browser.require/hello.js', 'foobar/features/hello', { browser: true, require: true }); -}); - -resolve('should handle mixed path/conditions', () => { - let pkg = { - "name": "foobar", - "exports": { - ".": [ - { - "import": "$root.import", - }, - "$root.string" - ], - "./foo": [ - { - "require": "$foo.require" - }, - "$foo.string" - ] - } - } - - pass(pkg, '$root.import'); - pass(pkg, '$root.import', 'foobar'); - - pass(pkg, '$foo.string', 'foo'); - pass(pkg, '$foo.string', 'foobar/foo'); - pass(pkg, '$foo.string', './foo'); - - pass(pkg, '$foo.require', 'foo', { require: true }); - pass(pkg, '$foo.require', 'foobar/foo', { require: true }); - pass(pkg, '$foo.require', './foo', { require: true }); -}); - -resolve('should handle file with leading dot', () => { - let pkg = { - "version": "2.41.0", - "name": "aws-cdk-lib", - "exports": { - ".": "./index.js", - "./package.json": "./package.json", - "./.jsii": "./.jsii", - "./.warnings.jsii.js": "./.warnings.jsii.js", - "./alexa-ask": "./alexa-ask/index.js" - } - }; - - pass(pkg, "./.warnings.jsii.js", ".warnings.jsii.js"); -}); - -resolve.run(); - -// --- - -const requires = suite('options.requires', { - "exports": { - "require": "$require", - "import": "$import", - } -}); - -requires('should ignore "require" keys by default', pkg => { - pass(pkg, '$import'); -}); - -requires('should use "require" key when defined first', pkg => { - pass(pkg, '$require', '.', { require: true }); -}); - -requires('should ignore "import" key when enabled', () => { - let pkg = { - "exports": { - "import": "$import", - "require": "$require", - } - }; - pass(pkg, '$require', '.', { require: true }); - pass(pkg, '$import', '.'); -}); - -requires('should match "default" if "require" is after', () => { - let pkg = { - "exports": { - "default": "$default", - "require": "$require", - } - }; - pass(pkg, '$default', '.', { require: true }); -}); - -requires.run(); - -// --- - -const browser = suite('options.browser', { - "exports": { - "browser": "$browser", - "node": "$node", - } -}); - -browser('should ignore "browser" keys by default', pkg => { - pass(pkg, '$node'); -}); - -browser('should use "browser" key when defined first', pkg => { - pass(pkg, '$browser', '.', { browser: true }); -}); - -browser('should ignore "node" key when enabled', () => { - let pkg = { - "exports": { - "node": "$node", - "import": "$import", - "browser": "$browser", - } - }; - // import defined before browser - pass(pkg, '$import', '.', { browser: true }); -}); - -browser.run(); - -// --- - -const conditions = suite('options.conditions', { - "exports": { - "production": "$prod", - "development": "$dev", - "default": "$default", - } -}); - -conditions('should ignore unknown conditions by default', pkg => { - pass(pkg, '$default'); -}); - -conditions('should recognize custom field(s) when specified', pkg => { - pass(pkg, '$dev', '.', { - conditions: ['development'] - }); - - pass(pkg, '$prod', '.', { - conditions: ['development', 'production'] - }); -}); - -conditions('should throw an error if no known conditions', ctx => { - let pkg = { - "name": "hello", - "exports": { - ...ctx.exports - }, - }; - - delete pkg.exports.default; - - try { - $exports.resolve(pkg); - assert.unreachable(); - } catch (err) { - assert.instance(err, Error); - assert.is(err.message, `No known conditions for "." entry in "hello" package`); - } -}); - -conditions.run(); - -// --- - -const unsafe = suite('options.unsafe', { - "exports": { - ".": { - "production": "$prod", - "development": "$dev", - "default": "$default", - }, - "./spec/type": { - "import": "$import", - "require": "$require", - "default": "$default" - }, - "./spec/env": { - "worker": { - "default": "$worker" - }, - "browser": "$browser", - "node": "$node", - "default": "$default" - } - } -}); - -unsafe('should ignore unknown conditions by default', pkg => { - pass(pkg, '$default', '.', { - unsafe: true, - }); -}); - -unsafe('should ignore "import" and "require" conditions by default', pkg => { - pass(pkg, '$default', './spec/type', { - unsafe: true, - }); - - pass(pkg, '$default', './spec/type', { - unsafe: true, - require: true, - }); -}); - -unsafe('should ignore "node" and "browser" conditions by default', pkg => { - pass(pkg, '$default', './spec/type', { - unsafe: true, - }); - - pass(pkg, '$default', './spec/type', { - unsafe: true, - browser: true, - }); -}); - -unsafe('should respect/accept any custom condition(s) when specified', pkg => { - // root, dev only - pass(pkg, '$dev', '.', { - unsafe: true, - conditions: ['development'] - }); - - // root, defined order - pass(pkg, '$prod', '.', { - unsafe: true, - conditions: ['development', 'production'] - }); - - // import vs require, defined order - pass(pkg, '$require', './spec/type', { - unsafe: true, - conditions: ['require'] - }); - - // import vs require, defined order - pass(pkg, '$import', './spec/type', { - unsafe: true, - conditions: ['import', 'require'] - }); - - // import vs require, defined order - pass(pkg, '$node', './spec/env', { - unsafe: true, - conditions: ['node'] - }); - - // import vs require, defined order - pass(pkg, '$browser', './spec/env', { - unsafe: true, - conditions: ['browser', 'node'] - }); - - // import vs require, defined order - pass(pkg, '$worker', './spec/env', { - unsafe: true, - conditions: ['browser', 'node', 'worker'] - }); -}); - -unsafe.run(); diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..72f356d --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,420 @@ +import * as uvu from 'uvu'; +import * as assert from 'uvu/assert'; +import * as $ from '../src/utils'; + +import type * as t from 'resolve.exports'; + +function describe( + name: string, + cb: (it: uvu.Test) => void +) { + let t = uvu.suite(name); + cb(t); + t.run(); +} + +describe('$.conditions', it => { + const EMPTY = {}; + + function run(o?: t.Options): string[] { + return [...$.conditions(o||{})]; + } + + it('should be a function', () => { + assert.type($.conditions, 'function'); + }); + + it('should return `Set` instance', () => { + let output = $.conditions(EMPTY); + assert.instance(output, Set); + }); + + it('default conditions', () => { + assert.equal( + [ ...$.conditions(EMPTY) ], + ['default', 'import', 'node'] + ); + }); + + it('default conditions :: unsafe', () => { + assert.equal( + run({ unsafe: true }), + ['default'] + ); + }); + + it('option.browser', () => { + assert.equal( + run({ browser: true }), + ['default', 'import', 'browser'] + ); + }); + + // unsafe ignores all but conditions + it('option.browser :: unsafe', () => { + let output = run({ + browser: true, + unsafe: true, + }); + assert.equal(output, ['default']); + }); + + it('option.require', () => { + assert.equal( + run({ require: true }), + ['default', 'require', 'node'] + ); + }); + + // unsafe ignores all but conditions + it('option.require :: unsafe', () => { + let output = run({ + require: true, + unsafe: true, + }); + assert.equal(output, ['default']); + }); + + it('option.conditions', () => { + let output = run({ conditions: ['foo', 'bar'] }); + assert.equal(output, ['default', 'foo', 'bar', 'import', 'node']); + }); + + it('option.conditions :: order', () => { + let output = run({ conditions: ['node', 'import', 'foobar'] }); + assert.equal(output, ['default', 'node', 'import', 'foobar']); + }); + + it('option.conditions :: unsafe', () => { + let output = run({ unsafe: true, conditions: ['foo', 'bar'] }); + assert.equal(output, ['default', 'foo', 'bar']); + }); + + it('option.conditions :: browser', () => { + let output = run({ browser: true, conditions: ['foo', 'bar'] }); + assert.equal(output, ['default', 'foo', 'bar', 'import', 'browser']); + }); + + it('option.conditions :: browser :: order', () => { + let output = run({ browser: true, conditions: ['browser', 'foobar'] }); + assert.equal(output, ['default', 'browser', 'foobar', 'import']); + }); + + it('option.conditions :: require', () => { + let output = run({ require: true, conditions: ['foo', 'bar'] }); + assert.equal(output, ['default', 'foo', 'bar', 'require', 'node']); + }); + + it('option.conditions :: require :: order', () => { + let output = run({ require: true, conditions: ['require', 'foobar'] }); + assert.equal(output, ['default', 'require', 'foobar', 'node']); + }); +}); + +describe('$.toEntry', it => { + const PKG = 'PACKAGE'; + const EXTERNAL = 'EXTERNAL'; + + function run(input: string, expect: string, externals?: boolean) { + // overloading not working -,- + let output = externals ? $.toEntry(PKG, input, true) : $.toEntry(PKG, input); + let msg = `"${input}" -> "${expect}"` + (externals ? ' (externals)' : ''); + assert.is(output, expect, msg); + } + + it('should be a function', () => { + assert.type($.toEntry, 'function'); + }); + + it('PKG', () => { + run(PKG, '.'); + run(PKG, '.', true); + }); + + it('.', () => { + run('.', '.'); + run('.', '.', true); + }); + + it('./', () => { + run('./', './'); + run('./', './', true); + }); + + it('#inner', () => { + run('#inner', '#inner'); + run('#inner', '#inner', true); + }); + + it('./foo', () => { + run('./foo', './foo'); + run('./foo', './foo', true); + }); + + it('foo', () => { + run('foo', './foo'); // forces path by default + run('foo', 'foo', true); + }); + + it('.ini', () => { + run('.ini', './.ini'); // forces path by default + run('.ini', '.ini', true); + }); + + // handle "import 'lib/lib';" case + it('./PKG', () => { + let input = './' + PKG; + run(input, input); + run(input, input, true); + }); + + it('PKG/subpath', () => { + let input = PKG + '/other'; + run(input, './other'); + run(input, './other', true); + }); + + it('PKG/#inner', () => { + let input = PKG + '/#inner'; + run(input, '#inner'); + run(input, '#inner', true); + }); + + it('PKG/.ini', () => { + let input = PKG + '/.ini'; + run(input, './.ini'); + run(input, './.ini', true); + }); + + it('EXTERNAL', () => { + run(EXTERNAL, './'+EXTERNAL); // forces path by default + run(EXTERNAL, EXTERNAL, true); + }); +}); + +describe('$.injects', it => { + function run<T extends t.Path[]>(value: string, input: T, expect: T) { + let output = $.injects(input, value); + assert.is(output, undefined); + assert.equal(input, expect); + } + + it('should be a function', () => { + assert.type($.injects, 'function'); + }); + + it('should replace "*" character', () => { + run('bar', ['./foo*.jpg'], ['./foobar.jpg']); + }); + + it('should replace multiple "*" characters w/ same value', () => { + run('bar', ['./*/foo-*.jpg'], ['./bar/foo-bar.jpg']); + }); + + // for the "./features/" => "./src/features/" scenario + it('should append `value` if missing "*" character', () => { + run('app.js', ['./src/features/'], ['./src/features/app.js']); + }); + + it('should handle mixed array input', () => { + run('xyz', + ['./foo/', './esm/*.mjs', './build/*/index-*.js'], + ['./foo/xyz', './esm/xyz.mjs', './build/xyz/index-xyz.js'], + ); + }); +}); + +describe('$.loop', it => { + const FILE = './file.js'; + const DEFAULT = './foobar.js'; + + type Expect = string | string[] | null | undefined; + function run(expect: Expect, map: t.Exports.Value, conditions?: string[]) { + let output = $.loop(map, new Set([ 'default', ...conditions||[] ])); + if (typeof expect == 'string') { + assert.ok(Array.isArray(output)); + assert.is(output[0], expect); + assert.is(output.length, 1); + } else { + // Array, null, undefined + assert.equal(output, expect); + } + } + + it('should be a function', () => { + assert.type($.loop, 'function'); + }); + + it('string', () => { + run('./foo.mjs', './foo.mjs'); + // @ts-expect-error + run('.', '.'); + }); + + it('empties', () => { + // @ts-expect-error + run(undefined, ''); + run(undefined, null); + run(undefined, []); + run(undefined, {}); + }); + + it('{ default }', () => { + run(FILE, { + default: FILE, + }); + + run(FILE, { + other: './unknown.js', + default: FILE, + }); + + run(undefined, { + other: './unknown.js', + }); + + run(FILE, { + foo: './foo.js', + default: { + bar: './bar.js', + default: { + baz: './baz.js', + default: FILE, + } + } + }); + }); + + it('{ custom }', () => { + let conditions = ['custom']; + + run(DEFAULT, { + default: DEFAULT, + custom: FILE, + }, conditions); + + run(FILE, { + custom: FILE, + default: DEFAULT, + }, conditions); + + run(undefined, { + foo: './foo.js', + bar: './bar.js', + }, conditions); + + run(FILE, { + foo: './foo.js', + custom: { + default: { + custom: FILE, + default: DEFAULT, + } + }, + default: { + custom: './bar.js' + } + }, conditions); + }); + + it('[ string ]', () => { + run( + [DEFAULT, FILE], + [DEFAULT, FILE] + ); + + run(undefined, [ + null, + ]); + + run( + [DEFAULT, FILE], + [null, DEFAULT, FILE] + ); + + run( + [DEFAULT, FILE], + [DEFAULT, null, FILE] + ); + }); + + it('[{ default }]', () => { + run([DEFAULT, FILE], [ + { + default: DEFAULT, + }, + FILE + ]); + + run([FILE, DEFAULT], [ + FILE, + null, + { + default: DEFAULT, + }, + ]); + + run([DEFAULT, FILE], [ + { + default: { + default: { + default: DEFAULT, + } + } + }, + null, + FILE + ]); + + run([DEFAULT, FILE, './foo.js'], [ + { + default: { + default: DEFAULT, + } + }, + null, + { + default: { + default: DEFAULT, + } + }, + FILE, + { + default: './foo.js' + } + ]); + }); + + it('{ [mixed] }', () => { + run([DEFAULT, FILE], { + default: [DEFAULT, FILE] + }); + + run([DEFAULT, FILE], { + default: [null, DEFAULT, FILE] + }); + + run([DEFAULT, FILE], { + default: [null, { + default: DEFAULT + }, FILE] + }); + + run([FILE, DEFAULT], { + default: { + custom: [{ + default: [FILE, FILE, null, DEFAULT] + }, null, DEFAULT, FILE] + } + }, ['custom']); + + run([DEFAULT, FILE], { + default: { + custom: [{ + custom: [DEFAULT, null], + default: [FILE, FILE, null, DEFAULT] + }, null, DEFAULT, FILE] + } + }, ['custom']); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7928b88 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "strict": true, + "target": "esnext", + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "nodenext", + "strictFunctionTypes": true, + "strictNullChecks": true, + "noImplicitThis": true, + "noImplicitAny": true, + "alwaysStrict": true, + "module": "esnext", + "noEmit": true, + "paths": { + "resolve.exports": ["./index.d.ts"] + } + }, + "include": [ + "src", "test" + ] +}