87 lines
3.1 KiB
TypeScript
87 lines
3.1 KiB
TypeScript
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;
|