extract-translations.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. #!/usr/bin/env tsx
  2. /**
  3. * Translation String Extraction Script
  4. *
  5. * This script scans TSX files for hardcoded Chinese strings and extracts them
  6. * into translation JSON files with semantic keys.
  7. *
  8. * Usage:
  9. * tsx scripts/extract-translations.ts [options]
  10. *
  11. * Options:
  12. * --dry-run Preview extraction without modifying files
  13. * --verbose Show detailed extraction process
  14. * --target Target directory to scan (default: src/app/[locale])
  15. */
  16. import * as fs from "node:fs";
  17. import * as path from "node:path";
  18. // Translation key naming convention: namespace.section.key
  19. // Example: dashboard.stats.totalRequests, settings.providers.addButton
  20. interface ExtractedString {
  21. original: string;
  22. key: string;
  23. file: string;
  24. line: number;
  25. context: string;
  26. namespace: string;
  27. needsReview: boolean;
  28. }
  29. interface ExtractionReport {
  30. totalFiles: number;
  31. totalStrings: number;
  32. extractedStrings: ExtractedString[];
  33. namespaceStats: Record<string, number>;
  34. }
  35. // Chinese character detection regex
  36. const _CHINESE_REGEX = /[\u4e00-\u9fa5]+/g;
  37. // Namespace mapping based on file path
  38. const NAMESPACE_MAP: Record<string, string> = {
  39. "/login/": "auth",
  40. "/dashboard/": "dashboard",
  41. "/settings/": "settings",
  42. "/usage-doc/": "usage",
  43. "page.tsx": "common",
  44. };
  45. // Common Chinese phrases mapping
  46. const PHRASE_MAP: Record<string, string> = {
  47. 仪表盘: "dashboard",
  48. 设置: "settings",
  49. 用户: "users",
  50. 供应商: "providers",
  51. 模型: "models",
  52. 请求: "requests",
  53. 成本: "cost",
  54. 统计: "stats",
  55. 日志: "logs",
  56. 配额: "quotas",
  57. 会话: "sessions",
  58. 密钥: "keys",
  59. 价格: "prices",
  60. 配置: "config",
  61. 数据: "data",
  62. 通知: "notifications",
  63. 版本: "versions",
  64. 敏感词: "sensitiveWords",
  65. 登录: "login",
  66. 退出: "logout",
  67. 保存: "save",
  68. 取消: "cancel",
  69. 删除: "delete",
  70. 编辑: "edit",
  71. 添加: "add",
  72. 刷新: "refresh",
  73. 搜索: "search",
  74. 导出: "export",
  75. 导入: "import",
  76. 确认: "confirm",
  77. 提交: "submit",
  78. 重置: "reset",
  79. 查看: "view",
  80. 复制: "copy",
  81. 下载: "download",
  82. 上传: "upload",
  83. 启用: "enabled",
  84. 禁用: "disabled",
  85. 成功: "success",
  86. 失败: "failed",
  87. 错误: "error",
  88. 警告: "warning",
  89. 信息: "info",
  90. 加载中: "loading",
  91. 标题: "title",
  92. 描述: "description",
  93. 名称: "name",
  94. 状态: "status",
  95. 时间: "time",
  96. 操作: "actions",
  97. 详情: "details",
  98. 列表: "list",
  99. 表单: "form",
  100. 按钮: "button",
  101. 输入: "input",
  102. 选择: "select",
  103. 选项: "options",
  104. 全部: "all",
  105. 无: "none",
  106. 是: "yes",
  107. 否: "no",
  108. 开: "on",
  109. 关: "off",
  110. };
  111. /**
  112. * Determine namespace from file path
  113. */
  114. function getNamespace(filePath: string): string {
  115. for (const [pattern, namespace] of Object.entries(NAMESPACE_MAP)) {
  116. if (filePath.includes(pattern)) {
  117. return namespace;
  118. }
  119. }
  120. return "common";
  121. }
  122. /**
  123. * Generate semantic key from Chinese string
  124. */
  125. function generateKey(
  126. chineseText: string,
  127. namespace: string,
  128. context: string
  129. ): { key: string; needsReview: boolean } {
  130. // Check if it's a common phrase
  131. const mapped = PHRASE_MAP[chineseText];
  132. if (mapped) {
  133. return { key: `${namespace}.${mapped}`, needsReview: false };
  134. }
  135. // Try to infer section from context
  136. let section = "misc";
  137. const contextLower = context.toLowerCase();
  138. if (contextLower.includes("title")) section = "title";
  139. else if (contextLower.includes("description")) section = "description";
  140. else if (contextLower.includes("button") || contextLower.includes("btn")) section = "actions";
  141. else if (contextLower.includes("label")) section = "labels";
  142. else if (contextLower.includes("placeholder")) section = "placeholders";
  143. else if (contextLower.includes("error")) section = "errors";
  144. else if (contextLower.includes("toast")) section = "toasts";
  145. else if (contextLower.includes("dialog")) section = "dialogs";
  146. // Generate a safe key from Chinese text
  147. const safeKey = chineseText
  148. .substring(0, 30)
  149. .replace(/[^\u4e00-\u9fa5]/g, "")
  150. .substring(0, 10);
  151. // Use Pinyin-like mapping for key (simplified)
  152. const keyPart = Array.from(safeKey)
  153. .map((_char, i) => `key${i}`)
  154. .join("");
  155. return {
  156. key: `${namespace}.${section}.${keyPart || "text"}`,
  157. needsReview: true, // Manual review needed for auto-generated keys
  158. };
  159. }
  160. /**
  161. * Extract Chinese strings from a file
  162. */
  163. function extractFromFile(filePath: string): ExtractedString[] {
  164. const content = fs.readFileSync(filePath, "utf-8");
  165. const lines = content.split("\n");
  166. const extracted: ExtractedString[] = [];
  167. const namespace = getNamespace(filePath);
  168. // Simple regex-based extraction
  169. // This is a simplified version - a full AST parser would be more robust
  170. lines.forEach((line, lineIndex) => {
  171. // Skip imports, comments
  172. if (
  173. line.trim().startsWith("import ") ||
  174. line.trim().startsWith("//") ||
  175. line.trim().startsWith("/*")
  176. ) {
  177. return;
  178. }
  179. // Find all Chinese strings
  180. const matches = line.matchAll(/["'`]([^"'`]*[\u4e00-\u9fa5]+[^"'`]*)["'`]/g);
  181. for (const match of matches) {
  182. const original = match[1];
  183. if (!original || original.length < 2) continue;
  184. // Get surrounding context (30 chars before and after)
  185. const matchIndex = line.indexOf(match[0]);
  186. const contextStart = Math.max(0, matchIndex - 30);
  187. const contextEnd = Math.min(line.length, matchIndex + match[0].length + 30);
  188. const context = line.substring(contextStart, contextEnd).trim();
  189. const { key, needsReview } = generateKey(original, namespace, context);
  190. extracted.push({
  191. original,
  192. key,
  193. file: filePath,
  194. line: lineIndex + 1,
  195. context,
  196. namespace,
  197. needsReview,
  198. });
  199. }
  200. });
  201. return extracted;
  202. }
  203. /**
  204. * Group extracted strings by namespace
  205. */
  206. function groupByNamespace(strings: ExtractedString[]): Record<string, ExtractedString[]> {
  207. const grouped: Record<string, ExtractedString[]> = {};
  208. for (const str of strings) {
  209. if (!grouped[str.namespace]) {
  210. grouped[str.namespace] = [];
  211. }
  212. grouped[str.namespace].push(str);
  213. }
  214. return grouped;
  215. }
  216. /**
  217. * Generate translation JSON files
  218. */
  219. function generateTranslationFiles(strings: ExtractedString[], dryRun: boolean): void {
  220. const grouped = groupByNamespace(strings);
  221. for (const [namespace, items] of Object.entries(grouped)) {
  222. const translationFile = path.join(process.cwd(), `messages/zh-CN/${namespace}.json`);
  223. // Read existing translations
  224. let translations: any = {};
  225. if (fs.existsSync(translationFile)) {
  226. translations = JSON.parse(fs.readFileSync(translationFile, "utf-8"));
  227. }
  228. // Add new translations (preserving existing structure)
  229. for (const item of items) {
  230. const keyParts = item.key.replace(`${namespace}.`, "").split(".");
  231. let current = translations;
  232. for (let i = 0; i < keyParts.length - 1; i++) {
  233. const part = keyParts[i];
  234. if (!current[part] || typeof current[part] !== "object") {
  235. current[part] = {};
  236. }
  237. current = current[part];
  238. }
  239. const finalKey = keyParts[keyParts.length - 1];
  240. // Only add if key doesn't exist or is not an object (avoid overwriting nested objects)
  241. if (!current[finalKey]) {
  242. current[finalKey] = item.original;
  243. }
  244. }
  245. if (!dryRun) {
  246. fs.writeFileSync(translationFile, `${JSON.stringify(translations, null, 2)}\n`, "utf-8");
  247. console.log(`✓ Updated ${namespace}.json with ${items.length} strings`);
  248. } else {
  249. console.log(`[DRY RUN] Would update ${namespace}.json with ${items.length} strings`);
  250. }
  251. }
  252. }
  253. /**
  254. * Generate extraction report
  255. */
  256. function generateReport(strings: ExtractedString[]): ExtractionReport {
  257. const namespaceStats: Record<string, number> = {};
  258. const files = new Set<string>();
  259. for (const str of strings) {
  260. files.add(str.file);
  261. namespaceStats[str.namespace] = (namespaceStats[str.namespace] || 0) + 1;
  262. }
  263. return {
  264. totalFiles: files.size,
  265. totalStrings: strings.length,
  266. extractedStrings: strings,
  267. namespaceStats,
  268. };
  269. }
  270. /**
  271. * Find all page.tsx files recursively
  272. */
  273. function findPageFiles(dir: string): string[] {
  274. const files: string[] = [];
  275. const entries = fs.readdirSync(dir, { withFileTypes: true });
  276. for (const entry of entries) {
  277. const fullPath = path.join(dir, entry.name);
  278. if (entry.isDirectory()) {
  279. if (!entry.name.startsWith(".") && entry.name !== "node_modules") {
  280. files.push(...findPageFiles(fullPath));
  281. }
  282. } else if (entry.name === "page.tsx") {
  283. files.push(fullPath);
  284. }
  285. }
  286. return files;
  287. }
  288. /**
  289. * Main extraction process
  290. */
  291. async function main() {
  292. const args = process.argv.slice(2);
  293. const dryRun = args.includes("--dry-run");
  294. const verbose = args.includes("--verbose");
  295. const targetIdx = args.indexOf("--target");
  296. const targetDir = targetIdx >= 0 ? args[targetIdx + 1] : "src/app/[locale]";
  297. console.log("🔍 Translation String Extraction");
  298. console.log("================================\n");
  299. // Find all page.tsx files
  300. const files = findPageFiles(path.join(process.cwd(), targetDir));
  301. console.log(`Found ${files.length} files to scan\n`);
  302. // Extract strings from all files
  303. const allStrings: ExtractedString[] = [];
  304. for (const file of files) {
  305. const extracted = extractFromFile(file);
  306. allStrings.push(...extracted);
  307. if (verbose && extracted.length > 0) {
  308. console.log(`📄 ${path.relative(process.cwd(), file)}: ${extracted.length} strings`);
  309. }
  310. }
  311. // Generate report
  312. const report = generateReport(allStrings);
  313. console.log("\n📊 Extraction Report");
  314. console.log("-------------------");
  315. console.log(`Total files scanned: ${report.totalFiles}`);
  316. console.log(`Total strings found: ${report.totalStrings}`);
  317. console.log(`\nNamespace breakdown:`);
  318. for (const [ns, count] of Object.entries(report.namespaceStats)) {
  319. console.log(` ${ns}: ${count} strings`);
  320. }
  321. // Show strings needing review
  322. const needsReview = allStrings.filter((s) => s.needsReview);
  323. if (needsReview.length > 0) {
  324. console.log(`\n⚠️ ${needsReview.length} strings need manual review`);
  325. if (verbose) {
  326. console.log("\nStrings needing review:");
  327. needsReview.slice(0, 10).forEach((s) => {
  328. console.log(` ${s.key}: "${s.original}" (${path.basename(s.file)}:${s.line})`);
  329. });
  330. if (needsReview.length > 10) {
  331. console.log(` ... and ${needsReview.length - 10} more`);
  332. }
  333. }
  334. }
  335. // Generate translation files
  336. console.log("\n📝 Generating translation files...");
  337. generateTranslationFiles(allStrings, dryRun);
  338. console.log("\nExtraction complete!");
  339. if (dryRun) {
  340. console.log(" (Run without --dry-run to save changes)");
  341. }
  342. }
  343. main().catch(console.error);