diff --git a/tools/i18n/ARCHITECTURE.md b/tools/i18n/ARCHITECTURE.md new file mode 100644 index 0000000000..ee4356cae7 --- /dev/null +++ b/tools/i18n/ARCHITECTURE.md @@ -0,0 +1,152 @@ +# WLED i18n Architecture + +## Overview + +Two-repository architecture separating **build toolchain** (core repo) from **translation files** (community repo). + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Core Repo (WLED/tools/i18n/) │ +│ ├── extract.py # Extract translatable strings from HTML │ +│ ├── build.py # Apply translations at build time │ +│ └── locales/ # Locale configuration │ +└─────────────────────────────────────────────────────────────┘ + ↓ calls +┌─────────────────────────────────────────────────────────────┐ +│ Translation Repo (WLED-translations//) │ +│ ├── static.json # Layer 1: Static HTML (429 entries) │ +│ ├── js.json # Layer 2: JS strings (45 entries) │ +│ ├── effects.json # Layer 3: Effect names (216 entries) │ +│ ├── palettes.json # Layer 4: Palette names (72 entries) │ +│ └── metadata.json # Version, coverage, maintainer │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Four-Layer Translation Architecture + +| Layer | Content | File | Implementation | Coverage | +|-------|---------|------|----------------|----------| +| **L1** | Static HTML | `static.json` | Regex replacement in HTML text | 429 strings | +| **L2** | JS strings | `js.json` | Replace JS string literals | 45 strings | +| **L3** | Effect names | `effects.json` | C++ PROGMEM `#undef` + redefine | 216/216 (100%) | +| **L4** | Palette names | `palettes.json` | C++ PROGMEM array replacement | 72/72 (100%) | + +--- + +## Build Flow + +### PlatformIO Configuration + +```ini +# platformio.ini +[env:esp32dev_zh_CN] +extends = env:esp32dev +build_flags = ${env:esp32dev.build_flags} -D WLED_LOCALE=zh_CN +extra_scripts = + ${env:esp32dev.extra_scripts} + pre:tools/i18n/build.py +``` + +### Build Steps + +1. `build.py` reads `wled00/data/*.htm` (English source) +2. Applies L1/L2 translations via regex replacement +3. Generates `i18n_effects.h` / `i18n_palettes.h` for L3/L4 (PROGMEM replacement) +4. Output to `build/i18n//` + +--- + +## How Dynamic Content Works + +The key insight: **PROGMEM replacement happens at compile time**, so JSON endpoints return translated strings automatically. + +``` +Browser ESP32 Firmware + │ │ + ├─ GET /json/palettes ─────────→│ PROGMEM array was replaced at compile time + │ ← {"0":"默认","1":"* 随机循环",...} ↓ + │ │ palettes.json → i18n_palettes.h + ├─ GET /json/effects ─────────→│ #undef _data_FX_MODE_STATIC + │ ← {"0":"常亮","1":"闪烁",...}│ #define _data_FX_MODE_STATIC "常亮" +``` + +No firmware code changes needed. The C++ PROGMEM strings are the single source of truth. + +--- + +## Grammar and Word Order + +WLED UI uses **short labels**, not full sentences: + +| Pattern | Example | i18n Impact | +|---------|---------|-------------| +| Single word | "Brightness", "Speed" | ✅ No issue | +| Label + value | "255 segments" | ✅ Works ("255 个段") | +| Full sentences | Almost none | ✅ N/A | +| Plural forms | Not used | ✅ N/A | +| Date formats | Not used in UI | ✅ N/A | + +The architecture **intentionally avoids** complex i18n patterns (ICU MessageFormat, plural rules) because WLED's UI doesn't need them. + +--- + +## What's NOT Translated (By Design) + +| Content | Reason | +|---------|--------| +| User-defined preset names | Belongs to user | +| Usermod settings pages | Dynamic HTML from firmware, varies by hardware | +| System info (IP, memory) | Universal data | +| Effect slider tooltips | Generated from mode data arrays | + +--- + +## Repository Structure + +### Core repo (WLED) + +``` +tools/i18n/ +├── extract.py # String extraction tool +├── build.py # Build-time translation applicator +├── ARCHITECTURE.md # This file +├── README.md # Usage documentation +└── locales/ # Locale configs +``` + +### Translation repo (WLED-translations) + +``` +/ +├── static.json # Layer 1: Static HTML text +├── js.json # Layer 2: JavaScript strings +├── effects.json # Layer 3: Effect names (PROGMEM) +├── palettes.json # Layer 4: Palette names (PROGMEM) +└── metadata.json # {"version":"1.0","coverage":"100%","maintainer":"..."} +en_template/ # English template for translators +``` + +--- + +## Adding a New Language + +1. Fork `WLED-translations` +2. Copy `en_template/` to `/` +3. Translate JSON files +4. Submit PR to translation repo + +No changes to WLED core needed. + +--- + +## Coverage Summary (zh_CN) + +| Layer | Content | Count | Status | +|-------|---------|-------|--------| +| L1 | Static HTML | 429 | ✅ Complete | +| L2 | JS strings | 45 | ✅ Complete | +| L3 | Effect names | 216/216 | ✅ 100% | +| L4 | Palette names | 72/72 | ✅ 100% | +| **Total** | | **762** | **100%** | diff --git a/tools/i18n/README.md b/tools/i18n/README.md new file mode 100644 index 0000000000..5a84ed75aa --- /dev/null +++ b/tools/i18n/README.md @@ -0,0 +1,119 @@ +# WLED i18n Toolchain + +Build-time internationalization for WLED Web UI. Translates HTML/JS strings at compile time with **zero runtime overhead**. + +## Architecture + +``` +WLED (core repo) WLED-translations (community repo) +tools/i18n/ / +├── extract.py ──────────────────→ static.json (Layer 1: HTML) +├── build.py ←─ applies ─────── js.json (Layer 2: JS) +├── ARCHITECTURE.md effects.json (Layer 3: PROGMEM) +└── README.md palettes.json(Layer 4: PROGMEM) +``` + +**Key principle:** Translations are maintained **out-of-tree** by community contributors, similar to usermods. Users build translated firmware locally. + +## Quick Start + +### 1. Clone both repos + +```bash +git clone https://github.com/wled/WLED.git +git clone https://github.com/wled/WLED-translations.git +``` + +### 2. Extract strings (optional, for translators) + +```bash +cd WLED +python3 tools/i18n/extract.py --stats +# Output: tools/i18n/locales/_template.json +``` + +### 3. Validate translations + +```bash +# Check coverage for a locale +python3 tools/i18n/extract.py --validate zh_CN + +# Output: +# Validating locale: zh_CN +# ======================================== +# Coverage: 429/429 (100.0%) +# PASSED: All strings translated +``` + +### 4. Build translated firmware + +```bash +cd WLED + +# Apply translations (Layer 1 + 2: HTML/JS) +python3 tools/i18n/build.py --locale zh_CN \ + --source-dir wled00/data \ + --translation-dir ../WLED-translations/zh_CN \ + --output-dir build/i18n/zh_CN + +# Build web UI headers +npm ci && npm run build + +# Build firmware +pio run -e esp32dev +``` + +### 5. PlatformIO integration (automatic) + +Add to `platformio.ini`: + +```ini +[env:esp32dev_zh_CN] +extends = env:esp32dev +build_flags = ${env:esp32dev.build_flags} -D WLED_LOCALE=zh_CN +extra_scripts = pre:tools/i18n/build.py +``` + +Then just: `pio run -e esp32dev_zh_CN` + +## Four-Layer Translation System + +| Layer | Content | Source | Method | +|-------|---------|--------|--------| +| **L1** | Static HTML | `static.json` | Regex replacement | +| **L2** | JS strings | `js.json` | Script block regex | +| **L3** | Effect names | `effects.json` | PROGMEM `#undef` + redefine | +| **L4** | Palette names | `palettes.json` | PROGMEM array replacement | + +## For Translators + +1. Fork [WLED-translations](https://github.com/wled/WLED-translations) +2. Copy `en_template/` to `/` +3. Edit JSON files — fill in `"translation"` fields +4. Validate: `python3 tools/i18n/extract.py --validate ` +5. Submit PR to WLED-translations repo + +## For Maintainers + +### Adding this toolchain to WLED core + +This PR adds only `tools/i18n/` — no changes to existing build pipeline. The tool is a pre-build step that runs before `npm run build`. + +### CI/CD integration + +```yaml +# .github/workflows/i18n-validate.yml +- name: Validate translations + run: | + git clone https://github.com/wled/WLED-translations.git + for locale in WLED-translations/*/; do + python3 tools/i18n/extract.py --validate $(basename $locale) + done +``` + +## Limitations + +1. **No runtime language switching** — language is fixed at build time +2. **External tools** (pixelforge, pixelmagic) — always English, downloaded on-the-fly +3. **C++ server-side strings** — ~12 strings in `xml.cpp` need separate handling +4. **User presets** — user-defined names are not translated (by design) diff --git a/tools/i18n/build.py b/tools/i18n/build.py new file mode 100644 index 0000000000..bf181d2304 --- /dev/null +++ b/tools/i18n/build.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +WLED i18n Build Script (v3 - fixes from coderabbitai review) +Generates translated HTML files from English source + locale JSON. + +Uses raw string replacement instead of BeautifulSoup serialization +to preserve original HTML formatting exactly (critical for ESP32 flash size). + +Fixes applied: +1. File-scoped HTML replacement (no cross-file bleed) +2. Script-block-aware HTML replacement (skip )', re.DOTALL | re.IGNORECASE) + last_end = 0 + + for match in pattern.finditer(content): + # Non-script content before this script block + before = content[last_end:match.start()] + if before: + segments.append((before, False)) + # The script block itself (including tags) + segments.append((match.group(0), True)) + last_end = match.end() + + # Remaining non-script content after last script block + after = content[last_end:] + if after: + segments.append((after, False)) + + return segments + + +def replace_html_text(content, original, translated): + """Replace HTML text content using exact string matching. + Handles: + - >original< (direct child text) + - > original < (with whitespace) + - >... original< (text after sibling element) + + IMPORTANT: content must be non-script segments only. + """ + escaped = re.escape(original) + total = 0 + + # Pattern 1: Text between > and or > and < (with optional whitespace) + p1 = re.compile(r'(>)\s*(' + escaped + r')\s*(' + translated + r'\g<3>', content) + total += n + + # Pattern 2: Text after a closing tag (e.g. Color palette

) + p2 = re.compile(r'()\s*(' + escaped + r')\s*(' + translated + r'\g<3>', content) + total += n + + # Pattern 3: Standalone text line (with leading whitespace) + p3 = re.compile(r'^(\s*)(' + escaped + r')(\s*)$', re.MULTILINE) + content, n = p3.subn(r'\g<1>' + translated + r'\g<3>', content) + total += n + + return content, total + + +def replace_html_attr(content, attr, original, translated): + """Replace HTML attribute value using exact string matching.""" + pattern = re.compile( + r'(' + re.escape(attr) + r'\s*=\s*")(' + re.escape(original) + r')(")', + re.IGNORECASE + ) + new_content, count = pattern.subn(r'\g<1>' + translated + r'\g<3>', content) + return new_content, count + + +def replace_js_in_block(script_block, original, translated): + """Replace a JS string literal within a single block. + Returns (new_block, count). + """ + for quote in ['"', "'", '`']: + escaped = re.escape(original) + # Match quoted string within this single script block + pattern = re.compile( + r'([' + quote + r'])(' + escaped + r')([' + quote + r'])' + ) + new_block, count = pattern.subn( + r'\g<1>' + translated + r'\g<3>', + script_block, count=1 + ) + if count > 0: + return new_block, count + + return script_block, 0 + + +def apply_translations(content, file_key, translations, lang_code): + """Apply all translations to a file's content. + Uses script-block-aware processing to avoid cross-contamination. + """ + total = 0 + + # 1. Update lang attribute + content = re.sub( + r'(]*lang\s*=\s*")([^"]+)(")', + r'\g<1>' + lang_code + r'\g<3>', + content, count=1 + ) + + # 2. Split into script/non-script segments + segments = split_script_blocks(content) + + # 3. Apply translations per-segment + file_translations = translations.get(file_key, {}) + new_segments = [] + + for segment_text, is_script in segments: + if is_script: + # Apply JS translations to script blocks + for key, entry in file_translations.items(): + if key.startswith('js:'): + segment_text, count = replace_js_in_block( + segment_text, entry['original'], entry['translation'] + ) + total += count + else: + # Apply HTML translations to non-script content only + for key, entry in file_translations.items(): + if key.startswith('html:'): + parts = key.split(':') + attr_name = parts[-1] + + if attr_name == 'text': + segment_text, count = replace_html_text( + segment_text, entry['original'], entry['translation'] + ) + total += count + elif attr_name in ('placeholder', 'title', 'alt', 'aria-label'): + segment_text, count = replace_html_attr( + segment_text, attr_name, entry['original'], entry['translation'] + ) + total += count + + new_segments.append(segment_text) + + return ''.join(new_segments), total + + +def build_locale(locale, source_dir=None, output_dir=None): + """Build translated HTM files for a given locale.""" + file_translations = load_translations(locale) + if not file_translations: + print(f"Warning: No translations found for {locale}") + return 0 + + lang_code = LOCALE_LANG.get(locale, locale.split('_')[0]) + src_dir = Path(source_dir) if source_dir else DATA_DIR + + htm_files = sorted(src_dir.glob('*.htm')) + if not htm_files: + print(f"Error: No .htm files found in {src_dir}", file=sys.stderr) + return 0 + + # BUG FIX #4: Never default to overwriting source files + if output_dir: + out_dir = Path(output_dir) + else: + out_dir = Path(tempfile.mkdtemp(prefix=f'wled_i18n_{locale}_')) + print(f"[i18n] No --output-dir specified, using temp: {out_dir}") + + out_dir.mkdir(parents=True, exist_ok=True) + + # Safety check: warn if output == source + if out_dir.resolve() == src_dir.resolve(): + print(f"WARNING: Output dir equals source dir ({src_dir}).", file=sys.stderr) + print(f" English source files will be overwritten!", file=sys.stderr) + print(f" Pass --output-dir to a different location.", file=sys.stderr) + + total_applied = 0 + + for filepath in htm_files: + file_key = filepath.name + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + content, applied = apply_translations(content, file_key, file_translations, lang_code) + + out_path = out_dir / file_key + with open(out_path, 'w', encoding='utf-8') as f: + f.write(content) + + status = f"{applied} translations" if applied else "no changes" + print(f" {file_key}: {status}") + total_applied += applied + + print(f"\nTotal: {total_applied} translations applied across {len(htm_files)} files") + print(f"Output: {out_dir}") + return total_applied + + +def main(): + import argparse + parser = argparse.ArgumentParser(description='Build translated WLED Web UI files') + parser.add_argument('--locale', required=True, help='Locale code (e.g. zh_CN)') + parser.add_argument('--source-dir', default=None, help='Source directory (default: wled00/data/)') + parser.add_argument('--output-dir', default=None, help='Output directory (default: temp dir)') + args = parser.parse_args() + + print(f"WLED i18n Build — {args.locale}") + print("=" * 40) + + count = build_locale(args.locale, args.source_dir, args.output_dir) + if count == 0: + print("\nWarning: No translations applied!") + + +# PlatformIO pre-build integration +def pre_build(source, target, env): + """PlatformIO pre-build script entry point.""" + import re as _re + locale = None + for flag in env.get('BUILD_FLAGS', []): + m = _re.match(r'-D\s*WLED_LOCALE=(\S+)', flag) + if m: + locale = m.group(1).strip() + break + + if not locale: + print("[i18n] No WLED_LOCALE set, skipping translation") + return + + print(f"[i18n] Building with locale: {locale}") + # Use build directory for output, not source directory + build_dir = Path(env.subst('$BUILD_DIR')) / 'i18n' / locale + build_locale(locale, output_dir=build_dir) + + +try: + Import("env") + env.AddPreAction("buildprog", pre_build) +except NameError: + if __name__ == '__main__': + main() diff --git a/tools/i18n/extract.py b/tools/i18n/extract.py new file mode 100644 index 0000000000..3e656089ff --- /dev/null +++ b/tools/i18n/extract.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +WLED i18n String Extractor +Extracts translatable strings from WLED Web UI HTML files. + +Usage: python3 extract.py [--locale zh_CN] + +Outputs: locales/.json (or locales/_template.json if no locale specified) + +Handles three layers: +1. Static HTML text (BeautifulSoup DOM parsing) +2. JS strings in @@ -252,61 +252,61 @@
-
+
-

2D setup

+

2D 设置

- Strip or panel: + 灯带或面板:

- +
diff --git a/wled00/data/settings_dmx.htm b/wled00/data/settings_dmx.htm index 391c2bdc97..0e6e1a75ea 100644 --- a/wled00/data/settings_dmx.htm +++ b/wled00/data/settings_dmx.htm @@ -1,9 +1,9 @@ - + - DMX Settings + DMX 设置 - -

Pin Info

-
Loading...
- + +

引脚信息

+
加载中...
+ diff --git a/wled00/data/settings_sec.htm b/wled00/data/settings_sec.htm index 4586d44a5d..19ae63b80a 100644 --- a/wled00/data/settings_sec.htm +++ b/wled00/data/settings_sec.htm @@ -1,9 +1,9 @@ - + - Security & Update Setup + 安全和更新设置