UNPKG

@dr.pogodin/react-helmet

Version:

Thread-safe Helmet for React 19+ and friends

274 lines (254 loc) 9.96 kB
import { HTML_TAG_MAP, SEO_PRIORITY_TAGS, TAG_NAMES, TAG_PROPERTIES } from './constants'; // eslint-disable-next-line @typescript-eslint/no-explicit-any /** * Finds the last object in the given array of registered props, * that has the specified prop defined, and returns the value of * that prop in that object. Returns `undefined` if no prop object * has that prop defined. */ function getInnermostProperty(props, propName) { for (let i = props.length - 1; i >= 0; --i) { const value = props[i][1][propName]; if (value !== undefined) return value; } return undefined; } export function getTitleFromPropsList(props) { let innermostTitle = getInnermostProperty(props, TAG_NAMES.TITLE); const innermostTemplate = getInnermostProperty(props, 'titleTemplate'); if (Array.isArray(innermostTitle)) { innermostTitle = innermostTitle.join(''); } if (innermostTemplate && innermostTitle) { // use function arg to avoid need to escape $ characters return innermostTemplate.replace(/%s/g, () => innermostTitle); } const innermostDefaultTitle = getInnermostProperty(props, 'defaultTitle'); // NOTE: We really want || here to match legacy behavior, where default title // was applied also when the given title was an empty string. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return (innermostTitle || innermostDefaultTitle) ?? undefined; } /** * Merges together attributes provided for the same element by different Helmet * instances. Attributes provided by later registered Helmet instances overwrite * the same attributes provided by the earlier registered instances. */ export function mergeAttributes(element, props) { const res = {}; for (const item of props) { const attrs = item[1][element]; if (attrs) Object.assign(res, attrs); } return res; } /** * Finds the latest registered Helmet instance with `base` props provided, * and with its `href` value set, and returns those `base` props. * NOTE: Based on the legacy getBaseTagFromPropsList(). */ export function aggregateBaseProps(props) { for (let i = props.length - 1; i >= 0; --i) { const res = props[i][1].base; if (res?.href || res?.target) return res; } return undefined; } /** * Determines the primary key in the given `props` object, accoding to the given * array of valid primary keys for the kind of props object. * TODO: Rather than passing an array of primaryProps around, it might be more * simple to just have a dedicated function for each possible kind of that * object. */ function getPrimaryProp(props, primaryProps) { // Looks up for the "primary attribute" key. let primaryAttributeKey; // TODO: Perhaps also check that the value of attribute being selected // as primary is actually defined? Right now, it implicitly assumes that // in such case the attribute is just not present as a key in `props`. for (const keyString of Object.keys(props)) { const key = keyString; // Special rule with link tags, since rel and href are both primary tags, // rel takes priority if (primaryProps.includes(key) && !(primaryAttributeKey === TAG_PROPERTIES.REL && props[primaryAttributeKey].toLowerCase() === 'canonical') && !(key === TAG_PROPERTIES.REL && props[key].toLowerCase() === 'stylesheet')) primaryAttributeKey = key; // Special case for innerHTML which doesn't work lowercased if (primaryProps.includes(key) && (key === TAG_PROPERTIES.INNER_HTML || key === TAG_PROPERTIES.CSS_TEXT || key === TAG_PROPERTIES.ITEM_PROP)) primaryAttributeKey = key; } return primaryAttributeKey ?? null; } export function getTagsFromPropsList(tagName, primaryAttributes, propsArray) { // Calculate list of tags, giving priority innermost component // (end of the propslist) const approvedSeenTags = {}; // TODO: Well, this is a touch one to refactor, while ensuring it does not // change any behavior aspect... let's stick to the legacy implementation, // with minimal updates, for now, then refactor it later. return propsArray.map(([, props]) => props).filter(props => { if (Array.isArray(props[tagName])) { return true; } if (typeof props[tagName] !== 'undefined') { // eslint-disable-next-line no-console console.warn(`Helmet: ${tagName} should be of type "Array". Instead found type "${typeof props[tagName]}"`); } return false; }).map(props => props[tagName]).reverse() // From last to first. .reduce((approvedTags, instanceTags) => { const instanceSeenTags = {}; instanceTags.filter(tag => { const primaryAttributeKey = getPrimaryProp(tag, primaryAttributes); if (!primaryAttributeKey || !tag[primaryAttributeKey]) { return false; } const value = tag[primaryAttributeKey].toLowerCase(); if (!approvedSeenTags[primaryAttributeKey]) { approvedSeenTags[primaryAttributeKey] = {}; } if (!instanceSeenTags[primaryAttributeKey]) { instanceSeenTags[primaryAttributeKey] = {}; } // essentially we collect every item that haven't been seen so far? if (!approvedSeenTags[primaryAttributeKey][value]) { instanceSeenTags[primaryAttributeKey][value] = true; return true; } return false; }).reverse() // so approved tags are accumulated from last to first .forEach(tag => { approvedTags.push(tag); }); // Update seen tags with tags from this instance const keys = Object.keys(instanceSeenTags); for (const attributeKey of keys) { const tagUnion = { ...approvedSeenTags[attributeKey], ...instanceSeenTags[attributeKey] }; approvedSeenTags[attributeKey] = tagUnion; } return approvedTags; }, []) // then reversed back to the from first-to-last order. .reverse(); } function getAnyTrueFromPropsArray(propsArray, propName) { for (const [, props] of propsArray) { if (props[propName]) return true; } return false; } export function flattenArray(possibleArray) { return Array.isArray(possibleArray) ? possibleArray.join('') : possibleArray; } function checkIfPropsMatch(props, toMatch) { for (const key of Object.keys(props)) { // e.g. if rel exists in the list of allowed props [amphtml, alternate, etc] // TODO: Do a better typing job here. if (toMatch[key]?.includes(props[key])) return true; } return false; } export function prioritizer(propsArray, propsToMatch) { const res = { default: Array(), priority: Array() }; if (propsArray) { for (const props of propsArray) { if (checkIfPropsMatch(props, propsToMatch)) { res.priority.push(props); } else { res.default.push(props); } } } return res; } // TODO: Perhaps, we better destruct the `obj` first, and then create the result // not including the key altogether? export const without = (obj, key) => ({ ...obj, [key]: undefined }); /** * Clones given props object deep enough to make it safe to push new items * to its array values, and re-assign its non-array values, without a risk * to mutate any externally owned objects. */ export function cloneProps(props) { const res = {}; for (const [key, value] of Object.entries(props)) { res[key] = Array.isArray(value) ? value.slice() : value; } return res; } /** * Merges `source` props into `target`, mutating the `target` object. */ export function mergeProps(target, source) { const tgt = target; for (const [key, srcValue] of Object.entries(source)) { if (Array.isArray(srcValue)) { const tgtValue = tgt[key]; tgt[key] = tgtValue ? tgtValue.concat(srcValue) : srcValue; } else tgt[key] = srcValue; } } /** * Adds given item to the specified prop array inside `target`. * It mutates the target. */ export function pushToPropArray(target, array, item) { const tgt = target[array]; if (tgt) tgt.push(item); // eslint-disable-next-line no-param-reassign else target[array] = [item]; } export function calcAggregatedState(props) { let links = getTagsFromPropsList(TAG_NAMES.LINK, [TAG_PROPERTIES.REL, TAG_PROPERTIES.HREF], props); let meta = getTagsFromPropsList('meta', [ // NOTE: In the legacy version "charSet", "httpEquiv", and "itemProp" // were given as HTML attributes: charset, http-equiv, itemprop. // I believe, it is already fine to replace them here now, but // let's be vigilant. TAG_PROPERTIES.NAME, 'charSet', 'httpEquiv', TAG_PROPERTIES.PROPERTY, 'itemProp'], props); let script = getTagsFromPropsList('script', [TAG_PROPERTIES.SRC, TAG_PROPERTIES.INNER_HTML], props); const prioritizeSeoTags = getAnyTrueFromPropsArray(props, 'prioritizeSeoTags'); let priority; if (prioritizeSeoTags) { const linkP = prioritizer(links, SEO_PRIORITY_TAGS.link); links = linkP.default; const metaP = prioritizer(meta, SEO_PRIORITY_TAGS.meta); meta = metaP.default; const scriptP = prioritizer(script, SEO_PRIORITY_TAGS.script); script = scriptP.default; priority = { links: linkP.priority, meta: metaP.priority, script: scriptP.priority }; } return { base: aggregateBaseProps(props), bodyAttributes: mergeAttributes('bodyAttributes', props), defer: getInnermostProperty(props, 'defer'), encodeSpecialCharacters: getInnermostProperty(props, 'encodeSpecialCharacters') ?? true, htmlAttributes: mergeAttributes('htmlAttributes', props), links, meta, noscript: getTagsFromPropsList('noscript', [TAG_PROPERTIES.INNER_HTML], props), onChangeClientState: getInnermostProperty(props, 'onChangeClientState'), priority, script, style: getTagsFromPropsList('style', [TAG_PROPERTIES.CSS_TEXT], props), title: getTitleFromPropsList(props), titleAttributes: mergeAttributes('titleAttributes', props) }; } export function propToAttr(prop) { return HTML_TAG_MAP[prop] ?? prop; } //# sourceMappingURL=utils.js.map