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
This commit is contained in:
N. Taylor Mullen 2015-01-22 14:41:40 -08:00
parent 32f0858e8f
commit 94230a5a14
7 changed files with 285 additions and 105 deletions

View File

@ -75,19 +75,19 @@ namespace Microsoft.AspNet.Razor.Runtime
} }
/// <summary> /// <summary>
/// Parameter {0} must not contain null tag names. /// Tag name cannot be null or whitespace.
/// </summary> /// </summary>
internal static string HtmlElementNameAttribute_AdditionalTagsCannotContainNull internal static string HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace
{ {
get { return GetString("HtmlElementNameAttribute_AdditionalTagsCannotContainNull"); } get { return GetString("HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace"); }
} }
/// <summary> /// <summary>
/// Parameter {0} must not contain null tag names. /// Tag name cannot be null or whitespace.
/// </summary> /// </summary>
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");
} }
/// <summary> /// <summary>
@ -122,6 +122,22 @@ namespace Microsoft.AspNet.Razor.Runtime
return string.Format(CultureInfo.CurrentCulture, GetString("TagHelperDescriptorResolver_EncounteredUnexpectedError"), p0, p1, p2); return string.Format(CultureInfo.CurrentCulture, GetString("TagHelperDescriptorResolver_EncounteredUnexpectedError"), p0, p1, p2);
} }
/// <summary>
/// Tag helpers cannot target element name '{0}' because it contains a '{1}' character.
/// </summary>
internal static string HtmlElementNameAttribute_InvalidElementName
{
get { return GetString("HtmlElementNameAttribute_InvalidElementName"); }
}
/// <summary>
/// Tag helpers cannot target element name '{0}' because it contains a '{1}' character.
/// </summary>
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) private static string GetString(string name, params string[] formatterNames)
{ {
var value = _resourceManager.GetString(name); var value = _resourceManager.GetString(name);

View File

@ -129,8 +129,8 @@
<data name="ScopeManager_EndCannotBeCalledWithoutACallToBegin" xml:space="preserve"> <data name="ScopeManager_EndCannotBeCalledWithoutACallToBegin" xml:space="preserve">
<value>Must call '{2}.{1}' before calling '{2}.{0}'.</value> <value>Must call '{2}.{1}' before calling '{2}.{0}'.</value>
</data> </data>
<data name="HtmlElementNameAttribute_AdditionalTagsCannotContainNull" xml:space="preserve"> <data name="HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace" xml:space="preserve">
<value>Parameter {0} must not contain null tag names.</value> <value>Tag name cannot be null or whitespace.</value>
</data> </data>
<data name="ArgumentCannotBeNullOrEmpty" xml:space="preserve"> <data name="ArgumentCannotBeNullOrEmpty" xml:space="preserve">
<value>The value cannot be null or empty.</value> <value>The value cannot be null or empty.</value>
@ -138,4 +138,7 @@
<data name="TagHelperDescriptorResolver_EncounteredUnexpectedError" xml:space="preserve"> <data name="TagHelperDescriptorResolver_EncounteredUnexpectedError" xml:space="preserve">
<value>Encountered an unexpected error when attempting to resolve tag helper directive '{0}' with value '{1}'. Error: {2}</value> <value>Encountered an unexpected error when attempting to resolve tag helper directive '{0}' with value '{1}'. Error: {2}</value>
</data> </data>
<data name="HtmlElementNameAttribute_InvalidElementName" xml:space="preserve">
<value>Tag helpers cannot target element name '{0}' because it contains a '{1}' character.</value>
</data>
</root> </root>

View File

@ -19,6 +19,8 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
/// <param name="tag">The HTML tag name for the <see cref="TagHelper"/> to target.</param> /// <param name="tag">The HTML tag name for the <see cref="TagHelper"/> to target.</param>
public HtmlElementNameAttribute([NotNull] string tag) public HtmlElementNameAttribute([NotNull] string tag)
{ {
ValidateTagName(tag, nameof(tag));
Tags = new[] { tag }; Tags = new[] { tag };
} }
@ -29,12 +31,12 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
/// <param name="additionalTags">Additional HTML tag names for the <see cref="TagHelper"/> to target.</param> /// <param name="additionalTags">Additional HTML tag names for the <see cref="TagHelper"/> to target.</param>
public HtmlElementNameAttribute([NotNull] string tag, [NotNull] params string[] additionalTags) 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( ValidateTagName(tagName, nameof(additionalTags));
nameof(additionalTags), }
Resources.FormatHtmlElementNameAttribute_AdditionalTagsCannotContainNull(nameof(additionalTags)));
};
var allTags = new List<string>(additionalTags); var allTags = new List<string>(additionalTags);
allTags.Add(tag); allTags.Add(tag);
@ -45,6 +47,23 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
/// <summary> /// <summary>
/// An <see cref="IEnumerable{string}"/> of tag names for the <see cref="TagHelper"/> to target. /// An <see cref="IEnumerable{string}"/> of tag names for the <see cref="TagHelper"/> to target.
/// </summary> /// </summary>
public IEnumerable<string> Tags { get; private set; } public IEnumerable<string> 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);
}
}
} }
} }

