diff --git a/components/features/navigation/LanguageSelector.vue b/components/features/navigation/LanguageSelector.vue index 55dbba8..2ffd3f9 100644 --- a/components/features/navigation/LanguageSelector.vue +++ b/components/features/navigation/LanguageSelector.vue @@ -1,5 +1,5 @@ @@ -166,17 +161,17 @@ const selectLocale = async (localeCode: string) => { @keydown="onMenuKeydown" class="absolute right-0 mt-2 w-48 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-lg ring-1 ring-black/5 dark:border-[var(--color-border)] dark:bg-[var(--color-surface)]" > - {{ loc.code }} {{ loc.name }} - + @@ -186,15 +181,15 @@ const selectLocale = async (localeCode: string) => { {{ t('navigation.change_language') }} - {{ loc.code }} {{ loc.name }} - + diff --git a/composables/useBlogContent.ts b/composables/useBlogContent.ts index 2a78772..9d4ad76 100644 --- a/composables/useBlogContent.ts +++ b/composables/useBlogContent.ts @@ -3,13 +3,11 @@ import type { PageCollectionItemBase } from '@nuxt/content' import type { LocaleObject } from 'vue-i18n-routing' import type { BlogArticle, - BlogAlternateLanguageLink, BlogAlternateHeader, BlogAuthorProfile } from '~/types/blog' type BlogCollectionKey = 'blog_de' | 'blog_en' -type HeadLink = { rel: string; href: string; hreflang?: string; type?: string } type AuthorCollectionItem = BlogAuthorProfile & PageCollectionItemBase declare module '@nuxt/content' { @@ -46,8 +44,26 @@ const normalizeLocales = (list: unknown[]): LocaleObject[] => ) .map((locale) => locale as LocaleObject) -const resolveHreflang = (locale: LocaleObject, fallback: string): string => - locale.iso || (locale as { _hreflang?: string })._hreflang || locale.code || fallback +// Last non-empty path segment of a (possibly absolute) URL — the blog slug. +const slugFromUrl = (url: string): string | undefined => { + try { + const path = url.includes('://') ? new URL(url).pathname : url + return path.split('/').filter(Boolean).at(-1) + } catch { + return url.split('/').filter(Boolean).at(-1) + } +} + +// Map an hreflang value (e.g. `de`, `de-DE`) back to a configured locale code. +const localeCodeFromHreflang = ( + hreflang: string, + available: LocaleObject[] +): string | undefined => { + const match = available.find( + (l) => l.code === hreflang || l.iso === hreflang || hreflang.split('-')[0] === l.code + ) + return match?.code +} export interface BlogOverviewOptions { /** @@ -124,10 +140,9 @@ export function useBlogOverview(options: BlogOverviewOptions = {}) { * Fetches a single blog article and its translations in other languages. * Uses i18n information together with @nuxt/content. */ -export function useBlogArticle() { +export async function useBlogArticle() { const { locale, locales } = useI18n() const route = useRoute() - const config = useRuntimeConfig() const blogCollection = computed( () => (`blog_${locale?.value || 'de'}`) as BlogCollectionKey ) @@ -135,6 +150,12 @@ export function useBlogArticle() { normalizeLocales((locales.value || []) as unknown[]) ) + // Lets `switchLocalePath`/`` resolve the correct + // localized slug instead of naively reusing the current one — blog posts + // use a different slug per language, so without this the language switcher + // produces a 404. + const setI18nParams = useSetI18nParams() + // Slug derived from catch-all route param; reactive on client navigation const slugSegments = computed(() => { const params = route.params as Record @@ -156,7 +177,7 @@ export function useBlogArticle() { // slugs — that key was empty during setup, so SSR captured an empty // authors list and the rendered author cards and Article JSON-LD shipped // without any author data. - const { data: payload } = useAsyncData<{ + const { data: payload } = await useAsyncData<{ article: BlogArticle | null authors: BlogAuthorProfile[] } | null>( @@ -168,12 +189,14 @@ export function useBlogArticle() { .where('slug', '=', slug.value) .first()) as BlogArticle | null - if (doc && !isReleased(doc)) { - throw createError({ statusCode: 404, statusMessage: 'Article not released' }) + // A missing or not-yet-released article is a 404. Returning a marker + // (instead of throwing here) lets us raise a single fatal error after + // the data resolves, which reliably renders the error page with the + // correct HTTP status during SSR. + if (!doc || !isReleased(doc)) { + return { article: null, authors: [] } } - if (!doc) return { article: null, authors: [] } - const slugs = (Array.isArray(doc.author) ? doc.author : [doc.author]) .filter(Boolean) .map((s) => String(s)) @@ -196,6 +219,12 @@ export function useBlogArticle() { { watch: [locale, slug] } ) + // A requested slug that resolves to no (released) article must surface a + // real 404 page instead of silently rendering an empty article. + if (slug.value && !payload.value?.article) { + throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true }) + } + const article = computed(() => payload.value?.article || null) const authors = computed(() => payload.value?.authors || []) @@ -204,110 +233,58 @@ export function useBlogArticle() { return { ...(article.value as BlogArticle), authors: authors.value || [] } }) - const alternateLanguages = ref([]) - - // Helper to resolve a base URL - const resolveBaseUrl = (): string => { - const pub = config.public as { siteUrl?: string; baseUrl?: string } - return pub.siteUrl || pub.baseUrl || 'https://onelitefeather.net' + // Publish the per-locale slugs into Nuxt i18n. This is what makes + // switchLocalePath / AND the i18n-generated + // canonical + hreflang tags (see layouts/default.vue) resolve to the + // correctly translated article URL instead of reusing the current + // language's slug. + const publishLocaleParams = (localeSlugs: Record) => { + const params: Record = {} + for (const [code, value] of Object.entries(localeSlugs)) { + if (value) params[code] = { slug: [value] } + } + if (Object.keys(params).length) setI18nParams(params) } - // Rebuild alternates whenever article/locale changes + // Resolve the translated slug for every locale whenever article/locale + // changes, from front-matter `alternates` when present, else by matching + // `translationKey` across the localized collections. watch([blog, locale, locales], async () => { - alternateLanguages.value = [] if (!blog.value) return + const localeSlugs: Record = {} + if (blog.value.slug) localeSlugs[locale.value] = blog.value.slug + if (blog.value.alternates && Array.isArray(blog.value.alternates)) { for (const alt of blog.value.alternates as BlogAlternateHeader[]) { if (!alt?.hreflang || !alt?.href) continue - alternateLanguages.value.push({ locale: alt.hreflang, url: alt.href }) + const code = localeCodeFromHreflang(alt.hreflang, availableLocales.value) + const altSlug = slugFromUrl(alt.href) + if (code && altSlug) localeSlugs[code] = altSlug } + publishLocaleParams(localeSlugs) return } - if (!blog.value.translationKey) return + if (!blog.value.translationKey) { + publishLocaleParams(localeSlugs) + return + } const otherLocales = availableLocales.value.filter((l) => l.code !== locale.value) - - const baseUrl = resolveBaseUrl() for (const otherLocale of otherLocales) { const translated = await queryCollection(`blog_${otherLocale.code}` as BlogCollectionKey) .where('translationKey', '=', blog.value?.translationKey) .first() - - if (translated) { - const hreflangValue = resolveHreflang(otherLocale, otherLocale.code) - - alternateLanguages.value.push({ - locale: hreflangValue, - url: `${baseUrl}/${otherLocale.code}/blog/${(translated as BlogArticle).slug}` - }) - } - } - }, { immediate: true }) - - // Canonical + hreflang Informationen für den Head - const headLinks = computed(() => { - const links: HeadLink[] = [] - // add favicon - links.push({ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }) - - if (!blog.value) return links - - const baseUrl = resolveBaseUrl() - - const currentLocaleObj = - availableLocales.value.find((l) => l.code === locale.value) || - ({ code: locale.value } as LocaleObject) - const currentHreflang = resolveHreflang(currentLocaleObj, locale.value) - - // Prefer explicit canonical from front-matter, else compute fallback - const canonicalUrl = - blog.value.canonical || `${baseUrl}/${locale.value}/blog/${blog.value.slug}` - - links.push({ rel: 'canonical', href: canonicalUrl }) - - // Build a de-dup set for alternates - const seen = new Set() - const pushAlt = (hreflang: string, href: string) => { - const key = `${hreflang}::${href}` - if (seen.has(key)) return - seen.add(key) - links.push({ rel: 'alternate', hreflang, href }) + const translatedSlug = (translated as BlogArticle | null)?.slug + if (translatedSlug) localeSlugs[otherLocale.code] = translatedSlug } - if (blog.value.alternates && Array.isArray(blog.value.alternates)) { - for (const alt of blog.value.alternates as BlogAlternateHeader[]) { - if (!alt?.hreflang || !alt?.href) continue - pushAlt(alt.hreflang, alt.href) - } - // Ensure current locale is present - const hasCurrent = [...seen].some((k) => k.startsWith(`${currentHreflang}::`)) - if (!hasCurrent) pushAlt(currentHreflang, canonicalUrl) - } else { - // Fallback: generate alternates programmatically from computed ref - pushAlt(currentHreflang, canonicalUrl) - for (const alt of alternateLanguages.value) pushAlt(alt.locale, alt.url) - - const defaultLocale = 'en' - if (locale.value === defaultLocale) { - pushAlt('x-default', canonicalUrl) - } else { - const defaultLocaleUrl = - alternateLanguages.value.find( - (alt) => alt.locale?.startsWith('en') || alt.locale === 'en' - )?.url || `${baseUrl}/${defaultLocale}/blog/${blog.value.slug}` - pushAlt('x-default', defaultLocaleUrl) - } - } - - return links - }) + publishLocaleParams(localeSlugs) + }, { immediate: true }) return { blog, - authors, - alternateLanguages, - headLinks + authors } } diff --git a/composables/usePageSeo.ts b/composables/usePageSeo.ts index 33d4a27..0b0a05e 100644 --- a/composables/usePageSeo.ts +++ b/composables/usePageSeo.ts @@ -18,51 +18,43 @@ const buildRobots = (opts: PageSeoOptions): string | undefined => { } export function usePageSeo(opts: PageSeoOptions = {}) { - const { locale, locales, t } = useI18n() + const { locale, t } = useI18n() const route = useRoute() const site = useSiteConfig() const switchLocalePath = useSwitchLocalePath() const img = useImage() const runtime = useRuntimeConfig() + // Absolute, query/hash-free URL. `switchLocalePath` preserves the current + // request's query string, so without stripping it every tracking param + // (utm/fbclid/posthog…) would spawn a distinct canonical + hreflang set + // and Google would report duplicates / pick its own canonical. + const cleanUrl = (path: string): string => { + const u = new URL(path || '/', site.url) + u.search = '' + u.hash = '' + return u.toString() + } + + // Canonical must be byte-identical to this locale's self-referencing + // hreflang entry below, otherwise the two become conflicting signals. const canonicalUrl = computed(() => { - if (opts.canonical) return new URL(opts.canonical, site.url).toString() - return new URL(route.fullPath || '/', site.url).toString() + if (opts.canonical) return cleanUrl(new URL(opts.canonical, site.url).toString()) + return cleanUrl(switchLocalePath(locale.value) || route.path || '/') }) - // Build hreflang alternate links for all configured locales + x-default - const alternateLinks = computed(() => { - const items: Array<{ rel: 'alternate'; hreflang: string; href: string }> = [] - const all = Array.isArray(locales.value) ? locales.value : [] - for (const l of all as Array) { - const code = typeof l === 'string' ? l : l.code - const iso = typeof l === 'string' ? l : (l.iso || l.code) - const path = switchLocalePath(code) || '/' - const href = new URL(path, site.url).toString() - items.push({ rel: 'alternate', hreflang: iso, href }) - } - // x-default points to the default locale URL (defaultLocale in nuxt.config.ts) - const defaultPath = switchLocalePath(DEFAULT_LOCALE) || '/' - const defaultHref = new URL(defaultPath, site.url).toString() - items.push({ rel: 'alternate', hreflang: 'x-default', href: defaultHref }) - return items - }) const robotsDirective = computed(() => buildRobots(opts)) const keywordsMeta = computed(() => normaliseKeywords(opts.keywords)) + // Canonical + hreflang are emitted once, app-wide, by @nuxtjs/i18n + // (see layouts/default.vue). Here we only add page-specific robots / + // keywords meta to avoid duplicate or conflicting link tags. useHead(() => { const meta: Array<{ name: string; content: string }> = [] if (robotsDirective.value) meta.push({ name: 'robots', content: robotsDirective.value }) if (keywordsMeta.value) meta.push({ name: 'keywords', content: keywordsMeta.value }) - return { - link: [ - { rel: 'canonical', href: canonicalUrl.value }, - { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, - ...alternateLinks.value - ], - meta - } + return { meta } }) // Social preview image for OG/Twitter @@ -78,22 +70,6 @@ export function usePageSeo(opts: PageSeoOptions = {}) { }) // Resolve og:locale from the current locale code - const currentLocaleObj = computed(() => { - const all = Array.isArray(locales.value) ? locales.value : [] - return (all as Array).find(l => (typeof l === 'string' ? l : l.code) === locale.value) - }) - const ogLocale = computed(() => { - const l = currentLocaleObj.value - if (!l) return locale.value - return typeof l === 'string' ? l : (l.iso || l.code) - }) - const ogLocaleAlternate = computed(() => { - const all = Array.isArray(locales.value) ? locales.value : [] - return (all as Array) - .filter(l => (typeof l === 'string' ? l : l.code) !== locale.value) - .map(l => (typeof l === 'string' ? l : (l.iso || l.code))) - }) - type SocialHandles = { twitterSite?: string; twitterCreator?: string } const socialHandles = (runtime.public as { social?: SocialHandles }).social const twitterSite = opts.twitterSite || socialHandles?.twitterSite || undefined @@ -112,8 +88,7 @@ export function usePageSeo(opts: PageSeoOptions = {}) { ogImageHeight: opts.imageHeight || (socialImage.value ? 630 : undefined), ogImageType: opts.imageType || (socialImage.value ? 'image/webp' : undefined), ogSiteName: site.name, - ogLocale: ogLocale, - ogLocaleAlternate: ogLocaleAlternate, + // og:locale + og:locale:alternate are emitted by @nuxtjs/i18n. twitterCard: opts.twitterCard || 'summary_large_image', twitterTitle: pageTitle, twitterDescription: pageDescription, diff --git a/error.vue b/error.vue new file mode 100644 index 0000000..92ca6f0 --- /dev/null +++ b/error.vue @@ -0,0 +1,40 @@ + + + diff --git a/i18n/locales/de.json b/i18n/locales/de.json index 389385b..2c10048 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -176,5 +176,11 @@ "share_native": "Teilen…", "copy_link": "Link kopieren", "copied": "Link kopiert" + }, + "error": { + "title_404": "Seite nicht gefunden", + "message_404": "Die gesuchte Seite existiert nicht oder wurde möglicherweise an eine neue Adresse verschoben.", + "title_generic": "Etwas ist schiefgelaufen", + "message_generic": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es gleich noch einmal." } } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 95a2321..5eae743 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -176,5 +176,11 @@ "share_native": "Share…", "copy_link": "Copy link", "copied": "Link copied" + }, + "error": { + "title_404": "Page not found", + "message_404": "The page you are looking for doesn’t exist or may have moved to a new address.", + "title_generic": "Something went wrong", + "message_generic": "An unexpected error occurred. Please try again in a moment." } } diff --git a/layouts/default.vue b/layouts/default.vue index 31cb596..1df2be7 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -1,10 +1,21 @@