diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml index e57bd1f..2fe44ff 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml @@ -9,7 +9,8 @@ + default-swap-style="HtmxSwap.InnerHtml" + no-swap="@(["204", "304", "4xx", "5xx"])"> diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js index 9ad59c3..60b19dc 100644 --- a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js +++ b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js @@ -1,31 +1,61 @@ document._r_htmx ||= ((document, htmx) => { - document.addEventListener("htmx:afterOnLoad", e => { - if (e.detail.boosted) { - const html = new DOMParser().parseFromString(e.detail.xhr.responseText, "text/html"); - const meta = html.querySelector("meta[name='htmx-config']"); + const listen = (type, listener) => { + document.addEventListener(type, listener); + }; - meta && (htmx.config.antiForgery = JSON.parse(meta.content).antiForgery); - } - }); - - document.addEventListener("htmx:configRequest", e => { - if (!/^get$/i.test(e.detail.verb)) { + const add_antiforgery = (method, headers, parameters) => { + if (!/^get$/i.test(method)) { const { headerName, formFieldName, requestToken } = htmx.config.antiForgery ?? {}; - if (requestToken && !e.detail.parameters[formFieldName]) { - headerName - ? e.detail.headers[headerName] = requestToken - : e.detail.parameters[formFieldName] = requestToken; + if (requestToken) { + if (!parameters.has?.(formFieldName) && !parameters[formFieldName]) + { + if (headerName) { + headers[headerName] = requestToken; + } + else + { + parameters.set + ? parameters.set(formFieldName, requestToken) + : parameters[formFieldName] = requestToken; + } + } } } + }; + + 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); + }; + + listen("htmx:afterOnLoad", e => { + let detail = e.detail; + detail.boosted && update_antiforgery(detail.xhr.responseText); + }); + + listen("htmx:after:request", e => { + let ctx = e.detail.ctx; + ctx.boosted && update_antiforgery(ctx.text); + }); + + listen("htmx:configRequest", e => { + let detail = e.detail; + add_antiforgery(detail.verb, detail.headers, detail.parameters); + }); + + listen("htmx:config:request", e => { + let request = e.detail.ctx.request; + add_antiforgery(request.method, request.headers, request.body); }); - document.addEventListener("rs:events", e => { - for (let kvp of e.detail.value) { + listen("rs:events", e => { + for (let kvp of e.detail.value || e.detail) { htmx.trigger(e.target, kvp.key, kvp.value); } }); diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js index a307273..65f77b3 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)=>(e.addEventListener("htmx:afterOnLoad",e=>{if(e.detail.boosted){const r=(new DOMParser).parseFromString(e.detail.xhr.responseText,"text/html").querySelector("meta[name='htmx-config']");r&&(t.config.antiForgery=JSON.parse(r.content).antiForgery)}}),e.addEventListener("htmx:configRequest",e=>{if(!/^get$/i.test(e.detail.verb)){const{headerName:r,formFieldName:a,requestToken:n}=t.config.antiForgery??{};n&&!e.detail.parameters[a]&&(r?e.detail.headers[r]=n:e.detail.parameters[a]=n)}}),e.addEventListener("rs:events",e=>{for(let r of e.detail.value)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,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 diff --git a/src/Ramstack.HtmxToolkit/Builder/ServiceCollectionExtensions.cs b/src/Ramstack.HtmxToolkit/Builder/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ee40f03 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/Builder/ServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Ramstack.HtmxToolkit.Builder; + +/// +/// Provides registration methods for HTMX Toolkit services. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers HTMX Toolkit configuration and its startup configuration cache. + /// + /// The service collection. + /// An optional delegate used to configure HTMX Toolkit. + /// + /// The same service collection. + /// + public static IServiceCollection AddHtmxToolkit(this IServiceCollection services, Action? configure = null) + { + services.AddOptions(); + + if (configure is not null) + services.Configure(configure); + + return services; + } +} diff --git a/src/Ramstack.HtmxToolkit/HtmxFetchMode.cs b/src/Ramstack.HtmxToolkit/HtmxFetchMode.cs new file mode 100644 index 0000000..46835c1 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxFetchMode.cs @@ -0,0 +1,31 @@ +namespace Ramstack.HtmxToolkit; + +/// +/// Specifies the request mode used by HTMX. +/// +/// +/// In HTMX 4.x this is passed as the mode option of the Fetch API. +/// +/// In HTMX 1.x and 2.x (compatibility mode) this maps to the selfRequestsOnly +/// boolean configuration option, where yields +/// and any other value yields . +/// +/// +public enum HtmxFetchMode +{ + /// + /// Allows requests only to the current origin. + /// + SameOrigin, + + /// + /// Allows cross-origin requests using CORS. + /// + Cors, + + /// + /// Allows restricted cross-origin requests that produce opaque responses. + /// Opaque responses cannot normally be swapped by HTMX. + /// + NoCors +} diff --git a/src/Ramstack.HtmxToolkit/HtmxHistoryMode.cs b/src/Ramstack.HtmxToolkit/HtmxHistoryMode.cs new file mode 100644 index 0000000..96c07fa --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxHistoryMode.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Ramstack.HtmxToolkit; + +/// +/// Specifies how HTMX history restoration is handled. +/// +public enum HtmxHistoryMode +{ + /// + /// Enables history snapshots and restoration. + /// + Enabled, + + /// + /// Disables HTMX history support. + /// + Disabled, + + /// + /// Reloads the page when restoring history in HTMX 4. HTMX 1 and 2 treat this as . + /// + Reload +} diff --git a/src/Ramstack.HtmxToolkit/HtmxHistoryModeJsonConverter.cs b/src/Ramstack.HtmxToolkit/HtmxHistoryModeJsonConverter.cs new file mode 100644 index 0000000..285c00f --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxHistoryModeJsonConverter.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ramstack.HtmxToolkit; + +/// +/// Represents a for values +/// written in the format expected by the history configuration option: +/// and +/// are written as booleans, while any other value is written as its lowercase +/// string representation (e.g. "reload"). +/// +/// +/// Unlike other enum values, which are serialized as strings, this one requires a custom +/// converter: and +/// must be written as actual JSON booleans rather than strings, since otherwise htmx +/// would not recognize them. +/// +internal sealed class HtmxHistoryModeJsonConverter : JsonConverter +{ + /// + /// Pre-encoded "reload" text; encoding it once as a static field avoids + /// repeated UTF-8 encoding overhead on each serialization. + /// + private static readonly JsonEncodedText s_reload = JsonEncodedText.Encode("reload"); + + /// + public override HtmxHistoryMode? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + /// + public override void Write(Utf8JsonWriter writer, HtmxHistoryMode? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + } + else + { + switch (value.GetValueOrDefault()) + { + case HtmxHistoryMode.Enabled: + writer.WriteBooleanValue(true); + break; + case HtmxHistoryMode.Disabled: + writer.WriteBooleanValue(false); + break; + default: + writer.WriteStringValue(s_reload); + break; + } + } + } +} diff --git a/src/Ramstack.HtmxToolkit/HtmxRequestCredentials.cs b/src/Ramstack.HtmxToolkit/HtmxRequestCredentials.cs new file mode 100644 index 0000000..e379f09 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxRequestCredentials.cs @@ -0,0 +1,23 @@ +namespace Ramstack.HtmxToolkit; + +/// +/// Specifies the credentials mode for an HTMX request. +/// +public enum HtmxRequestCredentials +{ + /// + /// Sends credentials only when the request targets the current origin. + /// + SameOrigin, + + /// + /// Always sends credentials with the request. + /// + Include, + + /// + /// Never sends credentials with the request. + /// + /// Supported only in HTMX 4.x. + Omit +} diff --git a/src/Ramstack.HtmxToolkit/HtmxSwap.cs b/src/Ramstack.HtmxToolkit/HtmxSwap.cs index b98d30f..62ee1f7 100644 --- a/src/Ramstack.HtmxToolkit/HtmxSwap.cs +++ b/src/Ramstack.HtmxToolkit/HtmxSwap.cs @@ -15,6 +15,26 @@ public enum HtmxSwap /// OuterHtml, + /// + /// Morphs the inner HTML of the target element. + /// + InnerMorph, + + /// + /// Morphs the target element itself. + /// + OuterMorph, + + /// + /// Synchronizes the target element with the response. + /// + OuterSync, + + /// + /// Replaces the text content of the target element. + /// + TextContent, + /// /// Inserts the response before the target element. /// diff --git a/src/Ramstack.HtmxToolkit/HtmxTargetVersion.cs b/src/Ramstack.HtmxToolkit/HtmxTargetVersion.cs new file mode 100644 index 0000000..3a0d111 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxTargetVersion.cs @@ -0,0 +1,22 @@ +namespace Ramstack.HtmxToolkit; + +/// +/// Specifies the HTMX major version targeted by generated markup. +/// +public enum HtmxTargetVersion +{ + /// + /// HTMX 1.x. + /// + V1, + + /// + /// HTMX 2.x. + /// + V2, + + /// + /// HTMX 4.x. + /// + V4 +} diff --git a/src/Ramstack.HtmxToolkit/HtmxToolkitOptions.cs b/src/Ramstack.HtmxToolkit/HtmxToolkitOptions.cs new file mode 100644 index 0000000..f7156af --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxToolkitOptions.cs @@ -0,0 +1,12 @@ +namespace Ramstack.HtmxToolkit; + +/// +/// Configures services provided by the HTMX toolkit. +/// +public sealed class HtmxToolkitOptions +{ + /// + /// Gets or sets the HTMX major version used for version-sensitive generated markup. + /// + public HtmxTargetVersion TargetVersion { get; set; } = HtmxTargetVersion.V2; +} diff --git a/src/Ramstack.HtmxToolkit/HtmxTriggerSpecsCacheJsonConverter.cs b/src/Ramstack.HtmxToolkit/HtmxTriggerSpecsCacheJsonConverter.cs new file mode 100644 index 0000000..1ab32d3 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxTriggerSpecsCacheJsonConverter.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ramstack.HtmxToolkit; + +/// +/// Represents a that serializes the triggerSpecsCache +/// configuration option from its boolean form: is written as an empty +/// JSON object ({}), instructing htmx to use a never-clearing trigger specification cache, +/// while and are written as JSON null. +/// +internal sealed class HtmxTriggerSpecsCacheJsonConverter : JsonConverter +{ + /// + public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + /// + public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options) + { + if (value.GetValueOrDefault()) + { + writer.WriteStartObject(); + writer.WriteEndObject(); + } + else + { + writer.WriteNullValue(); + } + } +} diff --git a/src/Ramstack.HtmxToolkit/Internal/EnumHelper.cs b/src/Ramstack.HtmxToolkit/Internal/EnumHelper.cs index f0983d4..bb5f687 100644 --- a/src/Ramstack.HtmxToolkit/Internal/EnumHelper.cs +++ b/src/Ramstack.HtmxToolkit/Internal/EnumHelper.cs @@ -58,6 +58,10 @@ public static string GetSwapValue(this HtmxSwap value) { HtmxSwap.InnerHtml => "innerHTML", HtmxSwap.OuterHtml => "outerHTML", + HtmxSwap.InnerMorph => "innerMorph", + HtmxSwap.OuterMorph => "outerMorph", + HtmxSwap.OuterSync => "outerSync", + HtmxSwap.TextContent => "textContent", HtmxSwap.BeforeBegin => "beforebegin", HtmxSwap.AfterBegin => "afterbegin", HtmxSwap.BeforeEnd => "beforeend", @@ -90,6 +94,42 @@ public static string GetHttpVerbValue(this HttpVerb value) }; } + /// + /// Converts a value to its corresponding Fetch API request mode string. + /// + /// The value. + /// + /// The string representation: "same-origin", "cors" or "no-cors". + /// + public static string GetFetchModeValue(this HtmxFetchMode value) + { + return value switch + { + HtmxFetchMode.SameOrigin => "same-origin", + HtmxFetchMode.Cors => "cors", + _ => "no-cors" + }; + } + + /// + /// Parses a string into a value. + /// + /// The string to parse. + /// + /// The parsed value if successful; + /// otherwise, . + /// + public static HtmxFetchMode? ParseHtmxFetchMode(string? expression) + { + return expression switch + { + "same-origin" => HtmxFetchMode.SameOrigin, + "cors" => HtmxFetchMode.Cors, + "no-cors" => HtmxFetchMode.NoCors, + _ => null + }; + } + /// /// Parses a string into a value. /// diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs index d6e45b7..4906758 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Html; @@ -21,23 +22,68 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper private readonly HtmxConfigData _config = new(); /// - /// Gets or sets a value indicating whether history is enabled. - /// Defaults to . + /// 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 . /// /// - /// Supported in HTMX 1.x and 2.x. This is mainly useful for testing. + /// + /// 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-enabled")] - public bool? HistoryEnabled + [HtmlAttributeName("history")] + public HtmxHistoryMode? History { - get => _config.HistoryEnabled; - set => _config.HistoryEnabled = value; + 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. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("history-cache-size")] public int? HistoryCacheSize { @@ -50,7 +96,7 @@ public int? HistoryCacheSize /// should be issued on history misses rather than using an AJAX request. /// Defaults to . /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("refresh-on-history-miss")] public bool? RefreshOnHistoryMiss { @@ -62,7 +108,10 @@ public bool? RefreshOnHistoryMiss /// Gets or sets the default swap style. /// Defaults to . /// - /// Supported in HTMX 1.x and 2.x. + /// + /// 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 { @@ -71,10 +120,21 @@ public HtmxSwap? 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. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("default-swap-delay")] public int? DefaultSwapDelay { @@ -83,9 +143,10 @@ public int? DefaultSwapDelay } /// - /// Gets or sets the default settle delay. Defaults to 20. + /// 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 and 2.x. + /// Supported in HTMX 1.x, 2.x, and 4.x. [HtmlAttributeName("default-settle-delay")] public int? DefaultSettleDelay { @@ -97,7 +158,10 @@ public int? DefaultSettleDelay /// Gets or sets a value indicating whether the indicator styles are loaded. /// Defaults to . /// - /// Supported in HTMX 1.x and 2.x. + /// + /// 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 { @@ -108,7 +172,7 @@ public bool? IncludeIndicatorStyles /// /// Gets or sets the indicator class. Defaults to htmx-indicator. /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x, 2.x, and 4.x. [HtmlAttributeName("indicator-class")] public string? IndicatorClass { @@ -119,7 +183,7 @@ public string? IndicatorClass /// /// Gets or sets the request class. Defaults to htmx-request. /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x, 2.x, and 4.x. [HtmlAttributeName("request-class")] public string? RequestClass { @@ -130,7 +194,7 @@ public string? RequestClass /// /// Gets or sets the added class. Defaults to htmx-added. /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("added-class")] public string? AddedClass { @@ -141,7 +205,7 @@ public string? AddedClass /// /// Gets or sets the swapping class. Defaults to htmx-swapping. /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("swapping-class")] public string? SwappingClass { @@ -152,7 +216,7 @@ public string? SwappingClass /// /// Gets or sets the settling class. Defaults to htmx-settling. /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("settling-class")] public string? SettlingClass { @@ -164,7 +228,7 @@ public string? SettlingClass /// Gets or sets a value indicating whether eval is allowed. /// Defaults to . /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("allow-eval")] public bool? AllowEval { @@ -176,7 +240,7 @@ public bool? AllowEval /// 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. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("allow-script-tags")] public bool? AllowScriptTags { @@ -188,7 +252,7 @@ public bool? AllowScriptTags /// Gets or sets a value meaning that no nonce will be added to inline scripts. /// Defaults to "". /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x, 2.x, and 4.x. [HtmlAttributeName("inline-script-nonce")] public string? InlineScriptNonce { @@ -196,11 +260,23 @@ public string? 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; + } + /// /// Gets or sets a value meaning that no nonce will be added to inline styles. /// Defaults to "". /// - /// Supported only in HTMX 2.x. + /// Supported only in HTMX 2.x. Removed in HTMX 4.x. [HtmlAttributeName("inline-style-nonce")] public string? InlineStyleNonce { @@ -212,7 +288,7 @@ public string? InlineStyleNonce /// 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. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("attributes-to-settle")] public string[]? AttributesToSettle { @@ -224,7 +300,7 @@ public string[]? AttributesToSettle /// 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. + /// Supported only in HTMX 1.x. Removed in HTMX 2.x and 4.x. [HtmlAttributeName("use-template-fragments")] public bool? UseTemplateFragments { @@ -235,7 +311,7 @@ public bool? UseTemplateFragments /// /// Gets or sets the WebSocket reconnect delay. Defaults to full-jitter. /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("ws-reconnect-delay")] public string? WsReconnectDelay { @@ -247,7 +323,7 @@ public string? WsReconnectDelay /// Gets or sets the type of binary data /// being received over the WebSocket connection. Defaults to . /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("ws-binary-type")] public HtmxBinaryType? WsBinaryType { @@ -261,7 +337,7 @@ public HtmxBinaryType? WsBinaryType /// 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. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("disable-selector")] public string? DisableSelector { @@ -274,7 +350,7 @@ public string? DisableSelector /// using credentials such as cookies, authorization headers or TLS client certificates. /// Defaults to . /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("with-credentials")] public bool? WithCredentials { @@ -288,7 +364,18 @@ public bool? WithCredentials /// and you can explicitly specify the inheritance with the hx-inherit attribute. /// Defaults to . /// - /// Supported only in HTMX 2.x. + /// + /// 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 { @@ -298,14 +385,44 @@ public bool? DisableInheritance /// /// Gets or sets the number of milliseconds a request can take before automatically being terminated. - /// Defaults to 0. + /// Defaults to 0 in HTMX 1.x and 2.x, and 60000 in HTMX 4.x. /// - /// Supported in HTMX 1.x and 2.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? 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 { - get => _config.Timeout; - set => _config.Timeout = value; + // NOTE: The getter exists primarily for debugging; performance is not a concern here. + get => EnumHelper.ParseHtmxFetchMode(_config.Mode); + set => _config.Mode = value?.GetFetchModeValue(); } /// @@ -313,7 +430,7 @@ public int? Timeout /// Defaults to in HTMX 1.x /// and in HTMX 2.x. /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("scroll-behavior")] public HtmxScrollBehavior? ScrollBehavior { @@ -326,7 +443,7 @@ public HtmxScrollBehavior? ScrollBehavior /// 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 and 2.x. + /// Supported in HTMX 1.x, 2.x, and 4.x. [HtmlAttributeName("default-focus-scroll")] public bool? DefaultFocusScroll { @@ -340,7 +457,7 @@ public bool? DefaultFocusScroll /// Defaults to . /// /// - /// Supported in HTMX 1.x and 2.x. + /// 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. /// @@ -357,7 +474,10 @@ public bool? GetCacheBusterParam /// should be used when swapping in new content. /// Defaults to . /// - /// Supported in HTMX 1.x and 2.x. + /// + /// 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 { @@ -365,11 +485,78 @@ public bool? 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. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("methods-that-use-url-params")] public HttpVerb[]? MethodsThatUseUrlParams { @@ -377,24 +564,11 @@ public HttpVerb[]? MethodsThatUseUrlParams set => _config.MethodsThatUseUrlParams = new HttpVerbArray(value); } - /// - /// Gets or sets a value indicating whether AJAX requests should be allowed only - /// to the same domain as the current document. - /// Defaults to in HTMX 1.x and in HTMX 2.x. - /// - /// Supported in HTMX 1.x and 2.x. - [HtmlAttributeName("self-requests-only")] - public bool? SelfRequestsOnly - { - get => _config.SelfRequestsOnly; - set => _config.SelfRequestsOnly = 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. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("ignore-title")] public bool? IgnoreTitle { @@ -408,7 +582,7 @@ public bool? IgnoreTitle /// the target defaults to body, causing the page to scroll to the top. /// Defaults to . /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName("scroll-into-view-on-boost")] public bool? ScrollIntoViewOnBoost { @@ -417,14 +591,19 @@ public bool? ScrollIntoViewOnBoost } /// - /// Gets or sets the cache to store evaluated trigger specifications into, - /// improving parsing performance at the cost of more memory usage. - /// You may define a simple object to use a never-clearing cache or implement your own system - /// using a proxy object. Defaults to . + /// 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. - [HtmlAttributeName("trigger-specs-cache")] - public string? TriggerSpecsCache + /// + /// 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; @@ -435,7 +614,7 @@ public string? TriggerSpecsCache /// Accepts an array of objects that define /// how htmx should handle responses matching specific status code patterns. /// - /// Supported only in HTMX 2.x. + /// Supported only in HTMX 2.x. Replaced by hx-status and noSwap in HTMX 4.x. [HtmlAttributeName("response-handling")] public IList? ResponseHandling { @@ -448,7 +627,7 @@ public IList? ResponseHandling /// on elements that are nested within the main response element. /// Defaults to . /// - /// Supported only in HTMX 2.x. + /// Supported only in HTMX 2.x. Removed in HTMX 4.x. [HtmlAttributeName("allow-nested-oob-swaps")] public bool? AllowNestedOobSwaps { @@ -463,7 +642,7 @@ public bool? AllowNestedOobSwaps /// This should always be disabled when using the HX-Request header /// to optionally return partial responses. /// - /// Supported only in HTMX 2.x. + /// Supported only in HTMX 2.x. Removed in HTMX 4.x. [HtmlAttributeName("history-restore-as-hx-request")] public bool? HistoryRestoreAsHxRequest { @@ -477,7 +656,7 @@ public bool? HistoryRestoreAsHxRequest /// Defaults to . /// This should always be enabled as this matches default browser form submit behavior. /// - /// Supported only in HTMX 2.x. + /// Supported only in HTMX 2.x. Removed in HTMX 4.x. [HtmlAttributeName("report-validity-of-forms")] public bool? ReportValidityOfForms { @@ -530,13 +709,42 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu /// internal sealed class HtmxConfigData { - public bool? HistoryEnabled { get; set; } + 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; } + + [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; } @@ -545,6 +753,7 @@ internal sealed class HtmxConfigData 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; } @@ -553,16 +762,26 @@ internal sealed class HtmxConfigData public string? DisableSelector { get; set; } public bool? WithCredentials { get; set; } public bool? DisableInheritance { get; set; } - public int? Timeout { 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 { get; set; } + public bool? SelfRequestsOnly => Mode is not null ? Mode == "same-origin" : null; public bool? IgnoreTitle { get; set; } public bool? ScrollIntoViewOnBoost { get; set; } - public string? TriggerSpecsCache { 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; } diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs index 7bfdeac..f77f907 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs @@ -12,7 +12,8 @@ namespace Ramstack.HtmxToolkit.TagHelpers; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, GenerationMode = JsonSourceGenerationMode.Default)] -[JsonSerializable(typeof(HtmxRequestTagHelper.HtmxRequestData))] +[JsonSerializable(typeof(HtmxRequestTagHelper.HtmxRequestDataLegacy))] +[JsonSerializable(typeof(HtmxRequestTagHelper.HtmxRequestDataV4))] internal partial class HtmxRequestJsonSerializerContext : JsonSerializerContext { /// diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs index 79f3b2c..b0b5805 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs @@ -2,30 +2,41 @@ using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.Options; namespace Ramstack.HtmxToolkit.TagHelpers; /// -/// Represents a implementation that applies the hx-request attribute to matching elements. +/// Represents a implementation that applies request configuration to matching elements. /// /// -/// hx-request is merge-inherited and can be placed on a parent element. +/// HTMX 1.x and 2.x use merge-inherited hx-request; HTMX 4.x uses hx-config. /// [HtmlTargetElement(Attributes = RequestTimeoutAttributeName)] [HtmlTargetElement(Attributes = RequestCredentialsAttributeName)] [HtmlTargetElement(Attributes = RequestNoHeadersAttributeName)] -public sealed class HtmxRequestTagHelper : TagHelper +[HtmlTargetElement(Attributes = RequestCacheAttributeName)] +[HtmlTargetElement(Attributes = RequestRedirectAttributeName)] +[HtmlTargetElement(Attributes = RequestReferrerAttributeName)] +[HtmlTargetElement(Attributes = RequestIntegrityAttributeName)] +[HtmlTargetElement(Attributes = RequestValidateAttributeName)] +public sealed class HtmxRequestTagHelper(IOptions options) : TagHelper { private const string RequestTimeoutAttributeName = "hx-request-timeout"; private const string RequestCredentialsAttributeName = "hx-request-credentials"; private const string RequestNoHeadersAttributeName = "hx-request-no-headers"; + private const string RequestCacheAttributeName = "hx-request-cache"; + private const string RequestRedirectAttributeName = "hx-request-redirect"; + private const string RequestReferrerAttributeName = "hx-request-referrer"; + private const string RequestIntegrityAttributeName = "hx-request-integrity"; + private const string RequestValidateAttributeName = "hx-request-validate"; private readonly HtmxRequestData _request = new(); /// /// Gets or sets the timeout for the request in milliseconds. /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x, 2.x, and 4.x. [HtmlAttributeName(RequestTimeoutAttributeName)] public int? Timeout { @@ -34,11 +45,24 @@ public int? Timeout } /// - /// Gets or sets a value indicating whether the request sends credentials. + /// Gets or sets the credentials mode for the request. /// - /// Supported in HTMX 1.x and 2.x. + /// + /// + /// In HTMX 1.x and 2.x this maps to the credentials boolean option of hx-request, + /// where yields and + /// yields . + /// + /// + /// In HTMX 4.x this maps to the credentials string option of hx-config. + /// + /// + /// is unsupported in HTMX 1.x and 2.x, so the option + /// is omitted and HTMX uses its default value. A future version may throw an exception instead. + /// + /// [HtmlAttributeName(RequestCredentialsAttributeName)] - public bool? Credentials + public HtmxRequestCredentials? Credentials { get => _request.Credentials; set => _request.Credentials = value; @@ -47,7 +71,7 @@ public bool? Credentials /// /// Gets or sets a value indicating whether htmx strips all request headers. /// - /// Supported in HTMX 1.x and 2.x. + /// Supported in HTMX 1.x and 2.x. Removed in HTMX 4.x. [HtmlAttributeName(RequestNoHeadersAttributeName)] public bool? NoHeaders { @@ -55,31 +79,134 @@ public bool? NoHeaders set => _request.NoHeaders = value; } + /// + /// Gets or sets the Fetch cache mode for the request. + /// + /// Supported only in HTMX 4.x. + [HtmlAttributeName(RequestCacheAttributeName)] + public string? Cache + { + get => _request.Cache; + set => _request.Cache = value; + } + + /// + /// Gets or sets the Fetch redirect mode for the request. + /// + /// Supported only in HTMX 4.x. + [HtmlAttributeName(RequestRedirectAttributeName)] + public string? Redirect + { + get => _request.Redirect; + set => _request.Redirect = value; + } + + /// + /// Gets or sets the referrer URL or referrer policy for the request. + /// + /// Supported only in HTMX 4.x. + [HtmlAttributeName(RequestReferrerAttributeName)] + public string? Referrer + { + get => _request.Referrer; + set => _request.Referrer = value; + } + + /// + /// Gets or sets the subresource integrity value for the request. + /// + /// Supported only in HTMX 4.x. + [HtmlAttributeName(RequestIntegrityAttributeName)] + public string? Integrity + { + get => _request.Integrity; + set => _request.Integrity = value; + } + + /// + /// Gets or sets a value indicating whether the form is validated before submission. + /// + /// Supported only in HTMX 4.x. + [HtmlAttributeName(RequestValidateAttributeName)] + public bool? Validate + { + get => _request.Validate; + set => _request.Validate = value; + } + /// public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { - if (Timeout is not null || Credentials is not null || NoHeaders is not null) - { - var info = HtmxRequestJsonSerializerContext.Default.HtmxRequestData; - var request = new HtmlString(JsonSerializer.Serialize(_request, info)); + var targetVersion = options.Value.TargetVersion; + var request = targetVersion == HtmxTargetVersion.V4 + ? JsonSerializer.Serialize(new HtmxRequestDataV4(_request), HtmxRequestJsonSerializerContext.Default.HtmxRequestDataV4) + : JsonSerializer.Serialize(new HtmxRequestDataLegacy(_request), HtmxRequestJsonSerializerContext.Default.HtmxRequestDataLegacy); + if (request != "{}") + { + var attributeName = targetVersion == HtmxTargetVersion.V4 ? "hx-config" : "hx-request"; output.Attributes.SetAttribute( - new TagHelperAttribute("hx-request", request, HtmlAttributeValueStyle.SingleQuotes)); + new TagHelperAttribute(attributeName, new HtmlString(request), HtmlAttributeValueStyle.SingleQuotes)); } return Task.CompletedTask; } - #region Inner type: HtmxRequestData + #region Inner types /// - /// Represents the serializable request configuration data. + /// Represents all typed request configuration data. /// internal sealed class HtmxRequestData { public int? Timeout { get; set; } - public bool? Credentials { get; set; } + public HtmxRequestCredentials? Credentials { get; set; } public bool? NoHeaders { get; set; } + public string? Cache { get; set; } + public string? Redirect { get; set; } + public string? Referrer { get; set; } + public string? Integrity { get; set; } + public bool? Validate { get; set; } + } + + /// + /// Projects request configuration into the hx-request contract used by HTMX 1.x and 2.x. + /// + internal readonly struct HtmxRequestDataLegacy(HtmxRequestData data) + { + public int? Timeout => data.Timeout; + + public bool? Credentials => data.Credentials switch + { + HtmxRequestCredentials.SameOrigin => false, + HtmxRequestCredentials.Include => true, + // TODO: Consider throwing an exception when an HTMX 4-only credentials mode is configured for HTMX 1.x or 2.x + // HtmxRequestCredentials.Omit => throw new InvalidOperationException(), + _ => null + }; + + public bool? NoHeaders => data.NoHeaders; + } + + /// + /// Projects request configuration into the hx-config contract used by HTMX 4.x. + /// + internal readonly struct HtmxRequestDataV4(HtmxRequestData data) + { + public int? Timeout => data.Timeout; + public string? Credentials => data.Credentials switch + { + HtmxRequestCredentials.SameOrigin => "same-origin", + HtmxRequestCredentials.Include => "include", + HtmxRequestCredentials.Omit => "omit", + _ => null + }; + + public string? Cache => data.Cache; + public string? Redirect => data.Redirect; + public string? Referrer => data.Referrer; + public string? Integrity => data.Integrity; + public bool? Validate => data.Validate; } #endregion diff --git a/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs index 19c5160..a6080fe 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs @@ -5,6 +5,10 @@ public class EnumHelperTests { [TestCase(HtmxSwap.InnerHtml, "innerHTML")] [TestCase(HtmxSwap.OuterHtml, "outerHTML")] + [TestCase(HtmxSwap.InnerMorph, "innerMorph")] + [TestCase(HtmxSwap.OuterMorph, "outerMorph")] + [TestCase(HtmxSwap.OuterSync, "outerSync")] + [TestCase(HtmxSwap.TextContent, "textContent")] [TestCase(HtmxSwap.BeforeBegin, "beforebegin")] [TestCase(HtmxSwap.AfterBegin, "afterbegin")] [TestCase(HtmxSwap.BeforeEnd, "beforeend")] @@ -23,6 +27,10 @@ public void GetSwapValue_ReturnsNull_ForNullValue() [TestCase("innerHTML", HtmxSwap.InnerHtml)] [TestCase("outerHTML", HtmxSwap.OuterHtml)] + [TestCase("innerMorph", HtmxSwap.InnerMorph)] + [TestCase("outerMorph", HtmxSwap.OuterMorph)] + [TestCase("outerSync", HtmxSwap.OuterSync)] + [TestCase("textContent", HtmxSwap.TextContent)] [TestCase("beforebegin", HtmxSwap.BeforeBegin)] [TestCase("afterbegin", HtmxSwap.AfterBegin)] [TestCase("beforeend", HtmxSwap.BeforeEnd)] diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs index 904ce5f..2f95d24 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs @@ -65,10 +65,14 @@ public async Task ProcessAsync_SerializesConfiguredOptions() { var helper = new HtmxConfigTagHelper(new StubAntiforgery()) { - HistoryEnabled = false, + LogAll = true, + Prefix = "data-custom-", + MetaCharacter = "-", + History = HtmxHistoryMode.Disabled, HistoryCacheSize = 42, RefreshOnHistoryMiss = true, DefaultSwapStyle = HtmxSwap.OuterHtml, + DefaultSwapEmpty = true, DefaultSwapDelay = 250, DefaultSettleDelay = 300, IncludeIndicatorStyles = false, @@ -80,6 +84,7 @@ public async Task ProcessAsync_SerializesConfiguredOptions() AllowEval = false, AllowScriptTags = false, InlineScriptNonce = "nonce", + Extensions = "sse, ws", InlineStyleNonce = "style-nonce", AttributesToSettle = ["class", "style"], UseTemplateFragments = true, @@ -88,16 +93,21 @@ public async Task ProcessAsync_SerializesConfiguredOptions() DisableSelector = "[data-disable]", WithCredentials = true, DisableInheritance = true, - Timeout = 5000, + 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], - SelfRequestsOnly = true, IgnoreTitle = true, ScrollIntoViewOnBoost = false, - TriggerSpecsCache = "{}", + TriggerSpecsCacheEnabled = true, AllowNestedOobSwaps = true, HistoryRestoreAsHxRequest = false, ReportValidityOfForms = true @@ -114,14 +124,21 @@ public async Task ProcessAsync_SerializesConfiguredOptions() var json = JsonHelper.ParseJson(content); + 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["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")); @@ -129,6 +146,7 @@ public async Task ProcessAsync_SerializesConfiguredOptions() 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); @@ -137,21 +155,45 @@ public async Task ProcessAsync_SerializesConfiguredOptions() 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["methodsThatUseUrlParams"].GetRawText(), Is.EqualTo("[\"get\",\"delete\"]")); - Assert.That(json["selfRequestsOnly"].GetBoolean(), Is.True); + 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"].GetString(), Is.EqualTo("{}")); + 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() + { + var helper = new HtmxConfigTagHelper(new StubAntiforgery()) + { + RequestClass = " & \"b\" 'c'" + }; + + var output = TestHelper.CreateTagHelperOutput(); + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + var content = GetContent(output); + Assert.That(content, Is.EqualTo("{\"requestClass\":\"\\u003Ca\\u003E \\u0026 \\u0022b\\u0022 \\u0027c\\u0027\"}")); + } + [Test] public async Task ProcessAsync_IncludeAntiForgeryToken_SerializesTokens() { diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs index ef7fc01..60c9d0f 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.Options; namespace Ramstack.HtmxToolkit.Tests; @@ -7,15 +8,15 @@ namespace Ramstack.HtmxToolkit.Tests; public class HtmxRequestTagHelperTests { [Test] - public async Task ProcessAsync_SerializesRequestConfiguration() + [TestCase(HtmxTargetVersion.V1)] + [TestCase(HtmxTargetVersion.V2)] + public async Task ProcessAsync_SerializesLegacyRequestConfiguration(HtmxTargetVersion version) { var output = TestHelper.CreateTagHelperOutput(); - var helper = new HtmxRequestTagHelper - { - Timeout = 500, - Credentials = true, - NoHeaders = false - }; + var helper = CreateHelper(version); + helper.Timeout = 500; + helper.Credentials = HtmxRequestCredentials.Include; + helper.NoHeaders = false; await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); var attribute = output.Attributes["hx-request"]; @@ -30,13 +31,11 @@ public async Task ProcessAsync_SerializesRequestConfiguration() } [Test] - public async Task ProcessAsync_OmitsUnsetProperties() + public async Task ProcessAsync_OmitsUnsetLegacyProperties() { var output = TestHelper.CreateTagHelperOutput(); - var helper = new HtmxRequestTagHelper - { - Timeout = 500 - }; + var helper = CreateHelper(HtmxTargetVersion.V2); + helper.Timeout = 500; await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); var attribute = output.Attributes["hx-request"]; @@ -50,14 +49,61 @@ public async Task ProcessAsync_OmitsUnsetProperties() Assert.That(json.ContainsKey("noHeaders"), Is.False); } + [Test] + public async Task ProcessAsync_SerializesHtmx4RequestConfiguration() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + helper.Timeout = 500; + helper.Credentials = HtmxRequestCredentials.Omit; + helper.NoHeaders = true; + helper.Cache = "no-cache"; + helper.Redirect = "manual"; + helper.Referrer = "no-referrer"; + helper.Integrity = "sha384-example"; + helper.Validate = true; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-config"]; + + Assert.That(attribute, Is.Not.Null); + Assert.That(attribute!.Value, Is.TypeOf()); + + var json = JsonHelper.ParseJson(attribute.Value.ToString()!); + Assert.That(json["timeout"].GetInt32(), Is.EqualTo(500)); + Assert.That(json["credentials"].GetString(), Is.EqualTo("omit")); + Assert.That(json["cache"].GetString(), Is.EqualTo("no-cache")); + Assert.That(json["redirect"].GetString(), Is.EqualTo("manual")); + Assert.That(json["referrer"].GetString(), Is.EqualTo("no-referrer")); + Assert.That(json["integrity"].GetString(), Is.EqualTo("sha384-example")); + Assert.That(json["validate"].GetBoolean(), Is.True); + Assert.That(json.ContainsKey("noHeaders"), Is.False); + Assert.That(output.Attributes["hx-request"], Is.Null); + } + + [Test] + public async Task ProcessAsync_OmitsHtmx4OnlyCredentialsModeForLegacyVersions() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V2); + helper.Timeout = 500; + helper.Credentials = HtmxRequestCredentials.Omit; + + 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.ContainsKey("credentials"), Is.False); + } + [Test] public async Task ProcessAsync_UsesSingleQuotesForJsonAttribute() { var output = TestHelper.CreateTagHelperOutput(); - var helper = new HtmxRequestTagHelper - { - Timeout = 500 - }; + var helper = CreateHelper(HtmxTargetVersion.V2); + helper.Timeout = 500; await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); @@ -68,10 +114,13 @@ public async Task ProcessAsync_UsesSingleQuotesForJsonAttribute() public async Task ProcessAsync_OmitsUnsetConfiguration() { var output = TestHelper.CreateTagHelperOutput(); - var helper = new HtmxRequestTagHelper(); + var helper = CreateHelper(HtmxTargetVersion.V2); await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); Assert.That(output.Attributes["hx-request"], Is.Null); } + + private static HtmxRequestTagHelper CreateHelper(HtmxTargetVersion version) => + new(Options.Create(new HtmxToolkitOptions { TargetVersion = version })); }