AngularJS and .NET MVC: Convert templates from html to javascript and bundle app and templates together

I am working on a project where I have an AngularJS app on my own .NET MVC website, and I will display this app on several remote websites.

Published:

TL;DR: To get the AngularJS templates to load correctly on the remote website, I needed to convert the html template files to javascript and bundle them together with the rest of the app. This article will try to explain why and how to achieve this.

What happens when you run your AngularJS app on a remote website?

My AngularJS app is not very complex, it has this file structure in Visual Studio:

AngularJS project structure

The project is using the .NET MVC 4 framework. That does not affect how I make the AngularJS app per se, but I will be bundling the app using the .NET framework later in the article.

To use the app, I simply create a html page with this content:

<head>
    <script src="/Scripts/angular/angular.js"></script>
    <script src="/Scripts/angular/angular-route.js"></script>
    <script src="/CreditcardApp/CreditcardApp.js"></script>
    <script src="/CreditcardApp/CreditcardController.js"></script>
    <!-- etc., add references to all the js files -->
</head>

<body>
    <h1>The local website</h1>
    <div ng-app="CreditcardApp">
        <div ng-view=""></div>
    </div>
</body>


Now the app will be loaded into the div at the bottom of the page.

As you see, the template files (Creditcard.html and Home.html) are not referenced in the html page. They are referenced inside CreditcardApp.js, which looks like this:

var CreditcardApp =
    angular.module("CreditcardApp", ["ngRoute"]);

CreditcardApp.config(["$routeProvider",
    "$locationProvider",
    function ($routeProvider) {
        $routeProvider.when("/home", {
        templateUrl: "/CreditcardApp/Templates/Home.html",
        controller: "HomeController" })
    }]);
I left out the other routes for readability, the Creditcard.html template is referenced in the same way as Home.html.

Then I create a new html page on another website:

<body>
    <script src="//www.mywebsite.com/Scripts/angular/angular.js"></script>
    <script src="//www.mywebsite.com/Scripts/angular/angular-route.js"></script>
    <script src="//www.mywebsite.com/CreditcardApp/CreditcardApp.js"></script>
    <script src="//www.mywebsite.com/CreditcardApp/CreditcardController.js"></script>
    <!-- etc., add references to all the js files -->

    <div ng-app="CreditcardApp">
        <div ng-view=""></div>
    </div>
</body>

Note that I moved the script tags to the body of the page. On the remote server, I (or rather the editor) does not have access to change the head of the page, just the content in the body.

Obviously, now I have to use full urls in the references to the js files, since they are located on another server. But I don't have a way of referencing the templates. When I open the page in a browser, the app does not load, I just get an error message:

Error: [$compile:tpload] Failed to load template: /CreditcardApp/Templates/Home.html
My first thought was to simply change the paths in CreditcardApp.js to full urls, like this:
var CreditcardApp =
    angular.module("CreditcardApp", ["ngRoute"]);

CreditcardApp.config(["$routeProvider",
    "$locationProvider",
    function ($routeProvider) {
        $routeProvider.when("/home", {
        templateUrl: 
            "http://www.mywebsite.com/CreditcardApp/Templates/Home.html",
        controller: "HomeController" })
 }]);
Unfortunately, this does not work. I get the same error message saying the template failed to load. Apparently AngularJS expects its templates to be on the same server as its code, and ignores the domain.

How to convert your templates to javascript and bundle them

Before I tackle the problem of how to load the templates on the remote server, I just want to quickly go through how I bundled my app with .NET MVC. This is standard procedure for all script files, and not just relevant for AngularJS apps.

Bundle and minify an AngularJS app in .NET MVC

In a standard .NET MVC project, you use a BundleConfig class to set up bundling and minification of scripts and stylesheets. For my project it looked like this:

using System;
using System.Web.Optimization;
using BundleTransformer.Core.Bundles;

namespace CreditcardApp
{
    public class BundleConfig
    {
        public const string Scripts = 
            "~/bundles/creditcard/scripts";

        public static void RegisterBundles(
            BundleCollection bundles)
        {
            // Bundle consisting of everything 
            // needed for the AngularJS app
            var scripts = new CustomScriptBundle(Scripts);

            // Add JQuery and Bootstrap
            scripts
                .Include("~/Scripts/jquery/jquery-2.1.1.js")
                .IncludeDirectory("~/Scripts/jquery.validate/", "*.js")
                .Include("~/Scripts/bootstrap.js");

            // Add init script, creates the containing the app
            scripts.Include("~/CreditcardApp/init.js");

            // Add AngularJS
            scripts
                .Include("~/Scripts/angular/angular.js")
                .Include("~/Scripts/angular/angular-route.js");

            // Add the main app
            scripts
                .Include("~/CreditcardApp/CreditcardApp.js")
                .Include("~/CreditcardApp/CreditcardController.js")
                .Include("~/CreditcardApp/CreditcardDirective.js")
                .Include("~/CreditcardApp/DataService.js")
                .Include("~/CreditcardApp/HomeController.js");

           bundles.Add(scripts);
        }
    }
}

If I return to my simple html page on the remote server, the whole app is now fetched by inserting a single script tag:

<script src="http://www.mywebsite.com/bundles/creditcard/scripts"></script>

