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
- 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:
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>
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:
~/Views/Scripts/OtherDynamic.js.cshtml:
<script> function OtherDynamicJsFunction() {
document.writeln("executing Other dynamic JS from: @Request.RawUrl...
")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 {
///
/// "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=null, object model=null)
{
var cssContent = ParseViewToContent(controller,cssViewName, "style", model);
if(cssContent==null) throw new HttpException(404,"CSS not found");
return new ContentResult() { Content = cssContent, ContentType = "text/css" };
}
///
/// "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=null, object model=null)
{
var jsContent = ParseViewToContent(controller,javascriptViewName, "script", model);
if(jsContent==null) throw new HttpException(404,"JS not found");
return new JavaScriptResult() {Script = jsContent };
}
///
/// "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
As you can see you can modify query string parameters in runtime using IE Developer Tools:
and see results immediately:
in Network tab you can see actual responses and low level information:
Dynamic Javascript response:
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.