Add `[HtmlAttributeName(..., DictionaryAttributePrefix="prefix")]` part II
- relates to #89 because that changes `string` property checks and needs this refactor - determine `string`-ness when creating `TagHelperAttributeDescriptor`s - add `TagHelperAttributeDescriptor.IsStringProperty` (set in constructor) - avoid repeated `string` comparisons and be more explicit - change `TagHelperBlockRewriter` to centralize more of the `string`-ness determination - also add `TryParseResult` DTO, avoiding multiple `out` parameters - refactor `CSharpTagHelperCodeRenderer` to allow reuse of core attribute value rendering - test all of it - add `TagHelperDescriptorTest` to confirm serialization / deserialization minor: - fix `TagHelperBlockRewriter.TryParseBlock()` end quote removal when tag is malformed nits: - remove dangling mention of fixed bug #220 - make recently-added `TagHelperBlockRewriterTest` tests realistic - multiple `TagHelperDescriptor`s for same tag helper have identical `Attributes`
This commit is contained in:
parent
2fe78d70db
commit
94f2f904b3
|
|
@ -67,14 +67,14 @@ namespace Microsoft.AspNet.Razor.Generator.Compiler.CSharp
|
||||||
|
|
||||||
RenderTagHelpersCreation(chunk, tagHelperDescriptors);
|
RenderTagHelpersCreation(chunk, tagHelperDescriptors);
|
||||||
|
|
||||||
var attributeDescriptors = tagHelperDescriptors.SelectMany(descriptor => descriptor.Attributes);
|
// Determine what attributes exist in the element and divide them up.
|
||||||
var boundHTMLAttributes = attributeDescriptors.Select(descriptor => descriptor.Name);
|
|
||||||
var htmlAttributes = chunk.Attributes;
|
var htmlAttributes = chunk.Attributes;
|
||||||
var unboundHTMLAttributes =
|
var attributeDescriptors = tagHelperDescriptors.SelectMany(descriptor => descriptor.Attributes);
|
||||||
htmlAttributes.Where(htmlAttribute => !boundHTMLAttributes.Contains(htmlAttribute.Key,
|
var unboundHtmlAttributes = htmlAttributes.Where(
|
||||||
StringComparer.OrdinalIgnoreCase));
|
attribute => !attributeDescriptors.Any(
|
||||||
|
descriptor => string.Equals(attribute.Key, descriptor.Name, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
RenderUnboundHTMLAttributes(unboundHTMLAttributes);
|
RenderUnboundHTMLAttributes(unboundHtmlAttributes);
|
||||||
|
|
||||||
// No need to run anything in design time mode.
|
// No need to run anything in design time mode.
|
||||||
if (!_designTimeMode)
|
if (!_designTimeMode)
|
||||||
|
|
@ -180,22 +180,24 @@ namespace Microsoft.AspNet.Razor.Generator.Compiler.CSharp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render all of the bound attribute values for the tag helper.
|
// Render all of the bound attribute values for the tag helper.
|
||||||
RenderBoundHTMLAttributes(chunk.Attributes,
|
RenderBoundHTMLAttributes(
|
||||||
tagHelperVariableName,
|
chunk.Attributes,
|
||||||
tagHelperDescriptor.Attributes,
|
tagHelperVariableName,
|
||||||
htmlAttributeValues);
|
tagHelperDescriptor.Attributes,
|
||||||
|
htmlAttributeValues);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RenderBoundHTMLAttributes(IList<KeyValuePair<string, Chunk>> chunkAttributes,
|
private void RenderBoundHTMLAttributes(
|
||||||
string tagHelperVariableName,
|
IList<KeyValuePair<string, Chunk>> chunkAttributes,
|
||||||
IEnumerable<TagHelperAttributeDescriptor> attributeDescriptors,
|
string tagHelperVariableName,
|
||||||
Dictionary<string, string> htmlAttributeValues)
|
IEnumerable<TagHelperAttributeDescriptor> attributeDescriptors,
|
||||||
|
Dictionary<string, string> htmlAttributeValues)
|
||||||
{
|
{
|
||||||
foreach (var attributeDescriptor in attributeDescriptors)
|
foreach (var attributeDescriptor in attributeDescriptors)
|
||||||
{
|
{
|
||||||
var matchingAttributes = chunkAttributes.Where(
|
var matchingAttributes = chunkAttributes.Where(
|
||||||
attr => string.Equals(attr.Key, attributeDescriptor.Name, StringComparison.OrdinalIgnoreCase));
|
kvp => string.Equals(kvp.Key, attributeDescriptor.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (matchingAttributes.Any())
|
if (matchingAttributes.Any())
|
||||||
{
|
{
|
||||||
|
|
@ -210,31 +212,15 @@ namespace Microsoft.AspNet.Razor.Generator.Compiler.CSharp
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var attributeValueRecorded = htmlAttributeValues.ContainsKey(attributeDescriptor.Name);
|
|
||||||
|
|
||||||
// Bufferable attributes are attributes that can have Razor code inside of them.
|
|
||||||
var bufferableAttribute = IsStringAttribute(attributeDescriptor);
|
|
||||||
|
|
||||||
// Plain text values are non Razor code (@DateTime.Now) values. If an attribute is bufferable it
|
|
||||||
// may be more than just a plain text value, it may also contain Razor code which is why we attempt
|
|
||||||
// to retrieve a plain text value here.
|
|
||||||
string textValue;
|
|
||||||
var isPlainTextValue = TryGetPlainTextValue(attributeValueChunk, out textValue);
|
|
||||||
|
|
||||||
// If we haven't recorded a value and we need to buffer an attribute value and the value is not
|
|
||||||
// plain text then we need to prepare the value prior to setting it below.
|
|
||||||
if (!attributeValueRecorded && bufferableAttribute && !isPlainTextValue)
|
|
||||||
{
|
|
||||||
BuildBufferedWritingScope(attributeValueChunk, htmlEncodeValues: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We capture the tag helpers property value accessor so we can retrieve it later (if we need to).
|
// We capture the tag helpers property value accessor so we can retrieve it later (if we need to).
|
||||||
var valueAccessor = string.Format(CultureInfo.InvariantCulture,
|
var valueAccessor = string.Format(
|
||||||
"{0}.{1}",
|
CultureInfo.InvariantCulture,
|
||||||
tagHelperVariableName,
|
"{0}.{1}",
|
||||||
attributeDescriptor.PropertyName);
|
tagHelperVariableName,
|
||||||
|
attributeDescriptor.PropertyName);
|
||||||
|
|
||||||
// If we haven't recorded this attribute value before then we need to record its value.
|
// If we haven't recorded this attribute value before then we need to record its value.
|
||||||
|
var attributeValueRecorded = htmlAttributeValues.ContainsKey(attributeDescriptor.Name);
|
||||||
if (!attributeValueRecorded)
|
if (!attributeValueRecorded)
|
||||||
{
|
{
|
||||||
// We only need to create attribute values once per HTML element (not once per tag helper).
|
// We only need to create attribute values once per HTML element (not once per tag helper).
|
||||||
|
|
@ -242,58 +228,16 @@ namespace Microsoft.AspNet.Razor.Generator.Compiler.CSharp
|
||||||
// helpers that need the value.
|
// helpers that need the value.
|
||||||
htmlAttributeValues.Add(attributeDescriptor.Name, valueAccessor);
|
htmlAttributeValues.Add(attributeDescriptor.Name, valueAccessor);
|
||||||
|
|
||||||
if (bufferableAttribute)
|
// Bufferable attributes are attributes that can have Razor code inside of them. Such
|
||||||
{
|
// attributes have string values and may be calculated using a temporary TextWriter or other
|
||||||
_writer.WriteStartAssignment(valueAccessor);
|
// buffer.
|
||||||
|
var bufferableAttribute = attributeDescriptor.IsStringProperty;
|
||||||
|
|
||||||
if (isPlainTextValue)
|
RenderNewAttributeValueAssignment(
|
||||||
{
|
attributeDescriptor,
|
||||||
// If the attribute is bufferable but has a plain text value that means the value
|
bufferableAttribute,
|
||||||
// is a string which needs to be surrounded in quotes.
|
attributeValueChunk,
|
||||||
RenderQuotedAttributeValue(textValue, attributeDescriptor);
|
valueAccessor);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// The value contains more than plain text e.g.
|
|
||||||
// stringAttribute ="Time: @DateTime.Now"
|
|
||||||
RenderBufferedAttributeValue(attributeDescriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
_writer.WriteLine(";");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Write out simple assignment for non-string property value. Try to keep the whole
|
|
||||||
// statement together and the #line pragma correct to make debugging possible.
|
|
||||||
using (var lineMapper = new CSharpLineMappingWriter(
|
|
||||||
_writer,
|
|
||||||
attributeValueChunk.Association.Start,
|
|
||||||
_context.SourceFile))
|
|
||||||
{
|
|
||||||
// Place the assignment LHS to align RHS with original attribute value's indentation.
|
|
||||||
// Unfortunately originalIndent is incorrect if original line contains tabs. Unable to
|
|
||||||
// use a CSharpPaddingBuilder because the Association has no Previous node; lost the
|
|
||||||
// original Span sequence when the parse tree was rewritten.
|
|
||||||
var originalIndent = attributeValueChunk.Start.CharacterIndex;
|
|
||||||
var generatedLength = valueAccessor.Length + " = ".Length;
|
|
||||||
var newIndent = originalIndent - generatedLength;
|
|
||||||
if (newIndent > 0)
|
|
||||||
{
|
|
||||||
_writer.Indent(newIndent);
|
|
||||||
}
|
|
||||||
|
|
||||||
_writer.WriteStartAssignment(valueAccessor);
|
|
||||||
lineMapper.MarkLineMappingStart();
|
|
||||||
|
|
||||||
// Write out bare expression for this attribute value. Property is not a string.
|
|
||||||
// So quoting or buffering are not helpful.
|
|
||||||
RenderRawAttributeValue(attributeValueChunk, attributeDescriptor, isPlainTextValue);
|
|
||||||
|
|
||||||
// End the assignment to the attribute.
|
|
||||||
lineMapper.MarkLineMappingEnd();
|
|
||||||
_writer.WriteLine(";");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execution contexts are a runtime feature.
|
// Execution contexts are a runtime feature.
|
||||||
if (_designTimeMode)
|
if (_designTimeMode)
|
||||||
|
|
@ -301,8 +245,8 @@ namespace Microsoft.AspNet.Razor.Generator.Compiler.CSharp
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var attributeName = firstAttribute.Key;
|
|
||||||
// We need to inform the context of the attribute value.
|
// We need to inform the context of the attribute value.
|
||||||
|
var attributeName = firstAttribute.Key;
|
||||||
_writer
|
_writer
|
||||||
.WriteStartInstanceMethodInvocation(
|
.WriteStartInstanceMethodInvocation(
|
||||||
ExecutionContextVariableName,
|
ExecutionContextVariableName,
|
||||||
|
|
@ -325,6 +269,79 @@ namespace Microsoft.AspNet.Razor.Generator.Compiler.CSharp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render assignment of attribute value to the value accessor.
|
||||||
|
private void RenderNewAttributeValueAssignment(
|
||||||
|
TagHelperAttributeDescriptor attributeDescriptor,
|
||||||
|
bool bufferableAttribute,
|
||||||
|
Chunk attributeValueChunk,
|
||||||
|
string valueAccessor)
|
||||||
|
{
|
||||||
|
// Plain text values are non Razor code (@DateTime.Now) values. If an attribute is bufferable it
|
||||||
|
// may be more than just a plain text value, it may also contain Razor code which is why we attempt
|
||||||
|
// to retrieve a plain text value here.
|
||||||
|
string textValue;
|
||||||
|
var isPlainTextValue = TryGetPlainTextValue(attributeValueChunk, out textValue);
|
||||||
|
|
||||||
|
if (bufferableAttribute)
|
||||||
|
{
|
||||||
|
if (!isPlainTextValue)
|
||||||
|
{
|
||||||
|
// If we haven't recorded a value and we need to buffer an attribute value and the value is not
|
||||||
|
// plain text then we need to prepare the value prior to setting it below.
|
||||||
|
BuildBufferedWritingScope(attributeValueChunk, htmlEncodeValues: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_writer.WriteStartAssignment(valueAccessor);
|
||||||
|
|
||||||
|
if (isPlainTextValue)
|
||||||
|
{
|
||||||
|
// If the attribute is bufferable but has a plain text value that means the value
|
||||||
|
// is a string which needs to be surrounded in quotes.
|
||||||
|
RenderQuotedAttributeValue(textValue, attributeDescriptor);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// The value contains more than plain text e.g. stringAttribute ="Time: @DateTime.Now".
|
||||||
|
RenderBufferedAttributeValue(attributeDescriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
_writer.WriteLine(";");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Write out simple assignment for non-string property value. Try to keep the whole
|
||||||
|
// statement together and the #line pragma correct to make debugging possible.
|
||||||
|
using (var lineMapper = new CSharpLineMappingWriter(
|
||||||
|
_writer,
|
||||||
|
attributeValueChunk.Association.Start,
|
||||||
|
_context.SourceFile))
|
||||||
|
{
|
||||||
|
// Place the assignment LHS to align RHS with original attribute value's indentation.
|
||||||
|
// Unfortunately originalIndent is incorrect if original line contains tabs. Unable to
|
||||||
|
// use a CSharpPaddingBuilder because the Association has no Previous node; lost the
|
||||||
|
// original Span sequence when the parse tree was rewritten.
|
||||||
|
var originalIndent = attributeValueChunk.Start.CharacterIndex;
|
||||||
|
var generatedLength = valueAccessor.Length + " = ".Length;
|
||||||
|
var newIndent = originalIndent - generatedLength;
|
||||||
|
if (newIndent > 0)
|
||||||
|
{
|
||||||
|
_writer.Indent(newIndent);
|
||||||
|
}
|
||||||
|
|
||||||
|
_writer.WriteStartAssignment(valueAccessor);
|
||||||
|
lineMapper.MarkLineMappingStart();
|
||||||
|
|
||||||
|
// Write out bare expression for this attribute value. Property is not a string.
|
||||||
|
// So quoting or buffering are not helpful.
|
||||||
|
RenderRawAttributeValue(attributeValueChunk, attributeDescriptor, isPlainTextValue);
|
||||||
|
|
||||||
|
// End the assignment to the attribute.
|
||||||
|
lineMapper.MarkLineMappingEnd();
|
||||||
|
_writer.WriteLine(";");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void RenderUnboundHTMLAttributes(IEnumerable<KeyValuePair<string, Chunk>> unboundHTMLAttributes)
|
private void RenderUnboundHTMLAttributes(IEnumerable<KeyValuePair<string, Chunk>> unboundHTMLAttributes)
|
||||||
{
|
{
|
||||||
// Build out the unbound HTML attributes for the tag builder
|
// Build out the unbound HTML attributes for the tag builder
|
||||||
|
|
@ -540,14 +557,6 @@ namespace Microsoft.AspNet.Razor.Generator.Compiler.CSharp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsStringAttribute(TagHelperAttributeDescriptor attributeDescriptor)
|
|
||||||
{
|
|
||||||
return string.Equals(
|
|
||||||
attributeDescriptor.TypeName,
|
|
||||||
typeof(string).FullName,
|
|
||||||
StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryGetPlainTextValue(Chunk chunk, out string plainText)
|
private static bool TryGetPlainTextValue(Chunk chunk, out string plainText)
|
||||||
{
|
{
|
||||||
var chunkBlock = chunk as ChunkBlock;
|
var chunkBlock = chunk as ChunkBlock;
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,11 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
IEnumerable<TagHelperDescriptor> descriptors,
|
IEnumerable<TagHelperDescriptor> descriptors,
|
||||||
ErrorSink errorSink)
|
ErrorSink errorSink)
|
||||||
{
|
{
|
||||||
var attributes = new List<KeyValuePair<string, SyntaxTreeNode>>();
|
// Ignore all but one descriptor per type since this method uses the TagHelperDescriptors only to get the
|
||||||
|
// contained TagHelperAttributeDescriptor's.
|
||||||
|
descriptors = descriptors.Distinct(TypeBasedTagHelperDescriptorComparer.Default);
|
||||||
|
|
||||||
// Build a dictionary so we can easily lookup expected attribute value lookups
|
var attributes = new List<KeyValuePair<string, SyntaxTreeNode>>();
|
||||||
IReadOnlyDictionary<string, string> attributeValueTypes =
|
|
||||||
descriptors.SelectMany(descriptor => descriptor.Attributes)
|
|
||||||
.Distinct(TagHelperAttributeDescriptorComparer.Default)
|
|
||||||
.ToDictionary(descriptor => descriptor.Name,
|
|
||||||
descriptor => descriptor.TypeName,
|
|
||||||
StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// We skip the first child "<tagname" and take everything up to the ending portion of the tag ">" or "/>".
|
// We skip the first child "<tagname" and take everything up to the ending portion of the tag ">" or "/>".
|
||||||
// The -2 accounts for both the start and end tags. If the tag does not have a valid structure then there's
|
// The -2 accounts for both the start and end tags. If the tag does not have a valid structure then there's
|
||||||
|
|
@ -56,40 +52,38 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
|
|
||||||
foreach (var child in attributeChildren)
|
foreach (var child in attributeChildren)
|
||||||
{
|
{
|
||||||
KeyValuePair<string, SyntaxTreeNode> attribute;
|
TryParseResult result;
|
||||||
bool succeeded = true;
|
|
||||||
|
|
||||||
if (child.IsBlock)
|
if (child.IsBlock)
|
||||||
{
|
{
|
||||||
succeeded = TryParseBlock(tagName, (Block)child, attributeValueTypes, errorSink, out attribute);
|
result = TryParseBlock(tagName, (Block)child, descriptors, errorSink);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
succeeded = TryParseSpan((Span)child, attributeValueTypes, errorSink, out attribute);
|
result = TryParseSpan((Span)child, descriptors, errorSink);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only want to track the attribute if we succeeded in parsing its corresponding Block/Span.
|
// Only want to track the attribute if we succeeded in parsing its corresponding Block/Span.
|
||||||
if (succeeded)
|
if (result != null)
|
||||||
{
|
{
|
||||||
// Check if it's a bound attribute that is minimized or not of type string and null or whitespace.
|
// Check if it's a bound attribute that is minimized or if it's a bound non-string attribute that
|
||||||
string attributeValueType;
|
// is null or whitespace.
|
||||||
if (attributeValueTypes.TryGetValue(attribute.Key, out attributeValueType) &&
|
if ((result.IsBoundAttribute && result.AttributeValueNode == null) ||
|
||||||
(attribute.Value == null ||
|
(result.IsBoundNonStringAttribute &&
|
||||||
!IsStringAttribute(attributeValueType) &&
|
IsNullOrWhitespaceAttributeValue(result.AttributeValueNode)))
|
||||||
IsNullOrWhitespaceAttributeValue(attribute.Value)))
|
|
||||||
{
|
{
|
||||||
var errorLocation = GetAttributeNameStartLocation(child);
|
var errorLocation = GetAttributeNameStartLocation(child);
|
||||||
|
|
||||||
errorSink.OnError(
|
errorSink.OnError(
|
||||||
errorLocation,
|
errorLocation,
|
||||||
RazorResources.FormatRewriterError_EmptyTagHelperBoundAttribute(
|
RazorResources.FormatRewriterError_EmptyTagHelperBoundAttribute(
|
||||||
attribute.Key,
|
result.AttributeName,
|
||||||
tagName,
|
tagName,
|
||||||
attributeValueType),
|
GetPropertyType(result.AttributeName, descriptors)),
|
||||||
attribute.Key.Length);
|
result.AttributeName.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
attributes.Add(new KeyValuePair<string, SyntaxTreeNode>(attribute.Key, attribute.Value));
|
attributes.Add(
|
||||||
|
new KeyValuePair<string, SyntaxTreeNode>(result.AttributeName, result.AttributeValueNode));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,11 +100,10 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
// This method handles cases when the attribute is a simple span attribute such as
|
// This method handles cases when the attribute is a simple span attribute such as
|
||||||
// class="something moresomething". This does not handle complex attributes such as
|
// class="something moresomething". This does not handle complex attributes such as
|
||||||
// class="@myclass". Therefore the span.Content is equivalent to the entire attribute.
|
// class="@myclass". Therefore the span.Content is equivalent to the entire attribute.
|
||||||
private static bool TryParseSpan(
|
private static TryParseResult TryParseSpan(
|
||||||
Span span,
|
Span span,
|
||||||
IReadOnlyDictionary<string, string> attributeValueTypes,
|
IEnumerable<TagHelperDescriptor> descriptors,
|
||||||
ErrorSink errorSink,
|
ErrorSink errorSink)
|
||||||
out KeyValuePair<string, SyntaxTreeNode> attribute)
|
|
||||||
{
|
{
|
||||||
var afterEquals = false;
|
var afterEquals = false;
|
||||||
var builder = new SpanBuilder
|
var builder = new SpanBuilder
|
||||||
|
|
@ -235,24 +228,29 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
span.Content.Length);
|
span.Content.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
attribute = default(KeyValuePair<string, SyntaxTreeNode>);
|
return null;
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isBoundNonStringAttribute;
|
||||||
|
var result = new TryParseResult
|
||||||
|
{
|
||||||
|
IsBoundAttribute = IsBoundAttribute(name, descriptors, out isBoundNonStringAttribute),
|
||||||
|
AttributeName = name,
|
||||||
|
};
|
||||||
|
result.IsBoundNonStringAttribute = isBoundNonStringAttribute;
|
||||||
|
|
||||||
// If we're not after an equal then we should treat the value as if it were a minimized attribute.
|
// 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;
|
var attributeValueBuilder = afterEquals ? builder : null;
|
||||||
attribute = CreateMarkupAttribute(name, attributeValueBuilder, attributeValueTypes);
|
result.AttributeValueNode = CreateMarkupAttribute(name, attributeValueBuilder, isBoundNonStringAttribute);
|
||||||
|
|
||||||
return true;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryParseBlock(
|
private static TryParseResult TryParseBlock(
|
||||||
string tagName,
|
string tagName,
|
||||||
Block block,
|
Block block,
|
||||||
IReadOnlyDictionary<string, string> attributeValueTypes,
|
IEnumerable<TagHelperDescriptor> descriptors,
|
||||||
ErrorSink errorSink,
|
ErrorSink errorSink)
|
||||||
out KeyValuePair<string, SyntaxTreeNode> attribute)
|
|
||||||
{
|
{
|
||||||
// TODO: Accept more than just spans: https://github.com/aspnet/Razor/issues/96.
|
// TODO: Accept more than just spans: https://github.com/aspnet/Razor/issues/96.
|
||||||
// The first child will only ever NOT be a Span if a user is doing something like:
|
// The first child will only ever NOT be a Span if a user is doing something like:
|
||||||
|
|
@ -265,9 +263,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
errorSink.OnError(block.Children.First().Start,
|
errorSink.OnError(block.Children.First().Start,
|
||||||
RazorResources.FormatTagHelpers_CannotHaveCSharpInTagDeclaration(tagName));
|
RazorResources.FormatTagHelpers_CannotHaveCSharpInTagDeclaration(tagName));
|
||||||
|
|
||||||
attribute = default(KeyValuePair<string, SyntaxTreeNode>);
|
return null;
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var builder = new BlockBuilder(block);
|
var builder = new BlockBuilder(block);
|
||||||
|
|
@ -276,7 +272,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
// i.e. <div class="plain text in attribute">
|
// i.e. <div class="plain text in attribute">
|
||||||
if (builder.Children.Count == 1)
|
if (builder.Children.Count == 1)
|
||||||
{
|
{
|
||||||
return TryParseSpan(childSpan, attributeValueTypes, errorSink, out attribute);
|
return TryParseSpan(childSpan, descriptors, errorSink);
|
||||||
}
|
}
|
||||||
|
|
||||||
var textSymbol = childSpan.Symbols.FirstHtmlSymbolAs(HtmlSymbolType.Text);
|
var textSymbol = childSpan.Symbols.FirstHtmlSymbolAs(HtmlSymbolType.Text);
|
||||||
|
|
@ -286,12 +282,17 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
{
|
{
|
||||||
errorSink.OnError(childSpan.Start, RazorResources.FormatTagHelpers_AttributesMustHaveAName(tagName));
|
errorSink.OnError(childSpan.Start, RazorResources.FormatTagHelpers_AttributesMustHaveAName(tagName));
|
||||||
|
|
||||||
attribute = default(KeyValuePair<string, SyntaxTreeNode>);
|
return null;
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Support no attribute values: https://github.com/aspnet/Razor/issues/220
|
// Have a name now. Able to determine correct isBoundNonStringAttribute value.
|
||||||
|
bool isBoundNonStringAttribute;
|
||||||
|
var result = new TryParseResult
|
||||||
|
{
|
||||||
|
IsBoundAttribute = IsBoundAttribute(name, descriptors, out isBoundNonStringAttribute),
|
||||||
|
AttributeName = name,
|
||||||
|
};
|
||||||
|
result.IsBoundNonStringAttribute = isBoundNonStringAttribute;
|
||||||
|
|
||||||
// Remove first child i.e. foo="
|
// Remove first child i.e. foo="
|
||||||
builder.Children.RemoveAt(0);
|
builder.Children.RemoveAt(0);
|
||||||
|
|
@ -301,10 +302,14 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
if (!endNode.IsBlock)
|
if (!endNode.IsBlock)
|
||||||
{
|
{
|
||||||
var endSpan = (Span)endNode;
|
var endSpan = (Span)endNode;
|
||||||
var endSymbol = (HtmlSymbol)endSpan.Symbols.Last();
|
|
||||||
|
// In some malformed cases e.g. <p bar="false', the last Span (false' in the ex.) may contain more
|
||||||
|
// than a single HTML symbol. Do not ignore those other symbols.
|
||||||
|
var symbolCount = endSpan.Symbols.Count();
|
||||||
|
var endSymbol = symbolCount == 1 ? (HtmlSymbol)endSpan.Symbols.First() : null;
|
||||||
|
|
||||||
// Checking to see if it's a quoted attribute, if so we should remove end quote
|
// Checking to see if it's a quoted attribute, if so we should remove end quote
|
||||||
if (IsQuote(endSymbol))
|
if (endSymbol != null && IsQuote(endSymbol))
|
||||||
{
|
{
|
||||||
builder.Children.RemoveAt(builder.Children.Count - 1);
|
builder.Children.RemoveAt(builder.Children.Count - 1);
|
||||||
}
|
}
|
||||||
|
|
@ -323,18 +328,17 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
if (child != null)
|
if (child != null)
|
||||||
{
|
{
|
||||||
// After pulling apart the block we just have a value span.
|
// After pulling apart the block we just have a value span.
|
||||||
|
|
||||||
var spanBuilder = new SpanBuilder(child);
|
var spanBuilder = new SpanBuilder(child);
|
||||||
|
|
||||||
attribute = CreateMarkupAttribute(name, spanBuilder, attributeValueTypes);
|
result.AttributeValueNode = CreateMarkupAttribute(name, spanBuilder, isBoundNonStringAttribute);
|
||||||
|
|
||||||
return true;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attribute = new KeyValuePair<string, SyntaxTreeNode>(name, block);
|
result.AttributeValueNode = block;
|
||||||
|
|
||||||
return true;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Block RebuildCodeGenerators(Block block)
|
private static Block RebuildCodeGenerators(Block block)
|
||||||
|
|
@ -429,22 +433,20 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
return nodeStart + firstNonWhitespaceSymbol.Start;
|
return nodeStart + firstNonWhitespaceSymbol.Start;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static KeyValuePair<string, SyntaxTreeNode> CreateMarkupAttribute(
|
private static SyntaxTreeNode CreateMarkupAttribute(
|
||||||
string name,
|
string name,
|
||||||
SpanBuilder builder,
|
SpanBuilder builder,
|
||||||
IReadOnlyDictionary<string, string> attributeValueTypes)
|
bool isBoundNonStringAttribute)
|
||||||
{
|
{
|
||||||
string attributeTypeName;
|
|
||||||
Span value = null;
|
Span value = null;
|
||||||
|
|
||||||
// Builder will be null in the case of minimized attributes
|
// Builder will be null in the case of minimized attributes
|
||||||
if (builder != null)
|
if (builder != null)
|
||||||
{
|
{
|
||||||
// If the attribute was requested by the tag helper and doesn't happen to be a string then we need to treat
|
// If the attribute was requested by a tag helper but the corresponding property was not a string,
|
||||||
// its value as code. Any non-string value can be any C# value so we need to ensure the SyntaxTreeNode
|
// then treat its value as code. A non-string value can be any C# value so we need to ensure the
|
||||||
// reflects that.
|
// SyntaxTreeNode reflects that.
|
||||||
if (attributeValueTypes.TryGetValue(name, out attributeTypeName) &&
|
if (isBoundNonStringAttribute)
|
||||||
!IsStringAttribute(attributeTypeName))
|
|
||||||
{
|
{
|
||||||
builder.Kind = SpanKind.Code;
|
builder.Kind = SpanKind.Code;
|
||||||
}
|
}
|
||||||
|
|
@ -452,7 +454,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
value = builder.Build();
|
value = builder.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new KeyValuePair<string, SyntaxTreeNode>(name, value);
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsNullOrWhitespaceAttributeValue(SyntaxTreeNode attributeValue)
|
private static bool IsNullOrWhitespaceAttributeValue(SyntaxTreeNode attributeValue)
|
||||||
|
|
@ -475,9 +477,35 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsStringAttribute(string attributeTypeName)
|
// Determines the full name of the Type of the property corresponding to an attribute with the given name.
|
||||||
|
private static string GetPropertyType(string name, IEnumerable<TagHelperDescriptor> descriptors)
|
||||||
{
|
{
|
||||||
return string.Equals(attributeTypeName, StringTypeName, StringComparison.OrdinalIgnoreCase);
|
var firstBoundAttribute = FindFirstBoundAttribute(name, descriptors);
|
||||||
|
|
||||||
|
return firstBoundAttribute?.TypeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determines whether an attribute with the given name is bound to a non-string tag helper property.
|
||||||
|
private static bool IsBoundAttribute(
|
||||||
|
string name,
|
||||||
|
IEnumerable<TagHelperDescriptor> descriptors,
|
||||||
|
out bool isBoundNonStringAttribute)
|
||||||
|
{
|
||||||
|
var firstBoundAttribute = FindFirstBoundAttribute(name, descriptors);
|
||||||
|
var isBoundAttribute = firstBoundAttribute != null;
|
||||||
|
isBoundNonStringAttribute = isBoundAttribute && !firstBoundAttribute.IsStringProperty;
|
||||||
|
|
||||||
|
return isBoundAttribute;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finds first TagHelperAttributeDescriptor matching given name.
|
||||||
|
private static TagHelperAttributeDescriptor FindFirstBoundAttribute(
|
||||||
|
string name,
|
||||||
|
IEnumerable<TagHelperDescriptor> descriptors)
|
||||||
|
{
|
||||||
|
return descriptors
|
||||||
|
.SelectMany(descriptor => descriptor.Attributes)
|
||||||
|
.FirstOrDefault(attribute => string.Equals(attribute.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsQuote(HtmlSymbol htmlSymbol)
|
private static bool IsQuote(HtmlSymbol htmlSymbol)
|
||||||
|
|
@ -485,5 +513,16 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal
|
||||||
return htmlSymbol.Type == HtmlSymbolType.DoubleQuote ||
|
return htmlSymbol.Type == HtmlSymbolType.DoubleQuote ||
|
||||||
htmlSymbol.Type == HtmlSymbolType.SingleQuote;
|
htmlSymbol.Type == HtmlSymbolType.SingleQuote;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class TryParseResult
|
||||||
|
{
|
||||||
|
public string AttributeName { get; set; }
|
||||||
|
|
||||||
|
public SyntaxTreeNode AttributeValueNode { get; set; }
|
||||||
|
|
||||||
|
public bool IsBoundAttribute { get; set; }
|
||||||
|
|
||||||
|
public bool IsBoundNonStringAttribute { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
// 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.
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using Microsoft.Framework.Internal;
|
||||||
|
|
||||||
namespace Microsoft.AspNet.Razor.TagHelpers
|
namespace Microsoft.AspNet.Razor.TagHelpers
|
||||||
{
|
{
|
||||||
|
|
@ -10,9 +12,13 @@ namespace Microsoft.AspNet.Razor.TagHelpers
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TagHelperAttributeDescriptor
|
public class TagHelperAttributeDescriptor
|
||||||
{
|
{
|
||||||
// Internal for testing
|
// Internal for testing i.e. for easy TagHelperAttributeDescriptor creation when PropertyInfo is available.
|
||||||
internal TagHelperAttributeDescriptor(string name, PropertyInfo propertyInfo)
|
internal TagHelperAttributeDescriptor([NotNull] string name, [NotNull] PropertyInfo propertyInfo)
|
||||||
: this(name, propertyInfo.Name, propertyInfo.PropertyType.FullName)
|
: this(
|
||||||
|
name,
|
||||||
|
propertyInfo.Name,
|
||||||
|
propertyInfo.PropertyType.FullName,
|
||||||
|
isStringProperty: propertyInfo.PropertyType == typeof(string))
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,35 +26,58 @@ namespace Microsoft.AspNet.Razor.TagHelpers
|
||||||
/// Instantiates a new instance of the <see cref="TagHelperAttributeDescriptor"/> class.
|
/// Instantiates a new instance of the <see cref="TagHelperAttributeDescriptor"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="name">The HTML attribute name.</param>
|
/// <param name="name">The HTML attribute name.</param>
|
||||||
/// <param name="propertyName">The name of the CLR property name that corresponds to the HTML
|
/// <param name="propertyName">The name of the CLR property that corresponds to the HTML attribute.</param>
|
||||||
/// attribute.</param>
|
|
||||||
/// <param name="typeName">
|
/// <param name="typeName">
|
||||||
/// The full name of the named (see <paramref name="propertyName"/>) property's
|
/// The full name of the named (see <paramref name="propertyName"/>) property's <see cref="System.Type"/>.
|
||||||
/// <see cref="System.Type"/>.
|
|
||||||
/// </param>
|
/// </param>
|
||||||
public TagHelperAttributeDescriptor(string name,
|
public TagHelperAttributeDescriptor(
|
||||||
string propertyName,
|
[NotNull] string name,
|
||||||
string typeName)
|
[NotNull] string propertyName,
|
||||||
|
[NotNull] string typeName)
|
||||||
|
: this(
|
||||||
|
name,
|
||||||
|
propertyName,
|
||||||
|
typeName,
|
||||||
|
isStringProperty: string.Equals(typeName, typeof(string).FullName, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal for testing i.e. for confirming above constructor sets `IsStringProperty` as expected.
|
||||||
|
internal TagHelperAttributeDescriptor(
|
||||||
|
[NotNull] string name,
|
||||||
|
[NotNull] string propertyName,
|
||||||
|
[NotNull] string typeName,
|
||||||
|
bool isStringProperty)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
PropertyName = propertyName;
|
PropertyName = propertyName;
|
||||||
TypeName = typeName;
|
TypeName = typeName;
|
||||||
|
IsStringProperty = isStringProperty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an indication whether this property is of type <see cref="string"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// If <c>true</c> the <see cref="TypeName"/> is for <see cref="string"/>. This causes the Razor parser
|
||||||
|
/// to allow empty values for attributes that have names matching <see cref="Name"/>. If <c>false</c>
|
||||||
|
/// empty values for such matching attributes lead to errors.
|
||||||
|
/// </value>
|
||||||
|
public bool IsStringProperty { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The HTML attribute name.
|
/// The HTML attribute name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name { get; private set; }
|
public string Name { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The name of the CLR property name that corresponds to the HTML attribute name.
|
/// The name of the CLR property that corresponds to the HTML attribute.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string PropertyName { get; private set; }
|
public string PropertyName { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The full name of the named (see <see name="PropertyName"/>) property's
|
/// The full name of the named (see <see name="PropertyName"/>) property's <see cref="System.Type"/>.
|
||||||
/// <see cref="System.Type"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string TypeName { get; private set; }
|
public string TypeName { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -24,13 +24,17 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normal comparer doesn't care about case, in tests we do. Also double-check IsStringProperty though
|
||||||
|
// it is inferred from TypeName.
|
||||||
return base.Equals(descriptorX, descriptorY) &&
|
return base.Equals(descriptorX, descriptorY) &&
|
||||||
// Normal comparer doesn't care about case, in tests we do.
|
descriptorX.IsStringProperty == descriptorY.IsStringProperty &&
|
||||||
string.Equals(descriptorX.Name, descriptorY.Name, StringComparison.Ordinal);
|
string.Equals(descriptorX.Name, descriptorY.Name, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int GetHashCode(TagHelperAttributeDescriptor descriptor)
|
public override int GetHashCode(TagHelperAttributeDescriptor descriptor)
|
||||||
{
|
{
|
||||||
|
// Rarely if ever hash TagHelperAttributeDescriptor. If we do, ignore IsStringProperty since it should
|
||||||
|
// not vary for a given TypeName i.e. will not change the bucket.
|
||||||
return HashCodeCombiner.Start()
|
return HashCodeCombiner.Start()
|
||||||
.Add(base.GetHashCode(descriptor))
|
.Add(base.GetHashCode(descriptor))
|
||||||
.Add(descriptor.Name, StringComparer.Ordinal)
|
.Add(descriptor.Name, StringComparer.Ordinal)
|
||||||
|
|
|
||||||
|
|
@ -289,8 +289,7 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
|
||||||
typeof(InheritedOverriddenAttributeTagHelper).FullName,
|
typeof(InheritedOverriddenAttributeTagHelper).FullName,
|
||||||
AssemblyName,
|
AssemblyName,
|
||||||
new[] {
|
new[] {
|
||||||
new TagHelperAttributeDescriptor("valid-attribute1",
|
new TagHelperAttributeDescriptor("valid-attribute1", validProperty1),
|
||||||
validProperty1),
|
|
||||||
new TagHelperAttributeDescriptor("Something-Else", validProperty2)
|
new TagHelperAttributeDescriptor("Something-Else", validProperty2)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
@ -363,14 +362,18 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var errorSink = new ErrorSink();
|
var errorSink = new ErrorSink();
|
||||||
var intProperty = typeof(InheritedSingleAttributeTagHelper).GetProperty(
|
|
||||||
nameof(InheritedSingleAttributeTagHelper.IntAttribute));
|
// Also confirm isStringProperty is calculated correctly.
|
||||||
var expectedDescriptor = new TagHelperDescriptor(
|
var expectedDescriptor = new TagHelperDescriptor(
|
||||||
"inherited-single-attribute",
|
"inherited-single-attribute",
|
||||||
typeof(InheritedSingleAttributeTagHelper).FullName,
|
typeof(InheritedSingleAttributeTagHelper).FullName,
|
||||||
AssemblyName,
|
AssemblyName,
|
||||||
new[] {
|
new[] {
|
||||||
new TagHelperAttributeDescriptor("int-attribute", intProperty)
|
new TagHelperAttributeDescriptor(
|
||||||
|
"int-attribute",
|
||||||
|
nameof(InheritedSingleAttributeTagHelper.IntAttribute),
|
||||||
|
typeof(int).FullName,
|
||||||
|
isStringProperty: false)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -450,8 +453,7 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
|
||||||
typeof(NonPublicAccessorTagHelper).FullName,
|
typeof(NonPublicAccessorTagHelper).FullName,
|
||||||
AssemblyName,
|
AssemblyName,
|
||||||
new[] {
|
new[] {
|
||||||
new TagHelperAttributeDescriptor(
|
new TagHelperAttributeDescriptor("valid-attribute", validProperty)
|
||||||
"valid-attribute", validProperty)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -471,15 +473,19 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var errorSink = new ErrorSink();
|
var errorSink = new ErrorSink();
|
||||||
var boundProperty = typeof(NotBoundAttributeTagHelper).GetProperty(
|
|
||||||
nameof(NotBoundAttributeTagHelper.BoundProperty));
|
// Also confirm isStringProperty is calculated correctly.
|
||||||
var expectedDescriptor = new TagHelperDescriptor(
|
var expectedDescriptor = new TagHelperDescriptor(
|
||||||
"not-bound-attribute",
|
"not-bound-attribute",
|
||||||
typeof(NotBoundAttributeTagHelper).FullName,
|
typeof(NotBoundAttributeTagHelper).FullName,
|
||||||
AssemblyName,
|
AssemblyName,
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new TagHelperAttributeDescriptor("bound-property", boundProperty)
|
new TagHelperAttributeDescriptor(
|
||||||
|
"bound-property",
|
||||||
|
nameof(NotBoundAttributeTagHelper.BoundProperty),
|
||||||
|
typeof(object).FullName,
|
||||||
|
isStringProperty: false)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -516,21 +522,30 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var errorSink = new ErrorSink();
|
var errorSink = new ErrorSink();
|
||||||
var validProp = typeof(MultiTagTagHelper).GetProperty(nameof(MultiTagTagHelper.ValidAttribute));
|
|
||||||
|
// Also confirm isStringProperty is calculated correctly.
|
||||||
var expectedDescriptors = new[] {
|
var expectedDescriptors = new[] {
|
||||||
new TagHelperDescriptor(
|
new TagHelperDescriptor(
|
||||||
"div",
|
"div",
|
||||||
typeof(MultiTagTagHelper).FullName,
|
typeof(MultiTagTagHelper).FullName,
|
||||||
AssemblyName,
|
AssemblyName,
|
||||||
new[] {
|
new[] {
|
||||||
new TagHelperAttributeDescriptor("valid-attribute", validProp)
|
new TagHelperAttributeDescriptor(
|
||||||
|
"valid-attribute",
|
||||||
|
nameof(MultiTagTagHelper.ValidAttribute),
|
||||||
|
typeof(string).FullName,
|
||||||
|
isStringProperty: true)
|
||||||
}),
|
}),
|
||||||
new TagHelperDescriptor(
|
new TagHelperDescriptor(
|
||||||
"p",
|
"p",
|
||||||
typeof(MultiTagTagHelper).FullName,
|
typeof(MultiTagTagHelper).FullName,
|
||||||
AssemblyName,
|
AssemblyName,
|
||||||
new[] {
|
new[] {
|
||||||
new TagHelperAttributeDescriptor("valid-attribute", validProp)
|
new TagHelperAttributeDescriptor(
|
||||||
|
"valid-attribute",
|
||||||
|
nameof(MultiTagTagHelper.ValidAttribute),
|
||||||
|
typeof(string).FullName,
|
||||||
|
isStringProperty: true)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1104,7 +1119,7 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
|
||||||
|
|
||||||
private class NotBoundAttributeTagHelper
|
private class NotBoundAttributeTagHelper
|
||||||
{
|
{
|
||||||
public string BoundProperty { get; set; }
|
public object BoundProperty { get; set; }
|
||||||
|
|
||||||
[HtmlAttributeNotBound]
|
[HtmlAttributeNotBound]
|
||||||
public string NotBoundProperty { get; set; }
|
public string NotBoundProperty { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ namespace Microsoft.AspNet.Razor.Test.Generator
|
||||||
{
|
{
|
||||||
public class CSharpTagHelperRenderingTest : TagHelperTestBase
|
public class CSharpTagHelperRenderingTest : TagHelperTestBase
|
||||||
{
|
{
|
||||||
private static IEnumerable<TagHelperDescriptor> DefaultPAndInputTagHelperDescriptors
|
private static IEnumerable<TagHelperDescriptor> DefaultPAndInputTagHelperDescriptors { get; }
|
||||||
=> BuildPAndInputTagHelperDescriptors(prefix: string.Empty);
|
= BuildPAndInputTagHelperDescriptors(prefix: string.Empty);
|
||||||
private static IEnumerable<TagHelperDescriptor> PrefixedPAndInputTagHelperDescriptors
|
private static IEnumerable<TagHelperDescriptor> PrefixedPAndInputTagHelperDescriptors { get; }
|
||||||
=> BuildPAndInputTagHelperDescriptors("THS");
|
= BuildPAndInputTagHelperDescriptors(prefix: "THS");
|
||||||
|
|
||||||
private static IEnumerable<TagHelperDescriptor> MinimizedTagHelpers_Descriptors
|
private static IEnumerable<TagHelperDescriptor> MinimizedTagHelpers_Descriptors
|
||||||
{
|
{
|
||||||
|
|
@ -609,6 +609,7 @@ namespace Microsoft.AspNet.Razor.Test.Generator
|
||||||
var pAgePropertyInfo = typeof(TestType).GetProperty("Age");
|
var pAgePropertyInfo = typeof(TestType).GetProperty("Age");
|
||||||
var inputTypePropertyInfo = typeof(TestType).GetProperty("Type");
|
var inputTypePropertyInfo = typeof(TestType).GetProperty("Type");
|
||||||
var checkedPropertyInfo = typeof(TestType).GetProperty("Checked");
|
var checkedPropertyInfo = typeof(TestType).GetProperty("Checked");
|
||||||
|
|
||||||
return new[]
|
return new[]
|
||||||
{
|
{
|
||||||
new TagHelperDescriptor(
|
new TagHelperDescriptor(
|
||||||
|
|
|
||||||
|
|
@ -712,13 +712,19 @@ namespace Microsoft.AspNet.Razor.TagHelpers
|
||||||
{
|
{
|
||||||
new TagHelperDescriptor(
|
new TagHelperDescriptor(
|
||||||
tagName: "input",
|
tagName: "input",
|
||||||
typeName: "InputTagHelper",
|
typeName: "InputTagHelper1",
|
||||||
assemblyName: "SomeAssembly",
|
assemblyName: "SomeAssembly",
|
||||||
attributes: new TagHelperAttributeDescriptor[0],
|
attributes: new[]
|
||||||
|
{
|
||||||
|
new TagHelperAttributeDescriptor(
|
||||||
|
"bound-required-string",
|
||||||
|
"BoundRequiredString",
|
||||||
|
typeof(string).FullName)
|
||||||
|
},
|
||||||
requiredAttributes: new[] { "unbound-required" }),
|
requiredAttributes: new[] { "unbound-required" }),
|
||||||
new TagHelperDescriptor(
|
new TagHelperDescriptor(
|
||||||
tagName: "input",
|
tagName: "input",
|
||||||
typeName: "InputTagHelper",
|
typeName: "InputTagHelper1",
|
||||||
assemblyName: "SomeAssembly",
|
assemblyName: "SomeAssembly",
|
||||||
attributes: new[]
|
attributes: new[]
|
||||||
{
|
{
|
||||||
|
|
@ -730,7 +736,7 @@ namespace Microsoft.AspNet.Razor.TagHelpers
|
||||||
requiredAttributes: new[] { "bound-required-string" }),
|
requiredAttributes: new[] { "bound-required-string" }),
|
||||||
new TagHelperDescriptor(
|
new TagHelperDescriptor(
|
||||||
tagName: "input",
|
tagName: "input",
|
||||||
typeName: "InputTagHelper",
|
typeName: "InputTagHelper2",
|
||||||
assemblyName: "SomeAssembly",
|
assemblyName: "SomeAssembly",
|
||||||
attributes: new[]
|
attributes: new[]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
// 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;
|
||||||
|
using System.Linq;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNet.Razor.TagHelpers
|
||||||
|
{
|
||||||
|
public class TagHelperDescriptorTest
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TagHelperDescriptor_CanBeSerialized()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var descriptor = new TagHelperDescriptor(
|
||||||
|
prefix: "prefix:",
|
||||||
|
tagName: "tag name",
|
||||||
|
typeName: "type name",
|
||||||
|
assemblyName: "assembly name",
|
||||||
|
attributes: Enumerable.Empty<TagHelperAttributeDescriptor>(),
|
||||||
|
requiredAttributes: new[] { "required attribute one", "required attribute two" });
|
||||||
|
var expectedSerializedDescriptor =
|
||||||
|
$"{{\"{ nameof(TagHelperDescriptor.Prefix) }\":\"prefix:\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.TagName) }\":\"tag name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.FullTagName) }\":\"prefix:tag name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.TypeName) }\":\"type name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.AssemblyName) }\":\"assembly name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.Attributes) }\":[]," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.RequiredAttributes) }\":" +
|
||||||
|
"[\"required attribute one\",\"required attribute two\"]}";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var serializedDescriptor = JsonConvert.SerializeObject(descriptor);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(expectedSerializedDescriptor, serializedDescriptor, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagHelperDescriptor_WithAttributes_CanBeSerialized()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var descriptor = new TagHelperDescriptor(
|
||||||
|
prefix: "prefix:",
|
||||||
|
tagName: "tag name",
|
||||||
|
typeName: "type name",
|
||||||
|
assemblyName: "assembly name",
|
||||||
|
attributes: new[]
|
||||||
|
{
|
||||||
|
new TagHelperAttributeDescriptor(
|
||||||
|
name: "attribute one",
|
||||||
|
propertyName: "property name",
|
||||||
|
typeName: "property type name"),
|
||||||
|
new TagHelperAttributeDescriptor(
|
||||||
|
name: "attribute two",
|
||||||
|
propertyName: "property name",
|
||||||
|
typeName: typeof(string).FullName),
|
||||||
|
},
|
||||||
|
requiredAttributes: Enumerable.Empty<string>());
|
||||||
|
var expectedSerializedDescriptor =
|
||||||
|
$"{{\"{ nameof(TagHelperDescriptor.Prefix) }\":\"prefix:\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.TagName) }\":\"tag name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.FullTagName) }\":\"prefix:tag name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.TypeName) }\":\"type name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.AssemblyName) }\":\"assembly name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.Attributes) }\":[" +
|
||||||
|
$"{{\"{ nameof(TagHelperAttributeDescriptor.IsStringProperty) }\":false," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.Name) }\":\"attribute one\"," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.PropertyName) }\":\"property name\"," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.TypeName) }\":\"property type name\"}}," +
|
||||||
|
$"{{\"{ nameof(TagHelperAttributeDescriptor.IsStringProperty) }\":true," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.Name) }\":\"attribute two\"," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.PropertyName) }\":\"property name\"," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.TypeName) }\":\"{ typeof(string).FullName }\"}}]," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.RequiredAttributes) }\":[]}}";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var serializedDescriptor = JsonConvert.SerializeObject(descriptor);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(expectedSerializedDescriptor, serializedDescriptor, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagHelperDescriptor_CanBeDeserialized()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var serializedDescriptor =
|
||||||
|
$"{{\"{nameof(TagHelperDescriptor.Prefix)}\":\"prefix:\"," +
|
||||||
|
$"\"{nameof(TagHelperDescriptor.TagName)}\":\"tag name\"," +
|
||||||
|
$"\"{nameof(TagHelperDescriptor.FullTagName)}\":\"prefix:tag name\"," +
|
||||||
|
$"\"{nameof(TagHelperDescriptor.TypeName)}\":\"type name\"," +
|
||||||
|
$"\"{nameof(TagHelperDescriptor.AssemblyName)}\":\"assembly name\"," +
|
||||||
|
$"\"{nameof(TagHelperDescriptor.Attributes)}\":[]," +
|
||||||
|
$"\"{nameof(TagHelperDescriptor.RequiredAttributes)}\":" +
|
||||||
|
"[\"required attribute one\",\"required attribute two\"]}";
|
||||||
|
var expectedDescriptor = new TagHelperDescriptor(
|
||||||
|
prefix: "prefix:",
|
||||||
|
tagName: "tag name",
|
||||||
|
typeName: "type name",
|
||||||
|
assemblyName: "assembly name",
|
||||||
|
attributes: Enumerable.Empty<TagHelperAttributeDescriptor>(),
|
||||||
|
requiredAttributes: new[] { "required attribute one", "required attribute two" });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var descriptor = JsonConvert.DeserializeObject<TagHelperDescriptor>(serializedDescriptor);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(descriptor);
|
||||||
|
Assert.Equal(expectedDescriptor.Prefix, descriptor.Prefix, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(expectedDescriptor.TagName, descriptor.TagName, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(expectedDescriptor.FullTagName, descriptor.FullTagName, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(expectedDescriptor.TypeName, descriptor.TypeName, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(expectedDescriptor.AssemblyName, descriptor.AssemblyName, StringComparer.Ordinal);
|
||||||
|
Assert.Empty(descriptor.Attributes);
|
||||||
|
Assert.Equal(expectedDescriptor.RequiredAttributes, descriptor.RequiredAttributes, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagHelperDescriptor_WithAttributes_CanBeDeserialized()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var serializedDescriptor =
|
||||||
|
$"{{\"{ nameof(TagHelperDescriptor.Prefix) }\":\"prefix:\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.TagName) }\":\"tag name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.FullTagName) }\":\"prefix:tag name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.TypeName) }\":\"type name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.AssemblyName) }\":\"assembly name\"," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.Attributes) }\":[" +
|
||||||
|
$"{{\"{ nameof(TagHelperAttributeDescriptor.IsStringProperty) }\":false," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.Name) }\":\"attribute one\"," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.PropertyName) }\":\"property name\"," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.TypeName) }\":\"property type name\"}}," +
|
||||||
|
$"{{\"{ nameof(TagHelperAttributeDescriptor.IsStringProperty) }\":true," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.Name) }\":\"attribute two\"," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.PropertyName) }\":\"property name\"," +
|
||||||
|
$"\"{ nameof(TagHelperAttributeDescriptor.TypeName) }\":\"{ typeof(string).FullName }\"}}]," +
|
||||||
|
$"\"{ nameof(TagHelperDescriptor.RequiredAttributes) }\":[]}}";
|
||||||
|
var expectedDescriptor = new TagHelperDescriptor(
|
||||||
|
prefix: "prefix:",
|
||||||
|
tagName: "tag name",
|
||||||
|
typeName: "type name",
|
||||||
|
assemblyName: "assembly name",
|
||||||
|
attributes: new[]
|
||||||
|
{
|
||||||
|
new TagHelperAttributeDescriptor(
|
||||||
|
name: "attribute one",
|
||||||
|
propertyName: "property name",
|
||||||
|
typeName: "property type name"),
|
||||||
|
new TagHelperAttributeDescriptor(
|
||||||
|
name: "attribute two",
|
||||||
|
propertyName: "property name",
|
||||||
|
typeName: typeof(string).FullName),
|
||||||
|
},
|
||||||
|
requiredAttributes: Enumerable.Empty<string>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var descriptor = JsonConvert.DeserializeObject<TagHelperDescriptor>(serializedDescriptor);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(descriptor);
|
||||||
|
Assert.Equal(expectedDescriptor.Prefix, descriptor.Prefix, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(expectedDescriptor.TagName, descriptor.TagName, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(expectedDescriptor.FullTagName, descriptor.FullTagName, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(expectedDescriptor.TypeName, descriptor.TypeName, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(expectedDescriptor.AssemblyName, descriptor.AssemblyName, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(2, descriptor.Attributes.Count);
|
||||||
|
Assert.Equal(expectedDescriptor.Attributes[0].IsStringProperty, descriptor.Attributes[0].IsStringProperty);
|
||||||
|
Assert.Equal(expectedDescriptor.Attributes[0].Name, descriptor.Attributes[0].Name, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(
|
||||||
|
expectedDescriptor.Attributes[0].PropertyName,
|
||||||
|
descriptor.Attributes[0].PropertyName,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
Assert.Equal(
|
||||||
|
expectedDescriptor.Attributes[0].TypeName,
|
||||||
|
descriptor.Attributes[0].TypeName,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
Assert.Equal(expectedDescriptor.Attributes[1].IsStringProperty, descriptor.Attributes[1].IsStringProperty);
|
||||||
|
Assert.Equal(expectedDescriptor.Attributes[1].Name, descriptor.Attributes[1].Name, StringComparer.Ordinal);
|
||||||
|
Assert.Equal(
|
||||||
|
expectedDescriptor.Attributes[1].PropertyName,
|
||||||
|
descriptor.Attributes[1].PropertyName,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
Assert.Equal(
|
||||||
|
expectedDescriptor.Attributes[1].TypeName,
|
||||||
|
descriptor.Attributes[1].TypeName,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
Assert.Empty(descriptor.RequiredAttributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3355,6 +3355,95 @@ namespace Microsoft.AspNet.Razor.Test.TagHelpers
|
||||||
SourceLocation.Zero)
|
SourceLocation.Zero)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"<p bar='false <strong'",
|
||||||
|
new MarkupBlock(
|
||||||
|
new MarkupTagHelperBlock("p",
|
||||||
|
new List<KeyValuePair<string, SyntaxTreeNode>>
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, SyntaxTreeNode>(
|
||||||
|
"bar",
|
||||||
|
new MarkupBlock(
|
||||||
|
factory.Markup("false"),
|
||||||
|
factory.Markup(" <strong")))
|
||||||
|
})),
|
||||||
|
new []
|
||||||
|
{
|
||||||
|
new RazorError(
|
||||||
|
string.Format(CultureInfo.InvariantCulture, errorFormatNoCloseAngle, "p"),
|
||||||
|
SourceLocation.Zero),
|
||||||
|
new RazorError(
|
||||||
|
string.Format(CultureInfo.InvariantCulture, errorFormatUnclosed, "p"),
|
||||||
|
SourceLocation.Zero)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"<p bar=false'",
|
||||||
|
new MarkupBlock(
|
||||||
|
new MarkupTagHelperBlock("p",
|
||||||
|
new List<KeyValuePair<string, SyntaxTreeNode>>
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, SyntaxTreeNode>(
|
||||||
|
"bar",
|
||||||
|
factory.Markup("false"))
|
||||||
|
})),
|
||||||
|
new []
|
||||||
|
{
|
||||||
|
new RazorError(
|
||||||
|
string.Format(CultureInfo.InvariantCulture, errorFormatNoCloseAngle, "p"),
|
||||||
|
SourceLocation.Zero),
|
||||||
|
new RazorError(
|
||||||
|
string.Format(CultureInfo.InvariantCulture, errorFormatUnclosed, "p"),
|
||||||
|
SourceLocation.Zero),
|
||||||
|
new RazorError(
|
||||||
|
"TagHelper attributes must be welformed.",
|
||||||
|
absoluteIndex: 12,
|
||||||
|
lineIndex: 0,
|
||||||
|
columnIndex: 12)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"<p bar=\"false'",
|
||||||
|
new MarkupBlock(
|
||||||
|
new MarkupTagHelperBlock("p",
|
||||||
|
new List<KeyValuePair<string, SyntaxTreeNode>>
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, SyntaxTreeNode>(
|
||||||
|
"bar",
|
||||||
|
factory.Markup("false'"))
|
||||||
|
})),
|
||||||
|
new []
|
||||||
|
{
|
||||||
|
new RazorError(
|
||||||
|
string.Format(CultureInfo.InvariantCulture, errorFormatNoCloseAngle, "p"),
|
||||||
|
SourceLocation.Zero),
|
||||||
|
new RazorError(
|
||||||
|
string.Format(CultureInfo.InvariantCulture, errorFormatUnclosed, "p"),
|
||||||
|
SourceLocation.Zero)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"<p bar=\"false' ></p>",
|
||||||
|
new MarkupBlock(
|
||||||
|
new MarkupTagHelperBlock("p",
|
||||||
|
new List<KeyValuePair<string, SyntaxTreeNode>>
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, SyntaxTreeNode>(
|
||||||
|
"bar",
|
||||||
|
new MarkupBlock(
|
||||||
|
factory.Markup("false'"),
|
||||||
|
factory.Markup(" ></p>")))
|
||||||
|
})),
|
||||||
|
new []
|
||||||
|
{
|
||||||
|
new RazorError(
|
||||||
|
string.Format(CultureInfo.InvariantCulture, errorFormatNoCloseAngle, "p"),
|
||||||
|
SourceLocation.Zero),
|
||||||
|
new RazorError(
|
||||||
|
string.Format(CultureInfo.InvariantCulture, errorFormatUnclosed, "p"),
|
||||||
|
SourceLocation.Zero)
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"<p foo bar<strong>",
|
"<p foo bar<strong>",
|
||||||
new MarkupBlock(
|
new MarkupBlock(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue