diff --git a/src/RazorBlade.Library/HtmlLayout.cs b/src/RazorBlade.Library/HtmlLayout.cs index 6abafcd..14d9981 100644 --- a/src/RazorBlade.Library/HtmlLayout.cs +++ b/src/RazorBlade.Library/HtmlLayout.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -15,7 +14,7 @@ public abstract class HtmlLayout : HtmlTemplate, IRazorLayout private IRazorLayout.IExecutionResult LayoutInput => _layoutInput ?? throw new InvalidOperationException("No layout is being rendered."); - async Task IRazorLayout.RenderLayoutAsync(IRazorLayout.IExecutionResult input) + async Task IRazorLayout.ExecuteLayoutAsync(IRazorLayout.IExecutionResult input) { input.CancellationToken.ThrowIfCancellationRequested(); var previousStatus = (Output, CancellationToken); @@ -24,20 +23,15 @@ public abstract class HtmlLayout : HtmlTemplate, IRazorLayout { _layoutInput = input; - var stringWriter = new StringWriter(); + var output = new StringWriter(); - Output = stringWriter; + Output = output; CancellationToken = input.CancellationToken; + // TODO fully reset/restore the state await ExecuteAsync().ConfigureAwait(false); - return new ExecutionResult - { - Body = new StringBuilderEncodedContent(stringWriter.GetStringBuilder()), - Layout = Layout, - Sections = _sections, - CancellationToken = CancellationToken - }; + return new ExecutionResult(this, output.GetStringBuilder()); } finally { @@ -49,7 +43,7 @@ public abstract class HtmlLayout : HtmlTemplate, IRazorLayout /// /// Returns the inner page body. /// - protected IEncodedContent RenderBody() + protected internal IEncodedContent RenderBody() => LayoutInput.Body; /// @@ -57,7 +51,7 @@ protected IEncodedContent RenderBody() /// /// The section name. /// The content to write to the output. - protected IEncodedContent RenderSection(string name) + protected internal IEncodedContent RenderSection(string name) => RenderSection(name, true); /// @@ -66,13 +60,13 @@ protected IEncodedContent RenderSection(string name) /// The section name. /// Whether the section is required. /// The content to write to the output. - protected IEncodedContent RenderSection(string name, bool required) + protected internal IEncodedContent RenderSection(string name, bool required) { var renderTask = RenderSectionAsync(name, required); return renderTask.IsCompleted ? renderTask.GetAwaiter().GetResult() - : Task.Run(async () => await renderTask.ConfigureAwait(false)).GetAwaiter().GetResult(); + : Task.Run(async () => await renderTask.ConfigureAwait(false), CancellationToken.None).GetAwaiter().GetResult(); } /// @@ -80,7 +74,7 @@ protected IEncodedContent RenderSection(string name, bool required) /// /// The section name. /// The content to write to the output. - protected Task RenderSectionAsync(string name) + protected internal Task RenderSectionAsync(string name) => RenderSectionAsync(name, true); /// @@ -89,29 +83,16 @@ protected Task RenderSectionAsync(string name) /// The section name. /// Whether the section is required. /// The content to write to the output. - protected async Task RenderSectionAsync(string name, bool required) + protected internal async Task RenderSectionAsync(string name, bool required) { - if (!LayoutInput.Sections.TryGetValue(name, out var sectionAction)) - { - if (required) - throw new InvalidOperationException($"Section '{name}' is not defined."); - - return StringBuilderEncodedContent.Empty; - } + var result = await LayoutInput.RenderSectionAsync(name).ConfigureAwait(false); - var previousOutput = Output; + if (result is not null) + return result; - try - { - var stringWriter = new StringWriter(); - Output = stringWriter; + if (required) + throw new InvalidOperationException($"Section '{name}' is not defined."); - await sectionAction.Invoke().ConfigureAwait(false); - return new StringBuilderEncodedContent(stringWriter.GetStringBuilder()); - } - finally - { - Output = previousOutput; - } + return StringBuilderEncodedContent.Empty; } } diff --git a/src/RazorBlade.Library/IRazorLayout.cs b/src/RazorBlade.Library/IRazorLayout.cs index 915938f..8b38879 100644 --- a/src/RazorBlade.Library/IRazorLayout.cs +++ b/src/RazorBlade.Library/IRazorLayout.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Threading; +using System.Threading; using System.Threading.Tasks; namespace RazorBlade; @@ -15,7 +13,7 @@ public interface IRazorLayout /// /// The input data. /// The output data after rendering the layout, which can be used for the next layout. - Task RenderLayoutAsync(IExecutionResult input); + Task ExecuteLayoutAsync(IExecutionResult input); /// /// The execution result of a page. @@ -33,13 +31,15 @@ public interface IExecutionResult IRazorLayout? Layout { get; } /// - /// The sections this page has defined. + /// The cancellation token. /// - IReadOnlyDictionary> Sections { get; } + CancellationToken CancellationToken { get; } /// - /// The cancellation token. + /// Renders a section. /// - CancellationToken CancellationToken { get; } + /// The section name. + /// The rendered output, or null if the section is not defined. + Task RenderSectionAsync(string name); } } diff --git a/src/RazorBlade.Library/RazorTemplate.cs b/src/RazorBlade.Library/RazorTemplate.cs index 48c6995..87b7218 100644 --- a/src/RazorBlade.Library/RazorTemplate.cs +++ b/src/RazorBlade.Library/RazorTemplate.cs @@ -15,7 +15,9 @@ namespace RazorBlade; /// public abstract class RazorTemplate : IEncodedContent { - private protected readonly Dictionary> _sections = new(StringComparer.OrdinalIgnoreCase); + private Dictionary>? _sections; + + private Dictionary> Sections => _sections ??= new(StringComparer.OrdinalIgnoreCase); /// /// The which receives the output. @@ -44,11 +46,11 @@ public string Render(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var renderTask = RenderAsync(cancellationToken); + var renderTask = RenderAsyncCore(cancellationToken); if (renderTask.IsCompleted) - return renderTask.Result; + return renderTask.GetAwaiter().GetResult().ToString(); - return Task.Run(async () => await renderTask.ConfigureAwait(false), CancellationToken.None).GetAwaiter().GetResult(); + return Task.Run(async () => await renderTask.ConfigureAwait(false), CancellationToken.None).GetAwaiter().GetResult().ToString(); } /// @@ -85,9 +87,8 @@ public async Task RenderAsync(CancellationToken cancellationToken = defa { cancellationToken.ThrowIfCancellationRequested(); - var output = new StringWriter(); - await RenderAsync(output, cancellationToken).ConfigureAwait(false); - return output.ToString(); + var stringBuilder = await RenderAsyncCore(cancellationToken).ConfigureAwait(false); + return stringBuilder.ToString(); } /// @@ -102,58 +103,53 @@ public async Task RenderAsync(TextWriter textWriter, CancellationToken cancellat { cancellationToken.ThrowIfCancellationRequested(); - var previousState = (Output, CancellationToken); + var stringBuilder = await RenderAsyncCore(cancellationToken).ConfigureAwait(false); + +#if NET6_0_OR_GREATER + await textWriter.WriteAsync(stringBuilder, cancellationToken).ConfigureAwait(false); +#else + await textWriter.WriteAsync(stringBuilder.ToString()).ConfigureAwait(false); +#endif + } + + private async Task RenderAsyncCore(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var previousState = (_sections, Output, CancellationToken, Layout); try { - var stringWriter = new StringWriter(); + var output = new StringWriter(); - Output = stringWriter; + _sections = null; + Output = output; CancellationToken = cancellationToken; + Layout = null; await ExecuteAsync().ConfigureAwait(false); if (Layout is null) + return output.GetStringBuilder(); + + IRazorLayout.IExecutionResult executionResult = new ExecutionResult(this, output.GetStringBuilder()); + + while (executionResult.Layout is { } layout) { -#if NET6_0_OR_GREATER - await textWriter.WriteAsync(stringWriter.GetStringBuilder(), cancellationToken).ConfigureAwait(false); -#else - await textWriter.WriteAsync(stringWriter.ToString()).ConfigureAwait(false); -#endif - } - else - { - IRazorLayout.IExecutionResult executionResult = new ExecutionResult - { - Body = new StringBuilderEncodedContent(stringWriter.GetStringBuilder()), - Layout = Layout, - Sections = _sections, - CancellationToken = CancellationToken - }; - - while (executionResult.Layout is { } layout) - { - CancellationToken.ThrowIfCancellationRequested(); - executionResult = await layout.RenderLayoutAsync(executionResult).ConfigureAwait(false); - } - - if (executionResult.Body is StringBuilderEncodedContent { StringBuilder: var resultStringBuilder }) - { -#if NET6_0_OR_GREATER - await textWriter.WriteAsync(resultStringBuilder, cancellationToken).ConfigureAwait(false); -#else - await textWriter.WriteAsync(resultStringBuilder.ToString()).ConfigureAwait(false); -#endif - } - else - { - executionResult.Body.WriteTo(textWriter); - } + cancellationToken.ThrowIfCancellationRequested(); + executionResult = await layout.ExecuteLayoutAsync(executionResult).ConfigureAwait(false); } + + if (executionResult.Body is StringBuilderEncodedContent { StringBuilder: var outputWithLayout }) + return outputWithLayout; + + var outerBodyResult = new StringWriter(); + executionResult.Body.WriteTo(outerBodyResult); + return outerBodyResult.GetStringBuilder(); } finally { - (Output, CancellationToken) = previousState; + (_sections, Output, CancellationToken, Layout) = previousState; } } @@ -229,13 +225,13 @@ protected internal virtual void Write(IEncodedContent? content) protected internal void DefineSection(string name, Func action) { #if NET6_0_OR_GREATER - if (!_sections.TryAdd(name, action)) + if (!Sections.TryAdd(name, action)) throw new InvalidOperationException($"Section '{name}' is already defined."); #else - if (_sections.ContainsKey(name)) + if (Sections.ContainsKey(name)) throw new InvalidOperationException($"Section '{name}' is already defined."); - _sections[name] = action; + Sections[name] = action; #endif } @@ -244,10 +240,41 @@ void IEncodedContent.WriteTo(TextWriter textWriter) private protected class ExecutionResult : IRazorLayout.IExecutionResult { - public IEncodedContent Body { get; set; } = null!; - public IRazorLayout? Layout { get; set; } - public IReadOnlyDictionary> Sections { get; set; } = null!; - public CancellationToken CancellationToken { get; set; } + private readonly RazorTemplate _page; + + public IEncodedContent Body { get; } + public IRazorLayout? Layout { get; } + public CancellationToken CancellationToken { get; } + + public ExecutionResult(RazorTemplate page, StringBuilder body) + { + _page = page; + Body = new StringBuilderEncodedContent(body); + Layout = page.Layout; + CancellationToken = page.CancellationToken; + } + + public async Task RenderSectionAsync(string name) + { + if (!_page.Sections.TryGetValue(name, out var sectionAction)) + return null; + + var previousOutput = _page.Output; + + try + { + var output = new StringWriter(); + _page.Output = output; + + await sectionAction().ConfigureAwait(false); + + return new StringBuilderEncodedContent(output.GetStringBuilder()); + } + finally + { + _page.Output = previousOutput; + } + } } private protected class StringBuilderEncodedContent : IEncodedContent diff --git a/src/RazorBlade.Tests/HtmlLayoutTests.cs b/src/RazorBlade.Tests/HtmlLayoutTests.cs new file mode 100644 index 0000000..99c4fb9 --- /dev/null +++ b/src/RazorBlade.Tests/HtmlLayoutTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using RazorBlade.Tests.Support; + +namespace RazorBlade.Tests; + +[TestFixture] +public class HtmlLayoutTests +{ + [Test] + public void should_render_layout() + { + var layout = new Layout(t => + { + t.Write("before "); + t.Write(t.RenderBody()); + t.Write(" after"); + }); + + var page = new Template(t => + { + t.Write("body"); + t.Layout = layout; + }); + + page.Render().ShouldEqual("before body after"); + } + + [Test] + public void should_render_sections() + { + var layout = new Layout(t => + { + t.Write("before "); + t.Write(t.RenderSection("foo")); + t.Write(" after"); + }); + + var page = new Template(t => + { + t.Layout = layout; + t.SetSection("foo", "foo section"); + }); + + page.Render().ShouldEqual("before foo section after"); + } + + [Test] + public void should_render_nested_layouts() + { + var outerLayout = new Layout(t => + { + t.Write("beforeA "); + t.Write(t.RenderBody()); + t.Write(" afterA"); + }); + + var innerLayout = new Layout(t => + { + t.Write("beforeB "); + t.Write(t.RenderBody()); + t.Write(" afterB"); + t.Layout = outerLayout; + }); + + var page = new Template(t => + { + t.Write("body"); + t.Layout = innerLayout; + }); + + page.Render().ShouldEqual("beforeA beforeB body afterB afterA"); + } + + [Test] + public void should_render_nested_layout_sections() + { + var outerLayout = new Layout(t => + { + t.Write(t.RenderSection("inner")); + t.Write(t.RenderSection("inner2", false)); + t.SetSection("outer", "outerSection "); + t.Write(t.RenderSection("outer", false)); + t.Write(t.RenderBody()); + }); + + var innerLayout = new Layout(t => + { + t.Write(t.RenderSection("page")); + t.Write(t.RenderSection("page2", false)); + t.SetSection("inner", "innerSection "); + t.Write(t.RenderSection("inner", false)); + t.Write(t.RenderBody()); + t.Layout = outerLayout; + }); + + var page = new Template(t => + { + t.SetSection("page", "pageSection"); + t.Layout = innerLayout; + }); + + page.Render().ShouldEqual("innerSection pageSection"); + } + + private class Template : HtmlTemplate + { + private readonly Action