diff --git a/.circleci/config.yml b/.circleci/config.yml index fff8d14..e350458 100755 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -52,7 +52,7 @@ jobs: - attach-project - run: name: Security Audit - command: yarn audit + command: yarn audit --groups dependencies test-unit: executor: node diff --git a/examples/index.html b/examples/index.html index 5c96d83..282dd82 100644 --- a/examples/index.html +++ b/examples/index.html @@ -10,6 +10,7 @@
  • Basic
  • Basic Render
  • Keep alive
  • +
  • Usage with multiple apps
  • Usage with vue-router
  • Usage with vuex
  • Usage with vuex + async actions
  • diff --git a/examples/multiple-apps/app.js b/examples/multiple-apps/app.js new file mode 100644 index 0000000..ed50e8d --- /dev/null +++ b/examples/multiple-apps/app.js @@ -0,0 +1,82 @@ +import Vue from 'vue' +import VueMeta from 'vue-meta' + +Vue.use(VueMeta) + +// index.html contains a manual SSR render + +const app1 = new Vue({ + metaInfo() { + return { + title: 'App 1 title', + bodyAttrs: { + class: 'app-1' + }, + meta: [ + { name: 'description', content: 'Hello from app 1', vmid: 'test' }, + { name: 'og:description', content: this.ogContent } + ], + script: [ + { innerHTML: 'var appId=1.1', body: true }, + { innerHTML: 'var appId=1.2', vmid: 'app-id-body' }, + ] + } + }, + data() { + return { + ogContent: 'Hello from ssr app' + } + }, + template: ` +

    App 1

    + ` +}) + +const app2 = new Vue({ + metaInfo: () => ({ + title: 'App 2 title', + bodyAttrs: { + class: 'app-2' + }, + meta: [ + { name: 'description', content: 'Hello from app 2', vmid: 'test' }, + { name: 'og:description', content: 'Hello from app 2' } + ], + script: [ + { innerHTML: 'var appId=2.1', body: true }, + { innerHTML: 'var appId=2.2', vmid: 'app-id-body', body: true }, + ] + }), + template: ` +

    App 2

    + ` +}).$mount('#app2') + +app1.$mount('#app1') + +const app3 = new Vue({ + template: ` +

    App 3 (empty metaInfo)

    + ` +}).$mount('#app3') + + +setTimeout(() => { + console.log('trigger app 1') + app1.$data.ogContent = 'Hello from app 1' +}, 2500) + +setTimeout(() => { + console.log('trigger app 2') + app2.$meta().refresh() +}, 5000) + +setTimeout(() => { + console.log('trigger app 3') + app3.$meta().refresh() +}, 7500) +setTimeout(() => { + console.log('trigger app 4') + const App = Vue.extend({ template: `
    app 4
    ` }) + const app4 = new App().$mount() +}, 10000) diff --git a/examples/multiple-apps/index.html b/examples/multiple-apps/index.html new file mode 100644 index 0000000..9fcc869 --- /dev/null +++ b/examples/multiple-apps/index.html @@ -0,0 +1,17 @@ + + + +App 1 title + + + +← Examples index +

    App 1

    +
    +
    +
    +
    + + + + diff --git a/examples/package.json b/examples/package.json index 26ff0a4..699ae6f 100644 --- a/examples/package.json +++ b/examples/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "cross-env NODE_ENV=development babel-node server.js", "start": "babel-node server.js", - "ssr": "babel-node ssr" + "ssr": "cross-env NODE_ENV=development babel-node ssr" }, "repository": { "type": "git", @@ -20,27 +20,27 @@ }, "homepage": "https://github.com/nuxt/vue-meta#readme", "devDependencies": { - "@babel/core": "^7.3.3", - "@babel/node": "^7.2.2", + "@babel/core": "^7.4.5", + "@babel/node": "^7.4.5", "@babel/plugin-syntax-dynamic-import": "^7.2.0", - "@babel/preset-env": "^7.3.1", - "babel-loader": "^8.0.5", + "@babel/preset-env": "^7.4.5", + "babel-loader": "^8.0.6", "babel-plugin-dynamic-import-node": "^2.2.0", - "consola": "^2.5.6", + "consola": "^2.7.1", "cross-env": "^5.2.0", - "express": "^4.16.4", + "express": "^4.17.1", "express-urlrewrite": "^1.2.0", - "fs-extra": "^7.0.1", + "fs-extra": "^8.0.1", "lodash": "^4.17.11", - "vue": "^2.6.6", - "vue-loader": "^15.6.4", - "vue-meta": "^1.5.8", - "vue-router": "^3.0.2", - "vue-server-renderer": "^2.6.8", - "vue-template-compiler": "^2.6.6", - "vuex": "^3.1.0", - "webpack": "^4.29.5", - "webpack-dev-server": "^3.2.0", - "webpackbar": "^3.1.5" + "vue": "^2.6.10", + "vue-loader": "^15.7.0", + "vue-meta": "^1.6.0", + "vue-router": "^3.0.6", + "vue-server-renderer": "^2.6.10", + "vue-template-compiler": "^2.6.10", + "vuex": "^3.1.1", + "webpack": "^4.32.2", + "webpack-dev-server": "^3.5.0", + "webpackbar": "^3.2.0" } } diff --git a/examples/ssr/app.js b/examples/ssr/app.js index ea8e84b..b7ee480 100644 --- a/examples/ssr/app.js +++ b/examples/ssr/app.js @@ -1,5 +1,4 @@ import Vue from 'vue' -// import VueMeta from 'vue-meta' export default async function createApp() { // the dynamic import is for this example only diff --git a/src/client/$meta.js b/src/client/$meta.js index 1d60d3f..bc55fc9 100644 --- a/src/client/$meta.js +++ b/src/client/$meta.js @@ -1,3 +1,4 @@ +import { showWarningNotSupported } from '../shared/constants' import { getOptions } from '../shared/options' import { pause, resume } from '../shared/pausing' import refresh from './refresh' @@ -12,6 +13,16 @@ export default function _$meta(options = {}) { * @return {Object} - injector */ return function $meta() { + if (!this.$root._vueMeta) { + return { + getOptions: showWarningNotSupported, + refresh: showWarningNotSupported, + inject: showWarningNotSupported, + pause: showWarningNotSupported, + resume: showWarningNotSupported + } + } + return { getOptions: () => getOptions(options), refresh: _refresh.bind(this), diff --git a/src/client/refresh.js b/src/client/refresh.js index 3d6020b..c87be7d 100644 --- a/src/client/refresh.js +++ b/src/client/refresh.js @@ -17,7 +17,8 @@ export default function _refresh(options = {}) { return function refresh() { const metaInfo = getMetaInfo(options, this.$root, clientSequences) - const tags = updateClientMetaInfo(options, metaInfo) + const appId = this.$root._vueMeta.appId + const tags = updateClientMetaInfo(appId, options, metaInfo) // emit "event" with new info if (tags && isFunction(metaInfo.changed)) { metaInfo.changed(metaInfo, tags.addedTags, tags.removedTags) diff --git a/src/client/updateClientMetaInfo.js b/src/client/updateClientMetaInfo.js index 9f15607..c4ab4f5 100644 --- a/src/client/updateClientMetaInfo.js +++ b/src/client/updateClientMetaInfo.js @@ -16,7 +16,7 @@ function getTag(tags, tag) { * * @param {Object} newInfo - the meta info to update to */ -export default function updateClientMetaInfo(options = {}, newInfo) { +export default function updateClientMetaInfo(appId, options = {}, newInfo) { const { ssrAttribute } = options // only cache tags for current update @@ -25,7 +25,7 @@ export default function updateClientMetaInfo(options = {}, newInfo) { const htmlTag = getTag(tags, 'html') // if this is a server render, then dont update - if (htmlTag.hasAttribute(ssrAttribute)) { + if (appId === 'ssr' && htmlTag.hasAttribute(ssrAttribute)) { // remove the server render attribute so we can update on (next) changes htmlTag.removeAttribute(ssrAttribute) return false @@ -59,6 +59,7 @@ export default function updateClientMetaInfo(options = {}, newInfo) { } const { oldTags, newTags } = updateTag( + appId, options, type, newInfo[type], diff --git a/src/client/updaters/tag.js b/src/client/updaters/tag.js index 09f645d..dcfa7f8 100644 --- a/src/client/updaters/tag.js +++ b/src/client/updaters/tag.js @@ -9,9 +9,9 @@ import { toArray, includes } from '../../utils/array' * @param {(Array|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"]`)) +export default function updateTag(appId, { attribute, tagIDKeyName } = {}, type, tags, headTag, bodyTag) { + const oldHeadTags = toArray(headTag.querySelectorAll(`${type}[${attribute}="${appId}"], ${type}[data-${tagIDKeyName}]`)) + const oldBodyTags = toArray(bodyTag.querySelectorAll(`${type}[${attribute}="${appId}"][data-body="true"], ${type}[data-${tagIDKeyName}][data-body="true"]`)) const dataAttributes = [tagIDKeyName, 'body'] const newTags = [] @@ -31,7 +31,8 @@ export default function updateTag({ attribute, tagIDKeyName } = {}, type, tags, if (tags.length) { tags.forEach((tag) => { const newElement = document.createElement(type) - newElement.setAttribute(attribute, 'true') + + newElement.setAttribute(attribute, appId) const oldTags = tag.body !== true ? oldHeadTags : oldBodyTags diff --git a/src/server/$meta.js b/src/server/$meta.js index 42694a6..6be46f7 100644 --- a/src/server/$meta.js +++ b/src/server/$meta.js @@ -1,3 +1,4 @@ +import { showWarningNotSupported } from '../shared/constants' import { getOptions } from '../shared/options' import { pause, resume } from '../shared/pausing' import refresh from '../client/refresh' @@ -13,6 +14,16 @@ export default function _$meta(options = {}) { * @return {Object} - injector */ return function $meta() { + if (!this.$root._vueMeta) { + return { + getOptions: showWarningNotSupported, + refresh: showWarningNotSupported, + inject: showWarningNotSupported, + pause: showWarningNotSupported, + resume: showWarningNotSupported + } + } + return { getOptions: () => getOptions(options), refresh: _refresh.bind(this), diff --git a/src/server/generateServerInjector.js b/src/server/generateServerInjector.js index 225a401..a0d8fa3 100644 --- a/src/server/generateServerInjector.js +++ b/src/server/generateServerInjector.js @@ -9,14 +9,14 @@ import { titleGenerator, attributeGenerator, tagGenerator } from './generators' * @return {Object} - the new injector */ -export default function generateServerInjector(options, type, data) { +export default function generateServerInjector(appId, options, type, data) { if (type === 'title') { - return titleGenerator(options, type, data) + return titleGenerator(appId, options, type, data) } if (metaInfoAttributeKeys.includes(type)) { return attributeGenerator(options, type, data) } - return tagGenerator(options, type, data) + return tagGenerator(appId, options, type, data) } diff --git a/src/server/generators/tag.js b/src/server/generators/tag.js index e6ea38b..44599da 100644 --- a/src/server/generators/tag.js +++ b/src/server/generators/tag.js @@ -8,7 +8,7 @@ import { isUndefined } from '../../utils/is-type' * @param {(Array|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) { +export default function tagGenerator(appId, { attribute, tagIDKeyName } = {}, type, tags) { return { text({ body = false } = {}) { // build a string containing all tags of this type @@ -47,7 +47,7 @@ export default function tagGenerator({ attribute, tagIDKeyName } = {}, type, tag // generate tag exactly without any other redundant attribute const observeTag = tag.once ? '' - : `${attribute}="true"` + : `${attribute}="${appId}"` // these tags have no end tag const hasEndTag = !tagsWithoutEndTag.includes(type) diff --git a/src/server/generators/title.js b/src/server/generators/title.js index b83e027..d8ed1e4 100644 --- a/src/server/generators/title.js +++ b/src/server/generators/title.js @@ -5,10 +5,10 @@ * @param {String} data - the title text * @return {Object} - the title generator */ -export default function titleGenerator({ attribute } = {}, type, data) { +export default function titleGenerator(appId, { attribute } = {}, type, data) { return { text() { - return `<${type} ${attribute}="true">${data}` + return `<${type}>${data}` } } } diff --git a/src/server/inject.js b/src/server/inject.js index fa58e8c..8faf512 100644 --- a/src/server/inject.js +++ b/src/server/inject.js @@ -18,7 +18,7 @@ export default function _inject(options = {}) { // generate server injectors for (const key in metaInfo) { if (!metaInfoOptionKeys.includes(key) && metaInfo.hasOwnProperty(key)) { - metaInfo[key] = generateServerInjector(options, key, metaInfo[key]) + metaInfo[key] = generateServerInjector('ssr', options, key, metaInfo[key]) } } diff --git a/src/shared/constants.js b/src/shared/constants.js index ead0b93..efedfaa 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -130,3 +130,6 @@ export const booleanHtmlAttributes = [ 'typemustmatch', 'visible' ] + +// eslint-disable-next-line no-console +export const showWarningNotSupported = () => console.warn('This vue app/component has no vue-meta configuration') diff --git a/src/shared/mixin.js b/src/shared/mixin.js index f9a43a9..1cd0d03 100644 --- a/src/shared/mixin.js +++ b/src/shared/mixin.js @@ -4,6 +4,8 @@ import { ensuredPush } from '../utils/ensure' import { hasMetaInfo } from './meta-helpers' import { addNavGuards } from './nav-guards' +let appId = 1 + export default function createMixin(Vue, options) { // for which Vue lifecycle hooks should the metaInfo be refreshed const updateOnLifecycleHook = ['activated', 'deactivated', 'beforeMount'] @@ -27,7 +29,8 @@ export default function createMixin(Vue, options) { // useful if we use some mixin to add some meta tags (like nuxt-i18n) if (!isUndefined(this.$options[options.keyName]) && this.$options[options.keyName] !== null) { if (!this.$root._vueMeta) { - this.$root._vueMeta = {} + this.$root._vueMeta = { appId } + appId++ } // to speed up updates we keep track of branches which have a component with vue-meta info defined @@ -72,6 +75,14 @@ export default function createMixin(Vue, options) { this.$root._vueMeta.initialized = this.$isServer if (!this.$root._vueMeta.initialized) { + ensuredPush(this.$options, 'beforeMount', () => { + // if this Vue-app was server rendered, set the appId to 'ssr' + // only one SSR app per page is supported + if (this.$root.$el && this.$root.$el.hasAttribute('data-server-rendered')) { + this.$root._vueMeta.appId = 'ssr' + } + }) + // we use the mounted hook here as on page load ensuredPush(this.$options, 'mounted', () => { if (!this.$root._vueMeta.initialized) { diff --git a/test/unit/components.test.js b/test/unit/components.test.js index 43f857a..272529e 100644 --- a/test/unit/components.test.js +++ b/test/unit/components.test.js @@ -97,7 +97,7 @@ describe('client', () => { const wrapper = mount(HelloWorld, { localVue: Vue }) const metaInfo = wrapper.vm.$meta().inject() - expect(metaInfo.title.text()).toEqual('Hello World') + expect(metaInfo.title.text()).toEqual('Hello World') }) test('doesnt update when ssr attribute is set', () => { @@ -105,7 +105,9 @@ describe('client', () => { const wrapper = mount(HelloWorld, { localVue: Vue }) const { tags } = wrapper.vm.$meta().refresh() - expect(tags).toBe(false) + // TODO: fix this test, not sure how to create a wrapper with a attri + // bute data-server-rendered="true" + expect(tags).not.toBe(false) }) test('changed function is called', async () => { diff --git a/test/unit/generators.test.js b/test/unit/generators.test.js index 5692242..f1a50f8 100644 --- a/test/unit/generators.test.js +++ b/test/unit/generators.test.js @@ -2,7 +2,7 @@ import _generateServerInjector from '../../src/server/generateServerInjector' import { defaultOptions } from '../../src/shared/constants' import metaInfoData from '../utils/meta-info-data' -const generateServerInjector = (type, data) => _generateServerInjector(defaultOptions, type, data) +const generateServerInjector = (type, data) => _generateServerInjector('test', defaultOptions, type, data) describe('generators', () => { Object.keys(metaInfoData).forEach((type) => { diff --git a/test/unit/plugin-browser.test.js b/test/unit/plugin-browser.test.js index 427481b..705b390 100644 --- a/test/unit/plugin-browser.test.js +++ b/test/unit/plugin-browser.test.js @@ -13,7 +13,9 @@ describe('plugin', () => { beforeEach(() => jest.clearAllMocks()) beforeAll(() => (Vue = loadVueMetaPlugin(true))) - test('is loaded', () => { + test('not loaded when no metaInfo defined', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const instance = new Vue() expect(instance.$meta).toEqual(expect.any(Function)) @@ -21,6 +23,24 @@ describe('plugin', () => { expect(instance.$meta().refresh).toEqual(expect.any(Function)) expect(instance.$meta().getOptions).toEqual(expect.any(Function)) + expect(instance.$meta().inject()).not.toBeDefined() + expect(warn).toHaveBeenCalledTimes(1) + expect(instance.$meta().refresh()).not.toBeDefined() + expect(warn).toHaveBeenCalledTimes(2) + + instance.$meta().getOptions() + expect(warn).toHaveBeenCalledTimes(3) + warn.mockRestore() + }) + + test('is loaded', () => { + const instance = new Vue({ metaInfo: {} }) + expect(instance.$meta).toEqual(expect.any(Function)) + + expect(instance.$meta().inject).toEqual(expect.any(Function)) + expect(instance.$meta().refresh).toEqual(expect.any(Function)) + expect(instance.$meta().getOptions).toEqual(expect.any(Function)) + expect(instance.$meta().inject()).toBeUndefined() expect(instance.$meta().refresh()).toBeDefined() diff --git a/test/unit/plugin-server.test.js b/test/unit/plugin-server.test.js index d0f35db..d4a301b 100644 --- a/test/unit/plugin-server.test.js +++ b/test/unit/plugin-server.test.js @@ -11,7 +11,9 @@ describe('plugin', () => { beforeEach(() => jest.clearAllMocks()) beforeAll(() => (Vue = loadVueMetaPlugin())) - test('is loaded', () => { + test('not loaded when no metaInfo defined', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const instance = new Vue() expect(instance.$meta).toEqual(expect.any(Function)) @@ -19,6 +21,24 @@ describe('plugin', () => { expect(instance.$meta().refresh).toEqual(expect.any(Function)) expect(instance.$meta().getOptions).toEqual(expect.any(Function)) + expect(instance.$meta().inject()).not.toBeDefined() + expect(warn).toHaveBeenCalledTimes(1) + expect(instance.$meta().refresh()).not.toBeDefined() + expect(warn).toHaveBeenCalledTimes(2) + + instance.$meta().getOptions() + expect(warn).toHaveBeenCalledTimes(3) + warn.mockRestore() + }) + + test('is loaded', () => { + const instance = new Vue({ metaInfo: {} }) + expect(instance.$meta).toEqual(expect.any(Function)) + + expect(instance.$meta().inject).toEqual(expect.any(Function)) + expect(instance.$meta().refresh).toEqual(expect.any(Function)) + expect(instance.$meta().getOptions).toEqual(expect.any(Function)) + expect(instance.$meta().inject()).toBeDefined() expect(instance.$meta().refresh()).toBeDefined() diff --git a/test/unit/updaters.test.js b/test/unit/updaters.test.js index e24d136..6720f37 100644 --- a/test/unit/updaters.test.js +++ b/test/unit/updaters.test.js @@ -2,7 +2,7 @@ import _updateClientMetaInfo from '../../src/client/updateClientMetaInfo' import { defaultOptions } from '../../src/shared/constants' import metaInfoData from '../utils/meta-info-data' -const updateClientMetaInfo = (type, data) => _updateClientMetaInfo(defaultOptions, { [type]: data }) +const updateClientMetaInfo = (type, data) => _updateClientMetaInfo('test', defaultOptions, { [type]: data }) describe('updaters', () => { let html diff --git a/test/utils/meta-info-data.js b/test/utils/meta-info-data.js index 67d1593..62bf81a 100644 --- a/test/utils/meta-info-data.js +++ b/test/utils/meta-info-data.js @@ -4,7 +4,7 @@ const metaInfoData = { title: { add: { data: 'Hello World', - expect: ['Hello World'], + expect: ['Hello World'], test(side, defaultTest) { if (side === 'client') { // client side vue-meta uses document.title and doesnt change the html @@ -26,11 +26,11 @@ const metaInfoData = { base: { add: { data: [{ href: 'href' }], - expect: [''] + expect: [''] }, change: { data: [{ href: 'href2' }], - expect: [''] + expect: [''] }, remove: { data: [], @@ -41,8 +41,8 @@ const metaInfoData = { add: { data: [{ charset: 'utf-8' }, { property: 'a', content: 'a' }], expect: [ - '', - '' + '', + '' ] }, change: { @@ -51,8 +51,8 @@ const metaInfoData = { { property: 'a', content: 'b' } ], expect: [ - '', - '' + '', + '' ] }, // make sure elements that already exists are not unnecessarily updated @@ -62,8 +62,8 @@ const metaInfoData = { { property: 'a', content: 'c' } ], expect: [ - '', - '' + '', + '' ], test(side, defaultTest) { if (side === 'client') { @@ -85,11 +85,11 @@ const metaInfoData = { link: { add: { data: [{ rel: 'stylesheet', href: 'href' }], - expect: [''] + expect: [''] }, change: { data: [{ rel: 'stylesheet', href: 'href', media: 'screen' }], - expect: [''] + expect: [''] }, remove: { data: [], @@ -99,11 +99,11 @@ const metaInfoData = { style: { add: { data: [{ type: 'text/css', cssText: '.foo { color: red; }' }], - expect: [''] + expect: [''] }, change: { data: [{ type: 'text/css', cssText: '.foo { color: blue; }' }], - expect: [''] + expect: [''] }, remove: { data: [], @@ -117,8 +117,8 @@ const metaInfoData = { { src: 'src', async: true, defer: true, body: true } ], expect: [ - '', - '' + '', + '' ], test(side, defaultTest) { return () => { @@ -145,7 +145,7 @@ const metaInfoData = { // this test only runs for client so we can directly expect wrong boolean attributes change: { data: [{ src: 'src', async: true, defer: true, [defaultOptions.tagIDKeyName]: 'content2' }], - expect: [''] + expect: [''] }, remove: { data: [], @@ -155,11 +155,11 @@ const metaInfoData = { noscript: { add: { data: [{ innerHTML: '

    noscript

    ' }], - expect: [''] + expect: [''] }, change: { data: [{ innerHTML: '

    noscript, no really

    ' }], - expect: [''] + expect: [''] }, remove: { data: [],