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(); - var context = TestHelper.CreateTagHelperContext("htmx-config", null, items); - var child = new ResponseHandlingTagHelper - { - Code = "404", - Swap = false - }; + var antiforgery = new StubAntiforgery(); + var output = TestHelper.CreateTagHelperOutput(); + + await CreateHelper(new HtmxToolkitOptions(), antiforgery) + .ProcessAsync(TestHelper.CreateTagHelperContext(), output); - var output = new TagHelperOutput("htmx-config", [], async (_, _) => + Assert.Multiple(() => { - await child.ProcessAsync(context, TestHelper.CreateTagHelperOutput("response-handling")); - return new DefaultTagHelperContent(); + Assert.That(antiforgery.StoredHttpContext, Is.Null); + Assert.That(output.Attributes["data-antiforgery-request-token"], Is.Null); + Assert.That(output.Attributes["data-antiforgery-header-name"], Is.Null); + Assert.That(output.Attributes["data-antiforgery-form-field-name"], Is.Null); }); + } - var helper = new HtmxConfigTagHelper(new StubAntiforgery()); - await helper.ProcessAsync(context, output); - - Assert.That(helper.ResponseHandling!.Count, Is.EqualTo(1)); - Assert.That(helper.ResponseHandling[0].Code, Is.EqualTo("404")); - Assert.That(helper.ResponseHandling[0].Swap, Is.False); + private static async Task> RenderJson(HtmxToolkitOptions options) + { + var output = TestHelper.CreateTagHelperOutput(); + await CreateHelper(options).ProcessAsync(TestHelper.CreateTagHelperContext(), output); + Assert.That(output.Attributes["content"]!.Value, Is.TypeOf()); + return JsonHelper.ParseJson(GetContent(output)); } + private static HtmxConfigTagHelper CreateHelper(HtmxToolkitOptions options, IAntiforgery? antiforgery = null) => + new(antiforgery ?? new StubAntiforgery(), Options.Create(options)); + private static string GetContent(TagHelperOutput output) => output.Attributes["content"]!.Value!.ToString()!; diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs index 60c9d0f..8673b2e 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs @@ -30,6 +30,40 @@ public async Task ProcessAsync_SerializesLegacyRequestConfiguration(HtmxTargetVe Assert.That(json["noHeaders"].GetBoolean(), Is.False); } + [Test] + [TestCase(HtmxTargetVersion.V1)] + [TestCase(HtmxTargetVersion.V2)] + public async Task ProcessAsync_SerializesSameOriginCredentialsAsFalse(HtmxTargetVersion version) + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(version); + helper.Credentials = HtmxRequestCredentials.SameOrigin; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-request"]; + + Assert.That(attribute, Is.Not.Null); + + var json = JsonHelper.ParseJson(attribute!.Value.ToString()!); + Assert.That(json["credentials"].GetBoolean(), Is.False); + } + + [Test] + public async Task ProcessAsync_SerializesNoHeadersWhenTrue() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V2); + helper.NoHeaders = true; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-request"]; + + Assert.That(attribute, Is.Not.Null); + + var json = JsonHelper.ParseJson(attribute!.Value.ToString()!); + Assert.That(json["noHeaders"].GetBoolean(), Is.True); + } + [Test] public async Task ProcessAsync_OmitsUnsetLegacyProperties() { @@ -81,6 +115,25 @@ public async Task ProcessAsync_SerializesHtmx4RequestConfiguration() Assert.That(output.Attributes["hx-request"], Is.Null); } + [Test] + [TestCase(HtmxRequestCredentials.SameOrigin, "same-origin")] + [TestCase(HtmxRequestCredentials.Include, "include")] + [TestCase(HtmxRequestCredentials.Omit, "omit")] + public async Task ProcessAsync_SerializesHtmx4CredentialsMode(HtmxRequestCredentials credentials, string expected) + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + helper.Credentials = credentials; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-config"]; + + Assert.That(attribute, Is.Not.Null); + + var json = JsonHelper.ParseJson(attribute!.Value.ToString()!); + Assert.That(json["credentials"].GetString(), Is.EqualTo(expected)); + } + [Test] public async Task ProcessAsync_OmitsHtmx4OnlyCredentialsModeForLegacyVersions() { @@ -121,6 +174,25 @@ public async Task ProcessAsync_OmitsUnsetConfiguration() Assert.That(output.Attributes["hx-request"], Is.Null); } - private static HtmxRequestTagHelper CreateHelper(HtmxTargetVersion version) => - new(Options.Create(new HtmxToolkitOptions { TargetVersion = version })); + private static HtmxRequestTagHelper CreateHelper(HtmxTargetVersion version) + { + var options = new HtmxToolkitOptions(); + + switch (version) + { + case HtmxTargetVersion.V1: + options.UseHtmxV1(); + break; + case HtmxTargetVersion.V2: + options.UseHtmxV2(); + break; + case HtmxTargetVersion.V4: + options.UseHtmxV4(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(version)); + } + + return new HtmxRequestTagHelper(Options.Create(options)); + } } diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxToolkitOptionsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxToolkitOptionsTests.cs new file mode 100644 index 0000000..36a9893 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxToolkitOptionsTests.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using Ramstack.HtmxToolkit.Builder; + +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxToolkitOptionsTests +{ + [Test] + public void DefaultsToHtmx2() + { + var options = new HtmxToolkitOptions(); + + Assert.Multiple(() => + { + Assert.That(options.TargetVersion, Is.EqualTo(HtmxTargetVersion.V2)); + Assert.That(options.HtmxConfig, Is.TypeOf()); + }); + } + + [Test] + public void UseHtmxV1_StoresInspectableConfig() + { + var options = new HtmxToolkitOptions(); + + options.UseHtmxV1(config => config.Timeout = 2500); + + var config = options.GetHtmxConfig(); + Assert.Multiple(() => + { + Assert.That(options.TargetVersion, Is.EqualTo(HtmxTargetVersion.V1)); + Assert.That(options.HtmxConfig, Is.SameAs(config)); + Assert.That(config.Timeout, Is.EqualTo(2500)); + }); + } + + [Test] + public void UseHtmxV4_StoresInspectableConfig() + { + var options = new HtmxToolkitOptions(); + + options.UseHtmxV4(config => config.DefaultTimeout = 5000); + + var config = options.GetHtmxConfig(); + Assert.Multiple(() => + { + Assert.That(options.TargetVersion, Is.EqualTo(HtmxTargetVersion.V4)); + Assert.That(options.HtmxConfig, Is.SameAs(config)); + Assert.That(config.DefaultTimeout, Is.EqualTo(5000)); + }); + } + + [Test] + public void UseHtmxV2_RepeatedCallsComposeOnSameConfig() + { + var options = new HtmxToolkitOptions(); + + options.UseHtmxV2(config => config.Timeout = 1000); + var first = options.HtmxConfig; + options.UseHtmxV2(config => config.DefaultSettleDelay = 20); + + var config = options.GetHtmxConfig(); + Assert.Multiple(() => + { + Assert.That(config, Is.SameAs(first)); + Assert.That(config.Timeout, Is.EqualTo(1000)); + Assert.That(config.DefaultSettleDelay, Is.EqualTo(20)); + }); + } + + [Test] + public void SelectingDifferentVersions_Throws() + { + var options = new HtmxToolkitOptions(); + options.UseHtmxV1(); + + Assert.That( + () => options.UseHtmxV4(), + Throws.TypeOf() + .With.Message.EqualTo("HTMX has already been configured for version V1. Cannot reconfigure to V4.")); + } + + [Test] + public void GetHtmxConfig_ForDifferentVersion_Throws() + { + var options = new HtmxToolkitOptions(); + options.UseHtmxV4(); + + Assert.That( + () => options.GetHtmxConfig(), + Throws.TypeOf() + .With.Message.EqualTo("HTMX configuration version 'V4' does not match the requested configuration type.") + ); + } + + [Test] + public void UseHtmxV4_AfterReadingDefault_ThrowsInvalidOperationException() + { + var options = new HtmxToolkitOptions(); + var defaultHtmxConfig = options.HtmxConfig; + + Assert.Multiple(() => + { + Assert.That(defaultHtmxConfig, Is.TypeOf()); + Assert.That( + () => options.UseHtmxV4(config => config.DefaultTimeout = 5000), + Throws.TypeOf() + .With.Message.EqualTo("HTMX has already been configured for version V2. Cannot reconfigure to V4.") + ); + }); + } + + + [Test] + public void AddHtmxToolkit_DependencyInjection_ExposesConfiguredOptions() + { + var services = new ServiceCollection(); + services.AddHtmxToolkit(options => + { + options.IncludeAntiforgeryToken = true; + options.UseHtmxV4(config => config.DefaultTimeout = 5000); + }); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + var config = options.GetHtmxConfig(); + + Assert.Multiple(() => + { + Assert.That(options.IncludeAntiforgeryToken, Is.True); + Assert.That(config.DefaultTimeout, Is.EqualTo(5000)); + }); + } + + [Test] + public void AddHtmxToolkit_WithoutConfiguration_ExposesDefaultOptions() + { + var services = new ServiceCollection(); + services.AddHtmxToolkit(); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Multiple(() => + { + Assert.That(options.TargetVersion, Is.EqualTo(HtmxTargetVersion.V2)); + Assert.That(options.HtmxConfig, Is.TypeOf()); + Assert.That(options.IncludeAntiforgeryToken, Is.False); + }); + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HttpVerbArrayTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HttpVerbArrayTests.cs deleted file mode 100644 index 859e9f2..0000000 --- a/tests/Ramstack.HtmxToolkit.Tests/HttpVerbArrayTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Ramstack.HtmxToolkit.Tests; - -[TestFixture] -public class HttpVerbArrayTests -{ - [Test] - public void EnumeratesVerbs_AsLowercaseStrings() - { - var verbs = new HttpVerbArray([HttpVerb.Get, HttpVerb.Post, HttpVerb.Delete]); - Assert.That(verbs, Is.EqualTo(new[] { "get", "post", "delete" })); - } - - [Test] - public void EnumeratesEmpty_ForNullArray() - { - var verbs = new HttpVerbArray(null); - Assert.That(verbs, Is.Empty); - } - - [Test] - public void EnumeratesEmpty_ForEmptyArray() - { - var verbs = new HttpVerbArray([]); - Assert.That(verbs, Is.Empty); - } - - [Test] - public void Values_ExposesUnderlyingArray() - { - var array = new[] { HttpVerb.Put }; - var verbs = new HttpVerbArray(array); - - Assert.That(verbs.Values, Is.SameAs(array)); - } -} diff --git a/tests/Ramstack.HtmxToolkit.Tests/ResponseHandlingTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/ResponseHandlingTagHelperTests.cs deleted file mode 100644 index 7678067..0000000 --- a/tests/Ramstack.HtmxToolkit.Tests/ResponseHandlingTagHelperTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace Ramstack.HtmxToolkit.Tests; - -[TestFixture] -public class ResponseHandlingTagHelperTests -{ - [Test] - public async Task ProcessAsync_AddsEntryToConfig() - { - var config = new HtmxConfigTagHelper(null!); - var items = new Dictionary - { - [typeof(HtmxConfigTagHelper)] = config - }; - - var entry = new ResponseHandlingTagHelper - { - Code = "4[0-9]{2}", - Swap = false, - Error = true, - IgnoreTitle = true, - Select = "#content", - Target = "#target", - SwapOverride = "innerHTML" - }; - - var output = TestHelper.CreateTagHelperOutput("response-handling"); - await entry.ProcessAsync( - TestHelper.CreateTagHelperContext("response-handling", null, items), - output); - - Assert.That(config.ResponseHandling!.Count, Is.EqualTo(1)); - - var actual = config.ResponseHandling![0]; - Assert.That(actual.Code, Is.EqualTo("4[0-9]{2}")); - Assert.That(actual.Swap, Is.False); - Assert.That(actual.Error, Is.True); - Assert.That(actual.IgnoreTitle, Is.True); - Assert.That(actual.Select, Is.EqualTo("#content")); - Assert.That(actual.Target, Is.EqualTo("#target")); - Assert.That(actual.SwapOverride, Is.EqualTo("innerHTML")); - } - - [Test] - public async Task ProcessAsync_SuppressesOutput() - { - var config = new HtmxConfigTagHelper(null!); - var items = new Dictionary - { - [typeof(HtmxConfigTagHelper)] = config - }; - - var entry = new ResponseHandlingTagHelper(); - var output = TestHelper.CreateTagHelperOutput("response-handling"); - - await entry.ProcessAsync( - TestHelper.CreateTagHelperContext("response-handling", null, items), - output); - - Assert.That(output.TagName, Is.Null); - Assert.That(output.IsContentModified, Is.True); - } - - [Test] - public void ProcessAsync_WithoutConfig_Throws() - { - var entry = new ResponseHandlingTagHelper(); - var output = TestHelper.CreateTagHelperOutput("response-handling"); - - Assert.ThrowsAsync( - async () => await entry.ProcessAsync( - TestHelper.CreateTagHelperContext("response-handling"), - output)); - } -}