diff --git a/src/RazorBlade.Library/HtmlLayout.cs b/src/RazorBlade.Library/HtmlLayout.cs new file mode 100644 index 0000000..6abafcd --- /dev/null +++ b/src/RazorBlade.Library/HtmlLayout.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace RazorBlade; + +/// +/// Base class for HTML layout pages. +/// +public abstract class HtmlLayout : HtmlTemplate, IRazorLayout +{ + private IRazorLayout.IExecutionResult? _layoutInput; + + private IRazorLayout.IExecutionResult LayoutInput => _layoutInput ?? throw new InvalidOperationException("No layout is being rendered."); + + async Task IRazorLayout.RenderLayoutAsync(IRazorLayout.IExecutionResult input) + { + input.CancellationToken.ThrowIfCancellationRequested(); + var previousStatus = (Output, CancellationToken); + + try + { + _layoutInput = input; + + var stringWriter = new StringWriter(); + + Output = stringWriter; + CancellationToken = input.CancellationToken; + + await ExecuteAsync().ConfigureAwait(false); + + return new ExecutionResult + { + Body = new StringBuilderEncodedContent(stringWriter.GetStringBuilder()), + Layout = Layout, + Sections = _sections, + CancellationToken = CancellationToken + }; + } + finally + { + _layoutInput = null; + (Output, CancellationToken) = previousStatus; + } + } + + /// + /// Returns the inner page body. + /// + protected IEncodedContent RenderBody() + => LayoutInput.Body; + + /// + /// Renders a required section and returns the result as encoded content. + /// + /// The section name. + /// The content to write to the output. + protected IEncodedContent RenderSection(string name) + => RenderSection(name, true); + + /// + /// Renders a section and returns the result as encoded content. + /// + /// The section name. + /// Whether the section is required. + /// The content to write to the output. + protected 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(); + } + + /// + /// Renders a required section asynchronously and returns the result as encoded content. + /// + /// The section name. + /// The content to write to the output. + protected Task RenderSectionAsync(string name) + => RenderSectionAsync(name, true); + + /// + /// Renders a section asynchronously and returns the result as encoded content. + /// + /// The section name. + /// Whether the section is required. + /// The content to write to the output. + protected 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 previousOutput = Output; + + try + { + var stringWriter = new StringWriter(); + Output = stringWriter; + + await sectionAction.Invoke().ConfigureAwait(false); + return new StringBuilderEncodedContent(stringWriter.GetStringBuilder()); + } + finally + { + Output = previousOutput; + } + } +} diff --git a/src/RazorBlade.Library/HtmlTemplate.cs b/src/RazorBlade.Library/HtmlTemplate.cs index e41eb3e..1a421fe 100644 --- a/src/RazorBlade.Library/HtmlTemplate.cs +++ b/src/RazorBlade.Library/HtmlTemplate.cs @@ -15,6 +15,13 @@ public abstract class HtmlTemplate : RazorTemplate { private AttributeInfo _currentAttribute; + /// + protected internal new HtmlLayout? Layout + { + get => base.Layout as HtmlLayout; + set => base.Layout = value; + } + // ReSharper disable once RedundantDisableWarningComment #pragma warning disable CA1822 diff --git a/src/RazorBlade.Library/IRazorLayout.cs b/src/RazorBlade.Library/IRazorLayout.cs new file mode 100644 index 0000000..915938f --- /dev/null +++ b/src/RazorBlade.Library/IRazorLayout.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace RazorBlade; + +/// +/// Represents a Razor layout page. +/// +public 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 RenderLayoutAsync(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 sections this page has defined. + /// + IReadOnlyDictionary> Sections { get; } + + /// + /// The cancellation token. + /// + CancellationToken CancellationToken { get; } + } +} diff --git a/src/RazorBlade.Library/RazorTemplate.cs b/src/RazorBlade.Library/RazorTemplate.cs index e64bcef..48c6995 100644 --- a/src/RazorBlade.Library/RazorTemplate.cs +++ b/src/RazorBlade.Library/RazorTemplate.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; @@ -14,7 +15,7 @@ namespace RazorBlade; /// public abstract class RazorTemplate : IEncodedContent { - private readonly Dictionary> _sections = new(StringComparer.OrdinalIgnoreCase); + private protected readonly Dictionary> _sections = new(StringComparer.OrdinalIgnoreCase); /// /// The which receives the output. @@ -24,7 +25,12 @@ public abstract class RazorTemplate : IEncodedContent /// /// The cancellation token. /// - protected internal CancellationToken CancellationToken { get; private set; } + protected internal CancellationToken CancellationToken { get; set; } + + /// + /// The layout to use. + /// + protected internal IRazorLayout? Layout { get; set; } /// /// Renders the template synchronously and returns the result as a string. @@ -100,10 +106,50 @@ public async Task RenderAsync(TextWriter textWriter, CancellationToken cancellat try { - Output = textWriter; + var stringWriter = new StringWriter(); + + Output = stringWriter; CancellationToken = cancellationToken; await ExecuteAsync().ConfigureAwait(false); + + if (Layout is null) + { +#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); + } + } } finally { @@ -195,4 +241,28 @@ protected internal void DefineSection(string name, Func action) void IEncodedContent.WriteTo(TextWriter textWriter) => Render(textWriter, CancellationToken.None); + + 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 protected class StringBuilderEncodedContent : IEncodedContent + { + public static IEncodedContent Empty { get; } = new StringBuilderEncodedContent(new StringBuilder()); + + public StringBuilder StringBuilder { get; } + + public StringBuilderEncodedContent(StringBuilder stringBuilder) + => StringBuilder = stringBuilder; + + public void WriteTo(TextWriter textWriter) + => textWriter.Write(StringBuilder); + + public override string ToString() + => StringBuilder.ToString(); + } }