Skip to content
Open
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
27 changes: 24 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import "./globals.css";
import type { ReactNode } from "react";
import { cookies, headers } from "next/headers";
import Script from "next/script";
import {
DEFAULT_LOCALE,
LOCALE_COOKIE,
getLocaleDir,
isSupportedLocale,
parseAcceptLanguage,
supportedLocales,
} from "@/lib/i18n-core";
import Providers from "./providers";

const themeInitScript = `
Expand Down Expand Up @@ -31,14 +40,26 @@ export const metadata = {
},
};

export default function RootLayout({ children }: { children: ReactNode }) {
export default async function RootLayout({ children }: { children: ReactNode }) {
const cookieStore = await cookies();
const headerStore = await headers();
const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value;
const initialLocale = isSupportedLocale(cookieLocale)
? cookieLocale
: parseAcceptLanguage(
headerStore.get("accept-language"),
supportedLocales,
DEFAULT_LOCALE
);
const dir = getLocaleDir(initialLocale);

return (
<html lang="en" suppressHydrationWarning>
<html lang={initialLocale} dir={dir} suppressHydrationWarning>
<body>
<Script id="theme-init" strategy="beforeInteractive">
{themeInitScript}
</Script>
<Providers>{children}</Providers>
<Providers initialLocale={initialLocale}>{children}</Providers>
</body>
</html>
);
Expand Down
11 changes: 9 additions & 2 deletions app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
"use client";

import { LanguageProvider } from "@/components/language-provider";
import type { Locale } from "@/lib/i18n-core";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ThemeProvider } from "@/components/theme-provider";

export default function Providers({ children }: { children: React.ReactNode }) {
export default function Providers({
children,
initialLocale,
}: {
children: React.ReactNode;
initialLocale: Locale;
}) {
return (
<ThemeProvider>
<LanguageProvider>
<LanguageProvider initialLocale={initialLocale}>
<TooltipProvider>{children}</TooltipProvider>
</LanguageProvider>
</ThemeProvider>
Expand Down
10 changes: 8 additions & 2 deletions components/language-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ type I18nContextValue = {

const I18nContext = createContext<I18nContextValue | null>(null);

export function LanguageProvider({ children }: { children: React.ReactNode }) {
const value = useI18nProvider();
export function LanguageProvider({
children,
initialLocale,
}: {
children: React.ReactNode;
initialLocale?: Locale;
}) {
const value = useI18nProvider(initialLocale);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

Expand Down
59 changes: 59 additions & 0 deletions lib/i18n-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export const supportedLocales = ["en", "ar"] as const;
export type Locale = (typeof supportedLocales)[number];
export const DEFAULT_LOCALE: Locale = "en";
export const LOCALE_COOKIE = "app-locale";

export const localeMeta: Record<Locale, { dir: "ltr" | "rtl"; label: string }> = {
en: { dir: "ltr", label: "English" },
ar: { dir: "rtl", label: "\u0627\u0644\u0639\u0631\u0628\u064a\u0629" },
};

export function isSupportedLocale(value: string | null | undefined): value is Locale {
return supportedLocales.includes(value as Locale);
}

export function parseAcceptLanguage<T extends string>(
header: string | null | undefined,
supported: readonly T[],
fallback: T
): T {
if (!header) return fallback;

// Parse "lang;q=0.5" entries, drop q=0 (explicit rejection), and pick
// the highest-q supported language. RFC 9110 §12.5.4: missing q
// defaults to 1.0. A header like "en;q=0.1, ar;q=1" must select ar.
const parsed: { tag: string; primary: string; q: number }[] = [];
for (const part of header.split(",")) {
const segments = part.trim().split(";");
const tag = segments[0]?.toLowerCase().trim();
if (!tag) continue;

let q = 1;
for (const param of segments.slice(1)) {
const [key, value] = param.split("=").map((s) => s.trim().toLowerCase());
if (key === "q") {
const num = Number(value);
if (!Number.isNaN(num)) q = num;
}
}

if (q <= 0) continue;
parsed.push({ tag, primary: tag.split("-")[0], q });
}

parsed.sort((a, b) => b.q - a.q);

for (const entry of parsed) {
const match = supported.find((locale) => {
const normalized = locale.toLowerCase();
return normalized === entry.tag || normalized === entry.primary;
});
if (match) return match;
}

return fallback;
}

export function getLocaleDir(locale: Locale) {
return localeMeta[locale].dir;
}
83 changes: 43 additions & 40 deletions lib/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,47 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import arMessages from "../locales/ar.json";
import enMessages from "../locales/en.json";
import {
DEFAULT_LOCALE,
LOCALE_COOKIE,
isSupportedLocale,
localeMeta,
supportedLocales,
type Locale,
} from "./i18n-core";

type Messages = Record<string, string>;

export const supportedLocales = ["en", "ar"] as const;
export type Locale = (typeof supportedLocales)[number];
const storageKey = "app-locale";
export {
DEFAULT_LOCALE,
LOCALE_COOKIE,
getLocaleDir,
isSupportedLocale,
parseAcceptLanguage,
supportedLocales,
type Locale,
} from "./i18n-core";

let enMessagesCache: Messages | null = null;
type Messages = Record<string, string>;

const localeMeta: Record<Locale, { dir: "ltr" | "rtl"; label: string }> = {
en: { dir: "ltr", label: "English" },
ar: { dir: "rtl", label: "\u0627\u0644\u0639\u0631\u0628\u064a\u0629" },
const cookieMaxAge = 60 * 60 * 24 * 365;
const messagesByLocale: Record<Locale, Messages> = {
en: enMessages,
ar: arMessages,
};

async function loadMessages(locale: Locale): Promise<Messages> {
switch (locale) {
case "ar":
return (await import("../locales/ar.json")).default;
case "en":
default:
return (await import("../locales/en.json")).default;
}
return messagesByLocale[locale];
}

function detectLocale(): Locale {
if (typeof window === "undefined") return "en";
const stored = window.localStorage.getItem(storageKey) as Locale | null;
if (stored && supportedLocales.includes(stored)) return stored;
const nav = navigator.language?.split("-")?.[0]?.toLowerCase();
if (nav && supportedLocales.includes(nav as Locale)) return nav as Locale;
return "en";
function persistLocale(locale: Locale) {
if (typeof window === "undefined") return;

window.localStorage.setItem(LOCALE_COOKIE, locale);
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=${cookieMaxAge}; samesite=lax`;
}

