diff --git a/lib/codegen/customBlocks.js b/lib/codegen/customBlocks.js index 0591b742e..6a106e9f4 100644 --- a/lib/codegen/customBlocks.js +++ b/lib/codegen/customBlocks.js @@ -1,11 +1,13 @@ const qs = require('querystring') -const { attrsToQuery } = require('./utils') +const { attrsToQuery, genMatchResource } = require('./utils') module.exports = function genCustomBlocksCode( + loaderContext, blocks, resourcePath, resourceQuery, - stringifyRequest + stringifyRequest, + enableInlineMatchResource ) { return ( `\n/* custom blocks */\n` + @@ -17,11 +19,22 @@ module.exports = function genCustomBlocksCode( ? `&issuerPath=${qs.escape(resourcePath)}` : '' const inheritQuery = resourceQuery ? `&${resourceQuery.slice(1)}` : '' + const externalQuery = block.attrs.src ? `&external` : `` const query = `?vue&type=custom&index=${i}&blockType=${qs.escape( block.type - )}${issuerQuery}${attrsQuery}${inheritQuery}` + )}${issuerQuery}${attrsQuery}${inheritQuery}${externalQuery}` + + let customRequest + + if (enableInlineMatchResource) { + customRequest = stringifyRequest( + genMatchResource(loaderContext, src, query, block.attrs.lang) + ) + } else { + customRequest = stringifyRequest(src + query) + } return ( - `import block${i} from ${stringifyRequest(src + query)}\n` + + `import block${i} from ${customRequest}\n` + `if (typeof block${i} === 'function') block${i}(component)` ) }) diff --git a/lib/codegen/styleInjection.js b/lib/codegen/styleInjection.js index 01075a5a9..dc5a4a1e2 100644 --- a/lib/codegen/styleInjection.js +++ b/lib/codegen/styleInjection.js @@ -1,4 +1,4 @@ -const { attrsToQuery } = require('./utils') +const { attrsToQuery, genMatchResource } = require('./utils') const hotReloadAPIPath = JSON.stringify(require.resolve('vue-hot-reload-api')) const nonWhitespaceRE = /\S+/ @@ -10,7 +10,8 @@ module.exports = function genStyleInjectionCode( stringifyRequest, needsHotReload, needsExplicitInjection, - isProduction + isProduction, + enableInlineMatchResource ) { let styleImportsCode = `` let styleInjectionCode = `` @@ -22,13 +23,23 @@ module.exports = function genStyleInjectionCode( function genStyleRequest(style, i) { const src = style.src || resourcePath const attrsQuery = attrsToQuery(style.attrs, 'css') - const inheritQuery = `&${loaderContext.resourceQuery.slice(1)}` + const lang = String(style.attrs.lang || 'css') + const inheritQuery = loaderContext.resourceQuery.slice(1) + ? `&${loaderContext.resourceQuery.slice(1)}` + : '' // make sure to only pass id not src importing so that we don't inject // duplicate tags when multiple components import the same css file const idQuery = !style.src || style.scoped ? `&id=${id}` : `` const prodQuery = isProduction ? `&prod` : `` - const query = `?vue&type=style&index=${i}${idQuery}${prodQuery}${attrsQuery}${inheritQuery}` - return stringifyRequest(src + query) + const externalQuery = style.src ? `&external` : `` + const query = `?vue&type=style&index=${i}${idQuery}${prodQuery}${attrsQuery}${inheritQuery}${externalQuery}` + let styleRequest + if (enableInlineMatchResource) { + styleRequest = stringifyRequest(genMatchResource(loaderContext, src, query, lang)) + } else { + styleRequest = stringifyRequest(src + query) + } + return styleRequest } function genCSSModulesCode(style, request, i) { diff --git a/lib/codegen/utils.js b/lib/codegen/utils.js index 84904edea..6e6ce367b 100644 --- a/lib/codegen/utils.js +++ b/lib/codegen/utils.js @@ -18,3 +18,31 @@ exports.attrsToQuery = (attrs, langFallback) => { } return query } + +exports.genMatchResource = (context, resourcePath, resourceQuery, lang) => { + resourceQuery = resourceQuery || '' + + const loaders = [] + const parsedQuery = qs.parse(resourceQuery.slice(1)) + + // process non-external resources + if ('vue' in parsedQuery && !('external' in parsedQuery)) { + const currentRequest = context.loaders + .slice(context.loaderIndex) + .map((obj) => obj.request) + loaders.push(...currentRequest) + } + const loaderString = loaders.join('!') + + return `${resourcePath}${lang ? `.${lang}` : ''}${resourceQuery}!=!${ + loaderString ? `${loaderString}!` : '' + }${resourcePath}${resourceQuery}` +} + +exports.testWebpack5 = (compiler) => { + if (!compiler) { + return false + } + const webpackVersion = compiler.webpack && compiler.webpack.version + return Boolean(webpackVersion && Number(webpackVersion.split('.')[0]) > 4) +} diff --git a/lib/index.d.ts b/lib/index.d.ts index 28a4e3c5f..0073c7ec2 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -18,6 +18,7 @@ declare namespace VueLoader { cacheIdentifier?: string prettify?: boolean exposeFilename?: boolean + experimentalInlineMatchResource?: boolean } } diff --git a/lib/index.js b/lib/index.js index 4019f93d4..41a0278a4 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,7 +4,11 @@ const qs = require('querystring') const plugin = require('./plugin') const selectBlock = require('./select') const loaderUtils = require('loader-utils') -const { attrsToQuery } = require('./codegen/utils') +const { + attrsToQuery, + testWebpack5, + genMatchResource +} = require('./codegen/utils') const genStylesCode = require('./codegen/styleInjection') const { genHotReloadCode } = require('./codegen/hotReload') const genCustomBlocksCode = require('./codegen/customBlocks') @@ -38,14 +42,16 @@ module.exports = function (source) { sourceMap, rootContext, resourcePath, - resourceQuery = '' + resourceQuery: _resourceQuery = '', + _compiler } = loaderContext - - const rawQuery = resourceQuery.slice(1) - const inheritQuery = `&${rawQuery}` + const isWebpack5 = testWebpack5(_compiler) + const rawQuery = _resourceQuery.slice(1) + const resourceQuery = rawQuery ? `&${rawQuery}` : '' const incomingQuery = qs.parse(rawQuery) const options = loaderUtils.getOptions(loaderContext) || {} - + const enableInlineMatchResource = + isWebpack5 && Boolean(options.experimentalInlineMatchResource) const isServer = target === 'node' const isShadow = !!options.shadowMode const isProduction = @@ -108,17 +114,27 @@ module.exports = function (source) { // script let scriptImport = `var script = {}` - // let isTS = false + let isTS = false const { script, scriptSetup } = descriptor if (script || scriptSetup) { - // const lang = script?.lang || scriptSetup?.lang - // isTS = !!(lang && /tsx?/.test(lang)) + const lang = script.lang || (scriptSetup && scriptSetup.lang) + isTS = !!(lang && /tsx?/.test(lang)) + const externalQuery = + script && !scriptSetup && script.src ? `&external` : `` const src = (script && !scriptSetup && script.src) || resourcePath const attrsQuery = attrsToQuery((scriptSetup || script).attrs, 'js') - const query = `?vue&type=script${attrsQuery}${inheritQuery}` - const request = stringifyRequest(src + query) + const query = `?vue&type=script${attrsQuery}${resourceQuery}${externalQuery}` + + let scriptRequest + if (enableInlineMatchResource) { + scriptRequest = stringifyRequest( + genMatchResource(loaderContext, src, query, lang || 'js') + ) + } else { + scriptRequest = stringifyRequest(src + query) + } scriptImport = - `import script from ${request}\n` + `export * from ${request}` // support named exports + `import script from ${scriptRequest}\n` + `export * from ${scriptRequest}` // support named exports } // template @@ -126,14 +142,21 @@ module.exports = function (source) { let templateRequest if (descriptor.template) { const src = descriptor.template.src || resourcePath + const externalQuery = descriptor.template.src ? `&external` : `` const idQuery = `&id=${id}` const scopedQuery = hasScoped ? `&scoped=true` : `` const attrsQuery = attrsToQuery(descriptor.template.attrs) // const tsQuery = // options.enableTsInTemplate !== false && isTS ? `&ts=true` : `` - const query = `?vue&type=template${idQuery}${scopedQuery}${attrsQuery}${inheritQuery}` - const request = (templateRequest = stringifyRequest(src + query)) - templateImport = `import { render, staticRenderFns } from ${request}` + const query = `?vue&type=template${idQuery}${scopedQuery}${attrsQuery}${resourceQuery}${externalQuery}` + if (enableInlineMatchResource) { + templateRequest = stringifyRequest( + genMatchResource(loaderContext, src, query, isTS ? 'ts' : 'js') + ) + } else { + templateRequest = stringifyRequest(src + query) + } + templateImport = `import { render, staticRenderFns } from ${templateRequest}` } // styles @@ -147,7 +170,8 @@ module.exports = function (source) { stringifyRequest, needsHotReload, isServer || isShadow, // needs explicit injection? - isProduction + isProduction, + enableInlineMatchResource ) } @@ -173,10 +197,12 @@ var component = normalizer( if (descriptor.customBlocks && descriptor.customBlocks.length) { code += genCustomBlocksCode( + loaderContext, descriptor.customBlocks, resourcePath, resourceQuery, - stringifyRequest + stringifyRequest, + enableInlineMatchResource ) } diff --git a/lib/loaders/pitcher.js b/lib/loaders/pitcher.js index 9aa4fd7a6..5989dbe37 100644 --- a/lib/loaders/pitcher.js +++ b/lib/loaders/pitcher.js @@ -5,6 +5,7 @@ const selfPath = require.resolve('../index') const templateLoaderPath = require.resolve('./templateLoader') const stylePostLoaderPath = require.resolve('./stylePostLoader') const { resolveCompiler } = require('../compiler') +const { testWebpack5 } = require('../codegen/utils') const isESLintLoader = (l) => /(\/|\\|@)eslint-loader/.test(l.path) const isNullLoader = (l) => /(\/|\\|@)null-loader/.test(l.path) @@ -53,6 +54,7 @@ module.exports.pitch = function (remainingRequest) { const options = loaderUtils.getOptions(this) const { cacheDirectory, cacheIdentifier } = options const query = qs.parse(this.resourceQuery.slice(1)) + const isWebpack5 = testWebpack5(this._compiler) let loaders = this.loaders @@ -78,7 +80,7 @@ module.exports.pitch = function (remainingRequest) { return } - const genRequest = (loaders) => { + const genRequest = (loaders, lang) => { // Important: dedupe since both the original rule // and the cloned rule would match a source import request. // also make sure to dedupe based on loader path. @@ -89,6 +91,8 @@ module.exports.pitch = function (remainingRequest) { // path AND query to be safe. const seen = new Map() const loaderStrings = [] + const enableInlineMatchResource = + isWebpack5 && options.experimentalInlineMatchResource loaders.forEach((loader) => { const identifier = @@ -101,6 +105,14 @@ module.exports.pitch = function (remainingRequest) { loaderStrings.push(request) } }) + if (enableInlineMatchResource) { + return loaderUtils.stringifyRequest( + this, + `${this.resourcePath}${lang ? `.${lang}` : ''}${ + this.resourceQuery + }!=!-!${[...loaderStrings, this.resourcePath + this.resourceQuery].join('!')}` + ) + } return loaderUtils.stringifyRequest( this, @@ -111,15 +123,51 @@ module.exports.pitch = function (remainingRequest) { // Inject style-post-loader before css-loader for scoped CSS and trimming if (query.type === `style`) { + if (isWebpack5 && this._compiler.options.experiments && this._compiler.options.experiments.css) { + // If user enables `experiments.css`, then we are trying to emit css code directly. + // Although we can target requests like `xxx.vue?type=style` to match `type: "css"`, + // it will make the plugin a mess. + if (!options.experimentalInlineMatchResource) { + this.emitError( + new Error( + '`experimentalInlineMatchResource` should be enabled if `experiments.css` enabled currently' + ) + ) + return '' + } + + if (query.inline || query.module) { + this.emitError( + new Error( + '`inline` or `module` is currently not supported with `experiments.css` enabled' + ) + ) + return '' + } + + const loaderString = [stylePostLoaderPath, ...loaders] + .map((loader) => { + return typeof loader === 'string' ? loader : loader.request + }) + .join('!') + + const styleRequest = loaderUtils.stringifyRequest( + this, + `${this.resourcePath}${query.lang ? `.${query.lang}` : ''}${ + this.resourceQuery + }!=!-!${loaderString}!${this.resourcePath + this.resourceQuery}` + ) + return `@import ${styleRequest};` + } + const cssLoaderIndex = loaders.findIndex(isCSSLoader) if (cssLoaderIndex > -1) { const afterLoaders = loaders.slice(0, cssLoaderIndex + 1) const beforeLoaders = loaders.slice(cssLoaderIndex + 1) - const request = genRequest([ - ...afterLoaders, - stylePostLoaderPath, - ...beforeLoaders - ]) + const request = genRequest( + [...afterLoaders, stylePostLoaderPath, ...beforeLoaders], + query.lang || 'css' + ) // console.log(request) return query.module ? `export { default } from ${request}; export * from ${request}` diff --git a/lib/plugin-webpack5.js b/lib/plugin-webpack5.js index 6c0046825..4a26bee6a 100644 --- a/lib/plugin-webpack5.js +++ b/lib/plugin-webpack5.js @@ -109,6 +109,8 @@ class VueLoaderPlugin { const vueLoaderUse = vueUse[vueLoaderUseIndex] vueLoaderUse.ident = 'vue-loader-options' vueLoaderUse.options = vueLoaderUse.options || {} + const enableInlineMatchResource = + vueLoaderUse.options.experimentalInlineMatchResource // for each user rule (expect the vue rule), create a cloned rule // that targets the corresponding language blocks in *.vue files. @@ -165,20 +167,52 @@ class VueLoaderPlugin { const parsed = qs.parse(query.slice(1)) return parsed.vue != null }, - options: { - cacheDirectory: vueLoaderUse.options.cacheDirectory, - cacheIdentifier: vueLoaderUse.options.cacheIdentifier - } + options: vueLoaderUse.options } - // replace original rules - compiler.options.module.rules = [ - pitcher, - ...jsRulesForRenderFn, - ...(is27 ? [templateCompilerRule] : []), - ...clonedRules, - ...rules - ] + if (enableInlineMatchResource) { + // Match rules using `vue-loader` + const vueLoaderRules = rules.filter((rule) => { + const matchOnce = (use) => { + let loaderString = '' + + if (!use) { + return loaderString + } + + if (typeof use === 'string') { + loaderString = use + } else if (Array.isArray(use)) { + loaderString = matchOnce(use[0]) + } else if (typeof use === 'object' && use.loader) { + loaderString = use.loader + } + return loaderString + } + + const loader = rule.loader || matchOnce(rule.use) + return ( + loader === require('../package.json').name || + loader.startsWith(require.resolve('./index')) + ) + }) + + compiler.options.module.rules = [ + pitcher, + ...rules.filter((rule) => !vueLoaderRules.includes(rule)), + ...(is27 ? [templateCompilerRule] : []), + ...clonedRules, + ...vueLoaderRules + ] + } else { + compiler.options.module.rules = [ + pitcher, + ...jsRulesForRenderFn, + ...(is27 ? [templateCompilerRule] : []), + ...clonedRules, + ...rules + ] + } } } diff --git a/lib/plugin.js b/lib/plugin.js index f9bbb0d7d..906e95ed5 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -1,12 +1,17 @@ -const webpack = require('webpack') -let VueLoaderPlugin = null - -if (webpack.version && webpack.version[0] > 4) { - // webpack5 and upper - VueLoaderPlugin = require('./plugin-webpack5') -} else { - // webpack4 and lower - VueLoaderPlugin = require('./plugin-webpack4') +const { testWebpack5 } = require('./codegen/utils') +const NS = 'vue-loader' +class VueLoaderPlugin { + apply(compiler) { + let Ctor = null + if (testWebpack5(compiler)) { + // webpack5 and upper + Ctor = require('./plugin-webpack5') + } else { + // webpack4 and lower + Ctor = require('./plugin-webpack4') + } + new Ctor().apply(compiler) + } } - +VueLoaderPlugin.NS = NS module.exports = VueLoaderPlugin diff --git a/package.json b/package.json index 3e90c9a41..6866b6168 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "typings": "lib/index.d.ts", "scripts": { "test": "jest --env node", + "test:match-resource": "INLINE_MATCH_RESOURCE=true jest --env node", "lint": "eslint lib test --fix", "build": "webpack --config example/webpack.config.js --hide-modules", "dev": "webpack-dev-server --config example/webpack.config.js --inline --hot", diff --git a/test/advanced.spec.js b/test/advanced.spec.js index 7fe5f4cc5..e7f187d80 100644 --- a/test/advanced.spec.js +++ b/test/advanced.spec.js @@ -5,7 +5,8 @@ const { mfs, genId, bundle, - mockBundleAndRun + mockBundleAndRun, + DEFAULT_VUE_USE } = require('./utils') test('support chaining with other loaders', done => { @@ -15,7 +16,7 @@ test('support chaining with other loaders', done => { config.module.rules[0] = { test: /\.vue$/, use: [ - 'vue-loader', + DEFAULT_VUE_USE, require.resolve('./mock-loaders/js') ] } @@ -33,7 +34,7 @@ test('inherit queries on files', done => { config.module.rules[0] = { test: /\.vue$/, use: [ - 'vue-loader', + DEFAULT_VUE_USE, require.resolve('./mock-loaders/query') ] } @@ -86,7 +87,6 @@ test('expose file basename as __file in production when exposeFilename enabled', ) }) - test('extract CSS', done => { bundle({ entry: 'extract-css.vue', @@ -94,7 +94,7 @@ test('extract CSS', done => { config.module.rules = [ { test: /\.vue$/, - use: 'vue-loader' + use: [DEFAULT_VUE_USE] }, { test: /\.css$/, @@ -135,7 +135,7 @@ test('extract CSS with code spliting', done => { config.module.rules = [ { test: /\.vue$/, - use: 'vue-loader' + use: [DEFAULT_VUE_USE] }, { test: /\.css$/, @@ -166,7 +166,7 @@ test('support rules with oneOf', async () => { entry, modify: config => { config.module.rules = [ - { test: /\.vue$/, loader: 'vue-loader' }, + { test: /\.vue$/, use: [DEFAULT_VUE_USE] }, { test: /\.css$/, use: 'vue-style-loader', @@ -221,7 +221,7 @@ test('should work with eslint loader', async () => { entry: 'basic.vue', modify: config => { config.module.rules.unshift({ - test: /\.vue$/, loader: 'vue-loader', enforce: 'pre' + test: /\.vue$/, use: [DEFAULT_VUE_USE], enforce: 'pre' }) } }, () => resolve()) diff --git a/test/edgeCases.spec.js b/test/edgeCases.spec.js index 20f18fff2..e2316a1fb 100644 --- a/test/edgeCases.spec.js +++ b/test/edgeCases.spec.js @@ -7,7 +7,8 @@ const { mfs, bundle, mockRender, - mockBundleAndRun + mockBundleAndRun, + DEFAULT_VUE_USE } = require('./utils') const assertComponent = ({ @@ -42,7 +43,7 @@ test('vue rule with include', done => { config.module.rules[0] = { test: /\.vue$/, include: /fixtures/, - loader: 'vue-loader' + use: [DEFAULT_VUE_USE] } } }, res => assertComponent(res, done)) @@ -55,7 +56,7 @@ test('test-less oneOf rules', done => { config.module.rules = [ { test: /\.vue$/, - loader: 'vue-loader' + use: [DEFAULT_VUE_USE] }, { oneOf: [ @@ -97,12 +98,7 @@ test('normalize multiple use + options', done => { modify: config => { config.module.rules[0] = { test: /\.vue$/, - use: [ - { - loader: 'vue-loader', - options: {} - } - ] + use: [DEFAULT_VUE_USE] } } }, () => done(), true) diff --git a/test/ssr.spec.js b/test/ssr.spec.js index 1781b5dd4..716f1395d 100644 --- a/test/ssr.spec.js +++ b/test/ssr.spec.js @@ -4,7 +4,8 @@ const { genId, bundle, baseConfig, - interopDefault + interopDefault, + DEFAULT_VUE_USE } = require('./utils') test('SSR style and moduleId extraction', done => { @@ -116,7 +117,7 @@ test('SSR + CSS Modules', done => { }), modify: config => { config.module.rules = [ - { test: /\.vue$/, loader: 'vue-loader' }, + { test: /\.vue$/, use: [DEFAULT_VUE_USE] }, { test: /\.css$/, use: baseLoaders diff --git a/test/style.spec.js b/test/style.spec.js index ddf183f3b..0e35e1a78 100644 --- a/test/style.spec.js +++ b/test/style.spec.js @@ -2,7 +2,8 @@ const normalizeNewline = require('normalize-newline') const { genId, mockRender, - mockBundleAndRun + mockBundleAndRun, + DEFAULT_VUE_USE } = require('./utils') test('scoped style', done => { @@ -126,7 +127,7 @@ test('CSS Modules', async () => { config.module.rules = [ { test: /\.vue$/, - loader: 'vue-loader' + use: [DEFAULT_VUE_USE] }, { test: /\.css$/, @@ -201,7 +202,7 @@ test('CSS Modules Extend', async () => { config.module.rules = [ { test: /\.vue$/, - loader: 'vue-loader' + use: [DEFAULT_VUE_USE] }, { test: /\.css$/, diff --git a/test/utils.js b/test/utils.js index 9403b3608..ab8e82bc4 100644 --- a/test/utils.js +++ b/test/utils.js @@ -9,6 +9,13 @@ const { createFsFromVolume, Volume } = require('memfs') const mfs = createFsFromVolume(new Volume()) const VueLoaderPlugin = require('../lib/plugin') +const DEFAULT_VUE_USE = { + loader: 'vue-loader', + options: { + experimentalInlineMatchResource: Boolean(process.env.INLINE_MATCH_RESOURCE) + } +} + const baseConfig = { mode: 'development', devtool: false, @@ -26,14 +33,7 @@ const baseConfig = { rules: [ { test: /\.vue$/, - loader: 'vue-loader' - }, - { - test: /\.css$/, - use: [ - 'vue-style-loader', - 'css-loader' - ] + use: [DEFAULT_VUE_USE] } ] }, @@ -53,13 +53,37 @@ function genId (file) { function bundle (options, cb, wontThrowError) { let config = merge({}, baseConfig, options) - + if (!options.experiments || !options.experiments.css) { + config.module && config.module.rules && config.module.rules.push({ + test: /\.css$/, + use: ['vue-style-loader', 'css-loader'] + }) + } if (config.vue) { - const vueOptions = options.vue + const vueOptions = { + // Test experimental inline match resource by default + experimentalInlineMatchResource: Boolean( + process.env.INLINE_MATCH_RESOURCE + ), + ...options.vue + } delete config.vue const vueIndex = config.module.rules.findIndex(r => r.test.test('.vue')) const vueRule = config.module.rules[vueIndex] - config.module.rules[vueIndex] = Object.assign({}, vueRule, { options: vueOptions }) + + // Detect `Rule.use` or `Rule.loader` and `Rule.options` combination + if (vueRule && typeof vueRule === 'object' && Array.isArray(vueRule.use)) { + // Vue usually locates at the first loader + if (vueRule.use && typeof vueRule.use[0] === 'object') { + vueRule.use[0] = Object.assign({}, vueRule.use[0], { + options: vueOptions + }) + } + } else { + config.module.rules[vueIndex] = Object.assign({}, vueRule, { + options: vueOptions + }) + } } if (/\.vue/.test(config.entry)) { @@ -163,5 +187,6 @@ module.exports = { mockBundleAndRun, mockRender, interopDefault, - initStylesForAllSubComponents + initStylesForAllSubComponents, + DEFAULT_VUE_USE }