ASP.NET Core Localization with JSON

July 12, 2020 5 minutes

The LORD said, “If as one people speaking the same language they have begun to do this, then nothing they plan to do will be impossible for them. Come, let us go down and confuse their language so they will not understand each other.”
- Genesis 11:6-7

The Internet is global.

It’s not uncommon nowadays for upcoming SaaS vendors to immediately offer their product to multiple markets around the world, each with a different language and expectations.

Having a monolingual application may not be enough this time.

This post covers how to start localizing our applications while still retaining our Feature Directories structure, regardless of whether we use areas or not.

Definitions

Globalization (g11n) is the process of building applications that support multiple cultures. Cultures in .NET [Core] are represented by the CultureInfo class and determine how dates and numbers are parsed (Culture), and what resource file is used for UI translations (UICulture).

Localization (l10n) goes a step further, as it also considers the idioms, slang, meanings of color, “pop culture”, etc. of the target audience when adapting a given work.

Internationalization (i18n) encompasses both previous concepts, but it’s often used synonymously with globalization.

More definitions: Globalization and localization terms

IStringLocalizer<T> and IViewLocalizer

It’s no surprise that the ASP.NET Core team already thought about this. By registering a few services and adding a single middleware to detect the user’s preferred locale things just work.

There are two interfaces we’ll commonly use:

  • IStringLocalizer: used to localize strings anywhere but view templates.
  • IViewLocalizer: used to localize strings in Razor view templates.

Both interfaces provide an indexer method where you provide a key and get a localized string back if available, or the key itself if there’s no available localization.

_localizer["Login"]

They’re almost identical interfaces, except that the former returns LocalizedString and the latter LocalizedHtmlString, which implements IHtmlContent.

About the defaults

If we follow the official guide at Globalization and Localization in ASP.NET Core you’ll end with the following:

  • .resx resource files, for strongly typed key-translation pairs.
  • The option to either name your resource files with dots to denote hierarchy, or to place them together with their related class or view.
  • The option to add custom view files specific for a given locale, either by using a locale suffix (e.g. Home.en-US.cshtml) or by placing them in their own directory (e.g. Views/MyController/en-US/Home.cshtml).

Everything is fine. Except…

Rider doesn't support .resx files yet

Yeah*.

I-it’s not like I wanted to use those Resource files anyways!

*To be fair, the issue is fixed, and it’s on the Early Access Program build which should be released Soon™.

Storing localization in JSON files

Given that I’m not going to edit .resx files manually an alternative is needed.

In the Localization Extensibility page of the official documentation, there’s a reference to a JSON-based IStringLocalizer implementation.

After installing My.Extensions.Localization.Json, we need to register the services with the IoC container…

// Important: AddJsonLocalization must be called *before* AddViewLocalization.
// Otherwise, view localization will just ignore the JSON localizer and use
// the default .resx localizer instead.
services.AddJsonLocalization(); // <== JSON IStringLocalizer
var builder = services.AddControllersWithViews()
    .AddViewLocalization(); // <== IViewLocalizer, among other things

…and add the middleware to the ASP.NET Core pipeline:

app.UseStaticFiles();
app.UseRequestLocalization("es", "en"); // <== Detect preferred culture
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });

After that, it’s just a matter of creating the .json resource files in the same directory as the related class or view.

Example image of JSON resource files for both Controller class and view file

Also, while not included in the picture, we could easily create a Login.en.cshtml file and it would be used instead of Login.cshtml when the locale is “en” anything specific.

Check the official documentation for examples on how to use the localization interfaces.

Common mistakes

The localization API is written with extensibility in mind, so it makes use of the Try variant of the IServiceCollection methods: if there’s no custom implementation registered for a given service, it registers the default implementation.

TL;DR: we need to register both JsonStringLocalizer and FeatureDirectoriesViewExpander before calling AddViewLocalization.

Calling AddJsonLocalization after AddViewLocalization

Doing this will cause ResourceManagerStringLocalizerFactory to be registered as the IStringLocalizerFactory implementation.

LocalizationServiceCollectionExtensions.cs:

[...]
// To enable unit testing
internal static void AddLocalizationServices(IServiceCollection services)
{
    services.TryAddSingleton<IStringLocalizerFactory, ResourceManagerStringLocalizerFactory>();
    services.TryAddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));
}
[...]

JsonLocalizationServiceCollectionExtensions.cs:

internal static void AddJsonLocalizationServices(IServiceCollection services)
{
    services.TryAddSingleton<IStringLocalizerFactory, JsonStringLocalizerFactory>();
    services.AddSingleton<IHtmlLocalizerFactory, JsonHtmlLocalizerFactory>();
    services.TryAddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));
    services.TryAddTransient(typeof(IStringLocalizer), typeof(StringLocalizer));
}

Registering FeatureDirectoriesViewExpander after calling AddViewLocalization

Doing this will cause culture-specific views in feature directories to be ignored.

Calling AddViewLocalization registers an additional view location expander that generates new locations based on what was registered previously.

MvcLocalizationServices.cs:

[...]
// To enable unit testing only 'MVC' specific services
public static void AddMvcViewLocalizationServices(
    IServiceCollection services,
    LanguageViewLocationExpanderFormat format)
{
    services.Configure<RazorViewEngineOptions>(
        options =>
        {
            options.ViewLocationExpanders.Add(new LanguageViewLocationExpander(format));
        });

    // ...
}
[...]

LanguageViewLocationExpander.cs:

[...]
private IEnumerable<string> ExpandViewLocationsCore(IEnumerable<string> viewLocations, CultureInfo cultureInfo)
{
    foreach (var location in viewLocations)
    {
        var temporaryCultureInfo = cultureInfo;

        while (temporaryCultureInfo != temporaryCultureInfo.Parent)
        {
            if (_format == LanguageViewLocationExpanderFormat.SubFolder)
            {
                yield return location.Replace("{0}", temporaryCultureInfo.Name + "/{0}");
            }
            else
            {
                yield return location.Replace("{0}", "{0}." + temporaryCultureInfo.Name);
            }

            temporaryCultureInfo = temporaryCultureInfo.Parent;
        }

        yield return location;
    }
}
[...]

Summary

ASP.NET Core has made it refreshingly easy to build web applications ready for international use from the get go. If you can, and want, to use .resx resource files, then you don’t need to install anything.

If you want to use JSON instead, then just install the My.Extensions.Localization.Json library and you’re ready to go.

ConfigureServices, including our FeatureDirectoriesViewExpander looks like this:

ConfigureServices after adding JsonLocalization

A sample project can be found at https://git.sinenie.cl/max/aspnetcore-localization-example