fd22b0bf58
V2 (Laravel re-make)
668 lines
16 KiB
JavaScript
Vendored
668 lines
16 KiB
JavaScript
Vendored
/*
|
|
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<string, MappingValue>|ConditionalMapping|DirectMapping} ExportsField */
|
|
/** @typedef {Record<string, MappingValue>} ImportsField */
|
|
|
|
/**
|
|
* @typedef {Object} PathTreeNode
|
|
* @property {Map<string, PathTreeNode>|null} children
|
|
* @property {MappingValue} folder
|
|
* @property {Map<string, MappingValue>|null} wildcards
|
|
* @property {Map<string, MappingValue>} files
|
|
*/
|
|
|
|
/**
|
|
* 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);
|
|
|
|
/**
|
|
* @param {ExportsField} exportsField the exports field
|
|
* @returns {FieldProcessor} process callback
|
|
*/
|
|
module.exports.processExportsField = function processExportsField(
|
|
exportsField
|
|
) {
|
|
return createFieldProcessor(
|
|
buildExportsFieldPathTree(exportsField),
|
|
assertExportsFieldRequest,
|
|
assertExportTarget
|
|
);
|
|
};
|
|
|
|
/**
|
|
* @param {ImportsField} importsField the exports field
|
|
* @returns {FieldProcessor} process callback
|
|
*/
|
|
module.exports.processImportsField = function processImportsField(
|
|
importsField
|
|
) {
|
|
return createFieldProcessor(
|
|
buildImportsFieldPathTree(importsField),
|
|
assertImportsFieldRequest,
|
|
assertImportTarget
|
|
);
|
|
};
|
|
|
|
/**
|
|
* @param {PathTreeNode} treeRoot root
|
|
* @param {(s: string) => string} assertRequest assertRequest
|
|
* @param {(s: string, f: boolean) => void} assertTarget assertTarget
|
|
* @returns {FieldProcessor} field processor
|
|
*/
|
|
function createFieldProcessor(treeRoot, assertRequest, assertTarget) {
|
|
return function fieldProcessor(request, conditionNames) {
|
|
request = assertRequest(request);
|
|
|
|
const match = findMatch(request, treeRoot);
|
|
|
|
if (match === null) return [];
|
|
|
|
const [mapping, remainRequestIndex] = 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);
|
|
}
|
|
|
|
const remainingRequest =
|
|
remainRequestIndex === request.length + 1
|
|
? undefined
|
|
: remainRequestIndex < 0
|
|
? request.slice(-remainRequestIndex - 1)
|
|
: request.slice(remainRequestIndex);
|
|
|
|
return directMapping(
|
|
remainingRequest,
|
|
remainRequestIndex < 0,
|
|
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 "/"`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trying to match request to field
|
|
* @param {string} request request
|
|
* @param {PathTreeNode} treeRoot path tree root
|
|
* @returns {[MappingValue, number]|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, treeRoot) {
|
|
if (request.length === 0) {
|
|
const value = treeRoot.files.get("");
|
|
|
|
return value ? [value, 1] : null;
|
|
}
|
|
|
|
if (
|
|
treeRoot.children === null &&
|
|
treeRoot.folder === null &&
|
|
treeRoot.wildcards === null
|
|
) {
|
|
const value = treeRoot.files.get(request);
|
|
|
|
return value ? [value, request.length + 1] : null;
|
|
}
|
|
|
|
let node = treeRoot;
|
|
let lastNonSlashIndex = 0;
|
|
let slashIndex = request.indexOf("/", 0);
|
|
|
|
/** @type {[MappingValue, number]|null} */
|
|
let lastFolderMatch = null;
|
|
|
|
const applyFolderMapping = () => {
|
|
const folderMapping = node.folder;
|
|
if (folderMapping) {
|
|
if (lastFolderMatch) {
|
|
lastFolderMatch[0] = folderMapping;
|
|
lastFolderMatch[1] = -lastNonSlashIndex - 1;
|
|
} else {
|
|
lastFolderMatch = [folderMapping, -lastNonSlashIndex - 1];
|
|
}
|
|
}
|
|
};
|
|
|
|
const applyWildcardMappings = (wildcardMappings, remainingRequest) => {
|
|
if (wildcardMappings) {
|
|
for (const [key, target] of wildcardMappings) {
|
|
if (remainingRequest.startsWith(key)) {
|
|
if (!lastFolderMatch) {
|
|
lastFolderMatch = [target, lastNonSlashIndex + key.length];
|
|
} else if (lastFolderMatch[1] < lastNonSlashIndex + key.length) {
|
|
lastFolderMatch[0] = target;
|
|
lastFolderMatch[1] = lastNonSlashIndex + key.length;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
while (slashIndex !== -1) {
|
|
applyFolderMapping();
|
|
|
|
const wildcardMappings = node.wildcards;
|
|
|
|
if (!wildcardMappings && node.children === null) return lastFolderMatch;
|
|
|
|
const folder = request.slice(lastNonSlashIndex, slashIndex);
|
|
|
|
applyWildcardMappings(wildcardMappings, folder);
|
|
|
|
if (node.children === null) return lastFolderMatch;
|
|
|
|
const newNode = node.children.get(folder);
|
|
|
|
if (!newNode) {
|
|
return lastFolderMatch;
|
|
}
|
|
|
|
node = newNode;
|
|
lastNonSlashIndex = slashIndex + 1;
|
|
slashIndex = request.indexOf("/", lastNonSlashIndex);
|
|
}
|
|
|
|
const remainingRequest =
|
|
lastNonSlashIndex > 0 ? request.slice(lastNonSlashIndex) : request;
|
|
|
|
const value = node.files.get(remainingRequest);
|
|
|
|
if (value) {
|
|
return [value, request.length + 1];
|
|
}
|
|
|
|
applyFolderMapping();
|
|
|
|
applyWildcardMappings(node.wildcards, remainingRequest);
|
|
|
|
return lastFolderMatch;
|
|
}
|
|
|
|
/**
|
|
* @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} subpathMapping 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,
|
|
subpathMapping,
|
|
mappingTarget,
|
|
conditionNames,
|
|
assert
|
|
) {
|
|
if (mappingTarget === null) return [];
|
|
|
|
if (typeof mappingTarget === "string") {
|
|
return [
|
|
targetMapping(remainingRequest, subpathMapping, mappingTarget, assert)
|
|
];
|
|
}
|
|
|
|
const targets = [];
|
|
|
|
for (const exp of mappingTarget) {
|
|
if (typeof exp === "string") {
|
|
targets.push(
|
|
targetMapping(remainingRequest, subpathMapping, exp, assert)
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const mapping = conditionalMapping(exp, conditionNames);
|
|
if (!mapping) continue;
|
|
const innerExports = directMapping(
|
|
remainingRequest,
|
|
subpathMapping,
|
|
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} subpathMapping 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,
|
|
subpathMapping,
|
|
mappingTarget,
|
|
assert
|
|
) {
|
|
if (remainingRequest === undefined) {
|
|
assert(mappingTarget, false);
|
|
return mappingTarget;
|
|
}
|
|
if (subpathMapping) {
|
|
assert(mappingTarget, true);
|
|
return mappingTarget + remainingRequest;
|
|
}
|
|
assert(mappingTarget, false);
|
|
return mappingTarget.replace(/\*/g, remainingRequest.replace(/\$/g, "$$"));
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* Internal helper to create path tree node
|
|
* to ensure that each node gets the same hidden class
|
|
* @returns {PathTreeNode} node
|
|
*/
|
|
function createNode() {
|
|
return {
|
|
children: null,
|
|
folder: null,
|
|
wildcards: null,
|
|
files: new Map()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Internal helper for building path tree
|
|
* @param {PathTreeNode} root root
|
|
* @param {string} path path
|
|
* @param {MappingValue} target target
|
|
*/
|
|
function walkPath(root, path, target) {
|
|
if (path.length === 0) {
|
|
root.folder = target;
|
|
return;
|
|
}
|
|
|
|
let node = root;
|
|
// Typical path tree can looks like
|
|
// root
|
|
// - files: ["a.js", "b.js"]
|
|
// - children:
|
|
// node1:
|
|
// - files: ["a.js", "b.js"]
|
|
let lastNonSlashIndex = 0;
|
|
let slashIndex = path.indexOf("/", 0);
|
|
|
|
while (slashIndex !== -1) {
|
|
const folder = path.slice(lastNonSlashIndex, slashIndex);
|
|
let newNode;
|
|
|
|
if (node.children === null) {
|
|
newNode = createNode();
|
|
node.children = new Map();
|
|
node.children.set(folder, newNode);
|
|
} else {
|
|
newNode = node.children.get(folder);
|
|
|
|
if (!newNode) {
|
|
newNode = createNode();
|
|
node.children.set(folder, newNode);
|
|
}
|
|
}
|
|
|
|
node = newNode;
|
|
lastNonSlashIndex = slashIndex + 1;
|
|
slashIndex = path.indexOf("/", lastNonSlashIndex);
|
|
}
|
|
|
|
if (lastNonSlashIndex >= path.length) {
|
|
node.folder = target;
|
|
} else {
|
|
const file = lastNonSlashIndex > 0 ? path.slice(lastNonSlashIndex) : path;
|
|
if (file.endsWith("*")) {
|
|
if (node.wildcards === null) node.wildcards = new Map();
|
|
node.wildcards.set(file.slice(0, -1), target);
|
|
} else {
|
|
node.files.set(file, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {ExportsField} field exports field
|
|
* @returns {PathTreeNode} tree root
|
|
*/
|
|
function buildExportsFieldPathTree(field) {
|
|
const root = createNode();
|
|
|
|
// handle syntax sugar, if exports field is direct mapping for "."
|
|
if (typeof field === "string") {
|
|
root.files.set("", field);
|
|
|
|
return root;
|
|
} else if (Array.isArray(field)) {
|
|
root.files.set("", field.slice());
|
|
|
|
return root;
|
|
}
|
|
|
|
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++;
|
|
}
|
|
|
|
root.files.set("", field);
|
|
return root;
|
|
}
|
|
|
|
throw new Error(
|
|
`Exports field key should be relative path and start with "." (key: ${JSON.stringify(
|
|
key
|
|
)})`
|
|
);
|
|
}
|
|
|
|
if (key.length === 1) {
|
|
root.files.set("", field[key]);
|
|
continue;
|
|
}
|
|
|
|
if (key.charCodeAt(1) !== slashCode) {
|
|
throw new Error(
|
|
`Exports field key should be relative path and start with "./" (key: ${JSON.stringify(
|
|
key
|
|
)})`
|
|
);
|
|
}
|
|
|
|
walkPath(root, key.slice(2), field[key]);
|
|
}
|
|
|
|
return root;
|
|
}
|
|
|
|
/**
|
|
* @param {ImportsField} field imports field
|
|
* @returns {PathTreeNode} root
|
|
*/
|
|
function buildImportsFieldPathTree(field) {
|
|
const root = createNode();
|
|
|
|
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
|
|
)})`
|
|
);
|
|
}
|
|
|
|
walkPath(root, key.slice(1), field[key]);
|
|
}
|
|
|
|
return root;
|
|
}
|