Skip to content

Commit

Permalink
Refactor layout
Browse files Browse the repository at this point in the history
  • Loading branch information
ltrzesniewski committed Dec 2, 2023
1 parent b48f38b commit 163e9c3
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 96 deletions.
25 changes: 18 additions & 7 deletions src/RazorBlade.Library/HtmlLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ namespace RazorBlade;
/// </summary>
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.IExecutionResult> IRazorLayout.ExecuteLayoutAsync(IRazorLayout.IExecutionResult input)
async Task<IRazorExecutionResult> IRazorLayout.ExecuteLayoutAsync(IRazorExecutionResult input)
{
try
{
Expand All @@ -30,7 +28,7 @@ public abstract class HtmlLayout : HtmlTemplate, IRazorLayout
/// Returns the inner page body.
/// </summary>
protected internal IEncodedContent RenderBody()
=> LayoutInput.Body;
=> GetLayoutInput().Body;

/// <summary>
/// Renders a required section and returns the result as encoded content.
Expand Down Expand Up @@ -71,14 +69,27 @@ protected internal Task<IEncodedContent> RenderSectionAsync(string name)
/// <returns>The content to write to the output.</returns>
protected internal async Task<IEncodedContent> 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;

if (required)
throw new InvalidOperationException($"Section '{name}' is not defined.");

return StringBuilderEncodedContent.Empty;
return HtmlString.Empty;
}

/// <summary>
/// Indicates if a given section is defined.
/// </summary>
/// <param name="name">The section name.</param>
protected internal bool IsSectionDefined(string name)
=> GetLayoutInput().IsSectionDefined(name);

/// <summary>
/// Ensures the template is being executed as a layout and returns the input data.
/// </summary>
private IRazorExecutionResult GetLayoutInput()
=> _layoutInput ?? throw new InvalidOperationException("The template is not being executed as a layout.");
}
5 changes: 5 additions & 0 deletions src/RazorBlade.Library/HtmlString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ public sealed class HtmlString : IEncodedContent
{
private readonly string _value;

/// <summary>
/// Represents an empty HTML-encoded string.
/// </summary>
public static HtmlString Empty { get; } = new(string.Empty);

/// <summary>
/// Creates a HTML-encoded string.
/// </summary>
Expand Down
4 changes: 1 addition & 3 deletions src/RazorBlade.Library/HtmlTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,5 @@ protected HtmlTemplate(TModel model)
/// This constructor is provided for the designer only. Do not use.
/// </summary>
protected HtmlTemplate()
{
throw new NotSupportedException("Use the constructor overload that takes a model.");
}
=> throw new NotSupportedException("Use the constructor overload that takes a model.");
}
38 changes: 38 additions & 0 deletions src/RazorBlade.Library/IRazorExecutionResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Threading;
using System.Threading.Tasks;

namespace RazorBlade;

/// <summary>
/// The execution result of a Razor template.
/// </summary>
internal interface IRazorExecutionResult
{
/// <summary>
/// The rendered body contents.
/// </summary>
IEncodedContent Body { get; }

/// <summary>
/// The layout this execution result should be wrapped in.
/// </summary>
IRazorLayout? Layout { get; }

/// <summary>
/// The cancellation token.
/// </summary>
CancellationToken CancellationToken { get; }

/// <summary>
/// Renders a section.
/// </summary>
/// <param name="name">The section name.</param>
/// <returns>The rendered output, or null if the section is not defined.</returns>
Task<IEncodedContent?> RenderSectionAsync(string name);

/// <summary>
/// Indicates if a given section is defined.
/// </summary>
/// <param name="name">The section name.</param>
bool IsSectionDefined(string name);
}
35 changes: 3 additions & 32 deletions src/RazorBlade.Library/IRazorLayout.cs
Original file line number Diff line number Diff line change
@@ -1,45 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks;

namespace RazorBlade;

/// <summary>
/// Represents a Razor layout page.
/// </summary>
public interface IRazorLayout
internal interface IRazorLayout
{
/// <summary>
/// Renders the layout for a given page.
/// </summary>
/// <param name="input">The input data.</param>
/// <returns>The output data after rendering the layout, which can be used for the next layout.</returns>
Task<IExecutionResult> ExecuteLayoutAsync(IExecutionResult input);

/// <summary>
/// The execution result of a page.
/// </summary>
public interface IExecutionResult
{
/// <summary>
/// The rendered body contents.
/// </summary>
IEncodedContent Body { get; }

/// <summary>
/// The layout this execution result needs to be wrapped in.
/// </summary>
IRazorLayout? Layout { get; }

/// <summary>
/// The cancellation token.
/// </summary>
CancellationToken CancellationToken { get; }

/// <summary>
/// Renders a section.
/// </summary>
/// <param name="name">The section name.</param>
/// <returns>The rendered output, or null if the section is not defined.</returns>
Task<IEncodedContent?> RenderSectionAsync(string name);
}
Task<IRazorExecutionResult> ExecuteLayoutAsync(IRazorExecutionResult input);
}
129 changes: 75 additions & 54 deletions src/RazorBlade.Library/RazorTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public abstract class RazorTemplate : IEncodedContent
/// <summary>
/// The layout to use.
/// </summary>
protected internal IRazorLayout? Layout { get; set; }
private protected IRazorLayout? Layout { get; set; }

/// <summary>
/// Renders the template synchronously and returns the result as a string.
Expand Down Expand Up @@ -112,6 +112,9 @@ public async Task RenderAsync(TextWriter textWriter, CancellationToken cancellat
#endif
}

/// <summary>
/// Renders the template asynchronously including its layout and returns the result as a <see cref="StringBuilder"/>.
/// </summary>
private async Task<StringBuilder> RenderAsyncCore(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Expand All @@ -124,21 +127,24 @@ private async Task<StringBuilder> 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();
executionResult.Body.WriteTo(outputStringWriter);
return outputStringWriter.GetStringBuilder();
}

private protected async Task<IRazorLayout.IExecutionResult> ExecuteAsyncCore(CancellationToken cancellationToken)
/// <summary>
/// Calls the <see cref="ExecuteAsync"/> method in a new <see cref="ExecutionScope"/>.
/// </summary>
private protected async Task<IRazorExecutionResult> 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);
}

/// <summary>
Expand Down Expand Up @@ -226,7 +232,47 @@ protected internal void DefineSection(string name, Func<Task> action)
void IEncodedContent.WriteTo(TextWriter textWriter)
=> Render(textWriter, CancellationToken.None);

private class ExecutionResult : IRazorLayout.IExecutionResult
/// <summary>
/// Saves the current state, resets it, and restores it when disposed.
/// </summary>
private class ExecutionScope : IDisposable
{
private readonly Dictionary<string, Func<Task>>? _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;
}
}

/// <summary>
/// Stores the result of a template execution.
/// </summary>
private class ExecutionResult : IRazorExecutionResult
{
private readonly RazorTemplate _page;
private readonly IReadOnlyDictionary<string, Func<Task>>? _sections;
Expand All @@ -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<IEncodedContent?> 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
/// <summary>
/// Stores the output of a template execution as a <see cref="StringBuilder"/>.
/// </summary>
/// <remarks>
/// StringBuilders can be combined more efficiently than strings, which is useful for layouts.
/// <see cref="TextWriter"/> has a dedicated <c>Write</c> overload for <see cref="StringBuilder"/>.
/// </remarks>
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<string, Func<Task>>? _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();
}
}
25 changes: 25 additions & 0 deletions src/RazorBlade.Tests/HtmlLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Template> _executeAction;
Expand Down

0 comments on commit 163e9c3

Please sign in to comment.