Skip to content

Commit

Permalink
feat: add option for prepending (no)script to body (#410)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
pimlie authored Jul 17, 2019
1 parent f90cd41 commit 05163a7
Show file tree
Hide file tree
Showing 11 changed files with 148 additions and 60 deletions.
9 changes: 1 addition & 8 deletions src/client/updateClientMetaInfo.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import { metaInfoOptionKeys, metaInfoAttributeKeys } from '../shared/constants'
import { isArray } from '../utils/is-type'
import { includes } from '../utils/array'
import { getTag } from '../utils/elements'
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
*
Expand Down
102 changes: 66 additions & 36 deletions src/client/updaters/tag.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { booleanHtmlAttributes } from '../../shared/constants'
import { toArray, includes } from '../../utils/array'
import { booleanHtmlAttributes, commonDataAttributes } 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`:
Expand All @@ -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
* @return {Object} - a representation of what tags changed
*/
export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type, tags, headTag, bodyTag) {
const oldHeadTags = toArray(headTag.querySelectorAll(`${type}[${attribute}="${appId}"], ${type}[data-${tagIDKeyName}]`))
const oldBodyTags = toArray(bodyTag.querySelectorAll(`${type}[${attribute}="${appId}"][data-body="true"], ${type}[data-${tagIDKeyName}][data-body="true"]`))
const dataAttributes = [tagIDKeyName, 'body']
const newTags = []
export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type, tags, head, body) {
const dataAttributes = [tagIDKeyName, ...commonDataAttributes]
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
Expand All @@ -29,64 +35,88 @@ export default function updateTag (appId, { attribute, tagIDKeyName } = {}, type
}

if (tags.length) {
tags.forEach((tag) => {
for (const tag of tags) {
const newElement = document.createElement(type)

newElement.setAttribute(attribute, appId)

const oldTags = tag.body !== true ? oldHeadTags : oldBodyTags

for (const attr in tag) {
if (tag.hasOwnProperty(attr)) {
if (attr === 'innerHTML') {
newElement.innerHTML = tag.innerHTML
} else if (attr === 'cssText') {
continue
}

if (attr === 'cssText') {
if (newElement.styleSheet) {
/* istanbul ignore next */
newElement.styleSheet.cssText = tag.cssText
} else {
newElement.appendChild(document.createTextNode(tag.cssText))
}
} else {
const _attr = includes(dataAttributes, attr)
? `data-${attr}`
: attr

const isBooleanAttribute = includes(booleanHtmlAttributes, attr)
if (isBooleanAttribute && !tag[attr]) {
continue
}
continue
}

const _attr = includes(dataAttributes, attr)
? `data-${attr}`
: attr

const value = isBooleanAttribute ? '' : tag[attr]
newElement.setAttribute(_attr, value)
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.
let indexToDelete
const hasEqualElement = oldTags.some((existingTag, index) => {
const hasEqualElement = oldElements.some((existingTag, index) => {
indexToDelete = index
return newElement.isEqualNode(existingTag)
})

if (hasEqualElement && (indexToDelete || indexToDelete === 0)) {
oldTags.splice(indexToDelete, 1)
oldElements.splice(indexToDelete, 1)
} else {
newTags.push(newElement)
newElements.push(newElement)
}
})
}
}

let oldElements = []
for (const current of Object.values(currentElements)) {
oldElements = [
...oldElements,
...current
]
}

// remove old elements
for (const element of oldElements) {
element.parentNode.removeChild(element)
}

const oldTags = oldHeadTags.concat(oldBodyTags)
oldTags.forEach(tag => tag.parentNode.removeChild(tag))
newTags.forEach((tag) => {
if (tag.getAttribute('data-body') === 'true') {
bodyTag.appendChild(tag)
} else {
headTag.appendChild(tag)
// insert new elements
for (const element of newElements) {
if (element.hasAttribute('data-body')) {
body.appendChild(element)
continue
}
})

return { oldTags, newTags }
if (element.hasAttribute('data-pbody')) {
body.insertBefore(element, body.firstChild)
continue
}

head.appendChild(element)
}

return {
oldTags: oldElements,
newTags: newElements
}
}
10 changes: 6 additions & 4 deletions src/server/generators/tag.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,8 +8,10 @@ import { booleanHtmlAttributes, tagsWithoutEndTag, tagsWithInnerContent, tagAttr
* @return {Object} - the tag generator
*/
export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}, type, tags) {
const dataAttributes = [tagIDKeyName, ...commonDataAttributes]

return {
text ({ body = false } = {}) {
text ({ body = false, pbody = false } = {}) {
// build a string containing all tags of this type
return tags.reduce((tagsStr, tag) => {
const tagKeys = Object.keys(tag)
Expand All @@ -18,7 +20,7 @@ export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {}
return tagsStr // Bail on empty tag object
}

if (Boolean(tag.body) !== body) {
if (Boolean(tag.body) !== body || Boolean(tag.pbody) !== pbody) {
return tagsStr
}

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

// these form the attribute list for this tag
let prefix = ''
if ([tagIDKeyName, 'body'].includes(attr)) {
if (dataAttributes.includes(attr)) {
prefix = 'data-'
}

Expand Down
3 changes: 3 additions & 0 deletions src/shared/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export const tagsWithInnerContent = ['noscript', 'script', 'style']
// Attributes which are inserted as childNodes instead of HTMLAttribute
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
export const booleanHtmlAttributes = [
'allowfullscreen',
Expand Down
31 changes: 31 additions & 0 deletions src/utils/elements.js
Original file line number Diff line number Diff line change
@@ -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(', ')))
}
15 changes: 6 additions & 9 deletions test/e2e/browser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,7 @@ describe(browserString, () => {
})

test('open page', async () => {
const webPath = '/index.html'

let url
if (browser.getLocalFolderUrl) {
url = browser.getLocalFolderUrl(webPath)
} else {
url = `file://${path.join(folder, webPath)}`
}
const url = browser.getUrl('/index.html')

