0 Comments
Minification is the process of removing unnecessary characters from the source code without changing its functionality.

In this post I will show you how we can optimize the JavaScript & CSS files using a custom minifiers tag helpers.

Now let us start with the CssMinifier which is nothing but a utility class that using online service https://cssminifier.com to minimize the CSS code an remove unnecessary characters.

CSS Minifier


using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;

namespace HelloMvc.TagHelpers
{
    public static class CssMinifier
{
    private const string URL_CSS_MINIFIER       = "https://cssminifier.com/raw";
    private const string POST_PAREMETER_NAME    = "input";

    public static async Task<String> MinifyCss(string inputCss)
    {
        List<KeyValuePair<String, String>> contentData = new List<KeyValuePair<String, String>>
        {
            new KeyValuePair<String, String>(POST_PAREMETER_NAME, inputCss)
        };

        using (HttpClient httpClient = new HttpClient())
        {
            using (FormUrlEncodedContent content = new FormUrlEncodedContent(contentData))
            {
                using (HttpResponseMessage response = await httpClient.PostAsync(URL_CSS_MINIFIER, content))
                {
                    response.EnsureSuccessStatusCode();
                    return await response.Content.ReadAsStringAsync();
                }
            }
        }
    }
}

}
Similarly JavaScriptMinifier which did the exact thing for the JavaScript content using https://javascript-minifier.com.

JavaScript Minifier


using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;

namespace HelloMvc.TagHelpers
{
    public static class JavaScriptMinifier
{
    private const string URL_JS_MINIFIER       = "https://javascript-minifier.com/raw";
    private const string POST_PAREMETER_NAME    = "input";

    public static async Task<String> MinifyJs(string inputJs)
    {
        List<KeyValuePair<String, String>> contentData = new List<KeyValuePair<String, String>>
        {
            new KeyValuePair<String, String>(POST_PAREMETER_NAME, inputJs)
        };

        using (HttpClient httpClient = new HttpClient())
        {
            using (FormUrlEncodedContent content = new FormUrlEncodedContent(contentData))
            {
                using (HttpResponseMessage response = await httpClient.PostAsync(URL_JS_MINIFIER, content))
                {
                    response.EnsureSuccessStatusCode();
                    return await response.Content.ReadAsStringAsync();
                }
            }
        }
    }
}
}
After we have the seen the core minification process, I will show you the code of both CssMinifierTagHelper and JavaScriptTagHelper that allow us to minifiy both inline styles and scripts as well as external files if it needs.

CSS Minifier TagHelper


using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.FileProviders;

namespace HelloMvc.TagHelpers
{
    [HtmlTargetElement("style")]
    [HtmlTargetElement("link", Attributes = MinifyAttributeName)]
    public class CssMinifierTagHelper: TagHelper
    {
        private const string MinifyAttributeName = "minify";
        private readonly IFileProvider _wwwroot;
        private readonly string _wwwrootFolder;
       
        public CssMinifierTagHelper(IHostingEnvironment env)        
        {
            _wwwroot = env.WebRootFileProvider;
            _wwwrootFolder = env.WebRootPath;
        }
       
        [HtmlAttributeName("rel")]
        public string Rel { get; set; }
       
        [HtmlAttributeName("href")]
        public string Href { get; set; }
       
        [HtmlAttributeName(MinifyAttributeName)]
        public bool? Minify {get; set;}
       
        public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
        {
            if (output.TagName == "style")
            {
                var content = output.GetChildContentAsync().Result.GetContent();
                var result = await CssMinifier.MinifyCss(content);
           
                output.Content.SetContent(result);
            }
           
            if (Rel == null || Href == null)
            {
                return;
            }

            if (output.TagName == "link" && Rel == "stylesheet")
            {
                if (!string.IsNullOrEmpty(Href))
                {
                    if(Minify.HasValue && !Minify.Value)
                    {
                        return;
                    }
                   
                    var fileInfo = _wwwroot.GetFileInfo(Href);
                    var cssDirectory = Href.Substring(0,Href.IndexOf(fileInfo.Name)-1);
                    var minFileName = fileInfo.Name.Replace(".css", ".min.css");
                    var minFilePath = Path.Combine(_wwwrootFolder, cssDirectory, minFileName);
                   
                    if (Rel != null)
                    {
                        output.Attributes.SetAttribute("rel", "stylesheet");
                    }
                   
                    if (File.Exists(minFilePath))
                    {
                        if (Href != null)
                        {
                            output.Attributes.SetAttribute("href", Href.Replace(".css", ".min.css"));
                        }
                       
                        return;
                    }
                   
                    using (var readStream = fileInfo.CreateReadStream())
                    using (var reader = new StreamReader(readStream, Encoding.UTF8))
                    {
                        var content = await CssMinifier.MinifyCss(await reader.ReadToEndAsync());
                       
                        using(var writer = new StreamWriter(File.Create(minFilePath), Encoding.UTF8))
                        {
                            await writer.WriteAsync(content);
                        }
                    }
                   
                    if (Href != null)
                    {
                        output.Attributes.SetAttribute("href", Href.Replace(".css", ".min.css"));
                    }
                }
            }
        }
    }
}

