From 9c1ed4a04b0824e77ffc3e2b9f9439686e6c4b5f Mon Sep 17 00:00:00 2001 From: Untone Date: Tue, 7 May 2024 18:38:03 +0300 Subject: [PATCH] orig-meta --- .../Article/AudioPlayer/PlayerPlaylist.tsx | 2 +- src/context/meta.tsx | 292 +++++++++++++----- 2 files changed, 212 insertions(+), 82 deletions(-) diff --git a/src/components/Article/AudioPlayer/PlayerPlaylist.tsx b/src/components/Article/AudioPlayer/PlayerPlaylist.tsx index 81e69d58..4d1ff700 100644 --- a/src/components/Article/AudioPlayer/PlayerPlaylist.tsx +++ b/src/components/Article/AudioPlayer/PlayerPlaylist.tsx @@ -3,7 +3,7 @@ import { For, Show, createSignal, lazy } from 'solid-js' import { useLocalize } from '../../../context/localize' import { MediaItem } from '../../../pages/types' -import { getDescription } from '../../../utils/meta' +import { getDescription } from '../../../utils' import { Icon } from '../../_shared/Icon' import { Popover } from '../../_shared/Popover' import { SharePopup, getShareUrl } from '../SharePopup' diff --git a/src/context/meta.tsx b/src/context/meta.tsx index c09f0dbd..3d36eb06 100644 --- a/src/context/meta.tsx +++ b/src/context/meta.tsx @@ -1,103 +1,219 @@ import { Component, - JSX, - ParentComponent, createContext, createRenderEffect, - createSignal, + createUniqueId, + JSX, onCleanup, + ParentComponent, + sharedConfig, useContext, } from 'solid-js' -import { isServer, spread } from 'solid-js/web' +import { isServer, spread, escape as escapeMeta, useAssets, ssr } from 'solid-js/web' export const MetaContext = createContext() interface TagDescription { tag: string - props: Record - cleanup?: () => void + props: Record + setting?: { close?: boolean; escape?: boolean } + id: string + name?: string + ref?: Element } export interface MetaContextType { - addTag: (tag: TagDescription) => void - removeTag: (tag: TagDescription) => void + 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() { - const tags = new Map() - - function addTag(tag: TagDescription) { - const key = getTagKey(tag) - tags.set(key, tag) - - const el = document.createElement(tag.tag) - spread(el, tag.props) - document.head.appendChild(el) - - tag.cleanup = () => { - document.head.removeChild(el) - tags.delete(key) - } + 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)) } - function removeTag(tag: TagDescription) { - const key = getTagKey(tag) - const existingTag = tags.get(key) - if (existingTag) { - if (existingTag.cleanup) existingTag.cleanup() - tags.delete(key) + 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, removeTag } + 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) - function addTag(tagDesc: TagDescription) { - tags.push(tagDesc) + 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) {}, } - - function removeTag(tag: TagDescription) { - const index = tags.findIndex((t) => getTagKey(t) === getTagKey(tag)) - if (index !== -1) { - tags.splice(index, 1) - } - } - - return { addTag, removeTag } } export const MetaProvider: ParentComponent = (props) => { const actions = isServer ? initServerProvider() : initClientProvider() - const [tags, setTags] = createSignal([]) - - const addTag = (tag: TagDescription) => { - actions.addTag(tag) - setTags([...tags(), tag]) - } - - const removeTag = (tag: TagDescription) => { - actions.removeTag(tag) - setTags(tags().filter((t) => getTagKey(t) !== getTagKey(tag))) - } - - onCleanup(() => { - for (const tag of tags()) { - tag.cleanup?.() - } - }) - - return {props.children} + return {props.children} } -const getTagKey = (tag: TagDescription) => { - const props = Object.entries(tag.props) - .filter(([k]) => k !== 'children') - .sort() +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 `${tag.tag}${JSON.stringify(props)}` + return null } export function useHead(tagDesc: TagDescription) { @@ -105,34 +221,48 @@ export function useHead(tagDesc: TagDescription) { if (!c) throw new Error(' should be in the tree') createRenderEffect(() => { - c.addTag(tagDesc) - - return () => { - c.removeTag(tagDesc) - } + const index = c?.addTag(tagDesc) + onCleanup(() => c?.removeTag(tagDesc, index)) }) } -const MetaTag = (tag: string, props: Record) => { - useHead({ tag, props }) - - return null +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 as Record) + MetaTag('title', props, { escape: true, close: true }) export const Style: Component> = (props) => - MetaTag('style', props as Record) + MetaTag('style', props, { close: true }) -export const Meta: Component> = (props) => - MetaTag('meta', props as Record) +export const Meta: Component> = (props) => MetaTag('meta', props) -export const Link: Component> = (props) => - MetaTag('link', props as Record) +export const Link: Component> = (props) => MetaTag('link', props) -export const Base: Component> = (props) => - MetaTag('base', props as Record) +export const Base: Component> = (props) => MetaTag('base', props) export const Stylesheet: Component, 'rel'>> = (props) => (