Files
blog/frontend/app/composables/useSeo.ts
T
davide 33b49c5dbb Add hand-rolled English support (/en/*) alongside the Italian site
PocketBase gains optional, manually-authored English fields on articles
and categories (same record, same slug), and the frontend serves an /en
counterpart of every dynamic route via thin page wrappers around shared
view components — no i18n library, consistent with the project's existing
minimalism stance for a two-locale site with ~20 UI strings.

An article/category with no translation 404s cleanly on its own /en
detail page and is filtered out of /en listings, and the header's
language-switch link falls back to the English blog index rather than a
dead link; resolving that requires a global route middleware, since the
layout's header renders before the page content in document order and so
can't react to state a page component sets during its own async setup.
2026-09-11 13:53:17 +02:00

62 lines
2.1 KiB
TypeScript

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<string, unknown>
}
/**
* 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,
})
}