UNPKG

@plone/volto

Version:
635 lines (551 loc) 18.1 kB
/* eslint no-console: 0 */ import React from 'react'; import objectAssign from 'object-assign'; import { ATTRIBUTE_NAMES, HELMET_ATTRIBUTE, HELMET_PROPS, HTML_TAG_MAP, REACT_TAG_MAP, SELF_CLOSING_TAGS, TAG_NAMES, TAG_PROPERTIES, } from './HelmetConstants.js'; const encodeSpecialCharacters = (str, encode = true) => { if (encode === false) { return String(str); } return String(str) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); }; const getTitleFromPropsList = (propsList) => { const innermostTitle = getInnermostProperty(propsList, TAG_NAMES.TITLE); const innermostTemplate = getInnermostProperty( propsList, HELMET_PROPS.TITLE_TEMPLATE, ); if (innermostTemplate && innermostTitle) { // use function arg to avoid need to escape $ characters return innermostTemplate.replace(/%s/g, () => Array.isArray(innermostTitle) ? innermostTitle.join('') : innermostTitle, ); } const innermostDefaultTitle = getInnermostProperty( propsList, HELMET_PROPS.DEFAULT_TITLE, ); return innermostTitle || innermostDefaultTitle || undefined; }; const getOnChangeClientState = (propsList) => { return ( getInnermostProperty(propsList, HELMET_PROPS.ON_CHANGE_CLIENT_STATE) || (() => {}) ); }; const getAttributesFromPropsList = (tagType, propsList) => { return propsList .filter((props) => typeof props[tagType] !== 'undefined') .map((props) => props[tagType]) .reduce((tagAttrs, current) => { return { ...tagAttrs, ...current }; }, {}); }; const getBaseTagFromPropsList = (primaryAttributes, propsList) => { return propsList .filter((props) => typeof props[TAG_NAMES.BASE] !== 'undefined') .map((props) => props[TAG_NAMES.BASE]) .reverse() .reduce((innermostBaseTag, tag) => { if (!innermostBaseTag.length) { const keys = Object.keys(tag); for (let i = 0; i < keys.length; i++) { const attributeKey = keys[i]; const lowerCaseAttributeKey = attributeKey.toLowerCase(); if ( primaryAttributes.indexOf(lowerCaseAttributeKey) !== -1 && tag[lowerCaseAttributeKey] ) { return innermostBaseTag.concat(tag); } } } return innermostBaseTag; }, []); }; const getTagsFromPropsList = (tagName, primaryAttributes, propsList) => { // Calculate list of tags, giving priority innermost component (end of the propslist) const approvedSeenTags = {}; return propsList .filter((props) => { if (Array.isArray(props[tagName])) { return true; } if (typeof props[tagName] !== 'undefined') { warn( `Helmet: ${tagName} should be of type "Array". Instead found type "${typeof props[ tagName ]}"`, ); } return false; }) .map((props) => props[tagName]) .reverse() .reduce((approvedTags, instanceTags) => { const instanceSeenTags = {}; instanceTags .filter((tag) => { let primaryAttributeKey; const keys = Object.keys(tag); for (let i = 0; i < keys.length; i++) { const attributeKey = keys[i]; const lowerCaseAttributeKey = attributeKey.toLowerCase(); // Special rule with link tags, since rel and href are both primary tags, rel takes priority if ( primaryAttributes.indexOf(lowerCaseAttributeKey) !== -1 && !( primaryAttributeKey === TAG_PROPERTIES.REL && tag[primaryAttributeKey].toLowerCase() === 'canonical' ) && !( lowerCaseAttributeKey === TAG_PROPERTIES.REL && tag[lowerCaseAttributeKey].toLowerCase() === 'stylesheet' ) ) { primaryAttributeKey = lowerCaseAttributeKey; } // Special case for innerHTML which doesn't work lowercased if ( primaryAttributes.indexOf(attributeKey) !== -1 && (attributeKey === TAG_PROPERTIES.INNER_HTML || attributeKey === TAG_PROPERTIES.CSS_TEXT || attributeKey === TAG_PROPERTIES.ITEM_PROP) ) { primaryAttributeKey = attributeKey; } } if (!primaryAttributeKey || !tag[primaryAttributeKey]) { return false; } const value = tag[primaryAttributeKey].toLowerCase(); if (!approvedSeenTags[primaryAttributeKey]) { approvedSeenTags[primaryAttributeKey] = {}; } if (!instanceSeenTags[primaryAttributeKey]) { instanceSeenTags[primaryAttributeKey] = {}; } if (!approvedSeenTags[primaryAttributeKey][value]) { instanceSeenTags[primaryAttributeKey][value] = true; return true; } return false; }) .reverse() .forEach((tag) => approvedTags.push(tag)); // Update seen tags with tags from this instance const keys = Object.keys(instanceSeenTags); for (let i = 0; i < keys.length; i++) { const attributeKey = keys[i]; const tagUnion = objectAssign( {}, approvedSeenTags[attributeKey], instanceSeenTags[attributeKey], ); approvedSeenTags[attributeKey] = tagUnion; } return approvedTags; }, []) .reverse(); }; const getInnermostProperty = (propsList, property) => { for (let i = propsList.length - 1; i >= 0; i--) { const props = propsList[i]; if (props.hasOwnProperty(property)) { return props[property]; } } return null; }; const reducePropsToState = (propsList) => ({ baseTag: getBaseTagFromPropsList( [TAG_PROPERTIES.HREF, TAG_PROPERTIES.TARGET], propsList, ), bodyAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.BODY, propsList), defer: getInnermostProperty(propsList, HELMET_PROPS.DEFER), encode: getInnermostProperty( propsList, HELMET_PROPS.ENCODE_SPECIAL_CHARACTERS, ), htmlAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.HTML, propsList), linkTags: getTagsFromPropsList( TAG_NAMES.LINK, [TAG_PROPERTIES.REL, TAG_PROPERTIES.HREF], propsList, ), metaTags: getTagsFromPropsList( TAG_NAMES.META, [ TAG_PROPERTIES.NAME, TAG_PROPERTIES.CHARSET, TAG_PROPERTIES.HTTPEQUIV, TAG_PROPERTIES.PROPERTY, TAG_PROPERTIES.ITEM_PROP, ], propsList, ), noscriptTags: getTagsFromPropsList( TAG_NAMES.NOSCRIPT, [TAG_PROPERTIES.INNER_HTML], propsList, ), onChangeClientState: getOnChangeClientState(propsList), scriptTags: getTagsFromPropsList( TAG_NAMES.SCRIPT, [TAG_PROPERTIES.SRC, TAG_PROPERTIES.INNER_HTML], propsList, ), styleTags: getTagsFromPropsList( TAG_NAMES.STYLE, [TAG_PROPERTIES.CSS_TEXT], propsList, ), title: getTitleFromPropsList(propsList), titleAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.TITLE, propsList), }); const rafPolyfill = (() => { let clock = Date.now(); return (callback) => { const currentTime = Date.now(); if (currentTime - clock > 16) { clock = currentTime; callback(currentTime); } else { setTimeout(() => { rafPolyfill(callback); }, 0); } }; })(); const cafPolyfill = (id) => clearTimeout(id); const requestAnimationFrame = typeof window !== 'undefined' ? (window.requestAnimationFrame && window.requestAnimationFrame.bind(window)) || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || rafPolyfill : global.requestAnimationFrame || rafPolyfill; const cancelAnimationFrame = typeof window !== 'undefined' ? window.cancelAnimationFrame || window.webkitCancelAnimationFrame || window.mozCancelAnimationFrame || cafPolyfill : global.cancelAnimationFrame || cafPolyfill; const warn = (msg) => { return console && typeof console.warn === 'function' && console.warn(msg); }; let _helmetCallback = null; const handleClientStateChange = (newState) => { if (_helmetCallback) { cancelAnimationFrame(_helmetCallback); } if (newState.defer) { _helmetCallback = requestAnimationFrame(() => { commitTagChanges(newState, () => { _helmetCallback = null; }); }); } else { commitTagChanges(newState); _helmetCallback = null; } }; const commitTagChanges = (newState, cb) => { const { baseTag, bodyAttributes, htmlAttributes, linkTags, metaTags, noscriptTags, onChangeClientState, scriptTags, styleTags, title, titleAttributes, } = newState; updateAttributes(TAG_NAMES.BODY, bodyAttributes); updateAttributes(TAG_NAMES.HTML, htmlAttributes); updateTitle(title, titleAttributes); const tagUpdates = { baseTag: updateTags(TAG_NAMES.BASE, baseTag), linkTags: updateTags(TAG_NAMES.LINK, linkTags), metaTags: updateTags(TAG_NAMES.META, metaTags), noscriptTags: updateTags(TAG_NAMES.NOSCRIPT, noscriptTags), scriptTags: updateTags(TAG_NAMES.SCRIPT, scriptTags), styleTags: updateTags(TAG_NAMES.STYLE, styleTags), }; const addedTags = {}; const removedTags = {}; Object.keys(tagUpdates).forEach((tagType) => { const { newTags, oldTags } = tagUpdates[tagType]; if (newTags.length) { addedTags[tagType] = newTags; } if (oldTags.length) { removedTags[tagType] = tagUpdates[tagType].oldTags; } }); cb && cb(); onChangeClientState(newState, addedTags, removedTags); }; const flattenArray = (possibleArray) => { return Array.isArray(possibleArray) ? possibleArray.join('') : possibleArray; }; const updateTitle = (title, attributes) => { if (typeof title !== 'undefined' && document.title !== title) { document.title = flattenArray(title); } updateAttributes(TAG_NAMES.TITLE, attributes); }; const updateAttributes = (tagName, attributes) => { const elementTag = document.getElementsByTagName(tagName)[0]; if (!elementTag) { return; } const helmetAttributeString = elementTag.getAttribute(HELMET_ATTRIBUTE); const helmetAttributes = helmetAttributeString ? helmetAttributeString.split(',') : []; const attributesToRemove = [].concat(helmetAttributes); const attributeKeys = Object.keys(attributes); for (let i = 0; i < attributeKeys.length; i++) { const attribute = attributeKeys[i]; const value = attributes[attribute] || ''; if (elementTag.getAttribute(attribute) !== value) { elementTag.setAttribute(attribute, value); } if (helmetAttributes.indexOf(attribute) === -1) { helmetAttributes.push(attribute); } const indexToSave = attributesToRemove.indexOf(attribute); if (indexToSave !== -1) { attributesToRemove.splice(indexToSave, 1); } } for (let i = attributesToRemove.length - 1; i >= 0; i--) { elementTag.removeAttribute(attributesToRemove[i]); } if (helmetAttributes.length === attributesToRemove.length) { elementTag.removeAttribute(HELMET_ATTRIBUTE); } else if ( elementTag.getAttribute(HELMET_ATTRIBUTE) !== attributeKeys.join(',') ) { elementTag.setAttribute(HELMET_ATTRIBUTE, attributeKeys.join(',')); } }; const updateTags = (type, tags) => { const headElement = document.head || document.querySelector(TAG_NAMES.HEAD); const tagNodes = headElement.querySelectorAll(`${type}[${HELMET_ATTRIBUTE}]`); const oldTags = Array.prototype.slice.call(tagNodes); const newTags = []; let indexToDelete; if (tags && tags.length) { tags.forEach((tag) => { const newElement = document.createElement(type); for (const attribute in tag) { if (tag.hasOwnProperty(attribute)) { if (attribute === TAG_PROPERTIES.INNER_HTML) { newElement.innerHTML = tag.innerHTML; } else if (attribute === TAG_PROPERTIES.CSS_TEXT) { if (newElement.styleSheet) { newElement.styleSheet.cssText = tag.cssText; } else { newElement.appendChild(document.createTextNode(tag.cssText)); } } else { const value = typeof tag[attribute] === 'undefined' ? '' : tag[attribute]; newElement.setAttribute(attribute, value); } } } newElement.setAttribute(HELMET_ATTRIBUTE, 'true'); // Remove a duplicate tag from domTagstoRemove, so it isn't cleared. if ( oldTags.some((existingTag, index) => { indexToDelete = index; return newElement.isEqualNode(existingTag); }) ) { oldTags.splice(indexToDelete, 1); } else { newTags.push(newElement); } }); } oldTags.forEach((tag) => tag.parentNode.removeChild(tag)); newTags.forEach((tag) => headElement.appendChild(tag)); return { oldTags, newTags, }; }; const generateElementAttributesAsString = (attributes) => Object.keys(attributes).reduce((str, key) => { const attr = typeof attributes[key] !== 'undefined' ? `${key}="${attributes[key]}"` : `${key}`; return str ? `${str} ${attr}` : attr; }, ''); const generateTitleAsString = (type, title, attributes, encode) => { const attributeString = generateElementAttributesAsString(attributes); const flattenedTitle = flattenArray(title); return attributeString ? `<${type} ${HELMET_ATTRIBUTE}="true" ${attributeString}>${encodeSpecialCharacters( flattenedTitle, encode, )}</${type}>` : `<${type} ${HELMET_ATTRIBUTE}="true">${encodeSpecialCharacters( flattenedTitle, encode, )}</${type}>`; }; const generateTagsAsString = (type, tags, encode) => tags.reduce((str, tag) => { const attributeHtml = Object.keys(tag) .filter( (attribute) => !( attribute === TAG_PROPERTIES.INNER_HTML || attribute === TAG_PROPERTIES.CSS_TEXT ), ) .reduce((string, attribute) => { const attr = typeof tag[attribute] === 'undefined' ? attribute : `${attribute}="${encodeSpecialCharacters( tag[attribute], encode, )}"`; return string ? `${string} ${attr}` : attr; }, ''); const tagContent = tag.innerHTML || tag.cssText || ''; const isSelfClosing = SELF_CLOSING_TAGS.indexOf(type) === -1; return `${str}<${type} ${HELMET_ATTRIBUTE}="true" ${attributeHtml}${ isSelfClosing ? `/>` : `>${tagContent}</${type}>` }`; }, ''); const convertElementAttributestoReactProps = (attributes, initProps = {}) => { return Object.keys(attributes).reduce((obj, key) => { obj[REACT_TAG_MAP[key] || key] = attributes[key]; return obj; }, initProps); }; const convertReactPropstoHtmlAttributes = (props, initAttributes = {}) => { return Object.keys(props).reduce((obj, key) => { obj[HTML_TAG_MAP[key] || key] = props[key]; return obj; }, initAttributes); }; const generateTitleAsReactComponent = (type, title, attributes) => { // assigning into an array to define toString function on it const initProps = { key: title, [HELMET_ATTRIBUTE]: true, }; const props = convertElementAttributestoReactProps(attributes, initProps); return [React.createElement(TAG_NAMES.TITLE, props, title)]; }; const generateTagsAsReactComponent = (type, tags) => tags.map((tag, i) => { const mappedTag = { key: i, [HELMET_ATTRIBUTE]: true, }; Object.keys(tag).forEach((attribute) => { const mappedAttribute = REACT_TAG_MAP[attribute] || attribute; if ( mappedAttribute === TAG_PROPERTIES.INNER_HTML || mappedAttribute === TAG_PROPERTIES.CSS_TEXT ) { const content = tag.innerHTML || tag.cssText; mappedTag.dangerouslySetInnerHTML = { __html: content }; } else { mappedTag[mappedAttribute] = tag[attribute]; } }); return React.createElement(type, mappedTag); }); const getMethodsForTag = (type, tags, encode) => { switch (type) { case TAG_NAMES.TITLE: return { toComponent: () => generateTitleAsReactComponent( type, tags.title, tags.titleAttributes, encode, ), toString: () => generateTitleAsString(type, tags.title, tags.titleAttributes, encode), }; case ATTRIBUTE_NAMES.BODY: case ATTRIBUTE_NAMES.HTML: return { toComponent: () => convertElementAttributestoReactProps(tags), toString: () => generateElementAttributesAsString(tags), }; default: return { toComponent: () => generateTagsAsReactComponent(type, tags), toString: () => generateTagsAsString(type, tags, encode), }; } }; const mapStateOnServer = ({ baseTag, bodyAttributes, encode, htmlAttributes, linkTags, metaTags, noscriptTags, scriptTags, styleTags, title = '', titleAttributes, }) => ({ base: getMethodsForTag(TAG_NAMES.BASE, baseTag, encode), bodyAttributes: getMethodsForTag( ATTRIBUTE_NAMES.BODY, bodyAttributes, encode, ), htmlAttributes: getMethodsForTag( ATTRIBUTE_NAMES.HTML, htmlAttributes, encode, ), link: getMethodsForTag(TAG_NAMES.LINK, linkTags, encode), meta: getMethodsForTag(TAG_NAMES.META, metaTags, encode), noscript: getMethodsForTag(TAG_NAMES.NOSCRIPT, noscriptTags, encode), script: getMethodsForTag(TAG_NAMES.SCRIPT, scriptTags, encode), style: getMethodsForTag(TAG_NAMES.STYLE, styleTags, encode), title: getMethodsForTag(TAG_NAMES.TITLE, { title, titleAttributes }, encode), }); export { convertReactPropstoHtmlAttributes }; export { handleClientStateChange }; export { mapStateOnServer }; export { reducePropsToState }; export { requestAnimationFrame }; export { warn };