2
0
mirror of https://github.com/tenrok/vue-meta.git synced 2026-06-13 06:02:24 +03:00

Merge branch 'next' into docs

This commit is contained in:
pimlie
2019-03-12 11:26:29 +01:00
70 changed files with 3062 additions and 1381 deletions
+4 -4
View File
@@ -1,13 +1,13 @@
{
"plugins": ["@babel/plugin-syntax-dynamic-import"],
"env": {
"test": {
"plugins": ["dynamic-import-node"],
"presets": [
[ "@babel/env", {
"targets": {
"node": "current"
}
"targets": { "node": "current" }
}]
]
}
}
},
}
+60 -10
View File
@@ -1,8 +1,15 @@
version: 2
defaults: &defaults
working_directory: ~/project
docker:
- image: circleci/node:latest
environment:
NODE_ENV: test
jobs:
build:
docker:
- image: circleci/node
setup:
<<: *defaults
steps:
# Checkout repository
- checkout
@@ -22,17 +29,60 @@ jobs:
paths:
- "node_modules"
# Lint
# Persist workspace
- persist_to_workspace:
root: ~/project
paths:
- node_modules
lint:
<<: *defaults
steps:
- checkout
- attach_workspace:
at: ~/project
- run:
name: Lint
command: yarn lint
# Test
audit:
<<: *defaults
steps:
- checkout
- attach_workspace:
at: ~/project
- run:
name: Test
command: yarn test
name: Security Audit
command: yarn audit
# Coverage
test-unit:
<<: *defaults
steps:
- checkout
- attach_workspace:
at: ~/project
- run:
name: Coverage
command: yarn codecov
name: Unit Tests
command: yarn test:unit --coverage && yarn coverage
test-e2e:
docker:
- image: circleci/node:latest-browsers
steps:
- checkout
- attach_workspace:
at: ~/project
- run:
name: E2E Tests
command: yarn test:e2e
workflows:
version: 2
commit:
jobs:
- setup
- lint: { requires: [setup] }
- audit: { requires: [setup] }
- test-unit: { requires: [lint] }
- test-e2e: { requires: [lint] }
+1
View File
@@ -40,6 +40,7 @@ package-lock.json
# built code
lib
es
.vue-meta
# examples yarn lock
examples/yarn.lock
+1
View File
@@ -22,6 +22,7 @@
"devDependencies": {
"@babel/core": "^7.3.3",
"@babel/node": "^7.2.2",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/preset-env": "^7.3.1",
"babel-loader": "^8.0.5",
"babel-plugin-dynamic-import-node": "^2.2.0",
+3 -2
View File
@@ -1,12 +1,13 @@
import Vue from 'vue'
import Router from 'vue-router'
import Meta from 'vue-meta'
import Home from './views/Home.vue'
import Post from './views/Post.vue'
Vue.use(Router)
Vue.use(Meta)
const Home = () => import('./views/Home.vue')
const Post = () => import('./views/Post.vue')
export default new Router({
mode: 'history',
base: '/vuex',
+1 -1
View File
@@ -1,5 +1,5 @@
module.exports = {
testEnvironment: 'node',
testEnvironment: 'jest-environment-jsdom-global',
expand: true,
+25 -17
View File
@@ -44,9 +44,8 @@
"typings": "types/index.d.ts",
"scripts": {
"build": "yarn build:other && yarn build:es",
"build:es": "rimraf es && babel src --env-name es --out-dir es --ignore 'src/browser.js'",
"build:es": "rimraf es && babel src --env-name es --out-dir es",
"build:other": "rimraf lib && rollup -c scripts/rollup.config.js",
"codecov": "codecov",
"dev": "cd examples && yarn dev && cd ..",
"docs": "vuepress dev --host 0.0.0.0 --port 3000 docs",
"docs:build": "vuepress build docs",
@@ -54,30 +53,33 @@
"prerelease": "git checkout master && git pull -r",
"release": "yarn lint && yarn test && yarn build && standard-version",
"postrelease": "git push origin master --follow-tags && yarn publish",
"test": "jest"
"test": "yarn test:unit && yarn test:e2e",
"test:e2e": "jest test/e2e",
"test:unit": "jest test/unit"
},
"dependencies": {
"deepmerge": "^3.2.0",
"lodash.isplainobject": "^4.0.6",
"lodash.uniqueid": "^4.0.1"
"deepmerge": "^3.2.0"
},
"resolutions": {
"webpack-dev-middleware": "3.6.0"
},
"devDependencies": {
"@babel/cli": "^7.2.3",
"@babel/core": "^7.3.3",
"@babel/core": "^7.3.4",
"@babel/node": "^7.2.2",
"@babel/preset-env": "^7.3.1",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/preset-env": "^7.3.4",
"@nuxt/babel-preset-app": "^2.4.5",
"@nuxtjs/eslint-config": "^0.0.1",
"@vue/server-test-utils": "^1.0.0-beta.29",
"@vue/test-utils": "^1.0.0-beta.29",
"babel-core": "^7.0.0-bridge",
"babel-eslint": "^10.0.1",
"babel-jest": "^24.1.0",
"babel-jest": "^24.4.0",
"babel-loader": "^8.0.5",
"babel-plugin-dynamic-import-node": "^2.2.0",
"codecov": "^3.2.0",
"eslint": "^5.14.1",
"eslint": "^5.15.1",
"eslint-config-standard": "^12.0.0",
"eslint-plugin-import": "^2.16.0",
"eslint-plugin-jest": "^22.3.0",
@@ -85,16 +87,22 @@
"eslint-plugin-promise": "^4.0.1",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^5.2.2",
"esm": "^3.2.5",
"jest": "^24.1.0",
"jsdom": "^13.2.0",
"jsdom-global": "^3.0.2",
"esm": "^3.2.14",
"fs-extra": "^7.0.1",
"is-wsl": "^1.1.0",
"jest": "^24.4.0",
"jest-environment-jsdom": "^24.4.0",
"jest-environment-jsdom-global": "^1.1.1",
"jsdom": "^14.0.0",
"lodash": "^4.17.11",
"puppeteer-core": "^1.13.0",
"rimraf": "^2.6.3",
"rollup": "^1.2.2",
"rollup": "^1.6.0",
"rollup-plugin-buble": "^0.19.6",
"rollup-plugin-commonjs": "^9.2.0",
"rollup-plugin-commonjs": "^9.2.1",
"rollup-plugin-json": "^3.1.0",
"rollup-plugin-node-resolve": "^4.0.0",
"rollup-plugin-node-resolve": "^4.0.1",
"rollup-plugin-replace": "^2.1.0",
"rollup-plugin-terser": "^4.0.4",
"standard-version": "^5.0.1",
"vue": "^2.6.8",
+68 -37
View File
@@ -2,7 +2,9 @@ import commonjs from 'rollup-plugin-commonjs'
import nodeResolve from 'rollup-plugin-node-resolve'
import json from 'rollup-plugin-json'
import buble from 'rollup-plugin-buble'
import replace from 'rollup-plugin-replace'
import { terser } from 'rollup-plugin-terser'
import defaultsDeep from 'lodash/defaultsDeep'
const pkg = require('../package.json')
@@ -13,42 +15,71 @@ const banner = `/**
*/
`.replace(/ {4}/gm, '').trim()
const baseConfig = {
input: 'src/browser.js',
output: {
file: pkg.web,
format: 'umd',
name: 'VueMeta',
sourcemap: false,
banner
},
plugins: [
json(),
nodeResolve(),
commonjs(),
buble(),
]
function rollupConfig({
plugins = [],
...config
}) {
const replaceConfig = {
exclude: 'node_modules/**',
delimiters: ['', ''],
values: {
// replaceConfig needs to have some values
'const polyfill = process.env.NODE_ENV === \'test\'': 'const polyfill = false',
}
}
if (!config.output.format || config.output.format === 'umd') {
replaceConfig.values = {
'const polyfill = process.env.NODE_ENV === \'test\'': 'const polyfill = true',
}
}
return defaultsDeep({}, config, {
input: 'src/browser.js',
output: {
name: 'VueMeta',
format: 'umd',
sourcemap: false,
banner
},
plugins: [
json(),
nodeResolve(),
replace(replaceConfig)
].concat(plugins),
})
}
export default [{
...baseConfig,
}, {
...baseConfig,
output: {
...baseConfig.output,
file: pkg.web.replace('.js', '.min.js'),
},
plugins: [
...baseConfig.plugins,
terser()
]
}, {
...baseConfig,
input: 'src/index.js',
output: {
...baseConfig.output,
file: pkg.main,
format: 'cjs'
},
external: Object.keys(pkg.dependencies)
}]
export default [
rollupConfig({
output: {
file: pkg.web,
},
plugins: [
commonjs(),
buble()
]
}),
rollupConfig({
output: {
file: pkg.web.replace('.js', '.min.js'),
},
plugins: [
commonjs(),
buble(),
terser()
]
}),
rollupConfig({
input: 'src/index.js',
output: {
file: pkg.main,
format: 'cjs'
},
plugins: [
commonjs()
],
external: Object.keys(pkg.dependencies)
})
]
+3 -3
View File
@@ -1,9 +1,9 @@
import { version } from '../package.json'
import createMixin from './shared/mixin'
import setOptions from './shared/options'
import { isUndefined } from './shared/typeof'
import { setOptions } from './shared/options'
import { isUndefined } from './utils/is-type'
import $meta from './client/$meta'
import { hasMetaInfo } from './shared/hasMetaInfo'
import { hasMetaInfo } from './shared/meta-helpers'
/**
* Plugin install function.
+2
View File
@@ -1,3 +1,4 @@
import { getOptions } from '../shared/options'
import { pause, resume } from '../shared/pausing'
import refresh from './refresh'
@@ -12,6 +13,7 @@ export default function _$meta(options = {}) {
*/
return function $meta() {
return {
getOptions: () => getOptions(options),
refresh: _refresh.bind(this),
inject,
pause: pause.bind(this),
+2 -4
View File
@@ -1,4 +1,4 @@
import { hasGlobalWindow } from '../shared/window'
import { hasGlobalWindow } from '../utils/window'
// fallback to timers if rAF not present
const stopUpdate = (hasGlobalWindow ? window.cancelAnimationFrame : null) || clearTimeout
@@ -15,9 +15,7 @@ const startUpdate = (hasGlobalWindow ? window.requestAnimationFrame : null) || (
* @return {Number} id - a new ID
*/
export default function batchUpdate(id, callback) {
if (id) {
stopUpdate(id)
}
stopUpdate(id)
return startUpdate(() => {
id = null
+4 -11
View File
@@ -1,16 +1,9 @@
import getMetaInfo from '../shared/getMetaInfo'
import { isFunction } from '../shared/typeof'
import { isFunction } from '../utils/is-type'
import { clientSequences } from '../shared/escaping'
import updateClientMetaInfo from './updateClientMetaInfo'
export default function _refresh(options = {}) {
const escapeSequences = [
[/&/g, '\u0026'],
[/</g, '\u003c'],
[/>/g, '\u003e'],
[/"/g, '\u0022'],
[/'/g, '\u0027']
]
/**
* When called, will update the current meta info with new meta info.
* Useful when updating meta info as the result of an asynchronous
@@ -22,12 +15,12 @@ export default function _refresh(options = {}) {
* @return {Object} - new meta info
*/
return function refresh() {
const metaInfo = getMetaInfo(options, this.$root, escapeSequences)
const metaInfo = getMetaInfo(options, this.$root, clientSequences)
const tags = updateClientMetaInfo(options, metaInfo)
// emit "event" with new info
if (tags && isFunction(metaInfo.changed)) {
metaInfo.changed.call(this, metaInfo, tags.addedTags, tags.removedTags)
metaInfo.changed(metaInfo, tags.addedTags, tags.removedTags)
}
return { vm: this, metaInfo, tags }
+49 -43
View File
@@ -1,7 +1,9 @@
import { metaInfoOptionKeys, metaInfoAttributeKeys } from '../shared/constants'
import { isArray } from '../utils/is-type'
import { includes } from '../utils/array'
import { updateAttribute, updateTag, updateTitle } from './updaters'
const getTag = (tags, tag) => {
function getTag(tags, tag) {
if (!tags[tag]) {
tags[tag] = document.getElementsByTagName(tag)[0]
}
@@ -22,49 +24,53 @@ export default function updateClientMetaInfo(options = {}, newInfo) {
const htmlTag = getTag(tags, 'html')
// if this is not a server render, then update
if (htmlTag.getAttribute(ssrAttribute) === null) {
// initialize tracked changes
const addedTags = {}
const removedTags = {}
for (const type in newInfo) {
// ignore these
if (metaInfoOptionKeys.includes(type)) {
continue
}
if (type === 'title') {
// update the title
updateTitle(newInfo.title)
continue
}
if (metaInfoAttributeKeys.includes(type)) {
const tagName = type.substr(0, 4)
updateAttribute(options, newInfo[type], getTag(tags, tagName))
continue
}
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
// if this is a server render, then dont update
if (htmlTag.hasAttribute(ssrAttribute)) {
// remove the server render attribute so we can update on (next) changes
htmlTag.removeAttribute(ssrAttribute)
return false
}
return false
// initialize tracked changes
const addedTags = {}
const removedTags = {}
for (const type in newInfo) {
// ignore these
if (includes(metaInfoOptionKeys, type)) {
continue
}
if (type === 'title') {
// update the title
updateTitle(newInfo.title)
continue
}
if (includes(metaInfoAttributeKeys, type)) {
const tagName = type.substr(0, 4)
updateAttribute(options, newInfo[type], getTag(tags, tagName))
continue
}
// tags should always be an array, ignore if it isnt
if (!isArray(newInfo[type])) {
continue
}
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 }
}
+12 -5
View File
@@ -1,3 +1,7 @@
import { booleanHtmlAttributes } from '../../shared/constants'
import { toArray, includes } from '../../utils/array'
import { isArray } from '../../utils/is-type'
/**
* Updates the document's html tag attributes
*
@@ -7,15 +11,18 @@
export default function updateAttribute({ attribute } = {}, attrs, tag) {
const vueMetaAttrString = tag.getAttribute(attribute)
const vueMetaAttrs = vueMetaAttrString ? vueMetaAttrString.split(',') : []
const toRemove = Array.from(vueMetaAttrs)
const toRemove = toArray(vueMetaAttrs)
const keepIndexes = []
for (const attr in attrs) {
if (attrs.hasOwnProperty(attr)) {
const value = attrs[attr] || ''
tag.setAttribute(attr, value)
const value = includes(booleanHtmlAttributes, attr)
? ''
: isArray(attrs[attr]) ? attrs[attr].join(' ') : attrs[attr]
if (!vueMetaAttrs.includes(attr)) {
tag.setAttribute(attr, value || '')
if (!includes(vueMetaAttrs, attr)) {
vueMetaAttrs.push(attr)
}
@@ -25,7 +32,7 @@ export default function updateAttribute({ attribute } = {}, attrs, tag) {
}
const removedAttributesCount = toRemove
.filter((el, index) => !keepIndexes.includes(index))
.filter((el, index) => !includes(keepIndexes, index))
.reduce((acc, attr) => {
tag.removeAttribute(attr)
return acc + 1
+11 -10
View File
@@ -1,4 +1,5 @@
import { isUndefined } from '../../shared/typeof'
import { isUndefined } from '../../utils/is-type'
import { toArray, includes } from '../../utils/array'
/**
* Updates meta tags inside <head> and <body> on the client. Borrowed from `react-helmet`:
@@ -9,8 +10,9 @@ import { isUndefined } from '../../shared/typeof'
* @return {Object} - a representation of what tags changed
*/
export default function updateTag({ attribute, tagIDKeyName } = {}, type, tags, headTag, bodyTag) {
const oldHeadTags = Array.from(headTag.querySelectorAll(`${type}[${attribute}]`))
const oldBodyTags = Array.from(bodyTag.querySelectorAll(`${type}[${attribute}][data-body="true"]`))
const oldHeadTags = toArray(headTag.querySelectorAll(`${type}[${attribute}]`))
const oldBodyTags = toArray(bodyTag.querySelectorAll(`${type}[${attribute}][data-body="true"]`))
const dataAttributes = [tagIDKeyName, 'body']
const newTags = []
if (tags.length > 1) {
@@ -20,13 +22,13 @@ export default function updateTag({ attribute, tagIDKeyName } = {}, type, tags,
const found = []
tags = tags.filter((x) => {
const k = JSON.stringify(x)
const res = !found.includes(k)
const res = !includes(found, k)
found.push(k)
return res
})
}
if (tags && tags.length) {
if (tags.length) {
tags.forEach((tag) => {
const newElement = document.createElement(type)
newElement.setAttribute(attribute, 'true')
@@ -44,13 +46,12 @@ export default function updateTag({ attribute, tagIDKeyName } = {}, type, tags,
} else {
newElement.appendChild(document.createTextNode(tag.cssText))
}
} else if ([tagIDKeyName, 'body'].includes(attr)) {
const _attr = `data-${attr}`
} else {
const _attr = includes(dataAttributes, attr)
? `data-${attr}`
: attr
const value = isUndefined(tag[attr]) ? '' : tag[attr]
newElement.setAttribute(_attr, value)
} else {
const value = isUndefined(tag[attr]) ? '' : tag[attr]
newElement.setAttribute(attr, value)
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { version } from '../package.json'
import createMixin from './shared/mixin'
import setOptions from './shared/options'
import { setOptions } from './shared/options'
import $meta from './server/$meta'
import { hasMetaInfo } from './shared/hasMetaInfo'
import { hasMetaInfo } from './shared/meta-helpers'
/**
* Plugin install function.
+3 -1
View File
@@ -1,5 +1,6 @@
import refresh from '../client/refresh'
import { getOptions } from '../shared/options'
import { pause, resume } from '../shared/pausing'
import refresh from '../client/refresh'
import inject from './inject'
export default function _$meta(options = {}) {
@@ -13,6 +14,7 @@ export default function _$meta(options = {}) {
*/
return function $meta() {
return {
getOptions: () => getOptions(options),
refresh: _refresh.bind(this),
inject: _inject.bind(this),
pause: pause.bind(this),
+2 -2
View File
@@ -1,5 +1,5 @@
import { booleanHtmlAttributes } from '../../shared/constants'
import { isUndefined } from '../../shared/typeof'
import { isUndefined, isArray } from '../../utils/is-type'
/**
* Generates tag attributes for use on the server.
@@ -20,7 +20,7 @@ export default function attributeGenerator({ attribute } = {}, type, data) {
attributeStr += isUndefined(data[attr]) || booleanHtmlAttributes.includes(attr)
? attr
: `${attr}="${data[attr]}"`
: `${attr}="${isArray(data[attr]) ? data[attr].join(' ') : data[attr]}"`
attributeStr += ' '
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { booleanHtmlAttributes, tagsWithoutEndTag, tagsWithInnerContent, tagAttributeAsInnerContent } from '../../shared/constants'
import { isUndefined } from '../../shared/typeof'
import { isUndefined } from '../../utils/is-type'
/**
* Generates meta, base, link, style, script, noscript tags for use on the server
+2 -9
View File
@@ -1,16 +1,9 @@
import getMetaInfo from '../shared/getMetaInfo'
import { metaInfoOptionKeys } from '../shared/constants'
import { serverSequences } from '../shared/escaping'
import generateServerInjector from './generateServerInjector'
export default function _inject(options = {}) {
const escapeSequences = [
[/&/g, '&amp;'],
[/</g, '&lt;'],
[/>/g, '&gt;'],
[/"/g, '&quot;'],
[/'/g, '&#x27;']
]
/**
* Converts the state of the meta info object such that each item
* can be compiled to a tag string on the server
@@ -20,7 +13,7 @@ export default function _inject(options = {}) {
*/
return function inject() {
// get meta info with sensible defaults
const metaInfo = getMetaInfo(options, this.$root, escapeSequences)
const metaInfo = getMetaInfo(options, this.$root, serverSequences)
// generate server injectors
for (const key in metaInfo) {
+34
View File
@@ -2,6 +2,24 @@
* These are constant variables used throughout the application.
*/
// set some sane defaults
export const defaultInfo = {
title: '',
titleChunk: '',
titleTemplate: '%s',
htmlAttrs: {},
bodyAttrs: {},
headAttrs: {},
base: [],
link: [],
meta: [],
style: [],
script: [],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: {}
}
// This is the name of the component option that contains all the information that
// gets converted to the various meta tags & attributes for the page.
export const keyName = 'metaInfo'
@@ -26,6 +44,15 @@ export const metaTemplateKeyName = 'template'
// This is the key name for the content-holding property
export const contentKeyName = 'content'
export const defaultOptions = {
keyName,
attribute,
ssrAttribute,
tagIDKeyName,
contentKeyName,
metaTemplateKeyName
}
// List of metaInfo property keys which are configuration options (and dont generate html)
export const metaInfoOptionKeys = [
'titleChunk',
@@ -35,6 +62,12 @@ export const metaInfoOptionKeys = [
'__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
export const metaInfoAttributeKeys = [
'htmlAttrs',
@@ -55,6 +88,7 @@ export const tagAttributeAsInnerContent = ['innerHTML', 'cssText']
// from: https://github.com/kangax/html-minifier/blob/gh-pages/src/htmlminifier.js#L202
export const booleanHtmlAttributes = [
'allowfullscreen',
'amp',
'async',
'autofocus',
'autoplay',
+70
View File
@@ -0,0 +1,70 @@
import { isString, isArray, isObject } from '../utils/is-type'
import { includes } from '../utils/array'
import { metaInfoOptionKeys, disableOptionKeys } from './constants'
export const serverSequences = [
[/&/g, '&amp;'],
[/</g, '&lt;'],
[/>/g, '&gt;'],
[/"/g, '&quot;'],
[/'/g, '&#x27;']
]
export const clientSequences = [
[/&/g, '\u0026'],
[/</g, '\u003c'],
[/>/g, '\u003e'],
[/"/g, '\u0022'],
[/'/g, '\u0027']
]
// sanitizes potentially dangerous characters
export function escape(info, options, escapeOptions) {
const { tagIDKeyName } = options
const { doEscape = v => v } = escapeOptions
const escaped = {}
for (const key in info) {
const value = info[key]
// no need to escape configuration options
if (includes(metaInfoOptionKeys, key)) {
escaped[key] = value
continue
}
let [ disableKey ] = disableOptionKeys
if (escapeOptions[disableKey] && includes(escapeOptions[disableKey], key)) {
// this info[key] doesnt need to escaped if the option is listed in __dangerouslyDisableSanitizers
escaped[key] = value
continue
}
const tagId = info[tagIDKeyName]
if (tagId) {
disableKey = disableOptionKeys[1]
// keys which are listed in __dangerouslyDisableSanitizersByTagID for the current vmid do not need to be escaped
if (escapeOptions[disableKey] && escapeOptions[disableKey][tagId] && includes(escapeOptions[disableKey][tagId], key)) {
escaped[key] = value
continue
}
}
if (isString(value)) {
escaped[key] = doEscape(value)
} else if (isArray(value)) {
escaped[key] = value.map((v) => {
return isObject(v)
? escape(v, options, escapeOptions)
: doEscape(v)
})
} else if (isObject(value)) {
escaped[key] = escape(value, options, escapeOptions)
} else {
escaped[key] = value
}
}
return escaped
}
+36 -38
View File
@@ -1,7 +1,8 @@
import deepmerge from 'deepmerge'
import uniqueId from 'lodash.uniqueid'
import { isUndefined, isFunction, isObject } from './typeof'
import uniqBy from './uniqBy'
import { isFunction, isObject } from '../utils/is-type'
import { findIndex } from '../utils/array'
import { merge } from './merge'
import { applyTemplate } from './template'
import { inMetaInfoBranch } from './meta-helpers'
/**
* Returns the `opts.option` $option value of the given `opts.component`.
@@ -17,15 +18,16 @@ import uniqBy from './uniqBy'
* @param {Object} [result={}] - result so far
* @return {Object} result - final aggregated result
*/
export default function getComponentOption({ component, deep, arrayMerge, keyName, metaTemplateKeyName, tagIDKeyName, contentKeyName } = {}, result = {}) {
const { $options } = component
export default function getComponentOption(options = {}, component, result = {}) {
const { keyName, metaTemplateKeyName, tagIDKeyName } = options
const { $options, $children } = component
if (component._inactive) {
return result
}
// only collect option data if it exists
if (!isUndefined($options[keyName]) && $options[keyName] !== null) {
if ($options[keyName]) {
let data = $options[keyName]
// if option is a function, replace it with it's result
@@ -33,46 +35,42 @@ export default function getComponentOption({ component, deep, arrayMerge, keyNam
data = data.call(component)
}
if (isObject(data)) {
// merge with existing options
result = deepmerge(result, data, { arrayMerge })
} else {
result = data
// ignore data if its not an object, then we keep our previous result
if (!isObject(data)) {
return result
}
// merge with existing options
result = merge(result, data, options)
}
// collect & aggregate child options if deep = true
if (deep && component.$children.length) {
component.$children.forEach((childComponent) => {
result = getComponentOption({
component: childComponent,
keyName,
deep,
arrayMerge
}, result)
if ($children.length) {
$children.forEach((childComponent) => {
// check if the childComponent is in a branch
// return otherwise so we dont walk all component branches unnecessarily
if (!inMetaInfoBranch(childComponent)) {
return
}
result = getComponentOption(options, childComponent, result)
})
}
if (metaTemplateKeyName && result.hasOwnProperty('meta')) {
result.meta = Object.keys(result.meta).map((metaKey) => {
const metaObject = result.meta[metaKey]
if (!metaObject.hasOwnProperty(metaTemplateKeyName) || !metaObject.hasOwnProperty(contentKeyName) || isUndefined(metaObject[metaTemplateKeyName])) {
return result.meta[metaKey]
}
if (metaTemplateKeyName && result.meta) {
// apply templates if needed
result.meta.forEach(metaObject => applyTemplate(options, metaObject))
const template = metaObject[metaTemplateKeyName]
delete metaObject[metaTemplateKeyName]
if (template) {
metaObject.content = isFunction(template) ? template(metaObject.content) : template.replace(/%s/g, metaObject.content)
}
return metaObject
// remove meta items with duplicate vmid's
result.meta = result.meta.filter((metaItem, index, arr) => {
return (
// keep meta item if it doesnt has a vmid
!metaItem.hasOwnProperty(tagIDKeyName) ||
// or if it's the first item in the array with this vmid
index === findIndex(arr, item => item[tagIDKeyName] === metaItem[tagIDKeyName])
)
})
result.meta = uniqBy(
result.meta,
metaObject => metaObject.hasOwnProperty(tagIDKeyName) ? metaObject[tagIDKeyName] : uniqueId()
)
}
return result
}
+20 -112
View File
@@ -1,12 +1,9 @@
import deepmerge from 'deepmerge'
import isPlainObject from 'lodash.isplainobject'
import { isFunction, isString } from './typeof'
import isArray from './isArray'
import { ensureIsArray } from '../utils/ensure'
import { applyTemplate } from './template'
import { defaultInfo, disableOptionKeys } from './constants'
import { escape } from './escaping'
import getComponentOption from './getComponentOption'
const applyTemplate = (component, template, chunk) =>
isFunction(template) ? template.call(component, chunk) : template.replace(/%s/g, chunk)
/**
* Returns the correct meta info for the given component
* (child components will overwrite parent meta info)
@@ -14,75 +11,9 @@ const applyTemplate = (component, template, chunk) =>
* @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, escapeSequences = []) {
// set some sane defaults
const defaultInfo = {
title: '',
titleChunk: '',
titleTemplate: '%s',
htmlAttrs: {},
bodyAttrs: {},
headAttrs: {},
meta: [],
base: [],
link: [],
style: [],
script: [],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: {}
}
export default function getMetaInfo(options = {}, component, escapeSequences = []) {
// 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 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
}
}
if (!shared) {
destination.push(targetItem)
}
}
return destination.concat(source)
}
})
let info = getComponentOption(options, component, defaultInfo)
// Remove all "template" tags from meta
@@ -92,8 +23,8 @@ export default function getMetaInfo({ keyName, tagIDKeyName, metaTemplateKeyName
}
// replace title with populated template
if (info.titleTemplate) {
info.title = applyTemplate(component, info.titleTemplate, info.titleChunk || '')
if (info.titleTemplate && info.titleTemplate !== '%s') {
applyTemplate({ component, contentKeyName: 'title' }, info, info.titleTemplate, info.titleChunk || '')
}
// convert base tag to an array so it can be handled the same way
@@ -102,47 +33,24 @@ export default function getMetaInfo({ keyName, tagIDKeyName, metaTemplateKeyName
info.base = Object.keys(info.base).length ? [info.base] : []
}
const ref = info.__dangerouslyDisableSanitizers
const refByTagID = info.__dangerouslyDisableSanitizersByTagID
const escapeOptions = {
doEscape: value => escapeSequences.reduce((val, [v, r]) => val.replace(v, r), value)
}
// sanitizes potentially dangerous characters
const escape = info => Object.keys(info).reduce((escaped, key) => {
let isDisabled = ref && ref.includes(key)
const tagID = info[tagIDKeyName]
if (!isDisabled && tagID) {
isDisabled = refByTagID && refByTagID[tagID] && refByTagID[tagID].includes(key)
}
const val = info[key]
escaped[key] = val
if (key === '__dangerouslyDisableSanitizers' || key === '__dangerouslyDisableSanitizersByTagID') {
return escaped
}
if (!isDisabled) {
if (isString(val)) {
escaped[key] = escapeSequences.reduce((val, [v, r]) => val.replace(v, r), val)
} else if (isPlainObject(val)) {
escaped[key] = escape(val)
} else if (isArray(val)) {
escaped[key] = val.map(escape)
} else {
escaped[key] = val
disableOptionKeys.forEach((disableKey, index) => {
if (index === 0) {
ensureIsArray(info, disableKey)
} else if (index === 1) {
for (const key in info[disableKey]) {
ensureIsArray(info[disableKey], key)
}
} else {
escaped[key] = val
}
return escaped
}, {})
// merge with defaults
info = deepmerge(defaultInfo, info)
escapeOptions[disableKey] = info[disableKey]
})
// begin sanitization
info = escape(info)
info = escape(info, options, escapeOptions)
return info
}
-3
View File
@@ -1,3 +0,0 @@
export function hasMetaInfo(vm = this) {
return vm && !!vm._vueMeta
}
-10
View File
@@ -1,10 +0,0 @@
/**
* checks if passed argument is an array
* @param {any} arr - the object to check
* @return {Boolean} - true if `arr` is an array
*/
export default function isArray(arr) {
return Array.isArray
? Array.isArray(arr)
: Object.prototype.toString.call(arr) === '[object Array]'
}
+91
View File
@@ -0,0 +1,91 @@
import deepmerge from 'deepmerge'
import { findIndex } from '../utils/array'
import { applyTemplate } from './template'
import { metaInfoAttributeKeys } from './constants'
export function arrayMerge({ component, tagIDKeyName, metaTemplateKeyName, contentKeyName }, 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
const destination = []
target.forEach((targetItem, targetIndex) => {
// no tagID so no need to check for duplicity
if (!targetItem[tagIDKeyName]) {
destination.push(targetItem)
return
}
const sourceIndex = findIndex(source, item => item[tagIDKeyName] === targetItem[tagIDKeyName])
const sourceItem = source[sourceIndex]
// source doesnt contain any duplicate vmid's, we can keep targetItem
if (sourceIndex === -1) {
destination.push(targetItem)
return
}
// when sourceItem explictly defines contentKeyName or innerHTML as undefined, its
// an indication that we need to skip the default behaviour or child has preference over parent
// which means we keep the targetItem and ignore/remove the sourceItem
if ((sourceItem.hasOwnProperty(contentKeyName) && sourceItem[contentKeyName] === undefined) ||
(sourceItem.hasOwnProperty('innerHTML') && sourceItem.innerHTML === undefined)) {
destination.push(targetItem)
// remove current index from source array so its not concatenated to destination below
source.splice(sourceIndex, 1)
return
}
// we now know that targetItem is a duplicate and we should ignore it in favor of sourceItem
// if source specifies null as content then ignore both the target as the source
if (sourceItem[contentKeyName] === null || sourceItem.innerHTML === null) {
// remove current index from source array so its not concatenated to destination below
source.splice(sourceIndex, 1)
return
}
// now we only need to check if the target has a template to combine it with the source
const targetTemplate = targetItem[metaTemplateKeyName]
if (!targetTemplate) {
return
}
const sourceTemplate = sourceItem[metaTemplateKeyName]
if (!sourceTemplate) {
// use parent template and child content
applyTemplate({ component, metaTemplateKeyName, contentKeyName }, sourceItem, targetTemplate)
} else if (!sourceItem[contentKeyName]) {
// use child template and parent content
applyTemplate({ component, metaTemplateKeyName, contentKeyName }, sourceItem, undefined, targetItem[contentKeyName])
}
})
return destination.concat(source)
}
export function merge(target, source, options = {}) {
// remove properties explicitly set to false so child components can
// optionally _not_ overwrite the parents content
// (for array properties this is checked in arrayMerge)
if (source.hasOwnProperty('title') && source.title === undefined) {
delete source.title
}
metaInfoAttributeKeys.forEach((attrKey) => {
if (!source[attrKey]) {
return
}
for (const key in source[attrKey]) {
if (source[attrKey].hasOwnProperty(key) && source[attrKey][key] === undefined) {
delete source[attrKey][key]
}
}
})
return deepmerge(target, source, {
arrayMerge: (t, s) => arrayMerge(options, t, s)
})
}
+11
View File
@@ -0,0 +1,11 @@
import { isUndefined, isObject } from '../utils/is-type'
// Vue $root instance has a _vueMeta object property, otherwise its a boolean true
export function hasMetaInfo(vm = this) {
return vm && (vm._vueMeta === true || isObject(vm._vueMeta))
}
// a component is in a metaInfo branch when itself has meta info or one of its (grand-)children has
export function inMetaInfoBranch(vm = this) {
return vm && !isUndefined(vm._vueMeta)
}
+30 -23
View File
@@ -1,6 +1,8 @@
import triggerUpdate from '../client/triggerUpdate'
import { isUndefined, isFunction } from './typeof'
import { ensuredPush } from './ensure'
import { isUndefined, isFunction } from '../utils/is-type'
import { ensuredPush } from '../utils/ensure'
import { hasMetaInfo } from './meta-helpers'
import { addNavGuards } from './nav-guards'
export default function createMixin(Vue, options) {
// for which Vue lifecycle hooks should the metaInfo be refreshed
@@ -13,10 +15,10 @@ export default function createMixin(Vue, options) {
get() {
// Show deprecation warning once when devtools enabled
if (Vue.config.devtools && !this.$root._vueMeta.hasMetaInfoDeprecationWarningShown) {
console.warn('VueMeta DeprecationWarning: _hasMetaInfo has been deprecated and will be removed in a future version. Please import hasMetaInfo and use hasMetaInfo(vm) instead') // eslint-disable-line no-console
console.warn('VueMeta DeprecationWarning: _hasMetaInfo has been deprecated and will be removed in a future version. Please use hasMetaInfo(vm) instead') // eslint-disable-line no-console
this.$root._vueMeta.hasMetaInfoDeprecationWarningShown = true
}
return !!this._vueMeta
return hasMetaInfo(this)
}
})
@@ -28,8 +30,18 @@ export default function createMixin(Vue, options) {
this.$root._vueMeta = {}
}
// to speed up updates we keep track of branches which have a component with vue-meta info defined
// if _vueMeta = true it has info, if _vueMeta = false a child has info
if (!this._vueMeta) {
this._vueMeta = true
let p = this.$parent
while (p && p !== this.$root) {
if (isUndefined(p._vueMeta)) {
p._vueMeta = false
}
p = p.$parent
}
}
// coerce function-style metaInfo to a computed prop so we can observe
@@ -55,44 +67,37 @@ export default function createMixin(Vue, options) {
// force an initial refresh on page load and prevent other lifecycleHooks
// to triggerUpdate until this initial refresh is finished
// this is to make sure that when a page is opened in an inactive tab which
// has throttled rAF/timers we still immeditately set the page title
// has throttled rAF/timers we still immediately set the page title
if (isUndefined(this.$root._vueMeta.initialized)) {
this.$root._vueMeta.initialized = this.$isServer
if (!this.$root._vueMeta.initialized) {
const $rootMeta = this.$root.$meta()
ensuredPush(this.$options, 'mounted', () => {
if (!this.$root._vueMeta.initialized) {
// refresh meta in nextTick so all child components have loaded
this.$nextTick(function () {
$rootMeta.refresh()
this.$root.$meta().refresh()
this.$root._vueMeta.initialized = true
})
}
})
// add vue-router navigation guard to prevent multiple updates during navigation
// only usefull on the client side
if (options.refreshOnceOnNavigation && this.$root.$router) {
const $router = this.$root.$router
$router.beforeEach((to, from, next) => {
$rootMeta.pause()
next()
})
$router.afterEach(() => {
const { vm, metaInfo } = $rootMeta.resume()
if (metaInfo && metaInfo.afterNavigation && isFunction(metaInfo.afterNavigation)) {
metaInfo.afterNavigation.call(vm, metaInfo)
}
})
// add the navigation guards if they havent been added yet
if (options.refreshOnceOnNavigation) {
addNavGuards(this)
}
}
}
// do not trigger refresh on the server side
if (!this.$isServer) {
// add the navigation guards if they havent been added yet
// if metaInfo is defined as a function, this does call the computed fn redundantly
// but as Vue internally caches the results of computed props it shouldnt hurt performance
if (!options.refreshOnceOnNavigation && this.$options[options.keyName].afterNavigation) {
addNavGuards(this)
}
// no need to add this hooks on server side
updateOnLifecycleHook.forEach((lifecycleHook) => {
ensuredPush(this.$options, lifecycleHook, () => triggerUpdate(this, lifecycleHook))
@@ -103,12 +108,14 @@ export default function createMixin(Vue, options) {
// Wait that element is hidden before refreshing meta tags (to support animations)
const interval = setInterval(() => {
if (this.$el && this.$el.offsetParent !== null) {
/* istanbul ignore next line */
return
}
clearInterval(interval)
if (!this.$parent) {
/* istanbul ignore next line */
return
}
+26
View File
@@ -0,0 +1,26 @@
import { isFunction } from '../utils/is-type'
export function addNavGuards(vm) {
// return when nav guards already added or no router exists
if (vm.$root._vueMeta.navGuards || !vm.$root.$router) {
/* istanbul ignore next */
return
}
vm.$root._vueMeta.navGuards = true
const $router = vm.$root.$router
const $meta = vm.$root.$meta()
$router.beforeEach((to, from, next) => {
$meta.pause()
next()
})
$router.afterEach(() => {
const { metaInfo } = $meta.resume()
if (metaInfo && metaInfo.afterNavigation && isFunction(metaInfo.afterNavigation)) {
metaInfo.afterNavigation(metaInfo)
}
})
}
+11 -31
View File
@@ -1,25 +1,7 @@
import { isObject, isFunction } from './typeof'
import { isObject } from '../utils/is-type'
import { defaultOptions } from './constants'
import {
keyName,
attribute,
ssrAttribute,
tagIDKeyName,
metaTemplateKeyName,
contentKeyName
} from './constants'
// set some default options
const defaultOptions = {
keyName,
contentKeyName,
metaTemplateKeyName,
attribute,
ssrAttribute,
tagIDKeyName
}
export default function setOptions(options) {
export function setOptions(options) {
// combine options
options = isObject(options) ? options : {}
@@ -29,15 +11,13 @@ export default function setOptions(options) {
}
}
if (options.afterNavigation && !isFunction(options.afterNavigation)) {
console.warn(`afterNavigation should be a function, received ${typeof options.afterNavigation} instead`) // eslint-disable-line no-console
options.afterNavigation = void 0
return options
}
if (options.afterNavigation && !options.refreshOnceOnNavigation) {
options.refreshOnceOnNavigation = true
}
return options
}
export function getOptions(options) {
const optionsCopy = {}
for (const key in options) {
optionsCopy[key] = options[key]
}
return optionsCopy
}
+23
View File
@@ -0,0 +1,23 @@
import { isUndefined, isFunction } from '../utils/is-type'
export function applyTemplate({ component, metaTemplateKeyName, contentKeyName }, headObject, template, chunk) {
if (isUndefined(template)) {
template = headObject[metaTemplateKeyName]
delete headObject[metaTemplateKeyName]
}
// return early if no template defined
if (!template) {
return false
}
if (isUndefined(chunk)) {
chunk = headObject[contentKeyName]
}
headObject[contentKeyName] = isFunction(template)
? template.call(component, chunk)
: template.replace(/%s/g, chunk)
return true
}
-7
View File
@@ -1,7 +0,0 @@
export default function uniqBy(inputArray, predicate) {
return inputArray
.filter((x, i, arr) => i === arr.length - 1
? true
: predicate(x) !== predicate(arr[i + 1])
)
}
+45
View File
@@ -0,0 +1,45 @@
/*
* To reduce build size, this file provides simple polyfills without
* overly excessive type checking and without modifying
* the global Array.prototype
* The polyfills are automatically removed in the commonjs build
* Also, only files in client/ & shared/ should use these functions
* files in server/ still use normal js function
*/
// this const is replaced by rollup to true for umd builds
// which means the polyfills are removed for other build formats
const polyfill = process.env.NODE_ENV === 'test'
export function findIndex(array, predicate) {
if (polyfill && !Array.prototype.findIndex) {
// idx needs to be a Number, for..in returns string
for (let idx = 0; idx < array.length; idx++) {
if (predicate.call(arguments[2], array[idx], idx, array)) {
return idx
}
}
return -1
}
return array.findIndex(predicate, arguments[2])
}
export function toArray(arg) {
if (polyfill && !Array.from) {
return Array.prototype.slice.call(arg)
}
return Array.from(arg)
}
export function includes(array, value) {
if (polyfill && !Array.prototype.includes) {
for (const idx in array) {
if (array[idx] === value) {
return true
}
}
return false
}
return array.includes(value)
}
+1 -2
View File
@@ -1,5 +1,4 @@
import isArray from './isArray'
import { isObject } from './typeof'
import { isArray, isObject } from './is-type'
export function ensureIsArray(arg, key) {
if (!key || !isObject(arg)) {
@@ -1,3 +1,12 @@
/**
* checks if passed argument is an array
* @param {any} arg - the object to check
* @return {Boolean} - true if `arg` is an array
*/
export function isArray(arg) {
return Array.isArray(arg)
}
export function isUndefined(arg) {
return typeof arg === 'undefined'
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { isUndefined } from './typeof'
import { isUndefined } from './is-type'
export function hasGlobalWindowFn() {
try {
-97
View File
@@ -1,97 +0,0 @@
import _getMetaInfo from '../src/shared/getMetaInfo'
import { mount, defaultOptions, loadVueMetaPlugin } from './utils'
import GoodbyeWorld from './fixtures/goodbye-world.vue'
import HelloWorld from './fixtures/hello-world.vue'
import KeepAlive from './fixtures/keep-alive.vue'
import Changed from './fixtures/changed.vue'
const getMetaInfo = component => _getMetaInfo(defaultOptions, component)
describe('client', () => {
let Vue
beforeAll(() => (Vue = loadVueMetaPlugin()))
test('meta-info refreshed on component\'s data change', () => {
const wrapper = mount(HelloWorld, { localVue: Vue })
let metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
wrapper.setData({ title: 'Goodbye World' })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Goodbye World')
})
test('child meta-info removed when child is toggled', () => {
const wrapper = mount(GoodbyeWorld, { localVue: Vue })
let metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
wrapper.setData({ childVisible: false })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Goodbye World')
wrapper.setData({ childVisible: true })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
})
test('child meta-info removed when keep-alive child is toggled', () => {
const wrapper = mount(KeepAlive, { localVue: Vue })
let metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
wrapper.setData({ childVisible: false })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Alive World')
wrapper.setData({ childVisible: true })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
})
test('meta-info is removed when destroyed', () => {
const parentComponent = new Vue({ render: h => h('div') })
const wrapper = mount(HelloWorld, { localVue: Vue, parentComponent })
let metaInfo = getMetaInfo(wrapper.vm.$parent)
expect(metaInfo.title).toEqual('Hello World')
wrapper.destroy()
jest.runAllTimers()
metaInfo = getMetaInfo(wrapper.vm.$parent)
expect(metaInfo.title).toEqual('')
})
test('meta-info can be rendered with inject', () => {
const wrapper = mount(HelloWorld, { localVue: Vue })
const metaInfo = wrapper.vm.$meta().inject()
expect(metaInfo.title.text()).toEqual('<title data-vue-meta="true">Hello World</title>')
})
test('changed function is called', () => {
const parentComponent = new Vue({ render: h => h('div') })
const wrapper = mount(Changed, { localVue: Vue, parentComponent })
let context
const changed = jest.fn(function () {
context = this
})
wrapper.setData({ changed })
wrapper.setData({ childVisible: true })
wrapper.vm.$parent.$meta().refresh()
expect(changed).toHaveBeenCalledTimes(1)
// TODO: this isnt what the docs say
expect(context._uid).not.toBe(wrapper.vm._uid)
})
})
@@ -11,16 +11,24 @@ export default {
components: {
HelloWorld
},
props: {
changed: {
type: Function
}
},
metaInfo() {
return {
changed: this.changed
changed: this._changed
}
},
data() {
return {
childVisible: false,
changed: () => {}
_changed: () => {}
}
},
mounted() {
this._changed = this.changed.bind(this)
}
}
</script>
+79
View File
@@ -0,0 +1,79 @@
import Browser from '../utils/browser'
import { buildFixture } from '../utils/build'
const browser = new Browser()
describe('basic browser with ssr page', () => {
let page = null
let url
let html
beforeAll(async () => {
const fixture = await buildFixture('basic')
url = fixture.url
html = fixture.html
await browser.start({
// slowMo: 50,
// headless: false
})
})
// Stop browser
afterAll(async () => {
if (page) await page.close()
await browser.close()
})
test('validate ssr', () => {
const htmlTag = html.match(/<html([^>]+)>/)[0]
expect(htmlTag).toContain('data-vue-meta-server-rendered')
expect(htmlTag).toContain(' lang="en" ')
expect(htmlTag).toContain(' amp ')
expect(htmlTag).not.toContain('allowfullscreen')
expect(html.match(/<title[^>]*>(.*?)<\/title>/)[1]).toBe('Home | Vue Meta Test')
expect(html.match(/<meta/g).length).toBe(2)
expect(html.match(/<meta/g).length).toBe(2)
const re = /<(no)?script[^>]+type="application\/ld\+json"[^>]*>(.*?)</g
const sanitizeCheck = []
let match
while ((match = re.exec(html))) {
sanitizeCheck.push(match[2])
}
expect(sanitizeCheck.length).toBe(3)
expect(() => JSON.parse(sanitizeCheck[0])).not.toThrow()
expect(() => JSON.parse(sanitizeCheck[1])).toThrow()
expect(() => JSON.parse(sanitizeCheck[2])).not.toThrow()
})
test('Open /', async () => {
page = await browser.page(url)
expect(await page.$attr('html', 'data-vue-meta-server-rendered')).toBe(null)
expect(await page.$attr('html', 'lang')).toBe('en')
expect(await page.$attr('html', 'amp')).toBe('')
expect(await page.$attr('html', 'allowfullscreen')).toBe(null)
expect(await page.$attr('head', 'test')).toBe('true')
expect(await page.$text('h1')).toBe('Basic')
expect(await page.$text('title')).toBe('Home | Vue Meta Test')
expect(await page.$$eval('meta', metas => metas.length)).toBe(2)
let sanitizeCheck = await page.$$text('script')
sanitizeCheck.push(...(await page.$$text('noscript')))
sanitizeCheck = sanitizeCheck.filter(v => !!v)
expect(sanitizeCheck.length).toBe(3)
expect(() => JSON.parse(sanitizeCheck[0])).not.toThrow()
expect(() => JSON.parse(sanitizeCheck[1])).not.toThrow()
expect(() => JSON.parse(sanitizeCheck[2])).not.toThrow()
})
test('/about', async () => {
const { hook } = await page.vueMeta.navigate('/about', false)
await hook
expect(await page.$text('title')).toBe('About')
expect(await page.$$eval('meta', metas => metas.length)).toBe(1)
})
})
-37
View File
@@ -1,37 +0,0 @@
import _getMetaInfo from '../src/shared/getMetaInfo'
import { defaultOptions, loadVueMetaPlugin } from './utils'
const getMetaInfo = (component, escapeSequences) => _getMetaInfo(defaultOptions, component, escapeSequences)
describe('escaping', () => {
let Vue
beforeAll(() => (Vue = loadVueMetaPlugin()))
test('special chars are escaped unless disabled', () => {
const component = new Vue({
metaInfo: {
title: 'Hello & Goodbye',
script: [{ innerHTML: 'Hello & Goodbye' }],
__dangerouslyDisableSanitizers: ['script']
}
})
expect(getMetaInfo(component, [[/&/g, '&amp;']])).toEqual({
title: 'Hello &amp; Goodbye',
titleChunk: 'Hello &amp; Goodbye',
titleTemplate: '%s',
htmlAttrs: {},
headAttrs: {},
bodyAttrs: {},
meta: [],
base: [],
link: [],
style: [],
script: [{ innerHTML: 'Hello & Goodbye' }],
noscript: [],
__dangerouslyDisableSanitizers: ['script'],
__dangerouslyDisableSanitizersByTagID: {}
})
})
})
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html data-vue-meta-server-rendered {{ htmlAttrs.text() }}>
<head {{ headAttrs.text() }}>
{{ meta.text() }}
{{ title.text() }}
{{ link.text() }}
{{ style.text() }}
{{ webpackAssets }}
{{ script.text() }}
{{ noscript.text() }}
</head>
<body {{ bodyAttrs.text() }}>
{{ app }}
{{ script.text({ body: true }) }}
{{ noscript.text({ body: true }) }}
</body>
</html>
-33
View File
@@ -1,33 +0,0 @@
<template>
<html {{ head.headAttrs.text() }}>
<head></head>
bla
</html>
</template>
<script>
export default {
metaInfo() {
return {
title: this.title
}
},
data() {
return {
title: 'Hello World',
htmlAttrs: {
lang: 'en'
},
meta: [
{ charset: 'utf-8' }
]
}
},
computed: {
head() {
return meta.inject()
}
}
}
</script>
+21
View File
@@ -0,0 +1,21 @@
<template>
<div id="app">
<h1>Basic</h1>
<router-view></router-view>
<p>Inspect Element to see the meta info</p>
</div>
</template>
<script>
export default {
metaInfo: {
meta: [
{ vmid: 'charset', charset: 'utf-8' }
]
},
mounted() {
this.$router.afterEach((to, from) => this.$emit('routeChanged', to, from))
window.$vueMeta = this
}
}
</script>
+10
View File
@@ -0,0 +1,10 @@
import Vue from 'vue'
import VueMeta from '../../../src/browser'
import App from './App.vue'
import createRouter from './router'
Vue.use(VueMeta)
App.router = createRouter()
new Vue(App).$mount('#app')
+18
View File
@@ -0,0 +1,18 @@
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
const Home = () => import('./views/home.vue')
const Post = () => import('./views/about.vue')
export default function createRouter() {
return new Router({
mode: 'hash',
base: '/',
routes: [
{ path: '/', component: Home },
{ path: '/about', component: Post }
]
})
}
+10
View File
@@ -0,0 +1,10 @@
import Vue from 'vue'
import VueMeta from '../../../src'
import App from './App.vue'
import createRouter from './router'
Vue.use(VueMeta)
App.router = createRouter()
export default new Vue(App)
+16
View File
@@ -0,0 +1,16 @@
<template>
<div>
<h2>About</h2>
<router-link to="/">Go to Home</router-link>
</div>
</template>
<script>
export default {
metaInfo() {
return {
title: 'About'
}
}
}
</script>
+37
View File
@@ -0,0 +1,37 @@
<template>
<div>
<h2>Home</h2>
<router-link to="/about">Go to About</router-link>
</div>
</template>
<script>
export default {
metaInfo() {
return {
title: 'Home',
titleTemplate: '%s | Vue Meta Test',
htmlAttrs: {
lang: 'en',
allowfullscreen: undefined,
amp: true
},
headAttrs: {
test: true
},
meta: [
{ name: 'description', content: 'Hello', vmid: 'test' }
],
script: [
{ vmid: 'ldjson', innerHTML: '{ "@context": "http://www.schema.org", "@type": "Organization" }', type: 'application/ld+json' },
{ innerHTML: '{ "more": "data" }', type: 'application/ld+json' }
],
noscript: [
{ innerHTML: '{ "body": "yes" }', body: true, type: 'application/ld+json' }
],
__dangerouslyDisableSanitizers: ['noscript'],
__dangerouslyDisableSanitizersByTagID: { ldjson: ['innerHTML'] }
}
}
}
</script>
-76
View File
@@ -1,76 +0,0 @@
import getComponentOption from '../src/shared/getComponentOption'
import { getVue } from './utils'
describe('getComponentOption', () => {
let Vue
beforeAll(() => (Vue = getVue()))
it('returns an empty object when no matching options are found', () => {
const component = new Vue()
const mergedOption = getComponentOption({ component, keyName: 'noop' })
expect(mergedOption).toEqual({})
})
it('fetches the given option from the given component', () => {
const component = new Vue({ someOption: 'foo' })
const mergedOption = getComponentOption({ component, keyName: 'someOption' })
expect(mergedOption).toEqual('foo')
})
it('calls a function option, injecting the component as context', () => {
const component = new Vue({
name: 'Foobar',
someFunc() {
return this.$options.name
}
})
const mergedOption = getComponentOption({ component, keyName: 'someFunc' })
// TODO: Should this be foobar or Foobar
expect(mergedOption).toEqual('Foobar')
})
it('fetches deeply nested component options and merges them', () => {
Vue.component('merge-child', { render: h => h('div'), foo: { bar: 'baz' } })
const component = new Vue({
foo: { fizz: 'buzz' },
el: document.createElement('div'),
render: h => h('div', null, [h('merge-child')])
})
const mergedOption = getComponentOption({ component, keyName: 'foo', deep: true })
expect(mergedOption).toEqual({ bar: 'baz', fizz: 'buzz' })
})
it('allows for a custom array merge strategy', () => {
Vue.component('array-child', {
render: h => h('div'),
foo: [
{ name: 'flower', content: 'rose' }
]
})
const component = new Vue({
foo: [
{ name: 'flower', content: 'tulip' }
],
el: document.createElement('div'),
render: h => h('div', null, [h('array-child')])
})
const mergedOption = getComponentOption({
component,
keyName: 'foo',
deep: true,
arrayMerge(target, source) {
return target.concat(source)
}
})
expect(mergedOption).toEqual([
{ name: 'flower', content: 'tulip' },
{ name: 'flower', content: 'rose' }
])
})
})
+196
View File
@@ -0,0 +1,196 @@
import _getMetaInfo from '../../src/shared/getMetaInfo'
import { mount, loadVueMetaPlugin, vmTick } from '../utils'
import { defaultOptions } from '../../src/shared/constants'
import GoodbyeWorld from '../components/goodbye-world.vue'
import HelloWorld from '../components/hello-world.vue'
import KeepAlive from '../components/keep-alive.vue'
import Changed from '../components/changed.vue'
const getMetaInfo = component => _getMetaInfo(defaultOptions, component)
jest.mock('../../src/utils/window', () => ({
hasGlobalWindow: false
}))
describe('client', () => {
let Vue
let html
beforeAll(() => {
Vue = loadVueMetaPlugin()
// force using timers, jest cant mock rAF
delete window.requestAnimationFrame
delete window.cancelAnimationFrame
html = document.createElement('html')
document._getElementsByTagName = document.getElementsByTagName
jest.spyOn(document, 'getElementsByTagName').mockImplementation((tag) => {
if (tag === 'html') {
return [html]
}
return document._getElementsByTagName(tag)
})
})
test('meta-info refreshed on component\'s data change', () => {
const wrapper = mount(HelloWorld, { localVue: Vue })
let metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
wrapper.setData({ title: 'Goodbye World' })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Goodbye World')
})
test('child meta-info removed when child is toggled', () => {
const wrapper = mount(GoodbyeWorld, { localVue: Vue })
let metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
wrapper.setData({ childVisible: false })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Goodbye World')
wrapper.setData({ childVisible: true })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
})
test('child meta-info removed when keep-alive child is toggled', () => {
const wrapper = mount(KeepAlive, { localVue: Vue })
let metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
wrapper.setData({ childVisible: false })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Alive World')
wrapper.setData({ childVisible: true })
metaInfo = getMetaInfo(wrapper.vm)
expect(metaInfo.title).toEqual('Hello World')
})
test('meta-info is removed when destroyed', () => {
const parentComponent = new Vue({ render: h => h('div') })
const wrapper = mount(HelloWorld, { localVue: Vue, parentComponent })
let metaInfo = getMetaInfo(wrapper.vm.$parent)
expect(metaInfo.title).toEqual('Hello World')
wrapper.destroy()
jest.runAllTimers()
metaInfo = getMetaInfo(wrapper.vm.$parent)
expect(metaInfo.title).toEqual('')
})
test('meta-info can be rendered with inject', () => {
const wrapper = mount(HelloWorld, { localVue: Vue })
const metaInfo = wrapper.vm.$meta().inject()
expect(metaInfo.title.text()).toEqual('<title data-vue-meta="true">Hello World</title>')
})
test('doesnt update when ssr attribute is set', () => {
html.setAttribute(defaultOptions.ssrAttribute, 'true')
const wrapper = mount(HelloWorld, { localVue: Vue })
const { tags } = wrapper.vm.$meta().refresh()
expect(tags).toBe(false)
})
test('changed function is called', async () => {
let context
const changed = jest.fn(function () {
context = this
})
const wrapper = mount(Changed, { localVue: Vue, propsData: { changed } })
await vmTick(wrapper.vm)
expect(wrapper.vm.$root._vueMeta.initialized).toBe(true)
// TODO: does changed need to run on initialization?
expect(changed).toHaveBeenCalledTimes(1)
wrapper.setData({ childVisible: true })
jest.runAllTimers()
expect(changed).toHaveBeenCalledTimes(2)
expect(context._uid).toBe(wrapper.vm._uid)
})
test('afterNavigation function is called with refreshOnce: true', () => {
const Vue = loadVueMetaPlugin(false, { refreshOnceOnNavigation: true })
const afterNavigation = jest.fn()
const component = Vue.component('nav-component', {
render: h => h('div'),
metaInfo: { afterNavigation }
})
const guards = {}
const wrapper = mount(component, {
localVue: Vue,
mocks: {
$router: {
beforeEach(fn) {
guards.before = fn
},
afterEach(fn) {
guards.after = fn
}
}
}
})
expect(guards.before).toBeDefined()
expect(guards.after).toBeDefined()
guards.before(null, null, () => {})
expect(wrapper.vm.$root._vueMeta.paused).toBe(true)
guards.after()
expect(afterNavigation).toHaveBeenCalled()
})
test('afterNavigation function is called with refreshOnce: false', () => {
const Vue = loadVueMetaPlugin(false, { refreshOnceOnNavigation: false })
const afterNavigation = jest.fn()
const component = Vue.component('nav-component', {
render: h => h('div'),
metaInfo: { afterNavigation }
})
const guards = {}
const wrapper = mount(component, {
localVue: Vue,
mocks: {
$router: {
beforeEach(fn) {
guards.before = fn
},
afterEach(fn) {
guards.after = fn
}
}
}
})
expect(guards.before).toBeDefined()
expect(guards.after).toBeDefined()
guards.before(null, null, () => {})
expect(wrapper.vm.$root._vueMeta.paused).toBe(true)
guards.after()
expect(afterNavigation).toHaveBeenCalled()
})
})
+74
View File
@@ -0,0 +1,74 @@
import _getMetaInfo from '../../src/shared/getMetaInfo'
import { loadVueMetaPlugin } from '../utils'
import { defaultOptions } from '../../src/shared/constants'
const getMetaInfo = (component, escapeSequences) => _getMetaInfo(defaultOptions, component, escapeSequences)
describe('escaping', () => {
let Vue
beforeAll(() => (Vue = loadVueMetaPlugin()))
test('special chars are escaped unless disabled', () => {
const component = new Vue({
metaInfo: {
htmlAttrs: { key: 1 },
title: 'Hello & Goodbye',
script: [{ innerHTML: 'Hello & Goodbye' }],
__dangerouslyDisableSanitizers: ['script']
}
})
expect(getMetaInfo(component, [[/&/g, '&amp;']])).toEqual({
title: 'Hello &amp; Goodbye',
titleChunk: 'Hello & Goodbye',
titleTemplate: '%s',
htmlAttrs: {
key: 1
},
headAttrs: {},
bodyAttrs: {},
meta: [],
base: [],
link: [],
style: [],
script: [{ innerHTML: 'Hello & Goodbye' }],
noscript: [],
__dangerouslyDisableSanitizers: ['script'],
__dangerouslyDisableSanitizersByTagID: {}
})
})
test('special chars are escaped unless disabled by vmid', () => {
const component = new Vue({
metaInfo: {
title: 'Hello',
script: [
{ vmid: 'yescape', innerHTML: 'Hello & Goodbye' },
{ vmid: 'noscape', innerHTML: 'Hello & Goodbye' }
],
__dangerouslyDisableSanitizersByTagID: { noscape: ['innerHTML'] }
}
})
expect(getMetaInfo(component, [[/&/g, '&amp;']])).toEqual({
title: 'Hello',
titleChunk: 'Hello',
titleTemplate: '%s',
htmlAttrs: {},
headAttrs: {},
bodyAttrs: {},
meta: [],
base: [],
link: [],
style: [],
script: [
{ innerHTML: 'Hello &amp; Goodbye', vmid: 'yescape' },
{ innerHTML: 'Hello & Goodbye', vmid: 'noscape' }
],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: { noscape: ['innerHTML'] }
})
})
})
@@ -1,6 +1,6 @@
import _generateServerInjector from '../src/server/generateServerInjector'
import { defaultOptions } from './utils'
import metaInfoData from './utils/meta-info-data'
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)
+127
View File
@@ -0,0 +1,127 @@
import getComponentOption from '../../src/shared/getComponentOption'
import { inMetaInfoBranch } from '../../src/shared/meta-helpers'
import { mount, getVue, loadVueMetaPlugin } from '../utils'
describe('getComponentOption', () => {
let Vue
beforeAll(() => (Vue = getVue()))
test('returns an empty object when no matching options are found', () => {
const component = new Vue()
const mergedOption = getComponentOption({ keyName: 'noop' }, component)
expect(mergedOption).toEqual({})
})
test('fetches the given option from the given component', () => {
const component = new Vue({ someOption: { foo: 'bar' } })
const mergedOption = getComponentOption({ keyName: 'someOption' }, component)
expect(mergedOption.foo).toBeDefined()
expect(mergedOption.foo).toEqual('bar')
})
test('calls a function option, injecting the component as context', () => {
const component = new Vue({
name: 'Foobar',
someFunc() {
return { opt: this.$options.name }
}
})
const mergedOption = getComponentOption({ keyName: 'someFunc' }, component)
// TODO: Should this be foobar or Foobar
expect(mergedOption.opt).toBeDefined()
expect(mergedOption.opt).toEqual('Foobar')
})
test('fetches deeply nested component options and merges them', () => {
const localVue = loadVueMetaPlugin(true, { keyName: 'foo' })
localVue.component('merge-child', { render: h => h('div'), foo: { bar: 'baz' } })
const component = localVue.component('parent', {
foo: { fizz: 'buzz' },
render: h => h('div', null, [h('merge-child')])
})
const wrapper = mount(component, { localVue })
const mergedOption = getComponentOption({ keyName: 'foo' }, wrapper.vm)
expect(mergedOption).toEqual({ bar: 'baz', fizz: 'buzz' })
})
/* this undocumented functionality has been removed
test('allows for a custom array merge strategy', () => {
const localVue = loadVueMetaPlugin(false, { keyName: 'foo' })
localVue.component('array-child', {
render: h => h('div'),
foo: {
meta: [
{ name: 'flower', content: 'rose' }
]
}
})
const component = localVue.component('parent', {
foo: {
meta: [
{ name: 'flower', content: 'tulip' }
]
},
render: h => h('div', null, [h('array-child')])
})
const wrapper = mount(component, { localVue })
const mergedOption = getComponentOption({
component: wrapper.vm,
keyName: 'foo',
arrayMerge(target, source) {
return target.concat(source)
}
})
expect(mergedOption).toEqual({ meta: [
{ name: 'flower', content: 'tulip' },
{ name: 'flower', content: 'rose' }
] })
}) */
test('only traverses branches with metaInfo components', () => {
const localVue = loadVueMetaPlugin(false, { keyName: 'foo' })
localVue.component('meta-child', {
foo: { bar: 'baz' },
render(h) {
return h('div', this.$slots.default)
}
})
localVue.component('nometa-child', {
render(h) {
return h('div', this.$slots.default)
}
})
const component = localVue.component('parent', {
render: h => h('div', null, [
h('meta-child', null, [ h('nometa-child') ]),
h('nometa-child', null, [ h('meta-child') ]),
h('nometa-child')
])
})
const wrapper = mount(component, { localVue })
const mergedOption = getComponentOption({ keyName: 'foo' }, wrapper.vm)
expect(mergedOption).toEqual({ bar: 'baz' })
expect(wrapper.vm.$children[0]._vueMeta).toBe(true)
expect(wrapper.vm.$children[1]._vueMeta).toBe(false)
expect(wrapper.vm.$children[2]._vueMeta).toBeUndefined()
expect(inMetaInfoBranch(wrapper.vm.$children[0])).toBe(true)
expect(inMetaInfoBranch(wrapper.vm.$children[0].$children[0])).toBe(false)
expect(inMetaInfoBranch(wrapper.vm.$children[1])).toBe(true)
expect(inMetaInfoBranch(wrapper.vm.$children[1].$children[0])).toBe(true)
expect(inMetaInfoBranch(wrapper.vm.$children[2])).toBe(false)
})
})
@@ -1,5 +1,6 @@
import _getMetaInfo from '../src/shared/getMetaInfo'
import { defaultOptions, loadVueMetaPlugin } from './utils'
import _getMetaInfo from '../../src/shared/getMetaInfo'
import { loadVueMetaPlugin } from '../utils'
import { defaultOptions } from '../../src/shared/constants'
const getMetaInfo = component => _getMetaInfo(defaultOptions, component)
@@ -117,7 +118,7 @@ describe('getMetaInfo', () => {
{
vmid: 'a',
property: 'a',
content: 'b'
content: 'a'
}
],
base: [],
@@ -482,8 +483,6 @@ describe('getMetaInfo', () => {
})
})
// TODO: Still failing :( Child template won't be applied if child has no content as well
test('properly uses meta templates with one-level-deep nested children template', () => {
Vue.component('merge-child', {
render: h => h('div'),
@@ -592,4 +591,233 @@ describe('getMetaInfo', () => {
__dangerouslyDisableSanitizersByTagID: {}
})
})
test('properly uses meta templates with one-level-deep nested children when parent has no template', () => {
Vue.component('merge-child', {
render: h => h('div'),
metaInfo: {
title: 'Hello',
meta: [
{
vmid: 'og:title',
property: 'og:title',
content: 'An important title!',
template: chunk => `${chunk} - My page`
}
]
}
})
const component = new Vue({
metaInfo: {
meta: [
{
vmid: 'og:title',
property: 'og:title',
content: 'Test title'
}
]
},
el: document.createElement('div'),
render: h => h('div', null, [h('merge-child')])
})
expect(getMetaInfo(component)).toEqual({
title: 'Hello',
titleChunk: 'Hello',
titleTemplate: '%s',
htmlAttrs: {},
headAttrs: {},
bodyAttrs: {},
meta: [
{
vmid: 'og:title',
property: 'og:title',
content: 'An important title! - My page'
}
],
base: [],
link: [],
style: [],
script: [],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: {}
})
})
test('no errors when metaInfo returns nothing', () => {
const component = new Vue({
metaInfo() {},
el: document.createElement('div'),
render: h => h('div', null, [])
})
expect(getMetaInfo(component)).toEqual({
title: '',
titleChunk: '',
titleTemplate: '%s',
htmlAttrs: {},
headAttrs: {},
bodyAttrs: {},
meta: [],
base: [],
link: [],
style: [],
script: [],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: {}
})
})
test('child can indicate its content should be ignored', () => {
Vue.component('merge-child', {
render: h => h('div'),
metaInfo: {
title: undefined,
bodyAttrs: {
class: undefined
},
meta: [
{
vmid: 'og:title',
content: undefined
}
]
}
})
const component = new Vue({
metaInfo: {
title: 'Hello',
bodyAttrs: {
class: 'class'
},
meta: [
{
vmid: 'og:title',
property: 'og:title',
content: 'Test title',
template: chunk => `${chunk} - My page`
}
]
},
el: document.createElement('div'),
render: h => h('div', null, [h('merge-child')])
})
expect(getMetaInfo(component)).toEqual({
title: 'Hello',
titleChunk: 'Hello',
titleTemplate: '%s',
bodyAttrs: {
class: 'class'
},
headAttrs: {},
htmlAttrs: {},
meta: [
{
vmid: 'og:title',
property: 'og:title',
content: 'Test title - My page'
}
],
base: [],
link: [],
style: [],
script: [],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: {}
})
})
test('child can indicate to remove parent vmids', () => {
Vue.component('merge-child', {
render: h => h('div'),
metaInfo: {
title: 'Hi',
meta: [
{
vmid: 'og:title',
content: null
}
]
}
})
const component = new Vue({
metaInfo: {
title: 'Hello',
meta: [
{
vmid: 'og:title',
property: 'og:title',
content: 'Test title',
template: chunk => `${chunk} - My page`
}
]
},
el: document.createElement('div'),
render: h => h('div', null, [h('merge-child')])
})
expect(getMetaInfo(component)).toEqual({
title: 'Hi',
titleChunk: 'Hi',
titleTemplate: '%s',
htmlAttrs: {},
headAttrs: {},
bodyAttrs: {},
meta: [],
base: [],
link: [],
style: [],
script: [],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: {}
})
})
test('attribute values can be an array', () => {
Vue.component('merge-child', {
render: h => h('div'),
metaInfo: {
bodyAttrs: {
class: ['foo']
}
}
})
const component = new Vue({
metaInfo: {
bodyAttrs: {
class: ['bar']
}
},
el: document.createElement('div'),
render: h => h('div', null, [h('merge-child')])
})
expect(getMetaInfo(component)).toEqual({
title: '',
titleChunk: '',
titleTemplate: '%s',
htmlAttrs: {},
headAttrs: {},
bodyAttrs: {
class: ['bar', 'foo']
},
meta: [],
base: [],
link: [],
style: [],
script: [],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: {}
})
})
})
@@ -1,10 +1,11 @@
import triggerUpdate from '../src/client/triggerUpdate'
import batchUpdate from '../src/client/batchUpdate'
import { mount, defaultOptions, vmTick, VueMetaBrowserPlugin, loadVueMetaPlugin } from './utils'
import triggerUpdate from '../../src/client/triggerUpdate'
import batchUpdate from '../../src/client/batchUpdate'
import { mount, vmTick, VueMetaBrowserPlugin, loadVueMetaPlugin } from '../utils'
import { defaultOptions } from '../../src/shared/constants'
jest.mock('../src/client/triggerUpdate')
jest.mock('../src/client/batchUpdate')
jest.mock('../package.json', () => ({
jest.mock('../../src/client/triggerUpdate')
jest.mock('../../src/client/batchUpdate')
jest.mock('../../package.json', () => ({
version: 'test-version'
}))
@@ -20,9 +21,14 @@ describe('plugin', () => {
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()
const options = instance.$meta().getOptions()
expect(options).toBeDefined()
expect(options.keyName).toBe(defaultOptions.keyName)
})
test('component has _hasMetaInfo set to true', () => {
@@ -42,8 +48,8 @@ describe('plugin', () => {
})
test('updates can be paused and resumed', async () => {
const _triggerUpdate = jest.requireActual('../src/client/triggerUpdate').default
const _batchUpdate = jest.requireActual('../src/client/batchUpdate').default
const _triggerUpdate = jest.requireActual('../../src/client/triggerUpdate').default
const _batchUpdate = jest.requireActual('../../src/client/batchUpdate').default
const triggerUpdateSpy = triggerUpdate.mockImplementation(_triggerUpdate)
const batchUpdateSpy = batchUpdate.mockImplementation(_batchUpdate)
@@ -1,6 +1,7 @@
import { mount, defaultOptions, VueMetaServerPlugin, loadVueMetaPlugin } from './utils'
import { mount, VueMetaServerPlugin, loadVueMetaPlugin } from '../utils'
import { defaultOptions } from '../../src/shared/constants'
jest.mock('../package.json', () => ({
jest.mock('../../package.json', () => ({
version: 'test-version'
}))
@@ -13,6 +14,17 @@ describe('plugin', () => {
test('is loaded', () => {
const instance = new Vue()
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()
const options = instance.$meta().getOptions()
expect(options).toBeDefined()
expect(options.keyName).toBe(defaultOptions.keyName)
})
test('component has _hasMetaInfo set to true', () => {
+14
View File
@@ -0,0 +1,14 @@
import { setOptions } from '../../src/shared/options'
import { defaultOptions } from '../../src/shared/constants'
describe('shared', () => {
test('can use setOptions', () => {
const keyName = 'MY KEY'
let options = { keyName }
options = setOptions(options)
expect(options.keyName).toBe(keyName)
expect(options.contentKeyName).toBeDefined()
expect(options.contentKeyName).toBe(defaultOptions.contentKeyName)
})
})
@@ -1,6 +1,6 @@
import _updateClientMetaInfo from '../src/client/updateClientMetaInfo'
import { defaultOptions } from './utils'
import metaInfoData from './utils/meta-info-data'
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 })
+63
View File
@@ -0,0 +1,63 @@
/**
* @jest-environment node
*/
import { findIndex, includes, toArray } from '../../src/utils/array'
import { ensureIsArray } from '../../src/utils/ensure'
import { hasGlobalWindowFn } from '../../src/utils/window'
describe('shared', () => {
afterEach(() => jest.restoreAllMocks())
test('ensureIsArray ensures var is array', () => {
let a = { p: 1 }
expect(ensureIsArray(a)).toEqual([])
a = 1
expect(ensureIsArray(a)).toEqual([])
a = [1]
expect(ensureIsArray(a)).toBe(a)
})
test('ensureIsArray ensures obj prop is array', () => {
const a = { p: 1 }
expect(ensureIsArray(a, 'p')).toEqual({ p: [] })
})
test('no error when window is not defined', () => {
expect(hasGlobalWindowFn()).toBe(false)
})
/* eslint-disable no-extend-native */
test('findIndex polyfill', () => {
const _findIndex = Array.prototype.findIndex
Array.prototype.findIndex = false
const arr = [1, 2, 3]
expect(findIndex(arr, v => v === 2)).toBe(1)
expect(findIndex(arr, v => v === 4)).toBe(-1)
Array.prototype.findIndex = _findIndex
})
test('includes polyfill', () => {
const _includes = Array.prototype.includes
Array.prototype.includes = false
const arr = [1, 2, 3]
expect(includes(arr, 2)).toBe(true)
expect(includes(arr, 4)).toBe(false)
Array.prototype.includes = _includes
})
test('from/toArray polyfill', () => {
const _from = Array.from
Array.from = false
expect(toArray('foo')).toEqual(['f', 'o', 'o'])
Array.from = _from
})
/* eslint-enable no-extend-native */
})
+99
View File
@@ -0,0 +1,99 @@
import puppeteer from 'puppeteer-core'
import ChromeDetector from './chrome'
export default class Browser {
constructor() {
this.detector = new ChromeDetector()
}
async start(options = {}) {
// https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions
const _opts = {
args: [
'--no-sandbox',
'--disable-setuid-sandbox'
],
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
...options
}
if (!_opts.executablePath) {
_opts.executablePath = this.detector.detect()
}
this.browser = await puppeteer.launch(_opts)
}
async close() {
if (!this.browser) return
await this.browser.close()
}
async page(url, globalName = 'vueMeta') {
if (!this.browser) throw new Error('Please call start() before page(url)')
const page = await this.browser.newPage()
// pass on console messages
const typeMap = {
debug: 'debug',
warning: 'warn',
error: 'error'
}
page.on('console', (msg) => {
if (typeMap[msg.type()]) {
console[typeMap[msg.type()]](msg.text()) // eslint-disable-line no-console
}
})
await page.goto(url)
page.$globalHandle = `window.$${globalName}`
await page.waitForFunction(`!!${page.$globalHandle}`)
page.html = () => page.evaluate(() => window.document.documentElement.outerHTML)
page.$text = (selector, trim) => page.$eval(selector, (el, trim) => {
return trim ? el.textContent.replace(/^\s+|\s+$/g, '') : el.textContent
}, trim)
page.$$text = (selector, trim) =>
page.$$eval(selector, (els, trim) => els.map((el) => {
return trim ? el.textContent.replace(/^\s+|\s+$/g, '') : el.textContent
}), trim)
page.$attr = (selector, attr) =>
page.$eval(selector, (el, attr) => el.getAttribute(attr), attr)
page.$$attr = (selector, attr) =>
page.$$eval(
selector,
(els, attr) => els.map(el => el.getAttribute(attr)),
attr
)
page.$vueMeta = await page.evaluateHandle(page.$globalHandle)
page.vueMeta = {
async navigate(path, waitEnd = true) {
const hook = page.evaluate(`
new Promise(resolve =>
${page.$globalHandle}.$once('routeChanged', resolve)
).then(() => new Promise(resolve => setTimeout(resolve, 50)))
`)
await page.evaluate(
($vueMeta, path) => $vueMeta.$router.push(path),
page.$vueMeta,
path
)
if (waitEnd) {
await hook
}
return { hook }
},
routeData() {
return page.evaluate(($vueMeta) => {
return {
path: $vueMeta.$route.path,
query: $vueMeta.$route.query
}
}, page.$vueMeta)
}
}
return page
}
}
+114
View File
@@ -0,0 +1,114 @@
import path from 'path'
import fs from 'fs-extra'
import { template } from 'lodash'
import webpack from 'webpack'
import VueLoaderPlugin from 'vue-loader/lib/plugin'
import { createRenderer } from 'vue-server-renderer'
const renderer = createRenderer()
export function webpackRun(config) {
const compiler = webpack(config)
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
reject(err)
}
resolve(stats.toJson())
})
})
}
export async function buildFixture(fixture, config = {}) {
if (!fixture) {
throw new Error('buildFixture should be called with a fixture name')
}
const fixturePath = path.resolve(__dirname, '..', 'fixtures', fixture)
config.entry = path.resolve(fixturePath, 'client.js')
if (!config.name) {
config.name = path.basename(path.dirname(config.entry))
}
const webpackConfig = createWebpackConfig(config)
// remove old files
await fs.remove(webpackConfig.output.path)
// run webpack
const webpackStats = await webpackRun(webpackConfig)
// for test debugging
webpackStats.errors.forEach(e => console.error(e)) // eslint-disable-line no-console
webpackStats.warnings.forEach(e => console.warn(e)) // eslint-disable-line no-console
const vueApp = await import(path.resolve(fixturePath, 'server')).then(m => m.default || m)
const templateFile = await fs.readFile(path.resolve(fixturePath, '..', 'app.template.html'), { encoding: 'utf8' })
const compiled = template(templateFile, { interpolate: /{{([\s\S]+?)}}/g })
const webpackAssets = webpackStats.assets.reduce((s, asset) => `${s}<script src="./${asset.name}"${asset.name.includes('chunk') ? '' : ' defer'}></script>\n`, '')
const app = await renderer.renderToString(vueApp)
// !!! run inject after renderToString !!!
const metaInfo = vueApp.$meta().inject()
const appFile = path.resolve(webpackStats.outputPath, 'index.html')
const html = compiled({ app, webpackAssets, ...metaInfo })
await fs.writeFile(appFile, html)
return {
url: `file://${appFile}`,
appFile,
webpackStats,
html,
metaInfo
}
}
export function createWebpackConfig(config = {}) {
const publicPath = '.vue-meta'
return {
mode: 'development',
output: {
path: path.join(path.dirname(config.entry), publicPath),
filename: '[name].js',
chunkFilename: '[id].chunk.js',
publicPath: `/${publicPath}/`
},
module: {
rules: [
{ test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' },
{ test: /\.vue$/, use: 'vue-loader' }
]
},
// Expose __dirname to allow automatically setting basename.
context: __dirname,
node: {
__dirname: true
},
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all'
}
}
}
},
plugins: [
new VueLoaderPlugin()
],
resolve: {
alias: {
'vue': 'vue/dist/vue.esm.js'
}
},
...config
}
}
+264
View File
@@ -0,0 +1,264 @@
/**
* @license Copyright 2016 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import fs from 'fs'
import path from 'path'
import { execSync, execFileSync } from 'child_process'
import isWsl from 'is-wsl'
import uniq from 'lodash/uniq'
const newLineRegex = /\r?\n/
/**
* This class is based on node-get-chrome
* https://github.com/mrlee23/node-get-chrome
* https://github.com/gwuhaolin/chrome-finder
*/
export default class ChromeDetector {
constructor() {
this.platform = isWsl ? 'wsl' : process.platform
}
detect(platform = this.platform) {
const handler = this[platform]
if (typeof handler !== 'function') {
throw new Error(`${platform} is not supported.`)
}
return this[platform]()[0]
}
darwin() {
const suffixes = [
'/Contents/MacOS/Chromium',
'/Contents/MacOS/Google Chrome Canary',
'/Contents/MacOS/Google Chrome'
]
const LSREGISTER =
'/System/Library/Frameworks/CoreServices.framework' +
'/Versions/A/Frameworks/LaunchServices.framework' +
'/Versions/A/Support/lsregister'
const installations = []
const customChromePath = this.resolveChromePath()
if (customChromePath) {
installations.push(customChromePath)
}
execSync(
`${LSREGISTER} -dump` +
" | grep -i '(google chrome\\( canary\\)\\?|chromium).app$'" +
' | awk \'{$1=""; print $0}\''
)
.toString()
.split(newLineRegex)
.forEach((inst) => {
suffixes.forEach((suffix) => {
const execPath = path.join(inst.trim(), suffix)
if (this.canAccess(execPath)) {
installations.push(execPath)
}
})
})
// Retains one per line to maintain readability.
// clang-format off
const priorities = [
{ regex: new RegExp(`^${process.env.HOME}/Applications/.*Chrome.app`), weight: 50 },
{ regex: new RegExp(`^${process.env.HOME}/Applications/.*Chrome Canary.app`), weight: 51 },
{ regex: new RegExp(`^${process.env.HOME}/Applications/.*Chromium.app`), weight: 52 },
{ regex: /^\/Applications\/.*Chrome.app/, weight: 100 },
{ regex: /^\/Applications\/.*Chrome Canary.app/, weight: 101 },
{ regex: /^\/Applications\/.*Chromium.app/, weight: 102 },
{ regex: /^\/Volumes\/.*Chrome.app/, weight: -3 },
{ regex: /^\/Volumes\/.*Chrome Canary.app/, weight: -2 },
{ regex: /^\/Volumes\/.*Chromium.app/, weight: -1 }
]
if (process.env.LIGHTHOUSE_CHROMIUM_PATH) {
priorities.push({ regex: new RegExp(process.env.LIGHTHOUSE_CHROMIUM_PATH), weight: 150 })
}
if (process.env.CHROME_PATH) {
priorities.push({ regex: new RegExp(process.env.CHROME_PATH), weight: 151 })
}
// clang-format on
return this.sort(installations, priorities)
}
/**
* Look for linux executables in 3 ways
* 1. Look into CHROME_PATH env variable
* 2. Look into the directories where .desktop are saved on gnome based distro's
* 3. Look for google-chrome-stable & google-chrome executables by using the which command
*/
linux() {
let installations = []
// 1. Look into CHROME_PATH env variable
const customChromePath = this.resolveChromePath()
if (customChromePath) {
installations.push(customChromePath)
}
// 2. Look into the directories where .desktop are saved on gnome based distro's
const desktopInstallationFolders = [
path.join(require('os').homedir(), '.local/share/applications/'),
'/usr/share/applications/'
]
desktopInstallationFolders.forEach((folder) => {
installations = installations.concat(this.findChromeExecutables(folder))
})
// Look for chromium(-browser) & google-chrome(-stable) executables by using the which command
const executables = [
'chromium-browser',
'chromium',
'google-chrome-stable',
'google-chrome'
]
executables.forEach((executable) => {
try {
const chromePath = execFileSync('which', [executable])
.toString()
.split(newLineRegex)[0]
if (this.canAccess(chromePath)) {
installations.push(chromePath)
}
} catch (e) {
// Not installed.
}
})
if (!installations.length) {
throw new Error(
'The environment variable CHROME_PATH must be set to ' +
'executable of a build of Chromium version 54.0 or later.'
)
}
const priorities = [
{ regex: /chromium-browser$/, weight: 51 },
{ regex: /chromium$/, weight: 50 },
{ regex: /chrome-wrapper$/, weight: 49 },
{ regex: /google-chrome-stable$/, weight: 48 },
{ regex: /google-chrome$/, weight: 47 }
]
if (process.env.LIGHTHOUSE_CHROMIUM_PATH) {
priorities.push({
regex: new RegExp(process.env.LIGHTHOUSE_CHROMIUM_PATH),
weight: 100
})
}
if (process.env.CHROME_PATH) {
priorities.push({ regex: new RegExp(process.env.CHROME_PATH), weight: 101 })
}
return this.sort(uniq(installations.filter(Boolean)), priorities)
}
wsl() {
// Manually populate the environment variables assuming it's the default config
process.env.LOCALAPPDATA = this.getLocalAppDataPath(process.env.PATH)
process.env.PROGRAMFILES = '/mnt/c/Program Files'
process.env['PROGRAMFILES(X86)'] = '/mnt/c/Program Files (x86)'
return this.win32()
}
win32() {
const installations = []
const sep = path.sep
const suffixes = [
`${sep}Chromium${sep}Application${sep}chrome.exe`,
`${sep}Google${sep}Chrome SxS${sep}Application${sep}chrome.exe`,
`${sep}Google${sep}Chrome${sep}Application${sep}chrome.exe`,
`${sep}chrome-win32${sep}chrome.exe`,
`${sep}Google${sep}Chrome Beta${sep}Application${sep}chrome.exe`
]
const prefixes = [
process.env.LOCALAPPDATA,
process.env.PROGRAMFILES,
process.env['PROGRAMFILES(X86)']
].filter(Boolean)
const customChromePath = this.resolveChromePath()
if (customChromePath) {
installations.push(customChromePath)
}
prefixes.forEach(prefix =>
suffixes.forEach((suffix) => {
const chromePath = path.join(prefix, suffix)
if (this.canAccess(chromePath)) {
installations.push(chromePath)
}
})
)
return installations
}
resolveChromePath() {
if (this.canAccess(process.env.CHROME_PATH)) {
return process.env.CHROME_PATH
}
if (this.canAccess(process.env.LIGHTHOUSE_CHROMIUM_PATH)) {
console.warn( // eslint-disable-line no-console
'ChromeLauncher',
'LIGHTHOUSE_CHROMIUM_PATH is deprecated, use CHROME_PATH env variable instead.'
)
return process.env.LIGHTHOUSE_CHROMIUM_PATH
}
}
getLocalAppDataPath(path) {
const userRegExp = /\/mnt\/([a-z])\/Users\/([^/:]+)\/AppData\//
const results = userRegExp.exec(path) || []
return `/mnt/${results[1]}/Users/${results[2]}/AppData/Local`
}
sort(installations, priorities) {
const defaultPriority = 10
return installations
.map((inst) => {
for (const pair of priorities) {
if (pair.regex.test(inst)) {
return { path: inst, weight: pair.weight }
}
}
return { path: inst, weight: defaultPriority }
})
.sort((a, b) => b.weight - a.weight)
.map(pair => pair.path)
}
canAccess(file) {
if (!file) {
return false
}
try {
fs.accessSync(file)
return true
} catch (e) {
return false
}
}
findChromeExecutables(folder) {
const argumentsRegex = /(^[^ ]+).*/ // Take everything up to the first space
const chromeExecRegex = '^Exec=/.*/(google-chrome|chrome|chromium)-.*'
const installations = []
if (this.canAccess(folder)) {
// Output of the grep & print looks like:
// /opt/google/chrome/google-chrome --profile-directory
// /home/user/Downloads/chrome-linux/chrome-wrapper %U
let execPaths
// Some systems do not support grep -R so fallback to -r.
// See https://github.com/GoogleChrome/chrome-launcher/issues/46 for more context.
try {
execPaths = execSync(
`grep -ER "${chromeExecRegex}" ${folder} | awk -F '=' '{print $2}'`
)
} catch (e) {
execPaths = execSync(
`grep -Er "${chromeExecRegex}" ${folder} | awk -F '=' '{print $2}'`
)
}
execPaths = execPaths
.toString()
.split(newLineRegex)
.map(execPath => execPath.replace(argumentsRegex, '$1'))
execPaths.forEach(
execPath => this.canAccess(execPath) && installations.push(execPath)
)
}
return installations
}
}
+1 -18
View File
@@ -1,17 +1,9 @@
import { mount, createLocalVue } from '@vue/test-utils'
import { renderToString } from '@vue/server-test-utils'
import { defaultOptions } from '../../src/shared/constants'
import VueMetaBrowserPlugin from '../../src/browser'
import VueMetaServerPlugin from '../../src'
import {
keyName,
attribute,
ssrAttribute,
tagIDKeyName,
metaTemplateKeyName,
contentKeyName
} from '../../src/shared/constants'
export {
mount,
renderToString,
@@ -19,15 +11,6 @@ export {
VueMetaServerPlugin
}
export const defaultOptions = {
keyName,
attribute,
ssrAttribute,
tagIDKeyName,
metaTemplateKeyName,
contentKeyName
}
export function getVue() {
return createLocalVue()
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { defaultOptions } from './'
import { defaultOptions } from '../../src/shared/constants'
const metaInfoData = {
title: {
@@ -196,12 +196,12 @@ const metaInfoData = {
},
bodyAttrs: {
add: {
data: { foo: 'bar' },
expect: ['<body foo="bar" data-vue-meta="foo">']
data: { foo: 'bar', fizz: ['fuzz', 'fozz'] },
expect: ['<body foo="bar" fizz="fuzz fozz" data-vue-meta="fizz,foo">']
},
change: {
data: { foo: 'baz' },
expect: ['<body foo="baz" data-vue-meta="foo">']
expect: ['<body foo="baz" data-vue-meta="fizz,foo">']
},
remove: {
data: {},
+1 -4
View File
@@ -1,5 +1,2 @@
import jsdom from 'jsdom-global'
jsdom()
jest.useFakeTimers()
jest.setTimeout(15000)
+956 -700
View File
File diff suppressed because it is too large Load Diff