Skip to content

Commit 95ee76f

Browse files
committed
[New] add exports support via engines and exportsCategory options
1 parent 0c3ec31 commit 95ee76f

15 files changed

+1834
-19
lines changed

.editorconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ indent_style = tab
2525
indent_style = space
2626
indent_size = 2
2727

28-
[{*.json,Makefile,CONTRIBUTING.md}]
28+
[{*.json,Makefile,CONTRIBUTING.md,readme.markdown}]
2929
max_line_length = unset
3030

3131
[test/{dotdot,resolver,module_dir,multirepo,node_path,pathfilter,precedence}/**/*]

.gitmodules

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[submodule "test/list-exports"]
2+
path = test/list-exports
3+
url = https://github.com/ljharb/list-exports.git
4+
shallow = true

eslint.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default [
1515
'consistent-return': 'off',
1616
curly: 'off',
1717
'dot-notation': ['error', { allowKeywords: true }],
18+
eqeqeq: ['error', 'allow-null'],
1819
'func-name-matching': 'off',
1920
'func-style': 'off',
2021
'global-require': 'warn',
@@ -27,6 +28,7 @@ export default [
2728
'max-statements-per-line': ['error', { max: 2 }],
2829
'max-statements': 'off',
2930
'multiline-comment-style': 'off',
31+
'no-extra-parens': 'off',
3032
'no-magic-numbers': 'off',
3133
'no-shadow': 'off',
3234
'no-use-before-define': 'off',

lib/async.js

Lines changed: 300 additions & 14 deletions
Large diffs are not rendered by default.

lib/exports-resolve.js

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
'use strict';
2+
3+
var objectKeys = require('object-keys');
4+
var $Error = require('es-errors');
5+
6+
// Check if an exports map key looks like a subpath (starts with '.')
7+
function isSubpathKey(key) {
8+
return key.length > 0 && key.charAt(0) === '.';
9+
}
10+
11+
// Normalize the exports field into a map of subpath -> target
12+
function normalizeExports(exportsField) {
13+
if (typeof exportsField === 'string') {
14+
return { __proto__: null, '.': exportsField };
15+
}
16+
if (Array.isArray(exportsField)) {
17+
return { __proto__: null, '.': exportsField };
18+
}
19+
if (typeof exportsField === 'object' && exportsField !== null) {
20+
var keys = objectKeys(exportsField);
21+
if (keys.length === 0) {
22+
return { __proto__: null };
23+
}
24+
// If any key starts with '.', it's a subpath map
25+
// If no key starts with '.', it's a conditions object for '.'
26+
var hasSubpath = false;
27+
for (var i = 0; !hasSubpath && i < keys.length; i++) {
28+
if (isSubpathKey(keys[i])) {
29+
hasSubpath = true;
30+
}
31+
}
32+
// Copy to new object with null prototype
33+
var result = { __proto__: null };
34+
for (var j = 0; j < keys.length; j++) {
35+
result[keys[j]] = exportsField[keys[j]];
36+
}
37+
if (hasSubpath) {
38+
return result;
39+
}
40+
return { __proto__: null, '.': result };
41+
}
42+
return null;
43+
}
44+
45+
// Resolve a target value through conditions
46+
// conditions: array of condition strings, or null (broken: string/array only)
47+
function resolveTarget(target, conditions) {
48+
if (typeof target === 'string') {
49+
return target;
50+
}
51+
52+
if (target === null) {
53+
return null;
54+
}
55+
56+
if (Array.isArray(target)) {
57+
for (var i = 0; i < target.length; i++) {
58+
var resolved = resolveTarget(target[i], conditions);
59+
if (resolved !== null && typeof resolved !== 'undefined') {
60+
return resolved;
61+
}
62+
}
63+
return null;
64+
}
65+
66+
if (typeof target === 'object') {
67+
// If no conditions supported (broken category), can't resolve objects
68+
if (conditions === null) {
69+
return null;
70+
}
71+
var keys = objectKeys(target);
72+
for (var j = 0; j < keys.length; j++) {
73+
var key = keys[j];
74+
for (var k = 0; k < conditions.length; k++) {
75+
if (key === conditions[k]) {
76+
var result = resolveTarget(target[key], conditions);
77+
if (result != null) {
78+
return result;
79+
}
80+
}
81+
}
82+
}
83+
return null;
84+
}
85+
86+
return null;
87+
}
88+
89+
// Validate a resolved path
90+
function validateTarget(target) {
91+
if (typeof target !== 'string') {
92+
return false;
93+
}
94+
if (target.slice(0, 2) !== './') {
95+
return false;
96+
}
97+
if (target.indexOf('/node_modules/') !== -1) {
98+
return false;
99+
}
100+
// Check for '..' path traversal
101+
var parts = target.split('/');
102+
for (var i = 0; i < parts.length; i++) {
103+
if (parts[i] === '..') {
104+
return false;
105+
}
106+
}
107+
return true;
108+
}
109+
110+
// Find the best pattern match for a subpath among keys with '*'
111+
function findPatternMatch(subpath, exportsMap, allowPatternTrailers) {
112+
var keys = objectKeys(exportsMap);
113+
var bestKey = null;
114+
var bestPrefixLen = -1;
115+
var bestMatch = '';
116+
117+
for (var i = 0; i < keys.length; i++) {
118+
var key = keys[i];
119+
var starIndex = key.indexOf('*');
120+
// Key must have exactly one '*'
121+
if (starIndex !== -1 && key.indexOf('*', starIndex + 1) === -1) {
122+
var prefix = key.slice(0, starIndex);
123+
var suffix = key.slice(starIndex + 1);
124+
125+
// Pattern trailers: if suffix is non-empty after *, need allowPatternTrailers
126+
if (suffix.length === 0 || allowPatternTrailers) {
127+
if (
128+
subpath.length >= prefix.length + suffix.length
129+
&& subpath.slice(0, prefix.length) === prefix
130+
&& (suffix.length === 0 || subpath.slice(subpath.length - suffix.length) === suffix)
131+
) {
132+
// Longest prefix wins
133+
if (prefix.length > bestPrefixLen) {
134+
bestPrefixLen = prefix.length;
135+
bestKey = key;
136+
bestMatch = subpath.slice(prefix.length, subpath.length - suffix.length);
137+
}
138+
}
139+
}
140+
}
141+
}
142+
143+
if (bestKey !== null) {
144+
return {
145+
__proto__: null, key: bestKey, match: bestMatch
146+
};
147+
}
148+
return null;
149+
}
150+
151+
// Find directory slash match (for categories that support it)
152+
function findDirSlashMatch(subpath, exportsMap) {
153+
var keys = objectKeys(exportsMap);
154+
var bestKey = null;
155+
var bestPrefixLen = -1;
156+
157+
for (var i = 0; i < keys.length; i++) {
158+
var key = keys[i];
159+
if (key.charAt(key.length - 1) === '/') {
160+
if (subpath.slice(0, key.length) === key && key.length > bestPrefixLen) {
161+
bestPrefixLen = key.length;
162+
bestKey = key;
163+
}
164+
}
165+
}
166+
167+
if (bestKey !== null) {
168+
return {
169+
__proto__: null, key: bestKey, remainder: subpath.slice(bestKey.length)
170+
};
171+
}
172+
return null;
173+
}
174+
175+
// Replace '*' in target string with match value
176+
function substitutePattern(target, match) {
177+
if (typeof target === 'string') {
178+
return target.split('*').join(match);
179+
}
180+
if (Array.isArray(target)) {
181+
var result = [];
182+
for (var i = 0; i < target.length; i++) {
183+
result.push(substitutePattern(target[i], match));
184+
}
185+
return result;
186+
}
187+
if (typeof target === 'object' && target !== null) {
188+
var obj = { __proto__: null };
189+
var keys = objectKeys(target);
190+
for (var j = 0; j < keys.length; j++) {
191+
obj[keys[j]] = substitutePattern(target[keys[j]], match);
192+
}
193+
return obj;
194+
}
195+
return target;
196+
}
197+
198+
// Main exports resolution function
199+
// exportsField: the value of package.json "exports"
200+
// subpath: the subpath to resolve (e.g., "." or "./foo/bar")
201+
// conditions: array of condition strings, or null for broken category
202+
// options: { patterns: boolean, patternTrailers: boolean, dirSlash: boolean }
203+
// Returns: resolved relative path string, or null if no exports field
204+
// Throws: when exports field exists but subpath is not exported
205+
module.exports = function resolveExports(exportsField, subpath, conditions, options) {
206+
if (typeof exportsField === 'undefined') {
207+
return null;
208+
}
209+
210+
var exportsMap = normalizeExports(exportsField);
211+
if (!exportsMap) {
212+
return null;
213+
}
214+
215+
var allowPatterns = options && options.patterns;
216+
var allowPatternTrailers = options && options.patternTrailers;
217+
var allowDirSlash = options && options.dirSlash;
218+
219+
// 1. Exact key match
220+
if (typeof exportsMap[subpath] !== 'undefined') {
221+
var resolved = resolveTarget(exportsMap[subpath], conditions);
222+
if (resolved !== null && typeof resolved !== 'undefined') {
223+
if (!validateTarget(resolved)) {
224+
var invalidError = new $Error('Invalid "exports" target "' + resolved + '" for subpath "' + subpath + '"');
225+
invalidError.code = 'ERR_INVALID_PACKAGE_CONFIG';
226+
throw invalidError;
227+
}
228+
return resolved;
229+
}
230+
// Target exists but resolved to null (explicitly not exported)
231+
var notExportedError = new $Error('Package subpath "' + subpath + '" is not defined by "exports"');
232+
notExportedError.code = 'ERR_PACKAGE_PATH_NOT_EXPORTED';
233+
throw notExportedError;
234+
}
235+
236+
// 2. Pattern match (keys with '*')
237+
if (allowPatterns) {
238+
var patternResult = findPatternMatch(subpath, exportsMap, allowPatternTrailers);
239+
if (patternResult) {
240+
var substituted = substitutePattern(exportsMap[patternResult.key], patternResult.match);
241+
var patternResolved = resolveTarget(substituted, conditions);
242+
if (patternResolved !== null && typeof patternResolved !== 'undefined') {
243+
if (!validateTarget(patternResolved)) {
244+
var patternInvalidError = new $Error('Invalid "exports" target "' + patternResolved + '" for subpath "' + subpath + '"');
245+
patternInvalidError.code = 'ERR_INVALID_PACKAGE_CONFIG';
246+
throw patternInvalidError;
247+
}
248+
return patternResolved;
249+
}
250+
}
251+
}
252+
253+
// 3. Directory slash match (for older categories)
254+
if (allowDirSlash) {
255+
var dirResult = findDirSlashMatch(subpath, exportsMap);
256+
if (dirResult) {
257+
var dirTarget = resolveTarget(exportsMap[dirResult.key], conditions);
258+
if (dirTarget !== null && typeof dirTarget !== 'undefined' && typeof dirTarget === 'string') {
259+
var dirResolved = dirTarget + dirResult.remainder;
260+
if (!validateTarget(dirResolved)) {
261+
var dirInvalidError = new $Error('Invalid "exports" target "' + dirResolved + '" for subpath "' + subpath + '"');
262+
dirInvalidError.code = 'ERR_INVALID_PACKAGE_CONFIG';
263+
throw dirInvalidError;
264+
}
265+
return dirResolved;
266+
}
267+
}
268+
}
269+
270+
var err = new $Error('Package subpath "' + subpath + '" is not defined by "exports"');
271+
err.code = 'ERR_PACKAGE_PATH_NOT_EXPORTED';
272+
throw err;
273+
};

lib/get-exports-category.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use strict';
2+
3+
var isCategory = require('node-exports-info/isCategory');
4+
var getCategoriesForRange = require('node-exports-info/getCategoriesForRange');
5+
var $TypeError = require('es-errors/type');
6+
7+
var selectMostRestrictive = require('./select-most-restrictive');
8+
9+
// Determine the active exports category from resolve options
10+
// Returns null if no exports resolution should be applied
11+
// Returns 'engines' if engines: true (needs consumer package.json lookup)
12+
// Throws TypeError if invalid options are provided
13+
/** @type {(opts?: { exportsCategory?: import('node-exports-info/getCategory').Category, engines?: boolean | string }) => null | import('node-exports-info/getCategory').Category} */
14+
module.exports = function getExportsCategory(opts) {
15+
if (!opts) {
16+
return null;
17+
}
18+
19+
var hasCategory = typeof opts.exportsCategory !== 'undefined';
20+
var engines = opts.engines;
21+
var hasEngines = typeof engines !== 'undefined' && engines !== false;
22+
23+
if (hasCategory && hasEngines) {
24+
throw new $TypeError('`exportsCategory` and `engines` are mutually exclusive.');
25+
}
26+
27+
if (hasCategory) {
28+
if (!isCategory(opts.exportsCategory)) {
29+
var catError = new $TypeError('Invalid exports category: "' + opts.exportsCategory + '"');
30+
catError.code = 'INVALID_EXPORTS_CATEGORY';
31+
throw catError;
32+
}
33+
return opts.exportsCategory;
34+
}
35+
36+
if (hasEngines) {
37+
// engines: true means read from consumer's package.json
38+
if (engines === true) {
39+
return 'engines';
40+
}
41+
42+
// engines must be a non-empty string (semver range)
43+
if (typeof engines !== 'string' || engines === '') {
44+
throw new $TypeError('`engines` must be `true`, `false`, or a non-empty string semver range.');
45+
}
46+
47+
var categories = getCategoriesForRange(engines);
48+
return selectMostRestrictive(categories);
49+
}
50+
51+
return null;
52+
};

lib/homedir.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ module.exports = os.homedir || function homedir() {
1717
}
1818

1919
if (process.platform === 'linux') {
20-
return home || (process.getuid() === 0 ? '/root' : (user ? '/home/' + user : null)); // eslint-disable-line no-extra-parens
20+
return home || (process.getuid() === 0 ? '/root' : (user ? '/home/' + user : null));
2121
}
2222

2323
return home || null;

lib/parse-package-specifier.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use strict';
2+
3+
/** @type {(x: string) => { __proto__: null, name: string, subpath: string }} */
4+
module.exports = function parsePackageSpecifier(x) {
5+
if (x.charAt(0) === '@') {
6+
var slashIndex = x.indexOf('/');
7+
if (slashIndex === -1) {
8+
return {
9+
__proto__: null, name: x, subpath: '.'
10+
};
11+
}
12+
var secondSlash = x.indexOf('/', slashIndex + 1);
13+
if (secondSlash === -1) {
14+
return {
15+
__proto__: null, name: x, subpath: '.'
16+
};
17+
}
18+
return {
19+
__proto__: null, name: x.slice(0, secondSlash), subpath: '.' + x.slice(secondSlash)
20+
};
21+
}
22+
var firstSlash = x.indexOf('/');
23+
if (firstSlash === -1) {
24+
return {
25+
__proto__: null, name: x, subpath: '.'
26+
};
27+
}
28+
return {
29+
__proto__: null, name: x.slice(0, firstSlash), subpath: '.' + x.slice(firstSlash)
30+
};
31+
};

0 commit comments

Comments
 (0)