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

feat: enable onload callbacks (#414)

* refactor(examples): run ssr example from server

* chore: switch to babel for build

buble complains too much

* feat: enable loaded callbacks

feat: add skip option

* examples: add async-callback browser example

* examples: fix server

* examples(ssr): add reactive script with callback

* fix: also skip on ssr

* chore: remove unused var

* feat: only add mutationobserver if DOM is still loading

feat: disconnect mutation observer once DOM has loaded

* examples: pass vmid to loadCallback instead of el

* feat: also support load callbacks for link/style tags

* test: add unit tests for load

* test: add load e2e test

* chore: fix lint

* chore: remove unused files

* test: fix e2e load callback test

* test: fix attempt

* examples: ie9 compatiblity

destructuring doesnt work in ie9

* fix: add onload attribute on ssr

dont rely on mutationobserver

* chore: lint ci conf

* refactor: remove loadCallbackAttribute config option

test: fix coverage for load

* test: improve coverage

* fix: only use console when it exists (for ie9)

* chore: fix coverage
This commit is contained in:
Pim
2019-07-24 10:18:40 +02:00
committed by GitHub
parent 05163a77a8
commit fc71e1f1c4
49 changed files with 963 additions and 632 deletions
-2
View File
@@ -134,5 +134,3 @@ workflows:
requires: [test-e2e-ssr]
filters:
branches: { ignore: /^pull\/.*/ }
@@ -11,9 +11,11 @@
<li><a href="basic-render">Basic Render</a></li>
<li><a href="keep-alive">Keep alive</a></li>
<li><a href="multiple-apps">Usage with multiple apps</a></li>
<li><a href="ssr">SSR</a></li>
<li><a href="vue-router">Usage with vue-router</a></li>
<li><a href="vuex">Usage with vuex</a></li>
<li><a href="vuex-async">Usage with vuex + async actions</a></li>
<li><a href="async-callback">Async Callback</a></li>
</ul>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
window.users.push({
'id': 1,
'name': 'Leanne Graham',
'username': 'Bret',
'email': 'Sincere@april.biz',
'address': {
'street': 'Kulas Light',
'suite': 'Apt. 556',
'city': 'Gwenborough',
'zipcode': '92998-3874',
'geo': {
'lat': '-37.3159',
'lng': '81.1496'
}
},
'phone': '1-770-736-8031 x56442',
'website': 'hildegard.org',
'company': {
'name': 'Romaguera-Crona',
'catchPhrase': 'Multi-layered client-server neural-net',
'bs': 'harness real-time e-markets'
}
})
+23
View File
@@ -0,0 +1,23 @@
window.users.push({
'id': 2,
'name': 'Ervin Howell',
'username': 'Antonette',
'email': 'Shanna@melissa.tv',
'address': {
'street': 'Victor Plains',
'suite': 'Suite 879',
'city': 'Wisokyburgh',
'zipcode': '90566-7771',
'geo': {
'lat': '-43.9509',
'lng': '-34.4618'
}
},
'phone': '010-692-6593 x09125',
'website': 'anastasia.net',
'company': {
'name': 'Deckow-Crist',
'catchPhrase': 'Proactive didactic contingency',
'bs': 'synergize scalable supply-chains'
}
})
+23
View File
@@ -0,0 +1,23 @@
window.users.push({
'id': 3,
'name': 'Clementine Bauch',
'username': 'Samantha',
'email': 'Nathan@yesenia.net',
'address': {
'street': 'Douglas Extension',
'suite': 'Suite 847',
'city': 'McKenziehaven',
'zipcode': '59590-4157',
'geo': {
'lat': '-68.6102',
'lng': '-47.0653'
}
},
'phone': '1-463-123-4447',
'website': 'ramiro.info',
'company': {
'name': 'Romaguera-Jacobson',
'catchPhrase': 'Face to face bifurcated interface',
'bs': 'e-enable strategic applications'
}
})
+23
View File
@@ -0,0 +1,23 @@
window.users.push({
'id': 4,
'name': 'Patricia Lebsack',
'username': 'Karianne',
'email': 'Julianne.OConner@kory.org',
'address': {
'street': 'Hoeger Mall',
'suite': 'Apt. 692',
'city': 'South Elvis',
'zipcode': '53919-4257',
'geo': {
'lat': '29.4572',
'lng': '-164.2990'
}
},
'phone': '493-170-9623 x156',
'website': 'kale.biz',
'company': {
'name': 'Robel-Corkery',
'catchPhrase': 'Multi-tiered zero tolerance productivity',
'bs': 'transition cutting-edge web services'
}
})
+88
View File
@@ -0,0 +1,88 @@
import Vue from 'vue'
import VueMeta from 'vue-meta'
Vue.use(VueMeta)
window.users = []
new Vue({
metaInfo () {
return {
title: 'Async Callback',
titleTemplate: '%s | Vue Meta Examples',
script: [
{
skip: this.count < 2,
vmid: 'potatoes',
src: '/user-3.js',
async: true,
callback: this.updateCounter
},
{
skip: this.count < 1,
vmid: 'vegetables',
src: '/user-2.js',
async: true,
callback: this.updateCounter
},
{
vmid: 'meat',
src: '/user-1.js',
async: true,
callback: el => this.loadCallback(el.getAttribute('data-vmid'))
},
...this.scripts
]
}
},
data () {
return {
count: 0,
scripts: [],
users: window.users
}
},
watch: {
count (val) {
if (val === 3) {
this.addScript()
}
}
},
methods: {
updateCounter () {
this.count++
},
addScript () {
this.scripts.push({
src: '/user-4.js',
callback: () => {
this.updateCounter()
}
})
},
loadCallback (vmid) {
if (vmid === 'meat') {
this.updateCounter()
}
}
},
template: `
<div id="app">
<h1>Async Callback</h1>
<p>{{ count }} scripts loaded</p>
<div>
<h2>Users</h2>
<ul>
<li
v-for="user in users"
:key="user.id"
>
<strong>{{ user.id }}</strong>: {{ user.name }}
</li>
</ul>
</div>
</div>
`
}).$mount('#app')
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>Async Callback Title</title>
<link rel="stylesheet" href="/global.css">
</head>
<body>
<a href="/">&larr; Examples index</a>
<div id="app"></div>
<script src="/__build__/async-callback.js"></script>
</body>
</html>
+2 -2
View File
@@ -11,10 +11,10 @@ Vue.component('child', {
default: ''
}
},
render(h) {
render (h) {
return h('h3', null, this.page)
},
metaInfo() {
metaInfo () {
return {
title: this.page
}
+2 -2
View File
@@ -11,11 +11,11 @@ Vue.component('foo', {
})
new Vue({
data() {
data () {
return { showFoo: false }
},
methods: {
show() {
show () {
this.showFoo = !this.showFoo
}
},
+7 -7
View File
@@ -6,7 +6,7 @@ Vue.use(VueMeta)
// index.html contains a manual SSR render
const app1 = new Vue({
metaInfo() {
metaInfo () {
return {
title: 'App 1 title',
bodyAttrs: {
@@ -14,15 +14,15 @@ const app1 = new Vue({
},
meta: [
{ name: 'description', content: 'Hello from app 1', vmid: 'test' },
{ name: 'og:description', content: this.ogContent }
{ name: 'og:description', content: this.ogContent }
],
script: [
{ innerHTML: 'var appId=1.1', body: true },
{ innerHTML: 'var appId=1.2', vmid: 'app-id-body' },
{ innerHTML: 'var appId=1.2', vmid: 'app-id-body' }
]
}
},
data() {
data () {
return {
ogContent: 'Hello from ssr app'
}
@@ -44,7 +44,7 @@ const app2 = new Vue({
],
script: [
{ innerHTML: 'var appId=2.1', body: true },
{ innerHTML: 'var appId=2.2', vmid: 'app-id-body', body: true },
{ innerHTML: 'var appId=2.2', vmid: 'app-id-body', body: true }
]
}),
template: `
@@ -60,7 +60,6 @@ const app3 = new Vue({
`
}).$mount('#app3')
setTimeout(() => {
console.log('trigger app 1')
app1.$data.ogContent = 'Hello from app 1'
@@ -75,8 +74,9 @@ setTimeout(() => {
console.log('trigger app 3')
app3.$meta().refresh()
}, 7500)
setTimeout(() => {
console.log('trigger app 4')
const App = Vue.extend({ template: `<div>app 4</div>` })
const app4 = new App().$mount()
new App().$mount()
}, 10000)
+18 -1
View File
@@ -6,11 +6,13 @@ import rewrite from 'express-urlrewrite'
import webpack from 'webpack'
import webpackDevMiddleware from 'webpack-dev-middleware'
import WebpackConfig from './webpack.config'
import { renderPage } from './ssr/server'
const app = express()
app.use(webpackDevMiddleware(webpack(WebpackConfig), {
publicPath: '/__build__/',
writeToDisk: false,
stats: {
colors: true,
chunks: false
@@ -21,12 +23,27 @@ fs.readdirSync(__dirname)
.filter(file => file !== 'ssr')
.forEach((file) => {
if (fs.statSync(path.join(__dirname, file)).isDirectory()) {
app.use(rewrite('/' + file + '/*', '/' + file + '/index.html'))
app.use(rewrite(`/${file}/*`, `/${file}/index.html`))
}
})
app.use(express.static(path.join(__dirname, '_static')))
app.use(express.static(__dirname))
app.use(async (req, res, next) => {
if (!req.url.startsWith('/ssr')) {
next()
}
try {
const html = await renderPage()
res.send(html)
} catch (e) {
consola.error('SSR Oops:', e)
next()
}
})
const host = process.env.HOST || 'localhost'
const port = process.env.PORT || 3000
+90
View File
@@ -0,0 +1,90 @@
import Vue from 'vue'
import VueMeta from '../../'
Vue.use(VueMeta, {
tagIDKeyName: 'hid'
})
export default function createApp () {
return new Vue({
components: {
Hello: {
template: '<p>Hello World</p>',
metaInfo: {
title: 'Hello World',
meta: [
{
hid: 'description',
name: 'description',
content: 'The description'
}
]
}
}
},
metaInfo () {
return {
title: 'Boring Title',
htmlAttrs: { amp: true },
meta: [
{
hid: 'description',
name: 'description',
content: 'Say something'
}
],
script: [
{
hid: 'ldjson-schema',
type: 'application/ld+json',
innerHTML: '{ "@context": "http://www.schema.org", "@type": "Organization" }'
}, {
type: 'application/ld+json',
innerHTML: '{ "body": "yes" }',
body: true
}, {
hid: 'my-async-script-with-load-callback',
src: '/user-1.js',
body: true,
defer: true,
callback: this.loadCallback
}, {
skip: this.count < 1,
src: '/user-2.js',
body: true,
callback: this.loadCallback
}
],
__dangerouslyDisableSanitizersByTagID: {
'ldjson-schema': ['innerHTML']
}
}
},
data () {
return {
count: 0,
users: process.server ? [] : window.users
}
},
methods: {
loadCallback () {
this.count++
}
},
template: `
<div id="app">
<hello/>
<p>{{ count }} users loaded</p>
<ul>
<li
v-for="user in users"
:key="user.id"
>
{{ user.id }}: {{ user.name }}
</li>
</ul>
</div>`
})
}
-55
View File
@@ -1,55 +0,0 @@
import Vue from 'vue'
export default async function createApp() {
// the dynamic import is for this example only
const vueMetaModule = process.env.NODE_ENV === 'development' ? '../../' : 'vue-meta'
const VueMeta = await import(vueMetaModule).then(m => m.default || m)
Vue.use(VueMeta, {
tagIDKeyName: 'hid'
})
return new Vue({
components: {
Hello: {
template: '<p>Hello</p>',
metaInfo: {
title: 'Coucou',
meta: [
{
hid: 'description',
name: 'description',
content: 'Coucou'
}
]
}
}
},
template: '<hello/>',
metaInfo: {
title: 'Hello',
htmlAttrs: { amp: true },
meta: [
{
hid: 'description',
name: 'description',
content: 'Hello World'
}
],
script: [
{
hid: 'ldjson-schema',
type: 'application/ld+json',
innerHTML: '{ "@context": "http://www.schema.org", "@type": "Organization" }'
}, {
type: 'application/ld+json',
innerHTML: '{ "body": "yes" }',
body: true
}
],
__dangerouslyDisableSanitizersByTagID: {
'ldjson-schema': ['innerHTML']
}
}
})
}
+9 -3
View File
@@ -1,16 +1,22 @@
<!doctype html>
<html data-vue-meta-server-rendered {{ htmlAttrs.text() }}>
<html {{ htmlAttrs.text(true) }}>
<head {{ headAttrs.text() }}>
{{ meta.text() }}
{{ title.text() }}
{{ meta.text() }}
<link rel="stylesheet" href="/global.css">
{{ link.text() }}
{{ style.text() }}
{{ webpackAssets }}
{{ script.text() }}
{{ noscript.text() }}
</head>
<body {{ bodyAttrs.text() }}>
{{ script.text({ pbody: true }) }}
{{ noscript.text({ pbody: true }) }}
<a href="/">&larr; Examples index</a>
{{ app }}
<script src="/__build__/ssr.js"></script>
{{ script.text({ body: true }) }}
{{ noscript.text({ body: true }) }}
</body>
+5
View File
@@ -0,0 +1,5 @@
import createApp from './App'
window.users = []
createApp().$mount('#app')
-3
View File
@@ -1,3 +0,0 @@
import createApp from './app'
createApp().$mount('#app')
-36
View File
@@ -1,36 +0,0 @@
import path from 'path'
import fs from 'fs-extra'
import template from 'lodash/template'
import { createRenderer } from 'vue-server-renderer'
import consola from 'consola'
import createApp from './server-entry'
const renderer = createRenderer()
async function createPage() {
const templateFile = path.resolve(__dirname, 'app.template.html')
const templateContent = await fs.readFile(templateFile, { encoding: 'utf8' })
// see: https://lodash.com/docs#template
const compiled = template(templateContent, { interpolate: /{{([\s\S]+?)}}/g })
const webpackAssets = '<link rel="stylesheet" href="../global.css">'
const serverApp = await createApp()
const appHtml = await renderer.renderToString(serverApp)
const pageHtml = compiled({
app: appHtml,
webpackAssets,
...serverApp.$meta().inject()
})
return pageHtml
}
consola.info(`Creating ssr page`)
createPage()
.then((pageHtml) => {
consola.info(`Done, page:`)
consola.log(pageHtml)
})
.catch(e => consola.error(e))
-3
View File
@@ -1,3 +0,0 @@
import createApp from './app'
export default createApp
+27
View File
@@ -0,0 +1,27 @@
import path from 'path'
import fs from 'fs-extra'
import template from 'lodash/template'
import { createRenderer } from 'vue-server-renderer'
import createApp from './App'
const renderer = createRenderer({ runInNewContext: false })
const templateFile = path.resolve(__dirname, 'app.template.html')
const templateContent = fs.readFileSync(templateFile, { encoding: 'utf8' })
// see: https://lodash.com/docs#template
const compiled = template(templateContent, { interpolate: /{{([\s\S]+?)}}/g })
process.server = true
export async function renderPage () {
const app = await createApp()
const appHtml = await renderer.renderToString(app)
const pageHtml = compiled({
app: appHtml,
...app.$meta().inject()
})
return pageHtml
}
+6 -6
View File
@@ -15,21 +15,21 @@ const ChildComponent = {
<h3>You're looking at the <strong>{{ page }}</strong> page</h3>
<p>Has metaInfo been updated? {{ metaUpdated }}</p>
</div>`,
metaInfo() {
metaInfo () {
return {
title: `${this.page} - ${this.date && this.date.toTimeString()}`,
afterNavigation() {
afterNavigation () {
metaUpdated = 'yes'
}
}
},
data() {
data () {
return {
date: null,
metaUpdated
}
},
mounted() {
mounted () {
setInterval(() => {
this.date = new Date()
}, 1000)
@@ -39,10 +39,10 @@ const ChildComponent = {
// this wrapper function is not a requirement for vue-router,
// just a demonstration that render-function style components also work.
// See https://github.com/nuxt/vue-meta/issues/9 for more info.
function view(page) {
function view (page) {
return {
name: `section-${page}`,
render(h) {
render (h) {
return h(ChildComponent, {
props: { page }
})
+7 -7
View File
@@ -36,33 +36,33 @@ export default new Vuex.Store({
// GETTERS
getters: {
isLoading(state) {
isLoading (state) {
return state.isLoading
},
post(state) {
post (state) {
return state.post
},
publishedPosts(state) {
publishedPosts (state) {
return state.posts.filter(post => post.published)
},
publishedPostsCount(state, getters) {
publishedPostsCount (state, getters) {
return getters.publishedPosts.length
}
},
// MUTATIONS
mutations: {
loadingState(state, { isLoading }) {
loadingState (state, { isLoading }) {
state.isLoading = isLoading
},
getPost(state, { slug }) {
getPost (state, { slug }) {
state.post = state.posts.find(post => post.slug === slug)
}
},
// ACTIONS
actions: {
getPost({ commit }, payload) {
getPost ({ commit }, payload) {
commit('loadingState', { isLoading: true })
setTimeout(() => {
commit('getPost', payload)
+5 -5
View File
@@ -36,27 +36,27 @@ export default new Vuex.Store({
// GETTERS
getters: {
post(state) {
post (state) {
return state.post
},
publishedPosts(state) {
publishedPosts (state) {
return state.posts.filter(post => post.published)
},
publishedPostsCount(state, getters) {
publishedPostsCount (state, getters) {
return getters.publishedPosts.length
}
},
// MUTATIONS
mutations: {
getPost(state, { slug }) {
getPost (state, { slug }) {
state.post = state.posts.find(post => post.slug === slug)
}
},
// ACTIONS
actions: {
getPost({ commit }, payload) {
getPost ({ commit }, payload) {
commit('getPost', payload)
}
}
+24 -5
View File
@@ -10,12 +10,16 @@ export default {
devtool: 'inline-source-map',
mode: 'development',
entry: fs.readdirSync(__dirname)
.filter(entry => entry !== 'ssr')
.reduce((entries, dir) => {
const fullDir = path.join(__dirname, dir)
const entry = path.join(fullDir, 'app.js')
if (fs.statSync(fullDir).isDirectory() && fs.existsSync(entry)) {
entries[dir] = entry
if (dir === 'ssr') {
entries[dir] = path.join(fullDir, 'browser.js')
} else {
const entry = path.join(fullDir, 'app.js')
if (fs.statSync(fullDir).isDirectory() && fs.existsSync(entry)) {
entries[dir] = entry
}
}
return entries
}, {}),
@@ -27,7 +31,22 @@ export default {
},
module: {
rules: [
{ test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' },
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage',
corejs: '2',
targets: { ie: 9, safari: '5.1' }
}]
]
}
}
},
{ test: /\.vue$/, use: 'vue-loader' }
]
},
+3 -1
View File
@@ -82,6 +82,7 @@
"browserstack-local": "^1.4.0",
"chromedriver": "^75.1.0",
"codecov": "^3.5.0",
"copy-webpack-plugin": "^5.0.3",
"eslint": "^6.0.1",
"eslint-config-standard": "^13.0.1",
"eslint-plugin-import": "^2.18.0",
@@ -93,6 +94,7 @@
"esm": "^3.2.25",
"fs-extra": "^8.1.0",
"geckodriver": "^1.16.2",
"get-port": "^5.0.0",
"is-wsl": "^2.1.0",
"jest": "^24.8.0",
"jest-environment-jsdom": "^24.8.0",
@@ -103,7 +105,7 @@
"puppeteer-core": "^1.18.1",
"rimraf": "^2.6.3",
"rollup": "^1.17.0",
"rollup-plugin-buble": "^0.19.8",
"rollup-plugin-babel": "^4.3.3",
"rollup-plugin-commonjs": "^10.0.1",
"rollup-plugin-json": "^4.0.0",
"rollup-plugin-node-resolve": "^5.2.0",
+7 -7
View File
@@ -1,7 +1,7 @@
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 babel from 'rollup-plugin-babel'
import replace from 'rollup-plugin-replace'
import { terser } from 'rollup-plugin-terser'
import defaultsDeep from 'lodash/defaultsDeep'
@@ -32,8 +32,8 @@ function rollupConfig({
}
}
// keep simple polyfills when buble plugin is used for build
if (plugins && plugins.some(p => p.name === 'buble')) {
// keep simple polyfills when babel plugin is used for build
if (plugins && plugins.some(p => p.name === 'babel')) {
replaceConfig.values = {
'const polyfill = process.env.NODE_ENV === \'test\'': 'const polyfill = true',
}
@@ -63,7 +63,7 @@ export default [
file: pkg.web,
},
plugins: [
buble()
babel()
]
},
// minimized umd web build
@@ -72,7 +72,7 @@ export default [
file: pkg.web.replace('.js', '.min.js'),
},
plugins: [
buble(),
babel(),
terser()
]
},
@@ -84,7 +84,7 @@ export default [
format: 'cjs'
},
plugins: [
buble()
babel()
],
external: Object.keys(pkg.dependencies)
},
@@ -96,7 +96,7 @@ export default [
format: 'es'
},
plugins: [
buble()
babel()
],
external: Object.keys(pkg.dependencies)
},
+1 -1
View File
@@ -1,4 +1,4 @@
import { showWarningNotSupported } from '../shared/constants'
import { showWarningNotSupported } from '../shared/log'
import { getOptions } from '../shared/options'
import { pause, resume } from '../shared/pausing'
import refresh from './refresh'
+118
View File
@@ -0,0 +1,118 @@
import { toArray } from '../utils/array'
const callbacks = []
export function isDOMLoaded (d = document) {
return d.readyState !== 'loading'
}
export function isDOMComplete (d = document) {
return d.readyState === 'complete'
}
export function waitDOMLoaded () {
if (isDOMLoaded()) {
return true
}
return new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve))
}
export function addCallback (query, callback) {
if (arguments.length === 1) {
callback = query
query = ''
}
callbacks.push([ query, callback ])
}
export function addCallbacks ({ tagIDKeyName }, type, tags, autoAddListeners) {
let hasAsyncCallback = false
for (const tag of tags) {
if (!tag[tagIDKeyName] || !tag.callback) {
continue
}
hasAsyncCallback = true
addCallback(`${type}[data-${tagIDKeyName}="${tag[tagIDKeyName]}"]`, tag.callback)
}
if (!autoAddListeners || !hasAsyncCallback) {
return hasAsyncCallback
}
return addListeners()
}
export function addListeners () {
if (isDOMComplete()) {
applyCallbacks()
return
}
// Instead of using a MutationObserver, we just apply
/* istanbul ignore next */
document.onreadystatechange = () => {
applyCallbacks()
}
}
export function applyCallbacks (matchElement) {
for (const [query, callback] of callbacks) {
const selector = `${query}[onload="this.__vm_l=1"]`
let elements = []
if (!matchElement) {
elements = toArray(document.querySelectorAll(selector))
}
if (matchElement && matchElement.matches(selector)) {
elements = [matchElement]
}
for (const element of elements) {
/* __vm_cb: whether the load callback has been called
* __vm_l: set by onload attribute, whether the element was loaded
* __vm_ev: whether the event listener was added or not
*/
if (element.__vm_cb) {
continue
}
const onload = () => {
/* Mark that the callback for this element has already been called,
* this prevents the callback to run twice in some (rare) conditions
*/
element.__vm_cb = true
/* onload needs to be removed because we only need the
* attribute after ssr and if we dont remove it the node
* will fail isEqualNode on the client
*/
element.removeAttribute('onload')
callback(element)
}
/* IE9 doesnt seem to load scripts synchronously,
* causing a script sometimes/often already to be loaded
* when we add the event listener below (thus adding an onload event
* listener has no use because it will never be triggered).
* Therefore we add the onload attribute during ssr, and
* check here if it was already loaded or not
*/
if (element.__vm_l) {
onload()
continue
}
if (!element.__vm_ev) {
element.__vm_ev = true
element.addEventListener('load', onload)
}
}
}
}
+15 -1
View File
@@ -1,7 +1,8 @@
import { metaInfoOptionKeys, metaInfoAttributeKeys } from '../shared/constants'
import { metaInfoOptionKeys, metaInfoAttributeKeys, tagsSupportingOnload } from '../shared/constants'
import { isArray } from '../utils/is-type'
import { includes } from '../utils/array'
import { getTag } from '../utils/elements'
import { addCallbacks, addListeners } from './load'
import { updateAttribute, updateTag, updateTitle } from './updaters'
/**
@@ -21,6 +22,19 @@ export default function updateClientMetaInfo (appId, options = {}, newInfo) {
if (appId === ssrAppId && htmlTag.hasAttribute(ssrAttribute)) {
// remove the server render attribute so we can update on (next) changes
htmlTag.removeAttribute(ssrAttribute)
// add load callbacks if the
let addLoadListeners = false
for (const type of tagsSupportingOnload) {
if (newInfo[type] && addCallbacks(options, type, newInfo[type])) {
addLoadListeners = true
}
}
if (addLoadListeners) {
addListeners()
}
return false
}
+42 -28
View File
@@ -10,7 +10,9 @@ import { queryElements, getElementsKey } from '../../utils/elements.js'
* @param {(Array<Object>|Object)} tags - an array of tag objects or a single object in case of base
* @return {Object} - a representation of what tags changed
*/
export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type, tags, head, body) {
export default function updateTag (appId, options = {}, type, tags, head, body) {
const { attribute, tagIDKeyName } = options
const dataAttributes = [tagIDKeyName, ...commonDataAttributes]
const newElements = []
@@ -36,38 +38,50 @@ export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type
if (tags.length) {
for (const tag of tags) {
if (tag.skip) {
continue
}
const newElement = document.createElement(type)
newElement.setAttribute(attribute, appId)
for (const attr in tag) {
if (tag.hasOwnProperty(attr)) {
if (attr === 'innerHTML') {
newElement.innerHTML = tag.innerHTML
continue
}
if (attr === 'cssText') {
if (newElement.styleSheet) {
/* istanbul ignore next */
newElement.styleSheet.cssText = tag.cssText
} else {
newElement.appendChild(document.createTextNode(tag.cssText))
}
continue
}
const _attr = includes(dataAttributes, attr)
? `data-${attr}`
: attr
const isBooleanAttribute = includes(booleanHtmlAttributes, attr)
if (isBooleanAttribute && !tag[attr]) {
continue
}
const value = isBooleanAttribute ? '' : tag[attr]
newElement.setAttribute(_attr, value)
/* istanbul ignore next */
if (!tag.hasOwnProperty(attr)) {
continue
}
if (attr === 'innerHTML') {
newElement.innerHTML = tag.innerHTML
continue
}
if (attr === 'cssText') {
if (newElement.styleSheet) {
/* istanbul ignore next */
newElement.styleSheet.cssText = tag.cssText
} else {
newElement.appendChild(document.createTextNode(tag.cssText))
}
continue
}
if (attr === 'callback') {
newElement.onload = () => tag[attr](newElement)
continue
}
const _attr = includes(dataAttributes, attr)
? `data-${attr}`
: attr
const isBooleanAttribute = includes(booleanHtmlAttributes, attr)
if (isBooleanAttribute && !tag[attr]) {
continue
}
const value = isBooleanAttribute ? '' : tag[attr]
newElement.setAttribute(_attr, value)
}
const oldElements = currentElements[getElementsKey(tag)]
+28 -17
View File
@@ -1,4 +1,10 @@
import { booleanHtmlAttributes, tagsWithoutEndTag, tagsWithInnerContent, tagAttributeAsInnerContent, commonDataAttributes } from '../../shared/constants'
import {
booleanHtmlAttributes,
tagsWithoutEndTag,
tagsWithInnerContent,
tagAttributeAsInnerContent,
commonDataAttributes
} from '../../shared/constants'
/**
* Generates meta, base, link, style, script, noscript tags for use on the server
@@ -8,12 +14,16 @@ import { booleanHtmlAttributes, tagsWithoutEndTag, tagsWithInnerContent, tagAttr
* @return {Object} - the tag generator
*/
export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}, type, tags) {
const dataAttributes = [tagIDKeyName, ...commonDataAttributes]
const dataAttributes = [tagIDKeyName, 'callback', ...commonDataAttributes]
return {
text ({ body = false, pbody = false } = {}) {
// build a string containing all tags of this type
return tags.reduce((tagsStr, tag) => {
if (tag.skip) {
return tagsStr
}
const tagKeys = Object.keys(tag)
if (tagKeys.length === 0) {
@@ -24,11 +34,13 @@ export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}
return tagsStr
}
let attrs = tag.once ? '' : ` ${attribute}="${ssrAppId}"`
// build a string containing all attributes of this tag
const attrs = tagKeys.reduce((attrsStr, attr) => {
for (const attr in tag) {
// these attributes are treated as children on the tag
if (tagAttributeAsInnerContent.includes(attr) || attr === 'once') {
return attrsStr
continue
}
// these form the attribute list for this tag
@@ -37,23 +49,23 @@ export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}
prefix = 'data-'
}
const isBooleanAttr = booleanHtmlAttributes.includes(attr)
if (isBooleanAttr && !tag[attr]) {
return attrsStr
if (attr === 'callback') {
attrs += ` onload="this.__vm_l=1"`
continue
}
return isBooleanAttr
? `${attrsStr} ${prefix}${attr}`
: `${attrsStr} ${prefix}${attr}="${tag[attr]}"`
}, '')
const isBooleanAttr = !prefix && booleanHtmlAttributes.includes(attr)
if (isBooleanAttr && !tag[attr]) {
continue
}
attrs += ` ${prefix}${attr}` + (isBooleanAttr ? '' : `="${tag[attr]}"`)
}
// grab child content from one of these attributes, if possible
const content = tag.innerHTML || tag.cssText || ''
// generate tag exactly without any other redundant attribute
const observeTag = tag.once
? ''
: `${attribute}="${ssrAppId}"`
// these tags have no end tag
const hasEndTag = !tagsWithoutEndTag.includes(type)
@@ -62,9 +74,8 @@ export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}
const hasContent = hasEndTag && tagsWithInnerContent.includes(type)
// the final string for this specific tag
return !hasContent
? `${tagsStr}<${type} ${observeTag}${attrs}${hasEndTag ? '/' : ''}>`
: `${tagsStr}<${type} ${observeTag}${attrs}>${content}</${type}>`
return `${tagsStr}<${type}${attrs}${!hasContent && hasEndTag ? '/' : ''}>` +
(hasContent ? `${content}</${type}>` : '')
}, '')
}
}
+3 -3
View File
@@ -79,6 +79,9 @@ export const metaInfoAttributeKeys = [
'bodyAttrs'
]
// HTML elements which support the onload event
export const tagsSupportingOnload = ['link', 'style', 'script']
// HTML elements which dont have a head tag (shortened to our needs)
// see: https://www.w3.org/TR/html52/document-metadata.html
export const tagsWithoutEndTag = ['base', 'meta', 'link']
@@ -137,6 +140,3 @@ export const booleanHtmlAttributes = [
'typemustmatch',
'visible'
]
// eslint-disable-next-line no-console
export const showWarningNotSupported = () => console.warn('This vue app/component has no vue-meta configuration')
+16
View File
@@ -0,0 +1,16 @@
import { hasGlobalWindow } from '../utils/window'
const _global = hasGlobalWindow ? window : global
const console = (_global.console = _global.console || {})
export function warn (...args) {
/* istanbul ignore next */
if (!console || !console.warn) {
return
}
console.warn(...args)
}
export const showWarningNotSupported = () => warn('This vue app/component has no vue-meta configuration')
+4 -4
View File
@@ -1,7 +1,8 @@
import deepmerge from 'deepmerge'
import { findIndex } from '../utils/array'
import { includes, findIndex } from '../utils/array'
import { applyTemplate } from './template'
import { metaInfoAttributeKeys, booleanHtmlAttributes } from './constants'
import { warn } from './log'
export function arrayMerge ({ component, tagIDKeyName, metaTemplateKeyName, contentKeyName }, target, source) {
// we concat the arrays without merging objects contained in,
@@ -80,9 +81,8 @@ export function merge (target, source, options = {}) {
for (const key in source[attrKey]) {
if (source[attrKey].hasOwnProperty(key) && source[attrKey][key] === undefined) {
if (booleanHtmlAttributes.includes(key)) {
// eslint-disable-next-line no-console
console.warn('VueMeta: Please note that since v2 the value undefined is not used to indicate boolean attributes anymore, see migration guide for details')
if (includes(booleanHtmlAttributes, key)) {
warn('VueMeta: Please note that since v2 the value undefined is not used to indicate boolean attributes anymore, see migration guide for details')
}
delete source[attrKey][key]
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { isUndefined, isFunction } from '../utils/is-type'
import { ensuredPush } from '../utils/ensure'
import { hasMetaInfo } from './meta-helpers'
import { addNavGuards } from './nav-guards'
import { warn } from './log'
let appId = 1
@@ -18,7 +19,7 @@ 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 use hasMetaInfo(vm) instead') // eslint-disable-line no-console
warn('VueMeta DeprecationWarning: _hasMetaInfo has been deprecated and will be removed in a future version. Please use hasMetaInfo(vm) instead')
this.$root._vueMeta.hasMetaInfoDeprecationWarningShown = true
}
return hasMetaInfo(this)
+17 -2
View File
@@ -5,6 +5,7 @@ import fs from 'fs'
import path from 'path'
import env from 'node-env-file'
import { createBrowser } from 'tib'
import { getPort } from '../utils/build'
const browserString = process.env.BROWSER_STRING || 'puppeteer/core'
@@ -21,13 +22,17 @@ describe(browserString, () => {
}
}
const port = await getPort()
browser = await createBrowser(browserString, {
folder,
staticServer: {
folder,
port
},
extendPage (page) {
return {
async navigate (path) {
// IMPORTANT: use (arrow) function with block'ed body
// see: https://github.com/tunnckoCoreLabs/parse-function/issues/179
await page.runAsyncScript((path) => {
return new Promise((resolve) => {
const oldTitle = document.title
@@ -58,6 +63,8 @@ describe(browserString, () => {
}
}
})
// browser.setLogLevel(['warn', 'error', 'log', 'info'])
})
afterAll(async () => {
@@ -94,6 +101,14 @@ describe(browserString, () => {
expect(await page.getElementCount('body noscript:first-child')).toBe(1)
expect(await page.getElementCount('body noscript:last-child')).toBe(1)
expect(await page.runScript(() => {
return window.loadTest
})).toBe('loaded')
expect(await page.runScript(() => {
return window.loadCallback
})).toBe('yes')
})
test('/about', async () => {
+2 -1
View File
@@ -5,7 +5,7 @@
{{ title.text() }}
{{ link.text() }}
{{ style.text() }}
{{ webpackAssets }}
{{ headAssets }}
{{ script.text() }}
{{ noscript.text() }}
</head>
@@ -13,6 +13,7 @@
{{ script.text({ pbody: true }) }}
{{ noscript.text({ pbody: true }) }}
{{ app }}
{{ bodyAssets }}
{{ script.text({ body: true }) }}
{{ noscript.text({ body: true }) }}
</body>
+1
View File
@@ -0,0 +1 @@
window.loadTest = 'loaded'
+2 -1
View File
@@ -24,7 +24,8 @@ export default {
],
script: [
{ vmid: 'ldjson', innerHTML: '{ "@context": "http://www.schema.org", "@type": "Organization" }', type: 'application/ld+json' },
{ innerHTML: '{ "more": "data" }', type: 'application/ld+json' }
{ innerHTML: '{ "more": "data" }', type: 'application/ld+json' },
{ vmid: 'loadtest', src: '/load-test.js', body: true, async: true, callback: () => (window.loadCallback = 'yes') }
],
noscript: [
{ innerHTML: '{ "pbody": "yes" }', pbody: true, type: 'application/ld+json' },
+2 -4
View File
@@ -3,8 +3,6 @@ import { defaultOptions } from '../../src/shared/constants'
import metaInfoData from '../utils/meta-info-data'
import { titleGenerator } from '../../src/server/generators'
defaultOptions.ssrAppId = 'test'
const generateServerInjector = (type, data) => _generateServerInjector(defaultOptions, type, data)
describe('generators', () => {
@@ -88,7 +86,7 @@ describe('extra tests', () => {
expect(scriptTags.text()).toBe('')
expect(scriptTags.text({ body: true })).toBe('')
expect(scriptTags.text({ pbody: true })).toBe('<script data-vue-meta="test" src="/script.js" data-pbody="true"></script>')
expect(scriptTags.text({ pbody: true })).toBe('<script data-vue-meta="ssr" src="/script.js" data-pbody="true"></script>')
})
test('script append body', () => {
@@ -96,7 +94,7 @@ describe('extra tests', () => {
const scriptTags = generateServerInjector('script', tags)
expect(scriptTags.text()).toBe('')
expect(scriptTags.text({ body: true })).toBe('<script data-vue-meta="test" src="/script.js" data-body="true"></script>')
expect(scriptTags.text({ body: true })).toBe('<script data-vue-meta="ssr" src="/script.js" data-body="true"></script>')
expect(scriptTags.text({ pbody: true })).toBe('')
})
})
+213
View File
@@ -0,0 +1,213 @@
import { pTick, createDOM } from '../utils'
const onLoadAttribute = {
k: 'onload',
v: 'this.__vm_l=1'
}
const getLoadAttribute = () => `${onLoadAttribute.k}="${onLoadAttribute.v}"`
describe('load callbacks', () => {
let load
beforeEach(async () => {
jest.resetModules()
load = await import('../../src/client/load')
})
afterEach(() => {
jest.restoreAllMocks()
})
test('isDOMLoaded', async () => {
jest.useRealTimers()
const { document } = createDOM()
await pTick()
jest.spyOn(document, 'readyState', 'get').mockReturnValue('loading')
expect(load.isDOMLoaded(document)).toBe(false)
jest.spyOn(document, 'readyState', 'get').mockReturnValue('interactive')
expect(load.isDOMLoaded(document)).toBe(true)
jest.spyOn(document, 'readyState', 'get').mockReturnValue('complete')
expect(load.isDOMLoaded(document)).toBe(true)
})
test('isDOMComplete', async () => {
jest.useRealTimers()
const { document } = createDOM()
await pTick()
jest.spyOn(document, 'readyState', 'get').mockReturnValue('loading')
expect(load.isDOMComplete(document)).toBe(false)
jest.spyOn(document, 'readyState', 'get').mockReturnValue('interactive')
expect(load.isDOMComplete(document)).toBe(false)
jest.spyOn(document, 'readyState', 'get').mockReturnValue('complete')
expect(load.isDOMComplete(document)).toBe(true)
})
test('waitDOMLoaded', async () => {
expect(load.waitDOMLoaded()).toBe(true)
jest.spyOn(document, 'readyState', 'get').mockReturnValue('loading')
const waitPromise = load.waitDOMLoaded()
expect(waitPromise).toEqual(expect.any(Promise))
const domLoaded = new Event('DOMContentLoaded')
document.dispatchEvent(domLoaded)
await expect(waitPromise).resolves.toEqual(expect.any(Object))
})
test('addCallback (no query)', () => {
const callback = () => {}
load.addCallback(callback)
const matches = jest.fn(() => false)
load.applyCallbacks({ matches })
expect(matches).toHaveBeenCalledTimes(1)
expect(matches).toHaveBeenCalledWith(`[${getLoadAttribute()}]`)
})
test('addCallback (query)', () => {
const callback = () => {}
load.addCallback('script', callback)
const matches = jest.fn(() => false)
load.applyCallbacks({ matches })
expect(matches).toHaveBeenCalledTimes(1)
expect(matches).toHaveBeenCalledWith(`script[${getLoadAttribute()}]`)
})
test('addCallbacks', () => {
const addListeners = jest.spyOn(document, 'querySelectorAll').mockReturnValue(false)
const config = { tagIDKeyName: 'test-id' }
const tags = [
{ [config.tagIDKeyName]: 'test1', callback: false },
{ [config.tagIDKeyName]: false, callback: () => {} },
{ [config.tagIDKeyName]: 'test1', callback: () => {} },
{ [config.tagIDKeyName]: 'test2', callback: () => {} }
]
load.addCallbacks(config, 'link', tags)
const matches = jest.fn(() => false)
load.applyCallbacks({ matches })
expect(matches).toHaveBeenCalledTimes(2)
expect(matches).toHaveBeenCalledWith(`link[data-${config.tagIDKeyName}="test1"][${getLoadAttribute()}]`)
expect(matches).toHaveBeenCalledWith(`link[data-${config.tagIDKeyName}="test2"][${getLoadAttribute()}]`)
expect(addListeners).not.toHaveBeenCalled()
})
test('addCallbacks (auto add listeners)', () => {
const addListeners = jest.spyOn(document, 'querySelectorAll').mockReturnValue(false)
const config = { tagIDKeyName: 'test-id', loadCallbackAttribute: 'test-load' }
const tags = [
{ [config.tagIDKeyName]: 'test1', callback: () => {} }
]
load.addCallbacks(config, 'style', tags, true)
const matches = jest.fn(() => false)
load.applyCallbacks({ matches })
expect(matches).toHaveBeenCalledTimes(1)
expect(matches).toHaveBeenCalledWith(`style[data-${config.tagIDKeyName}="test1"][${getLoadAttribute()}]`)
expect(addListeners).toHaveBeenCalled()
})
test('callback trigger', () => {
const { window, document } = createDOM()
const callback = jest.fn()
const el = document.createElement('script')
el.setAttribute(onLoadAttribute.k, onLoadAttribute.v)
document.body.appendChild(el)
load.addCallback(callback)
load.applyCallbacks(el)
const loadEvent = new window.Event('load')
el.dispatchEvent(loadEvent)
expect(callback).toHaveBeenCalled()
})
test('callback trigger (loaded before adding)', () => {
const { document } = createDOM()
const callback = jest.fn()
const el = document.createElement('script')
el.setAttribute(onLoadAttribute.k, onLoadAttribute.v)
el.__vm_l = 1
document.body.appendChild(el)
load.addCallback(callback)
load.applyCallbacks(el)
expect(callback).toHaveBeenCalled()
})
test('callback trigger (only once)', () => {
const { window, document } = createDOM()
const callback = jest.fn()
const el = document.createElement('script')
el.setAttribute(onLoadAttribute.k, onLoadAttribute.v)
document.body.appendChild(el)
el.__vm_l = 1
load.addCallback(callback)
load.applyCallbacks(el)
el.__vm_cb = true
const loadEvent = new window.Event('load')
el.dispatchEvent(loadEvent)
expect(callback).toHaveBeenCalledTimes(1)
})
test('only one event listener added', () => {
const { window, document } = createDOM()
const el = document.createElement('script')
const addEventListener = el.addEventListener.bind(el)
const addEventListenerSpy = jest.spyOn(el, 'addEventListener').mockImplementation((...args) => {
return addEventListener(...args)
})
el.setAttribute(onLoadAttribute.k, onLoadAttribute.v)
document.body.appendChild(el)
load.addCallback(() => {})
load.applyCallbacks(el)
const loadEvent1 = new window.Event('load')
el.dispatchEvent(loadEvent1)
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
el.setAttribute(onLoadAttribute.k, onLoadAttribute.v)
load.applyCallbacks(el)
const loadEvent2 = new window.Event('load')
el.dispatchEvent(loadEvent2)
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
})
})
+21 -3
View File
@@ -1,8 +1,9 @@
import _updateClientMetaInfo from '../../src/client/updateClientMetaInfo'
import { defaultOptions } from '../../src/shared/constants'
import { defaultOptions, ssrAppId, ssrAttribute } from '../../src/shared/constants'
import metaInfoData from '../utils/meta-info-data'
import * as load from '../../src/client/load'
const updateClientMetaInfo = (type, data) => _updateClientMetaInfo('test', defaultOptions, { [type]: data })
const updateClientMetaInfo = (type, data) => _updateClientMetaInfo(ssrAppId, defaultOptions, { [type]: data })
describe('updaters', () => {
let html
@@ -14,7 +15,7 @@ describe('updaters', () => {
Array.from(html.getElementsByTagName('meta')).forEach(el => el.parentNode.removeChild(el))
})
Object.keys(metaInfoData).forEach((type) => {
for (const type in metaInfoData) {
const typeTests = metaInfoData[type]
const testCases = {
@@ -93,5 +94,22 @@ describe('updaters', () => {
}
})
})
}
})
describe('extra tests', () => {
test('adds callback listener on hydration', () => {
const addListeners = load.addListeners
const addListenersSpy = jest.spyOn(load, 'addListeners').mockImplementation(addListeners)
const html = document.getElementsByTagName('html')[0]
html.setAttribute(ssrAttribute, 'true')
const data = [{ src: 'src1', [defaultOptions.tagIDKeyName]: 'content', callback: () => {} }]
const tags = updateClientMetaInfo('script', data)
expect(tags).toBe(false)
expect(html.hasAttribute(ssrAttribute)).toBe(false)
expect(addListenersSpy).toHaveBeenCalledTimes(1)
})
})
-99
View File
@@ -1,99 +0,0 @@
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
}
}
+18 -3
View File
@@ -2,11 +2,14 @@ import path from 'path'
import fs from 'fs-extra'
import { template } from 'lodash'
import webpack from 'webpack'
import CopyWebpackPlugin from 'copy-webpack-plugin'
import VueLoaderPlugin from 'vue-loader/lib/plugin'
import { createRenderer } from 'vue-server-renderer'
const renderer = createRenderer()
export { default as getPort } from 'get-port'
export function webpackRun (config) {
const compiler = webpack(config)
@@ -50,13 +53,22 @@ export async function buildFixture (fixture, config = {}) {
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 assets = webpackStats.assets.filter(asset => !asset.name.includes('load-test'))
const headAssets = assets
.filter(asset => asset.name.includes('chunk'))
.reduce((s, asset) => `${s}<script src="./${asset.name}"></script>\n`, '')
const bodyAssets = assets
.filter(asset => !asset.name.includes('chunk'))
.reduce((s, asset) => `${s}<script src="./${asset.name}"></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 })
const html = compiled({ app, headAssets, bodyAssets, ...metaInfo })
await fs.writeFile(appFile, html)
@@ -125,7 +137,10 @@ export function createWebpackConfig (config = {}) {
// make sure our simple polyfills are enabled
'NODE_ENV': '"test"'
}
})
}),
new CopyWebpackPlugin([
{ from: path.join(path.dirname(config.entry), 'static') }
])
],
resolve: {
alias: {
-264
View File
@@ -1,264 +0,0 @@
/**
* @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 TypeError(`${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
}
}
+13
View File
@@ -1,3 +1,4 @@
import { JSDOM } from 'jsdom'
import { mount, shallowMount, createWrapper, createLocalVue } from '@vue/test-utils'
import { renderToString } from '@vue/server-test-utils'
import { defaultOptions } from '../../src/shared/constants'
@@ -32,3 +33,15 @@ export const vmTick = (vm) => {
vm.$nextTick(resolve)
})
}
export const pTick = () => new Promise(resolve => process.nextTick(resolve))
export function createDOM (html = '<!DOCTYPE html>', options = {}) {
const dom = new JSDOM(html, options)
return {
dom,
window: dom.window,
document: dom.window.document
}
}
+22 -20
View File
@@ -26,11 +26,11 @@ const metaInfoData = {
base: {
add: {
data: [{ href: 'href' }],
expect: ['<base data-vue-meta="test" href="href">']
expect: ['<base data-vue-meta="ssr" href="href">']
},
change: {
data: [{ href: 'href2' }],
expect: ['<base data-vue-meta="test" href="href2">']
expect: ['<base data-vue-meta="ssr" href="href2">']
},
remove: {
data: [],
@@ -41,8 +41,8 @@ const metaInfoData = {
add: {
data: [{ charset: 'utf-8' }, { property: 'a', content: 'a' }],
expect: [
'<meta data-vue-meta="test" charset="utf-8">',
'<meta data-vue-meta="test" property="a" content="a">'
'<meta data-vue-meta="ssr" charset="utf-8">',
'<meta data-vue-meta="ssr" property="a" content="a">'
]
},
change: {
@@ -51,8 +51,8 @@ const metaInfoData = {
{ property: 'a', content: 'b' }
],
expect: [
'<meta data-vue-meta="test" charset="utf-16">',
'<meta data-vue-meta="test" property="a" content="b">'
'<meta data-vue-meta="ssr" charset="utf-16">',
'<meta data-vue-meta="ssr" property="a" content="b">'
]
},
// make sure elements that already exists are not unnecessarily updated
@@ -62,8 +62,8 @@ const metaInfoData = {
{ property: 'a', content: 'c' }
],
expect: [
'<meta data-vue-meta="test" charset="utf-16">',
'<meta data-vue-meta="test" property="a" content="c">'
'<meta data-vue-meta="ssr" charset="utf-16">',
'<meta data-vue-meta="ssr" property="a" content="c">'
],
test (side, defaultTest) {
if (side === 'client') {
@@ -85,11 +85,11 @@ const metaInfoData = {
link: {
add: {
data: [{ rel: 'stylesheet', href: 'href' }],
expect: ['<link data-vue-meta="test" rel="stylesheet" href="href">']
expect: ['<link data-vue-meta="ssr" rel="stylesheet" href="href">']
},
change: {
data: [{ rel: 'stylesheet', href: 'href', media: 'screen' }],
expect: ['<link data-vue-meta="test" rel="stylesheet" href="href" media="screen">']
expect: ['<link data-vue-meta="ssr" rel="stylesheet" href="href" media="screen">']
},
remove: {
data: [],
@@ -99,11 +99,11 @@ const metaInfoData = {
style: {
add: {
data: [{ type: 'text/css', cssText: '.foo { color: red; }' }],
expect: ['<style data-vue-meta="test" type="text/css">.foo { color: red; }</style>']
expect: ['<style data-vue-meta="ssr" type="text/css">.foo { color: red; }</style>']
},
change: {
data: [{ type: 'text/css', cssText: '.foo { color: blue; }' }],
expect: ['<style data-vue-meta="test" type="text/css">.foo { color: blue; }</style>']
expect: ['<style data-vue-meta="ssr" type="text/css">.foo { color: blue; }</style>']
},
remove: {
data: [],
@@ -113,20 +113,22 @@ const metaInfoData = {
script: {
add: {
data: [
{ src: 'src', async: false, defer: true, [defaultOptions.tagIDKeyName]: 'content' },
{ src: 'src1', async: false, defer: true, [defaultOptions.tagIDKeyName]: 'content', callback: () => {} },
{ src: 'src-prepend', async: true, defer: false, pbody: true },
{ src: 'src', async: false, defer: true, body: true }
{ src: 'src2', async: false, defer: true, body: true },
{ src: 'src3', async: false, skip: true }
],
expect: [
'<script data-vue-meta="test" src="src" defer data-vmid="content"></script>',
'<script data-vue-meta="test" src="src-prepend" async data-pbody="true"></script>',
'<script data-vue-meta="test" src="src" defer data-body="true"></script>'
'<script data-vue-meta="ssr" src="src1" defer data-vmid="content" onload="this.__vm_l=1"></script>',
'<script data-vue-meta="ssr" src="src-prepend" async data-pbody="true"></script>',
'<script data-vue-meta="ssr" src="src2" defer data-body="true"></script>'
],
test (side, defaultTest) {
return () => {
if (side === 'client') {
for (const index in this.expect) {
this.expect[index] = this.expect[index].replace(/(async|defer)/g, '$1=""')
this.expect[index] = this.expect[index].replace(/ onload="this.__vm_l=1"/, '')
}
const tags = defaultTest()
@@ -150,7 +152,7 @@ const metaInfoData = {
// this test only runs for client so we can directly expect wrong boolean attributes
change: {
data: [{ src: 'src', async: true, defer: true, [defaultOptions.tagIDKeyName]: 'content2' }],
expect: ['<script data-vue-meta="test" src="src" async="" defer="" data-vmid="content2"></script>']
expect: ['<script data-vue-meta="ssr" src="src" async="" defer="" data-vmid="content2"></script>']
},
remove: {
data: [],
@@ -160,11 +162,11 @@ const metaInfoData = {
noscript: {
add: {
data: [{ innerHTML: '<p>noscript</p>' }],
expect: ['<noscript data-vue-meta="test"><p>noscript</p></noscript>']
expect: ['<noscript data-vue-meta="ssr"><p>noscript</p></noscript>']
},
change: {
data: [{ innerHTML: '<p>noscript, no really</p>' }],
expect: ['<noscript data-vue-meta="test"><p>noscript, no really</p></noscript>']
expect: ['<noscript data-vue-meta="ssr"><p>noscript, no really</p></noscript>']
},
remove: {
data: [],
+16 -35
View File
@@ -1778,7 +1778,7 @@ acorn-globals@^4.1.0, acorn-globals@^4.3.2:
acorn "^6.0.1"
acorn-walk "^6.0.1"
acorn-jsx@^5.0.0, acorn-jsx@^5.0.1:
acorn-jsx@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.1.tgz#32a064fd925429216a09b141102bfdd185fae40e"
integrity sha512-HJ7CfNHrfJLlNTzIEUTj43LNWGkqpRLxm3YjAlcD0ACydk9XynzYsCBHxut+iqt+1aBXkx9UP/w/ZqMr13XIzg==
@@ -2534,20 +2534,6 @@ bser@^2.0.0:
dependencies:
node-int64 "^0.4.0"
buble@^0.19.8:
version "0.19.8"
resolved "https://registry.yarnpkg.com/buble/-/buble-0.19.8.tgz#d642f0081afab66dccd897d7b6360d94030b9d3d"
integrity sha512-IoGZzrUTY5fKXVkgGHw3QeXFMUNBFv+9l8a4QJKG1JhG3nCMHTdEX1DCOg8568E2Q9qvAQIiSokv6Jsgx8p2cA==
dependencies:
acorn "^6.1.1"
acorn-dynamic-import "^4.0.0"
acorn-jsx "^5.0.1"
chalk "^2.4.2"
magic-string "^0.25.3"
minimist "^1.2.0"
os-homedir "^2.0.0"
regexpu-core "^4.5.4"
buffer-from@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
@@ -3323,7 +3309,7 @@ copy-descriptor@^0.1.0:
resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
copy-webpack-plugin@^5.0.2:
copy-webpack-plugin@^5.0.2, copy-webpack-plugin@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-5.0.3.tgz#2179e3c8fd69f13afe74da338896f1f01a875b5c"
integrity sha512-PlZRs9CUMnAVylZq+vg2Juew662jWtwOXOqH4lbQD9ZFhRG9R7tVStOgHt21CBGVq7k5yIJaz8TXDLSjV+Lj8Q==
@@ -5044,6 +5030,13 @@ get-pkg-repo@^1.0.0:
parse-github-repo-url "^1.3.0"
through2 "^2.0.0"
get-port@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.0.0.tgz#aa22b6b86fd926dd7884de3e23332c9f70c031a6"
integrity sha512-imzMU0FjsZqNa6BqOjbbW6w5BivHIuQKopjpPqcnx0AVHJQKCxK1O+Ab3OrVXhrekqfVMjwA9ZYu062R+KcIsQ==
dependencies:
type-fest "^0.3.0"
get-stdin@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
@@ -7033,13 +7026,6 @@ magic-string@^0.25.2:
dependencies:
sourcemap-codec "^1.4.4"
magic-string@^0.25.3:
version "0.25.3"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.3.tgz#34b8d2a2c7fec9d9bdf9929a3fd81d271ef35be9"
integrity sha512-6QK0OpF/phMz0Q2AxILkX2mFhi7m+WMwTRg0LQKq/WBB0cDP4rYH3Wp4/d3OTXlrPLVJT/RFqj8tFeAR4nk8AA==
dependencies:
sourcemap-codec "^1.4.4"
make-dir@^2.0.0, make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@@ -7894,11 +7880,6 @@ os-homedir@^1.0.0:
resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
os-homedir@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-2.0.0.tgz#a0c76bb001a8392a503cbd46e7e650b3423a923c"
integrity sha512-saRNz0DSC5C/I++gFIaJTXoFJMRwiP5zHar5vV3xQ2TkgEw6hDCcU5F272JjUylpiVgBrZNQHnfjkLabTfb92Q==
os-locale@^3.0.0, os-locale@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a"
@@ -9313,13 +9294,13 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
hash-base "^3.0.0"
inherits "^2.0.1"
rollup-plugin-buble@^0.19.8:
version "0.19.8"
resolved "https://registry.yarnpkg.com/rollup-plugin-buble/-/rollup-plugin-buble-0.19.8.tgz#f9232e2bb62a7573d04f9705c1bd6f02c2a02c6a"
integrity sha512-8J4zPk2DQdk3rxeZvxgzhHh/rm5nJkjwgcsUYisCQg1QbT5yagW+hehYEW7ZNns/NVbDCTv4JQ7h4fC8qKGOKw==
rollup-plugin-babel@^4.3.3:
version "4.3.3"
resolved "https://registry.yarnpkg.com/rollup-plugin-babel/-/rollup-plugin-babel-4.3.3.tgz#7eb5ac16d9b5831c3fd5d97e8df77ba25c72a2aa"
integrity sha512-tKzWOCmIJD/6aKNz0H1GMM+lW1q9KyFubbWzGiOG540zxPPifnEAHTZwjo0g991Y+DyOZcLqBgqOdqazYE5fkw==
dependencies:
buble "^0.19.8"
rollup-pluginutils "^2.3.3"
"@babel/helper-module-imports" "^7.0.0"
rollup-pluginutils "^2.8.1"
rollup-plugin-commonjs@^10.0.1:
version "10.0.1"
@@ -9369,7 +9350,7 @@ rollup-plugin-terser@^5.1.1:
serialize-javascript "^1.7.0"
terser "^4.1.0"
rollup-pluginutils@^2.3.3, rollup-pluginutils@^2.5.0, rollup-pluginutils@^2.6.0, rollup-pluginutils@^2.8.1:
rollup-pluginutils@^2.5.0, rollup-pluginutils@^2.6.0, rollup-pluginutils@^2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.1.tgz#8fa6dd0697344938ef26c2c09d2488ce9e33ce97"
integrity sha512-J5oAoysWar6GuZo0s+3bZ6sVZAC0pfqKz68De7ZgDi5z63jOVZn1uJL/+z1jeKHNbGII8kAyHF5q8LnxSX5lQg==