Note that I also removed the div tag at the bottom, because I insert that programmatically into the page in the script init.js. Remember, my goal is for an editor to include my app on her own website as easily as possible. If I had full control of the site where the app would be displayed, I would have included the script tag in the head of the page and not in the body. Now the app will be displayed wherever the script tag is inserted, which gives the editor freedom to easily put other content before and after it. But my problem still remains, the app does not load on the remote site because it cannot find the template files.

I cannot include my html templates directly into the bundle. Bundles are meant for scripts and stylesheets (with specific bundle types for each), not html. But in AngularJS you can also create templates with javascript and insert them directly into the template cache. This is done like this:

angular.module("CreditcardApp")
    .run(['$templateCache', function(t) {
        t.put(
            "/CreditcardApp/Templates/Home.html",
            "Wow, some pretty exciting content!"
        );
        t.put(
            "/CreditcardApp/Templates/Creditcard.html",
            "And here is some more content."
        );
    }
]);

This is OK for very small templates, but it gets very confusing very quickly when you are trying to write the html for a large webpage all on one line and without any help from Visual Studio's intellisense.

Create a custom bundler to transform html templates to javascript

My solution is to combine the two techniques I have just discussed. I use the bundling framework to make my own custom bundler that transforms my html files to javascript and then use the above AngularJS method to insert the template into the template cache.

The solution was inspired by Andy Lee's blog post ASP.NET Bundling of Angular Templates.

The first step is to create a custom BundleTransform:

using System.Text;
using System.Web.Optimization;
namespace CreditcardApp
{
    public class AngularTemplatesTransform : IBundleTransform
    {
        private readonly string _moduleName;

        public AngularTemplatesTransform(string moduleName)
        {
            _moduleName = moduleName;
        }

        public void Process(
            BundleContext context, BundleResponse response)
        {
            var strBundleResponse = new StringBuilder();
            strBundleResponse.AppendFormat(
                "angular.module('{0}')", _moduleName);
            strBundleResponse.Append(
                ".run(['$templateCache', function(t) {");

            foreach (var file in response.Files)
            {
                var content = file.ApplyTransforms();
                content = content
                    .Replace("'", "\\'")
                    .Replace("\r", "");
                    .Replace("\n", "");
                var path = file.IncludedVirtualPath
                    .Replace("~", "");
                strBundleResponse.AppendFormat(
                    "t.put('{0}','{1}');", path, content);
            }

            strBundleResponse.Append("}]);");

            response.Files = new BundleFile[] { };
            response.Content = strBundleResponse.ToString();
            response.ContentType = "text/javascript";
        }
    }
}

The transformer creates a string with the necessary javascript to open AngularJS' template cache for writing. Then it inserts a put method for each template file and inserts the content of the template file into the method parameters.

Note that it also removes all linebreaks in the the html content and replaces apostrophes (') with escaped apostrophes (//'). The path to the template is converted from the format "~/CreditcardApp/Templates/Home.html" to "/CreditcardApp/Templates/Home.html".

When the content of all the template files are inserted, the string is returned to the bundler.

To use this custom transform, we need to create a custom bundle that uses it:

using System.Web.Optimization;
namespace CreditcardApp
{
    public class AngularTemplatesBundle : Bundle
    {
        public AngularTemplatesBundle(
            string moduleName, string virtualPath)
        : base(virtualPath,
            new AngularTemplatesTransform(moduleName)) { }
    }
}

And finally in the BundleConfig class, set up bundling and transformation of all our template files. This is the complete BundleConfig class:

using System;
using System.Web.Optimization;
using BundleTransformer.Core.Bundles;

namespace CreditcardApp
{
    public class BundleConfig
    {
        public const string Scripts =
            "~/bundles/creditcard/scripts";
        public const string Templates =
            "~/bundles/creditcard/templates";

        public static void RegisterBundles(
            BundleCollection bundles)
        {
            // Bundle consisting of everything needed for the AngularJS app
            var scripts = new CustomScriptBundle(Scripts);

            // Add JQuery and Bootstrap
            scripts
                .Include("~/Scripts/jquery/jquery-2.1.1.js")
                .IncludeDirectory("~/Scripts/jquery.validate/", "*.js")
                .Include("~/Scripts/bootstrap.js");

            // Add init script, creates the 

 containing the app
            scripts.Include("~/CreditcardApp/init.js");

            // Add AngularJS
            scripts
                .Include("~/Scripts/angular/angular.js")
                .Include("~/Scripts/angular/angular-route.js");

            // Add the main app
            scripts
                .Include("~/CreditcardApp/CreditcardApp.js")
                .Include("~/CreditcardApp/CreditcardController.js")
                .Include("~/CreditcardApp/CreditcardDirective.js")
                .Include("~/CreditcardApp/DataService.js")
                .Include("~/CreditcardApp/HomeController.js");

            bundles.Add(scripts);

            // Add the templates for the app
            var templates = new AngularTemplatesBundle("CreditcardApp", Templates)
                .IncludeDirectory("~/CreditcardApp/Templates/", "*.html");

            bundles.Add(templates);

            // This is just to force bundling even when in debug mode
            // to confirm that it works as expected
            BundleTable.EnableOptimizations = true;
        }
    }
}

Now, to display the AngularJS on our remote web page, we insert the following html:


<script src="http://www.mywebsite.com/bundles/creditcard/scripts"></script>
<script src="http://www.mywebsite.com/bundles/creditcard/templates"></script>

I can now ask the editors of the remote websites to add these two lines of code to their page and the app will load.

Categories: AngularJS JavaScript

Comments