Use i18n for localization and GraphQL to keep JSON files up to date in ASP.NET Core with Headless CMS – Sitecore 10


Be excellent to each other and party on fellow developers!

Do you miss coding in ASP.NET Core? Fear not dear developers, Sitecore 10 has finally did it! You can now start coding away in ASP.NET Core and create some lovely Sitecore apps. Check out Sitecore Headless Development conceptual overview on how to build your favorite web app in Sitecore 10.

Nick and his merry team did a great job of setting up Helix.Examples. And of course, my favorite is the helix-basic-aspnetcore sample solution. I really liked how they did the localization by using RESX files.

Today’s post will be about using i18n(with JSON files) as an alternative to RESX files. With the rise of SPA(Single Page Application) i18n(with JSON files) is more or less a standard. So let’s do the same in our ASP.NET Core Rendering Host. ๐Ÿ™‚

We could build our own “I18n Json” by implementing IStringLocalizer, but why re-invent the wheel. I found this very nice project – Askmethat-Aspnet-JsonLocalizer

Json Localizer library for .NetStandard and .NetCore Asp.net projects

The scenario is this:
We will replace the resource files(RESX files) with JSON files. The JSON files will be created automatically using a graphQL query to get the Sitecore Dictionary entries and finally, keep the JSON files updated with the latest from the Sitecore Dictionary.

Let’s get started!

As always we will use the very great Sitecore sandbox project – Helix.Examples.

We will divide the work into the following steps:
1. Setup Askmethat-Aspnet-JsonLocalizer to work properly with our ASP.NET Core Rendering Host solution
2. Create a service to fetch Sitecore Dictionary entries using GraphQL
3. Create a service for generating i18n JSON files
4. Putting it all together with Middleware

1. Setup Askmethat-Aspnet-JsonLocalizer to work properly with our Rendering Host solution

In our RenderingHost solution, we will create a Foundation project for our i18n adventures. Let’s call it BasicCompany.Foundation.Localization.Rendering. Here we will reference the nuget package Askmethat.Aspnet.JsonLocalizer. And the version of the nuget package will be placed in the Packages.props file.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <LangVersion>8</LangVersion>
    <RootNamespace>BasicCompany.Foundation.Localization.Rendering</RootNamespace>
    <AssemblyName>BasicCompany.Foundation.Localization.Rendering</AssemblyName>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Askmethat.Aspnet.JsonLocalizer" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\GraphQL\rendering\BasicCompany.Foundation.GraphQL.Rendering.csproj" />
  </ItemGroup>

</Project>

Did you guys notice that we are targeting netcoreapp3.1(and not netstandard2.1), it’s for the use of IWebHostEnvironment. I will soon shed some light on that topic ๐Ÿ™‚
We are also referencing the BasicCompany.Foundation.GraphQL.Rendering, I wonder why ๐Ÿ˜‰

In order to set up the Askmethat.Aspnet.JsonLocalizer we will use the extension method AddJsonLocalization(for IServiceCollection), it also have a set of options available. We will create our own extension method(class) for this:

using Askmethat.Aspnet.JsonLocalizer.Extensions;
using Askmethat.Aspnet.JsonLocalizer.JsonOptions;
using BasicCompany.Foundation.GraphQL.Rendering.Services;
using BasicCompany.Foundation.Localization.Rendering.Models;
using BasicCompany.Foundation.Localization.Rendering.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Globalization;
using System.Linq;

namespace BasicCompany.Foundation.Localization.Rendering.Extensions
{
    public static class ServiceCollectionExtensions
    {
        public static void AddFoundationLocalization(this IServiceCollection serviceCollection, IConfiguration configuration)
        {
            var supportedCultures = configuration.GetSection("Foundation:Multisite:SupportedLanguages").Get<string[]>().Select(lang => new CultureInfo(lang)).ToHashSet();
            var defaultLanguage = configuration.GetSection("Foundation:Multisite:DefaultLanguage").Get<string>();

            serviceCollection.AddJsonLocalization(options => {
                options.CacheDuration = TimeSpan.FromMinutes(configuration.GetSection("Foundation:Localization:CacheInMinutes:AddJsonLocalization").Get<int>());
                options.ResourcesPath = configuration.GetSection("Foundation:Localization:i18nLocationFolder").Get<string>();
                options.SupportedCultureInfos = supportedCultures;
                options.UseBaseName = false;
                options.IsAbsolutePath = true;
                options.LocalizationMode = LocalizationMode.I18n;
            });

            serviceCollection.TryAddSingleton<IGraphQLSearchService<SitecoreDictionarySearchParams, SitecoreDictionarySearchResults>, GraphQLSearchSitecoreDictionaryService>();
            serviceCollection.TryAddSingleton<SyncSitecoreDictionaryService>();

        }
        
    }
}

