diff --git a/README.md b/README.md
index 51760bc..7bfc87c 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,28 @@ use the following command
dotnet add package Ramstack.HtmxToolkit
```
+Register the toolkit and select the HTMX version used by the application:
+
+```csharp
+builder.Services.AddHtmxToolkit(options =>
+{
+ options.IncludeAntiforgeryToken = true;
+ options.UseHtmxV2(config =>
+ {
+ config.DefaultSwapStyle = HtmxSwap.OuterHtml;
+ config.Timeout = 5000;
+ config.GlobalViewTransitions = true;
+ });
+});
+```
+
+HTMX 2.x is used by default. Calling `UseHtmxV2` is optional when no version-specific
+settings are required:
+
+```csharp
+builder.Services.AddHtmxToolkit();
+```
+
## HttpRequest
The library provides a set of classes for working with `HttpRequest`.
@@ -609,7 +631,7 @@ You can also provide the values as a dictionary with the `hx-all-vals` attribute
### HtmxRequestTagHelper
-The `HtmxRequestTagHelper` configures the htmx request options supported by HTMX 1.x and 2.x.
+The `HtmxRequestTagHelper` configures the htmx request options supported by HTMX 1.9.x and 2.x.
Use the typed `hx-request-*` attributes instead of writing JSON manually:
```html
@@ -632,99 +654,110 @@ The following HTML will be generated:
### HtmxConfigTagHelper
-As with `hx-headers`, configuring `htmx` settings requires a JSON representation.
-For working with configuration, the `HtmxConfigTagHelper` class is provided.
+HTMX configuration is defined at application startup through `AddHtmxToolkit`.
+The version-specific callback exposes only settings supported by the selected HTMX version:
+
+```csharp
+builder.Services.AddHtmxToolkit(options =>
+{
+ options.IncludeAntiforgeryToken = true;
+ options.UseHtmxV2(config =>
+ {
+ config.DefaultSwapStyle = HtmxSwap.OuterHtml;
+ config.Timeout = 5000;
+ config.GlobalViewTransitions = true;
+ });
+});
+```
+
+Use the tag helper as a marker where the configuration meta element should be rendered:
```html
-
-
-
+
```
-The following code will be generated:
+
+The following markup will be generated:
```html
-
-
+ content='{"defaultSwapStyle":"outerHTML","timeout":5000,"globalViewTransitions":true}'
+ data-antiforgery-request-token="..."
+ data-antiforgery-header-name="RequestVerificationToken"
+ data-antiforgery-form-field-name="__RequestVerificationToken" />
```
-If desired or for the purpose of semantics, you can use `htmx-config` as the standalone name of the element:
-```html
-
-```
-
-#### Response Handling Configuration
-
-HTMX 2.x introduces the [`responseHandling`](https://htmx.org/docs/#response-handling) configuration option,
-allowing you to define how htmx should handle responses based on HTTP status codes.
-The library provides a child tag helper `` that can be placed inside ``
-to declaratively configure response handling rules.
+The marker can also be written as a `meta` element:
```html
-
-
-
-
-
-
+
+```
-
-
+HTMX 2.x is selected by default. Use `UseHtmxV1`, `UseHtmxV2`, or `UseHtmxV4` to select a
+version explicitly. Each configuration type follows the names used by that HTMX version, so HTMX 1.9.x
+and 2.x expose `DefaultSwapStyle` and `Timeout`, while HTMX 4.x exposes `DefaultSwap` and
+`DefaultTimeout`. Selecting different versions in the same configuration throws an exception.
-
-
+HTMX 4.x is currently in beta. To target it, select it explicitly and use its version-specific
+settings:
-
-
-
+```csharp
+builder.Services.AddHtmxToolkit(options =>
+{
+ options.UseHtmxV4(config =>
+ {
+ config.DefaultSwap = HtmxSwap.OuterHtml;
+ config.DefaultTimeout = 5000;
+ config.Transitions = true;
+ config.NoSwap = ["204", "304", "4xx", "5xx"];
+ });
+});
```
-The following code will be generated:
+The configured values remain available through dependency injection:
-```html
-
+```csharp
+public sealed class ConfigurationInspector(IOptions options)
+{
+ public HtmxV2Config HtmxConfig =>
+ options.Value.GetHtmxConfig();
+}
```
-The `` element supports the following attributes:
-
-| Attribute | Type | Description |
-|-----------------|----------|------------------------------------------------------------------|
-| `code` | `string` | Regular expression tested against response status codes |
-| `swap` | `bool?` | Whether the response should be swapped into the DOM |
-| `error` | `bool?` | Whether htmx should treat this response as an error |
-| `ignore-title` | `bool?` | Whether to ignore title tags in the response |
-| `select` | `string` | CSS selector to select content from the response |
-| `target` | `string` | CSS selector specifying an alternative target for the response |
-| `swap-override` | `string` | Alternative swap mechanism for the response |
+#### Response Handling Configuration
-Alternatively, you can set the entire response handling configuration directly as a Razor expression:
+HTMX 2.x introduces the [`responseHandling`](https://htmx.org/docs/#response-handling) configuration option,
+allowing you to define how htmx should handle responses based on HTTP status codes. Rules are
+configured in order through `HtmxV2Config`:
-```html
-
+```csharp
+builder.Services.AddHtmxToolkit(options =>
+{
+ options.UseHtmxV2(config =>
+ {
+ config.ResponseHandling =
+ [
+ new() { Code = "204", Swap = false },
+ new() { Code = "[23]..", Swap = true },
+ new() { Code = "422", Swap = true },
+ new() { Code = "[45]..", Swap = false, Error = true },
+ new() { Code = "...", Swap = true }
+ ];
+ });
+});
```
+HTMX 4.x removes `responseHandling`. To retain HTMX 2.x behavior that does not swap error
+responses, configure `NoSwap` as shown in the HTMX 4.x example above.
+
## Toolkit Script
The toolkit script provides antiforgery support and HTMX compatibility behavior.
If you have enabled **Antiforgery** token generation in the configuration
-(`include-antiforgery-token="true"`), include it to ensure the token is present in form
+(`IncludeAntiforgeryToken = true`), include it to ensure the token is present in form
parameters or headers and refreshed in a timely manner.
To do this, you can directly include the contents of the script file on the page:
@@ -790,9 +823,12 @@ or the debug version of the script.
## Supported Versions
-| | Version |
-|------|----------------|
-| .NET | 6, 7, 8, 9, 10 |
+All releases in the following HTMX version lines are supported:
+
+| | Version |
+|------|----------------------------------|
+| .NET | 6, 7, 8, 9, 10, 11 |
+| HTMX | 1.9.x, 2.x (default), 4.x (beta) |
## Contributions
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml
index fb412ae..507cea8 100644
--- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml
+++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml
@@ -51,7 +51,7 @@
hx-page="/Examples/HtmxRequest"
hx-page-handler="Delayed"
hx-request-timeout="1000"
- hx-on::timeout="document.querySelector('#timeout-result').textContent = 'Request timed out'"
+ hx-on::error="document.querySelector('#timeout-result').textContent = 'Request timed out'"
hx-target="#timeout-result">
Request with 1000 ms timeout
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml
index 2fe44ff..ae12d16 100644
--- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml
+++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml
@@ -6,17 +6,7 @@
@(ViewData["Title"] ?? "Home") — Ramstack.HtmxToolkit
-
-
-
-
-
-
-
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Program.cs b/samples/Ramstack.HtmxToolkit.Demo/Program.cs
index 38b63f6..45b7c64 100644
--- a/samples/Ramstack.HtmxToolkit.Demo/Program.cs
+++ b/samples/Ramstack.HtmxToolkit.Demo/Program.cs
@@ -1,8 +1,26 @@
+using Ramstack.HtmxToolkit;
using Ramstack.HtmxToolkit.Builder;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
+builder.Services.AddHtmxToolkit(options =>
+{
+ options.IncludeAntiforgeryToken = true;
+ options.UseHtmxV2(config =>
+ {
+ config.DefaultSwapStyle = HtmxSwap.InnerHtml;
+ config.MethodsThatUseUrlParams = [HttpVerb.Get, HttpVerb.Delete];
+ config.ResponseHandling =
+ [
+ new() { Code = "204", Swap = false },
+ new() { Code = "422", Swap = true },
+ new() { Code = "[23]..", Swap = true },
+ new() { Code = "[45]..", Swap = false, Error = true },
+ new() { Code = "...", Swap = true }
+ ];
+ });
+});
var app = builder.Build();
diff --git a/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs b/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs
index fcd249b..ea72b15 100644
--- a/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs
+++ b/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs
@@ -11,6 +11,16 @@ internal readonly struct AjaxContextWrapper
{
private readonly AjaxContext _context;
+ [JsonPropertyName("path")] public string? Path => _context.Path;
+ [JsonPropertyName("source")] public string? Source => _context.Source;
+ [JsonPropertyName("event")] public string? Event => _context.Event;
+ [JsonPropertyName("handler")] public string? Handler => _context.Handler;
+ [JsonPropertyName("target")] public string? Target => _context.Target;
+ [JsonPropertyName("swap")] public string? Swap => _context.Swap.GetSwapValue();
+ [JsonPropertyName("values")] public object? Values => _context.Values;
+ [JsonPropertyName("headers")] public IDictionary? Headers => _context.Headers;
+ [JsonPropertyName("select")] public string? Select => _context.Select;
+
///
/// Initializes a new instance of the .
///
@@ -21,14 +31,4 @@ public AjaxContextWrapper(string path, AjaxContext context)
context.Path = path;
_context = context;
}
-
- [JsonPropertyName("path")] public string? Path => _context.Path;
- [JsonPropertyName("source")] public string? Source => _context.Source;
- [JsonPropertyName("event")] public string? Event => _context.Event;
- [JsonPropertyName("handler")] public string? Handler => _context.Handler;
- [JsonPropertyName("target")] public string? Target => _context.Target;
- [JsonPropertyName("swap")] public string? Swap => _context.Swap.GetSwapValue();
- [JsonPropertyName("values")] public object? Values => _context.Values;
- [JsonPropertyName("headers")] public IDictionary? Headers => _context.Headers;
- [JsonPropertyName("select")] public string? Select => _context.Select;
}
diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js
index 60b19dc..05f6cdf 100644
--- a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js
+++ b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js
@@ -3,13 +3,24 @@ document._r_htmx ||= ((document, htmx) => {
document.addEventListener(type, listener);
};
+ const read_antiforgery = doc => {
+ let data = doc.querySelector("meta[name='htmx-config']")?.dataset || {};
+ return {
+ headerName: data.antiforgeryHeaderName,
+ formFieldName: data.antiforgeryFormFieldName,
+ requestToken: data.antiforgeryRequestToken
+ };
+ };
+
+ let antiforgery = read_antiforgery(document);
+
const add_antiforgery = (method, headers, parameters) => {
if (!/^get$/i.test(method)) {
const {
headerName,
formFieldName,
requestToken
- } = htmx.config.antiForgery ?? {};
+ } = antiforgery;
if (requestToken) {
if (!parameters.has?.(formFieldName) && !parameters[formFieldName])
@@ -29,9 +40,9 @@ document._r_htmx ||= ((document, htmx) => {
};
const update_antiforgery = content => {
- let html = new DOMParser().parseFromString(content || "", "text/html");
- let meta = html.querySelector("meta[name='htmx-config']");
- meta && (htmx.config.antiForgery = JSON.parse(meta.content).antiForgery);
+ let doc = new DOMParser().parseFromString(content || "", "text/html");
+ let val = read_antiforgery(doc);
+ val && (antiforgery = val);
};
listen("htmx:afterOnLoad", e => {
diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js
index 65f77b3..62a98c8 100644
--- a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js
+++ b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js
@@ -1 +1 @@
-document._r_htmx||=((e,t)=>{const r=(t,r)=>{e.addEventListener(t,r)},a=(e,r,a)=>{if(!/^get$/i.test(e)){const{headerName:e,formFieldName:o,requestToken:n}=t.config.antiForgery??{};n&&(a.has?.(o)||a[o]||(e?r[e]=n:a.set?a.set(o,n):a[o]=n))}},o=e=>{let r=(new DOMParser).parseFromString(e||"","text/html").querySelector("meta[name='htmx-config']");r&&(t.config.antiForgery=JSON.parse(r.content).antiForgery)};return r("htmx:afterOnLoad",e=>{let t=e.detail;t.boosted&&o(t.xhr.responseText)}),r("htmx:after:request",e=>{let t=e.detail.ctx;t.boosted&&o(t.text)}),r("htmx:configRequest",e=>{let t=e.detail;a(t.verb,t.headers,t.parameters)}),r("htmx:config:request",e=>{let t=e.detail.ctx.request;a(t.method,t.headers,t.body)}),r("rs:events",e=>{for(let r of e.detail.value||e.detail)t.trigger(e.target,r.key,r.value)}),!0})(document,htmx);
\ No newline at end of file
+document._r_htmx||=((e,t)=>{const r=(t,r)=>{e.addEventListener(t,r)},a=e=>{let t=e.querySelector("meta[name='htmx-config']")?.dataset||{};return{headerName:t.antiforgeryHeaderName,formFieldName:t.antiforgeryFormFieldName,requestToken:t.antiforgeryRequestToken}};let o=a(e);const s=(e,t,r)=>{if(!/^get$/i.test(e)){const{headerName:e,formFieldName:a,requestToken:s}=o;s&&(r.has?.(a)||r[a]||(e?t[e]=s:r.set?r.set(a,s):r[a]=s))}},d=e=>{let t=(new DOMParser).parseFromString(e||"","text/html"),r=a(t);r&&(o=r)};return r("htmx:afterOnLoad",e=>{let t=e.detail;t.boosted&&d(t.xhr.responseText)}),r("htmx:after:request",e=>{let t=e.detail.ctx;t.boosted&&d(t.text)}),r("htmx:configRequest",e=>{let t=e.detail;s(t.verb,t.headers,t.parameters)}),r("htmx:config:request",e=>{let t=e.detail.ctx.request;s(t.method,t.headers,t.body)}),r("rs:events",e=>{for(let r of e.detail.value||e.detail)t.trigger(e.target,r.key,r.value)}),!0})(document,htmx);
\ No newline at end of file
diff --git a/src/Ramstack.HtmxToolkit/Builder/EndpointRouteBuilderExtensions.cs b/src/Ramstack.HtmxToolkit/Builder/EndpointRouteBuilderExtensions.cs
index e0d7c12..158cc7d 100644
--- a/src/Ramstack.HtmxToolkit/Builder/EndpointRouteBuilderExtensions.cs
+++ b/src/Ramstack.HtmxToolkit/Builder/EndpointRouteBuilderExtensions.cs
@@ -35,9 +35,10 @@ public static IEndpointConventionBuilder MapHtmxToolkitScript(this IEndpointRout
///
public static IEndpointConventionBuilder MapHtmxToolkitScript(this IEndpointRouteBuilder builder, string path)
{
- if (path.Length == 0)
+ if (string.IsNullOrEmpty(path))
throw new ArgumentException(
- "The 'path' parameter cannot be null or empty.", nameof(path));
+ $"The '{nameof(path)}' parameter cannot be null or empty.",
+ nameof(path));
if (AssetPath != path)
{
@@ -49,10 +50,10 @@ public static IEndpointConventionBuilder MapHtmxToolkitScript(this IEndpointRout
HtmlHelperExtensions.DebugPath = new HtmlString(path + "?debug");
}
- return builder.MapGet(path, context =>
+ return builder.MapGet(path, static context =>
{
context.Response.ContentType = "text/javascript";
- context.Response.Headers["Cache-Control"] = "public,max-age=31536000";
+ context.Response.Headers.CacheControl = "public,max-age=31536000";
return context.Response.WriteAsync(
context.Request.QueryString.Value == "?debug"
diff --git a/src/Ramstack.HtmxToolkit/Builder/ServiceCollectionExtensions.cs b/src/Ramstack.HtmxToolkit/Builder/ServiceCollectionExtensions.cs
index ee40f03..d3ce941 100644
--- a/src/Ramstack.HtmxToolkit/Builder/ServiceCollectionExtensions.cs
+++ b/src/Ramstack.HtmxToolkit/Builder/ServiceCollectionExtensions.cs
@@ -3,12 +3,12 @@
namespace Ramstack.HtmxToolkit.Builder;
///
-/// Provides registration methods for HTMX Toolkit services.
+/// Provides extension methods for registering HTMX Toolkit services.
///
public static class ServiceCollectionExtensions
{
///
- /// Registers HTMX Toolkit configuration and its startup configuration cache.
+ /// Registers and configures HTMX Toolkit services.
///
/// The service collection.
/// An optional delegate used to configure HTMX Toolkit.
@@ -17,7 +17,9 @@ public static class ServiceCollectionExtensions
///
public static IServiceCollection AddHtmxToolkit(this IServiceCollection services, Action? configure = null)
{
- services.AddOptions();
+ services
+ .AddOptions()
+ .ValidateOnStart();
if (configure is not null)
services.Configure(configure);
diff --git a/src/Ramstack.HtmxToolkit/HtmxBinaryTypeJsonConverter.cs b/src/Ramstack.HtmxToolkit/HtmxBinaryTypeJsonConverter.cs
new file mode 100644
index 0000000..bc0a363
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/HtmxBinaryTypeJsonConverter.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using Ramstack.HtmxToolkit.Internal;
+
+namespace Ramstack.HtmxToolkit;
+
+///
+/// Represents a for nullable values.
+///
+internal sealed class HtmxBinaryTypeJsonConverter : JsonConverter
+{
+ ///
+ public override HtmxBinaryType? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ ///
+ public override void Write(Utf8JsonWriter writer, HtmxBinaryType? value, JsonSerializerOptions options)
+ {
+ if (value is null)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ writer.WriteStringValue(value.GetValueOrDefault().GetWsBinaryTypeValue());
+ }
+ }
+}
diff --git a/src/Ramstack.HtmxToolkit/HtmxConfig.cs b/src/Ramstack.HtmxToolkit/HtmxConfig.cs
new file mode 100644
index 0000000..721c014
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/HtmxConfig.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace Ramstack.HtmxToolkit;
+
+///
+/// Represents configuration for a specific major version of HTMX.
+///
+public abstract class HtmxConfig
+{
+ ///
+ /// Gets the configured HTMX major version.
+ ///
+ [JsonIgnore]
+ public HtmxTargetVersion TargetVersion { get; }
+
+ ///
+ /// Initializes a new instance of the class with the specified target HTMX version.
+ ///
+ /// The target major version of HTMX that this configuration applies to.
+ internal HtmxConfig(HtmxTargetVersion version) =>
+ TargetVersion = version;
+}
diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/HtmxConfigJsonSerializerContext.cs
similarity index 75%
rename from src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs
rename to src/Ramstack.HtmxToolkit/HtmxConfigJsonSerializerContext.cs
index 95ecb92..7dfcc3d 100644
--- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxConfigJsonSerializerContext.cs
@@ -2,17 +2,19 @@
using Ramstack.HtmxToolkit.Internal;
-namespace Ramstack.HtmxToolkit.TagHelpers;
+namespace Ramstack.HtmxToolkit;
///
-/// Provides source-generated JSON serialization metadata for HTMX configuration data.
+/// Represents source-generated JSON serialization metadata for HTMX configuration data.
///
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
GenerationMode = JsonSourceGenerationMode.Default)]
-[JsonSerializable(typeof(HtmxConfigTagHelper.HtmxConfigData))]
+[JsonSerializable(typeof(HtmxV1Config))]
+[JsonSerializable(typeof(HtmxV2Config))]
+[JsonSerializable(typeof(HtmxV4Config))]
internal partial class HtmxConfigJsonSerializerContext : JsonSerializerContext
{
///
diff --git a/src/Ramstack.HtmxToolkit/HtmxFetchModeJsonConverter.cs b/src/Ramstack.HtmxToolkit/HtmxFetchModeJsonConverter.cs
new file mode 100644
index 0000000..a1c5e1e
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/HtmxFetchModeJsonConverter.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using Ramstack.HtmxToolkit.Internal;
+
+namespace Ramstack.HtmxToolkit;
+
+///
+/// Represents a for nullable values.
+///
+internal sealed class HtmxFetchModeJsonConverter : JsonConverter
+{
+ ///
+ public override HtmxFetchMode? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ ///
+ public override void Write(Utf8JsonWriter writer, HtmxFetchMode? value, JsonSerializerOptions options)
+ {
+ if (value is null)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ writer.WriteStringValue(value.GetValueOrDefault().GetFetchModeValue());
+ }
+ }
+}
diff --git a/src/Ramstack.HtmxToolkit/HtmxScrollBehaviorJsonConverter.cs b/src/Ramstack.HtmxToolkit/HtmxScrollBehaviorJsonConverter.cs
new file mode 100644
index 0000000..02cf09c
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/HtmxScrollBehaviorJsonConverter.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using Ramstack.HtmxToolkit.Internal;
+
+namespace Ramstack.HtmxToolkit;
+
+///
+/// Represents a for nullable values.
+///
+internal sealed class HtmxScrollBehaviorJsonConverter : JsonConverter
+{
+ ///
+ public override HtmxScrollBehavior? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ ///
+ public override void Write(Utf8JsonWriter writer, HtmxScrollBehavior? value, JsonSerializerOptions options)
+ {
+ if (value is null)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ writer.WriteStringValue(value.GetValueOrDefault().GetScrollBehaviorValue());
+ }
+ }
+}
diff --git a/src/Ramstack.HtmxToolkit/HtmxSwapJsonConverter.cs b/src/Ramstack.HtmxToolkit/HtmxSwapJsonConverter.cs
new file mode 100644
index 0000000..ce56ff4
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/HtmxSwapJsonConverter.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using Ramstack.HtmxToolkit.Internal;
+
+namespace Ramstack.HtmxToolkit;
+
+///
+/// Represents a for nullable values.
+///
+internal sealed class HtmxSwapJsonConverter : JsonConverter
+{
+ ///
+ public override HtmxSwap? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ ///
+ public override void Write(Utf8JsonWriter writer, HtmxSwap? value, JsonSerializerOptions options)
+ {
+ if (value is null)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ writer.WriteStringValue(value.GetValueOrDefault().GetSwapValue());
+ }
+ }
+}
diff --git a/src/Ramstack.HtmxToolkit/HtmxToolkitOptions.cs b/src/Ramstack.HtmxToolkit/HtmxToolkitOptions.cs
index f7156af..c8260cd 100644
--- a/src/Ramstack.HtmxToolkit/HtmxToolkitOptions.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxToolkitOptions.cs
@@ -1,12 +1,157 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
namespace Ramstack.HtmxToolkit;
///
-/// Configures services provided by the HTMX toolkit.
+/// Represents configuration options for services provided by the HTMX toolkit.
///
public sealed class HtmxToolkitOptions
{
+ private HtmxConfig? _config;
+
+ ///
+ /// Gets the configuration for the selected HTMX major version.
+ ///
+ ///
+ /// If no version has been explicitly configured, this property defaults to HTMX 2.x.
+ ///
+ public HtmxConfig HtmxConfig
+ {
+ get
+ {
+ if (_config is null)
+ UseHtmxV2();
+
+ return _config;
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether antiforgery request metadata is rendered by the configuration tag helper.
+ ///
+ public bool IncludeAntiforgeryToken { get; set; }
+
+ ///
+ /// Gets the HTMX major version used for version-sensitive generated markup.
+ ///
+ public HtmxTargetVersion TargetVersion => HtmxConfig.TargetVersion;
+
+ ///
+ /// Selects and configures HTMX 1.x.
+ ///
+ /// The optional delegate used to configure HTMX 1.x.
+ ///
+ /// The current options instance.
+ ///
+ [MemberNotNull(nameof(_config))]
+ public HtmxToolkitOptions UseHtmxV1(Action? configure = null)
+ {
+ var config = SelectVersion(HtmxTargetVersion.V1, static () => new HtmxV1Config());
+ configure?.Invoke(config);
+ return this;
+ }
+
+ ///
+ /// Selects and configures HTMX 2.x.
+ ///
+ /// The optional delegate used to configure HTMX 2.x.
+ ///
+ /// The current options instance.
+ ///
+ [MemberNotNull(nameof(_config))]
+ public HtmxToolkitOptions UseHtmxV2(Action? configure = null)
+ {
+ var config = SelectVersion(HtmxTargetVersion.V2, static () => new HtmxV2Config());
+ configure?.Invoke(config);
+ return this;
+ }
+
+ ///
+ /// Selects and configures HTMX 4.x.
+ ///
+ /// The optional delegate used to configure HTMX 4.x.
+ ///
+ /// The current options instance.
+ ///
+ [MemberNotNull(nameof(_config))]
+ public HtmxToolkitOptions UseHtmxV4(Action? configure = null)
+ {
+ var config = SelectVersion(HtmxTargetVersion.V4, static () => new HtmxV4Config());
+ configure?.Invoke(config);
+ return this;
+ }
+
+ ///
+ /// Returns the selected version-specific HTMX configuration,
+ /// and throws an exception if the requested configuration type does not match the configured HTMX target version.
+ ///
+ /// The expected configuration type.
+ ///
+ /// The requested configuration instance.
+ ///
+ public TConfig GetHtmxConfig() where TConfig : HtmxConfig
+ {
+ if (HtmxConfig is TConfig config)
+ return config;
+
+ Error_ConfigTypeMismatch(TargetVersion);
+ return null;
+ }
+
+ ///
+ /// Selects an HTMX major version and registers its configuration instance.
+ ///
+ /// The version-specific configuration type.
+ /// The HTMX major version to select.
+ /// The factory used to create the configuration instance.
+ ///
+ /// The configuration instance for the selected version.
+ ///
+ [MemberNotNull(nameof(_config))]
+ private TConfig SelectVersion(HtmxTargetVersion version, Func factory) where TConfig : HtmxConfig
+ {
+ EnsureCanSelectVersion(version);
+
+ _config ??= factory();
+
+ Debug.Assert(_config.TargetVersion == version);
+ Debug.Assert(
+ version == HtmxTargetVersion.V1 && _config is HtmxV1Config
+ || version == HtmxTargetVersion.V2 && _config is HtmxV2Config
+ || version == HtmxTargetVersion.V4 && _config is HtmxV4Config);
+
+ return (TConfig)_config;
+ }
+
+ ///
+ /// Ensures that the specified HTMX version does not conflict with an already selected version.
+ ///
+ /// The HTMX target version to validate.
+ private void EnsureCanSelectVersion(HtmxTargetVersion version)
+ {
+ if (_config is { TargetVersion: var current })
+ if (current != version)
+ Error_ReconfigureTargetVersion(current, version);
+ }
+
+ ///
+ /// Throws an when the requested HTMX configuration type
+ /// does not match the configured target version.
+ ///
+ /// The current HTMX target version.
+ /// Always thrown.
+ [DoesNotReturn]
+ private static void Error_ConfigTypeMismatch(HtmxTargetVersion version) =>
+ throw new InvalidOperationException($"HTMX configuration version '{version}' does not match the requested configuration type.");
+
///
- /// Gets or sets the HTMX major version used for version-sensitive generated markup.
+ /// Throws an when attempting to reconfigure the HTMX target version.
///
- public HtmxTargetVersion TargetVersion { get; set; } = HtmxTargetVersion.V2;
+ /// The version that has already been configured.
+ /// The new version being attempted.
+ /// Always thrown.
+ [DoesNotReturn]
+ private static void Error_ReconfigureTargetVersion(HtmxTargetVersion currentVersion, HtmxTargetVersion newVersion) =>
+ throw new InvalidOperationException($"HTMX has already been configured for version {currentVersion}. Cannot reconfigure to {newVersion}.");
}
diff --git a/src/Ramstack.HtmxToolkit/HtmxV1Config.cs b/src/Ramstack.HtmxToolkit/HtmxV1Config.cs
new file mode 100644
index 0000000..f6cdd65
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/HtmxV1Config.cs
@@ -0,0 +1,200 @@
+using System.Text.Json.Serialization;
+
+namespace Ramstack.HtmxToolkit;
+
+///
+/// Represents the configuration for HTMX 1.x.
+///
+public sealed class HtmxV1Config() : HtmxConfig(HtmxTargetVersion.V1)
+{
+ ///
+ /// Gets or sets a value indicating whether HTMX history support is enabled.
+ /// Defaults to .
+ ///
+ public bool? HistoryEnabled { get; set; }
+
+ ///
+ /// Gets or sets the size of the history cache.
+ /// Defaults to 10.
+ ///
+ public int? HistoryCacheSize { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether a full page refresh should be issued
+ /// on history misses rather than using an AJAX request.
+ /// Defaults to .
+ ///
+ public bool? RefreshOnHistoryMiss { get; set; }
+
+ ///
+ /// Gets or sets the default swap style. Defaults to .
+ ///
+ [JsonConverter(typeof(HtmxSwapJsonConverter))]
+ public HtmxSwap? DefaultSwapStyle { get; set; }
+
+ ///
+ /// Gets or sets the default swap delay in milliseconds.
+ /// Defaults to 0.
+ ///
+ public int? DefaultSwapDelay { get; set; }
+
+ ///
+ /// Gets or sets the default settle delay in milliseconds.
+ /// Defaults to 20.
+ ///
+ public int? DefaultSettleDelay { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the indicator styles are loaded.
+ /// Defaults to .
+ ///
+ public bool? IncludeIndicatorStyles { get; set; }
+
+ ///
+ /// Gets or sets the indicator class.
+ /// Defaults to htmx-indicator.
+ ///
+ public string? IndicatorClass { get; set; }
+
+ ///
+ /// Gets or sets the request class.
+ /// Defaults to htmx-request.
+ ///
+ public string? RequestClass { get; set; }
+
+ ///
+ /// Gets or sets the added class.
+ /// Defaults to htmx-added.
+ ///
+ public string? AddedClass { get; set; }
+
+ ///
+ /// Gets or sets the swapping class.
+ /// Defaults to htmx-swapping.
+ ///
+ public string? SwappingClass { get; set; }
+
+ ///
+ /// Gets or sets the settling class.
+ /// Defaults to htmx-settling.
+ ///
+ public string? SettlingClass { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether eval is allowed.
+ /// Defaults to .
+ ///
+ public bool? AllowEval { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether script tags should be processed in new content.
+ /// Defaults to .
+ ///
+ public bool? AllowScriptTags { get; set; }
+
+ ///
+ /// Gets or sets the nonce added to inline scripts.
+ /// Defaults to an empty string.
+ ///
+ public string? InlineScriptNonce { get; set; }
+
+ ///
+ /// Gets or sets the attributes to settle during the settling phase.
+ /// Defaults to ["class", "style", "width", "height"].
+ ///
+ public string[]? AttributesToSettle { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether HTML template tags should be used for parsing content.
+ /// Defaults to .
+ ///
+ public bool? UseTemplateFragments { get; set; }
+
+ ///
+ /// Gets or sets the WebSocket reconnect delay. Defaults to full-jitter.
+ ///
+ public string? WsReconnectDelay { get; set; }
+
+ ///
+ /// Gets or sets the type of binary data received over WebSocket connections.
+ /// Defaults to .
+ ///
+ [JsonConverter(typeof(HtmxBinaryTypeJsonConverter))]
+ public HtmxBinaryType? WsBinaryType { get; set; }
+
+ ///
+ /// Gets or sets the selector for elements that HTMX must not process.
+ /// Defaults to [disable-htmx], [data-disable-htmx].
+ ///
+ public string? DisableSelector { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether cross-site requests include credentials.
+ /// Defaults to .
+ ///
+ public bool? WithCredentials { get; set; }
+
+ ///
+ /// Gets or sets the number of milliseconds a request can take before being terminated.
+ /// Defaults to 0.
+ ///
+ public int? Timeout { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether requests are restricted to the current origin.
+ /// Defaults to .
+ ///
+ public bool? SelfRequestsOnly { get; set; }
+
+ ///
+ /// Gets or sets the scrolling behavior for boosted links.
+ /// Defaults to .
+ ///
+ [JsonConverter(typeof(HtmxScrollBehaviorJsonConverter))]
+ public HtmxScrollBehavior? ScrollBehavior { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the focused element should be scrolled into view.
+ /// Defaults to .
+ ///
+ public bool? DefaultFocusScroll { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether a cache-busting parameter should be included in GET requests.
+ /// Defaults to .
+ ///
+ public bool? GetCacheBusterParam { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the View Transition API should be used for swaps.
+ /// Defaults to .
+ ///
+ public bool? GlobalViewTransitions { get; set; }
+
+ ///
+ /// Gets or sets the HTTP methods that use URL parameters.
+ /// Defaults to ["get"].
+ ///
+ [JsonConverter(typeof(HttpVerbArrayJsonConverter))]
+ public HttpVerb[]? MethodsThatUseUrlParams { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether document titles found in new content are ignored.
+ /// Defaults to .
+ ///
+ public bool? IgnoreTitle { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether boosted targets are scrolled into the viewport.
+ /// Defaults to .
+ ///
+ public bool? ScrollIntoViewOnBoost { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether HTMX uses a never-clearing cache for parsed trigger specifications.
+ /// Defaults to .
+ ///
+ [JsonPropertyName("triggerSpecsCache")]
+ [JsonConverter(typeof(HtmxTriggerSpecsCacheJsonConverter))]
+ public bool? TriggerSpecsCacheEnabled { get; set; }
+}
diff --git a/src/Ramstack.HtmxToolkit/HtmxV2Config.cs b/src/Ramstack.HtmxToolkit/HtmxV2Config.cs
new file mode 100644
index 0000000..a743a60
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/HtmxV2Config.cs
@@ -0,0 +1,232 @@
+using System.Text.Json.Serialization;
+
+using Ramstack.HtmxToolkit.TagHelpers;
+
+namespace Ramstack.HtmxToolkit;
+
+///
+/// Represents the configuration for HTMX 2.x.
+///
+public sealed class HtmxV2Config() : HtmxConfig(HtmxTargetVersion.V2)
+{
+ ///
+ /// Gets or sets a value indicating whether HTMX history support is enabled.
+ /// Defaults to .
+ ///
+ public bool? HistoryEnabled { get; set; }
+
+ ///
+ /// Gets or sets the size of the history cache.
+ /// Defaults to 10.
+ ///
+ public int? HistoryCacheSize { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether a full page refresh should be issued on history misses rather than using an AJAX request.
+ /// Defaults to .
+ ///
+ public bool? RefreshOnHistoryMiss { get; set; }
+
+ ///
+ /// Gets or sets the default swap style.
+ /// Defaults to .
+ ///
+ [JsonConverter(typeof(HtmxSwapJsonConverter))]
+ public HtmxSwap? DefaultSwapStyle { get; set; }
+
+ ///
+ /// Gets or sets the default swap delay in milliseconds.
+ /// Defaults to 0.
+ ///
+ public int? DefaultSwapDelay { get; set; }
+
+ ///
+ /// Gets or sets the default settle delay in milliseconds.
+ /// Defaults to 20.
+ ///
+ public int? DefaultSettleDelay { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the indicator styles are loaded.
+ /// Defaults to .
+ ///
+ public bool? IncludeIndicatorStyles { get; set; }
+
+ ///
+ /// Gets or sets the indicator class.
+ /// Defaults to htmx-indicator.
+ ///
+ public string? IndicatorClass { get; set; }
+
+ ///
+ /// Gets or sets the request class.
+ /// Defaults to htmx-request.
+ ///
+ public string? RequestClass { get; set; }
+
+ ///
+ /// Gets or sets the added class.
+ /// Defaults to htmx-added.
+ ///
+ public string? AddedClass { get; set; }
+
+ ///
+ /// Gets or sets the swapping class.
+ /// Defaults to htmx-swapping.
+ ///
+ public string? SwappingClass { get; set; }
+
+ ///
+ /// Gets or sets the settling class.
+ /// Defaults to htmx-settling.
+ ///
+ public string? SettlingClass { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether eval is allowed.
+ /// Defaults to .
+ ///
+ public bool? AllowEval { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether script tags should be processed in new content.
+ /// Defaults to .
+ ///
+ public bool? AllowScriptTags { get; set; }
+
+ ///
+ /// Gets or sets the nonce added to inline scripts.
+ /// Defaults to an empty string.
+ ///
+ public string? InlineScriptNonce { get; set; }
+
+ ///
+ /// Gets or sets the nonce added to inline styles.
+ /// Defaults to an empty string.
+ ///
+ public string? InlineStyleNonce { get; set; }
+
+ ///
+ /// Gets or sets the attributes to settle during the settling phase.
+ /// Defaults to ["class", "style", "width", "height"].
+ ///
+ public string[]? AttributesToSettle { get; set; }
+
+ ///
+ /// Gets or sets the WebSocket reconnect delay.
+ /// Defaults to full-jitter.
+ ///
+ public string? WsReconnectDelay { get; set; }
+
+ ///
+ /// Gets or sets the type of binary data received over WebSocket connections.
+ /// Defaults to .
+ ///
+ [JsonConverter(typeof(HtmxBinaryTypeJsonConverter))]
+ public HtmxBinaryType? WsBinaryType { get; set; }
+
+ ///
+ /// Gets or sets the selector for elements that HTMX must not process.
+ /// Defaults to [disable-htmx], [data-disable-htmx].
+ ///
+ public string? DisableSelector { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether cross-site requests include credentials.
+ /// Defaults to .
+ ///
+ public bool? WithCredentials { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether attribute inheritance is disabled.
+ /// Defaults to .
+ ///
+ public bool? DisableInheritance { get; set; }
+
+ ///
+ /// Gets or sets the number of milliseconds a request can take before being terminated.
+ /// Defaults to 0.
+ ///
+ public int? Timeout { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether requests are restricted to the current origin.
+ /// Defaults to .
+ ///
+ public bool? SelfRequestsOnly { get; set; }
+
+ ///
+ /// Gets or sets the scrolling behavior for boosted links.
+ /// Defaults to .
+ ///
+ [JsonConverter(typeof(HtmxScrollBehaviorJsonConverter))]
+ public HtmxScrollBehavior? ScrollBehavior { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the focused element should be scrolled into view.
+ /// Defaults to .
+ ///
+ public bool? DefaultFocusScroll { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether a cache-busting parameter should be included in GET requests.
+ /// Defaults to .
+ ///
+ public bool? GetCacheBusterParam { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the View Transition API should be used for swaps.
+ /// Defaults to .
+ ///
+ public bool? GlobalViewTransitions { get; set; }
+
+ ///
+ /// Gets or sets the HTTP methods that use URL parameters.
+ /// Defaults to ["get", "delete"].
+ ///
+ [JsonConverter(typeof(HttpVerbArrayJsonConverter))]
+ public HttpVerb[]? MethodsThatUseUrlParams { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether document titles found in new content are ignored.
+ /// Defaults to .
+ ///
+ public bool? IgnoreTitle { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether boosted targets are scrolled into the viewport.
+ /// Defaults to .
+ ///
+ public bool? ScrollIntoViewOnBoost { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether HTMX uses a never-clearing cache for parsed trigger specifications.
+ /// Defaults to .
+ ///
+ [JsonConverter(typeof(HtmxTriggerSpecsCacheJsonConverter))]
+ [JsonPropertyName("triggerSpecsCache")]
+ public bool? TriggerSpecsCacheEnabled { get; set; }
+
+ ///
+ /// Gets or sets the rules that determine how HTTP response status codes are handled.
+ ///
+ public IList? ResponseHandling { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether out-of-band swaps nested in the main response are processed.
+ /// Defaults to .
+ ///
+ public bool? AllowNestedOobSwaps { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether history restoration requests include HTMX request headers.
+ /// Defaults to .
+ ///
+ public bool? HistoryRestoreAsHxRequest { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether form validity is reported before a request is issued.
+ /// Defaults to .
+ ///
+ public bool? ReportValidityOfForms { get; set; }
+}
diff --git a/src/Ramstack.HtmxToolkit/HtmxV4Config.cs b/src/Ramstack.HtmxToolkit/HtmxV4Config.cs
new file mode 100644
index 0000000..ecf2b28
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/HtmxV4Config.cs
@@ -0,0 +1,148 @@
+using System.Text.Json.Serialization;
+
+namespace Ramstack.HtmxToolkit;
+
+///
+/// Represents the configuration for HTMX 4.x.
+///
+public sealed class HtmxV4Config() : HtmxConfig(HtmxTargetVersion.V4)
+{
+ ///
+ /// Gets or sets a value indicating whether all HTMX events are logged to the console.
+ /// Defaults to .
+ ///
+ public bool? LogAll { get; set; }
+
+ ///
+ /// Gets or sets the secondary attribute prefix recognized alongside hx-.
+ /// Defaults to data-hx-.
+ ///
+ public string? Prefix { get; set; }
+
+ ///
+ /// Gets or sets the character used instead of : in attribute names.
+ ///
+ public string? MetaCharacter { get; set; }
+
+ ///
+ /// Gets or sets how HTMX history restoration is handled.
+ /// Defaults to .
+ ///
+ [JsonConverter(typeof(HtmxHistoryModeJsonConverter))]
+ public HtmxHistoryMode? History { get; set; }
+
+ ///
+ /// Gets or sets the default swap style.
+ /// Defaults to .
+ ///
+ [JsonConverter(typeof(HtmxSwapJsonConverter))]
+ public HtmxSwap? DefaultSwap { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether an empty response body should replace the main swap target.
+ ///
+ public bool? DefaultSwapEmpty { get; set; }
+
+ ///
+ /// Gets or sets the default settle delay in milliseconds.
+ /// Defaults to 1.
+ ///
+ public int? DefaultSettleDelay { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the indicator styles are loaded.
+ /// Defaults to .
+ ///
+ [JsonPropertyName("includeIndicatorCSS")]
+ public bool? IncludeIndicatorCss { get; set; }
+
+ ///
+ /// Gets or sets the indicator class.
+ /// Defaults to htmx-indicator.
+ ///
+ public string? IndicatorClass { get; set; }
+
+ ///
+ /// Gets or sets the request class.
+ /// Defaults to htmx-request.
+ ///
+ public string? RequestClass { get; set; }
+
+ ///
+ /// Gets or sets a value meaning that no nonce will be added to inline scripts.
+ /// Defaults to "".
+ ///
+ public string? InlineScriptNonce { get; set; }
+
+ ///
+ /// Gets or sets a comma-separated list of extensions that HTMX is allowed to load.
+ /// Defaults to an empty string.
+ ///
+ public string? Extensions { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether HTMX attributes are inherited implicitly.
+ /// Defaults to .
+ ///
+ public bool? ImplicitInheritance { get; set; }
+
+ ///
+ /// Gets or sets the default request timeout in milliseconds.
+ /// Defaults to 60000.
+ ///
+ public int? DefaultTimeout { get; set; }
+
+ ///
+ /// Gets or sets the request mode passed to the Fetch API.
+ /// Defaults to same-origin.
+ ///
+ [JsonConverter(typeof(HtmxFetchModeJsonConverter))]
+ public HtmxFetchMode? Mode { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the focused element should be scrolled into view.
+ /// Defaults to and can be overridden using the focus-scroll swap modifier.
+ ///
+ public bool? DefaultFocusScroll { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the
+ /// View Transition API
+ /// should be used when swapping in new content. Defaults to .
+ ///
+ public bool? Transitions { get; set; }
+
+ ///
+ /// Gets or sets the attribute name prefixes to preserve during morphing.
+ /// Defaults to ["data-htmx-powered"].
+ ///
+ public string[]? MorphIgnore { get; set; }
+
+ ///
+ /// Gets or sets the selector for elements to skip during morphing.
+ /// Defaults to [hx-morph-skip].
+ ///
+ public string? MorphSkip { get; set; }
+
+ ///
+ /// Gets or sets the selector for elements whose children should not be morphed.
+ /// Defaults to [hx-morph-skip-children].
+ ///
+ public string? MorphSkipChildren { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of siblings scanned while matching elements during morphing.
+ /// Defaults to 10.
+ ///
+ public int? MorphScanLimit { get; set; }
+
+ ///
+ /// Gets or sets the response status codes or patterns for which HTMX does not perform a swap.
+ /// Defaults to [204, 304].
+ ///
+ ///
+ /// Although HTMX declares this option as a number array, it converts each entry to a string
+ /// at runtime and supports wildcard patterns such as "4xx" and "44x".
+ ///
+ public string[]? NoSwap { get; set; }
+}
diff --git a/src/Ramstack.HtmxToolkit/HttpVerbArrayJsonConverter.cs b/src/Ramstack.HtmxToolkit/HttpVerbArrayJsonConverter.cs
new file mode 100644
index 0000000..f88fb46
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/HttpVerbArrayJsonConverter.cs
@@ -0,0 +1,34 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using Ramstack.HtmxToolkit.Internal;
+
+namespace Ramstack.HtmxToolkit;
+
+///
+/// Represents a for arrays of values.
+///
+internal sealed class HttpVerbArrayJsonConverter : JsonConverter
+{
+ ///
+ public override HttpVerb[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ ///
+ public override void Write(Utf8JsonWriter writer, HttpVerb[]? value, JsonSerializerOptions options)
+ {
+ if (value is null)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ writer.WriteStartArray();
+
+ foreach (var verb in value)
+ writer.WriteStringValue(verb.GetHttpVerbValue());
+
+ writer.WriteEndArray();
+ }
+ }
+}
diff --git a/src/Ramstack.HtmxToolkit/Internal/HttpVerbArray.cs b/src/Ramstack.HtmxToolkit/Internal/HttpVerbArray.cs
deleted file mode 100644
index e8dcc0a..0000000
--- a/src/Ramstack.HtmxToolkit/Internal/HttpVerbArray.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using System.Collections;
-using System.Runtime.CompilerServices;
-
-namespace Ramstack.HtmxToolkit.Internal;
-
-///
-/// Represents a lightweight wrapper over an array of values.
-///
-internal readonly struct HttpVerbArray : IEnumerable
-{
- ///
- /// Gets the underlying array of values.
- ///
- public HttpVerb[]? Values { get; }
-
- ///
- /// Initializes a new instance of the struct.
- ///
- /// The array of values, or .
- public HttpVerbArray(HttpVerb[]? values) =>
- Values = values;
-
- ///
- /// Returns an enumerator that iterates through the collection.
- ///
- ///
- /// An enumerator that can be used to iterate through the collection.
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public Enumerator GetEnumerator() =>
- new(Values);
-
- ///
- IEnumerator IEnumerable.GetEnumerator() =>
- GetEnumerator();
-
- ///
- IEnumerator IEnumerable.GetEnumerator() =>
- GetEnumerator();
-
- ///
- /// Represents an enumerator that lazily converts values
- /// to lowercase string representations.
- ///
- public struct Enumerator : IEnumerator
- {
- private readonly HttpVerb[] _verbs;
- private int _index;
-
- ///
- public string Current
- {
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- get => _verbs[_index].GetHttpVerbValue();
- }
-
- ///
- /// Initializes a new instance of the structure.
- ///
- /// The array of values, or .
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- internal Enumerator(HttpVerb[]? verbs)
- {
- _verbs = verbs ?? [];
- _index = -1;
- }
-
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public bool MoveNext()
- {
- _index++;
- return (uint)_index < (uint)_verbs.Length;
- }
-
- object IEnumerator.Current => Current;
-
- ///
- public void Reset() =>
- _index = -1;
-
- ///
- public void Dispose()
- {
- }
- }
-}
diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs
index 4906758..c32c924 100644
--- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs
+++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs
@@ -1,686 +1,37 @@
using System.Text.Json;
-using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
-
-using Ramstack.HtmxToolkit.Internal;
+using Microsoft.Extensions.Options;
namespace Ramstack.HtmxToolkit.TagHelpers;
///
-/// Represents the implementation that applies to <meta> element
-/// to declaratively define htmx options.
+/// Represents the implementation that renders
+/// the application-wide HTMX configuration as a meta element.
///
+/// The service used to generate antiforgery tokens.
+/// The configured HTMX Toolkit options.
[HtmlTargetElement("meta", Attributes = "htmx-config", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("htmx-config", TagStructure = TagStructure.NormalOrSelfClosing)]
-public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper
+public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery, IOptions options) : TagHelper
{
- private readonly HtmxConfigData _config = new();
-
- ///
- /// Gets or sets a value indicating whether htmx logs all events to the console.
- /// Defaults to .
- ///
- /// Supported only in HTMX 4.x.
- [HtmlAttributeName("log-all")]
- public bool? LogAll
- {
- get => _config.LogAll;
- set => _config.LogAll = value;
- }
-
- ///
- /// Gets or sets the secondary attribute prefix recognized alongside hx-.
- /// Defaults to data-hx-.
- ///
- /// Supported only in HTMX 4.x.
- [HtmlAttributeName("prefix")]
- public string? Prefix
- {
- get => _config.Prefix;
- set => _config.Prefix = value;
- }
-
- ///
- /// Gets or sets the character used instead of : in attribute names.
- ///
- /// Supported only in HTMX 4.x.
- [HtmlAttributeName("meta-character")]
- public string? MetaCharacter
- {
- get => _config.MetaCharacter;
- set => _config.MetaCharacter = value;
- }
-
- ///
- /// Gets or sets how HTMX history restoration is handled.
- /// Defaults to .
- ///
- ///
- ///
- /// In HTMX 1.x and 2.x this maps to the historyEnabled boolean configuration option,
- /// where yields
- /// and any other value yields .
- ///
- ///
- /// In HTMX 4.x this maps to the history configuration option.
- ///
- ///
- /// For compatibility, the generated JSON includes both historyEnabled and history.
- ///
- ///
- [HtmlAttributeName("history")]
- public HtmxHistoryMode? History
- {
- get => _config.History;
- set => _config.History = value;
- }
-
- ///
- /// Gets or sets the size of the history cache. Defaults to 10.
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("history-cache-size")]
- public int? HistoryCacheSize
- {
- get => _config.HistoryCacheSize;
- set => _config.HistoryCacheSize = value;
- }
-
- ///
- /// Gets or sets a value indicating whether a full page refresh
- /// should be issued on history misses rather than using an AJAX request.
- /// Defaults to .
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("refresh-on-history-miss")]
- public bool? RefreshOnHistoryMiss
- {
- get => _config.RefreshOnHistoryMiss;
- set => _config.RefreshOnHistoryMiss = value;
- }
-
- ///
- /// Gets or sets the default swap style.
- /// Defaults to .
- ///
- ///
- /// Supported in HTMX 1.x, 2.x, and 4.x. HTMX 4.x uses the compatible defaultSwap key.
- /// For compatibility, the generated JSON includes both defaultSwapStyle and defaultSwap.
- ///
- [HtmlAttributeName("default-swap-style")]
- public HtmxSwap? DefaultSwapStyle
- {
- // NOTE: The getter exists primarily for debugging; performance is not a concern here.
- get => EnumHelper.ParseHtmxSwap(_config.DefaultSwapStyle);
- set => _config.DefaultSwapStyle = value.GetSwapValue();
- }
-
- ///
- /// Gets or sets a value indicating whether an empty response body should replace the main swap target.
- ///
- /// Supported only in HTMX 4.x.
- [HtmlAttributeName("default-swap-empty")]
- public bool? DefaultSwapEmpty
- {
- get => _config.DefaultSwapEmpty;
- set => _config.DefaultSwapEmpty = value;
- }
-
- ///
- /// Gets or sets the default swap delay. Defaults to 0.
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("default-swap-delay")]
- public int? DefaultSwapDelay
- {
- get => _config.DefaultSwapDelay;
- set => _config.DefaultSwapDelay = value;
- }
-
- ///
- /// Gets or sets the default settle delay. Defaults to 20 in HTMX 1.x and 2.x,
- /// and 1 in HTMX 4.x.
- ///
- /// Supported in HTMX 1.x, 2.x, and 4.x.
- [HtmlAttributeName("default-settle-delay")]
- public int? DefaultSettleDelay
- {
- get => _config.DefaultSettleDelay;
- set => _config.DefaultSettleDelay = value;
- }
-
- ///
- /// Gets or sets a value indicating whether the indicator styles are loaded.
- /// Defaults to .
- ///
- ///
- /// Supported in HTMX 1.x, 2.x, and 4.x. HTMX 4.x uses the compatible includeIndicatorCSS key.
- /// For compatibility, the generated JSON includes both includeIndicatorStyles and includeIndicatorCSS.
- ///
- [HtmlAttributeName("include-indicator-styles")]
- public bool? IncludeIndicatorStyles
- {
- get => _config.IncludeIndicatorStyles;
- set => _config.IncludeIndicatorStyles = value;
- }
-
- ///
- /// Gets or sets the indicator class. Defaults to htmx-indicator.
- ///
- /// Supported in HTMX 1.x, 2.x, and 4.x.
- [HtmlAttributeName("indicator-class")]
- public string? IndicatorClass
- {
- get => _config.IndicatorClass;
- set => _config.IndicatorClass = value;
- }
-
- ///
- /// Gets or sets the request class. Defaults to htmx-request.
- ///
- /// Supported in HTMX 1.x, 2.x, and 4.x.
- [HtmlAttributeName("request-class")]
- public string? RequestClass
- {
- get => _config.RequestClass;
- set => _config.RequestClass = value;
- }
-
- ///
- /// Gets or sets the added class. Defaults to htmx-added.
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("added-class")]
- public string? AddedClass
- {
- get => _config.AddedClass;
- set => _config.AddedClass = value;
- }
-
- ///
- /// Gets or sets the swapping class. Defaults to htmx-swapping.
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("swapping-class")]
- public string? SwappingClass
- {
- get => _config.SwappingClass;
- set => _config.SwappingClass = value;
- }
-
- ///
- /// Gets or sets the settling class. Defaults to htmx-settling.
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("settling-class")]
- public string? SettlingClass
- {
- get => _config.SettlingClass;
- set => _config.SettlingClass = value;
- }
-
- ///
- /// Gets or sets a value indicating whether eval is allowed.
- /// Defaults to .
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("allow-eval")]
- public bool? AllowEval
- {
- get => _config.AllowEval;
- set => _config.AllowEval = value;
- }
-
- ///
- /// Gets or sets a value indicating whether script tags should be processed in new content.
- /// Defaults to .
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("allow-script-tags")]
- public bool? AllowScriptTags
- {
- get => _config.AllowScriptTags;
- set => _config.AllowScriptTags = value;
- }
-
- ///
- /// Gets or sets a value meaning that no nonce will be added to inline scripts.
- /// Defaults to "".
- ///
- /// Supported in HTMX 1.x, 2.x, and 4.x.
- [HtmlAttributeName("inline-script-nonce")]
- public string? InlineScriptNonce
- {
- get => _config.InlineScriptNonce;
- set => _config.InlineScriptNonce = value;
- }
-
- ///
- /// Gets or sets a comma-separated list of extensions that htmx is allowed to load.
- /// Defaults to an empty string.
- ///
- /// Supported only in HTMX 4.x.
- [HtmlAttributeName("extensions")]
- public string? Extensions
- {
- get => _config.Extensions;
- set => _config.Extensions = value;
- }
+ private const string RequestTokenAttributeName = "data-antiforgery-request-token";
+ private const string HeaderNameAttributeName = "data-antiforgery-header-name";
+ private const string FormFieldNameAttributeName = "data-antiforgery-form-field-name";
///
- /// Gets or sets a value meaning that no nonce will be added to inline styles.
- /// Defaults to "".
- ///
- /// Supported only in HTMX 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("inline-style-nonce")]
- public string? InlineStyleNonce
- {
- get => _config.InlineStyleNonce;
- set => _config.InlineStyleNonce = value;
- }
-
- ///
- /// Gets or sets the attributes to settle during the settling phase.
- /// Defaults to ["class", "style", "width", "height"].
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("attributes-to-settle")]
- public string[]? AttributesToSettle
- {
- get => _config.AttributesToSettle;
- set => _config.AttributesToSettle = value;
- }
-
- ///
- /// Gets or sets a value indicating whether HTML template tags should be used for parsing content.
- /// Defaults to .
- ///
- /// Supported only in HTMX 1.x. Removed in HTMX 2.x and 4.x.
- [HtmlAttributeName("use-template-fragments")]
- public bool? UseTemplateFragments
- {
- get => _config.UseTemplateFragments;
- set => _config.UseTemplateFragments = value;
- }
-
- ///
- /// Gets or sets the WebSocket reconnect delay. Defaults to full-jitter.
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("ws-reconnect-delay")]
- public string? WsReconnectDelay
- {
- get => _config.WsReconnectDelay;
- set => _config.WsReconnectDelay = value;
- }
-
- ///
- /// Gets or sets the type of binary data
- /// being received over the WebSocket connection. Defaults to .
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("ws-binary-type")]
- public HtmxBinaryType? WsBinaryType
- {
- // NOTE: The getter exists primarily for debugging; performance is not a concern here.
- get => Enum.TryParse(_config.WsBinaryType ?? "", true, out var v) ? v : null;
- set => _config.WsBinaryType = value?.GetWsBinaryTypeValue();
- }
-
- ///
- /// Gets or sets the "disable" selector.
- /// Defaults to [disable-htmx], [data-disable-htmx].
- /// HTMX will not process elements with this attribute on it or a parent.
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("disable-selector")]
- public string? DisableSelector
- {
- get => _config.DisableSelector;
- set => _config.DisableSelector = value;
- }
-
- ///
- /// Gets or sets the value that allows cross-site Access-Control requests
- /// using credentials such as cookies, authorization headers or TLS client certificates.
- /// Defaults to .
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("with-credentials")]
- public bool? WithCredentials
- {
- get => _config.WithCredentials;
- set => _config.WithCredentials = value;
- }
-
- ///
- /// Gets or sets a value indicating whether htmx attribute inheritance is disabled.
- /// If set to , the inheritance of attributes is completely disabled
- /// and you can explicitly specify the inheritance with the hx-inherit attribute.
- /// Defaults to .
- ///
- ///
- /// Supported in HTMX 2.x and 4.x; not supported in HTMX 1.x.
- /// In HTMX 2.x this maps to the disableInheritance configuration option.
- ///
- /// In HTMX 4.x this maps to the inverse of the implicitInheritance
- /// configuration option.
- ///
- ///
- /// For compatibility, the generated JSON includes both disableInheritance and its inverse,
- /// implicitInheritance.
- ///
- ///
- [HtmlAttributeName("disable-inheritance")]
- public bool? DisableInheritance
- {
- get => _config.DisableInheritance;
- set => _config.DisableInheritance = value;
- }
-
- ///
- /// Gets or sets the number of milliseconds a request can take before automatically being terminated.
- /// Defaults to 0 in HTMX 1.x and 2.x, and 60000 in HTMX 4.x.
- ///
- ///
- /// Supported in HTMX 1.x and 2.x as the timeout configuration option.
- /// In HTMX 4.x this maps to the defaultTimeout configuration option.
- /// For compatibility, the generated JSON includes both timeout and defaultTimeout.
- ///
- [HtmlAttributeName("timeout")]
- public int? DefaultTimeout
- {
- get => _config.DefaultTimeout;
- set => _config.DefaultTimeout = value;
- }
-
- ///
- /// Gets or sets the request mode passed to the Fetch API.
- /// Defaults to same-origin.
- ///
- ///
- ///
- /// In HTMX 1.x and 2.x this maps to the selfRequestsOnly boolean configuration option,
- /// where yields
- /// and any other value yields .
- ///
- ///
- /// In HTMX 4.x this maps to the mode configuration option.
- ///
- ///
- /// For compatibility, the generated JSON includes both mode and the derived
- /// selfRequestsOnly value.
- ///
- ///
- [HtmlAttributeName("mode")]
- public HtmxFetchMode? Mode
- {
- // NOTE: The getter exists primarily for debugging; performance is not a concern here.
- get => EnumHelper.ParseHtmxFetchMode(_config.Mode);
- set => _config.Mode = value?.GetFetchModeValue();
- }
-
- ///
- /// Gets or sets a value indicating the behavior for a boosted link on page transitions.
- /// Defaults to in HTMX 1.x
- /// and in HTMX 2.x.
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("scroll-behavior")]
- public HtmxScrollBehavior? ScrollBehavior
- {
- // NOTE: The getter exists primarily for debugging; performance is not a concern here.
- get => Enum.TryParse(_config.ScrollBehavior ?? "", true, out var v) ? v : null;
- set => _config.ScrollBehavior = value?.GetScrollBehaviorValue();
- }
-
- ///
- /// Gets or sets a value indicating whether the focused element should be scrolled into view.
- /// Defaults to and can be overridden using the focus-scroll swap modifier.
- ///
- /// Supported in HTMX 1.x, 2.x, and 4.x.
- [HtmlAttributeName("default-focus-scroll")]
- public bool? DefaultFocusScroll
- {
- get => _config.DefaultFocusScroll;
- set => _config.DefaultFocusScroll = value;
- }
-
- ///
- /// Gets or sets a value indicating whether a cache‑busting parameter
- /// should be included in GET requests to avoid caching partial responses by the browser.
- /// Defaults to .
- ///
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- /// In HTMX 2.x, the format changed to org.htmx.cache-buster=targetElementId,
- /// where the target element is appended to the GET request.
- ///
- [HtmlAttributeName("get-cache-buster-param")]
- public bool? GetCacheBusterParam
- {
- get => _config.GetCacheBusterParam;
- set => _config.GetCacheBusterParam = value;
- }
-
- ///
- /// Gets or sets a value indicating whether the
- /// View Transition API
- /// should be used when swapping in new content.
- /// Defaults to .
- ///
- ///
- /// Supported in HTMX 1.x, 2.x, and 4.x. HTMX 4.x uses the compatible transitions key.
- /// For compatibility, the generated JSON includes both globalViewTransitions and transitions.
- ///
- [HtmlAttributeName("global-view-transitions")]
- public bool? GlobalViewTransitions
- {
- get => _config.GlobalViewTransitions;
- set => _config.GlobalViewTransitions = value;
- }
-
- ///
- /// Gets or sets the attribute name prefixes to preserve during morphing.
- /// Defaults to ["data-htmx-powered"].
- ///
- /// Supported only in HTMX 4.x.
- [HtmlAttributeName("morph-ignore")]
- public string[]? MorphIgnore
- {
- get => _config.MorphIgnore;
- set => _config.MorphIgnore = value;
- }
-
- ///
- /// Gets or sets the selector for elements to skip during morphing.
- /// Defaults to [hx-morph-skip].
- ///
- /// Supported only in HTMX 4.x.
- [HtmlAttributeName("morph-skip")]
- public string? MorphSkip
- {
- get => _config.MorphSkip;
- set => _config.MorphSkip = value;
- }
-
- ///
- /// Gets or sets the selector for elements whose children should not be morphed.
- /// Defaults to [hx-morph-skip-children].
- ///
- /// Supported only in HTMX 4.x.
- [HtmlAttributeName("morph-skip-children")]
- public string? MorphSkipChildren
- {
- get => _config.MorphSkipChildren;
- set => _config.MorphSkipChildren = value;
- }
-
- ///
- /// Gets or sets the maximum number of siblings scanned while matching elements during morphing.
- /// Defaults to 10.
- ///
- /// Supported only in HTMX 4.x.
- [HtmlAttributeName("morph-scan-limit")]
- public int? MorphScanLimit
- {
- get => _config.MorphScanLimit;
- set => _config.MorphScanLimit = value;
- }
-
- ///
- /// Gets or sets the response status codes or patterns for which htmx does not perform a swap.
- /// Defaults to [204, 304].
- ///
- ///
- /// Supported only in HTMX 4.x.
- ///
- /// Although the HTMX TypeScript declaration types this as number[], at runtime htmx
- /// converts each entry to a string and matches it against the status code as well as
- /// wildcard patterns such as "4xx" or "44x", so both forms are accepted.
- ///
- ///
- [HtmlAttributeName("no-swap")]
- public string[]? NoSwap
- {
- get => _config.NoSwap;
- set => _config.NoSwap = value;
- }
-
- ///
- /// Gets or sets a list of HTTP methods that use URL parameters.
- /// Defaults to ["get"] in HTMX 1.x and ["get", "delete"] in HTMX 2.x.
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("methods-that-use-url-params")]
- public HttpVerb[]? MethodsThatUseUrlParams
- {
- get => _config.MethodsThatUseUrlParams.GetValueOrDefault().Values;
- set => _config.MethodsThatUseUrlParams = new HttpVerbArray(value);
- }
-
- ///
- /// Gets or sets a value indicating whether htmx should not update the title of the document
- /// when a title tag is found in new content. Defaults to .
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("ignore-title")]
- public bool? IgnoreTitle
- {
- get => _config.IgnoreTitle;
- set => _config.IgnoreTitle = value;
- }
-
- ///
- /// Gets or sets a value indicating whether the target of a boosted element
- /// is scrolled into the viewport. If hx-target is omitted on a boosted element,
- /// the target defaults to body, causing the page to scroll to the top.
- /// Defaults to .
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("scroll-into-view-on-boost")]
- public bool? ScrollIntoViewOnBoost
- {
- get => _config.ScrollIntoViewOnBoost;
- set => _config.ScrollIntoViewOnBoost = value;
- }
-
- ///
- /// Gets or sets a value indicating whether htmx should use a never-clearing cache
- /// for evaluated trigger specifications, improving parsing performance
- /// at the cost of more memory usage. Defaults to .
- ///
- ///
- /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x.
- ///
- /// When , serialized as the triggerSpecsCache configuration option
- /// set to an empty object ({}). When , serialized as .
- ///
- ///
- [HtmlAttributeName("trigger-specs-cache-enabled")]
- public bool? TriggerSpecsCacheEnabled
- {
- get => _config.TriggerSpecsCache;
- set => _config.TriggerSpecsCache = value;
- }
-
- ///
- /// Gets or sets the default response handling behavior for HTTP response status codes.
- /// Accepts an array of objects that define
- /// how htmx should handle responses matching specific status code patterns.
- ///
- /// Supported only in HTMX 2.x. Replaced by hx-status and noSwap in HTMX 4.x.
- [HtmlAttributeName("response-handling")]
- public IList? ResponseHandling
- {
- get => _config.ResponseHandling;
- set => _config.ResponseHandling = value;
- }
-
- ///
- /// Gets or sets a value indicating whether to process OOB swaps
- /// on elements that are nested within the main response element.
- /// Defaults to .
- ///
- /// Supported only in HTMX 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("allow-nested-oob-swaps")]
- public bool? AllowNestedOobSwaps
- {
- get => _config.AllowNestedOobSwaps;
- set => _config.AllowNestedOobSwaps = value;
- }
-
- ///
- /// Gets or sets a value indicating whether to treat history cache miss
- /// full page reload requests as an "HX-Request" by returning the corresponding response header.
- /// Defaults to .
- /// This should always be disabled when using the HX-Request header
- /// to optionally return partial responses.
- ///
- /// Supported only in HTMX 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("history-restore-as-hx-request")]
- public bool? HistoryRestoreAsHxRequest
- {
- get => _config.HistoryRestoreAsHxRequest;
- set => _config.HistoryRestoreAsHxRequest = value;
- }
-
- ///
- /// Gets or sets a value indicating whether to report input validation errors
- /// to the end user and update focus to the first input that fails validation.
- /// Defaults to .
- /// This should always be enabled as this matches default browser form submit behavior.
- ///
- /// Supported only in HTMX 2.x. Removed in HTMX 4.x.
- [HtmlAttributeName("report-validity-of-forms")]
- public bool? ReportValidityOfForms
- {
- get => _config.ReportValidityOfForms;
- set => _config.ReportValidityOfForms = value;
- }
-
- ///
- /// Gets or sets a value indicating whether an antiforgery token should be included.
- /// Defaults to .
- ///
- /// This is a custom extension, not part of the htmx configuration.
- [HtmlAttributeName("include-antiforgery-token")]
- public bool IncludeAntiForgeryToken { get; set; }
-
- ///
- /// Gets or sets the .
+ /// Gets or sets the current view context.
///
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; } = null!;
///
- public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
+ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
if (output.TagName == "meta")
output.Attributes.RemoveAll("htmx-config");
@@ -689,105 +40,32 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu
output.TagMode = TagMode.SelfClosing;
output.Attributes.SetAttribute("name", "htmx-config");
- context.Items[typeof(HtmxConfigTagHelper)] = this;
- await output.GetChildContentAsync();
+ var json = options.Value.HtmxConfig switch
+ {
+ HtmxV1Config value => JsonSerializer.Serialize(value, HtmxConfigJsonSerializerContext.Default.HtmxV1Config),
+ HtmxV2Config value => JsonSerializer.Serialize(value, HtmxConfigJsonSerializerContext.Default.HtmxV2Config),
+ var value => JsonSerializer.Serialize((HtmxV4Config)value, HtmxConfigJsonSerializerContext.Default.HtmxV4Config)
+ };
- if (IncludeAntiForgeryToken)
- _config.AntiForgery = antiforgery.GetAndStoreTokens(ViewContext.HttpContext);
+ output.Attributes.SetAttribute(
+ new TagHelperAttribute("content", new HtmlString(json), HtmlAttributeValueStyle.SingleQuotes));
- var info = HtmxConfigJsonSerializerContext.Default.HtmxConfigData;
- var config = new HtmlString(JsonSerializer.Serialize(_config, info));
+ if (options.Value.IncludeAntiforgeryToken)
+ RenderAntiforgeryAttributes(output);
- output.Attributes.SetAttribute(
- new TagHelperAttribute("content", config, HtmlAttributeValueStyle.SingleQuotes));
+ return Task.CompletedTask;
}
- #region Inner type: HtmxConfigData
-
///
- /// Represents the serializable configuration data for the class.
+ /// Renders antiforgery request metadata into the output element.
///
- internal sealed class HtmxConfigData
+ /// The tag helper output to update.
+ private void RenderAntiforgeryAttributes(TagHelperOutput output)
{
- public bool? LogAll { get; set; }
- public string? Prefix { get; set; }
- public string? MetaCharacter { get; set; }
-
- ///
- /// Gets the legacy historyEnabled value derived from .
- /// Updated automatically whenever is assigned.
- ///
- public bool? HistoryEnabled { get; private set; }
+ var tokens = antiforgery.GetAndStoreTokens(ViewContext.HttpContext);
- [JsonConverter(typeof(HtmxHistoryModeJsonConverter))]
- public HtmxHistoryMode? History
- {
- get;
- set
- {
- field = value;
- HistoryEnabled = value switch
- {
- null => null,
- HtmxHistoryMode.Disabled => false,
- _ => true
- };
- }
- }
-
- public int? HistoryCacheSize { get; set; }
- public bool? RefreshOnHistoryMiss { get; set; }
- public string? DefaultSwapStyle { get; set; }
- public string? DefaultSwap => DefaultSwapStyle;
- public bool? DefaultSwapEmpty { get; set; }
- public int? DefaultSwapDelay { get; set; }
- public int? DefaultSettleDelay { get; set; }
- public bool? IncludeIndicatorStyles { get; set; }
- [JsonPropertyName("includeIndicatorCSS")]
- public bool? IncludeIndicatorCss => IncludeIndicatorStyles;
- public string? IndicatorClass { get; set; }
- public string? RequestClass { get; set; }
- public string? AddedClass { get; set; }
- public string? SwappingClass { get; set; }
- public string? SettlingClass { get; set; }
- public bool? AllowEval { get; set; }
- public bool? AllowScriptTags { get; set; }
- public string? InlineScriptNonce { get; set; }
- public string? Extensions { get; set; }
- public string? InlineStyleNonce { get; set; }
- public string[]? AttributesToSettle { get; set; }
- public bool? UseTemplateFragments { get; set; }
- public string? WsReconnectDelay { get; set; }
- public string? WsBinaryType { get; set; }
- public string? DisableSelector { get; set; }
- public bool? WithCredentials { get; set; }
- public bool? DisableInheritance { get; set; }
- public bool? ImplicitInheritance => DisableInheritance is bool value ? !value : null;
- public int? DefaultTimeout { get; set; }
- public int? Timeout => DefaultTimeout;
- public string? Mode { get; set; }
- public string? ScrollBehavior { get; set; }
- public bool? DefaultFocusScroll { get; set; }
- public bool? GetCacheBusterParam { get; set; }
- public bool? GlobalViewTransitions { get; set; }
- public bool? Transitions => GlobalViewTransitions;
- public string[]? MorphIgnore { get; set; }
- public string? MorphSkip { get; set; }
- public string? MorphSkipChildren { get; set; }
- public int? MorphScanLimit { get; set; }
- public string[]? NoSwap { get; set; }
- public HttpVerbArray? MethodsThatUseUrlParams { get; set; }
- public bool? SelfRequestsOnly => Mode is not null ? Mode == "same-origin" : null;
- public bool? IgnoreTitle { get; set; }
- public bool? ScrollIntoViewOnBoost { get; set; }
- [JsonConverter(typeof(HtmxTriggerSpecsCacheJsonConverter))]
- public bool? TriggerSpecsCache { get; set; }
- public IList? ResponseHandling { get; set; }
- public bool? AllowNestedOobSwaps { get; set; }
- public bool? HistoryRestoreAsHxRequest { get; set; }
- public bool? ReportValidityOfForms { get; set; }
- public AntiforgeryTokenSet? AntiForgery { get; set; }
+ output.Attributes.Add(RequestTokenAttributeName, tokens.RequestToken);
+ output.Attributes.Add(HeaderNameAttributeName, tokens.HeaderName);
+ output.Attributes.Add(FormFieldNameAttributeName, tokens.FormFieldName);
}
-
- #endregion
}
diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
index b0b5805..bd21571 100644
--- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
+++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
@@ -10,7 +10,8 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// Represents a implementation that applies request configuration to matching elements.
///
///
-/// HTMX 1.x and 2.x use merge-inherited hx-request; HTMX 4.x uses hx-config.
+/// HTMX 1.x and 2.x use merge-inherited hx-request
+/// HTMX 4.x uses hx-config
///
[HtmlTargetElement(Attributes = RequestTimeoutAttributeName)]
[HtmlTargetElement(Attributes = RequestCredentialsAttributeName)]
diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs
deleted file mode 100644
index e514b22..0000000
--- a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs
+++ /dev/null
@@ -1,107 +0,0 @@
-using Microsoft.AspNetCore.Razor.TagHelpers;
-
-namespace Ramstack.HtmxToolkit.TagHelpers;
-
-///
-/// Represents a that defines a single response handling entry
-/// as a child of the htmx-config tag helper.
-/// Each entry specifies how htmx should handle responses matching a particular HTTP status code pattern.
-///
-/// Supported only in HTMX 2.x.
-[HtmlTargetElement("response-handling", ParentTag = "htmx-config", TagStructure = TagStructure.WithoutEndTag)]
-public sealed class ResponseHandlingTagHelper : TagHelper
-{
- private readonly ResponseHandlingConfig _config = new();
-
- ///
- /// Gets or sets a regular expression that will be tested against response status codes.
- ///
- [HtmlAttributeName("code")]
- public string? Code
- {
- get => _config.Code;
- set => _config.Code = value;
- }
-
- ///
- /// Gets or sets a value indicating whether the response should be swapped into the DOM.
- ///
- [HtmlAttributeName("swap")]
- public bool? Swap
- {
- get => _config.Swap;
- set => _config.Swap = value;
- }
-
- ///
- /// Gets or sets a value indicating whether htmx should treat this response as an error.
- ///
- [HtmlAttributeName("error")]
- public bool? Error
- {
- get => _config.Error;
- set => _config.Error = value;
- }
-
- ///
- /// Gets or sets a value indicating whether htmx should ignore title tags in the response.
- ///
- [HtmlAttributeName("ignore-title")]
- public bool? IgnoreTitle
- {
- get => _config.IgnoreTitle;
- set => _config.IgnoreTitle = value;
- }
-
- ///
- /// Gets or sets a CSS selector to use to select content from the response.
- ///
- [HtmlAttributeName("select")]
- public string? Select
- {
- get => _config.Select;
- set => _config.Select = value;
- }
-
- ///
- /// Gets or sets a CSS selector specifying an alternative target for the response.
- ///
- [HtmlAttributeName("target")]
- public string? Target
- {
- get => _config.Target;
- set => _config.Target = value;
- }
-
- ///
- /// Gets or sets an alternative swap mechanism for the response.
- ///
- [HtmlAttributeName("swap-override")]
- public string? SwapOverride
- {
- get => _config.SwapOverride;
- set => _config.SwapOverride = value;
- }
-
- ///
- public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
- {
- if (context.Items.TryGetValue(typeof(HtmxConfigTagHelper), out var value) && value is HtmxConfigTagHelper parent)
- {
- parent.ResponseHandling ??= new List();
- parent.ResponseHandling.Add(_config);
-
- output.SuppressOutput();
- return Task.CompletedTask;
- }
-
- Error_NotNested();
- return Task.CompletedTask;
- }
-
- private static void Error_NotNested()
- {
- const string Message = "The '' tag helper can only be used inside the '' tag helper";
- throw new InvalidOperationException(Message);
- }
-}
diff --git a/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs
index a6080fe..4b5e135 100644
--- a/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs
+++ b/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs
@@ -79,6 +79,12 @@ public void GetWsBinaryTypeValue_ReturnsExpectedString(HtmxBinaryType value, str
public void GetScrollBehaviorValue_ReturnsExpectedString(HtmxScrollBehavior value, string expected) =>
Assert.That(value.GetScrollBehaviorValue(), Is.EqualTo(expected));
+ [TestCase(HtmxFetchMode.SameOrigin, "same-origin")]
+ [TestCase(HtmxFetchMode.Cors, "cors")]
+ [TestCase(HtmxFetchMode.NoCors, "no-cors")]
+ public void GetFetchModeValue_ReturnsExpectedString(HtmxFetchMode value, string expected) =>
+ Assert.That(value.GetFetchModeValue(), Is.EqualTo(expected));
+
[Test]
public void GetSwapValue_ReturnsNone_ForUndefinedValue() =>
Assert.That(((HtmxSwap)999).GetSwapValue(), Is.EqualTo("none"));
@@ -94,4 +100,8 @@ public void GetScrollBehaviorValue_ReturnsInstant_ForUndefinedValue() =>
[Test]
public void GetWsBinaryTypeValue_ReturnsArrayBuffer_ForUndefinedValue() =>
Assert.That(((HtmxBinaryType)999).GetWsBinaryTypeValue(), Is.EqualTo("arraybuffer"));
+
+ [Test]
+ public void GetFetchModeValue_ReturnsNoCors_ForUndefinedValue() =>
+ Assert.That(((HtmxFetchMode)999).GetFetchModeValue(), Is.EqualTo("no-cors"));
}
diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs
index 2f95d24..3182715 100644
--- a/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs
+++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs
@@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Razor.TagHelpers;
+using Microsoft.Extensions.Options;
namespace Ramstack.HtmxToolkit.Tests;
@@ -13,13 +14,16 @@ public class HtmxConfigTagHelperTests
public async Task ProcessAsync_RendersMetaTag()
{
var output = TestHelper.CreateTagHelperOutput();
- var helper = new HtmxConfigTagHelper(new StubAntiforgery());
+ var helper = CreateHelper(new HtmxToolkitOptions());
await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
- Assert.That(output.TagName, Is.EqualTo("meta"));
- Assert.That(output.TagMode, Is.EqualTo(TagMode.SelfClosing));
- Assert.That(output.Attributes["name"]!.Value, Is.EqualTo("htmx-config"));
+ Assert.Multiple(() =>
+ {
+ Assert.That(output.TagName, Is.EqualTo("meta"));
+ Assert.That(output.TagMode, Is.EqualTo(TagMode.SelfClosing));
+ Assert.That(output.Attributes["name"]!.Value, Is.EqualTo("htmx-config"));
+ });
}
[Test]
@@ -31,7 +35,7 @@ public async Task ProcessAsync_RemovesHtmxConfigAttribute_FromMetaTag()
};
var output = TestHelper.CreateTagHelperOutput("meta", attributes);
- var helper = new HtmxConfigTagHelper(new StubAntiforgery());
+ var helper = CreateHelper(new HtmxToolkitOptions());
await helper.ProcessAsync(TestHelper.CreateTagHelperContext("meta", attributes), output);
@@ -39,10 +43,10 @@ public async Task ProcessAsync_RemovesHtmxConfigAttribute_FromMetaTag()
}
[Test]
- public async Task ProcessAsync_WithDefaults_SerializesEmptyObject()
+ public async Task ProcessAsync_WithDefaults_SerializesEmptyHtmx2Object()
{
var output = TestHelper.CreateTagHelperOutput();
- var helper = new HtmxConfigTagHelper(new StubAntiforgery());
+ var helper = CreateHelper(new HtmxToolkitOptions());
await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
@@ -53,7 +57,7 @@ public async Task ProcessAsync_WithDefaults_SerializesEmptyObject()
public async Task ProcessAsync_UsesSingleQuotesForJsonAttribute()
{
var output = TestHelper.CreateTagHelperOutput();
- var helper = new HtmxConfigTagHelper(new StubAntiforgery());
+ var helper = CreateHelper(new HtmxToolkitOptions());
await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
@@ -61,191 +65,305 @@ public async Task ProcessAsync_UsesSingleQuotesForJsonAttribute()
}
[Test]
- public async Task ProcessAsync_SerializesConfiguredOptions()
+ public async Task ProcessAsync_SerializesEmptyMethodsThatUseUrlParams_AsEmptyArray()
{
- var helper = new HtmxConfigTagHelper(new StubAntiforgery())
- {
- LogAll = true,
- Prefix = "data-custom-",
- MetaCharacter = "-",
- History = HtmxHistoryMode.Disabled,
- HistoryCacheSize = 42,
- RefreshOnHistoryMiss = true,
- DefaultSwapStyle = HtmxSwap.OuterHtml,
- DefaultSwapEmpty = true,
- DefaultSwapDelay = 250,
- DefaultSettleDelay = 300,
- IncludeIndicatorStyles = false,
- IndicatorClass = "индикатор's",
- RequestClass = "my-request",
- AddedClass = "my-added",
- SwappingClass = "my-swapping",
- SettlingClass = "my-settling",
- AllowEval = false,
- AllowScriptTags = false,
- InlineScriptNonce = "nonce",
- Extensions = "sse, ws",
- InlineStyleNonce = "style-nonce",
- AttributesToSettle = ["class", "style"],
- UseTemplateFragments = true,
- WsReconnectDelay = "exponential",
- WsBinaryType = HtmxBinaryType.ArrayBuffer,
- DisableSelector = "[data-disable]",
- WithCredentials = true,
- DisableInheritance = true,
- DefaultTimeout = 5000,
- Mode = HtmxFetchMode.Cors,
- ScrollBehavior = HtmxScrollBehavior.Instant,
- DefaultFocusScroll = true,
- GetCacheBusterParam = true,
- GlobalViewTransitions = true,
- MorphIgnore = ["data-htmx-powered", "data-preserve"],
- MorphSkip = "[data-skip]",
- MorphSkipChildren = "[data-skip-children]",
- MorphScanLimit = 20,
- NoSwap = ["204", "4xx"],
- MethodsThatUseUrlParams = [HttpVerb.Get, HttpVerb.Delete],
- IgnoreTitle = true,
- ScrollIntoViewOnBoost = false,
- TriggerSpecsCacheEnabled = true,
- AllowNestedOobSwaps = true,
- HistoryRestoreAsHxRequest = false,
- ReportValidityOfForms = true
- };
+ var options = new HtmxToolkitOptions();
+ options.UseHtmxV2(htmx => htmx.MethodsThatUseUrlParams = []);
- var output = TestHelper.CreateTagHelperOutput();
- await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
+ var json = await RenderJson(options);
- Assert.That(output.Attributes["content"]!.Value, Is.TypeOf());
+ Assert.That(json["methodsThatUseUrlParams"].GetRawText(), Is.EqualTo("[]"));
+ }
+
+ [Test]
+ public async Task ProcessAsync_SerializesFullResponseHandlingConfig()
+ {
+ var options = new HtmxToolkitOptions();
+ options.UseHtmxV2(htmx => htmx.ResponseHandling =
+ [
+ new ResponseHandlingConfig
+ {
+ Code = "404",
+ Swap = false,
+ Error = true,
+ IgnoreTitle = true,
+ Select = "#content",
+ Target = "#target",
+ SwapOverride = "innerHTML"
+ }
+ ]);
+
+ var json = await RenderJson(options);
+
+ var entry = json["responseHandling"].EnumerateArray().Single();
+ Assert.Multiple(() =>
+ {
+ Assert.That(entry.GetProperty("code").GetString(), Is.EqualTo("404"));
+ Assert.That(entry.GetProperty("swap").GetBoolean(), Is.False);
+ Assert.That(entry.GetProperty("error").GetBoolean(), Is.True);
+ Assert.That(entry.GetProperty("ignoreTitle").GetBoolean(), Is.True);
+ Assert.That(entry.GetProperty("select").GetString(), Is.EqualTo("#content"));
+ Assert.That(entry.GetProperty("target").GetString(), Is.EqualTo("#target"));
+ Assert.That(entry.GetProperty("swapOverride").GetString(), Is.EqualTo("innerHTML"));
+ });
+ }
- var content = GetContent(output);
- Assert.That(content, Does.Contain("\"индикатор\\u0027s\""));
- Assert.That(content, Does.Not.Contain("'"));
+ [Test]
+ public async Task ProcessAsync_SerializesOnlyHtmx1Options()
+ {
+ var options = new HtmxToolkitOptions();
+ options.UseHtmxV1(htmx =>
+ {
+ htmx.HistoryEnabled = false;
+ htmx.HistoryCacheSize = 42;
+ htmx.RefreshOnHistoryMiss = true;
+ htmx.DefaultSwapStyle = HtmxSwap.OuterHtml;
+ htmx.DefaultSwapDelay = 250;
+ htmx.DefaultSettleDelay = 300;
+ htmx.IncludeIndicatorStyles = false;
+ htmx.IndicatorClass = "indicator";
+ htmx.RequestClass = "request";
+ htmx.AddedClass = "added";
+ htmx.SwappingClass = "swapping";
+ htmx.SettlingClass = "settling";
+ htmx.AllowEval = false;
+ htmx.AllowScriptTags = false;
+ htmx.InlineScriptNonce = "script-nonce";
+ htmx.AttributesToSettle = ["class", "style"];
+ htmx.UseTemplateFragments = true;
+ htmx.WsReconnectDelay = "exponential";
+ htmx.WsBinaryType = HtmxBinaryType.ArrayBuffer;
+ htmx.DisableSelector = "[data-disable]";
+ htmx.WithCredentials = true;
+ htmx.Timeout = 5000;
+ htmx.SelfRequestsOnly = false;
+ htmx.ScrollBehavior = HtmxScrollBehavior.Smooth;
+ htmx.DefaultFocusScroll = true;
+ htmx.GetCacheBusterParam = true;
+ htmx.GlobalViewTransitions = true;
+ htmx.MethodsThatUseUrlParams = [HttpVerb.Get, HttpVerb.Delete];
+ htmx.IgnoreTitle = true;
+ htmx.ScrollIntoViewOnBoost = false;
+ htmx.TriggerSpecsCacheEnabled = true;
+ });
- var json = JsonHelper.ParseJson(content);
+ var json = await RenderJson(options);
- Assert.That(json["logAll"].GetBoolean(), Is.True);
- Assert.That(json["prefix"].GetString(), Is.EqualTo("data-custom-"));
- Assert.That(json["metaCharacter"].GetString(), Is.EqualTo("-"));
- Assert.That(json["historyEnabled"].GetBoolean(), Is.False);
- Assert.That(json["history"].GetBoolean(), Is.False);
- Assert.That(json["historyCacheSize"].GetInt32(), Is.EqualTo(42));
- Assert.That(json["refreshOnHistoryMiss"].GetBoolean(), Is.True);
+ Assert.That(json.Keys, Is.EquivalentTo(new[]
+ {
+ "historyEnabled", "historyCacheSize", "refreshOnHistoryMiss", "defaultSwapStyle",
+ "defaultSwapDelay", "defaultSettleDelay", "includeIndicatorStyles", "indicatorClass",
+ "requestClass", "addedClass", "swappingClass", "settlingClass", "allowEval",
+ "allowScriptTags", "inlineScriptNonce", "attributesToSettle", "useTemplateFragments",
+ "wsReconnectDelay", "wsBinaryType", "disableSelector", "withCredentials", "timeout",
+ "selfRequestsOnly", "scrollBehavior", "defaultFocusScroll", "getCacheBusterParam",
+ "globalViewTransitions", "methodsThatUseUrlParams", "ignoreTitle", "scrollIntoViewOnBoost",
+ "triggerSpecsCache"
+ }));
Assert.That(json["defaultSwapStyle"].GetString(), Is.EqualTo("outerHTML"));
- Assert.That(json["defaultSwap"].GetString(), Is.EqualTo("outerHTML"));
- Assert.That(json["defaultSwapEmpty"].GetBoolean(), Is.True);
- 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("индикатор's"));
- Assert.That(json["includeIndicatorCSS"].GetBoolean(), Is.False);
- 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"));
- Assert.That(json["settlingClass"].GetString(), Is.EqualTo("my-settling"));
- Assert.That(json["allowEval"].GetBoolean(), Is.False);
- Assert.That(json["allowScriptTags"].GetBoolean(), Is.False);
- Assert.That(json["inlineScriptNonce"].GetString(), Is.EqualTo("nonce"));
- Assert.That(json["extensions"].GetString(), Is.EqualTo("sse, ws"));
- Assert.That(json["inlineStyleNonce"].GetString(), Is.EqualTo("style-nonce"));
- Assert.That(json["attributesToSettle"].GetRawText(), Is.EqualTo("[\"class\",\"style\"]"));
- Assert.That(json["useTemplateFragments"].GetBoolean(), Is.True);
- Assert.That(json["wsReconnectDelay"].GetString(), Is.EqualTo("exponential"));
Assert.That(json["wsBinaryType"].GetString(), Is.EqualTo("arraybuffer"));
- Assert.That(json["disableSelector"].GetString(), Is.EqualTo("[data-disable]"));
- Assert.That(json["withCredentials"].GetBoolean(), Is.True);
- Assert.That(json["disableInheritance"].GetBoolean(), Is.True);
- Assert.That(json["implicitInheritance"].GetBoolean(), Is.False);
- Assert.That(json["timeout"].GetInt32(), Is.EqualTo(5000));
- Assert.That(json["defaultTimeout"].GetInt32(), Is.EqualTo(5000));
- Assert.That(json["mode"].GetString(), Is.EqualTo("cors"));
- Assert.That(json["scrollBehavior"].GetString(), Is.EqualTo("instant"));
- Assert.That(json["defaultFocusScroll"].GetBoolean(), Is.True);
- Assert.That(json["getCacheBusterParam"].GetBoolean(), Is.True);
- Assert.That(json["globalViewTransitions"].GetBoolean(), Is.True);
- Assert.That(json["transitions"].GetBoolean(), Is.True);
- Assert.That(json["morphIgnore"].GetRawText(), Is.EqualTo("[\"data-htmx-powered\",\"data-preserve\"]"));
- Assert.That(json["morphSkip"].GetString(), Is.EqualTo("[data-skip]"));
- Assert.That(json["morphSkipChildren"].GetString(), Is.EqualTo("[data-skip-children]"));
- Assert.That(json["morphScanLimit"].GetInt32(), Is.EqualTo(20));
- Assert.That(json["noSwap"].GetRawText(), Is.EqualTo("[\"204\",\"4xx\"]"));
+ Assert.That(json["scrollBehavior"].GetString(), Is.EqualTo("smooth"));
Assert.That(json["methodsThatUseUrlParams"].GetRawText(), Is.EqualTo("[\"get\",\"delete\"]"));
- Assert.That(json["selfRequestsOnly"].GetBoolean(), Is.False);
- Assert.That(json["ignoreTitle"].GetBoolean(), Is.True);
- Assert.That(json["scrollIntoViewOnBoost"].GetBoolean(), Is.False);
Assert.That(json["triggerSpecsCache"].GetRawText(), Is.EqualTo("{}"));
- Assert.That(json["allowNestedOobSwaps"].GetBoolean(), Is.True);
- Assert.That(json["historyRestoreAsHxRequest"].GetBoolean(), Is.False);
- Assert.That(json["reportValidityOfForms"].GetBoolean(), Is.True);
}
[Test]
- public async Task ProcessAsync_EscapesHtmlSensitiveCharacters()
+ public async Task ProcessAsync_SerializesOnlyHtmx2Options_InResponseHandlingOrder()
{
- var helper = new HtmxConfigTagHelper(new StubAntiforgery())
+ var options = new HtmxToolkitOptions();
+ options.UseHtmxV2(htmx =>
{
- RequestClass = " & \"b\" 'c'"
- };
+ htmx.HistoryEnabled = true;
+ htmx.HistoryCacheSize = 42;
+ htmx.RefreshOnHistoryMiss = true;
+ htmx.DefaultSwapStyle = HtmxSwap.InnerHtml;
+ htmx.DefaultSwapDelay = 250;
+ htmx.DefaultSettleDelay = 300;
+ htmx.IncludeIndicatorStyles = false;
+ htmx.IndicatorClass = "indicator";
+ htmx.RequestClass = "request";
+ htmx.AddedClass = "added";
+ htmx.SwappingClass = "swapping";
+ htmx.SettlingClass = "settling";
+ htmx.AllowEval = false;
+ htmx.AllowScriptTags = false;
+ htmx.InlineScriptNonce = "script-nonce";
+ htmx.InlineStyleNonce = "style-nonce";
+ htmx.AttributesToSettle = ["class", "style"];
+ htmx.WsReconnectDelay = "exponential";
+ htmx.WsBinaryType = HtmxBinaryType.Blob;
+ htmx.DisableSelector = "[data-disable]";
+ htmx.WithCredentials = true;
+ htmx.DisableInheritance = true;
+ htmx.Timeout = 5000;
+ htmx.SelfRequestsOnly = true;
+ htmx.ScrollBehavior = HtmxScrollBehavior.Instant;
+ htmx.DefaultFocusScroll = true;
+ htmx.GetCacheBusterParam = true;
+ htmx.GlobalViewTransitions = true;
+ htmx.MethodsThatUseUrlParams = [HttpVerb.Get, HttpVerb.Delete];
+ htmx.IgnoreTitle = true;
+ htmx.ScrollIntoViewOnBoost = false;
+ htmx.TriggerSpecsCacheEnabled = true;
+ htmx.ResponseHandling =
+ [
+ new ResponseHandlingConfig { Code = "204", Swap = false },
+ new ResponseHandlingConfig { Code = "[45]..", Swap = false, Error = true }
+ ];
+ htmx.AllowNestedOobSwaps = true;
+ htmx.HistoryRestoreAsHxRequest = false;
+ htmx.ReportValidityOfForms = true;
+ });
+
+ var json = await RenderJson(options);
+
+ Assert.That(json.Keys, Is.EquivalentTo(new[]
+ {
+ "historyEnabled", "historyCacheSize", "refreshOnHistoryMiss", "defaultSwapStyle",
+ "defaultSwapDelay", "defaultSettleDelay", "includeIndicatorStyles", "indicatorClass",
+ "requestClass", "addedClass", "swappingClass", "settlingClass", "allowEval",
+ "allowScriptTags", "inlineScriptNonce", "inlineStyleNonce", "attributesToSettle",
+ "wsReconnectDelay", "wsBinaryType", "disableSelector", "withCredentials",
+ "disableInheritance", "timeout", "selfRequestsOnly", "scrollBehavior", "defaultFocusScroll",
+ "getCacheBusterParam", "globalViewTransitions", "methodsThatUseUrlParams", "ignoreTitle",
+ "scrollIntoViewOnBoost", "triggerSpecsCache", "responseHandling", "allowNestedOobSwaps",
+ "historyRestoreAsHxRequest", "reportValidityOfForms"
+ }));
+ Assert.That(json["defaultSwapStyle"].GetString(), Is.EqualTo("innerHTML"));
+ Assert.That(json["scrollBehavior"].GetString(), Is.EqualTo("instant"));
+
+ var responseHandling = json["responseHandling"].EnumerateArray().ToArray();
+ Assert.That(responseHandling[0].GetProperty("code").GetString(), Is.EqualTo("204"));
+ Assert.That(responseHandling[1].GetProperty("code").GetString(), Is.EqualTo("[45].."));
+ }
+
+ [Test]
+ public async Task ProcessAsync_SerializesOnlyHtmx4Options()
+ {
+ var options = new HtmxToolkitOptions();
+ options.UseHtmxV4(htmx =>
+ {
+ htmx.LogAll = true;
+ htmx.Prefix = "data-custom-";
+ htmx.MetaCharacter = "-";
+ htmx.History = HtmxHistoryMode.Reload;
+ htmx.DefaultSwap = HtmxSwap.OuterMorph;
+ htmx.DefaultSwapEmpty = true;
+ htmx.DefaultSettleDelay = 1;
+ htmx.IncludeIndicatorCss = false;
+ htmx.IndicatorClass = "indicator";
+ htmx.RequestClass = "request";
+ htmx.InlineScriptNonce = "nonce";
+ htmx.Extensions = "sse, ws";
+ htmx.ImplicitInheritance = false;
+ htmx.DefaultTimeout = 5000;
+ htmx.Mode = HtmxFetchMode.NoCors;
+ htmx.DefaultFocusScroll = true;
+ htmx.Transitions = true;
+ htmx.MorphIgnore = ["data-htmx-powered"];
+ htmx.MorphSkip = "[data-skip]";
+ htmx.MorphSkipChildren = "[data-skip-children]";
+ htmx.MorphScanLimit = 20;
+ htmx.NoSwap = ["204", "4xx"];
+ });
+
+ var json = await RenderJson(options);
+
+ Assert.That(json.Keys, Is.EquivalentTo(new[]
+ {
+ "logAll", "prefix", "metaCharacter", "history", "defaultSwap", "defaultSwapEmpty",
+ "defaultSettleDelay", "includeIndicatorCSS", "indicatorClass", "requestClass",
+ "inlineScriptNonce", "extensions", "implicitInheritance", "defaultTimeout", "mode",
+ "defaultFocusScroll", "transitions", "morphIgnore", "morphSkip", "morphSkipChildren",
+ "morphScanLimit", "noSwap"
+ }));
+ Assert.That(json["history"].GetString(), Is.EqualTo("reload"));
+ Assert.That(json["defaultSwap"].GetString(), Is.EqualTo("outerMorph"));
+ Assert.That(json["mode"].GetString(), Is.EqualTo("no-cors"));
+ Assert.That(json.ContainsKey("timeout"), Is.False);
+ Assert.That(json.ContainsKey("defaultSwapStyle"), Is.False);
+ }
+
+ [Test]
+ [TestCase(HtmxHistoryMode.Enabled, "true")]
+ [TestCase(HtmxHistoryMode.Disabled, "false")]
+ [TestCase(HtmxHistoryMode.Reload, "\"reload\"")]
+ public async Task ProcessAsync_SerializesHtmx4HistoryMode(HtmxHistoryMode mode, string expected)
+ {
+ var options = new HtmxToolkitOptions();
+ options.UseHtmxV4(htmx => htmx.History = mode);
+
+ var json = await RenderJson(options);
+
+ Assert.That(json["history"].GetRawText(), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public async Task ProcessAsync_EscapesHtmlSensitiveCharacters()
+ {
+ var options = new HtmxToolkitOptions();
+ options.UseHtmxV2(htmx => htmx.RequestClass = " & \"b\" 'c'");
var output = TestHelper.CreateTagHelperOutput();
- await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
+ await CreateHelper(options).ProcessAsync(TestHelper.CreateTagHelperContext(), output);
- var content = GetContent(output);
- Assert.That(content, Is.EqualTo("{\"requestClass\":\"\\u003Ca\\u003E \\u0026 \\u0022b\\u0022 \\u0027c\\u0027\"}"));
+ Assert.That(
+ GetContent(output),
+ Is.EqualTo("{\"requestClass\":\"\\u003Ca\\u003E \\u0026 \\u0022b\\u0022 \\u0027c\\u0027\"}"));
}
[Test]
- public async Task ProcessAsync_IncludeAntiForgeryToken_SerializesTokens()
+ public async Task ProcessAsync_IncludeAntiforgeryToken_RendersDataAttributes()
{
var antiforgery = new StubAntiforgery();
var httpContext = new DefaultHttpContext();
- var helper = new HtmxConfigTagHelper(antiforgery)
- {
- IncludeAntiForgeryToken = true,
- ViewContext = new ViewContext
- {
- HttpContext = httpContext
- }
- };
+ var options = new HtmxToolkitOptions { IncludeAntiforgeryToken = true };
+ var helper = CreateHelper(options, antiforgery);
+ helper.ViewContext = new ViewContext { HttpContext = httpContext };
var output = TestHelper.CreateTagHelperOutput();
await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
- Assert.That(antiforgery.StoredHttpContext, Is.SameAs(httpContext));
-
- var json = JsonHelper.ParseJson(GetContent(output));
- var antiForgery = json["antiForgery"];
-
- Assert.That(antiForgery.GetProperty("requestToken").GetString(), Is.EqualTo("request-token"));
- Assert.That(antiForgery.GetProperty("headerName").GetString(), Is.EqualTo("RequestVerificationToken"));
- Assert.That(antiForgery.GetProperty("cookieToken").GetString(), Is.EqualTo("cookie-token"));
+ Assert.Multiple(() =>
+ {
+ Assert.That(antiforgery.StoredHttpContext, Is.SameAs(httpContext));
+ Assert.That(output.Attributes["data-antiforgery-request-token"]!.Value, Is.EqualTo("request-token"));
+ Assert.That(output.Attributes["data-antiforgery-header-name"]!.Value, Is.EqualTo("RequestVerificationToken"));
+ Assert.That(output.Attributes["data-antiforgery-form-field-name"]!.Value, Is.EqualTo("__RequestVerificationToken"));
+ Assert.That(output.Attributes.Any(attribute => attribute.Name.Contains("cookie", StringComparison.OrdinalIgnoreCase)), Is.False);
+ Assert.That(GetContent(output), Is.EqualTo("{}"));
+ });
}
[Test]
- public async Task ProcessAsync_ExecutesChildContent_CollectsResponseHandlingEntries()
+ public async Task ProcessAsync_AntiforgeryDisabled_DoesNotRequestOrRenderTokens()
{
- var items = new Dictionary