Localizing files (*.jpg, *.css, *.js, etc) in ASP.NET

Localizing FilesWell, I ran into this challenge today and figure I would share it.

When initially browsing for an already-made solution I found people that would create a new Helper Method (in ASP.NET MVC) to generate localized URLs for content, but I just didn’t like this approach. Just for the record, here’s a link of where I read about such approach.

The reasons I don’t like the helper method approach are:

  • SEO-wise (not that SEO cares about stylesheets or javascript files), you’ll be generating different URLs on the fly for what is essentially the very same content except for the localization. This is, to me, simply a conceptual miss.
  • Development wise, you’ll endup with either an Url.Content method overload or a totally new method to create the URL for you, but you’ll bet at the mercy of your developers remembering to use the “localized” version of your URL generator (this is all thinking of an ASP.NET MVC environment).

So, I came up with this approach:

The Concept

Basically, instead of localizing when the server generates the URL, why not localize when the server receives the request for the actual resource file (be that what you need to serve… JPG or PNG images? Javascript files? CSS?).

Before you start working on this, you should probably understand that this is not an internationalization approach for your site. If you need to internationalize/localize your dynamic content, then you can start by looking at these 2 posts (post 1post 2). We use a cookie to identify the culture selected by the user… and that is how the following code will serve localized content.

So, what we’ll do here, is:

  1. Trap requests to static files
  2. Figure out if a localized version of the file exists (in our case, if a file with the same name in a subfolder with the culture exists)
  3. If the localized file exists, then serve it.
  4. If the localized file doesn’t exist, then server the original file.

The Code

We’ll need an HttpHandler… to handle the requests.

[file LocalizedStaticFileHandler.cs]

 public class LocalizedStaticFileHandler : IHttpHandler
{
    protected static List<FileSystemCheckEntry> FileSystemCheckCache = new List<FileSystemCheckEntry>();
    protected const int MAX_FILE_SYSTEM_CHECK_CACHE_SIZE = 100;
    /// <summary>
    /// You will need to configure this handler in the web.config file of your
    /// web and register it with IIS before being able to use it. For more information
    /// see the following link: https://go.microsoft.com/?linkid=8101007
    /// </summary>
    #region IHttpHandler Members
    public bool IsReusable
    {
        // Return false in case your Managed Handler cannot be reused for another request.
        // Usually this would be false in case you have some state information preserved per request.
        get { return true; }
    }
    public void ProcessRequest(HttpContext context)
    {
        // first, let's get the path to the file that is being requested
        var filePath = context.Server.MapPath(context.Request.Url.AbsolutePath);
        // let's check out the cookie, and see if we are supposed to look for a localized version of the file
        var locale = CultureHelper.GetDefaultCulture();
        var localeCookie = context.Request.Cookies[CultureHelper.CULTURE_COOKIE_NAME];
        if (localeCookie != null)
            locale = CultureHelper.GetImplementedCulture(localeCookie.Value);
        // see if the localized file exists
        var localFilePath = Path.Combine(Path.GetDirectoryName(filePath), locale, Path.GetFileName(filePath));
        // set output cache policy (see Global.asax::GetVaryByCustomString)
        context.Response.Cache.SetVaryByCustom("language");
        // if the file exists, then server the file
        if (this.FileSystemCheck(localFilePath))
        {
            this.ServeContent(localFilePath, context);
            return;
        }
        // if doesn't exist, transmit the requested file
        else if (this.FileSystemCheck(filePath))
        {
            this.ServeContent(filePath, context);
            return;
        }
        // if the file doesn't exist, then return a 404 error
        context.Response.StatusCode = 404;
    }
    protected bool FileSystemCheck(string filePath)
    {
        // see if the entry already exists
        FileSystemCheckEntry entry;
        lock (LocalizedStaticFileHandler.FileSystemCheckCache)
        {
            entry = LocalizedStaticFileHandler.FileSystemCheckCache.FirstOrDefault(x => x.FilePath == filePath);
            if (entry != null)
            {
                // since it was found, let's move to the top!
                LocalizedStaticFileHandler.FileSystemCheckCache.Remove(entry);
                LocalizedStaticFileHandler.FileSystemCheckCache.Insert(0, entry);
                return entry.Exists;
            }
        }
        // if it doesn't exist, then let's actually check for the file
        var exists = File.Exists(filePath);
        // now insert at the top and get rid of overflow
        lock (LocalizedStaticFileHandler.FileSystemCheckCache)
        {
            LocalizedStaticFileHandler.FileSystemCheckCache.Insert(0, new FileSystemCheckEntry { FilePath = filePath, Exists = exists });
            while (LocalizedStaticFileHandler.FileSystemCheckCache.Count > LocalizedStaticFileHandler.MAX_FILE_SYSTEM_CHECK_CACHE_SIZE)
                LocalizedStaticFileHandler.FileSystemCheckCache.RemoveAt(LocalizedStaticFileHandler.MAX_FILE_SYSTEM_CHECK_CACHE_SIZE);
        }
        return exists;
    }
    protected void ServeContent(string filePath, HttpContext context)
    {
        // clear current
        context.Response.Clear();
        context.Response.ClearContent();
        // set the MIME type
        switch (Path.GetExtension(filePath).ToLower())
        {
            case ".jpg":
            case ".jpeg":
                context.Response.ContentType = "image/jpeg";
                break;
            case ".gif":
                context.Response.ContentType = "image/gif";
                break;
            case ".png":
                context.Response.ContentType = "image/png";
                break;
            case ".css":
                context.Response.ContentType = "text/css";
                break;
            case ".js":
                context.Response.ContentType = "text/javascript";
                break;
        }
        context.Response.WriteFile(filePath);
    }
    #endregion
    protected class FileSystemCheckEntry
    {
        public string FilePath { get; set; }
        public bool Exists { get; set; }
    }
}

