November 30, 2012

Adding LESS support to the ASP.NET Optimization Framework

Please note this article applies to the current alpha release. A key difference in this release is that it now uses ASP.NET’s virtual path provider to load bundle files rather than touching the physical file system. You can read more information about the release here.

In this post I explain how to configure the asset optimization features introduced in ASP.NET MVC 4 to handle LESS.

You’ll find a good introduction to these features on the ASP.NET web site.

The bundling framework provides the IBundleTransform interface for custom processing of bundle files.

According to the article, adding support for LESS should be as simple as creating the following transform class (making use of the open source DotLess library):

public class LessTransform : IBundleTransform
{
    public void Process(BundleContext context, BundleResponse response)
    {
        response.Content = dotless.Core.Less.Parse(response.Content);
        response.ContentType = "text/css";
    }
}

Unfortunately, whilst this may work for a very simple LESS file, you’ll find things start to break if your LESS file contains @import statements.

The reason for this is that the dotless parser doesn’t have any reference to the original file location. The solution is to override how @import paths are resolved by dotless.

The standard FileReader class in the dotless library uses am IPathResolver interface to resolve file paths:

public string GetFileContents(string fileName)
{
    fileName = PathResolver.GetFullPath(fileName);

    return File.ReadAllText(fileName);
}

Since (in the alpha release) BundleResponse.BundleFiles is a collection of VirtualFile instances rather than physical file paths, it makes sense to use the VirtualFileReader available in the dotless library.

Unfortunately for us, this implementation does not use an IPathResolver to resolve the file paths, so let’s create one that does:

public class VirtualFileReader : IFileReader
{
    public IPathResolver PathResolver { get; set; }

    public VirtualFileReader(IPathResolver pathResolver)
    {
        Ensure.Argument.NotNull(pathResolver, "pathResolver");
        PathResolver = pathResolver;
    }

    /// <summary>
    /// Returns the binary contents of the specified file.
    /// </summary>
    /// <param name="fileName">The relative, absolute or virtual file path.</param>
    /// <returns>The contents of the specified file as a binary array.</returns>
    public byte[] GetBinaryFileContents(string fileName)
    {
        Ensure.Argument.NotNullOrEmpty(fileName, "fileName");
        fileName = PathResolver.GetFullPath(fileName);

        var virtualPathProvider = HostingEnvironment.VirtualPathProvider;
        var virtualFile = virtualPathProvider.GetFile(fileName);
        using (var stream = virtualFile.Open())
        {
            var buffer = new byte[stream.Length];
            stream.Read(buffer, 0, (int)stream.Length);
            return buffer;
        }
    }

    /// <summary>
    /// Returns the string contents of the specified file.
    /// </summary>
    /// <param name="fileName">The relative, absolute or virtual file path.</param>
    /// <returns>The contents of the specified file as string.</returns>
    public string GetFileContents(string fileName)
    {
        Ensure.Argument.NotNullOrEmpty(fileName, "fileName");
        fileName = PathResolver.GetFullPath(fileName);

        var virtualPathProvider = HostingEnvironment.VirtualPathProvider;
        var virtualFile = virtualPathProvider.GetFile(fileName);
        using (var streamReader = new StreamReader(virtualFile.Open()))
        {
            return streamReader.ReadToEnd();
        }
    }

    /// <summary>
    /// Returns a value that indicates if the specified file exists.
    /// </summary>
    /// <param name="fileName">The relative, absolute or virtual file path.</param>
    /// <returns>True if the file exists, otherwise false.</returns>
    public bool DoesFileExist(string fileName)
    {
        Ensure.Argument.NotNullOrEmpty(fileName, "fileName");
        fileName = PathResolver.GetFullPath(fileName);

        var virtualPathProvider = HostingEnvironment.VirtualPathProvider;
        return virtualPathProvider.FileExists(fileName);
    }

    public bool UseCacheDependencies { get { return false; } }
}

This implemenation now resolves each fileName by calling PathResolver.GetFullPath.

We now need to create an IPathResolver implementation that can convert both absolute and relative paths into virtual paths. This is necessary because your @import paths are likely to be relative. In fact, the official LESS compiler only supports relative paths (important if you’re using the Web Essentials extension for Visual Studio).

public class VirtualPathResolver : IPathResolver
{
    private string currentFileDirectory;
    private string currentFilePath;

    public VirtualPathResolver(string currentFilePath)
    {
        Ensure.Argument.NotNullOrEmpty(currentFilePath, "currentFilePath");
        CurrentFilePath = currentFilePath;
    }

    /// <summary>
    /// Gets or sets the path to the currently processed file.
    /// </summary>
    public string CurrentFilePath
    {
        get { return currentFilePath; }
        set
        {
            currentFilePath = value;
            currentFileDirectory = VirtualPathUtility.GetDirectory(value);
        }
    }

    /// <summary>
    /// Returns the virtual path for the specified file <param name="path"/>.
    /// </summary>
    /// <param name="path">The imported file path.</param>
    public string GetFullPath(string path)
    {
        Ensure.Argument.NotNullOrEmpty(path, "path");

        if (path[0] == '~') // a virtual path e.g. ~/assets/style.less
        {
            return path;
        }
        
        if (VirtualPathUtility.IsAbsolute(path)) // an absolute path e.g. /assets/style.less
        {
            return VirtualPathUtility.ToAppRelative(path,
                HostingEnvironment.IsHosted ? HostingEnvironment.ApplicationVirtualPath : "/");
        }

        // otherwise, assume relative e.g. style.less or ../../variables.less
        return VirtualPathUtility.Combine(currentFileDirectory, path);
    }
}