View File

@ -133,7 +133,9 @@ namespace Microsoft.AspNet.Razor.Parser
IDisposable tagBlockWrapper = null; IDisposable tagBlockWrapper = null;
try try
{ {
if (!EndOfFile && !AtSpecialTag) var atSpecialTag = AtSpecialTag;
if (!EndOfFile && !atSpecialTag)
{ {
// Start a Block tag. This is used to wrap things like <p> or <a class="btn"> etc. // Start a Block tag. This is used to wrap things like <p> or <a class="btn"> etc.
tagBlockWrapper = Context.StartBlock(BlockType.Tag); tagBlockWrapper = Context.StartBlock(BlockType.Tag);
@ -157,7 +159,7 @@ namespace Microsoft.AspNet.Razor.Parser
} }
else 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, private bool AfterTagStart(SourceLocation tagStart,
Stack<Tuple<HtmlSymbol, SourceLocation>> tags, Stack<Tuple<HtmlSymbol, SourceLocation>> tags,
bool atSpecialTag,
IDisposable tagBlockWrapper) IDisposable tagBlockWrapper)
{ {
if (!EndOfFile) if (!EndOfFile)
@ -197,9 +200,16 @@ namespace Microsoft.AspNet.Razor.Parser
// End Tag // End Tag
return EndTag(tagStart, tags, tagBlockWrapper); return EndTag(tagStart, tags, tagBlockWrapper);
case HtmlSymbolType.Bang: case HtmlSymbolType.Bang:
// Comment // Comment, CDATA, DOCTYPE, or a parser-escaped HTML tag.
Accept(_bufferedOpenAngle); if (atSpecialTag)
return BangTag(); {
Accept(_bufferedOpenAngle);
return BangTag();
}
else
{
goto default;
}
case HtmlSymbolType.QuestionMark: case HtmlSymbolType.QuestionMark:
// XML PI // XML PI
Accept(_bufferedOpenAngle); Accept(_bufferedOpenAngle);
@ -275,30 +285,48 @@ namespace Microsoft.AspNet.Razor.Parser
{ {
// Accept "/" and move next // Accept "/" and move next
Assert(HtmlSymbolType.ForwardSlash); Assert(HtmlSymbolType.ForwardSlash);
var solidus = CurrentSymbol; var forwardSlash = CurrentSymbol;
if (!NextToken()) if (!NextToken())
{ {
Accept(_bufferedOpenAngle); Accept(_bufferedOpenAngle);
Accept(solidus); Accept(forwardSlash);
return false; return false;
} }
else else
{ {
var tagName = String.Empty; var tagName = string.Empty;
if (At(HtmlSymbolType.Text)) 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; tagName = CurrentSymbol.Content;
} }
var matched = RemoveTag(tags, tagName, tagStart); var matched = RemoveTag(tags, tagName, tagStart);
if (tags.Count == 0 && if (tags.Count == 0 &&
String.Equals(tagName, SyntaxConstants.TextTagName, StringComparison.OrdinalIgnoreCase) && // Note tagName may contain a '!' escape character. This ensures </!text> doesn't match here.
// </!text> tags are treated like any other escaped HTML end tag.
string.Equals(tagName, SyntaxConstants.TextTagName, StringComparison.OrdinalIgnoreCase) &&
matched) matched)
{ {
return EndTextTag(solidus, tagBlockWrapper); return EndTextTag(forwardSlash, tagBlockWrapper);
} }
Accept(_bufferedOpenAngle); Accept(_bufferedOpenAngle);
Accept(solidus); Accept(forwardSlash);
OptionalBangEscape();
AcceptUntil(HtmlSymbolType.CloseAngle); AcceptUntil(HtmlSymbolType.CloseAngle);
@ -347,14 +375,22 @@ namespace Microsoft.AspNet.Razor.Parser
return seenCloseAngle; return seenCloseAngle;
} }
// Special tags include <! and <? tags // Special tags include <!--, <!DOCTYPE, <![CDATA and <? tags
private bool AtSpecialTag private bool AtSpecialTag
{ {
get get
{ {
return (At(HtmlSymbolType.OpenAngle) && if (At(HtmlSymbolType.OpenAngle))
(NextIs(HtmlSymbolType.Bang) || {
NextIs(HtmlSymbolType.QuestionMark))); if (NextIs(HtmlSymbolType.Bang))
{
return !IsBangEscape(lookahead: 1);
}
return NextIs(HtmlSymbolType.QuestionMark);
}
return false;
} }
} }
@ -651,20 +687,41 @@ namespace Microsoft.AspNet.Razor.Parser
private bool StartTag(Stack<Tuple<HtmlSymbol, SourceLocation>> tags, IDisposable tagBlockWrapper) private bool StartTag(Stack<Tuple<HtmlSymbol, SourceLocation>> tags, IDisposable tagBlockWrapper)
{ {
// If we're at text, it's the name, otherwise the name is "" HtmlSymbol bangSymbol = null;
HtmlSymbol tagName; HtmlSymbol potentialTagNameSymbol;
if (At(HtmlSymbolType.Text))
if (At(HtmlSymbolType.Bang))
{ {
tagName = CurrentSymbol; bangSymbol = CurrentSymbol;
potentialTagNameSymbol = Lookahead(count: 1);
} }
else 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<HtmlSymbol, SourceLocation> tag = Tuple.Create(tagName, _lastTagStart); Tuple<HtmlSymbol, SourceLocation> 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 <!text> doesn't match here.
// <!text> tags are treated like any other escaped HTML start tag.
string.Equals(tag.Item1.Content, SyntaxConstants.TextTagName, StringComparison.OrdinalIgnoreCase))
{ {
Output(SpanKind.Markup); Output(SpanKind.Markup);
Span.CodeGenerator = SpanCodeGenerator.Null; Span.CodeGenerator = SpanCodeGenerator.Null;
@ -709,7 +766,9 @@ namespace Microsoft.AspNet.Razor.Parser
return true; return true;
} }
Accept(_bufferedOpenAngle); Accept(_bufferedOpenAngle);
OptionalBangEscape();
Optional(HtmlSymbolType.Text); Optional(HtmlSymbolType.Text);
return RestOfTag(tag, tags, tagBlockWrapper); return RestOfTag(tag, tags, tagBlockWrapper);
} }

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using Microsoft.AspNet.Razor.Generator;
using Microsoft.AspNet.Razor.Parser.SyntaxTree; using Microsoft.AspNet.Razor.Parser.SyntaxTree;
using Microsoft.AspNet.Razor.Tokenizer.Symbols; using Microsoft.AspNet.Razor.Tokenizer.Symbols;
@ -42,58 +43,69 @@ namespace Microsoft.AspNet.Razor.Parser
{ {
if (NextIs(HtmlSymbolType.Bang)) if (NextIs(HtmlSymbolType.Bang))
{ {
AcceptAndMoveNext(); // Accept '<' // Checking to see if we meet the conditions of a special '!' tag: <!DOCTYPE, <![CDATA[, <!--.
BangTag(); if (!IsBangEscape(lookahead: 1))
{
AcceptAndMoveNext(); // Accept '<'
BangTag();
return;
}
// We should behave like a normal tag that has a parser escape, fall through to the normal
// tag logic.
} }
else if (NextIs(HtmlSymbolType.QuestionMark)) else if (NextIs(HtmlSymbolType.QuestionMark))
{ {
AcceptAndMoveNext(); // Accept '<' AcceptAndMoveNext(); // Accept '<'
XmlPI(); XmlPI();
return;
}
Output(SpanKind.Markup);
// Start tag block
var tagBlock = Context.StartBlock(BlockType.Tag);
AcceptAndMoveNext(); // Accept '<'
if (!At(HtmlSymbolType.ForwardSlash))
{
OptionalBangEscape();
// 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 else
{ {
Output(SpanKind.Markup); // Parsing an end tag
// This section can accept things like: '</p >' or '</p>' etc.
Optional(HtmlSymbolType.ForwardSlash);
// Start tag block // Whitespace here is invalid (according to the spec)
var tagBlock = Context.StartBlock(BlockType.Tag); OptionalBangEscape();
Optional(HtmlSymbolType.Text);
AcceptAndMoveNext(); // Accept '<' AcceptAll(HtmlSymbolType.WhiteSpace);
Optional(HtmlSymbolType.CloseAngle);
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: '</p >' or '</p>' 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();
} }
Output(SpanKind.Markup);
// End tag block
tagBlock.Dispose();
} }
} }
} }

View File

@ -184,5 +184,38 @@ namespace Microsoft.AspNet.Razor.Parser
Initialize(Span); Initialize(Span);
NextToken(); 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);
}
}
} }
} }

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNet.Razor.Parser.SyntaxTree; using Microsoft.AspNet.Razor.Parser.SyntaxTree;
using Microsoft.AspNet.Razor.Text; 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() protected internal bool NextToken()
{ {
PreviousSymbol = CurrentSymbol; PreviousSymbol = CurrentSymbol;