-
-
Notifications
You must be signed in to change notification settings - Fork 540
Add path mapping support to ESM and CJS loaders #1585
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
3be1fde
c542d98
0cbfa6c
e7082b1
69a397b
2fcb982
48f5262
a19454b
32e26e8
362935b
e573fd7
0012b22
b6352e3
85643fd
9c46688
bede1b6
80746c2
885b7b1
c3dbe73
8c5fd23
e66d236
23f40b1
54fdbba
78652b2
b8e6fb7
2973399
5ffd905
d200301
c621af0
ec038c1
d471d1d
d5728dc
5b686e1
cb3706e
d00bf73
398db86
a91d5d7
7829b54
9fd944e
4e0d363
09cc514
70d9bf5
e50abd4
73d6acc
d02cbed
283309d
f2e2f65
93ac2ac
bf7bfcd
c26b9dd
09ebf63
e0af738
31acd50
36377fc
56ef378
4dcff42
ba176bf
38ad3f5
1687de9
8e9b117
806ce74
0fd8a5c
02818e3
6faedd9
64bb679
058d2e2
4a3b6b4
dc7e950
c5ea9b0
7bfe31a
c8c35ca
99ca516
4fefc57
3a0067e
78e3eda
4f5bc35
ef926b9
7552fc4
6632481
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,30 +1,29 @@ | ||
exports.codes = { | ||
ERR_INPUT_TYPE_NOT_ALLOWED: createErrorCtor(joinArgs('ERR_INPUT_TYPE_NOT_ALLOWED')), | ||
ERR_INVALID_ARG_VALUE: createErrorCtor(joinArgs('ERR_INVALID_ARG_VALUE')), | ||
ERR_INVALID_MODULE_SPECIFIER: createErrorCtor(joinArgs('ERR_INVALID_MODULE_SPECIFIER')), | ||
ERR_INVALID_PACKAGE_CONFIG: createErrorCtor(joinArgs('ERR_INVALID_PACKAGE_CONFIG')), | ||
ERR_INVALID_PACKAGE_TARGET: createErrorCtor(joinArgs('ERR_INVALID_PACKAGE_TARGET')), | ||
ERR_MANIFEST_DEPENDENCY_MISSING: createErrorCtor(joinArgs('ERR_MANIFEST_DEPENDENCY_MISSING')), | ||
ERR_MODULE_NOT_FOUND: createErrorCtor((path, base, type = 'package') => { | ||
return `Cannot find ${type} '${path}' imported from ${base}` | ||
}), | ||
ERR_PACKAGE_IMPORT_NOT_DEFINED: createErrorCtor(joinArgs('ERR_PACKAGE_IMPORT_NOT_DEFINED')), | ||
ERR_PACKAGE_PATH_NOT_EXPORTED: createErrorCtor(joinArgs('ERR_PACKAGE_PATH_NOT_EXPORTED')), | ||
ERR_UNSUPPORTED_DIR_IMPORT: createErrorCtor(joinArgs('ERR_UNSUPPORTED_DIR_IMPORT')), | ||
ERR_UNSUPPORTED_ESM_URL_SCHEME: createErrorCtor(joinArgs('ERR_UNSUPPORTED_ESM_URL_SCHEME')), | ||
ERR_UNKNOWN_FILE_EXTENSION: createErrorCtor(joinArgs('ERR_UNKNOWN_FILE_EXTENSION')), | ||
} | ||
exports.codes = {} | ||
|
||
function joinArgs(name) { | ||
return (...args) => { | ||
return [name, ...args].join(' ') | ||
function defineError(code, buildMessage) { | ||
if (!buildMessage) { | ||
buildMessage = (...args) => args.join(' ') | ||
} | ||
} | ||
|
||
function createErrorCtor(errorMessageCreator) { | ||
return class CustomError extends Error { | ||
exports.codes[code] = class CustomError extends Error { | ||
constructor(...args) { | ||
super(errorMessageCreator(...args)) | ||
super(`${code}: ${buildMessage(...args)}`) | ||
this.code = code | ||
} | ||
} | ||
} | ||
|
||
defineError("ERR_INPUT_TYPE_NOT_ALLOWED") | ||
defineError("ERR_INVALID_ARG_VALUE") | ||
defineError("ERR_INVALID_MODULE_SPECIFIER") | ||
defineError("ERR_INVALID_PACKAGE_CONFIG") | ||
defineError("ERR_INVALID_PACKAGE_TARGET") | ||
defineError("ERR_MANIFEST_DEPENDENCY_MISSING") | ||
defineError("ERR_MODULE_NOT_FOUND", (path, base, type = 'package') => { | ||
return `Cannot find ${type} '${path}' imported from ${base}` | ||
}) | ||
defineError("ERR_PACKAGE_IMPORT_NOT_DEFINED") | ||
defineError("ERR_PACKAGE_PATH_NOT_EXPORTED") | ||
defineError("ERR_UNSUPPORTED_DIR_IMPORT") | ||
defineError("ERR_UNSUPPORTED_ESM_URL_SCHEME") | ||
defineError("ERR_UNKNOWN_FILE_EXTENSION") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
import type * as ts from 'typescript'; | ||
import { join as joinPath } from 'path'; | ||
|
||
// Path mapper returns a list of mapped specifiers or `null` if the | ||
// given `specifier` was not mapped. | ||
type PathMapper = (specifier: string) => string[] | null; | ||
|
||
export function createPathMapper( | ||
compilerOptions: ts.CompilerOptions | ||
): PathMapper { | ||
if (compilerOptions.paths) { | ||
if (!compilerOptions.baseUrl) { | ||
throw new Error(`Compiler option 'baseUrl' required when 'paths' is set`); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point. I assumed that we always get the configuration from To address this I think it make sense to resolve any base URL that is not a URL but a relative path to the current directory. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see, we almost always get the configuration from Maybe the correct approach is to resolve There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Alternatively, we could require There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good, let's do that. In There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, scratch that, seems we always pass options through TypeScript's API to be normalized, even when provided via our API:
So I guess we're all good. |
||
} | ||
|
||
const mappings = Object.entries(compilerOptions.paths).map( | ||
([patternString, outputs]) => ({ | ||
pattern: parsePattern(patternString), | ||
outputs, | ||
}) | ||
); | ||
const mappingConfig = { mappings, baseUrl: compilerOptions.baseUrl }; | ||
|
||
return function map(specifier: string): string[] | null { | ||
return mapPath(mappingConfig, specifier); | ||
}; | ||
} else { | ||
return () => null; | ||
} | ||
} | ||
|
||
interface MappingConfig { | ||
mappings: Mapping[]; | ||
baseUrl: string; | ||
} | ||
|
||
interface Mapping { | ||
pattern: Pattern; | ||
outputs: string[]; | ||
} | ||
|
||
type Pattern = | ||
| { | ||
type: 'wildcard'; | ||
prefix: string; | ||
suffix: string; | ||
} | ||
| { type: 'static'; value: string }; | ||
|
||
function mapPath(mappingConfig: MappingConfig, path: string): string[] | null { | ||
let bestMatchWeight = -Infinity; | ||
let bestMatch: [Mapping, string] | null = null; | ||
|
||
for (const mapping of mappingConfig.mappings) { | ||
if (patternWeight(mapping.pattern) > bestMatchWeight) { | ||
const match = matchPattern(mapping.pattern, path); | ||
if (match !== null) { | ||
bestMatch = [mapping, match]; | ||
bestMatchWeight = patternWeight(mapping.pattern); | ||
} | ||
} | ||
} | ||
|
||
if (bestMatch) { | ||
const [mapping, match] = bestMatch; | ||
return mapping.outputs.map((output) => | ||
joinPath(mappingConfig.baseUrl, output.replace('*', match)) | ||
); | ||
} else { | ||
return null; | ||
} | ||
} | ||
|
||
// Return the submatch when the pattern matches. | ||
// | ||
// For the wildcard pattern string `a*z` and candidate `afooz` this | ||
// returns `foo`. For the static pattern `bar` and the candidate `bar` | ||
// this returns `bar`. | ||
function matchPattern(pattern: Pattern, candidate: string): string | null { | ||
switch (pattern.type) { | ||
case 'wildcard': | ||
if ( | ||
candidate.length >= pattern.prefix.length + pattern.suffix.length && | ||
candidate.startsWith(pattern.prefix) && | ||
candidate.endsWith(pattern.suffix) | ||
) { | ||
return candidate.substring( | ||
pattern.prefix.length, | ||
candidate.length - pattern.suffix.length | ||
); | ||
} else { | ||
return null; | ||
} | ||
case 'static': | ||
if (pattern.value === candidate) { | ||
return candidate; | ||
} else { | ||
return null; | ||
} | ||
} | ||
} | ||
|
||
// Pattern weight to sort best matches. | ||
// | ||
// Static patterns have the highest weight. For wildcard patterns the | ||
// weight is determined by the length of the prefix before the glob | ||
// `*`. | ||
function patternWeight(pattern: Pattern): number { | ||
if (pattern.type === 'wildcard') { | ||
return pattern.prefix.length; | ||
} else { | ||
return Infinity; | ||
} | ||
} | ||
|
||
function parsePattern(patternString: string): Pattern { | ||
const indexOfStar = patternString.indexOf('*'); | ||
if (indexOfStar === -1) { | ||
return { type: 'static', value: patternString }; | ||
} | ||
|
||
if (patternString.indexOf('*', indexOfStar + 1) !== -1) { | ||
throw new Error(`Path pattern ${patternString} contains two wildcards '*'`); | ||
} | ||
|
||
return { | ||
type: 'wildcard', | ||
prefix: patternString.substring(0, indexOfStar), | ||
suffix: patternString.substring(indexOfStar + 1), | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import * as assert from 'assert'; | ||
cspotcode marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// Path is mapped | ||
import map1foo from 'map1/foo.js'; | ||
|
||
// Path is mapped using `.jsx` extension | ||
import map1jsx from 'map1/jsx.js'; | ||
|
||
// Path is mapped using the first candidate `mapped/2-foo` and not `mapped/2a-foo` | ||
import map2foo from 'map2/foo.js'; | ||
|
||
// Path is mapped using the second candidate because the first `mapped/2-bar.ts` | ||
// does not exist | ||
import map2bar from 'map2/bar.js'; | ||
|
||
// Path is mapped using `.js` extension | ||
import map2js from 'map2/js.js'; | ||
|
||
// Path is mapped using the more specific pattern instead of | ||
// `mapped/2-specific/foo | ||
import map2specific from 'map2/specific/foo.js'; | ||
|
||
// Path is mapped when using no wildcard | ||
import mapStatic from 'static'; | ||
|
||
assert.equal(map1foo, 'mapped/1-foo'); | ||
assert.equal(map1jsx, 'mapped/1-jsx'); | ||
assert.equal(map2foo, 'mapped/2-foo'); | ||
assert.equal(map2bar, 'mapped/2a-bar'); | ||
assert.equal(map2js, 'mapped/2a-js'); | ||
assert.equal(map2specific, 'mapped/2-specific-foo'); | ||
assert.equal(mapStatic, 'mapped/static'); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export default 'mapped/1-foo'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
export default 'mapped/1-jsx'; | ||
|
||
const React = { | ||
createElement() {}, | ||
}; | ||
const div = <div></div>; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export default 'mapped/2-foo'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export default 'mapped/2-specific-foo'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export default 'mapped/2a-bar'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export default 'mapped/2a/foo'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export default 'mapped/2a-js'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export default 'mapped/static'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"type": "module" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
{ | ||
"compilerOptions": { | ||
"module": "ESNext", | ||
"allowJs": true, | ||
"jsx": "react", | ||
"baseUrl": "./mapped", | ||
"paths": { | ||
"map1/*": ["./1-*"], | ||
"map2/*": ["./2-*", "./2a-*"], | ||
"map2/specific/*": ["./2-specific-*"], | ||
"static": ["./static.js"] | ||
}, | ||
"moduleResolution": "node" | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.