Add TagHelper attribute targeting.

- Transitioned HtmlElementNameAttribute into a more generic TargetElementAttribute. Targeting an HTML element can be done by attribute, tag or both.
- Updated TagHelperDescriptor to track required attributes.
- Updated TagHelperProvider to ask for provided attributes when resolving TagHelperDescriptors, this is used to apply RequiredAttributes.
- Updated TagHelperParseTreeRewriter to properly track HTML elements that coincide with a TagHelper scope based on the presence of RequiredAttributes.

#311
This commit is contained in:
N. Taylor Mullen 2015-03-01 16:26:18 -08:00
parent b3c60976a4
commit d22246f636
11 changed files with 505 additions and 198 deletions

View File

@ -75,19 +75,19 @@ namespace Microsoft.AspNet.Razor.Runtime
}
/// <summary>
/// Tag name cannot be null or whitespace.
/// {0} name cannot be null or whitespace.
/// </summary>
internal static string HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace
internal static string TargetElementAttribute_NameCannotBeNullOrWhitespace
{
get { return GetString("HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace"); }
get { return GetString("TargetElementAttribute_NameCannotBeNullOrWhitespace"); }
}
/// <summary>
/// Tag name cannot be null or whitespace.
/// {0} name cannot be null or whitespace.
/// </summary>
internal static string FormatHtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace()
internal static string FormatTargetElementAttribute_NameCannotBeNullOrWhitespace(object p0)
{
return GetString("HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace");
return string.Format(CultureInfo.CurrentCulture, GetString("TargetElementAttribute_NameCannotBeNullOrWhitespace"), p0);
}
/// <summary>
@ -123,19 +123,19 @@ namespace Microsoft.AspNet.Razor.Runtime
}
/// <summary>
/// Tag helpers cannot target element name '{0}' because it contains a '{1}' character.
/// Tag helpers cannot target {0} name '{1}' because it contains a '{2}' character.
/// </summary>
internal static string HtmlElementNameAttribute_InvalidElementName
internal static string TargetElementAttribute_InvalidName
{
get { return GetString("HtmlElementNameAttribute_InvalidElementName"); }
get { return GetString("TargetElementAttribute_InvalidName"); }
}
/// <summary>
/// Tag helpers cannot target element name '{0}' because it contains a '{1}' character.
/// Tag helpers cannot target {0} name '{1}' because it contains a '{2}' character.
/// </summary>
internal static string FormatHtmlElementNameAttribute_InvalidElementName(object p0, object p1)
internal static string FormatTargetElementAttribute_InvalidName(object p0, object p1, object p2)
{
return string.Format(CultureInfo.CurrentCulture, GetString("HtmlElementNameAttribute_InvalidElementName"), p0, p1);
return string.Format(CultureInfo.CurrentCulture, GetString("TargetElementAttribute_InvalidName"), p0, p1, p2);
}
/// <summary>
@ -170,6 +170,38 @@ namespace Microsoft.AspNet.Razor.Runtime
return string.Format(CultureInfo.CurrentCulture, GetString("TagHelperDescriptorResolver_InvalidTagHelperPrefixValue"), p0, p1, p2);
}
/// <summary>
/// Attribute
/// </summary>
internal static string TagHelperDescriptorFactory_Attribute
{
get { return GetString("TagHelperDescriptorFactory_Attribute"); }
}
/// <summary>
/// Attribute
/// </summary>
internal static string FormatTagHelperDescriptorFactory_Attribute()
{
return GetString("TagHelperDescriptorFactory_Attribute");
}
/// <summary>
/// Tag
/// </summary>
internal static string TagHelperDescriptorFactory_Tag
{
get { return GetString("TagHelperDescriptorFactory_Tag"); }
}
/// <summary>
/// Tag
/// </summary>
internal static string FormatTagHelperDescriptorFactory_Tag()
{
return GetString("TagHelperDescriptorFactory_Tag");
}
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);

View File

@ -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_ElementNameCannotBeNullOrWhitespace" xml:space="preserve">
<value>Tag name cannot be null or whitespace.</value>
<data name="TargetElementAttribute_NameCannotBeNullOrWhitespace" xml:space="preserve">
<value>{0} name cannot be null or whitespace.</value>
</data>
<data name="ArgumentCannotBeNullOrEmpty" xml:space="preserve">
<value>The value cannot be null or empty.</value>
@ -138,8 +138,8 @@
<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 name="TargetElementAttribute_InvalidName" xml:space="preserve">
<value>Tag helpers cannot target {0} name '{1}' because it contains a '{2}' character.</value>
</data>
<data name="TagHelperDescriptorResolver_InvalidTagHelperDirective" xml:space="preserve">
<value>Invalid tag helper directive '{0}'. Cannot have multiple '{0}' directives on a page.</value>
@ -147,4 +147,10 @@
<data name="TagHelperDescriptorResolver_InvalidTagHelperPrefixValue" xml:space="preserve">
<value>Invalid tag helper directive '{0}' value. '{1} is not allowed in prefix '{2}'.</value>
</data>
<data name="TagHelperDescriptorFactory_Attribute" xml:space="preserve">
<value>Attribute</value>
</data>
<data name="TagHelperDescriptorFactory_Tag" xml:space="preserve">
<value>Tag</value>
</data>
</root>

