diff --git a/src/Ramstack.HtmxToolkit/Internal/JsonOptions.cs b/src/Ramstack.HtmxToolkit/Internal/JsonOptions.cs index 676f597..4b8e29f 100644 --- a/src/Ramstack.HtmxToolkit/Internal/JsonOptions.cs +++ b/src/Ramstack.HtmxToolkit/Internal/JsonOptions.cs @@ -1,5 +1,7 @@ +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Unicode; namespace Ramstack.HtmxToolkit.Internal; @@ -8,6 +10,19 @@ namespace Ramstack.HtmxToolkit.Internal; /// internal static class JsonOptions { + /// + /// Encodes all Unicode ranges while escaping JavaScript and HTML-sensitive characters. + /// + private static readonly JavaScriptEncoder s_encoder = + JavaScriptEncoder.Create(new TextEncoderSettings(UnicodeRanges.All)); + + /// + /// Configures serializer options to preserve Unicode characters while escaping HTML-sensitive characters. + /// + /// The serializer options to configure. + public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) => + options.Encoder = s_encoder; + /// /// JSON serializer options using for property names and dictionary keys, /// and ignoring properties with values. diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs index d02b71a..95ecb92 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs @@ -1,11 +1,26 @@ using System.Text.Json.Serialization; +using Ramstack.HtmxToolkit.Internal; + namespace Ramstack.HtmxToolkit.TagHelpers; +/// +/// Provides source-generated JSON serialization metadata for HTMX configuration data. +/// [JsonSourceGenerationOptions( WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - GenerationMode = JsonSourceGenerationMode.Serialization)] + GenerationMode = JsonSourceGenerationMode.Default)] [JsonSerializable(typeof(HtmxConfigTagHelper.HtmxConfigData))] -internal partial class HtmxConfigJsonSerializerContext : JsonSerializerContext; +internal partial class HtmxConfigJsonSerializerContext : JsonSerializerContext +{ + /// + /// Initializes the default serializer context with HTML-safe Unicode encoding. + /// + static HtmxConfigJsonSerializerContext() + { + JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions); + s_defaultContext = new HtmxConfigJsonSerializerContext(s_defaultOptions); + } +} diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs index db3ef07..d6e45b7 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs @@ -516,10 +516,8 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu if (IncludeAntiForgeryToken) _config.AntiForgery = antiforgery.GetAndStoreTokens(ViewContext.HttpContext); - var config = new HtmlString( - JsonSerializer.Serialize( - _config, - HtmxConfigJsonSerializerContext.Default.HtmxConfigData)); + var info = HtmxConfigJsonSerializerContext.Default.HtmxConfigData; + var config = new HtmlString(JsonSerializer.Serialize(_config, info)); output.Attributes.SetAttribute( new TagHelperAttribute("content", config, HtmlAttributeValueStyle.SingleQuotes)); diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxDictionaryJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxDictionaryJsonSerializerContext.cs index ae4ce45..5aea042 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxDictionaryJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxDictionaryJsonSerializerContext.cs @@ -1,7 +1,22 @@ using System.Text.Json.Serialization; +using Ramstack.HtmxToolkit.Internal; + namespace Ramstack.HtmxToolkit.TagHelpers; +/// +/// Provides source-generated JSON serialization metadata for HTMX string dictionaries. +/// [JsonSerializable(typeof(IDictionary))] -[JsonSourceGenerationOptions(WriteIndented = false, GenerationMode = JsonSourceGenerationMode.Serialization)] -internal partial class HtmxDictionaryJsonSerializerContext : JsonSerializerContext; +[JsonSourceGenerationOptions(WriteIndented = false, GenerationMode = JsonSourceGenerationMode.Default)] +internal partial class HtmxDictionaryJsonSerializerContext : JsonSerializerContext +{ + /// + /// Initializes the default serializer context with HTML-safe Unicode encoding. + /// + static HtmxDictionaryJsonSerializerContext() + { + JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions); + s_defaultContext = new HtmxDictionaryJsonSerializerContext(s_defaultOptions); + } +} diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs index 9204d43..d69f0fc 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs @@ -42,8 +42,7 @@ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput outp var headers = new HtmlString(JsonSerializer.Serialize(Headers, info)); output.Attributes.SetAttribute( - new TagHelperAttribute("hx-headers", headers, HtmlAttributeValueStyle.SingleQuotes) - ); + new TagHelperAttribute("hx-headers", headers, HtmlAttributeValueStyle.SingleQuotes)); } return Task.CompletedTask; diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs index 9db20e7..7bfdeac 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs @@ -1,11 +1,26 @@ using System.Text.Json.Serialization; +using Ramstack.HtmxToolkit.Internal; + namespace Ramstack.HtmxToolkit.TagHelpers; +/// +/// Provides source-generated JSON serialization metadata for HTMX request configuration data. +/// [JsonSourceGenerationOptions( WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - GenerationMode = JsonSourceGenerationMode.Serialization)] + GenerationMode = JsonSourceGenerationMode.Default)] [JsonSerializable(typeof(HtmxRequestTagHelper.HtmxRequestData))] -internal partial class HtmxRequestJsonSerializerContext : JsonSerializerContext; +internal partial class HtmxRequestJsonSerializerContext : JsonSerializerContext +{ + /// + /// Initializes the default serializer context with HTML-safe Unicode encoding. + /// + static HtmxRequestJsonSerializerContext() + { + JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions); + s_defaultContext = new HtmxRequestJsonSerializerContext(s_defaultOptions); + } +} diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs index bb2b429..749afed 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs @@ -60,11 +60,9 @@ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput outp { if (Timeout is not null || Credentials is not null || NoHeaders is not null) { - var request = new HtmlString( - JsonSerializer.Serialize(_request, HtmxRequestJsonSerializerContext.Default.HtmxRequestData)); - - output.Attributes.SetAttribute( - new TagHelperAttribute("hx-request", request, HtmlAttributeValueStyle.SingleQuotes)); + var info = HtmxRequestJsonSerializerContext.Default.HtmxRequestData; + var request = new HtmlString(JsonSerializer.Serialize(_request, info)); + output.Attributes.SetAttribute(new TagHelperAttribute("hx-request", request)); } return Task.CompletedTask; diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs index 6a4503a..ddc87fd 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs @@ -38,7 +38,8 @@ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput outp { if (Values is { Count: > 0 } values) { - var json = JsonSerializer.Serialize(values, HtmxDictionaryJsonSerializerContext.Default.IDictionaryStringString); + var info = HtmxDictionaryJsonSerializerContext.Default.IDictionaryStringString; + var json = JsonSerializer.Serialize(values, info); var attribute = new TagHelperAttribute("hx-vals", new HtmlString(json), HtmlAttributeValueStyle.SingleQuotes); output.Attributes.SetAttribute(attribute); diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs index 0d73752..372195d 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Razor.TagHelpers; @@ -60,7 +61,7 @@ public async Task ProcessAsync_SerializesConfiguredOptions() DefaultSwapDelay = 250, DefaultSettleDelay = 300, IncludeIndicatorStyles = false, - IndicatorClass = "my-indicator", + IndicatorClass = "индикатор's", RequestClass = "my-request", AddedClass = "my-added", SwappingClass = "my-swapping", @@ -94,7 +95,13 @@ public async Task ProcessAsync_SerializesConfiguredOptions() var output = TestHelper.CreateTagHelperOutput(); await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); - var json = JsonHelper.ParseJson(GetContent(output)); + Assert.That(output.Attributes["content"]!.Value, Is.TypeOf()); + + var content = GetContent(output); + Assert.That(content, Does.Contain("\"индикатор\\u0027s\"")); + Assert.That(content, Does.Not.Contain("'")); + + var json = JsonHelper.ParseJson(content); Assert.That(json["historyEnabled"].GetBoolean(), Is.False); Assert.That(json["historyCacheSize"].GetInt32(), Is.EqualTo(42)); @@ -103,7 +110,7 @@ public async Task ProcessAsync_SerializesConfiguredOptions() Assert.That(json["defaultSwapDelay"].GetInt32(), Is.EqualTo(250)); Assert.That(json["defaultSettleDelay"].GetInt32(), Is.EqualTo(300)); Assert.That(json["includeIndicatorStyles"].GetBoolean(), Is.False); - Assert.That(json["indicatorClass"].GetString(), Is.EqualTo("my-indicator")); + Assert.That(json["indicatorClass"].GetString(), Is.EqualTo("индикатор's")); Assert.That(json["requestClass"].GetString(), Is.EqualTo("my-request")); Assert.That(json["addedClass"].GetString(), Is.EqualTo("my-added")); Assert.That(json["swappingClass"].GetString(), Is.EqualTo("my-swapping")); diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs index 70956a2..3186c9f 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Html; + namespace Ramstack.HtmxToolkit.Tests; [TestFixture] @@ -7,26 +9,38 @@ public class HtmxHeaderTagHelperTests public async Task ProcessAsync_SerializesHeaders() { var output = TestHelper.CreateTagHelperOutput(); - var helper = new HtmxHeaderTagHelper(); - helper.Headers["X-Requested-With"] = "XMLHttpRequest"; - helper.Headers["X-Custom"] = "value"; + var helper = new HtmxHeaderTagHelper + { + Headers = + { + ["X-Requested-With"] = "XMLHttpRequest", + ["X-Custom"] = "value's" + } + }; await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); var attribute = output.Attributes["hx-headers"]; Assert.That(attribute, Is.Not.Null); + Assert.That(attribute!.Value, Is.TypeOf()); + Assert.That(attribute.Value.ToString(), Is.EqualTo("{\"X-Requested-With\":\"XMLHttpRequest\",\"X-Custom\":\"value\\u0027s\"}")); - var json = JsonHelper.ParseJson(attribute!.Value.ToString()!); + var json = JsonHelper.ParseJson(attribute.Value.ToString()!); Assert.That(json["X-Requested-With"].GetString(), Is.EqualTo("XMLHttpRequest")); - Assert.That(json["X-Custom"].GetString(), Is.EqualTo("value")); + Assert.That(json["X-Custom"].GetString(), Is.EqualTo("value's")); } [Test] public void Headers_TreatsNamesAsCaseInsensitive() { - var helper = new HtmxHeaderTagHelper(); - helper.Headers["X-Custom"] = "first"; - helper.Headers["x-custom"] = "second"; + var helper = new HtmxHeaderTagHelper + { + Headers = + { + ["X-Custom"] = "first", + ["x-custom"] = "second" + } + }; Assert.That(helper.Headers, Has.Count.EqualTo(1)); Assert.That(helper.Headers["X-CUSTOM"], Is.EqualTo("second")); diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs index c66726b..d3a2692 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Html; + namespace Ramstack.HtmxToolkit.Tests; [TestFixture] @@ -18,8 +20,9 @@ public async Task ProcessAsync_SerializesRequestConfiguration() var attribute = output.Attributes["hx-request"]; Assert.That(attribute, Is.Not.Null); + Assert.That(attribute!.Value, Is.TypeOf()); - var json = JsonHelper.ParseJson(attribute!.Value.ToString()!); + var json = JsonHelper.ParseJson(attribute.Value.ToString()!); Assert.That(json["timeout"].GetInt32(), Is.EqualTo(500)); Assert.That(json["credentials"].GetBoolean(), Is.True); Assert.That(json["noHeaders"].GetBoolean(), Is.False); diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxValsTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxValsTagHelperTests.cs index 33c27a9..871ad1b 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxValsTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxValsTagHelperTests.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Html; + namespace Ramstack.HtmxToolkit.Tests; [TestFixture] @@ -7,17 +9,31 @@ public class HtmxValsTagHelperTests public async Task ProcessAsync_SerializesValues() { var output = TestHelper.CreateTagHelperOutput(); - var helper = new HtmxValsTagHelper(); - helper.Values["category"] = "books"; - helper.Values["sort"] = "title"; + var helper = new HtmxValsTagHelper + { + Values = + { + ["категория"] = "Детские книги '