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"; } }
Entity Framework Primer: Initial Configuration and Migration Build and deploy .NET projects with Rake and Albacore