Localization
Most web applications have to support a variety of languages. Sites like Youtube, and Wikipedia work in many languages.
The first time a user visits such a site, a language for her is automatically chosen depending on the browser language setting which is usually determined by the OS language and regional settings.
If the automatic selection does not result in what they expect, users can set their preferred language and this selection is stored in a client-side cookie or a user profile table.
Once a language is chosen, all texts are displayed in the selected language.
The Serenity platform is designed with localization in mind from the start.
You may see this yourself by setting your browser language or changing the language under the language dropdown of the Serenity demo (https://demo.serenity.is).
If you change to Turkish for example; and refresh your browser, you would see the demo in Turkish:
Here, the data is not translated but it is also possible to translate the user-entered data by some methods like culture extension tables.
Localization in .NET itself
.NET applications generally use string resource files (.resx) and satellite assemblies:
The localization system in .NET works fine for most applications. The main problem we see with satellite assemblies is that they require a compilation step and it is not very suitable for translation by third parties and adding new languages or editing translations after the application itself is deployed.
Localization in Serenity
Serenity uses a custom localization system to get the potential advantages below:
- Add/remove the available languages from the application itself
- Edit the localizations at run time, so that it can be done by third parties like a translator
- Store/distribute translations as JSON files
- Store translations in another medium such as a database if desired
ITextLocalizer Interface
Localization starts with the ITextLocalizer interface:
public interface ITextLocalizer
{
string TryGet(string key);
}
This is a very simple interface that only has a TryGet
method which provides the ability to retrieve a translation for the current UI language (CultureInfo.CurrentUICulture
) by its text key
.
Local Text Keys
Serenity uses local text keys
instead of ordinary untranslated texts, as the same string might have different translations depending on the context.
For example, the word Face
might have different meanings and different translations depending on the context, like a person's face, or the front surface of an object.
The text keys are generally defined in the following form:
Enums.Month.January
Enums.Month.December
Db.Northwind.Customer.CustomerName
Dialogs.YesButton
This form also categorizes them based on their purpose/context. For example, the Enums.Month.
prefixed keys are the ones for values of an enumeration called Month
. The Db.Northwind.Customer.
prefixed keys are for the fields of a Customer
entity in the Northwind
module.
LocalText Class
The LocalText class stores a local text key
and provides the ability to retrieve its translation for the current UI language (CultureInfo.CurrentUICulture
) by using the ITextLocalizer
interface.
public class LocalText
{
public const string InvariantLanguageID = "";
public static readonly LocalText Empty;
public LocalText(string key);
public string Key { get; private set; }
public static implicit operator LocalText(string key);
public string ToString(ITextLocalizer localizer);
}
A LocalText
instance can be created via its constructor by passing a text key, or via implicit casting from any string.
There is also a static LocalText
instance called Empty
which is similar to the String.Empty
.
Language Identifiers and The Invariant Language
A language ID is a code that assigns letters and/or numbers as identifiers or classifiers for languages.
Language IDs follow the RFC 1766 standard in the format <languagecode2>-<country/regioncode2>
, where languagecode2 is a lowercase two-letter code derived from ISO 639-1 and country/regioncode2 is an uppercase two-letter code derived from ISO 3166.
Some sample language IDs are:
en
: Englishen-US
: English as used in the United States (US is the ISO 3166-1 country code)en-GB
: English as used in the United Kingdom (GB is the ISO 3166-1 country code)es
: Spanishes-AR
: Spanish as used in Argentina
Similar to CultureInfo.InvariantCulture
, the invariant language (LocalText.InvariantLanguageID
) is the default language with an empty identifier.
Unless specified otherwise, the local texts in your assemblies, e.g. the ones defined in C# code are considered to be written in the invariant language. In other words, it is the language for the source texts to be translated into other languages.
Though it is usually considered to be English, you may assume your natural language as the invariant language.
ILocalTextRegistry Interface
As we mentioned before, the ITextLocalizer
interface is the one we use to access translations but where can it retrieve these translations from? That is the responsibility of the class which implements the ILocalTextRegistry interface:
public interface ILocalTextRegistry
{
string TryGet(string languageID, string key, bool pending);
void Add(string languageID, string key, string text);
}
This interface has the method TryGet
which returns a translation in a specific language for a text key.
It also has another method called Add
which is used to register translations.`
The default implementation for the ILocalTextRegistry
interface is the LocalTextRegistry class which stores these translations in memory using a set of dictionary objects.
The local text table is an in-memory table (dictionary) like:
Key | LanguageID | Text (Translation) |
---|---|---|
Dialogs.YesButton | en | Yes |
Dialogs.YesButton | tr | Evet |
Dialogs.NoButton | en | No |
Dialogs.NoButton | tr | Hayır |
Language Fallbacks
Both the ILocalTextRegistry.TryGet
and ITextLocalizer.TryGet
methods are supposed to return null
if a translation in the requested language and one of its fallback languages
are not available.
When a translation is not found in en-US
, it is acceptable to look for a translation in en
language, as they are closely related.
Two-letter language IDs (neutral languages) are implicitly language fallbacks of 4 letter country-specific codes. So the es
language is the language fallback of es-AR
and, en
is the language fallback of en-US
and en-GB
.
Invariant language is the final fallback of all languages implicitly.
Providers may also support setting language fallbacks explicitly, so you can set en-US
as the language fallback of en-UK
if needed.
This is how looking up a translation for a local text key works:
- If the current language has a translation for the key, return it.
- Check every explicitly defined language fallback for a translation.
- If language ID is a 4-letter country-specific code, check neutral language for a translation.
- Check invariant language for a translation.
- Return null otherwise
Let's say we set en-US
as the language fallback of en-UK
. If we search for a translation in en-UK
, it is looked up in this order:
- en-UK
- en-US
- en
- invariant
Initialization of the Local Text System
The default implementations of services required localization system is registered in Startup.cs
file via `CoreServiceCollections.AddTextRegistry method.
You may not find that in the Startup.cs
file, as the method is called internally by the AddServiceHandlers
:
services.AddServiceHandlers(); // this internally calls AddTextRegistry
There is also an InitializeLocalText
method in Startup.cs
file which adds local text keys and translations to the local text registry from a set of classes in your application and other sources like JSON files:
public static void InitializeLocalTexts(IServiceProvider services)
{
var env = services.GetRequiredService<IWebHostEnvironment>();
services.AddBaseTexts(env.WebRootFileProvider)
.AddJsonTexts(env.WebRootFileProvider, "Scripts/site/texts")
.AddJsonTexts(env.ContentRootFileProvider, "App_Data/texts");
}
Registering Translations Manually
You can add translations to the local text registry from the InitializeLocalTexts
method. Sources for these translations might be a database table, an XML file, embedded resources, etc.
public static void InitializeLocalTexts(IServiceProvider services)
{
var env = services.GetRequiredService<IWebHostEnvironment>();
services.AddBaseTexts(env.WebRootFileProvider)
// ..
var registry = services.GetRequiredService<ILocalTextRegistry>();
// load these texts from some source like a database table
registry.Add("es", "Dialogs.YesButton", "Sí");
registry.Add("fr", "Dialogs.YesButton", "Oui");
// ..
}
Please note that the order is important here, e.g. the texts added later will override the texts that may be added before them for the same text key and language ID pairs.
JSON Local Texts
Serenity supports local text registration through JSON files containing a simple key/value dictionary:
{
"Forms.Administration.User.DisplayName": "Display Name",
"Forms.Administration.User.Email": "E-mail",
"Forms.Administration.User.EntitySingular": "User",
"Forms.Administration.User.EntityPlural": "Users"
}
To register all local text keys and translations from JSON files in a folder, call AddJsonTexts with the file provider and the folder path:
services.AddJsonTexts(env.ContentRootFileProvider, "my/folder/with/localtext/jsonfiles")
File names in the folder must follow a convention:
{Some Prefix You Choose}.{LanguageID}.json
where {LanguageID}
is two or four-letter language code. Use invariant as language code for invariant language.
Some sample file names are:
site.texts.en-US.json
MyCoolTexts.es.json
user.texts.invariant.json
Files in a folder are parsed and added to the registry in their file name order. Thus, for the sample file names above, the order would be:
MyCoolTexts.es.json
site.texts.en-US.json
user.texts.invariant.json
This order is important as adding a translation in some language with same key overrides prior translation.
Default Folders Containing JSON Local Texts
As you might have noticed, the InitializeLocalTexts
method calls AddJsonTexts
for two predetermined folders:
.AddJsonTexts(env.WebRootFileProvider, "Scripts/site/texts")
.AddJsonTexts(env.ContentRootFileProvider, "App_Data/texts");
The first one is a folder that contains your application-specific translations.
The one under App_Data/texts
is the folder where the user translations made through Administration/Translations
page is saved by default. It is recommended to transfer texts from these files to application translation files under ~/Scripts/site/texts
before publishing.
Nested Local Texts
Serenity allows you to define nested static classes containing LocalText objects to register translations:
[NestedLocalTexts]
public static partial class Texts
{
public static class Site
{
public static class Dashboard
{
public static LocalText WelcomeMessage = "Welcome to Serenity";
}
}
public static class Validation
{
public static LocalText DeleteForeignKeyError =
"Can't delete record. '{0}' table has records that depends on this one!";
public static LocalText SavePrimaryKeyError =
"Can't save record. There is another record with the same {1} value!";
}
}
These definitions allow you to reference localized texts with intelli-sense support, without having to memorize the string keys.
These embedded translation definitions are commonly used to define default translations in the invariant language.
Here is a table of translations that are defined with this Texts class:
Key | LanguageID | Text (Translation) |
---|---|---|
Site.Dashboard.WelcomeMessage | Welcome to Serenity | |
Validation.DeleteForeignKeyError | Can't delete record... | |
Validation.SavePrimaryKeyError | Can't save record... |
Local text keys are generated from nested static class names with a dot inserted in between. The topmost static class (Texts) name is ignored though it is a good idea to name it something like Texts for consistency.
Unless otherwise stated, the language ID for these texts is considered to be the invariant language (empty string).
The topmost class (e.g. Texts here) for nested local text registration classes must have the NestedLocalTextsAttribute attribute.
public sealed class NestedLocalTextsAttribute : Attribute
{
public string LanguageID { get; set; }
public string Prefix { get; set; }
}
It has two optional properties named LanguageID
and Prefix
.
LanguageID
allows you to define the language translations are in.
It is a good idea to register default texts in the invariant language (even if texts are not in English), as it is the eventual language fallback for all languages.
If we used it like:
[NestedLocalTexts(LanguageID = "en-US")]
public static partial class Texts
{
}
The LanguageID
column in the translations table would be "en-US":
Key | LanguageID | Text (Translation) |
---|---|---|
Site.Dashboard.WelcomeMessage | en-US | Welcome to Serenity |
Validation.DeleteForeignKeyError | en-US | Can't delete record... |
The Prefix
property value is used as a prefix for local text keys:
[NestedLocalTexts(LanguageID = "en-US", Prefix = "APrefix.")]
public static partial class Texts
{
// ..
}
Key | LanguageID | Text (Translation) |
---|---|---|
APrefix.Site.Dashboard.WelcomeMessage | en-US | Welcome to Serenity |
APrefix.Validation.DeleteForeignKeyError | en-US | Can't delete record... |
NestedLocalTexts are automatically registered in the Startup.cs
file, via the AddBaseTexts
call in the InitializeLocalTexts
method.
Row Texts
Any DisplayName
attribute defined for a property of an entity class (Row
) automatically registers a local text key and its translation:
namespace Serene.Administration
{
[Module("Administration")]
[DisplayName("Users"), InstanceName("User")]
public sealed class UserRow
{
[DisplayName("User Id"), Identity, IdProperty]
public int? UserId { ... }
[DisplayName("Username"), Size(100), NotNull, QuickSearch, LookupInclude, NameProperty]
public string Username { ... }
[DisplayName("Source"), Size(4), NotNull, Insertable(false), Updatable(false), DefaultValue("site")]
public string Source
{
get => fields.Source[this];
set => fields.Source[this] = value;
}
//...
}
}
In the example UserRow
above, the following local texts are registered at startup:
Key | LanguageID | Text (Translation) |
---|---|---|
Db.Administration.UserId | User Id | |
Db.Administration.Username | Username | |
Db.Administration.Source | Source | |
Db.Administration.EntityPlural | Users | |
Db.Administration.EntitySingular | User |
The text is read from the DisplayName
attribute and the key is derived from the local text prefix
for the entity (which is "Db.Administration."
in this sample) and the property name.
EntityPlural
and EntitySingular
are special keys that correspond to the title (e.g. Users) and instance name for the entity class. Their values are read from DisplayName
and InstanceName
attributes on the row class.
Every entity has a local text prefix which is calculated from its module name which is "Administration"
in this sample, that is set via the Module
attribute. If no Module
attribute is present, the module name is calculated from the namespace.
The local text prefix can also be set by placing a LocalTextPrefixAttribute on the row class.
Another way is to set the LocalTextPrefix
property in the RowFields
class constructor but is not recommended.
Row texts are also automatically registered in the Startup.cs
file, via the AddBaseTexts
call in the InitializeLocalTexts
method.
Enumeration Texts
Display text for enumeration values can be specified with the DescriptionAttribute
.
namespace MyApplication
{
public enum Sample
{
[Description("First Value")]
Value1 = 1,
[Description("Second Value")]
Value2 = 2
}
}
This enumeration and its Description attributes define the following local text keys and translations:
Key | LanguageID | Text (Translation) |
---|---|---|
Enums.MyApplication.Sample.Value1 | First Value | |
Enums.MyApplication.Sample.Value2 | Second Value |
You can use these keys to access translated descriptions for enumeration values, or use the extension method GetText() defined for enumeration types (import namespace Serenity to make this extension method available).
Console.WriteLine(MyApplication.Sample.Value1.GetText(localizer));
...which returns
> First Value
Enum texts are also automatically registered in the Startup.cs
file, via the AddBaseTexts
call in the InitializeLocalTexts
method.
EnumKey Attribute
Enumeration translations use the full name of the enumeration type as the prefix to generate local text keys. This prefix can be overridden with EnumKeyAttribute:
namespace MyApplication
{
[EnumKey("Something")]
public enum Sample
{
[Description("First Value")]
Value1 = 1,
[Description("Second Value")]
Value2 = 2
}
}
Now defined keys and translations are:
Key | LanguageID | Text (Translation) |
---|---|---|
Enums.Something.Value1 | First Value | |
Enums.Something.Value2 | Second Value |
Please note that overriding the enum key is NOT recommended, as it is known to cause problems while using modular TypeScript.