2
0
mirror of https://github.com/tenrok/vue-meta.git synced 2026-06-08 22:02:25 +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"],
"env": {
"test": {
"plugins": ["istanbul"]
}
}
"presets": ["@babel/preset-env"]
}
+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
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
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))
const host = process.env.HOST || 'localhost'
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 renderer = require('vue-server-renderer').createRenderer()
const VueMeta = require('../')
const VueMeta = require(process.env.NODE_ENV === 'development' ? '../' : 'vue-meta')
Vue.use(VueMeta, {
tagIDKeyName: 'hid'
+1 -1
View File
@@ -1,6 +1,6 @@
const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer()
const VueMeta = require('../')
const VueMeta = require(process.env.NODE_ENV === 'development' ? '../' : 'vue-meta')
Vue.use(VueMeta, {
tagIDKeyName: 'hid'
+2 -2
View File
@@ -1,4 +1,3 @@
import assign from 'object-assign'
import Vue from 'vue'
import VueMeta from 'vue-meta'
import Router from 'vue-router'
@@ -41,6 +40,7 @@ const router = new Router({
})
const App = {
router,
template: `
<div id="app">
<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')
+3 -3
View File
@@ -1,9 +1,9 @@
import assign from 'object-assign'
import Vue from 'vue'
import store from './store'
import router from './router'
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 store from './store'
import router from './router'
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 path from 'path'
import webpack from 'webpack'
import WebpackBar from 'webpackbar'
import VueLoaderPlugin from 'vue-loader/lib/plugin'
export default {
devtool: 'inline-source-map',
@@ -28,7 +30,7 @@ export default {
resolve: {
alias: {
'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.
@@ -37,7 +39,8 @@ export default {
__dirname: true
},
plugins: [
// new webpack.optimize.CommonsChunkPlugin('shared.js'),
new WebpackBar(),
new VueLoaderPlugin(),
new webpack.DefinePlugin({
'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",
"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",
"author": "Declan de Wet <declandewet@me.com>",
"bugs": "https://github.com/declandewet/vue-meta/issues",
"bugs": "https://github.com/nuxt/vue-meta/issues",
"scripts": {
"build": "rollup -c",
"build": "rimraf lib && rollup -c",
"codecov": "codecov",
"deploy": "npm version",
"dev": "babel-node examples/server.js",
"lint": "standard --verbose | snazzy",
"minify": "uglifyjs lib/vue-meta.js -cm --comments -o lib/vue-meta.min.js",
"postbuild": "npm run minify",
"dev": "cd examples && npm run dev && cd ..",
"lint": "eslint src test",
"postdeploy": "git push origin master --follow-tags && npm run release",
"postversion": "npm run update-cdn && git add . && git commit -m \":ship: CDN update\"",
"prebuild": "rimraf lib",
"predeploy": "git checkout master && git pull -r",
"prerelease": "npm run build",
"pretest": "npm run lint",
"preversion": "npm run toc",
"release": "npm publish",
"test": "cross-env NODE_ENV=test karma start karma.conf.js",
"test": "jest",
"toc": "doctoc README.md --title '# Table of Contents'",
"update-cdn": "babel-node scripts/update-cdn.js"
},
"dependencies": {
"deepmerge": "^3.0.0",
"lodash.isplainobject": "^4.0.6",
"lodash.uniqueid": "^4.0.1",
"object-assign": "^4.1.1"
"lodash.uniqueid": "^4.0.1"
},
"devDependencies": {
"@babel/cli": "^7.1.5",
"@babel/core": "^7.1.6",
"@babel/node": "^7.0.0",
"@babel/node": "^7.2.2",
"@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-plugin-istanbul": "^5.1.0",
"chai": "^4.2.0",
"codecov": "^3.1.0",
"cross-env": "^5.2.0",
"css-loader": "^2.0.0",
"doctoc": "^1.4.0",
"es6-promise": "^4.2.5",
"express": "^4.16.4",
"express-urlrewrite": "^1.2.0",
"file-loader": "^3.0.0",
"karma": "^3.1.1",
"karma-chai": "^0.1.0",
"karma-coverage": "^1.1.2",
"karma-mocha": "^1.3.0",
"karma-mocha-reporter": "^2.2.5",
"karma-phantomjs-launcher": "^1.0.4",
"karma-sourcemap-loader": "^0.3.7",
"karma-webpack": "^3.0.5",
"mocha": "^5.2.0",
"phantomjs-prebuilt": "^2.1.16",
"eslint": "^5.13.0",
"eslint-config-standard": "^12.0.0",
"eslint-plugin-import": "^2.16.0",
"eslint-plugin-jest": "^22.2.2",
"eslint-plugin-node": "^8.0.1",
"eslint-plugin-promise": "^4.0.1",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^5.1.0",
"jest": "^24.1.0",
"jsdom": "^13.2.0",
"jsdom-global": "^3.0.2",
"rimraf": "^2.6.2",
"rollup": "^1.0.0",
"rollup-plugin-buble": "^0.19.4",
"rollup-plugin-commonjs": "^9.2.0",
"rollup-plugin-json": "^3.1.0",
"rollup-plugin-node-resolve": "^4.0.0",
"snazzy": "^8.0.0",
"standard": "^12.0.1",
"uglify-js": "^3.4.9",
"rollup-plugin-terser": "^4.0.4",
"update-section": "^0.3.3",
"vue": "^2.5.17",
"vue-loader": "^15.4.2",
"vue-router": "^3.0.2",
"vue-server-renderer": "^2.5.17",
"vue-template-compiler": "^2.5.17",
"vuex": "^3.0.1",
"webpack": "^4.26.1",
"webpack-dev-server": "^3.1.10"
"vue": "^2.6.3",
"vue-jest": "^3.0.2",
"vue-server-renderer": "^2.6.3",
"vue-template-compiler": "^2.6.3"
},
"files": [
"lib",
"types/index.d.ts",
"types/vue.d.ts"
],
"homepage": "https://github.com/declandewet/vue-meta",
"homepage": "https://github.com/nuxt/vue-meta",
"keywords": [
"attribute",
"google",
@@ -97,28 +83,12 @@
"vue"
],
"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",
"nyc": {
"exclude": [
"test/**/*.js"
]
},
"repository": {
"url": "git@github.com:declandewet/vue-meta.git",
"url": "git@github.com/nuxt/vue-meta.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 json from 'rollup-plugin-json'
import buble from 'rollup-plugin-buble'
import { terser } from 'rollup-plugin-terser'
const pkg = require('./package.json')
export default {
input: './src/index.js',
output: {
file: pkg.main,
format: 'umd',
name: 'VueMeta',
banner: `/**
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/index.js',
output: {
file: pkg.web,
format: 'umd',
name: 'VueMeta',
sourcemap: false,
banner
},
plugins: [
json(),
nodeResolve({ jsnext: true }),
commonjs(),
buble()
nodeResolve(),
commonJs(),
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
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
@@ -12,7 +12,7 @@ const startUpdate = (typeof window !== 'undefined' ? window.requestAnimationFram
* @param {Function} callback - the update to perform
* @return {Number} id - a new ID
*/
export default function batchUpdate (id, callback) {
export default function batchUpdate(id, callback) {
stopUpdate(id)
return startUpdate(() => {
id = null
+12 -5
View File
@@ -1,7 +1,7 @@
import getMetaInfo from '../shared/getMetaInfo'
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.
* 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 function refresh () {
const info = getMetaInfo(options)(this.$root)
updateClientMetaInfo(options).call(this, info)
return info
return function refresh() {
const metaInfo = getMetaInfo(options, this.$root)
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 updateTagAttributes from './updaters/updateTagAttributes'
import updateTags from './updaters/updateTags'
import { updateAttribute, updateTag, updateTitle } from './updaters'
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
/**
* Performs client-side updates when new meta info is received
*
* @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 = {}
// only cache tags for current update
const tags = {}
Object.keys(newInfo).forEach((key) => {
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
}
}
})
const htmlTag = getTag(tags, 'html')
// emit "event" with new info
if (typeof newInfo.changed === 'function') {
newInfo.changed.call(this, newInfo, addedTags, removedTags)
// 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((type) => {
// ignore these
if ([
'titleChunk',
'titleTemplate',
'changed',
'__dangerouslyDisableSanitizers',
'__dangerouslyDisableSanitizersByTagID'
].includes(type)) {
return
}
} else {
// remove the server render attribute so we can update on changes
htmlTag.removeAttribute(ssrAttribute)
}
if (type === 'title') {
// 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 install from './shared/plugin'
install.version = version
+18 -22
View File
@@ -1,25 +1,21 @@
import titleGenerator from './generators/titleGenerator'
import attrsGenerator from './generators/attrsGenerator'
import tagGenerator from './generators/tagGenerator'
import { titleGenerator, attributeGenerator, tagGenerator } from './generators'
export default function _generateServerInjector (options = {}) {
/**
* 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|Object|Array<Object>)} data - the data value
* @return {Object} - the new injector
*/
return function generateServerInjector (type, data) {
switch (type) {
case 'title':
return titleGenerator(options)(type, data)
case 'htmlAttrs':
case 'bodyAttrs':
case 'headAttrs':
return attrsGenerator(options)(type, data)
default:
return tagGenerator(options)(type, data)
}
/**
* 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|Object|Array<Object>)} data - the data value
* @return {Object} - the new injector
*/
export default function generateServerInjector(options, type, data) {
if (type === 'title') {
return titleGenerator(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 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
* 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
* @return {Object} - server meta info with `toString` methods
*/
return function inject () {
return function inject() {
// get meta info with sensible defaults
const info = getMetaInfo(options)(this.$root)
const metaInfo = getMetaInfo(options, this.$root)
// generate server injectors
for (let key in info) {
if (info.hasOwnProperty(key) && key !== 'titleTemplate' && key !== 'titleChunk') {
info[key] = generateServerInjector(options)(key, info[key])
for (const key in metaInfo) {
if (!['titleTemplate', 'titleChunk'].includes(key) && metaInfo.hasOwnProperty(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 refresh from '../client/refresh'
export default function _$meta (options = {}) {
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 function $meta() {
return {
inject: inject(options).bind(this),
refresh: refresh(options).bind(this)
+11 -9
View File
@@ -1,6 +1,6 @@
import deepmerge from 'deepmerge'
import uniqBy from './uniqBy'
import uniqueId from 'lodash.uniqueid'
import uniqBy from './uniqBy'
/**
* 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.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 {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
* @return {Object} result - final aggregated result
*/
export default function getComponentOption (opts, result = {}) {
const { component, option, deep, arrayMerge, metaTemplateKeyName, tagIDKeyName, contentKeyName } = opts
export default function getComponentOption({ component, deep, arrayMerge, keyName, metaTemplateKeyName, tagIDKeyName, contentKeyName } = {}, result = {}) {
const { $options } = component
if (component._inactive) return result
if (component._inactive) {
return result
}
// only collect option data if it exists
if (typeof $options[option] !== 'undefined' && $options[option] !== null) {
let data = $options[option]
if (typeof $options[keyName] !== 'undefined' && $options[keyName] !== null) {
let data = $options[keyName]
// if option is a function, replace it with it's result
if (typeof data === 'function') {
@@ -44,14 +45,15 @@ export default function getComponentOption (opts, result = {}) {
component.$children.forEach((childComponent) => {
result = getComponentOption({
component: childComponent,
option,
keyName,
deep,
arrayMerge
}, result)
})
}
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]
if (!metaObject.hasOwnProperty(metaTemplateKeyName) || !metaObject.hasOwnProperty(contentKeyName) || typeof metaObject[metaTemplateKeyName] === 'undefined') {
return result.meta[metaKey]
+128 -121
View File
@@ -3,7 +3,7 @@ import isPlainObject from 'lodash.isplainobject'
import isArray from './isArray'
import getComponentOption from './getComponentOption'
const escapeHTML = (str) => typeof window === 'undefined'
const escapeHTML = str => typeof window === 'undefined'
// server-side escape sequence
? String(str)
.replace(/&/g, '&amp;')
@@ -19,138 +19,145 @@ const escapeHTML = (str) => typeof window === 'undefined'
.replace(/"/g, '\u0022')
.replace(/'/g, '\u0027')
export default function _getMetaInfo (options = {}) {
const { keyName, tagIDKeyName, metaTemplateKeyName, contentKeyName } = options
/**
* 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: {}
}
const applyTemplate = (component, template, chunk) =>
typeof template === 'function' ? template.call(component, chunk) : template.replace(/%s/g, chunk)
// collect & aggregate all metaInfo $options
let info = getComponentOption({
component,
option: keyName,
deep: true,
metaTemplateKeyName,
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 (let targetIndex in target) {
const targetItem = target[targetIndex]
let shared = false
for (let 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 template defined in child but content in parent
if (targetTemplate && sourceTemplate && !sourceItem[contentKeyName]) {
sourceItem[contentKeyName] = applyTemplate(component)(sourceTemplate)(targetItem[contentKeyName])
delete sourceItem[metaTemplateKeyName]
}
shared = true
break
/**
* 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
*/
export default function getMetaInfo({ keyName, tagIDKeyName, metaTemplateKeyName, contentKeyName } = {}, 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({
deep: true,
component,
keyName,
metaTemplateKeyName,
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) {
destination.push(targetItem)
// If template defined in child but content in parent
if (targetTemplate && sourceTemplate && !sourceItem[contentKeyName]) {
sourceItem[contentKeyName] = applyTemplate(component, sourceTemplate, targetItem[contentKeyName])
delete sourceItem[metaTemplateKeyName]
}
shared = true
break
}
}
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 {
escaped[key] = val
if (!shared) {
destination.push(targetItem)
}
}
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 {
escaped[key] = val
}
} else {
escaped[key] = val
}
return escaped
}, {})
return escaped
}, {})
// merge with defaults
info = deepmerge(defaultInfo, info)
// merge with defaults
info = deepmerge(defaultInfo, info)
// begin sanitization
info = escape(info)
// begin sanitization
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
* @return {Boolean} - true if `arr` is an array
*/
export default function isArray (arr) {
export default function isArray(arr) {
return Array.isArray
? Array.isArray(arr)
: 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 $meta from './$meta'
import {
VUE_META_KEY_NAME,
@@ -19,7 +18,7 @@ if (typeof window !== 'undefined' && typeof window.Vue !== 'undefined') {
* Plugin install function.
* @param {Function} Vue - the Vue constructor.
*/
export default function VueMeta (Vue, options = {}) {
export default function VueMeta(Vue, options = {}) {
// set some default options
const defaultOptions = {
keyName: VUE_META_KEY_NAME,
@@ -29,8 +28,15 @@ export default function VueMeta (Vue, options = {}) {
ssrAttribute: VUE_META_SERVER_RENDERED_ATTRIBUTE,
tagIDKeyName: VUE_META_TAG_LIST_ID_KEY_NAME
}
// 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
Vue.prototype.$meta = $meta(options)
@@ -38,66 +44,115 @@ export default function VueMeta (Vue, options = {}) {
// store an id to keep track of DOM updates
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
Vue.mixin({
beforeCreate () {
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') {
if (typeof this.$options[options.keyName] !== 'undefined' && this.$options[options.keyName] !== null) {
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 = {}
// 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]
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
// 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())
})
this.$watch('$metaInfo', () => triggerUpdate(this))
}
},
activated () {
activated() {
if (this._hasMetaInfo) {
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => this.$meta().refresh())
triggerUpdate(this)
}
},
deactivated () {
deactivated() {
if (this._hasMetaInfo) {
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => this.$meta().refresh())
triggerUpdate(this)
}
},
beforeMount () {
// batch potential DOM updates to prevent extraneous re-rendering
beforeMount() {
if (this._hasMetaInfo) {
batchID = batchUpdate(batchID, () => this.$meta().refresh())
triggerUpdate(this)
}
},
destroyed () {
destroyed() {
// 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
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
if (this.$el && this.$el.offsetParent !== null) {
return
}
clearInterval(interval)
if (!this.$parent) return
batchID = batchUpdate(batchID, () => this.$meta().refresh())
if (!this.$parent) {
return
}
triggerUpdate(this)
}, 50)
}
}
}/**/
})
}
+1 -1
View File
@@ -1,4 +1,4 @@
export default function uniqBy (inputArray, predicate) {
export default function uniqBy(inputArray, predicate) {
return inputArray
.filter((x, i, arr) => i === arr.length - 1
? 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 {
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'
import { defaultOptions, loadVueMetaPlugin } from './utils'
// set some default options
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)
const getMetaInfo = component => _getMetaInfo(defaultOptions, component)
describe('getMetaInfo', () => {
// const container = document.createElement('div')
let component
let Vue
afterEach(() => component.$destroy())
beforeAll(() => (Vue = loadVueMetaPlugin()))
it('returns appropriate defaults when no meta info is found', () => {
component = new Vue()
expect(getMetaInfo(component)).to.eql({
test('returns appropriate defaults when no meta info is found', () => {
const component = new Vue()
expect(getMetaInfo(component)).toEqual({
title: '',
titleChunk: '',
titleTemplate: '%s',
@@ -47,8 +29,8 @@ describe('getMetaInfo', () => {
})
})
it('returns metaInfo when used in component', () => {
component = new Vue({
test('returns metaInfo when used in component', () => {
const component = new Vue({
metaInfo: {
title: 'Hello',
meta: [
@@ -56,7 +38,8 @@ describe('getMetaInfo', () => {
]
}
})
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello',
titleChunk: 'Hello',
titleTemplate: '%s',
@@ -75,8 +58,37 @@ describe('getMetaInfo', () => {
__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: {
title: 'Hello',
meta: [
@@ -93,7 +105,8 @@ describe('getMetaInfo', () => {
]
}
})
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello',
titleChunk: 'Hello',
titleTemplate: '%s',
@@ -117,8 +130,8 @@ describe('getMetaInfo', () => {
})
})
it('properly uses string titleTemplates', () => {
component = new Vue({
test('properly uses string titleTemplates', () => {
const component = new Vue({
metaInfo: {
title: 'Hello',
titleTemplate: '%s World',
@@ -127,7 +140,8 @@ describe('getMetaInfo', () => {
]
}
})
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello World',
titleChunk: 'Hello',
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`
component = new Vue({
const component = new Vue({
metaInfo: {
title: 'Hello',
titleTemplate,
@@ -159,7 +173,8 @@ describe('getMetaInfo', () => {
]
}
})
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello Function World',
titleChunk: 'Hello',
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) {
return `${chunk} ${this.helloWorldText}`
}
component = new Vue({
const component = new Vue({
metaInfo: {
title: 'Hello',
titleTemplate,
@@ -192,13 +207,14 @@ describe('getMetaInfo', () => {
{ charset: 'utf-8' }
]
},
data () {
data() {
return {
helloWorldText: 'Function World'
}
}
})
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello Function World',
titleChunk: 'Hello',
titleTemplate,
@@ -218,8 +234,8 @@ describe('getMetaInfo', () => {
})
})
it('properly uses string meta templates', () => {
component = new Vue({
test('properly uses string meta templates', () => {
const component = new Vue({
metaInfo: {
title: 'Hello',
meta: [
@@ -232,7 +248,8 @@ describe('getMetaInfo', () => {
]
}
})
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello',
titleChunk: 'Hello',
titleTemplate: '%s',
@@ -256,8 +273,8 @@ describe('getMetaInfo', () => {
})
})
it('properly uses function meta templates', () => {
component = new Vue({
test('properly uses function meta templates', () => {
const component = new Vue({
metaInfo: {
title: 'Hello',
meta: [
@@ -270,7 +287,8 @@ describe('getMetaInfo', () => {
]
}
})
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello',
titleChunk: 'Hello',
titleTemplate: '%s',
@@ -294,8 +312,8 @@ describe('getMetaInfo', () => {
})
})
it('properly uses content only if template is not defined', () => {
component = new Vue({
test('properly uses content only if template is not defined', () => {
const component = new Vue({
metaInfo: {
title: 'Hello',
meta: [
@@ -307,7 +325,8 @@ describe('getMetaInfo', () => {
]
}
})
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello',
titleChunk: 'Hello',
titleTemplate: '%s',
@@ -331,8 +350,8 @@ describe('getMetaInfo', () => {
})
})
it('properly uses content only if template is null', () => {
component = new Vue({
test('properly uses content only if template is null', () => {
const component = new Vue({
metaInfo: {
title: 'Hello',
meta: [
@@ -345,7 +364,8 @@ describe('getMetaInfo', () => {
]
}
})
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello',
titleChunk: 'Hello',
titleTemplate: '%s',
@@ -369,8 +389,8 @@ describe('getMetaInfo', () => {
})
})
it('properly uses content only if template is false', () => {
component = new Vue({
test('properly uses content only if template is false', () => {
const component = new Vue({
metaInfo: {
title: 'Hello',
meta: [
@@ -383,7 +403,8 @@ describe('getMetaInfo', () => {
]
}
})
expect(getMetaInfo(component)).to.eql({
expect(getMetaInfo(component)).toEqual({
title: 'Hello',
titleChunk: 'Hello',
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', {
template: '<div></div>',
render: h => h('div'),
metaInfo: {
title: 'Hello',
meta: [
@@ -422,7 +443,7 @@ describe('getMetaInfo', () => {
}
})
component = new Vue({
const component = new Vue({
metaInfo: {
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',
titleChunk: 'Hello',
titleTemplate: '%s',
@@ -463,9 +484,9 @@ describe('getMetaInfo', () => {
// 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', {
template: '<div></div>',
render: h => h('div'),
metaInfo: {
title: 'Hello',
meta: [
@@ -478,7 +499,7 @@ describe('getMetaInfo', () => {
}
})
component = new Vue({
const component = new Vue({
metaInfo: {
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',
titleChunk: 'Hello',
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', {
template: '<div></div>',
render: h => h('div'),
metaInfo: {
title: 'Hello',
meta: [
@@ -533,7 +554,7 @@ describe('getMetaInfo', () => {
}
})
component = new Vue({
const component = new Vue({
metaInfo: {
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',
titleChunk: 'Hello',
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()