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.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user