Thinking of CMS use cases here. Imagine a view like this:
// /Home/Index.cshtml
@model object
@{
var str = "My <b>CMS</b> content with razor code: @Html.ActionLink(\"Click\", \"Home\")"
}
@Html.MyCustomRazorStringRenderer(Model)
Expected output:
My <b>CMS</b> content with razor code: <a href="/Home/Click">Click</a>
What does MyCustomRazorStringRenderer look like? It must somehow do sth. like creating/using the ViewContext and render it (like here: Render a view as a string) but I can't quite get my head around it.
You will have to create a static class containing an extension method. The method must return an instance of MvcHtmlString that contains the safe rendered HTML output. Having said this, getting to the renderedOutput
properly means "hijacking" the Razor renderer, which is tricky.
What you're really doing is using the Razor engine outside of its intended environment, which is described here: http://vibrantcode.com/blog/2010/7/22/using-the-razor-parser-outside-of-aspnet.html
There is also a lot of good information here, from which I got a lot of inspiration for the code below: http://www.codemag.com/article/1103081
These classes are the starting point for this: RazorEngineHost, RazorTemplateEngine, CSharpCodeProvider, HtmlHelper.
Working code
I actually got an almost working version of this, but realized this is a really futile thing to do. The Razor engine works by generating code, which must then be compiled using CSharpCodeProvider
. This takes time. A lot of time!
The only viable and efficient way of doing this would be to save your template strings somewhere, precompile them, and call those compiled templates when called upon. This makes it basically useless for what you are after, because this would be exactly what ASP.NET MVC with Razor is good at - keeping Views in a good place, precompiling them, and calling upon them when referenced. Update: Well, maybe a heavy dose of caching might help, but I still wouldn't actually recommend this solution.
When generating the code, Razor emits calls to this.Write
and this.WriteLiteral
. Because this
is an object inheriting from a baseclass that you write yourself, it is up to you to provide implementations of Write
and WriteLiteral
.
If you are using any other HtmlHelper
extensions in your template string, you need to include assembly references and namespace imports for all of them. The code below adds the most common ones. Because of the nature of anonymous types, those cannot be used for model classes.
The MyRazorExtensions class
public static class MyRazorExtensions
{
public static MvcHtmlString RazorEncode(this HtmlHelper helper, string template)
{
return RazorEncode(helper, template, (object)null);
}
public static MvcHtmlString RazorEncode<TModel>(this HtmlHelper helper, string template, TModel model)
{
string output = Render(helper, template, model);
return new MvcHtmlString(output);
}
private static string Render<TModel>(HtmlHelper helper, string template, TModel model)
{
// 1. Create a host for the razor engine
// TModel CANNOT be an anonymous class!
var host = new RazorEngineHost(RazorCodeLanguage.GetLanguageByExtension("cshtml");
host.DefaultNamespace = typeof(MyTemplateBase<TModel>).Namespace;
host.DefaultBaseClass = nameof(MyTemplateBase<TModel>) + "<" + typeof(TModel).FullName + ">";
host.NamespaceImports.Add("System.Web.Mvc.Html");
// 2. Create an instance of the razor engine
var engine = new RazorTemplateEngine(host);
// 3. Parse the template into a CodeCompileUnit
using (var reader = new StringReader(template))
{
razorResult = engine.GenerateCode(reader);
}
if (razorResult.ParserErrors.Count > 0)
{
throw new InvalidOperationException($"{razorResult.ParserErrors.Count} errors when parsing template string!");
}
// 4. Compile the produced code into an assembly
var codeProvider = new CSharpCodeProvider();
var compilerParameters = new CompilerParameters { GenerateInMemory = true };
compilerParameters.ReferencedAssemblies.Add(typeof(MyTemplateBase<TModel>).Assembly.Location);
compilerParameters.ReferencedAssemblies.Add(typeof(TModel).Assembly.Location);
compilerParameters.ReferencedAssemblies.Add(typeof(HtmlHelper).Assembly.Location);
var compilerResult = codeProvider.CompileAssemblyFromDom(compilerParameters, razorResult.GeneratedCode);
if (compilerResult.Errors.HasErrors)
{
throw new InvalidOperationException($"{compilerResult.Errors.Count} errors when compiling template string!");
}
// 5. Create an instance of the compiled class and run it
var templateType = compilerResult.CompiledAssembly.GetType($"{host.DefaultNamespace}.{host.DefaultClassName}");
var templateImplementation = Activator.CreateInstance(templateType) as MyTemplateBase<TModel>;
templateImplementation.Model = model;
templateImplementation.Html = helper;
templateImplementation.Execute();
// 6. Return the html output
return templateImplementation.Output.ToString();
}
}
The MyTemplateBase<> class
public abstract class MyTemplateBase<TModel>
{
public TModel Model { get; set; }
public HtmlHelper Html { get; set; }
public void WriteLiteral(object output)
{
Output.Append(output.ToString());
}
public void Write(object output)
{
Output.Append(Html.Encode(output.ToString()));
}
public void Write(MvcHtmlString output)
{
Output.Append(output.ToString());
}
public abstract void Execute();
public StringBuilder Output { get; private set; } = new StringBuilder();
}
test.cshtml
@using WebApplication1.Models
<h2>Test</h2>
@Html.RazorEncode("<p>Paragraph output</p>")
@Html.RazorEncode("<p>Using a @Model</p>", "string model" )
@Html.RazorEncode("@for (int i = 0; i < 100; ++i) { <p>@i</p> }")
@Html.RazorEncode("@Html.ActionLink(Model.Text, Model.Action)", new TestModel { Text = "Foo", Action = "Bar" })
Update
Doing this "live" - having Razor compile and run for each page load is obviously too slow if you don't go heavy on the caching, but if you break out pieces of my code and have your CMS request a recompilation automatically whenever the contents of a page changes, you could do something really interesting here.