Session 12: i18n (10 languages, cookie-based), Africa tier .99, locale switcher, RTL Arabic (1305 tests)

This commit is contained in:
Kev
2026-06-10 22:24:40 -04:00
parent e5c45ecc8e
commit d957dee17b
27 changed files with 1834 additions and 29 deletions
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE, LOCALE_HEADER, isLocale, Locale } from '@/lib/locales';
/**
* Locale-detection middleware (Session 12).
*
* Detection priority:
* 1. URL prefix (/es/scan, /fr/pricing) — reserved for a future
* session that does the [locale] segment refactor. For now this
* branch is unreachable because no route uses the prefix.
* 2. NEXT_LOCALE cookie — set by the locale switcher and persisted
* across sessions.
* 3. Accept-Language header — best guess from the browser.
* 4. Default 'en'.
*
* The resolved locale lands on the `x-vyndr-locale` REQUEST header
* (NOT the response) so downstream server components can read it via
* `headers()` without parsing cookies themselves. The cookie is set
* on the response when the locale switcher fires (separate code
* path); the middleware itself doesn't write cookies.
*
* Skips: Next.js internals (`_next`), public files (anything with a
* dot in the path), and API routes (they don't render UI, no locale
* needed).
*/
function parseAcceptLanguage(header: string | null): Locale | null {
if (!header) return null;
// "en-US,en;q=0.9,es;q=0.8" → [{lang:'en-us', q:1}, {lang:'en', q:0.9}, ...]
const ranked = header
.split(',')
.map((chunk) => {
const [tag, ...params] = chunk.trim().split(';');
const qParam = params.find((p) => p.trim().startsWith('q='));
const q = qParam ? Number(qParam.split('=')[1]) : 1;
return { tag: tag.toLowerCase(), q: Number.isFinite(q) ? q : 1 };
})
.filter((entry) => entry.tag)
.sort((a, b) => b.q - a.q);
for (const entry of ranked) {
// Match the primary subtag (en-US → en).
const primary = entry.tag.split('-')[0];
if (isLocale(primary)) return primary;
}
return null;
}
function resolveLocale(req: NextRequest): Locale {
// 1. URL prefix — placeholder for the future [locale] refactor.
// Check the first path segment against the locale registry.
const firstSegment = req.nextUrl.pathname.split('/')[1] || '';
if (isLocale(firstSegment)) return firstSegment;
// 2. Cookie.
const cookie = req.cookies.get(LOCALE_COOKIE)?.value;
if (isLocale(cookie)) return cookie;
// 3. Accept-Language.
const fromHeader = parseAcceptLanguage(req.headers.get('accept-language'));
if (fromHeader) return fromHeader;
// 4. Default.
return DEFAULT_LOCALE;
}
export function middleware(req: NextRequest) {
const locale = resolveLocale(req);
// Stamp the request header so server components can read locale
// via `headers().get('x-vyndr-locale')`. NextResponse.next() with
// request headers is the canonical pattern for this.
const requestHeaders = new Headers(req.headers);
requestHeaders.set(LOCALE_HEADER, locale);
return NextResponse.next({
request: { headers: requestHeaders },
});
}
// Skip Next.js internals, public files, and API routes (no UI).
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
};
// Re-export so tests can import without pulling the full middleware.
export { resolveLocale, parseAcceptLanguage };
export const __SUPPORTED_LOCALES = LOCALES;