Skip to content

Commit 28dad49

Browse files
committed
feat: implement custom path resolving
1 parent 109649d commit 28dad49

16 files changed

Lines changed: 455 additions & 53 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Custom path-based locale resolver for Next.js client-side
3+
*
4+
* These are utility functions (not hooks) that can be called from anywhere,
5+
* including inside callbacks, event handlers, etc.
6+
*
7+
* Note: We use window.location instead of Next.js hooks because these functions
8+
* are called from within useCallback/event handlers where hooks cannot be used.
9+
*/
10+
11+
"use client";
12+
13+
import type { LocaleCode } from "@lingo.dev/compiler"
14+
import { sourceLocale } from "../../supported-locales";
15+
16+
/**
17+
* Get locale from the current pathname
18+
*
19+
* This is a regular function (not a hook) that can be called from anywhere.
20+
* It reads from window.location.pathname to extract the locale.
21+
*
22+
* @returns Locale code extracted from path or default locale
23+
*/
24+
export function getClientLocale(): LocaleCode {
25+
if (typeof window === "undefined") {
26+
return sourceLocale;
27+
}
28+
29+
try {
30+
const pathname = window.location.pathname;
31+
const segments = pathname.split("/").filter(Boolean);
32+
const potentialLocale = segments[0];
33+
34+
if (potentialLocale) {
35+
return potentialLocale as LocaleCode;
36+
}
37+
38+
return sourceLocale;
39+
} catch (error) {
40+
console.error("Error resolving locale from path:", error);
41+
return sourceLocale;
42+
}
43+
}
44+
45+
/**
46+
* Get the pathname for a given locale
47+
*
48+
* This is a utility function that computes what the path should be for a locale change.
49+
* It doesn't perform navigation - the caller is responsible for that.
50+
*
51+
* @param locale - Locale to switch to
52+
* @returns The new pathname with the locale prefix
53+
*/
54+
function getLocalePathname(locale: LocaleCode): string {
55+
if (typeof window === "undefined") {
56+
return `/${locale}`;
57+
}
58+
59+
try {
60+
const pathname = window.location.pathname;
61+
const segments = pathname.split("/").filter(Boolean);
62+
63+
// Replace the first segment (current locale) with the new locale
64+
if (segments[0]) {
65+
segments[0] = locale;
66+
} else {
67+
// If no segments, just add the locale
68+
segments.unshift(locale);
69+
}
70+
71+
return "/" + segments.join("/");
72+
} catch (error) {
73+
console.error("Error computing locale pathname:", error);
74+
return `/${locale}`;
75+
}
76+
}
77+
78+
/**
79+
* Returns new URL that will be used to navigate to the new locale
80+
*
81+
* @param locale - Locale to switch to
82+
*/
83+
export function persistLocale(locale: LocaleCode): string | undefined {
84+
if (typeof window === "undefined") {
85+
return;
86+
}
87+
88+
try {
89+
const newPath = getLocalePathname(locale);
90+
const search = window.location.search;
91+
const hash = window.location.hash;
92+
return newPath + search + hash;
93+
} catch (error) {
94+
console.error("Error persisting locale:", error);
95+
}
96+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Custom path-based locale resolver for Next.js server-side
3+
*
4+
* This resolver uses the next-intl pattern:
5+
* - Middleware extracts locale from URL path
6+
* - Middleware sets x-lingo-locale header
7+
* - Server components read this header
8+
*
9+
* This allows all Server Components to reliably access the locale
10+
* without needing to parse URLs or receive it via props.
11+
*
12+
* Falls back to the default locale if header is not set.
13+
*/
14+
15+
import { headers } from "next/headers";
16+
17+
/**
18+
* Get locale from middleware-set header
19+
*
20+
* The middleware extracts the locale from the URL path (e.g., /es/about)
21+
* and sets it in the x-lingo-locale header. This function reads that header.
22+
*
23+
* @returns Locale code from x-lingo-locale header or default locale
24+
*/
25+
export async function getServerLocale(): Promise<string> {
26+
const headersList = await headers();
27+
const locale = headersList.get("x-lingo-locale");
28+
return locale || "en";
29+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Metadata } from "next";
2+
import { Geist, Geist_Mono } from "next/font/google";
3+
import "../globals.css";
4+
import { LingoProvider } from "@lingo.dev/compiler/react/next";
5+
import type { ReactNode } from "react";
6+
import type { LocaleCode } from "@lingo.dev/compiler";
7+
8+
const geistSans = Geist({
9+
variable: "--font-geist-sans",
10+
subsets: ["latin"],
11+
});
12+
13+
const geistMono = Geist_Mono({
14+
variable: "--font-geist-mono",
15+
subsets: ["latin"],
16+
});
17+
18+
export const metadata: Metadata = {
19+
title: "Create Next App",
20+
description: "Generated by create next app",
21+
};
22+
23+
export default async function LocaleLayout({
24+
children,
25+
params,
26+
}: Readonly<{
27+
children: ReactNode;
28+
params: Promise<{ locale: LocaleCode }>;
29+
}>) {
30+
const { locale } = await params;
31+
32+
console.debug("LocaleLayout", { locale });
33+
return (
34+
<LingoProvider initialLocale={locale}>
35+
<html lang={locale}>
36+
<body
37+
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
38+
>
39+
{children}
40+
</body>
41+
</html>
42+
</LingoProvider>
43+
);
44+
}
File renamed without changes.
File renamed without changes.
Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,13 @@
1-
import type { Metadata } from "next";
2-
import { Geist, Geist_Mono } from "next/font/google";
3-
import "./globals.css";
4-
import { LingoProvider } from "@lingo.dev/compiler/react/next";
5-
6-
const geistSans = Geist({
7-
variable: "--font-geist-sans",
8-
subsets: ["latin"],
9-
});
10-
11-
const geistMono = Geist_Mono({
12-
variable: "--font-geist-mono",
13-
subsets: ["latin"],
14-
});
15-
16-
export const metadata: Metadata = {
17-
title: "Create Next App",
18-
description: "Generated by create next app",
19-
};
1+
import { ReactNode } from "react";
202

3+
/**
4+
* Root layout - minimal wrapper
5+
* The actual locale-aware layout is in [locale]/layout.tsx
6+
*/
217
export default function RootLayout({
228
children,
239
}: Readonly<{
24-
children: React.ReactNode;
10+
children: ReactNode;
2511
}>) {
26-
return (
27-
<LingoProvider>
28-
<html>
29-
<body
30-
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
31-
>
32-
{children}
33-
</body>
34-
</html>
35-
</LingoProvider>
36-
);
12+
return children;
3713
}
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import type { NextConfig } from "next";
22
import { withLingo } from "@lingo.dev/compiler/next";
3+
import { sourceLocale } from "./supported-locales";
4+
import { targetLocales } from "@/supported-locales";
35

46
const nextConfig: NextConfig = {};
57

68
export default async function (): Promise<NextConfig> {
79
return await withLingo(nextConfig, {
810
sourceRoot: "./app",
911
lingoDir: ".lingo",
10-
sourceLocale: "en",
11-
targetLocales: ["es", "de", "ru"],
12+
sourceLocale,
13+
targetLocales,
1214
useDirective: false, // Set to true to require 'use i18n' directive
1315
models: "lingo.dev",
1416
dev: {
1517
usePseudotranslator: true,
1618
},
1719
buildMode: "cache-only",
20+
// Use custom path-based locale resolver instead of cookies
21+
localePersistence: {
22+
type: "custom",
23+
},
1824
});
1925
}

demo/new-compiler-next16/proxy.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { sourceLocale, supportedLocales } from "@/supported-locales";
3+
4+
const SUPPORTED_LOCALES = supportedLocales;
5+
const DEFAULT_LOCALE = sourceLocale;
6+
7+
8+
/**
9+
* Get the preferred locale from Accept-Language header
10+
*/
11+
function getLocaleFromHeader(request: NextRequest): string | null {
12+
const acceptLanguage = request.headers.get("accept-language");
13+
if (!acceptLanguage) return null;
14+
15+
// Parse Accept-Language header (e.g., "en-US,en;q=0.9,es;q=0.8")
16+
const languages = acceptLanguage
17+
.split(",")
18+
.map((lang) => {
19+
const [code, qValue] = lang.trim().split(";q=");
20+
const quality = qValue ? parseFloat(qValue) : 1.0;
21+
// Extract base language code (e.g., "en" from "en-US")
22+
const baseCode = code.split("-")[0].toLowerCase();
23+
return { code: baseCode, quality };
24+
})
25+
.sort((a, b) => b.quality - a.quality);
26+
27+
// Find first supported locale
28+
for (const { code } of languages) {
29+
if (SUPPORTED_LOCALES.includes(code)) {
30+
return code;
31+
}
32+
}
33+
34+
return null;
35+
}
36+
37+
/**
38+
* Extract locale from pathname
39+
* Returns the locale code if found in the path, otherwise null
40+
*/
41+
function getLocaleFromPath(pathname: string): string | null {
42+
// Extract first segment
43+
const segments = pathname.split("/").filter(Boolean);
44+
const potentialLocale = segments[0];
45+
46+
if (
47+
potentialLocale &&
48+
SUPPORTED_LOCALES.includes(potentialLocale)
49+
) {
50+
return potentialLocale;
51+
}
52+
53+
return null;
54+
}
55+
56+
/**
57+
* Middleware to handle locale-based routing following Next.js 16 patterns
58+
*
59+
* Similar to next-intl's approach:
60+
* - Detects locale from URL path first
61+
* - Falls back to Accept-Language header for locale detection
62+
* - Redirects to appropriate locale if missing
63+
* - Sets x-lingo-locale header for Server Components (like next-intl does)
64+
*/
65+
export function proxy(request: NextRequest) {
66+
const pathname = request.nextUrl.pathname;
67+
68+
// Try to extract locale from path
69+
const localeFromPath = getLocaleFromPath(pathname);
70+
71+
if (localeFromPath) {
72+
// Already has locale in path, continue with request
73+
// BUT add x-lingo-locale header so Server Components can read it
74+
// This is the key pattern from next-intl!
75+
const response = NextResponse.next();
76+
response.headers.set("x-lingo-locale", localeFromPath);
77+
return response;
78+
}
79+
80+
// No locale in pathname - determine which locale to use
81+
const preferredLocale = getLocaleFromHeader(request) || DEFAULT_LOCALE;
82+
83+
// Redirect to locale-prefixed path
84+
const url = request.nextUrl.clone();
85+
url.pathname = `/${preferredLocale}${pathname === "/" ? "" : pathname}`;
86+
87+
return NextResponse.redirect(url);
88+
}
89+
90+
export const config = {
91+
// Match all pathnames except for:
92+
// - /api (API routes)
93+
// - /_next (Next.js internals)
94+
// - /_vercel (Vercel internals)
95+
// - /favicon.ico, /robots.txt (static files in public)
96+
// - Files with extensions (e.g., .js, .css, .png, .svg, etc.)
97+
matcher: [
98+
/*
99+
* Match all request paths except for the ones starting with:
100+
* - api (API routes)
101+
* - _next (Next.js internals)
102+
* - _vercel (Vercel internals)
103+
* - Files with extensions (static files)
104+
*/
105+
"/((?!api|_next|_vercel|.*\\..*).*)",
106+
],
107+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import type { LocaleCode } from "@lingo.dev/compiler"
2+
export const targetLocales: LocaleCode[] = ["es", "de", "ru"];
3+
export const sourceLocale: LocaleCode = "en";
4+
export const supportedLocales: LocaleCode[] = [...targetLocales, sourceLocale];

packages/new-compiler/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export type { PartialLingoConfig } from "./types";
2+
export type { LocaleCode } from "lingo.dev/spec";

0 commit comments

Comments
 (0)