From 6fa3e405af8abc628a9f76977d6cd25faa85994d Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Fri, 1 May 2015 16:43:53 -0700 Subject: [PATCH] Add support for minimized attributes in TagHelpers. - Updated the Razor parser to understand minimized attributes instead of just treating them like plain text. This just involved encompassing minimized attributes in their own blocks just like the other attributes found on the HTML tag. - Updated TagHelperParseTreeRewriter to only accept minimized attributes for unbound attributes. - Updated IReadOnlyTagHelperAttribute/TagHelperAttribute to have a Minimized property to indicate that an attribute was minimized. - Updated parser level block structures to represent minimized attributes as null syntax tree nodes. - Updated chunk level structures to represent minimized attributes as null chunks. #220 --- .../TagHelpers/IReadOnlyTagHelperAttribute.cs | 6 ++ .../TagHelpers/TagHelperAttribute.cs | 9 ++- .../TagHelpers/TagHelperExecutionContext.cs | 24 ++++++- .../CSharp/CSharpTagHelperCodeRenderer.cs | 69 +++++++++++++------ .../Generator/GeneratedTagHelperContext.cs | 6 ++ .../Generator/TagHelperCodeGenerator.cs | 20 ++++-- .../Parser/HtmlMarkupParser.Block.cs | 19 ++++- .../Parser/TagHelpers/TagHelperBlock.cs | 5 +- .../TagHelpers/TagHelperBlockRewriter.cs | 34 +++++---- 9 files changed, 144 insertions(+), 48 deletions(-) diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/IReadOnlyTagHelperAttribute.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/IReadOnlyTagHelperAttribute.cs index 0114f895fb..cdf90a0dbd 100644 --- a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/IReadOnlyTagHelperAttribute.cs +++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/IReadOnlyTagHelperAttribute.cs @@ -19,5 +19,11 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers /// Gets the value of the attribute. /// object Value { get; } + + /// + /// Gets an indication whether the attribute is minimized or not. + /// + /// If true, will be ignored. + bool Minimized { get; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperAttribute.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperAttribute.cs index a2f1e16133..96954d9346 100644 --- a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperAttribute.cs +++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperAttribute.cs @@ -41,6 +41,12 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers /// public object Value { get; set; } + /// + /// Gets or sets an indication whether the attribute is minimized or not. + /// + /// If true, will be ignored. + public bool Minimized { get; set; } + /// /// Converts the specified into a . /// @@ -61,7 +67,8 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers return other != null && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase) && - Equals(Value, other.Value); + Minimized == other.Minimized && + (Minimized || Equals(Value, other.Value)); } /// diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperExecutionContext.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperExecutionContext.cs index d72e854f44..2fc571a4af 100644 --- a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperExecutionContext.cs +++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperExecutionContext.cs @@ -39,7 +39,7 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers /// The HTML tag name in the Razor source. /// /// indicating whether or not the tag in the Razor source was self-closing. - /// The collection of items used to communicate with other + /// The collection of items used to communicate with other /// s /// An identifier unique to the HTML element this context is for. /// A delegate used to execute the child content asynchronously. @@ -133,6 +133,26 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers _tagHelpers.Add(tagHelper); } + /// + /// Tracks the minimized HTML attribute in and . + /// + /// The minimized HTML attribute name. + public void AddMinimizedHtmlAttribute([NotNull] string name) + { + HTMLAttributes.Add( + new TagHelperAttribute + { + Name = name, + Minimized = true + }); + AllAttributes.Add( + new TagHelperAttribute + { + Name = name, + Minimized = true + }); + } + /// /// Tracks the HTML attribute in and . /// @@ -168,7 +188,7 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers /// /// A that on completion returns the rendered child content. /// - /// Child content is only executed once. Successive calls to this method or successive executions of the + /// Child content is only executed once. Successive calls to this method or successive executions of the /// returned return a cached result. /// public async Task GetChildContentAsync() diff --git a/src/Microsoft.AspNet.Razor/Generator/Compiler/CodeBuilder/CSharp/CSharpTagHelperCodeRenderer.cs b/src/Microsoft.AspNet.Razor/Generator/Compiler/CodeBuilder/CSharp/CSharpTagHelperCodeRenderer.cs index 0de12eaece..910d806376 100644 --- a/src/Microsoft.AspNet.Razor/Generator/Compiler/CodeBuilder/CSharp/CSharpTagHelperCodeRenderer.cs +++ b/src/Microsoft.AspNet.Razor/Generator/Compiler/CodeBuilder/CSharp/CSharpTagHelperCodeRenderer.cs @@ -203,6 +203,13 @@ namespace Microsoft.AspNet.Razor.Generator.Compiler.CSharp var firstAttribute = matchingAttributes.First(); var attributeValueChunk = firstAttribute.Value; + // Minimized attributes are not valid for bound attributes. There will be an error for the bound + // attribute logged by TagHelperBlockRewriter already so we can skip. + if (attributeValueChunk == null) + { + continue; + } + var attributeValueRecorded = htmlAttributeValues.ContainsKey(attributeDescriptor.Name); // Bufferable attributes are attributes that can have Razor code inside of them. @@ -323,15 +330,21 @@ namespace Microsoft.AspNet.Razor.Generator.Compiler.CSharp // Build out the unbound HTML attributes for the tag builder foreach (var htmlAttribute in unboundHTMLAttributes) { - string textValue; + string textValue = null; + var isPlainTextValue = false; var attributeValue = htmlAttribute.Value; - var isPlainTextValue = TryGetPlainTextValue(attributeValue, out textValue); - // HTML attributes are always strings. So if this value is not plain text i.e. if the value contains - // C# code, then we need to buffer it. - if (!isPlainTextValue) + // A null attribute value means the HTML attribute is minimized. + if (attributeValue != null) { - BuildBufferedWritingScope(attributeValue, htmlEncodeValues: true); + isPlainTextValue = TryGetPlainTextValue(attributeValue, out textValue); + + // HTML attributes are always strings. So if this value is not plain text i.e. if the value contains + // C# code, then we need to buffer it. + if (!isPlainTextValue) + { + BuildBufferedWritingScope(attributeValue, htmlEncodeValues: true); + } } // Execution contexts are a runtime feature, therefore no need to add anything to them. @@ -340,27 +353,39 @@ namespace Microsoft.AspNet.Razor.Generator.Compiler.CSharp continue; } - _writer - .WriteStartInstanceMethodInvocation( - ExecutionContextVariableName, - _tagHelperContext.ExecutionContextAddHtmlAttributeMethodName) - .WriteStringLiteral(htmlAttribute.Key) - .WriteParameterSeparator() - .WriteStartMethodInvocation(_tagHelperContext.MarkAsHtmlEncodedMethodName); - - // If it's a plain text value then we need to surround the value with quotes. - if (isPlainTextValue) + // If we have a minimized attribute there is no value + if (attributeValue == null) { - _writer.WriteStringLiteral(textValue); + _writer + .WriteStartInstanceMethodInvocation( + ExecutionContextVariableName, + _tagHelperContext.ExecutionContextAddMinimizedHtmlAttributeMethodName) + .WriteStringLiteral(htmlAttribute.Key) + .WriteEndMethodInvocation(); } else { - RenderBufferedAttributeValueAccessor(_writer); - } + _writer + .WriteStartInstanceMethodInvocation( + ExecutionContextVariableName, + _tagHelperContext.ExecutionContextAddHtmlAttributeMethodName) + .WriteStringLiteral(htmlAttribute.Key) + .WriteParameterSeparator() + .WriteStartMethodInvocation(_tagHelperContext.MarkAsHtmlEncodedMethodName); - _writer - .WriteEndMethodInvocation(endLine: false) - .WriteEndMethodInvocation(); + // If it's a plain text value then we need to surround the value with quotes. + if (isPlainTextValue) + { + _writer.WriteStringLiteral(textValue); + } + else + { + RenderBufferedAttributeValueAccessor(_writer); + } + + _writer.WriteEndMethodInvocation(endLine: false) + .WriteEndMethodInvocation(); + } } } diff --git a/src/Microsoft.AspNet.Razor/Generator/GeneratedTagHelperContext.cs b/src/Microsoft.AspNet.Razor/Generator/GeneratedTagHelperContext.cs index 4a566cd798..57eb270b56 100644 --- a/src/Microsoft.AspNet.Razor/Generator/GeneratedTagHelperContext.cs +++ b/src/Microsoft.AspNet.Razor/Generator/GeneratedTagHelperContext.cs @@ -19,6 +19,7 @@ namespace Microsoft.AspNet.Razor.Generator ScopeManagerEndMethodName = "End"; ExecutionContextAddMethodName = "Add"; ExecutionContextAddTagHelperAttributeMethodName = "AddTagHelperAttribute"; + ExecutionContextAddMinimizedHtmlAttributeMethodName = "AddMinimizedHtmlAttribute"; ExecutionContextAddHtmlAttributeMethodName = "AddHtmlAttribute"; ExecutionContextOutputPropertyName = "Output"; MarkAsHtmlEncodedMethodName = "Html.Raw"; @@ -57,6 +58,11 @@ namespace Microsoft.AspNet.Razor.Generator /// public string ExecutionContextAddTagHelperAttributeMethodName { get; set; } + /// + /// The name of the method used to add minimized HTML attributes. + /// + public string ExecutionContextAddMinimizedHtmlAttributeMethodName { get; set; } + /// /// The name of the method used to add HTML attributes. /// diff --git a/src/Microsoft.AspNet.Razor/Generator/TagHelperCodeGenerator.cs b/src/Microsoft.AspNet.Razor/Generator/TagHelperCodeGenerator.cs index bb924aa398..84818fc09a 100644 --- a/src/Microsoft.AspNet.Razor/Generator/TagHelperCodeGenerator.cs +++ b/src/Microsoft.AspNet.Razor/Generator/TagHelperCodeGenerator.cs @@ -57,19 +57,25 @@ namespace Microsoft.AspNet.Razor.Generator foreach (var attribute in tagHelperBlock.Attributes) { - // Populates the code tree with chunks associated with attributes - attribute.Value.Accept(codeGenerator); + ChunkBlock attributeChunkValue = null; - var chunks = codeGenerator.Context.CodeTreeBuilder.CodeTree.Chunks; - var first = chunks.FirstOrDefault(); + if (attribute.Value != null) + { + // Populates the code tree with chunks associated with attributes + attribute.Value.Accept(codeGenerator); - attributes.Add(new KeyValuePair(attribute.Key, - new ChunkBlock + var chunks = codeGenerator.Context.CodeTreeBuilder.CodeTree.Chunks; + var first = chunks.FirstOrDefault(); + + attributeChunkValue = new ChunkBlock { Association = first?.Association, Children = chunks, Start = first == null ? SourceLocation.Zero : first.Start - })); + }; + } + + attributes.Add(new KeyValuePair(attribute.Key, attributeChunkValue)); // Reset the code tree builder so we can build a new one for the next attribute codeGenerator.Context.CodeTreeBuilder = new CodeTreeBuilder(); diff --git a/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Block.cs b/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Block.cs index 351db46641..ef2a330946 100644 --- a/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Block.cs +++ b/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Block.cs @@ -473,12 +473,25 @@ namespace Microsoft.AspNet.Razor.Parser if (!At(HtmlSymbolType.Equals)) { - // Saw a space or newline after the name, so just skip this attribute and continue around the loop - Accept(whitespace); - Accept(name); + // Minimized attribute + + // Output anything prior to the attribute, in most cases this will be the tag name: + // |. If in-between other attributes this will noop or output malformed attribute + // content (if the previous attribute was malformed). + Output(SpanKind.Markup); + + using (Context.StartBlock(BlockType.Markup)) + { + Accept(whitespace); + Accept(name); + Output(SpanKind.Markup); + } + return; } + // Not a minimized attribute, parse as if it were well-formed (if attribute turns out to be malformed we + // will go into recovery). Output(SpanKind.Markup); // Start a new markup block for the attribute diff --git a/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlock.cs b/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlock.cs index 48fb4df169..6b3f5f3362 100644 --- a/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlock.cs +++ b/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlock.cs @@ -38,7 +38,10 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers foreach (var attributeChildren in Attributes) { - attributeChildren.Value.Parent = this; + if (attributeChildren.Value != null) + { + attributeChildren.Value.Parent = this; + } } } diff --git a/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlockRewriter.cs b/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlockRewriter.cs index b564d78156..001d3d3deb 100644 --- a/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlockRewriter.cs +++ b/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlockRewriter.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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; @@ -71,11 +71,12 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal // Only want to track the attribute if we succeeded in parsing its corresponding Block/Span. if (succeeded) { - // Check if it's a bound attribute that is not of type string and happens to be null or whitespace. + // Check if it's a bound attribute that is minimized or not of type string and null or whitespace. string attributeValueType; if (attributeValueTypes.TryGetValue(attribute.Key, out attributeValueType) && + (attribute.Value == null || !IsStringAttribute(attributeValueType) && - IsNullOrWhitespaceAttributeValue(attribute.Value)) + IsNullOrWhitespaceAttributeValue(attribute.Value))) { var errorLocation = GetAttributeNameStartLocation(child); @@ -167,7 +168,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal { Debug.Assert( name != null, - "Name should never be null here. The parser should guaruntee an attribute has a name."); + "Name should never be null here. The parser should guarantee an attribute has a name."); // We've captured all leading whitespace and the attribute name. // We're now at: " asp-for|='...'" or " asp-for|=..." @@ -239,7 +240,9 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal return false; } - attribute = CreateMarkupAttribute(name, builder, attributeValueTypes); + // If we're not after an equal then we should treat the value as if it were a minimized attribute. + var attributeValueBuilder = afterEquals ? builder : null; + attribute = CreateMarkupAttribute(name, attributeValueBuilder, attributeValueTypes); return true; } @@ -432,17 +435,24 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal IReadOnlyDictionary attributeValueTypes) { string attributeTypeName; + Span value = null; - // If the attribute was requested by the tag helper and doesn't happen to be a string then we need to treat - // its value as code. Any non-string value can be any C# value so we need to ensure the SyntaxTreeNode - // reflects that. - if (attributeValueTypes.TryGetValue(name, out attributeTypeName) && - !IsStringAttribute(attributeTypeName)) + // Builder will be null in the case of minimized attributes + if (builder != null) { - builder.Kind = SpanKind.Code; + // If the attribute was requested by the tag helper and doesn't happen to be a string then we need to treat + // its value as code. Any non-string value can be any C# value so we need to ensure the SyntaxTreeNode + // reflects that. + if (attributeValueTypes.TryGetValue(name, out attributeTypeName) && + !IsStringAttribute(attributeTypeName)) + { + builder.Kind = SpanKind.Code; + } + + value = builder.Build(); } - return new KeyValuePair(name, builder.Build()); + return new KeyValuePair(name, value); } private static bool IsNullOrWhitespaceAttributeValue(SyntaxTreeNode attributeValue)