Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/Ramstack.HtmxToolkit/Internal/JsonOptions.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -8,6 +10,19 @@ namespace Ramstack.HtmxToolkit.Internal;
/// </summary>
internal static class JsonOptions
{
/// <summary>
/// Encodes all Unicode ranges while escaping JavaScript and HTML-sensitive characters.
/// </summary>
private static readonly JavaScriptEncoder s_encoder =
JavaScriptEncoder.Create(new TextEncoderSettings(UnicodeRanges.All));

/// <summary>
/// Configures serializer options to preserve Unicode characters while escaping HTML-sensitive characters.
/// </summary>
/// <param name="options">The serializer options to configure.</param>
public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) =>
options.Encoder = s_encoder;

/// <summary>
/// JSON serializer options using <see cref="JsonNamingPolicy.CamelCase"/> for property names and dictionary keys,
/// and ignoring properties with <see langword="null"/> values.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
using System.Text.Json.Serialization;

using Ramstack.HtmxToolkit.Internal;

namespace Ramstack.HtmxToolkit.TagHelpers;

/// <summary>
/// Provides source-generated JSON serialization metadata for HTMX configuration data.
/// </summary>
[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
{
/// <summary>
/// Initializes the default serializer context with HTML-safe Unicode encoding.
/// </summary>
static HtmxConfigJsonSerializerContext()
{
JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions);
s_defaultContext = new HtmxConfigJsonSerializerContext(s_defaultOptions);
}
}
6 changes: 2 additions & 4 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
using System.Text.Json.Serialization;

using Ramstack.HtmxToolkit.Internal;

namespace Ramstack.HtmxToolkit.TagHelpers;

/// <summary>
/// Provides source-generated JSON serialization metadata for HTMX string dictionaries.
/// </summary>
[JsonSerializable(typeof(IDictionary<string, string>))]
[JsonSourceGenerationOptions(WriteIndented = false, GenerationMode = JsonSourceGenerationMode.Serialization)]
internal partial class HtmxDictionaryJsonSerializerContext : JsonSerializerContext;
[JsonSourceGenerationOptions(WriteIndented = false, GenerationMode = JsonSourceGenerationMode.Default)]
internal partial class HtmxDictionaryJsonSerializerContext : JsonSerializerContext
{
/// <summary>
/// Initializes the default serializer context with HTML-safe Unicode encoding.
/// </summary>
static HtmxDictionaryJsonSerializerContext()
{
JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions);
s_defaultContext = new HtmxDictionaryJsonSerializerContext(s_defaultOptions);
}
}
3 changes: 1 addition & 2 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
using System.Text.Json.Serialization;

using Ramstack.HtmxToolkit.Internal;

namespace Ramstack.HtmxToolkit.TagHelpers;

/// <summary>
/// Provides source-generated JSON serialization metadata for HTMX request configuration data.
/// </summary>
[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
{
/// <summary>
/// Initializes the default serializer context with HTML-safe Unicode encoding.
/// </summary>
static HtmxRequestJsonSerializerContext()
{
JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions);
s_defaultContext = new HtmxRequestJsonSerializerContext(s_defaultOptions);
}
}
8 changes: 3 additions & 5 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 10 additions & 3 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<HtmlString>());

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));
Expand All @@ -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"));
Expand Down
30 changes: 22 additions & 8 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Html;

namespace Ramstack.HtmxToolkit.Tests;

[TestFixture]
Expand All @@ -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<HtmlString>());
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"));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Html;

namespace Ramstack.HtmxToolkit.Tests;

[TestFixture]
Expand All @@ -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<HtmlString>());

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);
Expand Down
26 changes: 21 additions & 5 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxValsTagHelperTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Html;

namespace Ramstack.HtmxToolkit.Tests;

[TestFixture]
Expand All @@ -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 =
{
["категория"] = "Детские книги '<script>\" &",
["sort"] = "title"
}
};

await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
var attribute = output.Attributes["hx-vals"];

Assert.That(attribute, Is.Not.Null);
Assert.That(attribute!.Value, Is.TypeOf<HtmlString>());

var serialized = attribute.Value.ToString()!;

Assert.That(serialized, Is.EqualTo("{\"категория\":\"Детские книги \\u0027\\u003Cscript\\u003E\\u0022 \\u0026\",\"sort\":\"title\"}"));
Assert.That(serialized, Does.Not.Contain("'"));
Assert.That(serialized, Does.Not.Contain("<"));
Assert.That(serialized, Does.Not.Contain(">"));
Assert.That(serialized, Does.Not.Contain("&"));

var json = JsonHelper.ParseJson(attribute!.Value.ToString()!);
Assert.That(json["category"].GetString(), Is.EqualTo("books"));
var json = JsonHelper.ParseJson(serialized);
Assert.That(json["категория"].GetString(), Is.EqualTo("Детские книги '<script>\" &"));
Assert.That(json["sort"].GetString(), Is.EqualTo("title"));
}

Expand Down
Loading