JavaScript Minifier TagHelper


using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.FileProviders;

namespace HelloMvc.TagHelpers
{
    [HtmlTargetElement("script")]
    public class JavaScriptMinifierTagHelper: TagHelper
    {
        private const string MinifyAttributeName = "minify";
        private readonly IFileProvider _wwwroot;
        private readonly string _wwwrootFolder;
       
        public JavaScriptMinifierTagHelper(IHostingEnvironment env)        
        {
            _wwwroot = env.WebRootFileProvider;
            _wwwrootFolder = env.WebRootPath;
        }

        [HtmlAttributeName("src")]
        public string Src { get; set; }
       
        [HtmlAttributeName(MinifyAttributeName)]
        public bool? Minify {get; set;}
       
        public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
        {        
            if (output.TagName == "script")
            {
                if (Src == null)
                {
                    var content = output.GetChildContentAsync().Result.GetContent();
                    var result = await JavaScriptMinifier.MinifyJs(content);
           
                    output.Content.SetContent(result);
                }
                else
                {
                    if(!Minify.HasValue)
                    {
                        if (Src != null)
                        {
                            output.Attributes.SetAttribute("src", Src);
                        }
                       
                        return;
                    }
                   
                    var fileInfo = _wwwroot.GetFileInfo(Src);
                    var jsDirectory = Src.Substring(0,Src.IndexOf(fileInfo.Name)-1);
                    var minFileName = fileInfo.Name.Insert(fileInfo.Name.Length-3,".min");
                    var minFilePath = Path.Combine(_wwwrootFolder, jsDirectory, minFileName);
                    
                    if (File.Exists(minFilePath))
                    {
                        if (Src != null)
                        {
                            output.Attributes.SetAttribute("src", Src.Replace(".js", ".min.js"));
                        }
                       
                        return;
                    }
                   
                    using (var readStream = fileInfo.CreateReadStream())
                    using (var reader = new StreamReader(readStream, Encoding.UTF8))
                    {
                        var content = await JavaScriptMinifier.MinifyJs(await reader.ReadToEndAsync());
                       
                        using(var writer = new StreamWriter(File.Create(minFilePath), Encoding.UTF8))
                        {
                            await writer.WriteAsync(content);
                        }
                    }
                   
                    if (Src != null)
                    {
                        output.Attributes.SetAttribute("src", Src.Replace(".js", ".min.js"));
                    }
                }
            }
        }
    }
}
For sake of the demo I didn't implement kind of watchers to monitor if some of the external styles or scripts change to minify them again, but this is totally possibly with the newly File System APIs in ASP.NET Core.

Finally I hope this post give you some of thought to how to start with your own custom tag helper.

Hint: The entire source code is hosted in Github

0 Comments

Previously I talked about localization in much details, today I will look to it from different angle. While the multilingual websites is common nowadays, there should be a way to let the user to switch between the languages to see the content in the language that he/she prefers.

Let's build a customizable language switcher with the help of tag helper and bootstrap.

In this article I will not dig in much details about tag helpers and bootstrap, so for those who don't know what the tag helper is, please read this link.

1. Preparing the assets

Our tag helper needs a set of icons to show the flags of the supported languages, there are some open source projects that provide a rich SVG country flags such as flag-icon-css but for simplicity I used some icons from this link. Of course we need bootstrap too, so you can get it from this link.

2. Configuring the dependencies

The main two dependencies that we need in our tag helper are  "Microsoft.AspNetCore.Mvc.TagHelpers"
"Microsoft.AspNetCore.Mvc.Localization"

3. Authoring LanguageSwitcherTagHelper

Now let's see some code

public enum DisplayMode
{
Image = 0,
ImageAndText = 1,
Text = 2
}

our tag helper contains three display modes:

  1. Image: which is display the flag of the supported languages
  2. ImageAndText: which is display the name and the flag of the supported languages
  3. Text: which is display the name of the supported languages
