Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions Fluid.Tests/MvcViewEngine/ViewEngineTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Fluid.Tests.Mocks;
using Fluid.ViewEngine;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
Expand Down Expand Up @@ -301,5 +302,101 @@ public async Task RenderViewOnlyAsyncStream_LargePropertyValue_Nested()
await _renderer.RenderViewAsync(sw, "Index.liquid", template);
await sw.FlushAsync();
}

[Fact]
public async Task ShouldApplyTemplateParsedCallback()
{
_mockFileProvider.Add("Views/Index.liquid", "{{ 1 | plus: 2 }}");

// Use a visitor to replace 2 with 4
_options.TemplateParsed = (path, template) =>
{
var visitor = new Fluid.Tests.Visitors.ReplaceTwosVisitor(Fluid.Values.NumberValue.Create(4));
return visitor.VisitTemplate(template);
};

var sw = new StringWriter();
await _renderer.RenderViewAsync(sw, "Index.liquid", new TemplateContext());
await sw.FlushAsync();

Assert.Equal("5", sw.ToString());

_options.TemplateParsed = null;
}

[Fact]
public async Task ShouldApplyTemplateParsedCallbackToNestedTemplates()
{
_mockFileProvider.Add("Views/Index.liquid", "{% partial 'world' %}");
_mockFileProvider.Add("Partials/World.liquid", "{{ 1 | plus: 2 }}");

// Use a visitor to replace 2 with 4
_options.TemplateParsed = (path, template) =>
{
var visitor = new Fluid.Tests.Visitors.ReplaceTwosVisitor(Fluid.Values.NumberValue.Create(4));
return visitor.VisitTemplate(template);
};

var sw = new StringWriter();
await _renderer.RenderViewAsync(sw, "Index.liquid", new TemplateContext());
await sw.FlushAsync();

Assert.Equal("5", sw.ToString());

_options.TemplateParsed = null;
}

[Fact]
public async Task ShouldApplyTemplateParsedCallbackToViewStarts()
{
_mockFileProvider.Add("Views/Index.liquid", "Hello");
_mockFileProvider.Add("Views/_ViewStart.liquid", "{{ 1 | plus: 2 }} ");

// Use a visitor to replace 2 with 4
_options.TemplateParsed = (path, template) =>
{
var visitor = new Fluid.Tests.Visitors.ReplaceTwosVisitor(Fluid.Values.NumberValue.Create(4));
return visitor.VisitTemplate(template);
};

var sw = new StringWriter();
await _renderer.RenderViewAsync(sw, "Index.liquid", new TemplateContext());
await sw.FlushAsync();

Assert.Equal("5 Hello", sw.ToString());

_options.TemplateParsed = null;
}

[Fact]
public async Task CompositeFluidTemplateShouldRenderUsingStatementsNotTemplates()
{
// This test verifies that CompositeFluidTemplate renders using its Statements property
// rather than iterating through the Templates property, which is important for
// consistency with FluidTemplate and for ensuring altered statements are rendered.

// Create a simple composite template
var parser = new FluidParser();
var template1 = parser.Parse("{{ 1 | plus: 2 }}");
var template2 = parser.Parse(" World");

var composite = new Fluid.Parser.CompositeFluidTemplate(template1, template2);

// Render it normally - should output "3 World"
var sw = new StringWriter();
await composite.RenderAsync(sw, System.Text.Encodings.Web.HtmlEncoder.Default, new TemplateContext());
Assert.Equal("3 World", sw.ToString());

// Now apply a visitor that alters the statements
var visitor = new Fluid.Tests.Visitors.ReplaceTwosVisitor(Fluid.Values.NumberValue.Create(4));
var altered = visitor.VisitTemplate(composite);

// The visitor should return a new template with altered statements
var sw2 = new StringWriter();
await altered.RenderAsync(sw2, System.Text.Encodings.Web.HtmlEncoder.Default, new TemplateContext());

// Should output "5 World" - the '2' has been replaced with '4', so 1+4=5
Assert.Equal("5 World", sw2.ToString());
}
}
}
11 changes: 11 additions & 0 deletions Fluid.ViewEngine/FluidViewEngineOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,16 @@ public class FluidViewEngineOptions
/// Gets or sets the delegate to execute when a view is rendered.
/// </summary>
public RenderingViewDelegate RenderingViewAsync { get; set; }

