#!/usr/bin/env python3 """Convert openpencil/apps/web/src/i18n/locales/*.ts files into Rust match-arm tables under crates/op-i18n/src/i18n/.rs. Each TS file exports a single object literal of the form: 'key.name': 'value', 'key.with.escape': 'a\\nb', 'key.with.unicode': '…', We emit a `pub fn lookup(key: &str) -> Option<&'static str>` that returns the value for known keys and None otherwise. Multi-line values aren't used in the TS source so a per-line regex is sufficient. """ import re import sys from pathlib import Path TS_DIR = Path("apps/web/src/i18n/locales") RS_DIR = Path("crates/op-i18n/src/i18n") # Map TS file basename → Rust file basename (sanitised). LOCALES = { "en": "en", "zh": "zh_cn", "zh-tw": "zh_tw", "ja": "ja", "ko": "ko", "fr": "fr", "es": "es", "de": "de", "pt": "pt", "ru": "ru", "hi": "hi", "tr": "tr", "th": "th", "vi": "vi", "id": "id", } # Match `'key': 'value'` / `'key': "value"` on one logical line. # Spans newline because some entries split key + value across lines: # 'long.key.name': # 'value here', KEY_RE = re.compile( r"""^\s*'([^']+)':\s*(?: '((?:[^'\\]|\\.)*)' | "((?:[^"\\]|\\.)*)" ),?\s*$""", re.VERBOSE | re.MULTILINE, ) KEY_AND_VALUE_RE = re.compile( r"""'([^']+)':\s*(?: '((?:[^'\\]|\\.)*)' | "((?:[^"\\]|\\.)*)" )\s*,?""", re.VERBOSE, ) def parse_ts(path: Path) -> list[tuple[str, str]]: text = path.read_text(encoding="utf-8") rows: list[tuple[str, str]] = [] for m in KEY_AND_VALUE_RE.finditer(text): key = m.group(1) value = m.group(2) if m.group(2) is not None else m.group(3) # TS uses \u escapes + \n / \t — Python read_text already # decoded the file as UTF-8, but the JS-style \uXXXX # escapes are still literal. Decode them. value = ( value .replace("\\\\", "\x00") # placeholder .replace("\\'", "'") .replace("\\\"", "\"") .replace("\\n", "\n") .replace("\\t", "\t") .replace("\\r", "\r") ) # Decode \uXXXX def _u(m): return chr(int(m.group(1), 16)) value = re.sub(r"\\u([0-9A-Fa-f]{4})", _u, value) value = value.replace("\x00", "\\") rows.append((key, value)) return rows def rust_escape(s: str) -> str: out = [] for c in s: if c == "\\": out.append("\\\\") elif c == "\"": out.append("\\\"") elif c == "\n": out.append("\\n") elif c == "\t": out.append("\\t") elif c == "\r": out.append("\\r") elif ord(c) < 0x20: out.append(f"\\u{{{ord(c):04x}}}") else: out.append(c) return "".join(out) def emit_rust(rows: list[tuple[str, str]], rs_module: str, locale_label: str) -> str: lines = [ "//! Generated from `apps/web/src/i18n/locales/{}.ts` —".format(locale_label), "//! do NOT hand-edit. Re-run `tools/convert-locales.py`.", "", "pub fn lookup(key: &str) -> Option<&'static str> {", " Some(match key {", ] seen: set[str] = set() for key, value in rows: if key in seen: continue seen.add(key) lines.append(f" \"{rust_escape(key)}\" => \"{rust_escape(value)}\",") lines.append(" _ => return None,") lines.append(" })") lines.append("}") lines.append("") return "\n".join(lines) def main() -> int: RS_DIR.mkdir(parents=True, exist_ok=True) for ts_name, rs_name in LOCALES.items(): ts_path = TS_DIR / f"{ts_name}.ts" if not ts_path.exists(): print(f"missing: {ts_path}", file=sys.stderr) continue rows = parse_ts(ts_path) rs_path = RS_DIR / f"{rs_name}.rs" rs_path.write_text(emit_rust(rows, rs_name, ts_name), encoding="utf-8") print(f"{ts_name} → {rs_path} ({len(rows)} keys)") return 0 if __name__ == "__main__": sys.exit(main())