Skip to content
Merged
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
1 change: 0 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
const nextConfig = {
output: "standalone",
reactStrictMode: true,
typedRoutes: true,
images: {
unoptimized: true,
remotePatterns: [
Expand Down
43 changes: 24 additions & 19 deletions src/app/layout.tsx → src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
import "./globals.css";
import "@/app/globals.css";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import {
DEFAULT_LOCALE,
LOCALE_COOKIE,
getLocaleDir,
isSupportedLocale,
parseAcceptLanguage,
supportedLocales,
} from "@/lib/i18n/core";
import { DEFAULT_LOCALE, getLocaleDir, isSupportedLocale, supportedLocales } from "@/lib/i18n/core";
import { getMetadataBase, toAbsoluteUrl } from "@/lib/seo";
import { AppHeader } from "@/components/layout/app-header";
import { AppFooter } from "@/components/layout/app-footer";
import Providers from "./providers";
import Providers from "@/app/providers";

const themeInitScript = `
try {
Expand Down Expand Up @@ -96,21 +88,34 @@ export const metadata: Metadata = {
shortcut: "/logo.svg",
apple: "/logo.svg",
},
other: {
"darkreader-lock": "",
},
};

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

export default async function RootLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const initialLocale = isSupportedLocale(locale) ? locale : DEFAULT_LOCALE;
const dir = getLocaleDir(initialLocale);

return (
<html lang={initialLocale} dir={dir} suppressHydrationWarning>
<head>
<script id="theme-init" dangerouslySetInnerHTML={{ __html: themeInitScript }} />
<script
id="theme-init"
dangerouslySetInnerHTML={{ __html: themeInitScript }}
suppressHydrationWarning
/>
</head>
<body>
<Providers initialLocale={initialLocale}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type CountryInfo = {
};

type Props = {
params: Promise<{ country: string }>;
params: Promise<{ locale?: string; country: string }>;
};

const countries = countriesData as CountryInfo[];
Expand Down Expand Up @@ -98,9 +98,9 @@ export const revalidate = 3600; // Cache on Vercel Edge CDN for 1 hour via ISR
export const dynamicParams = true;

export async function generateStaticParams() {
// Pre-render the top 20 most visited countries at build time.
// Other countries are generated on-demand upon first visit and cached by ISR.
return countries.slice(0, 20).map((country) => ({ country: country.slug }));
// Countries are generated on-demand upon first visit and cached by ISR for 1 hour.
// Returning an empty list avoids build-time DB/Redis connection timeouts in CI/Docker environments.
return [];
}

export default async function CountryLeaderboardPage({ params }: Props) {
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const revalidate = 1800; // Cache user profile on Edge CDN for 30 minutes
export const dynamicParams = true;

type Props = {
params: Promise<{ username: string }>;
params: Promise<{ locale?: string; username: string }>;
searchParams?: Promise<{ country?: string }>;
};

Expand Down
2 changes: 1 addition & 1 deletion src/components/layout/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function AppHeader() {
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container m-auto flex h-20 max-w-7xl items-center justify-between gap-3 px-4 sm:h-24">
<Link href="/" className="shrink-0" aria-label="DevImpact home">
<BrandLogo priority size="md" />
<BrandLogo size="md" />
</Link>

<nav className="flex min-w-0 items-center gap-1.5 sm:gap-2">
Expand Down
2 changes: 1 addition & 1 deletion src/components/layout/brand-logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const sizeClasses = {
xl: "h-24 w-24 sm:h-32 sm:w-32",
} as const;

export function BrandLogo({ size = "md", className, priority = true }: BrandLogoProps) {
export function BrandLogo({ size = "md", className, priority = false }: BrandLogoProps) {
return (
<span
className={cn(
Expand Down
12 changes: 12 additions & 0 deletions src/components/providers/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ReactNode } from "react";

// Suppress the React 19 / next-themes false-positive warning for inline theme scripts
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
const originalError = console.error;
console.error = (...args: unknown[]) => {
if (typeof args[0] === "string" && args[0].includes("Encountered a script tag")) {
return;
}
originalError.apply(console, args);
};
}

export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
Expand All @@ -11,6 +22,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
enableSystem
enableColorScheme
storageKey="devimpact-theme"
disableTransitionOnChange
>
{children}
</NextThemesProvider>
Expand Down
1 change: 1 addition & 0 deletions src/components/seo/json-ld.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function JsonLd({ data }: JsonLdProps) {
type="application/ld+json"
// JSON-LD must be rendered as raw JSON text
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
suppressHydrationWarning
/>
);
}
9 changes: 8 additions & 1 deletion src/features/scoring/tests/scoring-methodology.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,14 @@ describe("scoring methodology localization", () => {
});

test("methodology route file exists", () => {
const routePath = resolve(process.cwd(), "src", "app", "scoring-methodology", "page.tsx");
const routePath = resolve(
process.cwd(),
"src",
"app",
"[locale]",
"scoring-methodology",
"page.tsx",
);
const source = readFileSync(routePath, "utf8");
expect(source.includes("ScoringMethodologyPage")).toBe(true);
});
Expand Down
60 changes: 60 additions & 0 deletions src/lib/i18n/tests/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { middleware } from "@/middleware";
import { LOCALE_COOKIE } from "@/lib/i18n/core";

describe("middleware transparent locale rewrite", () => {
it("rewrites / to /[defaultLocale] and sets cookie when no cookie exists", () => {
const req = new NextRequest("http://localhost:3000/");
const res = middleware(req);

expect(res.headers.get("x-middleware-rewrite")).toBe("http://localhost:3000/en");
expect(res.cookies.get(LOCALE_COOKIE)?.value).toBe("en");
});

it("rewrites /leaderboard to /ar/leaderboard when app-locale cookie is ar", () => {
const req = new NextRequest("http://localhost:3000/leaderboard", {
headers: {
cookie: `${LOCALE_COOKIE}=ar`,
},
});
const res = middleware(req);

expect(res.headers.get("x-middleware-rewrite")).toBe("http://localhost:3000/ar/leaderboard");
});

it("rewrites / based on Accept-Language when no cookie exists", () => {
const req = new NextRequest("http://localhost:3000/scoring-methodology", {
headers: {
"accept-language": "ar-SA,ar;q=0.9,en;q=0.8",
},
});
const res = middleware(req);

expect(res.headers.get("x-middleware-rewrite")).toBe(
"http://localhost:3000/ar/scoring-methodology",
);
expect(res.cookies.get(LOCALE_COOKIE)?.value).toBe("ar");
});

it("passes through without rewrite if path already starts with supported locale", () => {
const req = new NextRequest("http://localhost:3000/ar/leaderboard");
const res = middleware(req);

expect(res.headers.get("x-middleware-rewrite")).toBeNull();
});

it("passes through without rewrite for manifest.webmanifest and metadata files", () => {
const manifestReq = new NextRequest("http://localhost:3000/manifest.webmanifest");
expect(middleware(manifestReq).headers.get("x-middleware-rewrite")).toBeNull();

const robotsReq = new NextRequest("http://localhost:3000/robots.txt");
expect(middleware(robotsReq).headers.get("x-middleware-rewrite")).toBeNull();

const sitemapReq = new NextRequest("http://localhost:3000/sitemap.xml");
expect(middleware(sitemapReq).headers.get("x-middleware-rewrite")).toBeNull();

const faviconReq = new NextRequest("http://localhost:3000/favicon.ico");
expect(middleware(faviconReq).headers.get("x-middleware-rewrite")).toBeNull();
});
});
61 changes: 38 additions & 23 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,53 @@ import {
} from "@/lib/i18n/core";

export function middleware(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
const { pathname } = request.nextUrl;

// Bypass APIs, metadata files, and static assets
if (
pathname.startsWith("/api") ||
pathname === "/manifest.webmanifest" ||
pathname === "/robots.txt" ||
pathname === "/sitemap.xml" ||
pathname === "/favicon.ico" ||
/\.[a-zA-Z0-9]+$/.test(pathname)
) {
return NextResponse.next();
}

// Check if pathname already starts with a supported locale (e.g. /en or /ar)
const pathnameLocale = supportedLocales.find(
(loc) => pathname === `/${loc}` || pathname.startsWith(`/${loc}/`),
);

// If already localized, pass through
if (pathnameLocale) {
return NextResponse.next();
}

// Determine target locale from cookie, then accept-language, then default
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
const hasValidCookie = isSupportedLocale(cookieLocale);
const locale = hasValidCookie
? cookieLocale
: parseAcceptLanguage(request.headers.get("accept-language"), supportedLocales, DEFAULT_LOCALE);

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

const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
// Rewrite /path to /[locale]/path transparently
const rewriteUrl = request.nextUrl.clone();
rewriteUrl.pathname = `/${locale}${pathname === "/" ? "" : pathname}`;

const response = NextResponse.rewrite(rewriteUrl);

// If cookie wasn't set or was invalid, set it now for persistence
if (!hasValidCookie) {
response.cookies.set(LOCALE_COOKIE, locale, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
});

return response;
}

requestHeaders.set("x-locale", cookieLocale);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
return response;
}

// Run only on routes that produce HTML or read the cookie. Skip Next.js
Expand All @@ -49,6 +64,6 @@ export function middleware(request: NextRequest) {
// 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)$).*)",
"/((?!_next/static|_next/image|api|favicon.ico|robots.txt|sitemap.xml|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|woff|woff2|ttf|otf|webmanifest)$).*)",
],
};
Loading