2
0
mirror of https://github.com/tenrok/vue-meta.git synced 2026-06-16 16:10:33 +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
+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