/* MIT License http://www.opensource.org/licenses/mit-license.php Author Ivan Kopeykin @vankop */ "use strict"; /** @typedef {string|(string|ConditionalMapping)[]} DirectMapping */ /** @typedef {{[k: string]: MappingValue}} ConditionalMapping */ /** @typedef {ConditionalMapping|DirectMapping|null} MappingValue */ /** @typedef {Record|ConditionalMapping|DirectMapping} ExportsField */ /** @typedef {Record} ImportsField */ /** * Processing exports/imports field * @callback FieldProcessor * @param {string} request request * @param {Set} 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} 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} 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; }