FireMidge.Libraries.I18n 1.0.0-beta

This is a prerelease version of FireMidge.Libraries.I18n.
dotnet add package FireMidge.Libraries.I18n --version 1.0.0-beta
NuGet\Install-Package FireMidge.Libraries.I18n -Version 1.0.0-beta
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="FireMidge.Libraries.I18n" Version="1.0.0-beta" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add FireMidge.Libraries.I18n --version 1.0.0-beta
#r "nuget: FireMidge.Libraries.I18n, 1.0.0-beta"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install FireMidge.Libraries.I18n as a Cake Addin
#addin nuget:?package=FireMidge.Libraries.I18n&version=1.0.0-beta&prerelease

// Install FireMidge.Libraries.I18n as a Cake Tool
#tool nuget:?package=FireMidge.Libraries.I18n&version=1.0.0-beta&prerelease

Internationalisation

Translations

Typically, translations are added via Resource files in C#.NET. However, they have some limitations, e.g. you would add one file per controller, instead of having one file per theme. This means duplicated translations, and no easy way of managing these duplicates. They are also stored in XML rather than YAML or another easy-to-read format. For this solution, we're using a slightly more customised system by using Apache Gettext with PO files, for which you can download a free editor from https://poedit.net/download for any OS. POEdit stores an internal database of translations so you can easily see what you have translated before, and will suggest the same translation again, avoiding duplicated effort. (Supposedly, it can also inherit translations from parent locales.)

We're using the NGettext library for the Gettext functionality. You can read more about Apache Gettext here: https://www.gnu.org/software/gettext/manual

How to edit translations

  1. Make sure you have Poedit installed.
  2. Find the correct folder for the locale and domain you're editing. E.g. for the locale "es-ES" and the domain "ErrorMessages", you will find the .po file in: I18n/es_ES/LC_MESSAGES/ErrorMessages.po. Open it with Poedit.
  3. Find the string you're looking for, edit, and save. That's it. Poedit will automatically update the .mo file on save and the new translations will be loaded (you may need to restart the service to take effect).

How to add a new translatable string

  1. Inject the ITranslator class into the class from which you need to translate a string.
  2. Call the correct method, depending on the domain. E.g. to translate an error message, call the ErrorMessage method, e.g: throw new BadHttpRequestException(_translator.ErrorMessage("Account with ID \"{0}\" not found", accountId));
  3. Use sequential placeholders for parameters in the string to be translated, ie {0} for the first parameter, {1} for the second, etc. Then pass the arguments in exactly that order.
  4. Now, open the corresponding .po file and navigate to TranslationUpdate from source code. If nothing happens, you may need to set it up first. Go to TranslationPropertiesSources Keywords. Add ErrorMessage to the list of keywords. The keyword needs to be the name of the method you're calling with the translatable string. So if your domain is "Titles", then you will have a method called Title in the Translator, and you need to add Title to the list of source keywords. Also make sure that your path (TranslationPropertiesSources Paths) is set to the root of the Service project. You can set it by dragging the folder from your browser into Poedit. Once you've updated the settings (if you had to), try Update from source code again.
  5. You should see the new string and can set a translation, then Save. If you can't see it, double check your Preferences and the source path.

How to create a new translation domain

  1. Create a new file in each supported locale with the name of the new domain, e.g. "Titles". If the supported domains are "es-ES", "es-MX" and "de-AT", then you need to create the files I18n/es_ES/LC_MESSAGES/Titles.po, I18n/es_MX/LC_MESSAGES/Titles.po and I18n/de_AT/LC_MESSAGES/Titles.po.
  2. Add a new method in Translator as well as in ITranslator, named after the new domain, which will return strings from the new translation domain.
  3. Open appsettings.json, and add the new translation domain to the array under I18n.TranslationDomains.
  4. Open Poedit and add a new source path (TranslationPropertiesSources Paths), named after the name of your new method (ie the new domain).
  5. Use the new method, then in Poedit, go to TranslationUpdate from source code to pull in the new strings and translate them, then Save. Done.

How to use this library

