2
0
mirror of https://github.com/tenrok/vue-meta.git synced 2026-06-24 08:30:33 +03:00

Merge pull request #319 from pimlie/feat-jest

feat: major refactor, cleanup and jest tests
This commit is contained in:
Sébastien Chopin
2019-02-19 17:18:02 +01:00
committed by GitHub
70 changed files with 8976 additions and 923 deletions
+1 -6
View File
@@ -1,8 +1,3 @@
{ {
"presets": ["@babel/preset-env"], "presets": ["@babel/preset-env"]
"env": {
"test": {
"plugins": ["istanbul"]
}
}
} }
+13
View File
@@ -0,0 +1,13 @@
{
"root": true,
"parserOptions": {
"parser": "babel-eslint",
"sourceType": "module"
},
"extends": [
"@nuxtjs"
],
"globals": {
"Vue": "readable"
}
}
+4
View File
@@ -32,9 +32,13 @@ jspm_packages
# Optional npm cache directory # Optional npm cache directory
.npm .npm
package-lock.json
# Optional REPL history # Optional REPL history
.node_repl_history .node_repl_history
# built code # built code
lib lib
# examples yarn lock
examples/yarn.lock
+3 -1
View File
@@ -847,4 +847,6 @@ If this were not the case, you would have to instruct Babel to convert `default`
# Examples # Examples
To run the examples; clone this repository & run `npm install` in the root directory, and then run `npm run dev`. Head to http://localhost:8080. To run the examples locally; clone this repository and run `cd examples && yarn install` in the root directory, then run `yarn start`. Head to http://localhost:3000 or run with `HOST=0.0.0.0 PORT=8080 yarn start` to change host or port
If you would like to help to develop vue-meta then run `yarn install` both in the root and examples dir and run `yarn dev`
+3
View File
@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}
+7010
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "vue-meta-examples",
"version": "1.0.0",
"description": "Examples for vue-meta",
"main": "server.js",
"private": true,
"scripts": {
"dev": "cross-env NODE_ENV=development babel-node server.js",
"start": "babel-node server.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nuxt/vue-meta.git"
},
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/nuxt/vue-meta/issues"
},
"homepage": "https://github.com/nuxt/vue-meta#readme",
"devDependencies": {
"@babel/core": "^7.2.2",
"@babel/node": "^7.2.2",
"@babel/preset-env": "^7.3.1",
"babel-loader": "^8.0.5",
"cross-env": "^5.2.0",
"express": "^4.16.4",
"express-urlrewrite": "^1.2.0",
"vue": "^2.6.3",
"vue-loader": "^15.6.2",
"vue-meta": "^1.5.8",
"vue-router": "^3.0.2",
"vue-template-compiler": "^2.6.3",
"vuex": "^3.1.0",
"webpack": "^4.26.1",
"webpack-dev-server": "^3.1.10",
"webpackbar": "^3.1.5"
}
}
+5 -3
View File
@@ -24,7 +24,9 @@ fs.readdirSync(__dirname).forEach(file => {
app.use(express.static(__dirname)) app.use(express.static(__dirname))
const port = process.env.PORT || 8080 const host = process.env.HOST || 'localhost'
module.exports = app.listen(port, () => { const port = process.env.PORT || 3000
console.log(`Server listening on http://localhost:${port}, Ctrl+C to stop`)
module.exports = app.listen(port, host, () => {
console.log(`Server listening on http://${host}:${port}, Ctrl+C to stop`)
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
const Vue = require('vue') const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer() const renderer = require('vue-server-renderer').createRenderer()
const VueMeta = require('../') const VueMeta = require(process.env.NODE_ENV === 'development' ? '../' : 'vue-meta')
Vue.use(VueMeta, { Vue.use(VueMeta, {
tagIDKeyName: 'hid' tagIDKeyName: 'hid'
+1 -1
View File
@@ -1,6 +1,6 @@
const Vue = require('vue') const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer() const renderer = require('vue-server-renderer').createRenderer()
const VueMeta = require('../') const VueMeta = require(process.env.NODE_ENV === 'development' ? '../' : 'vue-meta')
Vue.use(VueMeta, { Vue.use(VueMeta, {
tagIDKeyName: 'hid' tagIDKeyName: 'hid'
+13 -3
View File
@@ -1,4 +1,3 @@
import assign from 'object-assign'
import Vue from 'vue' import Vue from 'vue'
import VueMeta from 'vue-meta' import VueMeta from 'vue-meta'
import Router from 'vue-router' import Router from 'vue-router'
@@ -12,8 +11,18 @@ const ChildComponent = {
template: `<h3>You're looking at the <strong>{{ page }}</strong> page</h3>`, template: `<h3>You're looking at the <strong>{{ page }}</strong> page</h3>`,
metaInfo () { metaInfo () {
return { return {
title: this.page title: `${this.page} - ${this.date && this.date.toTimeString()}`
} }
},
data() {
return {
date: null
};
},
mounted() {
setInterval(() => {
this.date = new Date();
}, 1000);
} }
} }
@@ -41,6 +50,7 @@ const router = new Router({
}) })
const App = { const App = {
router,
template: ` template: `
<div id="app"> <div id="app">
<h1>vue-router</h1> <h1>vue-router</h1>
@@ -54,6 +64,6 @@ const App = {
` `
} }
const app = new Vue(assign(App, { router })) const app = new Vue(App)
app.$mount('#app') app.$mount('#app')
+3 -3
View File
@@ -1,9 +1,9 @@
import assign from 'object-assign'
import Vue from 'vue' import Vue from 'vue'
import store from './store' import store from './store'
import router from './router' import router from './router'
import App from './App.vue' import App from './App.vue'
const app = new Vue(assign(App, { router, store })) App.router = router
App.store = store
app.$mount('#app') new Vue(App).$mount('#app')
+3 -3
View File
@@ -1,9 +1,9 @@
import assign from 'object-assign'
import Vue from 'vue' import Vue from 'vue'
import store from './store' import store from './store'
import router from './router' import router from './router'
import App from './App.vue' import App from './App.vue'
const app = new Vue(assign(App, { router, store })) App.router = router
App.store = store
app.$mount('#app') new Vue(App).$mount('#app')
+5 -2
View File
@@ -1,6 +1,8 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import webpack from 'webpack' import webpack from 'webpack'
import WebpackBar from 'webpackbar'
import VueLoaderPlugin from 'vue-loader/lib/plugin'
export default { export default {
devtool: 'inline-source-map', devtool: 'inline-source-map',
@@ -28,7 +30,7 @@ export default {
resolve: { resolve: {
alias: { alias: {
'vue': 'vue/dist/vue.js', 'vue': 'vue/dist/vue.js',
'vue-meta': path.join(__dirname, '..', 'src') 'vue-meta': process.env.NODE_ENV === 'development' ? path.join(__dirname, '..', 'src') : 'vue-meta'
} }
}, },
// Expose __dirname to allow automatically setting basename. // Expose __dirname to allow automatically setting basename.
@@ -37,7 +39,8 @@ export default {
__dirname: true __dirname: true
}, },
plugins: [ plugins: [
// new webpack.optimize.CommonsChunkPlugin('shared.js'), new WebpackBar(),
new VueLoaderPlugin(),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development') 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development')
}) })
+47
View File
@@ -0,0 +1,47 @@
module.exports = {
testEnvironment: 'node',
expand: true,
forceExit: false,
// https://github.com/facebook/jest/pull/6747 fix warning here
// But its performance overhead is pretty bad (30+%).
// detectOpenHandles: true
setupFilesAfterEnv: ['./test/utils/setup'],
coverageDirectory: './coverage',
collectCoverageFrom: [
'**/src/**/*.js'
],
coveragePathIgnorePatterns: [
'node_modules'
],
testPathIgnorePatterns: [
'node_modules',
'old'
],
transformIgnorePatterns: [
'node_modules'
],
transform: {
'^.+\\.js$': 'babel-jest',
'.*\\.(vue)$': 'vue-jest'
},
moduleFileExtensions: [
'ts',
'js',
'json'
],
reporters: [
'default'
].concat(process.env.JEST_JUNIT_OUTPUT ? ['jest-junit'] : [])
}
-33
View File
@@ -1,33 +0,0 @@
import webpackConfig from './examples/webpack.config.babel'
delete webpackConfig.entry
export default (config) => {
config.set({
browsers: ['PhantomJS'],
frameworks: ['mocha', 'chai'],
reporters: ['mocha', 'coverage'],
files: ['test/index.js'],
preprocessors: {
'test/index.js': ['webpack', 'sourcemap']
},
coverageReporter: {
reporters: [
{ type: 'lcov' },
{ type: 'text' }
],
includeAllSources: true,
dir: 'coverage',
subdir: '.'
},
webpack: webpackConfig,
webpackMiddleware: {
noInfo: true
},
mochaReporter: {
showDiff: true,
output: 'full'
},
singleRun: true
})
}
-2
View File
@@ -1,2 +0,0 @@
require('@babel/register')
module.exports = require('./karma.conf.babel').default
+35 -65
View File
@@ -1,87 +1,73 @@
{ {
"name": "vue-meta", "name": "vue-meta",
"description": "manage page meta info in Vue 2.0 server-rendered apps", "description": "Manage page meta info in Vue 2.0 server-rendered apps",
"version": "1.5.8", "version": "1.5.8",
"author": "Declan de Wet <declandewet@me.com>", "author": "Declan de Wet <declandewet@me.com>",
"bugs": "https://github.com/declandewet/vue-meta/issues", "bugs": "https://github.com/nuxt/vue-meta/issues",
"scripts": { "scripts": {
"build": "rollup -c", "build": "rimraf lib && rollup -c scripts/rollup.config.js",
"codecov": "codecov", "codecov": "codecov",
"deploy": "npm version", "deploy": "npm version",
"dev": "babel-node examples/server.js", "dev": "cd examples && npm run dev && cd ..",
"lint": "standard --verbose | snazzy", "lint": "eslint src test",
"minify": "uglifyjs lib/vue-meta.js -cm --comments -o lib/vue-meta.min.js",
"postbuild": "npm run minify",
"postdeploy": "git push origin master --follow-tags && npm run release", "postdeploy": "git push origin master --follow-tags && npm run release",
"postversion": "npm run update-cdn && git add . && git commit -m \":ship: CDN update\"", "postversion": "npm run update-cdn && git add . && git commit -m \":ship: CDN update\"",
"prebuild": "rimraf lib",
"predeploy": "git checkout master && git pull -r", "predeploy": "git checkout master && git pull -r",
"prerelease": "npm run build", "prerelease": "npm run build",
"pretest": "npm run lint",
"preversion": "npm run toc", "preversion": "npm run toc",
"release": "npm publish", "release": "npm publish",
"test": "cross-env NODE_ENV=test karma start karma.conf.js", "test": "jest",
"toc": "doctoc README.md --title '# Table of Contents'", "toc": "doctoc README.md --title '# Table of Contents'",
"update-cdn": "babel-node scripts/update-cdn.js" "update-cdn": "babel-node scripts/update-cdn.js"
}, },
"dependencies": { "dependencies": {
"deepmerge": "^3.0.0", "deepmerge": "^3.0.0",
"lodash.isplainobject": "^4.0.6", "lodash.isplainobject": "^4.0.6",
"lodash.uniqueid": "^4.0.1", "lodash.uniqueid": "^4.0.1"
"object-assign": "^4.1.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.1.5",
"@babel/core": "^7.1.6", "@babel/core": "^7.1.6",
"@babel/node": "^7.0.0", "@babel/node": "^7.2.2",
"@babel/preset-env": "^7.1.6", "@babel/preset-env": "^7.1.6",
"@babel/register": "^7.0.0", "@nuxtjs/eslint-config": "^0.0.1",
"@vue/server-test-utils": "^1.0.0-beta.29",
"@vue/test-utils": "^1.0.0-beta.29",
"babel-core": "^7.0.0-bridge",
"babel-eslint": "^10.0.1",
"babel-jest": "^24.1.0",
"babel-loader": "^8.0.4", "babel-loader": "^8.0.4",
"babel-plugin-istanbul": "^5.1.0",
"chai": "^4.2.0",
"codecov": "^3.1.0", "codecov": "^3.1.0",
"cross-env": "^5.2.0",
"css-loader": "^2.0.0",
"doctoc": "^1.4.0", "doctoc": "^1.4.0",
"es6-promise": "^4.2.5", "eslint": "^5.13.0",
"express": "^4.16.4", "eslint-config-standard": "^12.0.0",
"express-urlrewrite": "^1.2.0", "eslint-plugin-import": "^2.16.0",
"file-loader": "^3.0.0", "eslint-plugin-jest": "^22.2.2",
"karma": "^3.1.1", "eslint-plugin-node": "^8.0.1",
"karma-chai": "^0.1.0", "eslint-plugin-promise": "^4.0.1",
"karma-coverage": "^1.1.2", "eslint-plugin-standard": "^4.0.0",
"karma-mocha": "^1.3.0", "eslint-plugin-vue": "^5.1.0",
"karma-mocha-reporter": "^2.2.5", "jest": "^24.1.0",
"karma-phantomjs-launcher": "^1.0.4", "jsdom": "^13.2.0",
"karma-sourcemap-loader": "^0.3.7", "jsdom-global": "^3.0.2",
"karma-webpack": "^3.0.5",
"mocha": "^5.2.0",
"phantomjs-prebuilt": "^2.1.16",
"rimraf": "^2.6.2", "rimraf": "^2.6.2",
"rollup": "^1.0.0", "rollup": "^1.0.0",
"rollup-plugin-buble": "^0.19.4", "rollup-plugin-buble": "^0.19.4",
"rollup-plugin-commonjs": "^9.2.0", "rollup-plugin-commonjs": "^9.2.0",
"rollup-plugin-json": "^3.1.0", "rollup-plugin-json": "^3.1.0",
"rollup-plugin-node-resolve": "^4.0.0", "rollup-plugin-node-resolve": "^4.0.0",
"snazzy": "^8.0.0", "rollup-plugin-terser": "^4.0.4",
"standard": "^12.0.1",
"uglify-js": "^3.4.9",
"update-section": "^0.3.3", "update-section": "^0.3.3",
"vue": "^2.5.17", "vue": "^2.6.3",
"vue-loader": "^15.4.2", "vue-jest": "^3.0.2",
"vue-router": "^3.0.2", "vue-server-renderer": "^2.6.3",
"vue-server-renderer": "^2.5.17", "vue-template-compiler": "^2.6.3"
"vue-template-compiler": "^2.5.17",
"vuex": "^3.0.1",
"webpack": "^4.26.1",
"webpack-dev-server": "^3.1.10"
}, },
"files": [ "files": [
"lib", "lib",
"types/index.d.ts", "types/index.d.ts",
"types/vue.d.ts" "types/vue.d.ts"
], ],
"homepage": "https://github.com/declandewet/vue-meta", "homepage": "https://github.com/nuxt/vue-meta",
"keywords": [ "keywords": [
"attribute", "attribute",
"google", "google",
@@ -97,28 +83,12 @@
"vue" "vue"
], ],
"license": "MIT", "license": "MIT",
"main": "lib/vue-meta.js", "main": "lib/vue-meta.common.js",
"web": "lib/vue-meta.js",
"module": "src/index.js",
"typings": "types/index.d.ts", "typings": "types/index.d.ts",
"nyc": {
"exclude": [
"test/**/*.js"
]
},
"repository": { "repository": {
"url": "git@github.com:declandewet/vue-meta.git", "url": "git@github.com/nuxt/vue-meta.git",
"type": "git" "type": "git"
},
"standard": {
"globals": [
"Vue",
"define",
"describe",
"it",
"expect",
"before",
"beforeEach",
"after",
"afterEach"
]
} }
} }
-27
View File
@@ -1,27 +0,0 @@
import commonjs from 'rollup-plugin-commonjs'
import nodeResolve from 'rollup-plugin-node-resolve'
import json from 'rollup-plugin-json'
import buble from 'rollup-plugin-buble'
const pkg = require('./package.json')
export default {
input: './src/index.js',
output: {
file: pkg.main,
format: 'umd',
name: 'VueMeta',
banner: `/**
* vue-meta v${pkg.version}
* (c) ${new Date().getFullYear()} Declan de Wet & Sébastien Chopin (@Atinux)
* @license MIT
*/
`.replace(/ {4}/gm, '').trim()
},
plugins: [
json(),
nodeResolve({ jsnext: true }),
commonjs(),
buble()
]
}
+55
View File
@@ -0,0 +1,55 @@
import commonjs from 'rollup-plugin-commonjs'
import nodeResolve from 'rollup-plugin-node-resolve'
import json from 'rollup-plugin-json'
import buble from 'rollup-plugin-buble'
import { terser } from 'rollup-plugin-terser'
const pkg = require('../package.json')
const banner = `/**
* vue-meta v${pkg.version}
* (c) ${new Date().getFullYear()} Declan de Wet & Sébastien Chopin (@Atinux)
* @license MIT
*/
`.replace(/ {4}/gm, '').trim()
const baseConfig = {
input: 'src/browser.js',
output: {
file: pkg.web,
format: 'umd',
name: 'VueMeta',
sourcemap: false,
banner
},
plugins: [
json(),
nodeResolve(),
commonjs(),
buble(),
]
}
export default [{
...baseConfig,
}, {
...baseConfig,
output: {
...baseConfig.output,
file: pkg.web.replace('.js', '.min.js'),
},
plugins: [
...baseConfig.plugins,
terser()
]
}, {
...baseConfig,
input: 'src/index.js',
output: {
...baseConfig.output,
file: pkg.main,
intro: 'var window',
format: 'cjs'
},
external: Object.keys(pkg.dependencies)
}]
+27
View File
@@ -0,0 +1,27 @@
import { version } from '../package.json'
import createMixin from './shared/mixin'
import setOptions from './shared/options'
import { isUndefined } from './shared/typeof'
import $meta from './client/$meta'
/**
* Plugin install function.
* @param {Function} Vue - the Vue constructor.
*/
function VueMeta(Vue, options = {}) {
options = setOptions(options)
Vue.prototype.$meta = $meta(options)
Vue.mixin(createMixin(options))
}
VueMeta.version = version
// automatic install
if (!isUndefined(window) && !isUndefined(window.Vue)) {
/* istanbul ignore next */
Vue.use(VueMeta)
}
export default VueMeta
+15
View File
@@ -0,0 +1,15 @@
import refresh from './refresh'
export default function _$meta(options = {}) {
/**
* Returns an injector for server-side rendering.
* @this {Object} - the Vue instance (a root component)
* @return {Object} - injector
*/
return function $meta() {
return {
inject: () => {},
refresh: refresh(options).bind(this)
}
}
}
+5 -3
View File
@@ -1,6 +1,8 @@
import { isUndefined } from '../shared/typeof'
// fallback to timers if rAF not present // fallback to timers if rAF not present
const stopUpdate = (typeof window !== 'undefined' ? window.cancelAnimationFrame : null) || clearTimeout const stopUpdate = (!isUndefined(window) ? window.cancelAnimationFrame : null) || clearTimeout
const startUpdate = (typeof window !== 'undefined' ? window.requestAnimationFrame : null) || ((cb) => setTimeout(cb, 0)) const startUpdate = (!isUndefined(window) ? window.requestAnimationFrame : null) || (cb => setTimeout(cb, 0))
/** /**
* Performs a batched update. Uses requestAnimationFrame to prevent * Performs a batched update. Uses requestAnimationFrame to prevent
@@ -12,7 +14,7 @@ const startUpdate = (typeof window !== 'undefined' ? window.requestAnimationFram
* @param {Function} callback - the update to perform * @param {Function} callback - the update to perform
* @return {Number} id - a new ID * @return {Number} id - a new ID
*/ */
export default function batchUpdate (id, callback) { export default function batchUpdate(id, callback) {
stopUpdate(id) stopUpdate(id)
return startUpdate(() => { return startUpdate(() => {
id = null id = null
+13 -5
View File
@@ -1,7 +1,8 @@
import getMetaInfo from '../shared/getMetaInfo' import getMetaInfo from '../shared/getMetaInfo'
import { isFunction } from '../shared/typeof'
import updateClientMetaInfo from './updateClientMetaInfo' import updateClientMetaInfo from './updateClientMetaInfo'
export default function _refresh (options = {}) { export default function _refresh(options = {}) {
/** /**
* When called, will update the current meta info with new meta info. * When called, will update the current meta info with new meta info.
* Useful when updating meta info as the result of an asynchronous * Useful when updating meta info as the result of an asynchronous
@@ -12,9 +13,16 @@ export default function _refresh (options = {}) {
* *
* @return {Object} - new meta info * @return {Object} - new meta info
*/ */
return function refresh () { return function refresh() {
const info = getMetaInfo(options)(this.$root) const metaInfo = getMetaInfo(options, this.$root)
updateClientMetaInfo(options).call(this, info)
return info const tags = updateClientMetaInfo(options, metaInfo)
// emit "event" with new info
if (tags && isFunction(metaInfo.changed)) {
metaInfo.changed.call(this, metaInfo, tags.addedTags, tags.removedTags)
}
return metaInfo
} }
} }
+61 -55
View File
@@ -1,64 +1,70 @@
import updateTitle from './updaters/updateTitle' import { metaInfoOptionKeys, metaInfoAttributeKeys } from '../shared/constants'
import updateTagAttributes from './updaters/updateTagAttributes' import { updateAttribute, updateTag, updateTitle } from './updaters'
import updateTags from './updaters/updateTags'
export default function _updateClientMetaInfo (options = {}) { const getTag = (tags, tag) => {
if (!tags[tag]) {
tags[tag] = document.getElementsByTagName(tag)[0]
}
return tags[tag]
}
/**
* Performs client-side updates when new meta info is received
*
* @param {Object} newInfo - the meta info to update to
*/
export default function updateClientMetaInfo(options = {}, newInfo) {
const { ssrAttribute } = options const { ssrAttribute } = options
/** // only cache tags for current update
* Performs client-side updates when new meta info is received const tags = {}
*
* @param {Object} newInfo - the meta info to update to
*/
return function updateClientMetaInfo (newInfo) {
const htmlTag = document.getElementsByTagName('html')[0]
// if this is not a server render, then update
if (htmlTag.getAttribute(ssrAttribute) === null) {
// initialize tracked changes
const addedTags = {}
const removedTags = {}
Object.keys(newInfo).forEach((key) => { const htmlTag = getTag(tags, 'html')
switch (key) {
// update the title
case 'title':
updateTitle(options)(newInfo.title)
break
// update attributes
case 'htmlAttrs':
updateTagAttributes(options)(newInfo[key], htmlTag)
break
case 'bodyAttrs':
updateTagAttributes(options)(newInfo[key], document.getElementsByTagName('body')[0])
break
case 'headAttrs':
updateTagAttributes(options)(newInfo[key], document.getElementsByTagName('head')[0])
break
// ignore these
case 'titleChunk':
case 'titleTemplate':
case 'changed':
case '__dangerouslyDisableSanitizers':
break
// catch-all update tags
default:
const headTag = document.getElementsByTagName('head')[0]
const bodyTag = document.getElementsByTagName('body')[0]
const { oldTags, newTags } = updateTags(options)(key, newInfo[key], headTag, bodyTag)
if (newTags.length) {
addedTags[key] = newTags
removedTags[key] = oldTags
}
}
})
// emit "event" with new info // if this is not a server render, then update
if (typeof newInfo.changed === 'function') { if (htmlTag.getAttribute(ssrAttribute) === null) {
newInfo.changed.call(this, newInfo, addedTags, removedTags) // initialize tracked changes
const addedTags = {}
const removedTags = {}
for (const type in newInfo) {
// ignore these
if (metaInfoOptionKeys.includes(type)) {
continue
}
if (type === 'title') {
// update the title
updateTitle(newInfo.title)
continue
}
if (metaInfoAttributeKeys.includes(type)) {
const tagName = type.substr(0, 4)
updateAttribute(options, newInfo[type], getTag(tags, tagName))
continue
}
const { oldTags, newTags } = updateTag(
options,
type,
newInfo[type],
getTag(tags, 'head'),
getTag(tags, 'body')
)
if (newTags.length) {
addedTags[type] = newTags
removedTags[type] = oldTags
} }
} else {
// remove the server render attribute so we can update on changes
htmlTag.removeAttribute(ssrAttribute)
} }
return { addedTags, removedTags }
} else {
// remove the server render attribute so we can update on changes
htmlTag.removeAttribute(ssrAttribute)
} }
return false
} }
+39
View File
@@ -0,0 +1,39 @@
/**
* Updates the document's html tag attributes
*
* @param {Object} attrs - the new document html attributes
* @param {HTMLElement} tag - the HTMLElement tag to update with new attrs
*/
export default function updateAttribute({ attribute } = {}, attrs, tag) {
const vueMetaAttrString = tag.getAttribute(attribute)
const vueMetaAttrs = vueMetaAttrString ? vueMetaAttrString.split(',') : []
const toRemove = Array.from(vueMetaAttrs)
const keepIndexes = []
for (const attr in attrs) {
if (attrs.hasOwnProperty(attr)) {
const value = attrs[attr] || ''
tag.setAttribute(attr, value)
if (!vueMetaAttrs.includes(attr)) {
vueMetaAttrs.push(attr)
}
// filter below wont ever check -1
keepIndexes.push(toRemove.indexOf(attr))
}
}
const removedAttributesCount = toRemove
.filter((el, index) => !keepIndexes.includes(index))
.reduce((acc, attr) => {
tag.removeAttribute(attr)
return acc + 1
}, 0)
if (vueMetaAttrs.length === removedAttributesCount) {
tag.removeAttribute(attribute)
} else {
tag.setAttribute(attribute, (vueMetaAttrs.sort()).join(','))
}
}
+3
View File
@@ -0,0 +1,3 @@
export { default as updateAttribute } from './attribute'
export { default as updateTitle } from './title'
export { default as updateTag } from './tag'
+84
View File
@@ -0,0 +1,84 @@
import { isUndefined } from '../../shared/typeof'
/**
* Updates meta tags inside <head> and <body> on the client. Borrowed from `react-helmet`:
* https://github.com/nfl/react-helmet/blob/004d448f8de5f823d10f838b02317521180f34da/src/Helmet.js#L195-L245
*
* @param {('meta'|'base'|'link'|'style'|'script'|'noscript')} type - the name of the tag
* @param {(Array<Object>|Object)} tags - an array of tag objects or a single object in case of base
* @return {Object} - a representation of what tags changed
*/
export default function updateTag({ attribute, tagIDKeyName } = {}, type, tags, headTag, bodyTag) {
const oldHeadTags = Array.from(headTag.querySelectorAll(`${type}[${attribute}]`))
const oldBodyTags = Array.from(bodyTag.querySelectorAll(`${type}[${attribute}][data-body="true"]`))
const newTags = []
if (tags.length > 1) {
// remove duplicates that could have been found by merging tags
// which include a mixin with metaInfo and that mixin is used
// by multiple components on the same page
const found = []
tags = tags.filter((x) => {
const k = JSON.stringify(x)
const res = !found.includes(k)
found.push(k)
return res
})
}
if (tags && tags.length) {
tags.forEach((tag) => {
const newElement = document.createElement(type)
newElement.setAttribute(attribute, 'true')
const oldTags = tag.body !== true ? oldHeadTags : oldBodyTags
for (const attr in tag) {
if (tag.hasOwnProperty(attr)) {
if (attr === 'innerHTML') {
newElement.innerHTML = tag.innerHTML
} else if (attr === 'cssText') {
if (newElement.styleSheet) {
/* istanbul ignore next */
newElement.styleSheet.cssText = tag.cssText
} else {
newElement.appendChild(document.createTextNode(tag.cssText))
}
} else if ([tagIDKeyName, 'body'].includes(attr)) {
const _attr = `data-${attr}`
const value = isUndefined(tag[attr]) ? '' : tag[attr]
newElement.setAttribute(_attr, value)
} else {
const value = isUndefined(tag[attr]) ? '' : tag[attr]
newElement.setAttribute(attr, value)
}
}
}
// Remove a duplicate tag from domTagstoRemove, so it isn't cleared.
let indexToDelete
const hasEqualElement = oldTags.some((existingTag, index) => {
indexToDelete = index
return newElement.isEqualNode(existingTag)
})
if (hasEqualElement && (indexToDelete || indexToDelete === 0)) {
oldTags.splice(indexToDelete, 1)
} else {
newTags.push(newElement)
}
})
}
const oldTags = oldHeadTags.concat(oldBodyTags)
oldTags.forEach(tag => tag.parentNode.removeChild(tag))
newTags.forEach((tag) => {
if (tag.getAttribute('data-body') === 'true') {
bodyTag.appendChild(tag)
} else {
headTag.appendChild(tag)
}
})
return { oldTags, newTags }
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Updates the document title
*
* @param {String} title - the new title of the document
*/
export default function updateTitle(title = document.title) {
document.title = title
}
@@ -1,37 +0,0 @@
export default function _updateTagAttributes (options = {}) {
const { attribute } = options
/**
* Updates the document's html tag attributes
*
* @param {Object} attrs - the new document html attributes
* @param {HTMLElement} tag - the HTMLElement tag to update with new attrs
*/
return function updateTagAttributes (attrs, tag) {
const vueMetaAttrString = tag.getAttribute(attribute)
const vueMetaAttrs = vueMetaAttrString ? vueMetaAttrString.split(',') : []
const toRemove = [].concat(vueMetaAttrs)
for (let attr in attrs) {
if (attrs.hasOwnProperty(attr)) {
const val = attrs[attr] || ''
tag.setAttribute(attr, val)
if (vueMetaAttrs.indexOf(attr) === -1) {
vueMetaAttrs.push(attr)
}
const saveIndex = toRemove.indexOf(attr)
if (saveIndex !== -1) {
toRemove.splice(saveIndex, 1)
}
}
}
let i = toRemove.length - 1
for (; i >= 0; i--) {
tag.removeAttribute(toRemove[i])
}
if (vueMetaAttrs.length === toRemove.length) {
tag.removeAttribute(attribute)
} else {
tag.setAttribute(attribute, vueMetaAttrs.join(','))
}
}
}
-86
View File
@@ -1,86 +0,0 @@
// borrow the slice method
const toArray = Function.prototype.call.bind(Array.prototype.slice)
export default function _updateTags (options = {}) {
const { attribute } = options
/**
* Updates meta tags inside <head> and <body> on the client. Borrowed from `react-helmet`:
* https://github.com/nfl/react-helmet/blob/004d448f8de5f823d10f838b02317521180f34da/src/Helmet.js#L195-L245
*
* @param {('meta'|'base'|'link'|'style'|'script'|'noscript')} type - the name of the tag
* @param {(Array<Object>|Object)} tags - an array of tag objects or a single object in case of base
* @return {Object} - a representation of what tags changed
*/
return function updateTags (type, tags, headTag, bodyTag) {
const oldHeadTags = toArray(headTag.querySelectorAll(`${type}[${attribute}]`))
const oldBodyTags = toArray(bodyTag.querySelectorAll(`${type}[${attribute}][data-body="true"]`))
const newTags = []
let indexToDelete
if (tags.length > 1) {
// remove duplicates that could have been found by merging tags
// which include a mixin with metaInfo and that mixin is used
// by multiple components on the same page
const found = []
tags = tags.map(x => {
const k = JSON.stringify(x)
if (found.indexOf(k) < 0) {
found.push(k)
return x
}
}).filter(x => x)
}
if (tags && tags.length) {
tags.forEach((tag) => {
const newElement = document.createElement(type)
const oldTags = tag.body !== true ? oldHeadTags : oldBodyTags
for (const attr in tag) {
if (tag.hasOwnProperty(attr)) {
if (attr === 'innerHTML') {
newElement.innerHTML = tag.innerHTML
} else if (attr === 'cssText') {
if (newElement.styleSheet) {
newElement.styleSheet.cssText = tag.cssText
} else {
newElement.appendChild(document.createTextNode(tag.cssText))
}
} else if ([options.tagIDKeyName, 'body'].indexOf(attr) !== -1) {
const _attr = `data-${attr}`
const value = (typeof tag[attr] === 'undefined') ? '' : tag[attr]
newElement.setAttribute(_attr, value)
} else {
const value = (typeof tag[attr] === 'undefined') ? '' : tag[attr]
newElement.setAttribute(attr, value)
}
}
}
newElement.setAttribute(attribute, 'true')
// Remove a duplicate tag from domTagstoRemove, so it isn't cleared.
if (oldTags.some((existingTag, index) => {
indexToDelete = index
return newElement.isEqualNode(existingTag)
})) {
oldTags.splice(indexToDelete, 1)
} else {
newTags.push(newElement)
}
})
}
const oldTags = oldHeadTags.concat(oldBodyTags)
oldTags.forEach((tag) => tag.parentNode.removeChild(tag))
newTags.forEach((tag) => {
if (tag.getAttribute('data-body') === 'true') {
bodyTag.appendChild(tag)
} else {
headTag.appendChild(tag)
}
})
return { oldTags, newTags }
}
}
-10
View File
@@ -1,10 +0,0 @@
export default function _updateTitle () {
/**
* Updates the document title
*
* @param {String} title - the new title of the document
*/
return function updateTitle (title = document.title) {
document.title = title
}
}
+17 -3
View File
@@ -1,6 +1,20 @@
import install from './shared/plugin'
import { version } from '../package.json' import { version } from '../package.json'
import createMixin from './shared/mixin'
import setOptions from './shared/options'
import $meta from './server/$meta'
install.version = version /**
* Plugin install function.
* @param {Function} Vue - the Vue constructor.
*/
function VueMeta(Vue, options = {}) {
options = setOptions(options)
export default install Vue.prototype.$meta = $meta(options)
Vue.mixin(createMixin(options))
}
VueMeta.version = version
export default VueMeta
+3 -3
View File
@@ -1,13 +1,13 @@
import inject from '../server/inject'
import refresh from '../client/refresh' import refresh from '../client/refresh'
import inject from './inject'
export default function _$meta (options = {}) { export default function _$meta(options = {}) {
/** /**
* Returns an injector for server-side rendering. * Returns an injector for server-side rendering.
* @this {Object} - the Vue instance (a root component) * @this {Object} - the Vue instance (a root component)
* @return {Object} - injector * @return {Object} - injector
*/ */
return function $meta () { return function $meta() {
return { return {
inject: inject(options).bind(this), inject: inject(options).bind(this),
refresh: refresh(options).bind(this) refresh: refresh(options).bind(this)
+19 -22
View File
@@ -1,25 +1,22 @@
import titleGenerator from './generators/titleGenerator' import { metaInfoAttributeKeys } from '../shared/constants'
import attrsGenerator from './generators/attrsGenerator' import { titleGenerator, attributeGenerator, tagGenerator } from './generators'
import tagGenerator from './generators/tagGenerator'
export default function _generateServerInjector (options = {}) { /**
/** * Converts a meta info property to one that can be stringified on the server
* Converts a meta info property to one that can be stringified on the server *
* * @param {String} type - the type of data to convert
* @param {String} type - the type of data to convert * @param {(String|Object|Array<Object>)} data - the data value
* @param {(String|Object|Array<Object>)} data - the data value * @return {Object} - the new injector
* @return {Object} - the new injector */
*/
return function generateServerInjector (type, data) { export default function generateServerInjector(options, type, data) {
switch (type) { if (type === 'title') {
case 'title': return titleGenerator(options, type, data)
return titleGenerator(options)(type, data)
case 'htmlAttrs':
case 'bodyAttrs':
case 'headAttrs':
return attrsGenerator(options)(type, data)
default:
return tagGenerator(options)(type, data)
}
} }
if (metaInfoAttributeKeys.includes(type)) {
return attributeGenerator(options, type, data)
}
return tagGenerator(options, type, data)
} }
+33
View File
@@ -0,0 +1,33 @@
import { booleanHtmlAttributes } from '../../shared/constants'
import { isUndefined } from '../../shared/typeof'
/**
* Generates tag attributes for use on the server.
*
* @param {('bodyAttrs'|'htmlAttrs'|'headAttrs')} type - the type of attributes to generate
* @param {Object} data - the attributes to generate
* @return {Object} - the attribute generator
*/
export default function attributeGenerator({ attribute } = {}, type, data) {
return {
text() {
let attributeStr = ''
const watchedAttrs = []
for (const attr in data) {
if (data.hasOwnProperty(attr)) {
watchedAttrs.push(attr)
attributeStr += isUndefined(data[attr]) || booleanHtmlAttributes.includes(attr)
? attr
: `${attr}="${data[attr]}"`
attributeStr += ' '
}
}
attributeStr += `${attribute}="${(watchedAttrs.sort()).join(',')}"`
return attributeStr
}
}
}
-31
View File
@@ -1,31 +0,0 @@
export default function _attrsGenerator (options = {}) {
const { attribute } = options
/**
* Generates tag attributes for use on the server.
*
* @param {('bodyAttrs'|'htmlAttrs'|'headAttrs')} type - the type of attributes to generate
* @param {Object} data - the attributes to generate
* @return {Object} - the attribute generator
*/
return function attrsGenerator (type, data) {
return {
text () {
let attributeStr = ''
let watchedAttrs = []
for (let attr in data) {
if (data.hasOwnProperty(attr)) {
watchedAttrs.push(attr)
attributeStr += `${
typeof data[attr] !== 'undefined'
? `${attr}="${data[attr]}"`
: attr
} `
}
}
attributeStr += `${attribute}="${watchedAttrs.join(',')}"`
return attributeStr.trim()
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export { default as attributeGenerator } from './attribute'
export { default as titleGenerator } from './title'
export { default as tagGenerator } from './tag'
+65
View File
@@ -0,0 +1,65 @@
import { booleanHtmlAttributes, tagsWithoutEndTag, tagsWithInnerContent, tagAttributeAsInnerContent } from '../../shared/constants'
import { isUndefined } from '../../shared/typeof'
/**
* Generates meta, base, link, style, script, noscript tags for use on the server
*
* @param {('meta'|'base'|'link'|'style'|'script'|'noscript')} the name of the tag
* @param {(Array<Object>|Object)} tags - an array of tag objects or a single object in case of base
* @return {Object} - the tag generator
*/
export default function tagGenerator({ attribute, tagIDKeyName } = {}, type, tags) {
return {
text({ body = false } = {}) {
// build a string containing all tags of this type
return tags.reduce((tagsStr, tag) => {
const tagKeys = Object.keys(tag)
if (tagKeys.length === 0) {
return tagsStr // Bail on empty tag object
}
if (Boolean(tag.body) !== body) {
return tagsStr
}
// build a string containing all attributes of this tag
const attrs = tagKeys.reduce((attrsStr, attr) => {
// these attributes are treated as children on the tag
if (tagAttributeAsInnerContent.includes(attr) || attr === 'once') {
return attrsStr
}
// these form the attribute list for this tag
let prefix = ''
if ([tagIDKeyName, 'body'].includes(attr)) {
prefix = 'data-'
}
return isUndefined(tag[attr]) || booleanHtmlAttributes.includes(attr)
? `${attrsStr} ${prefix}${attr}`
: `${attrsStr} ${prefix}${attr}="${tag[attr]}"`
}, '')
// grab child content from one of these attributes, if possible
const content = tag.innerHTML || tag.cssText || ''
// generate tag exactly without any other redundant attribute
const observeTag = tag.once
? ''
: `${attribute}="true"`
// these tags have no end tag
const hasEndTag = !tagsWithoutEndTag.includes(type)
// these tag types will have content inserted
const hasContent = hasEndTag && tagsWithInnerContent.includes(type)
// the final string for this specific tag
return !hasContent
? `${tagsStr}<${type} ${observeTag}${attrs}${hasEndTag ? '/' : ''}>`
: `${tagsStr}<${type} ${observeTag}${attrs}>${content}</${type}>`
}, '')
}
}
}
-59
View File
@@ -1,59 +0,0 @@
export default function _tagGenerator (options = {}) {
const { attribute } = options
/**
* Generates meta, base, link, style, script, noscript tags for use on the server
*
* @param {('meta'|'base'|'link'|'style'|'script'|'noscript')} the name of the tag
* @param {(Array<Object>|Object)} tags - an array of tag objects or a single object in case of base
* @return {Object} - the tag generator
*/
return function tagGenerator (type, tags) {
return {
text ({ body = false } = {}) {
// build a string containing all tags of this type
return tags.reduce((tagsStr, tag) => {
if (Object.keys(tag).length === 0) return tagsStr // Bail on empty tag object
if (!!tag.body !== body) return tagsStr
// build a string containing all attributes of this tag
const attrs = Object.keys(tag).reduce((attrsStr, attr) => {
switch (attr) {
// these attributes are treated as children on the tag
case 'innerHTML':
case 'cssText':
case 'once':
return attrsStr
// these form the attribute list for this tag
default:
if ([options.tagIDKeyName, 'body'].indexOf(attr) !== -1) {
return `${attrsStr} data-${attr}="${tag[attr]}"`
}
return typeof tag[attr] === 'undefined'
? `${attrsStr} ${attr}`
: `${attrsStr} ${attr}="${tag[attr]}"`
}
}, '').trim()
// grab child content from one of these attributes, if possible
const content = tag.innerHTML || tag.cssText || ''
// generate tag exactly without any other redundant attribute
const observeTag = tag.once
? ''
: `${attribute}="true" `
// these tags have no end tag
const hasEndTag = !['base', 'meta', 'link'].includes(type)
// these tag types will have content inserted
const hasContent = hasEndTag && ['noscript', 'script', 'style'].includes(type)
// the final string for this specific tag
return !hasContent
? `${tagsStr}<${type} ${observeTag}${attrs}${hasEndTag ? '/' : ''}>`
: `${tagsStr}<${type} ${observeTag}${attrs}>${content}</${type}>`
}, '')
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Generates title output for the server
*
* @param {'title'} type - the string "title"
* @param {String} data - the title text
* @return {Object} - the title generator
*/
export default function titleGenerator({ attribute } = {}, type, data) {
return {
text() {
return `<${type} ${attribute}="true">${data}</${type}>`
}
}
}
-18
View File
@@ -1,18 +0,0 @@
export default function _titleGenerator (options = {}) {
const { attribute } = options
/**
* Generates title output for the server
*
* @param {'title'} type - the string "title"
* @param {String} data - the title text
* @return {Object} - the title generator
*/
return function titleGenerator (type, data) {
return {
text () {
return `<${type} ${attribute}="true">${data}</${type}>`
}
}
}
}
+9 -7
View File
@@ -1,7 +1,8 @@
import getMetaInfo from '../shared/getMetaInfo' import getMetaInfo from '../shared/getMetaInfo'
import { metaInfoOptionKeys } from '../shared/constants'
import generateServerInjector from './generateServerInjector' import generateServerInjector from './generateServerInjector'
export default function _inject (options = {}) { export default function _inject(options = {}) {
/** /**
* Converts the state of the meta info object such that each item * Converts the state of the meta info object such that each item
* can be compiled to a tag string on the server * can be compiled to a tag string on the server
@@ -9,17 +10,18 @@ export default function _inject (options = {}) {
* @this {Object} - Vue instance - ideally the root component * @this {Object} - Vue instance - ideally the root component
* @return {Object} - server meta info with `toString` methods * @return {Object} - server meta info with `toString` methods
*/ */
return function inject () {
return function inject() {
// get meta info with sensible defaults // get meta info with sensible defaults
const info = getMetaInfo(options)(this.$root) const metaInfo = getMetaInfo(options, this.$root)
// generate server injectors // generate server injectors
for (let key in info) { for (const key in metaInfo) {
if (info.hasOwnProperty(key) && key !== 'titleTemplate' && key !== 'titleChunk') { if (!metaInfoOptionKeys.includes(key) && metaInfo.hasOwnProperty(key)) {
info[key] = generateServerInjector(options)(key, info[key]) metaInfo[key] = generateServerInjector(options, key, metaInfo[key])
} }
} }
return info return metaInfo
} }
} }
+78 -7
View File
@@ -4,24 +4,95 @@
// This is the name of the component option that contains all the information that // This is the name of the component option that contains all the information that
// gets converted to the various meta tags & attributes for the page. // gets converted to the various meta tags & attributes for the page.
export const VUE_META_KEY_NAME = 'metaInfo' export const keyName = 'metaInfo'
// This is the attribute vue-meta augments on elements to know which it should // This is the attribute vue-meta arguments on elements to know which it should
// manage and which it should ignore. // manage and which it should ignore.
export const VUE_META_ATTRIBUTE = 'data-vue-meta' export const attribute = 'data-vue-meta'
// This is the attribute that goes on the `html` tag to inform `vue-meta` // This is the attribute that goes on the `html` tag to inform `vue-meta`
// that the server has already generated the meta tags for the initial render. // that the server has already generated the meta tags for the initial render.
export const VUE_META_SERVER_RENDERED_ATTRIBUTE = 'data-vue-meta-server-rendered' export const ssrAttribute = 'data-vue-meta-server-rendered'
// This is the property that tells vue-meta to overwrite (instead of append) // This is the property that tells vue-meta to overwrite (instead of append)
// an item in a tag list. For example, if you have two `meta` tag list items // an item in a tag list. For example, if you have two `meta` tag list items
// that both have `vmid` of "description", then vue-meta will overwrite the // that both have `vmid` of "description", then vue-meta will overwrite the
// shallowest one with the deepest one. // shallowest one with the deepest one.
export const VUE_META_TAG_LIST_ID_KEY_NAME = 'vmid' export const tagIDKeyName = 'vmid'
// This is the key name for possible meta templates // This is the key name for possible meta templates
export const VUE_META_TEMPLATE_KEY_NAME = 'template' export const metaTemplateKeyName = 'template'
// This is the key name for the content-holding property // This is the key name for the content-holding property
export const VUE_META_CONTENT_KEY = 'content' export const contentKeyName = 'content'
// List of metaInfo property keys which are configuration options (and dont generate html)
export const metaInfoOptionKeys = [
'titleChunk',
'titleTemplate',
'changed',
'__dangerouslyDisableSanitizers',
'__dangerouslyDisableSanitizersByTagID'
]
// List of metaInfo property keys which only generates attributes and no tags
export const metaInfoAttributeKeys = [
'htmlAttrs',
'headAttrs',
'bodyAttrs'
]
// HTML elements which dont have a head tag (shortened to our needs)
// see: https://www.w3.org/TR/html52/document-metadata.html
export const tagsWithoutEndTag = ['base', 'meta', 'link']
// HTML elements which can have inner content (shortened to our needs)
export const tagsWithInnerContent = ['noscript', 'script', 'style']
// Attributes which are inserted as childNodes instead of HTMLAttribute
export const tagAttributeAsInnerContent = ['innerHTML', 'cssText']
// from: https://github.com/kangax/html-minifier/blob/gh-pages/src/htmlminifier.js#L202
export const booleanHtmlAttributes = [
'allowfullscreen',
'async',
'autofocus',
'autoplay',
'checked',
'compact',
'controls',
'declare',
'default',
'defaultchecked',
'defaultmuted',
'defaultselected',
'defer',
'disabled',
'enabled',
'formnovalidate',
'hidden',
'indeterminate',
'inert',
'ismap',
'itemscope',
'loop',
'multiple',
'muted',
'nohref',
'noresize',
'noshade',
'novalidate',
'nowrap',
'open',
'pauseonexit',
'readonly',
'required',
'reversed',
'scoped',
'seamless',
'selected',
'sortable',
'truespeed',
'typemustmatch',
'visible'
]
+16 -13
View File
@@ -1,6 +1,7 @@
import deepmerge from 'deepmerge' import deepmerge from 'deepmerge'
import uniqBy from './uniqBy'
import uniqueId from 'lodash.uniqueid' import uniqueId from 'lodash.uniqueid'
import { isUndefined, isFunction, isObject } from '../shared/typeof'
import uniqBy from './uniqBy'
/** /**
* Returns the `opts.option` $option value of the given `opts.component`. * Returns the `opts.option` $option value of the given `opts.component`.
@@ -10,28 +11,29 @@ import uniqueId from 'lodash.uniqueid'
* *
* @param {Object} opts - options * @param {Object} opts - options
* @param {Object} opts.component - Vue component to fetch option data from * @param {Object} opts.component - Vue component to fetch option data from
* @param {String} opts.option - what option to look for
* @param {Boolean} opts.deep - look for data in child components as well? * @param {Boolean} opts.deep - look for data in child components as well?
* @param {Function} opts.arrayMerge - how should arrays be merged? * @param {Function} opts.arrayMerge - how should arrays be merged?
* @param {String} opts.keyName - the name of the option to look for
* @param {Object} [result={}] - result so far * @param {Object} [result={}] - result so far
* @return {Object} result - final aggregated result * @return {Object} result - final aggregated result
*/ */
export default function getComponentOption (opts, result = {}) { export default function getComponentOption({ component, deep, arrayMerge, keyName, metaTemplateKeyName, tagIDKeyName, contentKeyName } = {}, result = {}) {
const { component, option, deep, arrayMerge, metaTemplateKeyName, tagIDKeyName, contentKeyName } = opts
const { $options } = component const { $options } = component
if (component._inactive) return result if (component._inactive) {
return result
}
// only collect option data if it exists // only collect option data if it exists
if (typeof $options[option] !== 'undefined' && $options[option] !== null) { if (!isUndefined($options[keyName]) && $options[keyName] !== null) {
let data = $options[option] let data = $options[keyName]
// if option is a function, replace it with it's result // if option is a function, replace it with it's result
if (typeof data === 'function') { if (isFunction(data)) {
data = data.call(component) data = data.call(component)
} }
if (typeof data === 'object') { if (isObject(data)) {
// merge with existing options // merge with existing options
result = deepmerge(result, data, { arrayMerge }) result = deepmerge(result, data, { arrayMerge })
} else { } else {
@@ -44,16 +46,17 @@ export default function getComponentOption (opts, result = {}) {
component.$children.forEach((childComponent) => { component.$children.forEach((childComponent) => {
result = getComponentOption({ result = getComponentOption({
component: childComponent, component: childComponent,
option, keyName,
deep, deep,
arrayMerge arrayMerge
}, result) }, result)
}) })
} }
if (metaTemplateKeyName && result.hasOwnProperty('meta')) { if (metaTemplateKeyName && result.hasOwnProperty('meta')) {
result.meta = Object.keys(result.meta).map(metaKey => { result.meta = Object.keys(result.meta).map((metaKey) => {
const metaObject = result.meta[metaKey] const metaObject = result.meta[metaKey]
if (!metaObject.hasOwnProperty(metaTemplateKeyName) || !metaObject.hasOwnProperty(contentKeyName) || typeof metaObject[metaTemplateKeyName] === 'undefined') { if (!metaObject.hasOwnProperty(metaTemplateKeyName) || !metaObject.hasOwnProperty(contentKeyName) || isUndefined(metaObject[metaTemplateKeyName])) {
return result.meta[metaKey] return result.meta[metaKey]
} }
@@ -61,7 +64,7 @@ export default function getComponentOption (opts, result = {}) {
delete metaObject[metaTemplateKeyName] delete metaObject[metaTemplateKeyName]
if (template) { if (template) {
metaObject.content = typeof template === 'function' ? template(metaObject.content) : template.replace(/%s/g, metaObject.content) metaObject.content = isFunction(template) ? template(metaObject.content) : template.replace(/%s/g, metaObject.content)
} }
return metaObject return metaObject
+129 -121
View File
@@ -1,9 +1,10 @@
import deepmerge from 'deepmerge' import deepmerge from 'deepmerge'
import isPlainObject from 'lodash.isplainobject' import isPlainObject from 'lodash.isplainobject'
import { isUndefined, isFunction, isString } from '../shared/typeof'
import isArray from './isArray' import isArray from './isArray'
import getComponentOption from './getComponentOption' import getComponentOption from './getComponentOption'
const escapeHTML = (str) => typeof window === 'undefined' const escapeHTML = str => isUndefined(window)
// server-side escape sequence // server-side escape sequence
? String(str) ? String(str)
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -19,138 +20,145 @@ const escapeHTML = (str) => typeof window === 'undefined'
.replace(/"/g, '\u0022') .replace(/"/g, '\u0022')
.replace(/'/g, '\u0027') .replace(/'/g, '\u0027')
export default function _getMetaInfo (options = {}) { const applyTemplate = (component, template, chunk) =>
const { keyName, tagIDKeyName, metaTemplateKeyName, contentKeyName } = options isFunction(template) ? template.call(component, chunk) : template.replace(/%s/g, chunk)
/**
* Returns the correct meta info for the given component
* (child components will overwrite parent meta info)
*
* @param {Object} component - the Vue instance to get meta info from
* @return {Object} - returned meta info
*/
return function getMetaInfo (component) {
// set some sane defaults
const defaultInfo = {
title: '',
titleChunk: '',
titleTemplate: '%s',
htmlAttrs: {},
bodyAttrs: {},
headAttrs: {},
meta: [],
base: [],
link: [],
style: [],
script: [],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: {}
}
// collect & aggregate all metaInfo $options /**
let info = getComponentOption({ * Returns the correct meta info for the given component
component, * (child components will overwrite parent meta info)
option: keyName, *
deep: true, * @param {Object} component - the Vue instance to get meta info from
metaTemplateKeyName, * @return {Object} - returned meta info
tagIDKeyName, */
contentKeyName, export default function getMetaInfo({ keyName, tagIDKeyName, metaTemplateKeyName, contentKeyName } = {}, component) {
arrayMerge (target, source) { // set some sane defaults
// we concat the arrays without merging objects contained in, const defaultInfo = {
// but we check for a `vmid` property on each object in the array title: '',
// using an O(1) lookup associative array exploit titleChunk: '',
// note the use of "for in" - we are looping through arrays here, not titleTemplate: '%s',
// plain objects htmlAttrs: {},
const destination = [] bodyAttrs: {},
for (let targetIndex in target) { headAttrs: {},
const targetItem = target[targetIndex] meta: [],
let shared = false base: [],
for (let sourceIndex in source) { link: [],
const sourceItem = source[sourceIndex] style: [],
if (targetItem[tagIDKeyName] && targetItem[tagIDKeyName] === sourceItem[tagIDKeyName]) { script: [],
const targetTemplate = targetItem[metaTemplateKeyName] noscript: [],
const sourceTemplate = sourceItem[metaTemplateKeyName] __dangerouslyDisableSanitizers: [],
if (targetTemplate && !sourceTemplate) { __dangerouslyDisableSanitizersByTagID: {}
sourceItem[contentKeyName] = applyTemplate(component)(targetTemplate)(sourceItem[contentKeyName]) }
}
// If template defined in child but content in parent // collect & aggregate all metaInfo $options
if (targetTemplate && sourceTemplate && !sourceItem[contentKeyName]) { let info = getComponentOption({
sourceItem[contentKeyName] = applyTemplate(component)(sourceTemplate)(targetItem[contentKeyName]) deep: true,
delete sourceItem[metaTemplateKeyName] component,
} keyName,
shared = true metaTemplateKeyName,
break tagIDKeyName,
contentKeyName,
arrayMerge(target, source) {
// we concat the arrays without merging objects contained in,
// but we check for a `vmid` property on each object in the array
// using an O(1) lookup associative array exploit
// note the use of "for in" - we are looping through arrays here, not
// plain objects
const destination = []
for (const targetIndex in target) {
const targetItem = target[targetIndex]
let shared = false
for (const sourceIndex in source) {
const sourceItem = source[sourceIndex]
if (targetItem[tagIDKeyName] && targetItem[tagIDKeyName] === sourceItem[tagIDKeyName]) {
const targetTemplate = targetItem[metaTemplateKeyName]
const sourceTemplate = sourceItem[metaTemplateKeyName]
if (targetTemplate && !sourceTemplate) {
sourceItem[contentKeyName] = applyTemplate(component, targetTemplate, sourceItem[contentKeyName])
} }
}
if (!shared) { // If template defined in child but content in parent
destination.push(targetItem) if (targetTemplate && sourceTemplate && !sourceItem[contentKeyName]) {
sourceItem[contentKeyName] = applyTemplate(component, sourceTemplate, targetItem[contentKeyName])
delete sourceItem[metaTemplateKeyName]
}
shared = true
break
} }
} }
return destination.concat(source) if (!shared) {
} destination.push(targetItem)
})
// Remove all "template" tags from meta
// backup the title chunk in case user wants access to it
if (info.title) {
info.titleChunk = info.title
}
// replace title with populated template
if (info.titleTemplate) {
info.title = applyTemplate(component)(info.titleTemplate)(info.titleChunk || '')
}
// convert base tag to an array so it can be handled the same way
// as the other tags
if (info.base) {
info.base = Object.keys(info.base).length ? [info.base] : []
}
const ref = info.__dangerouslyDisableSanitizers
const refByTagID = info.__dangerouslyDisableSanitizersByTagID
// sanitizes potentially dangerous characters
const escape = (info) => Object.keys(info).reduce((escaped, key) => {
let isDisabled = ref && ref.indexOf(key) > -1
const tagID = info[tagIDKeyName]
if (!isDisabled && tagID) {
isDisabled = refByTagID && refByTagID[tagID] && refByTagID[tagID].indexOf(key) > -1
}
const val = info[key]
escaped[key] = val
if (key === '__dangerouslyDisableSanitizers' || key === '__dangerouslyDisableSanitizersByTagID') {
return escaped
}
if (!isDisabled) {
if (typeof val === 'string') {
escaped[key] = escapeHTML(val)
} else if (isPlainObject(val)) {
escaped[key] = escape(val)
} else if (isArray(val)) {
escaped[key] = val.map(escape)
} else {
escaped[key] = val
} }
}
return destination.concat(source)
}
})
// Remove all "template" tags from meta
// backup the title chunk in case user wants access to it
if (info.title) {
info.titleChunk = info.title
}
// replace title with populated template
if (info.titleTemplate) {
info.title = applyTemplate(component, info.titleTemplate, info.titleChunk || '')
}
// convert base tag to an array so it can be handled the same way
// as the other tags
if (info.base) {
info.base = Object.keys(info.base).length ? [info.base] : []
}
const ref = info.__dangerouslyDisableSanitizers
const refByTagID = info.__dangerouslyDisableSanitizersByTagID
// sanitizes potentially dangerous characters
const escape = info => Object.keys(info).reduce((escaped, key) => {
let isDisabled = ref && ref.includes(key)
const tagID = info[tagIDKeyName]
if (!isDisabled && tagID) {
isDisabled = refByTagID && refByTagID[tagID] && refByTagID[tagID].includes(key)
}
const val = info[key]
escaped[key] = val
if (key === '__dangerouslyDisableSanitizers' || key === '__dangerouslyDisableSanitizersByTagID') {
return escaped
}
if (!isDisabled) {
if (isString(val)) {
escaped[key] = escapeHTML(val)
} else if (isPlainObject(val)) {
escaped[key] = escape(val)
} else if (isArray(val)) {
escaped[key] = val.map(escape)
} else { } else {
escaped[key] = val escaped[key] = val
} }
} else {
escaped[key] = val
}
return escaped return escaped
}, {}) }, {})
// merge with defaults // merge with defaults
info = deepmerge(defaultInfo, info) info = deepmerge(defaultInfo, info)
// begin sanitization // begin sanitization
info = escape(info) info = escape(info)
return info return info
}
} }
const applyTemplate = component => template => chunk =>
typeof template === 'function' ? template.call(component, chunk) : template.replace(/%s/g, chunk)
+1 -1
View File
@@ -3,7 +3,7 @@
* @param {any} arr - the object to check * @param {any} arr - the object to check
* @return {Boolean} - true if `arr` is an array * @return {Boolean} - true if `arr` is an array
*/ */
export default function isArray (arr) { export default function isArray(arr) {
return Array.isArray return Array.isArray
? Array.isArray(arr) ? Array.isArray(arr)
: Object.prototype.toString.call(arr) === '[object Array]' : Object.prototype.toString.call(arr) === '[object Array]'
+142
View File
@@ -0,0 +1,142 @@
import batchUpdate from '../client/batchUpdate'
import { isUndefined, isFunction } from '../shared/typeof'
export default function createMixin(options) {
// store an id to keep track of DOM updates
let batchID = null
// for which Vue lifecycle hooks should the metaInfo be refreshed
const updateOnLifecycleHook = ['activated', 'deactivated', 'beforeMount']
const triggerUpdate = (vm) => {
if (vm.$root._vueMetaInitialized) {
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => vm.$meta().refresh())
}
}
// watch for client side component updates
return {
beforeCreate() {
// Add a marker to know if it uses metaInfo
// _vnode is used to know that it's attached to a real component
// useful if we use some mixin to add some meta tags (like nuxt-i18n)
if (!isUndefined(this.$options[options.keyName]) && this.$options[options.keyName] !== null) {
this._hasMetaInfo = true
// coerce function-style metaInfo to a computed prop so we can observe
// it on creation
if (isFunction(this.$options[options.keyName])) {
if (isUndefined(this.$options.computed)) {
this.$options.computed = {}
}
this.$options.computed.$metaInfo = this.$options[options.keyName]
if (!this.$isServer) {
// if computed $metaInfo exists, watch it for updates & trigger a refresh
// when it changes (i.e. automatically handle async actions that affect metaInfo)
// credit for this suggestion goes to [Sébastien Chopin](https://github.com/Atinux)
this.$options.created = this.$options.created || []
this.$options.created.push(() => {
this.$watch('$metaInfo', () => triggerUpdate(this))
})
}
}
updateOnLifecycleHook.forEach((lifecycleHook) => {
this.$options[lifecycleHook] = this.$options[lifecycleHook] || []
this.$options[lifecycleHook].push(() => triggerUpdate(this))
})
// force an initial refresh on page load and prevent other lifecycleHooks
// to triggerUpdate until this initial refresh is finished
// this is to make sure that when a page is opened in an inactive tab which
// has throttled rAF/timers we still immeditately set the page title
if (isUndefined(this.$root._vueMetaInitialized)) {
this.$root._vueMetaInitialized = false
this.$root.$options.mounted = this.$root.$options.mounted || []
this.$root.$options.mounted.push(() => {
if (!this.$root._vueMetaInitialized) {
this.$nextTick(function () {
this.$root.$meta().refresh()
this.$root._vueMetaInitialized = true
})
}
})
}
// do not trigger refresh on the server side
if (!this.$isServer) {
// re-render meta data when returning from a child component to parent
this.$options.destroyed = this.$options.destroyed || []
this.$options.destroyed.push(() => {
// Wait that element is hidden before refreshing meta tags (to support animations)
const interval = setInterval(() => {
if (this.$el && this.$el.offsetParent !== null) {
return
}
clearInterval(interval)
if (!this.$parent) {
return
}
triggerUpdate(this)
}, 50)
})
}
}
}
/* Not yet removed
created() {
// if computed $metaInfo exists, watch it for updates & trigger a refresh
// when it changes (i.e. automatically handle async actions that affect metaInfo)
// credit for this suggestion goes to [Sébastien Chopin](https://github.com/Atinux)
if (!this.$isServer && this.$metaInfo) {
this.$watch('$metaInfo', () => triggerUpdate(this))
}
},
activated() {
if (this._hasMetaInfo) {
triggerUpdate(this)
}
},
deactivated() {
if (this._hasMetaInfo) {
triggerUpdate(this)
}
},
beforeMount() {
if (this._hasMetaInfo) {
triggerUpdate(this)
}
},
destroyed() {
// do not trigger refresh on the server side
if (this.$isServer) {
return
}
// re-render meta data when returning from a child component to parent
if (this._hasMetaInfo) {
// Wait that element is hidden before refreshing meta tags (to support animations)
const interval = setInterval(() => {
if (this.$el && this.$el.offsetParent !== null) {
return
}
clearInterval(interval)
if (!this.$parent) {
return
}
triggerUpdate(this)
}, 50)
}
}/**/
}
}
+32
View File
@@ -0,0 +1,32 @@
import { isObject } from '../shared/typeof'
import {
keyName,
attribute,
ssrAttribute,
tagIDKeyName,
metaTemplateKeyName,
contentKeyName
} from './constants'
// set some default options
const defaultOptions = {
keyName,
contentKeyName,
metaTemplateKeyName,
attribute,
ssrAttribute,
tagIDKeyName
}
export default function setOptions(options) {
// combine options
options = isObject(options) ? options : {}
for (const key in defaultOptions) {
if (!options[key]) {
options[key] = defaultOptions[key]
}
}
return options
}
-103
View File
@@ -1,103 +0,0 @@
import assign from 'object-assign'
import $meta from './$meta'
import batchUpdate from '../client/batchUpdate'
import {
VUE_META_KEY_NAME,
VUE_META_ATTRIBUTE,
VUE_META_SERVER_RENDERED_ATTRIBUTE,
VUE_META_TAG_LIST_ID_KEY_NAME,
VUE_META_TEMPLATE_KEY_NAME, VUE_META_CONTENT_KEY
} from './constants'
// automatic install
if (typeof window !== 'undefined' && typeof window.Vue !== 'undefined') {
Vue.use(VueMeta)
}
/**
* Plugin install function.
* @param {Function} Vue - the Vue constructor.
*/
export default function VueMeta (Vue, options = {}) {
// set some default options
const defaultOptions = {
keyName: VUE_META_KEY_NAME,
contentKeyName: VUE_META_CONTENT_KEY,
metaTemplateKeyName: VUE_META_TEMPLATE_KEY_NAME,
attribute: VUE_META_ATTRIBUTE,
ssrAttribute: VUE_META_SERVER_RENDERED_ATTRIBUTE,
tagIDKeyName: VUE_META_TAG_LIST_ID_KEY_NAME
}
// combine options
options = assign(defaultOptions, options)
// bind the $meta method to this component instance
Vue.prototype.$meta = $meta(options)
// store an id to keep track of DOM updates
let batchID = null
// watch for client side component updates
Vue.mixin({
beforeCreate () {
// Add a marker to know if it uses metaInfo
// _vnode is used to know that it's attached to a real component
// useful if we use some mixin to add some meta tags (like nuxt-i18n)
if (typeof this.$options[options.keyName] !== 'undefined') {
this._hasMetaInfo = true
}
// coerce function-style metaInfo to a computed prop so we can observe
// it on creation
if (typeof this.$options[options.keyName] === 'function') {
if (typeof this.$options.computed === 'undefined') {
this.$options.computed = {}
}
this.$options.computed.$metaInfo = this.$options[options.keyName]
}
},
created () {
// if computed $metaInfo exists, watch it for updates & trigger a refresh
// when it changes (i.e. automatically handle async actions that affect metaInfo)
// credit for this suggestion goes to [Sébastien Chopin](https://github.com/Atinux)
if (!this.$isServer && this.$metaInfo) {
this.$watch('$metaInfo', () => {
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => this.$meta().refresh())
})
}
},
activated () {
if (this._hasMetaInfo) {
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => this.$meta().refresh())
}
},
deactivated () {
if (this._hasMetaInfo) {
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => this.$meta().refresh())
}
},
beforeMount () {
// batch potential DOM updates to prevent extraneous re-rendering
if (this._hasMetaInfo) {
batchID = batchUpdate(batchID, () => this.$meta().refresh())
}
},
destroyed () {
// do not trigger refresh on the server side
if (this.$isServer) return
// re-render meta data when returning from a child component to parent
if (this._hasMetaInfo) {
// Wait that element is hidden before refreshing meta tags (to support animations)
const interval = setInterval(() => {
if (this.$el && this.$el.offsetParent !== null) return
clearInterval(interval)
if (!this.$parent) return
batchID = batchUpdate(batchID, () => this.$meta().refresh())
}, 50)
}
}
})
}
+16
View File
@@ -0,0 +1,16 @@
export function isUndefined(arg) {
return typeof arg === 'undefined'
}
export function isObject(arg) {
return typeof arg === 'object'
}
export function isFunction(arg) {
return typeof arg === 'function'
}
export function isString(arg) {
return typeof arg === 'string'
}
+1 -1
View File
@@ -1,4 +1,4 @@
export default function uniqBy (inputArray, predicate) { export default function uniqBy(inputArray, predicate) {
return inputArray return inputArray
.filter((x, i, arr) => i === arr.length - 1 .filter((x, i, arr) => i === arr.length - 1
? true ? true
+97
View File
@@ -0,0 +1,97 @@
import _getMetaInfo from '../src/shared/getMetaInfo'
import { mount, defaultOptions, loadVueMetaPlugin } from './utils'
import GoodbyeWorld from './fixtures/goodbye-world.vue'
import HelloWorld from './fixtures/hello-world.vue'
import KeepAlive from './fixtures/keep-alive.vue'
import Changed from './fixtures/changed.vue'
const getMetaInfo = component => _getMetaInfo(defaultOptions, component)
describe('client', () => {
let Vue
beforeAll(() => (Vue = loadVueMetaPlugin()))
test('meta-info refreshed on component\'s data change', () => {
const wrapper = mount(HelloWorld, { localVue: Vue })
let metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
wrapper.setData({ title: 'Goodbye World' })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Goodbye World')
})
test('child meta-info removed when child is toggled', () => {
const wrapper = mount(GoodbyeWorld, { localVue: Vue })
let metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
wrapper.setData({ childVisible: false })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Goodbye World')
wrapper.setData({ childVisible: true })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
})
test('child meta-info removed when keep-alive child is toggled', () => {
const wrapper = mount(KeepAlive, { localVue: Vue })
let metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
wrapper.setData({ childVisible: false })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Alive World')
wrapper.setData({ childVisible: true })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
})
test('meta-info is removed when destroyed', () => {
const parentComponent = new Vue({ render: h => h('div') })
const wrapper = mount(HelloWorld, { localVue: Vue, parentComponent })
let metaInfo = getMetaInfo(wrapper.vm.$parent)
expect(metaInfo.title).toEqual('Hello World')
wrapper.destroy()
jest.runAllTimers()
metaInfo = getMetaInfo(wrapper.vm.$parent)
expect(metaInfo.title).toEqual('')
})
test('meta-info can be rendered with inject', () => {
const wrapper = mount(HelloWorld, { localVue: Vue })
const metaInfo = wrapper.vm.$meta().inject()
expect(metaInfo.title.text()).toEqual('<title data-vue-meta="true">Hello World</title>')
})
test('changed function is called', () => {
const parentComponent = new Vue({ render: h => h('div') })
const wrapper = mount(Changed, { localVue: Vue, parentComponent })
let context
const changed = jest.fn(function () {
context = this
})
wrapper.setData({ changed })
wrapper.setData({ childVisible: true })
wrapper.vm.$parent.$meta().refresh()
expect(changed).toHaveBeenCalledTimes(1)
// TODO: this isnt what the docs say
expect(context._uid).not.toBe(wrapper.vm._uid)
})
})
+33
View File
@@ -0,0 +1,33 @@
<template>
<html {{ head.headAttrs.text() }}>
<head></head>
bla
</html>
</template>
<script>
export default {
metaInfo() {
return {
title: this.title
}
},
data() {
return {
title: 'Hello World',
htmlAttrs: {
lang: 'en'
},
meta: [
{ charset: 'utf-8' }
]
}
},
computed: {
head() {
return meta.inject()
}
}
}
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<div>
<hello-world v-if="childVisible"></hello-world>
</div>
</template>
<script>
import HelloWorld from './hello-world.vue'
export default {
components: {
HelloWorld
},
metaInfo() {
return {
changed: this.changed
}
},
data() {
return {
childVisible: false,
changed: () => {}
}
}
}
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<div>
<hello-world v-if="childVisible"></hello-world>
</div>
</template>
<script>
import HelloWorld from './hello-world.vue'
export default {
components: {
HelloWorld
},
metaInfo() {
return {
title: this.title,
}
},
data() {
return {
childVisible: true,
title: 'Goodbye World'
}
}
}
</script>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div>Test</div>
</template>
<script>
export default {
metaInfo() {
return {
title: this.title
}
},
data() {
return {
title: 'Hello World',
htmlAttrs: {
lang: 'en'
},
meta: [
{ charset: 'utf-8' }
]
}
}
}
</script>
+28
View File
@@ -0,0 +1,28 @@
<template>
<div>
<keep-alive>
<hello-world v-if="childVisible"></hello-world>
</keep-alive>
</div>
</template>
<script>
import HelloWorld from './hello-world.vue'
export default {
components: {
HelloWorld
},
metaInfo() {
return {
title: this.title,
}
},
data() {
return {
childVisible: true,
title: 'Alive World'
}
}
}
</script>
+61
View File
@@ -0,0 +1,61 @@
import _generateServerInjector from '../src/server/generateServerInjector'
import { defaultOptions } from './utils'
import metaInfoData from './utils/meta-info-data'
const generateServerInjector = (type, data) => _generateServerInjector(defaultOptions, type, data)
describe('generators', () => {
Object.keys(metaInfoData).forEach((type) => {
const typeTests = metaInfoData[type]
const testCases = {
add: (tags) => {
let html = tags.text()
// ssr only returns the attributes, convert to full tag
if (['htmlAttrs', 'headAttrs', 'bodyAttrs'].includes(type)) {
html = `<${type.substr(0, 4)} ${html}>`
}
typeTests.add.expect.forEach((expected) => {
expect(html).toContain(expected)
})
}
}
describe(`${type} type tests`, () => {
Object.keys(typeTests).forEach((action) => {
const testInfo = typeTests[action]
// return when no test case available
if (!testCases[action] && !testInfo.test) {
return
}
const defaultTestFn = () => {
const tags = generateServerInjector(type, testInfo.data)
testCases[action](tags)
return tags
}
let testFn
if (testInfo.test) {
testFn = testInfo.test('server', defaultTestFn)
if (testFn === true) {
testFn = defaultTestFn
}
} else {
testFn = defaultTestFn
}
if (testFn && typeof testFn === 'function') {
test(`${action} a tag`, () => {
expect.hasAssertions()
testFn()
})
}
})
})
})
})
-76
View File
@@ -1,76 +0,0 @@
import Vue from 'vue'
import getComponentOption from '../src/shared/getComponentOption'
describe('getComponentOption', () => {
const container = document.createElement('div')
let component
afterEach(() => component.$destroy())
it('returns an empty object when no matching options are found', () => {
component = new Vue()
const mergedOption = getComponentOption({ component, option: 'noop' })
expect(mergedOption).to.eql({})
})
it('fetches the given option from the given component', () => {
component = new Vue({ someOption: 'foo' })
const mergedOption = getComponentOption({ component, option: 'someOption' })
expect(mergedOption).to.eql('foo')
})
it('calls a function option, injecting the component as context', () => {
component = new Vue({
name: 'foobar',
someFunc () {
return this.$options.name
}
})
const mergedOption = getComponentOption({ component, option: 'someFunc' })
expect(mergedOption).to.eql('foobar')
})
it('fetches deeply nested component options and merges them', () => {
Vue.component('merge-child', { template: '<div></div>', foo: { bar: 'baz' } })
component = new Vue({
foo: { fizz: 'buzz' },
render: (h) => h('div', null, [h('merge-child')]),
el: container
})
const mergedOption = getComponentOption({ component, option: 'foo', deep: true })
expect(mergedOption).to.eql({ bar: 'baz', fizz: 'buzz' })
})
it('allows for a custom array merge strategy', () => {
Vue.component('array-child', {
template: '<div></div>',
foo: [
{ name: 'flower', content: 'rose' }
]
})
component = new Vue({
render: (h) => h('div', null, [h('array-child')]),
foo: [
{ name: 'flower', content: 'tulip' }
],
el: container
})
const mergedOption = getComponentOption({
component,
option: 'foo',
deep: true,
arrayMerge (target, source) {
return target.concat(source)
}
})
expect(mergedOption).to.eql([
{ name: 'flower', content: 'tulip' },
{ name: 'flower', content: 'rose' }
])
})
})
+76
View File
@@ -0,0 +1,76 @@
import getComponentOption from '../src/shared/getComponentOption'
import { getVue } from './utils'
describe('getComponentOption', () => {
let Vue
beforeAll(() => (Vue = getVue()))
it('returns an empty object when no matching options are found', () => {
const component = new Vue()
const mergedOption = getComponentOption({ component, keyName: 'noop' })
expect(mergedOption).toEqual({})
})
it('fetches the given option from the given component', () => {
const component = new Vue({ someOption: 'foo' })
const mergedOption = getComponentOption({ component, keyName: 'someOption' })
expect(mergedOption).toEqual('foo')
})
it('calls a function option, injecting the component as context', () => {
const component = new Vue({
name: 'Foobar',
someFunc() {
return this.$options.name
}
})
const mergedOption = getComponentOption({ component, keyName: 'someFunc' })
// TODO: Should this be foobar or Foobar
expect(mergedOption).toEqual('Foobar')
})
it('fetches deeply nested component options and merges them', () => {
Vue.component('merge-child', { render: h => h('div'), foo: { bar: 'baz' } })
const component = new Vue({
foo: { fizz: 'buzz' },
el: document.createElement('div'),
render: h => h('div', null, [h('merge-child')])
})
const mergedOption = getComponentOption({ component, keyName: 'foo', deep: true })
expect(mergedOption).toEqual({ bar: 'baz', fizz: 'buzz' })
})
it('allows for a custom array merge strategy', () => {
Vue.component('array-child', {
render: h => h('div'),
foo: [
{ name: 'flower', content: 'rose' }
]
})
const component = new Vue({
foo: [
{ name: 'flower', content: 'tulip' }
],
el: document.createElement('div'),
render: h => h('div', null, [h('array-child')])
})
const mergedOption = getComponentOption({
component,
keyName: 'foo',
deep: true,
arrayMerge(target, source) {
return target.concat(source)
}
})
expect(mergedOption).toEqual([
{ name: 'flower', content: 'tulip' },
{ name: 'flower', content: 'rose' }
])
})
})
@@ -1,35 +1,17 @@
import Vue from 'vue'
import _getMetaInfo from '../src/shared/getMetaInfo' import _getMetaInfo from '../src/shared/getMetaInfo'
import { import { defaultOptions, loadVueMetaPlugin } from './utils'
VUE_META_ATTRIBUTE,
VUE_META_CONTENT_KEY,
VUE_META_KEY_NAME,
VUE_META_SERVER_RENDERED_ATTRIBUTE,
VUE_META_TAG_LIST_ID_KEY_NAME,
VUE_META_TEMPLATE_KEY_NAME
} from '../src/shared/constants'
// set some default options const getMetaInfo = component => _getMetaInfo(defaultOptions, component)
const defaultOptions = {
keyName: VUE_META_KEY_NAME,
attribute: VUE_META_ATTRIBUTE,
ssrAttribute: VUE_META_SERVER_RENDERED_ATTRIBUTE,
metaTemplateKeyName: VUE_META_TEMPLATE_KEY_NAME,
contentKeyName: VUE_META_CONTENT_KEY,
tagIDKeyName: VUE_META_TAG_LIST_ID_KEY_NAME
}
const getMetaInfo = _getMetaInfo(defaultOptions)
describe('getMetaInfo', () => { describe('getMetaInfo', () => {
// const container = document.createElement('div') let Vue
let component
afterEach(() => component.$destroy()) beforeAll(() => (Vue = loadVueMetaPlugin()))
it('returns appropriate defaults when no meta info is found', () => { test('returns appropriate defaults when no meta info is found', () => {
component = new Vue() const component = new Vue()
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: '', title: '',
titleChunk: '', titleChunk: '',
titleTemplate: '%s', titleTemplate: '%s',
@@ -47,8 +29,8 @@ describe('getMetaInfo', () => {
}) })
}) })
it('returns metaInfo when used in component', () => { test('returns metaInfo when used in component', () => {
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
meta: [ meta: [
@@ -56,7 +38,8 @@ describe('getMetaInfo', () => {
] ]
} }
}) })
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello', title: 'Hello',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s', titleTemplate: '%s',
@@ -75,8 +58,37 @@ describe('getMetaInfo', () => {
__dangerouslyDisableSanitizersByTagID: {} __dangerouslyDisableSanitizersByTagID: {}
}) })
}) })
it('removes duplicate metaInfo in same component', () => {
component = new Vue({ test('converts base tag to array', () => {
const component = new Vue({
metaInfo: {
title: 'Hello',
base: { href: 'href' }
}
})
expect(getMetaInfo(component)).toEqual({
title: 'Hello',
titleChunk: 'Hello',
titleTemplate: '%s',
htmlAttrs: {},
headAttrs: {},
bodyAttrs: {},
meta: [],
base: [
{ href: 'href' }
],
link: [],
style: [],
script: [],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: {}
})
})
test('removes duplicate metaInfo in same component', () => {
const component = new Vue({
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
meta: [ meta: [
@@ -93,7 +105,8 @@ describe('getMetaInfo', () => {
] ]
} }
}) })
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello', title: 'Hello',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s', titleTemplate: '%s',
@@ -117,8 +130,8 @@ describe('getMetaInfo', () => {
}) })
}) })
it('properly uses string titleTemplates', () => { test('properly uses string titleTemplates', () => {
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
titleTemplate: '%s World', titleTemplate: '%s World',
@@ -127,7 +140,8 @@ describe('getMetaInfo', () => {
] ]
} }
}) })
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello World', title: 'Hello World',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s World', titleTemplate: '%s World',
@@ -147,10 +161,10 @@ describe('getMetaInfo', () => {
}) })
}) })
it('properly uses function titleTemplates', () => { test('properly uses function titleTemplates', () => {
const titleTemplate = chunk => `${chunk} Function World` const titleTemplate = chunk => `${chunk} Function World`
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
titleTemplate, titleTemplate,
@@ -159,7 +173,8 @@ describe('getMetaInfo', () => {
] ]
} }
}) })
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello Function World', title: 'Hello Function World',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate, titleTemplate,
@@ -179,12 +194,12 @@ describe('getMetaInfo', () => {
}) })
}) })
it('has the proper `this` binding when using function titleTemplates', () => { test('has the proper `this` binding when using function titleTemplates', () => {
const titleTemplate = function (chunk) { const titleTemplate = function (chunk) {
return `${chunk} ${this.helloWorldText}` return `${chunk} ${this.helloWorldText}`
} }
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
titleTemplate, titleTemplate,
@@ -192,13 +207,14 @@ describe('getMetaInfo', () => {
{ charset: 'utf-8' } { charset: 'utf-8' }
] ]
}, },
data () { data() {
return { return {
helloWorldText: 'Function World' helloWorldText: 'Function World'
} }
} }
}) })
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello Function World', title: 'Hello Function World',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate, titleTemplate,
@@ -218,8 +234,8 @@ describe('getMetaInfo', () => {
}) })
}) })
it('properly uses string meta templates', () => { test('properly uses string meta templates', () => {
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
meta: [ meta: [
@@ -232,7 +248,8 @@ describe('getMetaInfo', () => {
] ]
} }
}) })
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello', title: 'Hello',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s', titleTemplate: '%s',
@@ -256,8 +273,8 @@ describe('getMetaInfo', () => {
}) })
}) })
it('properly uses function meta templates', () => { test('properly uses function meta templates', () => {
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
meta: [ meta: [
@@ -270,7 +287,8 @@ describe('getMetaInfo', () => {
] ]
} }
}) })
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello', title: 'Hello',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s', titleTemplate: '%s',
@@ -294,8 +312,8 @@ describe('getMetaInfo', () => {
}) })
}) })
it('properly uses content only if template is not defined', () => { test('properly uses content only if template is not defined', () => {
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
meta: [ meta: [
@@ -307,7 +325,8 @@ describe('getMetaInfo', () => {
] ]
} }
}) })
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello', title: 'Hello',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s', titleTemplate: '%s',
@@ -331,8 +350,8 @@ describe('getMetaInfo', () => {
}) })
}) })
it('properly uses content only if template is null', () => { test('properly uses content only if template is null', () => {
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
meta: [ meta: [
@@ -345,7 +364,8 @@ describe('getMetaInfo', () => {
] ]
} }
}) })
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello', title: 'Hello',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s', titleTemplate: '%s',
@@ -369,8 +389,8 @@ describe('getMetaInfo', () => {
}) })
}) })
it('properly uses content only if template is false', () => { test('properly uses content only if template is false', () => {
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
meta: [ meta: [
@@ -383,7 +403,8 @@ describe('getMetaInfo', () => {
] ]
} }
}) })
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello', title: 'Hello',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s', titleTemplate: '%s',
@@ -407,9 +428,9 @@ describe('getMetaInfo', () => {
}) })
}) })
it('properly uses meta templates with one-level-deep nested children content', () => { test('properly uses meta templates with one-level-deep nested children content', () => {
Vue.component('merge-child', { Vue.component('merge-child', {
template: '<div></div>', render: h => h('div'),
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
meta: [ meta: [
@@ -422,7 +443,7 @@ describe('getMetaInfo', () => {
} }
}) })
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
meta: [ meta: [
{ {
@@ -433,11 +454,11 @@ describe('getMetaInfo', () => {
} }
] ]
}, },
render: (h) => h('div', null, [h('merge-child')]), el: document.createElement('div'),
el: document.createElement('div') render: h => h('div', null, [h('merge-child')])
}) })
expect(getMetaInfo(component)).to.eql({ expect(getMetaInfo(component)).toEqual({
title: 'Hello', title: 'Hello',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s', titleTemplate: '%s',
@@ -463,9 +484,9 @@ describe('getMetaInfo', () => {
// TODO: Still failing :( Child template won't be applied if child has no content as well // TODO: Still failing :( Child template won't be applied if child has no content as well
it('properly uses meta templates with one-level-deep nested children template', () => { test('properly uses meta templates with one-level-deep nested children template', () => {
Vue.component('merge-child', { Vue.component('merge-child', {
template: '<div></div>', render: h => h('div'),
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
meta: [ meta: [
@@ -478,7 +499,7 @@ describe('getMetaInfo', () => {
} }
}) })
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
meta: [ meta: [
{ {
@@ -489,11 +510,11 @@ describe('getMetaInfo', () => {
} }
] ]
}, },
render: (h) => h('div', null, [h('merge-child')]), el: document.createElement('div'),
el: document.createElement('div') render: h => h('div', null, [h('merge-child')])
}) })
expect(getMetaInfo(component)).to.eql({ expect(getMetaInfo(component)).toEqual({
title: 'Hello', title: 'Hello',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s', titleTemplate: '%s',
@@ -517,9 +538,9 @@ describe('getMetaInfo', () => {
}) })
}) })
it('properly uses meta templates with one-level-deep nested children template and content', () => { test('properly uses meta templates with one-level-deep nested children template and content', () => {
Vue.component('merge-child', { Vue.component('merge-child', {
template: '<div></div>', render: h => h('div'),
metaInfo: { metaInfo: {
title: 'Hello', title: 'Hello',
meta: [ meta: [
@@ -533,7 +554,7 @@ describe('getMetaInfo', () => {
} }
}) })
component = new Vue({ const component = new Vue({
metaInfo: { metaInfo: {
meta: [ meta: [
{ {
@@ -544,11 +565,11 @@ describe('getMetaInfo', () => {
} }
] ]
}, },
render: (h) => h('div', null, [h('merge-child')]), el: document.createElement('div'),
el: document.createElement('div') render: h => h('div', null, [h('merge-child')])
}) })
expect(getMetaInfo(component)).to.eql({ expect(getMetaInfo(component)).toEqual({
title: 'Hello', title: 'Hello',
titleChunk: 'Hello', titleChunk: 'Hello',
titleTemplate: '%s', titleTemplate: '%s',
-4
View File
@@ -1,4 +0,0 @@
const testsContext = require.context('.', true, /\.spec$/)
const srcContext = require.context('../src', true, /\.js$/)
testsContext.keys().forEach(testsContext)
srcContext.keys().forEach(srcContext)
+38
View File
@@ -0,0 +1,38 @@
import { mount, defaultOptions, VueMetaBrowserPlugin, loadVueMetaPlugin } from './utils'
jest.mock('../package.json', () => ({
version: 'test-version'
}))
describe('plugin', () => {
let Vue
beforeAll(() => (Vue = loadVueMetaPlugin(true)))
test('is loaded', () => {
const instance = new Vue()
expect(instance.$meta).toEqual(expect.any(Function))
expect(instance.$meta().inject).toEqual(expect.any(Function))
expect(instance.$meta().refresh).toEqual(expect.any(Function))
expect(instance.$meta().inject()).toBeUndefined()
expect(instance.$meta().refresh()).toBeDefined()
})
test('component has _hasMetaInfo set to true', () => {
const Component = Vue.component('test-component', {
template: '<div>Test</div>',
[defaultOptions.keyName]: {
title: 'Hello World'
}
})
const { vm } = mount(Component, { localVue: Vue })
expect(vm._hasMetaInfo).toBe(true)
})
test('plugin sets package version', () => {
expect(VueMetaBrowserPlugin.version).toBe('test-version')
})
})
+32
View File
@@ -0,0 +1,32 @@
import { mount, defaultOptions, VueMetaServerPlugin, loadVueMetaPlugin } from './utils'
jest.mock('../package.json', () => ({
version: 'test-version'
}))
describe('plugin', () => {
let Vue
beforeAll(() => (Vue = loadVueMetaPlugin()))
test('is loaded', () => {
const instance = new Vue()
expect(instance.$meta).toEqual(expect.any(Function))
})
test('component has _hasMetaInfo set to true', () => {
const Component = Vue.component('test-component', {
template: '<div>Test</div>',
[defaultOptions.keyName]: {
title: 'Hello World'
}
})
const { vm } = mount(Component, { localVue: Vue })
expect(vm._hasMetaInfo).toBe(true)
})
test('plugin sets package version', () => {
expect(VueMetaServerPlugin.version).toBe('test-version')
})
})
-33
View File
@@ -1,33 +0,0 @@
import Vue from 'vue'
import VueMeta from '../src/shared/plugin'
import {
VUE_META_KEY_NAME,
VUE_META_ATTRIBUTE,
VUE_META_SERVER_RENDERED_ATTRIBUTE,
VUE_META_TAG_LIST_ID_KEY_NAME
} from '../src/shared/constants'
describe('plugin', () => {
Vue.use(VueMeta, {
keyName: VUE_META_KEY_NAME,
attribute: VUE_META_ATTRIBUTE,
ssrAttribute: VUE_META_SERVER_RENDERED_ATTRIBUTE,
tagIDKeyName: VUE_META_TAG_LIST_ID_KEY_NAME
})
it('adds $meta() to Vue prototype', () => {
const instance = new Vue()
expect(instance.$meta).to.be.a('function')
})
it('components have _hasMetaInfo set to true', () => {
const Component = Vue.component('test-component', {
template: '<div>Test</div>',
[VUE_META_KEY_NAME]: {
title: 'helloworld'
}
})
const vm = new Vue(Component).$mount()
expect(vm._hasMetaInfo).to.equal(true)
})
})
+97
View File
@@ -0,0 +1,97 @@
import _updateClientMetaInfo from '../src/client/updateClientMetaInfo'
import { defaultOptions } from './utils'
import metaInfoData from './utils/meta-info-data'
const updateClientMetaInfo = (type, data) => _updateClientMetaInfo(defaultOptions, { [type]: data })
describe('updaters', () => {
let html
beforeAll(() => {
html = document.getElementsByTagName('html')[0]
// remove default meta charset
Array.from(html.getElementsByTagName('meta')).forEach(el => el.parentNode.removeChild(el))
})
Object.keys(metaInfoData).forEach((type) => {
const typeTests = metaInfoData[type]
const testCases = {
add: (tags) => {
typeTests.add.expect.forEach((expected, index) => {
if (!['title', 'htmlAttrs', 'headAttrs', 'bodyAttrs'].includes(type)) {
expect(tags.addedTags[type][index].outerHTML).toBe(expected)
}
expect(html.outerHTML).toContain(expected)
})
},
change: (tags) => {
typeTests.add.expect.forEach((expected, index) => {
if (!typeTests.change.expect.includes(expected)) {
expect(html.outerHTML).not.toContain(expected)
}
})
typeTests.change.expect.forEach((expected, index) => {
if (!['title', 'htmlAttrs', 'headAttrs', 'bodyAttrs'].includes(type)) {
expect(tags.addedTags[type][index].outerHTML).toBe(expected)
}
expect(html.outerHTML).toContain(expected)
})
},
remove: (tags) => {
// TODO: i'd expect tags.removedTags to be populated
typeTests.add.expect.forEach((expected, index) => {
expect(html.outerHTML).not.toContain(expected)
})
typeTests.change.expect.forEach((expected, index) => {
expect(html.outerHTML).not.toContain(expected)
})
expect(html.outerHTML).not.toContain(`<${type}`)
}
}
describe(`${type} type tests`, () => {
Object.keys(typeTests).forEach((action) => {
const testInfo = typeTests[action]
// return when no test case available
if (!testCases[action] && !testInfo.test) {
return
}
const defaultTestFn = () => {
const tags = updateClientMetaInfo(type, testInfo.data)
if (testCases[action]) {
testCases[action](tags)
}
return tags
}
let testFn
if (testInfo.test) {
testFn = testInfo.test('client', defaultTestFn)
if (testFn === true) {
testFn = defaultTestFn
}
} else {
testFn = defaultTestFn
}
if (testFn && typeof testFn === 'function') {
test(`${action} a tag`, () => {
expect.hasAssertions()
testFn()
})
}
})
})
})
})
+43
View File
@@ -0,0 +1,43 @@
import { mount, createLocalVue } from '@vue/test-utils'
import { renderToString } from '@vue/server-test-utils'
import VueMetaBrowserPlugin from '../../src/browser'
import VueMetaServerPlugin from '../../src'
import {
keyName,
attribute,
ssrAttribute,
tagIDKeyName,
metaTemplateKeyName,
contentKeyName
} from '../../src/shared/constants'
export {
mount,
renderToString,
VueMetaBrowserPlugin,
VueMetaServerPlugin
}
export const defaultOptions = {
keyName,
attribute,
ssrAttribute,
tagIDKeyName,
metaTemplateKeyName,
contentKeyName
}
export function getVue() {
return createLocalVue()
}
export function loadVueMetaPlugin(browser, options, localVue = getVue()) {
if (browser) {
localVue.use(VueMetaBrowserPlugin, Object.assign({}, defaultOptions, options))
} else {
localVue.use(VueMetaServerPlugin, Object.assign({}, defaultOptions, options))
}
return localVue
}
+220
View File
@@ -0,0 +1,220 @@
import { defaultOptions } from './'
const metaInfoData = {
title: {
add: {
data: 'Hello World',
expect: ['<title data-vue-meta="true">Hello World</title>'],
test(side, defaultTest) {
if (side === 'client') {
// client side vue-meta uses document.title and doesnt change the html
return () => {
this.expect[0] = '<title>Hello World</title>'
const spy = jest.spyOn(document, 'title', 'set')
defaultTest()
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith('Hello World')
}
} else {
return defaultTest
}
}
}
},
base: {
add: {
data: [{ href: 'href' }],
expect: ['<base data-vue-meta="true" href="href">']
},
change: {
data: [{ href: 'href2' }],
expect: ['<base data-vue-meta="true" href="href2">']
},
remove: {
data: [],
expect: ['']
}
},
meta: {
add: {
data: [{ charset: 'utf-8' }, { property: 'a', content: 'a' }],
expect: [
'<meta data-vue-meta="true" charset="utf-8">',
'<meta data-vue-meta="true" property="a" content="a">'
]
},
change: {
data: [
{ charset: 'utf-16' },
{ property: 'a', content: 'b' }
],
expect: [
'<meta data-vue-meta="true" charset="utf-16">',
'<meta data-vue-meta="true" property="a" content="b">'
]
},
// make sure elements that already exists are not unnecessarily updated
duplicate: {
data: [
{ charset: 'utf-16' },
{ property: 'a', content: 'c' }
],
expect: [
'<meta data-vue-meta="true" charset="utf-16">',
'<meta data-vue-meta="true" property="a" content="c">'
],
test(side, defaultTest) {
if (side === 'client') {
return () => {
const tags = defaultTest()
expect(tags.addedTags.meta.length).toBe(1)
// TODO: not sure if we really expect this
expect(tags.removedTags.meta.length).toBe(1)
}
}
}
},
remove: {
data: [],
expect: ['']
}
},
link: {
add: {
data: [{ rel: 'stylesheet', href: 'href' }],
expect: ['<link data-vue-meta="true" rel="stylesheet" href="href">']
},
change: {
data: [{ rel: 'stylesheet', href: 'href', media: 'screen' }],
expect: ['<link data-vue-meta="true" rel="stylesheet" href="href" media="screen">']
},
remove: {
data: [],
expect: ['']
}
},
style: {
add: {
data: [{ type: 'text/css', cssText: '.foo { color: red; }' }],
expect: ['<style data-vue-meta="true" type="text/css">.foo { color: red; }</style>']
},
change: {
data: [{ type: 'text/css', cssText: '.foo { color: blue; }' }],
expect: ['<style data-vue-meta="true" type="text/css">.foo { color: blue; }</style>']
},
remove: {
data: [],
expect: ['']
}
},
script: {
add: {
data: [
{ src: 'src', async: true, defer: true, [defaultOptions.tagIDKeyName]: 'content' },
{ src: 'src', async: true, defer: true, body: true }
],
expect: [
'<script data-vue-meta="true" src="src" async defer data-vmid="content"></script>',
'<script data-vue-meta="true" src="src" async defer data-body="true"></script>'
],
test(side, defaultTest) {
return () => {
if (side === 'client') {
for (const index in this.expect) {
this.expect[index] = this.expect[index].replace(/(async|defer)/g, '$1="true"')
}
const tags = defaultTest()
expect(tags.addedTags.script[0].parentNode.tagName).toBe('HEAD')
expect(tags.addedTags.script[1].parentNode.tagName).toBe('BODY')
} else {
// ssr doesnt generate data-body tags
const bodyScript = this.expect[1]
this.expect = [this.expect[0]]
const tags = defaultTest()
expect(tags.text()).not.toContain(bodyScript)
}
}
}
},
// this test only runs for client so we can directly expect wrong boolean attributes
change: {
data: [{ src: 'src', async: true, defer: true, [defaultOptions.tagIDKeyName]: 'content2' }],
expect: ['<script data-vue-meta="true" src="src" async="true" defer="true" data-vmid="content2"></script>']
},
remove: {
data: [],
expect: ['']
}
},
noscript: {
add: {
data: [{ innerHTML: '<p>noscript</p>' }],
expect: ['<noscript data-vue-meta="true"><p>noscript</p></noscript>']
},
change: {
data: [{ innerHTML: '<p>noscript, no really</p>' }],
expect: ['<noscript data-vue-meta="true"><p>noscript, no really</p></noscript>']
},
remove: {
data: [],
expect: ['']
}
},
htmlAttrs: {
add: {
data: { foo: 'bar' },
expect: ['<html foo="bar" data-vue-meta="foo">']
},
change: {
data: { foo: 'baz' },
expect: ['<html foo="baz" data-vue-meta="foo">']
},
remove: {
data: {},
expect: ['<html>']
}
},
headAttrs: {
add: {
data: { foo: 'bar' },
expect: ['<head foo="bar" data-vue-meta="foo">']
},
change: {
data: { foo: 'baz' },
expect: ['<head foo="baz" data-vue-meta="foo">']
},
remove: {
data: {},
expect: ['<head>']
}
},
bodyAttrs: {
add: {
data: { foo: 'bar' },
expect: ['<body foo="bar" data-vue-meta="foo">']
},
change: {
data: { foo: 'baz' },
expect: ['<body foo="baz" data-vue-meta="foo">']
},
remove: {
data: {},
expect: ['<body>']
}
},
empty: {
add: {
data: [{}],
expect: [''],
test: side => side === 'server'
}
}
}
export default metaInfoData
+5
View File
@@ -0,0 +1,5 @@
import jsdom from 'jsdom-global'
jsdom()
jest.useFakeTimers()