How to deliver ASP.NET Themes as a single, optimized resource
Share
I use ASP.NET Themes in many of my projects to take advantage of organizing styles and images in one logical area that is dynamically configurable at runtime. I also like to create a separate stylesheet for each component and organize them in their own folders, so I know exactly where to look when I’m changing my site’s appearance. After declaring a theme for my pages, ASP.NET fetches my style information for me and I go about my business.
At runtime, however, my best practices turn into a huge list of header links to each one of those nested style files I’ve carefully created. With little effort, I can surpass IE’s limit on the number of external CSS styles allowed on a page (it’s 30) and crash my application, which is always a difficult problem to diagnose. On top of that, because ASP.NET is doing the moving, I can’t compress, minify, or cache those themes on the server, I just have to grin and bear it, tweak IIS, remove my precious comments and readability and compact the CSS myself, or find an alternative to using ASP.NET Themes how I’d like.
I decided to build an IHttpHandler that will let me combine all of my theme scripts at runtime, minify them (using my port of the YUI Compressor’s CSS minification algorithm), and cache them on the server based on their file dependencies, so that the server will invalidate and rebuild the resource if I change any of the style files associated with that theme. Add stream compression and client caching for good measure, and what I get is a great style combiner that I don’t have to think about.
Ready to build this?
Our first task is to build out the IHttpHandler whose responsibility is to detect when we’re asking for a CSS resource and to perform the compression, combining, and caching mechanisms. Since we’re dealing with themes, we’ll need to be able to collect all of the CSS files located in a Theme folder, just as ASP.NET Themes does for us automatically.
First we’ll cook up a generic handler base that provides a compression method. Thanks to .NET 2.0, adding compression to an HttpResponse is straightforward: we just check what the client browser prefers to accept, GZip or Deflate, and wrap the original stream in the preferred flavor.
namespace Dimebrain.Handlers
{
public abstract class CompressionHandler : IHttpHandler
{
public abstract void ProcessRequest(HttpContext context);
public static Stream Compress(HttpContext context, HttpResponse response, Stream stream)
{
var encoding = context.Request.Headers["Accept-Encoding"];
if (encoding.IsNotNullOrEmpty())
{
var gzip = "gzip";
var deflate = "deflate";
var header = "Content-Encoding";
if(encoding.ContainsIgnoreCase(gzip))
{
response.AddHeader(header, gzip);
stream = new GZipStream(stream, CompressionMode.Compress);
}
else if (encoding.ContainsIgnoreCase(deflate))
{
response.AddHeader(header, deflate);
stream = new DeflateStream(stream, CompressionMode.Compress);
}
}
return stream;
}
public virtual bool IsReusable
{
get
{
return true;
}
}
}
}
And before you ask, here are the extension methods I used in the base class above:
public static class Extensions
{
public static bool IsNotNullOrEmpty(this string input)
{
return !String.IsNullOrEmpty(input);
}
public static bool ContainsIgnoreCase(this string left, string right)
{
var pattern = new Regex(right, RegexOptions.IgnoreCase);
return pattern.IsMatch(left);
}
}
Now we have a base class for our CSS handler. Our handler has access to the HttpContext request, just like a Page, so we can pass it a query string. To make it more useful for other purposes, we’ll define query string parameters that allow it to handle a theme, but also a single file, a directory, or a particular page.
public class CssHandler : CompressionHandler
{
public const string ContentType = "text/css";
public override void ProcessRequest(HttpContext context)
{
// Collect the query parameters
var page = context.Request.QueryString["p"];
var theme = context.Request.QueryString["t"];
var file = context.Request.QueryString["f"];
var directory = context.Request.QueryString["d"];
// Identify the response as CSS
var response = context.Response;
response.ContentType = ContentType;
// Wrap the response in a compression stream
var output = response.OutputStream;
output = Compress(context, response, output);
// Process any query parameters
ProcessByPage(context, page, output);
ProcessByTheme(context, theme, output);
ProcessByFile(context, file, output);
ProcessByDirectory(context, directory, output);
}
}
So far, our handler is simply collecting any parameters passed to it in the query string, setting the output to return as a compressed stream if the browser supports it by making use of our base class method, and calling methods to process the query parameters that are found. Next we’ll fill in our processing methods.
private static void ProcessByPage(HttpContext context, string page, Stream output)
{
if (page.IsNotNullOrEmpty())
{
switch (page)
{
case "MyPage":
// Typically you would determine what paths to combine using a data store
Combine(context, output, "~/Css");
break;
default:
break;
}
}
}
private static void ProcessByTheme(HttpContext context, string theme, Stream output)
{
if (theme.IsNotNullOrEmpty())
{
// Get all theme directories
var path = context.Server.MapPath("~/App_Themes/{0}".Fill(theme));
CombineFromDirectory(context, output, path);
}
}
private static void ProcessByFile(HttpContext context, string file, Stream output)
{
if (file.IsNotNullOrEmpty())
{
Combine(context, output, file.MapPathReverse());
}
}
private static void ProcessByDirectory(HttpContext context, string directory, Stream output)
{
if (directory.IsNotNullOrEmpty())
{
directory = context.Server.MapPath(directory);
CombineFromDirectory(context, output, directory);
}
}
Each of these methods has a companion static Combine method that’s going to do the real work. The processing methods are used to determine which directories we’ll be scanning for CSS files to combine. Remember, our handler wants to cache the results of combining so that we aren’t taxing our server every time a new client requests this information; if the underlying files haven’t changed, we should continue to serve the same content right out of the cache.
- ProcessByPage: This is a rudimentary example of how the handler might process a query string for a page. For example, I might include in my header control the following link in a page called MyPage.aspx:
<link href="css.axd?p=MyPage" type="text/css" rel="stylesheet" />
In the handler, we could define, or retrieve, a manifest list of directories that contain all of the styles that page requires. - ProcessByTheme: This is the method we’re most interested in. It will take a theme name, and use the CombineByDirectory method to collect all of the files in the entire sub-directory tree within that theme in order to combine them.
- ProcessByFile: This process will simply attempt to process a single file that is defined as a virtual path including the file name.
- ProcessByDirectory: Borrowing from the themes implementation, this will process an entire directory, including its sub-folders.
Here are the extension methods I’m using in the code example above:
public static class Extensions
{
public static bool IsNotNull(this object instance)
{
return instance != null;
}
// Returns the virtual file path for a specified physical file path
public static string MapPathReverse(this string path)
{
var context = HttpContext.Current;
if (context.IsNotNull())
{
if (context.Request.PhysicalApplicationPath.IsNotNull())
{
var root = context.Request.PhysicalApplicationPath.TrimEnd('');
var relative = path.Replace(root, String.Empty);
var clean = relative.Replace('', '/');
return clean.Insert(0, "~");
}
}
return String.Empty;
}
public static string Fill(this string format, params object[] args)
{
return String.Format(format, args);
}
}
Now that we have our common handler API in place, we can fill in the combination methods that do the work of combining CSS files and caching them per their parent relative directory.
private static void CombineFromDirectory(HttpContext context, Stream output, string directory)
{
// Get a list of all sub-directories and add the parent
var directories = Directory.GetDirectories(directory, "*", SearchOption.AllDirectories).ToList();
directories.Add(directory);
// Convert paths back to virtual
for (var p = 0; p < directories.Count; p++)
{
directories[p] = directories[p].MapPathReverse();
}
// Combine the directory manifest
Combine(context, output, directories);
}
private static void Combine(HttpContext context, Stream output, string relativePath)
{
Combine(context, output, new[] { relativePath });
}
private static void Combine(HttpContext context, Stream output, params string[] relativePaths)
{
Combine(context, output, relativePaths.ToList());
}
private static void Combine(HttpContext context, Stream output, IEnumerable<string> relativePaths)
{
using (var sw = new StreamWriter(output))
{
// HttpRuntime is faster than HttpContext.Current
var cache = HttpRuntime.Cache;
foreach (var relativePath in relativePaths)
{
// Check the cache for this relative path first
if (cache[relativePath].IsNull())
{
var sb = new StringBuilder();
var path = context.Server.MapPath(relativePath);
var files = new List<string>();
// Test that the provided path is not a full file
var pathAsFile = Path.GetFileName(path);
if (!pathAsFile.Contains('.'))
{
files.AddRange(Directory.GetFiles(path));
}
else
{
// Already a file
files.Add(path);
}
foreach (var file in files)
{
if (file.ContainsIgnoreCase(".css"))
{
// Read the file and minify it
var minified = File.ReadAllText(file).CssMinify();
// Write the file to a temporary builder
sb.Append(minified);
}
}
// Combine the string content
var content = sb.ToString();
// Create a cache dependency based on the files we just combined
var dependency = new CacheDependency(files.ToArray());
// Cache the content by path name, so we don't combine it twice
cache.Insert(relativePath, content, dependency);
sw.Write(content);
}
else
{
// It's already cached, so serve it up
var existing = cache[relativePath].ToString();
sw.Write(existing);
}
}
}
}
public static class Extensions
{
public static bool IsNull(this object instance)
{
return instance == null;
}
public static string CssMinify(this string input)
{
// See my post, "A better CSS minifier"
return input;
}
}
// Cache our work on the client
var clientCache = context.Response.Cache;
clientCache.SetCacheability(HttpCacheability.Public);
clientCache.SetValidUntilExpires(true);
clientCache.SetLastModifiedFromFileDependencies();
clientCache.SetETagFromFileDependencies();
(Note: You may want to customize this cache policy depending on your situation, i.e. you prefer cache control headers over ETags)
<add verb="*" path="css.axd" type="Dimebrain.Handlers.CssHandler" validate="false"/>
To provide automatic theme combining, we override the Page’s OnPreInit method, where we expect to find the links ASP.NET added to the page for us. What we’re going to do is collect all of those links, and replace them with a link to our handler requesting the page’s theme:
public class MyPage : Page
{
protected override void OnPreRender(EventArgs e)
{
CombineCssLinks();
base.OnPreRender(e);
}
private void CombineCssLinks()
{
var hasTheme = false;
var theme = this.Theme;
foreach (var link in Page.Header.Controls.OfType<HtmlLink>().ToList())
{
var attributes = link.Attributes; var href = attributes["href"]; var type = attributes["type"];
var isCss = type.EqualsIgno
reCase("text/css");
if (isCss && href.StartsWith("~"))
{
var url = ResolveUrl(href);
var file = Path.GetFileName(href);
var location = Server.UrlEncode(href);
if (url.ContainsIgnoreCase("App_Themes/{0}".Fill(theme)))
{
if (!hasTheme)
{
var query = String.Concat("css.axd?t=", theme);
attributes["href"] = url.Replace(file, query);
hasTheme = true;
}
else
{
// We've already injected a query for this theme; remove link
link.Parent.Controls.Remove(link);
}
}
else
{
// Make query from the same directory to preserve CSS URLs
var query = "css.axd?f=" + location;
attributes["href"] = url.Replace(file, query);
}
}
}
}
}
Here’s the missing extension method:
public static class Extensions
{
public static bool EqualsIgnoreCase(this string left, string right)
{
return String.Compare(left, right, true) == 0;
}
}
Now, your code will intercept the current ASP.NET Theme and serve it up as single, minified, compressed, and cached resource link! This will reduce your page size and increase its performance without requiring too much intervention on your part. And you can reuse the code for other CSS resources.
Socialized