213 lines
7.2 KiB
Python
213 lines
7.2 KiB
Python
|
|
"""
|
||
|
|
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")
|