From 9549dccc547591dfa8117f14012e26ef2b4ffd70 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Mon, 12 Mar 2018 14:22:54 -0700 Subject: [PATCH] Add @page directive Adds the @page directive and support for specifying routes in components at compile time. For now the route is required and must begin with a leading /. --- .../BlazorApi.cs | 5 + .../BlazorDiagnosticFactory.cs | 43 +++---- .../BlazorExtensionInitializer.cs | 1 + .../PageDirective.cs | 44 +++++++ .../PageDirectivePass.cs | 107 ++++++++++++++++++ .../Resources.Designer.cs | 36 ++++++ .../Resources.resx | 12 ++ .../Components/RouteAttribute.cs | 23 ++++ ...ntimeCodeGenerationRazorIntegrationTest.cs | 53 +++++++++ 9 files changed, 305 insertions(+), 19 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/PageDirective.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/PageDirectivePass.cs create mode 100644 src/Microsoft.AspNetCore.Blazor/Components/RouteAttribute.cs diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs index e324c4ff64..a625f63fec 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs @@ -59,6 +59,11 @@ namespace Microsoft.AspNetCore.Blazor.Razor public static readonly string ChildContent = nameof(ChildContent); } + public static class RouteAttribute + { + public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.Components.RouteAttribute"; + } + public static class BindMethods { public static readonly string GetValue = "Microsoft.AspNetCore.Blazor.Components.BindMethods.GetValue"; diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs index 7453746513..78fcf47890 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; +using System.IO; using System.Linq; using AngleSharp; using Microsoft.AspNetCore.Razor.Language; @@ -62,26 +63,30 @@ namespace Microsoft.AspNetCore.Blazor.Razor return RazorDiagnostic.Create(UnsupportedComplexContent, node.Source ?? SourceSpan.Undefined, attributeName, content); } - private static SourceSpan? CalculateSourcePosition( - SourceSpan? razorTokenPosition, - TextPosition htmlNodePosition) + public static readonly RazorDiagnosticDescriptor PageDirective_CannotBeImported = + new RazorDiagnosticDescriptor( + "BL9987", + () => Resources.PageDirectiveCannotBeImported, + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic CreatePageDirective_CannotBeImported(SourceSpan source) { - if (razorTokenPosition.HasValue) - { - var razorPos = razorTokenPosition.Value; - return new SourceSpan( - razorPos.FilePath, - razorPos.AbsoluteIndex + htmlNodePosition.Position, - razorPos.LineIndex + htmlNodePosition.Line - 1, - htmlNodePosition.Line == 1 - ? razorPos.CharacterIndex + htmlNodePosition.Column - 1 - : htmlNodePosition.Column - 1, - length: 1); - } - else - { - return null; - } + var fileName = Path.GetFileName(source.FilePath); + var diagnostic = RazorDiagnostic.Create(PageDirective_CannotBeImported, source, PageDirective.Directive.Directive, fileName); + + return diagnostic; + } + + public static readonly RazorDiagnosticDescriptor PageDirective_MustSpecifyRoute = + new RazorDiagnosticDescriptor( + "BL9988", + () => "The @page directive must specify a route template. The route template must be enclosed in quotes and begin with the '/' character.", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic CreatePageDirective_MustSpecifyRoute(SourceSpan? source) + { + var diagnostic = RazorDiagnostic.Create(PageDirective_MustSpecifyRoute, source ?? SourceSpan.Undefined); + return diagnostic; } } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs index 9bc46ac0a0..a324ce5935 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs @@ -55,6 +55,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor InheritsDirective.Register(builder); InjectDirective.Register(builder); LayoutDirective.Register(builder); + PageDirective.Register(builder); builder.Features.Remove(builder.Features.OfType().Single()); builder.Features.Add(new BlazorImportProjectFeature()); diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/PageDirective.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/PageDirective.cs new file mode 100644 index 0000000000..7fcc57eadc --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/PageDirective.cs @@ -0,0 +1,44 @@ +// 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 System; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + public class PageDirective + { + public static readonly DirectiveDescriptor Directive = DirectiveDescriptor.CreateDirective( + "page", + DirectiveKind.SingleLine, + builder => + { + builder.AddStringToken(Resources.PageDirective_RouteToken_Name, Resources.PageDirective_RouteToken_Description); + builder.Usage = DirectiveUsage.FileScopedMultipleOccurring; + builder.Description = Resources.PageDirective_Description; + }); + + private PageDirective(string routeTemplate, IntermediateNode directiveNode) + { + RouteTemplate = routeTemplate; + DirectiveNode = directiveNode; + } + + public string RouteTemplate { get; } + + public IntermediateNode DirectiveNode { get; } + + public static RazorProjectEngineBuilder Register(RazorProjectEngineBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.AddDirective(Directive); + builder.Features.Add(new PageDirectivePass()); + return builder; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/PageDirectivePass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/PageDirectivePass.cs new file mode 100644 index 0000000000..b3e5b8bc6f --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/PageDirectivePass.cs @@ -0,0 +1,107 @@ +// 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 System; +using System.Linq; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.CodeGeneration; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal class PageDirectivePass : IntermediateNodePassBase, IRazorDirectiveClassifierPass + { + protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) + { + if (codeDocument == null) + { + throw new ArgumentNullException(nameof(codeDocument)); + } + + if (documentNode == null) + { + throw new ArgumentNullException(nameof(documentNode)); + } + + var @namespace = documentNode.FindPrimaryNamespace(); + var @class = documentNode.FindPrimaryClass(); + if (@namespace == null || @class == null) + { + return; + } + + var directives = documentNode.FindDirectiveReferences(PageDirective.Directive); + if (directives.Count == 0) + { + return; + } + + // We don't allow @page directives in imports + for (var i = 0; i < directives.Count; i++) + { + var directive = directives[i]; + if (directive.Node.IsImported()) + { + directive.Node.Diagnostics.Add(BlazorDiagnosticFactory.CreatePageDirective_CannotBeImported(directive.Node.Source.Value)); + } + } + + // Insert the attributes 'on-top' of the class declaration, since classes don't directly support attributes. + var index = 0; + for (; index < @namespace.Children.Count; index++) + { + if (object.ReferenceEquals(@class, @namespace.Children[index])) + { + break; + } + } + + for (var i = 0; i < directives.Count; i++) + { + var pageDirective = (DirectiveIntermediateNode)directives[i].Node; + + // The parser also adds errors for invalid syntax, we just need to not crash. + var routeToken = pageDirective.Tokens.FirstOrDefault(); + + if (routeToken != null && + routeToken.Content.Length >= 3 && + routeToken.Content[0] == '\"' && + routeToken.Content[1] == '/' && + routeToken.Content[routeToken.Content.Length - 1] == '\"') + { + var template = routeToken.Content.Substring(1, routeToken.Content.Length - 2); + @namespace.Children.Insert(index++, new RouteAttributeExtensionNode(template)); + } + else + { + pageDirective.Diagnostics.Add(BlazorDiagnosticFactory.CreatePageDirective_MustSpecifyRoute(pageDirective.Source)); + } + } + } + + private class RouteAttributeExtensionNode : ExtensionIntermediateNode + { + public RouteAttributeExtensionNode(string template) + { + Template = template; + } + + public string Template { get; } + + public override IntermediateNodeCollection Children => IntermediateNodeCollection.ReadOnly; + + public override void Accept(IntermediateNodeVisitor visitor) => AcceptExtensionNode(this, visitor); + + public override void WriteNode(CodeTarget target, CodeRenderingContext context) + { + context.CodeWriter.Write("["); + context.CodeWriter.Write(BlazorApi.RouteAttribute.FullTypeName); + context.CodeWriter.Write("(\""); + context.CodeWriter.Write(Template); + context.CodeWriter.Write("\")"); + context.CodeWriter.Write("]"); + context.CodeWriter.WriteLine(); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs index d14a4346f5..c96d67be19 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs @@ -113,5 +113,41 @@ namespace Microsoft.AspNetCore.Blazor.Razor { return ResourceManager.GetString("LayoutDirective_TypeToken_Name", resourceCulture); } } + + /// + /// Looks up a localized string similar to Mark the page as a routable component.. + /// + internal static string PageDirective_Description { + get { + return ResourceManager.GetString("PageDirective_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An optional route template for the component.. + /// + internal static string PageDirective_RouteToken_Description { + get { + return ResourceManager.GetString("PageDirective_RouteToken_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to route template. + /// + internal static string PageDirective_RouteToken_Name { + get { + return ResourceManager.GetString("PageDirective_RouteToken_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The '@{0}' directive specified in {1} file will not be imported. The directive must appear at the top of each Razor cshtml file. + /// + internal static string PageDirectiveCannotBeImported { + get { + return ResourceManager.GetString("PageDirectiveCannotBeImported", resourceCulture); + } + } } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx index 77b30d74e5..cd58654757 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx @@ -135,4 +135,16 @@ TypeName + + The '@{0}' directive specified in {1} file will not be imported. The directive must appear at the top of each Razor cshtml file + + + Mark the page as a routable component. + + + An optional route template for the component. + + + route template + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor/Components/RouteAttribute.cs b/src/Microsoft.AspNetCore.Blazor/Components/RouteAttribute.cs new file mode 100644 index 0000000000..6ce6c6f17e --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Components/RouteAttribute.cs @@ -0,0 +1,23 @@ +// 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 System; + +namespace Microsoft.AspNetCore.Blazor.Components +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] + public class RouteAttribute : Attribute + { + public RouteAttribute(string template) + { + if (template == null) + { + throw new ArgumentNullException(nameof(template)); + } + + Template = template; + } + + public string Template { get; } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RuntimeCodeGenerationRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RuntimeCodeGenerationRazorIntegrationTest.cs index ad557be0ec..51adae4323 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RuntimeCodeGenerationRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RuntimeCodeGenerationRazorIntegrationTest.cs @@ -420,6 +420,59 @@ namespace Test } } #pragma warning restore 1591 +", generated); + } + + [Fact] + public void CodeGeneration_ChildComponent_WithPageDirective() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly +@page ""/MyPage"" +@page ""/AnotherRoute/{id}"" +"); + + // Assert + CompileToAssembly(generated); + + AssertSourceEquals(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + [Microsoft.AspNetCore.Blazor.Components.RouteAttribute(""/MyPage"")] + [Microsoft.AspNetCore.Blazor.Components.RouteAttribute(""/AnotherRoute/{id}"")] + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenComponent(0); + builder.CloseComponent(); + } + #pragma warning restore 1998 + } +} +#pragma warning restore 1591 ", generated); } }