webapp/src/context/meta.tsx
2024-05-07 18:38:03 +03:00

270 lines
7.6 KiB
TypeScript

import {
Component,
createContext,
createRenderEffect,
createUniqueId,
JSX,
onCleanup,
ParentComponent,
sharedConfig,
useContext,
} from 'solid-js'
import { isServer, spread, escape as escapeMeta, useAssets, ssr } from 'solid-js/web'
export const MetaContext = createContext<MetaContextType>()
interface TagDescription {
tag: string
props: Record<string, unknown>
setting?: { close?: boolean; escape?: boolean }
id: string
name?: string
ref?: Element
}
export interface MetaContextType {
addTag: (tag: TagDescription) => number
removeTag: (tag: TagDescription, index: number) => void
}
const cascadingTags = ['title', 'meta']
// https://html.spec.whatwg.org/multipage/semantics.html#the-title-element
const titleTagProperties: string[] = []
const metaTagProperties: string[] =
// https://html.spec.whatwg.org/multipage/semantics.html#the-meta-element
['name', 'http-equiv', 'content', 'charset', 'media']
// additional properties
.concat(['property'])
const getTagKey = (tag: TagDescription, properties: string[]) => {
// pick allowed properties and sort them
const tagProps = Object.fromEntries(
Object.entries(tag.props)
.filter(([k]) => properties.includes(k))
.sort(),
)
// treat `property` as `name` for meta tags
if (Object.hasOwn(tagProps, 'name') || Object.hasOwn(tagProps, 'property')) {
tagProps.name = tagProps.name || tagProps.property
tagProps.property = undefined
}
// concat tag name and properties as unique key for this tag
return tag.tag + JSON.stringify(tagProps)
}
function initClientProvider() {
if (!sharedConfig.context) {
const ssrTags = document.head.querySelectorAll('[data-sm]')
// `forEach` on `NodeList` is not supported in Googlebot, so use a workaround
Array.prototype.forEach.call(ssrTags, (ssrTag: Node) => ssrTag.parentNode?.removeChild(ssrTag))
}
const cascadedTagInstances = new Map()
// TODO: use one element for all tags of the same type, just swap out
// where the props get applied
function getElement(tag: TagDescription) {
if (tag.ref) {
return tag.ref
}
let el = document.querySelector(`[data-sm="${tag.id}"]`)
if (el) {
if (el.tagName.toLowerCase() !== tag.tag) {
if (el.parentNode) {
// remove the old tag
el.parentNode.removeChild(el)
}
// add the new tag
el = document.createElement(tag.tag)
}
// use the old tag
el.removeAttribute('data-sm')
} else {
// create a new tag
el = document.createElement(tag.tag)
}
return el
}
return {
addTag(tag: TagDescription) {
if (cascadingTags.indexOf(tag.tag) !== -1) {
const properties = tag.tag === 'title' ? titleTagProperties : metaTagProperties
const tagKey = getTagKey(tag, properties)
// only cascading tags need to be kept as singletons
if (!cascadedTagInstances.has(tagKey)) {
cascadedTagInstances.set(tagKey, [])
}
let instances = cascadedTagInstances.get(tagKey)
const index = instances.length
instances = [...instances, tag]
// track indices synchronously
cascadedTagInstances.set(tagKey, instances)
const element = getElement(tag)
tag.ref = element
spread(element, tag.props)
let lastVisited = null
for (let i = index - 1; i >= 0; i--) {
if (instances[i] != null) {
lastVisited = instances[i]
break
}
}
if (element.parentNode !== document.head) {
document.head.appendChild(element)
}
if (lastVisited?.ref?.parentNode) {
document.head?.removeChild(lastVisited.ref)
}
return index
}
const element = getElement(tag)
tag.ref = element
spread(element, tag.props)
if (element.parentNode !== document.head) {
document.head.appendChild(element)
}
return -1
},
removeTag(tag: TagDescription, index: number) {
const properties = tag.tag === 'title' ? titleTagProperties : metaTagProperties
const tagKey = getTagKey(tag, properties)
if (tag.ref) {
const t = cascadedTagInstances.get(tagKey)
if (t) {
if (tag.ref.parentNode) {
tag.ref.parentNode.removeChild(tag.ref)
for (let i = index - 1; i >= 0; i--) {
if (t[i] != null) {
document.head.appendChild(t[i].ref)
}
}
}
t[index] = null
cascadedTagInstances.set(tagKey, t)
} else if (tag.ref.parentNode) {
tag.ref.parentNode.removeChild(tag.ref)
}
}
},
}
}
function initServerProvider() {
const tags: TagDescription[] = []
useAssets(() => ssr(renderTags(tags)) as string)
return {
addTag(tagDesc: TagDescription) {
// tweak only cascading tags
if (cascadingTags.indexOf(tagDesc.tag) !== -1) {
const properties = tagDesc.tag === 'title' ? titleTagProperties : metaTagProperties
const tagDescKey = getTagKey(tagDesc, properties)
const index = tags.findIndex(
(prev) => prev.tag === tagDesc.tag && getTagKey(prev, properties) === tagDescKey,
)
if (index !== -1) {
tags.splice(index, 1)
}
}
tags.push(tagDesc)
return tags.length
},
// biome-ignore lint/suspicious/noEmptyBlockStatements: initial value
removeTag(_tag: TagDescription, _index: number) {},
}
}
export const MetaProvider: ParentComponent = (props) => {
const actions = isServer ? initServerProvider() : initClientProvider()
return <MetaContext.Provider value={actions || {}}>{props.children}</MetaContext.Provider>
}
const MetaTag = (
tag: string,
props: { [k: string]: string },
setting?: { escape?: boolean; close?: boolean },
) => {
useHead({
tag,
props,
setting,
id: createUniqueId(),
get name() {
return props.name || props.property
},
})
return null
}
export function useHead(tagDesc: TagDescription) {
const c = useContext(MetaContext)
if (!c) throw new Error('<MetaProvider /> should be in the tree')
createRenderEffect(() => {
const index = c?.addTag(tagDesc)
onCleanup(() => c?.removeTag(tagDesc, index))
})
}
function renderTags(tags: TagDescription[]) {
return tags
.map((tag) => {
const keys = Object.keys(tag.props)
const props = keys
.map((k) =>
k === 'children'
? ''
: ` ${k}="${
// @ts-expect-error
escapeMeta(tag.props[k], true)
}"`,
)
.join('')
const children = tag.props.children
if (tag.setting?.close) {
return `<${tag.tag} data-sm="${tag.id}"${props}>${
// @ts-expect-error
tag.setting?.escape ? escapeMeta(children) : children || ''
}</${tag.tag}>`
}
return `<${tag.tag} data-sm="${tag.id}"${props}/>`
})
.join('')
}
export const Title: Component<JSX.HTMLAttributes<HTMLTitleElement>> = (props) =>
MetaTag('title', props, { escape: true, close: true })
export const Style: Component<JSX.StyleHTMLAttributes<HTMLStyleElement>> = (props) =>
MetaTag('style', props, { close: true })
export const Meta: Component<JSX.MetaHTMLAttributes<HTMLMetaElement>> = (props) => MetaTag('meta', props)
export const Link: Component<JSX.LinkHTMLAttributes<HTMLLinkElement>> = (props) => MetaTag('link', props)
export const Base: Component<JSX.BaseHTMLAttributes<HTMLBaseElement>> = (props) => MetaTag('base', props)
export const Stylesheet: Component<Omit<JSX.LinkHTMLAttributes<HTMLLinkElement>, 'rel'>> = (props) => (
<Link rel="stylesheet" {...props} />
)