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 =
+ {
+ ["категория"] = "Детские книги '