From 11e8c7662ddb9372305aafc53e35df0c332273dd Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Mon, 24 Aug 2026 00:05:07 +0200 Subject: [PATCH 1/5] docs(i18n): add I18N.md for pak-scoped i18n extension (Approche 1) Documents I18N_load_extra() for compiled paks and the planned i18n.elf CLI for shell paks, mirroring the nextval.elf / pak-hooks.sh precedents already in the codebase. Implementation follows separately. --- I18N.md | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 I18N.md diff --git a/I18N.md b/I18N.md new file mode 100644 index 000000000..d1fd2032d --- /dev/null +++ b/I18N.md @@ -0,0 +1,171 @@ +# About NextUI i18n + +## The idea + +NextUI ships a minimal runtime translation layer (`workspace/all/common/i18n.c`): +a static hash table loaded from plain-text `.lang` files at +`.system/res/lang/{en,}.lang`, looked up through `T(key)`. Every +NextUI-core binary (`nextui`, `settings`, `minarch`, `battery`, `clock`, ...) +links `i18n.c` directly and calls `I18N_init(CFG_getLanguage())` once, early +in `GFX_init()`. `T(key)` returns the translated string, or the key itself +verbatim if there's no entry — so a missing translation never crashes or +shows a blank string, it just falls back to whatever was passed in. + +That part is core OS plumbing and unchanged by this doc. + +This page covers the second half: **letting a Tools pak — compiled or shell +— add its own vocabulary to the same running table**, without patching +NextUI's own `en.lang`/`fr.lang`, and without the OS ever having to trust or +preload a pak's content before that pak actually runs. + +## Two audiences, two doors + +### A compiled pak (C/C++) + +Link `i18n.c` into your pak's own binary exactly like the core tools do +(see any `workspace/all/*/makefile`'s `SOURCE` line), then, after the OS has +already called `I18N_init()` — i.e. from your own `main()`, once your +process is up — pull in your own file with: + +```c +int I18N_load_extra(const char *path); +``` + +This **appends** entries into the same table `T()` already reads from; it +does not clear it first, so the OS's own strings stay intact. Point it at a +file inside your own pak folder: + +```c +char path[MAX_PATH]; +snprintf(path, sizeof(path), "%s/res/lang/%s.lang", + pak_dir, I18N_active_code()); +I18N_load_extra(path); +``` + +Same file format as the OS's own `.lang` files (`key=value`, `#` comments, +`\n`/`\t`/`\\` escapes). From this point on your pak calls `T("mypak.title")` +exactly like core code does. + +### A shell pak + +Most Tools paks are `launch.sh` with no C at all, and have no way to call +`T()` directly. For these, use `i18n.elf` — a tiny CLI in the same family as +`nextval.elf` (already used by `MinUI.pak/launch.sh` to read settings from +shell): + +```sh +# OS vocabulary only +MSG=$(i18n.elf get btn.back) + +# OS vocabulary + this pak's own file, merged for this one call +MSG=$(i18n.elf get mypak.title -f "$(dirname "$0")/res/lang/$(i18n.elf lang).lang") +``` + +- `i18n.elf get ` — looks up `` against the OS table (current + active language, same as every other binary) and prints the resulting + string to stdout. Falls back to the key verbatim if there's no entry, same + semantics as `T()`. +- `i18n.elf get -f ` — same, but first loads `` as an + extra `.lang` file into a private in-process table before resolving the + key (`i18n.elf` is a one-shot process; nothing persists between + invocations, so this costs one small file parse per call — keep it out of + per-frame loops). +- `i18n.elf lang` — prints the currently active language code (`en`, `fr`, + ...), so a pak can pick the right file itself without duplicating + `CFG_getLanguage()` logic in shell. + +## Where your files live + +Nothing new to install or register — your `.lang` files live inside your own +pak folder, next to `launch.sh`: + +``` +Tools//MyPak.pak/ + launch.sh + res/lang/ + en.lang # required: your reference language + fr.lang # optional: any locale you want to cover +``` + +Only `en.lang` is required. `I18N_load_extra()` / `i18n.elf -f` will happily +merge a locale file with a handful of keys — anything missing there simply +falls through to whichever value `en.lang` (yours, or the OS's) already +provided, same fallback chain the OS uses for its own strings. + +There is deliberately **no directory the OS scans on its own** (unlike +`pak-hooks.sh`, which auto-discovers hook scripts at boot). i18n data is +pulled in by the pak itself, on demand, only while that pak is actually +running: + +- Removing the pak removes its strings with it — nothing orphaned in a + shared file, nothing to clean up. +- A pak that's installed but never opened costs the OS's i18n table nothing + — it's never loaded. +- The OS never merges third-party text into its own process without that + pak's own code asking for it first. + +## Naming your keys + +The table is a single flat namespace shared by the OS and every pak that +merges into it while running. Prefix every key you own with a short, stable +tag unique to your pak, the same convention already used for NextUI's own +bundled sub-tools: + +``` +sg.menu.browse=Browse +sg.btn.install=Install +``` + +(`sg.*` = ScrapeGoat, `ps.*` = Pak Store — both already following this +pattern in NextUI's own `en.lang`.) There's no enforcement in code; a +collision with an OS-core key or another pak's prefix will silently let one +value win. Picking a tag nobody else plausibly picked is on you. + +## Rules + +- Ship `en.lang` with your pak. It's your fallback of last resort — if a + key isn't in the active locale's file, `T()`/`i18n.elf` falls through to + whatever `en.lang` (yours first, then the OS's) provides, then to the key + itself. +- Load your extra file once, early (your `main()` for a compiled pak; once + per `launch.sh` run if you `i18n.elf -f` more than a couple of keys — + batch your lookups rather than shelling out per string). +- Never write into the OS's own `.system/res/lang/*.lang`. Those are core + files; your pak brings its own. +- `I18N_load_extra()` only appends — it's safe to call multiple times (e.g. + once for your own file, once more for a shared library pak you depend on) + without clobbering the OS's own entries. + +## Example: a shell pak's launch.sh + +```sh +#!/bin/sh +# MyPak.pak/launch.sh + +PAK_DIR="$(dirname "$0")" +LANG_FILE="$PAK_DIR/res/lang/$(i18n.elf lang).lang" + +TITLE=$(i18n.elf get mypak.title -f "$LANG_FILE") +echo "$TITLE" +``` + +## Example: a compiled pak's main() + +```c +#include "i18n.h" +#include "config.h" + +int main(int argc, char *argv[]) { + CFG_init(NULL, NULL); + I18N_init(CFG_getLanguage()); // OS vocabulary, as usual + + char path[MAX_PATH]; + snprintf(path, sizeof(path), "%s/res/lang/%s.lang", + SDCARD_PATH "/Tools/" PLATFORM "/MyPak.pak", + I18N_active_code()); + I18N_load_extra(path); // + this pak's own vocabulary + + puts(T("mypak.title")); + return 0; +} +``` From 1050ca5b95ea793498128bdf33d261a1c10bfa51 Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Mon, 24 Aug 2026 00:16:05 +0200 Subject: [PATCH 2/5] docs(i18n): add safe interpolation (i18n.elf format) and community translation packs - i18n.elf format: %s-only positional substitution, never handed to printf(1) directly, so a third-party-translated .lang value can't become a format-string footgun. - Document a pak-i18n-common pattern: a shared .userdata/shared/i18n-community//.lang path, merged via an extra -f, that lets a data-only pak translate someone else's pak without touching its code -- conditional on that pak already routing strings through T()/i18n.elf. --- I18N.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/I18N.md b/I18N.md index d1fd2032d..b8160d1b2 100644 --- a/I18N.md +++ b/I18N.md @@ -74,6 +74,53 @@ MSG=$(i18n.elf get mypak.title -f "$(dirname "$0")/res/lang/$(i18n.elf lang).lan ...), so a pak can pick the right file itself without duplicating `CFG_getLanguage()` logic in shell. +## Interpolated strings (`_fmt` keys) + +A `.lang` value can carry a `printf`-style placeholder, same convention +already used by NextUI core (`settings.ra.sync_pending_fmt=%u pending — send +to RA server`). A compiled pak resolves these exactly like core code does — +`T()` returns the template, the pak's own `snprintf` fills it in: + +```c +snprintf(buf, sizeof(buf), T("mypak.count_fmt"), n); +``` + +A shell pak **must not** do this by piping `i18n.elf get` straight into +`printf(1)`. The template comes out of a `.lang` file — potentially one +contributed by someone else entirely (see the community-translation pattern +below) — and handing a string you don't control to `printf` as its own +format argument is exactly the classic format-string footgun: an extra or +wrong specifier in a bad translation can crash the pak or print garbage, +and `%u` isn't even portable across `printf(1)` implementations. + +Use `i18n.elf format` instead: + +```sh +MSG=$(i18n.elf format mypak.count_fmt -f "$LANG_FILE" -- "$COUNT") +``` + +`i18n.elf format [-f ] -- ...` resolves `` (merging +`` first, same as `get`), then substitutes **`%s` placeholders only**, +positionally, against the trailing args — implemented as a small in-process +scanner, never handed to libc's `printf`/`vprintf`. A missing arg becomes an +empty string; an extra `%s` in a bad translation is left as literal text; +any other specifier (`%d`, `%u`, `%n`, ...) is *not* recognized and passed +through unexpanded. There is no code path where a `.lang` value can control +how many bytes get written or trigger undefined behavior. + +**Consequence for key authors**: a `_fmt` key meant to be consumed by a +shell pak must only ever use `%s`, never `%d`/`%u`. Format the number to +text yourself before passing it in: + +```sh +i18n.elf format mypak.count_fmt -f "$LANG_FILE" -- "$(printf '%d' "$COUNT")" +``` + +Compiled paks aren't bound by this — their own `snprintf` call controls the +types, so `%d`/`%u` stay fine for a C-side `_fmt` key. It's `i18n.elf +format`'s `%s`-only restriction that makes the shell path safe to feed +third-party or community-contributed translations without trusting them. + ## Where your files live Nothing new to install or register — your `.lang` files live inside your own @@ -136,6 +183,56 @@ value win. Picking a tag nobody else plausibly picked is on you. once for your own file, once more for a shared library pak you depend on) without clobbering the OS's own entries. +## Third-party translation packs + +Nothing above requires a pak to translate itself — a *separate* pak can +supply translations for someone else's pak, on one condition: **the target +pak has to already route its own strings through `T()`/`i18n.elf`**, even if +it only ever shipped `en.lang`. This mechanism has no hook into raw stdout +or arbitrary rendering — it only ever resolves keys a pak explicitly asks +for. If a pak still hardcodes literal strings, no translation pak can +intercept them; that pak needs its own small patch (replace literals with +keys + ship its own `en.lang`) before anyone can translate it. Retranslating +by matching on literal English text instead of a key was considered and +rejected — it breaks the safe-fallback/namespacing model this whole system +relies on and risks translating the same English word wrong depending on +context. + +For a pak that *is* already key-based but only ships `en.lang`, a +translation pak can fill the gap without touching its code, via one well-known +shared path, checked in addition to the target pak's own file: + +``` +$SDCARD_PATH/.userdata/shared/i18n-community//.lang +``` + +```sh +# inside TargetPak.pak/launch.sh — one extra -f, nothing else changes +COMMUNITY="$SDCARD_PATH/.userdata/shared/i18n-community/TargetPak.pak/$(i18n.elf lang).lang" +MSG=$(i18n.elf get target.title -f "$LANG_FILE" -f "$COMMUNITY") +``` + +(`i18n.elf get`/`format` accepting more than one `-f`, merged in order given, +is the one small addition needed on top of the Étape 2 CLI — each `-f` +appends, later ones can override earlier keys, same as +`I18N_load_extra()` itself.) A pak author who wants to be +community-translatable adds that one extra `-f` to their own lookups, once. + +A `pak-i18n-common`-style pak is then **pure data, no runtime component**: +it ships `/.lang` fragments for whichever known, +already key-based paks it covers, and drops them into that shared directory +once via a `boot.sh` (the pak-scoped lifecycle hook — presence-based, no +arming, see `HOOKS.md`). Uninstalling `pak-i18n-common` doesn't retroactively +un-drop those files by itself (a `boot.sh` copies, it doesn't symlink-and-track); +document it as leaving copies behind on removal, or have it use a real +symlink rather than a copy if the platform's filesystem allows it. + +In practice, covering a "top 10" of popular third-party paks with this +pattern is two separate jobs: contributing the small key-ification patch +upstream to each pak that doesn't have it yet (the actual bottleneck), and +then bundling the `.lang` fragments once that's done. The community pak only +ever solves the second half. + ## Example: a shell pak's launch.sh ```sh From c9892edecf2e0c38fae3bba139c6dec603f3c148 Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Mon, 24 Aug 2026 00:20:08 +0200 Subject: [PATCH 3/5] feat(i18n): add I18N_load_extra() for pak-scoped vocabulary Exposes the existing parse_file() as a public, append-only entry point: a pak (compiled, or i18n.elf on a shell pak's behalf) can merge its own .lang file into the table already loaded by I18N_init()/I18N_reload() without clearing the OS's own entries. No-op before I18N_init(); safe to call repeatedly, later files win on key collisions. Step 1 of I18N.md's pak-scoped i18n plan. --- workspace/all/common/i18n.c | 5 +++++ workspace/all/common/i18n.h | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/workspace/all/common/i18n.c b/workspace/all/common/i18n.c index 9ce4252d3..b7042ea5f 100644 --- a/workspace/all/common/i18n.c +++ b/workspace/all/common/i18n.c @@ -161,6 +161,11 @@ int I18N_reload(const char *lang_code) { return load_lang(lang_code); } +int I18N_load_extra(const char *path) { + if (!s_inited || !path) return 0; + return parse_file(path); +} + void I18N_quit(void) { table_clear(); s_active_code[0] = '\0'; diff --git a/workspace/all/common/i18n.h b/workspace/all/common/i18n.h index 97a1a06db..cb57f94d7 100644 --- a/workspace/all/common/i18n.h +++ b/workspace/all/common/i18n.h @@ -14,6 +14,13 @@ int I18N_reload(const char *lang_code); char *I18N_t(const char *key); const char *I18N_active_code(void); +// Merges an additional .lang file into the table already loaded by +// I18N_init()/I18N_reload(), without clearing it first -- lets a pak add +// its own keys (or a community translation override another pak's) on top +// of the OS vocabulary. Safe to call more than once; later files win on +// key collisions. No-op (returns 0) if called before I18N_init(). +int I18N_load_extra(const char *path); + #define T(k) I18N_t(k) #ifdef __cplusplus From 1151bd03f79327986206d5fcfa18612808199a54 Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Mon, 24 Aug 2026 00:25:09 +0200 Subject: [PATCH 4/5] docs(i18n): tighten I18N.md Cut repeated framing/rationale, keep the same technical content and examples. 269 -> 179 lines. --- I18N.md | 273 +++++++++++++++++++------------------------------------- 1 file changed, 92 insertions(+), 181 deletions(-) diff --git a/I18N.md b/I18N.md index b8160d1b2..d724e81fd 100644 --- a/I18N.md +++ b/I18N.md @@ -2,209 +2,135 @@ ## The idea -NextUI ships a minimal runtime translation layer (`workspace/all/common/i18n.c`): -a static hash table loaded from plain-text `.lang` files at -`.system/res/lang/{en,}.lang`, looked up through `T(key)`. Every -NextUI-core binary (`nextui`, `settings`, `minarch`, `battery`, `clock`, ...) -links `i18n.c` directly and calls `I18N_init(CFG_getLanguage())` once, early -in `GFX_init()`. `T(key)` returns the translated string, or the key itself -verbatim if there's no entry — so a missing translation never crashes or -shows a blank string, it just falls back to whatever was passed in. +NextUI ships a runtime translation layer (`workspace/all/common/i18n.c`): a +static hash table loaded from `.system/res/lang/{en,}.lang`, looked up +through `T(key)`. Every core binary links `i18n.c` and calls +`I18N_init(CFG_getLanguage())` once in `GFX_init()`. `T(key)` returns the +translation, or the key itself if there's no entry — a missing translation +never crashes, it just falls back to the input. -That part is core OS plumbing and unchanged by this doc. +That part is unchanged. This page covers the rest: letting a Tools pak — +compiled or shell — add its own vocabulary to the same table, without +patching NextUI's own `.lang` files and without the OS trusting a pak's +content before that pak actually runs. -This page covers the second half: **letting a Tools pak — compiled or shell -— add its own vocabulary to the same running table**, without patching -NextUI's own `en.lang`/`fr.lang`, and without the OS ever having to trust or -preload a pak's content before that pak actually runs. +## Two doors -## Two audiences, two doors - -### A compiled pak (C/C++) - -Link `i18n.c` into your pak's own binary exactly like the core tools do -(see any `workspace/all/*/makefile`'s `SOURCE` line), then, after the OS has -already called `I18N_init()` — i.e. from your own `main()`, once your -process is up — pull in your own file with: +**Compiled pak (C/C++)**: link `i18n.c` like core tools do (see any +`workspace/all/*/makefile`), then after `I18N_init()` has already run, merge +your own file: ```c -int I18N_load_extra(const char *path); -``` - -This **appends** entries into the same table `T()` already reads from; it -does not clear it first, so the OS's own strings stay intact. Point it at a -file inside your own pak folder: +int I18N_load_extra(const char *path); // appends, never clears the table -```c char path[MAX_PATH]; -snprintf(path, sizeof(path), "%s/res/lang/%s.lang", - pak_dir, I18N_active_code()); +snprintf(path, sizeof(path), "%s/res/lang/%s.lang", pak_dir, I18N_active_code()); I18N_load_extra(path); ``` -Same file format as the OS's own `.lang` files (`key=value`, `#` comments, -`\n`/`\t`/`\\` escapes). From this point on your pak calls `T("mypak.title")` -exactly like core code does. - -### A shell pak +Same `.lang` format as the OS (`key=value`, `#` comments, `\n`/`\t`/`\\` +escapes). From here on, call `T("mypak.title")` like core code does. -Most Tools paks are `launch.sh` with no C at all, and have no way to call -`T()` directly. For these, use `i18n.elf` — a tiny CLI in the same family as -`nextval.elf` (already used by `MinUI.pak/launch.sh` to read settings from -shell): +**Shell pak**: no C, so use `i18n.elf` (same family as `nextval.elf`, +already used by `MinUI.pak/launch.sh`): ```sh -# OS vocabulary only -MSG=$(i18n.elf get btn.back) - -# OS vocabulary + this pak's own file, merged for this one call MSG=$(i18n.elf get mypak.title -f "$(dirname "$0")/res/lang/$(i18n.elf lang).lang") ``` -- `i18n.elf get ` — looks up `` against the OS table (current - active language, same as every other binary) and prints the resulting - string to stdout. Falls back to the key verbatim if there's no entry, same - semantics as `T()`. -- `i18n.elf get -f ` — same, but first loads `` as an - extra `.lang` file into a private in-process table before resolving the - key (`i18n.elf` is a one-shot process; nothing persists between - invocations, so this costs one small file parse per call — keep it out of - per-frame loops). -- `i18n.elf lang` — prints the currently active language code (`en`, `fr`, - ...), so a pak can pick the right file itself without duplicating - `CFG_getLanguage()` logic in shell. +- `i18n.elf get [-f ]...` — resolves ``, merging each + `-f` file first (repeatable; later files win on collision). Falls back to + the key verbatim, same as `T()`. +- `i18n.elf lang` — prints the active language code. +- `i18n.elf format` — see interpolation below. -## Interpolated strings (`_fmt` keys) - -A `.lang` value can carry a `printf`-style placeholder, same convention -already used by NextUI core (`settings.ra.sync_pending_fmt=%u pending — send -to RA server`). A compiled pak resolves these exactly like core code does — -`T()` returns the template, the pak's own `snprintf` fills it in: +`i18n.elf` is a one-shot process, so `-f` costs a file parse per call — +batch your lookups rather than shelling out per string. -```c -snprintf(buf, sizeof(buf), T("mypak.count_fmt"), n); -``` +## Interpolated strings (`_fmt` keys) -A shell pak **must not** do this by piping `i18n.elf get` straight into -`printf(1)`. The template comes out of a `.lang` file — potentially one -contributed by someone else entirely (see the community-translation pattern -below) — and handing a string you don't control to `printf` as its own -format argument is exactly the classic format-string footgun: an extra or -wrong specifier in a bad translation can crash the pak or print garbage, -and `%u` isn't even portable across `printf(1)` implementations. +`.lang` values can carry `printf`-style placeholders (already used by core: +`settings.ra.sync_pending_fmt=%u pending — send to RA server`). A compiled +pak resolves these like core does: `snprintf(buf, sizeof(buf), +T("mypak.count_fmt"), n)`. -Use `i18n.elf format` instead: +A shell pak must **not** pipe `i18n.elf get` into `printf(1)` — the +template can come from a translation you don't control (see community packs +below), and handing an untrusted string to `printf` as its own format +argument is the classic format-string footgun. Use `i18n.elf format` +instead: ```sh MSG=$(i18n.elf format mypak.count_fmt -f "$LANG_FILE" -- "$COUNT") ``` -`i18n.elf format [-f ] -- ...` resolves `` (merging -`` first, same as `get`), then substitutes **`%s` placeholders only**, -positionally, against the trailing args — implemented as a small in-process -scanner, never handed to libc's `printf`/`vprintf`. A missing arg becomes an -empty string; an extra `%s` in a bad translation is left as literal text; -any other specifier (`%d`, `%u`, `%n`, ...) is *not* recognized and passed -through unexpanded. There is no code path where a `.lang` value can control -how many bytes get written or trigger undefined behavior. - -**Consequence for key authors**: a `_fmt` key meant to be consumed by a -shell pak must only ever use `%s`, never `%d`/`%u`. Format the number to -text yourself before passing it in: - -```sh -i18n.elf format mypak.count_fmt -f "$LANG_FILE" -- "$(printf '%d' "$COUNT")" -``` - -Compiled paks aren't bound by this — their own `snprintf` call controls the -types, so `%d`/`%u` stay fine for a C-side `_fmt` key. It's `i18n.elf -format`'s `%s`-only restriction that makes the shell path safe to feed -third-party or community-contributed translations without trusting them. +It substitutes **`%s` only**, positionally, via a small in-process scanner — +never handed to libc's `printf`/`vprintf`. A missing arg becomes empty; any +other specifier (`%d`, `%u`, `%n`...) is left as literal text, never +interpreted. Consequence: a `_fmt` key meant for a shell pak must only use +`%s` — format numbers to text yourself before passing them in. Compiled +paks aren't bound by this; their own `snprintf` controls the types. ## Where your files live -Nothing new to install or register — your `.lang` files live inside your own -pak folder, next to `launch.sh`: - ``` Tools//MyPak.pak/ launch.sh res/lang/ - en.lang # required: your reference language - fr.lang # optional: any locale you want to cover + en.lang # required + fr.lang # optional ``` -Only `en.lang` is required. `I18N_load_extra()` / `i18n.elf -f` will happily -merge a locale file with a handful of keys — anything missing there simply -falls through to whichever value `en.lang` (yours, or the OS's) already -provided, same fallback chain the OS uses for its own strings. +Only `en.lang` is required; missing keys in a locale file fall through to +`en.lang` then to the OS's own table, same chain the OS uses for itself. -There is deliberately **no directory the OS scans on its own** (unlike -`pak-hooks.sh`, which auto-discovers hook scripts at boot). i18n data is -pulled in by the pak itself, on demand, only while that pak is actually -running: +There's deliberately **no directory the OS scans on its own** (unlike +`pak-hooks.sh`'s auto-discovery) — a pak pulls its own vocabulary in, on +demand, only while running: -- Removing the pak removes its strings with it — nothing orphaned in a - shared file, nothing to clean up. -- A pak that's installed but never opened costs the OS's i18n table nothing - — it's never loaded. -- The OS never merges third-party text into its own process without that - pak's own code asking for it first. +- removing the pak removes its strings with it, nothing orphaned; +- an installed-but-never-opened pak costs the table nothing; +- the OS never merges third-party text without that pak asking for it. ## Naming your keys -The table is a single flat namespace shared by the OS and every pak that -merges into it while running. Prefix every key you own with a short, stable -tag unique to your pak, the same convention already used for NextUI's own -bundled sub-tools: +One flat namespace shared by the OS and every pak that merges into it. +Prefix your keys with a short tag unique to your pak — the convention +already used in NextUI's own `en.lang` (`sg.*` = ScrapeGoat, `ps.*` = Pak +Store): ``` sg.menu.browse=Browse sg.btn.install=Install ``` -(`sg.*` = ScrapeGoat, `ps.*` = Pak Store — both already following this -pattern in NextUI's own `en.lang`.) There's no enforcement in code; a -collision with an OS-core key or another pak's prefix will silently let one -value win. Picking a tag nobody else plausibly picked is on you. +Not enforced in code — a collision silently lets one value win. Pick a tag +nobody else plausibly picked. ## Rules -- Ship `en.lang` with your pak. It's your fallback of last resort — if a - key isn't in the active locale's file, `T()`/`i18n.elf` falls through to - whatever `en.lang` (yours first, then the OS's) provides, then to the key - itself. -- Load your extra file once, early (your `main()` for a compiled pak; once - per `launch.sh` run if you `i18n.elf -f` more than a couple of keys — - batch your lookups rather than shelling out per string). -- Never write into the OS's own `.system/res/lang/*.lang`. Those are core - files; your pak brings its own. -- `I18N_load_extra()` only appends — it's safe to call multiple times (e.g. - once for your own file, once more for a shared library pak you depend on) - without clobbering the OS's own entries. +- Ship `en.lang` with your pak; it's your fallback of last resort. +- Load your extra file once, early — a compiled pak's `main()`, or once per + `launch.sh` run for a shell pak. +- Never write into `.system/res/lang/*.lang` — those are core files. +- `I18N_load_extra()` only appends; safe to call more than once (e.g. your + own file, then a dependency's) without clobbering the OS's entries. ## Third-party translation packs -Nothing above requires a pak to translate itself — a *separate* pak can -supply translations for someone else's pak, on one condition: **the target -pak has to already route its own strings through `T()`/`i18n.elf`**, even if -it only ever shipped `en.lang`. This mechanism has no hook into raw stdout -or arbitrary rendering — it only ever resolves keys a pak explicitly asks -for. If a pak still hardcodes literal strings, no translation pak can -intercept them; that pak needs its own small patch (replace literals with -keys + ship its own `en.lang`) before anyone can translate it. Retranslating -by matching on literal English text instead of a key was considered and -rejected — it breaks the safe-fallback/namespacing model this whole system -relies on and risks translating the same English word wrong depending on -context. - -For a pak that *is* already key-based but only ships `en.lang`, a -translation pak can fill the gap without touching its code, via one well-known -shared path, checked in addition to the target pak's own file: +A *separate* pak can supply translations for someone else's pak — on one +condition: the target pak must already route its strings through +`T()`/`i18n.elf`, even if it only ever shipped `en.lang`. This mechanism +only resolves keys a pak explicitly asks for; it can't intercept hardcoded +literals. A pak that hasn't been made key-based needs that small patch first +(replace literals with keys + ship `en.lang`) before anyone can translate +it. Matching on literal English text instead of a key was considered and +rejected — it breaks the safe-fallback/namespacing model and risks +translating the same English word wrong depending on context. -``` -$SDCARD_PATH/.userdata/shared/i18n-community//.lang -``` +For an already key-based pak missing a locale, a translation pak fills the +gap without touching its code, via one well-known shared path passed as an +extra `-f`: ```sh # inside TargetPak.pak/launch.sh — one extra -f, nothing else changes @@ -212,55 +138,40 @@ COMMUNITY="$SDCARD_PATH/.userdata/shared/i18n-community/TargetPak.pak/$(i18n.elf MSG=$(i18n.elf get target.title -f "$LANG_FILE" -f "$COMMUNITY") ``` -(`i18n.elf get`/`format` accepting more than one `-f`, merged in order given, -is the one small addition needed on top of the Étape 2 CLI — each `-f` -appends, later ones can override earlier keys, same as -`I18N_load_extra()` itself.) A pak author who wants to be -community-translatable adds that one extra `-f` to their own lookups, once. - -A `pak-i18n-common`-style pak is then **pure data, no runtime component**: -it ships `/.lang` fragments for whichever known, -already key-based paks it covers, and drops them into that shared directory -once via a `boot.sh` (the pak-scoped lifecycle hook — presence-based, no -arming, see `HOOKS.md`). Uninstalling `pak-i18n-common` doesn't retroactively -un-drop those files by itself (a `boot.sh` copies, it doesn't symlink-and-track); -document it as leaving copies behind on removal, or have it use a real -symlink rather than a copy if the platform's filesystem allows it. - -In practice, covering a "top 10" of popular third-party paks with this -pattern is two separate jobs: contributing the small key-ification patch -upstream to each pak that doesn't have it yet (the actual bottleneck), and -then bundling the `.lang` fragments once that's done. The community pak only -ever solves the second half. - -## Example: a shell pak's launch.sh +A `pak-i18n-common`-style pak is then pure data, no runtime component: it +ships `/.lang` fragments and drops them into that +shared directory once via a `boot.sh` hook (see `HOOKS.md`). A `boot.sh` +that copies won't retroactively clean up on removal — symlink instead if the +platform allows it, or document that removal leaves copies behind. + +Covering a "top 10" of popular paks this way is really two jobs: getting +the key-ification patch merged upstream in each pak that doesn't have one +yet (the actual bottleneck), then bundling `.lang` fragments once that's +done. The community pak only ever solves the second half. + +## Examples ```sh #!/bin/sh # MyPak.pak/launch.sh - PAK_DIR="$(dirname "$0")" LANG_FILE="$PAK_DIR/res/lang/$(i18n.elf lang).lang" - TITLE=$(i18n.elf get mypak.title -f "$LANG_FILE") echo "$TITLE" ``` -## Example: a compiled pak's main() - ```c #include "i18n.h" #include "config.h" int main(int argc, char *argv[]) { CFG_init(NULL, NULL); - I18N_init(CFG_getLanguage()); // OS vocabulary, as usual + I18N_init(CFG_getLanguage()); char path[MAX_PATH]; snprintf(path, sizeof(path), "%s/res/lang/%s.lang", - SDCARD_PATH "/Tools/" PLATFORM "/MyPak.pak", - I18N_active_code()); - I18N_load_extra(path); // + this pak's own vocabulary + SDCARD_PATH "/Tools/" PLATFORM "/MyPak.pak", I18N_active_code()); + I18N_load_extra(path); puts(T("mypak.title")); return 0; From a3613b81ec53a3f14fc504001c366093cc29c0bb Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Mon, 24 Aug 2026 00:28:12 +0200 Subject: [PATCH 5/5] feat(i18n): add i18n.elf CLI for shell paks New workspace/all/i18n_cli/, built and packaged like nextval.elf: i18n.elf lang prints active language code i18n.elf get [-f ]... resolves a key, T() fallback semantics i18n.elf format [-f ]... -- args %s-only positional interpolation -f is repeatable (I18N_load_extra() per file, later wins) -- this is what lets a shell pak merge its own res/lang/ file, and optionally a second community-translation file on top, in one call. format never hands the resolved template to printf(3)/vprintf: only %s is recognized and substituted, any other specifier (%d, %u, %n...) is left as literal text. A .lang value -- including a third-party or community-contributed one -- can never control how many bytes get written. Wired into workspace/makefile (all + clean) and the root makefile's system: target, same placement as nextval.elf. Step 2 of I18N.md's pak-scoped i18n plan. --- makefile | 1 + workspace/all/i18n_cli/i18n.c | 74 +++++++++++++++++++++++++++++++++ workspace/all/i18n_cli/makefile | 35 ++++++++++++++++ workspace/makefile | 3 ++ 4 files changed, 113 insertions(+) create mode 100644 workspace/all/i18n_cli/i18n.c create mode 100644 workspace/all/i18n_cli/makefile diff --git a/makefile b/makefile index fed3da9f6..4796570a1 100644 --- a/makefile +++ b/makefile @@ -100,6 +100,7 @@ endif cp ./workspace/all/nextui/build/$(PLATFORM)/nextui.elf ./build/SYSTEM/$(PLATFORM)/bin/ cp ./workspace/all/minarch/build/$(PLATFORM)/minarch.elf ./build/SYSTEM/$(PLATFORM)/bin/ cp ./workspace/all/nextval/build/$(PLATFORM)/nextval.elf ./build/SYSTEM/$(PLATFORM)/bin/ + cp ./workspace/all/i18n_cli/build/$(PLATFORM)/i18n.elf ./build/SYSTEM/$(PLATFORM)/bin/ cp ./workspace/all/clock/build/$(PLATFORM)/clock.elf ./build/EXTRAS/Tools/$(PLATFORM)/Clock.pak/ cp ./workspace/all/minput/build/$(PLATFORM)/minput.elf ./build/EXTRAS/Tools/$(PLATFORM)/Input.pak/ cp ./workspace/all/settings/build/$(PLATFORM)/settings.elf ./build/EXTRAS/Tools/$(PLATFORM)/Settings.pak/ diff --git a/workspace/all/i18n_cli/i18n.c b/workspace/all/i18n_cli/i18n.c new file mode 100644 index 000000000..8621c2c62 --- /dev/null +++ b/workspace/all/i18n_cli/i18n.c @@ -0,0 +1,74 @@ +#include +#include +#include + +#include "defines.h" +#include "config.h" +#include "i18n.h" + +void printUsage(void) +{ + printf("usage: i18n get [-f ]...\n" + " i18n format [-f ]... [--] ...\n" + " i18n lang\n"); +} + +// %s-only, positional, never handed to printf(3)/vprintf: a .lang value +// (possibly a third-party or community-contributed translation, see +// I18N.md) can never control how many bytes get written or trigger +// undefined behavior. Any other specifier (%d, %u, %n, ...) is left as +// literal text, never interpreted. +static void format_s(const char *tmpl, char **args, int nargs) +{ + int ai = 0; + for (const char *p = tmpl; *p; ) { + if (p[0] == '%' && p[1] == 's') { + if (ai < nargs) fputs(args[ai++], stdout); + p += 2; + } else { + fputc(*p++, stdout); + } + } + fputc('\n', stdout); +} + +int main(int argc, char *argv[]) +{ + CFG_init(NULL, NULL); + I18N_init(CFG_getLanguage()); + + if (argc >= 2 && strcmp(argv[1], "-h") == 0) { + printUsage(); + return EXIT_SUCCESS; + } + + if (argc >= 2 && strcmp(argv[1], "lang") == 0) { + printf("%s\n", I18N_active_code()); + return EXIT_SUCCESS; + } + + int is_get = (argc >= 2 && strcmp(argv[1], "get") == 0); + int is_format = (argc >= 2 && strcmp(argv[1], "format") == 0); + + if ((is_get || is_format) && argc >= 3) { + const char *key = argv[2]; + + int i = 3; + for (; i + 1 < argc && strcmp(argv[i], "-f") == 0; i += 2) + I18N_load_extra(argv[i + 1]); + + if (is_get) { + printf("%s\n", T(key)); + return EXIT_SUCCESS; + } + + // format: everything left, after an optional "--", is %s args + if (i < argc && strcmp(argv[i], "--") == 0) i++; + format_s(T(key), &argv[i], argc - i); + return EXIT_SUCCESS; + } + + printf("Error: invalid arguments\n"); + printUsage(); + return EXIT_FAILURE; +} diff --git a/workspace/all/i18n_cli/makefile b/workspace/all/i18n_cli/makefile new file mode 100644 index 000000000..ce42b758a --- /dev/null +++ b/workspace/all/i18n_cli/makefile @@ -0,0 +1,35 @@ +########################################################### + +ifeq (,$(PLATFORM)) +PLATFORM=$(UNION_PLATFORM) +endif + +ifeq (,$(PLATFORM)) + $(error please specify PLATFORM, eg. PLATFORM=trimui make) +endif + +ifeq (,$(CROSS_COMPILE)) + $(error missing CROSS_COMPILE for this toolchain) +endif + +########################################################### + +include ../../$(PLATFORM)/platform/makefile.env + +########################################################### + +TARGET = i18n +INCDIR = -I. -I../common/ -I../../$(PLATFORM)/platform/ +SOURCE = $(TARGET).c ../common/utils.c ../common/config.c ../common/i18n.c + +CC = $(CROSS_COMPILE)gcc +CFLAGS += $(OPT) +CFLAGS += $(INCDIR) -DPLATFORM=\"$(PLATFORM)\" -std=gnu99 + +PRODUCT= build/$(PLATFORM)/$(TARGET).elf + +all: + mkdir -p build/$(PLATFORM) + $(CC) $(SOURCE) -o $(PRODUCT) $(CFLAGS) $(LDFLAGS) +clean: + rm -f $(PRODUCT) diff --git a/workspace/makefile b/workspace/makefile index f71af4d62..ce8659e57 100644 --- a/workspace/makefile +++ b/workspace/makefile @@ -28,6 +28,7 @@ ifeq ($(PLATFORM), desktop) #cd ./all/gametime/ && make cd ./all/minput/ && make cd ./all/nextval/ && make + cd ./all/i18n_cli/ && make cd ./all/settings/ && make else ifeq ($(PLATFORM), tg5040) @@ -50,6 +51,7 @@ endif cd ./all/minput/ && make cd ./all/syncsettings/ && make cd ./all/nextval/ && make + cd ./all/i18n_cli/ && make cd ./all/settings/ && make cd ./all/ledcontrol/ && make cd ./all/bootlogo/ && make @@ -97,6 +99,7 @@ endif cd ./all/gametime/ && make clean cd ./all/minput/ && make clean cd ./all/nextval/ && make clean + cd ./all/i18n_cli/ && make clean cd ./all/settings/ && make clean cd ./all/audiomon/ && make clean cd ./$(PLATFORM) && make clean