Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 15 additions & 20 deletions components/features/navigation/LanguageSelector.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, nextTick, navigateTo, onMounted, onBeforeUnmount } from '#imports';
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from '#imports';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faLanguage, faChevronDown } from '@fortawesome/free-solid-svg-icons'
const { t } = useI18n();
Expand All @@ -9,8 +9,7 @@ const props = defineProps<{
}>();

const variant = props.variant ?? 'desktop';
const { locale, locales, setLocale } = useI18n();
const switchLocalePath = useSwitchLocalePath();
const { locale, locales } = useI18n();
const isOpen = ref(false);
const buttonRef = ref<HTMLButtonElement | null>(null);
const menuRef = ref<HTMLElement | null>(null);
Expand Down Expand Up @@ -116,16 +115,12 @@ onBeforeUnmount(() => {
// Inform parent (e.g., mobile overlay) when a language was selected
const emit = defineEmits<{ (e: 'selected', locale: string): void }>();

const selectLocale = async (localeCode: string) => {
// Sprache setzen und dann ausdrücklich zur sprachspezifischen Route navigieren,
// damit die URL korrekt aktualisiert wird und serverseitige Navigation greift.
await setLocale(localeCode as 'de' | 'en');
const targetPath = switchLocalePath(localeCode);
if (targetPath) {
await navigateTo(targetPath);
}
// Navigation is handled by <SwitchLocalePathLink>, which resolves the
// correct localized path (including translated blog slugs via
// useSetI18nParams). We only handle the UI side-effects here.
const onSelect = (localeCode: string) => {
emit('selected', localeCode);
closeDropdown(true);
closeDropdown(false);
};
</script>

Expand Down Expand Up @@ -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)]"
>
<NuxtLink
<SwitchLocalePathLink
v-for="loc in availableLocales"
:key="loc.code"
:to="switchLocalePath(loc.code)"
:locale="loc.code"
role="menuitem"
class="flex items-center gap-3 px-4 py-2 text-sm font-medium text-[var(--color-text)]/70 transition-colors hover:bg-[var(--color-secondary)]/10 dark:text-[var(--color-text)]/85 dark:hover:bg-[var(--color-secondary)]/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-secondary)]"
@click.prevent="selectLocale(loc.code)"
@click="onSelect(loc.code)"
>
<span class="uppercase font-semibold text-[var(--color-secondary)]">{{ loc.code }}</span>
<span>{{ loc.name }}</span>
</NuxtLink>
</SwitchLocalePathLink>
</div>
</Transition>
</div>
Expand All @@ -186,15 +181,15 @@ const selectLocale = async (localeCode: string) => {
<FontAwesomeIcon :icon="faLanguage" class="text-xl" />
{{ t('navigation.change_language') }}
</div>
<NuxtLink
<SwitchLocalePathLink
v-for="loc in availableLocales"
:key="loc.code"
:to="switchLocalePath(loc.code)"
:locale="loc.code"
class="ml-4 flex items-center gap-3 rounded-xl px-6 py-2 text-sm font-medium text-[var(--color-text)]/70 transition-colors hover:bg-[var(--color-secondary)]/10 dark:text-[var(--color-text)]/85 dark:hover:bg-[var(--color-secondary)]/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-secondary)]"
@click.prevent="selectLocale(loc.code)"
@click="onSelect(loc.code)"
>
<span class="uppercase font-semibold text-[var(--color-secondary)]">{{ loc.code }}</span>
<span>{{ loc.name }}</span>
</NuxtLink>
</SwitchLocalePathLink>
</div>
</template>
163 changes: 70 additions & 93 deletions composables/useBlogContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand Down Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -124,17 +140,22 @@ 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<BlogCollectionKey>(
() => (`blog_${locale?.value || 'de'}`) as BlogCollectionKey
)
const availableLocales = computed<LocaleObject[]>(() =>
normalizeLocales((locales.value || []) as unknown[])
)

// Lets `switchLocalePath`/`<SwitchLocalePathLink>` 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<string[]>(() => {
const params = route.params as Record<string, string | string[] | undefined>
Expand All @@ -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>(
Expand All @@ -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))
Expand All @@ -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<BlogArticle | null>(() => payload.value?.article || null)
const authors = computed<BlogAuthorProfile[]>(() => payload.value?.authors || [])

Expand All @@ -204,110 +233,58 @@ export function useBlogArticle() {
return { ...(article.value as BlogArticle), authors: authors.value || [] }
})

const alternateLanguages = ref<BlogAlternateLanguageLink[]>([])

// 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 / <SwitchLocalePathLink> 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<string, string>) => {
const params: Record<string, { slug: string[] }> = {}
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<string, string> = {}
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<HeadLink[]>(() => {
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<string>()
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
}
}
Loading
Loading