mirror of
https://github.com/tenrok/vue-meta.git
synced 2026-06-24 19:30:35 +03:00
feat: add option for prepending (no)script to body (#410)
* feat: add option for prepending (no)script to body * test: use browser getUrl * refactor: use pbody insteadn of pody * test: add prepend/append body generator test * test: add prepend body updater test * chore: remove typo
This commit is contained in:
@@ -1,16 +1,9 @@
|
|||||||
import { metaInfoOptionKeys, metaInfoAttributeKeys } from '../shared/constants'
|
import { metaInfoOptionKeys, metaInfoAttributeKeys } from '../shared/constants'
|
||||||
import { isArray } from '../utils/is-type'
|
import { isArray } from '../utils/is-type'
|
||||||
import { includes } from '../utils/array'
|
import { includes } from '../utils/array'
|
||||||
|
import { getTag } from '../utils/elements'
|
||||||
import { updateAttribute, updateTag, updateTitle } from './updaters'
|
import { updateAttribute, updateTag, updateTitle } from './updaters'
|
||||||
|
|
||||||
function getTag (tags, tag) {
|
|
||||||
if (!tags[tag]) {
|
|
||||||
tags[tag] = document.getElementsByTagName(tag)[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags[tag]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs client-side updates when new meta info is received
|
* Performs client-side updates when new meta info is received
|
||||||
*
|
*
|
||||||
|
|||||||
+68
-38
@@ -1,5 +1,6 @@
|
|||||||
import { booleanHtmlAttributes } from '../../shared/constants'
|
import { booleanHtmlAttributes, commonDataAttributes } from '../../shared/constants'
|
||||||
import { toArray, includes } from '../../utils/array'
|
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`:
|
* Updates meta tags inside <head> and <body> on the client. Borrowed from `react-helmet`:
|
||||||
@@ -9,11 +10,16 @@ import { toArray, includes } from '../../utils/array'
|
|||||||
* @param {(Array<Object>|Object)} tags - an array of tag objects or a single object in case of base
|
* @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
|
* @return {Object} - a representation of what tags changed
|
||||||
*/
|
*/
|
||||||
export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type, tags, headTag, bodyTag) {
|
export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type, tags, head, body) {
|
||||||
const oldHeadTags = toArray(headTag.querySelectorAll(`${type}[${attribute}="${appId}"], ${type}[data-${tagIDKeyName}]`))
|
const dataAttributes = [tagIDKeyName, ...commonDataAttributes]
|
||||||
const oldBodyTags = toArray(bodyTag.querySelectorAll(`${type}[${attribute}="${appId}"][data-body="true"], ${type}[data-${tagIDKeyName}][data-body="true"]`))
|
const newElements = []
|
||||||
const dataAttributes = [tagIDKeyName, 'body']
|
|
||||||
const newTags = []
|
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) {
|
if (tags.length > 1) {
|
||||||
// remove duplicates that could have been found by merging tags
|
// remove duplicates that could have been found by merging tags
|
||||||
@@ -29,64 +35,88 @@ export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tags.length) {
|
if (tags.length) {
|
||||||
tags.forEach((tag) => {
|
for (const tag of tags) {
|
||||||
const newElement = document.createElement(type)
|
const newElement = document.createElement(type)
|
||||||
|
|
||||||
newElement.setAttribute(attribute, appId)
|
newElement.setAttribute(attribute, appId)
|
||||||
|
|
||||||
const oldTags = tag.body !== true ? oldHeadTags : oldBodyTags
|
|
||||||
|
|
||||||
for (const attr in tag) {
|
for (const attr in tag) {
|
||||||
if (tag.hasOwnProperty(attr)) {
|
if (tag.hasOwnProperty(attr)) {
|
||||||
if (attr === 'innerHTML') {
|
if (attr === 'innerHTML') {
|
||||||
newElement.innerHTML = tag.innerHTML
|
newElement.innerHTML = tag.innerHTML
|
||||||
} else if (attr === 'cssText') {
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attr === 'cssText') {
|
||||||
if (newElement.styleSheet) {
|
if (newElement.styleSheet) {
|
||||||
/* istanbul ignore next */
|
/* istanbul ignore next */
|
||||||
newElement.styleSheet.cssText = tag.cssText
|
newElement.styleSheet.cssText = tag.cssText
|
||||||
} else {
|
} else {
|
||||||
newElement.appendChild(document.createTextNode(tag.cssText))
|
newElement.appendChild(document.createTextNode(tag.cssText))
|
||||||
}
|
}
|
||||||
} else {
|
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 _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)]
|
||||||
|
|
||||||
// Remove a duplicate tag from domTagstoRemove, so it isn't cleared.
|
// Remove a duplicate tag from domTagstoRemove, so it isn't cleared.
|
||||||
let indexToDelete
|
let indexToDelete
|
||||||
const hasEqualElement = oldTags.some((existingTag, index) => {
|
const hasEqualElement = oldElements.some((existingTag, index) => {
|
||||||
indexToDelete = index
|
indexToDelete = index
|
||||||
return newElement.isEqualNode(existingTag)
|
return newElement.isEqualNode(existingTag)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (hasEqualElement && (indexToDelete || indexToDelete === 0)) {
|
if (hasEqualElement && (indexToDelete || indexToDelete === 0)) {
|
||||||
oldTags.splice(indexToDelete, 1)
|
oldElements.splice(indexToDelete, 1)
|
||||||
} else {
|
} else {
|
||||||
newTags.push(newElement)
|
newElements.push(newElement)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldTags = oldHeadTags.concat(oldBodyTags)
|
let oldElements = []
|
||||||
oldTags.forEach(tag => tag.parentNode.removeChild(tag))
|
for (const current of Object.values(currentElements)) {
|
||||||
newTags.forEach((tag) => {
|
oldElements = [
|
||||||
if (tag.getAttribute('data-body') === 'true') {
|
...oldElements,
|
||||||
bodyTag.appendChild(tag)
|
...current
|
||||||
} else {
|
]
|
||||||
headTag.appendChild(tag)
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return { oldTags, newTags }
|
// remove old elements
|
||||||
|
for (const element of oldElements) {
|
||||||
|
element.parentNode.removeChild(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert new elements
|
||||||
|
for (const element of newElements) {
|
||||||
|
if (element.hasAttribute('data-body')) {
|
||||||
|
body.appendChild(element)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.hasAttribute('data-pbody')) {
|
||||||
|
body.insertBefore(element, body.firstChild)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
head.appendChild(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oldTags: oldElements,
|
||||||
|
newTags: newElements
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { booleanHtmlAttributes, tagsWithoutEndTag, tagsWithInnerContent, tagAttributeAsInnerContent } 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
|
* Generates meta, base, link, style, script, noscript tags for use on the server
|
||||||
@@ -8,8 +8,10 @@ import { booleanHtmlAttributes, tagsWithoutEndTag, tagsWithInnerContent, tagAttr
|
|||||||
* @return {Object} - the tag generator
|
* @return {Object} - the tag generator
|
||||||
*/
|
*/
|
||||||
export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}, type, tags) {
|
export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}, type, tags) {
|
||||||
|
const dataAttributes = [tagIDKeyName, ...commonDataAttributes]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text ({ body = false } = {}) {
|
text ({ body = false, pbody = false } = {}) {
|
||||||
// build a string containing all tags of this type
|
// build a string containing all tags of this type
|
||||||
return tags.reduce((tagsStr, tag) => {
|
return tags.reduce((tagsStr, tag) => {
|
||||||
const tagKeys = Object.keys(tag)
|
const tagKeys = Object.keys(tag)
|
||||||
@@ -18,7 +20,7 @@ export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}
|
|||||||
return tagsStr // Bail on empty tag object
|
return tagsStr // Bail on empty tag object
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Boolean(tag.body) !== body) {
|
if (Boolean(tag.body) !== body || Boolean(tag.pbody) !== pbody) {
|
||||||
return tagsStr
|
return tagsStr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}
|
|||||||
|
|
||||||
// these form the attribute list for this tag
|
// these form the attribute list for this tag
|
||||||
let prefix = ''
|
let prefix = ''
|
||||||
if ([tagIDKeyName, 'body'].includes(attr)) {
|
if (dataAttributes.includes(attr)) {
|
||||||
prefix = 'data-'
|
prefix = 'data-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export const tagsWithInnerContent = ['noscript', 'script', 'style']
|
|||||||
// Attributes which are inserted as childNodes instead of HTMLAttribute
|
// Attributes which are inserted as childNodes instead of HTMLAttribute
|
||||||
export const tagAttributeAsInnerContent = ['innerHTML', 'cssText']
|
export const tagAttributeAsInnerContent = ['innerHTML', 'cssText']
|
||||||
|
|
||||||
|
// 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
|
// from: https://github.com/kangax/html-minifier/blob/gh-pages/src/htmlminifier.js#L202
|
||||||
export const booleanHtmlAttributes = [
|
export const booleanHtmlAttributes = [
|
||||||
'allowfullscreen',
|
'allowfullscreen',
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { toArray } from './array'
|
||||||
|
|
||||||
|
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 = {}) {
|
||||||
|
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(parentNode.querySelectorAll(queries.join(', ')))
|
||||||
|
}
|
||||||
@@ -67,14 +67,7 @@ describe(browserString, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('open page', async () => {
|
test('open page', async () => {
|
||||||
const webPath = '/index.html'
|
const url = browser.getUrl('/index.html')
|
||||||
|
|
||||||
let url
|
|
||||||
if (browser.getLocalFolderUrl) {
|
|
||||||
url = browser.getLocalFolderUrl(webPath)
|
|
||||||
} else {
|
|
||||||
url = `file://${path.join(folder, webPath)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
page = await browser.page(url)
|
page = await browser.page(url)
|
||||||
|
|
||||||
@@ -91,12 +84,16 @@ describe(browserString, () => {
|
|||||||
sanitizeCheck.push(...(await page.getTexts('noscript')))
|
sanitizeCheck.push(...(await page.getTexts('noscript')))
|
||||||
sanitizeCheck = sanitizeCheck.filter(v => !!v)
|
sanitizeCheck = sanitizeCheck.filter(v => !!v)
|
||||||
|
|
||||||
expect(sanitizeCheck.length).toBe(3)
|
expect(sanitizeCheck.length).toBe(4)
|
||||||
expect(() => JSON.parse(sanitizeCheck[0])).not.toThrow()
|
expect(() => JSON.parse(sanitizeCheck[0])).not.toThrow()
|
||||||
// TODO: check why this doesnt Throw when Home is dynamic loaded
|
// TODO: check why this doesnt Throw when Home is dynamic loaded
|
||||||
// (but that causes hydration error)
|
// (but that causes hydration error)
|
||||||
expect(() => JSON.parse(sanitizeCheck[1])).toThrow()
|
expect(() => JSON.parse(sanitizeCheck[1])).toThrow()
|
||||||
expect(() => JSON.parse(sanitizeCheck[2])).not.toThrow()
|
expect(() => JSON.parse(sanitizeCheck[2])).not.toThrow()
|
||||||
|
expect(() => JSON.parse(sanitizeCheck[3])).not.toThrow()
|
||||||
|
|
||||||
|
expect(await page.getElementCount('body noscript:first-child')).toBe(1)
|
||||||
|
expect(await page.getElementCount('body noscript:last-child')).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('/about', async () => {
|
test('/about', async () => {
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ describe('basic browser with ssr page', () => {
|
|||||||
expect(html.match(/<meta/g).length).toBe(2)
|
expect(html.match(/<meta/g).length).toBe(2)
|
||||||
expect(html.match(/<meta/g).length).toBe(2)
|
expect(html.match(/<meta/g).length).toBe(2)
|
||||||
|
|
||||||
|
// body prepend
|
||||||
|
expect(html.match(/<body[^>]*>\s*<noscript/g).length).toBe(1)
|
||||||
|
// body append
|
||||||
|
expect(html.match(/noscript>\s*<\/body/g).length).toBe(1)
|
||||||
|
|
||||||
const re = /<(no)?script[^>]+type="application\/ld\+json"[^>]*>(.*?)</g
|
const re = /<(no)?script[^>]+type="application\/ld\+json"[^>]*>(.*?)</g
|
||||||
const sanitizeCheck = []
|
const sanitizeCheck = []
|
||||||
let match
|
let match
|
||||||
@@ -25,9 +30,10 @@ describe('basic browser with ssr page', () => {
|
|||||||
sanitizeCheck.push(match[2])
|
sanitizeCheck.push(match[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(sanitizeCheck.length).toBe(3)
|
expect(sanitizeCheck.length).toBe(4)
|
||||||
expect(() => JSON.parse(sanitizeCheck[0])).not.toThrow()
|
expect(() => JSON.parse(sanitizeCheck[0])).not.toThrow()
|
||||||
expect(() => JSON.parse(sanitizeCheck[1])).toThrow()
|
expect(() => JSON.parse(sanitizeCheck[1])).toThrow()
|
||||||
expect(() => JSON.parse(sanitizeCheck[2])).not.toThrow()
|
expect(() => JSON.parse(sanitizeCheck[2])).not.toThrow()
|
||||||
|
expect(() => JSON.parse(sanitizeCheck[3])).not.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+2
@@ -10,6 +10,8 @@
|
|||||||
{{ noscript.text() }}
|
{{ noscript.text() }}
|
||||||
</head>
|
</head>
|
||||||
<body {{ bodyAttrs.text() }}>
|
<body {{ bodyAttrs.text() }}>
|
||||||
|
{{ script.text({ pbody: true }) }}
|
||||||
|
{{ noscript.text({ pbody: true }) }}
|
||||||
{{ app }}
|
{{ app }}
|
||||||
{{ script.text({ body: true }) }}
|
{{ script.text({ body: true }) }}
|
||||||
{{ noscript.text({ body: true }) }}
|
{{ noscript.text({ body: true }) }}
|
||||||
|
|||||||
Vendored
+1
@@ -27,6 +27,7 @@ export default {
|
|||||||
{ innerHTML: '{ "more": "data" }', type: 'application/ld+json' }
|
{ innerHTML: '{ "more": "data" }', type: 'application/ld+json' }
|
||||||
],
|
],
|
||||||
noscript: [
|
noscript: [
|
||||||
|
{ innerHTML: '{ "pbody": "yes" }', pbody: true, type: 'application/ld+json' },
|
||||||
{ innerHTML: '{ "body": "yes" }', body: true, type: 'application/ld+json' }
|
{ innerHTML: '{ "body": "yes" }', body: true, type: 'application/ld+json' }
|
||||||
],
|
],
|
||||||
__dangerouslyDisableSanitizers: ['noscript'],
|
__dangerouslyDisableSanitizers: ['noscript'],
|
||||||
|
|||||||
@@ -81,4 +81,22 @@ describe('extra tests', () => {
|
|||||||
const bodyAttrs = generateServerInjector('bodyAttrs', {})
|
const bodyAttrs = generateServerInjector('bodyAttrs', {})
|
||||||
expect(bodyAttrs.text(true)).toBe('')
|
expect(bodyAttrs.text(true)).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('script prepend body', () => {
|
||||||
|
const tags = [{ src: '/script.js', pbody: true }]
|
||||||
|
const scriptTags = generateServerInjector('script', tags)
|
||||||
|
|
||||||
|
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>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('script append body', () => {
|
||||||
|
const tags = [{ src: '/script.js', body: true }]
|
||||||
|
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({ pbody: true })).toBe('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -114,10 +114,12 @@ const metaInfoData = {
|
|||||||
add: {
|
add: {
|
||||||
data: [
|
data: [
|
||||||
{ src: 'src', async: false, defer: true, [defaultOptions.tagIDKeyName]: 'content' },
|
{ src: 'src', async: false, defer: true, [defaultOptions.tagIDKeyName]: 'content' },
|
||||||
|
{ src: 'src-prepend', async: true, defer: false, pbody: true },
|
||||||
{ src: 'src', async: false, defer: true, body: true }
|
{ src: 'src', async: false, defer: true, body: true }
|
||||||
],
|
],
|
||||||
expect: [
|
expect: [
|
||||||
'<script data-vue-meta="test" src="src" defer data-vmid="content"></script>',
|
'<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="test" src="src" defer data-body="true"></script>'
|
||||||
],
|
],
|
||||||
test (side, defaultTest) {
|
test (side, defaultTest) {
|
||||||
@@ -130,14 +132,17 @@ const metaInfoData = {
|
|||||||
|
|
||||||
expect(tags.addedTags.script[0].parentNode.tagName).toBe('HEAD')
|
expect(tags.addedTags.script[0].parentNode.tagName).toBe('HEAD')
|
||||||
expect(tags.addedTags.script[1].parentNode.tagName).toBe('BODY')
|
expect(tags.addedTags.script[1].parentNode.tagName).toBe('BODY')
|
||||||
|
expect(tags.addedTags.script[2].parentNode.tagName).toBe('BODY')
|
||||||
} else {
|
} else {
|
||||||
// ssr doesnt generate data-body tags
|
// ssr doesnt generate data-body tags
|
||||||
const bodyScript = this.expect[1]
|
const bodyPrepended = this.expect[1]
|
||||||
|
const bodyAppended = this.expect[2]
|
||||||
this.expect = [this.expect[0]]
|
this.expect = [this.expect[0]]
|
||||||
|
|
||||||
const tags = defaultTest()
|
const tags = defaultTest()
|
||||||
|
|
||||||
expect(tags.text()).not.toContain(bodyScript)
|
expect(tags.text()).not.toContain(bodyPrepended)
|
||||||
|
expect(tags.text()).not.toContain(bodyAppended)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user