We will set the supported languages and the location of the folder containing the JSON files, it’s all in the appsettings.json.
*Ignore the services (GraphQLSearchSitecoreDictionaryService and SyncSitecoreDictionaryService) for now ๐Ÿ˜‰

Let’s go to the BasicCompany.Project.BasicCompany.Rendering project to add the extension method, AddFoundationLocalization, to the IServiceCollection in Startup.cs.

// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
    |
    | 
    A lot of configurations and registrations here...
    |
    |

    services.Configure<RequestLocalizationOptions>(options =>
    {

        var supportedCultures = Configuration.GetSection("Foundation:Multisite:SupportedLanguages").Get<string[]>().Select(lang => new CultureInfo(lang)).ToList();

        var defaultLanguage = Configuration.GetSection("Foundation:Multisite:DefaultLanguage").Get<string>();

        // If you add languages in Sitecore which this site / Rendering Host should support, add them here.
        options.DefaultRequestCulture = new RequestCulture(defaultLanguage, defaultLanguage);
        options.SupportedCultures = supportedCultures;
        options.SupportedUICultures = supportedCultures;

        // Allow culture to be resolved via standard Sitecore URL prefix and query string (sc_lang).
        options.UseSitecoreRequestLocalization();


    });

    // Register services here
    services.AddFoundationGraphQL(Configuration);
    services.AddFoundationLocalization(Configuration);
    services.AddFeatureProducts();

}

It’s time to create the i18n folder in the BasicCompany.Project.BasicCompany.Rendering project. The folder will contain the localized JSON files.

Here is how the localization.en.json looks like

{
  "{CA323922-812F-4B5B-8ED6-F4683DBB6FC3}": "Next page",
  "{FF5CAD2F-306A-47E3-B80A-131E9AEC22BD}": "total",
  "{2F3ED876-06DA-42E2-A880-36B6F603719C}": "of",
  "{F4A1D9BA-5C13-4321-8BE2-A39FA827F24A}": "White",
  "{BA853104-7942-42B6-B1B4-E81E62FFFE79}": "Related Products",
  "{536E8251-88D0-4056-B777-E0EA1CA51F17}": "Price",
  "{895EFC7E-46E3-41AE-9A10-70FC21B1A686}": "Black",
  "{1F3C23BF-4414-432F-9C4C-DB8F4DB2A8FE}": "Red",
  "{8D0CDCA9-5923-479D-B07F-BEBA1AEB0191}": "Previous",
  "{5E15EA4C-DCD6-4CF9-8857-D2E38CCC20CF}": "to",
  "{5725D5E6-7F23-4F13-A716-C3445B50FDAD}": "LOVELY COLORS"
}

GUID’s and texts correspond to the Sitecore Dictionary Entry(Key and Phrase)

To use the localization, we just inject the IStringLocalizer. Here’s how it’s used in the ProductDetail.cshtml view(in the BasicCompany.Feature.Products.Rendering project)

@using BasicCompany.Feature.Products.Shared
@model ProductDetail
@inject IStringLocalizer Localizer

    <section class="product-detail columns is-centered is-vcentered">
        <div class="product-details column is-narrow has-text-centered-mobile">
            <h5>@Localizer[Constants.Dictionaries.ProductsDetailPrice]</h5>
            <p class="price">$<sc-text asp-for="Price"></sc-text></p>
            <sc-text asp-for="Features"></sc-text>
        </div>
        <div class="product-image column is-3">
            <img asp-for="Image" image-params="new { mw = 480, @as = 0 }" />
        </div>
    </section>

2. Create a service to fetch Sitecore Dictionary entries using GraphQL

