From 94230a5a145d11b3772cd54a1ae17b036ac326ff Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Thu, 22 Jan 2015 14:41:40 -0800 Subject: [PATCH] Add TagHelper parse level opt-out character '!'. - Added the ability to opt-out of TagHelper parsing by adding a '!' to the beginning of a tag name. - Modified parsing logic to allow bangs in tags. - Bangs in tags are removed from output always and are handled as meta code. #187 --- .../Properties/Resources.Designer.cs | 28 ++++- .../Resources.resx | 61 ++++++----- .../TagHelpers/HtmlElementNameAttribute.cs | 31 +++++- .../Parser/HtmlMarkupParser.Block.cs | 103 ++++++++++++++---- .../Parser/HtmlMarkupParser.Document.cs | 96 +++++++++------- .../Parser/HtmlMarkupParser.cs | 33 ++++++ .../Parser/TokenizerBackedParser.cs | 38 +++++++ 7 files changed, 285 insertions(+), 105 deletions(-) diff --git a/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs index dc9bb027a3..33d8f46ae5 100644 --- a/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs @@ -75,19 +75,19 @@ namespace Microsoft.AspNet.Razor.Runtime } /// - /// Parameter {0} must not contain null tag names. + /// Tag name cannot be null or whitespace. /// - internal static string HtmlElementNameAttribute_AdditionalTagsCannotContainNull + internal static string HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace { - get { return GetString("HtmlElementNameAttribute_AdditionalTagsCannotContainNull"); } + get { return GetString("HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace"); } } /// - /// Parameter {0} must not contain null tag names. + /// Tag name cannot be null or whitespace. /// - internal static string FormatHtmlElementNameAttribute_AdditionalTagsCannotContainNull(object p0) + internal static string FormatHtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace() { - return string.Format(CultureInfo.CurrentCulture, GetString("HtmlElementNameAttribute_AdditionalTagsCannotContainNull"), p0); + return GetString("HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace"); } /// @@ -122,6 +122,22 @@ namespace Microsoft.AspNet.Razor.Runtime return string.Format(CultureInfo.CurrentCulture, GetString("TagHelperDescriptorResolver_EncounteredUnexpectedError"), p0, p1, p2); } + /// + /// Tag helpers cannot target element name '{0}' because it contains a '{1}' character. + /// + internal static string HtmlElementNameAttribute_InvalidElementName + { + get { return GetString("HtmlElementNameAttribute_InvalidElementName"); } + } + + /// + /// Tag helpers cannot target element name '{0}' because it contains a '{1}' character. + /// + internal static string FormatHtmlElementNameAttribute_InvalidElementName(object p0, object p1) + { + return string.Format(CultureInfo.CurrentCulture, GetString("HtmlElementNameAttribute_InvalidElementName"), p0, p1); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNet.Razor.Runtime/Resources.resx b/src/Microsoft.AspNet.Razor.Runtime/Resources.resx index 1c7a84dccb..7469e1a225 100644 --- a/src/Microsoft.AspNet.Razor.Runtime/Resources.resx +++ b/src/Microsoft.AspNet.Razor.Runtime/Resources.resx @@ -1,17 +1,17 @@ - @@ -129,8 +129,8 @@ Must call '{2}.{1}' before calling '{2}.{0}'. - - Parameter {0} must not contain null tag names. + + Tag name cannot be null or whitespace. The value cannot be null or empty. @@ -138,4 +138,7 @@ Encountered an unexpected error when attempting to resolve tag helper directive '{0}' with value '{1}'. Error: {2} + + Tag helpers cannot target element name '{0}' because it contains a '{1}' character. + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/HtmlElementNameAttribute.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/HtmlElementNameAttribute.cs index f575de2c6c..e2b0f56de6 100644 --- a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/HtmlElementNameAttribute.cs +++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/HtmlElementNameAttribute.cs @@ -19,6 +19,8 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers /// The HTML tag name for the to target. public HtmlElementNameAttribute([NotNull] string tag) { + ValidateTagName(tag, nameof(tag)); + Tags = new[] { tag }; } @@ -29,12 +31,12 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers /// Additional HTML tag names for the to target. public HtmlElementNameAttribute([NotNull] string tag, [NotNull] params string[] additionalTags) { - if (additionalTags.Contains(null)) + ValidateTagName(tag, nameof(tag)); + + foreach (var tagName in additionalTags) { - throw new ArgumentNullException( - nameof(additionalTags), - Resources.FormatHtmlElementNameAttribute_AdditionalTagsCannotContainNull(nameof(additionalTags))); - }; + ValidateTagName(tagName, nameof(additionalTags)); + } var allTags = new List(additionalTags); allTags.Add(tag); @@ -45,6 +47,23 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers /// /// An of tag names for the to target. /// - public IEnumerable Tags { get; private set; } + public IEnumerable Tags { get; } + + private static void ValidateTagName(string tagName, string parameterName) + { + if (string.IsNullOrWhiteSpace(tagName)) + { + throw new ArgumentException( + Resources.HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace, + parameterName); + } + + if (tagName.Contains('!')) + { + throw new ArgumentException( + Resources.FormatHtmlElementNameAttribute_InvalidElementName(tagName, '!'), + parameterName); + } + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Block.cs b/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Block.cs index dfb409fb38..7b1d69034f 100644 --- a/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Block.cs +++ b/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Block.cs @@ -133,7 +133,9 @@ namespace Microsoft.AspNet.Razor.Parser IDisposable tagBlockWrapper = null; try { - if (!EndOfFile && !AtSpecialTag) + var atSpecialTag = AtSpecialTag; + + if (!EndOfFile && !atSpecialTag) { // Start a Block tag. This is used to wrap things like

