diff --git a/frontend/app/content.js b/frontend/app/content.js index 3e16164..a99322c 100644 --- a/frontend/app/content.js +++ b/frontend/app/content.js @@ -9,7 +9,7 @@ export const profileContent = { en: { locale: "en", htmlLang: "en", - alternateHref: "/pt/", + alternateHref: "/pt?lang=pt", alternateLabel: "PT", currentLabel: "EN", navAria: "Primary navigation", @@ -139,7 +139,7 @@ export const profileContent = { pt: { locale: "pt", htmlLang: "pt-BR", - alternateHref: "/", + alternateHref: "/?lang=en", alternateLabel: "EN", currentLabel: "PT", navAria: "Navegação principal", diff --git a/frontend/middleware.js b/frontend/middleware.js new file mode 100644 index 0000000..6f0c5cb --- /dev/null +++ b/frontend/middleware.js @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server"; + +const LOCALE_COOKIE = "rs_locale"; +const SUPPORTED_LOCALES = new Set(["en", "pt"]); +const PUBLIC_FILE = /\.(?:ico|png|jpg|jpeg|svg|webp|avif|gif|txt|xml|json|webmanifest|css|js|map|woff2?)$/i; +const RESERVED_PREFIXES = [ + "/_next", + "/_a2a", + "/.well-known", + "/api", + "/auth", + "/invoke", + "/mcp", + "/message:send", + "/message:stream", + "/tasks", + "/answers", + "/input-requests", + "/scope-grants", + "/scope-denials", +]; + +function isReservedPath(pathname) { + if (PUBLIC_FILE.test(pathname)) { + return true; + } + return RESERVED_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} + +function normalizeLocale(value) { + const locale = String(value || "").trim().toLowerCase(); + if (SUPPORTED_LOCALES.has(locale)) { + return locale; + } + return null; +} + +function localeFromCountry(request) { + const country = ( + request.headers.get("x-vercel-ip-country") + || request.headers.get("cf-ipcountry") + || request.headers.get("x-country-code") + || "" + ).trim().toUpperCase(); + if (country === "BR" || country === "PT") { + return "pt"; + } + return null; +} + +function localeFromAcceptLanguage(request) { + const header = request.headers.get("accept-language") || ""; + const ranked = header + .split(",") + .map((part) => { + const [tag, ...params] = part.trim().split(";"); + const q = params + .map((param) => param.trim()) + .find((param) => param.startsWith("q=")); + const weight = q ? Number.parseFloat(q.slice(2)) : 1; + return { + language: tag.toLowerCase().split("-")[0], + weight: Number.isFinite(weight) ? weight : 1, + }; + }) + .filter((item) => SUPPORTED_LOCALES.has(item.language)) + .sort((a, b) => b.weight - a.weight); + return ranked[0]?.language || null; +} + +function preferredLocale(request) { + return ( + normalizeLocale(request.cookies.get(LOCALE_COOKIE)?.value) + || localeFromAcceptLanguage(request) + || localeFromCountry(request) + || "en" + ); +} + +function withLocaleCookie(response, locale) { + response.cookies.set(LOCALE_COOKIE, locale, { + path: "/", + maxAge: 60 * 60 * 24 * 365, + sameSite: "lax", + }); + return response; +} + +export function middleware(request) { + const url = request.nextUrl; + const { pathname, searchParams } = url; + + if (isReservedPath(pathname)) { + return NextResponse.next(); + } + + const requestedLocale = normalizeLocale(searchParams.get("lang")); + if (requestedLocale) { + const cleanUrl = url.clone(); + cleanUrl.searchParams.delete("lang"); + const response = + cleanUrl.toString() === url.toString() + ? NextResponse.next() + : NextResponse.redirect(cleanUrl); + return withLocaleCookie(response, requestedLocale); + } + + if (pathname === "/") { + const locale = preferredLocale(request); + if (locale === "pt") { + const redirectUrl = url.clone(); + redirectUrl.pathname = "/pt/"; + return NextResponse.redirect(redirectUrl); + } + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/:path*"], +};