Skip to content

Commit 05163a7

Browse files
authored
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
1 parent f90cd41 commit 05163a7

File tree

11 files changed

+148
-60
lines changed

11 files changed

+148
-60
lines changed

src/client/updateClientMetaInfo.js

+1-8
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
import { metaInfoOptionKeys, metaInfoAttributeKeys } from '../shared/constants'
22
import { isArray } from '../utils/is-type'
33
import { includes } from '../utils/array'
4+
import { getTag } from '../utils/elements'
45
import { updateAttribute, updateTag, updateTitle } from './updaters'
56

6-
function getTag (tags, tag) {
7-
if (!tags[tag]) {
8-
tags[tag] = document.getElementsByTagName(tag)[0]
9-
}
10-
11-
return tags[tag]
12-
}
13-
147
/**
158
* Performs client-side updates when new meta info is received
169
*

src/client/updaters/tag.js

+66-36
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { booleanHtmlAttributes } from '../../shared/constants'
2-
import { toArray, includes } from '../../utils/array'
1+
import { booleanHtmlAttributes, commonDataAttributes } from '../../shared/constants'
2+
import { includes } from '../../utils/array'
3+
import { queryElements, getElementsKey } from '../../utils/elements.js'
34

45
/**
56
* Updates meta tags inside <head> and <body> on the client. Borrowed from `react-helmet`:
@@ -9,11 +10,16 @@ import { toArray, includes } from '../../utils/array'
910
* @param {(Array<Object>|Object)} tags - an array of tag objects or a single object in case of base
1011
* @return {Object} - a representation of what tags changed
1112
*/
12-
export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type, tags, headTag, bodyTag) {
13-
const oldHeadTags = toArray(headTag.querySelectorAll(`${type}[${attribute}="${appId}"], ${type}[data-${tagIDKeyName}]`))
14-
const oldBodyTags = toArray(bodyTag.querySelectorAll(`${type}[${attribute}="${appId}"][data-body="true"], ${type}[data-${tagIDKeyName}][data-body="true"]`))
15-
const dataAttributes = [tagIDKeyName, 'body']
16-
const newTags = []
13+
export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type, tags, head, body) {
14+
const dataAttributes = [tagIDKeyName, ...commonDataAttributes]
15+
const newElements = []
16+
17+
const queryOptions = { appId, attribute, type, tagIDKeyName }
18+
const currentElements = {
19+
head: queryElements(head, queryOptions),
20+
pbody: queryElements(body, queryOptions, { pbody: true }),
21+
body: queryElements(body, queryOptions, { body: true })
22+
}
1723

1824
if (tags.length > 1) {
1925
// remove duplicates that could have been found by merging tags
@@ -29,64 +35,88 @@ export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type
2935
}
3036

3137
if (tags.length) {
32-
tags.forEach((tag) => {
38+
for (const tag of tags) {
3339
const newElement = document.createElement(type)
34-
3540
newElement.setAttribute(attribute, appId)
3641

37-
const oldTags = tag.body !== true ? oldHeadTags : oldBodyTags
38-
3942
for (const attr in tag) {
4043
if (tag.hasOwnProperty(attr)) {
4144
if (attr === 'innerHTML') {
4245
newElement.innerHTML = tag.innerHTML
43-
} else if (attr === 'cssText') {
46+
continue
47+
}
48+
49+
if (attr === 'cssText') {
4450
if (newElement.styleSheet) {
4551
/* istanbul ignore next */
4652
newElement.styleSheet.cssText = tag.cssText
4753
} else {
4854
newElement.appendChild(document.createTextNode(tag.cssText))
4955
}
50-
} else {
51-
const _attr = includes(dataAttributes, attr)
52-
? `data-${attr}`
53-
: attr
54-
55-
const isBooleanAttribute = includes(booleanHtmlAttributes, attr)
56-
if (isBooleanAttribute && !tag[attr]) {
57-
continue
58-
}
56+
continue
57+
}
58+
59+
const _attr = includes(dataAttributes, attr)
60+
? `data-${attr}`
61+
: attr
5962

60-
const value = isBooleanAttribute ? '' : tag[attr]
61-
newElement.setAttribute(_attr, value)
63+
const isBooleanAttribute = includes(booleanHtmlAttributes, attr)
64+
if (isBooleanAttribute && !tag[attr]) {
65+
continue
6266
}
67+
68+
const value = isBooleanAttribute ? '' : tag[attr]
69+
newElement.setAttribute(_attr, value)
6370
}
6471
}
6572

73+
const oldElements = currentElements[getElementsKey(tag)]
74+
6675
// Remove a duplicate tag from domTagstoRemove, so it isn't cleared.
6776
let indexToDelete
68-
const hasEqualElement = oldTags.some((existingTag, index) => {
77+
const hasEqualElement = oldElements.some((existingTag, index) => {
6978
indexToDelete = index
7079
return newElement.isEqualNode(existingTag)
7180
})
7281

7382
if (hasEqualElement && (indexToDelete || indexToDelete === 0)) {
74-
oldTags.splice(indexToDelete, 1)
83+
oldElements.splice(indexToDelete, 1)
7584
} else {
76-
newTags.push(newElement)
85+
newElements.push(newElement)
7786
}
78-
})
87+
}
88+
}
89+
90+
let oldElements = []
91+
for (const current of Object.values(currentElements)) {
92+
oldElements = [
93+
...oldElements,
94+
...current
95+
]
96+
}
97+
98+
// remove old elements
99+
for (const element of oldElements) {
100+
element.parentNode.removeChild(element)
79101
}
80102

81-
const oldTags = oldHeadTags.concat(oldBodyTags)
82-
oldTags.forEach(tag => tag.parentNode.removeChild(tag))
83-
newTags.forEach((tag) => {
84-
if (tag.getAttribute('data-body') === 'true') {
85-
bodyTag.appendChild(tag)
86-
} else {
87-
headTag.appendChild(tag)
103+
// insert new elements
104+
for (const element of newElements) {
105+
if (element.hasAttribute('data-body')) {
106+
body.appendChild(element)
107+
continue
88108
}
89-
})
90109

91-
return { oldTags, newTags }
110+
if (element.hasAttribute('data-pbody')) {
111+
body.insertBefore(element, body.firstChild)
112+
continue
113+
}
114+
115+
head.appendChild(element)
116+
}
117+
118+
return {
119+
oldTags: oldElements,
120+
newTags: newElements
121+
}
92122
}

src/server/generators/tag.js

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { booleanHtmlAttributes, tagsWithoutEndTag, tagsWithInnerContent, tagAttributeAsInnerContent } from '../../shared/constants'
1+
import { booleanHtmlAttributes, tagsWithoutEndTag, tagsWithInnerContent, tagAttributeAsInnerContent, commonDataAttributes } from '../../shared/constants'
22

33
/**
44
* Generates meta, base, link, style, script, noscript tags for use on the server
@@ -8,8 +8,10 @@ import { booleanHtmlAttributes, tagsWithoutEndTag, tagsWithInnerContent, tagAttr
88
* @return {Object} - the tag generator
99
*/
1010
export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}, type, tags) {
11+
const dataAttributes = [tagIDKeyName, ...commonDataAttributes]
12+
1113
return {
12-
text ({ body = false } = {}) {
14+
text ({ body = false, pbody = false } = {}) {
1315
// build a string containing all tags of this type
1416
return tags.reduce((tagsStr, tag) => {
1517
const tagKeys = Object.keys(tag)
@@ -18,7 +20,7 @@ export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}
1820
return tagsStr // Bail on empty tag object
1921
}
2022

21-
if (Boolean(tag.body) !== body) {
23+
if (Boolean(tag.body) !== body || Boolean(tag.pbody) !== pbody) {
2224
return tagsStr
2325
}
2426

@@ -31,7 +33,7 @@ export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}
3133

3234
// these form the attribute list for this tag
3335
let prefix = ''
34-
if ([tagIDKeyName, 'body'].includes(attr)) {
36+
if (dataAttributes.includes(attr)) {
3537
prefix = 'data-'
3638
}
3739

src/shared/constants.js

+3
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ export const tagsWithInnerContent = ['noscript', 'script', 'style']
8989
// Attributes which are inserted as childNodes instead of HTMLAttribute
9090
export const tagAttributeAsInnerContent = ['innerHTML', 'cssText']
9191

92+
// Attributes which should be added with data- prefix
93+
export const commonDataAttributes = ['body', 'pbody']
94+
9295
// from: https://github.com/kangax/html-minifier/blob/gh-pages/src/htmlminifier.js#L202
9396
export const booleanHtmlAttributes = [
9497
'allowfullscreen',

src/utils/elements.js

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { toArray } from './array'
2+
3+
export function getTag (tags, tag) {
4+
if (!tags[tag]) {
5+
tags[tag] = document.getElementsByTagName(tag)[0]
6+
}
7+
8+
return tags[tag]
9+
}
10+
11+
export function getElementsKey ({ body, pbody }) {
12+
return body
13+
? 'body'
14+
: (pbody ? 'pbody' : 'head')
15+
}
16+
17+
export function queryElements (parentNode, { appId, attribute, type, tagIDKeyName }, attributes = {}) {
18+
const queries = [
19+
`${type}[${attribute}="${appId}"]`,
20+
`${type}[data-${tagIDKeyName}]`
21+
].map((query) => {
22+
for (const key in attributes) {
23+
const val = attributes[key]
24+
const attributeValue = val && val !== true ? `="${val}"` : ''
25+
query += `[data-${key}${attributeValue}]`
26+
}
27+
return query
28+
})
29+
30+
return toArray(parentNode.querySelectorAll(queries.join(', ')))
31+
}

test/e2e/browser.test.js

+6-9
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,7 @@ describe(browserString, () => {
6767
})
6868

6969
test('open page', async () => {
70-
const webPath = '/index.html'
71-
72-
let url
73-
if (browser.getLocalFolderUrl) {
74-
url = browser.getLocalFolderUrl(webPath)
75-
} else {
76-
url = `file://${path.join(folder, webPath)}`
77-
}
70+
const url = browser.getUrl('/index.html')
7871

7972
page = await browser.page(url)
8073

@@ -91,12 +84,16 @@ describe(browserString, () => {
9184
sanitizeCheck.push(...(await page.getTexts('noscript')))
9285
sanitizeCheck = sanitizeCheck.filter(v => !!v)
9386

94-
expect(sanitizeCheck.length).toBe(3)
87+
expect(sanitizeCheck.length).toBe(4)
9588
expect(() => JSON.parse(sanitizeCheck[0])).not.toThrow()
9689
// TODO: check why this doesnt Throw when Home is dynamic loaded
9790
// (but that causes hydration error)
9891
expect(() => JSON.parse(sanitizeCheck[1])).toThrow()
9992
expect(() => JSON.parse(sanitizeCheck[2])).not.toThrow()
93+
expect(() => JSON.parse(sanitizeCheck[3])).not.toThrow()
94+
95+
expect(await page.getElementCount('body noscript:first-child')).toBe(1)
96+
expect(await page.getElementCount('body noscript:last-child')).toBe(1)
10097
})
10198

10299
test('/about', async () => {

test/e2e/ssr.test.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,22 @@ describe('basic browser with ssr page', () => {
1818
expect(html.match(/<meta/g).length).toBe(2)
1919
expect(html.match(/<meta/g).length).toBe(2)
2020

21+
// body prepend
22+
expect(html.match(/<body[^>]*>\s*<noscript/g).length).toBe(1)
23+
// body append
24+
expect(html.match(/noscript>\s*<\/body/g).length).toBe(1)
25+
2126
const re = /<(no)?script[^>]+type="application\/ld\+json"[^>]*>(.*?)</g
2227
const sanitizeCheck = []
2328
let match
2429
while ((match = re.exec(html))) {
2530
sanitizeCheck.push(match[2])
2631
}
2732

28-
expect(sanitizeCheck.length).toBe(3)
33+
expect(sanitizeCheck.length).toBe(4)
2934
expect(() => JSON.parse(sanitizeCheck[0])).not.toThrow()
3035
expect(() => JSON.parse(sanitizeCheck[1])).toThrow()
3136
expect(() => JSON.parse(sanitizeCheck[2])).not.toThrow()
37+
expect(() => JSON.parse(sanitizeCheck[3])).not.toThrow()
3238
})
3339
})

test/fixtures/app.template.html

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
{{ noscript.text() }}
1111
</head>
1212
<body {{ bodyAttrs.text() }}>
13+
{{ script.text({ pbody: true }) }}
14+
{{ noscript.text({ pbody: true }) }}
1315
{{ app }}
1416
{{ script.text({ body: true }) }}
1517
{{ noscript.text({ body: true }) }}

test/fixtures/basic/views/home.vue

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default {
2727
{ innerHTML: '{ "more": "data" }', type: 'application/ld+json' }
2828
],
2929
noscript: [
30+
{ innerHTML: '{ "pbody": "yes" }', pbody: true, type: 'application/ld+json' },
3031
{ innerHTML: '{ "body": "yes" }', body: true, type: 'application/ld+json' }
3132
],
3233
__dangerouslyDisableSanitizers: ['noscript'],

test/unit/generators.test.js

+18
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,22 @@ describe('extra tests', () => {
8181
const bodyAttrs = generateServerInjector('bodyAttrs', {})
8282
expect(bodyAttrs.text(true)).toBe('')
8383
})
84+
85+
test('script prepend body', () => {
86+
const tags = [{ src: '/script.js', pbody: true }]
87+
const scriptTags = generateServerInjector('script', tags)
88+
89+
expect(scriptTags.text()).toBe('')
90+
expect(scriptTags.text({ body: true })).toBe('')
91+
expect(scriptTags.text({ pbody: true })).toBe('<script data-vue-meta="test" src="/script.js" data-pbody="true"></script>')
92+
})
93+
94+
test('script append body', () => {
95+
const tags = [{ src: '/script.js', body: true }]
96+
const scriptTags = generateServerInjector('script', tags)
97+
98+
expect(scriptTags.text()).toBe('')
99+
expect(scriptTags.text({ body: true })).toBe('<script data-vue-meta="test" src="/script.js" data-body="true"></script>')
100+
expect(scriptTags.text({ pbody: true })).toBe('')
101+
})
84102
})

test/utils/meta-info-data.js

+7-2
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,12 @@ const metaInfoData = {
114114
add: {
115115
data: [
116116
{ src: 'src', async: false, defer: true, [defaultOptions.tagIDKeyName]: 'content' },
117+
{ src: 'src-prepend', async: true, defer: false, pbody: true },
117118
{ src: 'src', async: false, defer: true, body: true }
118119
],
119120
expect: [
120121
'<script data-vue-meta="test" src="src" defer data-vmid="content"></script>',
122+
'<script data-vue-meta="test" src="src-prepend" async data-pbody="true"></script>',
121123
'<script data-vue-meta="test" src="src" defer data-body="true"></script>'
122124
],
123125
test (side, defaultTest) {
@@ -130,14 +132,17 @@ const metaInfoData = {
130132

131133
expect(tags.addedTags.script[0].parentNode.tagName).toBe('HEAD')
132134
expect(tags.addedTags.script[1].parentNode.tagName).toBe('BODY')
135+
expect(tags.addedTags.script[2].parentNode.tagName).toBe('BODY')
133136
} else {
134137
// ssr doesnt generate data-body tags
135-
const bodyScript = this.expect[1]
138+
const bodyPrepended = this.expect[1]
139+
const bodyAppended = this.expect[2]
136140
this.expect = [this.expect[0]]
137141

138142
const tags = defaultTest()
139143

140-
expect(tags.text()).not.toContain(bodyScript)
144+
expect(tags.text()).not.toContain(bodyPrepended)
145+
expect(tags.text()).not.toContain(bodyAppended)
141146
}
142147
}
143148
}

0 commit comments

Comments
 (0)