From 028fdae9d8c5d28ec8eddc9761fecd28fdc842ff Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Mon, 23 Dec 2024 13:30:22 +0100 Subject: [PATCH] Only translate missing keys --- examples/i18next/locales/en.json | 3 +- examples/i18next/locales/sv.json | 5 +- examples/next-international/locales/en.ts | 1 - examples/next-international/locales/fr.ts | 7 +- packages/cli/src/commands/diff.ts | 48 +++------ packages/cli/src/commands/translate.ts | 115 ++++++++++++++++------ packages/cli/src/utils.ts | 28 ++++++ 7 files changed, 135 insertions(+), 72 deletions(-) diff --git a/examples/i18next/locales/en.json b/examples/i18next/locales/en.json index 34b25ba..cfbcd21 100644 --- a/examples/i18next/locales/en.json +++ b/examples/i18next/locales/en.json @@ -5,5 +5,6 @@ }, "interpolated": "Have a nice day, {{name}}!", "pluralKey_one": "This is a nice example.", - "pluralKey_other": "This are nice examples." + "pluralKey_other": "This are nice examples.", + "missing_translation": "This should work" } diff --git a/examples/i18next/locales/sv.json b/examples/i18next/locales/sv.json index 76491c8..5e4259e 100644 --- a/examples/i18next/locales/sv.json +++ b/examples/i18next/locales/sv.json @@ -5,5 +5,6 @@ }, "interpolated": "Ha en trevlig dag, {{name}}!", "pluralKey_one": "Det här är ett fint exempel.", - "pluralKey_other": "Det här är fina exempel." -} + "pluralKey_other": "Det här är fina exempel.", + "missing_translation": "Detta bör fungera" +} \ No newline at end of file diff --git a/examples/next-international/locales/en.ts b/examples/next-international/locales/en.ts index 8d5ce43..76ac4f9 100644 --- a/examples/next-international/locales/en.ts +++ b/examples/next-international/locales/en.ts @@ -11,5 +11,4 @@ export default { "missing.translation.in.fr": "This should work", "cows#one": "A cow", "cows#other": "{count} cows", - "hello.world": "Hello World", } as const; diff --git a/examples/next-international/locales/fr.ts b/examples/next-international/locales/fr.ts index 75be5a8..aecd861 100644 --- a/examples/next-international/locales/fr.ts +++ b/examples/next-international/locales/fr.ts @@ -1,7 +1,7 @@ export default { - hello: "Bonjour", - welcome: "Bonjour {name}!", - "about.you": "Bonjour {name}! Vous avez {age} ans", + "hello": "Bonjour", + "welcome": "Bonjour {name} !", + "about.you": "Bonjour {name} ! Vous avez {age} ans", "scope.test": "Un domaine", "scope.more.test": "Un domaine", "scope.more.param": "Un domaine avec {param}", @@ -11,4 +11,5 @@ export default { "missing.translation.in.fr": "Cela devrait fonctionner", "cows#one": "Une vache", "cows#other": "{count} vaches", + "hello.world2": "Bonjour le monde !" } as const; diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index 2624d35..ebc9409 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -1,7 +1,7 @@ import { execSync } from "node:child_process"; import { intro, outro } from "@clack/prompts"; import chalk from "chalk"; -import { getConfig } from "../utils.js"; +import { extractChangedKeys, getConfig } from "../utils.js"; export async function diff() { intro("Checking for changes in source locale file..."); @@ -25,51 +25,25 @@ export async function diff() { process.exit(0); } - // Extract changed/added/removed keys from diff - const addedKeys = new Set(); - const removedKeys = new Set(); + const { addedKeys, removedKeys } = extractChangedKeys(diff); - for (const line of diff.split("\n")) { - if (line.startsWith("+") && !line.startsWith("+++")) { - const match = line.match(/['"]([\w_.]+)['"]/); - if (match) addedKeys.add(match[1]); - } else if (line.startsWith("-") && !line.startsWith("---")) { - const match = line.match(/['"]([\w_.]+)['"]/); - if (match) removedKeys.add(match[1]); - } - } - - // Remove keys that appear in both added and removed (these are modifications) - for (const key of addedKeys) { - if (removedKeys.has(key)) { - addedKeys.delete(key); - removedKeys.delete(key); - } - } - - if (addedKeys.size === 0 && removedKeys.size === 0) { + if (addedKeys.length === 0 && removedKeys.length === 0) { outro( chalk.yellow("No translation keys were added, modified or removed."), ); process.exit(0); } - let message = ""; - if (addedKeys.size > 0) { - message += chalk.green( - `Found ${addedKeys.size} added translation key${addedKeys.size === 1 ? "" : "s"}\n`, - ); - } - if (removedKeys.size > 0) { - message += chalk.red( - `Found ${removedKeys.size} removed translation key${removedKeys.size === 1 ? "" : "s"}`, - ); - } + const totalChanges = addedKeys.length + removedKeys.length; + outro( + chalk.blue( + `Found ${totalChanges} translation key${totalChanges === 1 ? "" : "s"} changed`, + ), + ); - outro(message); return { - addedKeys: Array.from(addedKeys), - removedKeys: Array.from(removedKeys), + addedKeys, + removedKeys, }; } catch (error) { outro(chalk.red("Failed to check for changes")); diff --git a/packages/cli/src/commands/translate.ts b/packages/cli/src/commands/translate.ts index 3075a8f..7aaa2db 100644 --- a/packages/cli/src/commands/translate.ts +++ b/packages/cli/src/commands/translate.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { createOpenAI } from "@ai-sdk/openai"; @@ -6,7 +7,7 @@ import { generateText } from "ai"; import chalk from "chalk"; import dedent from "dedent"; import { prompt as defaultPrompt } from "../prompt.js"; -import { getApiKey, getConfig } from "../utils.js"; +import { extractChangedKeys, getApiKey, getConfig } from "../utils.js"; export async function translate(targetLocale?: string) { intro("Starting translation process..."); @@ -32,7 +33,7 @@ export async function translate(targetLocale?: string) { }); const s = spinner(); - s.start("Translating to all target locales..."); + s.start("Checking for changes and translating to target locales..."); // Create translation tasks for all locales and file patterns const translationTasks = locales.flatMap((locale) => @@ -42,38 +43,66 @@ export async function translate(targetLocale?: string) { const targetPath = pattern.replace("[locale]", locale); try { - // Read source file - let sourceContent = ""; + // Get git diff for source file + const diff = execSync(`git diff HEAD -- ${sourcePath}`, { + encoding: "utf-8", + }); + + if (!diff) { + return { locale, sourcePath, success: true, noChanges: true }; + } + + const { addedKeys } = extractChangedKeys(diff); + + if (addedKeys.length === 0) { + return { locale, sourcePath, success: true, noChanges: true }; + } + + // Read source and target files + const sourceContent = await fs.readFile( + path.join(process.cwd(), sourcePath), + "utf-8", + ); + + let targetContent = ""; try { - sourceContent = await fs.readFile( - path.join(process.cwd(), sourcePath), + targetContent = await fs.readFile( + path.join(process.cwd(), targetPath), "utf-8", ); } catch (error) { - // Create source file if it doesn't exist - const sourceDir = path.dirname( - path.join(process.cwd(), sourcePath), - ); - await fs.mkdir(sourceDir, { recursive: true }); - await fs.writeFile( - path.join(process.cwd(), sourcePath), - "", - "utf-8", + // Create target file if it doesn't exist + const targetDir = path.dirname( + path.join(process.cwd(), targetPath), ); + await fs.mkdir(targetDir, { recursive: true }); + } + + // Prepare translation prompt with only new keys + const sourceObj = + format === "ts" + ? Function( + `return ${sourceContent.replace(/export default |as const;/g, "")}`, + )() + : JSON.parse(sourceContent); + + const newKeysObj: Record = {}; + for (const key of addedKeys) { + newKeysObj[key] = sourceObj[key]; } - // Prepare translation prompt const prompt = dedent` You are a professional translator working with ${format.toUpperCase()} files. Task: Translate the content below from ${source} to ${locale}. + Only translate the new keys provided. ${defaultPrompt} ${config.instructions ?? ""} - Source content: - ${sourceContent} + Source content (new keys only): + ${JSON.stringify(newKeysObj, null, 2)} Return only the translated content with identical structure. `; @@ -84,19 +113,37 @@ export async function translate(targetLocale?: string) { prompt, }); + // Parse the translated content + const translatedObj = + format === "ts" + ? Function(`return ${text.replace(/as const;?/g, "")}`)() + : JSON.parse(text); + + // Merge with existing translations + const existingObj = targetContent + ? format === "ts" + ? Function( + `return ${targetContent.replace(/export default |as const;/g, "")}`, + )() + : JSON.parse(targetContent) + : {}; + + const mergedObj = { ...existingObj, ...translatedObj }; + + // Format the final content + let finalContent = + format === "ts" + ? `export default ${JSON.stringify(mergedObj, null, 2)} as const;\n` + : JSON.stringify(mergedObj, null, 2); + // Run afterTranslate hook if defined - let finalContent = text; if (config.hooks?.afterTranslate) { finalContent = await config.hooks.afterTranslate({ - content: text, + content: finalContent, filePath: targetPath, }); } - // Ensure target directory exists - const targetDir = path.dirname(path.join(process.cwd(), targetPath)); - await fs.mkdir(targetDir, { recursive: true }); - // Write translated content await fs.writeFile( path.join(process.cwd(), targetPath), @@ -104,7 +151,7 @@ export async function translate(targetLocale?: string) { "utf-8", ); - return { locale, sourcePath, success: true }; + return { locale, sourcePath, success: true, addedKeys }; } catch (error) { return { locale, sourcePath, success: false, error }; } @@ -117,9 +164,23 @@ export async function translate(targetLocale?: string) { // Process results const failures = results.filter((r) => !r.success); + const changes = results.filter((r) => !r.noChanges && r.success); + + s.stop("Translation completed"); + + if (changes.length > 0) { + for (const result of changes) { + console.log( + chalk.green( + `✓ Translated ${result.addedKeys?.length} new keys for ${result.locale}`, + ), + ); + } + } else { + console.log(chalk.yellow("No new keys to translate")); + } if (failures.length > 0) { - s.stop("Translation completed with errors"); for (const failure of failures) { console.error( chalk.red( @@ -128,8 +189,6 @@ export async function translate(targetLocale?: string) { failure.error, ); } - } else { - s.stop("Translation completed successfully"); } outro( diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 4fd173c..96eac6a 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -77,3 +77,31 @@ export async function getConfig() { return config; } + +export function extractChangedKeys(diff: string) { + const addedKeys = new Set(); + const removedKeys = new Set(); + + for (const line of diff.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) { + const match = line.match(/['"]([\w_.]+)['"]/); + if (match) addedKeys.add(match[1]); + } else if (line.startsWith("-") && !line.startsWith("---")) { + const match = line.match(/['"]([\w_.]+)['"]/); + if (match) removedKeys.add(match[1]); + } + } + + // Remove keys that appear in both added and removed (these are modifications) + for (const key of addedKeys) { + if (removedKeys.has(key)) { + addedKeys.delete(key); + removedKeys.delete(key); + } + } + + return { + addedKeys: Array.from(addedKeys), + removedKeys: Array.from(removedKeys), + }; +}