You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

auto-gen-i18n.js 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. const fs = require('node:fs')
  2. const path = require('node:path')
  3. const vm = require('node:vm')
  4. const transpile = require('typescript').transpile
  5. const magicast = require('magicast')
  6. const { parseModule, generateCode, loadFile } = magicast
  7. const bingTranslate = require('bing-translate-api')
  8. const { translate } = bingTranslate
  9. const data = require('./languages.json')
  10. const targetLanguage = 'en-US'
  11. const i18nFolder = '../i18n' // Path to i18n folder relative to this script
  12. // https://github.com/plainheart/bing-translate-api/blob/master/src/met/lang.json
  13. const languageKeyMap = data.languages.reduce((map, language) => {
  14. if (language.supported) {
  15. if (language.value === 'zh-Hans' || language.value === 'zh-Hant')
  16. map[language.value] = language.value
  17. else
  18. map[language.value] = language.value.split('-')[0]
  19. }
  20. return map
  21. }, {})
  22. async function translateMissingKeyDeeply(sourceObj, targetObject, toLanguage) {
  23. const skippedKeys = []
  24. const translatedKeys = []
  25. await Promise.all(Object.keys(sourceObj).map(async (key) => {
  26. if (targetObject[key] === undefined) {
  27. if (typeof sourceObj[key] === 'object') {
  28. targetObject[key] = {}
  29. const result = await translateMissingKeyDeeply(sourceObj[key], targetObject[key], toLanguage)
  30. skippedKeys.push(...result.skipped)
  31. translatedKeys.push(...result.translated)
  32. }
  33. else {
  34. try {
  35. const source = sourceObj[key]
  36. if (!source) {
  37. targetObject[key] = ''
  38. return
  39. }
  40. // Skip template literal placeholders
  41. if (source === 'TEMPLATE_LITERAL_PLACEHOLDER') {
  42. console.log(`⏭️ Skipping template literal key: "${key}"`)
  43. skippedKeys.push(`${key}: ${source}`)
  44. return
  45. }
  46. // Only skip obvious code patterns, not normal text with parentheses
  47. const codePatterns = [
  48. /\{\{.*\}\}/, // Template variables like {{key}}
  49. /\$\{.*\}/, // Template literals ${...}
  50. /<[^>]+>/, // HTML/XML tags
  51. /function\s*\(/, // Function definitions
  52. /=\s*\(/, // Assignment with function calls
  53. ]
  54. const isCodeLike = codePatterns.some(pattern => pattern.test(source))
  55. if (isCodeLike) {
  56. console.log(`⏭️ Skipping code-like content: "${source.substring(0, 50)}..."`)
  57. skippedKeys.push(`${key}: ${source}`)
  58. return
  59. }
  60. console.log(`🔄 Translating: "${source}" to ${toLanguage}`)
  61. const { translation } = await translate(sourceObj[key], null, languageKeyMap[toLanguage])
  62. targetObject[key] = translation
  63. translatedKeys.push(`${key}: ${translation}`)
  64. console.log(`✅ Translated: "${translation}"`)
  65. }
  66. catch (error) {
  67. console.error(`❌ Error translating "${sourceObj[key]}" to ${toLanguage}. Key: ${key}`, error.message)
  68. skippedKeys.push(`${key}: ${sourceObj[key]} (Error: ${error.message})`)
  69. // Add retry mechanism for network errors
  70. if (error.message.includes('network') || error.message.includes('timeout')) {
  71. console.log(`🔄 Retrying translation for key: ${key}`)
  72. try {
  73. await new Promise(resolve => setTimeout(resolve, 1000)) // Wait 1 second
  74. const { translation } = await translate(sourceObj[key], null, languageKeyMap[toLanguage])
  75. targetObject[key] = translation
  76. translatedKeys.push(`${key}: ${translation}`)
  77. console.log(`✅ Retry successful: "${translation}"`)
  78. }
  79. catch (retryError) {
  80. console.error(`❌ Retry failed for key ${key}:`, retryError.message)
  81. }
  82. }
  83. }
  84. }
  85. }
  86. else if (typeof sourceObj[key] === 'object') {
  87. targetObject[key] = targetObject[key] || {}
  88. const result = await translateMissingKeyDeeply(sourceObj[key], targetObject[key], toLanguage)
  89. skippedKeys.push(...result.skipped)
  90. translatedKeys.push(...result.translated)
  91. }
  92. }))
  93. return { skipped: skippedKeys, translated: translatedKeys }
  94. }
  95. async function autoGenTrans(fileName, toGenLanguage, isDryRun = false) {
  96. const fullKeyFilePath = path.resolve(__dirname, i18nFolder, targetLanguage, `${fileName}.ts`)
  97. const toGenLanguageFilePath = path.resolve(__dirname, i18nFolder, toGenLanguage, `${fileName}.ts`)
  98. try {
  99. const content = fs.readFileSync(fullKeyFilePath, 'utf8')
  100. // Temporarily replace template literals with regular strings for AST parsing
  101. // This allows us to process other keys while skipping problematic ones
  102. let processedContent = content
  103. const templateLiteralPattern = /(resolutionTooltip):\s*`([^`]*)`/g
  104. processedContent = processedContent.replace(templateLiteralPattern, (match, key, value) => {
  105. console.log(`⏭️ Temporarily replacing template literal for key: ${key}`)
  106. return `${key}: "TEMPLATE_LITERAL_PLACEHOLDER"`
  107. })
  108. // Create a safer module environment for vm
  109. const moduleExports = {}
  110. const context = {
  111. exports: moduleExports,
  112. module: { exports: moduleExports },
  113. require,
  114. console,
  115. __filename: fullKeyFilePath,
  116. __dirname: path.dirname(fullKeyFilePath),
  117. }
  118. // Use vm.runInNewContext instead of eval for better security
  119. vm.runInNewContext(transpile(processedContent), context)
  120. const fullKeyContent = moduleExports.default || moduleExports
  121. if (!fullKeyContent || typeof fullKeyContent !== 'object')
  122. throw new Error(`Failed to extract translation object from ${fullKeyFilePath}`)
  123. // if toGenLanguageFilePath is not exist, create it
  124. if (!fs.existsSync(toGenLanguageFilePath)) {
  125. fs.writeFileSync(toGenLanguageFilePath, `const translation = {
  126. }
  127. export default translation
  128. `)
  129. }
  130. // To keep object format and format it for magicast to work: const translation = { ... } => export default {...}
  131. const readContent = await loadFile(toGenLanguageFilePath)
  132. const { code: toGenContent } = generateCode(readContent)
  133. // Also handle template literals in target file content
  134. let processedToGenContent = toGenContent
  135. processedToGenContent = processedToGenContent.replace(templateLiteralPattern, (match, key, value) => {
  136. console.log(`⏭️ Temporarily replacing template literal in target file for key: ${key}`)
  137. return `${key}: "TEMPLATE_LITERAL_PLACEHOLDER"`
  138. })
  139. const mod = await parseModule(`export default ${processedToGenContent.replace('export default translation', '').replace('const translation = ', '')}`)
  140. const toGenOutPut = mod.exports.default
  141. console.log(`\n🌍 Processing ${fileName} for ${toGenLanguage}...`)
  142. const result = await translateMissingKeyDeeply(fullKeyContent, toGenOutPut, toGenLanguage)
  143. // Generate summary report
  144. console.log(`\n📊 Translation Summary for ${fileName} -> ${toGenLanguage}:`)
  145. console.log(` ✅ Translated: ${result.translated.length} keys`)
  146. console.log(` ⏭️ Skipped: ${result.skipped.length} keys`)
  147. if (result.skipped.length > 0) {
  148. console.log(`\n⚠️ Skipped keys in ${fileName} (${toGenLanguage}):`)
  149. result.skipped.slice(0, 5).forEach(item => console.log(` - ${item}`))
  150. if (result.skipped.length > 5)
  151. console.log(` ... and ${result.skipped.length - 5} more`)
  152. }
  153. const { code } = generateCode(mod)
  154. let res = `const translation =${code.replace('export default', '')}
  155. export default translation
  156. `.replace(/,\n\n/g, ',\n').replace('};', '}')
  157. // Restore original template literals by reading from the original target file if it exists
  158. if (fs.existsSync(toGenLanguageFilePath)) {
  159. const originalContent = fs.readFileSync(toGenLanguageFilePath, 'utf8')
  160. // Extract original template literal content for resolutionTooltip
  161. const originalMatch = originalContent.match(/(resolutionTooltip):\s*`([^`]*)`/s)
  162. if (originalMatch) {
  163. const [fullMatch, key, value] = originalMatch
  164. res = res.replace(
  165. `${key}: "TEMPLATE_LITERAL_PLACEHOLDER"`,
  166. `${key}: \`${value}\``,
  167. )
  168. console.log(`🔄 Restored original template literal for key: ${key}`)
  169. }
  170. }
  171. if (!isDryRun) {
  172. fs.writeFileSync(toGenLanguageFilePath, res)
  173. console.log(`💾 Saved translations to ${toGenLanguageFilePath}`)
  174. }
  175. else {
  176. console.log(`🔍 [DRY RUN] Would save translations to ${toGenLanguageFilePath}`)
  177. }
  178. return result
  179. }
  180. catch (error) {
  181. console.error(`Error processing file ${fullKeyFilePath}:`, error.message)
  182. throw error
  183. }
  184. }
  185. // Add command line argument support
  186. const isDryRun = process.argv.includes('--dry-run')
  187. const targetFiles = process.argv
  188. .filter(arg => arg.startsWith('--file='))
  189. .map(arg => arg.split('=')[1])
  190. const targetLang = process.argv.find(arg => arg.startsWith('--lang='))?.split('=')[1]
  191. // Rate limiting helper
  192. function delay(ms) {
  193. return new Promise(resolve => setTimeout(resolve, ms))
  194. }
  195. async function main() {
  196. console.log('🚀 Starting auto-gen-i18n script...')
  197. console.log(`📋 Mode: ${isDryRun ? 'DRY RUN (no files will be modified)' : 'LIVE MODE'}`)
  198. const files = fs
  199. .readdirSync(path.resolve(__dirname, i18nFolder, targetLanguage))
  200. .filter(file => /\.ts$/.test(file)) // Only process .ts files
  201. .map(file => file.replace(/\.ts$/, ''))
  202. // Removed app-debug exclusion, now only skip specific problematic keys
  203. // Filter by target files if specified
  204. const filesToProcess = targetFiles.length > 0 ? files.filter(f => targetFiles.includes(f)) : files
  205. const languagesToProcess = targetLang ? [targetLang] : Object.keys(languageKeyMap)
  206. console.log(`📁 Files to process: ${filesToProcess.join(', ')}`)
  207. console.log(`🌍 Languages to process: ${languagesToProcess.join(', ')}`)
  208. let totalTranslated = 0
  209. let totalSkipped = 0
  210. let totalErrors = 0
  211. // Process files sequentially to avoid API rate limits
  212. for (const file of filesToProcess) {
  213. console.log(`\n📄 Processing file: ${file}`)
  214. // Process languages with rate limiting
  215. for (const language of languagesToProcess) {
  216. try {
  217. const result = await autoGenTrans(file, language, isDryRun)
  218. totalTranslated += result.translated.length
  219. totalSkipped += result.skipped.length
  220. // Rate limiting: wait 500ms between language processing
  221. await delay(500)
  222. }
  223. catch (e) {
  224. console.error(`❌ Error translating ${file} to ${language}:`, e.message)
  225. totalErrors++
  226. }
  227. }
  228. }
  229. // Final summary
  230. console.log('\n🎉 Auto-translation completed!')
  231. console.log('📊 Final Summary:')
  232. console.log(` ✅ Total keys translated: ${totalTranslated}`)
  233. console.log(` ⏭️ Total keys skipped: ${totalSkipped}`)
  234. console.log(` ❌ Total errors: ${totalErrors}`)
  235. if (isDryRun)
  236. console.log('\n💡 This was a dry run. To actually translate, run without --dry-run flag.')
  237. }
  238. main()