-
Notifications
You must be signed in to change notification settings - Fork 845
feat(compiler): custom resolvers #2024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
11d121e
109649d
28dad49
69ba2e5
aff4651
3c4fa7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| /** | ||
| * Custom path-based locale resolver for Next.js client-side | ||
| * | ||
| * These are utility functions (not hooks) that can be called from anywhere, | ||
| * including inside callbacks, event handlers, etc. | ||
| * | ||
| * Note: We use window.location instead of Next.js hooks because these functions | ||
| * are called from within useCallback/event handlers where hooks cannot be used. | ||
| */ | ||
|
|
||
| "use client"; | ||
|
|
||
| import type { LocaleCode } from "@lingo.dev/compiler" | ||
| import { sourceLocale } from "../../supported-locales"; | ||
|
|
||
| /** | ||
| * Get locale from the current pathname | ||
| * | ||
| * This is a regular function (not a hook) that can be called from anywhere. | ||
| * It reads from window.location.pathname to extract the locale. | ||
| * | ||
| * @returns Locale code extracted from path or default locale | ||
| */ | ||
| export function getClientLocale(): LocaleCode { | ||
| if (typeof window === "undefined") { | ||
| return sourceLocale; | ||
| } | ||
|
|
||
| try { | ||
| const pathname = window.location.pathname; | ||
| const segments = pathname.split("/").filter(Boolean); | ||
| const potentialLocale = segments[0]; | ||
|
|
||
| if (potentialLocale) { | ||
| return potentialLocale as LocaleCode; | ||
| } | ||
|
|
||
| return sourceLocale; | ||
| } catch (error) { | ||
| console.error("Error resolving locale from path:", error); | ||
| return sourceLocale; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Get the pathname for a given locale | ||
| * | ||
| * This is a utility function that computes what the path should be for a locale change. | ||
| * It doesn't perform navigation - the caller is responsible for that. | ||
| * | ||
| * @param locale - Locale to switch to | ||
| * @returns The new pathname with the locale prefix | ||
| */ | ||
| function getLocalePathname(locale: LocaleCode): string { | ||
| if (typeof window === "undefined") { | ||
| return `/${locale}`; | ||
| } | ||
|
|
||
| try { | ||
| const pathname = window.location.pathname; | ||
| const segments = pathname.split("/").filter(Boolean); | ||
|
|
||
| // Replace the first segment (current locale) with the new locale | ||
| if (segments[0]) { | ||
| segments[0] = locale; | ||
| } else { | ||
| // If no segments, just add the locale | ||
| segments.unshift(locale); | ||
| } | ||
|
|
||
| return "/" + segments.join("/"); | ||
| } catch (error) { | ||
| console.error("Error computing locale pathname:", error); | ||
| return `/${locale}`; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Returns new URL that will be used to navigate to the new locale | ||
| * | ||
| * @param locale - Locale to switch to | ||
| */ | ||
| export function persistLocale(locale: LocaleCode): string | undefined { | ||
| if (typeof window === "undefined") { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const newPath = getLocalePathname(locale); | ||
| const search = window.location.search; | ||
| const hash = window.location.hash; | ||
| return newPath + search + hash; | ||
| } catch (error) { | ||
| console.error("Error persisting locale:", error); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| /** | ||
| * Custom path-based locale resolver for Next.js server-side | ||
| * | ||
| * This resolver uses the next-intl pattern: | ||
| * - Middleware extracts locale from URL path | ||
| * - Middleware sets x-lingo-locale header | ||
| * - Server components read this header | ||
| * | ||
| * This allows all Server Components to reliably access the locale | ||
| * without needing to parse URLs or receive it via props. | ||
| * | ||
| * Falls back to the default locale if header is not set. | ||
| */ | ||
|
|
||
| import { headers } from "next/headers"; | ||
|
|
||
| /** | ||
| * Get locale from middleware-set header | ||
| * | ||
| * The middleware extracts the locale from the URL path (e.g., /es/about) | ||
| * and sets it in the x-lingo-locale header. This function reads that header. | ||
| * | ||
| * @returns Locale code from x-lingo-locale header or default locale | ||
| */ | ||
| export async function getServerLocale(): Promise<string> { | ||
| const headersList = await headers(); | ||
| const locale = headersList.get("x-lingo-locale"); | ||
| return locale || "en"; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import type { Metadata } from "next"; | ||
| import { Geist, Geist_Mono } from "next/font/google"; | ||
| import "../globals.css"; | ||
| import { LingoProvider } from "@lingo.dev/compiler/react/next"; | ||
| import type { ReactNode } from "react"; | ||
| import type { LocaleCode } from "@lingo.dev/compiler"; | ||
|
|
||
| const geistSans = Geist({ | ||
| variable: "--font-geist-sans", | ||
| subsets: ["latin"], | ||
| }); | ||
|
|
||
| const geistMono = Geist_Mono({ | ||
| variable: "--font-geist-mono", | ||
| subsets: ["latin"], | ||
| }); | ||
|
|
||
| export const metadata: Metadata = { | ||
| title: "Create Next App", | ||
| description: "Generated by create next app", | ||
| }; | ||
|
|
||
| export default async function LocaleLayout({ | ||
| children, | ||
| params, | ||
| }: Readonly<{ | ||
| children: ReactNode; | ||
| params: Promise<{ locale: LocaleCode }>; | ||
| }>) { | ||
| const { locale } = await params; | ||
|
|
||
| console.debug("LocaleLayout", { locale }); | ||
| return ( | ||
| <LingoProvider initialLocale={locale}> | ||
| <html lang={locale}> | ||
| <body | ||
| className={`${geistSans.variable} ${geistMono.variable} antialiased`} | ||
| > | ||
| {children} | ||
| </body> | ||
| </html> | ||
| </LingoProvider> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,37 +1,13 @@ | ||
| import type { Metadata } from "next"; | ||
| import { Geist, Geist_Mono } from "next/font/google"; | ||
| import "./globals.css"; | ||
| import { LingoProvider } from "@lingo.dev/compiler/react/next"; | ||
|
|
||
| const geistSans = Geist({ | ||
| variable: "--font-geist-sans", | ||
| subsets: ["latin"], | ||
| }); | ||
|
|
||
| const geistMono = Geist_Mono({ | ||
| variable: "--font-geist-mono", | ||
| subsets: ["latin"], | ||
| }); | ||
|
|
||
| export const metadata: Metadata = { | ||
| title: "Create Next App", | ||
| description: "Generated by create next app", | ||
| }; | ||
| import { ReactNode } from "react"; | ||
|
|
||
| /** | ||
| * Root layout - minimal wrapper | ||
| * The actual locale-aware layout is in [locale]/layout.tsx | ||
| */ | ||
| export default function RootLayout({ | ||
| children, | ||
| }: Readonly<{ | ||
| children: React.ReactNode; | ||
| children: ReactNode; | ||
| }>) { | ||
| return ( | ||
| <LingoProvider> | ||
| <html> | ||
| <body | ||
| className={`${geistSans.variable} ${geistMono.variable} antialiased`} | ||
| > | ||
| {children} | ||
| </body> | ||
| </html> | ||
| </LingoProvider> | ||
| ); | ||
| return children; | ||
| } | ||
|
Comment on lines
7
to
13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, the top-level app/layout.tsx root layout in Next.js 16 App Router still requires both <html> and <body> tags. Citations:
Add The top-level 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| "use client"; | ||
|
|
||
| import NextLink from "next/link"; | ||
| import { useLingoContext } from "@lingo.dev/compiler/react"; | ||
| import type { ComponentProps } from "react"; | ||
|
|
||
| /** | ||
| * Link component that automatically prefixes hrefs with current locale | ||
| */ | ||
| export function Link({ href, ...props }: ComponentProps<typeof NextLink>) { | ||
| const { locale } = useLingoContext(); | ||
|
|
||
| // If href is already locale-prefixed or external, use as-is | ||
| const localizedHref = typeof href === "string" && !href.startsWith("http") && !href.startsWith(`/${locale}`) | ||
| ? `/${locale}${href.startsWith("/") ? "" : "/"}${href}` | ||
| : href; | ||
|
Comment on lines
+13
to
+16
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Locale prefix check only guards against current locale, not all supported locales. If Consider checking against all supported locales: 🐛 Proposed fix+"use client";
+
+import NextLink from "next/link";
+import { useLingoContext } from "@lingo.dev/compiler/react";
+import type { ComponentProps } from "react";
+import { supportedLocales } from "@/supported-locales";
+
+/**
+ * Check if href already starts with a supported locale prefix
+ */
+function hasLocalePrefix(href: string): boolean {
+ return supportedLocales.some(
+ (loc) => href === `/${loc}` || href.startsWith(`/${loc}/`)
+ );
+}
+
/**
* Link component that automatically prefixes hrefs with current locale
*/
export function Link({ href, ...props }: ComponentProps<typeof NextLink>) {
const { locale } = useLingoContext();
- // If href is already locale-prefixed or external, use as-is
- const localizedHref = typeof href === "string" && !href.startsWith("http") && !href.startsWith(`/${locale}`)
+ // If href is already locale-prefixed or external, use as-is
+ const localizedHref = typeof href === "string" && !href.startsWith("http") && !hasLocalePrefix(href)
? `/${locale}${href.startsWith("/") ? "" : "/"}${href}`
: href;
return <NextLink href={localizedHref} {...props} />;
}🤖 Prompt for AI Agents |
||
|
|
||
| return <NextLink href={localizedHref} {...props} />; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,25 @@ | ||
| import type { NextConfig } from "next"; | ||
| import { withLingo } from "@lingo.dev/compiler/next"; | ||
| import { sourceLocale } from "./supported-locales"; | ||
| import { targetLocales } from "@/supported-locales"; | ||
|
|
||
| const nextConfig: NextConfig = {}; | ||
|
|
||
| export default async function (): Promise<NextConfig> { | ||
| return await withLingo(nextConfig, { | ||
| sourceRoot: "./app", | ||
| lingoDir: ".lingo", | ||
| sourceLocale: "en", | ||
| targetLocales: ["es", "de", "ru"], | ||
| sourceLocale, | ||
| targetLocales, | ||
| useDirective: false, // Set to true to require 'use i18n' directive | ||
| models: "lingo.dev", | ||
| dev: { | ||
| usePseudotranslator: true, | ||
| }, | ||
| buildMode: "cache-only", | ||
| // Use custom path-based locale resolver instead of cookies | ||
| localePersistence: { | ||
| type: "custom", | ||
| }, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's 2026 why the hell Next asks users to configure all this hell when they want path based i18n |
||
| import { sourceLocale, supportedLocales } from "@/supported-locales"; | ||
|
|
||
| const SUPPORTED_LOCALES = supportedLocales; | ||
| const DEFAULT_LOCALE = sourceLocale; | ||
|
|
||
|
|
||
| /** | ||
| * Get the preferred locale from Accept-Language header | ||
| */ | ||
| function getLocaleFromHeader(request: NextRequest): string | null { | ||
| const acceptLanguage = request.headers.get("accept-language"); | ||
| if (!acceptLanguage) return null; | ||
|
|
||
| // Parse Accept-Language header (e.g., "en-US,en;q=0.9,es;q=0.8") | ||
| const languages = acceptLanguage | ||
| .split(",") | ||
| .map((lang) => { | ||
| const [code, qValue] = lang.trim().split(";q="); | ||
| const quality = qValue ? parseFloat(qValue) : 1.0; | ||
| // Extract base language code (e.g., "en" from "en-US") | ||
| const baseCode = code.split("-")[0].toLowerCase(); | ||
| return { code: baseCode, quality }; | ||
| }) | ||
| .sort((a, b) => b.quality - a.quality); | ||
|
|
||
| // Find first supported locale | ||
| for (const { code } of languages) { | ||
| if (SUPPORTED_LOCALES.includes(code)) { | ||
| return code; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Extract locale from pathname | ||
| * Returns the locale code if found in the path, otherwise null | ||
| */ | ||
| function getLocaleFromPath(pathname: string): string | null { | ||
| // Extract first segment | ||
| const segments = pathname.split("/").filter(Boolean); | ||
| const potentialLocale = segments[0]; | ||
|
|
||
| if ( | ||
| potentialLocale && | ||
| SUPPORTED_LOCALES.includes(potentialLocale) | ||
| ) { | ||
| return potentialLocale; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Middleware to handle locale-based routing following Next.js 16 patterns | ||
| * | ||
| * Similar to next-intl's approach: | ||
| * - Detects locale from URL path first | ||
| * - Falls back to Accept-Language header for locale detection | ||
| * - Redirects to appropriate locale if missing | ||
| * - Sets x-lingo-locale header for Server Components (like next-intl does) | ||
| */ | ||
| export function proxy(request: NextRequest) { | ||
| const pathname = request.nextUrl.pathname; | ||
|
|
||
| // Try to extract locale from path | ||
| const localeFromPath = getLocaleFromPath(pathname); | ||
|
|
||
| if (localeFromPath) { | ||
| // Already has locale in path, continue with request | ||
| // BUT add x-lingo-locale header so Server Components can read it | ||
| // This is the key pattern from next-intl! | ||
| const response = NextResponse.next(); | ||
| response.headers.set("x-lingo-locale", localeFromPath); | ||
| return response; | ||
| } | ||
|
|
||
| // No locale in pathname - determine which locale to use | ||
| const preferredLocale = getLocaleFromHeader(request) || DEFAULT_LOCALE; | ||
|
|
||
| // Redirect to locale-prefixed path | ||
| const url = request.nextUrl.clone(); | ||
| url.pathname = `/${preferredLocale}${pathname === "/" ? "" : pathname}`; | ||
|
|
||
| return NextResponse.redirect(url); | ||
| } | ||
|
|
||
| export const config = { | ||
| // Match all pathnames except for: | ||
| // - /api (API routes) | ||
| // - /_next (Next.js internals) | ||
| // - /_vercel (Vercel internals) | ||
| // - /favicon.ico, /robots.txt (static files in public) | ||
| // - Files with extensions (e.g., .js, .css, .png, .svg, etc.) | ||
| matcher: [ | ||
| /* | ||
| * Match all request paths except for the ones starting with: | ||
| * - api (API routes) | ||
| * - _next (Next.js internals) | ||
| * - _vercel (Vercel internals) | ||
| * - Files with extensions (static files) | ||
| */ | ||
| "/((?!api|_next|_vercel|.*\\..*).*)", | ||
| ], | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import type { LocaleCode } from "@lingo.dev/compiler" | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a way to avoid repeating these |
||
| export const targetLocales: LocaleCode[] = ["es", "de", "ru"]; | ||
| export const sourceLocale: LocaleCode = "en"; | ||
| export const supportedLocales: LocaleCode[] = [...targetLocales, sourceLocale]; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export type { PartialLingoConfig } from "./types"; | ||
| export type { LocaleCode } from "lingo.dev/spec"; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense to reexport I think, because it's used in many places, and compiler could be the single dependency. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing validation against supported locales.
Unlike
proxy.tswhich validatesSUPPORTED_LOCALES.includes(potentialLocale), this function returns any first path segment as the locale without validation. This could cause client/server mismatch if an invalid locale somehow reaches the URL.Consider validating against the supported locales list:
🛡️ Proposed fix
import type { LocaleCode } from "@lingo.dev/compiler" -import { sourceLocale } from "../../supported-locales"; +import { sourceLocale, supportedLocales } from "../../supported-locales"; // ... in getClientLocale(): const potentialLocale = segments[0]; - if (potentialLocale) { - return potentialLocale as LocaleCode; + if (potentialLocale && supportedLocales.includes(potentialLocale as LocaleCode)) { + return potentialLocale as LocaleCode; }🤖 Prompt for AI Agents