import { createHooks } from 'hookable'; import { DomPlugin } from '@unhead/dom'; import { defineHeadPlugin, tagDedupeKey, tagWeight, HasElementTags, hashCode, NetworkEvents, SortModifiers, processTemplateParams, resolveTitleTemplate, IsBrowser, normaliseEntryTags, composableNames, whitelistSafeInput, unpackMeta } from '@unhead/shared'; export { composableNames } from '@unhead/shared'; const UsesMergeStrategy = ["templateParams", "htmlAttrs", "bodyAttrs"]; const DedupePlugin = defineHeadPlugin({ hooks: { "tag:normalise": function({ tag }) { ["hid", "vmid", "key"].forEach((key) => { 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) { const deduping = {}; ctx.tags.forEach((tag) => { const dedupeKey = (tag.key ? `${tag.tag}:${tag.key}` : tag._d) || tag._p; const dupedTag = deduping[dedupeKey]; if (dupedTag) { let strategy = tag?.tagDuplicateStrategy; if (!strategy && UsesMergeStrategy.includes(tag.tag)) strategy = "merge"; if (strategy === "merge") { const oldProps = dupedTag.props; ["class", "style"].forEach((key) => { if (tag.props[key] && oldProps[key]) { if (key === "style" && !oldProps[key].endsWith(";")) oldProps[key] += ";"; tag.props[key] = `${oldProps[key]} ${tag.props[key]}`; } }); deduping[dedupeKey].props = { ...oldProps, ...tag.props }; return; } else if (tag._e === dupedTag._e) { dupedTag._duped = dupedTag._duped || []; tag._d = `${dupedTag._d}:${dupedTag._duped.length + 1}`; dupedTag._duped.push(tag); return; } else if (tagWeight(tag) > tagWeight(dupedTag)) { return; } } const propCount = Object.keys(tag.props).length + (tag.innerHTML ? 1 : 0) + (tag.textContent ? 1 : 0); if (HasElementTags.includes(tag.tag) && propCount === 0) { delete deduping[dedupeKey]; return; } deduping[dedupeKey] = tag; }); const newTags = []; Object.values(deduping).forEach((tag) => { const dupes = tag._duped; delete tag._duped; newTags.push(tag); if (dupes) newTags.push(...dupes); }); ctx.tags = newTags; ctx.tags = ctx.tags.filter((t) => !(t.tag === "meta" && (t.props.name || t.props.property) && !t.props.content)); } } }); const PayloadPlugin = defineHeadPlugin({ mode: "server", hooks: { "tags:resolve": function(ctx) { const payload = {}; ctx.tags.filter((tag) => ["titleTemplate", "templateParams", "title"].includes(tag.tag) && tag._m === "server").forEach((tag) => { payload[tag.tag] = tag.tag.startsWith("title") ? tag.textContent : tag.props; }); Object.keys(payload).length && ctx.tags.push({ tag: "script", innerHTML: JSON.stringify(payload), props: { id: "unhead:payload", type: "application/json" } }); } } }); const ValidEventTags = ["script", "link", "bodyAttrs"]; function stripEventHandlers(tag) { const props = {}; const eventHandlers = {}; Object.entries(tag.props).forEach(([key, value]) => { if (key.startsWith("on") && typeof value === "function") { if (NetworkEvents.includes(key)) props[key] = `this.dataset.${key} = true`; eventHandlers[key] = value; } else { props[key] = value; } }); return { props, eventHandlers }; } const EventHandlersPlugin = defineHeadPlugin((head) => ({ hooks: { "tags:resolve": function(ctx) { for (const tag of ctx.tags) { if (ValidEventTags.includes(tag.tag)) { const { props, eventHandlers } = stripEventHandlers(tag); tag.props = props; if (Object.keys(eventHandlers).length) { if (tag.props.src || tag.props.href) tag.key = tag.key || hashCode(tag.props.src || tag.props.href); tag._eventHandlers = eventHandlers; } } } }, "dom:renderTag": function(ctx, dom, track) { if (!ctx.tag._eventHandlers) return; const $eventListenerTarget = ctx.tag.tag === "bodyAttrs" ? dom.defaultView : ctx.$el; Object.entries(ctx.tag._eventHandlers).forEach(([k, value]) => { const sdeKey = `${ctx.tag._d || ctx.tag._p}:${k}`; const eventName = k.slice(2).toLowerCase(); const eventDedupeKey = `data-h-${eventName}`; track(ctx.id, sdeKey, () => { }); if (ctx.$el.hasAttribute(eventDedupeKey)) return; ctx.$el.setAttribute(eventDedupeKey, ""); let observer; const handler = (e) => { value(e); observer?.disconnect(); }; if (k in ctx.$el.dataset) { handler(new Event(k.replace("on", ""))); } else if (NetworkEvents.includes(k) && typeof MutationObserver !== "undefined") { observer = new MutationObserver((e) => { const hasAttr = e.some((m) => m.attributeName === `data-${k}`); if (hasAttr) { handler(new Event(k.replace("on", ""))); observer?.disconnect(); } }); observer.observe(ctx.$el, { attributes: true }); } else { $eventListenerTarget.addEventListener(eventName, handler); } track(ctx.id, sdeKey, () => { observer?.disconnect(); $eventListenerTarget.removeEventListener(eventName, handler); ctx.$el.removeAttribute(eventDedupeKey); }); }); } } })); const DupeableTags = ["link", "style", "script", "noscript"]; const HashKeyedPlugin = defineHeadPlugin({ hooks: { "tag:normalise": ({ tag }) => { if (tag.key && DupeableTags.includes(tag.tag)) { tag.props["data-hid"] = tag._h = hashCode(tag.key); } } } }); const SortPlugin = defineHeadPlugin({ hooks: { "tags:resolve": (ctx) => { const tagPositionForKey = (key) => ctx.tags.find((tag) => tag._d === key)?._p; for (const { prefix, offset } of SortModifiers) { for (const tag of ctx.tags.filter((tag2) => typeof tag2.tagPriority === "string" && tag2.tagPriority.startsWith(prefix))) { const position = tagPositionForKey( tag.tagPriority.replace(prefix, "") ); if (typeof position !== "undefined") tag._p = position + offset; } } ctx.tags.sort((a, b) => a._p - b._p).sort((a, b) => tagWeight(a) - tagWeight(b)); } } }); const SupportedAttrs = { meta: "content", link: "href", htmlAttrs: "lang" }; const TemplateParamsPlugin = defineHeadPlugin((head) => ({ hooks: { "tags:resolve": (ctx) => { const { tags } = ctx; const title = tags.find((tag) => tag.tag === "title")?.textContent; const idx = tags.findIndex((tag) => tag.tag === "templateParams"); const params = idx !== -1 ? tags[idx].props : {}; const sep = params.separator || "|"; delete params.separator; params.pageTitle = processTemplateParams(params.pageTitle || title || "", params, sep); for (const tag of tags.filter((t) => t.processTemplateParams !== false)) { const v = SupportedAttrs[tag.tag]; if (v && typeof tag.props[v] === "string") { tag.props[v] = processTemplateParams(tag.props[v], params, sep); } else if (tag.processTemplateParams === true || ["titleTemplate", "title"].includes(tag.tag)) { ["innerHTML", "textContent"].forEach((p) => { if (typeof tag[p] === "string") tag[p] = processTemplateParams(tag[p], params, sep); }); } } head._templateParams = params; head._separator = sep; ctx.tags = tags.filter((tag) => tag.tag !== "templateParams"); } } })); const TitleTemplatePlugin = 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) { const newTitle = resolveTitleTemplate( tags[titleTemplateIdx].textContent, tags[titleIdx].textContent ); if (newTitle !== null) { tags[titleIdx].textContent = newTitle || tags[titleIdx].textContent; } else { delete tags[titleIdx]; } } else if (titleTemplateIdx !== -1) { const newTitle = resolveTitleTemplate( tags[titleTemplateIdx].textContent ); if (newTitle !== null) { tags[titleTemplateIdx].textContent = newTitle; tags[titleTemplateIdx].tag = "title"; titleTemplateIdx = -1; } } if (titleTemplateIdx !== -1) { delete tags[titleTemplateIdx]; } ctx.tags = tags.filter(Boolean); } } }); const XSSPlugin = defineHeadPlugin({ hooks: { "tags:afterResolve": function(ctx) { for (const tag of ctx.tags) { if (typeof tag.innerHTML === "string") { if (tag.innerHTML && ["application/ld+json", "application/json"].includes(tag.props.type)) { tag.innerHTML = tag.innerHTML.replace(/ { head.dirty = true; hooks.callHook("entries:updated", head); }; let entryCount = 0; let entries = []; const plugins = []; const head = { plugins, dirty: false, resolvedOptions: options, hooks, headEntries() { return entries; }, use(p) { const plugin = typeof p === "function" ? p(head) : p; if (!plugin.key || !plugins.some((p2) => p2.key === plugin.key)) { plugins.push(plugin); filterMode(plugin.mode, ssr) && hooks.addHooks(plugin.hooks || {}); } }, push(input, entryOptions) { delete entryOptions?.head; const entry = { _i: entryCount++, input, ...entryOptions }; if (filterMode(entry.mode, ssr)) { entries.push(entry); updated(); } return { dispose() { entries = entries.filter((e) => e._i !== entry._i); hooks.callHook("entries:updated", head); updated(); }, // a patch is the same as creating a new entry, just a nice DX patch(input2) { entries = entries.map((e) => { if (e._i === entry._i) { e.input = entry.input = input2; } return e; }); updated(); } }; }, async resolveTags() { const resolveCtx = { tags: [], entries: [...entries] }; await hooks.callHook("entries:resolve", resolveCtx); for (const entry of resolveCtx.entries) { const resolved = entry.resolvedInput || entry.input; entry.resolvedInput = await (entry.transform ? entry.transform(resolved) : resolved); if (entry.resolvedInput) { for (const tag of await normaliseEntryTags(entry)) { const tagCtx = { tag, entry, resolvedOptions: head.resolvedOptions }; await hooks.callHook("tag:normalise", tagCtx); resolveCtx.tags.push(tagCtx.tag); } } } await hooks.callHook("tags:beforeResolve", resolveCtx); await hooks.callHook("tags:resolve", resolveCtx); await hooks.callHook("tags:afterResolve", resolveCtx); return resolveCtx.tags; }, ssr }; [ DedupePlugin, PayloadPlugin, EventHandlersPlugin, HashKeyedPlugin, SortPlugin, TemplateParamsPlugin, TitleTemplatePlugin, XSSPlugin, ...options?.plugins || [] ].forEach((p) => head.use(p)); head.hooks.callHook("init", head); return head; } // @__NO_SIDE_EFFECTS__ function HashHydrationPlugin() { return defineHeadPlugin({}); } const importRe = /@import/; // @__NO_SIDE_EFFECTS__ function CapoPlugin(options) { return defineHeadPlugin({ hooks: { "tags:beforeResolve": function({ tags }) { for (const tag of tags) { if (tag.tagPosition && tag.tagPosition !== "head") continue; tag.tagPriority = tag.tagPriority || tagWeight(tag); if (tag.tagPriority !== 100) continue; const isTruthy = (val) => val === "" || val === true; const isScript = tag.tag === "script"; const isLink = tag.tag === "link"; if (isScript && isTruthy(tag.props.async)) { tag.tagPriority = 30; } else if (tag.tag === "style" && tag.innerHTML && importRe.test(tag.innerHTML)) { tag.tagPriority = 40; } else if (isScript && tag.props.src && !isTruthy(tag.props.defer) && !isTruthy(tag.props.async) && tag.props.type !== "module" && !tag.props.type?.endsWith("json")) { tag.tagPriority = 50; } else if (isLink && tag.props.rel === "stylesheet" || tag.tag === "style") { tag.tagPriority = 60; } else if (isLink && ["preload", "modulepreload"].includes(tag.props.rel)) { tag.tagPriority = 70; } else if (isScript && isTruthy(tag.props.defer) && tag.props.src && !isTruthy(tag.props.async)) { tag.tagPriority = 80; } else if (isLink && ["prefetch", "dns-prefetch", "prerender"].includes(tag.props.rel)) { tag.tagPriority = 90; } } options?.track && tags.push({ tag: "htmlAttrs", props: { "data-capo": "" } }); } } }); } const unheadComposablesImports = [ { from: "unhead", imports: composableNames } ]; function getActiveHead() { return activeHead; } function useHead(input, options = {}) { const head = options.head || getActiveHead(); return head?.push(input, options); } function useHeadSafe(input, options = {}) { return useHead(input, { ...options || {}, transform: whitelistSafeInput }); } function useServerHead(input, options = {}) { return useHead(input, { ...options, mode: "server" }); } function useServerHeadSafe(input, options = {}) { return useHeadSafe(input, { ...options, mode: "server" }); } function useSeoMeta(input, options) { const { title, titleTemplate, ...meta } = input; return useHead({ title, titleTemplate, // we need to input the meta so the reactivity will be resolved // @ts-expect-error runtime type _flatMeta: meta }, { ...options, transform(t) { const meta2 = unpackMeta({ ...t._flatMeta }); delete t._flatMeta; return { // @ts-expect-error runtime type ...t, meta: meta2 }; } }); } function useServerSeoMeta(input, options) { return useSeoMeta(input, { ...options || {}, mode: "server" }); } const UseScriptDefaults = { defer: true, fetchpriority: "low" }; function useScript(input, _options) { const options = _options || {}; const head = options.head || getActiveHead(); if (!head) throw new Error("No active head found, please provide a head instance or use the useHead composable"); const id = input.key || hashCode(input.src || (typeof input.innerHTML === "string" ? input.innerHTML : "")); const key = `use-script.${id}`; if (head._scripts?.[id]) return head._scripts[id]; async function transform(entry) { const script2 = await (options.transform || ((input2) => input2))(entry.script[0]); const ctx = { script: script2 }; await head.hooks.callHook("script:transform", ctx); return { script: [ctx.script] }; } function maybeHintEarlyConnection(rel) { if ( // opt-out options.skipEarlyConnections || !input.src.includes("//") || !head.ssr ) return; const key2 = `use-script.${id}.early-connection`; head.push({ link: [{ key: key2, rel, href: new URL(input.src).origin }] }, { mode: "server" }); } const script = { id, status: "awaitingLoad", loaded: false, remove() { if (script.status === "loaded") { script.entry?.dispose(); script.status = "removed"; head.hooks.callHook(`script:updated`, hookCtx); delete head._scripts?.[id]; return true; } return false; }, waitForLoad() { return new Promise((resolve) => { if (script.status === "loaded") resolve(options.use()); function watchForScriptLoaded({ script: script2 }) { if (script2.id === id && script2.status === "loaded") { resolve(options.use?.()); head.hooks.removeHook("script:updated", watchForScriptLoaded); } } head.hooks.hook("script:updated", watchForScriptLoaded); }); }, load() { if (script.status !== "awaitingLoad") return script.waitForLoad(); script.status = "loading"; head.hooks.callHook(`script:updated`, hookCtx); script.entry = head.push({ script: [ // async by default { ...UseScriptDefaults, ...input, key } ] }, { ...options, // @ts-expect-error untyped transform, head }); return script.waitForLoad(); } }; const hookCtx = { script }; NetworkEvents.forEach((fn) => { const _fn = typeof input[fn] === "function" ? input[fn].bind({}) : null; input[fn] = (e) => { script.status = fn === "onload" ? "loaded" : fn === "onerror" ? "error" : "loading"; head.hooks.callHook(`script:updated`, hookCtx); _fn && _fn(e); }; }); let trigger = options.trigger; if (trigger) { const isIdle = trigger === "idle"; if (isIdle) { if (head.ssr) trigger = "manual"; else trigger = new Promise((resolve) => requestIdleCallback(() => resolve())); } trigger === "manual" && (trigger = new Promise(() => { })); trigger instanceof Promise && trigger.then(script.load); maybeHintEarlyConnection(isIdle ? "preconnect" : "dns-prefetch"); } else { script.load(); maybeHintEarlyConnection("preconnect"); } function resolveInnerHtmlLoad(ctx) { if (ctx.tag.key === key) { if (ctx.tag.innerHTML) { setTimeout( () => { script.status = "loaded"; head.hooks.callHook("script:updated", hookCtx); typeof input.onload === "function" && input.onload(new Event("load")); }, 5 /* give inline script a chance to run */ ); } head.hooks.removeHook("dom:renderTag", resolveInnerHtmlLoad); } } head.hooks.hook("dom:renderTag", resolveInnerHtmlLoad); const instance = new Proxy({}, { get(_, fn) { const stub = options.stub?.({ script, fn }); if (stub) return stub; if (fn === "$script") return script; return (...args) => { if (head.ssr || !options.use) return; if (script.loaded) { const api = options.use(); return api[fn](...args); } else { return script.waitForLoad().then( (api) => { api[fn](...args); } ); } }; } }); head._scripts = head._scripts || {}; head._scripts[id] = instance; return instance; } export { CapoPlugin, HashHydrationPlugin, createHead, createHeadCore, createServerHead, getActiveHead, unheadComposablesImports, useHead, useHeadSafe, useScript, useSeoMeta, useServerHead, useServerHeadSafe, useServerSeoMeta };