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();
+ }
}