/// <summary>
/// <para>Represents the method that will handle the template parsed event.</para>
/// </summary>
public delegate IFluidTemplate TemplateParsedDelegate(string path, IFluidTemplate template);

/// <summary>
/// Gets or sets the delegate to execute when a template is parsed, before it is cached.
/// This can be used to apply AST visitors or rewriters to modify templates.
/// </summary>
public TemplateParsedDelegate TemplateParsed { get; set; }
}
}
6 changes: 6 additions & 0 deletions Fluid.ViewEngine/FluidViewRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,12 @@ protected virtual async ValueTask<IFluidTemplate> GetFluidTemplateAsync(string p

template = await ParseLiquidFileAsync(path, fileProvider, includeViewStarts);

// Allow user to modify the template before caching (e.g., apply visitors/rewriters)
if (_fluidViewEngineOptions.TemplateParsed != null)
{
template = _fluidViewEngineOptions.TemplateParsed(path, template);
}

cache.TemplateCache[path] = template;

return template;
Expand Down
73 changes: 68 additions & 5 deletions Fluid/Parser/CompositeFluidTemplate.cs
Original file line number Diff line number Diff line change
@@ -1,27 +1,90 @@
using System.Text.Encodings.Web;
using Fluid.Ast;
using System.Text.Encodings.Web;

namespace Fluid.Parser
{
public sealed class CompositeFluidTemplate : IFluidTemplate
public sealed class CompositeFluidTemplate : IFluidTemplate, IStatementList
{
public CompositeFluidTemplate(params IFluidTemplate[] templates)
{
Templates = new List<IFluidTemplate>(templates);
Statements = CollectStatements(templates);
}

public CompositeFluidTemplate(IReadOnlyList<IFluidTemplate> templates)
{
Templates = new List<IFluidTemplate>(templates);
Statements = CollectStatements(templates);
}

public IReadOnlyList<IFluidTemplate> Templates { get; }

public async ValueTask RenderAsync(TextWriter writer, TextEncoder encoder, TemplateContext context)
public IReadOnlyList<Statement> Statements { get; }

public ValueTask RenderAsync(TextWriter writer, TextEncoder encoder, TemplateContext context)
{
if (writer == null)
{
ExceptionHelper.ThrowArgumentNullException(nameof(writer));
}

if (encoder == null)
{
ExceptionHelper.ThrowArgumentNullException(nameof(encoder));
}

if (context == null)
{
ExceptionHelper.ThrowArgumentNullException(nameof(context));
}

var count = Statements.Count;
for (var i = 0; i < count; i++)
{
var task = Statements[i].WriteToAsync(writer, encoder, context);
if (!task.IsCompletedSuccessfully)
{
return Awaited(
task,
writer,
encoder,
context,
Statements,
startIndex: i + 1);
}
}

return new ValueTask();
}

private static async ValueTask Awaited(
ValueTask<Completion> task,
TextWriter writer,
TextEncoder encoder,
TemplateContext context,
IReadOnlyList<Statement> statements,
int startIndex)
{
await task;
for (var i = startIndex; i < statements.Count; i++)
{
await statements[i].WriteToAsync(writer, encoder, context);
}
}

private static List<Statement> CollectStatements(IEnumerable<IFluidTemplate> templates)
{
foreach (var template in Templates)
var statements = new List<Statement>();

foreach (var template in templates)
{
await template.RenderAsync(writer, encoder, context);
if (template is IStatementList statementList)
{
statements.AddRange(statementList.Statements);
}
}

return statements;
}
}
}
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,26 @@ var result = changed.Render();
Console.WriteLine(result); // writes -1
```

### Using visitors with the ViewEngine

When using the Fluid ASP.NET MVC ViewEngine or the standalone ViewEngine, you can apply visitors and rewriters to templates before they are cached by using the `TemplateParsed` callback:

```c#
services.AddMvc().AddFluid(options =>
{
options.TemplateParsed = (path, template) =>
{
var visitor = new MyCustomVisitor();
return visitor.VisitTemplate(template);
};
});
```

The `TemplateParsed` callback is invoked after a template is parsed but before it is cached. This means:
- The modified template is cached, improving performance
- The callback applies to all templates including partials and ViewStarts
- Each template is processed only once (when first parsed)

### Custom parsers

The [custom statements and expressions](#custom-parsers) can also be visited by using one of these methods:
Expand Down