The VirtualPathResolver class takes care of generating valid virtual paths. For this to work we need to set the CurrentFilePath before we process each file in the bundle.

Finally we can create an IBundleTransform implementation for processing LESS files:

public class LessBundleTransform : IBundleTransform
{
    public void Process(BundleContext context, BundleResponse bundle)
    {
        Ensure.Argument.NotNull(context, "context");
        Ensure.Argument.NotNull(bundle, "bundle");

        context.HttpContext.Response.Cache.SetLastModifiedFromFileDependencies();

        var lessParser = new Parser();
        ILessEngine lessEngine = CreateLessEngine(lessParser);

        var content = new StringBuilder();

        var bundleFiles = new List<VirtualFile>();

        foreach (var bundleFile in bundle.Files)
        {
            bundleFiles.Add(bundleFile);

            SetCurrentFilePath(lessParser, bundleFile.VirtualPath);

            using (var reader = new StreamReader(VirtualPathProvider.OpenFile(bundleFile.VirtualPath)))
            {
                content.Append(lessEngine.TransformToCss(reader.ReadToEnd(), bundleFile.VirtualPath));
                content.AppendLine();

                bundleFiles.AddRange(GetFileDependencies(lessParser));
            }
        }

        if (BundleTable.EnableOptimizations)
        {
            // include imports in bundle files to register cache dependencies
            bundle.Files = bundleFiles.Distinct().ToList();
        }

        bundle.ContentType = "text/css";
        bundle.Content = content.ToString();
    }

    /// <summary>
    /// Creates an instance of LESS engine.
    /// </summary>
    /// <param name="lessParser">The LESS parser.</param>
    private ILessEngine CreateLessEngine(Parser lessParser)
    {
        var logger = new AspNetTraceLogger(LogLevel.Debug, new Http());
        return new LessEngine(lessParser, logger, true, false);
    }

    /// <summary>
    /// Gets the file dependencies (@imports) of the LESS file being parsed.
    /// </summary>
    /// <param name="lessParser">The LESS parser.</param>
    /// <returns>An array of file references to the dependent file references.</returns>
    private IEnumerable<VirtualFile> GetFileDependencies(Parser lessParser)
    {
        var pathResolver = GetPathResolver(lessParser);

        foreach (var importPath in lessParser.Importer.Imports)
        {
            yield return HostingEnvironment.VirtualPathProvider.GetFile(pathResolver.GetFullPath(importPath));
        }

        lessParser.Importer.Imports.Clear();
    }

    /// <summary>
    /// Returns an <see cref="IPathResolver"/> instance used by the specified LESS lessParser.
    /// </summary>
    /// <param name="lessParser">The LESS parser.</param>
    private IPathResolver GetPathResolver(Parser lessParser)
    {
        var importer = lessParser.Importer as Importer;
        var fileReader = importer.FileReader as VirtualFileReader;

        return fileReader.PathResolver;
    }

    /// <summary>
    /// Informs the LESS parser about the path to the currently processed file. 
    /// This is done by using a custom <see cref="IPathResolver"/> implementation.
    /// </summary>
    /// <param name="lessParser">The LESS parser.</param>
    /// <param name="currentFilePath">The path to the currently processed file.</param>
    private void SetCurrentFilePath(Parser lessParser, string currentFilePath)
    {
        var importer = lessParser.Importer as Importer;

        if (importer == null)
            throw new InvalidOperationException("Unexpected dotless importer type.");

        var fileReader = importer.FileReader as VirtualFileReader;

        if (fileReader == null)
        {
            importer.FileReader = new VirtualFileReader(new VirtualPathResolver(currentFilePath));
        }
        else
        {
            var pathResolver = fileReader.PathResolver as VirtualPathResolver;

            if (pathResolver == null)
            {
                fileReader.PathResolver = new VirtualPathResolver(currentFilePath);
            }
            else
            {
                pathResolver.CurrentFilePath = currentFilePath;
            }
        }
    }
}

Most of the work happens in the Process function. Here we loop through each virtual file in the bundle and perform the following actions:

  1. The current VirtualFile instance is added to the bundleFiles collection.
  2. We call SetCurrentFilePath which makes sure the VirtualFileReader class is used to load the LESS file contents and the VirtualPathResolver class is used to resolve paths. It also sets the VirtualPathResolver.CurrentFilePath to that of the current bundle file being processed.
  3. The bundle file contents are processed by the dotless engine. The result (CSS) is appended to the StringBuilder instance.
  4. We loop through each @import path for the current bundle file and obtain a VirtualFile instance that is added the bundleFiles collection.

After each bundle file has been processed we set the BundleResponse.Content to that of the StringBuilder instance.

Caching

Point 4 is particularly important. By default only files directly included in the bundle are monitored for changes. This means that changes to any LESS files only referenced via an @import will not trigger cache invalidation.

We therefore keep track of every file that is imported, adding it to the BundleResponse.Files collection when optimizations are enabled. This ensures that all of the LESS files are monitored for changes:

if (BundleTable.EnableOptimizations)
{
    // include imports in bundle files to register cache dependencies
    bundle.Files = bundleFiles.Distinct().ToList();
}

Registering LESS bundles

The Bundle class has a constructor that takes any number of IBundleTransform instances. Use this to register your LESS bundles:

bundles.Add(new Bundle("~/bundles/styles", new LessBundleTransform())
    .Include("~/assets/less/styles.less"));

Code

Should be pushed up to GitHub in a few days.

© 2022 Ben Foster