From 0d08f0dba92dfa524e779fe392150167913b6a55 Mon Sep 17 00:00:00 2001 From: Declan de Wet Date: Tue, 1 Nov 2016 19:18:58 +0200 Subject: [PATCH] refactor code to be more modular & performant --- .editorconfig | 4 +- README.md | 2 +- package.json | 6 +- src/$meta.js | 11 ++ src/generateServerInjector.js | 34 +++++ src/getComponentOption.js | 50 ++++++++ src/getMetaInfo.js | 35 ++++++ src/index.js | 224 +++------------------------------ src/inject.js | 20 +++ src/updateClientMetaInfo.js | 16 +++ src/updateHtmlTagAttributes.js | 35 ++++++ src/updateTitleTag.js | 8 ++ 12 files changed, 236 insertions(+), 209 deletions(-) create mode 100644 src/$meta.js create mode 100644 src/generateServerInjector.js create mode 100644 src/getComponentOption.js create mode 100644 src/getMetaInfo.js create mode 100644 src/inject.js create mode 100644 src/updateClientMetaInfo.js create mode 100644 src/updateHtmlTagAttributes.js create mode 100644 src/updateTitleTag.js diff --git a/.editorconfig b/.editorconfig index fec6292..4a7ea30 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,8 +5,8 @@ indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 -trim_trailing_whitespace = false +trim_trailing_whitespace = true insert_final_newline = true [*.md] -trim_trailing_whitespace = false \ No newline at end of file +trim_trailing_whitespace = false diff --git a/README.md b/README.md index 0b8aaf1..7bb0f5a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- vue-meta + vue-meta

