diff --git a/src/Microsoft.AspNet.Html.Abstractions/HtmlContentBuilderExtensions.cs b/src/Microsoft.AspNet.Html.Abstractions/HtmlContentBuilderExtensions.cs index bfd5e3ef03..0e15077764 100644 --- a/src/Microsoft.AspNet.Html.Abstractions/HtmlContentBuilderExtensions.cs +++ b/src/Microsoft.AspNet.Html.Abstractions/HtmlContentBuilderExtensions.cs @@ -3,7 +3,9 @@ using System; using System.Diagnostics; +using System.Globalization; using System.IO; +using System.Text; using Microsoft.Framework.WebEncoders; namespace Microsoft.AspNet.Html.Abstractions @@ -13,6 +15,82 @@ namespace Microsoft.AspNet.Html.Abstractions /// public static class HtmlContentBuilderExtensions { + /// + /// Appends the specified to the existing content after replacing each format + /// item with the HTML encoded representation of the corresponding item in the + /// array. + /// + /// + /// The composite format (see http://msdn.microsoft.com/en-us/library/txafckwd.aspx). + /// The format string is assumed to be HTML encoded as-provided, and no further encoding will be performed. + /// + /// + /// The object array to format. Each element in the array will be formatted and then HTML encoded. + /// + /// A reference to this instance after the append operation has completed. + public static IHtmlContentBuilder AppendFormat( + this IHtmlContentBuilder builder, + string format, + params object[] args) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (format == null) + { + throw new ArgumentNullException(nameof(format)); + } + + if (args == null) + { + throw new ArgumentNullException(nameof(args)); + } + + builder.Append(new HtmlFormatString(format, args)); + return builder; + } + + /// + /// Appends the specified to the existing content with information from the + /// after replacing each format item with the HTML encoded + /// representation of the corresponding item in the array. + /// + /// An object that supplies culture-specific formatting information. + /// + /// The composite format (see http://msdn.microsoft.com/en-us/library/txafckwd.aspx). + /// The format string is assumed to be HTML encoded as-provided, and no further encoding will be performed. + /// + /// + /// The object array to format. Each element in the array will be formatted and then HTML encoded. + /// + /// A reference to this instance after the append operation has completed. + public static IHtmlContentBuilder AppendFormat( + this IHtmlContentBuilder builder, + IFormatProvider formatProvider, + string format, + params object[] args) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (format == null) + { + throw new ArgumentNullException(nameof(format)); + } + + if (args == null) + { + throw new ArgumentNullException(nameof(args)); + } + + builder.Append(new HtmlFormatString(formatProvider, format, args)); + return builder; + } + /// /// Appends an . /// @@ -132,5 +210,141 @@ namespace Microsoft.AspNet.Html.Abstractions } } } + + [DebuggerDisplay("{DebuggerToString()}")] + private class HtmlFormatString : IHtmlContent + { + private readonly IFormatProvider _formatProvider; + private readonly string _format; + private readonly object[] _args; + + public HtmlFormatString(string format, object[] args) + : this(null, format, args) + { + } + + public HtmlFormatString(IFormatProvider formatProvider, string format, object[] args) + { + Debug.Assert(format != null); + Debug.Assert(args != null); + + _formatProvider = formatProvider ?? CultureInfo.CurrentCulture; + _format = format; + _args = args; + } + + public void WriteTo(TextWriter writer, IHtmlEncoder encoder) + { + if (writer == null) + { + throw new ArgumentNullException(nameof(writer)); + } + + if (encoder == null) + { + throw new ArgumentNullException(nameof(encoder)); + } + + var formatProvider = new EncodingFormatProvider(_formatProvider, encoder); + writer.Write(string.Format(formatProvider, _format, _args)); + } + + private string DebuggerToString() + { + using (var writer = new StringWriter()) + { + WriteTo(writer, HtmlEncoder.Default); + return writer.ToString(); + } + } + } + + // This class implements Html encoding via an ICustomFormatter. Passing an instance of this + // class into a string.Format method or anything similar will evaluate arguments implementing + // IHtmlContent without HTML encoding them, and will give other arguments the standard + // composite format string treatment, and then HTML encode the result. + // + // Plenty of examples of ICustomFormatter and the interactions with string.Format here: + // https://msdn.microsoft.com/en-us/library/system.string.format(v=vs.110).aspx#Format6_Example + private class EncodingFormatProvider : IFormatProvider, ICustomFormatter + { + private readonly IHtmlEncoder _encoder; + private readonly IFormatProvider _formatProvider; + + public EncodingFormatProvider(IFormatProvider formatProvider, IHtmlEncoder encoder) + { + Debug.Assert(formatProvider != null); + Debug.Assert(encoder != null); + + _formatProvider = formatProvider; + _encoder = encoder; + } + + public string Format(string format, object arg, IFormatProvider formatProvider) + { + // This is the case we need to special case. We trust the IHtmlContent instance to do the + // right thing with encoding. + var htmlContent = arg as IHtmlContent; + if (htmlContent != null) + { + using (var writer = new StringWriter()) + { + htmlContent.WriteTo(writer, _encoder); + return writer.ToString(); + } + } + + // If we get here then 'arg' is not an IHtmlContent, and we want to handle it the way a normal + // string.Format would work, but then HTML encode the result. + // + // First check for an ICustomFormatter - if the IFormatProvider is a CultureInfo, then it's likely + // that ICustomFormatter will be null. + var customFormatter = (ICustomFormatter)_formatProvider.GetFormat(typeof(ICustomFormatter)); + if (customFormatter != null) + { + var result = customFormatter.Format(format, arg, _formatProvider); + if (result != null) + { + return _encoder.HtmlEncode(result); + } + } + + // Next check if 'arg' is an IFormattable (DateTime is an example). + // + // An IFormattable will likely call back into the IFormatterProvider and ask for more information + // about how to format itself. This is the typical case when IFormatterProvider is a CultureInfo. + var formattable = arg as IFormattable; + if (formattable != null) + { + var result = formattable.ToString(format, _formatProvider); + if (result != null) + { + return _encoder.HtmlEncode(result); + } + } + + // If we get here then there's nothing really smart left to try. + if (arg != null) + { + var result = arg.ToString(); + if (result != null) + { + return _encoder.HtmlEncode(result); + } + } + + return string.Empty; + } + + public object GetFormat(Type formatType) + { + if (formatType == typeof(ICustomFormatter)) + { + return this; + } + + return null; + } + } } } diff --git a/src/Microsoft.AspNet.Html.Abstractions/project.json b/src/Microsoft.AspNet.Html.Abstractions/project.json index eee0e0ab1b..e4f6452bf1 100644 --- a/src/Microsoft.AspNet.Html.Abstractions/project.json +++ b/src/Microsoft.AspNet.Html.Abstractions/project.json @@ -16,7 +16,7 @@ "dnx451": { }, "dnxcore50": { "dependencies": { - "System.Resources.ResourceManager": "4.0.1-beta-" + "System.Resources.ResourceManager": "4.0.1-beta-*" } } } diff --git a/src/Microsoft.Framework.WebEncoders.Core/project.json b/src/Microsoft.Framework.WebEncoders.Core/project.json index 54dbd1c28e..0da3e70874 100644 --- a/src/Microsoft.Framework.WebEncoders.Core/project.json +++ b/src/Microsoft.Framework.WebEncoders.Core/project.json @@ -18,7 +18,7 @@ "System.Diagnostics.Debug": "4.0.11-beta-*", "System.IO": "4.0.11-beta-*", "System.Reflection": "4.0.10-*", - "System.Resources.ResourceManager": "4.0.1-beta-", + "System.Resources.ResourceManager": "4.0.1-beta-*", "System.Runtime.Extensions": "4.0.11-beta-*", "System.Threading": "4.0.11-beta-*" } diff --git a/test/Microsoft.AspNet.Html.Abstractions.Test/HtmlContentBuilderExtensionsTest.cs b/test/Microsoft.AspNet.Html.Abstractions.Test/HtmlContentBuilderExtensionsTest.cs index 3aba376094..7c340933ec 100644 --- a/test/Microsoft.AspNet.Html.Abstractions.Test/HtmlContentBuilderExtensionsTest.cs +++ b/test/Microsoft.AspNet.Html.Abstractions.Test/HtmlContentBuilderExtensionsTest.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; +using Microsoft.AspNet.Testing; using Microsoft.Framework.WebEncoders; using Microsoft.Framework.WebEncoders.Testing; using Xunit; @@ -126,6 +128,220 @@ namespace Microsoft.AspNet.Html.Abstractions.Test entry => Assert.Equal("Hi", Assert.IsType(entry).Value)); } + [Fact] + public void Builder_AppendFormat() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat("{0} {1} {2} {3}!", "First", "Second", "Third", "Fourth"); + + // Assert + Assert.Equal( + "HtmlEncode[[First]] HtmlEncode[[Second]] HtmlEncode[[Third]] HtmlEncode[[Fourth]]!", + HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormat_HtmlContent() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat("{0}!", new EncodedString("First")); + + // Assert + Assert.Equal( + "First!", + HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormatContent_With1Argument() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat("0x{0:X} - hex equivalent for 50.", 50); + + // Assert + Assert.Equal( + "0xHtmlEncode[[32]] - hex equivalent for 50.", + HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormatContent_With2Arguments() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat("0x{0:X} - hex equivalent for {1}.", 50, 50); + + // Assert + Assert.Equal( + "0xHtmlEncode[[32]] - hex equivalent for HtmlEncode[[50]].", + HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormatContent_With3Arguments() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat("0x{0:X} - {1} equivalent for {2}.", 50, "hex", 50); + + // Assert + Assert.Equal( + "0xHtmlEncode[[32]] - HtmlEncode[[hex]] equivalent for HtmlEncode[[50]].", + HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormat_WithAlignmentComponent() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat("{0, -25} World!", "Hello"); + + // Assert + Assert.Equal( + "HtmlEncode[[Hello]] World!", + HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormat_WithFormatStringComponent() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat("0x{0:X}", 50); + + // Assert + Assert.Equal("0xHtmlEncode[[32]]", HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormat_WithCulture() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat( + CultureInfo.InvariantCulture, + "Numbers in InvariantCulture - {0, -5:N} {1} {2} {3}!", + 1.1, + 2.98, + 145.82, + 32.86); + + // Assert + Assert.Equal( + "Numbers in InvariantCulture - HtmlEncode[[1.10]] HtmlEncode[[2.98]] " + + "HtmlEncode[[145.82]] HtmlEncode[[32.86]]!", + HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormat_WithCulture_1Argument() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat( + CultureInfo.InvariantCulture, + "Numbers in InvariantCulture - {0:N}!", + 1.1); + + // Assert + Assert.Equal( + "Numbers in InvariantCulture - HtmlEncode[[1.10]]!", + HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormat_WithCulture_2Arguments() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat( + CultureInfo.InvariantCulture, + "Numbers in InvariantCulture - {0:N} {1}!", + 1.1, + 2.98); + + // Assert + Assert.Equal( + "Numbers in InvariantCulture - HtmlEncode[[1.10]] HtmlEncode[[2.98]]!", + HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormat_WithCulture_3Arguments() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat( + CultureInfo.InvariantCulture, + "Numbers in InvariantCulture - {0:N} {1} {2}!", + 1.1, + 2.98, + 3.12); + + // Assert + Assert.Equal( + "Numbers in InvariantCulture - HtmlEncode[[1.10]] HtmlEncode[[2.98]] HtmlEncode[[3.12]]!", + HtmlContentToString(builder)); + } + + [Fact] + public void Builder_AppendFormat_WithDifferentCulture() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + var culture = new CultureInfo("fr-FR"); + + // Act + builder.AppendFormat(culture, "{0} in french!", 1.21); + + // Assert + Assert.Equal( + "HtmlEncode[[1,21]] in french!", + HtmlContentToString(builder)); + } + + [Fact] + [ReplaceCulture] + public void Builder_AppendFormat_WithDifferentCurrentCulture() + { + // Arrange + var builder = new TestHtmlContentBuilder(); + + // Act + builder.AppendFormat(CultureInfo.CurrentCulture, "{0:D}", DateTime.Parse("01/02/2015")); + + // Assert + Assert.Equal( + "HtmlEncode[[01 February 2015]]", + HtmlContentToString(builder)); + } + private static string HtmlContentToString(IHtmlContent content) { using (var writer = new StringWriter()) @@ -164,7 +380,10 @@ namespace Microsoft.AspNet.Html.Abstractions.Test public void WriteTo(TextWriter writer, IHtmlEncoder encoder) { - throw new NotImplementedException(); + foreach (var entry in Entries) + { + entry.WriteTo(writer, encoder); + } } } @@ -179,7 +398,7 @@ namespace Microsoft.AspNet.Html.Abstractions.Test public void WriteTo(TextWriter writer, IHtmlEncoder encoder) { - throw new NotImplementedException(); + writer.Write(Value); } } @@ -194,7 +413,7 @@ namespace Microsoft.AspNet.Html.Abstractions.Test public void WriteTo(TextWriter writer, IHtmlEncoder encoder) { - throw new NotImplementedException(); + encoder.HtmlEncode(Value, writer); } }