import type { Locale } from '#shared/utils/locale' interface SeoOptions { locale: Locale /** The bare, unprefixed path, e.g. `/blog/mio-slug` — never `/en/...`. */ path: string title: string description: string type?: 'website' | 'article' image?: string /** False when no translation exists at all for this content — omits the alternate link. */ hasAlternate?: boolean jsonLd?: Record } /** * Centralises canonical/hreflang/OG/JSON-LD head tags so IT and EN pages for * the same content always agree on each other's URL — duplicating this per * page, doubled for two locales, is exactly how hreflang correctness drifts. */ export function useSeo(options: SeoOptions) { const { public: config } = useRuntimeConfig() const bare = options.path === '/' ? '' : options.path const itUrl = `${config.siteUrl}${bare}` const enUrl = `${config.siteUrl}/en${bare}` const canonical = options.locale === 'en' ? enUrl : itUrl useSeoMeta({ title: options.title, description: options.description, ogTitle: options.title, ogDescription: options.description, ogType: options.type ?? 'website', ogUrl: canonical, ogImage: options.image, twitterCard: options.image ? 'summary_large_image' : undefined, }) const link: Array<{ rel: 'canonical' | 'alternate'; href: string; hreflang?: string }> = [ { rel: 'canonical', href: canonical }, ] if (options.hasAlternate !== false) { link.push( { rel: 'alternate', hreflang: 'it', href: itUrl }, { rel: 'alternate', hreflang: 'en', href: enUrl }, { rel: 'alternate', hreflang: 'x-default', href: itUrl } ) } useHead({ htmlAttrs: { lang: options.locale }, // unhead's Link type narrows required keys per literal `rel` value in a // way a plain canonical/alternate/hreflang link doesn't fit — the shape // itself is valid HTML, so this is a type-only escape hatch. // eslint-disable-next-line @typescript-eslint/no-explicit-any link: link as any, script: options.jsonLd ? [{ type: 'application/ld+json', innerHTML: JSON.stringify(options.jsonLd) }] : undefined, }) }