brf/client/public/components/head.ts
2025-12-13 21:12:08 +01:00

178 lines
3.7 KiB
TypeScript

import { toChildArray, Component, type VNode } from 'preact'
import { mapKeys } from 'lowline'
type Tag = {
type: string
attributes: Record<string, string>
}
const CLASSNAME = '__preact_generated__'
const DOMAttributeNames: Record<string, string> = {
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<any>[])
.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 <title/>.
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
}
}