Skip to content

Commit

Permalink
Merge branch 'layout'
Browse files Browse the repository at this point in the history
  • Loading branch information
ltrzesniewski committed Dec 2, 2023
2 parents 25be9cf + ab0f124 commit 8aa175e
Show file tree
Hide file tree
Showing 21 changed files with 905 additions and 50 deletions.
15 changes: 15 additions & 0 deletions README.md
Expand Up @@ -91,6 +91,7 @@ For HTML templates, specify one of the following base classes with an `@inherits

- `RazorBlade.HtmlTemplate`
- `RazorBlade.HtmlTemplate<TModel>`
- `RazorBlade.HtmlLayout` (for layouts only)

If you'd like to write a plain text template (which never escapes HTML), the following classes are available:

Expand All @@ -109,6 +110,20 @@ Templates can be included in other templates by evaluating them, since they impl

The namespace of the generated class can be customized with the `@namespace` directive. The default value is deduced from the file location.

### Layouts

Layout templates may be written by inheriting from the `RazorBlade.HtmlLayout` class, which provides the relevant methods such as `RenderBody` and `RenderSection`. It inherits from `RazorBlade.HtmlTemplate`.

The layout to use can be specified through the `Layout` property of `RazorBlade.HtmlTemplate`. Given that all Razor templates are stateful and not thread-safe, always create a new instance of the layout page to use:

```Razor
@{
Layout = new LayoutToUse();
}
```

Layout pages can be nested, and can use sections. Unlike in ASP.NET, RazorBlade does not verify if the body and all sections have been used. Sections may also be executed multiple times.

### Executing templates

The `RazorTemplate` base class provides `Render` and `RenderAsync` methods to execute the template.
Expand Down
26 changes: 26 additions & 0 deletions src/RazorBlade.Analyzers.Tests/RazorBladeSourceGeneratorTests.cs
Expand Up @@ -324,6 +324,32 @@ public Task should_reject_tag_helper_directives()
);
}

[Test]
public Task should_handle_sections()
{
return Verify(
"""
Before section
@section SectionName { Section content }
After section
@section OtherSectionName { Answer is @(42) }
"""
);
}

[Test]
public Task should_detect_async_sections()
{
return Verify(
"""
@using System.Threading.Tasks
@if (42.ToString() == "42") {
@section SectionName { @await Task.FromResult(42) }
}
"""
);
}

private static GeneratorDriverRunResult Generate(string input,
string? csharpCode,
bool embeddedLibrary,
Expand Down
@@ -0,0 +1,54 @@
//HintName: TestNamespace.TestFile.Razor.g.cs
#pragma checksum "./TestFile.cshtml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "50dfde4afe6bc3a38a99983a56c31051d8674231"
// <auto-generated/>
#pragma warning disable 1591
namespace TestNamespace
{
#line hidden
#nullable restore
#line 1 "./TestFile.cshtml"
using System.Threading.Tasks;

#line default
#line hidden
#nullable disable
#nullable restore
internal partial class TestFile : global::RazorBlade.HtmlTemplate
#nullable disable
{
#pragma warning disable 1998
protected async override global::System.Threading.Tasks.Task ExecuteAsync()
{
#nullable restore
#line 2 "./TestFile.cshtml"
if (42.ToString() == "42") {


#line default
#line hidden
#nullable disable
DefineSection("SectionName", async() => {
WriteLiteral(" ");
#nullable restore
#line (3,29)-(3,54) 6 "./TestFile.cshtml"
Write(await Task.FromResult(42));
#line default
#line hidden
#nullable disable
WriteLiteral(" ");
}
);
#nullable restore
#line 3 "./TestFile.cshtml"

}

#line default
#line hidden
#nullable disable
}
#pragma warning restore 1998
}
}
#pragma warning restore 1591
@@ -0,0 +1,22 @@
//HintName: TestNamespace.TestFile.RazorBlade.g.cs
// <auto-generated/>

#nullable restore

namespace TestNamespace
{
partial class TestFile
{
/// <inheritdoc cref="M:RazorBlade.RazorTemplate.Render(System.Threading.CancellationToken)" />
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
[global::System.Obsolete("The generated template is async. Use RenderAsync instead.", DiagnosticId = "RB0003")]
public new string Render(global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
=> base.Render(cancellationToken);

/// <inheritdoc cref="M:RazorBlade.RazorTemplate.Render(System.IO.TextWriter,System.Threading.CancellationToken)" />
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
[global::System.Obsolete("The generated template is async. Use RenderAsync instead.", DiagnosticId = "RB0003")]
public new void Render(global::System.IO.TextWriter textWriter, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
=> base.Render(textWriter, cancellationToken);
}
}
@@ -0,0 +1,37 @@
//HintName: TestNamespace.TestFile.Razor.g.cs
#pragma checksum "./TestFile.cshtml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "c5ddc67791758895b8ee73dd2a45225d3418acdb"
// <auto-generated/>
#pragma warning disable 1591
namespace TestNamespace
{
#line hidden
#nullable restore
internal partial class TestFile : global::RazorBlade.HtmlTemplate
#nullable disable
{
#pragma warning disable 1998
protected async override global::System.Threading.Tasks.Task ExecuteAsync()
{
WriteLiteral("Before section\r\n");
DefineSection("SectionName", async() => {
WriteLiteral(" Section content ");
}
);
WriteLiteral("After section\r\n");
DefineSection("OtherSectionName", async() => {
WriteLiteral(" Answer is ");
#nullable restore
#line (4,41)-(4,43) 6 "./TestFile.cshtml"
Write(42);
#line default
#line hidden
#nullable disable
WriteLiteral(" ");
}
);
}
#pragma warning restore 1998
}
}
#pragma warning restore 1591
84 changes: 66 additions & 18 deletions src/RazorBlade.Analyzers/LibraryCodeGenerator.cs
Expand Up @@ -52,6 +52,8 @@ internal class LibraryCodeGenerator
private INamedTypeSymbol? _classSymbol;
private ImmutableArray<Diagnostic> _diagnostics;
private Compilation _compilation;
private SemanticModel? _semanticModel;
private ClassDeclarationSyntax? _classDeclarationSyntax;

public LibraryCodeGenerator(RazorCSharpDocument generatedDoc,
Compilation compilation,
Expand Down Expand Up @@ -86,7 +88,7 @@ public string Generate(CancellationToken cancellationToken)
using (_writer.BuildClassDeclaration(["partial"], _classSymbol.Name, null, Array.Empty<string>(), Array.Empty<TypeParameter>(), useNullableContext: false))
{
GenerateConstructors();
GenerateConditionalOnAsync();
GenerateConditionalOnAsync(cancellationToken);
}
}

Expand All @@ -107,17 +109,17 @@ private void Analyze(CancellationToken cancellationToken)
.AddSyntaxTrees(syntaxTree)
.AddSyntaxTrees(_additionalSyntaxTrees);

var semanticModel = _compilation.GetSemanticModel(syntaxTree);
_semanticModel = _compilation.GetSemanticModel(syntaxTree);

var classDeclarationNode = syntaxTree.GetRoot(cancellationToken)
.DescendantNodes()
.FirstOrDefault(static i => i.IsKind(SyntaxKind.ClassDeclaration));
_classDeclarationSyntax = syntaxTree.GetRoot(cancellationToken)
.DescendantNodes()
.FirstOrDefault(static i => i.IsKind(SyntaxKind.ClassDeclaration)) as ClassDeclarationSyntax;

_classSymbol = classDeclarationNode is ClassDeclarationSyntax classDeclarationSyntax
? semanticModel.GetDeclaredSymbol(classDeclarationSyntax, cancellationToken)
_classSymbol = _classDeclarationSyntax is not null
? _semanticModel.GetDeclaredSymbol(_classDeclarationSyntax, cancellationToken)
: null;

_diagnostics = semanticModel.GetDiagnostics(cancellationToken: cancellationToken);
_diagnostics = _semanticModel.GetDiagnostics(cancellationToken: cancellationToken);
}

private void GenerateConstructors()
Expand Down Expand Up @@ -164,26 +166,29 @@ private void GenerateConstructors()
}
}

private void GenerateConditionalOnAsync()
private void GenerateConditionalOnAsync(CancellationToken cancellationToken)
{
const string executeAsyncMethodName = "ExecuteAsync";
const string defineSectionMethodName = "DefineSection";

var conditionalOnAsyncAttribute = _compilation.GetTypeByMetadataName("RazorBlade.Support.ConditionalOnAsyncAttribute");
if (conditionalOnAsyncAttribute is null)
return;

var executeMethodSymbol = _classSymbol?.GetMembers("ExecuteAsync")
.OfType<IMethodSymbol>()
.FirstOrDefault(i => i.Parameters.IsEmpty && i.IsAsync);
var executeMethodSyntax = _classDeclarationSyntax?.ChildNodes()
.Where(m => m.IsKind(SyntaxKind.MethodDeclaration))
.OfType<MethodDeclarationSyntax>()
.FirstOrDefault(m => m.Identifier.ValueText == executeAsyncMethodName
&& m.Modifiers.Any(SyntaxKind.AsyncKeyword)
&& m.ParameterList.Parameters.Count == 0);

var methodLocation = executeMethodSymbol?.Locations.FirstOrDefault();
if (methodLocation is null)
if (executeMethodSyntax is null)
return;

// CS1998 = This async method lacks 'await' operators and will run synchronously.
var isTemplateSync = _diagnostics.Any(i => i.Id == "CS1998" && i.Location == methodLocation);

var isTemplateSync = IsTemplateSync();
var hiddenMethodSignatures = new HashSet<string>(StringComparer.Ordinal);

for (var baseClass = _classSymbol?.BaseType; baseClass is not (null or { SpecialType: SpecialType.System_Object }); baseClass = baseClass.BaseType)
foreach (var baseClass in _classSymbol.SelfAndBasesTypes().Skip(1))
{
foreach (var methodSymbol in baseClass.GetMembers().OfType<IMethodSymbol>())
{
Expand Down Expand Up @@ -233,6 +238,49 @@ private void GenerateConditionalOnAsync()
}
}

bool IsTemplateSync()
{
// CS1998 = This async method lacks 'await' operators and will run synchronously.
// The ExecuteAsync and all the DefineSection methods need to have this diagnostic for the template to be considered synchronous.

var diagnosticLocations = _diagnostics.Where(i => i.Id == "CS1998").Select(i => i.Location).ToHashSet();
if (!diagnosticLocations.Contains(executeMethodSyntax.Identifier.GetLocation()))
return false;

var defineSectionMethod = _classSymbol.SelfAndBasesTypes()
.SelectMany(t => t.GetMembers(defineSectionMethodName))
.OfType<IMethodSymbol>()
.FirstOrDefault(m => m.Parameters is
[
{ Type.SpecialType: SpecialType.System_String },
{ Type.TypeKind: TypeKind.Delegate }
]);

if (defineSectionMethod is null || executeMethodSyntax.Body is not { } executeMethodBody)
return true;

foreach (var node in executeMethodBody.DescendantNodes())
{
if (node is InvocationExpressionSyntax
{
ArgumentList.Arguments:
[
{ Expression: LiteralExpressionSyntax { RawKind: (int)SyntaxKind.StringLiteralExpression } },
{ Expression: ParenthesizedLambdaExpressionSyntax { AsyncKeyword.RawKind: (int)SyntaxKind.AsyncKeyword } lambda }
],
Expression: IdentifierNameSyntax { Identifier.ValueText: defineSectionMethodName } expression
}
&& !diagnosticLocations.Contains(lambda.ArrowToken.GetLocation())
&& SymbolEqualityComparer.Default.Equals(_semanticModel.GetSymbolInfo(expression, cancellationToken).Symbol, defineSectionMethod)
)
{
return false;
}
}

return true;
}

static string GetMethodSignatureFootprint(IMethodSymbol methodSymbol)
{
var sb = new StringBuilder();
Expand Down
2 changes: 2 additions & 0 deletions src/RazorBlade.Analyzers/RazorBladeSourceGenerator.cs
Expand Up @@ -5,6 +5,7 @@
using System.Text;
using System.Threading;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Extensions;
using Microsoft.AspNetCore.Razor.Language.Intermediate;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
Expand Down Expand Up @@ -112,6 +113,7 @@ private static RazorCSharpDocument GenerateRazorCode(SourceText sourceText, Inpu
cfg =>
{
ModelDirective.Register(cfg);
SectionDirective.Register(cfg);
cfg.SetCSharpLanguageVersion(globalOptions.ParseOptions.LanguageVersion);
Expand Down
12 changes: 12 additions & 0 deletions src/RazorBlade.Analyzers/Support/Extensions.cs
Expand Up @@ -7,6 +7,9 @@ namespace RazorBlade.Analyzers.Support;

internal static class Extensions
{
public static HashSet<T> ToHashSet<T>(this IEnumerable<T> items)
=> new(items);

public static IncrementalValuesProvider<T> WhereNotNull<T>(this IncrementalValuesProvider<T?> provider)
where T : class
=> provider.Where(static item => item is not null)!;
Expand All @@ -33,6 +36,15 @@ public static string EscapeCSharpKeyword(this string name)
? "@" + name
: name;

public static IEnumerable<INamedTypeSymbol> SelfAndBasesTypes(this INamedTypeSymbol? symbol)
{
while (symbol is not null)
{
yield return symbol;
symbol = symbol.BaseType;
}
}

private sealed class LambdaComparer<T>(Func<T, T, bool> equals, Func<T, int> getHashCode) : IEqualityComparer<T>
{
public bool Equals(T? x, T? y)
Expand Down
14 changes: 14 additions & 0 deletions src/RazorBlade.IntegrationTest/Layout.cshtml
@@ -0,0 +1,14 @@
@inherits RazorBlade.HtmlLayout
@* ReSharper disable Razor.SectionNotResolved *@
@{ Layout = new OuterLayout(); }

<h1>Header</h1>
This is the inner layout.

Section Foo: @RenderSection("Foo")
@RenderBody()
Section Bar: @RenderSection("Bar")
<i>Footer</i>
@section Baz {
This is <b>Baz</b>, from the inner layout.
}
5 changes: 5 additions & 0 deletions src/RazorBlade.IntegrationTest/OuterLayout.cshtml
@@ -0,0 +1,5 @@
@inherits RazorBlade.HtmlLayout
<div>Outer layout header</div>
@RenderBody()
Section Baz: @RenderSection("Baz")
<div>Outer layout footer</div>
11 changes: 11 additions & 0 deletions src/RazorBlade.IntegrationTest/PageWithLayout.cshtml
@@ -0,0 +1,11 @@
@inherits RazorBlade.HtmlTemplate
@* ReSharper disable Razor.SectionNotResolved *@
@{ Layout = new Layout(); }
<h2>Hello, world!</h2>
This is the body contents.
@section Foo {
This is <b>Foo</b>.
}
@section Bar {
This is <i>Bar</i>.
}

0 comments on commit 8aa175e

Please sign in to comment.