And also add the following to your Global.asax.cs file (to enable output caching based on the Culture Cookie):

1
2
3
4
5
6
7
8
9
10
11
12
public override string GetVaryByCustomString(System.Web.HttpContext context, string custom)
{
    if (custom == "language")
    {
        var locale = CultureHelper.GetDefaultCulture();
        var localeCookie = context.Request.Cookies[CultureHelper.CULTURE_COOKIE_NAME];
        if (localeCookie != null)
            locale = CultureHelper.GetImplementedCulture(localeCookie.Value);
        return locale;
    }
    return base.GetVaryByCustomString(context, custom);
}

You’ll note that this handler depends on a CultureHelper, as suggested in Nadeem’s post (so I won’t post the code here).

You can also note that we are using a FileSystemCheckCache. This is to avoid asking the File System everytime we need to figure out if a localized file exists. This can be a heave burden if you use UI frameworks with lots and lots of javascript and sprites.

So now, let’s enable the Handler on your web.config.

Put this inside your web.config’s configuration/system.web/httpHandlers tag:

1
2
3
4
5
6
7
8
<httpHandlers>
  <add verb="GET" path="*.gif" type="YourWebSite.Handlers.LocalizedStaticFileHandler" />
  <add verb="GET" path="*.jpeg" type="YourWebSite.Handlers.LocalizedStaticFileHandler" />
  <add verb="GET" path="*.jpg" type="YourWebSite.Handlers.LocalizedStaticFileHandler" />
  <add verb="GET" path="*.png" type="YourWebSite.Handlers.LocalizedStaticFileHandler" />
  <add verb="GET" path="*.css" type="YourWebSite.Handlers.LocalizedStaticFileHandler" />
  <add verb="GET" path="*.js" type="YourWebSite.Handlers.LocalizedStaticFileHandler" />
</httpHandlers>

And if you use IIS7 or above, then you’ll need to put the following within yourconfiguration/system.webServer/handlers tag:

1
2
3
4
5
6
7
<handlers><br>  <add name="LocaleGIF" verb="GET" path="*.gif" type="YourWebSite.Handlers.LocalizedStaticFileHandler" preCondition="integratedMode" />
  <add name="LocaleJPEG" verb="GET" path="*.jpeg" type="YourWebSite.Handlers.LocalizedStaticFileHandler" preCondition="integratedMode" />
  <add name="LocaleJPG" verb="GET" path="*.jpg" type="YourWebSite.Handlers.LocalizedStaticFileHandler" preCondition="integratedMode" />
  <add name="LocalePNG" verb="GET" path="*.png" type="YourWebSite.Handlers.LocalizedStaticFileHandler" preCondition="integratedMode" />
  <add name="LocaleCSS" verb="GET" path="*.css" type="YourWebSite.Handlers.LocalizedStaticFileHandler" preCondition="integratedMode" />
  <add name="LocaleJS" verb="GET" path="*.js" type="YourWebSite.Handlers.LocalizedStaticFileHandler" preCondition="integratedMode" />
</handlers>

and voila! You should now be able to just create localized versions of your files within a culture-specific subfolder.

- ~/Content/
  +-- images/
      +-- es-ES/
          +-- logo.png
      +-- logo.png

The Pros

  • You can continue to use standard Url.Content calls to generate content (or whatever approach you were already using for your ASP.NET application). Your developers won’t have to remember to use the localized version of your URL generators.
  • From a strict content perspective, you are reflecting the fact that you are serving the very same resource, only with localization tweaks. This means it’s your same company’s logo… just with a little subtitle in a different language.

The Cons

  • You may face a slight performance impact since we are checking for every single static file you serve if there is a localized version of it. You could apply the handler to specific subpaths or play with the Cache size to overcome this.
  • The Cache implementation is a little rough, and lots can be improved on this sense.