Skip to content

Commit 4fd48f6

Browse files
committed
feat(utils): findSubpath
Signed-off-by: Lexus Drumgold <unicornware@flexdevelopment.llc>
1 parent b5fd706 commit 4fd48f6

File tree

6 files changed

+335
-6
lines changed

6 files changed

+335
-6
lines changed

docs/.vitepress/theme/comments/link-replacements.json

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"{@linkcode findExports}": "/api/#findexports",
5252
"{@linkcode findRequires}": "/api/#findrequires",
5353
"{@linkcode findStaticImports}": "/api/#findstaticimports",
54+
"{@linkcode findSubpath}": "/api/#findsubpath",
5455
"{@linkcode getFormat}": "/api/#getformat",
5556
"{@linkcode getSource}": "/api/#getsource",
5657
"{@linkcode hasCJSSyntax}": "/api/#hascjssyntax",

src/utils/__tests__/conditions.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
import TEST_SUBJECT from '../conditions'
77

88
describe('unit:utils/CONDITIONS', () => {
9-
it('should be readonly set', () => {
10-
expect(TEST_SUBJECT).to.be.frozen.instanceof(Set)
9+
it('should be instance of Set', () => {
10+
expect(TEST_SUBJECT).to.be.instanceof(Set)
1111
})
1212

1313
it('should be sorted by priority', () => {
+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @file Unit Tests - findSubpath
3+
* @module mlly/utils/tests/unit/findSubpath
4+
*/
5+
6+
import type { FindSubpathOptions } from '#src/interfaces'
7+
import type { Exports, Imports, PackageJson } from '@flex-development/pkg-types'
8+
import fs from 'node:fs/promises'
9+
import { pathToFileURL } from 'node:url'
10+
import testSubject from '../find-subpath'
11+
12+
describe('unit:utils/findSubpath', () => {
13+
let options: FindSubpathOptions
14+
let pkgjson: PackageJson & { name: string }
15+
16+
beforeEach(async () => {
17+
options = { dir: pathToFileURL('./'), parent: import.meta.url }
18+
pkgjson = JSON.parse(await fs.readFile('package.json', 'utf8'))
19+
})
20+
21+
it('should return null if target is not found in context', () => {
22+
// Arrange
23+
const cases: Parameters<typeof testSubject>[1][] = [
24+
null,
25+
undefined,
26+
faker.datatype.number() as unknown as Exports,
27+
pkgjson.exports
28+
]
29+
30+
// Act + Expect
31+
cases.forEach(context => {
32+
expect(testSubject('./index.mjs', context, options)).to.be.null
33+
})
34+
})
35+
36+
it('should return defined subpath if target is found in context', () => {
37+
// Arrange
38+
const cases: [string, Exports | Imports | undefined, string][] = [
39+
['./dist/index', './dist/index.mjs', '.'],
40+
['./dist/index.mjs', './dist/index.mjs', '.'],
41+
['./dist/index.mjs', ['./dist/index.mjs'], '.'],
42+
['./dist/index', pkgjson.exports, '.'],
43+
['./dist/index.mjs', pkgjson.exports, '.'],
44+
['./package.json', pkgjson.exports, './package.json'],
45+
[
46+
'./dist/index.mjs',
47+
{
48+
import: './dist/index.mjs',
49+
require: './dist/index.cjs'
50+
},
51+
'.'
52+
],
53+
[
54+
'./dist/utils/conditions.mjs',
55+
{
56+
'./utils': {
57+
default: './dist/utils/index.mjs',
58+
require: './dist/utils/index.cjs'
59+
},
60+
'./utils/*': {
61+
default: './dist/utils/*.mjs',
62+
require: './dist/utils/*.cjs'
63+
}
64+
},
65+
'./utils/*'
66+
],
67+
[
68+
'./utils/find-subpath.mjs',
69+
{
70+
'./utils': './utils/index.mjs',
71+
'./utils/*': './utils/*.mjs'
72+
},
73+
'./utils/*'
74+
],
75+
[
76+
'./utils/find-subpath',
77+
{
78+
'./utils/*': './utils/*.mjs',
79+
'./utils': './utils/index.mjs'
80+
},
81+
'./utils/*'
82+
]
83+
]
84+
85+
// Act + Expect
86+
cases.forEach(([target, context, expected]) => {
87+
expect(testSubject(target, context, options)).to.equal(expected)
88+
})
89+
})
90+
})

src/utils/conditions.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,8 @@
88
*
99
* @see https://nodejs.org/api/packages.html#conditional-exports
1010
*
11-
* @const {Readonly<Set<string>>} CONDITIONS
11+
* @const {Set<string>} CONDITIONS
1212
*/
13-
const CONDITIONS: Readonly<Set<string>> = Object.freeze(
14-
new Set(['node', 'import'])
15-
)
13+
const CONDITIONS: Set<string> = new Set(['node', 'import'])
1614

1715
export default CONDITIONS

src/utils/find-subpath.ts

+239
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
/**
2+
* @file findSubpath
3+
* @module mlly/utils/findSubpath
4+
*/
5+
6+
import type { FindSubpathOptions } from '#src/interfaces'
7+
import getSubpaths from '#src/internal/get-subpaths'
8+
import validateString from '#src/internal/validate-string'
9+
import validateURLString from '#src/internal/validate-url-string'
10+
import type { NodeError } from '@flex-development/errnode'
11+
import pathe from '@flex-development/pathe'
12+
import type { Exports, Imports } from '@flex-development/pkg-types'
13+
import { isNIL, type Nullable } from '@flex-development/tutils'
14+
import { URL } from 'node:url'
15+
import compareSubpaths from './compare-subpaths'
16+
import CONDITIONS from './conditions'
17+
import isExportsSugar from './is-exports-sugar'
18+
import toURL from './to-url'
19+
20+
/**
21+
* Finds the subpath defined in `context`, a `package.json` [`exports`][1] or
22+
* [`imports`][2] field, that maps to the given package `target`.
23+
*
24+
* Supports extensionless targets. Returns `null` if a subpath is not found.
25+
*
26+
* [1]: https://nodejs.org/api/packages.html#exports
27+
* [2]: https://nodejs.org/api/packages.html#imports
28+
*
29+
* @see {@linkcode Exports}
30+
* @see {@linkcode FindSubpathOptions}
31+
* @see {@linkcode Imports}
32+
* @see https://nodejs.org/api/packages.html#subpath-exports
33+
* @see https://nodejs.org/api/packages.html#subpath-imports
34+
*
35+
* @param {string} target - Package target to find in `context`
36+
* @param {Exports | Imports | undefined} context - Package context
37+
* @param {FindSubpathOptions} options - Search options
38+
* @return {Nullable<string>} Subpath defined in `context` or `null`
39+
* @throws {NodeError<Error | TypeError>} If `target` is not a string, or if
40+
* either `options.dir` or `options.parent` is not a {@linkcode URL} instance or
41+
* a string
42+
*/
43+
const findSubpath = (
44+
target: string,
45+
context: Exports | Imports | undefined,
46+
options: FindSubpathOptions
47+
): Nullable<string> => {
48+
const {
49+
condition = 'default',
50+
conditions = CONDITIONS,
51+
dir,
52+
internal = false,
53+
parent
54+
} = options
55+
56+
// exit early if context is nil
57+
if (isNIL(context)) return null
58+
59+
// ensure specifier is a string
60+
validateString(target, 'target')
61+
62+
// exit early if target is an exactish match
63+
if (typeof context === 'string') {
64+
if (target === context || target === pathe.changeExt(context, '')) {
65+
return '.'
66+
}
67+
}
68+
69+
// ensure dir is an instance of URL or a string
70+
validateURLString(dir, 'options.dir')
71+
72+
// ensure dir is an instance of URL or a string
73+
validateURLString(parent, 'options.parent')
74+
75+
/**
76+
* Finds the subpath defined in `context`, a `package.json` [`exports`][1] or
77+
* [`imports`][2] field, that maps to the given package `target`.
78+
*
79+
* Returns `null` if a subpath is not found.
80+
*
81+
* [1]: https://nodejs.org/api/packages.html#exports
82+
* [2]: https://nodejs.org/api/packages.html#imports
83+
*
84+
* @param {string} target - Package target to find in `context`
85+
* @param {Exports | Imports | undefined} context - Package context
86+
* @param {string} [key='.'] - Subpath in `context` being checked
87+
* @return {Nullable<string>} Subpath defined in `context` or `null`
88+
*/
89+
const find = (
90+
target: string,
91+
context: Exports | Imports | undefined,
92+
key: string = '.'
93+
): Nullable<string> => {
94+
/**
95+
* Subpath defined in {@linkcode context} that maps to {@linkcode target}.
96+
*
97+
* @var {Nullable<string>} subpath
98+
*/
99+
let subpath: Nullable<string> = null
100+
101+
// match target to subpath
102+
switch (true) {
103+
case !isNIL(context) && typeof context === 'object':
104+
case typeof context === 'string':
105+
/**
106+
* URL of directory containing relevant `package.json` file.
107+
*
108+
* @const {string} pkgdir
109+
*/
110+
const pkgdir: string = toURL(dir).href.replace(/\/$/, '') + pathe.sep
111+
112+
/**
113+
* URL of relevant `package.json` file.
114+
*
115+
* @const {URL} pkg
116+
*/
117+
const pkg: URL = new URL('package.json', pkgdir)
118+
119+
// convert package context to object if using exports sugar
120+
if (!internal && isExportsSugar(context, pkg, parent)) {
121+
context = { [key]: context } as Record<string, Exports>
122+
}
123+
124+
// context is now an object
125+
context = context as Record<string, Exports>
126+
127+
/**
128+
* Subpaths defined in {@linkcode context}.
129+
*
130+
* **Note**: Sorted from least to greatest.
131+
*
132+
* @see {@linkcode compareSubpaths}
133+
*
134+
* @const {string[]} keys
135+
*/
136+
const subpaths: string[] = getSubpaths(
137+
context,
138+
internal,
139+
pkg,
140+
parent
141+
).sort((s1, s2) => compareSubpaths(s1, s2) * -1)
142+
143+
// match target to subpath defined in context
144+
for (const pkgsubpath of subpaths) {
145+
/**
146+
* Current package target being checked.
147+
*
148+
* @var {Exports} tar
149+
*/
150+
let tar: Exports = context[pkgsubpath]!
151+
152+
// find subpath
153+
switch (true) {
154+
case Array.isArray(tar):
155+
tar = tar as (Record<string, Exports> | string)[]
156+
157+
// try matching target based first match in target array
158+
for (const item of tar) {
159+
subpath = find(target, item, pkgsubpath)
160+
if (subpath) break
161+
}
162+
163+
break
164+
case typeof tar === 'object' && !isNIL(tar):
165+
tar = tar as Record<string, Exports>
166+
167+
// try matching target based on export conditions
168+
for (const prop of Object.getOwnPropertyNames(tar)) {
169+
if (prop === condition || conditions.has(prop)) {
170+
subpath = find(target, tar[prop], pkgsubpath)
171+
if (subpath) break
172+
}
173+
}
174+
175+
break
176+
case typeof tar === 'string':
177+
tar = tar as string
178+
179+
/**
180+
* {@linkcode tar} without file extension.
181+
*
182+
* @const {string} tar_no_ext
183+
*/
184+
const tar_ne: string = pathe.changeExt(tar, '')
185+
186+
/**
187+
* Index of pattern character (`'*'`) in {@linkcode tar}.
188+
*
189+
* @const {number} pattern
190+
*/
191+
const pattern: number = tar.indexOf('*')
192+
193+
switch (true) {
194+
// target is an exactish match
195+
case target === tar:
196+
case target === tar_ne:
197+
case pattern === -1 && (target === tar || target === tar_ne):
198+
subpath = pkgsubpath
199+
break
200+
// pattern character => try finding best match for target
201+
case pattern !== -1 && target.startsWith(tar.slice(0, pattern)):
202+
/**
203+
* Boolean indicating if {@linkcode target} ends with the
204+
* characters after the pattern character (`*`) in
205+
* {@linkcode tar}.
206+
*
207+
* @const {boolean} match
208+
*/
209+
const match: boolean =
210+
target.length >= tar.length &&
211+
tar.lastIndexOf('*') === pattern &&
212+
(target.endsWith(tar.slice(pattern + 1)) ||
213+
target.endsWith(tar_ne.slice(pattern + 1)))
214+
215+
// set subpath if match was found
216+
if (match) subpath = pkgsubpath
217+
218+
break
219+
}
220+
221+
break
222+
}
223+
224+
// stop searching for subpath if subpath has been found
225+
if (subpath) break
226+
}
227+
228+
break
229+
default:
230+
break
231+
}
232+
233+
return subpath
234+
}
235+
236+
return find(target, context)
237+
}
238+
239+
export default findSubpath

src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export { default as findDynamicImports } from './find-dynamic-imports'
1212
export { default as findExports } from './find-exports'
1313
export { default as findRequires } from './find-requires'
1414
export { default as findStaticImports } from './find-static-imports'
15+
export { default as findSubpath } from './find-subpath'
1516
export { default as getFormat } from './get-format'
1617
export { default as getSource } from './get-source'
1718
export { default as hasCJSSyntax } from './has-cjs-syntax'

0 commit comments

Comments
 (0)