I am trying to find a solution to output a razor view from a string at runtime. I have a database that has the HTML content of the view saved. This content is then loaded through a ViewComponent
and rendered out on the fly. This code is working perfectly for standard HTML scenarios using
return new HtmlContentViewComponentResult(new HtmlString("<p>A simple bit of html</p>"));
as the return type. However, the moment I try to add some dynamic content it simply treats it as RAW HTML and does not process any of the bindings
return new HtmlContentViewComponentResult(new HtmlString("<p>Name: @Model.Name</p>"));
The above code simply ignores the @
symbol and outputs the result as plain text.
Is there a way to achieve this in a simple manner?
I have some thoughts on creating and compiling the views at runtime and then assigning them a virtual path but that seems overly complicated and perhaps not the right approach (not to mention I have no idea how to achieve that).
Just for clarity, this is for a CMS type system that would allow the client to add/edit these views at runtime, hence the loading from the database approach.
EDIT : Adding some code for context. This is the InvokeAsync
method in my ViewComponent
. The configuration is loaded from the database. If there it a view path specified is will use that and render correctly. If there is Razor template content configured it attempts to render that but simply does so as RAW html without any model binding etc
public async Task<IViewComponentResult> InvokeAsync(int Id)
{
//Load up the component + configurations from database
var component = await _componentProvider.GetComponentByIdAsync(Id);
if (component != null && component.Configurations.Any())
{
//If the component has a view path configured use that first
string viewPath = component.GetConfiguration<string>(VIEW_PATH);
if (viewPath != null)
{
//this works and is rendered correctly using my DatabaseViewFileProvider implementation
return View(viewPath);
}
//There is no view path configured, but rather just a Razor Template/ HTML configured directly
string content = component.GetConfiguration<string>(VIEW_RAW);
//return the partial using the retrieved content. This just returns RAW html without parsing it as a Razor template i.e @Model.SomeValue etc
return new HtmlContentViewComponentResult(new HtmlString(content));
}
return Content(string.Empty);
}
I managed to go down the rabbit hole of a DynamicContentFileProvider
with a degree of success so thought I would post it here for any interested parties. There are still some holes in it but it is working correctly as a proof of concept.
First I created a DynamicContentFileProvider
public class DynamicContentFileProvider : IFileProvider
{
private readonly Regex PathSelector = new Regex(@"(dynamic)", RegexOptions.IgnoreCase);
public IDirectoryContents GetDirectoryContents(string subpath)
{
return new NotFoundDirectoryContents();
}
public IFileInfo GetFileInfo(string subpath)
{
if (PathSelector.IsMatch(subpath.ToLower() ?? string.Empty))
{
var result = new DynamicContentFileInfo(subpath, "<p>My silly page - @Current.Channel.Id</p>");
return result.Exists ? result as IFileInfo : new NotFoundFileInfo(subpath);
}
return new NotFoundFileInfo(subpath);
}
public IChangeToken Watch(string filter)
{
if (PathSelector.IsMatch(filter.ToLower() ?? string.Empty))
return new DynamicContentChangeToken(true);
return new DynamicContentChangeToken(false);
}
}
This class implements IFileProvider
. It has a Regex to match a specific path pattern. I will use this as a convention when generating "dynamic" type views. The important parts are the GetFileInfo
call which creates and returns a DynamicContentFileInfo
object. This object takes in a path (this is a psuedo path used to allow the view to be cached after compilation ) and it takes in the actual razor template as a string.( it's just hard-coded for now till I work out how to inject that part dynamically or fetch from a database, not sure )
Then I created a DynamicContentFileInfo
public class DynamicContentFileInfo : IFileInfo
{
public string Name { get; set; }
public string? PhysicalPath => null;
public byte[] ViewContent { get; set; }
public bool Exists { get; set; }
public bool IsDirectory => false;
public DateTimeOffset LastModified { get; set; }
public DynamicContentFileInfo(string name, string viewContent)
{
Name = name;
ViewContent = Encoding.UTF8.GetBytes(viewContent);
LastModified = DateTimeOffset.Now;
Exists = !string.IsNullOrEmpty(viewContent);
}
public long Length
{
get
{
using (var stream = new MemoryStream(this.ViewContent))
{
return stream.Length;
}
}
}
public Stream CreateReadStream()
{
return new MemoryStream(ViewContent);
}
}
This class simply wraps the given razor template and masquerades as a file by wrapping it in a MemoryStream
Then I created a DynamicContentChangeToken
public class DynamicContentChangeToken : IChangeToken
{
public bool ActiveChangeCallbacks => false;
public bool HasChanged { get; set; }
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state) => EmptyDisposable.Instance;
public DynamicContentChangeToken(bool hasChanged)
{
HasChanged = hasChanged;
}
internal class EmptyDisposable : IDisposable
{
public static EmptyDisposable Instance { get; } = new EmptyDisposable();
private EmptyDisposable() { }
public void Dispose() { }
}
}
This class takes in a bool flag to force the content to be marked as "changed" or not. This is returned in the IChangeToken Watch(string filter)
method call in the DynamicContentFileProvider
class. I again use a regex to match on the path and then either force the change or not. This allows unmatched views to remain in the cache whilst forcing recompilation of the dynamic stuff ( I will smarten this up later )
I had to register my file providers in the startup process
.AddRazorRuntimeCompilation(opts =>
{
opts.FileProviders.Clear();
opts.FileProviders.Add(new CompositeFileProvider(new ViewFileProvider(configuration), new DynamicContentFileProvider()));
});
Then finally, in order to render my dynamic content I need to use a path convention of dynamic when rendering dynamic content and it all works out
return View($"/dynamic/{UniqueIdOfContentFromDb}");
What's missing?
1.Well I still need to work out how to get the content from the Controller/ViewComponent/Page into the `DynamicContentFileProvider'. I can use the key in the path and load from the database but that would mean a redundant call, so still pondering this 2.I need a cool way to invalidate the view cache when a user edits the template in the database. I figure I can use the ModifiedDate in the database and compare, but again, that's another database call on every render, so who knows.
For now it's working in principle, so happy days!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With