Great! We have a working i18n localization. Next, is to create a service that will fetch us some fresh Sitecore Dictionary entries. We will use GraphQL for this.

Like we did in the previous post, Using GraphQL in ASP.NET Core with Headless CMS โ€“ Sitecore 10, we will use a search query(graphQL) for retrieving Sitecore Dictionary entries. Something like this:

query SitecoreDictionarySearch(
  $language: String!
  $rootItem: String!
) {
  search(
    rootItem: $rootItem
    language: $language
  ) {
    results {
      items {
        item {
          ... on DictionaryEntry {
            name
            key{
              value
            }
            phrase{
              value
            }
            
          }
        }
      }
      totalCount
    }
  }
}

Notice the โ€ฆ on DictionaryEntry. Itโ€™s a fragment and it means it will only list items that are using the DictionaryEntry template, don’t you just love that ๐Ÿ™‚
The parameter rootItem will contain the GUID id(or path) to the Sitecore Dictionary

Here’s the search result when searching for Swedish dictionary entries:

{
	"Search": {
		"Results": {
			"Items": [
				{
					"Item": {
						"Name": "Paging-Next",
						"Key": {
							"Value": "{CA323922-812F-4B5B-8ED6-F4683DBB6FC3}"
						},
						"Phrase": {
							"Value": "Nรคsta sida"
						}
					}
				},
				{
					"Item": {
						"Name": "Paging-Total",
						"Key": {
							"Value": "{FF5CAD2F-306A-47E3-B80A-131E9AEC22BD}"
						},
						"Phrase": {
							"Value": "totalt"
						}
					}
				},
				{
					"Item": {
						"Name": "Paging-Of",
						"Key": {
							"Value": "{2F3ED876-06DA-42E2-A880-36B6F603719C}"
						},
						"Phrase": {
							"Value": "av"
						}
					}
				},

                               
                AND SOME MORE ITEMS...                                


					],
			"TotalCount": 13
		}
	}
}
                          

Letโ€™s create a GraphQL file, with the name SitecoreDictionarySearch.graphql, for the search query and put it in the Foundation project, BasicCompany.Foundation.GraphQL.Rendering:

Let’s wrap the graphQL query in a search service – GraphQLSearchSitecoreDictionaryService. We are using the same concept as we did in the previous post, Using GraphQL in ASP.NET Core with Headless CMS โ€“ Sitecore 10, by calling the _graphQLProvider.SendQueryAsync method with all the parameters for the GraphQL query. We also set what object the JSON result should be deserialized too(Response object). The response(GraphQLResponse) will be remapped to a SitecoreDictionarySearchResults class

using BasicCompany.Foundation.GraphQL.Rendering.Infrastructure;
using BasicCompany.Foundation.GraphQL.Rendering.Services;
using BasicCompany.Foundation.Localization.Rendering.Models;
using GraphQL;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace BasicCompany.Foundation.Localization.Rendering.Services
{
    public class GraphQLSearchSitecoreDictionaryService : GraphQLSearchServiceAbstract<SitecoreDictionarySearchParams>, IGraphQLSearchService<SitecoreDictionarySearchParams, SitecoreDictionarySearchResults>
    {

        public GraphQLSearchSitecoreDictionaryService(IGraphQLProvider graphQLProvider) : base(graphQLProvider)
        {
        }


       
        public SitecoreDictionarySearchParams CreateParams()
        {
            return base.CreateSearchParams();
        }

        public async Task<SitecoreDictionarySearchResults> Search(SitecoreDictionarySearchParams searchParams)
        {
            GraphQLResponse<GraphQLServiceResponse> response = await _graphQlProvider.SendQueryAsync<GraphQLServiceResponse>(searchParams.IsInEditingMode, searchParams.GraphQLFile , new
                {
                    language = searchParams.Language,
                    rootItem = new Guid(searchParams.RootItemId).ToString("N"),
                }

            );


            return new SitecoreDictionarySearchResults()
            {
                SitecoreDictionaryList = response.Data.Search.Results.Items.Where(di => di.Item != null).Select(dictionary => new Models.SitecoreDictionary()
                {
                    Name = dictionary?.Item?.Name,
                    Key = dictionary?.Item?.Key?.Value,
                    Phrase = dictionary?.Item?.Phrase?.Value
                }).ToList(),
                Language = searchParams.Language,
                TotalCount = response.Data.Search.Results.TotalCount
            };
        }


        protected class GraphQLServiceResponse
        {
            public GraphQLServiceResponseSearch Search { get; set; }
        }


        protected class GraphQLServiceResponseSearch
        {
            public GraphQLSearchServiceInternalResults Results { get; set; }
        }


        protected class GraphQLSearchServiceInternalResults
        {
            public IEnumerable<SitecoreDictionarySearchItem> Items { get; set; }

            public long TotalCount { get; set; }
        }



        protected class SitecoreDictionarySearchItem
        {
            public SitecoreDictionary Item { get; set; }

        }


        protected class SitecoreDictionary
        {

            public string Name { get; set; }


            public Key Key { get; set; }


            public Phrase Phrase { get; set; }
        }

        protected class Key
        {
            public string Value { get; set; }
        }

        protected class Phrase
        {
            public string Value { get; set; }
        }

      
    }
}

