diff --git a/I18N.md b/I18N.md new file mode 100644 index 000000000..d724e81fd --- /dev/null +++ b/I18N.md @@ -0,0 +1,179 @@ +# About NextUI i18n + +## The idea + +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 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. + +## Two doors + +**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); // appends, never clears the table + +char path[MAX_PATH]; +snprintf(path, sizeof(path), "%s/res/lang/%s.lang", pak_dir, I18N_active_code()); +I18N_load_extra(path); +``` + +Same `.lang` format as the OS (`key=value`, `#` comments, `\n`/`\t`/`\\` +escapes). From here on, call `T("mypak.title")` like core code does. + +**Shell pak**: no C, so use `i18n.elf` (same family as `nextval.elf`, +already used by `MinUI.pak/launch.sh`): + +```sh +MSG=$(i18n.elf get mypak.title -f "$(dirname "$0")/res/lang/$(i18n.elf lang).lang") +``` + +- `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. + +`i18n.elf` is a one-shot process, so `-f` costs a file parse per call — +batch your lookups rather than shelling out per string. + +## Interpolated strings (`_fmt` keys) + +`.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)`. + +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") +``` + +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 + +``` +Tools//MyPak.pak/ + launch.sh + res/lang/ + en.lang # required + fr.lang # optional +``` + +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'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; +- 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 + +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 +``` + +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. +- 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 + +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. + +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 +COMMUNITY="$SDCARD_PATH/.userdata/shared/i18n-community/TargetPak.pak/$(i18n.elf lang).lang" +MSG=$(i18n.elf get target.title -f "$LANG_FILE" -f "$COMMUNITY") +``` + +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" +``` + +```c +#include "i18n.h" +#include "config.h" + +int main(int argc, char *argv[]) { + CFG_init(NULL, NULL); + 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); + + puts(T("mypak.title")); + return 0; +} +``` 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/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 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