From 09639713940457f8d9ab50bbd0ad05d98210c2b5 Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sun, 19 Jul 2026 15:58:02 -0400 Subject: [PATCH] fix: unrunnable CLI binary, lock poisoning on failed translation, deletion persistence, locale matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate shebang: src/cli.ts had its own #!/usr/bin/env node on top of the tsup banner, so dist/cli.js started with two shebang lines — a syntax error under node and bun that made the published binary unrunnable. CI now smoke-tests dist/cli.js --help under both runtimes. - Extract shared lock/sync logic into src/lock.ts (used by both the CLI and the Vite plugin) so lock semantics can no longer diverge: - Lock entries are committed only for keys whose batches succeeded for ALL target locales. Failed batches no longer poison the lock (previously a failed run wrote empty target files plus a fully-populated lock, and reruns reported "No changes detected"). - On any batch failure the CLI exits non-zero naming the failed locales and keys; the Vite plugin throws and fails the build. - Deleted source keys are pruned from target locale files and the lock even when there is nothing to translate (previously the early return skipped all writes and removed strings lived in target files forever). - CLI-written lock entries now preserve context (previously dropped, which made mixed CLI + Vite autoExtract usage re-translate every context-annotated key). - detectLocale now matches available locales case-insensitively and returns the canonical casing (browser "pt-br" matches available "pt-BR"). - The ai package is imported lazily inside translateBatch/translateMarkdown, so loading the Vite plugin for extract-only or fresh-lock builds works without ai installed. Peer range corrected to ">=3.0.0 <5.0.0" (the code uses LanguageModelV1, removed in ai v5). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 4 + bun.lock | 4 +- package.json | 2 +- src/cli.ts | 122 ++++---------- src/locale-detect.ts | 19 ++- src/lock.ts | 288 ++++++++++++++++++++++++++++++++ src/translate.ts | 13 +- src/vite.ts | 157 ++++-------------- tests/locale-detect.test.ts | 20 +++ tests/lock.test.ts | 323 ++++++++++++++++++++++++++++++++++++ 10 files changed, 728 insertions(+), 224 deletions(-) create mode 100644 src/lock.ts create mode 100644 tests/lock.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1f48bd..9284b34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,5 +12,9 @@ jobs: - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile - run: bun run build + # Smoke-test the CLI binary under both runtimes — catches shebang / + # bundling regressions that make the published bin unrunnable. + - run: node dist/cli.js --help + - run: bun dist/cli.js --help - run: bun test - run: bunx tsc --noEmit diff --git a/bun.lock b/bun.lock index 6c77253..043673f 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ "vite": "^5.0.0", }, "peerDependencies": { - "ai": ">=3.0.0", + "ai": ">=3.0.0 <5.0.0", "solid-js": ">=1.7.0", "vite": ">=4.0.0", }, @@ -816,6 +816,8 @@ "env-ci/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], + "fdir/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "handlebars/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/package.json b/package.json index f51cd06..56eef39 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "peerDependencies": { "solid-js": ">=1.7.0", "vite": ">=4.0.0", - "ai": ">=3.0.0" + "ai": ">=3.0.0 <5.0.0" }, "peerDependenciesMeta": { "vite": { diff --git a/src/cli.ts b/src/cli.ts index 93fc4ac..02a4be3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,6 @@ -#!/usr/bin/env node +// NOTE: no shebang here — tsup adds it via `banner` in tsup.config.ts. +// Having both produces a dist/cli.js with two shebang lines, which is a +// syntax error under node and bun (the published binary cannot run). import { readFileSync, @@ -7,10 +9,10 @@ import { mkdirSync, } from "node:fs"; import { resolve, join, dirname, relative, basename } from "node:path"; -import { hashContent } from "./hash.js"; import { translateBatch, translateMarkdown } from "./translate.js"; import { extractStringsFromSource } from "./extract.js"; -import type { CLIConfig, LockFile } from "./types.js"; +import { syncLocaleFiles, formatSyncFailures } from "./lock.js"; +import type { CLIConfig } from "./types.js"; const CONFIG_FILENAMES = [ "solid-translate.config.json", @@ -360,104 +362,40 @@ async function translateLocaleFiles( batchSize: number, systemPrompt?: string, ) { - const sourceFilePath = join(localesDir, `${sourceLocale}.json`); - if (!existsSync(sourceFilePath)) { + const result = await syncLocaleFiles({ + localesDir, + sourceLocale, + targetLocales, + batchSize, + translate: (batch, targetLocale, contexts) => + translateBatch( + model, + batch, + targetLocale, + sourceLocale, + systemPrompt, + contexts, + ), + log: (message) => console.log(message), + }); + + if (result.status === "no-source") { console.log( "No source locale file found. Run `solid-translate extract` first.", ); return; } - const sourceDict: Record = JSON.parse( - readFileSync(sourceFilePath, "utf-8"), - ); - - // Read lock file - const lockFilePath = join(localesDir, ".solid-translate.lock"); - let lock: LockFile = { version: 1, sourceLocale, keys: {} }; - if (existsSync(lockFilePath)) { - try { - lock = JSON.parse(readFileSync(lockFilePath, "utf-8")); - } catch { - // start fresh - } - } - - // Find changed keys - const changedKeys: Record = {}; - for (const [key, value] of Object.entries(sourceDict)) { - const hash = hashContent(value); - const existing = lock.keys[key]; - if (!existing || existing.hash !== hash) { - changedKeys[key] = value; - lock.keys[key] = { hash, source: value }; - } - } - - // Remove deleted keys - for (const key of Object.keys(lock.keys)) { - if (!(key in sourceDict)) { - delete lock.keys[key]; - } - } - - if (Object.keys(changedKeys).length === 0) { - console.log("No changes detected in locale files."); - return; - } - - const count = Object.keys(changedKeys).length; - console.log( - `Translating ${count} key${count > 1 ? "s" : ""} to ${targetLocales.length} locale${targetLocales.length > 1 ? "s" : ""}...`, - ); - - for (const targetLocale of targetLocales) { - const targetFilePath = join(localesDir, `${targetLocale}.json`); - - let existing: Record = {}; - if (existsSync(targetFilePath)) { - try { - existing = JSON.parse(readFileSync(targetFilePath, "utf-8")); - } catch { - // regenerate - } + if (result.failures.length > 0) { + console.error("\nTranslation failed for some batches:"); + for (const line of formatSyncFailures(result.failures)) { + console.error(` ${line}`); } - - const entries = Object.entries(changedKeys); - for (let i = 0; i < entries.length; i += batchSize) { - const batch = Object.fromEntries(entries.slice(i, i + batchSize)); - try { - const translated = await translateBatch( - model, - batch, - targetLocale, - sourceLocale, - systemPrompt, - ); - Object.assign(existing, translated); - } catch (err) { - console.error( - `Failed to translate batch for ${targetLocale}:`, - err, - ); - } - } - - // Remove deleted keys - for (const key of Object.keys(existing)) { - if (!(key in sourceDict)) { - delete existing[key]; - } - } - - const sorted = Object.fromEntries( - Object.entries(existing).sort(([a], [b]) => a.localeCompare(b)), + console.error( + "Failed keys were not recorded in the lock file — fix the error and rerun `solid-translate translate` to retry them.", ); - writeFileSync(targetFilePath, JSON.stringify(sorted, null, 2) + "\n"); - console.log(` ${targetLocale}: ${Object.keys(sorted).length} keys`); + process.exit(1); } - - writeFileSync(lockFilePath, JSON.stringify(lock, null, 2) + "\n"); } main().catch((err) => { diff --git a/src/locale-detect.ts b/src/locale-detect.ts index d26d58d..52a8601 100644 --- a/src/locale-detect.ts +++ b/src/locale-detect.ts @@ -16,16 +16,25 @@ export function detectLocale(availableLocales?: string[]): string { return normalizeLocale(browserLocales[0] || "en"); } - // Exact match + // Map normalized available locales back to their canonical casing so a + // browser "pt-br" can match an available "pt-BR" (and return "pt-BR"). + const canonical = new Map(); + for (const al of availableLocales) { + const normalized = normalizeLocale(al); + if (!canonical.has(normalized)) canonical.set(normalized, al); + } + + // Exact match (case-insensitive) for (const bl of browserLocales) { - const normalized = normalizeLocale(bl); - if (availableLocales.includes(normalized)) return normalized; + const match = canonical.get(normalizeLocale(bl)); + if (match) return match; } // Language-only match (e.g. "en-US" → "en") for (const bl of browserLocales) { - const lang = bl.split("-")[0]!.toLowerCase(); - if (availableLocales.includes(lang)) return lang; + const lang = normalizeLocale(bl).split("-")[0]!; + const match = canonical.get(lang); + if (match) return match; } return availableLocales[0] || "en"; diff --git a/src/lock.ts b/src/lock.ts new file mode 100644 index 0000000..acda072 --- /dev/null +++ b/src/lock.ts @@ -0,0 +1,288 @@ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { hashContent } from "./hash.js"; +import type { LockFile, LockFileEntry } from "./types.js"; + +/** + * Shared lock-file + locale-sync logic used by both the CLI and the Vite + * plugin. Keeping this single-sourced guarantees the two entry points agree + * on what the lock file means: an entry exists for a key if and only if that + * key has been successfully translated to every target locale. + */ + +/** Result of comparing the source dictionary against the lock file */ +export interface LockDiff { + /** Keys that are new or changed and need translation */ + changedKeys: Record; + /** + * Lock entries for the changed keys. These are *pending*: they must only + * be committed to the lock after translation succeeds for all locales. + */ + pendingEntries: Record; + /** Keys present in the lock but no longer in the source dictionary */ + deletedKeys: string[]; +} + +/** + * Compare the source dictionary against the lock file. + * + * When `contexts` is provided (Vite auto-extraction), a context change also + * marks a key as changed and the new context is recorded in the pending + * entry. When `contexts` is omitted (CLI), existing lock contexts are + * preserved unchanged so a CLI run never clobbers Vite-written contexts. + */ +export function diffLock( + sourceDict: Record, + lock: LockFile, + contexts?: Record, +): LockDiff { + const changedKeys: Record = {}; + const pendingEntries: Record = {}; + + for (const [key, value] of Object.entries(sourceDict)) { + const hash = hashContent(value); + const existing = lock.keys[key]; + const newContext = contexts ? contexts[key] : existing?.context; + const contextChanged = + contexts !== undefined && existing?.context !== contexts[key]; + + // Re-translate if the key is new, content changed, or context changed + if (!existing || existing.hash !== hash || contextChanged) { + changedKeys[key] = value; + pendingEntries[key] = { hash, source: value, context: newContext }; + } + } + + const deletedKeys = Object.keys(lock.keys).filter( + (key) => !(key in sourceDict), + ); + + return { changedKeys, pendingEntries, deletedKeys }; +} + +/** Translate one batch of changed keys for one target locale */ +export type TranslateFn = ( + batch: Record, + targetLocale: string, + contexts: Record, +) => Promise>; + +/** A translation batch that failed for a target locale */ +export interface SyncFailure { + locale: string; + keys: string[]; + error: unknown; +} + +export interface SyncResult { + status: "no-source" | "no-changes" | "synced"; + /** Keys translated successfully for ALL target locales (recorded in lock) */ + translatedKeys: string[]; + /** Keys removed from source and pruned from targets + lock */ + deletedKeys: string[]; + /** Failed batches. Non-empty means the run must be treated as failed. */ + failures: SyncFailure[]; +} + +export interface SyncOptions { + localesDir: string; + sourceLocale: string; + targetLocales: string[]; + batchSize: number; + translate: TranslateFn; + /** + * Context hints from auto-extraction (Vite). Omit to preserve existing + * lock contexts (CLI). + */ + contexts?: Record; + log?: (message: string) => void; +} + +/** + * Sync source locale changes into target locale files and the lock file. + * + * Guarantees: + * - Lock entries are committed only for keys whose batches succeeded for + * every target locale — the lock never claims a key is translated when + * it isn't. Failed keys stay "changed" and are retried on the next run. + * - Successfully translated batches are still written even when other + * batches fail; callers must surface `failures` (exit non-zero / throw). + * - Deleted source keys are pruned from target files and the lock even + * when there is nothing to translate (no AI calls needed). + */ +export async function syncLocaleFiles( + options: SyncOptions, +): Promise { + const { + localesDir, + sourceLocale, + targetLocales, + batchSize, + translate, + contexts, + log = () => {}, + } = options; + + const sourceFilePath = join(localesDir, `${sourceLocale}.json`); + if (!existsSync(sourceFilePath)) { + return { + status: "no-source", + translatedKeys: [], + deletedKeys: [], + failures: [], + }; + } + + const sourceDict: Record = JSON.parse( + readFileSync(sourceFilePath, "utf-8"), + ); + + const lockFilePath = join(localesDir, ".solid-translate.lock"); + let lock: LockFile = { version: 1, sourceLocale, keys: {} }; + if (existsSync(lockFilePath)) { + try { + lock = JSON.parse(readFileSync(lockFilePath, "utf-8")); + } catch { + // Corrupted lock file — start fresh + } + } + + const { changedKeys, pendingEntries, deletedKeys } = diffLock( + sourceDict, + lock, + contexts, + ); + + // Remove keys that no longer exist in source + for (const key of deletedKeys) { + delete lock.keys[key]; + } + + const changedCount = Object.keys(changedKeys).length; + + if (changedCount === 0 && deletedKeys.length === 0) { + log("No changes detected in locale files."); + return { + status: "no-changes", + translatedKeys: [], + deletedKeys: [], + failures: [], + }; + } + + if (changedCount === 0) { + // Deletions only — prune target files and the lock, no AI calls needed + for (const targetLocale of targetLocales) { + const targetFilePath = join(localesDir, `${targetLocale}.json`); + const existing = readTargetFile(targetFilePath); + writeTargetFile(targetFilePath, existing, sourceDict); + log(` ${targetLocale}: pruned deleted keys`); + } + writeFileSync(lockFilePath, JSON.stringify(lock, null, 2) + "\n"); + log( + `Removed ${deletedKeys.length} deleted key${deletedKeys.length > 1 ? "s" : ""} from target locales.`, + ); + return { status: "synced", translatedKeys: [], deletedKeys, failures: [] }; + } + + log( + `Translating ${changedCount} key${changedCount > 1 ? "s" : ""} to ${targetLocales.length} locale${targetLocales.length > 1 ? "s" : ""}...`, + ); + + // Context hints for the changed keys, passed to the translator + const changedContexts: Record = {}; + for (const key of Object.keys(changedKeys)) { + const ctx = pendingEntries[key]?.context; + if (ctx) changedContexts[key] = ctx; + } + + const failures: SyncFailure[] = []; + const failedKeys = new Set(); + + for (const targetLocale of targetLocales) { + const targetFilePath = join(localesDir, `${targetLocale}.json`); + + // Load existing translations to preserve unchanged keys + const existing = readTargetFile(targetFilePath); + + // Batch translate changed keys + const entries = Object.entries(changedKeys); + for (let i = 0; i < entries.length; i += batchSize) { + const batch = Object.fromEntries(entries.slice(i, i + batchSize)); + try { + const translated = await translate( + batch, + targetLocale, + changedContexts, + ); + Object.assign(existing, translated); + } catch (err) { + failures.push({ + locale: targetLocale, + keys: Object.keys(batch), + error: err, + }); + for (const key of Object.keys(batch)) { + failedKeys.add(key); + } + } + } + + writeTargetFile(targetFilePath, existing, sourceDict); + log(` ${targetLocale}: ${Object.keys(existing).length} keys`); + } + + // Commit lock entries only for keys that succeeded for ALL target locales. + // Failed keys keep their old entry (or none), so the next run retries them. + const translatedKeys: string[] = []; + for (const [key, entry] of Object.entries(pendingEntries)) { + if (failedKeys.has(key)) continue; + lock.keys[key] = entry; + translatedKeys.push(key); + } + + writeFileSync(lockFilePath, JSON.stringify(lock, null, 2) + "\n"); + + return { status: "synced", translatedKeys, deletedKeys, failures }; +} + +/** Format sync failures into a human-readable, single-line-per-batch report */ +export function formatSyncFailures(failures: SyncFailure[]): string[] { + return failures.map((failure) => { + const message = + failure.error instanceof Error + ? failure.error.message + : String(failure.error); + return `${failure.locale}: ${failure.keys.length} key${failure.keys.length > 1 ? "s" : ""} [${failure.keys.join(", ")}] — ${message}`; + }); +} + +function readTargetFile(targetFilePath: string): Record { + if (!existsSync(targetFilePath)) return {}; + try { + return JSON.parse(readFileSync(targetFilePath, "utf-8")); + } catch { + // Corrupted file — regenerate + return {}; + } +} + +function writeTargetFile( + targetFilePath: string, + translations: Record, + sourceDict: Record, +): void { + // Remove keys that no longer exist in source + for (const key of Object.keys(translations)) { + if (!(key in sourceDict)) { + delete translations[key]; + } + } + + // Sort keys for stable, diff-friendly output + const sorted = Object.fromEntries( + Object.entries(translations).sort(([a], [b]) => a.localeCompare(b)), + ); + + writeFileSync(targetFilePath, JSON.stringify(sorted, null, 2) + "\n"); +} diff --git a/src/translate.ts b/src/translate.ts index d53c202..0e4e1c8 100644 --- a/src/translate.ts +++ b/src/translate.ts @@ -1,7 +1,16 @@ -import { generateObject } from "ai"; import { z } from "zod"; import type { LanguageModelV1 } from "ai"; +/** + * Lazily import the `ai` package so that merely loading this module (e.g. + * via the Vite plugin on extract-only or fresh-lock builds) does not require + * `ai` to be installed. It is only needed when translation actually runs. + */ +async function loadGenerateObject() { + const { generateObject } = await import("ai"); + return generateObject; +} + /** * Translate a batch of key-value pairs from one locale to another using AI. * Supports optional per-key context hints for disambiguation. @@ -44,6 +53,7 @@ export async function translateBatch( } } + const generateObject = await loadGenerateObject(); const { object } = await generateObject({ model, schema: z.object({ @@ -84,6 +94,7 @@ export async function translateMarkdown( `- Return natural, idiomatic translations`, ].join("\n"); + const generateObject = await loadGenerateObject(); const { object } = await generateObject({ model, schema: z.object({ diff --git a/src/vite.ts b/src/vite.ts index cc4b8eb..1d9b3de 100644 --- a/src/vite.ts +++ b/src/vite.ts @@ -7,10 +7,10 @@ import { readdirSync, } from "node:fs"; import { resolve, join, relative } from "node:path"; -import { hashContent } from "./hash.js"; import { translateBatch } from "./translate.js"; import { extractStringsFromSource } from "./extract.js"; -import type { SolidTranslatePluginConfig, LockFile } from "./types.js"; +import { syncLocaleFiles, formatSyncFailures } from "./lock.js"; +import type { SolidTranslatePluginConfig } from "./types.js"; export type { SolidTranslatePluginConfig }; @@ -40,7 +40,6 @@ export function solidTranslate(config: SolidTranslatePluginConfig): Plugin { let root: string; let resolvedLocalesDir: string; - let lockFilePath: string; return { name: "solid-translate", @@ -48,7 +47,6 @@ export function solidTranslate(config: SolidTranslatePluginConfig): Plugin { configResolved(resolvedConfig: ResolvedConfig) { root = resolvedConfig.root; resolvedLocalesDir = resolve(root, localesDir); - lockFilePath = join(resolvedLocalesDir, ".solid-translate.lock"); }, async buildStart() { @@ -115,130 +113,41 @@ export function solidTranslate(config: SolidTranslatePluginConfig): Plugin { return; } - const sourceDict: Record = JSON.parse( - readFileSync(sourceFilePath, "utf-8"), - ); - - // Read or initialize lock file - let lock: LockFile = { version: 1, sourceLocale, keys: {} }; - if (existsSync(lockFilePath)) { - try { - lock = JSON.parse(readFileSync(lockFilePath, "utf-8")); - } catch { - // Corrupted lock file — start fresh - } - } - - // Determine which keys have changed or are new - const changedKeys: Record = {}; - for (const [key, value] of Object.entries(sourceDict)) { - const hash = hashContent(value); - const existing = lock.keys[key]; - const existingContext = existing?.context; - const newContext = contexts[key]; - - // Re-translate if content changed OR context changed - if ( - !existing || - existing.hash !== hash || - existingContext !== newContext - ) { - changedKeys[key] = value; - lock.keys[key] = { hash, source: value, context: newContext }; - } - } - - // Remove keys that no longer exist in source - for (const key of Object.keys(lock.keys)) { - if (!(key in sourceDict)) { - delete lock.keys[key]; - } - } - - if (Object.keys(changedKeys).length === 0) { - console.log( - "[solid-translate] No changes detected, skipping translation.", + const result = await syncLocaleFiles({ + localesDir: resolvedLocalesDir, + sourceLocale, + targetLocales, + batchSize, + // Only pass extraction contexts when autoExtract ran; otherwise + // preserve the contexts already recorded in the lock file. + contexts: autoExtract ? contexts : undefined, + translate: (batch, targetLocale, changedContexts) => + translateBatch( + model, + batch, + targetLocale, + sourceLocale, + systemPrompt, + changedContexts, + ), + log: (message) => console.log(`[solid-translate] ${message}`), + }); + + if (result.failures.length > 0) { + // Fail the build: successfully translated batches were written, but + // failed keys were NOT recorded in the lock, so they retry next run. + throw new Error( + [ + "[solid-translate] Translation failed for some batches:", + ...formatSyncFailures(result.failures).map((line) => ` ${line}`), + "Failed keys were not recorded in the lock file — fix the error and rebuild to retry them.", + ].join("\n"), ); - return; } - const count = Object.keys(changedKeys).length; - console.log( - `[solid-translate] Translating ${count} key${count > 1 ? "s" : ""} to ${targetLocales.length} locale${targetLocales.length > 1 ? "s" : ""}...`, - ); - - // Build context map for changed keys - const changedContexts: Record = {}; - for (const key of Object.keys(changedKeys)) { - const ctx = lock.keys[key]?.context; - if (ctx) changedContexts[key] = ctx; + if (result.status === "synced") { + console.log("[solid-translate] Translation complete."); } - - // Translate for each target locale - for (const targetLocale of targetLocales) { - const targetFilePath = join( - resolvedLocalesDir, - `${targetLocale}.json`, - ); - - // Load existing translations to preserve unchanged keys - let existing: Record = {}; - if (existsSync(targetFilePath)) { - try { - existing = JSON.parse(readFileSync(targetFilePath, "utf-8")); - } catch { - // Corrupted file — regenerate - } - } - - // Batch translate changed keys - const entries = Object.entries(changedKeys); - for (let i = 0; i < entries.length; i += batchSize) { - const batch = Object.fromEntries( - entries.slice(i, i + batchSize), - ); - try { - const translated = await translateBatch( - model, - batch, - targetLocale, - sourceLocale, - systemPrompt, - changedContexts, - ); - Object.assign(existing, translated); - } catch (err) { - console.error( - `[solid-translate] Failed to translate batch for ${targetLocale}:`, - err, - ); - } - } - - // Remove keys that no longer exist in source - for (const key of Object.keys(existing)) { - if (!(key in sourceDict)) { - delete existing[key]; - } - } - - // Sort keys for stable, diff-friendly output - const sorted = Object.fromEntries( - Object.entries(existing).sort(([a], [b]) => a.localeCompare(b)), - ); - - writeFileSync( - targetFilePath, - JSON.stringify(sorted, null, 2) + "\n", - ); - console.log( - `[solid-translate] ${targetLocale}: ${Object.keys(sorted).length} keys`, - ); - } - - // Write updated lock file - writeFileSync(lockFilePath, JSON.stringify(lock, null, 2) + "\n"); - console.log("[solid-translate] Translation complete."); }, resolveId(id: string) { diff --git a/tests/locale-detect.test.ts b/tests/locale-detect.test.ts index 192a66d..df1f851 100644 --- a/tests/locale-detect.test.ts +++ b/tests/locale-detect.test.ts @@ -62,4 +62,24 @@ describe("detectLocale", () => { mockNavigator(["EN-US"]); expect(detectLocale(["en-us", "fr"])).toBe("en-us"); }); + + test("matches mixed-case available locales case-insensitively", () => { + mockNavigator(["pt-br"]); + expect(detectLocale(["en", "pt-BR"])).toBe("pt-BR"); + }); + + test("returns canonical casing from availableLocales, not the browser's", () => { + mockNavigator(["PT-BR"]); + expect(detectLocale(["en", "pt-BR"])).toBe("pt-BR"); + }); + + test("language-only match is case-insensitive with canonical casing", () => { + mockNavigator(["FR-CA"]); + expect(detectLocale(["en", "FR"])).toBe("FR"); + }); + + test("normalizes underscores against mixed-case available locales", () => { + mockNavigator(["pt_BR"]); + expect(detectLocale(["en", "pt-BR"])).toBe("pt-BR"); + }); }); diff --git a/tests/lock.test.ts b/tests/lock.test.ts new file mode 100644 index 0000000..dfacf97 --- /dev/null +++ b/tests/lock.test.ts @@ -0,0 +1,323 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { diffLock, syncLocaleFiles, type TranslateFn } from "../src/lock"; +import { hashContent } from "../src/hash"; +import type { LockFile } from "../src/types"; + +/** Fake translator: uppercases values, tagging them with the locale */ +const fakeTranslate: TranslateFn = async (batch, targetLocale) => { + const out: Record = {}; + for (const [key, value] of Object.entries(batch)) { + out[key] = `${targetLocale}:${value.toUpperCase()}`; + } + return out; +}; + +/** Translator that must never be called (deletion-only paths) */ +const forbiddenTranslate: TranslateFn = async () => { + throw new Error("translate should not have been called"); +}; + +describe("diffLock", () => { + const emptyLock = (): LockFile => ({ + version: 1, + sourceLocale: "en", + keys: {}, + }); + + test("marks new keys as changed", () => { + const diff = diffLock({ hello: "Hello" }, emptyLock()); + expect(diff.changedKeys).toEqual({ hello: "Hello" }); + expect(diff.pendingEntries.hello).toEqual({ + hash: hashContent("Hello"), + source: "Hello", + }); + expect(diff.deletedKeys).toEqual([]); + }); + + test("skips unchanged keys", () => { + const lock = emptyLock(); + lock.keys.hello = { hash: hashContent("Hello"), source: "Hello" }; + const diff = diffLock({ hello: "Hello" }, lock); + expect(diff.changedKeys).toEqual({}); + expect(diff.deletedKeys).toEqual([]); + }); + + test("marks content changes as changed", () => { + const lock = emptyLock(); + lock.keys.hello = { hash: hashContent("Hello"), source: "Hello" }; + const diff = diffLock({ hello: "Hello!" }, lock); + expect(diff.changedKeys).toEqual({ hello: "Hello!" }); + }); + + test("reports deleted keys", () => { + const lock = emptyLock(); + lock.keys.gone = { hash: hashContent("Gone"), source: "Gone" }; + const diff = diffLock({}, lock); + expect(diff.changedKeys).toEqual({}); + expect(diff.deletedKeys).toEqual(["gone"]); + }); + + test("does not mutate the lock", () => { + const lock = emptyLock(); + diffLock({ hello: "Hello" }, lock); + expect(lock.keys).toEqual({}); + }); + + test("with contexts: context change marks key as changed", () => { + const lock = emptyLock(); + lock.keys.save = { + hash: hashContent("Save"), + source: "Save", + context: "Button to save a document", + }; + const diff = diffLock({ save: "Save" }, lock, { + save: "Verb: to save money", + }); + expect(diff.changedKeys).toEqual({ save: "Save" }); + expect(diff.pendingEntries.save!.context).toBe("Verb: to save money"); + }); + + test("with contexts: unchanged context is not re-translated", () => { + const lock = emptyLock(); + lock.keys.save = { + hash: hashContent("Save"), + source: "Save", + context: "Button to save a document", + }; + const diff = diffLock({ save: "Save" }, lock, { + save: "Button to save a document", + }); + expect(diff.changedKeys).toEqual({}); + }); + + test("without contexts (CLI): existing context is preserved, not treated as changed", () => { + const lock = emptyLock(); + lock.keys.save = { + hash: hashContent("Save"), + source: "Save", + context: "Button to save a document", + }; + const diff = diffLock({ save: "Save" }, lock); + expect(diff.changedKeys).toEqual({}); + }); + + test("without contexts (CLI): changed key carries existing context forward", () => { + const lock = emptyLock(); + lock.keys.save = { + hash: hashContent("Save"), + source: "Save", + context: "Button to save a document", + }; + const diff = diffLock({ save: "Save changes" }, lock); + expect(diff.pendingEntries.save!.context).toBe( + "Button to save a document", + ); + }); +}); + +describe("syncLocaleFiles", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "solid-translate-lock-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function writeSource(dict: Record) { + writeFileSync(join(dir, "en.json"), JSON.stringify(dict, null, 2) + "\n"); + } + + function readJSON(name: string): any { + return JSON.parse(readFileSync(join(dir, name), "utf-8")); + } + + function sync( + translate: TranslateFn, + overrides: Partial[0]> = {}, + ) { + return syncLocaleFiles({ + localesDir: dir, + sourceLocale: "en", + targetLocales: ["es"], + batchSize: 50, + translate, + ...overrides, + }); + } + + test("returns no-source when source file is missing", async () => { + const result = await sync(forbiddenTranslate); + expect(result.status).toBe("no-source"); + }); + + test("translates new keys and records them in the lock", async () => { + writeSource({ hello: "Hello", bye: "Bye" }); + const result = await sync(fakeTranslate); + + expect(result.status).toBe("synced"); + expect(result.failures).toEqual([]); + expect(result.translatedKeys.sort()).toEqual(["bye", "hello"]); + expect(readJSON("es.json")).toEqual({ + hello: "es:HELLO", + bye: "es:BYE", + }); + const lock: LockFile = readJSON(".solid-translate.lock"); + expect(Object.keys(lock.keys).sort()).toEqual(["bye", "hello"]); + + // Second run: no changes + const second = await sync(forbiddenTranslate); + expect(second.status).toBe("no-changes"); + }); + + test("failed batch does not poison the lock and is retried on rerun", async () => { + writeSource({ hello: "Hello" }); + + const failing: TranslateFn = async () => { + throw new Error("missing API key"); + }; + const result = await sync(failing); + + expect(result.status).toBe("synced"); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]!.locale).toBe("es"); + expect(result.failures[0]!.keys).toEqual(["hello"]); + expect(result.translatedKeys).toEqual([]); + + // Target file has no translation, and the lock must NOT claim it does + expect(readJSON("es.json")).toEqual({}); + const lock: LockFile = readJSON(".solid-translate.lock"); + expect(lock.keys).toEqual({}); + + // Rerun with a working translator picks the key back up (no silent + // "No changes detected" corruption) + const retry = await sync(fakeTranslate); + expect(retry.status).toBe("synced"); + expect(retry.translatedKeys).toEqual(["hello"]); + expect(readJSON("es.json")).toEqual({ hello: "es:HELLO" }); + }); + + test("per-batch failure: successful batches are written, failed keys excluded from lock", async () => { + writeSource({ a: "Alpha", b: "Bravo" }); + + // batchSize 1 → two batches; fail only the batch containing "b" + const partial: TranslateFn = async (batch, targetLocale) => { + if ("b" in batch) throw new Error("rate limited"); + return fakeTranslate(batch, targetLocale, {}); + }; + const result = await sync(partial, { batchSize: 1 }); + + expect(result.failures).toHaveLength(1); + expect(result.failures[0]!.keys).toEqual(["b"]); + expect(result.translatedKeys).toEqual(["a"]); + expect(readJSON("es.json")).toEqual({ a: "es:ALPHA" }); + const lock: LockFile = readJSON(".solid-translate.lock"); + expect(Object.keys(lock.keys)).toEqual(["a"]); + }); + + test("key failing in one locale is excluded from lock even if another locale succeeded", async () => { + writeSource({ hello: "Hello" }); + + const failFrOnly: TranslateFn = async (batch, targetLocale) => { + if (targetLocale === "fr") throw new Error("boom"); + return fakeTranslate(batch, targetLocale, {}); + }; + const result = await sync(failFrOnly, { targetLocales: ["es", "fr"] }); + + expect(result.failures).toHaveLength(1); + expect(result.failures[0]!.locale).toBe("fr"); + expect(result.translatedKeys).toEqual([]); + + // es got its translation written, but the lock records nothing so both + // locales retry the key next run + expect(readJSON("es.json")).toEqual({ hello: "es:HELLO" }); + expect(readJSON("fr.json")).toEqual({}); + const lock: LockFile = readJSON(".solid-translate.lock"); + expect(lock.keys).toEqual({}); + }); + + test("deletion-only change prunes target files and lock without AI calls", async () => { + // Seed: translate two keys normally + writeSource({ hello: "Hello", bye: "Bye" }); + await sync(fakeTranslate); + + // Delete one key from source; translator must not be called + writeSource({ hello: "Hello" }); + const result = await sync(forbiddenTranslate); + + expect(result.status).toBe("synced"); + expect(result.deletedKeys).toEqual(["bye"]); + expect(result.failures).toEqual([]); + expect(readJSON("es.json")).toEqual({ hello: "es:HELLO" }); + const lock: LockFile = readJSON(".solid-translate.lock"); + expect(Object.keys(lock.keys)).toEqual(["hello"]); + + // And the run after that is a clean no-op + const third = await sync(forbiddenTranslate); + expect(third.status).toBe("no-changes"); + }); + + test("deleting every source key prunes everything", async () => { + writeSource({ hello: "Hello" }); + await sync(fakeTranslate); + + writeSource({}); + const result = await sync(forbiddenTranslate); + expect(result.deletedKeys).toEqual(["hello"]); + expect(readJSON("es.json")).toEqual({}); + const lock: LockFile = readJSON(".solid-translate.lock"); + expect(lock.keys).toEqual({}); + }); + + test("CLI/vite lock parity: CLI-style run preserves context written by vite-style run", async () => { + // Vite-style run with autoExtract contexts + writeSource({ save: "Save" }); + await sync(fakeTranslate, { + contexts: { save: "Button to save a document" }, + }); + let lock: LockFile = readJSON(".solid-translate.lock"); + expect(lock.keys.save!.context).toBe("Button to save a document"); + + // CLI-style run (no contexts) sees no changes — no re-translation churn + const cliRun = await sync(forbiddenTranslate); + expect(cliRun.status).toBe("no-changes"); + + // CLI-style run after a source edit keeps the context in the lock + writeSource({ save: "Save changes" }); + await sync(fakeTranslate); + lock = readJSON(".solid-translate.lock"); + expect(lock.keys.save!.context).toBe("Button to save a document"); + expect(lock.keys.save!.source).toBe("Save changes"); + + // Vite-style run with the same context again: still no changes + const viteRun = await sync(forbiddenTranslate, { + contexts: { save: "Button to save a document" }, + }); + expect(viteRun.status).toBe("no-changes"); + }); + + test("passes context hints for changed keys to the translator", async () => { + writeSource({ save: "Save" }); + let seenContexts: Record | undefined; + const capture: TranslateFn = async (batch, targetLocale, contexts) => { + seenContexts = contexts; + return fakeTranslate(batch, targetLocale, contexts); + }; + await sync(capture, { contexts: { save: "Button label" } }); + expect(seenContexts).toEqual({ save: "Button label" }); + }); + + test("recovers from a corrupted lock file", async () => { + writeSource({ hello: "Hello" }); + writeFileSync(join(dir, ".solid-translate.lock"), "not json{"); + const result = await sync(fakeTranslate); + expect(result.status).toBe("synced"); + expect(result.translatedKeys).toEqual(["hello"]); + expect(existsSync(join(dir, "es.json"))).toBe(true); + }); +});