Localization¶
Cargonerds uses ABP's resource-based localization. Display strings live in per-culture JSON files that are compiled into the assembly as embedded resources, served at runtime through the ABP Virtual File System, and resolved through a typed localization resource marker class. Translations for the non-English cultures are produced automatically by the ResourceTranslator.CLI dotnet tool, wired into an MSBuild target that runs on Debug builds.
This page covers the server-side (ABP) localization used by the Blazor app, AuthServer and the
public web. The separate Next.js realtime frontend has its own next-intl stack — see
Realtime (Next.js) Frontend.
Concept: how ABP localization works¶
ABP localization is built around three pieces:
- A resource — a marker class decorated with
[LocalizationResourceName]that gives a logical name to a bundle of strings (e.g.Cargonerds). Code asks for strings throughIStringLocalizer<TResource>. - One JSON file per culture (
en.json,de-DE.json, …) under a folder, each containing aculturefield and aTextsdictionary of key → value. - A registration in
AbpLocalizationOptionsthat ties the resource to its default culture, its JSON folder (via the virtual file system) and any base types it inherits keys from.
Resources can inherit from other resources (AddBaseTypes). When a key is not found in a resource,
ABP falls through to its base types — this is how the app reuses ABP framework strings (validation,
UI) and how it overrides another module's strings.
The resources in this solution¶
The host app owns CargonerdsResource; the feature modules own their own resources. Each is a tiny
marker class plus a folder of JSON.
| Resource | Marker class | JSON folder | Registered in |
|---|---|---|---|
Cargonerds |
src/Cargonerds.Domain.Shared/Localization/CargonerdsResource.cs |
src/Cargonerds.Domain.Shared/Localization/Cargonerds/ |
CargonerdsDomainSharedModule |
Hub |
modules/hub/src/Hub.Domain.Shared/Localization/HubResource.cs |
modules/hub/src/Hub.Domain.Shared/Localization/Hub/ |
HubDomainSharedModule |
CodeEntities |
.../Hub.Domain.Shared/Localization/CodeEntitiesResource.cs |
.../Localization/CodeEntities/ |
HubDomainSharedModule |
Realtime |
.../Hub.Domain.Shared/Localization/RealtimeResource.cs |
.../Localization/Realtime/ |
HubDomainSharedModule |
Pricing |
modules/pricing/src/Pricing.Domain.Shared/Localization/PricingResource.cs |
.../Pricing.Domain.Shared/Localization/Pricing/ |
PricingDomainSharedModule |
The marker class is intentionally empty:
using Volo.Abp.Localization;
namespace Cargonerds.Localization;
[LocalizationResourceName("Cargonerds")]
public class CargonerdsResource { }
Note
Resources are always declared in the *.Domain.Shared layer because every other layer
(Domain, Application, HttpApi, Blazor) references it, so all of them can take a dependency on
IStringLocalizer<CargonerdsResource>. See Layered Architecture.
Resource registration (CargonerdsResource)¶
The primary resource is registered in
src/Cargonerds.Domain.Shared/CargonerdsDomainSharedModule.cs. Three things happen there: the
embedded JSON file set is registered with the VFS, the resource is added to
AbpLocalizationOptions, and the selectable languages are declared.
Configure<AbpVirtualFileSystemOptions>(options =>
{
options.FileSets.AddEmbedded<CargonerdsDomainSharedModule>();
});
Configure<AbpLocalizationOptions>(options =>
{
options
.Resources.Add<CargonerdsResource>("en") // default culture = en
.AddBaseTypes(typeof(AbpValidationResource)) // fall through to ABP validation texts
.AddVirtualJson("/Localization/Cargonerds"); // VFS folder of *.json
options.DefaultResourceType = typeof(CargonerdsResource);
options.Languages.Add(new LanguageInfo("en", "en", "English"));
options.Languages.Add(new LanguageInfo("de-DE", "de-DE", "Deutsch (Deutschland)"));
// ... 18 more
});
Key points:
- Default culture is
en.en.jsonis the source of truth — every key is authored there first, and the translation tool generates the rest from it. DefaultResourceTypemakesCargonerdsResourcethe fallback resource for the whole app, so an injectedIStringLocalizer(non-generic) and view localization resolve against it.AddVirtualJson("/Localization/Cargonerds")points at a folder in the virtual file system, not a physical path. The JSON files are embedded resources (see Virtual file system & embedded resources).AddBaseTypes(typeof(AbpValidationResource))lets validation messages resolve through the Cargonerds resource. Other layers add more base types (see next section).
Supported languages¶
The LanguageInfo entries registered in CargonerdsDomainSharedModule are the languages that
appear in the UI language switcher. There are 20:
| Culture | Display name | Culture | Display name | |
|---|---|---|---|---|
en |
English | it |
Italiano | |
en-GB |
English (United Kingdom) | cs |
Čeština | |
de-DE |
Deutsch (Deutschland) | hu |
Magyar | |
fr |
Français | ro-RO |
Română (România) | |
es |
Español | sv |
Svenska | |
pt-BR |
Português (Brasil) | fi |
Suomi | |
ru |
Русский | sk |
Slovenčina | |
tr |
Türkçe | is |
Íslenska | |
ar |
العربية | zh-Hans |
简体中文 | |
hi |
हिन्दी | zh-Hant |
繁體中文 |
JSON files exist for cultures that are not selectable
The Localization/Cargonerds folder ships 25 culture files — five more than are registered
as LanguageInfo: hr, nl, pl-PL, sl, vi. These are produced by the translation tool
(its TargetCultures list is wider than the registered languages) but are not added to
options.Languages, so they exist on disk but never appear in the language switcher. A culture
only becomes selectable once it is added to the Languages list above.
zh-Hans / zh-Hant are registered but not auto-translated
Simplified and Traditional Chinese are registered languages, but they are not in the
translator's TargetCultures, so the tool will not regenerate them. Their JSON must be
maintained by hand — when you add a key, remember to add the Chinese values manually.
Extending and overriding resources in other layers¶
CargonerdsResource is registered in Domain.Shared with only AbpValidationResource as a base
type. Later layers add more base types and override folders so that the right framework strings are
in scope for each host.
graph TD
AVR[AbpValidationResource]
AUR[AbpUiResource]
ACR[AccountResource]
AO["/Localization/AccountOverrides<br/>(VFS folder)"]
CR[CargonerdsResource]
AVR -->|base type, Domain.Shared| CR
AUR -->|base type, HttpApi + Blazor.Client + AuthServer| CR
ACR -->|base type, AuthServer only| CR
AO -.->|AddVirtualJson onto AccountResource| ACR
classDef abp fill:#eef,stroke:#88a;
class AVR,AUR,ACR abp;
| Layer / module | What it adds | File |
|---|---|---|
Cargonerds.HttpApi |
AddBaseTypes(AbpUiResource) on CargonerdsResource |
src/Cargonerds.HttpApi/CargonerdsHttpApiModule.cs |
Cargonerds.Blazor.Client |
AddBaseTypes(AbpUiResource) on CargonerdsResource |
src/Cargonerds.Blazor.Client/CargonerdsBlazorClientModule.cs (ConfigureLocalization) |
Cargonerds.AuthServer |
AddBaseTypes(AbpUiResource, AccountResource) and Account text overrides |
src/Cargonerds.AuthServer/CargonerdsAuthServerModule.cs |
Cargonerds.Web.Public |
DataAnnotations → CargonerdsResource |
src/Cargonerds.Web.Public/CargonerdsWebPublicModule.cs |
Account text overrides¶
The AuthServer overrides selected texts of ABP's Account module by layering a JSON folder onto the
existing AccountResource — a clean way to change a few framework strings without forking the
module:
Configure<AbpLocalizationOptions>(options =>
{
options.Resources.Get<CargonerdsResource>().AddBaseTypes(typeof(AbpUiResource), typeof(AccountResource));
options.Resources.Get<AccountResource>().AddVirtualJson("/Localization/AccountOverrides");
});
The override files live in src/Cargonerds.Domain.Shared/Localization/AccountOverrides/ and exist
for three cultures only — en, en-GB, de-DE:
{
"culture": "en",
"texts": {
"NotAMemberYet": "No account yet?",
"Register": "Sign up",
"CompleteRegistration": "Complete Registration"
}
}
Because these are merged onto AccountResource, the keys must match ABP's own Account keys; any key
present here wins over the one shipped by the Account module for that culture.
DataAnnotations (Web.Public)¶
The public web maps [Required]/[StringLength]/etc. validation messages to the resource via
AbpMvcDataAnnotationsLocalizationOptions:
context.Services.PreConfigure<AbpMvcDataAnnotationsLocalizationOptions>(options =>
{
options.AddAssemblyResource(
typeof(CargonerdsResource),
typeof(CargonerdsDomainSharedModule).Assembly,
typeof(CargonerdsApplicationContractsModule).Assembly,
/* ... */);
});
JSON file shape and key conventions¶
Every culture file has a culture field and a Texts dictionary:
{
"culture": "en",
"Texts": {
"Actions": "Actions",
"ActiveShipments": "Active Shipments",
"AddEntity": "Add {0}",
"AllowedRange": "Allowed range: {0}–{1}",
"ApiKeyDeletionConfirmationMessage": "Are you sure you want to delete the API key '{0}'?",
"AppName": "Cargonerds"
}
}
Conventions used across the codebase:
- Grouped keys use a
Group:Keyform, e.g.Booking:AddContainer,Menu:Dashboard,Permission:ApiKeys:Create,ContainerMode:9. The colons are just part of the key string. - Placeholders are positional (
{0},{1}) or named ({Name}); ABP fills them from the arguments you pass to the localizer. - Keys are kept alphabetically sorted — the translation tool re-sorts on every run
(
AutoSort: true), which keeps diffs small.
Casing inconsistency: Texts vs texts
The Cargonerds, Hub and Pricing files use "Texts" (capital T) while the AccountOverrides
files use "texts" (lowercase). ABP's JSON localizer tolerates both, but be aware of the
difference when copy-pasting between folders.
Adding a key¶
- Add the key/value to the source
en.jsonof the relevant resource folder (Localization/Cargonerds/en.jsonfor app strings, or Hub'sLocalization/Hub/en.json, etc.):
-
On the next Debug build, the
TranslateMSBuild target auto-generates the value into all target cultures. Review and commit the changed JSON files. (Rememberzh-Hans/zh-Hantare not auto-translated — fill them by hand.) -
Consume the key:
public class Foo(IStringLocalizer<CargonerdsResource> l)
{
public string Title => l["MyNewKey"];
public string Added => l["AddEntity", "Container"]; // -> "Add Container"
}
In ABP Blazor base components an L property is already provided, so existing components use
L["Booking:Submit"] directly.
Adding a language¶
- Add a JSON file named after the culture (e.g.
nl.json) undersrc/Cargonerds.Domain.Shared/Localization/Cargonerds/(one already exists for several "produced but unregistered" cultures — see the note above). - Register it in
CargonerdsDomainSharedModule.ConfigureServices:
- Add the culture to
TargetCulturesintranslation.options.jsonif it should be auto-translated, and add it to the other resource folders (Hub, Pricing) as needed. - Add Account-specific overrides under
Localization/AccountOverrides/only if you need to change Account texts for that culture.
Translation pipeline (ResourceTranslator.CLI)¶
Non-English files are generated by the ResourceTranslator.CLI global dotnet tool, which calls
Azure Cognitive Services Translator. Each *.Domain.Shared project carries an identical
translation.options.json:
{
"TextTranslationEndpoint": "https://api.cognitive.microsofttranslator.com/",
"ApiKey": "…",
"FileOutputFormat": "{Culture}.{Extension}",
"TargetCultures": "ar,cs,de-DE,en,en-GB,es,fi,fr,hi,hr,hu,is,it,pt-BR,nl,pl-PL,ro-RO,ru,sk,sv,sl,tr,vi",
"Region": "germanywestcentral",
"Encoding": "utf-8",
"AutoSort": true
}
The tool is invoked from a Translate MSBuild target in each *.Domain.Shared.csproj. It installs
(or updates) the global tool, then runs the translator once per resource folder, always against
that folder's en.json source:
<Target Name="Translate" AfterTargets="Build" Condition="'$(Configuration)' == 'Debug'">
<Exec IgnoreExitCode="true" Command="dotnet tool install --global ResourceTranslator.CLI" />
<Exec IgnoreExitCode="true" Command="dotnet tool update --global ResourceTranslator.CLI" />
<Exec IgnoreExitCode="true"
Command="resourceTranslator --optionsfile $(ProjectDir)translation.options.json -f $(ProjectDir)Localization\Cargonerds\en.json" />
</Target>
The Hub project (which owns three resources) has three resourceTranslator <Exec> lines — one for
Localization\Hub\en.json, Localization\CodeEntities\en.json and Localization\Realtime\en.json.
Tip
TargetCultures is deliberately wider than the registered LanguageInfo list, which is why
files like nl.json, pl-PL.json, hr.json, sl.json, vi.json exist but are not selectable.
To stop generating a culture, remove it from TargetCultures; to make a generated culture
selectable, add a LanguageInfo.
The translation target is fragile by design
- It runs only on Debug builds, so a Release/CI build never refreshes translations.
- Every Debug build runs
dotnet tool install/update --global— the first build needs network access. - Every
<Exec>usesIgnoreExitCode="true", so a translator or network failure silently leaves translations stale and never breaks the build. If a new key only shows up inen, suspect a failed translate step. ApiKeyis a plaintext Azure Translator key committed to the repo (in three copies). It should be rotated and moved to a secret store. See Configuration.
Virtual file system & embedded resources¶
The JSON files are compiled as embedded resources, not content files, so they ship inside the assembly and are served through the virtual file system rather than from disk:
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
<!-- ... -->
<EmbeddedResource Include="Localization\Cargonerds\*.json" />
<Content Remove="Localization\Cargonerds\*.json" />
<EmbeddedResource Include="Localization\AccountOverrides\*.json" />
<Content Remove="Localization\AccountOverrides\*.json" />
The module then registers its embedded file set so AddVirtualJson("/Localization/Cargonerds") can
resolve against it:
Configure<AbpVirtualFileSystemOptions>(options =>
options.FileSets.AddEmbedded<CargonerdsDomainSharedModule>());
Live editing in development¶
Because the files are embedded, changing a JSON value would normally require a rebuild. In development the MVC hosts swap the embedded set for the physical folder so edits are picked up without recompiling:
options.FileSets.ReplaceEmbeddedByPhysical<CargonerdsDomainSharedModule>(
Path.Combine(env.ContentRootPath, "..", "Cargonerds.Domain.Shared"));
CargonerdsWebPublicModule does the same. See the
Virtual file system docs
for the AddEmbedded / ReplaceEmbeddedByPhysical pattern.
Runtime culture selection¶
| Host | Mechanism |
|---|---|
Cargonerds.AuthServer (MVC) |
app.UseAbpRequestLocalization() in OnApplicationInitialization |
Cargonerds.Web.Public (MVC) |
app.UseAbpRequestLocalization() in OnApplicationInitialization |
Cargonerds.Blazor / .Client (WASM) |
LeptonX language menu + ABP LanguageManagement module; culture flows through the framework cookie |
UseAbpRequestLocalization() wires ABP's request-localization pipeline (culture from the
.AspNetCore.Culture cookie, the Accept-Language header, or a query string) from the registered
LanguageInfo list. The Blazor WebAssembly client has no UseAbpRequestLocalization call (it is a
client app); it depends on the ABP LanguageManagement module —
LanguageManagementDomainSharedModule in Domain.Shared and
LanguageManagementBlazorWebAssemblyModule in CargonerdsBlazorClientModule — and the LeptonX
language switcher, with the selected culture carried in the framework's culture cookie.
Exception (error-code) localization¶
Business exceptions throw with an error code like Cargonerds:ApiKeys:NameAlreadyExists. ABP maps
the namespace prefix of that code to a resource so the message can be localized. Each module wires
its own namespaces via AbpExceptionLocalizationOptions.MapCodeNamespace:
Configure<AbpExceptionLocalizationOptions>(options =>
{
options.MapCodeNamespace("Cargonerds", typeof(CargonerdsResource));
});
The Hub module maps the Hub and CodeEntities namespaces to HubResource /
CodeEntitiesResource; Pricing maps Pricing to PricingResource. The text for an error code is
just a normal localization key (the part after the namespace) in the mapped resource's JSON. See the
Error Codes Reference.
Gotchas¶
Neutral vs specific culture: de.json vs de-DE.json
The Cargonerds resource ships only de-DE.json (no neutral de.json), and de-DE is the
registered language — so it is clean. The Hub and Pricing resources, however, each ship
both de.json and de-DE.json. Only de-DE is registered and only de-DE is in
TargetCultures, so the neutral de.json files are stale leftovers that the translator no
longer regenerates, yet they can still resolve for a bare de request and may diverge from
de-DE. (Recent git history shows related neutral-culture bugs in CountryLookup — the same
de vs de-DE footgun.)
TargetCultures⊋ registered languages. The translator emitshr,nl,pl-PL,sl,vi, but they are not registered asLanguageInfo, so they are dead weight on disk. Converselyzh-Hans/zh-Hantare registered but not inTargetCultures, so their JSON must be hand-maintained.- Committed translator API key.
translation.options.json(three copies) carries a plaintext Azure Translator key — rotate it and move it to a secret. - Casing inconsistency.
Texts(Cargonerds/Hub/Pricing) vstexts(AccountOverrides). ABP tolerates both. - Translate is Debug-only, self-installing, and failure-silent. A missing key in non-English cultures usually means a failed (but ignored) translate step.
Related pages¶
- Theming & Branding — LeptonX theme, MudBlazor integration and the white-labeling
system (whose branding falls back to the localized
AppName). - Blazor Frontend — how the Blazor client consumes resources and the LanguageManagement module.
- Realtime (Next.js) Frontend — the independent
next-intllocalization of the Next.js app. - Error Codes Reference — exception-code → resource mapping.
- Hub module and Pricing module — the module-owned resources.
- Layered Architecture and ABP Patterns — why resources live in Domain.Shared.