export function useI18nProvider() {
const [locale, setLocaleState] = useState<Locale>("en");
const [messages, setMessages] = useState<Messages>(() => {
if (enMessagesCache) return enMessagesCache;
// eslint-disable-next-line @typescript-eslint/no-var-requires
enMessagesCache = require("../locales/en.json");
return enMessagesCache as Messages;
});
export function useI18nProvider(initialLocale: Locale = DEFAULT_LOCALE) {
const [locale, setLocaleState] = useState<Locale>(initialLocale);
const [messages, setMessages] = useState<Messages>(() => messagesByLocale[initialLocale]);
const [ready, setReady] = useState<boolean>(true);

const changeLocale = useCallback((next: Locale) => {
Expand All @@ -48,24 +50,25 @@ export function useI18nProvider() {
.then((m) => {
setMessages(m);
setLocaleState(next);
if (typeof window !== "undefined") {
window.localStorage.setItem(storageKey, next);
}
persistLocale(next);
setReady(true);
})
.catch((err) => {
console.warn("[i18n] failed to load locale, falling back to en", err);
if (enMessagesCache) setMessages(enMessagesCache);
setLocaleState("en");
setMessages(messagesByLocale[DEFAULT_LOCALE]);
setLocaleState(DEFAULT_LOCALE);
setReady(true);
});
}, []);

useEffect(() => {
if (typeof window === "undefined") return;
const detected = detectLocale();
changeLocale(detected);
}, [changeLocale]);

const stored = window.localStorage.getItem(LOCALE_COOKIE);
if (isSupportedLocale(stored) && stored !== locale) {
changeLocale(stored);
}
}, [changeLocale, locale]);

useEffect(() => {
if (typeof document === "undefined") return;
Expand Down
36 changes: 36 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
DEFAULT_LOCALE,
LOCALE_COOKIE,
isSupportedLocale,
parseAcceptLanguage,
supportedLocales,
} from "./lib/i18n-core";

export function middleware(request: NextRequest) {
const response = NextResponse.next();
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;

if (!isSupportedLocale(cookieLocale)) {
const locale = parseAcceptLanguage(
request.headers.get("accept-language"),
supportedLocales,
DEFAULT_LOCALE
);

response.cookies.set(LOCALE_COOKIE, locale, { path: "/" });
}

return response;
}

// Run only on routes that produce HTML or read the cookie. Skip Next.js
// internals, the optimized image endpoint, public static assets, and API
// routes — they don't need a locale cookie and we want their responses
// to stay cacheable.
export const config = {
matcher: [
"/((?!_next/static|_next/image|api|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|woff|woff2|ttf|otf)$).*)",
],
};