Add translation files

  1. In your Presentation layer (typically a project ending in .Service), add a folder for your translation files, e.g. I18n.
  2. Create a sub-folder for each locale you are supporting.
  3. Inside each locale folder, create another folder called LC_MESSAGES - the name of this is hardcoded into the NGettext library that this library utilises.
  4. Inside the LC_MESSAGES folder, create one .po file for each translation domain, e.g. ErrorMessages.po. It's important that the name matches the name of the translation domain exactly.

Configure POEdit

  1. Open the .po file with Poedit. In TranslationProperties, set the correct language, depending on which file you're editing.
  2. Under Sources Paths, make sure the base path is the root folder of your presentation layer project, not the I18n folder, as it is by default.
  3. Under Sources Keywords, create one entry per translation domain you are planning on using, e.g. for a domain of ErrorMessages, add an entry named ErrorMessage (note the singular form in the source path).
  4. As soon as you are actually using a string in your project, you can then go to TranslationUpdate from source code, and it should populate with all your used strings. If they don't show up, double-check that you are using the correct source path and the right source keyword(s).

Set up Startup

In Startup.cs, add the following lines (adapting them as necessary):

services.AddTranslationServices(
    defaultCulture,
    Configuration.GetSection("I18n:SupportedLocales"),
    Configuration.GetSection("I18n:TranslationDomains")
);

You can create defaultCulture like so: new CultureInfo("en-GB");. I would recommend to get the default culture either from appsettings.json or from an .env file.

SupportedLocales is an array of other supported locales. These should match the folders you created. TranslationDomains is an array of strings with your translation domains.

If you set these up in appsettings.json, they may look like this:

{
  "I18n": {
    "SupportedLocales": [
      "en-GB",
      "en",
      "de-AT",
      "de",
      "es-ES",
      "es-MX",
      "es"
    ],
    "TranslationDomains": [
      "ErrorMessages",
      "Titles"
    ]
  }
}

Still in Startup.cs, in the Configure method, add these lines (no need to adjust them):

app.UseRequestLocalization(
    app.ApplicationServices.GetRequiredService<IOptions<RequestLocalizationOptions>>().Value
);

app.UseMiddleware<LocalisationHandler>();

The first utilises MS's request localisation middleware, which extracts the locale(s) from the Accept-Language HTTP header, and cross-sections them against the supported locales you set up earlier. If there is an overlap, it will take the exact, or closest one it found and make it available via context.Features.Get<IRequestCultureFeature>()?.RequestCulture in the next middleware (set up with the 2nd line) - the LocalisationHandler.

The LocalisationHandler takes the locale that had been extracted by the RequestLocalisation middleware, and sends it to the scoped TranslationAdapter implementation, which in turn loads the relevant translation files.

This allows all other instances in the code, which accept the ITranslationAdapter implementation, to get strings translated into the requested locale, without having to have access to the locale itself.

Creating an ITranslator

If you were to use the ITranslationAdapter implementation directly, you need to pass the relevant translation domain as a string every time you need a string translated. To be more explicit, it is highly recommended to create an ITranslator interface with one method per translation domain. If your translation domains are ErrorMessages, SuccessMessages and Titles, you would create these three methods (note the singular in the method names):

public interface ITranslator
{
    public string ErrorMessage(string text, params object[] args);

    public string SuccessMessage(string text, params object[] args);

    public string Title(string text, params object[] args);
}

Your concrete implementation would then take the ITranslationAdapter through the constructor, and forward the calls for each translation domain by hardcoding the names there (or maybe you find another way) - this way, they are in only this one place, and you will have a relatively easy time renaming translation domains.

Remember the POEdit set-up for the source keywords? This refers to the methods that you call with the translation strings. So if you were to use the ITranslationAdapter directly, the only source keyword you would need is GetString, which is a standard one in Apache Gettext, which means you wouldn't have to set up any source keywords in POEdit.

Using the ITranslator

To then actually translate a string, simply inject the ITranslator that you created (remember to tie up the interface with the concrete implementation) and pass a string! To provide parameters, simply add them to the same method call. To create placeholders in the strings for the variables, use {0}, {1}, etc, e.g.:

throw new BadHttpRequestException(_translator.ErrorMessage("Account with ID \"{0}\" not found", accountId));

If, in another language, the parameter order changes, just use e.g. {1} first and {0} second. The number refers to the position of the argument you're passing to the method call.

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
1.0.0-beta 149 4/13/2022