| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- #!/usr/bin/env node
- import fs from 'fs';
- import path from 'path';
- import { fileURLToPath } from 'url';
- const __filename = fileURLToPath(import.meta.url);
- const __dirname = path.dirname(__filename);
- // Configuration
- const DEFAULT_LOCALES_DIR = '../../src/i18n/locales';
- /**
- * Get all supported languages by scanning .po files in the locales directory
- */
- function getSupportedLanguages(localesDir) {
- const files = fs.readdirSync(localesDir);
- return files
- .filter(file => file.endsWith('.po'))
- .map(file => file.slice(0, -3)) // Remove .po extension
- .filter(lang => lang !== 'en') // Skip English (source language)
- .sort();
- }
- /**
- * Parse a .po file and extract missing translations (empty msgstr entries)
- */
- function parsePOFile(filePath) {
- const content = fs.readFileSync(filePath, 'utf-8');
- const missingMsgids = [];
- // Split into entries using double newline as separator
- const entries = content.split(/\n\s*\n/);
- for (const entry of entries) {
- const lines = entry.trim().split('\n');
- if (!lines.length) continue;
- const msgidLines = [];
- const msgstrLines = [];
- let currentSection = null;
- for (const line of lines) {
- const trimmedLine = line.trim();
- if (trimmedLine.startsWith('#')) {
- continue;
- } else if (trimmedLine.startsWith('msgid ')) {
- currentSection = 'msgid';
- msgidLines.push(trimmedLine.slice(6)); // Remove 'msgid '
- } else if (trimmedLine.startsWith('msgstr ')) {
- currentSection = 'msgstr';
- msgstrLines.push(trimmedLine.slice(7)); // Remove 'msgstr '
- } else if (trimmedLine.startsWith('"') && currentSection) {
- if (currentSection === 'msgid') {
- msgidLines.push(trimmedLine);
- } else if (currentSection === 'msgstr') {
- msgstrLines.push(trimmedLine);
- }
- }
- }
- // Check if we have a msgid and empty msgstr
- if (msgidLines.length && msgstrLines.length) {
- const msgstrContent = msgstrLines.join('');
- if (msgstrContent === '""' || msgstrContent === '') {
- // Extract msgid content
- const msgidContent = msgidLines.join('');
- if (msgidContent.startsWith('"') && msgidContent.endsWith('"')) {
- const msgidText = msgidContent.slice(1, -1); // Remove outer quotes
- // Unescape common escape sequences
- const unescapedText = msgidText
- .replace(/\\"/g, '"')
- .replace(/\\n/g, '\n')
- .replace(/\\\\/g, '\\');
- if (unescapedText) {
- // Skip empty msgid (header)
- missingMsgids.push(unescapedText);
- }
- }
- }
- }
- }
- return missingMsgids;
- }
- /**
- * Extract missing translations from all locale files and generate LLM prompt
- */
- function extractMissingTranslations(
- localesDir = DEFAULT_LOCALES_DIR,
- outputFile = 'missing-translations.txt',
- ) {
- const localesPath = path.resolve(__dirname, localesDir);
- if (!fs.existsSync(localesPath)) {
- console.error(`Locales directory not found: ${localesPath}`);
- process.exit(1);
- }
- console.log(`Extracting missing translations from: ${localesPath}`);
- const missingByLanguage = {};
- let totalMissing = 0;
- // Get all supported languages from the directory
- const supportedLanguages = getSupportedLanguages(localesPath);
- console.log(`Found ${supportedLanguages.length} language files: ${supportedLanguages.join(', ')}`);
- // Process each supported language
- for (const lang of supportedLanguages) {
- const poFilePath = path.join(localesPath, `${lang}.po`);
- if (!fs.existsSync(poFilePath)) {
- console.warn(`Warning: .po file not found for ${lang}`);
- continue;
- }
- const missingMsgids = parsePOFile(poFilePath);
- if (missingMsgids.length > 0) {
- missingByLanguage[lang] = missingMsgids;
- totalMissing += missingMsgids.length;
- console.log(`${lang}: ${missingMsgids.length} missing translations`);
- } else {
- console.log(`${lang}: 0 missing translations`);
- }
- }
- // Generate LLM prompt with missing translations
- const promptLines = [
- '# Translation Request for Vendure Dashboard',
- '',
- 'Please translate the missing message IDs below for each language. The context is a dashboard for an e-commerce platform called Vendure.',
- '',
- '## Instructions:',
- '1. Translate each msgid into the target language',
- '2. Maintain the original formatting, including placeholders like {0}, {buttonText}, etc.',
- '3. Keep HTML tags and markdown formatting intact',
- '4. Use appropriate UI/technical terminology for each language',
- '5. Return translations in the exact format: language_code followed by msgid|msgstr pairs',
- '6. These strings are for use in the Lingui library and use ICU MessageFormat',
- '7. Always assume e-commerce context unless clearly indicated otherwise',
- '',
- '## Expected Output Format:',
- '```',
- 'language_code',
- 'msgid_text|translated_text',
- 'msgid_text|translated_text',
- '---',
- 'language_code',
- 'msgid_text|translated_text',
- '---',
- '```',
- '',
- '## Missing Translations:',
- '',
- ];
- // Add missing translations for each language
- for (const [lang, msgids] of Object.entries(missingByLanguage)) {
- promptLines.push(lang);
- for (const msgid of msgids) {
- promptLines.push(msgid);
- }
- promptLines.push('---');
- }
- // Write to output file
- const outputPath = path.resolve(outputFile);
- fs.writeFileSync(outputPath, promptLines.join('\n'), 'utf-8');
- console.log(`\nExtraction completed!`);
- console.log(`Total missing translations: ${totalMissing}`);
- console.log(`Languages with missing translations: ${Object.keys(missingByLanguage).length}`);
- console.log(`Prompt written to: ${outputPath}`);
- console.log(`\nNext steps:`);
- console.log(`1. Copy the content of ${outputFile} to Claude or another LLM`);
- console.log(`2. Save the translated output to a file (e.g., translations.txt)`);
- console.log(`3. Run: node i18n-tool.js apply <translations-file>`);
- }
- /**
- * Apply translations from LLM output back to .po files
- */
- function applyTranslations(translationsFile, localesDir = DEFAULT_LOCALES_DIR) {
- const localesPath = path.resolve(__dirname, localesDir);
- const translationsPath = path.resolve(translationsFile);
- if (!fs.existsSync(localesPath)) {
- console.error(`Locales directory not found: ${localesPath}`);
- process.exit(1);
- }
- if (!fs.existsSync(translationsPath)) {
- console.error(`Translations file not found: ${translationsPath}`);
- process.exit(1);
- }
- console.log(`Applying translations from: ${translationsPath}`);
- console.log(`Target directory: ${localesPath}\n`);
- // Read and parse the translations file
- const translationsContent = fs.readFileSync(translationsPath, 'utf-8');
- const languageBlocks = translationsContent.split(/\n---\n?/).filter(block => block.trim());
- // Parse translations by language
- const translationsByLanguage = {};
- languageBlocks.forEach(block => {
- const lines = block.trim().split('\n');
- const languageCode = lines[0].trim();
- if (!languageCode) return;
- translationsByLanguage[languageCode] = {};
- // Parse each translation line (format: msgid|msgstr)
- for (let i = 1; i < lines.length; i++) {
- const line = lines[i].trim();
- if (!line) continue;
- const pipeIndex = line.indexOf('|');
- if (pipeIndex === -1) {
- console.warn(`Warning: Line "${line.substring(0, 50)}..." has no pipe separator, skipping`);
- continue;
- }
- const msgid = line.substring(0, pipeIndex);
- const msgstr = line.substring(pipeIndex + 1);
- translationsByLanguage[languageCode][msgid] = msgstr;
- }
- });
- // Apply translations to each language file
- Object.entries(translationsByLanguage).forEach(([languageCode, translations]) => {
- const translationCount = Object.keys(translations).length;
- console.log(`\nProcessing ${languageCode} (${translationCount} translations)...`);
- updatePoFile(localesPath, languageCode, translations);
- });
- console.log('\nDone!');
- }
- /**
- * Function to escape special characters in strings for .po files
- */
- function escapePoString(str) {
- return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t');
- }
- /**
- * Function to escape string for use in regex (including already-escaped quotes)
- */
- function escapeRegex(str) {
- // First escape for .po format
- const poEscaped = escapePoString(str);
- // Then escape for regex (note: backslashes are already doubled from escapePoString)
- return poEscaped.replace(/[.*+?^${}()|[\]]/g, '\\$&');
- }
- /**
- * Function to find and update msgstr in .po file
- */
- function updatePoFile(localesDir, languageCode, translations) {
- const poFilePath = path.join(localesDir, `${languageCode}.po`);
- if (!fs.existsSync(poFilePath)) {
- console.warn(`Warning: .po file not found for language ${languageCode}: ${poFilePath}`);
- return;
- }
- let poContent = fs.readFileSync(poFilePath, 'utf-8');
- let updated = 0;
- let notFound = [];
- // Process each translation
- Object.entries(translations).forEach(([msgid, msgstr]) => {
- // Escape the msgid for regex matching
- const escapedMsgidForRegex = escapeRegex(msgid);
- // Pattern to match msgid with empty msgstr
- const pattern = new RegExp(`(msgid "${escapedMsgidForRegex}"\\s*\\n)(msgstr "")`, 'gm');
- const matches = poContent.match(pattern);
- if (matches) {
- poContent = poContent.replace(pattern, `$1msgstr "${escapePoString(msgstr)}"`);
- updated++;
- } else {
- notFound.push(msgid);
- }
- });
- // Write updated content back to file
- if (updated > 0) {
- fs.writeFileSync(poFilePath, poContent, 'utf-8');
- console.log(`✓ ${languageCode}: Updated ${updated} translations`);
- } else {
- console.log(`- ${languageCode}: No translations updated`);
- }
- if (notFound.length > 0) {
- console.log(` ⚠ ${notFound.length} msgids not found in .po file`);
- if (notFound.length <= 5) {
- notFound.forEach(msg => console.log(` - "${msg}"`));
- }
- }
- }
- /**
- * Main CLI interface
- */
- function main() {
- const args = process.argv.slice(2);
- const command = args[0];
- switch (command) {
- case 'extract':
- const outputFile = args[1] || 'missing-translations.txt';
- const localesDir = args[2] || DEFAULT_LOCALES_DIR;
- extractMissingTranslations(localesDir, outputFile);
- break;
- case 'apply':
- if (args.length < 2) {
- console.error('Usage: node i18n-tool.js apply <translations-file> [locales-dir]');
- console.error('Example: node i18n-tool.js apply translations.txt');
- process.exit(1);
- }
- const translationsFile = args[1];
- const targetLocalesDir = args[2] || DEFAULT_LOCALES_DIR;
- applyTranslations(translationsFile, targetLocalesDir);
- break;
- default:
- console.log('Vendure Dashboard i18n Tool');
- console.log('');
- console.log('Usage:');
- console.log(' node i18n-tool.js extract [output-file] [locales-dir]');
- console.log(' Extract missing translations and generate LLM prompt');
- console.log('');
- console.log(' node i18n-tool.js apply <translations-file> [locales-dir]');
- console.log(' Apply translated strings back to .po files');
- console.log('');
- console.log('Examples:');
- console.log(' node i18n-tool.js extract');
- console.log(' node i18n-tool.js extract prompt.txt');
- console.log(' node i18n-tool.js apply translations.txt');
- console.log('');
- console.log('Workflow:');
- console.log(' 1. Add new messages to dashboard components');
- console.log(' 2. Run: lingui extract');
- console.log(' 3. Run: node i18n-tool.js extract');
- console.log(' 4. Copy prompt to LLM (Claude, etc.) and get translations');
- console.log(' 5. Save LLM output to a file');
- console.log(' 6. Run: node i18n-tool.js apply <translations-file>');
- break;
- }
- }
- // Run the CLI if this file is executed directly
- if (import.meta.url === `file://${process.argv[1]}`) {
- main();
- }
- export {
- applyTranslations,
- escapePoString,
- escapeRegex,
- extractMissingTranslations,
- parsePOFile,
- updatePoFile,
- };
|