[HtmlTargetElement("language-switcher")]
public class LanguageSwitcherTagHelper : TagHelper
{
private IOptions<RequestLocalizationOptions> _locOptions;

public LanguageSwitcherTagHelper(IOptions<RequestLocalizationOptions> options)
{
_locOptions = options;
}

[ViewContext, HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; }

public DisplayMode Mode { get; set; } = DisplayMode.ImageAndText;

public override void Process(TagHelperContext context, TagHelperOutput output)
{
var selectedCulture = ViewContext.HttpContext.Features.Get<IRequestCultureFeature>().RequestCulture.Culture;
var cultures = _locOptions.Value.SupportedUICultures;

output.TagName = null;

switch (Mode)
{
case DisplayMode.ImageAndText:
output.Content.AppendHtml(@"<ul class='nav navbar-nav navbar-right'>
<li class='dropdown'>
<a href='#' class='dropdown-toggle' data-toggle='dropdown' role='button' aria-haspopup='true' aria-expanded='false'><img src='/images/" + selectedCulture.TwoLetterISOLanguageName + ".png' /> " + selectedCulture.EnglishName + @"<span class='caret'></span></a>
<ul class='dropdown-menu'>");
foreach (var culture in cultures)
{
output.Content.AppendHtml($"<li><a href='#' onclick=\"useCookie('{culture.TwoLetterISOLanguageName}')\"><img src='/images/{culture.TwoLetterISOLanguageName}.png' /> {culture.EnglishName}</a></li>");
}
break;
case DisplayMode.Image:
output.Content.AppendHtml(@"<ul class='nav navbar-nav navbar-right'>
<li class='dropdown'>
<a href='#' class='dropdown-toggle' data-toggle='dropdown' role='button' aria-haspopup='true' aria-expanded='false'><img src='/images/" + selectedCulture.TwoLetterISOLanguageName + @".png' /> <span class='caret'></span></a>
<ul class='dropdown-menu'>");
foreach (var culture in cultures)
{
output.Content.AppendHtml($"<li><a href='#' onclick=\"useCookie('{culture.TwoLetterISOLanguageName}')\"><img src='/images/{culture.TwoLetterISOLanguageName}.png' /></a></li>");
}
break;
case DisplayMode.Text:
output.Content.AppendHtml(@"<ul class='nav navbar-nav navbar-right'>
<li class='dropdown'>
<a href='#' class='dropdown-toggle' data-toggle='dropdown' role='button' aria-haspopup='true' aria-expanded='false'> " + selectedCulture.EnglishName + @"<span class='caret'></span></a>
<ul class='dropdown-menu'>");
foreach (var culture in cultures)
{
output.Content.AppendHtml($"<li><a href='#' onclick=\"useCookie('{culture.TwoLetterISOLanguageName}')\">{culture.EnglishName}</a></li>");
}
break;
}
output.Content.AppendHtml(@"</ul>
</li>
</ul>");

output.Content.AppendHtml(@"<script type='text/javascript'>
function useCookie(code) {{
var culture = code;
var uiCulture = code;
var cookieValue = '" + CookieRequestCultureProvider.DefaultCookieName + @"=c='+code+'|uic='+code;
document.cookie = cookieValue;
window.location.reload();
}}
</script>");
}
}

the LanguageSwitcherTagHelper is simply render a bootstrap dropdown menu that contains the supported languages based on the DisplayMode property.

after that you can use our tag helper in the view as the following:

<language-switcher></language-switcher>

the final result will look like this

Hint: The entire source code is hosted in Github.

1 Comments

gettext is an internationalization and localization (i18n) system commonly used for writing multilingual programs on Unix-like computer operating systems. The most commonly used implementation of gettext is GNU gettext released by the GNU project in 1995. [wikipedia]

The GNU gettext mainly using PO (Portable Object) files which are the files that contains the actual translations.

PO files is famous and widely used in many open source projects such as WordPress Orchard CMS and much more. In this article we will see how to support the PO files in ASP.NET Core 1.0 using Localization APIs & Roslyn.

Let us have a look to the following file:

msgid ""
msgstr ""
"Project-Id-Version: Localization Sample\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-03-14 15:27+0100\n"
"PO-Revision-Date: 2016-03-14 02:44+0100\n"
"Last-Translator: Hisham Bin Ateya <hishamco_2007@yahoo.com>\n"
"Language-Team: ES <es@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

#: Startup.cs:76
msgid "Hello"
msgstr "Hola"

#: Startup.cs:75
msgid "Request Localization Sample"
msgstr "Solicitud de localizaci&oacute;n de la muestra"

#: Startup.cs:77,78
msgid "1 apple"
msgid_plural "{0} apples"
msgstr[0] "1 manzana"
msgstr[1] "{0} manzanas"

The above file is es-ES.po which is target the Spanish Language, the files start with few lines (headers) which is nothing but metadata about the file. If we notice there are some interested lines such as "Plural-Forms: nplurals=2; plural=(n != 1);\n" which contains the number of the plural forms in Spanish as well as the plural form expression.

The rest of the file contains a pair of msgid & msgstr which they represent the key or the word or the phrase to be translated and the value that contains the translation itself. Perhaps there is a msgid_plural will show up in the files, and this represent the plural key for a certain word or phrase, followed by an array of msgstr that contain all the plural forms, the key part here is the translation are determined by the above plural form in our case plural=(n != 1), so in case n is equal to 1 the msgstr[0] will show up otherwise msgstr[1].

Now let us dig into the source code.

The Entry class represents a key-value pair for both singular and plural forms.

public class Entry
{
public string SingularId { get; set; }
public string PluralId { get; set; }
public List<string> Values { get; set; }
}
The Resource class contains the metadata of the culture info and the translation them self.
public class Resource
{
public string Language { get; set; }
public int PluralsNo { get; set; }
public string PluralForm { get; set; }
public List<Entry> Entries { get; } = new List<Entry>();
}
The POParser class is the core component that parse the entire PO file and produce a meaningful Resource object.
public class POParser
{
private string _path;
private readonly Resource _resource = new Resource();

public Resource Resource
{
get { return _resource; }
}

public POParser(string path)
{
_path = path;
}

public void Parse()
{
if (!File.Exists(_path))
{
return;
}

using (var reader = new StreamReader(_path))
{
string line, key, pluralKey, value;
while ((line = reader.ReadLine()) != null)
{
if (string.IsNullOrEmpty(line))
{
continue;
}

if (line[0] == '"')
{
if (line.StartsWith("\"Plural-Forms"))
{
_resource.PluralsNo = Convert.ToInt32(line.Substring(line.IndexOf("nplurals=") + 9, 1));
_resource.PluralForm = line.Substring(line.IndexOf("plural=") + 7, line.LastIndexOf(";") - (line.IndexOf("plural=") + 7));
}
continue;
}

if (line[0] == '#')
{
continue;
}

if (line.StartsWith("msgid"))
{
key = line.Substring(7).Trim('"');

if (string.IsNullOrEmpty(key))
{
continue;
}

line = reader.ReadLine();

if (line.StartsWith("msgstr"))
{
value = line.Substring(8).Trim('"');
_resource.Entries.Add(new Entry() { SingularId = key, Values = new List<string>() { value } });

}
else if (line.StartsWith("msgid_plural"))
{
pluralKey = line.Substring(14).Trim('"');
_resource.Entries.Add(new Entry() { SingularId = key, PluralId = pluralKey, Values = new List<string>() });
while ((line = reader.ReadLine()) != null)
{
if (line.StartsWith("msgstr"))
{
value = line.Substring(line.IndexOf(" ") + 1).Trim('"');
_resource.Entries.Last().Values.Add(value);
}
}
}
}
}
}
}
}
The POStringLocalizerFactory is responsible for create the POStringLocalizer object.
public class POStringLocalizerFactory : IStringLocalizerFactory
{
private static string _resourcesRelativePath;

public POStringLocalizerFactory(IOptions<LocalizationOptions> localizationOptions)
{
if (localizationOptions == null)
{
throw new ArgumentNullException(nameof(localizationOptions));
}

_resourcesRelativePath = localizationOptions.Value.ResourcesPath ?? string.Empty;
}

public IStringLocalizer Create(Type resourceSource)
{
var cachedReourse = Cache.GetOrAdd();
return new POStringLocalizer(cachedReourse);
}

public IStringLocalizer Create(string baseName, string location)
{
return Create(null);
}

private static class Cache
{
private static readonly Dictionary<string, Resource> _lookup = new Dictionary<string, Resource>();

public static Resource GetOrAdd()
{
var culture = CultureInfo.CurrentCulture.ToString();

if(!_lookup.ContainsKey(culture))
{
var path = Path.Combine(_resourcesRelativePath, culture + ".po");
var parser = new POParser(path);

parser.Parse();
_lookup.Add(culture, parser.Resource);
}
return _lookup[culture];
}
}
}
The POStringLocalizer class is responsible to fetch the translation from the underlying source.
public class POStringLocalizer : IStringLocalizer
{
private readonly Resource _resource;

public POStringLocalizer(Resource resource)
{
_resource = resource;
}

public LocalizedString this[string name]
{
get
{
var value = GetString(name);
return new LocalizedString(name, value ?? name, resourceNotFound: value == null);
}
}

public LocalizedString this[string name, params object[] arguments]
{
get
{
var format = GetString(name);
var value = string.Format(format ?? name, arguments);
return new LocalizedString(name, value, resourceNotFound: format == null);
}
}

public IStringLocalizer WithCulture(CultureInfo culture)
{
CultureInfo.DefaultThreadCurrentCulture = culture;
return new POStringLocalizer(_resource);
}

public IEnumerable<LocalizedString> GetAllStrings(bool includeAncestorCultures)
{
return null;
}

private string GetString(string name)
{
if (_resource == null)
return name;
var resource = _resource.Entries.SingleOrDefault(e => e.SingularId == name);
return resource == null ? name : resource.Values[0];
}

public string Plural(string name, int count)
{
if (!_resource.Entries.Any(e => e.PluralId == name))
{
return name;
}

int index = GetPluralRule(count);
return string.Format(_resource.Entries.Single(e => e.PluralId == name).Values[index], count);
}

private int GetPluralRule(int n)
{
var culure = CultureInfo.Culture.ToString();
if(culture == "fr")
{
return Convert.ToInt32(n > 1);
}
else
{
return (n != 1 ? 1 : 0);
}
}
}

The code of GetPluralRule is very stupid :) I made it for the sake of the demo, but you can use the one that I mentioned in my previous article Pluralization in ASP.NET Core 1.0.

Also you can use the Roslyn APIs to evaluate the plural form expression that available in the PO file on fly. The code may change to something like this:

private async int GetPluralRule(int n)
{
var expression = _resource.PluralForm.Replace("n", n.ToString());
object result = await CSharpScript.EvaluateAsync(expression);
return Convert.ToInt32(result);
}

In the above function I used the Scripting APIs which is a part of the Roslyn source code, which provide a rich APIs to deal with and execute a CSharp scripts at the run-time, for more information about Roslyn Scripting APIs check this link, also you can check the entire source code which is available on github.

At the end of this article I hope we make PO ❤️ Localization in ASP.NET Core 1.0.

0 Comments

Pluralization is a complex problem, as different languages have a variety of complex rules for pluralization. English language is one of the simplest languages because it have two plural forms: one for the singular and another for plural, which is make it easy to implement pluralization for English Language.

Let us have an example:

1 apple, 2 apples and 100 apples

As we said before "1 apple" is a singular form, while the others are plural form, some of us will said it's easy to implement the pluralization .. wait a minute!! and have a look to plural forms link or this link, perhaps you will not believe that there are some language has more complex rules for pluralization such as Arabic Language, which is my mother language :)

It's code time, let us simplify the entire process of pluralization, as we know there's no one solution for this problem and if you look to many programming languages and frameworks there are different flavors, so let us see what can I come up with.

First of let us implement a simple pluralization for English language. As we mentioned before English language has two plural form, so it's easy to create a simple function that give us a proper form.

public static string Plural(this IStringLocalizer localizer, bool isPlural, string name, params object[] arguments)
{
string value = localizer[name,arguments];
int index = (isPlural ? 1 : 0);
return value.Split('|')[index];
}

I presume that value of the key in the resource file is separated by "|" to distinguish between the singular and plural forms, and this is will applied into the underneath examples.

In the above example I extend the IStringLocalizer interface to have a new method named Plural, which will give us the right form, and resource file will look like

<data name="apple" xml:space="preserve">
<value>{0} apple|{0} apples</value>
</data>

After that we can simple used as T.Plural("apple", false) to get the singular form and T.Plural("apple", true) to get the plural form.

Now let us dig into more realistic code, because there are many language other than English.

In the following section I will dig into two ways to implement the pluralization:

1- Implicit

In this way the pluralization rules are implicit, all the magic will happen behind the scene.

msgid "%s apple"
msgid_plural "%s apples"
msgstr[0] ""
msgstr[1] ""
"Project-Id-Version: Space9\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2014-10-24 00:49+0200\n"
"PO-Revision-Date: 2014-10-24 00:49+0200\n"
"Last-Translator: Anastis Sourgoutsidis <anastis@cssigniter.com>\n"
"Language-Team: CSSIgniter LLC <info@cssigniter.com>\n"
"Language: el\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: __;_e;__ngettext:1,2;_n:1,2;__ngettext_noop:1,2;"
"_n_noop:1,2;_c,_nc:4c,1,2;_x:1,2c;_nx:4c,1,2;_nx_noop:4c,1,2;_ex:1,2c;"
"esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n"
"X-Poedit-Basepath: .\n"
"X-Textdomain-Support: yes\n"
"X-Generator: Poedit 1.6.10\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SearchPath-0: .\n"
"X-Poedit-SearchPath-1: ..\n"

There are some interesting lines

"Plural-Forms: nplurals=2; plural=(n != 1);\n" which is define the plural forms for the English language

msgid "%s apple"
msgid_plural "%s apples"
msgstr[0] ""
msgstr[1] ""

which define the singular and plural keys, and the values which is in this case are two.

When I was thinking to implement that I asked myself should I have n keys per language? the answer it depends but for generic case the resource file will be large, specially for those languages which have more than two plurals forms, again I'm think for Arabic language which have six forms :) , so I come up with an idea to have all the values per key separated by "|" pipe symbol, in this case I will reduce the amount of key value pair in the resource file regardless what the language is.

public static string Plural(this IStringLocalizer localizer, string name, params object[] arguments)
{
string value = localizer[name,arguments];
int count = Convert.ToInt32(arguments[0]);
int plural = GetPluralForms(count);
return value.Split('|')[plural];
}
The code is quite simple, using IStringLocalizer to get the value of the passed key, after that I called the magic function GetPluralForms() which gets the number of the plural forms for the current language as the following:
private static int GetPluralForms(int n)
{
string code = Thread.CurrentThread.CurrentCulture.TwoLetterISOLanguageName;
int plural=0;
switch (code)
{
// nplural=1
case "ay":
case "bo":
case "cgg":
case "dz":
case "fa":
case "id":
case "ja":
case "jbo":
case "ka":
case "kk":
case "km":
case "ko":
case "ky":
case "lo":
case "ms":
case "my":
case "sah":
case "su":
case "th":
case "tt":
case "ug":
case "vi":
case "wo":
case "zh_CN":
case "zh_HK":
case "zh_TW":
plural = 0;
break;
// nplural=2
case "ach":
case "ak":
case "am":
case "arn":
case "br":
case "fil":
case "fr":
case "gun":
case "ln":
case "mfe":
case "mg":
case "mi":
case "oc":
case "pt_BR":
case "tg":
case "ti":
case "tr":
case "uz":
case "wa":
plural = (n > 1 ? 1 : 0);
break;
case "af":
case "an":
case "anp":
case "as":
case "ast":
case "az":
case "bg":
case "bn":
case "brx":
case "ca":
case "da":
case "de":
case "doi":
case "el":
case "en":
case "eo":
case "es":
case "es_AR":
case "et":
case "eu":
case "ff":
case "fi":
case "fo":
case "fur":
case "fy":
case "gl":
case "gu":
case "ha":
case "he":
case "hi":
case "hne":
case "hu":
case "hy":
case "ia":
case "it":
case "kl":
case "kn":
case "ku":
case "lb":
case "mai":
case "ml":
case "mn":
case "mni":
case "mr":
case "nah":
case "nap":
case "nb":
case "ne":
case "nl":
case "nn":
case "no":
case "nso":
case "or":
case "pa":
case "pap":
case "pms":
case "ps":
case "pt":
case "rm":
case "rw":
case "sat":
case "sco":
case "sd":
case "se":
case "si":
case "so":
case "son":
case "sq":
case "sv":
case "sw":
case "ta":
case "te":
case "tk":
case "ur":
case "yo":
plural = (n != 1 ? 1 : 0);
break;
case "is":
plural = (n % 10 != 1 || n % 100 == 11 ? 1 : 0);
break;
case "jv":
plural = (n != 0 ? 1 : 0);
break;
case "mk":
plural = (n == 1 || n % 10 == 1 ? 0 : 1);
break;
// nplural=3
case "be":
case "bs":
case "hr":
case "lt":
plural = (n % 10 == 1 && n % 100 != 11 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 <10 || n % 100 >= 20) ? 1 : 2);
break;
case "cs":
plural = ((n == 1) ? 0 : (n >= 2 && n <= 4) ? 1 : 2);
break;
case "csb":
case "pl":
plural = ((n == 1) ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
break;
case "lv":
plural = (n % 10 == 1 && n % 100 != 11 ? 0 : n != 0 ? 1 : 2);
break;
case "mnk":
plural = (n == 0 ? 0 : n == 1 ? 1 : 2);
break;
case "ro":
plural = (n == 1 ? 0 : (n == 0 || (n % 100 > 0 && n % 100 < 20)) ? 1 : 2);
break;
// nplural=4
case "cy":
plural = ((n == 1) ? 0 : (n ==2 ) ? 1 : (n != 8 && n != 11) ? 2 : 3);
break;
case "gd":
plural = ((n == 1 || n == 11) ? 0 : (n == 2 || n == 12) ? 1 : (n > 2 && n < 20) ? 2 : 3);
break;
case "kw":
plural = ((n == 1) ? 0 : (n == 2) ? 1 : (n == 3) ? 2 : 3);
break;
case "mt":
plural = (n == 1 ? 0 : n == 0 || ( n % 100 > 1 && n % 100 < 11) ? 1 : (n % 100 > 10 && n % 100 < 20 ) ? 2 : 3);
break;
case "sl":
plural = (n % 100==1 ? 1 : n % 100 == 2 ? 2 : n % 100 == 3 || n % 100 == 4 ? 3 : 0);
break;
case "ru":
case "sr":
case "uk":
plural = (n % 10 == 1 && n % 100 != 11 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
break;
case "sk":
plural = ((n == 1) ? 0 : (n >= 2 && n <= 4) ? 1 : 2);
break;
// nplural=5
case "ga":
plural = (n == 1 ? 0 : n == 2 ? 1 : (n > 2 && n < 7) ? 2 :(n > 6 && n < 11) ? 3 : 4);
break;
// nplural=6
case "ar":
plural = (n == 0 ? 0 : n == 1 ? 1 : n == 2 ? 2 : n % 100 >= 3 && n % 100 <= 10 ? 3 : n %100 >= 11 ? 4 : 5);
break;
}
return plural;
}

2- Explicit

You may also create more explicit pluralization rules easily:

apples => "[0] There are no apples|[1-19] There are some apples|[20-*] There are many apples"

This technique is inspired by laravel framework, and if you notice the pluralization rules are more explicit in the values in the resource file.

Here there are three case

[0] which means if the count is equal zero you will get There are no apples

[1-19] which means if the count between one and nineteen you will get There are some apples

[20-*] which means if the count is greater than or equal twenty you will get There are many apples

The explicit rules is more powerful, but you need to write them your own, the code of this technique may looks like

public static string Plural(this IStringLocalizer localizer, string name, params object[] arguments)
{
string value = localizer[name,arguments];
var parts = value.Split('|');
var plural="";
int n = Convert.ToInt32(arguments[0]);
foreach (var part in parts)
{
var tmp = part.Substring(1, part.IndexOf(']')-1);
if (tmp.Contains("-"))
{
var tokens = tmp.Split('-');
int min = Convert.ToInt32(tokens[0]);
int max = (tokens[1]=="*"?int.MaxValue:Convert.ToInt32(tokens[1]));
if (n >= min && n <= max)
{
plural= part.Split(']')[1];
break;
}
}
else if(tmp.Contains(","))
{
var tokens = tmp.Split(',');
if (tokens.Any(t=>Convert.ToInt32(t)==n))
{
plural= part.Split(']')[1];
break;
}
}
else
{
if(Convert.ToInt32(tmp) == n)
{
plural= part.Split(']')[1];
break;
}
}
}
return plural;
}
You may need sort of caching to avoid string processing for each requested key, for the sake of the demo I didn't implement that.

0 Comments

1. Localization Culture Providers

ASP.NET Core 1.0 came up with five providers that can determine the current culture of the web application:

  • AcceptLanguageHeaderRequestCultureProvider
  • CookieRequestCultureProvider
  • CustomRequestCultureProvider
  • QueryStringRequestCultureProvider

So, it can determine the culture either from cookies, http header or query string. And if we have a look to the source code in the localization repository, we will notice that all the providers inherit from RequestCultureProvider. The developer can easily use this extensible point to create a new culture provider that retrieves the culture from custom source by inheriting from the previous class.

In this article, I'm going to create a new culture provider called ConfigurationRequestCultureProvider which retrieves the culture from configuration file (JSON) in our case. As I mentioned before, we need to inherit from RequestCultureProvider and we will get the culture from the configuration file using the configuration APIs as the following:

public class ConfigurationRequestCultureProvider : RequestCultureProvider
{
public override Task<ProviderCultureResult>
DetermineProviderCultureResult(HttpContext httpContext)
{
if (httpContext == null)
{
throw new ArgumentNullException(nameof(httpContext));
}

var builder = new ConfigurationBuilder();
builder.AddJsonFile("config.json");

var config = builder.Build();
string culture = config["culture"];
string uiCulture = config["uiCulture"];

culture = culture ?? "en-US";
uiCulture = uiCulture ?? culture;

return Task.FromResult(new ProviderCultureResult(culture, uiCulture));
}
}
As we saw before, we get cuture information using culture & uiCulture keys which are defined in the json configuration file below:
{
"culture": "ar-SA"
"uiCulture": "ar-SA"
}
After that, we need to add the new provider into RequestCultureProviders property which is available in RequestLocalizationOptions class.
public void Configure(IApplicationBuilder app, IStringLocalizer<startup> localizer)
{
var supportedCultures = new List<cultureinfo>
{
new CultureInfo("en-US"),
new CultureInfo("ar-SA")
};

var options = new RequestLocalizationOptions()
{
DefaultRequestCulture = new RequestCulture("en-US"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};

options.RequestCultureProviders.Insert(0, new JsonRequestCultureProvider());
app.UseRequestLocalization(options);
...
}

You can find the source of the above sample on the ASP.NET Entropy repository.

2. Localization Resources

The second point that the ASP.NET developer may use to extend the localization is specifying the localization entries aka Resource, which a key/value pair, that contains all the required entries with their translation for a specific culture. ASP.NETCore 1.0 out-of-the-box uses the old source .resx files to store the culture specific entries, but give you the ability to switch into your custom storage such as XML, JSON, .. etc., to retrieve the localization entries.

In this article, we will use a memory storage using EntityFramework, to store the localization entries. I will not dive into much details about EF, so we will start building our needed models Culture and Resource.

public class Culture
{
public int Id { get; set; }
public string Name { get; set; }
public virtual List<Resource> Resources { get; set; }
}
public class Resource
{
public int Id { get; set; }
public string Key { get; set; }
public string Value { get; set; }
public virtual Culture Culture { get; set; }
}
After that, we define the DataContext:
public class LocalizationDbContext : DbContext
{
public DbSet<culture> Cultures { get; set; }
public DbSet<resource> Resources { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseInMemoryDatabase();
}
}
At this point, finish defining the required objects for the data store, now we will start to use the extensible point by implementing the following interfaces IStringLocalizer, IStringLocalizer<T>, IStringLocalizerFactory.
public class EFStringLocalizer : IStringLocalizer
{
private readonly LocalizationDbContext _db;

public EFStringLocalizer(LocalizationDbContext db)
{
_db = db;
}

public LocalizedString this[string name]
{
get
{
var value = GetString(name);
return new LocalizedString(name, value ?? name, resourceNotFound: value == null);
}
}

public LocalizedString this[string name, params object[] arguments]
{
get
{
var format = GetString(name);
var value = string.Format(format ?? name, arguments);
return new LocalizedString(name, value, resourceNotFound: format == null);
}
}

public IStringLocalizer WithCulture(CultureInfo culture)
{
CultureInfo.DefaultThreadCurrentCulture = culture;
return new EFStringLocalizer(_db);
}

public IEnumerable<LocalizedString> GetAllStrings(bool includeAncestorCultures)
{
return _db.Resources
.Include(r => r.Culture)
.Where(r => r.Culture.Name == CultureInfo.CurrentCulture.Name)
.Select(r => new LocalizedString(r.Key, r.Value, true));
}

private string GetString(string name)
{
return _db.Resources
.Include(r => r.Culture)
.Where(r => r.Culture.Name == CultureInfo.CurrentCulture.Name)
.FirstOrDefault(r => r.Key == name)?.Value;
}
}

As we saw from the previous code, all the needed functions are implemented to fetch the localization values from the memory using an object of LocalizationDbContext class that we defined previously.

In the same way, we can implement the generic version of the IStringLocalizer.

public class EFStringLocalizer<T> : IStringLocalizer<T>
{
private readonly LocalizationDbContext _db;

public EFStringLocalizer(LocalizationDbContext db)
{
_db = db;
}

public LocalizedString this[string name]
{
get
{
var value = GetString(name);
return new LocalizedString(name, value ?? name, resourceNotFound: value == null);
}
}

public LocalizedString this[string name, params object[] arguments]
{
get
{
var format = GetString(name);
var value = string.Format(format ?? name, arguments);
return new LocalizedString(name, value, resourceNotFound: format == null);
}
}

public IStringLocalizer WithCulture(CultureInfo culture)
{
CultureInfo.DefaultThreadCurrentCulture = culture;
return new EFStringLocalizer(_db);
}

public IEnumerable<LocalizedString> GetAllStrings(bool includeAncestorCultures)
{
return _db.Resources
.Include(r => r.Culture)
.Where(r => r.Culture.Name == CultureInfo.CurrentCulture.Name)
.Select(r => new LocalizedString(r.Key, r.Value, true));
}

private string GetString(string name)
{
return _db.Resources
.Include(r => r.Culture)
.Where(r => r.Culture.Name == CultureInfo.CurrentCulture.Name)
.FirstOrDefault(r => r.Key == name)?.Value;
}
}
The IStringLocalizerFactory interface is responsible to create an object of IStringLocalizer.
public class EFStringLocalizerFactory : IStringLocalizerFactory
{
private readonly LocalizationDBContext _db;

public EFStringLocalizerFactory()
{
_db = new LocalizationDBContext();
_db.AddRange(
new Culture
{
Name = "en-US",
Resources = new List<Resource>()
{ new Resource { Key = "Hello", Value = "Hello" } }
},
new Culture
{
Name = "fr-FR",
Resources = new List<Resource>()
{ new Resource { Key = "Hello", Value = "Bonjour" } }
},
new Culture
{
Name = "es-ES",
Resources = new List<Resource>()
{ new Resource { Key = "Hello", Value = "Hola" } }
},
new Culture
{
Name = "jp-JP",
Resources = new List<Resource>()
{ new Resource { Key = "Hello", Value = "?????" } }
},
new Culture
{
Name = "zh",
Resources = new List<Resource>()
{ new Resource { Key = "Hello", Value = "??" } }
},
new Culture
{
Name = "zh-CN",
Resources = new List<Resource>()
{ new Resource { Key = "Hello", Value = "??" } }
}
);
_db.SaveChanges();
}

public IStringLocalizer Create(Type resourceSource)
{
return new EFStringLocalizer(_db);
}

public IStringLocalizer Create(string baseName, string location)
{
return new EFStringLocalizer(_db);
}
}
Last but not least, instantiation of EFStringLocalizerFactory is required in the localization middleware, to let the localization use the customized localization resource.
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IStringLocalizerFactory, EFStringLocalizerFactory>();
}
Finally, using the created object via Dependency Injection.
public void Configure(IApplicationBuilder app, IStringLocalizerFactory localizerFactory)
{
var localizer = localizerFactory.Create(null);
...
}

You can find the source of the above sample on the ASP.NET Entropy repository.

For more information about localization, you can have a look at the source code on the ASP.NET Localization repository.