Spaces:
Sleeping
Sleeping
/* | |
MIT License http://www.opensource.org/licenses/mit-license.php | |
Author Ivan Kopeykin @vankop | |
*/ | |
; | |
/** @typedef {string|(string|ConditionalMapping)[]} DirectMapping */ | |
/** @typedef {{[k: string]: MappingValue}} ConditionalMapping */ | |
/** @typedef {ConditionalMapping|DirectMapping|null} MappingValue */ | |
/** @typedef {Record<string, MappingValue>|ConditionalMapping|DirectMapping} ExportsField */ | |
/** @typedef {Record<string, MappingValue>} ImportsField */ | |
/** | |
* Processing exports/imports field | |
* @callback FieldProcessor | |
* @param {string} request request | |
* @param {Set<string>} conditionNames condition names | |
* @returns {string[]} resolved paths | |
*/ | |
/* | |
Example exports field: | |
{ | |
".": "./main.js", | |
"./feature": { | |
"browser": "./feature-browser.js", | |
"default": "./feature.js" | |
} | |
} | |
Terminology: | |
Enhanced-resolve name keys ("." and "./feature") as exports field keys. | |
If value is string or string[], mapping is called as a direct mapping | |
and value called as a direct export. | |
If value is key-value object, mapping is called as a conditional mapping | |
and value called as a conditional export. | |
Key in conditional mapping is called condition name. | |
Conditional mapping nested in another conditional mapping is called nested mapping. | |
---------- | |
Example imports field: | |
{ | |
"#a": "./main.js", | |
"#moment": { | |
"browser": "./moment/index.js", | |
"default": "moment" | |
}, | |
"#moment/": { | |
"browser": "./moment/", | |
"default": "moment/" | |
} | |
} | |
Terminology: | |
Enhanced-resolve name keys ("#a" and "#moment/", "#moment") as imports field keys. | |
If value is string or string[], mapping is called as a direct mapping | |
and value called as a direct export. | |
If value is key-value object, mapping is called as a conditional mapping | |
and value called as a conditional export. | |
Key in conditional mapping is called condition name. | |
Conditional mapping nested in another conditional mapping is called nested mapping. | |
*/ | |
const slashCode = "/".charCodeAt(0); | |
const dotCode = ".".charCodeAt(0); | |
const hashCode = "#".charCodeAt(0); | |
const patternRegEx = /\*/g; | |
/** | |
* @param {ExportsField} exportsField the exports field | |
* @returns {FieldProcessor} process callback | |
*/ | |
module.exports.processExportsField = function processExportsField( | |
exportsField | |
) { | |
return createFieldProcessor( | |
buildExportsField(exportsField), | |
request => (request.length === 0 ? "." : "./" + request), | |
assertExportsFieldRequest, | |
assertExportTarget | |
); | |
}; | |
/** | |
* @param {ImportsField} importsField the exports field | |
* @returns {FieldProcessor} process callback | |
*/ | |
module.exports.processImportsField = function processImportsField( | |
importsField | |
) { | |
return createFieldProcessor( | |
buildImportsField(importsField), | |
request => "#" + request, | |
assertImportsFieldRequest, | |
assertImportTarget | |
); | |
}; | |
/** | |
* @param {ExportsField | ImportsField} field root | |
* @param {(s: string) => string} normalizeRequest Normalize request, for `imports` field it adds `#`, for `exports` field it adds `.` or `./` | |
* @param {(s: string) => string} assertRequest assertRequest | |
* @param {(s: string, f: boolean) => void} assertTarget assertTarget | |
* @returns {FieldProcessor} field processor | |
*/ | |
function createFieldProcessor( | |
field, | |
normalizeRequest, | |
assertRequest, | |
assertTarget | |
) { | |
return function fieldProcessor(request, conditionNames) { | |
request = assertRequest(request); | |
const match = findMatch(normalizeRequest(request), field); | |
if (match === null) return []; | |
const [mapping, remainingRequest, isSubpathMapping, isPattern] = match; | |
/** @type {DirectMapping|null} */ | |
let direct = null; | |
if (isConditionalMapping(mapping)) { | |
direct = conditionalMapping( | |
/** @type {ConditionalMapping} */ (mapping), | |
conditionNames | |
); | |
// matching not found | |
if (direct === null) return []; | |
} else { | |
direct = /** @type {DirectMapping} */ (mapping); | |
} | |
return directMapping( | |
remainingRequest, | |
isPattern, | |
isSubpathMapping, | |
direct, | |
conditionNames, | |
assertTarget | |
); | |
}; | |
} | |
/** | |
* @param {string} request request | |
* @returns {string} updated request | |
*/ | |
function assertExportsFieldRequest(request) { | |
if (request.charCodeAt(0) !== dotCode) { | |
throw new Error('Request should be relative path and start with "."'); | |
} | |
if (request.length === 1) return ""; | |
if (request.charCodeAt(1) !== slashCode) { | |
throw new Error('Request should be relative path and start with "./"'); | |
} | |
if (request.charCodeAt(request.length - 1) === slashCode) { | |
throw new Error("Only requesting file allowed"); | |
} | |
return request.slice(2); | |
} | |
/** | |
* @param {string} request request | |
* @returns {string} updated request | |
*/ | |
function assertImportsFieldRequest(request) { | |
if (request.charCodeAt(0) !== hashCode) { | |
throw new Error('Request should start with "#"'); | |
} | |
if (request.length === 1) { | |
throw new Error("Request should have at least 2 characters"); | |
} | |
if (request.charCodeAt(1) === slashCode) { | |
throw new Error('Request should not start with "#/"'); | |
} | |
if (request.charCodeAt(request.length - 1) === slashCode) { | |
throw new Error("Only requesting file allowed"); | |
} | |
return request.slice(1); | |
} | |
/** | |
* @param {string} exp export target | |
* @param {boolean} expectFolder is folder expected | |
*/ | |
function assertExportTarget(exp, expectFolder) { | |
if ( | |
exp.charCodeAt(0) === slashCode || | |
(exp.charCodeAt(0) === dotCode && exp.charCodeAt(1) !== slashCode) | |
) { | |
throw new Error( | |
`Export should be relative path and start with "./", got ${JSON.stringify( | |
exp | |
)}.` | |
); | |
} | |
const isFolder = exp.charCodeAt(exp.length - 1) === slashCode; | |
if (isFolder !== expectFolder) { | |
throw new Error( | |
expectFolder | |
? `Expecting folder to folder mapping. ${JSON.stringify( | |
exp | |
)} should end with "/"` | |
: `Expecting file to file mapping. ${JSON.stringify( | |
exp | |
)} should not end with "/"` | |
); | |
} | |
} | |
/** | |
* @param {string} imp import target | |
* @param {boolean} expectFolder is folder expected | |
*/ | |
function assertImportTarget(imp, expectFolder) { | |
const isFolder = imp.charCodeAt(imp.length - 1) === slashCode; | |
if (isFolder !== expectFolder) { | |
throw new Error( | |
expectFolder | |
? `Expecting folder to folder mapping. ${JSON.stringify( | |
imp | |
)} should end with "/"` | |
: `Expecting file to file mapping. ${JSON.stringify( | |
imp | |
)} should not end with "/"` | |
); | |
} | |
} | |
/** | |
* @param {string} a first string | |
* @param {string} b second string | |
* @returns {number} compare result | |
*/ | |
function patternKeyCompare(a, b) { | |
const aPatternIndex = a.indexOf("*"); | |
const bPatternIndex = b.indexOf("*"); | |
const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1; | |
const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1; | |
if (baseLenA > baseLenB) return -1; | |
if (baseLenB > baseLenA) return 1; | |
if (aPatternIndex === -1) return 1; | |
if (bPatternIndex === -1) return -1; | |
if (a.length > b.length) return -1; | |
if (b.length > a.length) return 1; | |
return 0; | |
} | |
/** | |
* Trying to match request to field | |
* @param {string} request request | |
* @param {ExportsField | ImportsField} field exports or import field | |
* @returns {[MappingValue, string, boolean, boolean]|null} match or null, number is negative and one less when it's a folder mapping, number is request.length + 1 for direct mappings | |
*/ | |
function findMatch(request, field) { | |
if ( | |
Object.prototype.hasOwnProperty.call(field, request) && | |
!request.includes("*") && | |
!request.endsWith("/") | |
) { | |
const target = /** @type {{[k: string]: MappingValue}} */ (field)[request]; | |
return [target, "", false, false]; | |
} | |
/** @type {string} */ | |
let bestMatch = ""; | |
/** @type {string|undefined} */ | |
let bestMatchSubpath; | |
const keys = Object.getOwnPropertyNames(field); | |
for (let i = 0; i < keys.length; i++) { | |
const key = keys[i]; | |
const patternIndex = key.indexOf("*"); | |
if (patternIndex !== -1 && request.startsWith(key.slice(0, patternIndex))) { | |
const patternTrailer = key.slice(patternIndex + 1); | |
if ( | |
request.length >= key.length && | |
request.endsWith(patternTrailer) && | |
patternKeyCompare(bestMatch, key) === 1 && | |
key.lastIndexOf("*") === patternIndex | |
) { | |
bestMatch = key; | |
bestMatchSubpath = request.slice( | |
patternIndex, | |
request.length - patternTrailer.length | |
); | |
} | |
} | |
// For legacy `./foo/` | |
else if ( | |
key[key.length - 1] === "/" && | |
request.startsWith(key) && | |
patternKeyCompare(bestMatch, key) === 1 | |
) { | |
bestMatch = key; | |
bestMatchSubpath = request.slice(key.length); | |
} | |
} | |
if (bestMatch === "") return null; | |
const target = /** @type {{[k: string]: MappingValue}} */ (field)[bestMatch]; | |
const isSubpathMapping = bestMatch.endsWith("/"); | |
const isPattern = bestMatch.includes("*"); | |
return [ | |
target, | |
/** @type {string} */ (bestMatchSubpath), | |
isSubpathMapping, | |
isPattern | |
]; | |
} | |
/** | |
* @param {ConditionalMapping|DirectMapping|null} mapping mapping | |
* @returns {boolean} is conditional mapping | |
*/ | |
function isConditionalMapping(mapping) { | |
return ( | |
mapping !== null && typeof mapping === "object" && !Array.isArray(mapping) | |
); | |
} | |
/** | |
* @param {string|undefined} remainingRequest remaining request when folder mapping, undefined for file mappings | |
* @param {boolean} isPattern true, if mapping is a pattern (contains "*") | |
* @param {boolean} isSubpathMapping true, for subpath mappings | |
* @param {DirectMapping|null} mappingTarget direct export | |
* @param {Set<string>} conditionNames condition names | |
* @param {(d: string, f: boolean) => void} assert asserting direct value | |
* @returns {string[]} mapping result | |
*/ | |
function directMapping( | |
remainingRequest, | |
isPattern, | |
isSubpathMapping, | |
mappingTarget, | |
conditionNames, | |
assert | |
) { | |
if (mappingTarget === null) return []; | |
if (typeof mappingTarget === "string") { | |
return [ | |
targetMapping( | |
remainingRequest, | |
isPattern, | |
isSubpathMapping, | |
mappingTarget, | |
assert | |
) | |
]; | |
} | |
/** @type {string[]} */ | |
const targets = []; | |
for (const exp of mappingTarget) { | |
if (typeof exp === "string") { | |
targets.push( | |
targetMapping( | |
remainingRequest, | |
isPattern, | |
isSubpathMapping, | |
exp, | |
assert | |
) | |
); | |
continue; | |
} | |
const mapping = conditionalMapping(exp, conditionNames); | |
if (!mapping) continue; | |
const innerExports = directMapping( | |
remainingRequest, | |
isPattern, | |
isSubpathMapping, | |
mapping, | |
conditionNames, | |
assert | |
); | |
for (const innerExport of innerExports) { | |
targets.push(innerExport); | |
} | |
} | |
return targets; | |
} | |
/** | |
* @param {string|undefined} remainingRequest remaining request when folder mapping, undefined for file mappings | |
* @param {boolean} isPattern true, if mapping is a pattern (contains "*") | |
* @param {boolean} isSubpathMapping true, for subpath mappings | |
* @param {string} mappingTarget direct export | |
* @param {(d: string, f: boolean) => void} assert asserting direct value | |
* @returns {string} mapping result | |
*/ | |
function targetMapping( | |
remainingRequest, | |
isPattern, | |
isSubpathMapping, | |
mappingTarget, | |
assert | |
) { | |
if (remainingRequest === undefined) { | |
assert(mappingTarget, false); | |
return mappingTarget; | |
} | |
if (isSubpathMapping) { | |
assert(mappingTarget, true); | |
return mappingTarget + remainingRequest; | |
} | |
assert(mappingTarget, false); | |
let result = mappingTarget; | |
if (isPattern) { | |
result = result.replace( | |
patternRegEx, | |
remainingRequest.replace(/\$/g, "$$") | |
); | |
} | |
return result; | |
} | |
/** | |
* @param {ConditionalMapping} conditionalMapping_ conditional mapping | |
* @param {Set<string>} conditionNames condition names | |
* @returns {DirectMapping|null} direct mapping if found | |
*/ | |
function conditionalMapping(conditionalMapping_, conditionNames) { | |
/** @type {[ConditionalMapping, string[], number][]} */ | |
let lookup = [[conditionalMapping_, Object.keys(conditionalMapping_), 0]]; | |
loop: while (lookup.length > 0) { | |
const [mapping, conditions, j] = lookup[lookup.length - 1]; | |
const last = conditions.length - 1; | |
for (let i = j; i < conditions.length; i++) { | |
const condition = conditions[i]; | |
// assert default. Could be last only | |
if (i !== last) { | |
if (condition === "default") { | |
throw new Error("Default condition should be last one"); | |
} | |
} else if (condition === "default") { | |
const innerMapping = mapping[condition]; | |
// is nested | |
if (isConditionalMapping(innerMapping)) { | |
const conditionalMapping = /** @type {ConditionalMapping} */ ( | |
innerMapping | |
); | |
lookup[lookup.length - 1][2] = i + 1; | |
lookup.push([conditionalMapping, Object.keys(conditionalMapping), 0]); | |
continue loop; | |
} | |
return /** @type {DirectMapping} */ (innerMapping); | |
} | |
if (conditionNames.has(condition)) { | |
const innerMapping = mapping[condition]; | |
// is nested | |
if (isConditionalMapping(innerMapping)) { | |
const conditionalMapping = /** @type {ConditionalMapping} */ ( | |
innerMapping | |
); | |
lookup[lookup.length - 1][2] = i + 1; | |
lookup.push([conditionalMapping, Object.keys(conditionalMapping), 0]); | |
continue loop; | |
} | |
return /** @type {DirectMapping} */ (innerMapping); | |
} | |
} | |
lookup.pop(); | |
} | |
return null; | |
} | |
/** | |
* @param {ExportsField} field exports field | |
* @returns {ExportsField} normalized exports field | |
*/ | |
function buildExportsField(field) { | |
// handle syntax sugar, if exports field is direct mapping for "." | |
if (typeof field === "string" || Array.isArray(field)) { | |
return { ".": field }; | |
} | |
const keys = Object.keys(field); | |
for (let i = 0; i < keys.length; i++) { | |
const key = keys[i]; | |
if (key.charCodeAt(0) !== dotCode) { | |
// handle syntax sugar, if exports field is conditional mapping for "." | |
if (i === 0) { | |
while (i < keys.length) { | |
const charCode = keys[i].charCodeAt(0); | |
if (charCode === dotCode || charCode === slashCode) { | |
throw new Error( | |
`Exports field key should be relative path and start with "." (key: ${JSON.stringify( | |
key | |
)})` | |
); | |
} | |
i++; | |
} | |
return { ".": field }; | |
} | |
throw new Error( | |
`Exports field key should be relative path and start with "." (key: ${JSON.stringify( | |
key | |
)})` | |
); | |
} | |
if (key.length === 1) { | |
continue; | |
} | |
if (key.charCodeAt(1) !== slashCode) { | |
throw new Error( | |
`Exports field key should be relative path and start with "./" (key: ${JSON.stringify( | |
key | |
)})` | |
); | |
} | |
} | |
return field; | |
} | |
/** | |
* @param {ImportsField} field imports field | |
* @returns {ImportsField} normalized imports field | |
*/ | |
function buildImportsField(field) { | |
const keys = Object.keys(field); | |
for (let i = 0; i < keys.length; i++) { | |
const key = keys[i]; | |
if (key.charCodeAt(0) !== hashCode) { | |
throw new Error( | |
`Imports field key should start with "#" (key: ${JSON.stringify(key)})` | |
); | |
} | |
if (key.length === 1) { | |
throw new Error( | |
`Imports field key should have at least 2 characters (key: ${JSON.stringify( | |
key | |
)})` | |
); | |
} | |
if (key.charCodeAt(1) === slashCode) { | |
throw new Error( | |
`Imports field key should not start with "#/" (key: ${JSON.stringify( | |
key | |
)})` | |
); | |
} | |
} | |
return field; | |
} | |