UNPKG

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.

581 lines (537 loc) 17 kB
/* MIT License http://www.opensource.org/licenses/mit-license.php Author Tobias Koppers @sokra */ "use strict"; const { SyncHook } = require("tapable"); /** @typedef {import("enhanced-resolve").ResolveRequest} ResolveRequest */ /** @typedef {import("../../declarations/WebpackOptions").Falsy} Falsy */ /** @typedef {import("../../declarations/WebpackOptions").RuleSetUseItem} RuleSetUseItem */ /** @typedef {import("../../declarations/WebpackOptions").RuleSetLoaderOptions} RuleSetLoaderOptions */ /** @typedef {import("../../declarations/WebpackOptions").RuleSetRule} RuleSetRule */ /** @typedef {import("../../declarations/WebpackOptions").RuleSetConditionAbsolute} RuleSetConditionAbsolute */ /** @typedef {(Falsy | RuleSetRule)[]} RuleSetRules */ /** * Defines the rule condition function type used by this module. * @typedef {(value: EffectData[keyof EffectData]) => boolean} RuleConditionFunction */ /** * Defines the rule condition type used by this module. * @typedef {object} RuleCondition * @property {string | string[]} property * @property {boolean} matchWhenEmpty * @property {RuleConditionFunction} fn */ /** * Defines the condition type used by this module. * @typedef {object} Condition * @property {boolean} matchWhenEmpty * @property {RuleConditionFunction} fn */ /** * Defines the effect data type used by this module. * @typedef {object} EffectData * @property {string=} resource * @property {string=} realResource * @property {string=} resourceQuery * @property {string=} resourceFragment * @property {string=} scheme * @property {ImportAttributes=} attributes * @property {string=} mimetype * @property {string} dependency * @property {ResolveRequest["descriptionFileData"]=} descriptionData * @property {string=} compiler * @property {string} issuer * @property {string} issuerLayer * @property {string=} phase */ /** * Defines the compiled rule type used by this module. * @typedef {object} CompiledRule * @property {RuleCondition[]} conditions * @property {(Effect | ((effectData: EffectData) => Effect[]))[]} effects * @property {CompiledRule[]=} rules * @property {CompiledRule[]=} oneOf */ /** @typedef {"use" | "use-pre" | "use-post"} EffectUseType */ /** * Defines the effect use type used by this module. * @typedef {object} EffectUse * @property {EffectUseType} type * @property {{ loader: string, options?: string | null | Record<string, EXPECTED_ANY>, ident?: string }} value */ /** * Defines the effect basic type used by this module. * @typedef {object} EffectBasic * @property {string} type * @property {EXPECTED_ANY} value */ /** @typedef {EffectUse | EffectBasic} Effect */ /** @typedef {Map<string, RuleSetLoaderOptions>} References */ /** * Defines the rule set type used by this module. * @typedef {object} RuleSet * @property {References} references map of references in the rule set (may grow over time) * @property {(effectData: EffectData) => Effect[]} exec execute the rule set */ /** * Defines the keys of types type used by this module. * @template T * @template {T[keyof T]} V * @typedef {({ [key in keyof Required<T>]: Required<T>[key] extends V ? key : never })[keyof T]} KeysOfTypes */ /** @typedef {Set<string>} UnhandledProperties */ /** @typedef {(data: EffectData) => (RuleSetUseItem | (Falsy | RuleSetUseItem)[])} RuleSetUseFn */ /** @typedef {(value: string) => boolean} RuleSetConditionFn */ /** @typedef {{ apply: (ruleSetCompiler: RuleSetCompiler) => void }} RuleSetPlugin */ class RuleSetCompiler { /** * Creates an instance of RuleSetCompiler. * @param {RuleSetPlugin[]} plugins plugins */ constructor(plugins) { this.hooks = Object.freeze({ /** @type {SyncHook<[string, RuleSetRule, UnhandledProperties, CompiledRule, References]>} */ rule: new SyncHook([ "path", "rule", "unhandledProperties", "compiledRule", "references" ]) }); if (plugins) { for (const plugin of plugins) { plugin.apply(this); } } } /** * Returns compiled RuleSet. * @param {RuleSetRules} ruleSet raw user provided rules * @returns {RuleSet} compiled RuleSet */ compile(ruleSet) { /** @type {References} */ const refs = new Map(); const rules = this.compileRules("ruleSet", ruleSet, refs); /** * Returns true, if the rule has matched. * @param {EffectData} data data passed in * @param {CompiledRule} rule the compiled rule * @param {Effect[]} effects an array where effects are pushed to * @returns {boolean} true, if the rule has matched */ const execRule = (data, rule, effects) => { for (const condition of rule.conditions) { const p = condition.property; if (Array.isArray(p)) { /** @type {EXPECTED_ANY} */ let current = data; for (const subProperty of p) { if ( current && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, subProperty) ) { current = current[/** @type {keyof EffectData} */ (subProperty)]; } else { current = undefined; break; } } if (current !== undefined) { if (!condition.fn(current)) return false; continue; } } else if (p in data) { const value = data[/** @type {keyof EffectData} */ (p)]; if (value !== undefined) { if (!condition.fn(value)) return false; continue; } } if (!condition.matchWhenEmpty) { return false; } } for (const effect of rule.effects) { if (typeof effect === "function") { const returnedEffects = effect(data); for (const effect of returnedEffects) { effects.push(effect); } } else { effects.push(effect); } } if (rule.rules) { for (const childRule of rule.rules) { execRule(data, childRule, effects); } } if (rule.oneOf) { for (const childRule of rule.oneOf) { if (execRule(data, childRule, effects)) { break; } } } return true; }; return { references: refs, exec: (data) => { /** @type {Effect[]} */ const effects = []; for (const rule of rules) { execRule(data, rule, effects); } return effects; } }; } /** * Returns rules. * @param {string} path current path * @param {RuleSetRules} rules the raw rules provided by user * @param {References} refs references * @returns {CompiledRule[]} rules */ compileRules(path, rules, refs) { return rules .filter(Boolean) .map((rule, i) => this.compileRule( `${path}[${i}]`, /** @type {RuleSetRule} */ (rule), refs ) ); } /** * Returns normalized and compiled rule for processing. * @param {string} path current path * @param {RuleSetRule} rule the raw rule provided by user * @param {References} refs references * @returns {CompiledRule} normalized and compiled rule for processing */ compileRule(path, rule, refs) { /** @type {UnhandledProperties} */ const unhandledProperties = new Set( Object.keys(rule).filter( (key) => rule[/** @type {keyof RuleSetRule} */ (key)] !== undefined ) ); /** @type {CompiledRule} */ const compiledRule = { conditions: [], effects: [], rules: undefined, oneOf: undefined }; this.hooks.rule.call(path, rule, unhandledProperties, compiledRule, refs); if (unhandledProperties.has("rules")) { unhandledProperties.delete("rules"); const rules = rule.rules; if (!Array.isArray(rules)) { throw this.error(path, rules, "Rule.rules must be an array of rules"); } compiledRule.rules = this.compileRules(`${path}.rules`, rules, refs); } if (unhandledProperties.has("oneOf")) { unhandledProperties.delete("oneOf"); const oneOf = rule.oneOf; if (!Array.isArray(oneOf)) { throw this.error(path, oneOf, "Rule.oneOf must be an array of rules"); } compiledRule.oneOf = this.compileRules(`${path}.oneOf`, oneOf, refs); } if (unhandledProperties.size > 0) { throw this.error( path, rule, `Properties ${[...unhandledProperties].join(", ")} are unknown` ); } return compiledRule; } /** * Returns compiled condition. * @param {string} path current path * @param {RuleSetLoaderOptions} condition user provided condition value * @returns {Condition} compiled condition */ compileCondition(path, condition) { if (condition === "") { return { matchWhenEmpty: true, fn: (str) => str === "" }; } if (!condition) { throw this.error( path, condition, "Expected condition but got falsy value" ); } if (typeof condition === "string") { return { matchWhenEmpty: condition.length === 0, fn: (str) => typeof str === "string" && str.startsWith(condition) }; } if (typeof condition === "function") { try { return { matchWhenEmpty: condition(""), fn: /** @type {RuleConditionFunction} */ (condition) }; } catch (_err) { throw this.error( path, condition, "Evaluation of condition function threw error" ); } } if (condition instanceof RegExp) { return { matchWhenEmpty: condition.test(""), fn: (v) => typeof v === "string" && condition.test(v) }; } if (Array.isArray(condition)) { const items = condition.map((c, i) => this.compileCondition(`${path}[${i}]`, c) ); return this.combineConditionsOr(items); } if (typeof condition !== "object") { throw this.error( path, condition, `Unexpected ${typeof condition} when condition was expected` ); } /** @type {Condition[]} */ const conditions = []; for (const key of Object.keys(condition)) { const value = condition[key]; switch (key) { case "or": if (value) { if (!Array.isArray(value)) { throw this.error( `${path}.or`, condition.or, "Expected array of conditions" ); } conditions.push(this.compileCondition(`${path}.or`, value)); } break; case "and": if (value) { if (!Array.isArray(value)) { throw this.error( `${path}.and`, condition.and, "Expected array of conditions" ); } let i = 0; for (const item of value) { conditions.push(this.compileCondition(`${path}.and[${i}]`, item)); i++; } } break; case "not": if (value) { const matcher = this.compileCondition(`${path}.not`, value); const fn = matcher.fn; conditions.push({ matchWhenEmpty: !matcher.matchWhenEmpty, fn: /** @type {RuleConditionFunction} */ ((v) => !fn(v)) }); } break; default: throw this.error( `${path}.${key}`, condition[key], `Unexpected property ${key} in condition` ); } } if (conditions.length === 0) { throw this.error( path, condition, "Expected condition, but got empty thing" ); } return this.combineConditionsAnd(conditions); } /** * Combine conditions or. * @param {Condition[]} conditions some conditions * @returns {Condition} merged condition */ combineConditionsOr(conditions) { if (conditions.length === 0) { return { matchWhenEmpty: false, fn: () => false }; } else if (conditions.length === 1) { return conditions[0]; } return { matchWhenEmpty: conditions.some((c) => c.matchWhenEmpty), fn: (v) => conditions.some((c) => c.fn(v)) }; } /** * Combine conditions and. * @param {Condition[]} conditions some conditions * @returns {Condition} merged condition */ combineConditionsAnd(conditions) { if (conditions.length === 0) { return { matchWhenEmpty: false, fn: () => false }; } else if (conditions.length === 1) { return conditions[0]; } return { matchWhenEmpty: conditions.every((c) => c.matchWhenEmpty), fn: (v) => conditions.every((c) => c.fn(v)) }; } /** * Returns an error object. * @param {string} path current path * @param {EXPECTED_ANY} value value at the error location * @param {string} message message explaining the problem * @returns {Error} an error object */ error(path, value, message) { return new Error( `Compiling RuleSet failed: ${message} (at ${path}: ${value})` ); } /** * Best-effort detection of a user-registered loader (or explicit module type) * for a resource, without building the whole rule set. Used to resolve the * `experiments.css`/`experiments.html`/`experiments.asyncWebAssembly` "auto" * defaults: when the user already handles these files the built-in module type * stays off, so enabling it by default is non-breaking. * * `include`/`exclude` are treated leniently — a matching `test`/`resource`/ * `include` with a loader counts even when another condition would narrow it — * so a loader scoped to e.g. `include: /src/` still keeps the built-in type off * and those files are not double-processed. `enforce: "pre"`/`"post"` loaders * are ignored: they don't establish the module type (e.g. a stylelint pre-loader * must not suppress the built-in css type). A `test` regexp that references the * extension (e.g. `/source\.css$/`) also counts even if the sample path itself * doesn't match, so a loader scoped to specific filenames is still detected. * @param {import("../../declarations/WebpackOptions").RuleSetRules | undefined} rules user `module.rules` * @param {string} resource sample resource path (e.g. `"/file.css"`) * @param {boolean=} inherited whether an enclosing rule already matched the resource * @returns {boolean} whether a rule assigns a loader or module type to the resource */ static hasRuleForResource(rules, resource, inherited = false) { if (!rules) return false; // `\.css` for `/file.css`, `\.wasm` for `/file.wasm`, … const extProbe = `\\.${resource.slice(resource.lastIndexOf(".") + 1)}`; for (const rule of rules) { if (!rule || typeof rule !== "object") continue; const matched = inherited || matchesResource(rule.test, resource, extProbe) || matchesResource(rule.resource, resource, extProbe) || matchesResource(rule.include, resource, extProbe); const establishesType = rule.type !== undefined || ((rule.use !== undefined || rule.loader !== undefined) && rule.enforce === undefined); if (matched && establishesType) return true; if ( rule.oneOf && RuleSetCompiler.hasRuleForResource(rule.oneOf, resource, matched) ) { return true; } if ( rule.rules && RuleSetCompiler.hasRuleForResource(rule.rules, resource, matched) ) { return true; } } return false; } } // Bare compiler reused to match single conditions via `compileCondition` // (no plugins needed); created lazily on first use. /** @type {RuleSetCompiler | undefined} */ let conditionCompiler; /** * Matches a single rule condition against a sample string, reusing the ruleset * condition logic (string/regexp/function/array/and/or/not). * @param {RuleSetConditionAbsolute | undefined} condition condition * @param {string} resource sample resource path * @returns {boolean} whether the condition matches */ const matchRuleSetCondition = (condition, resource) => { if (condition === undefined) return false; if (conditionCompiler === undefined) { conditionCompiler = new RuleSetCompiler([]); } try { return conditionCompiler .compileCondition("ruleSet", condition) .fn(resource); } catch (_err) { return false; } }; /** * Whether any regexp in the condition references the extension probe (e.g. * `\.css`), i.e. the rule clearly targets that extension even if the generic * sample path doesn't match its (possibly filename-scoped) pattern. * @param {EXPECTED_ANY} condition condition * @param {string} extProbe escaped extension probe (e.g. `"\\.css"`) * @returns {boolean} whether a regexp in the condition targets the extension */ const conditionReferencesExtension = (condition, extProbe) => { if (condition instanceof RegExp) return condition.source.includes(extProbe); if (Array.isArray(condition)) { return condition.some((c) => conditionReferencesExtension(c, extProbe)); } if (condition && typeof condition === "object") { return ( conditionReferencesExtension(condition.and, extProbe) || conditionReferencesExtension(condition.or, extProbe) ); } return false; }; /** * A condition counts as targeting the resource if it matches the sample path or * references the extension via a regexp. * @param {RuleSetConditionAbsolute | undefined} condition condition * @param {string} resource sample resource path * @param {string} extProbe escaped extension probe (e.g. `"\\.css"`) * @returns {boolean} whether the condition targets the resource's extension */ const matchesResource = (condition, resource, extProbe) => condition !== undefined && (matchRuleSetCondition(condition, resource) || conditionReferencesExtension(condition, extProbe)); module.exports = RuleSetCompiler;