From 11d121e8a5654c317c54b90789672b03e54cbb0f Mon Sep 17 00:00:00 2001 From: Aleksandr Slepchenkov Date: Sat, 28 Feb 2026 23:21:16 +0100 Subject: [PATCH 1/5] chore: cleanup translation-server There were a bunch of leftover from experiments. e.g. _lingoConfig was a hack, a field with a config copy added to the config object which can be read by the translation-server cli to parse the arguments. But bundlers were always complaining about it, plus in next with our async setup it would not work. So there is no way to parse the config now. We could add some though if needed, but honestly parsing the file and finding our config section. --- packages/new-compiler/src/plugin/next.ts | 42 +-- .../src/translation-server/README.md | 259 +++------------- .../src/translation-server/cli.ts | 284 +++--------------- .../translation-server/translation-server.ts | 2 +- packages/new-compiler/src/types.ts | 3 + .../src/utils/config-factory.test.ts | 14 + .../new-compiler/src/utils/config-factory.ts | 37 ++- 7 files changed, 148 insertions(+), 493 deletions(-) diff --git a/packages/new-compiler/src/plugin/next.ts b/packages/new-compiler/src/plugin/next.ts index c9dadae5c..588fb85f8 100644 --- a/packages/new-compiler/src/plugin/next.ts +++ b/packages/new-compiler/src/plugin/next.ts @@ -210,31 +210,39 @@ export async function withLingo( // We have two barriers, a simple one here and a more complex one inside the startTranslationServer which doesn't start the server if it can find one running. // We do not use isMainRunner here, because we need to start the server as early as possible, so the loaders get the translation server url. The main runner in dev mode runs after a dev server process is started. if (isDev && !process.env.LINGO_TRANSLATION_SERVER_URL) { - const translationServer = await startOrGetTranslationServer({ - translationService: new TranslationService(lingoConfig, logger), - onError: (err) => { - logger.error("Translation server error:", err); - }, - onReady: (port) => { - logger.info(`Translation server started successfully on port: ${port}`); - }, - config: lingoConfig, - }); - process.env.LINGO_TRANSLATION_SERVER_URL = translationServer.url; - if (translationServer.server) { - // We start the server in the same process, so we should be fine without any sync cleanup. Server should be killed with the process. - registerCleanupOnCurrentProcess({ - asyncCleanup: async () => { - await translationServer.server.stop(); + if (lingoConfig.dev.translationServerUrl) { + logger.info(`Using existing translation server URL (${lingoConfig.dev.translationServerUrl}) from config`); + process.env.LINGO_TRANSLATION_SERVER_URL = + lingoConfig.dev.translationServerUrl; + } else { + const translationServer = await startOrGetTranslationServer({ + translationService: new TranslationService(lingoConfig, logger), + onError: (err) => { + logger.error("Translation server error:", err); }, + onReady: (port) => { + logger.info( + `Translation server started successfully on port: ${port}`, + ); + }, + config: lingoConfig, }); + process.env.LINGO_TRANSLATION_SERVER_URL = translationServer.url; + if (translationServer.server) { + // We start the server in the same process, so we should be fine without any sync cleanup. Server should be killed with the process. + registerCleanupOnCurrentProcess({ + asyncCleanup: async () => { + await translationServer.server.stop(); + }, + }); + } } } const translationServerUrl = process.env.LINGO_TRANSLATION_SERVER_URL; if (isMainRunner()) { - // We need to cleaup the file only once, to avoid having extra translation introduced into the build, or old translation to pile up. + // We need to clean up the file only once to avoid having extra translation introduced into the build, or old translation to pile up. cleanupExistingMetadata(metadataFilePath); registerCleanupOnCurrentProcess({ diff --git a/packages/new-compiler/src/translation-server/README.md b/packages/new-compiler/src/translation-server/README.md index 9f7c33757..8be7091e3 100644 --- a/packages/new-compiler/src/translation-server/README.md +++ b/packages/new-compiler/src/translation-server/README.md @@ -1,104 +1,33 @@ # Translation Server -The Lingo.dev translation server provides on-demand translation generation during development. +The Lingo.dev translation server provides on-demand translation generation during development and build time. ## Overview The translation server is automatically started by the bundler plugins (Vite, Webpack, Next.js) during development. It serves translations via HTTP endpoints and caches results to disk. -## Architecture - -### Two Implementations - -1. **Raw HTTP Server** (`translation-server.ts`) - Original implementation -2. **Hono Server** (`translation-server-hono.ts`) - **Recommended** modern implementation ✨ - -See [HONO_COMPARISON.md](./HONO_COMPARISON.md) for detailed comparison. - ### Key Features -✅ **Automatic port detection** - Finds available port starting from 60000 -✅ **Server reuse** - Reuses existing server if already running -✅ **Built-in timeouts** - Prevents hanging requests (Hono only) -✅ **Global error handling** - Catches all errors automatically (Hono only) -✅ **Metadata reload** - Always uses latest translation entries -✅ **Multiple translation providers** - Lingo.dev, Groq, Google, OpenRouter, Mistral, Ollama +**Automatic port detection** - Finds available port starting from 60000 (or the one configured as the start port) +**Server reuse** - Reuses existing server if already running on the same port with same configuration +**Real-time updates** - Notifies connected clients via WebSockets when translations are updated ## Files ``` translation-server/ -├── translation-server.ts # Raw HTTP implementation -├── translation-server-hono.ts # Hono implementation (recommended) -├── cli.ts # Standalone CLI -├── README.md # This file -├── CLI.md # CLI documentation -├── HONO_COMPARISON.md # Implementation comparison -├── lingo.config.example.json # Example config (Lingo.dev) -└── lingo.config.llm.example.json # Example config (custom LLMs) +├── translation-server.ts # Main HTTP & WebSocket server implementation +├── cli.ts # Standalone CLI to start server manually +├── logger.ts # Server-specific logging utilities +├── ws-events.ts # WebSocket event types for realtime communication with dev widget +└── README.md ``` ## Usage -### Option 1: Automatic (Bundler Plugin) - -**Next.js:** - -```typescript -// next.config.ts -import { withLingo } from "@lingo.dev/compiler/next"; - -export default withLingo( - { reactStrictMode: true }, - { - sourceLocale: "en", - targetLocales: ["es", "fr", "de"], - models: "lingo.dev", - }, -); -``` - -**Vite:** - -```typescript -// vite.config.ts -import { lingoPlugin } from "@lingo.dev/compiler/vite"; - -export default { - plugins: [ - lingoPlugin({ - sourceLocale: "en", - targetLocales: ["es", "fr", "de"], - models: "lingo.dev", - }), - ], -}; -``` - -The plugin automatically starts the translation server on port 60000. - -### Option 2: Standalone CLI - -The CLI can automatically detect and read your existing Next.js or Vite configuration: - -```bash -# Auto-detects next.config.ts or vite.config.ts in current directory -npx lingo-translation-server - -# Override specific options -npx lingo-translation-server --port 3456 --use-pseudo +The plugin automatically starts the translation server on port 60000 (or the next available port). -# Use explicit config file (legacy) -npx lingo-translation-server --config ./lingo.config.json -``` - -**Auto-detection order:** - -1. Explicit `--config` file if provided -2. Auto-detect `next.config.ts` / `vite.config.ts` -3. Defaults + CLI options - -See [CLI.md](./CLI.md) for complete CLI documentation. +See the [Development](#development) section for details on starting the server manually. ## API Endpoints @@ -108,16 +37,14 @@ See [CLI.md](./CLI.md) for complete CLI documentation. GET /health ``` -Returns server status and uptime. +Returns server status and configuration hash. **Response:** ```json { - "status": "ok", "port": "http://127.0.0.1:60000", - "uptime": 123.45, - "memory": { ... } + "configHash": "a1b2c3d4e5f6" } ``` @@ -127,7 +54,7 @@ Returns server status and uptime. GET /translations/:locale ``` -Returns all translations for the specified locale. +Returns all translations for the specified locale. Triggers translation for any missing entries in metadata. **Response:** @@ -153,7 +80,7 @@ Content-Type: application/json } ``` -Returns translations for specific hashes. +Returns translations for specific hashes. Triggers translation for any requested hashes that are not yet in cache. **Response:** @@ -174,9 +101,9 @@ Returns translations for specific hashes. **1. Lingo.dev Engine (Recommended)** -```typescript +```json { - models: "lingo.dev"; + "models": "lingo.dev" } ``` @@ -184,12 +111,11 @@ Requires: `LINGODOTDEV_API_KEY` environment variable **2. Custom LLM Models** -```typescript +```json { - models: { - "es": "groq:llama3-70b", - "fr": "google:gemini-pro", - "de": "openrouter:anthropic/claude-3-haiku" + "models": { + "en:es": "google:gemini-2.0-flash", + "*:*": "groq:llama3-8b-8192" } } ``` @@ -198,56 +124,41 @@ Requires: Provider-specific API keys (GROQ_API_KEY, GOOGLE_API_KEY, etc.) **3. Pseudotranslator (Dev/Testing)** -```typescript +```json { - dev: { - usePseudotranslator: true; + "dev": { + "usePseudotranslator": true } } ``` No API key required. Outputs pseudolocalized text (e.g., "Hello" → "Ĥéĺĺó"). -### Timeouts - -All timeouts are configurable via `DEFAULT_TIMEOUTS` in `utils/timeout.ts`: - -| Operation | Default | Configurable | -| ------------------ | ------- | --------------------- | -| File I/O | 10s | Yes | -| Metadata load/save | 15s | Yes | -| AI API calls | 60s | Yes | -| HTTP requests | 30s | Yes (CLI `--timeout`) | -| Server startup | 30s | Yes | - ## Development ### Testing the Server Locally -```bash -# Quick start with defaults -pnpm server - -# Show help -pnpm server:help +The main purpose of running the translation server locally is to uncouple the process from the bundler startup which could be tricky. -# With options -pnpm server -- --port 3456 --use-pseudo +From the demo project root directory. e.g. `demos/new-compiler-next16` run +```bash +pnpm tsx ../../packages/new-compiler/src/translation-server/cli.ts --port 3456 --target-locales "es,fr,de,ja" --source-locale "en" --source-root app --lingo-dir ".lingo" ``` +Make sure there the `--lingo-dir` option is set to the same directory as in the project settings, and the same for the `--source-root` option. + ### Server Logs The translation server writes logs to both: -1. **Console output** - Standard console.log output -2. **Log file** - `.lingo/translation-server.log` +1. **Console output** - Standard console output +2. **Log file** - `.lingo/translation-server.log` (relative to the project root) The log file includes: - Timestamped entries -- All log levels (debug, info, warn, error) -- Formatted JSON objects -- Full request/response traces +- Log levels (debug, info, warn, error) +- Full request/response traces in debug mode **View logs:** @@ -259,79 +170,17 @@ tail -f .lingo/translation-server.log Get-Content .lingo/translation-server.log -Wait -Tail 50 ``` -**Note:** The log file is appended to on each server start, so you may want to clear it periodically: - -```bash -# Unix -rm .lingo/translation-server.log - -# Windows -del .lingo\translation-server.log -``` - -### Switching to Hono Implementation - -The loader already uses Hono by default (see `dev-server-loader.ts`): - -```typescript -import { startOrGetTranslationServerHono } from "../translation-server/translation-server-hono"; - -const server = await startOrGetTranslationServerHono({ ... }); -``` - ## Troubleshooting ### Port Already in Use -**Problem:** `Error: Port 60000 is already in use` +**Problem:** `Error: listen EADDRINUSE: address already in use :::60000` **Solutions:** -1. The plugin automatically finds the next available port -2. Or use CLI with `--port` option to specify a different port -3. Check if an old server is still running: `lsof -i :60000` (Unix) or `netstat -ano | findstr :60000` (Windows) - -### Compilation Freezes When Switching Locale - -**Fixed!** We added comprehensive timeouts to prevent hanging: - -1. ✅ File I/O operations timeout after 10-15s -2. ✅ AI API calls timeout after 60s -3. ✅ HTTP requests timeout after 30s -4. ✅ Server startup times out after 30s -5. ✅ Hono provides global request timeout - -See commit history and `utils/timeout.ts` for implementation. - -### Metadata Not Found - -**Normal!** Metadata is created during file transformation. The server will work once you: - -1. Start your dev server -2. Files get transformed by the bundler -3. Metadata is stored in `.lingo/metadata-dev/` (LMDB database) - -## Migration Guide - -### From Raw HTTP to Hono - -Already done in `dev-server-loader.ts`! If you have custom code: - -**Before:** - -```typescript -import { startOrGetTranslationServer } from "./translation-server"; -const { server, url } = await startOrGetTranslationServer(options); -``` - -**After:** - -```typescript -import { startOrGetTranslationServerHono } from "./translation-server-hono"; -const { server, url } = await startOrGetTranslationServerHono(options); -``` - -Same API, drop-in replacement! ✨ +1. The server automatically finds the next available port if 60000 is taken. +2. Use CLI with `--port` option to specify a different port. +3. Check if an old server is still running: `lsof -i :60000` (Unix) or `netstat -ano | findstr :60000` (Windows). ## Performance @@ -340,34 +189,4 @@ Same API, drop-in replacement! ✨ 1. **Disk cache** - `.lingo/cache/{locale}.json` 2. **Cache-first** - Check cache before translating 3. **Lazy loading** - Only translate missing hashes -4. **Metadata reload** - Ensures fresh entries on every request - -### Translation Speed - -Typical times (100 entries): - -- **Pseudotranslator**: ~100ms (instant) -- **Cached**: ~50ms (disk read) -- **Lingo.dev Engine**: ~2-5s (API call) -- **LLM (Groq/Google)**: ~3-10s (API call) - -## Security Notes - -⚠️ **Development Only** - -This server is NOT designed for production: - -- No authentication -- Binds to localhost only -- No rate limiting -- No request validation (beyond basics) -- Regenerates on every request - -For production, use build-time translation generation (automatic in Next.js plugin). - -## Related Documentation - -- [CLI.md](./CLI.md) - Standalone CLI usage -- [HONO_COMPARISON.md](./HONO_COMPARISON.md) - Implementation comparison -- [../README.md](../README.md) - Main compiler documentation -- [lingo.config.example.json](./lingo.config.example.json) - Example config +4. **Metadata reload** - Reloads metadata on dictionary requests to ensure fresh entries diff --git a/packages/new-compiler/src/translation-server/cli.ts b/packages/new-compiler/src/translation-server/cli.ts index 41643a962..f936a521d 100644 --- a/packages/new-compiler/src/translation-server/cli.ts +++ b/packages/new-compiler/src/translation-server/cli.ts @@ -1,52 +1,7 @@ #!/usr/bin/env node -/** - * CLI entry point for running translation server as standalone process - * - * Requirements: Node.js 18.3.0 or higher (for parseArgs support) - * - * Usage: - * npx @lingo.dev/compiler/translation-server [options] - * - * Options: - * --port Port to start server on (default: 60000) - * --source-locale Source locale (default: "en") - * --target-locales Comma-separated target locales (default: "es,fr,de") - * --lingo-dir Lingo directory path (default: ".lingo") - * --source-root Source root directory (default: cwd) - * --models Models config: "lingo.dev" or "locale:provider:model" format - * --prompt Custom translation prompt - * --timeout Request timeout in ms (default: 30000) - * --use-pseudo Use pseudotranslator (default: false) - * --config Path to config file (JSON) - * --help Show this help message - * - * Examples: - * # Start with defaults - * npx @lingo.dev/compiler/translation-server - * - * # Custom port and locales - * npx @lingo.dev/compiler/translation-server --port 3456 --target-locales "es,fr,de,ja" - * - * # Use Lingo.dev Engine - * LINGODOTDEV_API_KEY=your-key npx @lingo.dev/compiler/translation-server --models lingo.dev - * - * # Use custom LLM models - * npx @lingo.dev/compiler/translation-server --models "es:groq:llama3-70b,fr:google:gemini-pro" - * - * # Load from config file - * npx @lingo.dev/compiler/translation-server --config ./lingo.config.json - */ import { parseArgs } from "node:util"; -import { access } from "fs/promises"; -import { join } from "path"; -import { pathToFileURL } from "url"; -import type { - LingoConfig, - PartialLingoConfig, - TranslationMiddlewareConfig, -} from "../types"; -import type { LingoNextPluginOptions } from "../plugin/next"; +import type { LingoConfig } from "../types"; import { logger } from "../utils/logger"; import { startOrGetTranslationServer } from "./translation-server"; import { createLingoConfig } from "../utils/config-factory"; @@ -65,6 +20,7 @@ interface CLIOptions { usePseudo?: boolean; config?: string; help?: boolean; + env?: "development" | "production"; } /** @@ -84,15 +40,25 @@ function parseCliArgs(): CLIOptions { "use-pseudo": { type: "boolean" }, config: { type: "string" }, help: { type: "boolean" }, + env: { type: "string" }, }, allowPositionals: false, }); - const parsedSourceLocale = parseLocaleOrThrow(values["source-locale"]); + const parsedSourceLocale = values["source-locale"] + ? parseLocaleOrThrow(values["source-locale"]) + : undefined; const parsedTargetLocales = values["target-locales"] ?.split(",") .map((s) => parseLocaleOrThrow(s)); + const env = values.env as "development" | "production" | undefined; + if (env && env !== "development" && env !== "production") { + throw new Error( + `Invalid --env value: "${env}". Must be "development" or "production"`, + ); + } + return { port: values.port ? parseInt(values.port, 10) : undefined, sourceLocale: parsedSourceLocale, @@ -105,6 +71,7 @@ function parseCliArgs(): CLIOptions { usePseudo: values["use-pseudo"], config: values.config, help: values.help, + env, }; } @@ -139,185 +106,28 @@ function parseModelsString( return models; } -/** - * Detect and find bundler config file - */ -async function findBundlerConfig( - cwd: string = process.cwd(), -): Promise<{ type: "next" | "vite"; path: string } | null> { - // Next.js config files (in priority order) - const nextConfigs = [ - "next.config.ts", - "next.config.mjs", - "next.config.js", - "next.config.cjs", - ]; - - // Vite config files (in priority order) - const viteConfigs = [ - "vite.config.ts", - "vite.config.mts", - "vite.config.js", - "vite.config.mjs", - ]; - - // Check Next.js configs - for (const configFile of nextConfigs) { - const configPath = join(cwd, configFile); - try { - await access(configPath); - return { type: "next", path: configPath }; - } catch { - // File doesn't exist, continue - } - } - - // Check Vite configs - for (const configFile of viteConfigs) { - const configPath = join(cwd, configFile); - try { - await access(configPath); - return { type: "vite", path: configPath }; - } catch { - // File doesn't exist, continue - } - } - - return null; -} - -/** - * Extract Lingo configuration from Next.js config - */ -function extractNextLingoConfig( - nextConfig: any, -): Partial | null { - // Next.js config is typically wrapped with withLingo(config, lingoOptions) - // The withLingo function attaches the original lingoOptions to _lingoConfig - - if (nextConfig._lingoConfig) { - logger.debug("Found _lingoConfig in Next.js config"); - return nextConfig._lingoConfig; - } - - logger.debug("No _lingoConfig found in Next.js config"); - return null; -} - -/** - * Extract Lingo configuration from Vite config - */ -function extractViteLingoConfig( - viteConfig: any, -): Partial | null { - // Vite config has plugins array - if (!viteConfig.plugins || !Array.isArray(viteConfig.plugins)) { - logger.debug("No plugins array found in Vite config"); - return null; - } - - logger.debug(`Searching through ${viteConfig.plugins.length} Vite plugins`); - - // Find the lingo plugin - for (const plugin of viteConfig.plugins) { - if ( - plugin && - (plugin.name === "lingo-compiler" || plugin.name === "lingo") - ) { - logger.debug(`Found lingo plugin: ${plugin.name}`); - // Plugin has config attached via _lingoConfig - if (plugin._lingoConfig) { - logger.debug("Found _lingoConfig in Vite plugin"); - return plugin._lingoConfig; - } - } - } - - logger.debug("No lingo plugin with _lingoConfig found"); - return null; -} - -/** - * Load and parse bundler config file - */ -async function loadBundlerConfig( - cwd: string = process.cwd(), -): Promise { - const detected = await findBundlerConfig(cwd); - - if (!detected) { - logger.debug("No bundler config found"); - return null; - } - - logger.info(`Found ${detected.type} config: ${detected.path}`); - - try { - // Use dynamic import to load the config - const configUrl = pathToFileURL(detected.path).href; - const configModule = await import(configUrl); - - // Get the default export - const config = configModule.default; - - // Handle async configs - const resolvedConfig = - typeof config === "function" ? await config() : config; - - // Extract Lingo configuration based on bundler type - if (detected.type === "next") { - const lingoConfig = extractNextLingoConfig(resolvedConfig); - if (lingoConfig) { - logger.info("Extracted Lingo configuration from Next.js config"); - return lingoConfig as PartialLingoConfig; - } - } else if (detected.type === "vite") { - const lingoConfig = extractViteLingoConfig(resolvedConfig); - if (lingoConfig) { - logger.info("Extracted Lingo configuration from Vite config"); - return lingoConfig as PartialLingoConfig; - } - } - - logger.warn( - `Found ${detected.type} config but could not extract Lingo configuration`, - ); - logger.warn( - "Please provide configuration via CLI options or ensure your config uses the Lingo plugin", - ); - return null; - } catch (error) { - logger.error(`Failed to load bundler config:`, error); - return null; - } -} - /** * Merge CLI options with config file and defaults */ -function buildConfig( - cliOpts: CLIOptions, - fileConfig?: PartialLingoConfig, -): LingoConfig { - const sourceLocale = cliOpts.sourceLocale || fileConfig?.sourceLocale; - const targetLocales = cliOpts.targetLocales || fileConfig?.targetLocales; +function buildConfig(cliOpts: CLIOptions): LingoConfig { + const sourceLocale = cliOpts.sourceLocale; + const targetLocales = cliOpts.targetLocales; if (!sourceLocale || !targetLocales) { throw new Error( `Missing required sourceLocale or targetLocales. Please provide via CLI options or ensure your config uses the Lingo plugin`, ); } - // Priority: CLI > config file > defaults return createLingoConfig({ sourceLocale, targetLocales, - lingoDir: cliOpts.lingoDir || fileConfig?.lingoDir, - sourceRoot: cliOpts.sourceRoot || fileConfig?.sourceRoot || process.cwd(), - models: cliOpts.models || fileConfig?.models, - prompt: cliOpts.prompt || fileConfig?.prompt, + lingoDir: cliOpts.lingoDir, + sourceRoot: cliOpts.sourceRoot || process.cwd(), + models: cliOpts.models, + prompt: cliOpts.prompt, + environment: cliOpts.env || "development", dev: { - usePseudotranslator: - cliOpts.usePseudo ?? fileConfig?.dev?.usePseudotranslator, - translationServerStartPort: fileConfig?.dev?.translationServerStartPort, + usePseudotranslator: cliOpts.usePseudo, + translationServerStartPort: cliOpts.port, }, }); } @@ -330,7 +140,8 @@ function showHelp(): void { Translation Server CLI Usage: - npx @lingo.dev/compiler/translation-server [options] + # Run script from the project root where next or vite configuration is present. + pnpm tsx ../../packages/new-compiler/src/translation-server/cli.ts [options] Options: --port Port to start server on (default: 60000) @@ -342,34 +153,23 @@ Options: --prompt Custom translation prompt --timeout Request timeout in ms (default: 30000) --use-pseudo Use pseudotranslator (default: false) + --env Environment: "development" or "production" (default: "development") --config Path to config file (JSON) --help Show this help message Examples: + # Run script from the project root where next or vite configuration is present. # Start with defaults - npx @lingo.dev/compiler/translation-server + pnpm tsx ../../packages/new-compiler/src/translation-server/cli.ts --port 3456 --target-locales "es,fr,de,ja" # Custom port and locales - npx @lingo.dev/compiler/translation-server --port 3456 --target-locales "es,fr,de,ja" + pnpm tsx ../../packages/new-compiler/src/translation-server/cli.ts --port 3456 --target-locales "es,fr,de,ja" --port 3456 --target-locales "es,fr,de,ja" # Use Lingo.dev Engine - LINGODOTDEV_API_KEY=your-key npx @lingo.dev/compiler/translation-server --models lingo.dev + LINGODOTDEV_API_KEY=your-key pnpm tsx ../../packages/new-compiler/src/translation-server/cli.ts --port 3456 --target-locales "es,fr,de,ja" --models lingo.dev # Use custom LLM models - npx @lingo.dev/compiler/translation-server --models "es:groq:llama3-70b,fr:google:gemini-pro" - - # Load from config file - npx @lingo.dev/compiler/translation-server --config ./lingo.config.json - -Config File Format (JSON): - { - "sourceLocale": "en", - "targetLocales": ["es", "fr", "de"], - "lingoDir": ".lingo", - "sourceRoot": ".", - "models": "lingo.dev", - "prompt": "Translate naturally, preserve formatting" - } + pnpm tsx ../../packages/new-compiler/src/translation-server/cli.ts --port 3456 --target-locales "es,fr,de,ja" --models "es:groq:llama3-70b,fr:google:gemini-pro" Environment Variables: LINGODOTDEV_API_KEY API key for Lingo.dev Engine @@ -402,28 +202,17 @@ function checkNodeVersion(): void { */ export async function main(): Promise { try { - // Check Node.js version first checkNodeVersion(); const cliOpts = parseCliArgs(); - // Show help if requested if (cliOpts.help) { showHelp(); process.exit(0); } - // Try to auto-detect bundler config - logger.info("Searching for bundler configuration..."); - const fileConfig: PartialLingoConfig | undefined = - (await loadBundlerConfig()) || undefined; - - if (!fileConfig) { - logger.info("No bundler config found, using defaults + CLI options"); - } - // Build final configuration - const config = buildConfig(cliOpts, fileConfig); + const config = buildConfig(cliOpts); // Determine final port: CLI option > config > default const startPort = @@ -432,6 +221,7 @@ export async function main(): Promise { // Log configuration logger.info("Starting translation server with configuration:"); logger.info(` Port: ${startPort}`); + logger.info(` Environment: ${config.environment}`); logger.info(` Source Locale: ${config.sourceLocale}`); logger.info(` Target Locales: ${(config.targetLocales || []).join(", ")}`); logger.info(` Lingo Directory: ${config.lingoDir}`); @@ -445,7 +235,6 @@ export async function main(): Promise { // Start server const { server, url } = await startOrGetTranslationServer({ config, - // requestTimeout: cliOpts.timeout || 30000, onError: (err) => { logger.error("Translation server error:", err); }, @@ -473,7 +262,10 @@ export async function main(): Promise { process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); } catch (error) { - logger.error("Failed to start translation server:", error); + logger.error( + "Failed to start the translation server:", + error instanceof Error ? error.message : "Unknown error", + ); process.exit(1); } } 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 a375b6c8e..e57fca975 100644 --- a/packages/new-compiler/src/types.ts +++ b/packages/new-compiler/src/types.ts @@ -165,6 +165,9 @@ export type LingoConfig = { */ translationServerStartPort: number; + /** + * URL of the translation server in development mode if you manually start it + */ translationServerUrl?: string; }; diff --git a/packages/new-compiler/src/utils/config-factory.test.ts b/packages/new-compiler/src/utils/config-factory.test.ts index 644115ab8..185ca9154 100644 --- a/packages/new-compiler/src/utils/config-factory.test.ts +++ b/packages/new-compiler/src/utils/config-factory.test.ts @@ -84,4 +84,18 @@ describe("createLingoConfig pluralization defaults", () => { }), ).toThrow(/pluralization\.model/); }); + + it("removes undefined keys and preserves defaults", () => { + const config = createLingoConfig({ + sourceLocale: "en", + targetLocales: ["es"], + models: undefined, + dev: { + translationServerStartPort: undefined, + }, + } as any); + + expect(config.models).toBe("lingo.dev"); // default value from DEFAULT_CONFIG + expect(config.dev.translationServerStartPort).toBe(60000); // default value from DEFAULT_CONFIG + }); }); diff --git a/packages/new-compiler/src/utils/config-factory.ts b/packages/new-compiler/src/utils/config-factory.ts index 38a96b809..1da22d21a 100644 --- a/packages/new-compiler/src/utils/config-factory.ts +++ b/packages/new-compiler/src/utils/config-factory.ts @@ -90,33 +90,52 @@ function inferPluralizationModel( export function createLingoConfig( options: PartialLingoConfig & Partial>, ): LingoConfig { + const cleanOptions = { ...options }; + + const cleanObject = (obj: any) => { + if (!obj || typeof obj !== "object") return; + Object.keys(obj).forEach((key) => { + if (obj[key] === undefined) { + delete obj[key]; + } else if ( + obj[key] && + typeof obj[key] === "object" && + !Array.isArray(obj[key]) + ) { + cleanObject(obj[key]); + } + }); + }; + + cleanObject(cleanOptions); + const config: LingoConfig = { ...DEFAULT_CONFIG, - ...options, + ...cleanOptions, environment: - options.environment ?? + cleanOptions.environment ?? (process.env.NODE_ENV === "development" ? "development" : "production"), - cacheType: options.cacheType ?? "local", + cacheType: cleanOptions.cacheType ?? "local", dev: { ...DEFAULT_CONFIG.dev, - ...options.dev, + ...cleanOptions.dev, }, pluralization: { ...DEFAULT_CONFIG.pluralization, - ...options.pluralization, + ...cleanOptions.pluralization, }, localePersistence: { ...DEFAULT_CONFIG.localePersistence, - ...options.localePersistence, + ...cleanOptions.localePersistence, config: { ...DEFAULT_CONFIG.localePersistence.config, - ...options.localePersistence?.config, + ...cleanOptions.localePersistence?.config, }, }, }; - const explicitEnabled = options.pluralization?.enabled; - const explicitModel = options.pluralization?.model; + const explicitEnabled = cleanOptions.pluralization?.enabled; + const explicitModel = cleanOptions.pluralization?.model; const hasExplicitModel = typeof explicitModel === "string" && explicitModel.trim().length > 0; const hasExplicitEnabled = typeof explicitEnabled === "boolean"; From 109649d5503a2a420a96161df1bd18c736d24c9f Mon Sep 17 00:00:00 2001 From: Aleksandr Slepchenkov Date: Sun, 1 Mar 2026 22:18:57 +0100 Subject: [PATCH 2/5] fix: add missing notion about url in the lingo config --- .../new-compiler/src/translation-server/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/new-compiler/src/translation-server/README.md b/packages/new-compiler/src/translation-server/README.md index 8be7091e3..8c589ebcb 100644 --- a/packages/new-compiler/src/translation-server/README.md +++ b/packages/new-compiler/src/translation-server/README.md @@ -147,6 +147,18 @@ pnpm tsx ../../packages/new-compiler/src/translation-server/cli.ts --port 3456 - Make sure there the `--lingo-dir` option is set to the same directory as in the project settings, and the same for the `--source-root` option. +Set the url returned in logs to the lingo config + +```js +export const config = { + // Other config options... + dev: { + usePseudotranslator: true, + translationServerUrl: "http://127.0.0.1:3456" + } +} +``` + ### Server Logs The translation server writes logs to both: From 28dad49cbfc850a9b6ee71fe893aba1ade5dde68 Mon Sep 17 00:00:00 2001 From: Aleksandr Slepchenkov Date: Sun, 1 Mar 2026 22:43:33 +0100 Subject: [PATCH 3/5] feat: implement custom path resolving --- .../app/.lingo/locale-resolver-client.ts | 96 ++++++++++++++++ .../app/.lingo/locale-resolver-server.ts | 29 +++++ .../app/[locale]/layout.tsx | 44 +++++++ .../app/{ => [locale]}/page.tsx | 0 .../app/{ => [locale]}/test/page.tsx | 0 demo/new-compiler-next16/app/layout.tsx | 38 ++----- demo/new-compiler-next16/next.config.ts | 10 +- demo/new-compiler-next16/proxy.ts | 107 ++++++++++++++++++ demo/new-compiler-next16/supported-locales.ts | 4 + packages/new-compiler/src/index.ts | 1 + packages/new-compiler/src/plugin/next.ts | 25 +++- .../src/plugin/resolve-locale-resolver.ts | 89 +++++++++++++++ .../src/react/shared/LingoProvider.tsx | 33 +++--- packages/new-compiler/src/types.ts | 6 +- .../src/virtual/code-generator.ts | 18 +++ .../new-compiler/src/virtual/locale/client.ts | 8 +- 16 files changed, 455 insertions(+), 53 deletions(-) create mode 100644 demo/new-compiler-next16/app/.lingo/locale-resolver-client.ts create mode 100644 demo/new-compiler-next16/app/.lingo/locale-resolver-server.ts create mode 100644 demo/new-compiler-next16/app/[locale]/layout.tsx rename demo/new-compiler-next16/app/{ => [locale]}/page.tsx (100%) rename demo/new-compiler-next16/app/{ => [locale]}/test/page.tsx (100%) create mode 100644 demo/new-compiler-next16/proxy.ts create mode 100644 demo/new-compiler-next16/supported-locales.ts create mode 100644 packages/new-compiler/src/plugin/resolve-locale-resolver.ts 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 100% rename from demo/new-compiler-next16/app/page.tsx rename to demo/new-compiler-next16/app/[locale]/page.tsx diff --git a/demo/new-compiler-next16/app/test/page.tsx b/demo/new-compiler-next16/app/[locale]/test/page.tsx similarity index 100% rename from demo/new-compiler-next16/app/test/page.tsx rename to demo/new-compiler-next16/app/[locale]/test/page.tsx diff --git a/demo/new-compiler-next16/app/layout.tsx b/demo/new-compiler-next16/app/layout.tsx index e2cb1b74a..97d882c7b 100644 --- a/demo/new-compiler-next16/app/layout.tsx +++ b/demo/new-compiler-next16/app/layout.tsx @@ -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 ( - - - - {children} - - - - ); + return children; } 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/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..f54c4e531 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, getLocalePathname } 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__(); } From 69ba2e5522d94400c9747eaec5e80662b01ba549 Mon Sep 17 00:00:00 2001 From: Aleksandr Slepchenkov Date: Sun, 1 Mar 2026 23:01:48 +0100 Subject: [PATCH 4/5] feat: link with locale --- .../new-compiler-next16/app/[locale]/page.tsx | 4 ++-- .../components/LocaleLink.tsx | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 demo/new-compiler-next16/components/LocaleLink.tsx diff --git a/demo/new-compiler-next16/app/[locale]/page.tsx b/demo/new-compiler-next16/app/[locale]/page.tsx index 0345199f6..d11de384f 100644 --- a/demo/new-compiler-next16/app/[locale]/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 ) { + 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 ; +} From aff4651b9a13bdde6b4a5ca4652a10da7192aa54 Mon Sep 17 00:00:00 2001 From: Aleksandr Slepchenkov Date: Sun, 1 Mar 2026 23:41:27 +0100 Subject: [PATCH 5/5] Apply suggestions from code review --- packages/new-compiler/src/virtual/code-generator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/new-compiler/src/virtual/code-generator.ts b/packages/new-compiler/src/virtual/code-generator.ts index f54c4e531..614b29055 100644 --- a/packages/new-compiler/src/virtual/code-generator.ts +++ b/packages/new-compiler/src/virtual/code-generator.ts @@ -49,7 +49,7 @@ 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, getLocalePathname } from '@lingo.dev/compiler/virtual/locale-client';`; + return `export { getClientLocale, persistLocale } from '@lingo.dev/compiler/virtual/locale-client';`; } // Default cookie-based resolver