From 05163a77a8f3245d1f3fdbaf45cb318430476a44 Mon Sep 17 00:00:00 2001 From: Pim Date: Wed, 17 Jul 2019 22:26:33 +0200 Subject: [PATCH] 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 --- src/client/updateClientMetaInfo.js | 9 +-- src/client/updaters/tag.js | 102 +++++++++++++++++++---------- src/server/generators/tag.js | 10 +-- src/shared/constants.js | 3 + src/utils/elements.js | 31 +++++++++ test/e2e/browser.test.js | 15 ++--- test/e2e/ssr.test.js | 8 ++- test/fixtures/app.template.html | 2 + test/fixtures/basic/views/home.vue | 1 + test/unit/generators.test.js | 18 +++++ test/utils/meta-info-data.js | 9 ++- 11 files changed, 148 insertions(+), 60 deletions(-) create mode 100644 src/utils/elements.js diff --git a/src/client/updateClientMetaInfo.js b/src/client/updateClientMetaInfo.js index b29e6dac..140a494f 100644 --- a/src/client/updateClientMetaInfo.js +++ b/src/client/updateClientMetaInfo.js @@ -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 * diff --git a/src/client/updaters/tag.js b/src/client/updaters/tag.js index 1a36ebe7..ab6a3d19 100644 --- a/src/client/updaters/tag.js +++ b/src/client/updaters/tag.js @@ -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 and on the client. Borrowed from `react-helmet`: @@ -9,11 +10,16 @@ import { toArray, includes } from '../../utils/array' * @param {(Array|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 @@ -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 + } } diff --git a/src/server/generators/tag.js b/src/server/generators/tag.js index 9431be65..7994673b 100644 --- a/src/server/generators/tag.js +++ b/src/server/generators/tag.js @@ -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 @@ -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) @@ -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 } @@ -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-' } diff --git a/src/shared/constants.js b/src/shared/constants.js index 1df737bc..cb472a28 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -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', diff --git a/src/utils/elements.js b/src/utils/elements.js new file mode 100644 index 00000000..ce12af80 --- /dev/null +++ b/src/utils/elements.js @@ -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(', '))) +} diff --git a/test/e2e/browser.test.js b/test/e2e/browser.test.js index 28535969..fb8ce144 100644 --- a/test/e2e/browser.test.js +++ b/test/e2e/browser.test.js @@ -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) @@ -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 () => { diff --git a/test/e2e/ssr.test.js b/test/e2e/ssr.test.js index 2a927207..c9eee3ed 100644 --- a/test/e2e/ssr.test.js +++ b/test/e2e/ssr.test.js @@ -18,6 +18,11 @@ describe('basic browser with ssr page', () => { expect(html.match(/]*>\s*