diff --git a/src/Renderers/FluentEmail.Liquid/FluentEmail.Liquid.csproj b/src/Renderers/FluentEmail.Liquid/FluentEmail.Liquid.csproj index b628429d..dceefbab 100644 --- a/src/Renderers/FluentEmail.Liquid/FluentEmail.Liquid.csproj +++ b/src/Renderers/FluentEmail.Liquid/FluentEmail.Liquid.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Renderers/FluentEmail.Liquid/FluidViewTemplate.cs b/src/Renderers/FluentEmail.Liquid/FluidViewTemplate.cs deleted file mode 100644 index 21477075..00000000 --- a/src/Renderers/FluentEmail.Liquid/FluidViewTemplate.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentEmail.Liquid.Tags; - -using Fluid; - -namespace FluentEmail.Liquid -{ - public class FluidViewTemplate : BaseFluidTemplate - { - static FluidViewTemplate() - { - Factory.RegisterTag("layout"); - Factory.RegisterTag("renderbody"); - Factory.RegisterBlock("section"); - Factory.RegisterTag("rendersection"); - } - } -} diff --git a/src/Renderers/FluentEmail.Liquid/LiquidParser.cs b/src/Renderers/FluentEmail.Liquid/LiquidParser.cs new file mode 100644 index 00000000..00351086 --- /dev/null +++ b/src/Renderers/FluentEmail.Liquid/LiquidParser.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Fluid; +using Fluid.Ast; + +public class LiquidParser : FluidParser +{ + public LiquidParser() + { + RegisterExpressionTag("layout", OnRegisterLayoutTag); + RegisterEmptyTag("renderbody", OnRegisterRenderBodyTag); + RegisterIdentifierBlock("section", OnRegisterSectionBlock); + RegisterIdentifierTag("rendersection", OnRegisterSectionTag); + } + + private async ValueTask OnRegisterLayoutTag(Expression expression, TextWriter writer, TextEncoder encoder, TemplateContext context) + { + const string viewExtension = ".liquid"; + + var relativeLayoutPath = (await expression.EvaluateAsync(context)).ToStringValue(); + + if (!relativeLayoutPath.EndsWith(viewExtension, StringComparison.OrdinalIgnoreCase)) + { + relativeLayoutPath += viewExtension; + } + + context.AmbientValues["Layout"] = relativeLayoutPath; + + return Completion.Normal; + } + + private async ValueTask OnRegisterRenderBodyTag(TextWriter writer, TextEncoder encoder, TemplateContext context) + { + static void ThrowParseException() + { + throw new ParseException("Could not render body, Layouts can't be evaluated directly."); + } + + if (context.AmbientValues.TryGetValue("Body", out var body)) + { + await writer.WriteAsync((string)body); + } + else + { + ThrowParseException(); + } + + return Completion.Normal; + } + + private ValueTask OnRegisterSectionBlock(string sectionName, IReadOnlyList statements, TextWriter writer, TextEncoder encoder, TemplateContext context) + { + if (context.AmbientValues.TryGetValue("Sections", out var sections)) + { + var dictionary = (Dictionary>) sections; + + dictionary[sectionName] = statements.ToList(); + } + + return new ValueTask(Completion.Normal); + } + + private async ValueTask OnRegisterSectionTag(string sectionName, TextWriter writer, TextEncoder encoder, TemplateContext context) + { + if (context.AmbientValues.TryGetValue("Sections", out var sections)) + { + var dictionary = (Dictionary>) sections; + + if (dictionary.TryGetValue(sectionName, out var section)) + { + foreach(var statement in section) + { + await statement.WriteToAsync(writer, encoder, context); + } + } + } + + return Completion.Normal; + } +} \ No newline at end of file diff --git a/src/Renderers/FluentEmail.Liquid/LiquidRenderer.cs b/src/Renderers/FluentEmail.Liquid/LiquidRenderer.cs index fb916603..fe53645a 100644 --- a/src/Renderers/FluentEmail.Liquid/LiquidRenderer.cs +++ b/src/Renderers/FluentEmail.Liquid/LiquidRenderer.cs @@ -12,13 +12,13 @@ namespace FluentEmail.Liquid { public class LiquidRenderer : ITemplateRenderer { - private static readonly Func FluidTemplateFactory = () => new FluidViewTemplate(); - private readonly IOptions _options; + private readonly LiquidParser _parser; public LiquidRenderer(IOptions options) { _options = options; + _parser = new LiquidParser(); } public string Parse(string template, T model, bool isHtml = true) @@ -34,7 +34,7 @@ public async Task ParseAsync(string template, T model, bool isHtml = var fileProvider = rendererOptions.FileProvider; var viewTemplate = ParseTemplate(template); - var context = new TemplateContext(model) + var context = new TemplateContext(model, rendererOptions.TemplateOptions) { // provide some services to all statements AmbientValues = @@ -42,9 +42,10 @@ public async Task ParseAsync(string template, T model, bool isHtml = ["FileProvider"] = fileProvider, ["Sections"] = new Dictionary>() }, - ParserFactory = FluidViewTemplate.Factory, - TemplateFactory = FluidTemplateFactory, - FileProvider = fileProvider + Options = + { + FileProvider = fileProvider + } }; rendererOptions.ConfigureTemplateContext?.Invoke(context, model!); @@ -55,7 +56,7 @@ public async Task ParseAsync(string template, T model, bool isHtml = if (context.AmbientValues.TryGetValue("Layout", out var layoutPath)) { context.AmbientValues["Body"] = body; - var layoutTemplate = ParseLiquidFile((string) layoutPath, fileProvider!); + var layoutTemplate = ParseLiquidFile((string)layoutPath, fileProvider!); return await layoutTemplate.RenderAsync(context, rendererOptions.TextEncoder); } @@ -63,7 +64,7 @@ public async Task ParseAsync(string template, T model, bool isHtml = return body; } - private static FluidViewTemplate ParseLiquidFile( + private IFluidTemplate ParseLiquidFile( string path, IFileProvider? fileProvider) { @@ -85,9 +86,9 @@ static void ThrowMissingFileProviderException() return ParseTemplate(sr.ReadToEnd()); } - private static FluidViewTemplate ParseTemplate(string content) + private IFluidTemplate ParseTemplate(string content) { - if (!FluidViewTemplate.TryParse(content, out var template, out var errors)) + if (!_parser.TryParse(content, out var template, out var errors)) { throw new Exception(string.Join(Environment.NewLine, errors)); } diff --git a/src/Renderers/FluentEmail.Liquid/LiquidRendererOptions.cs b/src/Renderers/FluentEmail.Liquid/LiquidRendererOptions.cs index feed0c4a..8f8df34b 100644 --- a/src/Renderers/FluentEmail.Liquid/LiquidRendererOptions.cs +++ b/src/Renderers/FluentEmail.Liquid/LiquidRendererOptions.cs @@ -25,5 +25,10 @@ public class LiquidRendererOptions /// File provider to use, used when resolving references in templates, like master layout. /// public IFileProvider? FileProvider { get; set; } + + /// + /// Set custom Template Options for Fluid + /// + public TemplateOptions TemplateOptions { get; set; } = new(); } } \ No newline at end of file diff --git a/src/Renderers/FluentEmail.Liquid/Tags/LayoutTag.cs b/src/Renderers/FluentEmail.Liquid/Tags/LayoutTag.cs deleted file mode 100644 index 2b2b5cdf..00000000 --- a/src/Renderers/FluentEmail.Liquid/Tags/LayoutTag.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.IO; -using System.Text.Encodings.Web; -using System.Threading.Tasks; - -using Fluid; -using Fluid.Ast; -using Fluid.Tags; - -namespace FluentEmail.Liquid.Tags -{ - internal sealed class LayoutTag : ExpressionTag - { - private const string ViewExtension = ".liquid"; - - public override async ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context, Expression expression) - { - var relativeLayoutPath = (await expression.EvaluateAsync(context)).ToStringValue(); - if (!relativeLayoutPath.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase)) - { - relativeLayoutPath += ViewExtension; - } - - context.AmbientValues["Layout"] = relativeLayoutPath; - return Completion.Normal; - } - } -} diff --git a/src/Renderers/FluentEmail.Liquid/Tags/RegisterSectionBlock.cs b/src/Renderers/FluentEmail.Liquid/Tags/RegisterSectionBlock.cs deleted file mode 100644 index a5151466..00000000 --- a/src/Renderers/FluentEmail.Liquid/Tags/RegisterSectionBlock.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Fluid; -using Fluid.Ast; -using Fluid.Tags; - -namespace FluentEmail.Liquid.Tags -{ - internal sealed class RegisterSectionBlock : IdentifierBlock - { - private static readonly ValueTask Normal = new ValueTask(Completion.Normal); - - public override ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context, string sectionName, List statements) - { - if (context.AmbientValues.TryGetValue("Sections", out var sections)) - { - var dictionary = (Dictionary>) sections; - dictionary[sectionName] = statements; - } - - return Normal; - } - } -} diff --git a/src/Renderers/FluentEmail.Liquid/Tags/RenderBodyTag.cs b/src/Renderers/FluentEmail.Liquid/Tags/RenderBodyTag.cs deleted file mode 100644 index 7a65af2b..00000000 --- a/src/Renderers/FluentEmail.Liquid/Tags/RenderBodyTag.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.IO; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Fluid; -using Fluid.Ast; -using Fluid.Tags; - -namespace FluentEmail.Liquid.Tags -{ - internal sealed class RenderBodyTag : SimpleTag - { - public override async ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context) - { - static void ThrowParseException() - { - throw new ParseException("Could not render body, Layouts can't be evaluated directly."); - } - - if (context.AmbientValues.TryGetValue("Body", out var body)) - { - await writer.WriteAsync((string)body); - } - else - { - ThrowParseException(); - } - - return Completion.Normal; - } - } -} diff --git a/src/Renderers/FluentEmail.Liquid/Tags/RenderSectionTag.cs b/src/Renderers/FluentEmail.Liquid/Tags/RenderSectionTag.cs deleted file mode 100644 index a5a15cc0..00000000 --- a/src/Renderers/FluentEmail.Liquid/Tags/RenderSectionTag.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Fluid; -using Fluid.Ast; -using Fluid.Tags; - -namespace FluentEmail.Liquid.Tags -{ - internal sealed class RenderSectionTag : IdentifierTag - { - public override async ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context, string sectionName) - { - if (context.AmbientValues.TryGetValue("Sections", out var sections)) - { - var dictionary = (Dictionary>) sections; - if (dictionary.TryGetValue(sectionName, out var section)) - { - foreach(var statement in section) - { - await statement.WriteToAsync(writer, encoder, context); - } - } - } - - return Completion.Normal; - } - } -} diff --git a/test/FluentEmail.Liquid.Tests/ComplexModel/ComplexModelRenderTests.cs b/test/FluentEmail.Liquid.Tests/ComplexModel/ComplexModelRenderTests.cs new file mode 100644 index 00000000..04e642ee --- /dev/null +++ b/test/FluentEmail.Liquid.Tests/ComplexModel/ComplexModelRenderTests.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using FluentEmail.Core; +using Fluid; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; +using NUnit.Framework; + +namespace FluentEmail.Liquid.Tests.ComplexModel +{ + public class ComplexModelRenderTests + { + public ComplexModelRenderTests() + { + SetupRenderer(); + } + + [Test] + public void Can_Render_Complex_Model_Properties() + { + var model = new ParentModel + { + ParentName = new NameDetails { Firstname = "Luke", Surname = "Dinosaur" }, + ChildrenNames = new List + { + new NameDetails { Firstname = "ChildFirstA", Surname = "ChildLastA" }, + new NameDetails { Firstname = "ChildFirstB", Surname = "ChildLastB" } + } + }; + + var expected = @" +Parent: Luke +Children: + +* ChildFirstA ChildLastA +* ChildFirstB ChildLastB +"; + + var email = Email + .From(TestData.FromEmail) + .To(TestData.ToEmail) + .Subject(TestData.Subject) + .UsingTemplate(Template(), model); + email.Data.Body.Should().Be(expected); + } + + private string Template() + { + return @" +Parent: {{ ParentName.Firstname }} +Children: +{% for Child in ChildrenNames %} +* {{ Child.Firstname }} {{ Child.Surname }}{% endfor %} +"; + } + + private static void SetupRenderer( + IFileProvider fileProvider = null, + Action configureTemplateContext = null) + { + var options = new LiquidRendererOptions + { + FileProvider = fileProvider, + ConfigureTemplateContext = configureTemplateContext, + TemplateOptions = new TemplateOptions { MemberAccessStrategy = new UnsafeMemberAccessStrategy() } + }; + Email.DefaultRenderer = new LiquidRenderer(Options.Create(options)); + } + } +} \ No newline at end of file diff --git a/test/FluentEmail.Liquid.Tests/ComplexModel/ParentModel.cs b/test/FluentEmail.Liquid.Tests/ComplexModel/ParentModel.cs new file mode 100644 index 00000000..029f3449 --- /dev/null +++ b/test/FluentEmail.Liquid.Tests/ComplexModel/ParentModel.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace FluentEmail.Liquid.Tests +{ + public class ParentModel + { + public string Id { get; set; } + public NameDetails ParentName { get; set; } + public List ChildrenNames { get; set; } = new List(); + } + + public class NameDetails + { + public string Firstname { get; set; } + public string Surname { get; set; } + } +} \ No newline at end of file diff --git a/test/FluentEmail.Liquid.Tests/ComplexModel/TestData.cs b/test/FluentEmail.Liquid.Tests/ComplexModel/TestData.cs new file mode 100644 index 00000000..e1dd598c --- /dev/null +++ b/test/FluentEmail.Liquid.Tests/ComplexModel/TestData.cs @@ -0,0 +1,9 @@ +namespace FluentEmail.Liquid.Tests +{ + public static class TestData + { + public const string ToEmail = "bob@test.com"; + public const string FromEmail = "johno@test.com"; + public const string Subject = "sup dawg"; + } +} \ No newline at end of file diff --git a/test/FluentEmail.Liquid.Tests/FluentEmail.Liquid.Tests.csproj b/test/FluentEmail.Liquid.Tests/FluentEmail.Liquid.Tests.csproj index e2363f10..e2c5dcfc 100644 --- a/test/FluentEmail.Liquid.Tests/FluentEmail.Liquid.Tests.csproj +++ b/test/FluentEmail.Liquid.Tests/FluentEmail.Liquid.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/test/FluentEmail.Liquid.Tests/LiquidTests.cs b/test/FluentEmail.Liquid.Tests/LiquidTests.cs index 368e20e0..2d53e3b9 100644 --- a/test/FluentEmail.Liquid.Tests/LiquidTests.cs +++ b/test/FluentEmail.Liquid.Tests/LiquidTests.cs @@ -22,7 +22,7 @@ public class LiquidTests public void SetUp() { // default to have no file provider, only required when layout files are in use - SetupRenderer(null); + SetupRenderer(); } private static void SetupRenderer( @@ -51,7 +51,6 @@ public void Model_With_List_Template_Matches() Assert.AreEqual("sup LUKE here is a list 123", email.Data.Body); } - [Test] public void Custom_Context_Values() { @@ -155,8 +154,7 @@ public void Should_be_able_to_use_project_layout() { SetupRenderer(new PhysicalFileProvider(Path.Combine(new FileInfo(Assembly.GetExecutingAssembly().Location).Directory!.FullName, "EmailTemplates"))); - const string template = @" -{% layout '_layout.liquid' %} + const string template = @"{% layout '_layout.liquid' %} sup {{ Name }} here is a list {% for i in Numbers %}{{ i }}{% endfor %}"; var email = new Email(FromEmail) @@ -167,14 +165,12 @@ public void Should_be_able_to_use_project_layout() Assert.AreEqual($"

Hello!

{Environment.NewLine}
{Environment.NewLine}sup LUKE here is a list 123
", email.Data.Body); } - [Test] public void Should_be_able_to_use_embedded_layout() { SetupRenderer(new EmbeddedFileProvider(typeof(LiquidTests).Assembly, "FluentEmail.Liquid.Tests.EmailTemplates")); - const string template = @" -{% layout '_embedded.liquid' %} + const string template = @"{% layout '_embedded.liquid' %} sup {{ Name }} here is a list {% for i in Numbers %}{{ i }}{% endfor %}"; var email = new Email(FromEmail)