diff --git a/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs
index e5b254e1e1..abc7c4710b 100644
--- a/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs
+++ b/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs
@@ -218,6 +218,38 @@ namespace Microsoft.AspNet.Razor.Runtime
return string.Format(CultureInfo.CurrentCulture, GetString("TagHelperDescriptorFactory_InvalidBoundAttributeName"), p0, p1, p2);
}
+ ///
+ /// Cannot add a '{0}' with a null '{1}'.
+ ///
+ internal static string TagHelperAttributeList_CannotAddWithNullName
+ {
+ get { return GetString("TagHelperAttributeList_CannotAddWithNullName"); }
+ }
+
+ ///
+ /// Cannot add a '{0}' with a null '{1}'.
+ ///
+ internal static string FormatTagHelperAttributeList_CannotAddWithNullName(object p0, object p1)
+ {
+ return string.Format(CultureInfo.CurrentCulture, GetString("TagHelperAttributeList_CannotAddWithNullName"), p0, p1);
+ }
+
+ ///
+ /// Cannot add a {0} with inconsistent names. The {1} property '{2}' must match the location '{3}'.
+ ///
+ internal static string TagHelperAttributeList_CannotAddAttribute
+ {
+ get { return GetString("TagHelperAttributeList_CannotAddAttribute"); }
+ }
+
+ ///
+ /// Cannot add a {0} with inconsistent names. The {1} property '{2}' must match the location '{3}'.
+ ///
+ internal static string FormatTagHelperAttributeList_CannotAddAttribute(object p0, object p1, object p2, object p3)
+ {
+ return string.Format(CultureInfo.CurrentCulture, GetString("TagHelperAttributeList_CannotAddAttribute"), p0, p1, p2, p3);
+ }
+
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);
diff --git a/src/Microsoft.AspNet.Razor.Runtime/Resources.resx b/src/Microsoft.AspNet.Razor.Runtime/Resources.resx
index 19695614ab..af51cbfab4 100644
--- a/src/Microsoft.AspNet.Razor.Runtime/Resources.resx
+++ b/src/Microsoft.AspNet.Razor.Runtime/Resources.resx
@@ -156,4 +156,10 @@
Invalid tag helper bound property '{0}.{1}'. Tag helpers cannot bind to HTML attributes beginning with '{2}'.
+
+ Cannot add a '{0}' with a null '{1}'.
+
+
+ Cannot add a {0} with inconsistent names. The {1} property '{2}' must match the location '{3}'.
+
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/IReadOnlyTagHelperAttribute.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/IReadOnlyTagHelperAttribute.cs
new file mode 100644
index 0000000000..c297b26d79
--- /dev/null
+++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/IReadOnlyTagHelperAttribute.cs
@@ -0,0 +1,23 @@
+// 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;
+
+namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
+{
+ ///
+ /// A read-only HTML tag helper attribute.
+ ///
+ public interface IReadOnlyTagHelperAttribute : IEquatable
+ {
+ ///
+ /// Gets the name of the attribute.
+ ///
+ string Name { get; }
+
+ ///
+ /// Gets the value of the attribute.
+ ///
+ object Value { get; }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/ReadOnlyTagHelperAttributeList.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/ReadOnlyTagHelperAttributeList.cs
new file mode 100644
index 0000000000..a4653e38a4
--- /dev/null
+++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/ReadOnlyTagHelperAttributeList.cs
@@ -0,0 +1,195 @@
+// 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;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Framework.Internal;
+
+namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
+{
+ ///
+ /// A read-only collection of s.
+ ///
+ ///
+ /// The type of s in the collection.
+ ///
+ public class ReadOnlyTagHelperAttributeList : IReadOnlyList
+ where TAttribute : IReadOnlyTagHelperAttribute
+ {
+ ///
+ /// Instantiates a new instance of with an empty
+ /// collection.
+ ///
+ protected ReadOnlyTagHelperAttributeList()
+ {
+ Attributes = new List();
+ }
+
+ ///
+ /// Instantiates a new instance of with the specified
+ /// .
+ ///
+ /// The collection to wrap.
+ public ReadOnlyTagHelperAttributeList([NotNull] IEnumerable attributes)
+ {
+ Attributes = new List(attributes);
+ }
+
+ ///
+ /// The underlying collection of s.
+ ///
+ /// Intended for use in a non-read-only subclass. Changes to this will
+ /// affect all getters that provides.
+ protected List Attributes { get; }
+
+ ///
+ public TAttribute this[int index]
+ {
+ get
+ {
+ return Attributes[index];
+ }
+ }
+
+ ///
+ /// Gets the first with
+ /// matching .
+ ///
+ ///
+ /// The of the to get.
+ ///
+ /// The first with
+ /// matching .
+ ///
+ /// is compared case-insensitively.
+ public TAttribute this[[NotNull] string name]
+ {
+ get
+ {
+ return Attributes.FirstOrDefault(attribute => NameEquals(name, attribute));
+ }
+ }
+
+ ///
+ public int Count
+ {
+ get
+ {
+ return Attributes.Count;
+ }
+ }
+
+ ///
+ /// Determines whether a matching exists in the
+ /// collection.
+ ///
+ /// The to locate.
+ ///
+ /// true if an matching exists in the
+ /// collection; otherwise, false.
+ ///
+ ///
+ /// s is compared case-insensitively.
+ ///
+ public bool Contains([NotNull] TAttribute item)
+ {
+ return Attributes.Contains(item);
+ }
+
+ ///
+ /// Determines whether a with the same
+ /// exists in the collection.
+ ///
+ /// The of the
+ /// to get.
+ ///
+ /// true if a with the same
+ /// exists in the collection; otherwise, false.
+ ///
+ /// is compared case-insensitively.
+ public bool ContainsName([NotNull] string name)
+ {
+ return Attributes.Any(attribute => NameEquals(name, attribute));
+ }
+
+ ///
+ /// Searches for a matching in the collection and
+ /// returns the zero-based index of the first occurrence.
+ ///
+ /// The to locate.
+ /// The zero-based index of the first occurrence of a matching
+ /// in the collection, if found; otherwise, –1.
+ ///
+ /// s is compared case-insensitively.
+ ///
+ public int IndexOf([NotNull] TAttribute item)
+ {
+ return Attributes.IndexOf(item);
+ }
+
+ ///
+ /// Retrieves the first with
+ /// matching .
+ ///
+ /// The of the
+ /// to get.
+ /// When this method returns, the first with
+ /// matching , if found; otherwise,
+ /// null.
+ /// true if a with the same
+ /// exists in the collection; otherwise, false.
+ /// is compared case-insensitively.
+ public bool TryGetAttribute([NotNull] string name, out TAttribute attribute)
+ {
+ attribute = Attributes.FirstOrDefault(attr => NameEquals(name, attr));
+
+ return attribute != null;
+ }
+
+ ///
+ /// Retrieves s in the collection with
+ /// matching .
+ ///
+ /// The of the
+ /// s to get.
+ /// When this method returns, the s with
+ /// matching , if at least one is
+ /// found; otherwise, null.
+ /// true if at least one with the same
+ /// exists in the collection; otherwise, false.
+ /// is compared case-insensitively.
+ public bool TryGetAttributes([NotNull] string name, out IEnumerable attributes)
+ {
+ attributes = Attributes.Where(attribute => NameEquals(name, attribute));
+
+ return attributes.Any();
+ }
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+ ///
+ public IEnumerator GetEnumerator()
+ {
+ return Attributes.GetEnumerator();
+ }
+
+ ///
+ /// Determines if the specified has the same name as .
+ ///
+ /// The value to compare against s
+ /// .
+ /// The attribute to compare against.
+ /// true if case-insensitively matches s
+ /// .
+ protected static bool NameEquals(string name, [NotNull] TAttribute attribute)
+ {
+ return string.Equals(name, attribute.Name, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperAttribute.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperAttribute.cs
new file mode 100644
index 0000000000..16abc15b2c
--- /dev/null
+++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperAttribute.cs
@@ -0,0 +1,82 @@
+// 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.Internal.Web.Utils;
+
+namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
+{
+ ///
+ /// An HTML tag helper attribute.
+ ///
+ public class TagHelperAttribute : IReadOnlyTagHelperAttribute
+ {
+ private static readonly int TypeHashCode = typeof(TagHelperAttribute).GetHashCode();
+
+ ///
+ /// Instantiates a new instance of .
+ ///
+ public TagHelperAttribute()
+ {
+ }
+
+ ///
+ /// Instantiates a new instance of with the specified
+ /// and .
+ ///
+ /// The of the attribute.
+ /// The of the attribute.
+ public TagHelperAttribute(string name, object value)
+ {
+ Name = name;
+ Value = value;
+ }
+
+ ///
+ /// Gets or sets the name of the attribute.
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// Gets or sets the value of the attribute.
+ ///
+ public object Value { get; set; }
+
+ ///
+ /// Converts the specified into a .
+ ///
+ /// The of the created .
+ /// Created s is set to null.
+ public static implicit operator TagHelperAttribute(string value)
+ {
+ return new TagHelperAttribute
+ {
+ Value = value
+ };
+ }
+
+ ///
+ /// is compared case-insensitively.
+ public bool Equals(IReadOnlyTagHelperAttribute other)
+ {
+ return
+ other != null &&
+ string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase) &&
+ Equals(Value, other.Value);
+ }
+
+ ///
+ public override bool Equals(object obj)
+ {
+ var other = obj as IReadOnlyTagHelperAttribute;
+
+ return Equals(other);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return TypeHashCode;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperAttributeList.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperAttributeList.cs
new file mode 100644
index 0000000000..d0f2a0c0f6
--- /dev/null
+++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperAttributeList.cs
@@ -0,0 +1,239 @@
+// 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 Microsoft.Framework.Internal;
+
+namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
+{
+ ///
+ /// A collection of s.
+ ///
+ public class TagHelperAttributeList : ReadOnlyTagHelperAttributeList, IList
+ {
+ ///
+ /// Instantiates a new instance of with an empty collection.
+ ///
+ public TagHelperAttributeList()
+ : base()
+ {
+ }
+
+ ///
+ /// Instantiates a new instance of with the specified
+ /// .
+ ///
+ /// The collection to wrap.
+ public TagHelperAttributeList([NotNull] IEnumerable attributes)
+ : base(attributes)
+ {
+ }
+
+ ///
+ ///
+ /// 's must not be null.
+ ///
+ public new TagHelperAttribute this[int index]
+ {
+ get
+ {
+ return base[index];
+ }
+ [param: NotNull]
+ set
+ {
+ if (value.Name == null)
+ {
+ throw new ArgumentException(
+ Resources.FormatTagHelperAttributeList_CannotAddWithNullName(
+ typeof(TagHelperAttribute).FullName,
+ nameof(TagHelperAttribute.Name)),
+ nameof(value));
+ }
+
+ Attributes[index] = value;
+ }
+ }
+
+ ///
+ /// Gets the first with matching
+ /// . When setting, replaces the first matching
+ /// with the specified and removes any additional
+ /// matching s. If a matching is not found,
+ /// adds the specified to the end of the collection.
+ ///
+ ///
+ /// The of the to get or set.
+ ///
+ /// The first with matching
+ /// .
+ ///
+ /// is compared case-insensitively. When setting,
+ /// s must be null or
+ /// case-insensitively match the specified .
+ ///
+ ///
+ /// var attributes = new TagHelperAttributeList();
+ ///
+ /// // Will "value" be converted to a TagHelperAttribute with a null Name
+ /// attributes["name"] = "value";
+ ///
+ /// // TagHelperAttribute.Name must match the specified name.
+ /// attributes["name"] = new TagHelperAttribute("name", "value");
+ ///
+ ///
+ public new TagHelperAttribute this[[NotNull] string name]
+ {
+ get
+ {
+ return base[name];
+ }
+ [param: NotNull]
+ set
+ {
+ // Name will be null if user attempts to set the attribute via an implicit conversion:
+ // output.Attributes["someName"] = "someValue"
+ if (value.Name == null)
+ {
+ value.Name = name;
+ }
+ else if (!NameEquals(name, value))
+ {
+ throw new ArgumentException(
+ Resources.FormatTagHelperAttributeList_CannotAddAttribute(
+ nameof(TagHelperAttribute),
+ nameof(TagHelperAttribute.Name),
+ value.Name,
+ name),
+ nameof(name));
+ }
+
+ var attributeReplaced = false;
+
+ for (var i = 0; i < Attributes.Count; i++)
+ {
+ if (NameEquals(name, Attributes[i]))
+ {
+ // We replace the first attribute with the provided value, remove all the rest.
+ if (!attributeReplaced)
+ {
+ // We replace the first attribute we find with the same name.
+ Attributes[i] = value;
+ attributeReplaced = true;
+ }
+ else
+ {
+ Attributes.RemoveAt(i--);
+ }
+ }
+ }
+
+ // If we didn't replace an attribute value we should add value to the end of the collection.
+ if (!attributeReplaced)
+ {
+ Add(value);
+ }
+ }
+ }
+
+ ///
+ bool ICollection.IsReadOnly
+ {
+ get
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Adds a to the end of the collection with the specified
+ /// and .
+ ///
+ /// The of the attribute to add.
+ /// The of the attribute to add.
+ public void Add([NotNull] string name, object value)
+ {
+ Attributes.Add(new TagHelperAttribute(name, value));
+ }
+
+ ///
+ ///
+ /// 's must not be null.
+ ///
+ public void Add([NotNull] TagHelperAttribute attribute)
+ {
+ if (attribute.Name == null)
+ {
+ throw new ArgumentException(
+ Resources.FormatTagHelperAttributeList_CannotAddWithNullName(
+ typeof(TagHelperAttribute).FullName,
+ nameof(TagHelperAttribute.Name)),
+ nameof(attribute));
+ }
+
+ Attributes.Add(attribute);
+ }
+
+ ///
+ ///
+ /// 's must not be null.
+ ///
+ public void Insert(int index, [NotNull] TagHelperAttribute attribute)
+ {
+ if (attribute.Name == null)
+ {
+ throw new ArgumentException(
+ Resources.FormatTagHelperAttributeList_CannotAddWithNullName(
+ typeof(TagHelperAttribute).FullName,
+ nameof(TagHelperAttribute.Name)),
+ nameof(attribute));
+ }
+
+ Attributes.Insert(index, attribute);
+ }
+
+ ///
+ public void CopyTo([NotNull] TagHelperAttribute[] array, int index)
+ {
+ Attributes.CopyTo(array, index);
+ }
+
+ ///
+ ///
+ /// s is compared case-insensitively.
+ ///
+ public bool Remove([NotNull] TagHelperAttribute attribute)
+ {
+ return Attributes.Remove(attribute);
+ }
+
+ ///
+ public void RemoveAt(int index)
+ {
+ Attributes.RemoveAt(index);
+ }
+
+ ///
+ /// Removes all s with matching
+ /// .
+ ///
+ ///
+ /// The of s to remove.
+ ///
+ ///
+ /// true if at least 1 was removed; otherwise, false.
+ ///
+ /// is compared case-insensitively.
+ public bool RemoveAll([NotNull] string name)
+ {
+ return Attributes.RemoveAll(attribute => NameEquals(name, attribute)) > 0;
+ }
+
+ ///
+ public void Clear()
+ {
+ Attributes.Clear();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperContext.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperContext.cs
index 0d9ac0d58a..973a246444 100644
--- a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperContext.cs
+++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperContext.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using Microsoft.Framework.Internal;
@@ -25,12 +26,13 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers
/// A delegate used to execute and retrieve the rendered child content
/// asynchronously.
public TagHelperContext(
- [NotNull] IDictionary allAttributes,
+ [NotNull] IEnumerable allAttributes,
[NotNull] IDictionary