diff --git a/src/Microsoft.AspNetCore.Html.Abstractions/HtmlContentBuilderExtensions.cs b/src/Microsoft.AspNetCore.Html.Abstractions/HtmlContentBuilderExtensions.cs index 439b746a55..f7474ca3f2 100644 --- a/src/Microsoft.AspNetCore.Html.Abstractions/HtmlContentBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.Html.Abstractions/HtmlContentBuilderExtensions.cs @@ -48,7 +48,7 @@ namespace Microsoft.AspNetCore.Html throw new ArgumentNullException(nameof(args)); } - builder.AppendHtml(new HtmlFormatString(format, args)); + builder.AppendHtml(new HtmlFormattableString(format, args)); return builder; } @@ -88,7 +88,7 @@ namespace Microsoft.AspNetCore.Html throw new ArgumentNullException(nameof(args)); } - builder.AppendHtml(new HtmlFormatString(formatProvider, format, args)); + builder.AppendHtml(new HtmlFormattableString(formatProvider, format, args)); return builder; } @@ -219,147 +219,5 @@ namespace Microsoft.AspNetCore.Html builder.AppendHtml(encoded); return builder; } - - [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, HtmlEncoder 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 HtmlEncoder _encoder; - private readonly IFormatProvider _formatProvider; - - public EncodingFormatProvider(IFormatProvider formatProvider, HtmlEncoder encoder) - { - Debug.Assert(formatProvider != null); - Debug.Assert(encoder != null); - - _formatProvider = formatProvider; - _encoder = encoder; - } - - public string Format(string format, object arg, IFormatProvider formatProvider) - { - // These are the cases we need to special case. We trust the HtmlEncodedString or IHtmlContent instance - // to do the right thing with encoding. - var htmlString = arg as HtmlEncodedString; - if (htmlString != null) - { - return htmlString.ToString(); - } - - 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.Encode(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.Encode(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.Encode(result); - } - } - - return string.Empty; - } - - public object GetFormat(Type formatType) - { - if (formatType == typeof(ICustomFormatter)) - { - return this; - } - - return null; - } - } } } diff --git a/src/Microsoft.AspNetCore.Html.Abstractions/HtmlFormattableString.cs b/src/Microsoft.AspNetCore.Html.Abstractions/HtmlFormattableString.cs new file mode 100644 index 0000000000..82e9b69d98 --- /dev/null +++ b/src/Microsoft.AspNetCore.Html.Abstractions/HtmlFormattableString.cs @@ -0,0 +1,184 @@ +// 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.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text.Encodings.Web; + +namespace Microsoft.AspNetCore.Html +{ + /// + /// An implementation of composite string formatting + /// (see https://msdn.microsoft.com/en-us/library/txafckwd(v=vs.110).aspx) which HTML encodes + /// formatted arguments. + /// + [DebuggerDisplay("{DebuggerToString()}")] + public class HtmlFormattableString : IHtmlContent + { + private readonly IFormatProvider _formatProvider; + private readonly string _format; + private readonly object[] _args; + + /// + /// Creates a new with the given and + /// . + /// + /// A composite format string. + /// An array that contains objects to format. + public HtmlFormattableString(string format, params object[] args) + : this(formatProvider: null, format: format, args: args) + { + } + + /// + /// Creates a new with the given , + /// and . + /// + /// An object that provides culture-specific formatting information. + /// A composite format string. + /// An array that contains objects to format. + public HtmlFormattableString(IFormatProvider formatProvider, string format, params object[] args) + { + if (format == null) + { + throw new ArgumentNullException(nameof(format)); + } + + if (args == null) + { + throw new ArgumentNullException(nameof(args)); + } + + _formatProvider = formatProvider ?? CultureInfo.CurrentCulture; + _format = format; + _args = args; + } + + /// + public void WriteTo(TextWriter writer, HtmlEncoder 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 HtmlEncoder _encoder; + private readonly IFormatProvider _formatProvider; + + private StringWriter _writer; + + public EncodingFormatProvider(IFormatProvider formatProvider, HtmlEncoder encoder) + { + Debug.Assert(formatProvider != null); + Debug.Assert(encoder != null); + + _formatProvider = formatProvider; + _encoder = encoder; + } + + public string Format(string format, object arg, IFormatProvider formatProvider) + { + // These are the cases we need to special case. We trust the HtmlEncodedString or IHtmlContent instance + // to do the right thing with encoding. + var htmlString = arg as HtmlEncodedString; + if (htmlString != null) + { + return htmlString.ToString(); + } + + var htmlContent = arg as IHtmlContent; + if (htmlContent != null) + { + _writer = _writer ?? new StringWriter(); + + htmlContent.WriteTo(_writer, _encoder); + + var result = _writer.ToString(); + _writer.GetStringBuilder().Clear(); + + return result; + } + + // 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.Encode(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.Encode(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.Encode(result); + } + } + + return string.Empty; + } + + public object GetFormat(Type formatType) + { + if (formatType == typeof(ICustomFormatter)) + { + return this; + } + + return null; + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Html.Abstractions.Test/HtmlFormattableStringTest.cs b/test/Microsoft.AspNetCore.Html.Abstractions.Test/HtmlFormattableStringTest.cs new file mode 100644 index 0000000000..6f466e6046 --- /dev/null +++ b/test/Microsoft.AspNetCore.Html.Abstractions.Test/HtmlFormattableStringTest.cs @@ -0,0 +1,217 @@ +// 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.Globalization; +using System.IO; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.WebEncoders.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Html +{ + public class HtmlFormattableStringTest + { + [Fact] + public void HtmlFormattableString_EmptyArgs() + { + // Arrange + var formattableString = new HtmlFormattableString("Hello, World!"); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal("Hello, World!", result); + } + + [Fact] + public void HtmlFormattableString_EmptyArgsAndCulture() + { + // Arrange + var formattableString = new HtmlFormattableString(CultureInfo.CurrentCulture, "Hello, World!"); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal("Hello, World!", result); + } + + [Fact] + public void HtmlFormattableString_MultipleArguments() + { + // Arrange + var formattableString = new HtmlFormattableString("{0} {1} {2} {3}!", "First", "Second", "Third", "Fourth"); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal( + "HtmlEncode[[First]] HtmlEncode[[Second]] HtmlEncode[[Third]] HtmlEncode[[Fourth]]!", + result); + } + + [Fact] + public void HtmlFormattableString_WithHtmlEncodedString() + { + // Arrange + var formattableString = new HtmlFormattableString("{0}!", new HtmlEncodedString("First")); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal("First!", result); + } + + [Fact] + public void HtmlFormattableString_WithOtherIHtmlContent() + { + // Arrange + var builder = new HtmlContentBuilder(); + builder.Append("First"); + + var formattableString = new HtmlFormattableString("{0}!", builder); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal("HtmlEncode[[First]]!", result); + } + + // This test is needed to ensure the shared StringWriter gets cleared. + [Fact] + public void HtmlFormattableString_WithMultipleHtmlContentArguments() + { + // Arrange + var formattableString = new HtmlFormattableString( + "Happy {0}, {1}!", + new HtmlEncodedString("Birthday"), + new HtmlContentBuilder().Append("Billy")); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal("Happy Birthday, HtmlEncode[[Billy]]!", result); + } + + [Fact] + public void HtmlFormattableString_WithHtmlEncodedString_AndOffset() + { + // Arrange + var formattableString = new HtmlFormattableString("{0, 20}!", new HtmlEncodedString("First")); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal(" First!", result); + } + + [Fact] + public void HtmlFormattableString_With3Arguments() + { + // Arrange + var formattableString = new HtmlFormattableString("0x{0:X} - {1} equivalent for {2}.", 50, "hex", 50); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal( + "0xHtmlEncode[[32]] - HtmlEncode[[hex]] equivalent for HtmlEncode[[50]].", + result); + } + + [Fact] + public void HtmlFormattableString_WithAlignmentComponent() + { + // Arrange + var formattableString = new HtmlFormattableString("{0, -25} World!", "Hello"); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal( + "HtmlEncode[[Hello]] World!", result); + } + + [Fact] + public void HtmlFormattableString_WithFormatStringComponent() + { + // Arrange + var formattableString = new HtmlFormattableString("0x{0:X}", 50); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal("0xHtmlEncode[[32]]", result); + } + + [Fact] + public void HtmlFormattableString_WithCulture() + { + // Arrange + var formattableString = new HtmlFormattableString( + CultureInfo.InvariantCulture, + "Numbers in InvariantCulture - {0, -5:N} {1} {2} {3}!", + 1.1, + 2.98, + 145.82, + 32.86); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal( + "Numbers in InvariantCulture - HtmlEncode[[1.10]] HtmlEncode[[2.98]] " + + "HtmlEncode[[145.82]] HtmlEncode[[32.86]]!", + result); + } + + [Fact] + [ReplaceCulture("en-US", "en-US")] + public void HtmlFormattableString_UsesPassedInCulture() + { + // Arrange + var culture = new CultureInfo("fr-FR"); + var formattableString = new HtmlFormattableString(culture, "{0} in french!", 1.21); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal("HtmlEncode[[1,21]] in french!", result); + } + + [Fact] + [ReplaceCulture("de-DE", "de-DE")] + public void HtmlFormattableString_UsesCurrentCulture() + { + // Arrange + var formattableString = new HtmlFormattableString("{0:D}", DateTime.Parse("01/02/2015")); + + // Act + var result = HtmlContentToString(formattableString); + + // Assert + Assert.Equal("HtmlEncode[[Sonntag, 1. Februar 2015]]", result); + } + + private static string HtmlContentToString(IHtmlContent content) + { + using (var writer = new StringWriter()) + { + content.WriteTo(writer, new HtmlTestEncoder()); + return writer.ToString(); + } + } + } +}