@plone/volto
Version:
Volto
635 lines (551 loc) • 18.1 kB
JavaScript
/* 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
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 };