178 lines
3.7 KiB
TypeScript
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
|
|
}
|
|
}
|