From eb44883da2ddb2d9b67470954ddde0c69ec0524e Mon Sep 17 00:00:00 2001 From: Lucas Trzesniewski Date: Wed, 3 Apr 2024 23:17:26 +0200 Subject: [PATCH 1/6] Add FlushAsync() method --- src/RazorBlade.Library/HtmlLayout.cs | 6 +- src/RazorBlade.Library/RazorTemplate.cs | 159 +++++++++++++++++++----- 2 files changed, 128 insertions(+), 37 deletions(-) diff --git a/src/RazorBlade.Library/HtmlLayout.cs b/src/RazorBlade.Library/HtmlLayout.cs index d02b3f0..82cbf72 100644 --- a/src/RazorBlade.Library/HtmlLayout.cs +++ b/src/RazorBlade.Library/HtmlLayout.cs @@ -21,7 +21,7 @@ async Task IRazorLayout.ExecuteLayoutAsync(IRazorExecutio try { _layoutInput = input; - return await ExecuteAsyncCore(input.CancellationToken); + return await ExecuteAsyncCore(null, input.CancellationToken); } finally { @@ -29,12 +29,12 @@ async Task IRazorLayout.ExecuteLayoutAsync(IRazorExecutio } } - private protected override Task ExecuteAsyncCore(CancellationToken cancellationToken) + private protected override Task ExecuteAsyncCore(TextWriter? targetOutput, CancellationToken cancellationToken) { if (_layoutInput is null) throw new InvalidOperationException(_contentsRequiredErrorMessage); - return base.ExecuteAsyncCore(cancellationToken); + return base.ExecuteAsyncCore(targetOutput, cancellationToken); } /// diff --git a/src/RazorBlade.Library/RazorTemplate.cs b/src/RazorBlade.Library/RazorTemplate.cs index 11faf74..3d37ba7 100644 --- a/src/RazorBlade.Library/RazorTemplate.cs +++ b/src/RazorBlade.Library/RazorTemplate.cs @@ -15,6 +15,8 @@ namespace RazorBlade; /// public abstract class RazorTemplate : IEncodedContent { + private ExecutionScope? _executionScope; + private IRazorLayout? _layout; private Dictionary>? _sections; private Dictionary> Sections => _sections ??= new(StringComparer.OrdinalIgnoreCase); @@ -22,7 +24,7 @@ public abstract class RazorTemplate : IEncodedContent /// /// The which receives the output. /// - protected internal TextWriter Output { get; internal set; } = new StreamWriter(Stream.Null); + protected internal TextWriter Output { get; internal set; } = TextWriter.Null; /// /// The cancellation token. @@ -32,7 +34,18 @@ public abstract class RazorTemplate : IEncodedContent /// /// The layout to use. /// - private protected IRazorLayout? Layout { get; set; } + private protected IRazorLayout? Layout + { + get => _layout; + set + { + if (ReferenceEquals(value, _layout)) + return; + + _executionScope?.EnsureCanChangeLayout(); + _layout = value; + } + } /// /// Renders the template synchronously and returns the result as a string. @@ -46,11 +59,23 @@ public string Render(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var renderTask = RenderAsyncCore(cancellationToken); + var textWriter = new StringWriter(); + var renderTask = RenderAsyncCore(textWriter, cancellationToken); + if (renderTask.IsCompleted) - return renderTask.GetAwaiter().GetResult().ToString(); + { + renderTask.GetAwaiter().GetResult(); + return textWriter.ToString(); + } - return Task.Run(async () => await renderTask.ConfigureAwait(false), CancellationToken.None).GetAwaiter().GetResult().ToString(); + return Task.Run( + async () => + { + await renderTask.ConfigureAwait(false); + return textWriter.ToString(); + }, + CancellationToken.None + ).GetAwaiter().GetResult(); } /// @@ -66,7 +91,8 @@ public void Render(TextWriter textWriter, CancellationToken cancellationToken = { cancellationToken.ThrowIfCancellationRequested(); - var renderTask = RenderAsync(textWriter, cancellationToken); + var renderTask = RenderAsyncCore(textWriter, cancellationToken); + if (renderTask.IsCompleted) { renderTask.GetAwaiter().GetResult(); @@ -87,8 +113,9 @@ public async Task RenderAsync(CancellationToken cancellationToken = defa { cancellationToken.ThrowIfCancellationRequested(); - var stringBuilder = await RenderAsyncCore(cancellationToken).ConfigureAwait(false); - return stringBuilder.ToString(); + var textWriter = new StringWriter(); + await RenderAsyncCore(textWriter, cancellationToken).ConfigureAwait(false); + return textWriter.ToString(); } /// @@ -103,23 +130,17 @@ public async Task RenderAsync(TextWriter textWriter, CancellationToken cancellat { cancellationToken.ThrowIfCancellationRequested(); - 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 + await RenderAsyncCore(textWriter, cancellationToken).ConfigureAwait(false); } /// /// Renders the template asynchronously including its layout and returns the result as a . /// - private async Task RenderAsyncCore(CancellationToken cancellationToken) + private async Task RenderAsyncCore(TextWriter targetOutput, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var executionResult = await ExecuteAsyncCore(cancellationToken); + var executionResult = await ExecuteAsyncCore(targetOutput, cancellationToken); while (executionResult.Layout is { } layout) { @@ -127,27 +148,39 @@ private async Task RenderAsyncCore(CancellationToken cancellation executionResult = await layout.ExecuteLayoutAsync(executionResult).ConfigureAwait(false); } - if (executionResult.Body is EncodedContent { Output: var outputStringBuilder }) - return outputStringBuilder; + switch (executionResult.Body) + { + case EncodedContent { Output: var bufferedOutput }: + await WriteStringBuilderToOutputAndFlushAsync(bufferedOutput, targetOutput, cancellationToken).ConfigureAwait(false); + break; - // Fallback case, shouldn't happen - var outputStringWriter = new StringWriter(); - executionResult.Body.WriteTo(outputStringWriter); - return outputStringWriter.GetStringBuilder(); + case { } body: // Fallback case, shouldn't happen + body.WriteTo(targetOutput); + break; + } } /// /// Calls the method in a new . /// - private protected virtual async Task ExecuteAsyncCore(CancellationToken cancellationToken) + private protected virtual async Task ExecuteAsyncCore(TextWriter? targetOutput, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - using var executionScope = new ExecutionScope(this, cancellationToken); + using var executionScope = ExecutionScope.Start(this, targetOutput, cancellationToken); await ExecuteAsync().ConfigureAwait(false); return new ExecutionResult(executionScope); } + /// + /// Writes the buffered output to the target output then flushes the output stream. + /// + /// + /// This feature is not compatible with layouts. + /// + protected Task FlushAsync() + => _executionScope?.FlushAsync() ?? Task.CompletedTask; + /// /// Executes the template and appends the result to . /// @@ -230,6 +263,27 @@ protected internal void DefineSection(string name, Func action) #endif } + /// + /// Writes the contents of a to a asynchronuously. + /// + private static async Task WriteStringBuilderToOutputAndFlushAsync(StringBuilder input, TextWriter output, CancellationToken cancellationToken) + { + if (input.Length == 0) + return; + +#if NET6_0_OR_GREATER + await output.WriteAsync(input, cancellationToken).ConfigureAwait(false); +#else + await output.WriteAsync(input.ToString()).ConfigureAwait(false); +#endif + +#if NET8_0_OR_GREATER + await output.FlushAsync(cancellationToken).ConfigureAwait(false); +#else + await output.FlushAsync().ConfigureAwait(false); +#endif + } + void IEncodedContent.WriteTo(TextWriter textWriter) => Render(textWriter, CancellationToken.None); @@ -238,35 +292,69 @@ void IEncodedContent.WriteTo(TextWriter textWriter) /// private class ExecutionScope : IDisposable { + private readonly ExecutionScope? _previousExecutionScope; private readonly Dictionary>? _previousSections; private readonly TextWriter _previousOutput; private readonly CancellationToken _previousCancellationToken; private readonly IRazorLayout? _previousLayout; + private readonly TextWriter? _targetOutput; + private bool _layoutFrozen; + public RazorTemplate Page { get; } - public StringBuilder Output { get; } = new(); + public StringBuilder BufferedOutput { get; } = new(); - public ExecutionScope(RazorTemplate page, CancellationToken cancellationToken) + private ExecutionScope(RazorTemplate page, TextWriter? targetOutput, CancellationToken cancellationToken) { Page = page; + _targetOutput = targetOutput; + _previousExecutionScope = page._executionScope; _previousSections = page._sections; _previousOutput = page.Output; _previousCancellationToken = page.CancellationToken; _previousLayout = page.Layout; + page._executionScope = this; + page._layout = null; page._sections = null; - page.Output = new StringWriter(Output); + page.Output = new StringWriter(BufferedOutput); page.CancellationToken = cancellationToken; - page.Layout = null; } + public static ExecutionScope Start(RazorTemplate page, TextWriter? targetOutput, CancellationToken cancellationToken) + => new(page, targetOutput, cancellationToken); + public void Dispose() { + Page._executionScope = _previousExecutionScope; + Page._layout = _previousLayout; Page._sections = _previousSections; Page.Output = _previousOutput; Page.CancellationToken = _previousCancellationToken; - Page.Layout = _previousLayout; + } + + public void FreezeLayout() + => _layoutFrozen = true; + + public void EnsureCanChangeLayout() + { + if (_layoutFrozen) + throw new InvalidOperationException("The layout can no longer be changed."); + } + + public async Task FlushAsync() + { + if (Page.Layout is not null) + throw new InvalidOperationException("The output cannot be flushed when a layout is used."); + + FreezeLayout(); + + if (_targetOutput is not null) + { + await WriteStringBuilderToOutputAndFlushAsync(BufferedOutput, _targetOutput, Page.CancellationToken).ConfigureAwait(false); + BufferedOutput.Clear(); + } } } @@ -286,7 +374,7 @@ public ExecutionResult(ExecutionScope executionScope) { _page = executionScope.Page; _sections = _page._sections; - Body = new EncodedContent(executionScope.Output); + Body = new EncodedContent(executionScope.BufferedOutput); Layout = _page.Layout; CancellationToken = _page.CancellationToken; } @@ -299,10 +387,13 @@ public bool IsSectionDefined(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. + using var executionScope = ExecutionScope.Start(_page, null, CancellationToken); + + _page._layout = Layout; // The section might reference this instance. + executionScope.FreezeLayout(); + await sectionAction().ConfigureAwait(false); - return new EncodedContent(executionScope.Output); + return new EncodedContent(executionScope.BufferedOutput); } } From 7f50d7aeb957719ba7b5aff9e98e6388b3458831 Mon Sep 17 00:00:00 2001 From: Lucas Trzesniewski Date: Fri, 5 Apr 2024 21:44:52 +0200 Subject: [PATCH 2/6] Refactor RazorTemplate --- src/RazorBlade.Library/RazorTemplate.cs | 193 +++++++++--------- src/RazorBlade.Tests/HtmlLayoutTests.cs | 18 +- src/RazorBlade.Tests/HtmlTemplateTests.cs | 9 +- .../PlainTextTemplateTests.cs | 9 +- src/RazorBlade.Tests/RazorTemplateTests.cs | 36 ++-- 5 files changed, 121 insertions(+), 144 deletions(-) diff --git a/src/RazorBlade.Library/RazorTemplate.cs b/src/RazorBlade.Library/RazorTemplate.cs index 3d37ba7..a2aae76 100644 --- a/src/RazorBlade.Library/RazorTemplate.cs +++ b/src/RazorBlade.Library/RazorTemplate.cs @@ -16,35 +16,24 @@ namespace RazorBlade; public abstract class RazorTemplate : IEncodedContent { private ExecutionScope? _executionScope; - private IRazorLayout? _layout; - private Dictionary>? _sections; - - private Dictionary> Sections => _sections ??= new(StringComparer.OrdinalIgnoreCase); /// /// The which receives the output. /// - protected internal TextWriter Output { get; internal set; } = TextWriter.Null; + protected internal TextWriter Output => _executionScope?.BufferedOutput ?? TextWriter.Null; /// /// The cancellation token. /// - protected internal CancellationToken CancellationToken { get; private set; } + protected internal CancellationToken CancellationToken => _executionScope?.CancellationToken ?? CancellationToken.None; /// /// The layout to use. /// private protected IRazorLayout? Layout { - get => _layout; - set - { - if (ReferenceEquals(value, _layout)) - return; - - _executionScope?.EnsureCanChangeLayout(); - _layout = value; - } + get => _executionScope?.Layout; + set => (_executionScope ?? throw new InvalidOperationException("The layout can only be set while the template is executing.")).SetLayout(value); } /// @@ -150,8 +139,8 @@ private async Task RenderAsyncCore(TextWriter targetOutput, CancellationToken ca switch (executionResult.Body) { - case EncodedContent { Output: var bufferedOutput }: - await WriteStringBuilderToOutputAndFlushAsync(bufferedOutput, targetOutput, cancellationToken).ConfigureAwait(false); + case BufferedContent { Output: var bufferedOutput }: + await WriteStringBuilderToOutputAsync(bufferedOutput, targetOutput, cancellationToken).ConfigureAwait(false); break; case { } body: // Fallback case, shouldn't happen @@ -167,7 +156,7 @@ private protected virtual async Task ExecuteAsyncCore(Tex { cancellationToken.ThrowIfCancellationRequested(); - using var executionScope = ExecutionScope.Start(this, targetOutput, cancellationToken); + using var executionScope = ExecutionScope.StartBody(this, targetOutput, cancellationToken); await ExecuteAsync().ConfigureAwait(false); return new ExecutionResult(executionScope); } @@ -252,35 +241,24 @@ protected internal virtual void Write(IEncodedContent? content) [EditorBrowsable(EditorBrowsableState.Never)] protected internal void DefineSection(string name, Func action) { -#if NET6_0_OR_GREATER - if (!Sections.TryAdd(name, action)) - throw new InvalidOperationException($"Section '{name}' is already defined."); -#else - if (Sections.ContainsKey(name)) - throw new InvalidOperationException($"Section '{name}' is already defined."); + if (_executionScope is not { } executionScope) + throw new InvalidOperationException("Sections can only be defined while the template is executing."); - Sections[name] = action; -#endif + executionScope.DefineSection(name, action); } /// /// Writes the contents of a to a asynchronuously. /// - private static async Task WriteStringBuilderToOutputAndFlushAsync(StringBuilder input, TextWriter output, CancellationToken cancellationToken) + private static Task WriteStringBuilderToOutputAsync(StringBuilder input, TextWriter output, CancellationToken cancellationToken) { if (input.Length == 0) - return; + return Task.CompletedTask; #if NET6_0_OR_GREATER - await output.WriteAsync(input, cancellationToken).ConfigureAwait(false); + return output.WriteAsync(input, cancellationToken); #else - await output.WriteAsync(input.ToString()).ConfigureAwait(false); -#endif - -#if NET8_0_OR_GREATER - await output.FlushAsync(cancellationToken).ConfigureAwait(false); -#else - await output.FlushAsync().ConfigureAwait(false); + return output.WriteAsync(input.ToString()); #endif } @@ -288,74 +266,112 @@ void IEncodedContent.WriteTo(TextWriter textWriter) => Render(textWriter, CancellationToken.None); /// - /// Saves the current state, resets it, and restores it when disposed. + /// Stores the state of a template execution. /// private class ExecutionScope : IDisposable { + private readonly RazorTemplate _page; + private readonly TextWriter? _targetOutput; private readonly ExecutionScope? _previousExecutionScope; - private readonly Dictionary>? _previousSections; - private readonly TextWriter _previousOutput; - private readonly CancellationToken _previousCancellationToken; - private readonly IRazorLayout? _previousLayout; - private readonly TextWriter? _targetOutput; + private IRazorLayout? _layout; private bool _layoutFrozen; + private Dictionary>? _sections; + + public StringWriter BufferedOutput { get; } = new(); + public IRazorLayout? Layout => _layout; + public CancellationToken CancellationToken { get; } + + public static ExecutionScope StartBody(RazorTemplate page, TextWriter? targetOutput, CancellationToken cancellationToken) + => new(page, targetOutput, cancellationToken); - public RazorTemplate Page { get; } - public StringBuilder BufferedOutput { get; } = new(); + private static ExecutionScope StartSection(ExecutionScope parent) + => new(parent._page, null, parent.CancellationToken) + { + _layout = parent._layout, // The section might reference the layout instance. + _layoutFrozen = true + }; private ExecutionScope(RazorTemplate page, TextWriter? targetOutput, CancellationToken cancellationToken) { - Page = page; + _page = page; _targetOutput = targetOutput; + CancellationToken = cancellationToken; _previousExecutionScope = page._executionScope; - _previousSections = page._sections; - _previousOutput = page.Output; - _previousCancellationToken = page.CancellationToken; - _previousLayout = page.Layout; - page._executionScope = this; - page._layout = null; - page._sections = null; - page.Output = new StringWriter(BufferedOutput); - page.CancellationToken = cancellationToken; } - public static ExecutionScope Start(RazorTemplate page, TextWriter? targetOutput, CancellationToken cancellationToken) - => new(page, targetOutput, cancellationToken); - public void Dispose() { - Page._executionScope = _previousExecutionScope; - Page._layout = _previousLayout; - Page._sections = _previousSections; - Page.Output = _previousOutput; - Page.CancellationToken = _previousCancellationToken; + if (ReferenceEquals(_page._executionScope, this)) + _page._executionScope = _previousExecutionScope; } - public void FreezeLayout() - => _layoutFrozen = true; - - public void EnsureCanChangeLayout() + public void SetLayout(IRazorLayout? layout) { + if (ReferenceEquals(layout, _layout)) + return; + if (_layoutFrozen) throw new InvalidOperationException("The layout can no longer be changed."); + + _layout = layout; } public async Task FlushAsync() { - if (Page.Layout is not null) + if (_layout is not null) throw new InvalidOperationException("The output cannot be flushed when a layout is used."); - FreezeLayout(); + // A part of the output will be written to the target output and discarded, + // so disallow setting a layout later on, as that would lead to inconsistent results. + _layoutFrozen = true; if (_targetOutput is not null) { - await WriteStringBuilderToOutputAndFlushAsync(BufferedOutput, _targetOutput, Page.CancellationToken).ConfigureAwait(false); - BufferedOutput.Clear(); + var bufferedOutput = BufferedOutput.GetStringBuilder(); + await WriteStringBuilderToOutputAsync(bufferedOutput, _targetOutput, CancellationToken).ConfigureAwait(false); + bufferedOutput.Clear(); + +#if NET8_0_OR_GREATER + await _targetOutput.FlushAsync(CancellationToken).ConfigureAwait(false); +#else + await _targetOutput.FlushAsync().ConfigureAwait(false); +#endif } } + + public BufferedContent ToBufferedContent() + => new(BufferedOutput.GetStringBuilder()); + + public bool IsSectionDefined(string name) + => _sections is { } sections && sections.ContainsKey(name); + + public void DefineSection(string name, Func action) + { + var sections = _sections ??= new(StringComparer.OrdinalIgnoreCase); + +#if NET6_0_OR_GREATER + if (!sections.TryAdd(name, action)) + throw new InvalidOperationException($"Section '{name}' is already defined."); +#else + if (sections.ContainsKey(name)) + throw new InvalidOperationException($"Section '{name}' is already defined."); + + sections[name] = action; +#endif + } + + public async Task RenderSectionAsync(string name) + { + if (_sections is not { } sections || !sections.TryGetValue(name, out var sectionAction)) + return null; + + using var sectionScope = StartSection(this); + await sectionAction().ConfigureAwait(false); + return sectionScope.ToBufferedContent(); + } } /// @@ -363,38 +379,23 @@ public async Task FlushAsync() /// private class ExecutionResult : IRazorExecutionResult { - private readonly RazorTemplate _page; - private readonly IReadOnlyDictionary>? _sections; + private readonly ExecutionScope _executionScope; public IEncodedContent Body { get; } - public IRazorLayout? Layout { get; } - public CancellationToken CancellationToken { get; } + public IRazorLayout? Layout => _executionScope.Layout; + public CancellationToken CancellationToken => _executionScope.CancellationToken; public ExecutionResult(ExecutionScope executionScope) { - _page = executionScope.Page; - _sections = _page._sections; - Body = new EncodedContent(executionScope.BufferedOutput); - Layout = _page.Layout; - CancellationToken = _page.CancellationToken; + _executionScope = executionScope; + Body = executionScope.ToBufferedContent(); } public bool IsSectionDefined(string name) - => _sections?.ContainsKey(name) is true; + => _executionScope.IsSectionDefined(name); - public async Task RenderSectionAsync(string name) - { - if (_sections is null || !_sections.TryGetValue(name, out var sectionAction)) - return null; - - using var executionScope = ExecutionScope.Start(_page, null, CancellationToken); - - _page._layout = Layout; // The section might reference this instance. - executionScope.FreezeLayout(); - - await sectionAction().ConfigureAwait(false); - return new EncodedContent(executionScope.BufferedOutput); - } + public Task RenderSectionAsync(string name) + => _executionScope.RenderSectionAsync(name); } /// @@ -402,13 +403,13 @@ public bool IsSectionDefined(string name) /// /// /// StringBuilders can be combined more efficiently than strings, which is useful for layouts. - /// has a dedicated Write overload for . + /// has a dedicated Write overload for in some frameworks. /// - private class EncodedContent : IEncodedContent + private class BufferedContent : IEncodedContent { public StringBuilder Output { get; } - public EncodedContent(StringBuilder value) + public BufferedContent(StringBuilder value) => Output = value; public void WriteTo(TextWriter textWriter) diff --git a/src/RazorBlade.Tests/HtmlLayoutTests.cs b/src/RazorBlade.Tests/HtmlLayoutTests.cs index 44241fb..8aa3f04 100644 --- a/src/RazorBlade.Tests/HtmlLayoutTests.cs +++ b/src/RazorBlade.Tests/HtmlLayoutTests.cs @@ -184,16 +184,11 @@ public void should_throw_on_render() Assert.Throws(() => ((RazorTemplate)layout).Render(CancellationToken.None)); } - private class Template : HtmlTemplate + private class Template(Action