2
0
mirror of https://github.com/tenrok/vue-meta.git synced 2026-06-23 15:20:34 +03:00

feat: add support for setting attributes from multiple apps

chore: improve build size
This commit is contained in:
pimlie
2019-09-13 13:09:22 +02:00
committed by Pim
parent 0ab76ee16b
commit d9b0ab29da
16 changed files with 251 additions and 77 deletions
+10
View File
@@ -69,6 +69,7 @@ export default function createApp () {
return { return {
title: 'Boring Title', title: 'Boring Title',
htmlAttrs: { amp: true }, htmlAttrs: { amp: true },
bodyAttrs: { class: 'main-app' },
meta: [ meta: [
{ {
skip: this.count < 1, skip: this.count < 1,
@@ -116,6 +117,14 @@ export default function createApp () {
users: process.server ? [] : window.users users: process.server ? [] : window.users
} }
}, },
mounted() {
const { set, remove } = this.$meta().addApp('client-only')
set({
bodyAttrs: { class: 'client-only' }
})
setTimeout(() => remove(), 3000)
},
methods: { methods: {
loadCallback () { loadCallback () {
this.count++ this.count++
@@ -140,6 +149,7 @@ export default function createApp () {
const { set } = app.$meta().addApp('custom') const { set } = app.$meta().addApp('custom')
set({ set({
bodyAttrs: { class: 'custom-app' },
meta: [{ charset: 'utf-8' }] meta: [{ charset: 'utf-8' }]
}) })
+6
View File
@@ -18,6 +18,9 @@ const ChildComponent = {
metaInfo () { metaInfo () {
return { return {
title: `${this.page} - ${this.date && this.date.toTimeString()}`, title: `${this.page} - ${this.date && this.date.toTimeString()}`,
bodyAttrs: {
class: 'child-component'
},
afterNavigation () { afterNavigation () {
metaUpdated = 'yes' metaUpdated = 'yes'
} }
@@ -82,6 +85,9 @@ const app = new Vue(App)
const { set, remove } = app.$meta().addApp('custom') const { set, remove } = app.$meta().addApp('custom')
set({ set({
bodyAttrs: {
class: 'custom-app'
},
meta: [ meta: [
{ charset: 'utf=8' } { charset: 'utf=8' }
] ]
+4 -2
View File
@@ -43,13 +43,15 @@ function rollupConfig({
const isBrowserBuild = !config.output || !config.output.format || config.output.format === 'umd' || config.output.file.includes('.browser.') const isBrowserBuild = !config.output || !config.output.format || config.output.format === 'umd' || config.output.file.includes('.browser.')
const replaceConfig = { const replaceConfig = {
exclude: 'node_modules/**', exclude: 'node_modules/(?!is-mergeable-object)',
delimiters: ['', ''], delimiters: ['', ''],
values: { values: {
// replaceConfig needs to have some values // replaceConfig needs to have some values
'const polyfill = process.env.NODE_ENV === \'test\'': 'const polyfill = true', 'const polyfill = process.env.NODE_ENV === \'test\'': 'const polyfill = true',
'process.env.VERSION': `"${version}"`, 'process.env.VERSION': `"${version}"`,
'process.server' : isBrowserBuild ? 'false' : 'true' 'process.server' : isBrowserBuild ? 'false' : 'true',
// remove react stuff from is-mergeable-object
'|| isReactElement(value)': '|| false'
} }
} }
+5 -1
View File
@@ -60,7 +60,11 @@ export function addListeners () {
} }
export function applyCallbacks (matchElement) { export function applyCallbacks (matchElement) {
callbacks.forEach(([query, callback]) => { callbacks.forEach((args) => {
// do not use destructuring for args, it increases transpiled size
// due to var checks while we are guaranteed the structure of the cb
const query = args[0]
const callback = args[1]
const selector = `${query}[onload="this.__vm_l=1"]` const selector = `${query}[onload="this.__vm_l=1"]`
let elements = [] let elements = []
+1 -1
View File
@@ -56,7 +56,7 @@ export default function updateClientMetaInfo (appId, options = {}, newInfo) {
if (includes(metaInfoAttributeKeys, type)) { if (includes(metaInfoAttributeKeys, type)) {
const tagName = type.substr(0, 4) const tagName = type.substr(0, 4)
updateAttribute(options, newInfo[type], getTag(tags, tagName)) updateAttribute(appId, options, type, newInfo[type], getTag(tags, tagName))
continue continue
} }
+53 -27
View File
@@ -1,6 +1,9 @@
import { booleanHtmlAttributes } from '../../shared/constants' import { booleanHtmlAttributes } from '../../shared/constants'
import { toArray, includes } from '../../utils/array' import { includes } from '../../utils/array'
import { isArray } from '../../utils/is-type'
// keep a local map of attribute values
// instead of adding it to the html
export const attributeMap = {}
/** /**
* Updates the document's html tag attributes * Updates the document's html tag attributes
@@ -8,39 +11,62 @@ import { isArray } from '../../utils/is-type'
* @param {Object} attrs - the new document html attributes * @param {Object} attrs - the new document html attributes
* @param {HTMLElement} tag - the HTMLElement tag to update with new attrs * @param {HTMLElement} tag - the HTMLElement tag to update with new attrs
*/ */
export default function updateAttribute ({ attribute } = {}, attrs, tag) { export default function updateAttribute (appId, { attribute } = {}, type, attrs, tag) {
const vueMetaAttrString = tag.getAttribute(attribute) const vueMetaAttrString = tag.getAttribute(attribute)
const vueMetaAttrs = vueMetaAttrString ? vueMetaAttrString.split(',') : [] if (vueMetaAttrString) {
const toRemove = toArray(vueMetaAttrs) attributeMap[type] = JSON.parse(decodeURI(vueMetaAttrString))
tag.removeAttribute(attribute)
}
const keepIndexes = [] let data = attributeMap[type] || {}
for (const attr in attrs) {
if (attrs.hasOwnProperty(attr)) {
const value = includes(booleanHtmlAttributes, attr)
? ''
: isArray(attrs[attr]) ? attrs[attr].join(' ') : attrs[attr]
tag.setAttribute(attr, value || '') const toUpdate = []
if (!includes(vueMetaAttrs, attr)) { // remove attributes from the map
vueMetaAttrs.push(attr) // which have been removed for this appId
for (const attr in data) {
if (data[attr] && appId in data[attr]) {
toUpdate.push(attr)
if (!attrs[attr]) {
delete data[attr][appId]
} }
// filter below wont ever check -1
keepIndexes.push(toRemove.indexOf(attr))
} }
} }
const removedAttributesCount = toRemove for (const attr in attrs) {
.filter((el, index) => !includes(keepIndexes, index)) const attrData = data[attr]
.reduce((acc, attr) => {
tag.removeAttribute(attr)
return acc + 1
}, 0)
if (vueMetaAttrs.length === removedAttributesCount) { if (!attrData || attrData[appId] !== attrs[attr]) {
tag.removeAttribute(attribute) toUpdate.push(attr)
} else {
tag.setAttribute(attribute, (vueMetaAttrs.sort()).join(',')) if (attrs[attr]) {
data[attr] = data[attr] || {}
data[attr][appId] = attrs[attr]
} else {
delete data[attr][appId]
}
}
} }
for (const attr of toUpdate) {
const attrData = data[attr]
const attrValues = []
for (const appId in attrData) {
Array.prototype.push.apply(attrValues, [].concat(attrData[appId]))
}
if (attrValues.length) {
const attrValue = includes(booleanHtmlAttributes, attr) && attrValues.some(Boolean)
? ''
: attrValues.filter(Boolean).join(' ')
tag.setAttribute(attr, attrValue)
} else {
tag.removeAttribute(attr)
}
}
attributeMap[type] = data
} }
+20 -7
View File
@@ -47,17 +47,30 @@ export default function generateServerInjector (options, metaInfo) {
} }
if (metaInfoAttributeKeys.includes(type)) { if (metaInfoAttributeKeys.includes(type)) {
let str = attributeGenerator(options, type, serverInjector.data[type], arg) const attributeData = {}
if (serverInjector.extraData) { const data = serverInjector.data[type]
for (const appId in serverInjector.extraData) { if (data) {
const data = serverInjector.extraData[appId][type] for (const attr in data) {
const extraStr = attributeGenerator(options, type, data, arg) attributeData[attr] = {
str = `${str}${extraStr}` [options.ssrAppId]: data[attr]
}
} }
} }
return str for (const appId in serverInjector.extraData) {
const data = serverInjector.extraData[appId][type]
if (data) {
for (const attr in data) {
attributeData[attr] = {
...attributeData[attr],
[appId]: data[attr]
}
}
}
}
return attributeGenerator(options, type, attributeData, arg)
} }
let str = tagGenerator(options, type, serverInjector.data[type], arg) let str = tagGenerator(options, type, serverInjector.data[type], arg)
+11 -8
View File
@@ -1,5 +1,4 @@
import { booleanHtmlAttributes } from '../../shared/constants' import { booleanHtmlAttributes } from '../../shared/constants'
import { isUndefined, isArray } from '../../utils/is-type'
/** /**
* Generates tag attributes for use on the server. * Generates tag attributes for use on the server.
@@ -10,22 +9,26 @@ import { isUndefined, isArray } from '../../utils/is-type'
*/ */
export default function attributeGenerator ({ attribute, ssrAttribute } = {}, type, data, addSrrAttribute) { export default function attributeGenerator ({ attribute, ssrAttribute } = {}, type, data, addSrrAttribute) {
let attributeStr = '' let attributeStr = ''
const watchedAttrs = []
for (const attr in data) { for (const attr in data) {
if (data.hasOwnProperty(attr)) { const attrData = data[attr]
watchedAttrs.push(attr) const attrValues = []
attributeStr += isUndefined(data[attr]) || booleanHtmlAttributes.includes(attr) for (const appId in attrData) {
? attr attrValues.push(...[].concat(attrData[appId]))
: `${attr}="${isArray(data[attr]) ? data[attr].join(' ') : data[attr]}"` }
if (attrValues.length) {
attributeStr += booleanHtmlAttributes.includes(attr) && attrValues.some(Boolean)
? `${attr}`
: `${attr}="${attrValues.join(' ')}"`
attributeStr += ' ' attributeStr += ' '
} }
} }
if (attributeStr) { if (attributeStr) {
attributeStr += `${attribute}="${(watchedAttrs.sort()).join(',')}"` attributeStr += `${attribute}="${encodeURI(JSON.stringify(data))}"`
} }
if (type === 'htmlAttrs' && addSrrAttribute) { if (type === 'htmlAttrs' && addSrrAttribute) {
+9 -1
View File
@@ -1,5 +1,7 @@
import updateClientMetaInfo from '../client/updateClientMetaInfo' import updateClientMetaInfo from '../client/updateClientMetaInfo'
import { removeElementsByAppId } from '../utils/elements' import { updateAttribute } from '../client/updaters'
import { metaInfoAttributeKeys } from '../shared/constants'
import { getTag, removeElementsByAppId } from '../utils/elements'
let appsMetaInfo let appsMetaInfo
@@ -24,6 +26,12 @@ export function setMetaInfo (vm, appId, options, metaInfo) {
export function removeMetaInfo (vm, appId, options) { export function removeMetaInfo (vm, appId, options) {
if (vm && vm.$el) { if (vm && vm.$el) {
const tags = {}
for (const type of metaInfoAttributeKeys) {
const tagName = type.substr(0, 4)
updateAttribute(appId, options, type, {}, getTag(tags, tagName))
}
return removeElementsByAppId(options, appId) return removeElementsByAppId(options, appId)
} }
+7 -8
View File
@@ -57,19 +57,18 @@ export const defaultOptions = {
ssrAppId ssrAppId
} }
// The metaInfo property keys which are used to disable escaping
export const disableOptionKeys = [
'__dangerouslyDisableSanitizers',
'__dangerouslyDisableSanitizersByTagID'
]
// List of metaInfo property keys which are configuration options (and dont generate html) // List of metaInfo property keys which are configuration options (and dont generate html)
export const metaInfoOptionKeys = [ export const metaInfoOptionKeys = [
'titleChunk', 'titleChunk',
'titleTemplate', 'titleTemplate',
'changed', 'changed',
'__dangerouslyDisableSanitizers', ...disableOptionKeys
'__dangerouslyDisableSanitizersByTagID'
]
// The metaInfo property keys which are used to disable escaping
export const disableOptionKeys = [
'__dangerouslyDisableSanitizers',
'__dangerouslyDisableSanitizersByTagID'
] ]
// List of metaInfo property keys which only generates attributes and no tags // List of metaInfo property keys which only generates attributes and no tags
+7 -2
View File
@@ -34,7 +34,10 @@ export function escape (info, options, escapeOptions, escapeKeys) {
continue continue
} }
let [ disableKey ] = disableOptionKeys // do not use destructuring for disableOptionKeys, it increases transpiled size
// due to var checks while we are guaranteed the structure of the cb
let disableKey = disableOptionKeys[0]
if (escapeOptions[disableKey] && includes(escapeOptions[disableKey], key)) { if (escapeOptions[disableKey] && includes(escapeOptions[disableKey], key)) {
// this info[key] doesnt need to escaped if the option is listed in __dangerouslyDisableSanitizers // this info[key] doesnt need to escaped if the option is listed in __dangerouslyDisableSanitizers
escaped[key] = value escaped[key] = value
@@ -81,8 +84,10 @@ export function escape (info, options, escapeOptions, escapeKeys) {
} }
export function escapeMetaInfo (options, info, escapeSequences = []) { export function escapeMetaInfo (options, info, escapeSequences = []) {
// do not use destructuring for seq, it increases transpiled size
// due to var checks while we are guaranteed the structure of the cb
const escapeOptions = { const escapeOptions = {
doEscape: value => escapeSequences.reduce((val, [v, r]) => val.replace(v, r), value) doEscape: value => escapeSequences.reduce((val, seq) => val.replace(seq[0], seq[1]), value)
} }
disableOptionKeys.forEach((disableKey, index) => { disableOptionKeys.forEach((disableKey, index) => {
+11 -9
View File
@@ -1,6 +1,6 @@
import { getComponentMetaInfo } from '../../src/shared/getComponentOption' import { getComponentMetaInfo } from '../../src/shared/getComponentOption'
import _getMetaInfo from '../../src/shared/getMetaInfo' import _getMetaInfo from '../../src/shared/getMetaInfo'
import { mount, createWrapper, loadVueMetaPlugin, vmTick } from '../utils' import { mount, createWrapper, loadVueMetaPlugin, vmTick, clearClientAttributeMap } from '../utils'
import { defaultOptions } from '../../src/shared/constants' import { defaultOptions } from '../../src/shared/constants'
import GoodbyeWorld from '../components/goodbye-world.vue' import GoodbyeWorld from '../components/goodbye-world.vue'
@@ -226,6 +226,13 @@ describe('client', () => {
// this component uses a computed prop to simulate a non-synchronous // this component uses a computed prop to simulate a non-synchronous
// metaInfo update like you would have with a Vuex mutation // metaInfo update like you would have with a Vuex mutation
const Component = Vue.extend({ const Component = Vue.extend({
metaInfo () {
return {
htmlAttrs: {
theme: this.theme
}
}
},
data () { data () {
return { return {
hiddenTheme: 'light' hiddenTheme: 'light'
@@ -239,14 +246,7 @@ describe('client', () => {
beforeMount () { beforeMount () {
this.hiddenTheme = 'dark' this.hiddenTheme = 'dark'
}, },
render: h => h('div'), render: h => h('div')
metaInfo () {
return {
htmlAttrs: {
theme: this.theme
}
}
}
}) })
const vm = new Component().$mount(el) const vm = new Component().$mount(el)
@@ -263,6 +263,8 @@ describe('client', () => {
}) })
test('changes during hydration initialization trigger an update', async () => { test('changes during hydration initialization trigger an update', async () => {
clearClientAttributeMap()
html.setAttribute(defaultOptions.ssrAttribute, 'true') html.setAttribute(defaultOptions.ssrAttribute, 'true')
const el = document.createElement('div') const el = document.createElement('div')
+1 -1
View File
@@ -29,7 +29,7 @@ describe('generators', () => {
const testInfo = typeTests[action] const testInfo = typeTests[action]
// return when no test case available // return when no test case available
if (!testCases[action] && !testInfo.test) { if (!testCases[action]) {
return return
} }
+3 -1
View File
@@ -2,6 +2,7 @@ import _updateClientMetaInfo from '../../src/client/updateClientMetaInfo'
import { defaultOptions, ssrAppId, ssrAttribute } from '../../src/shared/constants' import { defaultOptions, ssrAppId, ssrAttribute } from '../../src/shared/constants'
import metaInfoData from '../utils/meta-info-data' import metaInfoData from '../utils/meta-info-data'
import * as load from '../../src/client/load' import * as load from '../../src/client/load'
import { clearClientAttributeMap } from '../utils'
const updateClientMetaInfo = (type, data) => _updateClientMetaInfo(ssrAppId, defaultOptions, { [type]: data }) const updateClientMetaInfo = (type, data) => _updateClientMetaInfo(ssrAppId, defaultOptions, { [type]: data })
@@ -43,7 +44,6 @@ describe('updaters', () => {
}, },
remove: (tags) => { remove: (tags) => {
// TODO: i'd expect tags.removedTags to be populated // TODO: i'd expect tags.removedTags to be populated
typeTests.add.expect.forEach((expected, index) => { typeTests.add.expect.forEach((expected, index) => {
expect(html.outerHTML).not.toContain(expected) expect(html.outerHTML).not.toContain(expected)
}) })
@@ -57,6 +57,8 @@ describe('updaters', () => {
} }
describe(`${type} type tests`, () => { describe(`${type} type tests`, () => {
beforeAll(() => clearClientAttributeMap())
Object.keys(typeTests).forEach((action) => { Object.keys(typeTests).forEach((action) => {
const testInfo = typeTests[action] const testInfo = typeTests[action]
+9
View File
@@ -1,6 +1,7 @@
import { JSDOM } from 'jsdom' import { JSDOM } from 'jsdom'
import { mount, shallowMount, createWrapper, createLocalVue } from '@vue/test-utils' import { mount, shallowMount, createWrapper, createLocalVue } from '@vue/test-utils'
import { renderToString } from '@vue/server-test-utils' import { renderToString } from '@vue/server-test-utils'
import { attributeMap } from '../../src/client/updaters/attribute'
import { defaultOptions } from '../../src/shared/constants' import { defaultOptions } from '../../src/shared/constants'
import VueMetaPlugin from '../../src' import VueMetaPlugin from '../../src'
@@ -39,3 +40,11 @@ export function createDOM (html = '<!DOCTYPE html>', options = {}) {
document: dom.window.document document: dom.window.document
} }
} }
// dirty hack to remove data from previous test
// this is ok because this code normally only runs on
// the client and not during ssr
// TODO: findout why jest.resetModules doesnt work for this
export function clearClientAttributeMap() {
Object.keys(attributeMap).forEach(key => delete attributeMap[key])
}
+94 -9
View File
@@ -1,4 +1,5 @@
import { defaultOptions } from '../../src/shared/constants' import { defaultOptions } from '../../src/shared/constants'
import { attributeMap } from '../../src/client/updaters/attribute'
const metaInfoData = { const metaInfoData = {
title: { title: {
@@ -187,43 +188,127 @@ const metaInfoData = {
htmlAttrs: { htmlAttrs: {
add: { add: {
data: { foo: 'bar' }, data: { foo: 'bar' },
expect: ['<html foo="bar" data-vue-meta="foo">'] expect: ['<html foo="bar" data-vue-meta="%7B%22foo%22:%7B%22ssr%22:%22bar%22%7D%7D">'],
test (side, defaultTest) {
return () => {
if (side === 'client') {
this.expect[0] = this.expect[0].replace(/ data-vue-meta="[^"]+"/, '')
}
defaultTest()
if (side === 'client') {
expect(attributeMap).toEqual({ htmlAttrs: { foo: { ssr: 'bar' } } })
}
}
}
}, },
change: { change: {
data: { foo: 'baz' }, data: { foo: 'baz' },
expect: ['<html foo="baz" data-vue-meta="foo">'] expect: ['<html foo="baz">'],
test (side, defaultTest) {
return () => {
defaultTest()
expect(attributeMap).toEqual({ htmlAttrs: { foo: { ssr: 'baz' } } })
}
}
}, },
remove: { remove: {
data: {}, data: {},
expect: ['<html>'] expect: ['<html>'],
test (side, defaultTest) {
return () => {
defaultTest()
expect(attributeMap).toEqual({ htmlAttrs: { foo: {} } })
}
}
} }
}, },
headAttrs: { headAttrs: {
add: { add: {
data: { foo: 'bar' }, data: { foo: 'bar' },
expect: ['<head foo="bar" data-vue-meta="foo">'] expect: ['<head foo="bar" data-vue-meta="%7B%22foo%22:%7B%22ssr%22:%22bar%22%7D%7D">'],
test (side, defaultTest) {
return () => {
if (side === 'client') {
this.expect[0] = this.expect[0].replace(/ data-vue-meta="[^"]+"/, '')
}
defaultTest()
if (side === 'client') {
expect(attributeMap).toEqual({ headAttrs: { foo: { ssr: 'bar' } } })
}
}
}
}, },
change: { change: {
data: { foo: 'baz' }, data: { foo: 'baz' },
expect: ['<head foo="baz" data-vue-meta="foo">'] expect: ['<head foo="baz">'],
test (side, defaultTest) {
return () => {
defaultTest()
expect(attributeMap).toEqual({ headAttrs: { foo: { ssr: 'baz' } } })
}
}
}, },
remove: { remove: {
data: {}, data: {},
expect: ['<head>'] expect: ['<head>'],
test (side, defaultTest) {
return () => {
defaultTest()
expect(attributeMap).toEqual({ headAttrs: { foo: {} } })
}
}
} }
}, },
bodyAttrs: { bodyAttrs: {
add: { add: {
data: { foo: 'bar', fizz: ['fuzz', 'fozz'] }, data: { foo: 'bar', fizz: ['fuzz', 'fozz'] },
expect: ['<body foo="bar" fizz="fuzz fozz" data-vue-meta="fizz,foo">'] expect: ['<body foo="bar" fizz="fuzz fozz" data-vue-meta="%7B%22foo%22:%7B%22ssr%22:%22bar%22%7D,%22fizz%22:%7B%22ssr%22:%5B%22fuzz%22,%22fozz%22%5D%7D%7D">'],
test (side, defaultTest) {
return () => {
if (side === 'client') {
this.expect[0] = this.expect[0].replace(/ data-vue-meta="[^"]+"/, '')
}
defaultTest()
if (side === 'client') {
expect(attributeMap).toEqual({ bodyAttrs: {
foo: { ssr: 'bar' },
fizz: { ssr: ['fuzz', 'fozz'] }
}})
}
}
}
}, },
change: { change: {
data: { foo: 'baz' }, data: { foo: 'baz' },
expect: ['<body foo="baz" data-vue-meta="fizz,foo">'] expect: ['<body foo="baz">'],
test (side, defaultTest) {
return () => {
defaultTest()
expect(attributeMap).toEqual({ bodyAttrs: { foo: { ssr: 'baz' }, fizz: {} } })
}
}
}, },
remove: { remove: {
data: {}, data: {},
expect: ['<body>'] expect: ['<body>'],
test (side, defaultTest) {
return () => {
defaultTest()
expect(attributeMap).toEqual({ bodyAttrs: { foo: {}, fizz: {} } })
}
}
} }
} }
} }