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.
62 lines
2.1 KiB
TypeScript
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,
|
|
})
|
|
}
|