Localization for Scripts (JavaScript) and Style Sheets in ASP.NET






4.75/5 (14 votes)
Often JavaScript content and/or styles in ASP.NET need to be localized too. This article shows how this can be leveraged by using an HttpHandler.
Introduction
In ASP.NET applications, replacing strings by their respective resources often isn't enough to localize. Most of the applications nowadays extensively use JavaScript especially to utilize AJAX. Hence, it is likely that the JavaScript gets moved into a separate file in order to keep the markup neat.
This article shows how JavaScript and style sheet content can be localized using an HttpHandler
.
Background
Note: This article is thought as a 'How to' more than a detailed description of all the technologies used. Hence, it is targeted at intermediate and advanced programmers.
The idea is that all requests for *.js and *.css files get processed by some custom written code. That's where HttpHandler
s come in handy. The handler gets executed every time a file type which it is registered for gets requested. This registration is configured in the web.config file.
Our HttpHandler
then replaces all resource place holders by their respective localized strings stored in a resource file.
Preparing Resources
If the resources are to be stored in a separate library, a couple of things need to be kept in mind.
In order to be able to access the resources, the access modifier of the neutral culture resource file wrapper class needs to be configured as public
. This can be achieved by double clicking the resource from the Solution Explorer and then changing the 'Access Modifier' dropdown (as shown in the picture below) to Public.
If the resources live in the App_GlobalResources folder of the web application and the HttpHandler
does not live within the web application project, the following line has to be added to the AssemblyInfo.cs file of the web application:
[assembly: InternalsVisibleTo("SmartSoft.Web")]
This makes all internal types of the web application visible for the declared assembly.
The specified string needs to correspond to the external library containing the HttpHandler
. If that assembly is signed, it is mandatory that the fully qualified name is used including the public key token.
Preparing a JavaScript File
In order to know where resources shall be loaded from, the JavaScript file needs to have some extra information attached. The resources configuration has to be contained in XML as shown below being commented out. It doesn't matter where this definition is located within the file, and the file can contain multiple definitions too.
//<resourceSettings> // <resourceLocation name="WebScriptResources" // type="SmartSoft.Resources.WebScripts, // SmartSoft.Resources, Version=1.0.0.0, Culture=neutral" /> //</resourceSettings>
The name
defines the resource key used in the resource place holder(s) and the type
is the fully qualified type name used to load the requested resource on runtime using Reflection.
The resource place holders can then be used everywhere in the file like this:
alert("<%$ Resources: WebScriptResources, TestAlert %>");
You may have noticed that the resource declaration is exactly the same as the standard one used by the .NET Framework. This helps if the file content gets extracted from an existing markup. :)
Preparing a CSS File
The resource configuration for a style sheet is exactly the same as described above for JavaScript files.
/* <resourceSettings> <resourceLocation name="WebStylesResources" type="HelveticSolutions.Resources.WebStyles, HelveticSolutions.Resources, Version=1.0.0.0, Culture=neutral" /> </resourceSettings> */
And the place holders:
body { background-color: <%$ Resources: WebStylesResources, BodyBackgroundColor %>; }
The web.config
A HttpHandler
can be configured for any type of request. For the ResourceHandler
, the following two entries need to be added for the *.js and *.css file extensions:
<system.web> <httpHandlers> <add path="*.js" verb="*" type="HelveticSolutions.Web.Internationalization.ResourceHandler, HelveticSolutions.Web, Version=1.0.0.0, Culture=neutral" validate="false"/> <add path="*.css" verb="*" type="HelveticSolutions.Web.Internationalization.ResourceHandler, HelveticSolutions.Web, Version=1.0.0.0, Culture=neutral" validate="false"/> </httpHandlers> </system.web>
This causes the web server to re-route the requests to the HttpHandler
.
In case of using IIS as web server, the application mappings need to be extended for these two file extensions. A description of how to do that can be found here.
It is important that the option 'Verify that file exists' is not checked.
The ResourceHandler (HttpHandler)
Most of the ResourceHandler
is pretty much self-explanatory. However, the ApplyCulture
event may need some explanation.
Since there might be several simultaneous requests (hopefully), it is crucial that each and every request sets the culture according to the user's latest choice before replacing the resource place holders. In the attached example, the Session
is used as storage to cache the user's language preference. Alternatively, it could be determined from the browser settings or somewhere else.
In any case, it is strongly recommended to register the ApplyCulture
event during the session start as outlined under "The Global.asax file".
using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using System.Threading; using System.Web; using System.Web.SessionState; using System.Xml.Linq; namespace HelveticSolutions.Web.Internationalization { /// <summary> /// A custom HTTP handler that replaces resource /// place holders by their respective localized values. /// </summary> public class ResourceHandler : IHttpHandler, IRequiresSessionState { #region members /// <summary>Regex pattern to extract /// the resource place holders.</summary> private const string RESOURCEPATTERN = @"<\x25{1}\x24{1}\s*Resources\s*:\s*(?<declaration >\w+\s*,\s*\w+)\s*%>"; /// <summary>Regex pattern to extract /// the resource location settings.</summary> private const string SETTINGSPATTERN = @"<resourcesettings>(?>.|\n)+?resourceSettings>"; /// <summary>Caches the default culture set /// when the handler got instantiated.</summary> private CultureInfo defaultCulture; #endregion public delegate CultureInfo OnApplyCulture(); public static event OnApplyCulture ApplyCulture; #region constructor /// <summary> /// Initializes a new instance of the <see cref="ResourceHandler" /> class. /// </summary> public ResourceHandler() { defaultCulture = Thread.CurrentThread.CurrentCulture; } #endregion #region IHttpHandler Members /// <summary> /// Gets a value indicating whether another request can use /// the <see cref="T:System.Web.IHttpHandler" /> instance. /// </summary> /// <returns> /// True if the <see cref="T:System.Web.IHttpHandler"/> /// instance is reusable; otherwise, false. public bool IsReusable { get { return true; } } /// <summary> /// Enables processing of HTTP Web requests by a custom HttpHandler /// that implements the <see cref="T:System.Web.IHttpHandler" /> interface. /// </summary> /// <param name="context"> /// An <see cref="T:System.Web.HttpContext" /> object that /// provides references to the intrinsic server objects (for example, Request, /// Response, Session, and Server) used to service HTTP requests. /// </param> public void ProcessRequest(HttpContext context) { context.Response.Buffer = false; // Retrieve culture information from session if (ApplyCulture != null) { CultureInfo culture = ApplyCulture() ?? defaultCulture; if (culture != null) { // Set culture to current thread Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(culture.Name); Thread.CurrentThread.CurrentUICulture = culture; } } string physicalFilePath = context.Request.PhysicalPath; string fileContent = string.Empty; string convertedFile = string.Empty; // Determine whether file exists if (File.Exists(physicalFilePath)) { // Read content from file using (StreamReader streamReader = File.OpenText(physicalFilePath)) { fileContent = streamReader.ReadToEnd(); if (!string.IsNullOrEmpty(fileContent)) { // Load resource location types Dictionary<string,> locationTypes = GetResourceLocationTypes(fileContent); // Find and replace resource place holders convertedFile = ReplaceResourcePlaceholders(fileContent, locationTypes); } } } context.Response.ContentType = GetHttpMimeType(physicalFilePath); context.Response.Output.Write(convertedFile); context.Response.Flush(); } #endregion /// <summary> /// Gets the resource location types defined as resource settings in a string. /// </summary> /// <param name="fileContent">The file content.</param> /// <returns>A <see cref="Dictionary{TKey,TValue}"/> /// containing all resource location types.</returns> private static Dictionary<string,> GetResourceLocationTypes(string fileContent) { try { // Attempt to extract the resource settings from the file content Match settingsMatch = Regex.Match(fileContent, SETTINGSPATTERN); Dictionary<string,> resourceLocations = new Dictionary<string,>(); while (settingsMatch.Success) { // Get matched string and clean it up string value = settingsMatch.Groups[0].Value.Replace("///", string.Empty).Replace("//", string.Empty); XElement settings = XElement.Parse(value); // Load resource location assemblies and reflect the resource type Dictionary<string,> newLocations = settings.Elements("resourceLocation") .Where(el => el.Attribute("name") != null && el.Attribute("type") != null) .ToDictionary( el => el.Attribute("name").Value, el => GetTypeFromFullQualifiedString (el.Attribute("type").Value)); // Merge resource location dictionaries resourceLocations = new[] { resourceLocations, newLocations } .SelectMany(dict => dict) .ToDictionary(pair => pair.Key, pair => pair.Value); // Find next regex match settingsMatch = settingsMatch.NextMatch(); } return resourceLocations; } catch (Exception ex) { // Attempt to read resource settings failed // TODO: Write to log return null; } } /// <summary> /// Replaces the resource placeholders. /// </summary> /// <param name="fileContent">Content of the file.</param> /// <param name="resourceLocations">The resource locations.</param> /// <returns>File content with localized strings.</returns> private static string ReplaceResourcePlaceholders(string fileContent, IDictionary<string,> resourceLocations) { string outputString = fileContent; Match resourceMatch = Regex.Match(fileContent, RESOURCEPATTERN); List<string> replacedResources = new List<string >(); while (resourceMatch.Success) { // Determine whether a valid match was found if (resourceMatch.Groups["declaration"] != null) { // Extract resource arguments -> always two // arguments expected: 1. resource location name, 2. resource name string[] arguments = resourceMatch.Groups["declaration"].Value.Split(','); if (arguments.Length < 2) { throw new ArgumentException("Resource declaration"); } string resourceLocationName = arguments[0].Trim(); string resourceName = arguments[1].Trim(); // Determine whether the same resource has been // already replaced before if (!replacedResources.Contains(string.Concat( resourceLocationName, resourceName))) { Type resourceLocationType; if (resourceLocations.TryGetValue(resourceLocationName, out resourceLocationType)) { PropertyInfo resourcePropertyInfo = resourceLocationType.GetProperty(resourceName); if (resourcePropertyInfo != null) { // Load resource string string localizedValue = resourcePropertyInfo.GetValue(null, BindingFlags.Static, null, null, null).ToString(); // Replace place holder outputString = outputString.Replace(resourceMatch.Groups[0].Value, localizedValue); // Cache replaced resource name replacedResources.Add(string.Concat (resourceLocationName, resourceName)); } else { throw new ArgumentException("Resource name"); } } else { throw new ArgumentException("Resource location"); } } } // Find next regex match resourceMatch = resourceMatch.NextMatch(); } return outputString; } /// <summary> /// Determines and returns the <see cref="Type" /> /// of a given full qualified type string. /// </summary> /// <param name="typeString">The full qualified type string. E.g. /// 'SampleLibrary.SampleType, SampleLibrary, /// Version=1.0.0.0, Culture=neutral'.</param> /// <returns>The determined <see cref="Type"/>.</returns> private static Type GetTypeFromFullQualifiedString(string typeString) { // Extract resource manager type name and // full qualified assembly name from string string fullQualifiedAssemblyName = typeString.Substring(typeString.IndexOf(",") + 1, typeString.Length - 1 - typeString.IndexOf(",")).Trim(); string fullTypeName = typeString.Split(',')[0].Trim(); // Determine whether assembly is already loaded Assembly repositoryAssembly = AppDomain.CurrentDomain.GetAssemblies() .Where(a => !string.IsNullOrEmpty(a.FullName) && a.FullName.Equals(fullQualifiedAssemblyName)) .FirstOrDefault(); if (repositoryAssembly == null) { // Load assembly repositoryAssembly = AppDomain.CurrentDomain.Load(fullQualifiedAssemblyName); } // Attempt to load type return repositoryAssembly.GetTypes() .Where(t => t.FullName.Equals(fullTypeName)) .FirstOrDefault(); } /// <summary> /// Gets the HTTP MIME type. /// </summary> /// <param name="fileName">Name of the file.</param> /// <returns>The HTTP MIME type.</returns> private static string GetHttpMimeType(string fileName) { string fileExtension = new FileInfo(fileName).Extension; switch (fileExtension) { case "js": return "application/javascript"; case "css": return "text/css"; default: return "text/plain"; } } } }
Processing content:
- Set the current thread culture.
- Load the requested file content from the file system.
- Extract resource configuration(s) using a Regular Expression.
- Try to load each configured resource type using Reflection.
- Extract resource place holders using a Regular Expression and replace them with the respective localized string using Reflection.
- Return modified file content.
The Global.asax File
As mentioned above, at the session start, the ApplyCulture
event of the ResourceHandler
should be registered. This enables the web application to handle multiple simultaneous requests from users with different culture settings.
protected void Session_Start(object sender, EventArgs e) { ResourceHandler.ApplyCulture += ResourceHandler_ApplyCulture; } private CultureInfo ResourceHandler_ApplyCulture() { // Retrieve culture information from session string culture = Convert.ToString(Session["Culture"]); // Check whether a culture is stored in the session if (!string.IsNullOrEmpty(culture)) { return new CultureInfo(culture); } // No culture info available from session -> pass on // the currently used culture for this session. return Thread.CurrentThread.CurrentCulture; }