View File

@ -1,70 +0,0 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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.Collections.Generic;
using System.Linq;
using Microsoft.Framework.Internal;
namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
{
/// <summary>
/// Used to override a <see cref="ITagHelper"/>'s default tag name target.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class HtmlElementNameAttribute : Attribute
{
/// <summary>
/// Instantiates a new instance of the <see cref="HtmlElementNameAttribute"/> class.
/// </summary>
/// <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 };
}
/// <summary>
/// Instantiates a new instance of the <see cref="HtmlElementNameAttribute"/> class.
/// </summary>
/// <param name="tag">The HTML tag name 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)
{
ValidateTagName(tag, nameof(tag));
foreach (var tagName in additionalTags)
{
ValidateTagName(tagName, nameof(additionalTags));
}
var allTags = new List<string>(additionalTags);
allTags.Add(tag);
Tags = allTags;
}
/// <summary>
/// An <see cref="IEnumerable{string}"/> of tag names for the <see cref="TagHelper"/> to target.
/// </summary>
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

@ -6,7 +6,10 @@ using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.AspNet.Razor.Parser;
using Microsoft.AspNet.Razor.TagHelpers;
using Microsoft.AspNet.Razor.Text;
using Microsoft.Framework.Internal;
namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
{
@ -28,34 +31,52 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
// TODO: Investigate if we should cache TagHelperDescriptors for types:
// https://github.com/aspnet/Razor/issues/165
public static ICollection<char> InvalidNonWhitespaceNameCharacters { get; } = new HashSet<char>(
new[] { '@', '!', '<', '/', '?', '[', '>', ']', '=', '"', '\'' });
/// <summary>
/// Creates a <see cref="TagHelperDescriptor"/> from the given <paramref name="type"/>.
/// </summary>
/// <param name="assemblyName">The assembly name that contains <paramref name="type"/>.</param>
/// <param name="type">The type to create a <see cref="TagHelperDescriptor"/> from.</param>
/// <returns>A <see cref="TagHelperDescriptor"/> that describes the given <paramref name="type"/>.</returns>
public static IEnumerable<TagHelperDescriptor> CreateDescriptors(string assemblyName, Type type)
public static IEnumerable<TagHelperDescriptor> CreateDescriptors(
string assemblyName,
[NotNull] Type type,
[NotNull] ParserErrorSink errorSink)
{
var tagNames = GetTagNames(type);
var typeName = type.FullName;
var typeInfo = type.GetTypeInfo();
var attributeDescriptors = GetAttributeDescriptors(type);
var targetElementAttributes = GetValidTargetElementAttributes(typeInfo, errorSink);
var tagHelperDescriptors =
BuildTagHelperDescriptors(
typeInfo,
assemblyName,
attributeDescriptors,
targetElementAttributes);
return tagNames.Select(tagName =>
new TagHelperDescriptor(
prefix: string.Empty,
tagName: tagName,
typeName: typeName,
assemblyName: assemblyName,
attributes: attributeDescriptors));
return tagHelperDescriptors.Distinct(TagHelperDescriptorComparer.Default);
}
private static IEnumerable<string> GetTagNames(Type tagHelperType)
private static IEnumerable<TargetElementAttribute> GetValidTargetElementAttributes(
TypeInfo typeInfo,
ParserErrorSink errorSink)
{
var typeInfo = tagHelperType.GetTypeInfo();
var attributes = typeInfo.GetCustomAttributes<HtmlElementNameAttribute>(inherit: false);
var targetElementAttributes = typeInfo.GetCustomAttributes<TargetElementAttribute>(inherit: false);
return targetElementAttributes.Where(attribute => ValidTargetElementAttributeNames(attribute, errorSink));
}
private static IEnumerable<TagHelperDescriptor> BuildTagHelperDescriptors(
TypeInfo typeInfo,
string assemblyName,
IEnumerable<TagHelperAttributeDescriptor> attributeDescriptors,
IEnumerable<TargetElementAttribute> targetElementAttributes)
{
var typeName = typeInfo.FullName;
// If there isn't an attribute specifying the tag name derive it from the name
if (!attributes.Any())
if (!targetElementAttributes.Any())
{
var name = typeInfo.Name;
@ -64,11 +85,122 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
name = name.Substring(0, name.Length - TagHelperNameEnding.Length);
}
return new[] { ToHtmlCase(name) };
return new[]
{
BuildTagHelperDescriptor(
ToHtmlCase(name),
typeName,
assemblyName,
attributeDescriptors,
requiredAttributes: Enumerable.Empty<string>())
};
}
// Remove duplicate tag names.
return attributes.SelectMany(attribute => attribute.Tags).Distinct();
return targetElementAttributes.Select(
attribute => BuildTagHelperDescriptor(typeName, assemblyName, attributeDescriptors, attribute));
}
private static TagHelperDescriptor BuildTagHelperDescriptor(
string typeName,
string assemblyName,
IEnumerable<TagHelperAttributeDescriptor> attributeDescriptors,
TargetElementAttribute targetElementAttribute)
{
var requiredAttributes = GetCommaSeparatedValues(targetElementAttribute.Attributes);
return BuildTagHelperDescriptor(
targetElementAttribute.Tag,
typeName,
assemblyName,
attributeDescriptors,
requiredAttributes);
}
private static TagHelperDescriptor BuildTagHelperDescriptor(
string tagName,
string typeName,
string assemblyName,
IEnumerable<TagHelperAttributeDescriptor> attributeDescriptors,
IEnumerable<string> requiredAttributes)
{
return new TagHelperDescriptor(
prefix: string.Empty,
tagName: tagName,
typeName: typeName,
assemblyName: assemblyName,
attributes: attributeDescriptors,
requiredAttributes: requiredAttributes);
}
/// <summary>
/// Internal for testing.
/// </summary>
internal static IEnumerable<string> GetCommaSeparatedValues(string text)
{
// We don't want to remove empty entries, need to notify users of invalid values.
return text?.Split(',').Select(tagName => tagName.Trim()) ?? Enumerable.Empty<string>();
}
/// <summary>
/// Internal for testing.
/// </summary>
internal static bool ValidTargetElementAttributeNames(
TargetElementAttribute attribute,
ParserErrorSink errorSink)
{
var validTagName = ValidateName(attribute.Tag, targetingAttributes: false, errorSink: errorSink);
var validAttributeNames = true;
var attributeNames = GetCommaSeparatedValues(attribute.Attributes);
foreach (var attributeName in attributeNames)
{
if (!ValidateName(attributeName, targetingAttributes: true, errorSink: errorSink))
{
validAttributeNames = false;
}
}
return validTagName && validAttributeNames;
}
private static bool ValidateName(
string name,
bool targetingAttributes,
ParserErrorSink errorSink)
{
var targetName = targetingAttributes ?
Resources.TagHelperDescriptorFactory_Attribute :
Resources.TagHelperDescriptorFactory_Tag;
var validName = true;
if (string.IsNullOrWhiteSpace(name))
{
errorSink.OnError(
SourceLocation.Zero,
Resources.FormatTargetElementAttribute_NameCannotBeNullOrWhitespace(targetName));
validName = false;
}
else
{
foreach (var character in name)
{
if (char.IsWhiteSpace(character) ||
InvalidNonWhitespaceNameCharacters.Contains(character))
{
errorSink.OnError(
SourceLocation.Zero,
Resources.FormatTargetElementAttribute_InvalidName(
targetName.ToLower(),
name,
character));
validName = false;
}
}
}
return validName;
}
private static IEnumerable<TagHelperAttributeDescriptor> GetAttributeDescriptors(Type type)

View File

@ -25,8 +25,6 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
{ TagHelperDirectiveType.RemoveTagHelper, SyntaxConstants.CSharp.RemoveTagHelperKeyword },
{ TagHelperDirectiveType.TagHelperPrefix, SyntaxConstants.CSharp.TagHelperPrefixKeyword },
};
private static readonly HashSet<char> InvalidNonWhitespacePrefixCharacters =
new HashSet<char>(new[] { '@', '!', '<', '!', '/', '?', '[', '>', ']', '=', '"', '\'' });
private readonly TagHelperTypeResolver _typeResolver;
@ -131,7 +129,7 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
// Convert types to TagHelperDescriptors
var descriptors = tagHelperTypes.SelectMany(
type => TagHelperDescriptorFactory.CreateDescriptors(assemblyName, type));
type => TagHelperDescriptorFactory.CreateDescriptors(assemblyName, type, errorSink));
return descriptors;
}
@ -150,7 +148,8 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
descriptor.TagName,
descriptor.TypeName,
descriptor.AssemblyName,
descriptor.Attributes));
descriptor.Attributes,
descriptor.RequiredAttributes));
}
return descriptors;
@ -198,7 +197,7 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
{
// Prefixes are correlated with tag names, tag names cannot have whitespace.
if (char.IsWhiteSpace(character) ||
InvalidNonWhitespacePrefixCharacters.Contains(character))
TagHelperDescriptorFactory.InvalidNonWhitespaceNameCharacters.Contains(character))
{
errorSink.OnError(
directiveLocation,

View File

@ -0,0 +1,54 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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.AspNet.Razor.TagHelpers;
namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
{
/// <summary>
/// Provides an <see cref="ITagHelper"/>'s target.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class TargetElementAttribute : Attribute
{
public const string CatchAllDescriptorTarget = TagHelperDescriptorProvider.CatchAllDescriptorTarget;
/// <summary>
/// Instantiates a new instance of the <see cref="TargetElementAttribute"/> class with <see cref="Tag"/>
/// set to <c>*</c>.
/// </summary>
/// <remarks>A <c>*</c> <see cref="Tag"/> value indicates an <see cref="ITagHelper"/>
/// that targets all HTML elements with the required <see cref="Attributes"/>.</remarks>
public TargetElementAttribute()
: this(CatchAllDescriptorTarget)
{
}
/// <summary>
/// Instantiates a new instance of the <see cref="TargetElementAttribute"/> class.
/// </summary>
/// <param name="tag">
/// The HTML tag the <see cref="ITagHelper"/> targets.
/// </param>
/// <remarks>A <c>*</c> <paramref name="tag"/> value indicates an <see cref="ITagHelper"/>
/// that targets all HTML elements with the required <see cref="Attributes"/>.</remarks>
public TargetElementAttribute(string tag)
{
Tag = tag;
}
/// <summary>
/// The HTML tag the <see cref="ITagHelper"/> targets.
/// </summary>
/// <remarks>A <c>*</c> <see cref="Tag"/> value indicates an <see cref="ITagHelper"/>
/// that targets all HTML elements with the required <see cref="Attributes"/>.</remarks>
public string Tag { get; }
/// <summary>
/// A comma-separated <see cref="string"/> of attributes the HTML element must contain for the
/// <see cref="ITagHelper"/> to run.
/// </summary>
public string Attributes { get; set; }
}
}

View File

@ -224,9 +224,16 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
if (name == null)
{
errorSink.OnError(span.Start,
RazorResources.TagHelperBlockRewriter_TagHelperAttributesMustBeWelformed,
span.Content.Length);
// We couldn't find a name, if the original span content was whitespace it ultimately means the tag
// that owns this "attribute" is malformed and is expecting a user to type a new attribute.
// ex: <myTH class="btn"| |
if (!string.IsNullOrWhiteSpace(span.Content))
{
errorSink.OnError(
span.Start,
RazorResources.TagHelperBlockRewriter_TagHelperAttributesMustBeWelformed,
span.Content.Length);
}
attribute = default(KeyValuePair<string, SyntaxTreeNode>);

View File

@ -14,14 +14,14 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
public class TagHelperParseTreeRewriter : ISyntaxTreeRewriter
{
private TagHelperDescriptorProvider _provider;
private Stack<TagHelperBlockBuilder> _tagStack;
private Stack<TagHelperBlockTracker> _trackerStack;
private Stack<BlockBuilder> _blockStack;
private BlockBuilder _currentBlock;
public TagHelperParseTreeRewriter(TagHelperDescriptorProvider provider)
{
_provider = provider;
_tagStack = new Stack<TagHelperBlockBuilder>();
_trackerStack = new Stack<TagHelperBlockTracker>();
_blockStack = new Stack<BlockBuilder>();
}
@ -41,7 +41,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
CodeGenerator = input.CodeGenerator
});
var activeTagHelpers = _tagStack.Count;
var activeTagHelpers = _trackerStack.Count;
foreach (var child in input.Children)
{
@ -76,14 +76,14 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
// We captured the number of active tag helpers at the start of our logic, it should be the same. If not
// it means that there are malformed tag helpers at the top of our stack.
if (activeTagHelpers != _tagStack.Count)
if (activeTagHelpers != _trackerStack.Count)
{
// Malformed tag helpers built here will be tag helpers that do not have end tags in the current block
// scope. Block scopes are special cases in Razor such as @<p> would cause an error because there's no
// matching end </p> tag in the template block scope and therefore doesn't make sense as a tag helper.
BuildMalformedTagHelpers(_tagStack.Count - activeTagHelpers, context);
BuildMalformedTagHelpers(_trackerStack.Count - activeTagHelpers, context);
Debug.Assert(activeTagHelpers == _tagStack.Count);
Debug.Assert(activeTagHelpers == _trackerStack.Count);
}
BuildCurrentlyTrackedBlock();
@ -91,8 +91,6 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
private bool TryRewriteTagHelper(Block tagBlock, RewritingContext context)
{
// TODO: Fully handle malformed tags: https://github.com/aspnet/Razor/issues/104
// Get tag name of the current block (doesn't matter if it's an end or start tag)
var tagName = GetTagName(tagBlock);
@ -104,22 +102,39 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
var descriptors = Enumerable.Empty<TagHelperDescriptor>();
if (IsPotentialTagHelper(tagName, tagBlock))
{
descriptors = _provider.GetTagHelpers(tagName);
}
// If there aren't any TagHelperDescriptors registered then we aren't a TagHelper
if (!descriptors.Any())
if (!IsPotentialTagHelper(tagName, tagBlock))
{
return false;
}
var tracker = _trackerStack.Count > 0 ? _trackerStack.Peek() : null;
var tagNameScope = tracker?.Builder.TagName ?? string.Empty;
if (!IsEndTag(tagBlock))
{
// We're in a begin tag helper block
// We're now in a start tag block, we first need to see if the tag block is a tag helper.
var providedAttributes = GetAttributeNames(tagBlock);
var validTagStructure = ValidTagStructure(tagName, tagBlock, context);
descriptors = _provider.GetDescriptors(tagName, providedAttributes);
// If there aren't any TagHelperDescriptors registered then we aren't a TagHelper
if (!descriptors.Any())
{
// If the current tag matches the current TagHelper scope it means the parent TagHelper matched
// all the required attributes but the current one did not; therefore, we need to increment the
// OpenMatchingTags counter for current the TagHelperBlock so we don't end it too early.
// ex: <myth req="..."><myth></myth></myth> We don't want the first myth to close on the inside
// tag.
if (string.Equals(tagNameScope, tagName, StringComparison.OrdinalIgnoreCase))
{
tracker.OpenMatchingTags++;
}
return false;
}
// We're in a start TagHelper block.
var validTagStructure = ValidateTagStructure(tagName, tagBlock, context);
var builder = TagHelperBlockRewriter.Rewrite(
tagName,
@ -144,32 +159,43 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
}
else
{
// We're in an end tag helper block.
var tagNameScope = _tagStack.Count > 0 ? _tagStack.Peek().TagName : string.Empty;
// Validate that our end tag helper matches the currently scoped tag helper, if not we
// need to error.
// Validate that our end tag matches the currently scoped tag, if not we may need to error.
if (tagNameScope.Equals(tagName, StringComparison.OrdinalIgnoreCase))
{
ValidTagStructure(tagName, tagBlock, context);
// If there are additional end tags required before we can build our block it means we're in a
// situation like this: <myth req="..."><myth></myth></myth> where we're at the inside </myth>.
if (tracker.OpenMatchingTags > 0)
{
tracker.OpenMatchingTags--;
return false;
}
ValidateTagStructure(tagName, tagBlock, context);
BuildCurrentlyTrackedTagHelperBlock(tagBlock);
}
else
{
// If there are not TagHelperDescriptors associated with the end tag block that also have no
// required attributes then it means we can't be a TagHelper, bail out.
if (!_provider.GetDescriptors(tagName, attributeNames: Enumerable.Empty<string>()).Any())
{
return false;
}
// Current tag helper scope does not match the end tag. Attempt to recover the tag
// helper by looking up the previous tag helper scopes for a matching tag. If we
// can't recover it means there was no corresponding tag helper begin tag.
// can't recover it means there was no corresponding tag helper start tag.
if (TryRecoverTagHelper(tagName, tagBlock, context))
{
ValidTagStructure(tagName, tagBlock, context);
ValidateTagStructure(tagName, tagBlock, context);
// Successfully recovered, move onto the next element.
}
else
{
// Could not recover, the end tag helper has no corresponding begin tag, create
// Could not recover, the end tag helper has no corresponding start tag, create
// an error based on the current childBlock.
context.ErrorSink.OnError(
tagBlock.Start,
@ -183,13 +209,61 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
return true;
}
private static bool ValidTagStructure(string tagName, Block tag, RewritingContext context)
private IEnumerable<string> GetAttributeNames(Block tagBlock)
{
// Need to calculate how many children we should take that represent the attributes.
var childrenOffset = IsPartialTag(tagBlock) ? 1 : 2;
var attributeChildren = tagBlock.Children.Skip(1).Take(tagBlock.Children.Count() - childrenOffset);
var attributeNames = new List<string>();
foreach (var child in attributeChildren)
{
Span childSpan;
if (child.IsBlock)
{
childSpan = ((Block)child).FindFirstDescendentSpan();
if (childSpan == null)
{
continue;
}
}
else
{
childSpan = child as Span;
}
var attributeName = childSpan
.Content
.Split(separator: new[] { '=' }, count: 2)[0]
.TrimStart();
attributeNames.Add(attributeName);
}
return attributeNames;
}
private static bool ValidateTagStructure(string tagName, Block tag, RewritingContext context)
{
// We assume an invalid structure until we verify that the tag meets all of our "valid structure" criteria.
var invalidStructure = true;
if (IsPartialTag(tag))
{
context.ErrorSink.OnError(
tag.Start,
RazorResources.FormatTagHelpersParseTreeRewriter_MissingCloseAngle(tagName));
return false;
}
return true;
}
private static bool IsPartialTag(Block tagBlock)
{
// No need to validate the tag end because in order to be a tag block it must start with '<'.
var tagEnd = tag.Children.Last() as Span;
var tagEnd = tagBlock.Children.Last() as Span;
// If our tag end is not a markup span it means it's some sort of code SyntaxTreeNode (not a valid format)
if (tagEnd != null && tagEnd.Kind == SpanKind.Markup)
@ -198,18 +272,11 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
if (endSymbol != null && endSymbol.Type == HtmlSymbolType.CloseAngle)
{
invalidStructure = false;
return false;
}
}
if (invalidStructure)
{
context.ErrorSink.OnError(
tag.Start,
RazorResources.FormatTagHelpersParseTreeRewriter_MissingCloseAngle(tagName));
}
return !invalidStructure;
return true;
}
private void BuildCurrentlyTrackedBlock()
@ -239,7 +306,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
{
// Track the original end tag so the editor knows where each piece of the TagHelperBlock lies
// for formatting.
_tagStack.Pop().SourceEndTag = endTag;
_trackerStack.Pop().Builder.SourceEndTag = endTag;
BuildCurrentlyTrackedBlock();
}
@ -256,11 +323,6 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
childSpan.Kind != SpanKind.Transition;
}
private bool IsRegisteredTagHelper(string tagName)
{
return _provider.GetTagHelpers(tagName).Any();
}
private void TrackBlock(BlockBuilder builder)
{
_currentBlock = builder;
@ -270,7 +332,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
private void TrackTagHelperBlock(TagHelperBlockBuilder builder)
{
_tagStack.Push(builder);
_trackerStack.Push(new TagHelperBlockTracker(builder));
TrackBlock(builder);
}
@ -279,9 +341,9 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
{
var malformedTagHelperCount = 0;
foreach (var tag in _tagStack)
foreach (var tracker in _trackerStack)
{
if (tag.TagName.Equals(tagName, StringComparison.OrdinalIgnoreCase))
if (tracker.Builder.TagName.Equals(tagName, StringComparison.OrdinalIgnoreCase))
{
break;
}
@ -289,9 +351,9 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
malformedTagHelperCount++;
}
// If the malformedTagHelperCount == _tagStack.Count it means we couldn't find a begin tag for the tag
// If the malformedTagHelperCount == _tagStack.Count it means we couldn't find a start tag for the tag
// helper, can't recover.
if (malformedTagHelperCount != _tagStack.Count)
if (malformedTagHelperCount != _trackerStack.Count)
{
BuildMalformedTagHelpers(malformedTagHelperCount, context);
@ -302,7 +364,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
return true;
}
// Could not recover tag helper. Aka we found a tag helper end tag without a corresponding begin tag.
// Could not recover tag helper. Aka we found a tag helper end tag without a corresponding start tag.
return false;
}
@ -310,7 +372,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
{
for (var i = 0; i < count; i++)
{
var malformedTagHelper = _tagStack.Peek();
var malformedTagHelper = _trackerStack.Peek().Builder;
context.ErrorSink.OnError(
malformedTagHelper.Start,
@ -357,5 +419,17 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
Debug.Assert(tagBlock.Type == BlockType.Tag);
Debug.Assert(tagBlock.Children.First() is Span);
}
private class TagHelperBlockTracker
{
public TagHelperBlockTracker(TagHelperBlockBuilder builder)
{
Builder = builder;
}
public TagHelperBlockBuilder Builder { get; }
public uint OpenMatchingTags { get; set; }
}
}
}

View File

@ -14,9 +14,10 @@ namespace Microsoft.AspNet.Razor.TagHelpers
/// <summary>
/// Internal for testing.
/// </summary>
internal TagHelperDescriptor([NotNull] string tagName,
[NotNull] string typeName,
[NotNull] string assemblyName)
internal TagHelperDescriptor(
[NotNull] string tagName,
[NotNull] string typeName,
[NotNull] string assemblyName)
: this(
tagName,
typeName,
@ -33,12 +34,31 @@ namespace Microsoft.AspNet.Razor.TagHelpers
[NotNull] string typeName,
[NotNull] string assemblyName,
[NotNull] IEnumerable<TagHelperAttributeDescriptor> attributes)
: this(
tagName,
typeName,
assemblyName,
attributes,
requiredAttributes: Enumerable.Empty<string>())
{
}
/// <summary>
/// Internal for testing.
/// </summary>
internal TagHelperDescriptor(
[NotNull] string tagName,
[NotNull] string typeName,
[NotNull] string assemblyName,
[NotNull] IEnumerable<TagHelperAttributeDescriptor> attributes,
[NotNull] IEnumerable<string> requiredAttributes)
: this(
prefix: string.Empty,
tagName: tagName,
typeName: typeName,
assemblyName: assemblyName,
attributes: attributes)
tagName: tagName,
typeName: typeName,
assemblyName: assemblyName,
attributes: attributes,
requiredAttributes: requiredAttributes)
{
}
@ -57,12 +77,16 @@ namespace Microsoft.AspNet.Razor.TagHelpers
/// <param name="attributes">
/// The <see cref="TagHelperAttributeDescriptor"/>s to request from the HTML tag.
/// </param>
/// <param name="requiredAttributes">
/// The attribute names required for the tag helper to target the HTML tag.
/// </param>
public TagHelperDescriptor(
string prefix,
[NotNull] string tagName,
[NotNull] string typeName,
[NotNull] string assemblyName,
[NotNull] IEnumerable<TagHelperAttributeDescriptor> attributes)
[NotNull] IEnumerable<TagHelperAttributeDescriptor> attributes,
[NotNull] IEnumerable<string> requiredAttributes)
{
Prefix = prefix ?? string.Empty;
TagName = tagName;
@ -70,6 +94,7 @@ namespace Microsoft.AspNet.Razor.TagHelpers
TypeName = typeName;
AssemblyName = assemblyName;
Attributes = new List<TagHelperAttributeDescriptor>(attributes);
RequiredAttributes = new List<string>(requiredAttributes);
}
/// <summary>
@ -102,6 +127,11 @@ namespace Microsoft.AspNet.Razor.TagHelpers
/// <summary>
/// The list of attributes the tag helper expects.
/// </summary>
public virtual List<TagHelperAttributeDescriptor> Attributes { get; private set; }
public IList<TagHelperAttributeDescriptor> Attributes { get; private set; }
/// <summary>
/// The list of required attribute names the tag helper expects to target an element.
/// </summary>
public IList<string> RequiredAttributes { get; private set; }
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Internal.Web.Utils;
namespace Microsoft.AspNet.Razor.TagHelpers
@ -29,15 +30,22 @@ namespace Microsoft.AspNet.Razor.TagHelpers
/// <c>false</c> otherwise.</returns>
/// <remarks>
/// Determines equality based on <see cref="TagHelperDescriptor.TypeName"/>,
/// <see cref="TagHelperDescriptor.AssemblyName"/>, <see cref="TagHelperDescriptor.TagName"/> and
/// <see cref="TagHelperDescriptor.Prefix"/>.
/// <see cref="TagHelperDescriptor.AssemblyName"/>, <see cref="TagHelperDescriptor.TagName"/>,
/// and <see cref="TagHelperDescriptor.RequiredAttributes"/>.
/// </remarks>
public bool Equals(TagHelperDescriptor descriptorX, TagHelperDescriptor descriptorY)
{
return string.Equals(descriptorX.TypeName, descriptorY.TypeName, StringComparison.Ordinal) &&
string.Equals(descriptorX.TagName, descriptorY.TagName, StringComparison.OrdinalIgnoreCase) &&
string.Equals(descriptorX.Prefix, descriptorY.Prefix, StringComparison.OrdinalIgnoreCase) &&
string.Equals(descriptorX.AssemblyName, descriptorY.AssemblyName, StringComparison.Ordinal);
string.Equals(descriptorX.AssemblyName, descriptorY.AssemblyName, StringComparison.Ordinal) &&
Enumerable.SequenceEqual(
descriptorX.RequiredAttributes.OrderBy(
attribute => attribute,
StringComparer.OrdinalIgnoreCase),
descriptorY.RequiredAttributes.OrderBy(
attribute => attribute,
StringComparer.OrdinalIgnoreCase),
StringComparer.OrdinalIgnoreCase);
}
/// <summary>
@ -47,11 +55,22 @@ namespace Microsoft.AspNet.Razor.TagHelpers
/// <returns>An <see cref="int"/> that uniquely identifies the given <paramref name="descriptor"/>.</returns>
public int GetHashCode(TagHelperDescriptor descriptor)
{
return HashCodeCombiner.Start()
.Add(descriptor.TagName, StringComparer.OrdinalIgnoreCase)
.Add(descriptor.TypeName, StringComparer.Ordinal)
.Add(descriptor.AssemblyName, StringComparer.Ordinal)
.CombinedHash;
var hashCodeCombiner = HashCodeCombiner
.Start()
.Add(descriptor.TypeName, StringComparer.Ordinal)
.Add(descriptor.TagName, StringComparer.OrdinalIgnoreCase)
.Add(descriptor.AssemblyName, StringComparer.Ordinal);
var attributes = descriptor.RequiredAttributes.OrderBy(
attribute => attribute,
StringComparer.OrdinalIgnoreCase);
foreach (var attribute in attributes)
{
hashCodeCombiner.Add(attributes);
}
return hashCodeCombiner.CombinedHash;
}
}
}

View File

@ -12,7 +12,7 @@ namespace Microsoft.AspNet.Razor.TagHelpers
/// </summary>
public class TagHelperDescriptorProvider
{
private const string CatchAllDescriptorTarget = "*";
public const string CatchAllDescriptorTarget = "*";
private IDictionary<string, HashSet<TagHelperDescriptor>> _registrations;
private string _tagHelperPrefix;
@ -37,13 +37,12 @@ namespace Microsoft.AspNet.Razor.TagHelpers
/// </summary>
/// <param name="tagName">The name of the HTML tag to match. Providing a '*' tag name
/// retrieves catch-all <see cref="TagHelperDescriptor"/>s (descriptors that target every tag).</param>
/// <param name="attributeNames">Attributes the HTML element must contain to match.</param>
/// <returns><see cref="TagHelperDescriptor"/>s that apply to the given <paramref name="tagName"/>.
/// Will return an empty <see cref="Enumerable" /> if no <see cref="TagHelperDescriptor"/>s are
/// found.</returns>
public IEnumerable<TagHelperDescriptor> GetTagHelpers(string tagName)
public IEnumerable<TagHelperDescriptor> GetDescriptors(string tagName, IEnumerable<string> attributeNames)
{
HashSet<TagHelperDescriptor> descriptors;
if (!string.IsNullOrEmpty(_tagHelperPrefix) &&
(tagName.Length <= _tagHelperPrefix.Length ||
!tagName.StartsWith(_tagHelperPrefix, StringComparison.OrdinalIgnoreCase)))
@ -52,29 +51,54 @@ namespace Microsoft.AspNet.Razor.TagHelpers
return Enumerable.Empty<TagHelperDescriptor>();
}
HashSet<TagHelperDescriptor> catchAllDescriptors;
IEnumerable<TagHelperDescriptor> descriptors;
// Ensure there's a HashSet to use.
if (!_registrations.TryGetValue(CatchAllDescriptorTarget, out descriptors))
if (!_registrations.TryGetValue(CatchAllDescriptorTarget, out catchAllDescriptors))
{
descriptors = new HashSet<TagHelperDescriptor>(TagHelperDescriptorComparer.Default);
}
// If the requested tag name is the catch-all target, we should short circuit.
if (tagName.Equals(CatchAllDescriptorTarget, StringComparison.OrdinalIgnoreCase))
else
{
return descriptors;
descriptors = catchAllDescriptors;
}
// If we have a tag name associated with the requested name, return the descriptors +
// all of the catch-all descriptors.
HashSet<TagHelperDescriptor> matchingDescriptors;
if (_registrations.TryGetValue(tagName, out matchingDescriptors))
// If the requested tag name is the catch-all target, we shouldn't do the work of concatenating extra
// descriptors.
if (!tagName.Equals(CatchAllDescriptorTarget, StringComparison.OrdinalIgnoreCase))
{
return matchingDescriptors.Concat(descriptors);
// If we have a tag name associated with the requested name, we need to combine matchingDescriptors
// with all the catch-all descriptors.
HashSet<TagHelperDescriptor> matchingDescriptors;
if (_registrations.TryGetValue(tagName, out matchingDescriptors))
{
descriptors = matchingDescriptors.Concat(descriptors);
}
}
// We couldn't any descriptors associated with the requested tag name, return all
// of the "catch-all" tag descriptors (there may not be any).
return descriptors;
var applicableDescriptors = ApplyRequiredAttributes(descriptors, attributeNames);
return applicableDescriptors;
}
private IEnumerable<TagHelperDescriptor> ApplyRequiredAttributes(
IEnumerable<TagHelperDescriptor> descriptors,
IEnumerable<string> attributeNames)
{
return descriptors.Where(
descriptor =>
{
foreach (var requiredAttribute in descriptor.RequiredAttributes)
{
if (!attributeNames.Contains(requiredAttribute, StringComparer.OrdinalIgnoreCase))
{
return false;
}
}
return true;
});
}
private void Register(TagHelperDescriptor descriptor)