or etc. tagBlockWrapper = Context.StartBlock(BlockType.Tag); @@ -157,7 +159,7 @@ namespace Microsoft.AspNet.Razor.Parser } else { - complete = AfterTagStart(tagStart, tags, tagBlockWrapper); + complete = AfterTagStart(tagStart, tags, atSpecialTag, tagBlockWrapper); } } @@ -187,6 +189,7 @@ namespace Microsoft.AspNet.Razor.Parser private bool AfterTagStart(SourceLocation tagStart, Stack> tags, + bool atSpecialTag, IDisposable tagBlockWrapper) { if (!EndOfFile) @@ -197,9 +200,16 @@ namespace Microsoft.AspNet.Razor.Parser // End Tag return EndTag(tagStart, tags, tagBlockWrapper); case HtmlSymbolType.Bang: - // Comment - Accept(_bufferedOpenAngle); - return BangTag(); + // Comment, CDATA, DOCTYPE, or a parser-escaped HTML tag. + if (atSpecialTag) + { + Accept(_bufferedOpenAngle); + return BangTag(); + } + else + { + goto default; + } case HtmlSymbolType.QuestionMark: // XML PI Accept(_bufferedOpenAngle); @@ -275,30 +285,48 @@ namespace Microsoft.AspNet.Razor.Parser { // Accept "/" and move next Assert(HtmlSymbolType.ForwardSlash); - var solidus = CurrentSymbol; + var forwardSlash = CurrentSymbol; if (!NextToken()) { Accept(_bufferedOpenAngle); - Accept(solidus); + Accept(forwardSlash); return false; } else { - var tagName = String.Empty; - if (At(HtmlSymbolType.Text)) + var tagName = string.Empty; + HtmlSymbol bangSymbol = null; + + if (At(HtmlSymbolType.Bang)) + { + bangSymbol = CurrentSymbol; + + var nextSymbol = Lookahead(count: 1); + + if (nextSymbol != null && nextSymbol.Type == HtmlSymbolType.Text) + { + tagName = "!" + nextSymbol.Content; + } + } + else if (At(HtmlSymbolType.Text)) { tagName = CurrentSymbol.Content; } + var matched = RemoveTag(tags, tagName, tagStart); if (tags.Count == 0 && - String.Equals(tagName, SyntaxConstants.TextTagName, StringComparison.OrdinalIgnoreCase) && + // Note tagName may contain a '!' escape character. This ensures doesn't match here. + // tags are treated like any other escaped HTML end tag. + string.Equals(tagName, SyntaxConstants.TextTagName, StringComparison.OrdinalIgnoreCase) && matched) { - return EndTextTag(solidus, tagBlockWrapper); + return EndTextTag(forwardSlash, tagBlockWrapper); } Accept(_bufferedOpenAngle); - Accept(solidus); + Accept(forwardSlash); + + OptionalBangEscape(); AcceptUntil(HtmlSymbolType.CloseAngle); @@ -347,14 +375,22 @@ namespace Microsoft.AspNet.Razor.Parser return seenCloseAngle; } - // Special tags include > tags, IDisposable tagBlockWrapper) { - // If we're at text, it's the name, otherwise the name is "" - HtmlSymbol tagName; - if (At(HtmlSymbolType.Text)) + HtmlSymbol bangSymbol = null; + HtmlSymbol potentialTagNameSymbol; + + if (At(HtmlSymbolType.Bang)) { - tagName = CurrentSymbol; + bangSymbol = CurrentSymbol; + + potentialTagNameSymbol = Lookahead(count: 1); } else { - tagName = new HtmlSymbol(CurrentLocation, String.Empty, HtmlSymbolType.Unknown); + potentialTagNameSymbol = CurrentSymbol; + } + + HtmlSymbol tagName; + + if (potentialTagNameSymbol == null || potentialTagNameSymbol.Type != HtmlSymbolType.Text) + { + tagName = new HtmlSymbol(potentialTagNameSymbol.Start, string.Empty, HtmlSymbolType.Unknown); + } + else if (bangSymbol != null) + { + tagName = new HtmlSymbol(bangSymbol.Start, "!" + potentialTagNameSymbol.Content, HtmlSymbolType.Text); + } + else + { + tagName = potentialTagNameSymbol; } Tuple tag = Tuple.Create(tagName, _lastTagStart); - if (tags.Count == 0 && String.Equals(tag.Item1.Content, SyntaxConstants.TextTagName, StringComparison.OrdinalIgnoreCase)) + if (tags.Count == 0 && + // Note tagName may contain a '!' escape character. This ensures doesn't match here. + // tags are treated like any other escaped HTML start tag. + string.Equals(tag.Item1.Content, SyntaxConstants.TextTagName, StringComparison.OrdinalIgnoreCase)) { Output(SpanKind.Markup); Span.CodeGenerator = SpanCodeGenerator.Null; @@ -709,7 +766,9 @@ namespace Microsoft.AspNet.Razor.Parser return true; } + Accept(_bufferedOpenAngle); + OptionalBangEscape(); Optional(HtmlSymbolType.Text); return RestOfTag(tag, tags, tagBlockWrapper); } diff --git a/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Document.cs b/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Document.cs index 93da1f4c92..1d1e0a6755 100644 --- a/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Document.cs +++ b/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.Document.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; +using Microsoft.AspNet.Razor.Generator; using Microsoft.AspNet.Razor.Parser.SyntaxTree; using Microsoft.AspNet.Razor.Tokenizer.Symbols; @@ -42,58 +43,69 @@ namespace Microsoft.AspNet.Razor.Parser { if (NextIs(HtmlSymbolType.Bang)) { - AcceptAndMoveNext(); // Accept '<' - BangTag(); + // Checking to see if we meet the conditions of a special '!' tag: ' or '

' etc. + Optional(HtmlSymbolType.ForwardSlash); - // Start tag block - var tagBlock = Context.StartBlock(BlockType.Tag); - - AcceptAndMoveNext(); // Accept '<' - - if (!At(HtmlSymbolType.ForwardSlash)) - { - // Parsing a start tag - var scriptTag = At(HtmlSymbolType.Text) && - string.Equals(CurrentSymbol.Content, "script", StringComparison.OrdinalIgnoreCase); - Optional(HtmlSymbolType.Text); - TagContent(); // Parse the tag, don't care about the content - Optional(HtmlSymbolType.ForwardSlash); - Optional(HtmlSymbolType.CloseAngle); - - if (scriptTag) - { - Output(SpanKind.Markup); - tagBlock.Dispose(); - - SkipToEndScriptAndParseCode(); - return; - } - } - else - { - // Parsing an end tag - // This section can accept things like: '

' or '

' etc. - Optional(HtmlSymbolType.ForwardSlash); - // Whitespace here is invalid (according to the spec) - Optional(HtmlSymbolType.Text); - AcceptAll(HtmlSymbolType.WhiteSpace); - Optional(HtmlSymbolType.CloseAngle); - } - - Output(SpanKind.Markup); - - // End tag block - tagBlock.Dispose(); + // Whitespace here is invalid (according to the spec) + OptionalBangEscape(); + Optional(HtmlSymbolType.Text); + AcceptAll(HtmlSymbolType.WhiteSpace); + Optional(HtmlSymbolType.CloseAngle); } + + Output(SpanKind.Markup); + + // End tag block + tagBlock.Dispose(); } } } diff --git a/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.cs b/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.cs index 71b40efcda..626e6aa55c 100644 --- a/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.cs +++ b/src/Microsoft.AspNet.Razor/Parser/HtmlMarkupParser.cs @@ -184,5 +184,38 @@ namespace Microsoft.AspNet.Razor.Parser Initialize(Span); NextToken(); } + + private bool IsBangEscape(int lookahead) + { + var potentialBang = Lookahead(lookahead); + + if (potentialBang != null && + potentialBang.Type == HtmlSymbolType.Bang) + { + var afterBang = Lookahead(lookahead + 1); + + return afterBang != null && + afterBang.Type == HtmlSymbolType.Text && + !string.Equals(afterBang.Content, "DOCTYPE", StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + private void OptionalBangEscape() + { + if (IsBangEscape(lookahead: 0)) + { + Output(SpanKind.Markup); + + // Accept the parser escape character '!'. + Assert(HtmlSymbolType.Bang); + AcceptAndMoveNext(); + + // Setup the metacode span that we will be outputing. + Span.CodeGenerator = SpanCodeGenerator.Null; + Output(SpanKind.MetaCode, AcceptedCharacters.None); + } + } } } diff --git a/src/Microsoft.AspNet.Razor/Parser/TokenizerBackedParser.cs b/src/Microsoft.AspNet.Razor/Parser/TokenizerBackedParser.cs index ec100b6aac..288d91d355 100644 --- a/src/Microsoft.AspNet.Razor/Parser/TokenizerBackedParser.cs +++ b/src/Microsoft.AspNet.Razor/Parser/TokenizerBackedParser.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; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNet.Razor.Parser.SyntaxTree; using Microsoft.AspNet.Razor.Text; @@ -75,6 +76,43 @@ namespace Microsoft.AspNet.Razor.Parser } } + protected TSymbol Lookahead(int count) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + else if (count == 0) + { + return CurrentSymbol; + } + + // We add 1 in order to store the current symbol. + var symbols = new TSymbol[count + 1]; + var currentSymbol = CurrentSymbol; + + symbols[0] = currentSymbol; + + // We need to look forward "count" many times. + for (var i = 1; i <= count; i++) + { + NextToken(); + symbols[i] = CurrentSymbol; + } + + // Restore Tokenizer's location to where it was pointing before the look-ahead. + for (var i = count; i >= 0; i--) + { + PutBack(symbols[i]); + } + + // The PutBacks above will set CurrentSymbol to null. EnsureCurrent will set our CurrentSymbol to the + // next symbol. + EnsureCurrent(); + + return symbols[count]; + } + protected internal bool NextToken() { PreviousSymbol = CurrentSymbol;