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

feat: convert to ts (wip)

This commit is contained in:
pimlie
2020-06-01 01:05:59 +02:00
parent 303eae1603
commit 28d3fc1923
106 changed files with 4478 additions and 5037 deletions
+68
View File
@@ -0,0 +1,68 @@
import { h, defineComponent, Teleport, PropType, VNode, VNodeProps } from 'vue'
import { isArray } from '@vue/shared'
import { renderMeta } from './render'
import { getCurrentManager } from './useApi'
import { MetainfoActive } from './types'
export interface MetainfoProps {
metainfo: MetainfoActive
}
export function addVnode(targets: any, target: string, vnode: VNode) {
if (!targets[target]) {
targets[target] = []
}
targets[target].push(vnode)
}
export const MetainfoImpl = defineComponent({
name: 'Metainfo',
props: {
metainfo: {
type: Object as PropType<MetainfoActive>,
required: true,
},
},
setup({ metainfo }, { slots }) {
return () => {
const targets: any = {}
const manager = getCurrentManager()
for (const key in metainfo) {
const config = manager.config[key] || {}
const vnodes = renderMeta(
{ metainfo, slots },
key,
metainfo[key],
config
)
let defaultTarget =
(key !== 'base' && metainfo[key].target) || config.target || 'head'
if (isArray(vnodes)) {
for (const { target, vnode } of vnodes) {
addVnode(targets, target || defaultTarget, vnode)
}
continue
}
const { target, vnode } = vnodes
addVnode(targets, target || defaultTarget, vnode)
}
// console.log('TARGETS', targets)
return Object.keys(targets).map(target => {
return h(Teleport, { to: target }, targets[target])
})
}
},
})
export const Metainfo = (MetainfoImpl as any) as {
new (): {
$props: VNodeProps & MetainfoProps
}
}
-123
View File
@@ -1,123 +0,0 @@
import { toArray } from '../utils/array'
import { querySelector, removeAttribute } from '../utils/elements'
const callbacks = []
export function isDOMLoaded (d) {
return (d || document).readyState !== 'loading'
}
export function isDOMComplete (d) {
return (d || document).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
tags.forEach((tag) => {
if (!tag[tagIDKeyName] || !tag.callback) {
return
}
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) {
callbacks.forEach((args) => {
// do not use destructuring for args, it increases transpiled size
// due to var checks while we are guaranteed the structure of the cb
const query = args[0]
const callback = args[1]
const selector = `${query}[onload="this.__vm_l=1"]`
let elements = []
if (!matchElement) {
elements = toArray(querySelector(selector))
}
if (matchElement && matchElement.matches(selector)) {
elements = [matchElement]
}
elements.forEach((element) => {
/* __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) {
return
}
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
*/
removeAttribute(element, '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()
return
}
if (!element.__vm_ev) {
element.__vm_ev = true
element.addEventListener('load', onload)
}
})
})
}
-61
View File
@@ -1,61 +0,0 @@
import { clientSequences } from '../shared/escaping'
import { rootConfigKey } from '../shared/constants'
import { showWarningNotSupported } from '../shared/log'
import { getComponentMetaInfo } from '../shared/getComponentOption'
import { getAppsMetaInfo, clearAppsMetaInfo } from '../shared/additional-app'
import getMetaInfo from '../shared/getMetaInfo'
import { isFunction } from '../utils/is-type'
import updateClientMetaInfo from './updateClientMetaInfo'
/**
* When called, will update the current meta info with new meta info.
* Useful when updating meta info as the result of an asynchronous
* action that resolves after the initial render takes place.
*
* Credit to [Sébastien Chopin](https://github.com/Atinux) for the suggestion
* to implement this method.
*
* @return {Object} - new meta info
*/
export default function refresh (rootVm, options) {
options = options || {}
// make sure vue-meta was initiated
if (!rootVm[rootConfigKey]) {
showWarningNotSupported()
return {}
}
// collect & aggregate all metaInfo $options
const rawInfo = getComponentMetaInfo(options, rootVm)
const metaInfo = getMetaInfo(options, rawInfo, clientSequences, rootVm)
const { appId } = rootVm[rootConfigKey]
let tags = updateClientMetaInfo(appId, options, metaInfo)
// emit "event" with new info
if (tags && isFunction(metaInfo.changed)) {
metaInfo.changed(metaInfo, tags.tagsAdded, tags.tagsRemoved)
tags = {
addedTags: tags.tagsAdded,
removedTags: tags.tagsRemoved
}
}
const appsMetaInfo = getAppsMetaInfo()
if (appsMetaInfo) {
for (const additionalAppId in appsMetaInfo) {
updateClientMetaInfo(additionalAppId, options, appsMetaInfo[additionalAppId])
delete appsMetaInfo[additionalAppId]
}
clearAppsMetaInfo(true)
}
return {
vm: rootVm,
metaInfo: metaInfo, // eslint-disable-line object-shorthand
tags
}
}
-42
View File
@@ -1,42 +0,0 @@
import { rootConfigKey } from '../shared/constants'
// store an id to keep track of DOM updates
let batchId = null
export function triggerUpdate ({ debounceWait }, rootVm, hookName) {
// if an update was triggered during initialization or when an update was triggered by the
// metaInfo watcher, set initialized to null
// then we keep falsy value but know we need to run a triggerUpdate after initialization
if (!rootVm[rootConfigKey].initialized && (rootVm[rootConfigKey].initializing || hookName === 'watcher')) {
rootVm[rootConfigKey].initialized = null
}
if (rootVm[rootConfigKey].initialized && !rootVm[rootConfigKey].pausing) {
// batch potential DOM updates to prevent extraneous re-rendering
// eslint-disable-next-line no-void
batchUpdate(() => void rootVm.$meta().refresh(), debounceWait)
}
}
/**
* Performs a batched update.
*
* @param {(null|Number)} id - the ID of this update
* @param {Function} callback - the update to perform
* @return {Number} id - a new ID
*/
export function batchUpdate (callback, timeout) {
timeout = timeout === undefined ? 10 : timeout
if (!timeout) {
callback()
return
}
clearTimeout(batchId)
batchId = setTimeout(() => {
callback()
}, timeout)
return batchId
}
-85
View File
@@ -1,85 +0,0 @@
import { metaInfoOptionKeys, metaInfoAttributeKeys, tagsSupportingOnload } from '../shared/constants'
import { isArray } from '../utils/is-type'
import { includes } from '../utils/array'
import { getTag, removeAttribute } from '../utils/elements'
import { addCallbacks, addListeners } from './load'
import { updateAttribute, updateTag, updateTitle } from './updaters'
/**
* Performs client-side updates when new meta info is received
*
* @param {Object} newInfo - the meta info to update to
*/
export default function updateClientMetaInfo (appId, options, newInfo) {
options = options || {}
const { ssrAttribute, ssrAppId } = options
// only cache tags for current update
const tags = {}
const htmlTag = getTag(tags, 'html')
// if this is a server render, then dont update
if (appId === ssrAppId && htmlTag.hasAttribute(ssrAttribute)) {
// remove the server render attribute so we can update on (next) changes
removeAttribute(htmlTag, ssrAttribute)
// add load callbacks if the
let addLoadListeners = false
tagsSupportingOnload.forEach((type) => {
if (newInfo[type] && addCallbacks(options, type, newInfo[type])) {
addLoadListeners = true
}
})
if (addLoadListeners) {
addListeners()
}
return false
}
// initialize tracked changes
const tagsAdded = {}
const tagsRemoved = {}
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(appId, options, type, 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(
appId,
options,
type,
newInfo[type],
getTag(tags, 'head'),
getTag(tags, 'body')
)
if (newTags.length) {
tagsAdded[type] = newTags
tagsRemoved[type] = oldTags
}
}
return { tagsAdded, tagsRemoved }
}
-73
View File
@@ -1,73 +0,0 @@
import { booleanHtmlAttributes } from '../../shared/constants'
import { includes } from '../../utils/array'
import { removeAttribute } from '../../utils/elements'
// keep a local map of attribute values
// instead of adding it to the html
export const attributeMap = {}
/**
* Updates the document's html tag attributes
*
* @param {Object} attrs - the new document html attributes
* @param {HTMLElement} tag - the HTMLElement tag to update with new attrs
*/
export default function updateAttribute (appId, options, type, attrs, tag) {
const { attribute } = options || {}
const vueMetaAttrString = tag.getAttribute(attribute)
if (vueMetaAttrString) {
attributeMap[type] = JSON.parse(decodeURI(vueMetaAttrString))
removeAttribute(tag, attribute)
}
const data = attributeMap[type] || {}
const toUpdate = []
// remove attributes from the map
// which have been removed for this appId
for (const attr in data) {
if (data[attr] && appId in data[attr]) {
toUpdate.push(attr)
if (!attrs[attr]) {
delete data[attr][appId]
}
}
}
for (const attr in attrs) {
const attrData = data[attr]
if (!attrData || attrData[appId] !== attrs[attr]) {
toUpdate.push(attr)
if (attrs[attr]) {
data[attr] = data[attr] || {}
data[attr][appId] = attrs[attr]
}
}
}
for (const attr of toUpdate) {
const attrData = data[attr]
const attrValues = []
for (const appId in attrData) {
Array.prototype.push.apply(attrValues, [].concat(attrData[appId]))
}
if (attrValues.length) {
const attrValue = includes(booleanHtmlAttributes, attr) && attrValues.some(Boolean)
? ''
: attrValues.filter(Boolean).join(' ')
tag.setAttribute(attr, attrValue)
} else {
removeAttribute(tag, attr)
}
}
attributeMap[type] = data
}
-3
View File
@@ -1,3 +0,0 @@
export { default as updateAttribute } from './attribute'
export { default as updateTitle } from './title'
export { default as updateTag } from './tag'
-141
View File
@@ -1,141 +0,0 @@
import { booleanHtmlAttributes, commonDataAttributes, tagProperties } from '../../shared/constants'
import { includes } from '../../utils/array'
import { queryElements, getElementsKey } from '../../utils/elements.js'
/**
* Updates meta tags inside <head> and <body> on the client. Borrowed from `react-helmet`:
* https://github.com/nfl/react-helmet/blob/004d448f8de5f823d10f838b02317521180f34da/src/Helmet.js#L195-L245
*
* @param {('meta'|'base'|'link'|'style'|'script'|'noscript')} type - the name of the tag
* @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, options, type, tags, head, body) {
const { attribute, tagIDKeyName } = options || {}
const dataAttributes = commonDataAttributes.slice()
dataAttributes.push(tagIDKeyName)
const newElements = []
const queryOptions = { appId, attribute, type, tagIDKeyName }
const currentElements = {
head: queryElements(head, queryOptions),
pbody: queryElements(body, queryOptions, { pbody: true }),
body: queryElements(body, queryOptions, { body: true })
}
if (tags.length > 1) {
// remove duplicates that could have been found by merging tags
// which include a mixin with metaInfo and that mixin is used
// by multiple components on the same page
const found = []
tags = tags.filter((x) => {
const k = JSON.stringify(x)
const res = !includes(found, k)
found.push(k)
return res
})
}
tags.forEach((tag) => {
if (tag.skip) {
return
}
const newElement = document.createElement(type)
if (!tag.once) {
newElement.setAttribute(attribute, appId)
}
Object.keys(tag).forEach((attr) => {
/* istanbul ignore next */
if (includes(tagProperties, attr)) {
return
}
if (attr === 'innerHTML') {
newElement.innerHTML = tag.innerHTML
return
}
if (attr === 'json') {
newElement.innerHTML = JSON.stringify(tag.json)
return
}
if (attr === 'cssText') {
if (newElement.styleSheet) {
/* istanbul ignore next */
newElement.styleSheet.cssText = tag.cssText
} else {
newElement.appendChild(document.createTextNode(tag.cssText))
}
return
}
if (attr === 'callback') {
newElement.onload = () => tag[attr](newElement)
return
}
const _attr = includes(dataAttributes, attr)
? `data-${attr}`
: attr
const isBooleanAttribute = includes(booleanHtmlAttributes, attr)
if (isBooleanAttribute && !tag[attr]) {
return
}
const value = isBooleanAttribute ? '' : tag[attr]
newElement.setAttribute(_attr, value)
})
const oldElements = currentElements[getElementsKey(tag)]
// Remove a duplicate tag from domTagstoRemove, so it isn't cleared.
let indexToDelete
const hasEqualElement = oldElements.some((existingTag, index) => {
indexToDelete = index
return newElement.isEqualNode(existingTag)
})
if (hasEqualElement && (indexToDelete || indexToDelete === 0)) {
oldElements.splice(indexToDelete, 1)
} else {
newElements.push(newElement)
}
})
const oldElements = []
for (const type in currentElements) {
Array.prototype.push.apply(oldElements, currentElements[type])
}
// remove old elements
oldElements.forEach((element) => {
element.parentNode.removeChild(element)
})
// insert new elements
newElements.forEach((element) => {
if (element.hasAttribute('data-body')) {
body.appendChild(element)
return
}
if (element.hasAttribute('data-pbody')) {
body.insertBefore(element, body.firstChild)
return
}
head.appendChild(element)
})
return {
oldTags: oldElements,
newTags: newElements
}
}
-12
View File
@@ -1,12 +0,0 @@
/**
* Updates the document title
*
* @param {String} title - the new title of the document
*/
export default function updateTitle (title) {
if (!title && title !== '') {
return
}
document.title = title
}
+79
View File
@@ -0,0 +1,79 @@
import { isArray } from '@vue/shared'
import { tags } from './config/tags'
import { TODO } from './types'
export interface ConfigOption {
tag?: string
target?: string
group?: boolean
nameAttribute?: string
contentAttribute?: string
nameless?: boolean
namespaced?: boolean
namespacedAttribute?: boolean
}
const defaultMapping: { [key: string]: ConfigOption } = {
body: {
tag: 'script',
target: 'body',
},
base: {
contentAttribute: 'href',
},
charset: {
tag: 'meta',
nameless: true,
contentAttribute: 'charset',
},
description: {
tag: 'meta',
},
og: {
group: true,
namespacedAttribute: true,
tag: 'meta',
nameAttribute: 'property',
},
twitter: {
group: true,
namespacedAttribute: true,
tag: 'meta',
},
}
export { defaultMapping }
export function hasConfig(name: string): boolean {
return !!tags[name] || !!defaultMapping[name]
}
export function getConfigKey(
name: string | Array<string>,
key: string,
config: TODO,
dontLog?: boolean
): any {
if (!dontLog) {
// console.log('getConfigKey', name, key, getConfigKey(name, key, config, true), config)
}
if (config && key in config) {
return config[key]
}
if (isArray(name)) {
for (const _name of name) {
if (_name && _name in tags) {
return tags[_name][key]
}
}
return
}
if (name in tags) {
const tag = tags[name]
return tag[key]
}
}
+55
View File
@@ -0,0 +1,55 @@
export interface TagConfig {
nameAttribute?: string
contentAttributes: boolean | Array<string>
[key: string]: any
}
const tags: { [key: string]: TagConfig } = {
title: {
contentAttributes: false,
},
base: {
contentAttributes: ['href', 'target'],
},
meta: {
nameAttribute: 'name',
contentAttributes: ['content', 'name', 'http-equiv', 'charset'],
},
link: {
contentAttributes: [
'href',
'crossorigin',
'rel',
'media',
'integrity',
'hreflang',
'type',
'referrerpolicy',
'sizes',
'imagesrcset',
'imagesizes',
'as',
'color',
],
},
style: {
contentAttributes: ['media'],
},
script: {
contentAttributes: [
'src',
'type',
'nomodule',
'async',
'defer',
'crossorigin',
'integrity',
'referrerpolicy',
],
},
noscript: {
contentAttributes: false,
},
}
export { tags }
+2
View File
@@ -0,0 +1,2 @@
// Global compile-time constants
declare var __DEV__: boolean
-33
View File
@@ -1,33 +0,0 @@
import { version } from '../package.json'
import { showWarningNotSupportedInBrowserBundle } from './shared/log'
import createMixin from './shared/mixin'
import { setOptions } from './shared/options'
import $meta from './shared/$meta'
import generate from './server/generate'
import { hasMetaInfo } from './shared/meta-helpers'
/**
* Plugin install function.
* @param {Function} Vue - the Vue constructor.
*/
function install (Vue, options) {
if (Vue.__vuemeta_installed) {
return
}
Vue.__vuemeta_installed = true
options = setOptions(options)
Vue.prototype.$meta = function () {
return $meta.call(this, options)
}
Vue.mixin(createMixin(Vue, options))
}
export default {
version,
install,
generate: (metaInfo, options) => process.server ? generate(metaInfo, options) : showWarningNotSupportedInBrowserBundle('generate'),
hasMetaInfo
}
+2
View File
@@ -0,0 +1,2 @@
export { createManager } from './manager'
export * from './useApi'
+4
View File
@@ -0,0 +1,4 @@
import { markRaw, reactive } from 'vue'
export const shadow = markRaw({})
export const active = reactive({})
+4
View File
@@ -0,0 +1,4 @@
export * from './globals'
export * from './remove'
export * from './set'
export * from './update'
+6
View File
@@ -0,0 +1,6 @@
import { setByObject } from './set'
import { MetaContext } from '../types'
export function remove(context: MetaContext) {
setByObject(context, {})
}
+34
View File
@@ -0,0 +1,34 @@
import { hasOwn } from '@vue/shared'
import { clone } from '../utils'
import { ActiveNode, MetaContext, PathSegments, ShadowNode } from '../types'
export function resolveActive(
context: MetaContext,
key: string,
pathSegments: PathSegments,
shadowParent: ShadowNode,
activeParent: ActiveNode
) {
let value
if (shadowParent[key].length > 1) {
// Is this useful? Idea is to prevent the user from messing with these options by mistake
const getShadow = () => Object.freeze(clone(shadowParent[key]))
const getActive = () => Object.freeze(clone(activeParent[key]))
value = context.manager.resolver.resolve(
key,
pathSegments,
getShadow,
getActive
)
} else if (shadowParent[key].length) {
value = shadowParent[key][0].value
}
if (value === undefined) {
delete activeParent[key]
} else if (!hasOwn(activeParent, key) || activeParent[key] !== value) {
activeParent[key] = value
}
}
+91
View File
@@ -0,0 +1,91 @@
import { isPlainObject, hasOwn } from '@vue/shared'
import { shadow, active } from './globals'
import { resolveActive } from './resolve'
import { ActiveNode, MetaContext, PathSegments, ShadowNode } from '../types'
export function set(
context: MetaContext,
key: string,
value: any,
shadowParent: ShadowNode = shadow,
activeParent: ActiveNode = active,
pathSegments: PathSegments = []
) {
if (isPlainObject(value)) {
// shadow & active should always be in sync
// if not we have bigger fish to fry
if (!shadowParent[key]) {
shadowParent[key] = {}
activeParent[key] = {}
}
return setByObject(
context,
value,
shadowParent[key],
activeParent[key],
pathSegments
)
}
let idx = -1
if (!shadowParent[key]) {
shadowParent[key] = []
} else {
// check if we already have a value listed for this element for this context
idx = shadowParent[key].findIndex(
({ context: $context }: { context: MetaContext }) => $context === context
)
}
// if this context/key combo exists but value is undefined, remove it
if (idx > -1 && value === undefined) {
shadowParent[key].splice(idx, 1)
// overwrite current value for context/key combo
} else if (idx > -1) {
shadowParent[key][idx].value = value
// new context/key combo so just add value
} else if (value) {
shadowParent[key].push({ context, value })
}
resolveActive(context, key, pathSegments, shadowParent, activeParent)
}
export function setByObject(
context: MetaContext,
value: any,
shadowParent: ShadowNode = shadow,
activeParent: ActiveNode = active,
pathSegments: PathSegments = []
) {
// cleanup properties that no longer exists
for (const key in shadowParent) {
if (hasOwn(value, key)) {
continue
}
if (isPlainObject(shadowParent[key])) {
setByObject(context, {}, shadowParent[key], activeParent[key], [
...pathSegments,
key,
])
continue
}
set(context, key, undefined, shadowParent, activeParent, [
...pathSegments,
key,
])
}
// set new values
for (const key in value) {
set(context, key, value[key], shadowParent, activeParent, [
...pathSegments,
key,
])
}
}
+20
View File
@@ -0,0 +1,20 @@
import { shadow, active } from './globals'
import { set } from './set'
import { MetaContext, PathSegments, ShadowNode, ActiveNode } from '../types'
export function update(
context: MetaContext,
pathSegments: PathSegments,
key: string,
value: any
) {
let shadowParent: ShadowNode = shadow
let activeParent: ActiveNode = active
for (const segment of pathSegments) {
shadowParent = shadowParent[segment]
activeParent = activeParent[segment]
}
set(context, key, value, shadowParent, activeParent)
}
+18
View File
@@ -0,0 +1,18 @@
import { App } from 'vue'
import { Metainfo } from './Metainfo'
import { metaInfoKey } from './symbols'
import { active } from './info/globals'
import { Manager } from './manager'
declare module '@vue/runtime-core' {
interface ComponentInternalInstance {
$metaManager: Manager
}
}
export function applyMetaPlugin(app: App, manager: Manager) {
app.component('Metainfo', Metainfo)
app.config.globalProperties.$metaManager = manager
app.provide(metaInfoKey, active)
}
+55
View File
@@ -0,0 +1,55 @@
import { App } from 'vue'
import { isFunction } from '@vue/shared'
import { defaultMapping } from './config'
import { applyMetaPlugin } from './install'
import * as deepestResolver from './resolvers/deepest'
import { TODO, ActiveResolverObject, MetaContext, PathSegments } from './types'
export type ManagerOptions = {
install(app: App): void
}
export type Manager = {
readonly config: TODO
resolver: ActiveResolverObject
install(app: App): void
}
export function createManager(options: TODO = {}): Manager {
const { resolver = deepestResolver } = options
// TODO: validate resolver
const manager: Manager = {
resolver: {
setup(context: MetaContext) {
if (!resolver || isFunction(resolver)) {
return
}
resolver.setup(context)
},
resolve(key: string, pathSegments: PathSegments, getShadow, getActive) {
if (!resolver) {
return
}
if (isFunction(resolver)) {
return resolver(key, pathSegments, getShadow, getActive)
}
return resolver.resolve(key, pathSegments, getShadow, getActive)
},
},
config: {
...defaultMapping,
...options.config,
},
install(app: App) {
applyMetaPlugin(app, this)
},
}
return manager
}
+48
View File
@@ -0,0 +1,48 @@
import { markRaw } from 'vue'
import { isObject } from '@vue/shared'
import { update } from './info/update'
import { MetaContext, MetainfoInput, PathSegments } from './types'
interface Target extends MetainfoInput {
__vm_proxy?: any
}
export function createProxy(
target: Target,
handler: ProxyHandler<object>
): Target {
return markRaw(new Proxy(target, handler))
}
export function createHandler(
context: MetaContext,
pathSegments: PathSegments = []
): ProxyHandler<object> {
return {
get(target: object, key: string, receiver: object) {
const value = Reflect.get(target, key, receiver)
if (!isObject(value)) {
return value
}
if (!value.__vm_proxy) {
const keyPath: PathSegments = [...pathSegments, key]
const handler = /*#__PURE__*/ createHandler(context, keyPath)
value.__vm_proxy = createProxy(value, handler)
}
return value.__vm_proxy
},
set(
target: object,
key: string,
value: unknown,
receiver: object
): boolean {
update(context, pathSegments, key, value)
return true
},
}
}
+220
View File
@@ -0,0 +1,220 @@
import { h, VNode } from 'vue'
import { isArray } from '@vue/shared'
import { getConfigKey } from './config'
import { TODO } from './types'
export interface RenderContext {
slots: any
[key: string]: TODO
}
export interface GroupConfig {
group: string
data: Array<TODO> | TODO
tagNamespace?: string
fullName?: string
slotName?: string
}
export interface SlotScopeProperties {
content: any
metainfo: any
[key: string]: any
}
export type RenderedMetainfoNode = {
vnode: VNode
target?: string
}
export type RenderedMetainfo = Array<RenderedMetainfoNode>
export function renderMeta(
context: RenderContext,
key: string,
data: TODO,
config: TODO
): RenderedMetainfo | RenderedMetainfoNode {
// console.info('renderMeta', key, data, config)
if (config.group) {
return renderGroup(context, key, data, config)
}
return renderTag(context, key, data, config)
}
export function renderGroup(
context: RenderContext,
key: string,
data: TODO,
config: TODO
): RenderedMetainfo | RenderedMetainfoNode {
// console.info('renderGroup', key, data, config)
if (isArray(data)) {
config.contentAttributes = getConfigKey(
[key, config.tag],
'contentAttributes',
config
)
return data.map(_data => renderTag(context, key, _data, config)).flat()
}
return Object.keys(data)
.map(childKey => {
const groupConfig: GroupConfig = {
group: key,
data,
}
if (config.namespaced) {
groupConfig.tagNamespace =
config.namespaced === true ? key : config.namespaced
} else if (config.namespacedAttribute) {
const namespace =
config.namespacedAttribute === true ? key : config.namespacedAttribute
groupConfig.fullName = `${namespace}:${childKey}`
groupConfig.slotName = `${namespace}(${childKey})`
}
return renderTag(context, key, data[childKey], config, groupConfig)
})
.flat()
}
export function renderTag(
context: RenderContext,
key: string,
data: TODO,
config: TODO = {},
groupConfig?: GroupConfig
): RenderedMetainfo | RenderedMetainfoNode {
if (!config.group && isArray(data)) {
data = { content: data }
}
let content, hasChilds
if (isArray(data)) {
return data
.map(child => {
return renderTag(context, key, child, config, groupConfig)
})
.flat()
} else if (data.content && isArray(data.content)) {
content = data.content.map((child: string | TODO) => {
if (typeof child === 'string') {
return child
}
return renderTag(context, key, child, config, groupConfig)
})
hasChilds = true
} else {
content = data
}
const { tag = config.tag || key } = data
const fullName = (groupConfig && groupConfig.fullName) || key
const slotName = (groupConfig && groupConfig.slotName) || key
let { attrs: attributes } = data
if (!attributes && typeof data === 'object') {
attributes = { ...data }
delete attributes.tag
delete attributes.content
delete attributes.target
} else {
attributes = {}
}
if (hasChilds) {
content = getSlotContent(context, slotName, content, config, data)
} else {
const contentAttributes = getConfigKey(tag, 'contentAttributes', config)
if (contentAttributes) {
if (!config.nameless) {
const nameAttribute = getConfigKey(tag, 'nameAttribute', config)
if (nameAttribute) {
attributes[nameAttribute] = fullName
}
}
const contentAttribute = config.contentAttribute || contentAttributes[0]
attributes[contentAttribute] = getSlotContent(
context,
slotName,
attributes[contentAttribute] || content,
config,
groupConfig
)
content = undefined
} else {
content = getSlotContent(context, slotName, content, config, data)
}
}
const finalTag =
groupConfig && groupConfig.tagNamespace
? `${groupConfig.tagNamespace}:${tag}`
: tag
// console.info('FINAL TAG', finalTag)
// console.log(' ATTRIBUTES', attributes)
// console.log(' CONTENT', content)
// // console.log(data, attributes, config)
if (hasChilds) {
for (const child of content) {
if (typeof child === 'string') {
continue
}
if (child.type === finalTag) {
return content
}
break
}
}
const vnode = h(finalTag, attributes, content)
return {
target: data.target,
vnode,
}
}
export function getSlotContent(
{ metainfo, slots }: RenderContext,
slotName: string,
content: any,
config: TODO,
groupConfig?: GroupConfig
): TODO {
if (!slots[slotName]) {
return content
}
const slotProps: SlotScopeProperties = {
content,
metainfo,
}
if (groupConfig && groupConfig.group) {
slotProps[groupConfig.group] = groupConfig.data
}
content = slots[slotName](slotProps)
if (content.length) {
return content[0].children
}
return ''
}
+15
View File
@@ -0,0 +1,15 @@
import {
ActiveNode,
/*ActiveResolverSetup, ActiveResolverMethod,*/ MetaContext,
PathSegments,
ShadowNode,
} from '../types'
export function setup(context: MetaContext): void {}
export function resolve(
key: string,
pathSegments: PathSegments,
shadow: ShadowNode,
active: ActiveNode
): any {}
+1
View File
@@ -0,0 +1 @@
export interface Resolver {}
-12
View File
@@ -1,12 +0,0 @@
import getMetaInfo from '../shared/getMetaInfo'
import { serverSequences } from '../shared/escaping'
import { setOptions } from '../shared/options'
import generateServerInjector from './generateServerInjector'
export default function generate (rawInfo, options) {
options = setOptions(options)
const metaInfo = getMetaInfo(options, rawInfo, serverSequences)
const serverInjector = generateServerInjector(options, metaInfo)
return serverInjector.injectors
}
-94
View File
@@ -1,94 +0,0 @@
import { metaInfoOptionKeys, metaInfoAttributeKeys, defaultInfo } from '../shared/constants'
import { titleGenerator, attributeGenerator, tagGenerator } from './generators'
/**
* Converts a meta info property to one that can be stringified on the server
*
* @param {String} type - the type of data to convert
* @param {(String|Object|Array<Object>)} data - the data value
* @return {Object} - the new injector
*/
export default function generateServerInjector (options, metaInfo) {
const serverInjector = {
data: metaInfo,
extraData: undefined,
addInfo (appId, metaInfo) {
this.extraData = this.extraData || {}
this.extraData[appId] = metaInfo
},
callInjectors (opts) {
const m = this.injectors
// only call title for the head
return (opts.body || opts.pbody ? '' : m.title.text(opts)) +
m.meta.text(opts) +
m.link.text(opts) +
m.style.text(opts) +
m.script.text(opts) +
m.noscript.text(opts)
},
injectors: {
head: ln => serverInjector.callInjectors({ ln }),
bodyPrepend: ln => serverInjector.callInjectors({ ln, pbody: true }),
bodyAppend: ln => serverInjector.callInjectors({ ln, body: true })
}
}
for (const type in defaultInfo) {
if (metaInfoOptionKeys.includes(type)) {
continue
}
serverInjector.injectors[type] = {
text (arg) {
if (type === 'title') {
return titleGenerator(options, type, serverInjector.data[type], arg)
}
if (metaInfoAttributeKeys.includes(type)) {
const attributeData = {}
const data = serverInjector.data[type]
if (data) {
for (const attr in data) {
attributeData[attr] = {
[options.ssrAppId]: data[attr]
}
}
}
if (serverInjector.extraData) {
for (const appId in serverInjector.extraData) {
const data = serverInjector.extraData[appId][type]
if (data) {
for (const attr in data) {
attributeData[attr] = {
...attributeData[attr],
[appId]: data[attr]
}
}
}
}
}
return attributeGenerator(options, type, attributeData, arg)
}
let str = tagGenerator(options, type, serverInjector.data[type], arg)
if (serverInjector.extraData) {
for (const appId in serverInjector.extraData) {
const data = serverInjector.extraData[appId][type]
const extraStr = tagGenerator(options, type, data, { appId, ...arg })
str = `${str}${extraStr}`
}
}
return str
}
}
}
return serverInjector
}
-40
View File
@@ -1,40 +0,0 @@
import { booleanHtmlAttributes } from '../../shared/constants'
/**
* Generates tag attributes for use on the server.
*
* @param {('bodyAttrs'|'htmlAttrs'|'headAttrs')} type - the type of attributes to generate
* @param {Object} data - the attributes to generate
* @return {Object} - the attribute generator
*/
export default function attributeGenerator (options, type, data, addSrrAttribute) {
const { attribute, ssrAttribute } = options || {}
let attributeStr = ''
for (const attr in data) {
const attrData = data[attr]
const attrValues = []
for (const appId in attrData) {
attrValues.push(...[].concat(attrData[appId]))
}
if (attrValues.length) {
attributeStr += booleanHtmlAttributes.includes(attr) && attrValues.some(Boolean)
? `${attr}`
: `${attr}="${attrValues.join(' ')}"`
attributeStr += ' '
}
}
if (attributeStr) {
attributeStr += `${attribute}="${encodeURI(JSON.stringify(data))}"`
}
if (type === 'htmlAttrs' && addSrrAttribute) {
return `${ssrAttribute}${attributeStr ? ' ' : ''}${attributeStr}`
}
return attributeStr
}
-3
View File
@@ -1,3 +0,0 @@
export { default as attributeGenerator } from './attribute'
export { default as titleGenerator } from './title'
export { default as tagGenerator } from './tag'
-92
View File
@@ -1,92 +0,0 @@
import {
booleanHtmlAttributes,
tagsWithoutEndTag,
tagsWithInnerContent,
tagAttributeAsInnerContent,
tagProperties,
commonDataAttributes
} from '../../shared/constants'
/**
* Generates meta, base, link, style, script, noscript tags for use on the server
*
* @param {('meta'|'base'|'link'|'style'|'script'|'noscript')} the name of the tag
* @param {(Array<Object>|Object)} tags - an array of tag objects or a single object in case of base
* @return {Object} - the tag generator
*/
export default function tagGenerator (options, type, tags, generatorOptions) {
const { ssrAppId, attribute, tagIDKeyName } = options || {}
const { appId, body = false, pbody = false, ln = false } = generatorOptions || {}
const dataAttributes = [tagIDKeyName, ...commonDataAttributes]
if (!tags || !tags.length) {
return ''
}
// 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) {
return tagsStr // Bail on empty tag object
}
if (Boolean(tag.body) !== body || Boolean(tag.pbody) !== pbody) {
return tagsStr
}
let attrs = tag.once ? '' : ` ${attribute}="${appId || ssrAppId}"`
// build a string containing all attributes of this tag
for (const attr in tag) {
// these attributes are treated as children on the tag
if (tagAttributeAsInnerContent.includes(attr) || tagProperties.includes(attr)) {
continue
}
if (attr === 'callback') {
attrs += ' onload="this.__vm_l=1"'
continue
}
// these form the attribute list for this tag
let prefix = ''
if (dataAttributes.includes(attr)) {
prefix = 'data-'
}
const isBooleanAttr = !prefix && booleanHtmlAttributes.includes(attr)
if (isBooleanAttr && !tag[attr]) {
continue
}
attrs += ` ${prefix}${attr}` + (isBooleanAttr ? '' : `="${tag[attr]}"`)
}
let json = ''
if (tag.json) {
json = JSON.stringify(tag.json)
}
// grab child content from one of these attributes, if possible
const content = tag.innerHTML || tag.cssText || json
// generate tag exactly without any other redundant attribute
// these tags have no end tag
const hasEndTag = !tagsWithoutEndTag.includes(type)
// these tag types will have content inserted
const hasContent = hasEndTag && tagsWithInnerContent.includes(type)
// the final string for this specific tag
return `${tagsStr}<${type}${attrs}${!hasContent && hasEndTag ? '/' : ''}>` +
(hasContent ? `${content}</${type}>` : '') +
(ln ? '\n' : '')
}, '')
}
-16
View File
@@ -1,16 +0,0 @@
/**
* Generates title output for the server
*
* @param {'title'} type - the string "title"
* @param {String} data - the title text
* @return {Object} - the title generator
*/
export default function titleGenerator (options, type, data, generatorOptions) {
const { ln } = generatorOptions || {}
if (!data) {
return ''
}
return `<${type}>${data}</${type}>${ln ? '\n' : ''}`
}
-42
View File
@@ -1,42 +0,0 @@
import { serverSequences } from '../shared/escaping'
import { rootConfigKey } from '../shared/constants'
import { showWarningNotSupported } from '../shared/log'
import { getComponentMetaInfo } from '../shared/getComponentOption'
import { getAppsMetaInfo, clearAppsMetaInfo } from '../shared/additional-app'
import getMetaInfo from '../shared/getMetaInfo'
import generateServerInjector from './generateServerInjector'
/**
* Converts the state of the meta info object such that each item
* can be compiled to a tag string on the server
*
* @vm {Object} - Vue instance - ideally the root component
* @return {Object} - server meta info with `toString` methods
*/
export default function inject (rootVm, options) {
// make sure vue-meta was initiated
if (!rootVm[rootConfigKey]) {
showWarningNotSupported()
return {}
}
// collect & aggregate all metaInfo $options
const rawInfo = getComponentMetaInfo(options, rootVm)
const metaInfo = getMetaInfo(options, rawInfo, serverSequences, rootVm)
// generate server injector
const serverInjector = generateServerInjector(options, metaInfo)
// add meta info from additional apps
const appsMetaInfo = getAppsMetaInfo()
if (appsMetaInfo) {
for (const additionalAppId in appsMetaInfo) {
serverInjector.addInfo(additionalAppId, appsMetaInfo[additionalAppId])
delete appsMetaInfo[additionalAppId]
}
clearAppsMetaInfo(true)
}
return serverInjector.injectors
}
-46
View File
@@ -1,46 +0,0 @@
import refresh from '../client/refresh'
import inject from '../server/inject'
import { addApp } from './additional-app'
import { showWarningNotSupportedInBrowserBundle } from './log'
import { addNavGuards } from './nav-guards'
import { pause, resume } from './pausing'
import { getOptions } from './options'
export default function $meta (options) {
options = options || {}
/**
* Returns an injector for server-side rendering.
* @this {Object} - the Vue instance (a root component)
* @return {Object} - injector
*/
const $root = this.$root
return {
getOptions: () => getOptions(options),
setOptions: (newOptions) => {
const refreshNavKey = 'refreshOnceOnNavigation'
if (newOptions && newOptions[refreshNavKey]) {
options.refreshOnceOnNavigation = !!newOptions[refreshNavKey]
addNavGuards($root)
}
const debounceWaitKey = 'debounceWait'
if (newOptions && debounceWaitKey in newOptions) {
const debounceWait = parseInt(newOptions[debounceWaitKey])
if (!isNaN(debounceWait)) {
options.debounceWait = debounceWait
}
}
const waitOnDestroyedKey = 'waitOnDestroyed'
if (newOptions && waitOnDestroyedKey in newOptions) {
options.waitOnDestroyed = !!newOptions[waitOnDestroyedKey]
}
},
refresh: () => refresh($root, options),
inject: () => process.server ? inject($root, options) : showWarningNotSupportedInBrowserBundle('inject'),
pause: () => pause($root),
resume: () => resume($root),
addApp: appId => addApp($root, appId, options)
}
}
-52
View File
@@ -1,52 +0,0 @@
import updateClientMetaInfo from '../client/updateClientMetaInfo'
import { updateAttribute } from '../client/updaters'
import { metaInfoAttributeKeys } from '../shared/constants'
import { getTag, removeElementsByAppId } from '../utils/elements'
let appsMetaInfo
export function addApp (rootVm, appId, options) {
return {
set: metaInfo => setMetaInfo(rootVm, appId, options, metaInfo),
remove: () => removeMetaInfo(rootVm, appId, options)
}
}
export function setMetaInfo (rootVm, appId, options, metaInfo) {
// if a vm exists _and_ its mounted then immediately update
if (rootVm && rootVm.$el) {
return updateClientMetaInfo(appId, options, metaInfo)
}
// store for later, the info
// will be set on the first refresh
appsMetaInfo = appsMetaInfo || {}
appsMetaInfo[appId] = metaInfo
}
export function removeMetaInfo (rootVm, appId, options) {
if (rootVm && rootVm.$el) {
const tags = {}
for (const type of metaInfoAttributeKeys) {
const tagName = type.substr(0, 4)
updateAttribute(appId, options, type, {}, getTag(tags, tagName))
}
return removeElementsByAppId(options, appId)
}
if (appsMetaInfo[appId]) {
delete appsMetaInfo[appId]
clearAppsMetaInfo()
}
}
export function getAppsMetaInfo () {
return appsMetaInfo
}
export function clearAppsMetaInfo (force) {
if (force || !Object.keys(appsMetaInfo).length) {
appsMetaInfo = undefined
}
}
-157
View File
@@ -1,157 +0,0 @@
/**
* These are constant variables used throughout the application.
*/
// set some sane defaults
export const defaultInfo = {
title: undefined,
titleChunk: '',
titleTemplate: '%s',
htmlAttrs: {},
bodyAttrs: {},
headAttrs: {},
base: [],
link: [],
meta: [],
style: [],
script: [],
noscript: [],
__dangerouslyDisableSanitizers: [],
__dangerouslyDisableSanitizersByTagID: {}
}
export const rootConfigKey = '_vueMeta'
// 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'
// This is the attribute vue-meta arguments on elements to know which it should
// manage and which it should ignore.
export const attribute = 'data-vue-meta'
// This is the attribute that goes on the `html` tag to inform `vue-meta`
// that the server has already generated the meta tags for the initial render.
export const ssrAttribute = 'data-vue-meta-server-rendered'
// This is the property that tells vue-meta to overwrite (instead of append)
// an item in a tag list. For example, if you have two `meta` tag list items
// that both have `vmid` of "description", then vue-meta will overwrite the
// shallowest one with the deepest one.
export const tagIDKeyName = 'vmid'
// This is the key name for possible meta templates
export const metaTemplateKeyName = 'template'
// This is the key name for the content-holding property
export const contentKeyName = 'content'
// The id used for the ssr app
export const ssrAppId = 'ssr'
// How long meta update
export const debounceWait = 10
// How long meta update
export const waitOnDestroyed = true
export const defaultOptions = {
keyName,
attribute,
ssrAttribute,
tagIDKeyName,
contentKeyName,
metaTemplateKeyName,
waitOnDestroyed,
debounceWait,
ssrAppId
}
// might be a bit ugly, but minimizes the browser bundles a bit
const defaultInfoKeys = Object.keys(defaultInfo)
// The metaInfo property keys which are used to disable escaping
export const disableOptionKeys = [
defaultInfoKeys[12],
defaultInfoKeys[13]
]
// List of metaInfo property keys which are configuration options (and dont generate html)
export const metaInfoOptionKeys = [
defaultInfoKeys[1],
defaultInfoKeys[2],
'changed',
...disableOptionKeys
]
// List of metaInfo property keys which only generates attributes and no tags
export const metaInfoAttributeKeys = [
defaultInfoKeys[3],
defaultInfoKeys[4],
defaultInfoKeys[5]
]
// 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']
// HTML elements which can have inner content (shortened to our needs)
export const tagsWithInnerContent = ['noscript', 'script', 'style']
// Attributes which are inserted as childNodes instead of HTMLAttribute
export const tagAttributeAsInnerContent = ['innerHTML', 'cssText', 'json']
export const tagProperties = ['once', 'skip', 'template']
// Attributes which should be added with data- prefix
export const commonDataAttributes = ['body', 'pbody']
// from: https://github.com/kangax/html-minifier/blob/gh-pages/src/htmlminifier.js#L202
export const booleanHtmlAttributes = [
'allowfullscreen',
'amp',
'amp-boilerplate',
'async',
'autofocus',
'autoplay',
'checked',
'compact',
'controls',
'declare',
'default',
'defaultchecked',
'defaultmuted',
'defaultselected',
'defer',
'disabled',
'enabled',
'formnovalidate',
'hidden',
'indeterminate',
'inert',
'ismap',
'itemscope',
'loop',
'multiple',
'muted',
'nohref',
'noresize',
'noshade',
'novalidate',
'nowrap',
'open',
'pauseonexit',
'readonly',
'required',
'reversed',
'scoped',
'seamless',
'selected',
'sortable',
'truespeed',
'typemustmatch',
'visible'
]
-108
View File
@@ -1,108 +0,0 @@
import { isString, isArray, isPureObject } from '../utils/is-type'
import { includes } from '../utils/array'
import { ensureIsArray } from '../utils/ensure'
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, escapeKeys) {
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
}
// do not use destructuring for disableOptionKeys, it increases transpiled size
// due to var checks while we are guaranteed the structure of the cb
let disableKey = disableOptionKeys[0]
if (escapeOptions[disableKey] && includes(escapeOptions[disableKey], key)) {
// 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) => {
if (isPureObject(v)) {
return escape(v, options, escapeOptions, true)
}
return doEscape(v)
})
} else if (isPureObject(value)) {
escaped[key] = escape(value, options, escapeOptions, true)
} else {
escaped[key] = value
}
if (escapeKeys) {
const escapedKey = doEscape(key)
if (key !== escapedKey) {
escaped[escapedKey] = escaped[key]
delete escaped[key]
}
}
}
return escaped
}
export function escapeMetaInfo (options, info, escapeSequences) {
escapeSequences = escapeSequences || []
// do not use destructuring for seq, it increases transpiled size
// due to var checks while we are guaranteed the structure of the cb
const escapeOptions = {
doEscape: value => escapeSequences.reduce((val, seq) => val.replace(seq[0], seq[1]), value)
}
disableOptionKeys.forEach((disableKey, index) => {
if (index === 0) {
ensureIsArray(info, disableKey)
} else if (index === 1) {
for (const key in info[disableKey]) {
ensureIsArray(info[disableKey], key)
}
}
escapeOptions[disableKey] = info[disableKey]
})
// begin sanitization
return escape(info, options, escapeOptions)
}
-65
View File
@@ -1,65 +0,0 @@
import { isObject } from '../utils/is-type'
import { defaultInfo } from './constants'
import { merge } from './merge'
import { inMetaInfoBranch } from './meta-helpers'
export function getComponentMetaInfo (options, component) {
return getComponentOption(options || {}, component, defaultInfo)
}
/**
* Returns the `opts.option` $option value of the given `opts.component`.
* If methods are encountered, they will be bound to the component context.
* If `opts.deep` is true, will recursively merge all child component
* `opts.option` $option values into the returned result.
*
* @param {Object} opts - options
* @param {Object} opts.component - Vue component to fetch option data from
* @param {Boolean} opts.deep - look for data in child components as well?
* @param {Function} opts.arrayMerge - how should arrays be merged?
* @param {String} opts.keyName - the name of the option to look for
* @param {Object} [result={}] - result so far
* @return {Object} result - final aggregated result
*/
export function getComponentOption (options, component, result) {
result = result || {}
if (component._inactive) {
return result
}
options = options || {}
const { keyName } = options
const { $metaInfo, $options, $children } = component
// only collect option data if it exists
if ($options[keyName]) {
// if $metaInfo exists then [keyName] was defined as a function
// and set to the computed prop $metaInfo in the mixin
// using the computed prop should be a small performance increase
// because Vue caches those internally
const data = $metaInfo || $options[keyName]
// only merge data with result when its an object
// eg it could be a function when metaInfo() returns undefined
// dueo to the or statement above
if (isObject(data)) {
result = merge(result, data, options)
}
}
// collect & aggregate child options if deep = true
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)
})
}
return result
}
-52
View File
@@ -1,52 +0,0 @@
import { findIndex } from '../utils/array'
import { escapeMetaInfo } from '../shared/escaping'
import { applyTemplate } from './template'
/**
* Returns the correct meta info for the given component
* (child components will overwrite parent meta info)
*
* @param {Object} component - the Vue instance to get meta info from
* @return {Object} - returned meta info
*/
export default function getMetaInfo (options, info, escapeSequences, component) {
options = options || {}
escapeSequences = escapeSequences || []
const { tagIDKeyName } = options
// Remove all "template" tags from meta
// backup the title chunk in case user wants access to it
if (info.title) {
info.titleChunk = info.title
}
// replace title with populated template
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
// as the other tags
if (info.base) {
info.base = Object.keys(info.base).length ? [info.base] : []
}
if (info.meta) {
// remove meta items with duplicate vmid's
info.meta = info.meta.filter((metaItem, index, arr) => {
const hasVmid = !!metaItem[tagIDKeyName]
if (!hasVmid) {
return true
}
const isFirstItemForVmid = index === findIndex(arr, item => item[tagIDKeyName] === metaItem[tagIDKeyName])
return isFirstItemForVmid
})
// apply templates if needed
info.meta.forEach(metaObject => applyTemplate(options, metaObject))
}
return escapeMetaInfo(options, info, escapeSequences)
}
-18
View File
@@ -1,18 +0,0 @@
import { hasGlobalWindow } from '../utils/window'
const _global = hasGlobalWindow ? window : global
const console = _global.console || {}
export function warn (str) {
/* istanbul ignore next */
if (!console || !console.warn) {
return
}
console.warn(str)
}
export const showWarningNotSupportedInBrowserBundle = method => warn(`${method} is not supported in browser builds`)
export const showWarningNotSupported = () => warn('This vue app/component has no vue-meta configuration')
-111
View File
@@ -1,111 +0,0 @@
import deepmerge from 'deepmerge'
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,
// but we check for a `vmid` property on each object in the array
// using an O(1) lookup associative array exploit
const destination = []
if (!target.length && !source.length) {
return 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 (
(contentKeyName in sourceItem && sourceItem[contentKeyName] === undefined) ||
('innerHTML' in sourceItem && 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)
// set template to true to indicate template was already applied
sourceItem.template = true
return
}
if (!sourceItem[contentKeyName]) {
// use parent content and child template
applyTemplate({ component, metaTemplateKeyName, contentKeyName }, sourceItem, undefined, targetItem[contentKeyName])
}
})
return destination.concat(source)
}
let warningShown = false
export function merge (target, source, options) {
options = 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.title === undefined) {
delete source.title
}
metaInfoAttributeKeys.forEach((attrKey) => {
if (!source[attrKey]) {
return
}
for (const key in source[attrKey]) {
if (key in source[attrKey] && source[attrKey][key] === undefined) {
if (includes(booleanHtmlAttributes, key) && !warningShown) {
warn('VueMeta: Please note that since v2 the value undefined is not used to indicate boolean attributes anymore, see migration guide for details')
warningShown = true
}
delete source[attrKey][key]
}
}
})
return deepmerge(target, source, {
arrayMerge: (t, s) => arrayMerge(options, t, s)
})
}
-14
View File
@@ -1,14 +0,0 @@
import { isUndefined, isObject } from '../utils/is-type'
import { rootConfigKey } from './constants'
// Vue $root instance has a _vueMeta object property, otherwise its a boolean true
export function hasMetaInfo (vm) {
vm = vm || this
return vm && (vm[rootConfigKey] === true || isObject(vm[rootConfigKey]))
}
// a component is in a metaInfo branch when itself has meta info or one of its (grand-)children has
export function inMetaInfoBranch (vm) {
vm = vm || this
return vm && !isUndefined(vm[rootConfigKey])
}
-196
View File
@@ -1,196 +0,0 @@
import { triggerUpdate } from '../client/update'
import { isUndefined, isFunction } from '../utils/is-type'
import { find } from '../utils/array'
import { rootConfigKey } from './constants'
import { hasMetaInfo } from './meta-helpers'
import { addNavGuards } from './nav-guards'
import { warn } from './log'
let appId = 1
export default function createMixin (Vue, options) {
// for which Vue lifecycle hooks should the metaInfo be refreshed
const updateOnLifecycleHook = ['activated', 'deactivated', 'beforeMount']
// watch for client side component updates
return {
beforeCreate () {
const rootKey = '$root'
const $root = this[rootKey]
const $options = this.$options
const devtoolsEnabled = Vue.config.devtools
Object.defineProperty(this, '_hasMetaInfo', {
configurable: true,
get () {
// Show deprecation warning once when devtools enabled
if (devtoolsEnabled && !$root[rootConfigKey].deprecationWarningShown) {
warn('VueMeta DeprecationWarning: _hasMetaInfo has been deprecated and will be removed in a future version. Please use hasMetaInfo(vm) instead')
$root[rootConfigKey].deprecationWarningShown = true
}
return hasMetaInfo(this)
}
})
// Add a marker to know if it uses metaInfo
// _vnode is used to know that it's attached to a real component
// useful if we use some mixin to add some meta tags (like nuxt-i18n)
if (isUndefined($options[options.keyName]) || $options[options.keyName] === null) {
return
}
if (!$root[rootConfigKey]) {
$root[rootConfigKey] = { appId }
appId++
if (devtoolsEnabled && $root.$options[options.keyName]) {
// use nextTick so the children should be added to $root
this.$nextTick(() => {
// find the first child that lists fnOptions
const child = find($root.$children, c => c.$vnode && c.$vnode.fnOptions)
if (child && child.$vnode.fnOptions[options.keyName]) {
warn(`VueMeta has detected a possible global mixin which adds a ${options.keyName} property to all Vue components on the page. This could cause severe performance issues. If possible, use $meta().addApp to add meta information instead`)
}
})
}
}
// 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[rootConfigKey]) {
this[rootConfigKey] = true
let parent = this.$parent
while (parent && parent !== $root) {
if (isUndefined(parent[rootConfigKey])) {
parent[rootConfigKey] = false
}
parent = parent.$parent
}
}
// coerce function-style metaInfo to a computed prop so we can observe
// it on creation
if (isFunction($options[options.keyName])) {
$options.computed = $options.computed || {}
$options.computed.$metaInfo = $options[options.keyName]
if (!this.$isServer) {
// if computed $metaInfo exists, watch it for updates & trigger a refresh
// when it changes (i.e. automatically handle async actions that affect metaInfo)
// credit for this suggestion goes to [Sébastien Chopin](https://github.com/Atinux)
this.$on('hook:created', function () {
this.$watch('$metaInfo', function () {
triggerUpdate(options, this[rootKey], 'watcher')
})
})
}
}
// 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 immediately set the page title
if (isUndefined($root[rootConfigKey].initialized)) {
$root[rootConfigKey].initialized = this.$isServer
if (!$root[rootConfigKey].initialized) {
if (!$root[rootConfigKey].initializedSsr) {
$root[rootConfigKey].initializedSsr = true
this.$on('hook:beforeMount', function () {
const $root = this
// if this Vue-app was server rendered, set the appId to 'ssr'
// only one SSR app per page is supported
if ($root.$el && $root.$el.nodeType === 1 && $root.$el.hasAttribute('data-server-rendered')) {
$root[rootConfigKey].appId = options.ssrAppId
}
})
}
// we use the mounted hook here as on page load
this.$on('hook:mounted', function () {
const $root = this[rootKey]
if (!$root[rootConfigKey].initialized) {
// used in triggerUpdate to check if a change was triggered
// during initialization
$root[rootConfigKey].initializing = true
// refresh meta in nextTick so all child components have loaded
this.$nextTick(function () {
const { tags, metaInfo } = $root.$meta().refresh()
// After ssr hydration (identifier by tags === false) check
// if initialized was set to null in triggerUpdate. That'd mean
// that during initilazation changes where triggered which need
// to be applied OR a metaInfo watcher was triggered before the
// current hook was called
// (during initialization all changes are blocked)
if (tags === false && $root[rootConfigKey].initialized === null) {
this.$nextTick(() => triggerUpdate(options, $root, 'init'))
}
$root[rootConfigKey].initialized = true
delete $root[rootConfigKey].initializing
// add the navigation guards if they havent been added yet
// they are needed for the afterNavigation callback
if (!options.refreshOnceOnNavigation && metaInfo.afterNavigation) {
addNavGuards($root)
}
})
}
})
// add the navigation guards if requested
if (options.refreshOnceOnNavigation) {
addNavGuards($root)
}
}
}
this.$on('hook:destroyed', function () {
// do not trigger refresh:
// - when user configured to not wait for transitions on destroyed
// - when the component doesnt have a parent
// - doesnt have metaInfo defined
if (!this.$parent || !hasMetaInfo(this)) {
return
}
delete this._hasMetaInfo
this.$nextTick(() => {
if (!options.waitOnDestroyed || !this.$el || !this.$el.offsetParent) {
triggerUpdate(options, this.$root, 'destroyed')
return
}
// 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)
triggerUpdate(options, this.$root, 'destroyed')
}, 50)
})
})
// do not trigger refresh on the server side
if (this.$isServer) {
/* istanbul ignore next */
return
}
// no need to add this hooks on server side
updateOnLifecycleHook.forEach((lifecycleHook) => {
this.$on(`hook:${lifecycleHook}`, function () {
triggerUpdate(options, this[rootKey], lifecycleHook)
})
})
}
}
}
-30
View File
@@ -1,30 +0,0 @@
import { isFunction } from '../utils/is-type'
import { rootConfigKey } from './constants'
import { pause, resume } from './pausing'
export function addNavGuards (rootVm) {
const router = rootVm.$router
// return when nav guards already added or no router exists
if (rootVm[rootConfigKey].navGuards || !router) {
/* istanbul ignore next */
return
}
rootVm[rootConfigKey].navGuards = true
router.beforeEach((to, from, next) => {
pause(rootVm)
next()
})
router.afterEach(() => {
rootVm.$nextTick(() => {
const { metaInfo } = resume(rootVm)
if (metaInfo && isFunction(metaInfo.afterNavigation)) {
metaInfo.afterNavigation(metaInfo)
}
})
})
}
-34
View File
@@ -1,34 +0,0 @@
import { isObject, isUndefined } from '../utils/is-type'
import { defaultOptions } from './constants'
export function setOptions (options) {
// combine options
options = isObject(options) ? options : {}
// The options are set like this so they can
// be minified by terser while keeping the
// user api intact
// terser --mangle-properties keep_quoted=strict
/* eslint-disable dot-notation */
return {
keyName: options['keyName'] || defaultOptions.keyName,
attribute: options['attribute'] || defaultOptions.attribute,
ssrAttribute: options['ssrAttribute'] || defaultOptions.ssrAttribute,
tagIDKeyName: options['tagIDKeyName'] || defaultOptions.tagIDKeyName,
contentKeyName: options['contentKeyName'] || defaultOptions.contentKeyName,
metaTemplateKeyName: options['metaTemplateKeyName'] || defaultOptions.metaTemplateKeyName,
debounceWait: isUndefined(options['debounceWait']) ? defaultOptions.debounceWait : options['debounceWait'],
waitOnDestroyed: isUndefined(options['waitOnDestroyed']) ? defaultOptions.waitOnDestroyed : options['waitOnDestroyed'],
ssrAppId: options['ssrAppId'] || defaultOptions.ssrAppId,
refreshOnceOnNavigation: !!options['refreshOnceOnNavigation']
}
/* eslint-enable dot-notation */
}
export function getOptions (options) {
const optionsCopy = {}
for (const key in options) {
optionsCopy[key] = options[key]
}
return optionsCopy
}
-15
View File
@@ -1,15 +0,0 @@
import { rootConfigKey } from './constants'
export function pause (rootVm, refresh) {
rootVm[rootConfigKey].pausing = true
return () => resume(rootVm, refresh)
}
export function resume (rootVm, refresh) {
rootVm[rootConfigKey].pausing = false
if (refresh || refresh === undefined) {
return rootVm.$meta().refresh()
}
}
-30
View File
@@ -1,30 +0,0 @@
import { isUndefined, isFunction } from '../utils/is-type'
export function applyTemplate ({ component, metaTemplateKeyName, contentKeyName }, headObject, template, chunk) {
if (template === true || headObject[metaTemplateKeyName] === true) {
// abort, template was already applied
return false
}
if (isUndefined(template) && headObject[metaTemplateKeyName]) {
template = headObject[metaTemplateKeyName]
headObject[metaTemplateKeyName] = true
}
// return early if no template defined
if (!template) {
// cleanup faulty template properties
delete headObject[metaTemplateKeyName]
return false
}
if (isUndefined(chunk)) {
chunk = headObject[contentKeyName]
}
headObject[contentKeyName] = isFunction(template)
? template.call(component, chunk)
: template.replace(/%s/g, chunk)
return true
}
+15
View File
@@ -0,0 +1,15 @@
import { InjectionKey } from 'vue'
import { MetainfoActive } from './types'
export const hasSymbol =
typeof Symbol === 'function' && typeof Symbol.toStringTag === 'symbol'
export const PolySymbol = (name: string) =>
// vm = vue meta
hasSymbol
? Symbol(__DEV__ ? '[vue-meta]: ' + name : name)
: (__DEV__ ? '[vue-meta]: ' : '_vm_') + name
export const metaInfoKey = PolySymbol(
__DEV__ ? 'metainfo' : 'mi'
) as InjectionKey<MetainfoActive>
+44
View File
@@ -0,0 +1,44 @@
import { ComponentInternalInstance } from 'vue'
import { Manager } from '../manager'
export type Immutable<T> = {
readonly [P in keyof T]: Immutable<T[P]>
}
export type TODO = any
export type PathSegments = Array<string>
export interface MetainfoInput {
[key: string]: TODO
}
export interface MetainfoActive {
[key: string]: TODO
}
export type MetaContext = {
id: string | symbol
vm?: ComponentInternalInstance | null
manager: Manager
}
export type ActiveResolverSetup = (context: MetaContext) => void
export type ActiveResolverMethod = (
key: string,
pathSegments: PathSegments,
shadow: ShadowNode,
active: ActiveNode
) => any
export interface ActiveResolverObject {
setup?: ActiveResolverSetup
resolve: ActiveResolverMethod
}
export interface ShadowNode {
[key: string]: TODO
}
export interface ActiveNode {
[key: string]: TODO
}
+56
View File
@@ -0,0 +1,56 @@
import { inject, getCurrentInstance, onUnmounted } from 'vue'
import { setByObject, remove } from './info'
import { Manager } from './manager'
import { createProxy, createHandler } from './proxy'
import { metaInfoKey, PolySymbol } from './symbols'
import { MetaContext, MetainfoActive, MetainfoInput } from './types'
let contextCounter: number = 0
export function useMeta(obj: MetainfoInput, manager?: Manager) {
const vm = getCurrentInstance()
if (vm) {
console.log(vm)
manager = vm.appContext.config.globalProperties.$metaManager
}
if (!manager) {
// oopsydoopsy
throw new Error('No manager or current instance')
return
}
const context: MetaContext = {
id: PolySymbol(`context ${contextCounter++}`),
vm,
manager,
}
let unmount = <T extends Function = () => any>() => remove(context)
if (vm) {
onUnmounted(unmount)
}
if (manager.resolver.setup) {
manager.resolver.setup(context)
}
setByObject(context, obj)
const handler = /*#__PURE__*/ createHandler(context)
const meta = createProxy(obj, handler)
return {
meta,
unmount,
}
}
export function useMetainfo(): MetainfoActive {
return inject(metaInfoKey)!
}
export function getCurrentManager(): Manager {
const vm = getCurrentInstance()!
return vm.appContext.config.globalProperties.$metaManager
}
-58
View File
@@ -1,58 +0,0 @@
/*
* 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 find (array, predicate, thisArg) {
if (polyfill && !Array.prototype.find) {
// idx needs to be a Number, for..in returns string
for (let idx = 0; idx < array.length; idx++) {
if (predicate.call(thisArg, array[idx], idx, array)) {
return array[idx]
}
}
return
}
return array.find(predicate, thisArg)
}
export function findIndex (array, predicate, thisArg) {
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(thisArg, array[idx], idx, array)) {
return idx
}
}
return -1
}
return array.findIndex(predicate, thisArg)
}
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)
}
+23
View File
@@ -0,0 +1,23 @@
import { isArray, isObject } from '@vue/shared'
// See: https://github.com/vuejs/vue-next/blob/08b4e8815da4e8911058ccbab986bea6365c3352/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts
export function clone(v: any): any {
if (isArray(v)) {
return v.map(clone)
}
if (isObject(v)) {
const res: any = {}
for (const key in v) {
if (key === 'context') {
res[key] = v[key]
} else {
res[key] = clone(v[key])
}
}
return res
}
return v
}
-43
View File
@@ -1,43 +0,0 @@
import { toArray } from './array'
export const querySelector = (arg, el) => (el || document).querySelectorAll(arg)
export function getTag (tags, tag) {
if (!tags[tag]) {
tags[tag] = document.getElementsByTagName(tag)[0]
}
return tags[tag]
}
export function getElementsKey ({ body, pbody }) {
return body
? 'body'
: (pbody ? 'pbody' : 'head')
}
export function queryElements (parentNode, { appId, attribute, type, tagIDKeyName }, attributes) {
attributes = attributes || {}
const queries = [
`${type}[${attribute}="${appId}"]`,
`${type}[data-${tagIDKeyName}]`
].map((query) => {
for (const key in attributes) {
const val = attributes[key]
const attributeValue = val && val !== true ? `="${val}"` : ''
query += `[data-${key}${attributeValue}]`
}
return query
})
return toArray(querySelector(queries.join(', '), parentNode))
}
export function removeElementsByAppId ({ attribute }, appId) {
toArray(querySelector(`[${attribute}="${appId}"]`)).map(el => el.remove())
}
export function removeAttribute (el, attributeName) {
el.removeAttribute(attributeName)
}
-12
View File
@@ -1,12 +0,0 @@
import { isArray, isObject } from './is-type'
export function ensureIsArray (arg, key) {
if (!key || !isObject(arg)) {
return isArray(arg) ? arg : []
}
if (!isArray(arg[key])) {
arg[key] = []
}
return arg
}
+1
View File
@@ -0,0 +1 @@
export * from './clone'
-28
View File
@@ -1,28 +0,0 @@
/**
* 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'
}
export function isObject (arg) {
return typeof arg === 'object'
}
export function isPureObject (arg) {
return typeof arg === 'object' && arg !== null
}
export function isFunction (arg) {
return typeof arg === 'function'
}
export function isString (arg) {
return typeof arg === 'string'
}
-11
View File
@@ -1,11 +0,0 @@
import { isUndefined } from './is-type'
export function hasGlobalWindowFn () {
try {
return !isUndefined(window)
} catch (e) {
return false
}
}
export const hasGlobalWindow = hasGlobalWindowFn()