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
+
+
+
+
+
+
Test
+
+
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 }}
+
+
+
+
+
+
Test
+
+
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
+}