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); + }); +});