Part III in this series looked at how view templates are dynamically compiled including the processing of simple expressions (like 1 + 1
) embedded in the templates. However, view templates are useful when we can pass values to them to be shown in the variable areas of the template. In this final post of the series, we'll demonstrate how values added to the ViewData
in the controller can be passed to the view template and rendered in the final output.
In the Razor view engine, accessing a value from ViewData looks like this:
Hello @@ViewData["name"]
For our mustache syntax, we want a more readable syntax like this:
Hello {{name}}
For the above to work name
must exist as some sort of variable in the compiled template and have an assigned value.
You can find the full contents of the source files discussed below on this GitHub gist.
Identify Replaceable Values
The first step is knowing all the replaceable values in the view template. Since we allow expressions in our mustache syntax, we need a way to parse C# expressions and identify the variables. As we are already compiling the view template, we can use diagnostic messages from the compilation process to indirectly obtain the list of replaceable values.
In StacheTemplateCompiler.cs
, add the following method:
private static List<string> GetMissingProperties(CSharpCompilation compilation)
{
var missingProperties = compilation.GetDiagnostics().Where(d => d.Id.Equals("CS0103"))
.Select(d => d.GetMessage()).Distinct()
.Select(m => m.Replace("The name '", string.Empty)
.Replace("' does not exist in the current context", string.Empty));
return missingProperties.ToList();
}
We're using the Roslyn compiler for the heavy lifting. Any names it reports as not existing in the current context are replaceable values in the view template.
Adjust Compiled Template
Once we know this list, we can create the class document a second time and declare the replaceable values as properties. In StacheTemplateCompiler.cs
, insert the following into the Compile()
method right before loading the assembly:
var missingProperties = GetMissingProperties(compilation);
if(missingProperties.Any())
{
var secondClassDocument = CreateClassDocument(parsedResult, templateClassName, missingProperties);
var secondSyntaxTree = CSharpSyntaxTree.ParseText(secondClassDocument);
compilation = compilation.ReplaceSyntaxTree(syntaxTree, secondSyntaxTree);
}
Additionally modify CreateClassDocument()
to accept a list of strings as the last paramater named properties
(optional, with default value of null). Insert the following code right after the creation of the StringBuilder
object.
if(properties != null)
{
foreach(var property in properties){
result.AppendLine($"public dynamic {property} {{get; set;}}");
}
}
We declare each replaceable value as a dynamic property. Although we have been using strings in the examples, values sent through ViewData
from the controller could have other types. Therefore treating it as a dynamic property gives us the flexibility to accept all types.
To use the dynamic
keyword, we need to include references to additional assemblies when creating the compilation. Update the beginning of the CreateCompilation()
method as follows:
var assemblyLocations = new List<string>{
// system runtime assembly
typeof(object).Assembly.Location,
// the following are needed for dynamic keyword support
Assembly.Load(new AssemblyName("System.Linq.Expressions")).Location,
Assembly.Load(new AssemblyName("Microsoft.CSharp")).Location,
Assembly.Load(new AssemblyName("System.Runtime")).Location,
Assembly.Load(new AssemblyName("netstandard")).Location
};
var references = assemblyLocations.Select(location => MetadataReference.CreateFromFile(location));
Pass Values to View Template
At this point, we have code to successfully complile a view template while including C# properties for each replaceable value in the template. The last step is to set the properties with the the actual values provided from ViewData
when rendering the view template.
In StacheView.cs
, update the RenderAsync()
method to set the properties using Reflection before invoking the Execute()
method on the compiled instance:
var properties = instance.GetType().GetRuntimeProperties()
.Where((property) =>
{
return property.GetIndexParameters().Length == 0 &&
property.SetMethod != null &&
!property.SetMethod.IsStatic;
});
foreach(var prop in properties){
if(context.ViewData.ContainsKey(prop.Name)){
prop.SetValue(instance, context.ViewData[prop.Name]);
}
}
To test the changes, we will modify the Bar
action in HomeController.cs
we have been using to the following:
public IActionResult Bar(){
ViewData["name"] = "Jonathan";
return View();
}
And modify the view template Bar.stache
as follows:
Hello {{name}}, your name has {{name.Length}} characters.
If you now run the application (dotnet run
) and navigate to https://localhost:5001/home/bar
, you should see:
Hello Jonathan, your name has 8 characters.
We have successfuly passed a value from the controller to the view template.
Summary
In this series, we created an alternative view engine with its own template syntax. We parsed the template, created a compilation, and used it to render values supplied by the controller. Hopefully this illustrated the modular nature of ASP.NET and its ability to accept third party view engines.
If you're interested in other alternatives, this Stack Overflow answer compares various view engines. Also check out NVue, my experimental attempt at a view engine based on the Vue.js template syntax.
Top comments (1)
Good content!!