Skip to content

Commit 8aa175e

Browse files
committed
Merge branch 'layout'
2 parents 25be9cf + ab0f124 commit 8aa175e

21 files changed

+905
-50
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ For HTML templates, specify one of the following base classes with an `@inherits
9191

9292
- `RazorBlade.HtmlTemplate`
9393
- `RazorBlade.HtmlTemplate<TModel>`
94+
- `RazorBlade.HtmlLayout` (for layouts only)
9495

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

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

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

113+
### Layouts
114+
115+
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`.
116+
117+
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:
118+
119+
```Razor
120+
@{
121+
Layout = new LayoutToUse();
122+
}
123+
```
124+
125+
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.
126+
112127
### Executing templates
113128

114129
The `RazorTemplate` base class provides `Render` and `RenderAsync` methods to execute the template.

src/RazorBlade.Analyzers.Tests/RazorBladeSourceGeneratorTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,32 @@ public Task should_reject_tag_helper_directives()
324324
);
325325
}
326326

327+
[Test]
328+
public Task should_handle_sections()
329+
{
330+
return Verify(
331+
"""
332+
Before section
333+
@section SectionName { Section content }
334+
After section
335+
@section OtherSectionName { Answer is @(42) }
336+
"""
337+
);
338+
}
339+
340+
[Test]
341+
public Task should_detect_async_sections()
342+
{
343+
return Verify(
344+
"""
345+
@using System.Threading.Tasks
346+
@if (42.ToString() == "42") {
347+
@section SectionName { @await Task.FromResult(42) }
348+
}
349+
"""
350+
);
351+
}
352+
327353
private static GeneratorDriverRunResult Generate(string input,
328354
string? csharpCode,
329355
bool embeddedLibrary,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//HintName: TestNamespace.TestFile.Razor.g.cs
2+
#pragma checksum "./TestFile.cshtml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "50dfde4afe6bc3a38a99983a56c31051d8674231"
3+
// <auto-generated/>
4+
#pragma warning disable 1591
5+
namespace TestNamespace
6+
{
7+
#line hidden
8+
#nullable restore
9+
#line 1 "./TestFile.cshtml"
10+
using System.Threading.Tasks;
11+
12+
#line default
13+
#line hidden
14+
#nullable disable
15+
#nullable restore
16+
internal partial class TestFile : global::RazorBlade.HtmlTemplate
17+
#nullable disable
18+
{
19+
#pragma warning disable 1998
20+
protected async override global::System.Threading.Tasks.Task ExecuteAsync()
21+
{
22+
#nullable restore
23+
#line 2 "./TestFile.cshtml"
24+
if (42.ToString() == "42") {
25+
26+
27+
#line default
28+
#line hidden
29+
#nullable disable
30+
DefineSection("SectionName", async() => {
31+
WriteLiteral(" ");
32+
#nullable restore
33+
#line (3,29)-(3,54) 6 "./TestFile.cshtml"
34+
Write(await Task.FromResult(42));
35+
36+
#line default
37+
#line hidden
38+
#nullable disable
39+
WriteLiteral(" ");
40+
}
41+
);
42+
#nullable restore
43+
#line 3 "./TestFile.cshtml"
44+
45+
}
46+
47+
#line default
48+
#line hidden
49+
#nullable disable
50+
}
51+
#pragma warning restore 1998
52+
}
53+
}
54+
#pragma warning restore 1591
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//HintName: TestNamespace.TestFile.RazorBlade.g.cs
2+
// <auto-generated/>
3+
4+
#nullable restore
5+
6+
namespace TestNamespace
7+
{
8+
partial class TestFile
9+
{
10+
/// <inheritdoc cref="M:RazorBlade.RazorTemplate.Render(System.Threading.CancellationToken)" />
11+
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
12+
[global::System.Obsolete("The generated template is async. Use RenderAsync instead.", DiagnosticId = "RB0003")]
13+
public new string Render(global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
14+
=> base.Render(cancellationToken);
15+
16+
/// <inheritdoc cref="M:RazorBlade.RazorTemplate.Render(System.IO.TextWriter,System.Threading.CancellationToken)" />
17+
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
18+
[global::System.Obsolete("The generated template is async. Use RenderAsync instead.", DiagnosticId = "RB0003")]
19+
public new void Render(global::System.IO.TextWriter textWriter, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
20+
=> base.Render(textWriter, cancellationToken);
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//HintName: TestNamespace.TestFile.Razor.g.cs
2+
#pragma checksum "./TestFile.cshtml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "c5ddc67791758895b8ee73dd2a45225d3418acdb"
3+
// <auto-generated/>
4+
#pragma warning disable 1591
5+
namespace TestNamespace
6+
{
7+
#line hidden
8+
#nullable restore
9+
internal partial class TestFile : global::RazorBlade.HtmlTemplate
10+
#nullable disable
11+
{
12+
#pragma warning disable 1998
13+
protected async override global::System.Threading.Tasks.Task ExecuteAsync()
14+
{
15+
WriteLiteral("Before section\r\n");
16+
DefineSection("SectionName", async() => {
17+
WriteLiteral(" Section content ");
18+
}
19+
);
20+
WriteLiteral("After section\r\n");
21+
DefineSection("OtherSectionName", async() => {
22+
WriteLiteral(" Answer is ");
23+
#nullable restore
24+
#line (4,41)-(4,43) 6 "./TestFile.cshtml"
25+
Write(42);
26+
27+
#line default
28+
#line hidden
29+
#nullable disable
30+
WriteLiteral(" ");
31+
}
32+
);
33+
}
34+
#pragma warning restore 1998
35+
}
36+
}
37+
#pragma warning restore 1591

src/RazorBlade.Analyzers/LibraryCodeGenerator.cs

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ private static readonly SymbolDisplayFormat _paramFootprintFormat
5252
private INamedTypeSymbol? _classSymbol;
5353
private ImmutableArray<Diagnostic> _diagnostics;
5454
private Compilation _compilation;
55+
private SemanticModel? _semanticModel;
56+
private ClassDeclarationSyntax? _classDeclarationSyntax;
5557

5658
public LibraryCodeGenerator(RazorCSharpDocument generatedDoc,
5759
Compilation compilation,
@@ -86,7 +88,7 @@ public string Generate(CancellationToken cancellationToken)
8688
using (_writer.BuildClassDeclaration(["partial"], _classSymbol.Name, null, Array.Empty<string>(), Array.Empty<TypeParameter>(), useNullableContext: false))
8789
{
8890
GenerateConstructors();
89-
GenerateConditionalOnAsync();
91+
GenerateConditionalOnAsync(cancellationToken);
9092
}
9193
}
9294

@@ -107,17 +109,17 @@ private void Analyze(CancellationToken cancellationToken)
107109
.AddSyntaxTrees(syntaxTree)
108110
.AddSyntaxTrees(_additionalSyntaxTrees);
109111

110-
var semanticModel = _compilation.GetSemanticModel(syntaxTree);
112+
_semanticModel = _compilation.GetSemanticModel(syntaxTree);
111113

112-
var classDeclarationNode = syntaxTree.GetRoot(cancellationToken)
113-
.DescendantNodes()
114-
.FirstOrDefault(static i => i.IsKind(SyntaxKind.ClassDeclaration));
114+
_classDeclarationSyntax = syntaxTree.GetRoot(cancellationToken)
115+
.DescendantNodes()
116+
.FirstOrDefault(static i => i.IsKind(SyntaxKind.ClassDeclaration)) as ClassDeclarationSyntax;
115117

116-
_classSymbol = classDeclarationNode is ClassDeclarationSyntax classDeclarationSyntax
117-
? semanticModel.GetDeclaredSymbol(classDeclarationSyntax, cancellationToken)
118+
_classSymbol = _classDeclarationSyntax is not null
119+
? _semanticModel.GetDeclaredSymbol(_classDeclarationSyntax, cancellationToken)
118120
: null;
119121

120-
_diagnostics = semanticModel.GetDiagnostics(cancellationToken: cancellationToken);
122+
_diagnostics = _semanticModel.GetDiagnostics(cancellationToken: cancellationToken);
121123
}
122124

123125
private void GenerateConstructors()
@@ -164,26 +166,29 @@ private void GenerateConstructors()
164166
}
165167
}
166168

167-
private void GenerateConditionalOnAsync()
169+
private void GenerateConditionalOnAsync(CancellationToken cancellationToken)
168170
{
171+
const string executeAsyncMethodName = "ExecuteAsync";
172+
const string defineSectionMethodName = "DefineSection";
173+
169174
var conditionalOnAsyncAttribute = _compilation.GetTypeByMetadataName("RazorBlade.Support.ConditionalOnAsyncAttribute");
170175
if (conditionalOnAsyncAttribute is null)
171176
return;
172177

173-
var executeMethodSymbol = _classSymbol?.GetMembers("ExecuteAsync")
174-
.OfType<IMethodSymbol>()
175-
.FirstOrDefault(i => i.Parameters.IsEmpty && i.IsAsync);
178+
var executeMethodSyntax = _classDeclarationSyntax?.ChildNodes()
179+
.Where(m => m.IsKind(SyntaxKind.MethodDeclaration))
180+
.OfType<MethodDeclarationSyntax>()
181+
.FirstOrDefault(m => m.Identifier.ValueText == executeAsyncMethodName
182+
&& m.Modifiers.Any(SyntaxKind.AsyncKeyword)
183+
&& m.ParameterList.Parameters.Count == 0);
176184

177-
var methodLocation = executeMethodSymbol?.Locations.FirstOrDefault();
178-
if (methodLocation is null)
185+
if (executeMethodSyntax is null)
179186
return;
180187

181-
// CS1998 = This async method lacks 'await' operators and will run synchronously.
182-
var isTemplateSync = _diagnostics.Any(i => i.Id == "CS1998" && i.Location == methodLocation);
183-
188+
var isTemplateSync = IsTemplateSync();
184189
var hiddenMethodSignatures = new HashSet<string>(StringComparer.Ordinal);
185190

186-
for (var baseClass = _classSymbol?.BaseType; baseClass is not (null or { SpecialType: SpecialType.System_Object }); baseClass = baseClass.BaseType)
191+
foreach (var baseClass in _classSymbol.SelfAndBasesTypes().Skip(1))
187192
{
188193
foreach (var methodSymbol in baseClass.GetMembers().OfType<IMethodSymbol>())
189194
{
@@ -233,6 +238,49 @@ private void GenerateConditionalOnAsync()
233238
}
234239
}
235240

241+
bool IsTemplateSync()
242+
{
243+
// CS1998 = This async method lacks 'await' operators and will run synchronously.
244+
// The ExecuteAsync and all the DefineSection methods need to have this diagnostic for the template to be considered synchronous.
245+
246+
var diagnosticLocations = _diagnostics.Where(i => i.Id == "CS1998").Select(i => i.Location).ToHashSet();
247+
if (!diagnosticLocations.Contains(executeMethodSyntax.Identifier.GetLocation()))
248+
return false;
249+
250+
var defineSectionMethod = _classSymbol.SelfAndBasesTypes()
251+
.SelectMany(t => t.GetMembers(defineSectionMethodName))
252+
.OfType<IMethodSymbol>()
253+
.FirstOrDefault(m => m.Parameters is
254+
[
255+
{ Type.SpecialType: SpecialType.System_String },
256+
{ Type.TypeKind: TypeKind.Delegate }
257+
]);
258+
259+
if (defineSectionMethod is null || executeMethodSyntax.Body is not { } executeMethodBody)
260+
return true;
261+
262+
foreach (var node in executeMethodBody.DescendantNodes())
263+
{
264+
if (node is InvocationExpressionSyntax
265+
{
266+
ArgumentList.Arguments:
267+
[
268+
{ Expression: LiteralExpressionSyntax { RawKind: (int)SyntaxKind.StringLiteralExpression } },
269+
{ Expression: ParenthesizedLambdaExpressionSyntax { AsyncKeyword.RawKind: (int)SyntaxKind.AsyncKeyword } lambda }
270+
],
271+
Expression: IdentifierNameSyntax { Identifier.ValueText: defineSectionMethodName } expression
272+
}
273+
&& !diagnosticLocations.Contains(lambda.ArrowToken.GetLocation())
274+
&& SymbolEqualityComparer.Default.Equals(_semanticModel.GetSymbolInfo(expression, cancellationToken).Symbol, defineSectionMethod)
275+
)
276+
{
277+
return false;
278+
}
279+
}
280+
281+
return true;
282+
}
283+
236284
static string GetMethodSignatureFootprint(IMethodSymbol methodSymbol)
237285
{
238286
var sb = new StringBuilder();

src/RazorBlade.Analyzers/RazorBladeSourceGenerator.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Text;
66
using System.Threading;
77
using Microsoft.AspNetCore.Razor.Language;
8+
using Microsoft.AspNetCore.Razor.Language.Extensions;
89
using Microsoft.AspNetCore.Razor.Language.Intermediate;
910
using Microsoft.CodeAnalysis;
1011
using Microsoft.CodeAnalysis.CSharp;
@@ -112,6 +113,7 @@ private static RazorCSharpDocument GenerateRazorCode(SourceText sourceText, Inpu
112113
cfg =>
113114
{
114115
ModelDirective.Register(cfg);
116+
SectionDirective.Register(cfg);
115117

116118
cfg.SetCSharpLanguageVersion(globalOptions.ParseOptions.LanguageVersion);
117119

src/RazorBlade.Analyzers/Support/Extensions.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ namespace RazorBlade.Analyzers.Support;
77

88
internal static class Extensions
99
{
10+
public static HashSet<T> ToHashSet<T>(this IEnumerable<T> items)
11+
=> new(items);
12+
1013
public static IncrementalValuesProvider<T> WhereNotNull<T>(this IncrementalValuesProvider<T?> provider)
1114
where T : class
1215
=> provider.Where(static item => item is not null)!;
@@ -33,6 +36,15 @@ public static string EscapeCSharpKeyword(this string name)
3336
? "@" + name
3437
: name;
3538

39+
public static IEnumerable<INamedTypeSymbol> SelfAndBasesTypes(this INamedTypeSymbol? symbol)
40+
{
41+
while (symbol is not null)
42+
{
43+
yield return symbol;
44+
symbol = symbol.BaseType;
45+
}
46+
}
47+
3648
private sealed class LambdaComparer<T>(Func<T, T, bool> equals, Func<T, int> getHashCode) : IEqualityComparer<T>
3749
{
3850
public bool Equals(T? x, T? y)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
@inherits RazorBlade.HtmlLayout
2+
@* ReSharper disable Razor.SectionNotResolved *@
3+
@{ Layout = new OuterLayout(); }
4+
5+
<h1>Header</h1>
6+
This is the inner layout.
7+
8+
Section Foo: @RenderSection("Foo")
9+
@RenderBody()
10+
Section Bar: @RenderSection("Bar")
11+
<i>Footer</i>
12+
@section Baz {
13+
This is <b>Baz</b>, from the inner layout.
14+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@inherits RazorBlade.HtmlLayout
2+
<div>Outer layout header</div>
3+
@RenderBody()
4+
Section Baz: @RenderSection("Baz")
5+
<div>Outer layout footer</div>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
@inherits RazorBlade.HtmlTemplate
2+
@* ReSharper disable Razor.SectionNotResolved *@
3+
@{ Layout = new Layout(); }
4+
<h2>Hello, world!</h2>
5+
This is the body contents.
6+
@section Foo {
7+
This is <b>Foo</b>.
8+
}
9+
@section Bar {
10+
This is <i>Bar</i>.
11+
}

0 commit comments

Comments
 (0)