const fs = require('node:fs') const path = require('node:path') const vm = require('node:vm') const transpile = require('typescript').transpile const magicast = require('magicast') const { parseModule, generateCode, loadFile } = magicast const bingTranslate = require('bing-translate-api') const { translate } = bingTranslate const data = require('./languages.json') const targetLanguage = 'en-US' const i18nFolder = '../i18n' // Path to i18n folder relative to this script // https://github.com/plainheart/bing-translate-api/blob/master/src/met/lang.json const languageKeyMap = data.languages.reduce((map, language) => { if (language.supported) { if (language.value === 'zh-Hans' || language.value === 'zh-Hant') map[language.value] = language.value else map[language.value] = language.value.split('-')[0] } return map }, {}) const supportedLanguages = Object.keys(languageKeyMap) function parseArgs(argv) { const args = { files: [], languages: [], isDryRun: false, help: false, errors: [], } const collectValues = (startIndex) => { const values = [] let cursor = startIndex + 1 while (cursor < argv.length && !argv[cursor].startsWith('--')) { const value = argv[cursor].trim() if (value) values.push(value) cursor++ } return { values, nextIndex: cursor - 1 } } const validateList = (values, flag) => { if (!values.length) { args.errors.push(`${flag} requires at least one value. Example: ${flag} app billing`) return false } const invalid = values.find(value => value.includes(',')) if (invalid) { args.errors.push(`${flag} expects space-separated values. Example: ${flag} app billing`) return false } return true } for (let index = 2; index < argv.length; index++) { const arg = argv[index] if (arg === '--dry-run') { args.isDryRun = true continue } if (arg === '--help' || arg === '-h') { args.help = true break } if (arg.startsWith('--file=')) { args.errors.push('--file expects space-separated values. Example: --file app billing') continue } if (arg === '--file') { const { values, nextIndex } = collectValues(index) if (validateList(values, '--file')) args.files.push(...values) index = nextIndex continue } if (arg.startsWith('--lang=')) { args.errors.push('--lang expects space-separated values. Example: --lang zh-Hans ja-JP') continue } if (arg === '--lang') { const { values, nextIndex } = collectValues(index) if (validateList(values, '--lang')) args.languages.push(...values) index = nextIndex continue } } return args } function printHelp() { console.log(`Usage: pnpm run auto-gen-i18n [options] Options: --file Process only specific files; provide space-separated names and repeat --file if needed --lang Process only specific locales; provide space-separated locales and repeat --lang if needed (default: all supported except en-US) --dry-run Preview changes without writing files -h, --help Show help Examples: pnpm run auto-gen-i18n -- --file app common --lang zh-Hans ja-JP pnpm run auto-gen-i18n -- --dry-run `) } function protectPlaceholders(text) { const placeholders = [] let safeText = text const patterns = [ /\{\{[^{}]+\}\}/g, // mustache /\$\{[^{}]+\}/g, // template expressions /<[^>]+?>/g, // html-like tags ] patterns.forEach((pattern) => { safeText = safeText.replace(pattern, (match) => { const token = `__PH_${placeholders.length}__` placeholders.push({ token, value: match }) return token }) }) return { safeText, restore(translated) { return placeholders.reduce((result, { token, value }) => result.replace(new RegExp(token, 'g'), value), translated) }, } } async function translateText(source, toLanguage) { if (typeof source !== 'string') return { value: source, skipped: false } const trimmed = source.trim() if (!trimmed) return { value: source, skipped: false } const { safeText, restore } = protectPlaceholders(source) try { const { translation } = await translate(safeText, null, languageKeyMap[toLanguage]) return { value: restore(translation), skipped: false } } catch (error) { console.error(`āŒ Error translating to ${toLanguage}:`, error.message) return { value: source, skipped: true, error: error.message } } } async function translateMissingKeyDeeply(sourceObj, targetObject, toLanguage) { const skippedKeys = [] const translatedKeys = [] const entries = Object.keys(sourceObj) const processArray = async (sourceArray, targetArray, parentKey) => { for (let i = 0; i < sourceArray.length; i++) { const item = sourceArray[i] const pathKey = `${parentKey}[${i}]` const existingTarget = targetArray[i] if (typeof item === 'object' && item !== null) { const targetChild = (Array.isArray(existingTarget) || typeof existingTarget === 'object') ? existingTarget : (Array.isArray(item) ? [] : {}) const childResult = await translateMissingKeyDeeply(item, targetChild, toLanguage) targetArray[i] = targetChild skippedKeys.push(...childResult.skipped.map(k => `${pathKey}.${k}`)) translatedKeys.push(...childResult.translated.map(k => `${pathKey}.${k}`)) } else { if (existingTarget !== undefined) continue const translationResult = await translateText(item, toLanguage) targetArray[i] = translationResult.value ?? '' if (translationResult.skipped) skippedKeys.push(`${pathKey}: ${item}`) else translatedKeys.push(pathKey) } } } for (const key of entries) { const sourceValue = sourceObj[key] const targetValue = targetObject[key] if (targetValue === undefined) { if (Array.isArray(sourceValue)) { const translatedArray = [] await processArray(sourceValue, translatedArray, key) targetObject[key] = translatedArray } else if (typeof sourceValue === 'object' && sourceValue !== null) { targetObject[key] = {} const result = await translateMissingKeyDeeply(sourceValue, targetObject[key], toLanguage) skippedKeys.push(...result.skipped.map(k => `${key}.${k}`)) translatedKeys.push(...result.translated.map(k => `${key}.${k}`)) } else { const translationResult = await translateText(sourceValue, toLanguage) targetObject[key] = translationResult.value ?? '' if (translationResult.skipped) skippedKeys.push(`${key}: ${sourceValue}`) else translatedKeys.push(key) } } else if (Array.isArray(sourceValue)) { const targetArray = Array.isArray(targetValue) ? targetValue : [] await processArray(sourceValue, targetArray, key) targetObject[key] = targetArray } else if (typeof sourceValue === 'object' && sourceValue !== null) { const targetChild = targetValue && typeof targetValue === 'object' ? targetValue : {} targetObject[key] = targetChild const result = await translateMissingKeyDeeply(sourceValue, targetChild, toLanguage) skippedKeys.push(...result.skipped.map(k => `${key}.${k}`)) translatedKeys.push(...result.translated.map(k => `${key}.${k}`)) } else { // Overwrite when type is different or value is missing to keep structure in sync const shouldUpdate = typeof targetValue !== typeof sourceValue || targetValue === undefined || targetValue === null if (shouldUpdate) { const translationResult = await translateText(sourceValue, toLanguage) targetObject[key] = translationResult.value ?? '' if (translationResult.skipped) skippedKeys.push(`${key}: ${sourceValue}`) else translatedKeys.push(key) } } } return { skipped: skippedKeys, translated: translatedKeys } } async function autoGenTrans(fileName, toGenLanguage, isDryRun = false) { const fullKeyFilePath = path.resolve(__dirname, i18nFolder, targetLanguage, `${fileName}.ts`) const toGenLanguageFilePath = path.resolve(__dirname, i18nFolder, toGenLanguage, `${fileName}.ts`) try { const content = fs.readFileSync(fullKeyFilePath, 'utf8') // Create a safer module environment for vm const moduleExports = {} const context = { exports: moduleExports, module: { exports: moduleExports }, require, console, __filename: fullKeyFilePath, __dirname: path.dirname(fullKeyFilePath), } // Use vm.runInNewContext instead of eval for better security vm.runInNewContext(transpile(content), context) const fullKeyContent = moduleExports.default || moduleExports if (!fullKeyContent || typeof fullKeyContent !== 'object') throw new Error(`Failed to extract translation object from ${fullKeyFilePath}`) // if toGenLanguageFilePath is not exist, create it if (!fs.existsSync(toGenLanguageFilePath)) { fs.writeFileSync(toGenLanguageFilePath, `const translation = { } export default translation `) } // To keep object format and format it for magicast to work: const translation = { ... } => export default {...} const readContent = await loadFile(toGenLanguageFilePath) const { code: toGenContent } = generateCode(readContent) const mod = await parseModule(`export default ${toGenContent.replace('export default translation', '').replace('const translation = ', '')}`) const toGenOutPut = mod.exports.default console.log(`\nšŸŒ Processing ${fileName} for ${toGenLanguage}...`) const result = await translateMissingKeyDeeply(fullKeyContent, toGenOutPut, toGenLanguage) // Generate summary report console.log(`\nšŸ“Š Translation Summary for ${fileName} -> ${toGenLanguage}:`) console.log(` āœ… Translated: ${result.translated.length} keys`) console.log(` ā­ļø Skipped: ${result.skipped.length} keys`) if (result.skipped.length > 0) { console.log(`\nāš ļø Skipped keys in ${fileName} (${toGenLanguage}):`) result.skipped.slice(0, 5).forEach(item => console.log(` - ${item}`)) if (result.skipped.length > 5) console.log(` ... and ${result.skipped.length - 5} more`) } const { code } = generateCode(mod) let res = `const translation =${code.replace('export default', '')} export default translation `.replace(/,\n\n/g, ',\n').replace('};', '}') if (!isDryRun) { fs.writeFileSync(toGenLanguageFilePath, res) console.log(`šŸ’¾ Saved translations to ${toGenLanguageFilePath}`) } else { console.log(`šŸ” [DRY RUN] Would save translations to ${toGenLanguageFilePath}`) } return result } catch (error) { console.error(`Error processing file ${fullKeyFilePath}:`, error.message) throw error } } // Add command line argument support const args = parseArgs(process.argv) const isDryRun = args.isDryRun const targetFiles = args.files const targetLangs = args.languages // Rate limiting helper function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } async function main() { if (args.help) { printHelp() return } if (args.errors.length) { args.errors.forEach(message => console.error(`āŒ ${message}`)) printHelp() process.exit(1) return } console.log('šŸš€ Starting auto-gen-i18n script...') console.log(`šŸ“‹ Mode: ${isDryRun ? 'DRY RUN (no files will be modified)' : 'LIVE MODE'}`) const filesInEn = fs .readdirSync(path.resolve(__dirname, i18nFolder, targetLanguage)) .filter(file => /\.ts$/.test(file)) // Only process .ts files .map(file => file.replace(/\.ts$/, '')) // Filter by target files if specified const filesToProcess = targetFiles.length > 0 ? filesInEn.filter(f => targetFiles.includes(f)) : filesInEn const languagesToProcess = Array.from(new Set((targetLangs.length > 0 ? targetLangs : supportedLanguages) .filter(lang => lang !== targetLanguage))) const unknownLangs = languagesToProcess.filter(lang => !languageKeyMap[lang]) if (unknownLangs.length) { console.error(`āŒ Unsupported languages: ${unknownLangs.join(', ')}`) process.exit(1) } if (!filesToProcess.length) { console.log('ā„¹ļø No files to process based on provided arguments') return } if (!languagesToProcess.length) { console.log('ā„¹ļø No languages to process (did you only specify en-US?)') return } console.log(`šŸ“ Files to process: ${filesToProcess.join(', ')}`) console.log(`šŸŒ Languages to process: ${languagesToProcess.join(', ')}`) let totalTranslated = 0 let totalSkipped = 0 let totalErrors = 0 // Process files sequentially to avoid API rate limits for (const file of filesToProcess) { console.log(`\nšŸ“„ Processing file: ${file}`) // Process languages with rate limiting for (const language of languagesToProcess) { try { const result = await autoGenTrans(file, language, isDryRun) totalTranslated += result.translated.length totalSkipped += result.skipped.length // Rate limiting: wait 500ms between language processing await delay(500) } catch (e) { console.error(`āŒ Error translating ${file} to ${language}:`, e.message) totalErrors++ } } } // Final summary console.log('\nšŸŽ‰ Auto-translation completed!') console.log('šŸ“Š Final Summary:') console.log(` āœ… Total keys translated: ${totalTranslated}`) console.log(` ā­ļø Total keys skipped: ${totalSkipped}`) console.log(` āŒ Total errors: ${totalErrors}`) if (isDryRun) console.log('\nšŸ’” This was a dry run. To actually translate, run without --dry-run flag.') if (totalErrors > 0) process.exitCode = 1 } main().catch((error) => { console.error('āŒ Unexpected error:', error.message) process.exit(1) })