""" Batch i18n transformation script. Reads the zh translation file, creates a reverse mapping (Chinese text -> key), then replaces Chinese text in all .tsx/.ts/.jsx files with t() calls. """ import re import os import json # Parse the zh translation file to create reverse mapping def parse_translation_file(filepath): """Extract all key-value pairs from the TypeScript translation object.""" with open(filepath, 'r', encoding='utf-8') as f: content = f.read() # Remove the wrapper and export content = re.sub(r'^const \w+ = ', '', content) content = re.sub(r';\s*export default \w+;\s*$', '', content) # Parse the nested object structure mapping = {} # Chinese text -> "namespace.key" def extract_keys(obj_str, prefix=''): """Recursively extract keys from nested object string.""" # Match key: value pairs # Keys can be: identifier, 'string', or "string" # Values can be: 'string', "string", or nested { } i = 0 while i < len(obj_str): # Find key key_match = re.match(r'\s*(\w+)\s*:\s*', obj_str[i:]) if not key_match: # Try quoted key key_match = re.match(r"\s*'([^']+)'\s*:\s*", obj_str[i:]) if not key_match: i += 1 continue key = key_match.group(1) i += key_match.end() if i >= len(obj_str): break # Check value type if obj_str[i] == '{': # Nested object - find matching brace depth = 1 j = i + 1 while j < len(obj_str) and depth > 0: if obj_str[j] == '{': depth += 1 elif obj_str[j] == '}': depth -= 1 j += 1 # Recurse into nested object nested_content = obj_str[i+1:j-1] full_prefix = f"{prefix}{key}." if prefix else f"{key}." extract_keys(nested_content, full_prefix) i = j elif obj_str[i] == "'" or obj_str[i] == '"': # String value quote = obj_str[i] j = i + 1 while j < len(obj_str): if obj_str[j] == '\\': j += 2 continue if obj_str[j] == quote: break j += 1 value = obj_str[i+1:j] full_key = f"{prefix}{key}" if prefix else key if value and len(value) > 1: mapping[value] = full_key i = j + 1 else: # Skip non-string values i += 1 extract_keys(content) return mapping # Build the mapping zh_mapping = parse_translation_file('src/locales/zh/index.ts') # Sort by length (longest first) to avoid partial replacements sorted_mappings = sorted(zh_mapping.items(), key=lambda x: len(x[0]), reverse=True) print(f"Loaded {len(sorted_mappings)} translation keys") # Files to process target_dirs = [ 'src/pages', 'src/components', ] # Files to skip skip_files = [ 'src/components/LanguageSwitcher.tsx', 'src/components/Layout.tsx', ] def get_target_files(): files = [] for target_dir in target_dirs: for root, dirs, filenames in os.walk(target_dir): for fname in filenames: if fname.endswith(('.tsx', '.ts', '.jsx', '.js')): filepath = os.path.join(root, fname).replace('\\', '/') if filepath not in skip_files: files.append(filepath) return files target_files = get_target_files() print(f"Found {len(target_files)} files to process") # Statistics total_replacements = 0 files_modified = 0 for filepath in target_files: try: with open(filepath, 'r', encoding='utf-8') as f: content = f.read() except: continue original = content file_replacements = 0 # Check if file already has useTranslation has_i18n = 'useTranslation' in content # Do replacements for chinese_text, key in sorted_mappings: if chinese_text not in content: continue # Pattern 1: In single-quoted strings: '中文' old1 = f"'{chinese_text}'" new1 = f"t('{key}')" if old1 in content: count = content.count(old1) content = content.replace(old1, new1) file_replacements += count # Pattern 2: In double-quoted strings: "中文" old2 = f'"{chinese_text}"' new2 = f"t('{key}')" if old2 in content: count = content.count(old2) content = content.replace(old2, new2) file_replacements += count # Pattern 3: In JSX text: >中文< old3 = f'>{chinese_text}<' new3 = f"{{t('{key}')}}<" if old3 in content: count = content.count(old3) content = content.replace(old3, new3) file_replacements += count # Pattern 4: In template literals: `中文` old4 = f'`{chinese_text}`' new4 = f"t('{key}')" if old4 in content: count = content.count(old4) content = content.replace(old4, new4) file_replacements += count if file_replacements == 0: continue # Add useTranslation import if needed if not has_i18n and file_replacements > 0: # Find the last import line import_pattern = r"((?:import .+?;\n)+)" matches = list(re.finditer(import_pattern, content)) if matches: last_import = matches[-1] insert_pos = last_import.end() content = content[:insert_pos] + "import { useTranslation } from 'react-i18next';\n" + content[insert_pos:] # Add const { t } = useTranslation(); after component declaration # Look for function component patterns # Pattern 1: export default function ComponentName() { func_pattern = r'(export default function \w+\([^)]*\)\s*\{)' match = re.search(func_pattern, content) if match: insert_after = match.end() content = content[:insert_after] + '\n\tconst { t } = useTranslation();' + content[insert_after:] else: # Pattern 2: const ComponentName = () => { or function ComponentName() { func_pattern2 = r'((?:export default )?(?:const|function) \w+\s*(?:=\s*)?(?:\([^)]*\))?\s*(?:=>)?\s*\{)' match = re.search(func_pattern2, content) if match: insert_after = match.end() content = content[:insert_after] + '\n\tconst { t } = useTranslation();' + content[insert_after:] if content != original: with open(filepath, 'w', encoding='utf-8') as f: f.write(content) files_modified += 1 total_replacements += file_replacements print(f" {filepath}: {file_replacements} replacements") print(f"\nDone! Modified {files_modified} files, {total_replacements} total replacements")