Sex, drugs and sausage rolls

Tech and Life… as seen by Tallmaris (il Gran Maestro)

Advanced Bundling and Minification of Coffeescripts in MVC4

Hi All,

In the past couple of days I have tried to solve a problem in ASP.NET and MVC4 regarding the bundling and minification of Coffee files. You can see the details in my question on stackoverflow.

Basically I am trying to have my coffee scripts, defined in a bundle, to be rendered separately when in debug (but after being compiled into js of course) and rendered as a single minified bundle in production.

The latter part of the problem is easily accomplished by simply defining the bundle as such:

bundles.Add(new Bundle("~/bundles/appcoffee", new CoffeeBundler(), new JsMinify()).Include(
            "~/Scripts/app/*.coffee",
            "~/Scripts/app/routers/*.coffee",
            "~/Scripts/app/models/*.coffee",
            "~/Scripts/app/collections/*.coffee",
            "~/Scripts/app/views/*.coffee"));

Yes, it is a Backbone app! CoffeeBundler is a custom IBundleTransform. See at the bottom of the article to get the code.

Now, to get them to be rendered as split files, it would be nice to have an helper similar to Scripts.Render() for Coffee files. This helper should render separate script tags, one for each coffee file. Moreover, we need to make sure that the rendered script tag points to a Javascript file, since the browser still does not know how to compile coffeescript!

I have come up with a solution for this, which is still in its early stages but shows pretty much what I have in mind. Having some of the System.Web.Optimization classes (like AssetManager) open for use rather than defined as internal would of course make things a lot easier.

The Helper

I have decided to create an HtmlHelper extension for this. Let’s have a look at the code and comment on it:

    public static class HtmlHelperExtensions
    {
        public static MvcHtmlString RenderCoffeeBundle(this HtmlHelper htmlHelper, string virtualPath)
        {
            if (String.IsNullOrEmpty(virtualPath))
                throw new ArgumentException("virtualPath must be defined", "virtualPath");

            var list = GetPathsList(virtualPath);
            // The GetPathsList method tries to emulate the
            // EliminateDuplicatesAndResolveUrls method found in the AssetManager.

            var stringBuilder = new StringBuilder();
            foreach (string path in list)
            {
                stringBuilder.Append(RenderScriptTag(path));
                stringBuilder.Append(Environment.NewLine);
            }
            return MvcHtmlString.Create(stringBuilder.ToString());
        }
    }

Ok, the main job here is to try and emulate the EliminateDuplicatesAndResolveUrls. We do that in the GetPathsList method:

        private static IEnumerable GetPathsList(string virtualPath)
        {
            var list = new List();

            if (BundleResolver.Current.IsBundleVirtualPath(virtualPath))
            {
                if (!BundleTable.EnableOptimizations)
                {
                    foreach (var path in BundleResolver.Current.GetBundleContents(virtualPath))
                    {
                        var bundlePath = "~/autoBundle" + ResolveVirtualPath(path.Replace("coffee", "js"));
                        BundleTable.Bundles.Add(new Bundle(bundlePath, new CoffeeBundler()).Include(path));
                        // TODO: Get the actual CustomTransform used in the Bundle
                        // rather than forcing "new CoffeeBundler()" like here
                        list.Add(bundlePath);
                    }
                }
                else
                    list.Add(BundleResolver.Current.GetBundleUrl(virtualPath));
            }
            else
                list.Add(virtualPath);

            return list.Select(ResolveVirtualPath).ToList();
        }

        private static string RenderScriptTag(string path)
        {
            return "<script src=\"" + HttpUtility.UrlPathEncode(path) + "\"></script>";
        }

        private static string ResolveVirtualPath(string virtualPath)
        {
            return VirtualPathUtility.ToAbsolute(virtualPath);;
        }

We first check to see if the passed path is defined as a Bundle Virtual Path and if it’s not we just add it and render it as it is.

If it is a bundle path, we then decide if we are in debug or not and proceed accordingly.

If debug is false this is as simple as rendering the script tag with the BundleUrl coming from the resolver: this will add an hash as a query parameter that will change only when the files in the bundle change to facilitate browser caching.

If we are in debug mode, we need to split the bundle into separate files first. Then for each file we need to create a script tag, but we also need, since the file is a coffee file, to define a Bundle that uses our CoffeeBundler to compile the file into Javascript.

We create the bundle in the usual way adding it to the BundleTable; we use the full path as the bundle link (we replace .coffee with .js just to make the resource content clearer) and prepend it with a string like “~/autobundle/” to avoid conflicts with other defined bundles.

As the TODO comment in the code explains, it would be nice to have a way of getting the IBundleTransform from the ones already defined in the Bundle rather than hardcoding new CoffeeBundler(). I could not find a way to do it but if any one has any idea is more than welcome to contribute.

To use the helper in a Razor View, with the bundle defined like at the top of the article, is as simple as doing:

@Html.RenderCoffeeBundle("~/bundles/appcoffee")

The results

With debug set to false (note the hash in the query parameter):

<script type="text/javascript" src="/bundles/appcoffee?v=n9AwwPjNemn8LF0uzbkCPpfy150XckBCIDYvPfW2QHk1"></script>

With debug set to true:

<script type="text/javascript" src="/autoBundle/Scripts/app/App.js"></script>
<script type="text/javascript" src="/autoBundle/Scripts/app/routers/AppRouter.js"></script>
<script type="text/javascript" src="/autoBundle/Scripts/app/models/MyModel.js"></script>
<script type="text/javascript" src="/autoBundle/Scripts/app/collections/MyCollection.js"></script>
<script type="text/javascript" src="/autoBundle/Scripts/app/views/MyView.js"></script>

Bonus content: The Custom Transform

The CoffeeBundler class defined above is an implementation of IBundleTransform. I have used CoffeeSharp as the engine for compiling coffeescripts, you can easily add it to your solution using NuGet. This is the code, you can find a lot of resources online with more details:

public class CoffeeBundler : IBundleTransform
{
    public void Process(BundleContext context, BundleResponse response)
    {
        var coffeeScriptEngine = new CoffeeSharp.CoffeeScriptEngine();
        string compiledCoffeeScript = String.Empty;
        foreach (var file in response.Files)
        {
            using (var reader = new StreamReader(file.FullName))
            {
                compiledCoffeeScript += coffeeScriptEngine.Compile(reader.ReadToEnd());
                reader.Close();
            }
        }
        response.Content = compiledCoffeeScript;
        response.ContentType = "text/javascript";
    }
}

, , , ,

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.