i18n-tool.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. #!/usr/bin/env node
  2. import fs from 'fs';
  3. import path from 'path';
  4. import { fileURLToPath } from 'url';
  5. const __filename = fileURLToPath(import.meta.url);
  6. const __dirname = path.dirname(__filename);
  7. // Configuration
  8. const DEFAULT_LOCALES_DIR = '../../src/i18n/locales';
  9. /**
  10. * Get all supported languages by scanning .po files in the locales directory
  11. */
  12. function getSupportedLanguages(localesDir) {
  13. const files = fs.readdirSync(localesDir);
  14. return files
  15. .filter(file => file.endsWith('.po'))
  16. .map(file => file.slice(0, -3)) // Remove .po extension
  17. .filter(lang => lang !== 'en') // Skip English (source language)
  18. .sort();
  19. }
  20. /**
  21. * Parse a .po file and extract missing translations (empty msgstr entries)
  22. */
  23. function parsePOFile(filePath) {
  24. const content = fs.readFileSync(filePath, 'utf-8');
  25. const missingMsgids = [];
  26. // Split into entries using double newline as separator
  27. const entries = content.split(/\n\s*\n/);
  28. for (const entry of entries) {
  29. const lines = entry.trim().split('\n');
  30. if (!lines.length) continue;
  31. const msgidLines = [];
  32. const msgstrLines = [];
  33. let currentSection = null;
  34. for (const line of lines) {
  35. const trimmedLine = line.trim();
  36. if (trimmedLine.startsWith('#')) {
  37. continue;
  38. } else if (trimmedLine.startsWith('msgid ')) {
  39. currentSection = 'msgid';
  40. msgidLines.push(trimmedLine.slice(6)); // Remove 'msgid '
  41. } else if (trimmedLine.startsWith('msgstr ')) {
  42. currentSection = 'msgstr';
  43. msgstrLines.push(trimmedLine.slice(7)); // Remove 'msgstr '
  44. } else if (trimmedLine.startsWith('"') && currentSection) {
  45. if (currentSection === 'msgid') {
  46. msgidLines.push(trimmedLine);
  47. } else if (currentSection === 'msgstr') {
  48. msgstrLines.push(trimmedLine);
  49. }
  50. }
  51. }
  52. // Check if we have a msgid and empty msgstr
  53. if (msgidLines.length && msgstrLines.length) {
  54. const msgstrContent = msgstrLines.join('');
  55. if (msgstrContent === '""' || msgstrContent === '') {
  56. // Extract msgid content
  57. const msgidContent = msgidLines.join('');
  58. if (msgidContent.startsWith('"') && msgidContent.endsWith('"')) {
  59. const msgidText = msgidContent.slice(1, -1); // Remove outer quotes
  60. // Unescape common escape sequences
  61. const unescapedText = msgidText
  62. .replace(/\\"/g, '"')
  63. .replace(/\\n/g, '\n')
  64. .replace(/\\\\/g, '\\');
  65. if (unescapedText) {
  66. // Skip empty msgid (header)
  67. missingMsgids.push(unescapedText);
  68. }
  69. }
  70. }
  71. }
  72. }
  73. return missingMsgids;
  74. }
  75. /**
  76. * Extract missing translations from all locale files and generate LLM prompt
  77. */
  78. function extractMissingTranslations(
  79. localesDir = DEFAULT_LOCALES_DIR,
  80. outputFile = 'missing-translations.txt',
  81. ) {
  82. const localesPath = path.resolve(__dirname, localesDir);
  83. if (!fs.existsSync(localesPath)) {
  84. console.error(`Locales directory not found: ${localesPath}`);
  85. process.exit(1);
  86. }
  87. console.log(`Extracting missing translations from: ${localesPath}`);
  88. const missingByLanguage = {};
  89. let totalMissing = 0;
  90. // Get all supported languages from the directory
  91. const supportedLanguages = getSupportedLanguages(localesPath);
  92. console.log(`Found ${supportedLanguages.length} language files: ${supportedLanguages.join(', ')}`);
  93. // Process each supported language
  94. for (const lang of supportedLanguages) {
  95. const poFilePath = path.join(localesPath, `${lang}.po`);
  96. if (!fs.existsSync(poFilePath)) {
  97. console.warn(`Warning: .po file not found for ${lang}`);
  98. continue;
  99. }
  100. const missingMsgids = parsePOFile(poFilePath);
  101. if (missingMsgids.length > 0) {
  102. missingByLanguage[lang] = missingMsgids;
  103. totalMissing += missingMsgids.length;
  104. console.log(`${lang}: ${missingMsgids.length} missing translations`);
  105. } else {
  106. console.log(`${lang}: 0 missing translations`);
  107. }
  108. }
  109. // Generate LLM prompt with missing translations
  110. const promptLines = [
  111. '# Translation Request for Vendure Dashboard',
  112. '',
  113. 'Please translate the missing message IDs below for each language. The context is a dashboard for an e-commerce platform called Vendure.',
  114. '',
  115. '## Instructions:',
  116. '1. Translate each msgid into the target language',
  117. '2. Maintain the original formatting, including placeholders like {0}, {buttonText}, etc.',
  118. '3. Keep HTML tags and markdown formatting intact',
  119. '4. Use appropriate UI/technical terminology for each language',
  120. '5. Return translations in the exact format: language_code followed by msgid|msgstr pairs',
  121. '6. These strings are for use in the Lingui library and use ICU MessageFormat',
  122. '7. Always assume e-commerce context unless clearly indicated otherwise',
  123. '',
  124. '## Expected Output Format:',
  125. '```',
  126. 'language_code',
  127. 'msgid_text|translated_text',
  128. 'msgid_text|translated_text',
  129. '---',
  130. 'language_code',
  131. 'msgid_text|translated_text',
  132. '---',
  133. '```',
  134. '',
  135. '## Missing Translations:',
  136. '',
  137. ];
  138. // Add missing translations for each language
  139. for (const [lang, msgids] of Object.entries(missingByLanguage)) {
  140. promptLines.push(lang);
  141. for (const msgid of msgids) {
  142. promptLines.push(msgid);
  143. }
  144. promptLines.push('---');
  145. }
  146. // Write to output file
  147. const outputPath = path.resolve(outputFile);
  148. fs.writeFileSync(outputPath, promptLines.join('\n'), 'utf-8');
  149. console.log(`\nExtraction completed!`);
  150. console.log(`Total missing translations: ${totalMissing}`);
  151. console.log(`Languages with missing translations: ${Object.keys(missingByLanguage).length}`);
  152. console.log(`Prompt written to: ${outputPath}`);
  153. console.log(`\nNext steps:`);
  154. console.log(`1. Copy the content of ${outputFile} to Claude or another LLM`);
  155. console.log(`2. Save the translated output to a file (e.g., translations.txt)`);
  156. console.log(`3. Run: node i18n-tool.js apply <translations-file>`);
  157. }
  158. /**
  159. * Apply translations from LLM output back to .po files
  160. */
  161. function applyTranslations(translationsFile, localesDir = DEFAULT_LOCALES_DIR) {
  162. const localesPath = path.resolve(__dirname, localesDir);
  163. const translationsPath = path.resolve(translationsFile);
  164. if (!fs.existsSync(localesPath)) {
  165. console.error(`Locales directory not found: ${localesPath}`);
  166. process.exit(1);
  167. }
  168. if (!fs.existsSync(translationsPath)) {
  169. console.error(`Translations file not found: ${translationsPath}`);
  170. process.exit(1);
  171. }
  172. console.log(`Applying translations from: ${translationsPath}`);
  173. console.log(`Target directory: ${localesPath}\n`);
  174. // Read and parse the translations file
  175. const translationsContent = fs.readFileSync(translationsPath, 'utf-8');
  176. const languageBlocks = translationsContent.split(/\n---\n?/).filter(block => block.trim());
  177. // Parse translations by language
  178. const translationsByLanguage = {};
  179. languageBlocks.forEach(block => {
  180. const lines = block.trim().split('\n');
  181. const languageCode = lines[0].trim();
  182. if (!languageCode) return;
  183. translationsByLanguage[languageCode] = {};
  184. // Parse each translation line (format: msgid|msgstr)
  185. for (let i = 1; i < lines.length; i++) {
  186. const line = lines[i].trim();
  187. if (!line) continue;
  188. const pipeIndex = line.indexOf('|');
  189. if (pipeIndex === -1) {
  190. console.warn(`Warning: Line "${line.substring(0, 50)}..." has no pipe separator, skipping`);
  191. continue;
  192. }
  193. const msgid = line.substring(0, pipeIndex);
  194. const msgstr = line.substring(pipeIndex + 1);
  195. translationsByLanguage[languageCode][msgid] = msgstr;
  196. }
  197. });
  198. // Apply translations to each language file
  199. Object.entries(translationsByLanguage).forEach(([languageCode, translations]) => {
  200. const translationCount = Object.keys(translations).length;
  201. console.log(`\nProcessing ${languageCode} (${translationCount} translations)...`);
  202. updatePoFile(localesPath, languageCode, translations);
  203. });
  204. console.log('\nDone!');
  205. }
  206. /**
  207. * Function to escape special characters in strings for .po files
  208. */
  209. function escapePoString(str) {
  210. return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t');
  211. }
  212. /**
  213. * Function to escape string for use in regex (including already-escaped quotes)
  214. */
  215. function escapeRegex(str) {
  216. // First escape for .po format
  217. const poEscaped = escapePoString(str);
  218. // Then escape for regex (note: backslashes are already doubled from escapePoString)
  219. return poEscaped.replace(/[.*+?^${}()|[\]]/g, '\\$&');
  220. }
  221. /**
  222. * Function to find and update msgstr in .po file
  223. */
  224. function updatePoFile(localesDir, languageCode, translations) {
  225. const poFilePath = path.join(localesDir, `${languageCode}.po`);
  226. if (!fs.existsSync(poFilePath)) {
  227. console.warn(`Warning: .po file not found for language ${languageCode}: ${poFilePath}`);
  228. return;
  229. }
  230. let poContent = fs.readFileSync(poFilePath, 'utf-8');
  231. let updated = 0;
  232. let notFound = [];
  233. // Process each translation
  234. Object.entries(translations).forEach(([msgid, msgstr]) => {
  235. // Escape the msgid for regex matching
  236. const escapedMsgidForRegex = escapeRegex(msgid);
  237. // Pattern to match msgid with empty msgstr
  238. const pattern = new RegExp(`(msgid "${escapedMsgidForRegex}"\\s*\\n)(msgstr "")`, 'gm');
  239. const matches = poContent.match(pattern);
  240. if (matches) {
  241. poContent = poContent.replace(pattern, `$1msgstr "${escapePoString(msgstr)}"`);
  242. updated++;
  243. } else {
  244. notFound.push(msgid);
  245. }
  246. });
  247. // Write updated content back to file
  248. if (updated > 0) {
  249. fs.writeFileSync(poFilePath, poContent, 'utf-8');
  250. console.log(`✓ ${languageCode}: Updated ${updated} translations`);
  251. } else {
  252. console.log(`- ${languageCode}: No translations updated`);
  253. }
  254. if (notFound.length > 0) {
  255. console.log(` ⚠ ${notFound.length} msgids not found in .po file`);
  256. if (notFound.length <= 5) {
  257. notFound.forEach(msg => console.log(` - "${msg}"`));
  258. }
  259. }
  260. }
  261. /**
  262. * Main CLI interface
  263. */
  264. function main() {
  265. const args = process.argv.slice(2);
  266. const command = args[0];
  267. switch (command) {
  268. case 'extract':
  269. const outputFile = args[1] || 'missing-translations.txt';
  270. const localesDir = args[2] || DEFAULT_LOCALES_DIR;
  271. extractMissingTranslations(localesDir, outputFile);
  272. break;
  273. case 'apply':
  274. if (args.length < 2) {
  275. console.error('Usage: node i18n-tool.js apply <translations-file> [locales-dir]');
  276. console.error('Example: node i18n-tool.js apply translations.txt');
  277. process.exit(1);
  278. }
  279. const translationsFile = args[1];
  280. const targetLocalesDir = args[2] || DEFAULT_LOCALES_DIR;
  281. applyTranslations(translationsFile, targetLocalesDir);
  282. break;
  283. default:
  284. console.log('Vendure Dashboard i18n Tool');
  285. console.log('');
  286. console.log('Usage:');
  287. console.log(' node i18n-tool.js extract [output-file] [locales-dir]');
  288. console.log(' Extract missing translations and generate LLM prompt');
  289. console.log('');
  290. console.log(' node i18n-tool.js apply <translations-file> [locales-dir]');
  291. console.log(' Apply translated strings back to .po files');
  292. console.log('');
  293. console.log('Examples:');
  294. console.log(' node i18n-tool.js extract');
  295. console.log(' node i18n-tool.js extract prompt.txt');
  296. console.log(' node i18n-tool.js apply translations.txt');
  297. console.log('');
  298. console.log('Workflow:');
  299. console.log(' 1. Add new messages to dashboard components');
  300. console.log(' 2. Run: lingui extract');
  301. console.log(' 3. Run: node i18n-tool.js extract');
  302. console.log(' 4. Copy prompt to LLM (Claude, etc.) and get translations');
  303. console.log(' 5. Save LLM output to a file');
  304. console.log(' 6. Run: node i18n-tool.js apply <translations-file>');
  305. break;
  306. }
  307. }
  308. // Run the CLI if this file is executed directly
  309. if (import.meta.url === `file://${process.argv[1]}`) {
  310. main();
  311. }
  312. export {
  313. applyTranslations,
  314. escapePoString,
  315. escapeRegex,
  316. extractMissingTranslations,
  317. parsePOFile,
  318. updatePoFile,
  319. };