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 @@
+
+
+
+
+
+
+ {{ error?.statusCode || 500 }}
+
+
+ {{ title }}
+
+
+ {{ message }}
+
+
+
+
+
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 @@