From c18128c96516e3af26e304e302fe3b657e4ac1af Mon Sep 17 00:00:00 2001 From: MOH Date: Sun, 15 Mar 2026 06:02:27 +0100 Subject: [PATCH] Fix runtime default locale resolution --- app/[locale]/(site)/about/page.tsx | 6 +- app/[locale]/(site)/contact/actions.ts | 2 +- app/[locale]/(site)/contact/page.tsx | 6 +- app/[locale]/(site)/layout.tsx | 4 +- app/[locale]/(site)/page.tsx | 6 +- app/[locale]/(site)/portfolio/[slug]/page.tsx | 19 ++- .../(site)/portfolio/category/[slug]/page.tsx | 14 ++- .../(site)/portfolio/category/page.tsx | 8 +- app/[locale]/(site)/portfolio/page.tsx | 22 +++- app/[locale]/(site)/success/page.tsx | 6 +- app/[locale]/coming-soon/page.tsx | 6 +- app/_admin/maintenance/actions.ts | 7 +- app/_admin/marquee/actions.ts | 7 +- app/_admin/portfolio/actions.ts | 10 +- app/_admin/site-settings/actions.ts | 10 +- app/sitemap.ts | 17 +-- .../admin/portfolio-projects-overview.tsx | 7 +- components/layout/locale-toggle.tsx | 4 +- components/site/portfolio-category-filter.tsx | 6 +- components/site/portfolio-project-detail.tsx | 21 ++-- components/site/portfolio-project-grid.tsx | 4 +- i18n/request.ts | 6 +- lib/locale.ts | 26 ++-- lib/metadata.ts | 12 +- lib/toast.tsx | 8 +- middleware.ts | 19 +-- tests/locale.test.ts | 16 ++- tests/metadata.test.ts | 41 ++++++ tests/middleware.test.ts | 118 ++++++++++++++++++ 29 files changed, 330 insertions(+), 108 deletions(-) create mode 100644 tests/middleware.test.ts diff --git a/app/[locale]/(site)/about/page.tsx b/app/[locale]/(site)/about/page.tsx index 80e4f46..1a1b5dd 100644 --- a/app/[locale]/(site)/about/page.tsx +++ b/app/[locale]/(site)/about/page.tsx @@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server"; import { Container } from "@/components/layout/container"; import { PageHero } from "@/components/layout/page-hero"; import { AppCard } from "@/components/ui/app-card"; -import { resolveLocale } from "@/lib/locale"; +import { FALLBACK_LOCALE, resolveLocale } from "@/lib/locale"; import { buildLocalizedMetadata } from "@/lib/metadata"; type AboutPageProps = { @@ -17,7 +17,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata({ params }: AboutPageProps): Promise { const { locale } = await params; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" }); return await buildLocalizedMetadata({ @@ -30,7 +30,7 @@ export async function generateMetadata({ params }: AboutPageProps): Promise { const { locale } = await params; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); const t = await getTranslations({ locale: localeKey, namespace: "contactPage" }); return await buildLocalizedMetadata({ @@ -35,7 +35,7 @@ export async function generateMetadata({ params }: ContactPageProps): Promise { const { locale } = await params; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); const [t, siteSettings] = await Promise.all([ getTranslations({ locale: localeKey, namespace: "homepage" }), getSiteSettings(), @@ -60,7 +60,7 @@ export async function generateMetadata({ params }: HomePageProps): Promise { const { locale, slug } = await params; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); const item = await getPublishedPortfolioProjectBySlug(slug); if (!item) { @@ -47,14 +48,17 @@ export default async function PortfolioItemPage({ params, }: PortfolioItemPageProps) { const { locale, slug } = await params; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); const item = await getPublishedPortfolioProjectBySlug(slug); if (!item) { notFound(); } - const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" }); + const [t, siteSettings] = await Promise.all([ + getTranslations({ locale: localeKey, namespace: "portfolioDetail" }), + getSiteSettings(), + ]); return ( <> @@ -64,7 +68,12 @@ export default async function PortfolioItemPage({ /> - + ); diff --git a/app/[locale]/(site)/portfolio/category/[slug]/page.tsx b/app/[locale]/(site)/portfolio/category/[slug]/page.tsx index 665261c..564cc75 100644 --- a/app/[locale]/(site)/portfolio/category/[slug]/page.tsx +++ b/app/[locale]/(site)/portfolio/category/[slug]/page.tsx @@ -6,8 +6,9 @@ import { Container } from "@/components/layout/container"; import { PageHero } from "@/components/layout/page-hero"; import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter"; import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid"; +import { getSiteSettings } from "@/lib/app-config"; import { buildLocalizedMetadata } from "@/lib/metadata"; -import { resolveLocale } from "@/lib/locale"; +import { FALLBACK_LOCALE, resolveLocale } from "@/lib/locale"; import { getActivePortfolioCategories, getActivePortfolioCategoryBySlug, @@ -26,7 +27,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata({ params }: PortfolioCategoryPageProps): Promise { const { locale, slug } = await params; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); const category = await getActivePortfolioCategoryBySlug(slug); @@ -51,9 +52,10 @@ export default async function PortfolioCategoryPage({ params, }: PortfolioCategoryPageProps) { const { locale, slug } = await params; - const localeKey = resolveLocale(locale); - const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); - const [categories, category, projects] = await Promise.all([ + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); + const [t, siteSettings, categories, category, projects] = await Promise.all([ + getTranslations({ locale: localeKey, namespace: "portfolioPage" }), + getSiteSettings(), getActivePortfolioCategories(), getActivePortfolioCategoryBySlug(slug), getPublishedPortfolioProjects({ categorySlug: slug }), @@ -75,12 +77,14 @@ export default async function PortfolioCategoryPage({ { const { locale } = await params; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); return await buildLocalizedMetadata({ @@ -42,12 +43,15 @@ export default async function PortfolioPage({ params, searchParams, ]); - const localeKey = resolveLocale(locale); - const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); + const [t, siteSettings] = await Promise.all([ + getTranslations({ locale: localeKey, namespace: "portfolioPage" }), + getSiteSettings(), + ]); const selectedCategory = resolvedSearchParams?.category ?? ""; if (selectedCategory) { - redirect(getLocalizedPath(localeKey, `/portfolio/category/${selectedCategory}`)); + redirect(getLocalizedPath(localeKey, `/portfolio/category/${selectedCategory}`, siteSettings.defaultLocale)); } const [categories, projects] = await Promise.all([ @@ -65,9 +69,15 @@ export default async function PortfolioPage({ /> - + { const { locale } = await params; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); const t = await getTranslations({ locale: localeKey, namespace: "successPage" }); return await buildLocalizedMetadata({ @@ -34,7 +34,7 @@ export async function generateMetadata({ params }: SuccessPageProps): Promise { noStore(); const { locale } = await params; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" }); const siteSettings = await getSiteSettings(); @@ -40,7 +40,7 @@ export default async function ComingSoonPage({ }: ComingSoonPageProps) { noStore(); const { locale } = await params; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, FALLBACK_LOCALE); const [t, siteSettings] = await Promise.all([ getTranslations({ locale: localeKey, namespace: "comingSoon" }), getSiteSettings(), diff --git a/app/_admin/maintenance/actions.ts b/app/_admin/maintenance/actions.ts index a841a4f..e112bd8 100644 --- a/app/_admin/maintenance/actions.ts +++ b/app/_admin/maintenance/actions.ts @@ -7,7 +7,7 @@ import { routing } from "@/i18n/routing"; import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { getLocalizedPath } from "@/lib/locale"; -import { setMaintenanceMode } from "@/lib/app-config"; +import { getSiteSettings, setMaintenanceMode } from "@/lib/app-config"; async function ensureAdmin() { if (!(await isAdminAuthenticated())) { @@ -33,10 +33,11 @@ export async function updateMaintenanceModeAction(formData: FormData) { revalidatePath(toInternalAdminPath("/")); revalidatePath(toInternalAdminPath("/maintenance")); revalidatePath(toInternalAdminPath(redirectUrl.pathname)); + const siteSettings = await getSiteSettings(); for (const appLocale of routing.locales) { - revalidatePath(getLocalizedPath(appLocale), "layout"); - revalidatePath(getLocalizedPath(appLocale, "/coming-soon")); + revalidatePath(getLocalizedPath(appLocale, "/", siteSettings.defaultLocale), "layout"); + revalidatePath(getLocalizedPath(appLocale, "/coming-soon", siteSettings.defaultLocale)); } redirect(`${redirectUrl.pathname}${redirectUrl.search}`); diff --git a/app/_admin/marquee/actions.ts b/app/_admin/marquee/actions.ts index 4812140..3cb65fb 100644 --- a/app/_admin/marquee/actions.ts +++ b/app/_admin/marquee/actions.ts @@ -6,7 +6,7 @@ import { isRedirectError } from "next/dist/client/components/redirect-error"; import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; -import { updateMarqueeSettings } from "@/lib/app-config"; +import { getSiteSettings, updateMarqueeSettings } from "@/lib/app-config"; import { routing } from "@/i18n/routing"; import { getLocalizedPath } from "@/lib/locale"; @@ -27,10 +27,11 @@ function withMessage(pathname: string, type: "success" | "error", message: strin async function revalidateMarqueePages() { revalidatePath(toInternalAdminPath("/")); revalidatePath(toInternalAdminPath("/marquee")); + const siteSettings = await getSiteSettings(); for (const locale of routing.locales) { - revalidatePath(getLocalizedPath(locale), "layout"); - revalidatePath(getLocalizedPath(locale)); + revalidatePath(getLocalizedPath(locale, "/", siteSettings.defaultLocale), "layout"); + revalidatePath(getLocalizedPath(locale, "/", siteSettings.defaultLocale)); } } diff --git a/app/_admin/portfolio/actions.ts b/app/_admin/portfolio/actions.ts index 77f1123..46c10f7 100644 --- a/app/_admin/portfolio/actions.ts +++ b/app/_admin/portfolio/actions.ts @@ -16,6 +16,7 @@ import { removeManagedMediaFile } from "@/lib/media-storage"; import { mediaFieldInputSchema } from "@/lib/media-validation"; import { prisma } from "@/lib/prisma"; import { isCheckedFormValue } from "@/lib/form-data"; +import { getSiteSettings } from "@/lib/app-config"; import { assetInputSchema, categoryInputSchema, @@ -92,9 +93,10 @@ async function revalidatePortfolioPages() { revalidatePath(toInternalAdminPath("/portfolio/categories")); revalidatePath(toInternalAdminPath("/portfolio/projects")); revalidatePath("/portfolio"); + const siteSettings = await getSiteSettings(); for (const locale of routing.locales) { - revalidatePath(getLocalizedPath(locale, "/portfolio")); + revalidatePath(getLocalizedPath(locale, "/portfolio", siteSettings.defaultLocale)); } } @@ -520,9 +522,10 @@ export async function saveProjectAction(formData: FormData) { await revalidatePortfolioPages(); revalidatePath(toInternalAdminPath(`/portfolio/projects/${projectResult.project.id}`)); revalidatePath(`/portfolio/${projectResult.project.slug}`); + const siteSettings = await getSiteSettings(); for (const locale of routing.locales) { - revalidatePath(getLocalizedPath(locale, `/portfolio/${projectResult.project.slug}`)); + revalidatePath(getLocalizedPath(locale, `/portfolio/${projectResult.project.slug}`, siteSettings.defaultLocale)); } redirect( @@ -591,9 +594,10 @@ export async function deleteProjectAction(formData: FormData) { await revalidatePortfolioPages(); revalidatePath(`/portfolio/${project.slug}`); + const siteSettings = await getSiteSettings(); for (const locale of routing.locales) { - revalidatePath(getLocalizedPath(locale, `/portfolio/${project.slug}`)); + revalidatePath(getLocalizedPath(locale, `/portfolio/${project.slug}`, siteSettings.defaultLocale)); } redirect(withMessage(getAdminAppPath("/portfolio"), "success", "Project deleted.")); diff --git a/app/_admin/site-settings/actions.ts b/app/_admin/site-settings/actions.ts index 43ad089..16338e2 100644 --- a/app/_admin/site-settings/actions.ts +++ b/app/_admin/site-settings/actions.ts @@ -79,7 +79,7 @@ async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) } } -async function revalidateSiteSettingsPages() { +async function revalidateSiteSettingsPages(defaultLocale: SiteSettings["defaultLocale"]) { revalidatePath("/", "layout"); revalidatePath(toInternalAdminPath("/")); revalidatePath(toInternalAdminPath("/site-settings")); @@ -88,10 +88,10 @@ async function revalidateSiteSettingsPages() { const publicPaths = ["/", "/about", "/portfolio", "/contact", "/success", "/coming-soon"]; for (const locale of routing.locales) { - revalidatePath(getLocalizedPath(locale), "layout"); + revalidatePath(getLocalizedPath(locale, "/", defaultLocale), "layout"); for (const path of publicPaths) { - revalidatePath(getLocalizedPath(locale, path)); + revalidatePath(getLocalizedPath(locale, path, defaultLocale)); } } } @@ -259,7 +259,7 @@ export async function saveSiteBrandSettingsAction(formData: FormData) { ], }); - await revalidateSiteSettingsPages(); + await revalidateSiteSettingsPages(parsedSettings.defaultLocale); redirect(withMessage(getAdminAppPath("/site-settings/brand"), "success", "Einstellungen gespeichert.")); } catch (error) { if (isRedirectError(error)) { @@ -321,7 +321,7 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) { } await updateSiteSettings(parsedSettings); - await revalidateSiteSettingsPages(); + await revalidateSiteSettingsPages(parsedSettings.defaultLocale); redirect(withMessage(getAdminAppPath("/site-settings/localization"), "success", "Einstellungen gespeichert.")); } catch (error) { if (isRedirectError(error)) { diff --git a/app/sitemap.ts b/app/sitemap.ts index e85e171..8b1d204 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -2,6 +2,7 @@ import type { MetadataRoute } from "next"; import { unstable_noStore as noStore } from "next/cache"; import { routing } from "@/i18n/routing"; +import { getSiteSettings } from "@/lib/app-config"; import { getLocalizedPath } from "@/lib/locale"; import { getPublishedPortfolioProjects } from "@/lib/portfolio"; @@ -15,10 +16,11 @@ function toAbsoluteUrl(pathname: string): string { function buildLocalizedEntries( pathname: string, + defaultLocale: "de" | "en" | "ar", options?: Pick, ): MetadataRoute.Sitemap { return routing.locales.map((locale) => ({ - url: toAbsoluteUrl(getLocalizedPath(locale, pathname)), + url: toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale)), lastModified: options?.lastModified, changeFrequency: options?.changeFrequency, priority: options?.priority, @@ -27,6 +29,7 @@ function buildLocalizedEntries( export default async function sitemap(): Promise { noStore(); + const siteSettings = await getSiteSettings(); let projects: Awaited> = []; @@ -41,30 +44,30 @@ export default async function sitemap(): Promise { ); return [ - ...buildLocalizedEntries("/", { + ...buildLocalizedEntries("/", siteSettings.defaultLocale, { changeFrequency: "weekly", priority: 1, }), - ...buildLocalizedEntries("/about", { + ...buildLocalizedEntries("/about", siteSettings.defaultLocale, { changeFrequency: "monthly", priority: 0.8, }), - ...buildLocalizedEntries("/portfolio", { + ...buildLocalizedEntries("/portfolio", siteSettings.defaultLocale, { changeFrequency: "weekly", priority: 0.9, }), ...categories.flatMap((category) => - buildLocalizedEntries(`/portfolio/category/${category.slug}`, { + buildLocalizedEntries(`/portfolio/category/${category.slug}`, siteSettings.defaultLocale, { changeFrequency: "weekly", priority: 0.8, }), ), - ...buildLocalizedEntries("/contact", { + ...buildLocalizedEntries("/contact", siteSettings.defaultLocale, { changeFrequency: "monthly", priority: 0.7, }), ...projects.flatMap((project) => - buildLocalizedEntries(`/portfolio/${project.slug}`, { + buildLocalizedEntries(`/portfolio/${project.slug}`, siteSettings.defaultLocale, { lastModified: project.publishedAt ?? undefined, changeFrequency: "monthly", priority: 0.8, diff --git a/components/admin/portfolio-projects-overview.tsx b/components/admin/portfolio-projects-overview.tsx index 419b837..beb6715 100644 --- a/components/admin/portfolio-projects-overview.tsx +++ b/components/admin/portfolio-projects-overview.tsx @@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { CardContent } from "@/components/ui/card"; import { getAdminAppPath } from "@/lib/admin-routing"; +import { getSiteSettings } from "@/lib/app-config"; import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedValue, type PortfolioCategoryView, type PortfolioProjectView } from "@/lib/portfolio"; @@ -28,11 +29,13 @@ const copy = { empty: "Noch keine Projekte vorhanden.", }; -export function PortfolioProjectsOverview({ +export async function PortfolioProjectsOverview({ categories, projects, selectedCategory, }: PortfolioProjectsOverviewProps) { + const siteSettings = await getSiteSettings(); + return (
@@ -102,7 +105,7 @@ export function PortfolioProjectsOverview({

@@ -267,11 +270,12 @@ function ProjectHeader({ function GridTemplate({ item, locale, + defaultLocale, t, }: PortfolioProjectDetailProps) { return (
- + {item.coverImagePath ? ( @@ -309,11 +313,12 @@ function GridTemplate({ function StoryTemplate({ item, locale, + defaultLocale, t, }: PortfolioProjectDetailProps) { return (
- + {item.coverImagePath ? ( @@ -356,6 +361,7 @@ function StoryTemplate({ function CaseStudyTemplate({ item, locale, + defaultLocale, t, }: PortfolioProjectDetailProps) { const [challenge, solution, outcome, ...restSections] = item.sections; @@ -365,7 +371,7 @@ function CaseStudyTemplate({ return (
- +
@@ -441,17 +447,18 @@ function CaseStudyTemplate({ export function PortfolioProjectDetail({ item, locale, + defaultLocale, t, }: PortfolioProjectDetailProps) { const viewMode = resolvePortfolioProjectViewMode(item.viewMode); if (viewMode === "STORY") { - return ; + return ; } if (viewMode === "CASE_STUDY") { - return ; + return ; } - return ; + return ; } diff --git a/components/site/portfolio-project-grid.tsx b/components/site/portfolio-project-grid.tsx index 163f119..7e5a77e 100644 --- a/components/site/portfolio-project-grid.tsx +++ b/components/site/portfolio-project-grid.tsx @@ -9,6 +9,7 @@ import { getLocalizedValue, type PortfolioProjectView } from "@/lib/portfolio"; type PortfolioProjectGridProps = { locale: AppLocale; + defaultLocale: AppLocale; projects: PortfolioProjectView[]; emptyLabel: string; openLabel: string; @@ -16,6 +17,7 @@ type PortfolioProjectGridProps = { export function PortfolioProjectGrid({ locale, + defaultLocale, projects, emptyLabel, openLabel, @@ -27,7 +29,7 @@ export function PortfolioProjectGrid({
diff --git a/i18n/request.ts b/i18n/request.ts index 91d69df..633d93c 100644 --- a/i18n/request.ts +++ b/i18n/request.ts @@ -1,15 +1,13 @@ import { getRequestConfig } from "next-intl/server"; import { getSiteSettings } from "@/lib/app-config"; - -import { routing } from "./routing"; +import { isSupportedLocale } from "@/lib/locale"; export default getRequestConfig(async ({ requestLocale }) => { const siteSettings = await getSiteSettings(); const requestedLocale = await requestLocale; const locale = - requestedLocale && - routing.locales.includes(requestedLocale as (typeof routing.locales)[number]) + isSupportedLocale(requestedLocale) ? requestedLocale : siteSettings.defaultLocale; diff --git a/lib/locale.ts b/lib/locale.ts index 0f84347..481e2e3 100644 --- a/lib/locale.ts +++ b/lib/locale.ts @@ -1,21 +1,23 @@ -import { routing } from "../i18n/routing"; +import { appLocales } from "../i18n/routing"; -export type AppLocale = (typeof routing.locales)[number]; +export type AppLocale = (typeof appLocales)[number]; -export function resolveLocale(locale: string): AppLocale { - if (locale === "en" || locale === "ar") { - return locale; - } +export const FALLBACK_LOCALE: AppLocale = "de"; - return routing.defaultLocale; +export function isSupportedLocale(locale: string | undefined | null): locale is AppLocale { + return locale === "de" || locale === "en" || locale === "ar"; +} + +export function resolveLocale(locale: string | undefined | null, fallbackLocale: AppLocale): AppLocale { + return isSupportedLocale(locale) ? locale : fallbackLocale; } export function getDirection(locale: string): "ltr" | "rtl" { - return resolveLocale(locale) === "ar" ? "rtl" : "ltr"; + return locale === "ar" ? "rtl" : "ltr"; } export function stripLocalePrefix(pathname: string): string { - for (const locale of routing.locales) { + for (const locale of appLocales) { if (pathname === `/${locale}`) { return "/"; } @@ -31,7 +33,7 @@ export function stripLocalePrefix(pathname: string): string { export function getLocalizedPath( locale: string, pathname = "/", - defaultLocale: AppLocale = routing.defaultLocale, + defaultLocale: AppLocale, ): string { return getLocalizedPathWithDefault(locale, pathname, defaultLocale); } @@ -39,9 +41,9 @@ export function getLocalizedPath( export function getLocalizedPathWithDefault( locale: string, pathname = "/", - defaultLocale: AppLocale = routing.defaultLocale, + defaultLocale: AppLocale, ): string { - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, defaultLocale); const normalizedPath = pathname === "" ? "/" : pathname; const strippedPath = stripLocalePrefix(normalizedPath); diff --git a/lib/metadata.ts b/lib/metadata.ts index 1b61fff..ffe141c 100644 --- a/lib/metadata.ts +++ b/lib/metadata.ts @@ -1,6 +1,6 @@ import type { Metadata } from "next"; -import { routing } from "../i18n/routing"; +import { appLocales } from "../i18n/routing"; import { PAGE_TITLE_TOKEN, SITE_NAME_TOKEN, @@ -22,9 +22,9 @@ function toAbsoluteUrl(pathname: string): string { return new URL(pathname, getSiteUrl()).toString(); } -export function buildLocaleAlternates(pathname: string, defaultLocale = routing.defaultLocale) { +export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) { const languages = Object.fromEntries( - routing.locales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]), + appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]), ) as Record; return { @@ -120,8 +120,8 @@ export async function buildLocalizedMetadata({ description, applyTitleTemplate, }: LocalizedMetadataInput): Promise { - const localeKey = resolveLocale(locale); const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]); + const localeKey = resolveLocale(locale, settings.defaultLocale); return buildLocalizedMetadataFromConfig({ settings, @@ -152,7 +152,7 @@ export function buildLocalizedMetadataFromConfig(input: { description, applyTitleTemplate = true, } = input; - const localeKey = resolveLocale(locale); + const localeKey = resolveLocale(locale, settings.defaultLocale); const localeSettings = settings.locales[localeKey]; const resolvedDescription = description?.trim() || localeSettings.siteDescription; const resolvedTitle = applyTitleTemplate @@ -167,7 +167,7 @@ export function buildLocalizedMetadataFromConfig(input: { openGraph: { title: resolvedTitle, description: resolvedDescription, - url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname)), + url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)), siteName: localeSettings.siteName, locale: localeKey, type: "website", diff --git a/lib/toast.tsx b/lib/toast.tsx index 0b97a04..9d05699 100644 --- a/lib/toast.tsx +++ b/lib/toast.tsx @@ -6,7 +6,7 @@ import { type ToastPosition, } from "react-hot-toast"; -import { resolveLocale } from "@/lib/locale"; +import { FALLBACK_LOCALE, resolveLocale } from "@/lib/locale"; type ToastVariant = "default" | "success" | "error" | "loading"; @@ -14,13 +14,13 @@ type ToastMessage = string; function getCurrentLocale() { if (typeof window === "undefined") { - return "de" as const; + return FALLBACK_LOCALE; } const pathname = window.location.pathname; - const maybeLocale = pathname.split("/")[1] || "de"; + const maybeLocale = pathname.split("/")[1] || FALLBACK_LOCALE; - return resolveLocale(maybeLocale); + return resolveLocale(maybeLocale, FALLBACK_LOCALE); } function getToastPosition(isArabic: boolean): ToastPosition { diff --git a/middleware.ts b/middleware.ts index 03d472d..536a4a3 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,7 +2,7 @@ import createMiddleware from "next-intl/middleware"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { appLocales, createI18nRouting, routing } from "./i18n/routing"; +import { appLocales, createI18nRouting } from "./i18n/routing"; import { fromDevelopmentAdminPath, getAdminBaseUrl, @@ -14,7 +14,12 @@ import { isLegacyAdminPath, toInternalAdminPath, } from "./lib/admin-routing"; -import { getLocalizedPathWithDefault, stripLocalePrefix } from "./lib/locale"; +import { + FALLBACK_LOCALE, + getLocalizedPathWithDefault, + isSupportedLocale, + stripLocalePrefix, +} from "./lib/locale"; const ADMIN_SESSION_COOKIE = "moh_admin_session"; @@ -23,10 +28,6 @@ type SiteRuntimeState = { maintenanceEnabled: boolean; }; -function isSupportedLocale(locale: string | undefined): locale is (typeof appLocales)[number] { - return locale === "ar" || locale === "en" || locale === "de"; -} - function getPathLocale(pathname: string, fallbackLocale: (typeof appLocales)[number]) { const locale = pathname.split("/")[1]; @@ -48,7 +49,7 @@ async function getSiteRuntimeState(request: NextRequest): Promise { + it("recognizes supported locales explicitly", () => { + expect(isSupportedLocale("ar")).toBe(true); + expect(isSupportedLocale("en")).toBe(true); + expect(isSupportedLocale("de")).toBe(true); + expect(isSupportedLocale("fr")).toBe(false); + expect(isSupportedLocale(undefined)).toBe(false); + }); + + it("resolves invalid locales with an explicit fallback", () => { + expect(resolveLocale("ar", "de")).toBe("ar"); + expect(resolveLocale("fr", "en")).toBe("en"); + expect(resolveLocale("", FALLBACK_LOCALE)).toBe("de"); + }); + it("keeps the configured default locale on the bare domain", () => { expect(getLocalizedPathWithDefault("ar", "/", "ar")).toBe("/"); expect(getLocalizedPathWithDefault("de", "/", "ar")).toBe("/de"); diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts index 44b96c2..b0beb39 100644 --- a/tests/metadata.test.ts +++ b/tests/metadata.test.ts @@ -4,6 +4,7 @@ import { buildDefaultSiteSettings } from "../lib/site-settings"; import { applyTitleTemplateFn, buildAppMetadataFromConfig, + buildLocaleAlternates, buildLocalizedMetadataFromConfig, } from "../lib/metadata"; @@ -96,4 +97,44 @@ describe("metadata helpers", () => { expect(metadata.title).toBe("اسم الموقع"); }); + + it("builds alternates and canonical from the runtime default locale", () => { + const alternates = buildLocaleAlternates("/about", "ar"); + + expect(alternates.canonical).toBe("https://mohfarawati.de/about"); + expect(alternates.languages.ar).toBe("https://mohfarawati.de/about"); + expect(alternates.languages.de).toBe("https://mohfarawati.de/de/about"); + expect(alternates.languages["x-default"]).toBe("https://mohfarawati.de/about"); + }); + + it("builds localized metadata urls against the configured default locale", () => { + const settings = buildDefaultSiteSettings("Studio Moh"); + settings.defaultLocale = "ar"; + + const metadata = buildLocalizedMetadataFromConfig({ + settings, + bindings: { + siteLogoLight: null, + siteLogoDark: null, + favicon: null, + defaultOgImage: null, + }, + locale: "de", + pathname: "/about", + title: "About", + }); + + expect(metadata.alternates).toMatchObject({ + canonical: "https://mohfarawati.de/about", + languages: { + ar: "https://mohfarawati.de/about", + de: "https://mohfarawati.de/de/about", + en: "https://mohfarawati.de/en/about", + "x-default": "https://mohfarawati.de/about", + }, + }); + expect(metadata.openGraph).toMatchObject({ + url: "https://mohfarawati.de/de/about", + }); + }); }); diff --git a/tests/middleware.test.ts b/tests/middleware.test.ts new file mode 100644 index 0000000..1d06c42 --- /dev/null +++ b/tests/middleware.test.ts @@ -0,0 +1,118 @@ +import { NextResponse } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const createMiddlewareMock = vi.fn(); +const intlHandlerMock = vi.fn(() => NextResponse.next()); + +vi.mock("next-intl/middleware", () => ({ + default: createMiddlewareMock, +})); + +vi.mock("../lib/admin-routing", () => ({ + fromDevelopmentAdminPath: (pathname: string) => pathname, + getAdminBaseUrl: () => "https://admin.example.com", + getRequestHostname: (_forwardedHost: string | null, host: string | null, hostname: string) => host ?? hostname, + isDevelopmentAdminPath: () => false, + isAdminHost: () => false, + hasDedicatedAdminHost: () => false, + isInternalAdminPath: () => false, + isLegacyAdminPath: () => false, + toInternalAdminPath: (pathname: string) => pathname, +})); + +function createMockRequest(url: string) { + const nextUrl = new URL(url) as URL & { clone: () => URL }; + nextUrl.clone = () => new URL(nextUrl.toString()); + + return { + url, + nextUrl, + headers: new Headers({ + host: nextUrl.host, + }), + cookies: { + has: vi.fn(() => false), + }, + }; +} + +describe("middleware locale runtime config", () => { + beforeEach(() => { + vi.resetModules(); + createMiddlewareMock.mockReset(); + intlHandlerMock.mockReset(); + intlHandlerMock.mockReturnValue(NextResponse.next()); + createMiddlewareMock.mockReturnValue(intlHandlerMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("passes the runtime default locale into next-intl middleware", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + defaultLocale: "ar", + maintenanceEnabled: false, + }), + })), + ); + + const { default: middleware } = await import("../middleware"); + const request = createMockRequest("https://example.com/"); + + await middleware(request as never); + + expect(createMiddlewareMock).toHaveBeenCalledTimes(1); + expect(createMiddlewareMock).toHaveBeenCalledWith( + expect.objectContaining({ + defaultLocale: "ar", + }), + ); + expect(intlHandlerMock).toHaveBeenCalledTimes(1); + }); + + it("falls back safely when the runtime locale lookup fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network failed"); + }), + ); + + const { default: middleware } = await import("../middleware"); + const request = createMockRequest("https://example.com/"); + + await middleware(request as never); + + expect(createMiddlewareMock).toHaveBeenCalledWith( + expect.objectContaining({ + defaultLocale: "de", + }), + ); + }); + + it("redirects maintenance traffic using the runtime default locale", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + defaultLocale: "ar", + maintenanceEnabled: true, + }), + })), + ); + + const { default: middleware } = await import("../middleware"); + const request = createMockRequest("https://example.com/"); + + const response = await middleware(request as never); + + expect(response.headers.get("location")).toBe("https://example.com/coming-soon"); + expect(intlHandlerMock).not.toHaveBeenCalled(); + }); +});