diff --git a/package.json b/package.json index 9aef143..e21363f 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,11 @@ "define", "describe", "it", - "expect" + "expect", + "before", + "beforeEach", + "after", + "afterEach" ] } } diff --git a/src/$meta.js b/src/$meta.js new file mode 100644 index 0000000..908a537 --- /dev/null +++ b/src/$meta.js @@ -0,0 +1,11 @@ +import inject from './inject' + +/** + * Returns an injector for server-side rendering. + * @this {Object} - the Vue instance (a root component) + * @return {Object} - injector + */ +export default function $meta () { + // bind inject method to this component + return { inject: inject.bind(this) } +} diff --git a/src/generateServerInjector.js b/src/generateServerInjector.js new file mode 100644 index 0000000..d303812 --- /dev/null +++ b/src/generateServerInjector.js @@ -0,0 +1,34 @@ +import { VUE_META_ATTRIBUTE } from './constants' + +/** + * 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)} data - the data value + * @return {Object} - the new injector + */ +export default function generateServerInjector (type, data) { + console.log('server injector called for', type, 'with', data) + switch (type) { + case 'title': + return { + toString: () => `<${type} ${VUE_META_ATTRIBUTE}="true">${data}` + } + case 'htmlAttrs': { + return { + toString () { + 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 += `${VUE_META_ATTRIBUTE}="${watchedAttrs.join(',')}"` + return attributeStr.trim() + } + } + } + } +} diff --git a/src/getComponentOption.js b/src/getComponentOption.js new file mode 100644 index 0000000..7c7274e --- /dev/null +++ b/src/getComponentOption.js @@ -0,0 +1,50 @@ +import deepmerge from 'deepmerge' + +/** + * Returns the `opts.option` $option value of the given `opts.component`. + * If methods are encountered, they will be bound to the component context. + * If `opts.deep` is true, will recursively merge all child component + * `opts.option` $option values into the returned result. + * + * @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 {Object} [result={}] - result so far + * @return {Object} - final aggregated result + */ +export default function getComponentOption (opts, result = {}) { + const { component, option, deep } = opts + const { $options } = component + + // only collect option data if it exists + if ($options[option]) { + const data = $options[option] + + // TODO: check data is plain object, throw if not + + // bind context of option methods (if any) to this component + for (const key in data) { + if (data.hasOwnProperty(key)) { + const value = data[key] + if (typeof value === 'function') { + data[key] = value.bind(component) + } + } + } + + // merge with existing options + result = deepmerge(result, data) + } + + // collect & aggregate child options if deep = true + if (deep) { + const { $children } = component + for (let i = 0, len = $children.length; i < len; i++) { + const component = $children[i] + result = getComponentOption({ option, deep, component }, result) + } + } + + return result +} diff --git a/src/getMetaInfo.js b/src/getMetaInfo.js new file mode 100644 index 0000000..46a6836 --- /dev/null +++ b/src/getMetaInfo.js @@ -0,0 +1,35 @@ +import getComponentOption from './getComponentOption' + +/** + * 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 (component) { + // collect & aggregate all metaInfo $options + const info = getComponentOption({ component, option: 'metaInfo', deep: true }) + + // if any info options are a function, coerce them to the result of a call + for (let key in info) { + if (info.hasOwnProperty(key)) { + const value = info[key] + if (typeof value === 'function') { + info[key] = value() + } + } + } + + // backup the title chunk + if (info.title) { + info.titleChunk = info.title + } + + // replace title with populated template + if (info.titleTemplate && info.titleChunk) { + info.title = info.titleTemplate.replace(/%s/g, info.titleChunk) + } + + return info +} diff --git a/src/index.js b/src/index.js index c399534..6cb0f56 100644 --- a/src/index.js +++ b/src/index.js @@ -1,210 +1,24 @@ -import deepMerge from 'deepmerge' -import { VUE_META_ATTRIBUTE } from './constants' +import $meta from './$meta' +import getMetaInfo from './getMetaInfo' +import updateClientMetaInfo from './updateClientMetaInfo' -// initialize vue-meta -const VueMeta = {} - -// initialize manager -const _manager = {} - -/** - * Registers the plugin with Vue.js - * Pass it like so: Vue.use(VueMeta) - * @param {Function} Vue - the Vue constructor - */ -VueMeta.install = function install (Vue) { - // if we've already installed, don't do anything - if (VueMeta.install.installed) return - - // set installation inspection flag - VueMeta.install.installed = true - - // listen for when components mount - when they do, - // update the meta info & the DOM - Vue.mixin({ - mounted () { - this.$root.$meta().updateMetaInfo() - } - }) - - /** - * returns a cached manager API for use on the server - * @return {Object} - manager (The programmatic API for this module) - */ - Vue.prototype.$meta = function $meta () { - _manager.getMetaInfo = _manager.getMetaInfo || Vue.util.bind(getMetaInfo, this) - _manager.updateMetaInfo = _manager.updateMetaInfo || updateMetaInfo - _manager.inject = _manager.inject || inject - return _manager - } - - /** - * Converts the state of the meta info object such that each item - * can be compiled to a tag string on the server - * @return {Object} - server meta info with `toString` methods - */ - function inject () { - const info = this.getMetaInfo() - const serverMetaInfo = {} - for (let key in info) { - if (info.hasOwnProperty(key)) { - serverMetaInfo[key] = generateServerInjector(key, info[key]) - } - } - return serverMetaInfo - } - - /** - * 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)} data - the data value - * @return {Object} - the new injector - */ - function generateServerInjector (type, data) { - switch (type) { - case 'title': - return { - toString: () => `<${type} ${VUE_META_ATTRIBUTE}="true">${data}` - } - case 'htmlAttrs': { - return { - toString () { - 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 += `${VUE_META_ATTRIBUTE}="${watchedAttrs.join(',')}"` - return attributeStr.trim() - } - } - } - } - } - - /** - * Updates meta info and renders it to the DOM - */ - function updateMetaInfo () { - const newMeta = this.getMetaInfo() - if (newMeta.title) { - updateTitle(newMeta.title) - } - if (newMeta.htmlAttrs) { - updateHtmlAttrs(newMeta.htmlAttrs) - } - } - - /** - * Fetches corresponding meta info for the current component state - * @return {Object} - all the meta info for currently matched components - */ - function getMetaInfo () { - const info = getMetaInfoDefinition(Vue, this) - if (info.titleTemplate) { - info.title = info.titleTemplate.replace('%s', info.title) - } - return info - } -} - -/** - * Recursively traverses each component, checking for a `metaInfo` - * option. It then merges all these options into one object, giving - * higher priority to deeply nested components. - * - * NOTE: This function uses Vue.prototype.$children, the results of which - * are not gauranted to be in order. For this reason, try to avoid - * using the same `metaInfo` property in sibling components. - * - * @param {Function} Vue - the Vue constructor - * @param {Object} $instance - the current instance - * @param {Object} [metaInfo={}] - the merged options - * @return {Object} metaInfo - the merged options - */ -function getMetaInfoDefinition (Vue, $instance, metaInfo = { - title: '', - htmlAttrs: {} -}) { - // if current instance has a metaInfo option... - if ($instance.$options.metaInfo) { - const componentMetaInfo = $instance.$options.metaInfo - - // ...convert all function type keys to raw data - // (this allows meta info to be inferred from props & data)... - for (let key in componentMetaInfo) { - if (componentMetaInfo.hasOwnProperty(key)) { - const val = componentMetaInfo[key] - if (typeof val === 'function') { - componentMetaInfo[key] = val.call($instance) - } - } - } - - // ...then merge the data into metaInfo - metaInfo = deepMerge(metaInfo, componentMetaInfo) - } - - // check if any children also have a metaInfo option, if so, merge - // them into existing data - const len = $instance.$children.length - if (len) { - for (let i = 0; i < len; i++) { - metaInfo = getMetaInfoDefinition(Vue, $instance.$children[i], metaInfo) - } - } - - // meta info is ready for consumption - return metaInfo -} - -/** - * updates the document title - * @param {String} title - the new title of the document - */ -function updateTitle (title) { - document.title = title || document.title -} - -/** - * updates the document's html tag attributes - * @param {Object} attrs - the new document html attributes - */ -function updateHtmlAttrs (attrs) { - const tag = document.getElementsByTagName('html')[0] - const vueMetaAttrString = tag.getAttribute(VUE_META_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(VUE_META_ATTRIBUTE) - } else { - tag.setAttribute(VUE_META_ATTRIBUTE, vueMetaAttrs.join(',')) - } -} - -// automatic installation when global context +// automatic install if (typeof Vue !== 'undefined') { Vue.use(VueMeta) } -export default VueMeta +/** + * Plugin install function. + * @param {Function} Vue - the Vue constructor. + */ +export default function VueMeta (Vue) { + // bind the $meta method to this component instance + Vue.prototype.$meta = $meta + + // watch for client side updates + Vue.mixin({ + mounted () { + updateClientMetaInfo(getMetaInfo(this.$root)) + } + }) +} diff --git a/src/inject.js b/src/inject.js new file mode 100644 index 0000000..a95e465 --- /dev/null +++ b/src/inject.js @@ -0,0 +1,20 @@ +import getMetaInfo from './getMetaInfo' +import generateServerInjector from './generateServerInjector' + +/** + * Converts the state of the meta info object such that each item + * can be compiled to a tag string on the server + * + * @this {Object} - Vue instance - ideally the root component + * @return {Object} - server meta info with `toString` methods + */ +export default function inject () { + const info = getMetaInfo(this.$root) + const serverMetaInfo = {} + for (let key in info) { + if (info.hasOwnProperty(key) && key !== 'titleTemplate') { + serverMetaInfo[key] = generateServerInjector(key, info[key]) + } + } + return serverMetaInfo +} diff --git a/src/updateClientMetaInfo.js b/src/updateClientMetaInfo.js new file mode 100644 index 0000000..618a966 --- /dev/null +++ b/src/updateClientMetaInfo.js @@ -0,0 +1,16 @@ +import updateTitleTag from './updateTitleTag' +import updateHtmlTagAttributes from './updateHtmlTagAttributes' + +/** + * Performs client-side updates when new meta info is received + * + * @param {Object} newInfo - the meta info to update to + */ +export default function updateClientMetaInfo (newInfo) { + if (newInfo.title) { + updateTitleTag(newInfo.title) + } + if (newInfo.htmlAttrs) { + updateHtmlTagAttributes(newInfo.htmlAttrs) + } +} diff --git a/src/updateHtmlTagAttributes.js b/src/updateHtmlTagAttributes.js new file mode 100644 index 0000000..bce573a --- /dev/null +++ b/src/updateHtmlTagAttributes.js @@ -0,0 +1,35 @@ +import { VUE_META_ATTRIBUTE } from './constants' + +/** + * updates the document's html tag attributes + * + * @param {Object} attrs - the new document html attributes + */ +export default function updateHtmlTagAttributes (attrs) { + const tag = document.getElementsByTagName('html')[0] + const vueMetaAttrString = tag.getAttribute(VUE_META_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(VUE_META_ATTRIBUTE) + } else { + tag.setAttribute(VUE_META_ATTRIBUTE, vueMetaAttrs.join(',')) + } +} diff --git a/src/updateTitleTag.js b/src/updateTitleTag.js new file mode 100644 index 0000000..f4fc9ed --- /dev/null +++ b/src/updateTitleTag.js @@ -0,0 +1,8 @@ +/** + * updates the document title + * + * @param {String} title - the new title of the document + */ +export default function updateTitleTag (title = document.title) { + document.title = title +}