diff --git a/demo/new-compiler-next16/app/.lingo/locale-resolver-client.ts b/demo/new-compiler-next16/app/.lingo/locale-resolver-client.ts new file mode 100644 index 000000000..f4ae2cb4e --- /dev/null +++ b/demo/new-compiler-next16/app/.lingo/locale-resolver-client.ts @@ -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); + } +} diff --git a/demo/new-compiler-next16/app/.lingo/locale-resolver-server.ts b/demo/new-compiler-next16/app/.lingo/locale-resolver-server.ts new file mode 100644 index 000000000..1968f89a4 --- /dev/null +++ b/demo/new-compiler-next16/app/.lingo/locale-resolver-server.ts @@ -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 { + const headersList = await headers(); + const locale = headersList.get("x-lingo-locale"); + return locale || "en"; +} diff --git a/demo/new-compiler-next16/app/[locale]/layout.tsx b/demo/new-compiler-next16/app/[locale]/layout.tsx new file mode 100644 index 000000000..d5c9cc55e --- /dev/null +++ b/demo/new-compiler-next16/app/[locale]/layout.tsx @@ -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 ( + + + + {children} + + + + ); +} diff --git a/demo/new-compiler-next16/app/page.tsx b/demo/new-compiler-next16/app/[locale]/page.tsx similarity index 96% rename from demo/new-compiler-next16/app/page.tsx rename to demo/new-compiler-next16/app/[locale]/page.tsx index 0345199f6..d11de384f 100644 --- a/demo/new-compiler-next16/app/page.tsx +++ b/demo/new-compiler-next16/app/[locale]/page.tsx @@ -1,6 +1,6 @@ import { Counter } from "@/components/Counter"; import { LocaleSwitcher } from "@lingo.dev/compiler/react"; -import Link from "next/link"; +import { Link } from "@/components/LocaleLink"; import { ServerChild } from "@/components/ServerChild"; import { ClientChildWrapper } from "@/components/ClientChildWrapper"; @@ -22,7 +22,7 @@ export default function Home() {
Lingo.dev compiler Next demo ) { - return ( - - - - {children} - - - - ); + return children; } diff --git a/demo/new-compiler-next16/components/LocaleLink.tsx b/demo/new-compiler-next16/components/LocaleLink.tsx new file mode 100644 index 000000000..9491b405e --- /dev/null +++ b/demo/new-compiler-next16/components/LocaleLink.tsx @@ -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) { + 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; + + return ; +} diff --git a/demo/new-compiler-next16/next.config.ts b/demo/new-compiler-next16/next.config.ts index 1118cc7b6..b938e1a7d 100644 --- a/demo/new-compiler-next16/next.config.ts +++ b/demo/new-compiler-next16/next.config.ts @@ -1,5 +1,7 @@ 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 = {}; @@ -7,13 +9,17 @@ export default async function (): Promise { 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", + }, }); } diff --git a/demo/new-compiler-next16/proxy.ts b/demo/new-compiler-next16/proxy.ts new file mode 100644 index 000000000..f6e213e29 --- /dev/null +++ b/demo/new-compiler-next16/proxy.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +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|.*\\..*).*)", + ], +}; diff --git a/demo/new-compiler-next16/supported-locales.ts b/demo/new-compiler-next16/supported-locales.ts new file mode 100644 index 000000000..ef1551d0f --- /dev/null +++ b/demo/new-compiler-next16/supported-locales.ts @@ -0,0 +1,4 @@ +import type { LocaleCode } from "@lingo.dev/compiler" +export const targetLocales: LocaleCode[] = ["es", "de", "ru"]; +export const sourceLocale: LocaleCode = "en"; +export const supportedLocales: LocaleCode[] = [...targetLocales, sourceLocale]; diff --git a/packages/new-compiler/src/index.ts b/packages/new-compiler/src/index.ts index 4d408c54e..7f23f621d 100644 --- a/packages/new-compiler/src/index.ts +++ b/packages/new-compiler/src/index.ts @@ -1 +1,2 @@ export type { PartialLingoConfig } from "./types"; +export type { LocaleCode } from "lingo.dev/spec"; diff --git a/packages/new-compiler/src/plugin/next.ts b/packages/new-compiler/src/plugin/next.ts index 588fb85f8..597be04d5 100644 --- a/packages/new-compiler/src/plugin/next.ts +++ b/packages/new-compiler/src/plugin/next.ts @@ -13,6 +13,7 @@ import { cleanupExistingMetadata, getMetadataPath } from "../metadata/manager"; import { registerCleanupOnCurrentProcess } from "./cleanup"; import { useI18nRegex } from "./transform/use-i18n"; import { TranslationService } from "../translators"; +import { resolveCustomResolverPaths } from "./resolve-locale-resolver"; export type LingoNextPluginOptions = PartialLingoConfig; @@ -259,9 +260,31 @@ export async function withLingo( ); const existingResolveAlias = existingTurbopackConfig.resolveAlias; + let customResolverAliases = {}; + + // Custom locale resolvers: + // When using custom resolvers (localePersistence.type === "custom"), + // we map abstract module paths to the user's actual files via Turbopack resolveAlias. + // This allows virtual modules to import from '@lingo.dev/compiler/virtual/locale-*' + // which Turbopack resolves to the user's actual locale resolver files. + // + // Convention: Resolver files must be at //locale-resolver-{server|client}.ts + if (lingoConfig.localePersistence.type === "custom") { + const resolvedPaths = resolveCustomResolverPaths( + lingoConfig.sourceRoot, + lingoConfig.lingoDir, + process.cwd(), + ); + + customResolverAliases = { + "@lingo.dev/compiler/virtual/locale-server": resolvedPaths.serverResolver, + "@lingo.dev/compiler/virtual/locale-client": resolvedPaths.clientResolver, + }; + } + const mergedResolveAlias = { ...existingResolveAlias, - // TODO (AleksandrSl 08/12/2025): Describe what have to be done to support custom resolvers + ...customResolverAliases, }; let turbopackConfig: Partial; diff --git a/packages/new-compiler/src/plugin/resolve-locale-resolver.ts b/packages/new-compiler/src/plugin/resolve-locale-resolver.ts new file mode 100644 index 000000000..27609ad25 --- /dev/null +++ b/packages/new-compiler/src/plugin/resolve-locale-resolver.ts @@ -0,0 +1,89 @@ +/** + * Utilities for resolving custom locale resolver paths + */ + +import fs from "fs"; +import path from "path"; + +const EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]; + +/** + * Normalize path for Turbopack compatibility + * Turbopack requires forward slashes, even on Windows + */ +export function normalizeTurbopackPath(filePath: string): string { + return filePath.replace(/\\/g, "/"); +} + +/** + * Resolve a locale resolver file path + * Tries the provided path with various extensions + * + * @param basePath - Base path from config (e.g., "./locale-resolver-server") + * @param projectRoot - Project root directory + * @returns Resolved absolute path + */ +function resolveResolverPath(basePath: string, projectRoot: string): string { + // Try with the provided extension first + const absolutePath = path.resolve(projectRoot, basePath); + if (fs.existsSync(absolutePath)) { + return absolutePath; + } + + for (const ext of EXTENSIONS) { + const pathWithExt = absolutePath + ext; + if (fs.existsSync(pathWithExt)) { + return pathWithExt; + } + } + + return absolutePath; +} + +/** + * Resolve custom locale resolver paths for Turbopack + * + * Convention: Custom resolvers must be located at: + * - //locale-resolver-server.ts (or .js, .tsx, .jsx) + * - //locale-resolver-client.ts (or .js, .tsx, .jsx) + * + * Returns relative, normalized paths for Turbopack. + * Turbopack requires relative paths (starting with ./ or ../) to properly + * bundle user files. Absolute paths are treated as external modules. + * + * @param sourceRoot - Source root directory (e.g., "./app") + * @param lingoDir - Lingo directory (e.g., ".lingo") + * @param projectRoot - Project root directory (for resolving absolute paths) + * @returns Object with normalized server and client resolver paths (relative to projectRoot) + */ +export function resolveCustomResolverPaths( + sourceRoot: string, + lingoDir: string, + projectRoot: string, +): { + serverResolver: string; + clientResolver: string; +} { + const baseDir = path.join(projectRoot, sourceRoot, lingoDir); + + const serverPath = resolveResolverPath("locale-resolver-server", baseDir); + const clientPath = resolveResolverPath("locale-resolver-client", baseDir); + + // Convert absolute paths to relative paths from projectRoot + // Turbopack needs relative paths to bundle them correctly + const relativeServerPath = path.relative(projectRoot, serverPath); + const relativeClientPath = path.relative(projectRoot, clientPath); + + // Ensure paths start with ./ for Turbopack compatibility + const serverWithPrefix = relativeServerPath.startsWith(".") + ? relativeServerPath + : `./${relativeServerPath}`; + const clientWithPrefix = relativeClientPath.startsWith(".") + ? relativeClientPath + : `./${relativeClientPath}`; + + return { + serverResolver: normalizeTurbopackPath(serverWithPrefix), + clientResolver: normalizeTurbopackPath(clientWithPrefix), + }; +} diff --git a/packages/new-compiler/src/react/shared/LingoProvider.tsx b/packages/new-compiler/src/react/shared/LingoProvider.tsx index f9700d3b2..80c5b1de9 100644 --- a/packages/new-compiler/src/react/shared/LingoProvider.tsx +++ b/packages/new-compiler/src/react/shared/LingoProvider.tsx @@ -38,8 +38,9 @@ export type LingoProviderProps = PropsWithChildren<{ * Optional router instance for Next.js integration * If provided, calls router.refresh() after locale change * This ensures Server Components re-render with new locale + * For path-based routing, also needs push() for navigation */ - router?: { refresh: () => void }; + router?: { refresh: () => void; push: (path: string) => void }; /** * Development widget configuration @@ -178,22 +179,26 @@ function LingoProvider__Prod({ /** * Change locale - * - For Next.js SSR: triggers server re-render via router.refresh() + * - For path-based routing: uses router.push() to navigate + * - For cookie-based routing: uses persistLocale() + router.refresh() * - For SPAs: lazy loads translations from /translations/{locale}.json */ const setLocale = useCallback( async (newLocale: LocaleCode) => { - // 1. Persist to cookie so server can read it on next render - persistLocale(newLocale); + const newUrl = persistLocale(newLocale); - // 2. Update local state for immediate UI feedback + // Update local state for immediate UI feedback setLocaleState(newLocale); - // 3a. Next.js pattern: Trigger server re-render + // Next.js pattern: Trigger server re-render if (router) { - router.refresh(); + if (newUrl) { + router.push(newUrl); + } else { + router.refresh(); + } } - // 3b. SPA pattern: Lazy load translations + // SPA pattern: Lazy load translations else { await loadTranslations(newLocale); } @@ -399,15 +404,17 @@ function LingoProvider__Dev({ */ const setLocale = useCallback( async (newLocale: LocaleCode) => { - // 1. Persist to cookie (unless disabled) - persistLocale(newLocale); + const newUrl = persistLocale(newLocale); - // 2. Update state setLocaleState(newLocale); - // 3. Reload Server Components (if router provided) + // Reload Server Components (if router provided) if (router) { - router.refresh(); + if (newUrl) { + router.push(newUrl); + } else { + router.refresh(); + } } // Fetch translations from API endpoint diff --git a/packages/new-compiler/src/translation-server/translation-server.ts b/packages/new-compiler/src/translation-server/translation-server.ts index 73c257b1b..af89321b4 100644 --- a/packages/new-compiler/src/translation-server/translation-server.ts +++ b/packages/new-compiler/src/translation-server/translation-server.ts @@ -314,7 +314,7 @@ export class TranslationServer { this.metadata = await loadMetadata(getMetadataPath(this.config)); this.logger.debug( `Reloaded metadata: ${Object.keys(this.metadata).length} entries`, - ); + ); } catch (error) { this.logger.warn("Failed to reload metadata:", error); this.metadata = {}; diff --git a/packages/new-compiler/src/types.ts b/packages/new-compiler/src/types.ts index e57fca975..c0dec00e8 100644 --- a/packages/new-compiler/src/types.ts +++ b/packages/new-compiler/src/types.ts @@ -24,9 +24,11 @@ export interface CookieConfig { /** * Locale persistence configuration - * Currently only supports cookie-based persistence + * Supports cookie-based persistence or custom resolvers */ -export type LocalePersistenceConfig = { type: "cookie"; config: CookieConfig }; +export type LocalePersistenceConfig = + | { type: "cookie"; config: CookieConfig } + | { type: "custom"; }; /** * Field that we require users to fill in the config. The rest could be taken from defaults. diff --git a/packages/new-compiler/src/virtual/code-generator.ts b/packages/new-compiler/src/virtual/code-generator.ts index 2c7883326..614b29055 100644 --- a/packages/new-compiler/src/virtual/code-generator.ts +++ b/packages/new-compiler/src/virtual/code-generator.ts @@ -28,6 +28,13 @@ export const sourceLocale = ${JSON.stringify(config.sourceLocale)}; * Exports async getServerLocale() function */ export function generateServerLocaleModule(config: LingoConfig): string { + if (config.localePersistence.type === "custom") { + // For custom resolvers, import from an abstract path that will be + // resolved by Turbopack's resolveAlias to the actual user file + return `export { getServerLocale } from '@lingo.dev/compiler/virtual/locale-server';`; + } + + // Default cookie-based resolver return ` import { createNextCookieLocaleResolver } from '@lingo.dev/compiler/react/next'; export const getServerLocale = createNextCookieLocaleResolver({ cookieConfig: ${JSON.stringify(config.localePersistence.config)}, defaultLocale: ${JSON.stringify(config.sourceLocale)} }); @@ -39,6 +46,13 @@ export const getServerLocale = createNextCookieLocaleResolver({ cookieConfig: ${ * Exports getClientLocale() and persistLocale() functions */ export function generateClientLocaleModule(config: LingoConfig): string { + if (config.localePersistence.type === "custom") { + // For custom resolvers, import from an abstract path that will be + // resolved by Turbopack's resolveAlias to the actual user file + return `export { getClientLocale, persistLocale } from '@lingo.dev/compiler/virtual/locale-client';`; + } + + // Default cookie-based resolver const cookieName = config.localePersistence.config.name; const maxAge = config.localePersistence.config.maxAge; @@ -57,5 +71,9 @@ export function persistLocale(locale) { document.cookie = \`${cookieName}=\${locale}; path=/; max-age=${maxAge}\`; } } + +export function getLocalePathname(locale) { + return null; // Not used for cookie-based routing +} `; } diff --git a/packages/new-compiler/src/virtual/locale/client.ts b/packages/new-compiler/src/virtual/locale/client.ts index e9e3fd532..504becfa6 100644 --- a/packages/new-compiler/src/virtual/locale/client.ts +++ b/packages/new-compiler/src/virtual/locale/client.ts @@ -2,20 +2,20 @@ import type { LocaleCode } from "lingo.dev/spec"; /** * Get the current locale on the client - * Reads from cookie * @returns Resolved locale code */ export function getClientLocale(): LocaleCode { return "en"; } -const __NOOP_PERSIST_LOCALE__ = () => {}; +const __NOOP_PERSIST_LOCALE__ = () => { return undefined }; /** * Persist the locale on the client - * Writes to cookie * @param locale - Locale code to persist + * + * May return the new url in case the redirect is needed after setting the locale */ -export function persistLocale(locale: LocaleCode): void { +export function persistLocale(locale: LocaleCode): string | undefined { return __NOOP_PERSIST_LOCALE__(); }