3. Create a service for generating i18n JSON files

The next part is to write the graphQL search result to the i18n “JSON files”. Let’s create a service for that – SyncSitecoreDictionaryService. Notice the _webHostEnvironment.ContentRootPath, it’s for locating the content root in the “running” application. Also, see how the IMemoryCache is used, we don’t want to update the i18n files all the time. IMemoryCache is perfect for that.

using BasicCompany.Foundation.GraphQL.Rendering.Infrastructure;
using BasicCompany.Foundation.GraphQL.Rendering.Services;
using BasicCompany.Foundation.Localization.Rendering.Models;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

namespace BasicCompany.Foundation.Localization.Rendering.Services
{
    public class SyncSitecoreDictionaryService
    {
        private readonly IMemoryCache _memoryCache;
        private readonly ILogger<SyncSitecoreDictionaryService> _logger;
        private readonly IConfiguration _configuration;
        private readonly IWebHostEnvironment _webHostEnvironment;
        private readonly IGraphQLSearchService<SitecoreDictionarySearchParams, SitecoreDictionarySearchResults> _graphQLSearchService;

        public SyncSitecoreDictionaryService(ILogger<SyncSitecoreDictionaryService> logger,
            IConfiguration configuration,
            IMemoryCache memoryCache,
            IWebHostEnvironment webHostEnvironment,
            IGraphQLSearchService<SitecoreDictionarySearchParams, SitecoreDictionarySearchResults> graphQLSearchService)
        {
            _memoryCache = memoryCache;
            _logger = logger;
            _configuration = configuration;
            _webHostEnvironment = webHostEnvironment;
            _graphQLSearchService = graphQLSearchService ?? throw new ArgumentNullException(nameof(graphQLSearchService));
        }

        public Task<bool> Generatei18n(string language)
        {
            return Generatei18n(language, null);
        }



        public async Task<bool> Generatei18n(string language, bool? isInEditMode)
        {

            isInEditMode ??= false;

            var i18nLocation = Path.Combine(_webHostEnvironment.ContentRootPath, _configuration.GetSection("Foundation:Localization:i18nLocationFolder").Get<string>());

            var localizationFile = Path.Combine(i18nLocation, $"localization.{language}.json");

            var cacheKey = localizationFile;

            return await _memoryCache.GetOrCreateAsync(
                    cacheKey,
                    async e =>
                    {
                        e.SetOptions(new MemoryCacheEntryOptions
                        {
                            AbsoluteExpirationRelativeToNow =
                                TimeSpan.FromMinutes(_configuration.GetSection("Foundation:Localization:CacheInMinutes:i18nFilesLifeTime").Get<int>())
                        });


                        return await WriteContentToFile();
                    });




            async Task<bool> WriteContentToFile()
            {


                try
                {


                    File.Delete(localizationFile);
                    File.Create(localizationFile).Dispose();

                    var searchParams = _graphQLSearchService.CreateParams();

                    searchParams.GraphQLFile = GraphQLFiles.SitecoreDictionarySearch;
                    searchParams.Language = language;
                    searchParams.RootItemId = _configuration.GetSection("Sitecore:DefaultSiteDictionary").Get<string>();
                    searchParams.IsInEditingMode = isInEditMode;

                    var result = await _graphQLSearchService.Search(searchParams);

                    if (result == null || !result.SitecoreDictionaryList.Any())
                        throw new Exception($"Dictionary seem to be missing for language: {searchParams.Language}");

                    using (var stream = File.Open(localizationFile, FileMode.Open, FileAccess.Write))
                    using (var streamWriter = new StreamWriter(stream))
                    using (var writer = new JsonTextWriter(streamWriter))
                    {
                        writer.Formatting = Formatting.Indented;

                        writer.WriteStartObject();
                        {

                            foreach (var item in result.SitecoreDictionaryList)
                            {
                                if (string.IsNullOrWhiteSpace(item.Key))
                                    continue;


                                writer.WritePropertyName(item.Key);
                                writer.WriteValue(item.Phrase);

                            }

                        }
                        writer.WriteEndObject();
                    }

                    return true;

                }
                catch (Exception ex)
                {
                    _logger.LogError("Error occurred", ex);
                    return false;
                }



            }
        }
    }
}


