Thursday, April 18, 2013

Dynamically generated Javascript and CSS in ASP.NET MVC using Razor Views

I was always wondering why ASP.NET allows to generate dynamically HTML (including inline CSS and JS), but does not allow to do the same for standalone JS and CSS files. Today I spent some time to implement relatively simple and convenient solution of the problem on ASP.NET MVC platform.

Here is the list of features I want to get from solution:
  • Intelli-sense in editor of dynamic CSS and JS
  • Fully functional C# razor with server-side C# code in dynamic content
  • Ability to pass Model and use request parameters
  • I still want to use some static CSS, and JS besides dynamic
  • Adding another dynamic JS/CSS file should be as easy as adding static JS/CSS file
Here is what OUT OF SCOPE:
  • Bundling and minification of resources

Short story

To achieve these goals, I use existing functionality of CSHTML editor, which provides everything we need for inline CSS and JS. I create partial cshtml view with single Html element, either or and after rendering I trim the root tag and get dynamically rendered content file.
The steps you will need to do:
1) Implement CSHTML files to render dynamic resources
2) Implement Style and Script controllers, overriding handling unknown action, using "Magic" extensions
3) Modify web.config to allow controllers handle static file requests
4) Implement Magic extensions.
5) Enjoy

Here you can download source code.


Long story:

 

CREATING TARGET VIEW

Let's start with our final view, which declares our implementation goals. It is super simple home page view, that references our dynamic and static content, and demonstrate visually that using of dynamic resources works:
Index.cshtml:

@{
    ViewBag.Title = "Home page";
}
<link href="~/Styles/static.css" rel="stylesheet" />
<link href="~/Styles/dynamic.css?color=grey" rel="stylesheet" />
<script src="~/Scripts/static.js"></script>
<script src="~/Scripts/dynamicWithModel.js?message=Hallo%20world&otherParameter=My parameter value"></script>
<script src="~/Scripts/otherDynamic.js"></script>
 
<h2 class="dynamicCss">@ViewBag.Title</h2>
 
<script>
    StaticJsFunction();
    DynamicJsFunction();
    OtherDynamicJsFunction();
</script>

You can see that this view uses dynamic CSS and static CSS. It also uses one static JS file, and two dynamic JS files. Dynamic references has query string parameters which will impact the rendering of the resource. In DynamicWithModel.js I also wants to use model that I pass to the JS-view and which is respected during JS rendering.

I want my dynamic resources to be handler by controllers, while static resources should be stored just in Styles and Scripts folder of the web application.


Creating JS and CSS


For static resources I create folders in the root of the project. Regarding dynamic resources, since controllers should be named Styles and Scripts, I will create view folders accordingly with CSHTML files:

image

Content of those static files is very simple:
~/scripts/static.js:

