From ed65c479861e6367389101bbdaa2f8a7da965993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Tue, 12 Oct 2021 15:27:25 -0700 Subject: [PATCH] Separate Includes and Partials in View Engine (#377) --- Fluid.MvcSample/Views/Home/Index.liquid | 2 +- Fluid.MvcSample/Views/_Layout.liquid | 2 +- Fluid.MvcViewEngine/FluidMvcViewOptions.cs | 15 +- Fluid.MvcViewEngine/FluidRendering.cs | 2 +- Fluid.MvcViewEngine/FluidViewEngine.cs | 10 +- .../FluidViewEngineOptionsSetup.cs | 19 ++- Fluid.Tests/Fluid.Tests.csproj | 1 + Fluid.Tests/Mocks/MockFileInfo.cs | 6 +- Fluid.Tests/Mocks/MockFileProvider.cs | 24 +++- .../{MvcViewEngineTests.cs => SampleTests.cs} | 2 +- Fluid.Tests/MvcViewEngine/ViewEngineTests.cs | 135 ++++++++++++++++++ Fluid.ViewEngine/Constants.cs | 1 + Fluid.ViewEngine/FileProviderMapper.cs | 21 +-- Fluid.ViewEngine/FluidViewEngineOptions.cs | 17 ++- Fluid.ViewEngine/FluidViewParser.cs | 55 ++++++- Fluid.ViewEngine/FluidViewRenderer.cs | 54 ++++--- Fluid.ViewEngine/IFluidViewRenderer.cs | 1 + Fluid/ExceptionHelper.cs | 14 ++ Fluid/Fluid.csproj | 2 +- Fluid/TemplateContext.cs | 27 +++- Fluid/TemplateOptions.cs | 2 +- README.md | 19 ++- 22 files changed, 353 insertions(+), 78 deletions(-) rename Fluid.Tests/MvcViewEngine/{MvcViewEngineTests.cs => SampleTests.cs} (96%) create mode 100644 Fluid.Tests/MvcViewEngine/ViewEngineTests.cs diff --git a/Fluid.MvcSample/Views/Home/Index.liquid b/Fluid.MvcSample/Views/Home/Index.liquid index 3235430a..2285b06f 100644 --- a/Fluid.MvcSample/Views/Home/Index.liquid +++ b/Fluid.MvcSample/Views/Home/Index.liquid @@ -7,7 +7,7 @@ Hello World from Liquid 2 {% endfor %} {% assign invoker = 'Index' %} -{% include 'Home/_Partial' %} +{% partial 'Home/_Partial' %} {% section footer %} This is the footer diff --git a/Fluid.MvcSample/Views/_Layout.liquid b/Fluid.MvcSample/Views/_Layout.liquid index 8069e198..e629738f 100644 --- a/Fluid.MvcSample/Views/_Layout.liquid +++ b/Fluid.MvcSample/Views/_Layout.liquid @@ -3,7 +3,7 @@ {{ ViewData['Title'] }} {% assign invoker = '_Layout' %} - {% include '_Partial' %} + {% partial '_Partial' %} {% renderbody %} diff --git a/Fluid.MvcViewEngine/FluidMvcViewOptions.cs b/Fluid.MvcViewEngine/FluidMvcViewOptions.cs index 6a4a8eca..15b2ef12 100644 --- a/Fluid.MvcViewEngine/FluidMvcViewOptions.cs +++ b/Fluid.MvcViewEngine/FluidMvcViewOptions.cs @@ -5,19 +5,6 @@ namespace Fluid.MvcViewEngine { public class FluidMvcViewOptions : FluidViewEngineOptions { - /// - /// Gets les list of view location formats. - /// - /// - /// The first argument '{0}' is the view name. - /// The second argument '{1}' is the controller name. - /// The third argument '{2}' is the area name. - /// - /// - /// "Views/{1}/{0}" - /// "Views/Shared/{0}" - /// - public IList ViewLocationFormats { get; } = new List(); - + // Placeholder for future options specific to the MVC view engine } } diff --git a/Fluid.MvcViewEngine/FluidRendering.cs b/Fluid.MvcViewEngine/FluidRendering.cs index 541153e0..f969a6c2 100644 --- a/Fluid.MvcViewEngine/FluidRendering.cs +++ b/Fluid.MvcViewEngine/FluidRendering.cs @@ -24,7 +24,7 @@ public FluidRendering( _options.TemplateOptions.MemberAccessStrategy.Register(); _options.TemplateOptions.MemberAccessStrategy.Register(); - _options.TemplateOptions.FileProvider = new FileProviderMapper(_options.IncludesFileProvider ?? _hostingEnvironment.ContentRootFileProvider, _options.ViewsPath); + _options.TemplateOptions.FileProvider = _options.PartialsFileProvider ?? _hostingEnvironment.ContentRootFileProvider; _fluidViewRenderer = new FluidViewRenderer(_options); diff --git a/Fluid.MvcViewEngine/FluidViewEngine.cs b/Fluid.MvcViewEngine/FluidViewEngine.cs index 94f6f86d..981a3246 100644 --- a/Fluid.MvcViewEngine/FluidViewEngine.cs +++ b/Fluid.MvcViewEngine/FluidViewEngine.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using Fluid.ViewEngine; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewEngines; @@ -13,7 +14,6 @@ public class FluidViewEngine : IFluidViewEngine { private FluidRendering _fluidRendering; private readonly IWebHostEnvironment _hostingEnvironment; - public static readonly string ViewExtension = ".liquid"; private const string ControllerKey = "controller"; private const string AreaKey = "area"; private FluidMvcViewOptions _options; @@ -41,13 +41,13 @@ private ViewEngineResult LocatePageFromViewLocations(ActionContext actionContext var checkedLocations = new List(); - foreach (var location in _options.ViewLocationFormats) + foreach (var location in _options.ViewsLocationFormats) { - var view = string.Format(location, viewName, controllerName, areaName); + var view = String.Format(location, viewName, controllerName, areaName); if (fileProvider.GetFileInfo(view).Exists) { - return ViewEngineResult.Found("Default", new FluidView(view, _fluidRendering)); + return ViewEngineResult.Found(viewName, new FluidView(view, _fluidRendering)); } checkedLocations.Add(view); @@ -116,7 +116,7 @@ private static bool IsRelativePath(string name) Debug.Assert(!string.IsNullOrEmpty(name)); // Though ./ViewName looks like a relative path, framework searches for that view using view locations. - return name.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase); + return name.EndsWith(Constants.ViewExtension, StringComparison.OrdinalIgnoreCase); } public static string GetNormalizedRouteValue(ActionContext context, string key) diff --git a/Fluid.MvcViewEngine/FluidViewEngineOptionsSetup.cs b/Fluid.MvcViewEngine/FluidViewEngineOptionsSetup.cs index 955a3551..729797fd 100644 --- a/Fluid.MvcViewEngine/FluidViewEngineOptionsSetup.cs +++ b/Fluid.MvcViewEngine/FluidViewEngineOptionsSetup.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Hosting; +using Fluid.ViewEngine; +using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Options; namespace Fluid.MvcViewEngine @@ -11,10 +12,18 @@ internal class FluidViewEngineOptionsSetup : ConfigureOptions { - options.IncludesFileProvider = webHostEnvironment.ContentRootFileProvider; - options.ViewsFileProvider = webHostEnvironment.ContentRootFileProvider; - options.ViewLocationFormats.Add("Views/{1}/{0}" + FluidViewEngine.ViewExtension); - options.ViewLocationFormats.Add("Views/Shared/{0}" + FluidViewEngine.ViewExtension); + options.PartialsFileProvider = new FileProviderMapper(webHostEnvironment.ContentRootFileProvider, "Views"); + options.ViewsFileProvider = new FileProviderMapper(webHostEnvironment.ContentRootFileProvider, "Views"); + + options.ViewsLocationFormats.Clear(); + options.ViewsLocationFormats.Add("/{1}/{0}" + Constants.ViewExtension); + options.ViewsLocationFormats.Add("/Shared/{0}" + Constants.ViewExtension); + + options.PartialsLocationFormats.Clear(); + options.PartialsLocationFormats.Add("{0}" + Constants.ViewExtension); + options.PartialsLocationFormats.Add("/Partials/{0}" + Constants.ViewExtension); + options.PartialsLocationFormats.Add("/Partials/{1}/{0}" + Constants.ViewExtension); + options.PartialsLocationFormats.Add("/Shared/Partials/{0}" + Constants.ViewExtension); }) { } diff --git a/Fluid.Tests/Fluid.Tests.csproj b/Fluid.Tests/Fluid.Tests.csproj index 21d724d9..9b8ac923 100644 --- a/Fluid.Tests/Fluid.Tests.csproj +++ b/Fluid.Tests/Fluid.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/Fluid.Tests/Mocks/MockFileInfo.cs b/Fluid.Tests/Mocks/MockFileInfo.cs index 864d4541..bf0ca081 100644 --- a/Fluid.Tests/Mocks/MockFileInfo.cs +++ b/Fluid.Tests/Mocks/MockFileInfo.cs @@ -7,6 +7,10 @@ namespace Fluid.Tests.Mocks { public class MockFileInfo : IFileInfo { + public static readonly MockFileInfo Null = new MockFileInfo("", "") { _exists = false }; + + private bool _exists = true; + public MockFileInfo(string name, string content) { Name = name; @@ -14,7 +18,7 @@ public MockFileInfo(string name, string content) } public string Content { get; set; } - public bool Exists => true; + public bool Exists => _exists; public bool IsDirectory => false; diff --git a/Fluid.Tests/Mocks/MockFileProvider.cs b/Fluid.Tests/Mocks/MockFileProvider.cs index 2bbc5066..89499d69 100644 --- a/Fluid.Tests/Mocks/MockFileProvider.cs +++ b/Fluid.Tests/Mocks/MockFileProvider.cs @@ -2,15 +2,18 @@ using Microsoft.Extensions.Primitives; using System; using System.Collections.Generic; +using System.IO; namespace Fluid.Tests.Mocks { public class MockFileProvider : IFileProvider { private Dictionary _files = new Dictionary(); + private readonly bool _caseSensitive; - public MockFileProvider() + public MockFileProvider(bool caseSensitive = false) { + _caseSensitive = caseSensitive; } public IDirectoryContents GetDirectoryContents(string subpath) @@ -20,18 +23,22 @@ public IDirectoryContents GetDirectoryContents(string subpath) public IFileInfo GetFileInfo(string path) { + path = NormalizePath(path); + if (_files.ContainsKey(path)) { return _files[path]; } else { - return null; + return MockFileInfo.Null; } } public MockFileProvider Add(string path, string content) { + path = NormalizePath(path); + _files[path] = new MockFileInfo(path, content); return this; } @@ -40,5 +47,18 @@ public IChangeToken Watch(string filter) { throw new NotImplementedException(); } + + private string NormalizePath(string path) + { + path = path.Replace('\\', '/'); + path = path.Replace('/', Path.DirectorySeparatorChar); + + if (!_caseSensitive) + { + return path.ToLowerInvariant(); + } + + return path; + } } } diff --git a/Fluid.Tests/MvcViewEngine/MvcViewEngineTests.cs b/Fluid.Tests/MvcViewEngine/SampleTests.cs similarity index 96% rename from Fluid.Tests/MvcViewEngine/MvcViewEngineTests.cs rename to Fluid.Tests/MvcViewEngine/SampleTests.cs index 37013933..2759d5f3 100644 --- a/Fluid.Tests/MvcViewEngine/MvcViewEngineTests.cs +++ b/Fluid.Tests/MvcViewEngine/SampleTests.cs @@ -6,7 +6,7 @@ namespace Fluid.Tests.MvcViewEngine { - public class MvcViewEngineTests + public class SampleTests { [Fact] public void ShouldParseIndex() diff --git a/Fluid.Tests/MvcViewEngine/ViewEngineTests.cs b/Fluid.Tests/MvcViewEngine/ViewEngineTests.cs new file mode 100644 index 00000000..2eea500b --- /dev/null +++ b/Fluid.Tests/MvcViewEngine/ViewEngineTests.cs @@ -0,0 +1,135 @@ +using Fluid.Tests.Mocks; +using Fluid.ViewEngine; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace Fluid.Tests.MvcViewEngine +{ + public class ViewEngineTests + { + FluidViewEngineOptions _options = new (); + FluidViewRenderer _renderer; + MockFileProvider _mockFileProvider = new (); + + public ViewEngineTests() + { + _options.PartialsFileProvider = new FileProviderMapper(_mockFileProvider, "Partials"); + _options.ViewsFileProvider = new FileProviderMapper(_mockFileProvider, "Views"); + + _options.TemplateOptions.MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance; + + _options.ViewsLocationFormats.Clear(); + _options.ViewsLocationFormats.Add("/{0}" + Constants.ViewExtension); + + _options.PartialsLocationFormats.Clear(); + _options.PartialsLocationFormats.Add("{0}" + Constants.ViewExtension); + _options.PartialsLocationFormats.Add("/Partials/{0}" + Constants.ViewExtension); + + _renderer = new FluidViewRenderer(_options); + } + + [Fact] + public async Task ShouldRenderView() + { + _mockFileProvider.Add("Views/Index.liquid", "Hello World"); + + var sw = new StringWriter(); + await _renderer.RenderViewAsync(sw, "Index.liquid", new TemplateContext()); + await sw.FlushAsync(); + + Assert.Equal("Hello World", sw.ToString()); + } + + [Fact] + public async Task ShouldImportViewStart() + { + _mockFileProvider.Add("Views/Index.liquid", "Hello World"); + _mockFileProvider.Add("Views/_ViewStart.liquid", "ViewStart"); + + var sw = new StringWriter(); + await _renderer.RenderViewAsync(sw, "Index.liquid", new TemplateContext()); + await sw.FlushAsync(); + + Assert.Equal("ViewStartHello World", sw.ToString()); + } + + [Fact] + public async Task ShouldImportViewStartRecursively() + { + _mockFileProvider.Add("Views/Home/Index.liquid", "Hello World"); + _mockFileProvider.Add("Views/Home/_ViewStart.liquid", "ViewStart1"); + _mockFileProvider.Add("Views/_ViewStart.liquid", "ViewStart2"); + + var sw = new StringWriter(); + await _renderer.RenderViewAsync(sw, "Home/Index.liquid", new TemplateContext()); + await sw.FlushAsync(); + + Assert.Equal("ViewStart1ViewStart2Hello World", sw.ToString()); + } + + [Fact] + public async Task ShouldIncludePartialsUsingSpecifiFileProvider() + { + _mockFileProvider.Add("Views/Index.liquid", "Hello {% partial 'world' %}"); + _mockFileProvider.Add("Partials/World.liquid", "World"); + + var sw = new StringWriter(); + await _renderer.RenderViewAsync(sw, "Index.liquid", new TemplateContext()); + await sw.FlushAsync(); + + Assert.Equal("Hello World", sw.ToString()); + } + + [Fact] + public async Task ShouldIncludePartialsWithArguments() + { + _mockFileProvider.Add("Views/Index.liquid", "Hello {% partial 'world', x: 1 %}"); + _mockFileProvider.Add("Partials/World.liquid", "World {{ x }}"); + + var sw = new StringWriter(); + await _renderer.RenderViewAsync(sw, "Index.liquid", new TemplateContext()); + await sw.FlushAsync(); + + Assert.Equal("Hello World 1", sw.ToString()); + } + + [Fact] + public async Task ShouldApplyLayout() + { + _mockFileProvider.Add("Views/Index.liquid", "{% layout '_Layout' %}Hi"); + _mockFileProvider.Add("Views/_Layout.liquid", "A {% renderbody %} B"); + + var sw = new StringWriter(); + await _renderer.RenderViewAsync(sw, "Index.liquid", new TemplateContext()); + await sw.FlushAsync(); + + Assert.Equal("A Hi B", sw.ToString()); + } + + [Fact] + public async Task ShouldRenderSection() + { + _mockFileProvider.Add("Views/Index.liquid", "{% section s %}S1{% endsection %}A {% rendersection s %} B"); + + var sw = new StringWriter(); + await _renderer.RenderViewAsync(sw, "Index.liquid", new TemplateContext()); + await sw.FlushAsync(); + + Assert.Equal("A S1 B", sw.ToString()); + } + + [Fact] + public async Task ShouldRenderSectionInLayout() + { + _mockFileProvider.Add("Views/Index.liquid", "{% layout '_Layout' %}Hi{% section s %}S1{% endsection %}"); + _mockFileProvider.Add("Views/_Layout.liquid", "A {% rendersection s %} {% renderbody %} B"); + + var sw = new StringWriter(); + await _renderer.RenderViewAsync(sw, "Index.liquid", new TemplateContext()); + await sw.FlushAsync(); + + Assert.Equal("A S1 Hi B", sw.ToString()); + } + } +} diff --git a/Fluid.ViewEngine/Constants.cs b/Fluid.ViewEngine/Constants.cs index 3e1630a3..b7e3f4a0 100644 --- a/Fluid.ViewEngine/Constants.cs +++ b/Fluid.ViewEngine/Constants.cs @@ -6,6 +6,7 @@ public static class Constants public const string SectionsIndex = "$$sections"; public const string BodyIndex = "$$body"; public const string LayoutIndex = "$$layout"; + public const string RendererIndex = "$$renderer"; public const string ViewStartFilename = "_ViewStart.liquid"; public const string ViewExtension = ".liquid"; diff --git a/Fluid.ViewEngine/FileProviderMapper.cs b/Fluid.ViewEngine/FileProviderMapper.cs index 4defc1a8..1a29140f 100644 --- a/Fluid.ViewEngine/FileProviderMapper.cs +++ b/Fluid.ViewEngine/FileProviderMapper.cs @@ -7,34 +7,35 @@ namespace Fluid.ViewEngine public class FileProviderMapper : IFileProvider { private readonly IFileProvider _fileProvider; - private readonly string _partialsFolder; + private readonly string _mappedFolder; - public FileProviderMapper(IFileProvider fileProvider) + public FileProviderMapper(IFileProvider fileProvider, string mappedFolder) { _fileProvider = fileProvider; - } + _mappedFolder = mappedFolder; - public FileProviderMapper(IFileProvider fileProvider, string partialsFolder = "Partials") - { - _fileProvider = fileProvider; - _partialsFolder = partialsFolder; + if (!_mappedFolder.EndsWith("/") || _mappedFolder.EndsWith("\\")) + { + _mappedFolder = _mappedFolder + Path.DirectorySeparatorChar; + } } public IDirectoryContents GetDirectoryContents(string subpath) { - var path = Path.Combine(_partialsFolder, subpath); + var path = _mappedFolder + subpath; return _fileProvider.GetDirectoryContents(path); } public IFileInfo GetFileInfo(string subpath) { - var path = Path.Combine(_partialsFolder, subpath); + var path = _mappedFolder + subpath; return _fileProvider.GetFileInfo(path); } public IChangeToken Watch(string filter) { - return _fileProvider.Watch(filter); + var mappedFilter = _mappedFolder + filter; + return _fileProvider.Watch(mappedFilter); } } } diff --git a/Fluid.ViewEngine/FluidViewEngineOptions.cs b/Fluid.ViewEngine/FluidViewEngineOptions.cs index 607fb4d7..2b066fd8 100644 --- a/Fluid.ViewEngine/FluidViewEngineOptions.cs +++ b/Fluid.ViewEngine/FluidViewEngineOptions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.FileProviders; +using System.Collections.Generic; using System.Text.Encodings.Web; namespace Fluid.ViewEngine @@ -31,12 +32,22 @@ public class FluidViewEngineOptions /// /// Gets or sets the used to access includes. /// - public IFileProvider IncludesFileProvider { get; set; } + public IFileProvider PartialsFileProvider { get; set; } /// - /// Gets or sets the path of the views. Default is "Views" + /// Gets the list of view location format strings. The formatting arguments can differ for each implementation of . /// - public string ViewsPath { get; set; } = "Views"; + /// + /// /Views/{0}.liquid + /// + public List ViewsLocationFormats { get; } = new(); + /// + /// Gets the list of partial views location format strings. The formatting arguments can differ for each implementation of . + /// + /// + /// /Views/Includes/{0}.liquid + /// + public List PartialsLocationFormats { get; } = new(); } } diff --git a/Fluid.ViewEngine/FluidViewParser.cs b/Fluid.ViewEngine/FluidViewParser.cs index f6649656..7f1fc868 100644 --- a/Fluid.ViewEngine/FluidViewParser.cs +++ b/Fluid.ViewEngine/FluidViewParser.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using static Parlot.Fluent.Parsers; namespace Fluid.ViewEngine { @@ -15,7 +16,10 @@ public FluidViewParser() if (context.AmbientValues.TryGetValue(Constants.SectionsIndex, out var sections)) { var dictionary = sections as Dictionary>; - if (dictionary.TryGetValue(identifier, out var section)) + + // dictionary can be null if no "section" tag was invoked + + if (dictionary != null && dictionary.TryGetValue(identifier, out var section)) { foreach (var statement in section) { @@ -46,6 +50,15 @@ public FluidViewParser() if (context.AmbientValues.TryGetValue(Constants.SectionsIndex, out var sections)) { var dictionary = sections as Dictionary>; + + if (dictionary == null) + { + // Lazily initialize the sections dictionary + + dictionary = new Dictionary>(); + context.AmbientValues[Constants.SectionsIndex] = dictionary; + } + dictionary[identifier] = statements; } @@ -77,6 +90,46 @@ public FluidViewParser() return Completion.Normal; }); + + var partialExpression = OneOf( + Primary.AndSkip(Comma).And(Separated(Comma, Identifier.AndSkip(Colon).And(Primary).Then(static x => new AssignStatement(x.Item1, x.Item2)))).Then(x => new { Expression = x.Item1, Assignments = x.Item2 }), + Primary.Then(x => new { Expression = x, Assignments = new List() }) + ).ElseError("Invalid 'partial' tag"); + + RegisterParserTag("partial", partialExpression, static async (partialStatement, writer, encoder, context) => + { + var relativePartialPath = (await partialStatement.Expression.EvaluateAsync(context)).ToStringValue(); + + context.IncrementSteps(); + + try + { + context.EnterChildScope(); + + if (!relativePartialPath.EndsWith(Constants.ViewExtension, StringComparison.OrdinalIgnoreCase)) + { + relativePartialPath += Constants.ViewExtension; + } + + var renderer = context.AmbientValues[Constants.RendererIndex] as IFluidViewRenderer; + + if (partialStatement.Assignments != null) + { + foreach (var assignStatement in partialStatement.Assignments) + { + await assignStatement.WriteToAsync(writer, encoder, context); + } + } + + await renderer.RenderPartialAsync(writer, relativePartialPath, context); + } + finally + { + context.ReleaseScope(); + } + + return Completion.Normal; + }); } } } diff --git a/Fluid.ViewEngine/FluidViewRenderer.cs b/Fluid.ViewEngine/FluidViewRenderer.cs index fa3a0594..6796f36c 100644 --- a/Fluid.ViewEngine/FluidViewRenderer.cs +++ b/Fluid.ViewEngine/FluidViewRenderer.cs @@ -20,7 +20,7 @@ public FluidViewRenderer(FluidViewEngineOptions fluidViewEngineOptions) { _fluidViewEngineOptions = fluidViewEngineOptions; - _fluidViewEngineOptions.TemplateOptions.FileProvider = new FileProviderMapper(_fluidViewEngineOptions.IncludesFileProvider, _fluidViewEngineOptions.ViewsPath); + _fluidViewEngineOptions.TemplateOptions.FileProvider = _fluidViewEngineOptions.PartialsFileProvider ?? _fluidViewEngineOptions.ViewsFileProvider ?? new NullFileProvider(); } private readonly FluidViewEngineOptions _fluidViewEngineOptions; @@ -29,10 +29,15 @@ public virtual async Task RenderViewAsync(TextWriter writer, string relativePath { // Provide some services to all statements context.AmbientValues[Constants.ViewPathIndex] = relativePath; - context.AmbientValues[Constants.SectionsIndex] = new Dictionary>(); + context.AmbientValues[Constants.SectionsIndex] = null; // it is lazily initialized when first used + context.AmbientValues[Constants.RendererIndex] = this; var template = await GetFluidTemplateAsync(relativePath, _fluidViewEngineOptions.ViewsFileProvider, true); + // The body is rendered and buffer before the Layout since it can contain fragments + // that need to be rendered as part of the Layout. + // Also the body or its _ViewStarts might contain a Layout tag. + var body = await template.RenderAsync(context, _fluidViewEngineOptions.TextEncoder); // If a layout is specified while rendering a view, execute it @@ -44,22 +49,37 @@ public virtual async Task RenderViewAsync(TextWriter writer, string relativePath await layoutTemplate.RenderAsync(writer, _fluidViewEngineOptions.TextEncoder, context); } + else + { + writer.Write(body); + } + } + + public virtual async Task RenderPartialAsync(TextWriter writer, string relativePath, TemplateContext context) + { + // Substitute View Path + context.AmbientValues.TryGetValue(Constants.ViewPathIndex, out var viewPath); + context.AmbientValues[Constants.ViewPathIndex] = relativePath; + + var template = await GetFluidTemplateAsync(relativePath, _fluidViewEngineOptions.PartialsFileProvider, false); + + await template.RenderAsync(writer, _fluidViewEngineOptions.TextEncoder, context); } protected virtual List FindViewStarts(string viewPath, IFileProvider fileProvider) { var viewStarts = new List(); int index = viewPath.Length - 1; - while (!String.IsNullOrEmpty(viewPath) && - !(String.Equals(viewPath, _fluidViewEngineOptions.ViewsPath, StringComparison.OrdinalIgnoreCase))) + + while (!String.IsNullOrEmpty(viewPath)) { - index = viewPath.LastIndexOf('/', index); - if (index == -1) { return viewStarts; } + index = viewPath.LastIndexOf('/', index); + viewPath = viewPath.Substring(0, index + 1); var viewStartPath = viewPath + Constants.ViewStartFilename; @@ -70,19 +90,6 @@ protected virtual List FindViewStarts(string viewPath, IFileProvider fil { viewStarts.Add(viewStartPath); } - else - { - // Try with the lower cased version for backward compatibility, c.f. https://github.com/sebastienros/fluid/issues/361 - - viewStartPath = viewPath + Constants.ViewStartFilename.ToLowerInvariant(); - - viewStartInfo = fileProvider.GetFileInfo(viewStartPath); - - if (viewStartInfo.Exists) - { - viewStarts.Add(viewStartPath); - } - } index = index - 1; } @@ -116,6 +123,13 @@ protected virtual void SetCachedTemplate(string path, IFluidTemplate template) protected virtual async ValueTask ParseLiquidFileAsync(string path, IFileProvider fileProvider, bool includeViewStarts) { + var fileInfo = fileProvider.GetFileInfo(path); + + if (!fileInfo.Exists) + { + return new FluidTemplate(); + } + var subTemplates = new List(); if (includeViewStarts) @@ -137,8 +151,6 @@ protected virtual async ValueTask ParseLiquidFileAsync(string pa } } - var fileInfo = fileProvider.GetFileInfo(path); - using (var stream = fileInfo.CreateReadStream()) { using (var sr = new StreamReader(stream)) diff --git a/Fluid.ViewEngine/IFluidViewRenderer.cs b/Fluid.ViewEngine/IFluidViewRenderer.cs index b9c42591..f450f4be 100644 --- a/Fluid.ViewEngine/IFluidViewRenderer.cs +++ b/Fluid.ViewEngine/IFluidViewRenderer.cs @@ -6,5 +6,6 @@ namespace Fluid.ViewEngine public interface IFluidViewRenderer { Task RenderViewAsync(TextWriter writer, string path, TemplateContext context); + Task RenderPartialAsync(TextWriter writer, string path, TemplateContext context); } } diff --git a/Fluid/ExceptionHelper.cs b/Fluid/ExceptionHelper.cs index 336d7931..0f134d72 100644 --- a/Fluid/ExceptionHelper.cs +++ b/Fluid/ExceptionHelper.cs @@ -15,6 +15,13 @@ public static void ThrowArgumentNullException(string paramName, string? message throw new ArgumentNullException(paramName, message); } + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowInvalidOperationException(string message) + { + throw new InvalidOperationException(message); + } + [DoesNotReturn] [MethodImpl(MethodImplOptions.NoInlining)] public static void ThrowArgumentOutOfRangeException(string paramName, string message) @@ -29,6 +36,13 @@ public static void ThrowParseException(string message) throw new ParseException(message); } + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowMaximumRecursionException() + { + throw new InvalidOperationException("The maximum level of recursion has been reached. Your script must have a cyclic include statement."); + } + [DoesNotReturn] [MethodImpl(MethodImplOptions.NoInlining)] public static void ThrowMaximumStatementsException() diff --git a/Fluid/Fluid.csproj b/Fluid/Fluid.csproj index 9049e7fc..bf59c27c 100644 --- a/Fluid/Fluid.csproj +++ b/Fluid/Fluid.csproj @@ -12,7 +12,7 @@ - + diff --git a/Fluid/TemplateContext.cs b/Fluid/TemplateContext.cs index 7cfe188d..525c1559 100644 --- a/Fluid/TemplateContext.cs +++ b/Fluid/TemplateContext.cs @@ -25,7 +25,12 @@ public TemplateContext() : this(TemplateOptions.Default) /// Whether the members of the model can be accessed by default. public TemplateContext(object model, TemplateOptions options, bool allowModelMembers = true) : this(options) { - Model = model ?? throw new ArgumentNullException(nameof(model)); + if (model == null) + { + ExceptionHelper.ThrowArgumentNullException(nameof(model)); + } + + Model = model; AllowModelMembers = allowModelMembers; } @@ -49,7 +54,12 @@ public TemplateContext(TemplateOptions options) /// Whether the members of the model can be accessed by default. public TemplateContext(object model, bool allowModelMembers = true) : this() { - Model = model ?? throw new ArgumentNullException(nameof(model)); + if (model == null) + { + ExceptionHelper.ThrowArgumentNullException(nameof(model)); + } + + Model = model; AllowModelMembers = allowModelMembers; } @@ -73,12 +83,15 @@ public TemplateContext(object model, bool allowModelMembers = true) : this() /// public TimeZoneInfo TimeZone { get; set; } = TemplateOptions.Default.TimeZone; - internal void IncrementSteps() + /// + /// Increments the number of statements the current template is processing. + /// + public void IncrementSteps() { var maxSteps = Options.MaxSteps; if (maxSteps > 0 && _steps++ > maxSteps) { - ExceptionHelper.ThrowMaximumStatementsException(); + ExceptionHelper.ThrowMaximumRecursionException(); } } @@ -111,7 +124,8 @@ public void EnterChildScope() { if (Options.MaxRecursion > 0 && _recursion++ > Options.MaxRecursion) { - throw new InvalidOperationException("The maximum level of recursion has been reached. Your script must have a cyclic include statement."); + ExceptionHelper.ThrowMaximumRecursionException(); + return; } LocalScope = LocalScope.EnterChildScope(); @@ -131,7 +145,8 @@ public void ReleaseScope() if (LocalScope == null) { - throw new InvalidOperationException(); + ExceptionHelper.ThrowInvalidOperationException("Release scoped invoked without corresponding EnterChildScope"); + return; } } diff --git a/Fluid/TemplateOptions.cs b/Fluid/TemplateOptions.cs index c3f85c3f..b7e718c9 100644 --- a/Fluid/TemplateOptions.cs +++ b/Fluid/TemplateOptions.cs @@ -17,7 +17,7 @@ public class TemplateOptions public MemberAccessStrategy MemberAccessStrategy { get; set; } = new DefaultMemberAccessStrategy(); /// - /// Gets or sets the used to access files. + /// Gets or sets the used to access files for include and render statements. /// public IFileProvider FileProvider { get; set; } = new NullFileProvider(); diff --git a/README.md b/README.md index 779f1643..8820f2e4 100644 --- a/README.md +++ b/README.md @@ -565,7 +565,7 @@ Hello ## ASP.NET MVC View Engine -To provide a convenient view engine implementation for ASP.NET Core MVC the grammar is extended as described in [Customizing tags](#customizing-tags) by adding these new tags: +The package `Fluid.MvcViewEngine` provides a convenient way to use Liquid as a replacement or in combination of Razor in ASP.NET MVC. ### Configuration @@ -610,7 +610,7 @@ More way to register types and members can be found in the [Allow-listing object #### Registering custom tags -When using the MVC View engine, custom tags can be added to the parser. Refer to [this section](https://github.com/sebastienros/fluid#registering-a-custom-tag) on how to create custom tags. +When using the MVC View engine, custom tags can still be added to the parser. Refer to [this section](https://github.com/sebastienros/fluid#registering-a-custom-tag) on how to create custom tags. It is recommended to create a custom class inheriting from `FluidViewParser`, and to customize the tags in the constructor of this new class. This class can then be registered as the default parser for the MVC view engine. @@ -641,7 +641,7 @@ public class Startup { public void ConfigureServices(IServiceCollection services) { - services.Configure(options => + services.Configure(options => { options.Parser = new CustomFluidViewParser(); }); @@ -740,7 +740,7 @@ You can also define other variables or render some content. ### Custom views locations -It is possible to add custom file locations containing views by adding them to `FluidViewEngineOptions.ViewLocationFormats`. +It is possible to add custom file locations containing views by adding them to `MvcViewOptions.ViewsLocationForm`. The default ones are: - `Views/{1}/{0}` @@ -756,6 +756,17 @@ This difference makes Fluid very adapted for rapid development cycles where the
+## View Engine + +The Fluid ASP.NET MVC View Engine is based on an MVC agnostic view engine provided in the `Fluid.ViewEngine` package. The same options and features are available, but without +requiring ASP.NET MVC. This is useful to provide the same experience to build template using layouts and sections. + +### Usage + +Use the class `FluidViewRenderer : IFluidViewRender` and `FluidViewEngineOptions`. + + + ## Whitespace control Liquid follows strict rules with regards to whitespace support. By default all spaces and new lines are preserved from the template.