The local function WriteContentToFile, makes the graphQL search and writes the search result to the i18n (JSON) file.

What I really like with this approach, is that we don’t have to create the i18n files manually. They will be automatically generated.

4. Putting it all together with Middleware

Cool! We have a service for generating i18n JSON files! So where will we use this service? Well, if it was a classic Sitecore web app(Asp.Net Framework) we would probably put it in a pipeline. Guess what? In ASP.NET Core there is something that is quite similar to the Sitecore Pipelines, Middleware.
Let’s create a middleware and call it SyncSitecoreDictionaryMiddleware

using BasicCompany.Foundation.Localization.Rendering.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
using System;
using System.Threading.Tasks;

namespace BasicCompany.Foundation.Localization.Rendering.Infrastructure
{
    public class SyncSitecoreDictionaryMiddleware
    {
       
        private readonly RequestDelegate _next;
        public SyncSitecoreDictionaryMiddleware(RequestDelegate next)
        {
            _next = next;
        }


        public async  Task InvokeAsync(HttpContext context, SyncSitecoreDictionaryService syncSitecoreDictionaryService)
        {
           
            if (syncSitecoreDictionaryService == null)
                throw new ArgumentNullException(nameof(syncSitecoreDictionaryService));

            var cultureFeature = context.Features.Get<IRequestCultureFeature>();

            _ = await syncSitecoreDictionaryService.Generatei18n(cultureFeature.RequestCulture.Culture.Name);

            await _next(context);
        }
    }
}



*Notice how we use context.Features.Get<IRequestCultureFeature>() to get the current culture.

We will place the Middleware in the Startup.cs in the BasicCompany.Project.BasicCompany.Rendering project. But first, we need to create an extension class for the IApplicationBuilder to add our newly created middleware.

using BasicCompany.Foundation.Localization.Rendering.Infrastructure;
using Microsoft.AspNetCore.Builder;

namespace BasicCompany.Foundation.Localization.Rendering.Extensions
{
    public static class SyncSitecoreDictionaryMiddlewareExtensions
    {
        public static IApplicationBuilder UseSyncSitecoreDictionary(
            this IApplicationBuilder app)
        {
            return app.UseMiddleware<SyncSitecoreDictionaryMiddleware>();
        }
    }
}

And finally, we can add the UseSyncSitecoreDictionary to Startup.cs. Let’s put it in the end, right before the UseEndpoints stuff.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{

    |
    |
 
    A lot of HTTP configurations here...

    | 
    |

	// Sync i18n with Sitecore Dictionary  
    app.UseSyncSitecoreDictionary();


    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute(
            "error",
            "error",
            new { controller = "Default", action = "Error" }
        );

        // Enables the default Sitecore URL pattern with a language prefix.
        endpoints.MapSitecoreLocalizedRoute("sitecore", "Index", "Default");

        // Fall back to language-less routing as well, and use the default culture (en).
        endpoints.MapFallbackToController("Index", "Default");
    });
}

Lovely ๐Ÿ™‚

Let’s have a look at the result:

Success! Notice how the label “COLORS” changes when a publish is made. This means that the i18n file got updated with the latest from the Sitecore Dictionary entries.

Thatโ€™s all for now folks ๐Ÿ™‚


Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.