Skip to content

Commit

Permalink
Only translate missing keys
Browse files Browse the repository at this point in the history
  • Loading branch information
pontusab committed Dec 23, 2024
1 parent a16086e commit 028fdae
Show file tree
Hide file tree
Showing 7 changed files with 135 additions and 72 deletions.
3 changes: 2 additions & 1 deletion examples/i18next/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 3 additions & 2 deletions examples/i18next/locales/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
1 change: 0 additions & 1 deletion examples/next-international/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
7 changes: 4 additions & 3 deletions examples/next-international/locales/fr.ts
Original file line number Diff line number Diff line change
@@ -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}",
Expand All @@ -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;
48 changes: 11 additions & 37 deletions packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
@@ -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...");
Expand All @@ -25,51 +25,25 @@ export async function diff() {
process.exit(0);
}

// Extract changed/added/removed keys from diff
const addedKeys = new Set<string>();
const removedKeys = new Set<string>();
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"));
Expand Down
115 changes: 87 additions & 28 deletions packages/cli/src/commands/translate.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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...");
Expand All @@ -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) =>
Expand All @@ -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<string, string> = {};
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.
`;
Expand All @@ -84,27 +113,45 @@ 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),
finalContent,
"utf-8",
);

return { locale, sourcePath, success: true };
return { locale, sourcePath, success: true, addedKeys };
} catch (error) {
return { locale, sourcePath, success: false, error };
}
Expand All @@ -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(
Expand All @@ -128,8 +189,6 @@ export async function translate(targetLocale?: string) {
failure.error,
);
}
} else {
s.stop("Translation completed successfully");
}

outro(
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,31 @@ export async function getConfig() {

return config;
}

export function extractChangedKeys(diff: string) {
const addedKeys = new Set<string>();
const removedKeys = new Set<string>();

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

0 comments on commit 028fdae

Please sign in to comment.