Localisation scaffold
Localisation scaffold (issue #90)
Section titled “Localisation scaffold (issue #90)”English only today; this is the plumbing so strings can be translated later and so the number of hardcoded strings can only go down.
Format: gettext .po, not CSV
Section titled “Format: gettext .po, not CSV”ui/l10n/en.po is the source table (msgid = key, msgstr = English). Chosen over CSV because a .po is
read directly by ResourceLoader (no .import sidecar and no generated .translation files for the headless
gate, the tvOS symlinked tree or the export to chase), it diffs per entry, and it supports msgctxt and plurals
that vendor tooling expects. It lives under ui/ so platform/tvos/ already sees it through its ui symlink.
It is registered in [internationalization] locale/translations of BOTH project.godot and
platform/tvos/project.godot (comments in the latter must use ;). Fallback locale stays Godot’s default en.
A new language is a new ui/l10n/<locale>.po plus one more entry in that setting in both files.
Adding a string
Section titled “Adding a string”- Add
msgid "area.name"/msgstr "Text"toui/l10n/en.po. Keys are lowercasearea.name(file or panel first). Decoy drone text says “Decoy”, never “Sentinel”. - In code:
label.text = L10n.t("area.name")(ui/l10n.gd). Unknown keys render as the key itself. tests/test_l10n.gdfails if anyL10n.t("...")inmain.gd/ui/is missing fromen.po.
Worked example: ui/tutorial_skip_chip.gd (label_text), with a test that the rendered text is unchanged.
Dynamic text ("%d / %d"): key the format string, then L10n.t("k") % [a, b].
The string ratchet
Section titled “The string ratchet”tests/test_ui_string_ratchet.gd scans main.gd + ui/**/*.gd with UiStringScan
(tools/l10n/ui_string_scan.gd) and compares per-file counts to tests/ui_string_allowlist.json. Counts may
only go down: over = new hardcoded string; under = rerun
godot --headless --path . -s res://tools/l10n/write_string_baseline.gd (it refuses to raise a count).
Rule: a non-comment line counts once if any of these hit (and the literal still has a letter after removing
%d-style specs, {placeholders} and \n/\t):
- it assigns a literal to
text,text +=,tooltip_text,placeholder_text,title, or callsset_text("..."); - it passes a literal as the first argument of a display helper (
UiStringScan.HELPERS:_make_btn,_button,_hint_label,_lbl,_label,set_status,_toast,_row_btn, …). Add a helper name when you add a helper; - it holds a sentence-case multi-word literal (
"Quit to menu").
Skipped: comment lines and trailing comments, lines starting print/push_error/push_warning/assert/func,
lines calling load/preload/get_node/connect/add_to_group/… (paths, signal and group names), and any line
with # l10n-ok: <reason> (debug-only text). The Dev-only debug_settings_panel.gd is still counted.
Allowlist upkeep: the writer refuses to raise the TOTAL (a file rename keeps the total, so it needs no flag); the test fails on stale entries (an allowlist file that no longer exists) and on any per-file rise or drop.
Blind spots (the count under-reports or over-reports): text built from non-literals; single-word or
lowercase/ALL-CAPS literals passed to unlisted helpers ("SHOP", "Retry"); text in dictionaries/arrays
that is not sentence-case; multi-line string arrays only count if a line has a sentence-case literal;
.tscn/.tres text; lines skipped by the connect(/load( filter that also assign text; and false positives
where a sentence-case literal is not displayed (a log tag, a dict key such as "Max HP" for the console).
Use # l10n-ok: for the latter. Converting these is still desirable; they just are not fully gated.
Keys are plain String literals, not &"...": tests/test_token_keys_exist.gd treats every dotted &"a.b"
literal in ui/ as a UI-token key and would fail on them.