2
0
mirror of https://github.com/tenrok/vue-meta.git synced 2026-06-24 01:10:35 +03:00

feat: major refactor, cleanup and jest tests

This commit is contained in:
pimlie
2019-02-09 21:45:22 +01:00
parent 9dfb001d4e
commit 5d64d43862
61 changed files with 8598 additions and 822 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
@@ -38,3 +38,7 @@ jspm_packages
# built code # built code
lib lib
# yarn
yarn.lock
yarn-error.log
+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; clone this repository & run `cd examples && npm install` in the root directory, and then run `npm run start`. Head to http://localhost:8080.
If you would like to help to develop vue-meta then run `npm install` both in the root and examples dir and run `npm run 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=dev 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"
}
}
+4 -2
View File
@@ -24,7 +24,9 @@ fs.readdirSync(__dirname).forEach(file => {
app.use(express.static(__dirname)) app.use(express.static(__dirname))
const host = process.env.HOST || 'localhost'
const port = process.env.PORT || 8080 const port = process.env.PORT || 8080
module.exports = app.listen(port, () => {
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'
+2 -2
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'
@@ -41,6 +40,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 +54,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",
"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"
]
} }
} }
+37 -11
View File
@@ -1,27 +1,53 @@
import commonjs from 'rollup-plugin-commonjs' import commonJs from 'rollup-plugin-commonjs'
import nodeResolve from 'rollup-plugin-node-resolve' import nodeResolve from 'rollup-plugin-node-resolve'
import json from 'rollup-plugin-json' import json from 'rollup-plugin-json'
import buble from 'rollup-plugin-buble' import buble from 'rollup-plugin-buble'
import { terser } from 'rollup-plugin-terser'
const pkg = require('./package.json') const pkg = require('./package.json')
export default { const banner = `/**
input: './src/index.js',
output: {
file: pkg.main,
format: 'umd',
name: 'VueMeta',
banner: `/**
* vue-meta v${pkg.version} * vue-meta v${pkg.version}
* (c) ${new Date().getFullYear()} Declan de Wet & Sébastien Chopin (@Atinux) * (c) ${new Date().getFullYear()} Declan de Wet & Sébastien Chopin (@Atinux)
* @license MIT * @license MIT
*/ */
`.replace(/ {4}/gm, '').trim() `.replace(/ {4}/gm, '').trim()
const baseConfig = {
input: './src/index.js',
output: {
file: pkg.web,
format: 'umd',
name: 'VueMeta',
sourcemap: false,
banner
}, },
plugins: [ plugins: [
json(), json(),
nodeResolve({ jsnext: true }), nodeResolve(),
commonjs(), commonJs(),
buble() buble(),
] ]
} }
export default [{
...baseConfig,
}, {
...baseConfig,
output: {
...baseConfig.output,
file: pkg.web.replace('.js', '.min.js'),
},
plugins: [
...baseConfig.plugins,
terser()
]
}, {
...baseConfig,
output: {
...baseConfig.output,
file: pkg.main,
format: 'cjs'
},
external: Object.keys(pkg.dependencies)
}]
+2 -2
View File
@@ -1,6 +1,6 @@
// 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 = (typeof window !== 'undefined' ? window.cancelAnimationFrame : null) || clearTimeout
const startUpdate = (typeof window !== 'undefined' ? window.requestAnimationFrame : null) || ((cb) => setTimeout(cb, 0)) const startUpdate = (typeof window !== 'undefined' ? 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 +12,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
+12 -5
View File
@@ -1,7 +1,7 @@
import getMetaInfo from '../shared/getMetaInfo' import getMetaInfo from '../shared/getMetaInfo'
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 +12,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 && typeof metaInfo.changed === 'function') {
metaInfo.changed.call(this, metaInfo, tags.addedTags, tags.removedTags)
}
return metaInfo
} }
} }
+67 -56
View File
@@ -1,64 +1,75 @@
import updateTitle from './updaters/updateTitle' import { updateAttribute, updateTag, updateTitle } from './updaters'
import updateTagAttributes from './updaters/updateTagAttributes'
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 = {}
Object.keys(newInfo).forEach((type) => {
// ignore these
if ([
'titleChunk',
'titleTemplate',
'changed',
'__dangerouslyDisableSanitizers',
'__dangerouslyDisableSanitizersByTagID'
].includes(type)) {
return
} }
} else {
// remove the server render attribute so we can update on changes if (type === 'title') {
htmlTag.removeAttribute(ssrAttribute) // update the title
} updateTitle(newInfo.title)
return
}
if (['htmlAttrs', 'headAttrs', 'bodyAttrs'].includes(type)) {
const tagName = type.substr(0, 4)
updateAttribute(options, newInfo[type], getTag(tags, tagName))
return
}
const { oldTags, newTags } = updateTag(
options,
type,
newInfo[type],
getTag(tags, 'head'),
getTag(tags, 'body')
)
if (newTags.length) {
addedTags[type] = newTags
removedTags[type] = oldTags
}
})
return { addedTags, removedTags }
} else {
// remove the server render attribute so we can update on changes
htmlTag.removeAttribute(ssrAttribute)
} }
return false
} }
+35
View File
@@ -0,0 +1,35 @@
/**
* 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 = [].concat(vueMetaAttrs)
for (const attr in attrs) {
if (attrs.hasOwnProperty(attr)) {
const val = attrs[attr] || ''
tag.setAttribute(attr, val)
if (vueMetaAttrs.indexOf(attr) === -1) {
vueMetaAttrs.push(attr)
}
const keepIndex = toRemove.indexOf(attr)
if (keepIndex !== -1) {
toRemove.splice(keepIndex, 1)
}
}
}
toRemove.forEach(attr => tag.removeAttribute(attr))
if (vueMetaAttrs.length === toRemove.length) {
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 @@
// borrow the slice method
const toArray = Function.prototype.call.bind(Array.prototype.slice)
/**
* 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 = toArray(headTag.querySelectorAll(`${type}[${attribute}]`))
const oldBodyTags = toArray(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) {
newElement.styleSheet.cssText = tag.cssText
} else {
newElement.appendChild(document.createTextNode(tag.cssText))
}
} else if ([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)
}
}
}
// 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
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import install from './shared/plugin'
import { version } from '../package.json' import { version } from '../package.json'
import install from './shared/plugin'
install.version = version install.version = version
+18 -22
View File
@@ -1,25 +1,21 @@
import titleGenerator from './generators/titleGenerator' import { titleGenerator, attributeGenerator, tagGenerator } from './generators'
import attrsGenerator from './generators/attrsGenerator'
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 (['htmlAttrs', 'headAttrs', 'bodyAttrs'].includes(type)) {
return attributeGenerator(options, type, data)
}
return tagGenerator(options, type, data)
} }
+30
View File
@@ -0,0 +1,30 @@
/**
* 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 += typeof data[attr] !== 'undefined'
? `${attr}="${data[attr]}"`
: 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'
+60
View File
@@ -0,0 +1,60 @@
/**
* 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) => {
if (Object.keys(tag).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 = Object.keys(tag).reduce((attrsStr, attr) => {
// these attributes are treated as children on the tag
if (['innerHTML', 'cssText', 'once'].includes(attr)) {
return attrsStr
}
// these form the attribute list for this tag
let prefix = ''
if ([tagIDKeyName, 'body'].includes(attr)) {
prefix = 'data-'
}
return typeof tag[attr] === 'undefined'
? `${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 = !['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}>`
}, '')
}
}
}
-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}>`
}
}
}
}
+8 -7
View File
@@ -1,7 +1,7 @@
import getMetaInfo from '../shared/getMetaInfo' import getMetaInfo from '../shared/getMetaInfo'
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 +9,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 (!['titleTemplate', 'titleChunk'].includes(key) && metaInfo.hasOwnProperty(key)) {
info[key] = generateServerInjector(options)(key, info[key]) metaInfo[key] = generateServerInjector(options, key, metaInfo[key])
} }
} }
return info return metaInfo
} }
} }
+2 -2
View File
@@ -1,13 +1,13 @@
import inject from '../server/inject' import inject from '../server/inject'
import refresh from '../client/refresh' import refresh from '../client/refresh'
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)
+11 -9
View File
@@ -1,6 +1,6 @@
import deepmerge from 'deepmerge' import deepmerge from 'deepmerge'
import uniqBy from './uniqBy'
import uniqueId from 'lodash.uniqueid' import uniqueId from 'lodash.uniqueid'
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,21 +10,22 @@ 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 (typeof $options[keyName] !== 'undefined' && $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 (typeof data === 'function') {
@@ -44,14 +45,15 @@ 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) || typeof metaObject[metaTemplateKeyName] === 'undefined') {
return result.meta[metaKey] return result.meta[metaKey]
+128 -121
View File
@@ -3,7 +3,7 @@ import isPlainObject from 'lodash.isplainobject'
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 => typeof window === 'undefined'
// server-side escape sequence // server-side escape sequence
? String(str) ? String(str)
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -19,138 +19,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 typeof template === 'function' ? 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.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 { } 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]'
+89 -34
View File
@@ -1,6 +1,5 @@
import assign from 'object-assign'
import $meta from './$meta'
import batchUpdate from '../client/batchUpdate' import batchUpdate from '../client/batchUpdate'
import $meta from './$meta'
import { import {
VUE_META_KEY_NAME, VUE_META_KEY_NAME,
@@ -19,7 +18,7 @@ if (typeof window !== 'undefined' && typeof window.Vue !== 'undefined') {
* Plugin install function. * Plugin install function.
* @param {Function} Vue - the Vue constructor. * @param {Function} Vue - the Vue constructor.
*/ */
export default function VueMeta (Vue, options = {}) { export default function VueMeta(Vue, options = {}) {
// set some default options // set some default options
const defaultOptions = { const defaultOptions = {
keyName: VUE_META_KEY_NAME, keyName: VUE_META_KEY_NAME,
@@ -29,8 +28,15 @@ export default function VueMeta (Vue, options = {}) {
ssrAttribute: VUE_META_SERVER_RENDERED_ATTRIBUTE, ssrAttribute: VUE_META_SERVER_RENDERED_ATTRIBUTE,
tagIDKeyName: VUE_META_TAG_LIST_ID_KEY_NAME tagIDKeyName: VUE_META_TAG_LIST_ID_KEY_NAME
} }
// combine options // combine options
options = assign(defaultOptions, options) options = typeof options === 'object' ? options : {}
for (const key in defaultOptions) {
if (!options[key]) {
options[key] = defaultOptions[key]
}
}
// bind the $meta method to this component instance // bind the $meta method to this component instance
Vue.prototype.$meta = $meta(options) Vue.prototype.$meta = $meta(options)
@@ -38,66 +44,115 @@ export default function VueMeta (Vue, options = {}) {
// store an id to keep track of DOM updates // store an id to keep track of DOM updates
let batchID = null let batchID = null
const triggerUpdate = (vm) => {
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => vm.$meta().refresh())
}
// watch for client side component updates // watch for client side component updates
Vue.mixin({ Vue.mixin({
beforeCreate () { beforeCreate() {
// Add a marker to know if it uses metaInfo // Add a marker to know if it uses metaInfo
// _vnode is used to know that it's attached to a real component // _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) // useful if we use some mixin to add some meta tags (like nuxt-i18n)
if (typeof this.$options[options.keyName] !== 'undefined') { if (typeof this.$options[options.keyName] !== 'undefined' && this.$options[options.keyName] !== null) {
this._hasMetaInfo = true this._hasMetaInfo = true
}
// coerce function-style metaInfo to a computed prop so we can observe // coerce function-style metaInfo to a computed prop so we can observe
// it on creation // it on creation
if (typeof this.$options[options.keyName] === 'function') { if (typeof this.$options[options.keyName] === 'function') {
if (typeof this.$options.computed === 'undefined') { if (typeof this.$options.computed === 'undefined') {
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))
})
}
}
['activated', 'deactivated', 'beforeMount'].forEach((lifecycleHook) => {
this.$options[lifecycleHook] = this.$options[lifecycleHook] || []
this.$options[lifecycleHook].push(() => triggerUpdate(this))
})
// 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)
})
} }
this.$options.computed.$metaInfo = this.$options[options.keyName]
} }
}, }
created () { /* Not yet removed
created() {
// if computed $metaInfo exists, watch it for updates & trigger a refresh // if computed $metaInfo exists, watch it for updates & trigger a refresh
// when it changes (i.e. automatically handle async actions that affect metaInfo) // 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) // credit for this suggestion goes to [Sébastien Chopin](https://github.com/Atinux)
if (!this.$isServer && this.$metaInfo) { if (!this.$isServer && this.$metaInfo) {
this.$watch('$metaInfo', () => { this.$watch('$metaInfo', () => triggerUpdate(this))
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => this.$meta().refresh())
})
} }
}, },
activated () { activated() {
if (this._hasMetaInfo) { if (this._hasMetaInfo) {
// batch potential DOM updates to prevent extraneous re-rendering triggerUpdate(this)
batchID = batchUpdate(batchID, () => this.$meta().refresh())
} }
}, },
deactivated () { deactivated() {
if (this._hasMetaInfo) { if (this._hasMetaInfo) {
// batch potential DOM updates to prevent extraneous re-rendering triggerUpdate(this)
batchID = batchUpdate(batchID, () => this.$meta().refresh())
} }
}, },
beforeMount () { beforeMount() {
// batch potential DOM updates to prevent extraneous re-rendering
if (this._hasMetaInfo) { if (this._hasMetaInfo) {
batchID = batchUpdate(batchID, () => this.$meta().refresh()) triggerUpdate(this)
} }
}, },
destroyed () { destroyed() {
// do not trigger refresh on the server side // do not trigger refresh on the server side
if (this.$isServer) return if (this.$isServer) {
return
}
// re-render meta data when returning from a child component to parent // re-render meta data when returning from a child component to parent
if (this._hasMetaInfo) { if (this._hasMetaInfo) {
// Wait that element is hidden before refreshing meta tags (to support animations) // Wait that element is hidden before refreshing meta tags (to support animations)
const interval = setInterval(() => { const interval = setInterval(() => {
if (this.$el && this.$el.offsetParent !== null) return if (this.$el && this.$el.offsetParent !== null) {
return
}
clearInterval(interval) clearInterval(interval)
if (!this.$parent) return
batchID = batchUpdate(batchID, () => this.$meta().refresh()) if (!this.$parent) {
return
}
triggerUpdate(this)
}, 50) }, 50)
} }
} }/**/
}) })
} }
+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
+79
View File
@@ -0,0 +1,79 @@
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'
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>')
})
})
+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 {
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.only(`${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)
-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)
})
})
+32
View File
@@ -0,0 +1,32 @@
import { mount, defaultOptions, VueMetaPlugin, 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(VueMetaPlugin.version).toBe('test-version')
})
})
+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.only(`${action} a tag`, () => {
expect.hasAssertions()
testFn()
})
}
})
})
})
})
+37
View File
@@ -0,0 +1,37 @@
import { mount, createLocalVue } from '@vue/test-utils'
import { renderToString } from '@vue/server-test-utils'
import VueMetaPlugin from '../../src'
import {
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'
export {
mount,
renderToString,
VueMetaPlugin
}
export 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
}
export function getVue() {
return createLocalVue()
}
export function loadVueMetaPlugin(options, localVue = getVue()) {
localVue.use(VueMetaPlugin, Object.assign({}, defaultOptions, options))
return localVue
}
+216
View File
@@ -0,0 +1,216 @@
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="true" defer="true" data-vmid="content"></script>',
'<script data-vue-meta="true" src="src" async="true" defer="true" data-body="true"></script>'
],
test(side, defaultTest) {
return () => {
if (side === 'client') {
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)
}
}
}
},
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()