function StaticJsFunction() {
    document.writeln("executing static JS

");
}


~/scripts/static.css:

body {
    background-color:InfoBackground;
}


Here are dynamic resources:
~/Views/Styles/dynamic.css.cshtml:


@{    var className = "dynamicCss";
    var bkColor = Request.QueryString["color"]??"red";
    }<style>.@className
{
    background-color:@bkColor;
}
</style>

As you see CSHTML is standard view with a single block STYLE node. Background color is retrieved from query string parameter, in case parameter is not specified default RED color is used.

~/Views/Scripts/DynamicWithModel.js.cshtml:


@model string<script>function DynamicJsFunction() {
    document.writeln("executing dynamic JS from: @Request.RawUrl...")
    @if (Model != null)
    {
        @: { document.writeln("Message passed to Model: @Model"); }
    }
    @if (Request.QueryString.HasKeys())
    {
        foreach (string key in Request.QueryString.Keys)
        {
            @:{ document.writeln("Query string parameter. Key: @key , Value: @Request.QueryString[key]"); }
        }
    }
}
</script>



As you see this  JS is a function renders to current document model and query string parameters which were specified during rendering the JS content.

~/Views/Scripts/OtherDynamic.js.cshtml:

<script>    function OtherDynamicJsFunction() {
        document.writeln("executing Other dynamic JS from: @Request.RawUrl...
")
        @if (Request.QueryString.HasKeys())
        {
            foreach (string key in Request.QueryString.Keys)
            {
                @:{ document.writeln("Query string parameter. Key: @key , Value: @Request.QueryString[key]"); }
            }
        }
    }
</script>
This function does not use model, but also renders current query string parameters to Html document.



Creating Scripts and Styles Controllers


I’m creating two controllers which handles requests coming to ~/Styles/* and ~/Scripts/* paths.


    public class StylesController : Controller
    {
        public ActionResult Index()
        {
            return Content("Styles folder");
        }
 
        protected override void HandleUnknownAction(string actionName)
        {
            var res = this.CssFromView(actionName);
            res.ExecuteResult(ControllerContext);
        }
    }

Since I don't want to register action for every single CSS and JS file, I override HandleUnknownAction to handle all requests to controller that were not associated with declared action.

    public class ScriptsController : Controller
    {
        [ActionName("DynamicWithModel.js")]
        public ActionResult Dynamic(string message)
        {
            return this.JavaScriptFromView(model:message);
        }
 
        public ActionResult Index()
        {
            return Content("Scripts folder");
        }
 
        protected override void HandleUnknownAction(string actionName)
        {
            var res = this.JavaScriptFromView();
            res.ExecuteResult(ControllerContext);
        }
 
    }

For DynamicWithModel.js i want to pass model, retrieving it from input parameter. In this case since I cannot create method containing dot in a name, I have to use attribute ActionName (alternatively I can avoid using dots in resource names in Index.chtml).

In order to make these controllers handle requests with file extensions in URL you must modify your web.config:


<system.webServer>
    <modules runAllManagedModulesForAllRequests="true"/>




Implementing the "Magic" extensions:


As you might notice I used custom controller extensions JavaScriptFromView and CssFromView - these guys do all the magic. Here is it's implementation:


using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Web; namespace System.Web.Mvc {     /// 

    /// Mvc extensions for dynamic CSS and JS    /// 

    public static class MvcExtensions    {
        /// 









        /// CSS content result rendered by partial view specified        /// 
        /// "controller">current controller
        /// "cssViewName">view name, which contains partial view with one STYLE block only
        /// "model">optional model to pass to partial view for rendering
        /// 
        public static ActionResult CssFromView(this Controller controller, string cssViewName=nullobject model=null)
        {
            var cssContent = ParseViewToContent(controller,cssViewName, "style", model);
            if(cssContent==nullthrow new HttpException(404,"CSS not found");
            return new ContentResult() { Content = cssContent, ContentType = "text/css" };
        }

        /// 










        /// Javascript content result rendered by partial view specified        /// 
        /// "controller">current controller
        /// "javascriptViewName">view name, which contains partial view with one SCRIPT block only
        /// "model">optional model to pass to partial view for rendering
        /// 
        public static ActionResult JavaScriptFromView(this Controller controller, string javascriptViewName=nullobject model=null)
        {
            var jsContent = ParseViewToContent(controller,javascriptViewName, "script", model);
            if(jsContent==nullthrow new HttpException(404,"JS not found");
            return new JavaScriptResult() {Script = jsContent };
        }

        /// 










        /// Parse view and render it to a string, trimming specified HTML tag        /// 
        /// "controller">controller which renders the view
        /// "viewName">name of cshtml file with content. If null, then actionName used
        /// "tagName">Content rendered expected to be wrapped with this html tag, and it will be trimmed from result
        /// "model">model to pass for view to render
        /// 
        static string ParseViewToContent(Controller controller, string viewName, string tagName, object model = null)
        {
            using (var viewContentWriter = new StringWriter())
            {
                if (model != null)
                    controller.ViewData.Model = model;

                if (string.IsNullOrEmpty(viewName))
                    viewName = controller.RouteData.GetRequiredString("action");

                var viewResult = new ViewResult()
                {
                    ViewName = viewName,
                    ViewData = controller.ViewData,
                    TempData = controller.TempData,
                    ViewEngineCollection = controller.ViewEngineCollection
                };

                var viewEngineResult = controller.ViewEngineCollection.FindPartialView(controller.ControllerContext, viewName);
                if (viewEngineResult.View == null)
                    return null;

                try                {
                    var viewContext = new ViewContext(controller.ControllerContext, viewEngineResult.View, controller.ViewData, controller.TempData, viewContentWriter);
                    viewEngineResult.View.Render(viewContext, viewContentWriter);
                    var viewString = viewContentWriter.ToString().Trim('\r''\n'' ');
                    var regex = string.Format("<{0}[^>]*>(.*?)</{0}>", tagName);
                    var res = Regex.Match(viewString, regex, RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.Multiline | RegexOptions.Singleline);
                    if (res.Success && res.Groups.Count > 1)
                        return res.Groups[1].Value;
                    else throw new InvalidProgramException(string.Format("Dynamic content produced by viewResult '{0}' expected to be wrapped in '{1}' tag", viewName, tagName));
                }
                finally                {
                    if (viewEngineResult.View != null)
                        viewEngineResult.ViewEngine.ReleaseView(controller.ControllerContext, viewEngineResult.View);
                }
            }

        }

    }
}

 
Show time
image

As you can see you can modify query string parameters in runtime using IE Developer Tools:

image


and see results immediately:

image

in Network tab you can see actual responses and low level information:

image



image


Dynamic Javascript response:

image


Again, you can download source code from here.

Additional tricks:

Since you can add inline code actually inline you probably won't need it, but You also can use those controllers' actions as child actions to render inline resources if you need using Html.RenderAction/ Html.Action(...) extensions on your parent HTML view. If you do this make sure your Response.ContentType is not overwritten by Render action and if it was you need manually to restore it.


FileHandler VS Controller competition for handling file request


If you noticed there is a competition between Static file Handler and your Controller for handling requests to resource file (js/css). So it is important to understand how it works. So here it is:

By default StaticFileHandler has priority. This means if you create file ~/Views/Scripts/static.js.cshtml, and you already have file ~/Scripts/static.js - the last one (real static) will be used. But you can change this behavior using Routing. You need to do is to add in your RouteConfig.cs (located in App_Start for MVC4 template) the following line:

            routes.RouteExistingFiles = true;


It will deactivate Static File handler and redirect all file requests to Controllers.