page = await browser.page(url)

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

expect(sanitizeCheck.length).toBe(3)
expect(sanitizeCheck.length).toBe(4)
expect(() => JSON.parse(sanitizeCheck[0])).not.toThrow()
// TODO: check why this doesnt Throw when Home is dynamic loaded
// (but that causes hydration error)
expect(() => JSON.parse(sanitizeCheck[1])).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 () => {
Expand Down
8 changes: 7 additions & 1 deletion test/e2e/ssr.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,22 @@ describe('basic browser with ssr page', () => {
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 sanitizeCheck = []
let match
while ((match = re.exec(html))) {
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[1])).toThrow()
expect(() => JSON.parse(sanitizeCheck[2])).not.toThrow()
expect(() => JSON.parse(sanitizeCheck[3])).not.toThrow()
})
})
2 changes: 2 additions & 0 deletions test/fixtures/app.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
{{ noscript.text() }}
</head>
<body {{ bodyAttrs.text() }}>
{{ script.text({ pbody: true }) }}
{{ noscript.text({ pbody: true }) }}
{{ app }}
{{ script.text({ body: true }) }}
{{ noscript.text({ body: true }) }}
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/basic/views/home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default {
{ innerHTML: '{ "more": "data" }', type: 'application/ld+json' }
],
noscript: [
{ innerHTML: '{ "pbody": "yes" }', pbody: true, type: 'application/ld+json' },
{ innerHTML: '{ "body": "yes" }', body: true, type: 'application/ld+json' }
],
__dangerouslyDisableSanitizers: ['noscript'],
Expand Down
18 changes: 18 additions & 0 deletions test/unit/generators.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,22 @@ describe('extra tests', () => {
const bodyAttrs = generateServerInjector('bodyAttrs', {})
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('')
})
})
9 changes: 7 additions & 2 deletions test/utils/meta-info-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,12 @@ const metaInfoData = {
add: {
data: [
{ 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 }
],
expect: [
'<script data-vue-meta="test" src="src" defer data-vmid="content"></script>',
'<script data-vue-meta="test" src="src-prepend" async data-pbody="true"></script>',
'<script data-vue-meta="test" src="src" defer data-body="true"></script>'
],
test (side, defaultTest) {
Expand All @@ -130,14 +132,17 @@ const metaInfoData = {

expect(tags.addedTags.script[0].parentNode.tagName).toBe('HEAD')
expect(tags.addedTags.script[1].parentNode.tagName).toBe('BODY')
expect(tags.addedTags.script[2].parentNode.tagName).toBe('BODY')
} else {
// 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]]

const tags = defaultTest()

expect(tags.text()).not.toContain(bodyScript)
expect(tags.text()).not.toContain(bodyPrepended)
expect(tags.text()).not.toContain(bodyAppended)
}
}
}
Expand Down

0 comments on commit 05163a7

Please sign in to comment.