diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRazorEngine.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRazorEngine.cs index 040b4ec7b2..b93a95b7dd 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRazorEngine.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRazorEngine.cs @@ -22,6 +22,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor _engine = RazorEngine.Create(configure => { FunctionsDirective.Register(configure); + TemporaryLayoutPass.Register(configure); configure.SetBaseType(BlazorComponent.FullTypeName); diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TemporaryLayoutPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TemporaryLayoutPass.cs new file mode 100644 index 0000000000..f18e7f6207 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TemporaryLayoutPass.cs @@ -0,0 +1,118 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + /// + /// This code is temporary. It finds top-level expressions of the form + /// @Layout() + /// ... and converts them into [Layout(typeof(SomeType))] attributes on the class. + /// Once we're able to add Blazor-specific directives and have them show up in tooling, + /// we'll replace this with a simpler and cleaner "@Layout SomeType" directive. + /// + internal class TemporaryLayoutPass : IntermediateNodePassBase, IRazorDirectiveClassifierPass + { + // Example: "Layout>()" + // Captures: MyApp.Namespace.SomeType + private static readonly Regex LayoutSourceRegex + = new Regex(@"^\s*Layout\s*<(.+)\>\s*\(\s*\)\s*$"); + private const string LayoutAttributeTypeName + = "Microsoft.AspNetCore.Blazor.Layouts.LayoutAttribute"; + + public static void Register(IRazorEngineBuilder configuration) + { + configuration.Features.Add(new TemporaryLayoutPass()); + } + + protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) + { + var visitor = new Visitor(); + visitor.Visit(documentNode); + + if (visitor.DidFindLayoutDeclaration) + { + visitor.MethodNode.Children.Remove(visitor.LayoutNode); + + var attributeNode = new CSharpCodeIntermediateNode(); + attributeNode.Children.Add(new IntermediateToken() + { + Kind = TokenKind.CSharp, + Content = $"[{LayoutAttributeTypeName}(typeof ({visitor.LayoutType}))]" + Environment.NewLine, + }); + + var classNodeIndex = visitor + .NamespaceNode + .Children + .IndexOf(visitor.ClassNode); + visitor.NamespaceNode.Children.Insert(classNodeIndex, attributeNode); + } + } + + private class Visitor : IntermediateNodeWalker + { + public bool DidFindLayoutDeclaration { get; private set; } + public NamespaceDeclarationIntermediateNode NamespaceNode { get; private set; } + public ClassDeclarationIntermediateNode ClassNode { get; private set; } + public MethodDeclarationIntermediateNode MethodNode { get; private set; } + public CSharpExpressionIntermediateNode LayoutNode { get; private set; } + public string LayoutType { get; private set; } + + public override void VisitNamespaceDeclaration(NamespaceDeclarationIntermediateNode node) + { + NamespaceNode = node; + base.VisitNamespaceDeclaration(node); + } + + public override void VisitClassDeclaration(ClassDeclarationIntermediateNode node) + { + ClassNode = node; + base.VisitClassDeclaration(node); + } + + public override void VisitMethodDeclaration(MethodDeclarationIntermediateNode methodNode) + { + var topLevelExpressions = methodNode.Children.OfType(); + foreach (var csharpExpression in topLevelExpressions) + { + if (csharpExpression.Children.Count == 1) + { + var child = csharpExpression.Children[0]; + if (child is IntermediateToken intermediateToken) + { + if (TryGetLayoutType(intermediateToken.Content, out string layoutType)) + { + DidFindLayoutDeclaration = true; + MethodNode = methodNode; + LayoutNode = csharpExpression; + LayoutType = layoutType; + } + } + } + } + + base.VisitMethodDeclaration(methodNode); + } + + private bool TryGetLayoutType(string sourceCode, out string layoutType) + { + var match = LayoutSourceRegex.Match(sourceCode); + if (match.Success) + { + layoutType = match.Groups[1].Value; + return true; + } + else + { + layoutType = null; + return false; + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Components/RazorToolingWorkaround.cs b/src/Microsoft.AspNetCore.Blazor/Components/RazorToolingWorkaround.cs index db7b860b2c..9f69c73d6e 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/RazorToolingWorkaround.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/RazorToolingWorkaround.cs @@ -24,7 +24,13 @@ namespace Microsoft.AspNetCore.Mvc namespace Microsoft.AspNetCore.Mvc.Razor { - public class RazorPage: BlazorComponent { } + public class RazorPage: BlazorComponent + { + // This is temporary and exists only to support TemporaryLayoutPass. + // It will be removed when we can add Blazor-specific directives. + public object Layout() where TLayout : IComponent + => throw new NotImplementedException(); + } namespace Internal { diff --git a/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutAttribute.cs b/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutAttribute.cs new file mode 100644 index 0000000000..d05798a866 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutAttribute.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Blazor.Components; +using System; + +namespace Microsoft.AspNetCore.Blazor.Layouts +{ + /// + /// Indicates that the associated component type uses a specified layout. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public class LayoutAttribute : Attribute + { + /// + /// The type of the layout. The type always implements . + /// + public Type LayoutType { get; private set; } + + /// + /// Constructs an instance of . + /// + /// The type of the layout. This must implement . + public LayoutAttribute(Type layoutType) + { + LayoutType = layoutType ?? throw new ArgumentNullException(nameof(layoutType)); + + if (!typeof(IComponent).IsAssignableFrom(layoutType)) + { + throw new ArgumentException($"Invalid layout type: {layoutType.FullName} " + + $"does not implement {typeof(IComponent).FullName}."); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs index 2c6b911788..59469f1159 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Blazor.Build.Core.RazorCompilation; using Microsoft.AspNetCore.Blazor.Components; +using Microsoft.AspNetCore.Blazor.Layouts; using Microsoft.AspNetCore.Blazor.Rendering; using Microsoft.AspNetCore.Blazor.RenderTree; using Microsoft.AspNetCore.Blazor.Test.Shared; @@ -360,6 +361,34 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test frame => AssertFrame.Component(frame, 0)); } + [Fact] + public void ComponentsDoNotHaveLayoutAttributeByDefault() + { + // Arrange/Act + var component = CompileToComponent($"Hello"); + + // Assert + Assert.Null(component.GetType().GetCustomAttribute()); + } + + [Fact] + public void SupportsLayoutDeclarationsViaTemporarySyntax() + { + // Arrange/Act + var testComponentTypeName = typeof(TestComponent).FullName.Replace('+', '.'); + var component = CompileToComponent( + $"@(Layout<{testComponentTypeName}>())" + + $"Hello"); + var frames = GetRenderTree(component); + + // Assert + var layoutAttribute = component.GetType().GetCustomAttribute(); + Assert.NotNull(layoutAttribute); + Assert.Equal(typeof(TestComponent), layoutAttribute.LayoutType); + Assert.Collection(frames, + frame => AssertFrame.Text(frame, "Hello")); + } + private static RenderTreeFrame[] GetRenderTree(IComponent component) { var renderer = new TestRenderer();