Skip to content

Commit 8d560dc

Browse files
committed
fix(i18n): respect Accept-Language q-values and scope middleware
Address codex review on #126: - parseAcceptLanguage now parses q-values (default 1.0 per RFC 9110 §12.5.4), drops q=0 (explicit rejection), and selects the highest-q supported language. Previously a header like 'en;q=0.1, ar;q=1' picked en because the loop scanned in textual order and ignored q. - middleware.ts now exports a matcher config that excludes _next/static, _next/image, /api, common static asset extensions, and crawl files. Asset and API responses no longer attach a Set-Cookie header and remain cacheable. Typecheck still clean.
1 parent e611d6d commit 8d560dc

2 files changed

Lines changed: 35 additions & 4 deletions

File tree

lib/i18n-core.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,35 @@ export function parseAcceptLanguage<T extends string>(
1919
): T {
2020
if (!header) return fallback;
2121

22+
// Parse "lang;q=0.5" entries, drop q=0 (explicit rejection), and pick
23+
// the highest-q supported language. RFC 9110 §12.5.4: missing q
24+
// defaults to 1.0. A header like "en;q=0.1, ar;q=1" must select ar.
25+
const parsed: { tag: string; primary: string; q: number }[] = [];
2226
for (const part of header.split(",")) {
23-
const tag = part.trim().split(";")[0]?.toLowerCase();
24-
const primary = tag?.split("-")[0];
27+
const segments = part.trim().split(";");
28+
const tag = segments[0]?.toLowerCase().trim();
29+
if (!tag) continue;
30+
31+
let q = 1;
32+
for (const param of segments.slice(1)) {
33+
const [key, value] = param.split("=").map((s) => s.trim().toLowerCase());
34+
if (key === "q") {
35+
const num = Number(value);
36+
if (!Number.isNaN(num)) q = num;
37+
}
38+
}
39+
40+
if (q <= 0) continue;
41+
parsed.push({ tag, primary: tag.split("-")[0], q });
42+
}
43+
44+
parsed.sort((a, b) => b.q - a.q);
45+
46+
for (const entry of parsed) {
2547
const match = supported.find((locale) => {
2648
const normalized = locale.toLowerCase();
27-
return normalized === tag || normalized === primary;
49+
return normalized === entry.tag || normalized === entry.primary;
2850
});
29-
3051
if (match) return match;
3152
}
3253

middleware.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,13 @@ export function middleware(request: NextRequest) {
2424

2525
return response;
2626
}
27+
28+
// Run only on routes that produce HTML or read the cookie. Skip Next.js
29+
// internals, the optimized image endpoint, public static assets, and API
30+
// routes — they don't need a locale cookie and we want their responses
31+
// to stay cacheable.
32+
export const config = {
33+
matcher: [
34+
"/((?!_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)$).*)",
35+
],
36+
};

0 commit comments

Comments
 (0)