diff --git a/src/plugin.ts b/src/plugin.ts index 7810a2a9d..ef15d063f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,203 +1,12 @@ -import qs from 'querystring' -import * as webpack from 'webpack' -import { VueLoaderOptions } from './' -const RuleSet = require('webpack/lib/RuleSet') - -const id = 'vue-loader-plugin' -const NS = 'vue-loader' - -class VueLoaderPlugin implements webpack.Plugin { - static NS = NS - - apply(compiler: webpack.Compiler) { - // inject NS for plugin installation check in the main loader - compiler.hooks.compilation.tap(id, compilation => { - compilation.hooks.normalModuleLoader.tap(id, (loaderContext: any) => { - loaderContext[NS] = true - }) - }) - - const rawRules = compiler.options.module!.rules - // use webpack's RuleSet utility to normalize user rules - const rules = new RuleSet(rawRules).rules as webpack.RuleSetRule[] - - // find the rule that applies to vue files - let vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue`)) - if (vueRuleIndex < 0) { - vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue.html`)) - } - const vueRule = rules[vueRuleIndex] - - if (!vueRule) { - throw new Error( - `[VueLoaderPlugin Error] No matching rule for .vue files found.\n` + - `Make sure there is at least one root-level rule that matches .vue or .vue.html files.` - ) - } - - if (vueRule.oneOf) { - throw new Error( - `[VueLoaderPlugin Error] vue-loader 15 currently does not support vue rules with oneOf.` - ) - } - - // get the normlized "use" for vue files - const vueUse = vueRule.use as webpack.RuleSetLoader[] - // get vue-loader options - const vueLoaderUseIndex = vueUse.findIndex(u => { - return /^vue-loader|(\/|\\|@)vue-loader/.test(u.loader || '') - }) - - if (vueLoaderUseIndex < 0) { - throw new Error( - `[VueLoaderPlugin Error] No matching use for vue-loader is found.\n` + - `Make sure the rule matching .vue files include vue-loader in its use.` - ) - } - - const vueLoaderUse = vueUse[vueLoaderUseIndex] - const vueLoaderOptions = (vueLoaderUse.options = - vueLoaderUse.options || {}) as VueLoaderOptions - - // for each user rule (expect the vue rule), create a cloned rule - // that targets the corresponding language blocks in *.vue files. - const clonedRules = rules.filter(r => r !== vueRule).map(cloneRule) - - // rule for template compiler - const templateCompilerRule = { - loader: require.resolve('./templateLoader'), - test: /\.vue$/, - resourceQuery: (query: string) => { - const parsed = qs.parse(query.slice(1)) - return parsed.vue != null && parsed.type === 'template' - }, - options: vueLoaderOptions - } - - // for each rule that matches plain .js files, also create a clone and - // match it against the compiled template code inside *.vue files, so that - // compiled vue render functions receive the same treatment as user code - // (mostly babel) - const matchesJS = createMatcher(`test.js`) - const jsRulesForRenderFn = rules - .filter(r => r !== vueRule && matchesJS(r)) - .map(cloneRuleForRenderFn) - - // pitcher for block requests (for injecting stylePostLoader and deduping - // loaders matched for src imports) - const pitcher = { - loader: require.resolve('./pitcher'), - resourceQuery: (query: string) => { - const parsed = qs.parse(query.slice(1)) - return parsed.vue != null - } - } - - // replace original rules - compiler.options.module!.rules = [ - pitcher, - ...jsRulesForRenderFn, - templateCompilerRule, - ...clonedRules, - ...rules - ] - } -} - -function createMatcher(fakeFile: string) { - return (rule: webpack.RuleSetRule) => { - // #1201 we need to skip the `include` check when locating the vue rule - const clone = Object.assign({}, rule) - delete clone.include - const normalized = RuleSet.normalizeRule(clone, {}, '') - return !rule.enforce && normalized.resource && normalized.resource(fakeFile) - } -} - -function cloneRule(rule: webpack.RuleSetRule) { - const resource = rule.resource as Function - const resourceQuery = rule.resourceQuery as Function - // Assuming `test` and `resourceQuery` tests are executed in series and - // synchronously (which is true based on RuleSet's implementation), we can - // save the current resource being matched from `test` so that we can access - // it in `resourceQuery`. This ensures when we use the normalized rule's - // resource check, include/exclude are matched correctly. - let currentResource: string - const res = { - ...rule, - resource: { - test: (resource: string) => { - currentResource = resource - return true - } - }, - resourceQuery: (query: string) => { - const parsed = qs.parse(query.slice(1)) - if (parsed.vue == null) { - return false - } - if (resource && parsed.lang == null) { - return false - } - const fakeResourcePath = `${currentResource}.${parsed.lang}` - if (resource && !resource(fakeResourcePath)) { - return false - } - if (resourceQuery && !resourceQuery(query)) { - return false - } - return true - } - } - - if (rule.rules) { - res.rules = rule.rules.map(cloneRule) - } - - if (rule.oneOf) { - res.oneOf = rule.oneOf.map(cloneRule) - } - - return res -} - -function cloneRuleForRenderFn(rule: webpack.RuleSetRule) { - const resource = rule.resource as Function - const resourceQuery = rule.resourceQuery as Function - let currentResource: string - const res = { - ...rule, - resource: { - test: (resource: string) => { - currentResource = resource - return true - } - }, - resourceQuery: (query: string) => { - const parsed = qs.parse(query.slice(1)) - if (parsed.vue == null || parsed.type !== 'template') { - return false - } - const fakeResourcePath = `${currentResource}.js` - if (resource && !resource(fakeResourcePath)) { - return false - } - if (resourceQuery && !resourceQuery(query)) { - return false - } - return true - } - } - - if (rule.rules) { - res.rules = rule.rules.map(cloneRuleForRenderFn) - } - - if (rule.oneOf) { - res.oneOf = rule.oneOf.map(cloneRuleForRenderFn) - } - - return res +const webpack = require('webpack') +let VueLoaderPlugin = null + +if (webpack.version && webpack.version[0] > 4) { + // webpack5 and upper + VueLoaderPlugin = require('./pluginWebpack5') +} else { + // webpack4 and lower + VueLoaderPlugin = require('./pluginWebpack4') } module.exports = VueLoaderPlugin diff --git a/src/pluginWebpack4.ts b/src/pluginWebpack4.ts new file mode 100644 index 000000000..a09ac71dd --- /dev/null +++ b/src/pluginWebpack4.ts @@ -0,0 +1,200 @@ +import qs from 'querystring' +import * as webpack from 'webpack' +import { VueLoaderOptions } from './' + +const RuleSet = require('webpack/lib/RuleSet') + +const id = 'vue-loader-plugin' +const NS = 'vue-loader' + +class VueLoaderPlugin implements webpack.Plugin { + static NS = NS + + apply(compiler: webpack.Compiler) { + // inject NS for plugin installation check in the main loader + compiler.hooks.compilation.tap(id, compilation => { + compilation.hooks.normalModuleLoader.tap(id, (loaderContext: any) => { + loaderContext[NS] = true + }) + }) + + const rawRules = compiler.options.module!.rules + // use webpack's RuleSet utility to normalize user rules + const rules = new RuleSet(rawRules).rules as webpack.RuleSetRule[] + + // find the rule that applies to vue files + let vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue`)) + if (vueRuleIndex < 0) { + vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue.html`)) + } + const vueRule = rules[vueRuleIndex] + + if (!vueRule) { + throw new Error( + `[VueLoaderPlugin Error] No matching rule for .vue files found.\n` + + `Make sure there is at least one root-level rule that matches .vue or .vue.html files.` + ) + } + + if (vueRule.oneOf) { + throw new Error( + `[VueLoaderPlugin Error] vue-loader currently does not support vue rules with oneOf.` + ) + } + + // get the normlized "use" for vue files + const vueUse = vueRule.use as webpack.RuleSetLoader[] + // get vue-loader options + const vueLoaderUseIndex = vueUse.findIndex(u => { + return /^vue-loader|(\/|\\|@)vue-loader/.test(u.loader || '') + }) + + if (vueLoaderUseIndex < 0) { + throw new Error( + `[VueLoaderPlugin Error] No matching use for vue-loader is found.\n` + + `Make sure the rule matching .vue files include vue-loader in its use.` + ) + } + + const vueLoaderUse = vueUse[vueLoaderUseIndex] + const vueLoaderOptions = (vueLoaderUse.options = + vueLoaderUse.options || {}) as VueLoaderOptions + + // for each user rule (expect the vue rule), create a cloned rule + // that targets the corresponding language blocks in *.vue files. + const clonedRules = rules.filter(r => r !== vueRule).map(cloneRule) + + // rule for template compiler + const templateCompilerRule = { + loader: require.resolve('./templateLoader'), + test: /\.vue$/, + resourceQuery: (query: string) => { + const parsed = qs.parse(query.slice(1)) + return parsed.vue != null && parsed.type === 'template' + }, + options: vueLoaderOptions + } + + // for each rule that matches plain .js files, also create a clone and + // match it against the compiled template code inside *.vue files, so that + // compiled vue render functions receive the same treatment as user code + // (mostly babel) + const matchesJS = createMatcher(`test.js`) + const jsRulesForRenderFn = rules + .filter(r => r !== vueRule && matchesJS(r)) + .map(cloneRuleForRenderFn) + + // pitcher for block requests (for injecting stylePostLoader and deduping + // loaders matched for src imports) + const pitcher = { + loader: require.resolve('./pitcher'), + resourceQuery: (query: string) => { + const parsed = qs.parse(query.slice(1)) + return parsed.vue != null + } + } + + // replace original rules + compiler.options.module!.rules = [ + pitcher, + ...jsRulesForRenderFn, + templateCompilerRule, + ...clonedRules, + ...rules + ] + } +} + +function createMatcher(fakeFile: string) { + return (rule: webpack.RuleSetRule) => { + // #1201 we need to skip the `include` check when locating the vue rule + const clone = Object.assign({}, rule) + delete clone.include + const normalized = RuleSet.normalizeRule(clone, {}, '') + return !rule.enforce && normalized.resource && normalized.resource(fakeFile) + } +} + +function cloneRule(rule: webpack.RuleSetRule) { + const resource = rule.resource as Function + const resourceQuery = rule.resourceQuery as Function + // Assuming `test` and `resourceQuery` tests are executed in series and + // synchronously (which is true based on RuleSet's implementation), we can + // save the current resource being matched from `test` so that we can access + // it in `resourceQuery`. This ensures when we use the normalized rule's + // resource check, include/exclude are matched correctly. + let currentResource: string + const res = { + ...rule, + resource: (resource: string) => { + currentResource = resource + return true + }, + resourceQuery: (query: string) => { + const parsed = qs.parse(query.slice(1)) + if (parsed.vue == null) { + return false + } + if (resource && parsed.lang == null) { + return false + } + const fakeResourcePath = `${currentResource}.${parsed.lang}` + if (resource && !resource(fakeResourcePath)) { + return false + } + if (resourceQuery && !resourceQuery(query)) { + return false + } + return true + } + } + + if (rule.rules) { + res.rules = rule.rules.map(cloneRule) + } + + if (rule.oneOf) { + res.oneOf = rule.oneOf.map(cloneRule) + } + + return res +} + +function cloneRuleForRenderFn(rule: webpack.RuleSetRule) { + const resource = rule.resource as Function + const resourceQuery = rule.resourceQuery as Function + let currentResource: string + const res = { + ...rule, + resource: (resource: string) => { + currentResource = resource + return true + }, + resourceQuery: (query: string) => { + const parsed = qs.parse(query.slice(1)) + if (parsed.vue == null || parsed.type !== 'template') { + return false + } + const fakeResourcePath = `${currentResource}.js` + if (resource && !resource(fakeResourcePath)) { + return false + } + if (resourceQuery && !resourceQuery(query)) { + return false + } + return true + } + } + + if (rule.rules) { + res.rules = rule.rules.map(cloneRuleForRenderFn) + } + + if (rule.oneOf) { + res.oneOf = rule.oneOf.map(cloneRuleForRenderFn) + } + + return res +} + +module.exports = VueLoaderPlugin diff --git a/src/pluginWebpack5.ts b/src/pluginWebpack5.ts new file mode 100644 index 000000000..e0395b800 --- /dev/null +++ b/src/pluginWebpack5.ts @@ -0,0 +1,310 @@ +import qs from 'querystring' +import { VueLoaderOptions } from './' +import { RuleSetRule, Compiler } from 'webpack' + +const id = 'vue-loader-plugin' +const NS = 'vue-loader' + +const NormalModule = require('webpack/lib/NormalModule') +const BasicEffectRulePlugin = require('webpack/lib/rules/BasicEffectRulePlugin') +const BasicMatcherRulePlugin = require('webpack/lib/rules/BasicMatcherRulePlugin') +const UseEffectRulePlugin = require('webpack/lib/rules/UseEffectRulePlugin') +const RuleSetCompiler = require('webpack/lib/rules/RuleSetCompiler') as RuleSetCompiler + +type RawRule = RuleSetRule + +// webpack 5 doesn't export the internal Rule types so we have to shim it here +// (hopefully it will do in the future) +type RuleSetCompiler = { + new (plugins: any): { + compile(rawRules: RawRule[]): RuleSet + compileRule( + path: string, + rawRule: RawRule, + refs: Map + ): CompiledRule + } +} + +interface RuleSet { + references: Map + exec(data: object): Effect[] +} + +interface CompiledRule { + conditions: RuleCondition[] + effects: Effect[] + rules: CompiledRule[] + oneOf: CompiledRule[] +} + +interface RuleCondition { + property: string + matchWhenEmpty: boolean + fn(input: string): boolean +} + +interface BasicEffect { + type: 'type' | 'sideEffects' | 'parser' | 'resolve' + value: any +} + +interface UseEffect { + type: 'use' + value: { + loader: string + options: any + ident: string + } +} + +type Effect = BasicEffect | UseEffect + +const ruleSetCompiler = new RuleSetCompiler([ + new BasicMatcherRulePlugin('test', 'resource'), + new BasicMatcherRulePlugin('include', 'resource'), + new BasicMatcherRulePlugin('exclude', 'resource', true), + new BasicMatcherRulePlugin('resource'), + new BasicMatcherRulePlugin('conditions'), + new BasicMatcherRulePlugin('resourceQuery'), + new BasicMatcherRulePlugin('realResource'), + new BasicMatcherRulePlugin('issuer'), + new BasicMatcherRulePlugin('compiler'), + new BasicEffectRulePlugin('type'), + new BasicEffectRulePlugin('sideEffects'), + new BasicEffectRulePlugin('parser'), + new BasicEffectRulePlugin('resolve'), + new UseEffectRulePlugin() +]) + +class VueLoaderPlugin { + static NS = NS + + apply(compiler: Compiler) { + // add NS marker so that the loader can detect and report missing plugin + compiler.hooks.compilation.tap(id, compilation => { + NormalModule.getCompilationHooks(compilation).loader.tap( + id, + (loaderContext: any) => { + loaderContext[NS] = true + } + ) + }) + + const rules = compiler.options.module!.rules + let rawVueRule: RawRule + let vueRules: Effect[] = [] + + for (const rawRule of rules) { + // skip the `include` check when locating the vue rule + vueRules = match(rawRule, 'foo.vue') + if (!vueRules.length) { + vueRules = match(rawRule, 'foo.vue.html') + } + if (vueRules.length > 0) { + if (rawRule.oneOf) { + throw new Error( + `[VueLoaderPlugin Error] vue-loader currently does not support vue rules with oneOf.` + ) + } + rawVueRule = rawRule + break + } + } + if (!vueRules.length) { + throw new Error( + `[VueLoaderPlugin Error] No matching rule for .vue files found.\n` + + `Make sure there is at least one root-level rule that matches .vue or .vue.html files.` + ) + } + + // get the normlized "use" for vue files + const vueUse = vueRules + .filter(rule => rule.type === 'use') + .map(rule => rule.value) + + // get vue-loader options + const vueLoaderUseIndex = vueUse.findIndex(u => { + return /^vue-loader|(\/|\\|@)vue-loader/.test(u.loader) + }) + + if (vueLoaderUseIndex < 0) { + throw new Error( + `[VueLoaderPlugin Error] No matching use for vue-loader is found.\n` + + `Make sure the rule matching .vue files include vue-loader in its use.` + ) + } + + // make sure vue-loader options has a known ident so that we can share + // options by reference in the template-loader by using a ref query like + // template-loader??vue-loader-options + const vueLoaderUse = vueUse[vueLoaderUseIndex] + const vueLoaderOptions = (vueLoaderUse.options = + vueLoaderUse.options || {}) as VueLoaderOptions + + // for each user rule (expect the vue rule), create a cloned rule + // that targets the corresponding language blocks in *.vue files. + const refs = new Map() + const clonedRules = rules + .filter(r => r !== rawVueRule) + .map(rawRule => + cloneRule(rawRule, refs, langBlockRuleCheck, langBlockRuleResource) + ) + + // fix conflict with config.loader and config.options when using config.use + delete rawVueRule!.loader + delete rawVueRule!.options + rawVueRule!.use = vueUse + + // rule for template compiler + const templateCompilerRule = { + loader: require.resolve('./templateLoader'), + test: /\.vue$/, + resourceQuery: (query: string) => { + const parsed = qs.parse(query.slice(1)) + return parsed.vue != null && parsed.type === 'template' + }, + options: vueLoaderOptions + } + + // for each rule that matches plain .js files, also create a clone and + // match it against the compiled template code inside *.vue files, so that + // compiled vue render functions receive the same treatment as user code + // (mostly babel) + const jsRulesForRenderFn = rules + .filter(r => r !== rawVueRule && match(r, 'test.js').length > 0) + .map(rawRule => cloneRule(rawRule, refs, jsRuleCheck, jsRuleResource)) + + // global pitcher (responsible for injecting template compiler loader & CSS + // post loader) + const pitcher = { + loader: require.resolve('./pitcher'), + resourceQuery: (query: string) => { + const parsed = qs.parse(query.slice(1)) + return parsed.vue != null + } + } + + // replace original rules + compiler.options.module!.rules = [ + pitcher, + ...jsRulesForRenderFn, + templateCompilerRule, + ...clonedRules, + ...rules + ] + } +} + +const matcherCache = new WeakMap() + +function match(rule: RawRule, fakeFile: string): Effect[] { + let ruleSet = matcherCache.get(rule) + if (!ruleSet) { + // skip the `include` check when locating the vue rule + const clonedRawRule = { ...rule } + delete clonedRawRule.include + + ruleSet = ruleSetCompiler.compile([ + { + rules: [clonedRawRule] + } + ]) + matcherCache.set(rule, ruleSet) + } + + return ruleSet.exec({ + resource: fakeFile + }) +} + +const langBlockRuleCheck = ( + query: qs.ParsedUrlQuery, + rule: CompiledRule +): boolean => { + return !rule.conditions.length || query.lang != null +} + +const langBlockRuleResource = ( + query: qs.ParsedUrlQuery, + resource: string +): string => `${resource}.${query.lang}` + +const jsRuleCheck = (query: qs.ParsedUrlQuery): boolean => { + return query.lang === 'template' +} + +const jsRuleResource = (query: qs.ParsedUrlQuery, resource: string): string => + `${resource}.js` + +let uid = 0 + +function cloneRule( + rawRule: RawRule, + refs: Map, + ruleCheck: (query: qs.ParsedUrlQuery, rule: CompiledRule) => boolean, + ruleResource: (query: qs.ParsedUrlQuery, resource: string) => string +): RawRule { + const compiledRule = ruleSetCompiler.compileRule( + `clonedRuleSet-${++uid}`, + rawRule, + refs + ) + + // do not process rule with enforce + if (!rawRule.enforce) { + const ruleUse = compiledRule.effects + .filter(effect => effect.type === 'use') + .map((effect: UseEffect) => effect.value) + // fix conflict with config.loader and config.options when using config.use + delete rawRule.loader + delete rawRule.options + rawRule.use = ruleUse + } + + let currentResource: string + const res = { + ...rawRule, + resource: (resources: string) => { + currentResource = resources + return true + }, + resourceQuery: (query: string) => { + const parsed = qs.parse(query.slice(1)) + if (parsed.vue == null) { + return false + } + if (!ruleCheck(parsed, compiledRule)) { + return false + } + const fakeResourcePath = ruleResource(parsed, currentResource) + for (const condition of compiledRule.conditions) { + // add support for resourceQuery + const request = + condition.property === 'resourceQuery' ? query : fakeResourcePath + if (condition && !condition.fn(request)) { + return false + } + } + return true + } + } + + delete res.test + + if (rawRule.rules) { + res.rules = rawRule.rules.map(rule => + cloneRule(rule, refs, ruleCheck, ruleResource) + ) + } + + if (rawRule.oneOf) { + res.oneOf = rawRule.oneOf.map(rule => + cloneRule(rule, refs, ruleCheck, ruleResource) + ) + } + + return res +} + +module.exports = VueLoaderPlugin