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>
/// Parameter {0} must not contain null tag names.
/// Tag name cannot be null or whitespace.
/// </summary>
internal static string HtmlElementNameAttribute_AdditionalTagsCannotContainNull
internal static string HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace
{
get { return GetString("HtmlElementNameAttribute_AdditionalTagsCannotContainNull"); }
get { return GetString("HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace"); }
}
/// <summary>
/// Parameter {0} must not contain null tag names.
/// Tag name cannot be null or whitespace.
/// </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>
@ -122,6 +122,22 @@ namespace Microsoft.AspNet.Razor.Runtime
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)
{
var value = _resourceManager.GetString(name);

View File

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
@ -129,8 +129,8 @@
<data name="ScopeManager_EndCannotBeCalledWithoutACallToBegin" xml:space="preserve">
<value>Must call '{2}.{1}' before calling '{2}.{0}'.</value>
</data>
<data name="HtmlElementNameAttribute_AdditionalTagsCannotContainNull" xml:space="preserve">
<value>Parameter {0} must not contain null tag names.</value>
<data name="HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace" xml:space="preserve">
<value>Tag name cannot be null or whitespace.</value>
</data>
<data name="ArgumentCannotBeNullOrEmpty" xml:space="preserve">
<value>The value cannot be null or empty.</value>
@ -138,4 +138,7 @@
<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>
</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>

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>
public HtmlElementNameAttribute([NotNull] string tag)
{
ValidateTagName(tag, nameof(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>
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<string>(additionalTags);
allTags.Add(tag);
@ -45,6 +47,23 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
/// <summary>
/// An <see cref="IEnumerable{string}"/> of tag names for the <see cref="TagHelper"/> to target.
/// </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;
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.
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<Tuple<HtmlSymbol, SourceLocation>> 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 </!text> doesn't match here.
// </!text> 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 <! and <? tags
// Special tags include <!--, <!DOCTYPE, <![CDATA and <? tags
private bool AtSpecialTag
{
get
{
return (At(HtmlSymbolType.OpenAngle) &&
(NextIs(HtmlSymbolType.Bang) ||
NextIs(HtmlSymbolType.QuestionMark)));
if (At(HtmlSymbolType.OpenAngle))
{
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)
{
// 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<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);
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);
}

View File

@ -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: <!DOCTYPE, <![CDATA[, <!--.
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))
{
AcceptAndMoveNext(); // Accept '<'
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
{
Output(SpanKind.Markup);
// Parsing an end tag
// This section can accept things like: '</p >' or '</p>' 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: '</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();
// 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();
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -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;