webpack
Version:
Packs ECMAScript/CommonJs/AMD modules for the browser. Allows you to split your codebase into multiple bundles, which can be loaded on demand. Supports loaders to preprocess files, i.e. json, jsx, es7, css, less, ... and your custom stuff.
832 lines (758 loc) • 21.8 kB
JavaScript
/*
MIT License http://www.opensource.org/licenses/mit-license.php
Author Tobias Koppers @sokra
*/
"use strict";
const path = require("path");
const webpackSchema = require("../../schemas/WebpackOptions.json");
/** @import { JSONSchema4, JSONSchema6, JSONSchema7 } from "json-schema" */
/** @typedef {JSONSchema4 | JSONSchema6 | JSONSchema7} JSONSchema */
/** @typedef {JSONSchema & { absolutePath: boolean, instanceof: string, cli: { helper?: boolean, exclude?: boolean, description?: string, negatedDescription?: string, resetDescription?: string } }} Schema */
/**
* Defines the path item type used by this module.
* @typedef {object} PathItem
* @property {Schema} schema the part of the schema
* @property {string} path the path in the config
* @property {string} segment the JSON pointer segment of this schema part (absolute when starting with "#")
*/
/** @typedef {"unknown-argument" | "unexpected-non-array-in-path" | "unexpected-non-object-in-path" | "prototype-pollution-in-path" | "multiple-values-unexpected" | "invalid-value"} ProblemType */
/** @typedef {string | number | boolean | RegExp} Value */
/**
* Defines the problem type used by this module.
* @typedef {object} Problem
* @property {ProblemType} type
* @property {string} path
* @property {string} argument
* @property {Value=} value
* @property {number=} index
* @property {string=} expected
*/
/**
* Defines the local problem type used by this module.
* @typedef {object} LocalProblem
* @property {ProblemType} type
* @property {string} path
* @property {string=} expected
*/
/** @typedef {{ [key: string]: EnumValue }} EnumValueObject */
/** @typedef {EnumValue[]} EnumValueArray */
/** @typedef {string | number | boolean | EnumValueObject | EnumValueArray | null} EnumValue */
/**
* Defines the argument config type used by this module.
* @typedef {object} ArgumentConfig
* @property {string=} description
* @property {string=} negatedDescription
* @property {string} path
* @property {boolean} multiple
* @property {"enum" | "string" | "path" | "number" | "boolean" | "RegExp" | "reset"} type
* @property {EnumValue[]=} values
*/
/** @typedef {"string" | "number" | "boolean"} SimpleType */
/**
* Defines the argument type used by this module.
* @typedef {object} Argument
* @property {string | undefined} description
* @property {SimpleType} simpleType
* @property {boolean} multiple
* @property {ArgumentConfig[]} configs
*/
/** @typedef {Record<string, Argument>} Flags */
/** @typedef {Record<string, EXPECTED_ANY>} ObjectConfiguration */
/**
* Returns object of arguments.
* @param {Schema=} schema a json schema to create arguments for (by default webpack schema is used)
* @returns {Flags} object of arguments
*/
const getArguments = (schema = webpackSchema) => {
/** @type {Flags} */
const flags = {};
/**
* Path to argument name.
* @param {string} input input
* @returns {string} result
*/
const pathToArgumentName = (input) =>
input
.replace(/\./g, "-")
.replace(/\[\]/g, "")
.replace(
/(\p{Uppercase_Letter}+|\p{Lowercase_Letter}|\d)(\p{Uppercase_Letter}+)/gu,
"$1-$2"
)
.replace(/-?[^\p{Uppercase_Letter}\p{Lowercase_Letter}\d]+/gu, "-")
.toLowerCase();
/**
* Returns schema part.
* @param {string} path path
* @returns {Schema} schema part
*/
const getSchemaPart = (path) => {
const newPath = path.split("/");
let schemaPart = schema;
for (let i = 1; i < newPath.length; i++) {
const inner = schemaPart[/** @type {keyof Schema} */ (newPath[i])];
if (!inner) {
break;
}
schemaPart = inner;
}
return schemaPart;
};
/**
* Returns description.
* @param {PathItem[]} path path in the schema
* @returns {string | undefined} description
*/
const getDescription = (path) => {
for (const { schema } of path) {
if (schema.cli) {
if (schema.cli.helper) continue;
if (schema.cli.description) return schema.cli.description;
}
if (schema.description) return schema.description;
}
};
/**
* Gets negated description.
* @param {PathItem[]} path path in the schema
* @returns {string | undefined} negative description
*/
const getNegatedDescription = (path) => {
for (const { schema } of path) {
if (schema.cli) {
if (schema.cli.helper) continue;
if (schema.cli.negatedDescription) return schema.cli.negatedDescription;
}
}
};
/**
* Gets reset description.
* @param {PathItem[]} path path in the schema
* @returns {string | undefined} reset description
*/
const getResetDescription = (path) => {
for (const { schema } of path) {
if (schema.cli) {
if (schema.cli.helper) continue;
if (schema.cli.resetDescription) return schema.cli.resetDescription;
}
}
};
/**
* Schema to argument config.
* @param {Schema} schemaPart schema
* @returns {Pick<ArgumentConfig, "type" | "values"> | undefined} partial argument config
*/
const schemaToArgumentConfig = (schemaPart) => {
if (schemaPart.enum) {
return {
type: "enum",
values: schemaPart.enum
};
}
if (schemaPart.const !== undefined) {
return {
type: "enum",
values: [schemaPart.const]
};
}
switch (schemaPart.type) {
case "number":
return {
type: "number"
};
case "string":
return {
type: schemaPart.absolutePath ? "path" : "string"
};
case "boolean":
return {
type: "boolean"
};
}
if (schemaPart.instanceof === "RegExp") {
return {
type: "RegExp"
};
}
return undefined;
};
/**
* Adds the provided path to this object.
* @param {PathItem[]} path path in the schema
* @returns {void}
*/
const addResetFlag = (path) => {
const schemaPath = path[0].path;
const name = pathToArgumentName(`${schemaPath}.reset`);
const description =
getResetDescription(path) ||
`Clear all items provided in '${schemaPath}' configuration. ${getDescription(
path
)}`;
flags[name] = {
configs: [
{
type: "reset",
multiple: false,
description,
path: schemaPath
}
],
description: undefined,
simpleType:
/** @type {SimpleType} */
(/** @type {SimpleType | undefined} */ (undefined)),
multiple: /** @type {boolean} */ (
/** @type {boolean | undefined} */ (undefined)
)
};
};
/**
* Joins the per-item segments into a JSON pointer (built only when needed).
* @param {PathItem[]} path full path in schema (deepest item first)
* @returns {string} JSON pointer to the deepest schema part
*/
const getOriginPath = (path) => {
let origin = "";
for (let i = path.length - 1; i >= 0; i--) {
const { segment } = path[i];
origin = segment[0] === "#" ? segment : origin + segment;
}
return origin;
};
/**
* Adds the provided path to this object.
* @param {PathItem[]} path full path in schema
* @param {boolean} multiple inside of an array
* @returns {number} number of arguments added
*/
const addFlag = (path, multiple) => {
const argConfigBase = schemaToArgumentConfig(path[0].schema);
if (!argConfigBase) return 0;
const negatedDescription = getNegatedDescription(path);
const name = pathToArgumentName(path[0].path);
/** @type {ArgumentConfig} */
const argConfig = {
...argConfigBase,
multiple,
description: getDescription(path),
path: path[0].path
};
if (negatedDescription) {
argConfig.negatedDescription = negatedDescription;
}
if (!flags[name]) {
flags[name] = {
configs: [],
description: undefined,
simpleType:
/** @type {SimpleType} */
(/** @type {SimpleType | undefined} */ (undefined)),
multiple: /** @type {boolean} */ (
/** @type {boolean | undefined} */ (undefined)
)
};
}
if (
flags[name].configs.some(
(item) => JSON.stringify(item) === JSON.stringify(argConfig)
)
) {
return 0;
}
if (
flags[name].configs.some(
(item) => item.type === argConfig.type && item.multiple !== multiple
)
) {
if (multiple) {
throw new Error(
`Conflicting schema for ${path[0].path} (${getOriginPath(
path
)}) with ${
argConfig.type
} type (array type must be before single item type)`
);
}
return 0;
}
flags[name].configs.push(argConfig);
return 1;
};
// TODO support `not`
/**
* Returns added arguments.
* @param {Schema} schemaPart the current schema
* @param {string} schemaPath the current path in the config
* @param {PathItem[]} path all previous visited schemaParts
* @param {string | null} inArray if inside of an array, the path to the array
* @param {string} segment the JSON pointer segment of the current schema
* @returns {number} added arguments
*/
const traverse = (
schemaPart,
schemaPath = "",
path = [],
inArray = null,
segment = "#"
) => {
while (schemaPart.$ref) {
segment = schemaPart.$ref;
schemaPart = getSchemaPart(schemaPart.$ref);
}
const repetitions = path.filter(({ schema }) => schema === schemaPart);
if (
repetitions.length >= 2 ||
repetitions.some(({ path }) => path === schemaPath)
) {
return 0;
}
if (schemaPart.cli && schemaPart.cli.exclude) return 0;
/** @type {PathItem[]} */
const fullPath = [
{ schema: schemaPart, path: schemaPath, segment },
...path
];
let addedArguments = 0;
addedArguments += addFlag(fullPath, Boolean(inArray));
// Collect flags from both branches of a conditional schema.
const conditional = /** @type {JSONSchema7} */ (schemaPart);
if (conditional.if) {
for (const key of /** @type {const} */ (["if", "then", "else"])) {
const subSchema = conditional[key];
if (subSchema && typeof subSchema === "object") {
addedArguments += traverse(
/** @type {Schema} */
(subSchema),
schemaPath,
fullPath,
inArray,
`/${key}`
);
}
}
}
if (schemaPart.type === "object") {
if (schemaPart.properties) {
for (const property of Object.keys(schemaPart.properties)) {
addedArguments += traverse(
/** @type {Schema} */
(schemaPart.properties[property]),
schemaPath ? `${schemaPath}.${property}` : property,
fullPath,
inArray,
`/properties/${property}`
);
}
}
return addedArguments;
}
if (schemaPart.type === "array") {
if (inArray) {
return 0;
}
if (Array.isArray(schemaPart.items)) {
const i = 0;
for (const item of schemaPart.items) {
addedArguments += traverse(
/** @type {Schema} */
(item),
`${schemaPath}.${i}`,
fullPath,
schemaPath,
`/items/${i}`
);
}
return addedArguments;
}
addedArguments += traverse(
/** @type {Schema} */
(schemaPart.items),
`${schemaPath}[]`,
fullPath,
schemaPath,
"/items"
);
if (addedArguments > 0) {
addResetFlag(fullPath);
addedArguments++;
}
return addedArguments;
}
const ofKey = schemaPart.oneOf
? "oneOf"
: schemaPart.anyOf
? "anyOf"
: schemaPart.allOf
? "allOf"
: undefined;
if (ofKey) {
const items = /** @type {Schema[]} */ (schemaPart[ofKey]);
for (let i = 0; i < items.length; i++) {
addedArguments += traverse(
items[i],
schemaPath,
fullPath,
inArray,
`/${ofKey}/${i}`
);
}
return addedArguments;
}
return addedArguments;
};
traverse(schema);
// Summarize flags
for (const name of Object.keys(flags)) {
/** @type {Argument} */
const argument = flags[name];
argument.description = argument.configs.reduce((desc, { description }) => {
if (!desc) return description;
if (!description) return desc;
if (desc.includes(description)) return desc;
return `${desc} ${description}`;
}, /** @type {string | undefined} */ (undefined));
argument.simpleType =
/** @type {SimpleType} */
(
argument.configs.reduce((t, argConfig) => {
/** @type {SimpleType} */
let type = "string";
switch (argConfig.type) {
case "number":
type = "number";
break;
case "reset":
case "boolean":
type = "boolean";
break;
case "enum": {
const values =
/** @type {NonNullable<ArgumentConfig["values"]>} */
(argConfig.values);
if (values.every((v) => typeof v === "boolean")) type = "boolean";
if (values.every((v) => typeof v === "number")) type = "number";
break;
}
}
if (t === undefined) return type;
return t === type ? t : "string";
}, /** @type {SimpleType | undefined} */ (undefined))
);
argument.multiple = argument.configs.some((c) => c.multiple);
}
return flags;
};
/** @typedef {ObjectConfiguration | ParsedValue | undefined} ConfigurationValue */
/** @type {WeakMap<ConfigurationValue[], number>} */
const cliAddedItems = new WeakMap();
/** @typedef {string | number} Property */
/**
* Whether a path segment would walk into the prototype chain.
* @param {string} name path segment
* @returns {boolean} true when the segment is unsafe to write through
*/
const isUnsafeKey = (name) =>
name === "__proto__" || name === "constructor" || name === "prototype";
/**
* Gets object and property.
* @param {ObjectConfiguration} config configuration
* @param {string} schemaPath path in the config
* @param {number | undefined} index index of value when multiple values are provided, otherwise undefined
* @returns {{ problem?: LocalProblem, object?: ObjectConfiguration, property?: Property, value?: ConfigurationValue }} problem or object with property and value
*/
const getObjectAndProperty = (config, schemaPath, index = 0) => {
if (!schemaPath) return { value: config };
const parts = schemaPath.split(".");
const property = /** @type {string} */ (parts.pop());
let current = config;
let i = 0;
for (const part of parts) {
const isArray = part.endsWith("[]");
const name = isArray ? part.slice(0, -2) : part;
if (isUnsafeKey(name)) {
return {
problem: {
type: "prototype-pollution-in-path",
path: parts.slice(0, i).join(".")
}
};
}
let value = current[name];
if (isArray) {
if (value === undefined) {
value = {};
current[name] = [...Array.from({ length: index }), value];
cliAddedItems.set(current[name], index + 1);
} else if (!Array.isArray(value)) {
return {
problem: {
type: "unexpected-non-array-in-path",
path: parts.slice(0, i).join(".")
}
};
} else {
let addedItems = cliAddedItems.get(value) || 0;
while (addedItems <= index) {
value.push(undefined);
addedItems++;
}
cliAddedItems.set(value, addedItems);
const x = value.length - addedItems + index;
if (value[x] === undefined) {
value[x] = {};
} else if (value[x] === null || typeof value[x] !== "object") {
return {
problem: {
type: "unexpected-non-object-in-path",
path: parts.slice(0, i).join(".")
}
};
}
value = value[x];
}
} else if (value === undefined) {
value = current[name] = {};
} else if (value === null || typeof value !== "object") {
return {
problem: {
type: "unexpected-non-object-in-path",
path: parts.slice(0, i).join(".")
}
};
}
current = value;
i++;
}
if (isUnsafeKey(property.endsWith("[]") ? property.slice(0, -2) : property)) {
return {
problem: {
type: "prototype-pollution-in-path",
path: parts.join(".")
}
};
}
const value = current[property];
if (property.endsWith("[]")) {
const name = property.slice(0, -2);
const value = current[name];
if (value === undefined) {
current[name] = [...Array.from({ length: index }), undefined];
cliAddedItems.set(current[name], index + 1);
return { object: current[name], property: index, value: undefined };
} else if (!Array.isArray(value)) {
current[name] = [value, ...Array.from({ length: index }), undefined];
cliAddedItems.set(current[name], index + 1);
return { object: current[name], property: index + 1, value: undefined };
}
let addedItems = cliAddedItems.get(value) || 0;
while (addedItems <= index) {
value.push(undefined);
addedItems++;
}
cliAddedItems.set(value, addedItems);
const x = value.length - addedItems + index;
if (value[x] === undefined) {
value[x] = {};
} else if (value[x] === null || typeof value[x] !== "object") {
return {
problem: {
type: "unexpected-non-object-in-path",
path: schemaPath
}
};
}
return {
object: value,
property: x,
value: value[x]
};
}
return { object: current, property, value };
};
/**
* Updates value using the provided config.
* @param {ObjectConfiguration} config configuration
* @param {string} schemaPath path in the config
* @param {ParsedValue} value parsed value
* @param {number | undefined} index index of value when multiple values are provided, otherwise undefined
* @returns {LocalProblem | null} problem or null for success
*/
const setValue = (config, schemaPath, value, index) => {
const { problem, object, property } = getObjectAndProperty(
config,
schemaPath,
index
);
if (problem) return problem;
/** @type {ObjectConfiguration} */
(object)[/** @type {Property} */ (property)] = value;
return null;
};
/**
* Process argument config.
* @param {ArgumentConfig} argConfig processing instructions
* @param {ObjectConfiguration} config configuration
* @param {Value} value the value
* @param {number | undefined} index the index if multiple values provided
* @returns {LocalProblem | null} a problem if any
*/
const processArgumentConfig = (argConfig, config, value, index) => {
if (index !== undefined && !argConfig.multiple) {
return {
type: "multiple-values-unexpected",
path: argConfig.path
};
}
const parsed = parseValueForArgumentConfig(argConfig, value);
if (parsed === undefined) {
return {
type: "invalid-value",
path: argConfig.path,
expected: getExpectedValue(argConfig)
};
}
const problem = setValue(config, argConfig.path, parsed, index);
if (problem) return problem;
return null;
};
/**
* Gets expected value.
* @param {ArgumentConfig} argConfig processing instructions
* @returns {string | undefined} expected message
*/
const getExpectedValue = (argConfig) => {
switch (argConfig.type) {
case "boolean":
return "true | false";
case "RegExp":
return "regular expression (example: /ab?c*/)";
case "enum":
return /** @type {NonNullable<ArgumentConfig["values"]>} */ (
argConfig.values
)
.map((v) => `${v}`)
.join(" | ");
case "reset":
return "true (will reset the previous value to an empty array)";
default:
return argConfig.type;
}
};
/** @typedef {null | string | number | boolean | RegExp | EnumValue | []} ParsedValue */
const DECIMAL_NUMBER_REGEXP = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
/**
* Parses value for argument config.
* @param {ArgumentConfig} argConfig processing instructions
* @param {Value} value the value
* @returns {ParsedValue | undefined} parsed value
*/
const parseValueForArgumentConfig = (argConfig, value) => {
switch (argConfig.type) {
case "string":
if (typeof value === "string") {
return value;
}
break;
case "path":
if (typeof value === "string") {
return path.resolve(value);
}
break;
case "number":
if (typeof value === "number") return value;
if (typeof value === "string" && DECIMAL_NUMBER_REGEXP.test(value)) {
const n = Number(value);
if (!Number.isNaN(n)) return n;
}
break;
case "boolean":
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
break;
case "RegExp":
if (value instanceof RegExp) return value;
if (typeof value === "string") {
// cspell:word yugi
const match = /^\/(.*)\/([yugi]*)$/.exec(value);
if (match && !/[^\\]\//.test(match[1])) {
return new RegExp(match[1], match[2]);
}
}
break;
case "enum": {
const values =
/** @type {EnumValue[]} */
(argConfig.values);
if (values.includes(/** @type {Exclude<Value, RegExp>} */ (value))) {
return value;
}
for (const item of values) {
if (`${item}` === value) return item;
}
break;
}
case "reset":
if (value === true) return [];
break;
}
};
/** @typedef {Record<string, Value[]>} Values */
/**
* Processes the provided arg.
* @param {Flags} args object of arguments
* @param {ObjectConfiguration} config configuration
* @param {Values} values object with values
* @returns {Problem[] | null} problems or null for success
*/
const processArguments = (args, config, values) => {
/** @type {Problem[]} */
const problems = [];
for (const key of Object.keys(values)) {
const arg = args[key];
if (!arg) {
problems.push({
type: "unknown-argument",
path: "",
argument: key
});
continue;
}
/**
* Processes the provided value.
* @param {Value} value value
* @param {number | undefined} i index
*/
const processValue = (value, i) => {
/** @type {Problem[]} */
const currentProblems = [];
for (const argConfig of arg.configs) {
const problem = processArgumentConfig(argConfig, config, value, i);
if (!problem) {
return;
}
currentProblems.push({
...problem,
argument: key,
value,
index: i
});
}
problems.push(...currentProblems);
};
const value = values[key];
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
processValue(value[i], i);
}
} else {
processValue(value, undefined);
}
}
if (problems.length === 0) return null;
return problems;
};
module.exports.getArguments = getArguments;
module.exports.processArguments = processArguments;