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() interface TagDescription { tag: string props: Record 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 {props.children} } 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(' 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 || '' }` } return `<${tag.tag} data-sm="${tag.id}"${props}/>` }) .join('') } export const Title: Component> = (props) => MetaTag('title', props, { escape: true, close: true }) export const Style: Component> = (props) => MetaTag('style', props, { close: true }) export const Meta: Component> = (props) => MetaTag('meta', props) export const Link: Component> = (props) => MetaTag('link', props) export const Base: Component> = (props) => MetaTag('base', props) export const Stylesheet: Component, 'rel'>> = (props) => ( )