import { toChildArray, Component, type VNode } from 'preact' import { mapKeys } from 'lowline' type Tag = { type: string attributes: Record } const CLASSNAME = '__preact_generated__' const DOMAttributeNames: Record = { acceptCharset: 'accept-charset', className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv', } const isBrowser = typeof window !== 'undefined' let mounted: Head[] = [] function reducer(components: Head[]) { return components .map((c) => toChildArray(c.props.children) as VNode[]) .reduce((result, c) => result.concat(c), []) .reverse() .filter(unique()) .reverse() .reduce( (result, c) => { if (c.type === 'title') { result.title += toChildArray(c.props.children).join('') } else { result.tags.push({ type: c.type as string, attributes: mapKeys(c.props, (_value, key) => DOMAttributeNames[key] || key), }) } return result }, { title: '', tags: [] } as { title: string; tags: Tag[] }, ) } function updateClient({ title, tags }: { title: string; tags: Tag[] }) { const head = document.head const prevElements = Array.from(head.getElementsByClassName(CLASSNAME)) for (const tag of tags) { const el = createDOMElement(tag) const prevIndex = prevElements.findIndex((prevEl) => prevEl.isEqualNode(el)) if (~prevIndex) { prevElements.splice(prevIndex, 1) } else { head.appendChild(el) } } prevElements.forEach((prevEl) => prevEl.remove()) document.title = title } function createDOMElement(tag: Tag) { const el = document.createElement(tag.type) const attributes = tag.attributes || {} el.setAttribute('class', CLASSNAME) for (const p in attributes || {}) { const attribute = DOMAttributeNames[p] || p.toLowerCase() el.setAttribute(attribute, attributes[p]) } return el } const METATYPES = ['name', 'httpEquiv', 'charSet', 'itemProp'] // returns a function for filtering head child elements // which shouldn't be duplicated, like . function unique() { const tags: string[] = [] const metaTypes: string[] = [] const metaCategories: Record<string, string[]> = {} return (h: VNode<Record<string, string>>) => { switch (h.type) { case 'base': if (~tags.indexOf(h.type)) { return false } tags.push(h.type) break case 'meta': for (let i = 0, len = METATYPES.length; i < len; i++) { const metatype = METATYPES[i] if (!(metatype in h)) { continue } if (metatype === 'charSet') { if (~metaTypes.indexOf(metatype)) { return false } metaTypes.push(metatype) } else { const category = h.props[metatype] as string const categories: string[] = metaCategories[metatype] || [] if (~categories.indexOf(category)) { return false } categories.push(category) metaCategories[metatype] = categories } } break } return true } } function update() { if (isBrowser) updateClient(reducer(mounted)) } export default class Head extends Component { static rewind() { const state = reducer(mounted) mounted = [] return state } static clear() { mounted = [] } componentDidUpdate() { update() } // eslint-disable-next-line react/no-deprecated componentWillMount() { mounted.push(this) update() } componentWillUnmount() { const i = mounted.indexOf(this) if (~i) { mounted.splice(i, 1) } update() } render() { return null } }