diff --git a/package.json b/package.json index 10043e4e..75b3de57 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "export:sizes": "pnpm -r --parallel --filter=./packages/** run export:sizes", "bump": "bumpp package.json packages/*/package.json --commit --push --tag", "release": "pnpm build && pnpm bump && pnpm -r publish --no-git-checks", - "lint": "eslint . --fix" + "lint": "eslint . --fix", + "benchmark": "vitest bench" }, "devDependencies": { "@antfu/eslint-config": "^2.21.2", diff --git a/packages/addons/src/plugins/inferSeoMetaPlugin.ts b/packages/addons/src/plugins/inferSeoMetaPlugin.ts index a4997c87..84c30dcf 100644 --- a/packages/addons/src/plugins/inferSeoMetaPlugin.ts +++ b/packages/addons/src/plugins/inferSeoMetaPlugin.ts @@ -34,7 +34,7 @@ export interface InferSeoMetaPluginOptions { const inputKey = entry.resolvedInput ? 'resolvedInput' : 'input' const input = entry[inputKey] const weight = (typeof input.titleTemplate === 'object' ? input.titleTemplate?.tagPriority : false) || entry.tagPriority || 100 - if (typeof input.titleTemplate !== 'undefined' && weight <= lastWeight) { + if (input.titleTemplate !== undefined && weight <= lastWeight) { titleTemplate = input.titleTemplate lastWeight = weight } diff --git a/packages/addons/src/unplugin/TreeshakeServerComposables.ts b/packages/addons/src/unplugin/TreeshakeServerComposables.ts index 614992e8..bbd4f770 100644 --- a/packages/addons/src/unplugin/TreeshakeServerComposables.ts +++ b/packages/addons/src/unplugin/TreeshakeServerComposables.ts @@ -23,7 +23,7 @@ export interface TreeshakeServerComposablesOptions extends BaseTransformerTypes } export const TreeshakeServerComposables = createUnplugin((options: TreeshakeServerComposablesOptions = {}) => { - options.enabled = typeof options.enabled !== 'undefined' ? options.enabled : true + options.enabled = options.enabled !== undefined ? options.enabled : true return { name: 'unhead:remove-server-composables', diff --git a/packages/addons/src/unplugin/vite.ts b/packages/addons/src/unplugin/vite.ts index ed4504d8..718eeb8c 100644 --- a/packages/addons/src/unplugin/vite.ts +++ b/packages/addons/src/unplugin/vite.ts @@ -7,7 +7,7 @@ export type { UnpluginOptions } export default (options: UnpluginOptions = {}): Plugin[] => { return [ - TreeshakeServerComposables.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.treeshake || {} }), - UseSeoMetaTransform.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.transformSeoMeta || {} }), + TreeshakeServerComposables.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.treeshake }), + UseSeoMetaTransform.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.transformSeoMeta }), ] } diff --git a/packages/dom/src/debounced.ts b/packages/dom/src/debounced.ts index fa1c7aa8..250ca648 100644 --- a/packages/dom/src/debounced.ts +++ b/packages/dom/src/debounced.ts @@ -12,11 +12,13 @@ export interface DebouncedRenderDomHeadOptions extends RenderDomHeadOptions { /** * Queue a debounced update of the DOM head. */ -export async function debouncedRenderDOMHead>(head: T, options: DebouncedRenderDomHeadOptions = {}) { +export function debouncedRenderDOMHead>(head: T, options: DebouncedRenderDomHeadOptions = {}) { const fn = options.delayFn || (fn => setTimeout(fn, 10)) - return head._domUpdatePromise = head._domUpdatePromise || new Promise(resolve => fn(async () => { - await renderDOMHead(head, options) - delete head._domUpdatePromise - resolve() + return head._domUpdatePromise = head._domUpdatePromise || new Promise(resolve => fn(() => { + return renderDOMHead(head, options) + .then(() => { + delete head._domUpdatePromise + resolve() + }) })) } diff --git a/packages/dom/src/domPlugin.ts b/packages/dom/src/domPlugin.ts index 63c37920..588126a0 100644 --- a/packages/dom/src/domPlugin.ts +++ b/packages/dom/src/domPlugin.ts @@ -14,7 +14,7 @@ export interface DomPluginOptions extends RenderDomHeadOptions { return { mode: 'client', hooks: { - 'entries:updated': function (head) { + 'entries:updated': (head) => { // async load the renderDOMHead function debouncedRenderDOMHead(head, options) }, diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index a200317e..79c255fb 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -31,7 +31,7 @@ export async function renderDOMHead>(head: T, options: Ren const tags = (await head.resolveTags()) .map(tag => { tag, - id: HasElementTags.includes(tag.tag) ? hashTag(tag) : tag.tag, + id: HasElementTags.has(tag.tag) ? hashTag(tag) : tag.tag, shouldRender: true, }) let state = head._dom as DomState @@ -40,12 +40,19 @@ export async function renderDOMHead>(head: T, options: Ren state = { elMap: { htmlAttrs: dom.documentElement, bodyAttrs: dom.body }, } as any as DomState + + const takenDedupeKeys = new Set() + for (const key of ['body', 'head']) { const children = dom[key as 'head' | 'body']?.children const tags: HeadTag[] = [] - for (const c of [...children].filter(c => HasElementTags.includes(c.tagName.toLowerCase()))) { + for (const c of children) { + const tag = c.tagName.toLowerCase() as HeadTag['tag'] + if (!HasElementTags.has(tag)) { + continue + } const t: HeadTag = { - tag: c.tagName.toLowerCase() as HeadTag['tag'], + tag, props: await normaliseProps( c.getAttributeNames() .reduce((props, name) => ({ ...props, [name]: c.getAttribute(name) }), {}), @@ -53,11 +60,15 @@ export async function renderDOMHead>(head: T, options: Ren innerHTML: c.innerHTML, } // we need to account for the fact that duplicate tags may exist as some are supported, increment the dedupe key + const dedupeKey = tagDedupeKey(t) + let d = dedupeKey let i = 1 - let d = tagDedupeKey(t) - while (d && tags.find(t => t._d === d)) - d = `${d}:${i++}` - t._d = d || undefined + while (d && takenDedupeKeys.has(d)) + d = `${dedupeKey}:${i++}` + if (d) { + t._d = d + takenDedupeKeys.add(d) + } tags.push(t) state.elMap[c.getAttribute('data-hid') || hashTag(t)] = c } @@ -65,7 +76,7 @@ export async function renderDOMHead>(head: T, options: Ren } // presume all side effects are stale, we mark them as not stale if they're re-introduced - state.pendingSideEffects = { ...state.sideEffects || {} } + state.pendingSideEffects = { ...state.sideEffects } state.sideEffects = {} function track(id: string, scope: string, fn: () => void) { @@ -78,44 +89,69 @@ export async function renderDOMHead>(head: T, options: Ren const isAttrTag = tag.tag.endsWith('Attrs') state.elMap[id] = $el if (!isAttrTag) { - ;['textContent', 'innerHTML'].forEach((k) => { - // @ts-expect-error unkeyed - tag[k] && tag[k] !== $el[k] && ($el[k] = tag[k]) - }) + if (tag.textContent && tag.textContent !== $el.textContent) { + $el.textContent = tag.textContent + } + if (tag.innerHTML && tag.innerHTML !== $el.innerHTML) { + $el.innerHTML = tag.innerHTML + } track(id, 'el', () => { // the element may have been removed by a duplicate tag or something out of our control state.elMap[id]?.remove() delete state.elMap[id] }) } - // we need to attach event listeners as they can have side effects such as onload - for (const [k, value] of Object.entries(tag._eventHandlers || {})) { - if ($el.getAttribute(`data-${k}`) !== '') { - // avoid overriding - (tag!.tag === 'bodyAttrs' ? dom!.defaultView! : $el).addEventListener(k.replace('on', ''), value.bind($el)) - $el.setAttribute(`data-${k}`, '') + if (tag._eventHandlers) { + // we need to attach event listeners as they can have side effects such as onload + for (const k in tag._eventHandlers) { + if (!Object.prototype.hasOwnProperty.call(tag._eventHandlers, k)) { + continue + } + + if ($el.getAttribute(`data-${k}`) !== '') { + // avoid overriding + (tag!.tag === 'bodyAttrs' ? dom!.defaultView! : $el).addEventListener( + // onload -> load + k.substring(2), + tag._eventHandlers[k].bind($el), + ) + $el.setAttribute(`data-${k}`, '') + } } } - Object.entries(tag.props).forEach(([k, value]) => { + for (const k in tag.props) { + if (!Object.prototype.hasOwnProperty.call(tag.props, k)) { + continue + } + const value = tag.props[k] + const ck = `attr:${k}` // class attributes have their own side effects to allow for merging if (k === 'class') { + if (!value) { + continue + } // if the user is providing an empty string, then it's removing the class // the side effect clean up should remove it - for (const c of (value || '').split(' ').filter(Boolean)) { + for (const c of value.split(' ')) { // always clear side effects isAttrTag && track(id, `${ck}:${c}`, () => $el.classList.remove(c)) !$el.classList.contains(c) && $el.classList.add(c) } } else if (k === 'style') { + if (!value) { + continue + } // style attributes have their own side effects to allow for merging - for (const c of (value || '').split(';').filter(Boolean)) { - const [k, ...v] = c.split(':').map(s => s.trim()) + for (const c of value.split(';')) { + const propIndex = c.indexOf(':') + const k = c.substring(0, propIndex).trim() + const v = c.substring(propIndex + 1).trim() track(id, `${ck}:${k}`, () => { ($el as any as ElementCSSInlineStyle).style.removeProperty(k) }) - ;($el as any as ElementCSSInlineStyle).style.setProperty(k, v.join(':')) + ;($el as any as ElementCSSInlineStyle).style.setProperty(k, v) } } else { @@ -123,7 +159,7 @@ export async function renderDOMHead>(head: T, options: Ren $el.getAttribute(k) !== value && $el.setAttribute(k, (value as string | boolean) === true ? '' : String(value)) isAttrTag && track(id, ck, () => $el.removeAttribute(k)) } - }) + } } const pending: DomRenderTagContext[] = [] @@ -144,11 +180,13 @@ export async function renderDOMHead>(head: T, options: Ren continue } ctx.$el = ctx.$el || state.elMap[id] - if (ctx.$el) + if (ctx.$el) { trackCtx(ctx) - else + } + else if (HasElementTags.has(tag.tag)) { // tag does not exist, we need to render it (if it's an element tag) - HasElementTags.includes(tag.tag) && pending.push(ctx) + pending.push(ctx) + } } // 3. render tags which require a dom element to be created or requires scanning DOM to determine duplicate for (const ctx of pending) { @@ -168,7 +206,9 @@ export async function renderDOMHead>(head: T, options: Ren frag.bodyClose && dom.body.appendChild(frag.bodyClose) // clear all side effects still pending - Object.values(state.pendingSideEffects).forEach(fn => fn()) + for (const k in state.pendingSideEffects) { + state.pendingSideEffects[k]() + } head._dom = state head.dirty = false await head.hooks.callHook('dom:rendered', { renders: tags }) diff --git a/packages/schema-org/src/core/resolve.ts b/packages/schema-org/src/core/resolve.ts index 2a603e55..8efa61fe 100644 --- a/packages/schema-org/src/core/resolve.ts +++ b/packages/schema-org/src/core/resolve.ts @@ -118,11 +118,18 @@ export function resolveNodeId(node: T, ctx: SchemaOrgGraph, res alias = type.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() } const hashNodeData: Record = {} - Object.entries(node).forEach(([key, val]) => { - // remove runtime private fields - if (!key.startsWith('_')) - hashNodeData[key] = val - }) + for (const key in node) { + if (key[0] === '_') { + continue + } + + if (!Object.prototype.hasOwnProperty.call(node, key)) { + continue + } + + hashNodeData[key] = node[key] + } + node['@id'] = prefixId(ctx.meta[prefix], `#/schema/${alias}/${node['@id'] || hashCode(JSON.stringify(hashNodeData))}`) return node } diff --git a/packages/schema-org/src/core/util.ts b/packages/schema-org/src/core/util.ts index 94c1767e..f57e49b3 100644 --- a/packages/schema-org/src/core/util.ts +++ b/packages/schema-org/src/core/util.ts @@ -59,7 +59,7 @@ export function normaliseNodes(nodes: SchemaOrgNode[]) { const nodeKey = resolveAsGraphKey(n['@id'] || hash(n)) as Id const groupedKeys = groupBy(Object.keys(n), (key) => { const val = n[key] - if (key.startsWith('_')) + if (key[0] === '_') return 'ignored' if (Array.isArray(val) || typeof val === 'object') return 'relations' diff --git a/packages/schema-org/src/plugin.ts b/packages/schema-org/src/plugin.ts index a833fd2e..72ba3575 100644 --- a/packages/schema-org/src/plugin.ts +++ b/packages/schema-org/src/plugin.ts @@ -33,10 +33,10 @@ export function SchemaOrgUnheadPlugin(config: MetaInput, meta: () => Partial ({ key: 'schema-org', hooks: { - 'entries:resolve': function () { + 'entries:resolve': () => { graph = createSchemaOrgGraph() }, - 'tag:normalise': async function ({ tag }) { + 'tag:normalise': async ({ tag }) => { if (tag.key === 'schema-org-graph') { // this is a bit expensive, load in seperate chunk const { loadResolver } = await import('./resolver') @@ -75,7 +75,7 @@ export function SchemaOrgUnheadPlugin(config: MetaInput, meta: () => Partial { // find the schema.org node, should be a single instance for (const tag of ctx.tags) { if (tag.tag === 'script' && tag.key === 'schema-org-graph') { diff --git a/packages/schema-org/src/utils/index.ts b/packages/schema-org/src/utils/index.ts index 5cd28b3c..90dd26d2 100644 --- a/packages/schema-org/src/utils/index.ts +++ b/packages/schema-org/src/utils/index.ts @@ -75,20 +75,17 @@ export function asArray(input: any) { } export function dedupeMerge(node: T, field: keyof T, value: any) { - const dedupeMerge: any[] = [] - const input = asArray(node[field]) - dedupeMerge.push(...input) - const data = new Set(dedupeMerge) + const data = new Set(asArray(node[field])) data.add(value) // @ts-expect-error untyped key - node[field] = [...data.values()].filter(Boolean) + node[field] = [...data].filter(Boolean) } export function prefixId(url: string, id: Id | string) { // already prefixed if (hasProtocol(id)) return id as Id - if (!id.startsWith('#')) + if (id[0] !== '#') id = `#${id}` return withBase(id, url) as Id } @@ -117,7 +114,7 @@ export function resolveDefaultType(node: Thing, defaultType: Arrayable) export function resolveWithBase(base: string, urlOrPath: string) { // can't apply base if there's a protocol - if (!urlOrPath || hasProtocol(urlOrPath) || (!urlOrPath.startsWith('/') && !urlOrPath.startsWith('#'))) + if (!urlOrPath || hasProtocol(urlOrPath) || ((urlOrPath[0] !== '/') && (urlOrPath[0] !== '#'))) return urlOrPath return withBase(urlOrPath, base) } @@ -132,7 +129,11 @@ export function resolveAsGraphKey(key?: Id | string) { * Removes attributes which have a null or undefined value */ export function stripEmptyProperties(obj: any) { - Object.keys(obj).forEach((k) => { + for (const k in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, k)) { + continue + } + if (obj[k] && typeof obj[k] === 'object') { // avoid walking vue reactivity if (obj[k].__v_isReadonly || obj[k].__v_isRef) @@ -140,8 +141,9 @@ export function stripEmptyProperties(obj: any) { stripEmptyProperties(obj[k]) return } - if (obj[k] === '' || obj[k] === null || typeof obj[k] === 'undefined') + if (obj[k] === '' || obj[k] === null || obj[k] === undefined) delete obj[k] - }) + } + return obj } diff --git a/packages/schema-org/src/vue/runtime/components.ts b/packages/schema-org/src/vue/runtime/components.ts index bb442484..a97771aa 100644 --- a/packages/schema-org/src/vue/runtime/components.ts +++ b/packages/schema-org/src/vue/runtime/components.ts @@ -49,7 +49,7 @@ function ignoreKey(s: string) { if (s.startsWith('aria-') || s.startsWith('data-')) return false - return ['class', 'style'].includes(s) + return s === 'class' || s === 'style' } export function defineSchemaOrgComponent(name: string, defineFn: (input: any) => any): ReturnType { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 3a9efe21..ef3ad9cb 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,14 +1,14 @@ -export const SelfClosingTags = ['meta', 'link', 'base'] -export const TagsWithInnerContent = ['title', 'titleTemplate', 'script', 'style', 'noscript'] -export const HasElementTags = [ +export const SelfClosingTags = new Set(['meta', 'link', 'base']) +export const TagsWithInnerContent = new Set(['title', 'titleTemplate', 'script', 'style', 'noscript']) +export const HasElementTags = new Set([ 'base', 'meta', 'link', 'style', 'script', 'noscript', -] -export const ValidHeadTags = [ +]) +export const ValidHeadTags = new Set([ 'title', 'titleTemplate', 'templateParams', @@ -20,11 +20,11 @@ export const ValidHeadTags = [ 'style', 'script', 'noscript', -] +]) -export const UniqueTags = ['base', 'title', 'titleTemplate', 'bodyAttrs', 'htmlAttrs', 'templateParams'] +export const UniqueTags = new Set(['base', 'title', 'titleTemplate', 'bodyAttrs', 'htmlAttrs', 'templateParams']) -export const TagConfigKeys = ['tagPosition', 'tagPriority', 'tagDuplicateStrategy', 'children', 'innerHTML', 'textContent', 'processTemplateParams'] +export const TagConfigKeys = new Set(['tagPosition', 'tagPriority', 'tagDuplicateStrategy', 'children', 'innerHTML', 'textContent', 'processTemplateParams']) export const IsBrowser = typeof window !== 'undefined' diff --git a/packages/shared/src/hashCode.ts b/packages/shared/src/hashCode.ts index 99c76ed9..7ea1890c 100644 --- a/packages/shared/src/hashCode.ts +++ b/packages/shared/src/hashCode.ts @@ -11,5 +11,19 @@ export function hashCode(s: string) { } export function hashTag(tag: HeadTag) { - return tag._h || hashCode(tag._d ? tag._d : `${tag.tag}:${tag.textContent || tag.innerHTML || ''}:${Object.entries(tag.props).map(([key, value]) => `${key}:${String(value)}`).join(',')}`) + if (tag._h) { + return tag._h + } + + if (tag._d) { + return hashCode(tag._d) + } + + let content = `${tag.tag}:${tag.textContent || tag.innerHTML || ''}:` + + for (const key in tag.props) { + content += `${key}:${tag.props[key]},` + } + + return hashCode(content) } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 898ac8d1..1ae8e8b9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,4 +9,5 @@ export * from './safe' export * from './normalise' export * from './sort' export * from './script' +export * from './thenable' export * from './templateParams' diff --git a/packages/shared/src/meta.ts b/packages/shared/src/meta.ts index 8b6a6efe..551e3eb6 100644 --- a/packages/shared/src/meta.ts +++ b/packages/shared/src/meta.ts @@ -82,16 +82,17 @@ const MetaPackingSchema: Record = { }, } as const -const openGraphNamespaces = [ +const openGraphNamespaces = new Set([ 'og', 'book', 'article', 'profile', -] +]) export function resolveMetaKeyType(key: string): keyof BaseMeta { - const fKey = fixKeyCase(key).split(':')[0] - if (openGraphNamespaces.includes(fKey)) + const fKey = fixKeyCase(key) + const prefixIndex = fKey.indexOf(':') + if (openGraphNamespaces.has(fKey.substring(0, prefixIndex))) return 'property' return MetaPackingSchema[key]?.metaKey || 'name' } @@ -102,8 +103,9 @@ export function resolveMetaKeyValue(key: string): string { function fixKeyCase(key: string) { const updated = key.replace(/([A-Z])/g, '-$1').toLowerCase() - const fKey = updated.split('-')[0] - if (openGraphNamespaces.includes(fKey) || fKey === 'twitter') + const prefixIndex = updated.indexOf('-') + const fKey = updated.substring(0, prefixIndex) + if (fKey === 'twitter' || openGraphNamespaces.has(fKey)) return key.replace(/([A-Z])/g, ':$1').toLowerCase() return updated } @@ -118,8 +120,12 @@ function changeKeyCasingDeep(input: T): T { return input const output: Record = {} - for (const [key, value] of Object.entries(input as object)) - output[fixKeyCase(key)] = changeKeyCasingDeep(value) + for (const key in input) { + if (!Object.prototype.hasOwnProperty.call(input, key)) { + continue + } + output[fixKeyCase(key)] = changeKeyCasingDeep(input[key]) + } return output as T } @@ -147,14 +153,18 @@ export function resolvePackedMetaObjectValue(value: string, key: string): string ) } -const ObjectArrayEntries = ['og:image', 'og:video', 'og:audio', 'twitter:image'] +const ObjectArrayEntries = new Set(['og:image', 'og:video', 'og:audio', 'twitter:image']) function sanitize(input: Record) { const out: Record = {} - Object.entries(input).forEach(([k, v]) => { + for (const k in input) { + if (!Object.prototype.hasOwnProperty.call(input, k)) { + continue + } + const v = input[k] if (String(v) !== 'false' && k) out[k] = v - }) + } return out } @@ -163,13 +173,17 @@ function handleObjectEntry(key: string, v: Record) { const value: Record = sanitize(v) const fKey = fixKeyCase(key) const attr = resolveMetaKeyType(fKey) - if (ObjectArrayEntries.includes(fKey as keyof MetaFlatInput)) { + if (ObjectArrayEntries.has(fKey as keyof MetaFlatInput)) { const input: MetaFlatInput = {} - // we need to prefix the keys with og: - Object.entries(value).forEach(([k, v]) => { + for (const k in value) { + if (!Object.prototype.hasOwnProperty.call(value, k)) { + continue + } + + // we need to prefix the keys with og: // @ts-expect-error untyped - input[`${key}${k === 'url' ? '' : `${k.charAt(0).toUpperCase()}${k.slice(1)}`}`] = v - }) + input[`${key}${k === 'url' ? '' : `${k[0].toUpperCase()}${k.slice(1)}`}`] = value[k] + } return unpackMeta(input) // sort by property name // @ts-expect-error untyped @@ -186,24 +200,30 @@ export function unpackMeta(input: T): Required['m const extras: BaseMeta[] = [] // need to handle array input of the object const primitives: Record = {} - Object.entries(input).forEach(([key, value]) => { + for (const key in input) { + if (!Object.prototype.hasOwnProperty.call(input, key)) { + continue + } + + const value = input[key] + if (!Array.isArray(value)) { if (typeof value === 'object' && value) { - if (ObjectArrayEntries.includes(fixKeyCase(key) as keyof MetaFlatInput)) { + if (ObjectArrayEntries.has(fixKeyCase(key) as keyof MetaFlatInput)) { extras.push(...handleObjectEntry(key, value)) - return + continue } primitives[key] = sanitize(value) } else { primitives[key] = value } - return + continue } - value.forEach((v) => { + for (const v of value) { extras.push(...(typeof v === 'string' ? unpackMeta({ [key]: v }) as BaseMeta[] : handleObjectEntry(key, v))) - }) - }) + } + } const meta = unpackToArray((primitives), { key({ key }) { diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 92b09fe2..fab46811 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -1,32 +1,39 @@ import type { Head, HeadEntry, HeadTag } from '@unhead/schema' -import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags, asArray } from '.' +import { type Thenable, thenable } from './thenable' +import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags } from '.' + +export function normaliseTag(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry, normalizedProps?: HeadTag['props']): Thenable { + const props = normalizedProps || normaliseProps( + // explicitly check for an object + // @ts-expect-error untyped + typeof input === 'object' && typeof input !== 'function' && !(input instanceof Promise) + ? { ...input } + : { [(tagName === 'script' || tagName === 'noscript' || tagName === 'style') ? 'innerHTML' : 'textContent']: input }, + (tagName === 'templateParams' || tagName === 'titleTemplate'), + ) + + if (props instanceof Promise) { + return props.then(val => normaliseTag(tagName, input, e, val)) + } -export async function normaliseTag(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry): Promise { // input can be a function or an object, we need to clone it const tag = { tag: tagName, - props: await normaliseProps( - // explicitly check for an object - // @ts-expect-error untyped - typeof input === 'object' && typeof input !== 'function' && !(input instanceof Promise) - ? { ...input } - : { [['script', 'noscript', 'style'].includes(tagName) ? 'innerHTML' : 'textContent']: input }, - ['templateParams', 'titleTemplate'].includes(tagName), - ), + props: props as T['props'], } as T // merge options from the entry - TagConfigKeys.forEach((k) => { + for (const k of TagConfigKeys) { // @ts-expect-error untyped - const val = typeof tag.props[k] !== 'undefined' ? tag.props[k] : e[k] - if (typeof val !== 'undefined') { - // strip innerHTML and textContent for tags which don't support it - if (!['innerHTML', 'textContent', 'children'].includes(k) || TagsWithInnerContent.includes(tag.tag)) { + const val = tag.props[k] !== undefined ? tag.props[k] : e[k] + if (val !== undefined) { + // strip innerHTML and textContent for tags which don't support it= + if (!(k === 'innerHTML' || k === 'textContent' || k === 'children') || TagsWithInnerContent.has(tag.tag)) { // @ts-expect-error untyped tag[k === 'children' ? 'innerHTML' : k] = val } delete tag.props[k] } - }) + } // TODO remove v2 if (tag.props.body) { // inserting dangerous javascript potentially @@ -57,29 +64,39 @@ export function normaliseStyleClassProps(key: T, v: // finally, check we don't have spaces, we may need to split again return String((Array.isArray(v) ? v.join(sep) : v)) ?.split(sep) - .filter(c => c.trim()) - .filter(Boolean) + .filter(c => Boolean(c.trim())) .join(sep) } -export async function normaliseProps(props: T['props'], virtual?: boolean): Promise { - // handle boolean props, see https://html.spec.whatwg.org/#boolean-attributes - for (const k of Object.keys(props)) { +function nestedNormaliseProps( + props: T['props'], + virtual: boolean, + keys: (keyof T['props'])[], + startIndex: number, +): Thenable { + for (let i = startIndex; i < keys.length; i += 1) { + const k = keys[i] + // handle boolean props, see https://html.spec.whatwg.org/#boolean-attributes // class has special handling - if (['class', 'style'].includes(k)) { + if (k === 'class' || k === 'style') { // @ts-expect-error untyped props[k] = normaliseStyleClassProps(k, props[k]) continue } - // first resolve any promises - // @ts-expect-error untyped - if (props[k] instanceof Promise) - // @ts-expect-error untyped - props[k] = await props[k] - if (!virtual && !TagConfigKeys.includes(k)) { + + // @ts-expect-error no reason for: The left-hand side of an 'instanceof' expression must be of type 'any', an object type or a type parameter. + if (props[k] instanceof Promise) { + return props[k].then((val) => { + props[k] = val + + return nestedNormaliseProps(props, virtual, keys, i) + }) + } + + if (!virtual && !TagConfigKeys.has(k as string)) { const v = String(props[k]) // data keys get special treatment, we opt for more verbose syntax - const isDataKey = k.startsWith('data-') + const isDataKey = (k as string).startsWith('data-') if (v === 'true' || v === '') { // @ts-expect-error untyped props[k] = isDataKey ? 'true' : true @@ -93,28 +110,76 @@ export async function normaliseProps(props: T['props'], virtu } } } +} + +export function normaliseProps(props: T['props'], virtual: boolean = false): Thenable { + const resolvedProps = nestedNormaliseProps(props, virtual, Object.keys(props), 0) + + if (resolvedProps instanceof Promise) { + return resolvedProps.then(() => props) + } + return props } // support 1024 tag ids per entry (includes updates) export const TagEntityBits = 10 -export async function normaliseEntryTags(e: HeadEntry): Promise { - const tagPromises: Promise[] = [] - Object.entries(e.resolvedInput as {}) - .filter(([k, v]) => typeof v !== 'undefined' && ValidHeadTags.includes(k)) - .forEach(([k, value]) => { - const v = asArray(value) - // @ts-expect-error untyped - tagPromises.push(...v.map(props => normaliseTag(k as keyof Head, props, e)).flat()) - }) - return (await Promise.all(tagPromises)) - .flat() - .filter(Boolean) - .map((t: HeadTag, i) => { +function nestedNormaliseEntryTags(headTags: HeadTag[], tagPromises: Thenable[], startIndex: number): Thenable { + for (let i = startIndex; i < tagPromises.length; i += 1) { + const tags = tagPromises[i] + + if (tags instanceof Promise) { + return tags.then((val) => { + tagPromises[i] = val + + return nestedNormaliseEntryTags(headTags, tagPromises, i) + }) + } + + if (Array.isArray(tags)) { + headTags.push(...tags) + } + else { + headTags.push(tags) + } + } +} + +export function normaliseEntryTags(e: HeadEntry): Thenable { + const tagPromises: Thenable[] = [] + const input = e.resolvedInput as T + for (const k in input) { + if (!Object.prototype.hasOwnProperty.call(input, k)) { + continue + } + const v = input[k as keyof typeof input] + if (v === undefined || !ValidHeadTags.has(k)) { + continue + } + if (Array.isArray(v)) { + for (const props of v) { + // @ts-expect-error untyped + tagPromises.push(normaliseTag(k as keyof Head, props, e)) + } + continue + } + // @ts-expect-error untyped + tagPromises.push(normaliseTag(k as keyof Head, v, e)) + } + + if (tagPromises.length === 0) { + return [] + } + + const headTags: HeadTag[] = [] + + return thenable(nestedNormaliseEntryTags(headTags, tagPromises, 0), () => ( + headTags.map((t, i) => { t._e = e._i e.mode && (t._m = e.mode) t._p = (e._i << TagEntityBits) + i return t - }) as unknown as HeadTag[] + }) + )) } diff --git a/packages/shared/src/safe.ts b/packages/shared/src/safe.ts index 877887a9..a294c004 100644 --- a/packages/shared/src/safe.ts +++ b/packages/shared/src/safe.ts @@ -69,7 +69,7 @@ export function whitelistSafeInput(input: Record { const val = meta[key] // block bad rel types - if (key === 'rel' && ['stylesheet', 'canonical', 'modulepreload', 'prerender', 'preload', 'prefetch'].includes(val)) + if (key === 'rel' && (val === 'stylesheet' || val === 'canonical' || val === 'modulepreload' || val === 'prerender' || val === 'preload' || val === 'prefetch')) return if (key === 'href') { diff --git a/packages/shared/src/script.ts b/packages/shared/src/script.ts index 84fd4b81..b8b91d72 100644 --- a/packages/shared/src/script.ts +++ b/packages/shared/src/script.ts @@ -1,2 +1,2 @@ -export const NetworkEvents = ['onload', 'onerror', 'onabort', 'onprogress', 'onloadstart'] -export const ScriptNetworkEvents = ['onload', 'onerror'] +export const NetworkEvents = new Set(['onload', 'onerror', 'onabort', 'onprogress', 'onloadstart']) +export const ScriptNetworkEvents = new Set(['onload', 'onerror']) diff --git a/packages/shared/src/sort.ts b/packages/shared/src/sort.ts index 93dbc1bb..65c5f3cd 100644 --- a/packages/shared/src/sort.ts +++ b/packages/shared/src/sort.ts @@ -14,18 +14,18 @@ export const TAG_ALIASES = { } as const export function tagWeight(tag: T) { - let weight = 100 const priority = tag.tagPriority if (typeof priority === 'number') return priority + let weight = 100 if (tag.tag === 'meta') { // CSP needs to be as it effects the loading of assets if (tag.props['http-equiv'] === 'content-security-policy') weight = -30 // charset must come early in case there's non-utf8 characters in the HTML document - if (tag.props.charset) + else if (tag.props.charset) weight = -20 - if (tag.props.name === 'viewport') + else if (tag.props.name === 'viewport') weight = -15 } else if (tag.tag === 'link' && tag.props.rel === 'preconnect') { @@ -35,9 +35,9 @@ export function tagWeight(tag: T) { else if (tag.tag in TAG_WEIGHTS) { weight = TAG_WEIGHTS[tag.tag as keyof typeof TAG_WEIGHTS] } - if (typeof priority === 'string' && priority in TAG_ALIASES) { - // @ts-expect-error untyped - return weight + TAG_ALIASES[priority] + if (priority && priority in TAG_ALIASES) { + // @ts-expect-e+rror untyped + return weight + TAG_ALIASES[priority as keyof typeof TAG_ALIASES] } return weight } diff --git a/packages/shared/src/tagDedupeKey.ts b/packages/shared/src/tagDedupeKey.ts index a1bec7b7..4b2791ee 100644 --- a/packages/shared/src/tagDedupeKey.ts +++ b/packages/shared/src/tagDedupeKey.ts @@ -1,31 +1,30 @@ import type { HeadTag } from '@unhead/schema' import { UniqueTags } from '.' -export function tagDedupeKey(tag: T, fn?: (key: string) => boolean): string | false { +const allowedMetaProperties = ['name', 'property', 'http-equiv'] + +export function tagDedupeKey(tag: T): string | false { const { props, tag: tagName } = tag // must only be a single base so we always dedupe - if (UniqueTags.includes(tagName)) + if (UniqueTags.has(tagName)) return tagName // support only a single canonical if (tagName === 'link' && props.rel === 'canonical') return 'canonical' - // must only be a single charset if (props.charset) return 'charset' - const name = ['id'] - if (tagName === 'meta') - name.push(...['name', 'property', 'http-equiv']) - for (const n of name) { + if (props.id) { + return `${tagName}:id:${props.id}` + } + + for (const n of allowedMetaProperties) { // open graph props can have multiple tags with the same property - if (typeof props[n] !== 'undefined') { - const val = String(props[n]) - if (fn && !fn(val)) - return false + if (props[n] !== undefined) { // for example: meta-name-description - return `${tagName}:${n}:${val}` + return `${tagName}:${n}:${props[n]}` } } return false diff --git a/packages/shared/src/templateParams.ts b/packages/shared/src/templateParams.ts index 73c95347..cce2cf68 100644 --- a/packages/shared/src/templateParams.ts +++ b/packages/shared/src/templateParams.ts @@ -2,26 +2,31 @@ import type { TemplateParams } from '@unhead/schema' const sepSub = '%separator' +// for each % token replace it with the corresponding runtime config or an empty value +function sub(p: TemplateParams, token: string) { + let val: string | undefined + if (token === 's' || token === 'pageTitle') { + val = p.pageTitle as string + } + // support . notation + else if (token.includes('.')) { + const dotIndex = token.indexOf('.') + // @ts-expect-error untyped + val = p[token.substring(0, dotIndex)]?.[token.substring(dotIndex + 1)] + } + else { val = p[token] as string | undefined } + return val !== undefined + // need to escape val for json + ? (val || '').replace(/"/g, '\\"') + : undefined +} + +const sepSubRe = new RegExp(`${sepSub}(?:\\s*${sepSub})*`, 'g') + export function processTemplateParams(s: string, p: TemplateParams, sep: string) { // return early if (typeof s !== 'string' || !s.includes('%')) return s - // for each % token replace it with the corresponding runtime config or an empty value - function sub(token: string) { - let val: string | undefined - if (['s', 'pageTitle'].includes(token)) { val = p.pageTitle as string } - // support . notation - else if (token.includes('.')) { - // @ts-expect-error untyped - val = token.split('.').reduce((acc, key) => acc ? (acc[key] || undefined) : undefined, p) as string - } - else { val = p[token] as string | undefined } - return typeof val !== 'undefined' - // need to escape val for json - ? (val || '').replace(/"/g, '\\"') - : false - } - // need to avoid replacing url encoded values let decoded = s try { @@ -29,27 +34,35 @@ export function processTemplateParams(s: string, p: TemplateParams, sep: string) } catch {} // find all tokens in decoded - const tokens: string[] = (decoded.match(/%(\w+\.+\w+)|%(\w+)/g) || []).sort().reverse() - // for each tokens, replace in the original string s - tokens.forEach((token) => { - const re = sub(token.slice(1)) - if (typeof re === 'string') { - // replace the re using regex as word separators - s = s.replace(new RegExp(`\\${token}(\\W|$)`, 'g'), (_, args) => `${re}${args}`).trim() + const tokens = decoded.match(/%\w+(?:\.\w+)?/g) + + if (!tokens) { + return s + } + + const hasSepSub = s.includes(sepSub) + + s = s.replace(/%\w+(?:\.\w+)?/g, (token) => { + if (token === sepSub || !tokens.includes(token)) { + return token } - }) + + const re = sub(p, token.slice(1)) + return re !== undefined + ? re + : token + }).trim() // we wait to transform the separator as we need to transform all other tokens first // we need to remove separators if they're next to each other or if they're at the start or end of the string // for example: %separator %separator %title should return %title - if (s.includes(sepSub)) { + if (hasSepSub) { if (s.endsWith(sepSub)) - s = s.slice(0, -sepSub.length).trim() + s = s.slice(0, -sepSub.length) if (s.startsWith(sepSub)) - s = s.slice(sepSub.length).trim() + s = s.slice(sepSub.length) // make sure we don't have two separators next to each other - s = s.replace(new RegExp(`\\${sepSub}\\s*\\${sepSub}`, 'g'), sepSub) - s = processTemplateParams(s, { separator: sep }, sep) + s = s.replace(sepSubRe, sep).trim() } return s } diff --git a/packages/shared/src/thenable.ts b/packages/shared/src/thenable.ts new file mode 100644 index 00000000..6cc8e658 --- /dev/null +++ b/packages/shared/src/thenable.ts @@ -0,0 +1,9 @@ +export type Thenable = Promise | T + +export function thenable(val: T, thenFn: (val: Awaited) => R): Promise | R { + if (val instanceof Promise) { + return val.then(thenFn) + } + + return thenFn(val as Awaited) +} diff --git a/packages/ssr/src/util/propsToString.ts b/packages/ssr/src/util/propsToString.ts index e4321bf0..e8d18292 100644 --- a/packages/ssr/src/util/propsToString.ts +++ b/packages/ssr/src/util/propsToString.ts @@ -3,12 +3,19 @@ function encodeAttribute(value: string) { } export function propsToString(props: Record) { - const attrs: string[] = [] + let attrs = '' - for (const [key, value] of Object.entries(props)) { - if (value !== false && value !== null) - attrs.push(value === true ? key : `${key}="${encodeAttribute(value)}"`) + for (const key in props) { + if (!Object.prototype.hasOwnProperty.call(props, key)) { + continue + } + + const value = props[key] + + if (value !== false && value !== null) { + attrs += value === true ? ` ${key}` : ` ${key}="${encodeAttribute(value)}"` + } } - return `${attrs.length > 0 ? ' ' : ''}${attrs.join(' ')}` + return attrs } diff --git a/packages/ssr/src/util/ssrRenderTags.ts b/packages/ssr/src/util/ssrRenderTags.ts index e583804d..7973d9c0 100644 --- a/packages/ssr/src/util/ssrRenderTags.ts +++ b/packages/ssr/src/util/ssrRenderTags.ts @@ -3,25 +3,29 @@ import { propsToString, tagToString } from '.' export function ssrRenderTags(tags: T[], options?: RenderSSRHeadOptions) { const schema: { - tags: Record<'head' | 'bodyClose' | 'bodyOpen', string[]> + tags: Record<'head' | 'bodyClose' | 'bodyOpen', string> htmlAttrs: HeadTag['props'] bodyAttrs: HeadTag['props'] - } = { htmlAttrs: {}, bodyAttrs: {}, tags: { head: [], bodyClose: [], bodyOpen: [] } } + } = { htmlAttrs: {}, bodyAttrs: {}, tags: { head: '', bodyClose: '', bodyOpen: '' } } + + const lineBreaks = !options?.omitLineBreaks ? '\n' : '' for (const tag of tags) { if (tag.tag === 'htmlAttrs' || tag.tag === 'bodyAttrs') { - schema[tag.tag] = { ...schema[tag.tag], ...tag.props } + Object.assign(schema[tag.tag], tag.props) continue } - schema.tags[tag.tagPosition || 'head'].push(tagToString(tag)) + const s = tagToString(tag) + const tagPosition = tag.tagPosition || 'head' + schema.tags[tagPosition] += schema.tags[tagPosition] + ? `${lineBreaks}${s}` + : s } - const lineBreaks = !options?.omitLineBreaks ? '\n' : '' - return { - headTags: schema.tags.head.join(lineBreaks), - bodyTags: schema.tags.bodyClose.join(lineBreaks), - bodyTagsOpen: schema.tags.bodyOpen.join(lineBreaks), + headTags: schema.tags.head, + bodyTags: schema.tags.bodyClose, + bodyTagsOpen: schema.tags.bodyOpen, htmlAttrs: propsToString(schema.htmlAttrs), bodyAttrs: propsToString(schema.bodyAttrs), } diff --git a/packages/ssr/src/util/tagToString.ts b/packages/ssr/src/util/tagToString.ts index 5136d78c..8bfbfa0f 100644 --- a/packages/ssr/src/util/tagToString.ts +++ b/packages/ssr/src/util/tagToString.ts @@ -36,13 +36,13 @@ export function tagToString(tag: T) { const attrs = propsToString(tag.props) const openTag = `<${tag.tag}${attrs}>` // get the encoding depending on the tag type - if (!TagsWithInnerContent.includes(tag.tag)) - return SelfClosingTags.includes(tag.tag) ? openTag : `${openTag}` + if (!TagsWithInnerContent.has(tag.tag)) + return SelfClosingTags.has(tag.tag) ? openTag : `${openTag}` // dangerously using innerHTML, we don't encode this let content = String(tag.innerHTML || '') if (tag.textContent) // content needs to be encoded to avoid XSS, only for title content = escapeHtml(String(tag.textContent)) - return SelfClosingTags.includes(tag.tag) ? openTag : `${openTag}${content}` + return SelfClosingTags.has(tag.tag) ? openTag : `${openTag}${content}` } diff --git a/packages/unhead/src/composables/useHeadSafe.ts b/packages/unhead/src/composables/useHeadSafe.ts index be7f32a1..f518aca2 100644 --- a/packages/unhead/src/composables/useHeadSafe.ts +++ b/packages/unhead/src/composables/useHeadSafe.ts @@ -6,10 +6,10 @@ import type { import { whitelistSafeInput } from '@unhead/shared' import { useHead } from './useHead' -export function useHeadSafe(input: HeadSafe, options: HeadEntryOptions = {}): ActiveHeadEntry | void { +export function useHeadSafe(input: HeadSafe, options?: HeadEntryOptions): ActiveHeadEntry | void { // @ts-expect-error untyped return useHead(input, { - ...(options || {}), + ...options, transform: whitelistSafeInput, }) } diff --git a/packages/unhead/src/composables/useScript.ts b/packages/unhead/src/composables/useScript.ts index 3a8d92e2..baccace3 100644 --- a/packages/unhead/src/composables/useScript.ts +++ b/packages/unhead/src/composables/useScript.ts @@ -29,7 +29,7 @@ export function useScript>(_input: UseScr script.status = s head.hooks.callHook(`script:updated`, hookCtx) } - const trigger = typeof options.trigger !== 'undefined' ? options.trigger : 'client' + const trigger = options.trigger !== undefined ? options.trigger : 'client' ScriptNetworkEvents .forEach((fn) => { const _fn = typeof input[fn] === 'function' ? input[fn].bind(options.eventContext) : null @@ -110,7 +110,7 @@ export function useScript>(_input: UseScr return stub if (k === '$script') return proxy.$script - const exists = _ && k in _ && typeof _[k] !== 'undefined' + const exists = _ && k in _ && _[k] !== undefined head.hooks.callHook('script:instance-fn', { script, fn: k, exists }) return exists ? Reflect.get(_, k) diff --git a/packages/unhead/src/composables/useServerSeoMeta.ts b/packages/unhead/src/composables/useServerSeoMeta.ts index ef604bea..9a585c3c 100644 --- a/packages/unhead/src/composables/useServerSeoMeta.ts +++ b/packages/unhead/src/composables/useServerSeoMeta.ts @@ -3,7 +3,7 @@ import { useSeoMeta } from './useSeoMeta' export function useServerSeoMeta(input: UseSeoMetaInput, options?: HeadEntryOptions): ActiveHeadEntry | void { return useSeoMeta(input, { - ...(options || {}), + ...options, mode: 'server', }) } diff --git a/packages/unhead/src/createHead.ts b/packages/unhead/src/createHead.ts index 26ce62fb..7ba11c95 100644 --- a/packages/unhead/src/createHead.ts +++ b/packages/unhead/src/createHead.ts @@ -96,13 +96,12 @@ export function createHeadCore(options: CreateHeadOptions = }, // a patch is the same as creating a new entry, just a nice DX patch(input) { - entries = entries.map((e) => { + for (const e of entries) { if (e._i === entry._i) { // bit hacky syncing e.input = entry.input = input } - return e - }) + } updated() }, } diff --git a/packages/unhead/src/optionalPlugins/capoPlugin.ts b/packages/unhead/src/optionalPlugins/capoPlugin.ts index 9f7a4e50..b6c6bf5d 100644 --- a/packages/unhead/src/optionalPlugins/capoPlugin.ts +++ b/packages/unhead/src/optionalPlugins/capoPlugin.ts @@ -5,7 +5,7 @@ const importRe = /@import/ /* @__NO_SIDE_EFFECTS__ */ export function CapoPlugin(options: { track?: boolean }) { return defineHeadPlugin({ hooks: { - 'tags:beforeResolve': function ({ tags }) { + 'tags:beforeResolve': ({ tags }) => { // handle 9 and down in capo for (const tag of tags) { if (tag.tagPosition && tag.tagPosition !== 'head') @@ -35,7 +35,7 @@ const importRe = /@import/ // SYNC_STYLES tag.tagPriority = 60 } - else if (isLink && ['preload', 'modulepreload'].includes(tag.props.rel)) { + else if (isLink && (tag.props.rel === 'preload' || tag.props.rel === 'modulepreload')) { // PRELOAD tag.tagPriority = 70 } @@ -43,7 +43,7 @@ const importRe = /@import/ // DEFER_SCRIPT tag.tagPriority = 80 } - else if (isLink && ['prefetch', 'dns-prefetch', 'prerender'].includes(tag.props.rel)) { + else if (isLink && (tag.props.rel === 'prefetch' || tag.props.rel === 'dns-prefetch' || tag.props.rel === 'prerender')) { tag.tagPriority = 90 } } diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 78f26ef4..c1ce4d1e 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -1,27 +1,33 @@ import type { HeadTag } from '@unhead/schema' import { HasElementTags, defineHeadPlugin, tagDedupeKey, tagWeight } from '@unhead/shared' -const UsesMergeStrategy = ['templateParams', 'htmlAttrs', 'bodyAttrs'] +const UsesMergeStrategy = new Set(['templateParams', 'htmlAttrs', 'bodyAttrs']) export default defineHeadPlugin({ hooks: { - 'tag:normalise': function ({ tag }) { + 'tag:normalise': ({ tag }) => { // support for third-party dedupe keys - ['hid', 'vmid', 'key'].forEach((key) => { - if (tag.props[key]) { - tag.key = tag.props[key] - delete tag.props[key] - } - }) + if (tag.props.hid) { + tag.key = tag.props.hid + delete tag.props.hid + } + if (tag.props.vmid) { + tag.key = tag.props.vmid + delete tag.props.vmid + } + if (tag.props.key) { + tag.key = tag.props.key + delete tag.props.key + } const generatedKey = tagDedupeKey(tag) const dedupe = generatedKey || (tag.key ? `${tag.tag}:${tag.key}` : false) if (dedupe) tag._d = dedupe }, - 'tags:resolve': function (ctx) { + 'tags:resolve': (ctx) => { // 1. Dedupe tags - const deduping: Record = {} - ctx.tags.forEach((tag) => { + const deduping: Record = Object.create(null) + for (const tag of ctx.tags) { // need a seperate dedupe key other than _d const dedupeKey = (tag.key ? `${tag.tag}:${tag.key}` : tag._d) || tag._p! const dupedTag: HeadTag = deduping[dedupeKey] @@ -29,31 +35,31 @@ export default defineHeadPlugin({ if (dupedTag) { // default strategy is replace, unless we're dealing with a html or body attrs let strategy = tag?.tagDuplicateStrategy - if (!strategy && UsesMergeStrategy.includes(tag.tag)) + if (!strategy && UsesMergeStrategy.has(tag.tag)) strategy = 'merge' if (strategy === 'merge') { const oldProps = dupedTag.props - // apply oldProps to current props - ;['class', 'style'].forEach((key) => { - if (oldProps[key]) { - if (tag.props[key]) { - // ensure style merge doesn't result in invalid css - if (key === 'style' && !oldProps[key].endsWith(';')) - oldProps[key] += ';' - - tag.props[key] = `${oldProps[key]} ${tag.props[key]}` - } - else { - tag.props[key] = oldProps[key] - } + // special handle for styles + if (oldProps.style && tag.props.style) { + if (oldProps.style[oldProps.style.length - 1] !== ';') { + oldProps.style += ';' } - }) + tag.props.style = `${oldProps.style} ${tag.props.style}` + } + // special handle for classes + if (oldProps.class && tag.props.class) { + tag.props.class = `${oldProps.class} ${tag.props.class}` + } + else if (oldProps.class) { + tag.props.class = oldProps.class + } + // apply oldProps to current props deduping[dedupeKey].props = { ...oldProps, ...tag.props, } - return + continue } else if (tag._e === dupedTag._e) { // add the duped tag to the current tag @@ -63,34 +69,37 @@ export default defineHeadPlugin({ tag._d = `${dupedTag._d}:${dupedTag._duped.length + 1}` // @ts-expect-error runtime type dupedTag._duped.push(tag) - return + continue } else if (tagWeight(tag) > tagWeight(dupedTag)) { // check tag weights - return + continue } } - const propCount = Object.keys(tag.props).length + (tag.innerHTML ? 1 : 0) + (tag.textContent ? 1 : 0) + // PERF: compute the number of props keys after static check + const hasProps = tag.innerHTML || tag.textContent || Object.keys(tag.props).length !== 0 // if the new tag does not have any props, we're trying to remove the duped tag from the DOM - if (HasElementTags.includes(tag.tag) && propCount === 0) { + if (!hasProps && HasElementTags.has(tag.tag)) { // find the tag with the same key delete deduping[dedupeKey] - return + continue } // make sure the tag we're replacing has a lower tag weight deduping[dedupeKey] = tag - }) + } const newTags: HeadTag[] = [] - Object.values(deduping).forEach((tag) => { + for (const key in deduping) { + const tag = deduping[key] // @ts-expect-error runtime type const dupes = tag._duped - // @ts-expect-error runtime type - delete tag._duped newTags.push(tag) // add the duped tags to the new tags - if (dupes) + if (dupes) { + // @ts-expect-error runtime type + delete tag._duped newTags.push(...dupes) - }) + } + } ctx.tags = newTags // now filter out invalid meta // TODO separate plugin diff --git a/packages/unhead/src/plugins/eventHandlers.ts b/packages/unhead/src/plugins/eventHandlers.ts index b6966d43..5b8e47f4 100644 --- a/packages/unhead/src/plugins/eventHandlers.ts +++ b/packages/unhead/src/plugins/eventHandlers.ts @@ -1,6 +1,6 @@ import { NetworkEvents, defineHeadPlugin, hashCode } from '@unhead/shared' -const ValidEventTags = ['script', 'link', 'bodyAttrs'] +const ValidEventTags = new Set(['script', 'link', 'bodyAttrs']) /** * Supports DOM event handlers (i.e `onload`) as functions. @@ -9,30 +9,69 @@ const ValidEventTags = ['script', 'link', 'bodyAttrs'] */ export default defineHeadPlugin(head => ({ hooks: { - 'tags:resolve': function (ctx) { - for (const tag of ctx.tags.filter(t => ValidEventTags.includes(t.tag))) { - // must be a valid tag - Object.entries(tag.props) - .forEach(([key, value]) => { - if (key.startsWith('on') && typeof value === 'function') { - // insert a inline script to set the status of onload and onerror - if (head.ssr && NetworkEvents.includes(key)) - tag.props[key] = `this.dataset.${key}fired = true` - - else delete tag.props[key] - tag._eventHandlers = tag._eventHandlers || {} - tag._eventHandlers![key] = value - } - }) - if (head.ssr && tag._eventHandlers && (tag.props.src || tag.props.href)) + 'tags:resolve': (ctx) => { + for (const tag of ctx.tags) { + if (!ValidEventTags.has(tag.tag)) { + continue + } + + const props = tag.props + + for (const key in props) { + // on + if (key[0] !== 'o' || key[1] !== 'n') { + continue + } + + if (!Object.prototype.hasOwnProperty.call(props, key)) { + continue + } + + const value = props[key] + + if (typeof value !== 'function') { + continue + } + + // insert a inline script to set the status of onload and onerror + if (head.ssr && NetworkEvents.has(key)) { + props[key] = `this.dataset.${key}fired = true` + } + else { + delete props[key] + } + + tag._eventHandlers = tag._eventHandlers || {} + tag._eventHandlers![key] = value + } + + if (head.ssr && tag._eventHandlers && (tag.props.src || tag.props.href)) { tag.key = tag.key || hashCode(tag.props.src || tag.props.href) + } } }, - 'dom:renderTag': function ({ $el, tag }) { + 'dom:renderTag': ({ $el, tag }) => { + const dataset = ($el as HTMLScriptElement | undefined)?.dataset + + if (!dataset) { + return + } + // this is only handling SSR rendered tags with event handlers - for (const k of Object.keys($el?.dataset as HTMLScriptElement || {}).filter(k => NetworkEvents.some(e => `${e}fired` === k))) { - const ek = k.replace('fired', '') - tag._eventHandlers?.[ek]?.call($el, new Event(ek.replace('on', ''))) + for (const k in dataset) { + if (!k.endsWith('fired')) { + continue + } + + // onloadfired -> onload + const ek = k.slice(0, -5) + + if (!NetworkEvents.has(ek)) { + continue + } + + // onload -> load + tag._eventHandlers?.[ek]?.call($el, new Event(ek.substring(2))) } }, }, diff --git a/packages/unhead/src/plugins/hashKeyed.ts b/packages/unhead/src/plugins/hashKeyed.ts index 92f1839b..69dbb3c1 100644 --- a/packages/unhead/src/plugins/hashKeyed.ts +++ b/packages/unhead/src/plugins/hashKeyed.ts @@ -1,13 +1,13 @@ import { defineHeadPlugin, hashCode } from '@unhead/shared' -const DupeableTags = ['link', 'style', 'script', 'noscript'] +const DupeableTags = new Set(['link', 'style', 'script', 'noscript']) export default defineHeadPlugin({ hooks: { 'tag:normalise': ({ tag }) => { // only if the user has provided a key // only tags which can't dedupe themselves, ssr only - if (tag.key && DupeableTags.includes(tag.tag)) { + if (tag.key && DupeableTags.has(tag.tag)) { // add a HTML key so the client-side can hydrate without causing duplicates tag.props['data-hid'] = tag._h = hashCode(tag.key!) } diff --git a/packages/unhead/src/plugins/payload.ts b/packages/unhead/src/plugins/payload.ts index 26507503..f212fa08 100644 --- a/packages/unhead/src/plugins/payload.ts +++ b/packages/unhead/src/plugins/payload.ts @@ -3,19 +3,31 @@ import { defineHeadPlugin } from '@unhead/shared' export default defineHeadPlugin({ mode: 'server', hooks: { - 'tags:resolve': function (ctx) { + 'tags:resolve': (ctx) => { const payload: { titleTemplate?: string | ((s: string) => string), templateParams?: Record, title?: string } = {} - ctx.tags.filter(tag => ['titleTemplate', 'templateParams', 'title'].includes(tag.tag) && tag._m === 'server') - .forEach((tag) => { - // @ts-expect-error untyped - payload[tag.tag] = tag.tag.startsWith('title') ? tag.textContent : tag.props + + let hasPayload = false + + for (const tag of ctx.tags) { + if (tag._m !== 'server' || (tag.tag !== 'titleTemplate' && tag.tag !== 'templateParams' && tag.tag !== 'title')) { + continue + } + + // @ts-expect-error untyped + payload[tag.tag] = tag.tag === 'title' || tag.tag === 'titleTemplate' + ? tag.textContent + : tag.props + hasPayload = true + } + + if (hasPayload) { + // add tag for rendering + ctx.tags.push({ + tag: 'script', + innerHTML: JSON.stringify(payload), + props: { id: 'unhead:payload', type: 'application/json' }, }) - // add tag for rendering - Object.keys(payload).length && ctx.tags.push({ - tag: 'script', - innerHTML: JSON.stringify(payload), - props: { id: 'unhead:payload', type: 'application/json' }, - }) + } }, }, }) diff --git a/packages/unhead/src/plugins/sort.ts b/packages/unhead/src/plugins/sort.ts index e75da2e4..b98fc62e 100644 --- a/packages/unhead/src/plugins/sort.ts +++ b/packages/unhead/src/plugins/sort.ts @@ -3,25 +3,44 @@ import { SortModifiers, defineHeadPlugin, tagWeight } from '@unhead/shared' export default defineHeadPlugin({ hooks: { 'tags:resolve': (ctx) => { - const tagPositionForKey = (key: string) => ctx.tags.find(tag => tag._d === key)?._p - // 2a. Sort based on priority // now we need to check render priority for each before: rule and use the dedupe key index - for (const { prefix, offset } of SortModifiers) { - for (const tag of ctx.tags.filter(tag => typeof tag.tagPriority === 'string' && tag.tagPriority!.startsWith(prefix))) { - const position = tagPositionForKey( - (tag.tagPriority as string).replace(prefix, ''), - ) - if (typeof position !== 'undefined') + for (const tag of ctx.tags) { + if (typeof tag.tagPriority !== 'string') { + continue + } + + for (const { prefix, offset } of SortModifiers) { + if (!tag.tagPriority.startsWith(prefix)) { + continue + } + + const key = (tag.tagPriority as string).substring(prefix.length) + + const position = ctx.tags.find(tag => tag._d === key)?._p + + if (position !== undefined) { tag._p = position + offset + break + } } } - ctx.tags - // 2b. sort tags in their natural order - .sort((a, b) => a._p! - b._p!) + ctx.tags.sort((a, b) => { + const aWeight = tagWeight(a) + const bWeight = tagWeight(b) + // 2c. sort based on critical tags - .sort((a, b) => tagWeight(a) - tagWeight(b)) + if (aWeight < bWeight) { + return -1 + } + else if (aWeight > bWeight) { + return 1 + } + + // 2b. sort tags in their natural order + return a._p! - b._p! + }) }, }, }) diff --git a/packages/unhead/src/plugins/templateParams.ts b/packages/unhead/src/plugins/templateParams.ts index 224b68a7..b6aa0d7a 100644 --- a/packages/unhead/src/plugins/templateParams.ts +++ b/packages/unhead/src/plugins/templateParams.ts @@ -7,40 +7,58 @@ const SupportedAttrs = { htmlAttrs: 'lang', } as const +const contentAttrs = ['innerHTML', 'textContent'] + export default defineHeadPlugin(head => ({ hooks: { 'tags:resolve': (ctx) => { const { tags } = ctx - // find templateParams - const title = tags.find(tag => tag.tag === 'title')?.textContent - const idx = tags.findIndex(tag => tag.tag === 'templateParams') + let templateParams: TemplateParams | undefined + for (let i = 0; i < tags.length; i += 1) { + const tag = tags[i] + + if (tag.tag !== 'templateParams') { + continue + } + + templateParams = ctx.tags.splice(i, 1)[0].props + + i -= 1 + } // we always process params so we can substitute the title - const params = idx !== -1 ? tags[idx].props as unknown as TemplateParams : {} + const params = (templateParams || {}) as TemplateParams // ensure a separator exists const sep = params.separator || '|' delete params.separator // pre-process title - params.pageTitle = processTemplateParams(params.pageTitle as string || title || '', params, sep) - for (const tag of tags.filter(t => t.processTemplateParams !== false)) { + params.pageTitle = processTemplateParams( + // find templateParams + params.pageTitle as string || tags.find(tag => tag.tag === 'title')?.textContent || '', + params, + sep, + ) + for (const tag of tags) { + if (tag.processTemplateParams === false) { + continue + } // @ts-expect-error untyped const v = SupportedAttrs[tag.tag] if (v && typeof tag.props[v] === 'string') { tag.props[v] = processTemplateParams(tag.props[v], params, sep) } // everything else requires explicit opt-in - else if (tag.processTemplateParams === true || ['titleTemplate', 'title'].includes(tag.tag)) { - ['innerHTML', 'textContent'].forEach((p) => { + else if (tag.processTemplateParams || tag.tag === 'titleTemplate' || tag.tag === 'title') { + for (const p of contentAttrs) { // @ts-expect-error untyped if (typeof tag[p] === 'string') // @ts-expect-error untyped tag[p] = processTemplateParams(tag[p], params, sep) - }) + } } } // resolved template params head._templateParams = params head._separator = sep - ctx.tags = tags.filter(tag => tag.tag !== 'templateParams') }, }, })) diff --git a/packages/unhead/src/plugins/titleTemplate.ts b/packages/unhead/src/plugins/titleTemplate.ts index 6a84dff5..235111a6 100644 --- a/packages/unhead/src/plugins/titleTemplate.ts +++ b/packages/unhead/src/plugins/titleTemplate.ts @@ -1,41 +1,52 @@ +import type { HeadTag } from '@unhead/schema' import { defineHeadPlugin, resolveTitleTemplate } from '@unhead/shared' export default defineHeadPlugin({ hooks: { 'tags:resolve': (ctx) => { const { tags } = ctx - let titleTemplateIdx = tags.findIndex(i => i.tag === 'titleTemplate') - const titleIdx = tags.findIndex(i => i.tag === 'title') - if (titleIdx !== -1 && titleTemplateIdx !== -1) { + + let titleTag: HeadTag | undefined + let titleTemplateTag: HeadTag | undefined + for (let i = 0; i < tags.length; i += 1) { + const tag = tags[i] + + if (tag.tag === 'title') { + titleTag = tag + } + else if (tag.tag === 'titleTemplate') { + titleTemplateTag = tag + } + } + + if (titleTemplateTag && titleTag) { const newTitle = resolveTitleTemplate( - tags[titleTemplateIdx].textContent!, - tags[titleIdx].textContent, + titleTemplateTag.textContent!, + titleTag.textContent, ) + if (newTitle !== null) { - tags[titleIdx].textContent = newTitle || tags[titleIdx].textContent + titleTag.textContent = newTitle || titleTag.textContent } else { - // remove the title tag - delete tags[titleIdx] + ctx.tags.splice(ctx.tags.indexOf(titleTag), 1) } } - // titleTemplate is set but title is not set, convert to a title - else if (titleTemplateIdx !== -1) { + else if (titleTemplateTag) { const newTitle = resolveTitleTemplate( - tags[titleTemplateIdx].textContent!, + titleTemplateTag.textContent!, ) + if (newTitle !== null) { - tags[titleTemplateIdx].textContent = newTitle - tags[titleTemplateIdx].tag = 'title' - titleTemplateIdx = -1 + titleTemplateTag.textContent = newTitle + titleTemplateTag.tag = 'title' + titleTemplateTag = undefined } } - if (titleTemplateIdx !== -1) { - // remove the titleTemplate tag - delete tags[titleTemplateIdx] - } - ctx.tags = tags.filter(Boolean) + if (titleTemplateTag) { + ctx.tags.splice(ctx.tags.indexOf(titleTemplateTag), 1) + } }, }, }) diff --git a/packages/unhead/src/plugins/xss.ts b/packages/unhead/src/plugins/xss.ts index 57c32600..852b60ef 100644 --- a/packages/unhead/src/plugins/xss.ts +++ b/packages/unhead/src/plugins/xss.ts @@ -2,15 +2,15 @@ import { defineHeadPlugin } from '@unhead/shared' export default defineHeadPlugin({ hooks: { - 'tags:afterResolve': function (ctx) { + 'tags:afterResolve': (ctx) => { for (const tag of ctx.tags) { if (typeof tag.innerHTML === 'string') { - if (tag.innerHTML && ['application/ld+json', 'application/json'].includes(tag.props.type)) + if (tag.innerHTML && (tag.props.type === 'application/ld+json' || tag.props.type === 'application/json')) { // ensure tags get encoded, this is only for JSON, it will break HTML if used - { tag.innerHTML = tag.innerHTML.replace(/ { // copied from https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/vue2-plugin.ts _Vue.mixin({ beforeCreate() { diff --git a/packages/vue/src/VueUseHeadPolyfill.ts b/packages/vue/src/VueUseHeadPolyfill.ts index b10f13a4..d8d223f2 100644 --- a/packages/vue/src/VueUseHeadPolyfill.ts +++ b/packages/vue/src/VueUseHeadPolyfill.ts @@ -44,7 +44,7 @@ export function polyfillAsVueUseHead(head: VueHeadClient polyfilled.addHeadObjs = head.push polyfilled.addReactiveEntry = (input, options) => { const api = useHead(input, options) - if (typeof api !== 'undefined') + if (api !== undefined) return api.dispose return () => {} } diff --git a/packages/vue/src/composables/useServerSeoMeta.ts b/packages/vue/src/composables/useServerSeoMeta.ts index da29f9f6..ec9dfcfc 100644 --- a/packages/vue/src/composables/useServerSeoMeta.ts +++ b/packages/vue/src/composables/useServerSeoMeta.ts @@ -3,5 +3,5 @@ import type { UseHeadOptions, UseSeoMetaInput } from '../types' import { useSeoMeta } from './useSeoMeta' export function useServerSeoMeta(input: UseSeoMetaInput, options?: UseHeadOptions): ActiveHeadEntry | void { - return useSeoMeta(input, { ...(options || {}), mode: 'server' }) + return useSeoMeta(input, { ...options, mode: 'server' }) } diff --git a/packages/vue/src/env.ts b/packages/vue/src/env.ts index edd07752..bb4b6e62 100644 --- a/packages/vue/src/env.ts +++ b/packages/vue/src/env.ts @@ -1,3 +1,3 @@ import { version } from 'vue' -export const Vue3 = version.startsWith('3') +export const Vue3 = version[0] === '3' diff --git a/packages/vue/src/plugins/VueReactivityPlugin.ts b/packages/vue/src/plugins/VueReactivityPlugin.ts index 72736f48..95ecb080 100644 --- a/packages/vue/src/plugins/VueReactivityPlugin.ts +++ b/packages/vue/src/plugins/VueReactivityPlugin.ts @@ -3,7 +3,7 @@ import { resolveUnrefHeadInput } from '../utils' export default defineHeadPlugin({ hooks: { - 'entries:resolve': function (ctx) { + 'entries:resolve': (ctx) => { for (const entry of ctx.entries) entry.resolvedInput = resolveUnrefHeadInput(entry.input) }, diff --git a/packages/vue/src/utils.ts b/packages/vue/src/utils.ts index 2cf508ef..31c2d419 100644 --- a/packages/vue/src/utils.ts +++ b/packages/vue/src/utils.ts @@ -5,7 +5,7 @@ function resolveUnref(r: any) { return typeof r === 'function' ? r() : unref(r) } -export function resolveUnrefHeadInput(ref: any, lastKey: string | number = ''): any { +export function resolveUnrefHeadInput(ref: any): any { // allow promises to bubble through if (ref instanceof Promise) return ref @@ -14,17 +14,27 @@ export function resolveUnrefHeadInput(ref: any, lastKey: string | number = ''): return root if (Array.isArray(root)) - return root.map(r => resolveUnrefHeadInput(r, lastKey)) + return root.map(r => resolveUnrefHeadInput(r)) if (typeof root === 'object') { - return Object.fromEntries( - Object.entries(root).map(([k, v]) => { - // title template and raw dom events should stay functions, we support a ref'd string though - if (k === 'titleTemplate' || k.startsWith('on')) - return [k, unref(v)] - return [k, resolveUnrefHeadInput(v, k)] - }), - ) + const resolved: Record = {} + + for (const k in root) { + if (!Object.prototype.hasOwnProperty.call(root, k)) { + continue + } + + if (k === 'titleTemplate' || (k[0] === 'o' && k[1] === 'n')) { + resolved[k] = unref(root[k]) + + continue + } + + resolved[k] = resolveUnrefHeadInput(root[k]) + } + + return resolved } + return root } diff --git a/packages/vue/src/vue2/index.ts b/packages/vue/src/vue2/index.ts index 6bcf2bb9..91b9f24a 100644 --- a/packages/vue/src/vue2/index.ts +++ b/packages/vue/src/vue2/index.ts @@ -4,7 +4,7 @@ import { headSymbol } from '../createHead' import { useHead } from '../composables/useHead' import { Vue3 } from '../env' -export const UnheadPlugin: Plugin = function (_Vue) { +export const UnheadPlugin: Plugin = (_Vue) => { // copied from https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/vue2-plugin.ts _Vue.mixin({ created() { diff --git a/test/shared/templateParams.bench.ts b/test/shared/templateParams.bench.ts new file mode 100644 index 00000000..7745d5df --- /dev/null +++ b/test/shared/templateParams.bench.ts @@ -0,0 +1,46 @@ +import { bench, describe } from 'vitest' + +import { processTemplateParams } from '@unhead/shared' + +describe('processTemplateParams', () => { + bench('basic', () => { + processTemplateParams('%s %separator %siteName', { + pageTitle: 'hello world', + siteName: 'My Awesome Site', + }, '/') + }) + + bench('nested props', () => { + processTemplateParams('%params.nested %anotherParams.nested', { + pageTitle: 'hello world', + siteName: 'My Awesome Site', + params: { + nested: 'yes', + }, + anotherParams: { + nested: 'another yes', + }, + }, '/') + }) + + bench('not found props', () => { + processTemplateParams('%test %another %name %value', { + pageTitle: 'hello world', + siteName: 'My Awesome Site', + }, '/') + }) + + bench('with url', () => { + processTemplateParams('https://cdn.example.com/some%20image.jpg', { + pageTitle: 'hello world', + siteName: 'My Awesome Site', + }, '/') + }) + + bench('simple string', () => { + processTemplateParams('My Awesome Simple String', { + pageTitle: 'hello world', + siteName: 'My Awesome Site', + }, '/') + }) +})