From 824b5ab9efd0ea83ac71aaf3d1d7b23a9ef679f3 Mon Sep 17 00:00:00 2001 From: pimlie Date: Tue, 11 Jun 2019 14:57:27 +0200 Subject: [PATCH] feat: first commit for vue-meta webpack loader --- packages/webpack-loader/jest.config.js | 3 + packages/webpack-loader/package.json | 17 ++ packages/webpack-loader/src/loader.js | 91 ++++++++++ packages/webpack-loader/src/plugin.js | 21 +++ .../webpack-loader/test/fixtures/entry.js | 9 + .../webpack-loader/test/fixtures/static.vue | 10 ++ .../test/fixtures/with-bindings.vue | 10 ++ packages/webpack-loader/test/template.test.js | 43 +++++ packages/webpack-loader/test/utils.js | 168 ++++++++++++++++++ 9 files changed, 372 insertions(+) create mode 100644 packages/webpack-loader/jest.config.js create mode 100644 packages/webpack-loader/package.json create mode 100644 packages/webpack-loader/src/loader.js create mode 100644 packages/webpack-loader/src/plugin.js create mode 100644 packages/webpack-loader/test/fixtures/entry.js create mode 100644 packages/webpack-loader/test/fixtures/static.vue create mode 100644 packages/webpack-loader/test/fixtures/with-bindings.vue create mode 100644 packages/webpack-loader/test/template.test.js create mode 100644 packages/webpack-loader/test/utils.js diff --git a/packages/webpack-loader/jest.config.js b/packages/webpack-loader/jest.config.js new file mode 100644 index 0000000..95495de --- /dev/null +++ b/packages/webpack-loader/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + testEnvironment: 'node' +} diff --git a/packages/webpack-loader/package.json b/packages/webpack-loader/package.json new file mode 100644 index 0000000..0ef4dcd --- /dev/null +++ b/packages/webpack-loader/package.json @@ -0,0 +1,17 @@ +{ + "name": "@vue-meta/webpack-loader", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "peerDependencies": { + "vue-template-compiler": "^2.6.10" + }, + "devDependencies": { + "jest": "^24.8.0", + "jsdom": "^15.1.1", + "memory-fs": "^0.4.1", + "vue": "^2.6.10", + "vue-template-compiler": "^2.6.10", + "webpack": "^4.33.0" + } +} diff --git a/packages/webpack-loader/src/loader.js b/packages/webpack-loader/src/loader.js new file mode 100644 index 0000000..2f58632 --- /dev/null +++ b/packages/webpack-loader/src/loader.js @@ -0,0 +1,91 @@ +const qs = require('querystring') +const { compile } = require('vue-template-compiler') + +function vnodesToMetaData(vnodes, metaInfo = {}) { + let hasBindings = false + for (const vnode of vnodes) { + if (vnode.type !== 1) { + continue + } + + if (!metaInfo[vnode.tag]) { + metaInfo[vnode.tag] = [] + } + + const metaData = {} + if (vnode.attrsList.length) { + vnode.attrs.forEach(attr => (metaData[attr.name] = `${attr.dynamic === undefined ? '' : 'this.'}${attr.value}`)) + } + + if (vnode.children.length) { + metaData.innerHTML = vnode.children.reduce((acc, child) => { + if (child.static) { + return `"${child.text}"` + } + + return acc + child.tokens.reduce((bcc, token) => { + if (typeof token === 'object' && token['@binding']) { + hasBindings = true + return `${bcc}${bcc ? ' + ' : ''}this.${token['@binding']}` + } + + return `${bcc}${bcc ? ' + ' : ''}"${token}"` + }, '') + }, '') + } + + metaInfo[vnode.tag].push(metaData) + } + + return [ + metaInfo, + hasBindings + ] +} + +function templify(value) { + if (Array.isArray(value)) { + const arrStr = value.map(templify).join(',') + return `[${arrStr}]` + } + + if (typeof value === 'object') { + const objStr = Object.keys(value).map(key => `"${key}": ${templify(value[key])}`).join(',') + return `{${objStr}}` + } + + return value +} + +module.exports = function (source, map) { + const rawQuery = this.resourceQuery.slice(1) + const inheritQuery = `&${rawQuery}` + const incomingQuery = qs.parse(rawQuery) + + if (incomingQuery.type !== 'custom' || incomingQuery.blockType !== 'head') { + this.callback(null, source, map) + } + + const vnodes = compile(`${source}`) + + const [ metaInfo, hasBindings ] = vnodesToMetaData(vnodes.ast.children) + + let content + if (hasBindings) { + content = ` + Component.options.computed = { + ...Component.options.computed, + metaInfo() { + return ${templify(metaInfo)} + } + }` + } else { + content = ` + Component.options.data = { + ...Component.options.data, + metaInfo: ${templify(metaInfo)} + }` + } + + this.callback(null, `export default function (Component) { ${content} }`, map) +} diff --git a/packages/webpack-loader/src/plugin.js b/packages/webpack-loader/src/plugin.js new file mode 100644 index 0000000..43cb971 --- /dev/null +++ b/packages/webpack-loader/src/plugin.js @@ -0,0 +1,21 @@ +module.exports = class VueMetaLoaderPlugin { + apply(compiler) { + const rules = compiler.options.module.rules + const vueRule = rules.find(r => r.loader.includes('vue-loader')) + + if (!vueRule) { + console.error('vue-loader not found in webpack config') + return + } + + if (vueRule.use) { + console.error('vue-meta-loader plugin should be added before the vue-loader plugin') + return + } + + compiler.options.module.rules.push({ + resourceQuery: /blockType=head/, + loader: require.resolve('./loader.js') + }) + } +} diff --git a/packages/webpack-loader/test/fixtures/entry.js b/packages/webpack-loader/test/fixtures/entry.js new file mode 100644 index 0000000..431e1ed --- /dev/null +++ b/packages/webpack-loader/test/fixtures/entry.js @@ -0,0 +1,9 @@ +import Component from '~target' +import * as exports from '~target' + +if (typeof window !== 'undefined') { + window.module = Component + window.exports = exports +} + +export default Component diff --git a/packages/webpack-loader/test/fixtures/static.vue b/packages/webpack-loader/test/fixtures/static.vue new file mode 100644 index 0000000..120d445 --- /dev/null +++ b/packages/webpack-loader/test/fixtures/static.vue @@ -0,0 +1,10 @@ + + Static Title + + + + diff --git a/packages/webpack-loader/test/fixtures/with-bindings.vue b/packages/webpack-loader/test/fixtures/with-bindings.vue new file mode 100644 index 0000000..491bc0d --- /dev/null +++ b/packages/webpack-loader/test/fixtures/with-bindings.vue @@ -0,0 +1,10 @@ + + {{ title }} + + + + diff --git a/packages/webpack-loader/test/template.test.js b/packages/webpack-loader/test/template.test.js new file mode 100644 index 0000000..28f8e30 --- /dev/null +++ b/packages/webpack-loader/test/template.test.js @@ -0,0 +1,43 @@ +const path = require('path') + +const { + mockRender, + mockBundleAndRun +} = require('./utils') + +test('static metadata', (done) => { + mockBundleAndRun({ + entry: 'static.vue' + }, ({ window, module }) => { + expect(module.data.metaInfo).toBeDefined() + + const vnode = mockRender(module) + + expect(vnode.metaInfo.title).toBeDefined() + expect(vnode.metaInfo.title[0].innerHTML).toBe('Static Title') + expect(vnode.metaInfo.meta).toBeDefined() + expect(vnode.metaInfo.meta[0].name).toBe('description') + expect(vnode.metaInfo.meta[0].content).toBe('static description') + done() + }) +}) + +test('metadata with bindings', (done) => { + mockBundleAndRun({ + entry: 'with-bindings.vue' + }, ({ window, module }) => { + expect(module.computed.metaInfo).not.toBeUndefined() + + const vnode = mockRender(module, { + title: 'Test Title', + description: 'Test Description' + }) + + expect(vnode.metaInfo.title).toBeDefined() + expect(vnode.metaInfo.title[0].innerHTML).toBe('Test Title') + expect(vnode.metaInfo.meta).toBeDefined() + expect(vnode.metaInfo.meta[0].name).toBe('description') + expect(vnode.metaInfo.meta[0].content).toBe('Test Description') + done() + }) +}) diff --git a/packages/webpack-loader/test/utils.js b/packages/webpack-loader/test/utils.js new file mode 100644 index 0000000..01ddf74 --- /dev/null +++ b/packages/webpack-loader/test/utils.js @@ -0,0 +1,168 @@ +const path = require('path') +const Vue = require('vue') +const hash = require('hash-sum') +const { JSDOM, VirtualConsole } = require('jsdom') +const webpack = require('webpack') +const merge = require('webpack-merge') +const MemoryFS = require('memory-fs') +const VueLoaderPlugin = require('vue-loader/lib/plugin') + +const mfs = new MemoryFS() +const VueMetaLoaderPlugin = require('../src/plugin') + +const baseConfig = { + mode: 'development', + devtool: false, + output: { + path: '/', + filename: 'test.build.js' + }, + module: { + rules: [ + { + test: /\.vue$/, + loader: 'vue-loader' + }, + { + test: /\.css$/, + use: [ + 'vue-style-loader', + 'css-loader' + ] + } + ] + }, + plugins: [ + new VueMetaLoaderPlugin(), + new VueLoaderPlugin(), + new webpack.optimize.ModuleConcatenationPlugin() + ] +} + +function genId(file) { + return hash(path.join('test', 'fixtures', file).replace(/\\/g, '/')) +} + +function bundle(options, cb, wontThrowError) { + let config = merge({}, baseConfig, options) + + if (config.vue) { + const vueOptions = 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 }) + } + + if (/\.vue/.test(config.entry)) { + const vueFile = config.entry + config = merge(config, { + entry: require.resolve('./fixtures/entry'), + resolve: { + alias: { + '~target': path.resolve(__dirname, './fixtures', vueFile) + } + } + }) + } + + if (options.modify) { + delete config.modify + options.modify(config) + } + + const webpackCompiler = webpack(config) + webpackCompiler.outputFileSystem = mfs + webpackCompiler.run((err, stats) => { + const errors = stats.compilation.errors + if (!wontThrowError) { + expect(err).toBeNull() + if (errors && errors.length) { + errors.forEach((error) => { + console.error(error.message) + }) + } + expect(errors).toHaveLength(0) + } + cb(mfs.readFileSync('/test.build.js').toString(), stats, err) + }) +} + +function mockBundleAndRun(options, assert, wontThrowError) { + const { suppressJSDOMConsole = true } = options + delete options.suppressJSDOMConsole + bundle(options, (code, bundleStats, bundleError) => { + let dom, jsdomError + try { + dom = new JSDOM(``, { + runScripts: 'outside-only', + virtualConsole: suppressJSDOMConsole ? new VirtualConsole() : null + }) + dom.window.eval(code) + } catch (e) { + console.error(`JSDOM error:\n${e.stack}`) + jsdomError = e + } + + const { window } = dom + const { module, exports } = window + const instance = {} + if (module && module.beforeCreate) { + module.beforeCreate.forEach(hook => hook.call(instance)) + } + assert({ + window, + module, + exports, + instance, + code, + jsdomError, + bundleStats, + bundleError + }) + }, wontThrowError) +} + +function mockRender(options, extraData = {}) { + const vm = new Vue({ + ...options, + data() { + return { + ...(typeof options.data === 'function' ? options.data() : options.data), + ...extraData + } + } + }) + vm.$mount() + return vm +} + +function interopDefault(module) { + return module + ? module.default ? module.default : module + : module +} + +function initStylesForAllSubComponents(module) { + if (module.components) { + for (const name in module.components) { + const sub = module.components[name] + const instance = {} + if (sub && sub.beforeCreate) { + sub.beforeCreate.forEach(hook => hook.call(instance)) + } + initStylesForAllSubComponents(sub) + } + } +} + +module.exports = { + mfs, + baseConfig, + genId, + bundle, + mockBundleAndRun, + mockRender, + interopDefault, + initStylesForAllSubComponents +}