Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 47 additions & 14 deletions src/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,20 @@ export async function syncLocaleFiles(

const changedCount = Object.keys(changedKeys).length;

if (changedCount === 0 && deletedKeys.length === 0) {
// Self-heal: keys the lock considers translated but that are absent from a
// target locale file (e.g. after an interrupted or previously-buggy run)
// still need translation for that locale, regardless of the lock diff.
const missingByLocale: Record<string, string[]> = {};
for (const targetLocale of targetLocales) {
const existing = readTargetFile(join(localesDir, `${targetLocale}.json`));
const missing = Object.keys(sourceDict).filter(
(key) => !(key in changedKeys) && !(key in existing),
);
if (missing.length > 0) missingByLocale[targetLocale] = missing;
}
const missingLocaleCount = Object.keys(missingByLocale).length;

if (changedCount === 0 && deletedKeys.length === 0 && missingLocaleCount === 0) {
log("No changes detected in locale files.");
return {
status: "no-changes",
Expand All @@ -170,7 +183,7 @@ export async function syncLocaleFiles(
};
}

if (changedCount === 0) {
if (changedCount === 0 && missingLocaleCount === 0) {
// Deletions only — prune target files and the lock, no AI calls needed
for (const targetLocale of targetLocales) {
const targetFilePath = join(localesDir, `${targetLocale}.json`);
Expand All @@ -185,17 +198,26 @@ export async function syncLocaleFiles(
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<string, string> = {};
for (const key of Object.keys(changedKeys)) {
const ctx = pendingEntries[key]?.context;
if (ctx) changedContexts[key] = ctx;
if (changedCount > 0) {
log(
`Translating ${changedCount} key${changedCount > 1 ? "s" : ""} to ${targetLocales.length} locale${targetLocales.length > 1 ? "s" : ""}...`,
);
}
if (missingLocaleCount > 0) {
const healTotal = Object.values(missingByLocale).reduce(
(sum, keys) => sum + keys.length,
0,
);
log(
`Healing ${healTotal} key${healTotal > 1 ? "s" : ""} missing from ${missingLocaleCount} locale file${missingLocaleCount > 1 ? "s" : ""}...`,
);
}

// Context hints, passed to the translator. Changed keys carry their pending
// entry's context; healed keys fall back to the committed lock entry.
const contextFor = (key: string): string | undefined =>
pendingEntries[key]?.context ?? lock.keys[key]?.context;

const failures: SyncFailure[] = [];
const failedKeys = new Set<string>();

Expand All @@ -205,15 +227,26 @@ export async function syncLocaleFiles(
// Load existing translations to preserve unchanged keys
const existing = readTargetFile(targetFilePath);

// Batch translate changed keys
const entries = Object.entries(changedKeys);
// Changed keys for every locale, plus this locale's healed keys
const localeEntries: Record<string, string> = { ...changedKeys };
for (const key of missingByLocale[targetLocale] ?? []) {
localeEntries[key] = sourceDict[key]!;
}
const localeContexts: Record<string, string> = {};
for (const key of Object.keys(localeEntries)) {
const ctx = contextFor(key);
if (ctx) localeContexts[key] = ctx;
}

// Batch translate
const entries = Object.entries(localeEntries);
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,
localeContexts,
);
Object.assign(existing, translated);
} catch (err) {
Expand Down
123 changes: 108 additions & 15 deletions src/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,64 @@ async function loadGenerateObject() {
return generateObject;
}

async function loadGenerateText() {
const { generateText } = await import("ai");
return generateText;
}

/**
* Pull a JSON object out of a model text response. Tolerates markdown code
* fences and prose around the object; takes the outermost `{...}` span.
* Exported for tests.
*/
export function extractJsonObject(text: string): Record<string, unknown> {
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start === -1 || end <= start) {
throw new Error("model response contained no JSON object");
}
const parsed: unknown = JSON.parse(text.slice(start, end + 1));
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("model response was not a JSON object");
}
return parsed as Record<string, unknown>;
}

/**
* Normalize a parsed model response into a translations dictionary limited to
* the requested keys, and report which requested keys are missing (absent,
* non-string, or empty). Unwraps a `{ "translations": { ... } }` envelope if
* the model added one. Exported for tests.
*/
export function collectBatchTranslations(
parsed: Record<string, unknown>,
requestedKeys: string[],
): { translations: Record<string, string>; missing: string[] } {
let dict = parsed;
const inner = parsed["translations"];
if (
typeof inner === "object" &&
inner !== null &&
!Array.isArray(inner) &&
// Only unwrap when the envelope key is not itself a requested key
!requestedKeys.includes("translations")
) {
dict = inner as Record<string, unknown>;
}

const translations: Record<string, string> = {};
const missing: string[] = [];
for (const key of requestedKeys) {
const value = dict[key];
if (typeof value === "string" && value.length > 0) {
translations[key] = value;
} else {
missing.push(key);
}
}
return { translations, missing };
}

/**
* Translate a batch of key-value pairs from one locale to another using AI.
* Supports optional per-key context hints for disambiguation.
Expand Down Expand Up @@ -53,22 +111,57 @@ export async function translateBatch(
}
}

const generateObject = await loadGenerateObject();
const { object } = await generateObject({
model,
schema: z.object({
translations: z.record(z.string(), z.string()),
}),
system: systemPrompt || defaultSystem,
prompt: [
`Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
`Return a JSON object with the exact same keys and the translated values.`,
contextSection,
JSON.stringify(entries, null, 2),
].join("\n"),
});
// generateText + manual JSON parsing instead of generateObject: a
// Record<string, string> compiles to a JSON schema made only of
// `additionalProperties`, which several providers' structured-output modes
// handle badly — Gemini (via OpenRouter) silently returns `{}` and OpenAI's
// strict mode rejects the schema outright. Free-form JSON with strict
// post-validation works across every provider.
const generateText = await loadGenerateText();
const basePrompt = [
`Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
`Respond with ONLY a JSON object — no prose, no code fences — containing the exact same keys and the translated values.`,
contextSection,
JSON.stringify(entries, null, 2),
].join("\n");

const attempt = async (prompt: string) => {
const { text } = await generateText({
model,
system: systemPrompt || defaultSystem,
prompt,
});
return collectBatchTranslations(extractJsonObject(text), keys);
};

let { translations, missing } = await attempt(basePrompt);

if (missing.length > 0) {
// One corrective retry for just the missing keys, then hard-fail so the
// caller records the batch as failed instead of committing a poisoned
// lock over silently-untranslated keys.
const retryEntries: Record<string, string> = {};
for (const key of missing) retryEntries[key] = entries[key]!;
const retry = await attempt(
[
`Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
`Respond with ONLY a JSON object — no prose, no code fences — containing the exact same keys and the translated values.`,
contextSection,
JSON.stringify(retryEntries, null, 2),
].join("\n"),
);
translations = { ...translations, ...retry.translations };
missing = retry.missing;
}

if (missing.length > 0) {
const sample = missing.slice(0, 3).join('", "');
throw new Error(
`model returned no translation for ${missing.length} of ${keys.length} keys (e.g. "${sample}")`,
);
}

return object.translations;
return translations;
}

/**
Expand Down
Loading
Loading