diff --git a/src/RazorBlade.Library/HtmlLayout.cs b/src/RazorBlade.Library/HtmlLayout.cs index bf20c03..f3104fd 100644 --- a/src/RazorBlade.Library/HtmlLayout.cs +++ b/src/RazorBlade.Library/HtmlLayout.cs @@ -9,11 +9,9 @@ namespace RazorBlade; /// public abstract class HtmlLayout : HtmlTemplate, IRazorLayout { - private IRazorLayout.IExecutionResult? _layoutInput; + private IRazorExecutionResult? _layoutInput; - private IRazorLayout.IExecutionResult LayoutInput => _layoutInput ?? throw new InvalidOperationException("No layout is being rendered."); - - async Task IRazorLayout.ExecuteLayoutAsync(IRazorLayout.IExecutionResult input) + async Task IRazorLayout.ExecuteLayoutAsync(IRazorExecutionResult input) { try { @@ -30,7 +28,7 @@ public abstract class HtmlLayout : HtmlTemplate, IRazorLayout /// Returns the inner page body. /// protected internal IEncodedContent RenderBody() - => LayoutInput.Body; + => GetLayoutInput().Body; /// /// Renders a required section and returns the result as encoded content. @@ -71,7 +69,7 @@ protected internal Task RenderSectionAsync(string name) /// The content to write to the output. protected internal async Task RenderSectionAsync(string name, bool required) { - var result = await LayoutInput.RenderSectionAsync(name).ConfigureAwait(false); + var result = await GetLayoutInput().RenderSectionAsync(name).ConfigureAwait(false); if (result is not null) return result; @@ -79,6 +77,19 @@ protected internal async Task RenderSectionAsync(string name, b if (required) throw new InvalidOperationException($"Section '{name}' is not defined."); - return StringBuilderEncodedContent.Empty; + return HtmlString.Empty; } + + /// + /// Indicates if a given section is defined. + /// + /// The section name. + protected internal bool IsSectionDefined(string name) + => GetLayoutInput().IsSectionDefined(name); + + /// + /// Ensures the template is being executed as a layout and returns the input data. + /// + private IRazorExecutionResult GetLayoutInput() + => _layoutInput ?? throw new InvalidOperationException("The template is not being executed as a layout."); } diff --git a/src/RazorBlade.Library/HtmlString.cs b/src/RazorBlade.Library/HtmlString.cs index 594d061..6d4939c 100644 --- a/src/RazorBlade.Library/HtmlString.cs +++ b/src/RazorBlade.Library/HtmlString.cs @@ -9,6 +9,11 @@ public sealed class HtmlString : IEncodedContent { private readonly string _value; + /// + /// Represents an empty HTML-encoded string. + /// + public static HtmlString Empty { get; } = new(string.Empty); + /// /// Creates a HTML-encoded string. /// diff --git a/src/RazorBlade.Library/HtmlTemplate.cs b/src/RazorBlade.Library/HtmlTemplate.cs index 1a421fe..aaef0a3 100644 --- a/src/RazorBlade.Library/HtmlTemplate.cs +++ b/src/RazorBlade.Library/HtmlTemplate.cs @@ -155,7 +155,5 @@ protected HtmlTemplate(TModel model) /// This constructor is provided for the designer only. Do not use. /// protected HtmlTemplate() - { - throw new NotSupportedException("Use the constructor overload that takes a model."); - } + => throw new NotSupportedException("Use the constructor overload that takes a model."); } diff --git a/src/RazorBlade.Library/IRazorExecutionResult.cs b/src/RazorBlade.Library/IRazorExecutionResult.cs new file mode 100644 index 0000000..af6fd4d --- /dev/null +++ b/src/RazorBlade.Library/IRazorExecutionResult.cs @@ -0,0 +1,38 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace RazorBlade; + +/// +/// The execution result of a Razor template. +/// +internal interface IRazorExecutionResult +{ + /// + /// The rendered body contents. + /// + IEncodedContent Body { get; } + + /// + /// The layout this execution result should be wrapped in. + /// + IRazorLayout? Layout { get; } + + /// + /// The cancellation token. + /// + CancellationToken CancellationToken { get; } + + /// + /// Renders a section. + /// + /// The section name. + /// The rendered output, or null if the section is not defined. + Task RenderSectionAsync(string name); + + /// + /// Indicates if a given section is defined. + /// + /// The section name. + bool IsSectionDefined(string name); +} diff --git a/src/RazorBlade.Library/IRazorLayout.cs b/src/RazorBlade.Library/IRazorLayout.cs index 8b38879..d1ce858 100644 --- a/src/RazorBlade.Library/IRazorLayout.cs +++ b/src/RazorBlade.Library/IRazorLayout.cs @@ -1,45 +1,16 @@ -using System.Threading; -using System.Threading.Tasks; +using System.Threading.Tasks; namespace RazorBlade; /// /// Represents a Razor layout page. /// -public interface IRazorLayout +internal interface IRazorLayout { /// /// Renders the layout for a given page. /// /// The input data. /// The output data after rendering the layout, which can be used for the next layout. - Task ExecuteLayoutAsync(IExecutionResult input); - - /// - /// The execution result of a page. - /// - public interface IExecutionResult - { - /// - /// The rendered body contents. - /// - IEncodedContent Body { get; } - - /// - /// The layout this execution result needs to be wrapped in. - /// - IRazorLayout? Layout { get; } - - /// - /// The cancellation token. - /// - CancellationToken CancellationToken { get; } - - /// - /// Renders a section. - /// - /// The section name. - /// The rendered output, or null if the section is not defined. - Task RenderSectionAsync(string name); - } + Task ExecuteLayoutAsync(IRazorExecutionResult input); } diff --git a/src/RazorBlade.Library/RazorTemplate.cs b/src/RazorBlade.Library/RazorTemplate.cs index d0acd2f..5f6f894 100644 --- a/src/RazorBlade.Library/RazorTemplate.cs +++ b/src/RazorBlade.Library/RazorTemplate.cs @@ -32,7 +32,7 @@ public abstract class RazorTemplate : IEncodedContent /// /// The layout to use. /// - protected internal IRazorLayout? Layout { get; set; } + private protected IRazorLayout? Layout { get; set; } /// /// Renders the template synchronously and returns the result as a string. @@ -112,6 +112,9 @@ public async Task RenderAsync(TextWriter textWriter, CancellationToken cancellat #endif } + /// + /// Renders the template asynchronously including its layout and returns the result as a . + /// private async Task RenderAsyncCore(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -124,7 +127,7 @@ private async Task RenderAsyncCore(CancellationToken cancellation executionResult = await layout.ExecuteLayoutAsync(executionResult).ConfigureAwait(false); } - if (executionResult.Body is StringBuilderEncodedContent { StringBuilder: var outputStringBuilder }) + if (executionResult.Body is EncodedContent { Output: var outputStringBuilder }) return outputStringBuilder; var outputStringWriter = new StringWriter(); @@ -132,13 +135,16 @@ private async Task RenderAsyncCore(CancellationToken cancellation return outputStringWriter.GetStringBuilder(); } - private protected async Task ExecuteAsyncCore(CancellationToken cancellationToken) + /// + /// Calls the method in a new . + /// + private protected async Task ExecuteAsyncCore(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); using var executionScope = new ExecutionScope(this, cancellationToken); await ExecuteAsync().ConfigureAwait(false); - return new ExecutionResult(this, executionScope.Output); + return new ExecutionResult(executionScope); } /// @@ -226,7 +232,47 @@ protected internal void DefineSection(string name, Func action) void IEncodedContent.WriteTo(TextWriter textWriter) => Render(textWriter, CancellationToken.None); - private class ExecutionResult : IRazorLayout.IExecutionResult + /// + /// Saves the current state, resets it, and restores it when disposed. + /// + private class ExecutionScope : IDisposable + { + private readonly Dictionary>? _previousSections; + private readonly TextWriter _previousOutput; + private readonly CancellationToken _previousCancellationToken; + private readonly IRazorLayout? _previousLayout; + + public RazorTemplate Page { get; } + public StringBuilder Output { get; } = new(); + + public ExecutionScope(RazorTemplate page, CancellationToken cancellationToken) + { + Page = page; + + _previousSections = page._sections; + _previousOutput = page.Output; + _previousCancellationToken = page.CancellationToken; + _previousLayout = page.Layout; + + page._sections = null; + page.Output = new StringWriter(Output); + page.CancellationToken = cancellationToken; + page.Layout = null; + } + + public void Dispose() + { + Page._sections = _previousSections; + Page.Output = _previousOutput; + Page.CancellationToken = _previousCancellationToken; + Page.Layout = _previousLayout; + } + } + + /// + /// Stores the result of a template execution. + /// + private class ExecutionResult : IRazorExecutionResult { private readonly RazorTemplate _page; private readonly IReadOnlyDictionary>? _sections; @@ -235,73 +281,48 @@ private class ExecutionResult : IRazorLayout.IExecutionResult public IRazorLayout? Layout { get; } public CancellationToken CancellationToken { get; } - public ExecutionResult(RazorTemplate page, StringBuilder body) + public ExecutionResult(ExecutionScope executionScope) { - _page = page; - _sections = page._sections; - Body = new StringBuilderEncodedContent(body); - Layout = page.Layout; - CancellationToken = page.CancellationToken; + _page = executionScope.Page; + _sections = _page._sections; + Body = new EncodedContent(executionScope.Output); + Layout = _page.Layout; + CancellationToken = _page.CancellationToken; } + public bool IsSectionDefined(string name) + => _sections?.ContainsKey(name) is true; + public async Task RenderSectionAsync(string name) { if (_sections is null || !_sections.TryGetValue(name, out var sectionAction)) return null; using var executionScope = new ExecutionScope(_page, CancellationToken); + _page.Layout = Layout; // The section might reference this instance. await sectionAction().ConfigureAwait(false); - return new StringBuilderEncodedContent(executionScope.Output); + return new EncodedContent(executionScope.Output); } } - private protected class StringBuilderEncodedContent : IEncodedContent + /// + /// Stores the output of a template execution as a . + /// + /// + /// StringBuilders can be combined more efficiently than strings, which is useful for layouts. + /// has a dedicated Write overload for . + /// + private class EncodedContent : IEncodedContent { - public static IEncodedContent Empty { get; } = new StringBuilderEncodedContent(new StringBuilder()); - - public StringBuilder StringBuilder { get; } + public StringBuilder Output { get; } - public StringBuilderEncodedContent(StringBuilder stringBuilder) - => StringBuilder = stringBuilder; + public EncodedContent(StringBuilder value) + => Output = value; public void WriteTo(TextWriter textWriter) - => textWriter.Write(StringBuilder); + => textWriter.Write(Output); public override string ToString() - => StringBuilder.ToString(); - } - - private class ExecutionScope : IDisposable - { - private readonly RazorTemplate _page; - - private readonly Dictionary>? _sections; - private readonly TextWriter _output; - private readonly CancellationToken _cancellationToken; - private readonly IRazorLayout? _layout; - - public StringBuilder Output { get; } - - public ExecutionScope(RazorTemplate page, CancellationToken cancellationToken) - { - _page = page; - - _sections = page._sections; - _output = page.Output; - _cancellationToken = page.CancellationToken; - _layout = page.Layout; - - Output = new StringBuilder(); - page.Output = new StringWriter(Output); - page.CancellationToken = cancellationToken; - } - - public void Dispose() - { - _page._sections = _sections; - _page.Output = _output; - _page.CancellationToken = _cancellationToken; - _page.Layout = _layout; - } + => Output.ToString(); } } diff --git a/src/RazorBlade.Tests/HtmlLayoutTests.cs b/src/RazorBlade.Tests/HtmlLayoutTests.cs index 99c4fb9..fce4422 100644 --- a/src/RazorBlade.Tests/HtmlLayoutTests.cs +++ b/src/RazorBlade.Tests/HtmlLayoutTests.cs @@ -104,6 +104,31 @@ public void should_render_nested_layout_sections() page.Render().ShouldEqual("innerSection pageSection"); } + [Test] + public void should_keep_the_layout_in_sections() + { + var sectionRendered = false; + + var layout = new Layout(t => t.Write(t.RenderSection("section"))); + + var page = new Template(t => + { + t.Layout = layout; + t.DefineSection( + "section", + () => + { + t.Layout.ShouldBeTheSameAs(layout); + sectionRendered = true; + return Task.CompletedTask; + } + ); + }); + + page.Render(); + sectionRendered.ShouldBeTrue(); + } + private class Template : HtmlTemplate { private readonly Action