Localization
GenHub localizes application UI text with .NET .resx resources. English is the neutral language embedded in GenHub.dll; translated resources are compiled into culture-specific satellite assemblies.
The foundation is intentionally small:
ILocalizationServiceresolves and formats strings, exposes available cultures, and changes the active culture.LocalizationServiceuses .NETResourceManagerfallback and raisesINotifyPropertyChangednotifications when the culture changes.LocalizeExtensiongives Avalonia views a live binding to a resource key.LocalizationModuleregisters one shared service for every platform host.
Language selection, persisted preference, UI string migration, translation coverage, and right-to-left layout are separate feature concerns built on this foundation.
Resource layout
The neutral English resource is:
GenHub/GenHub/Resources/Localization/Strings.resxAdd translations beside it using a valid culture name:
Strings.fr.resx
Strings.de.resx
Strings.ar.resx
Strings.pt-BR.resxAt build time, .NET creates a satellite assembly under the matching culture directory. GenHub discovers those directories at startup, so there is no manually maintained supported-language list.
If a translated resource omits a key, ResourceManager follows the normal culture hierarchy and ultimately uses the value from Strings.resx. If the key does not exist in any resource, GenHub logs the miss and displays the key so the problem remains visible.
Resource keys
Use dot-separated keys that identify the feature and UI purpose:
Settings.Appearance.Title
Settings.Appearance.Language.Label
GameProfiles.Create.Confirm
Downloads.Status.QueuedKeep keys stable after release. Add translator comments when context or placeholders are not obvious. Every translation of a formatted string must preserve the same numbered placeholders as the English resource.
Do not localize log templates, protocol values, manifest identifiers, command-line arguments, or other developer-facing technical strings.
Avalonia views
Reference the markup namespace clr-namespace:GenHub.Common.Markup and bind the property to a key:
<UserControl xmlns:localization="clr-namespace:GenHub.Common.Markup">
<TextBlock Text="{localization:Localize Settings.Appearance.Title}" />
</UserControl>The extension binds through the application-scoped localization service (LocalizeExtension). When SetCulture changes the active culture, all localized indexer bindings are notified and refresh immediately without recreating the view or restarting GenHub.
For tool names or dynamically loaded plugin titles where the model is ToolMetadata or IToolPlugin, use LocalizedToolNameConverter:
<TextBlock Text="{Binding, Converter={StaticResource LocalizedToolNameConverter}}" />View models and services
Inject ILocalizationService when text must be produced in code:
var title = localizationService.GetString("GameProfiles.Create.Title");
var status = localizationService.GetString("Downloads.Status.Progress", completed, total);Only request cultures returned by AvailableCultures, and check the operation result:
var result = localizationService.SetCulture(selectedCulture);
if (result.Failed)
{
// Surface result.FirstError through the caller's normal error path.
}Culture switching is synchronous because it performs no long-running I/O. Do not wrap it in Task.Run, block on a task, or introduce a reactive package solely for change notification.
The selected language is applied to CurrentUICulture and DefaultThreadCurrentUICulture for resource lookup. It does not replace CurrentCulture, so changing the UI language cannot silently alter unrelated regional number, date, parsing, or serialization behavior. Format arguments passed to GetString use the selected localization culture.
Adding coverage
Every localization change should test the behavior it introduces. At minimum:
- a translated key resolves from the requested satellite assembly;
- an omitted translated key falls back to English;
- placeholders format correctly in the active culture;
- an unavailable culture fails without changing the current culture;
- a successful culture change refreshes live bindings;
- an invalid satellite assembly is ignored without aborting discovery of other languages.
Run tests before pushing localized changes:
dotnet build
dotnet testMerging concurrent changes
Every feature branch touches .resx localization files, so plain line-based merging conflicts constantly, and git's built-in merge=union is unsafe here: it aligns insertions at shared anchor lines and can silently drop a </data> closing tag while reporting success. The repository instead merges whole <data name="..."> blocks by resource key (scripts/git_merge_resx.py, selected by the merge=resx attribute in .gitattributes): independent additions from both sides are both kept, deletions are honored, and only a genuine same-key disagreement stops the merge. Nobody needs to configure or invoke anything for pull requests; the automation described below runs the driver server-side. (Local git merge or git rebase operations on developer machines will still use standard line-based merging unless the custom driver is configured locally.)
To configure the driver locally, run this once per clone:
git config merge.resx.driver "python3 scripts/git_merge_resx.py %O %A %B"On Windows, use python instead of python3:
git config merge.resx.driver "python scripts/git_merge_resx.py %O %A %B"CI validates .resx localization files on each run (scripts/validate_resx.py): well-formed XML with flat <data> blocks, no duplicate keys, exact key-set parity across cultures, and preserved {0}-style placeholders across each resource group (run locally with python scripts/validate_resx.py on Windows or python3 scripts/validate_resx.py on Linux/macOS).
Automatic merges
GitHub never runs custom merge drivers, so the driver above cannot resolve pull request conflicts server-side: without further help, one merged localization pull request would leave every other open one showing conflicts for a human to rebase. The Resx Auto Merge workflow (.github/workflows/resx-auto-merge.yml) closes that gap. It runs scripts/auto_merge_development.py, which merges development into each open pull request with the driver configured and pushes only fully clean results. Anything needing judgment is left for the author: history is never rewritten (merge commits only, never force-pushes), and draft pull requests, forks, and pull requests labeled no-automerge are skipped.
The workflow stays cheap with two gates. The job itself runs only when the push touched .resx files or the merge tooling, and the script then compares each pull request in memory first: pull requests GitHub already shows as mergeable are never touched, pull requests conflicting outside managed resx files are skipped for their author, and only resx-only conflicts reach a real merge. Trigger it by hand from the Actions tab (workflow_dispatch) if ever needed outside a push to development.
NOTE
Pushes to .github/workflows/* require an AUTO_MERGE_PAT secret with contents: write and workflows: write permissions. When that secret is absent, PRs touching workflow files will fail to merge with a clear log message and will be skipped cleanly.
Language Selection & Persistence
User language preference is saved in UserSettings and persisted automatically by IUserSettingsService:
- Model Property:
UserSettings.Language(string culture identifier, default:"en"defined inLocalizationConstants.DefaultCultureName). - ViewModel Integration:
SettingsViewModelexposesAvailableLanguages(IReadOnlyList<LanguageOption>) andSelectedLanguage(LanguageOption?).LanguageOptionencapsulates(CultureInfo Culture, string DisplayName).- When
SelectedLanguagechanges,SettingsViewModelcalls_localizationService.SetCulture(selectedOption.Culture). - On
SaveSettingsCommand, the selected culture'sNameis written toUserSettings.Languageand persisted to disk. - When resetting to defaults,
SelectedLanguageresets to the default English culture (en). SettingsViewModellistens to_localizationService.PropertyChangedonCurrentCultureto keep its dropdown synchronized if culture is changed elsewhere.
Application Startup Flow
During application startup in App.axaml.cs:
App.Initialize()retrieves the persistedUserSettings.Language.- If configured and valid,
_localizationService.SetCulture(...)is invoked before Avalonia XAML loading begins. Resources[LocalizationConstants.ResourceServiceKey]is populated with_localizationService.AvaloniaXamlLoader.Load(this)executes, resolving all{localization:Localize ...}markup bindings directly with the restored culture.
Guidelines for Future Agents & Contributors
When adding new features, views, dialogs, or modifying existing ones, strictly follow these requirements:
- Extract every user-facing string to
Strings.resx:- Neutral English is maintained in
GenHub/GenHub/Resources/Localization/Strings.resx. - Use hierarchical, semantic dot-separated keys:
- Views:
<View>.<Section>.<Element>, e.g.,Settings.Appearance.Language.Label - Common/Shared:
Common.<Action>, e.g.,Common.Save,Common.Cancel - Tooltips:
<View>.<Control>.ToolTiporTooltips.<Control> - Errors/Validation:
<Feature>.Error.<Reason>orValidation.<Rule>
- Views:
- Neutral English is maintained in
- Never hardcode string literals:
- In XAML views: Bind with
{localization:Localize Key}. - In C# ViewModels / Services: Call
_localizationService.GetString("Key").
- In XAML views: Bind with
- Preserve Case-Insensitive Key Uniqueness:
- MSBuild resource generators on Windows treat resource keys case-insensitively. Do not define keys that differ only in casing (e.g., avoid having both
Foo.BarandFoo.bar).
- MSBuild resource generators on Windows treat resource keys case-insensitively. Do not define keys that differ only in casing (e.g., avoid having both
- Parameter Placeholders:
- Format strings with parameters must use standard indexed placeholders
{0},{1}, etc. - Example:
<value>Downloaded {0} of {1}</value> - C# call:
_localizationService.GetString("Downloads.Status.Progress", completed, total)
- Format strings with parameters must use standard indexed placeholders
- Strict 1:1 Resx Key Parity Across All Languages:
- Every key present in
Strings.resxmust also exist in all satellite.resxfiles (Strings.ar.resx,Strings.ru.resx, etc.). - When adding, updating, or removing keys in
Strings.resx, always apply the corresponding changes to all satellite files simultaneously with appropriate translations. - Never leave translated resources missing keys from the neutral file.
- Every key present in
- Dynamic Collections & Navigation Sidebars:
- When navigation items or sidebars (such as
SettingsViewModel.Sections) are generated from collections with localized titles, subscribe to_localizationService.PropertyChanged(checking forCurrentCultureor indexer changes) to update item titles dynamically upon culture switches without requiring an application restart.
- When navigation items or sidebars (such as
- Tool & Plugin Localization:
- For dynamically loaded tools/plugins where models implement
ToolMetadataorIToolPlugin, useLocalizedToolNameConverter({Binding, Converter={StaticResource LocalizedToolNameConverter}}) in XAML. The converter checks forTools.Plugin.<ToolId>.Namein resources, falling back to the plugin's metadata title.
- For dynamically loaded tools/plugins where models implement
- Adding New Language Translations (Future PRs):
- Create satellite resource files matching the culture name beside
Strings.resx:Strings.de.resx(German)Strings.fr.resx(French)Strings.zh-Hans.resx(Simplified Chinese)Strings.es.resx(Spanish)
- Ensure all keys from
Strings.resxare copied and translated with strict 1:1 key parity. - At runtime,
LocalizationServicediscovers satellite assemblies automatically and populatesAvailableCultures, which will appear in the Settings Language selector without manual list edits.
- Create satellite resource files matching the culture name beside
