mirror of
https://github.com/tenrok/vue-meta.git
synced 2026-06-23 10:40:34 +03:00
feat: first commit for vue-meta webpack loader
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node'
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(`<head>${source}</head>`)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<head>
|
||||||
|
<title>Static Title</title>
|
||||||
|
<meta name="description" content="static description">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>Test</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<head>
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<meta key="description" name="description" :content="description">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>Test</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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(`<!DOCTYPE html><html><head></head><body></body></html>`, {
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user