diff --git a/README.md b/README.md
index edcb775..256a668 100644
--- a/README.md
+++ b/README.md
@@ -4,28 +4,32 @@
[](https://github.com/rameel/ramstack.htmxtoolkit/actions/workflows/test.yml)
[](LICENSE)
-HtmxToolkit connects [HTMX](https://htmx.org/) with ASP.NET Core. It adds strongly typed request and response headers, MVC action filters, Razor Tag Helpers, application-wide HTMX configuration, and antiforgery support.
+HtmxToolkit integrates [HTMX](https://htmx.org/) with ASP.NET Core. It provides strongly typed APIs for request and response headers,
+MVC action filters, Razor Tag Helpers, application-wide HTMX configuration, and antiforgery support.
-The package targets .NET 6 and can be used by applications running on .NET 6 or later. It supports HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. HTMX 2.x is selected by default.
+The package targets .NET 6 and can be used by applications running on .NET 6 or later.
+It supports HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. HTMX 2.x is selected by default.
## Features
-- Detect HTMX and boosted requests without comparing header strings.
+- Detect HTMX requests, including boosted requests, without comparing header strings.
- Read and write all standard HTMX headers through strongly typed APIs.
- Route HTMX requests to dedicated MVC actions with `[HtmxRequest]`.
- Configure response behavior fluently or with `[HtmxResponse]`.
- Generate HTMX URLs, headers, values, and request options with Razor Tag Helpers.
- Render version-specific HTMX configuration from ASP.NET Core options.
-- Add antiforgery tokens to unsafe HTMX requests with a small companion script.
+- Add antiforgery tokens to non-GET HTMX requests with a small companion script.
## Designed for Low Overhead
-HtmxToolkit is designed to make HTMX integration inexpensive on the application's request path:
+HtmxToolkit is designed to minimize HTMX integration overhead in the application's request-processing path:
-- `HtmxRequestHeaders` and `HtmxResponseHeaders` are readonly, single-reference structs. In normal use they add no wrapper allocation while preserving a strongly typed API.
-- Version-specific HTMX configuration is serialized only when it changes; the resulting JSON is cached and reused across requests.
-- Known JSON shapes use source-generated `System.Text.Json` metadata, avoiding reflection-based metadata discovery at runtime. Event details passed to `TriggerEvent` are the deliberate exception because their types are defined by the application.
-- Work is skipped for non-HTMX requests, and state-passing overloads allow static callbacks when callers need to avoid closure allocations.
+- `HtmxRequestHeaders` and `HtmxResponseHeaders` are `readonly` structs, each containing a single reference.
+ In normal use, they incur no wrapper allocations while preserving a strongly typed API.
+- Version-specific HTMX configuration is serialized only when the configuration changes; the resulting JSON is cached and reused across requests.
+- Known JSON shapes use source-generated `System.Text.Json` metadata, avoiding reflection-based metadata discovery at runtime.
+ Event details passed to `TriggerEvent` are the deliberate exception because their types are defined by the application.
+- Work is skipped for non-HTMX requests, and overloads that accept state allow callers to use static callbacks and avoid closure allocations.
## Installation
@@ -50,14 +54,14 @@ builder.Services.AddHtmxToolkit();
Make the Tag Helpers and toolkit types available to Razor views in `_ViewImports.cshtml`:
-```razor
+```html
@using Ramstack.HtmxToolkit
@addTagHelper *, Ramstack.HtmxToolkit
```
Render the configuration metadata in the document `
`:
-```razor
+```html
@@ -71,16 +75,16 @@ app.MapHtmxToolkitScript();
Load HTMX first, then the toolkit script in the layout:
-```razor
+```html
```
-The default script URL contains a content hash, so it can be cached indefinitely and is invalidated automatically when the script changes.
+The default script URL contains a content hash, so the script can be cached indefinitely. When the script changes, its URL changes automatically.
You can now generate an HTMX URL from ASP.NET Core route information:
-```razor
+```html
htmx
.Retarget("#profile")
.Reswap(HtmxSwap.OuterHtml)
- .TriggerEvent("profile-updated", new { id = profile.Id }));
+ .TriggerEvent("profile-updated"));
```
> [!NOTE]
-> The callback runs only for an HTMX request, so regular requests avoid unnecessary response work.
+> The callback runs only for HTMX requests, so non-HTMX requests avoid unnecessary response work.
The fluent API supports:
@@ -189,15 +186,15 @@ app.MapGet("/profile", (HttpResponse response) =>
```
> [!TIP]
-> For a callback that captures state, use the generic overload to avoid a closure allocation.
+> When a callback needs state, use the generic overload to pass it explicitly and avoid a closure allocation.
```csharp
Response.Htmx(
- static (htmx, id) => htmx.TriggerEvent("profile-updated", new { id }),
- profile.Id);
+ static (htmx, path) => htmx.TriggerEvent("content-updated", new { path }),
+ Request.Path.Value);
```
-Call `Response.GetHtmxHeaders()` for direct strongly typed access, or use `HtmxResponseHeaderNames` with lower-level APIs.
+Call `Response.GetHtmxHeaders()` for direct access to the strongly typed response headers, or use `HtmxResponseHeaderNames` with lower-level APIs.
### Declarative Responses
@@ -206,34 +203,34 @@ Controllers can set common response headers declaratively:
```csharp
[HtmxRequest]
[HtmxResponse(
- Retarget = "#comments",
+ Retarget = "#results",
Reswap = HtmxSwap.BeforeEnd)]
-public IActionResult AddComment(CommentInput input)
+public IActionResult LoadMore()
{
- var comment = repository.Add(input);
- return PartialView("_Comment", comment);
+ return PartialView("_MoreResults");
}
```
-`HtmxResponseAttribute` supports `Refresh`, `Reswap`, `ReswapExpression`, `Retarget`, and `Reselect`. Use `ReswapExpression` for a complete expression with swap modifiers, such as `innerHTML show:#result:top`.
+`HtmxResponseAttribute` supports `Refresh`, `Reswap`, `ReswapExpression`, `Retarget`, and `Reselect`.
+Use `ReswapExpression` for a complete expression with swap modifiers, such as `innerHTML show:#result:top`.
## Tag Helpers
HtmxToolkit includes five Tag Helpers:
-| Tag Helper | Purpose |
-| --- | --- |
-| `HtmxUrlTagHelper` | Builds HTMX request URLs from routes, controllers, actions, or Razor Pages. |
-| `HtmxHeaderTagHelper` | Serializes custom `hx-headers` values. |
-| `HtmxValsTagHelper` | Serializes additional `hx-vals` request values. |
-| `HtmxRequestTagHelper` | Generates version-specific `hx-request` or `hx-config` options. |
-| `HtmxConfigTagHelper` | Renders application configuration and antiforgery metadata. |
+| Tag Helper | Purpose |
+|------------------------|-----------------------------------------------------------------------------|
+| `HtmxUrlTagHelper` | Builds HTMX request URLs from routes, controllers, actions, or Razor Pages. |
+| `HtmxHeaderTagHelper` | Serializes custom `hx-headers` values. |
+| `HtmxValsTagHelper` | Serializes additional `hx-vals` request values. |
+| `HtmxRequestTagHelper` | Generates version-specific `hx-request` or `hx-config` options. |
+| `HtmxConfigTagHelper` | Renders application configuration and antiforgery metadata. |
### URL Generation
Controller and action:
-```razor
+```html
@@ -253,13 +250,14 @@ Razor Page handler:
```
-Use `hx-all-route-data` for an `IDictionary` of route values. The helper also supports `hx-route`, `hx-host`, `hx-protocol`, and `hx-fragment`.
+Use `hx-all-route-data` for an `IDictionary` of route values.
+The helper also supports `hx-route`, `hx-host`, `hx-protocol`, and `hx-fragment`.
-### Headers And Values
+### Headers and Values
Create `hx-headers` without manually escaping JSON:
-```razor
+```html
@@ -269,7 +267,7 @@ Create `hx-headers` without manually escaping JSON:
Add request values in the same way:
-```razor
+```html
@@ -283,7 +281,7 @@ Use `hx-all-headers` or `hx-all-vals` to supply an `IDictionary`
For HTMX 1.9.x and 2.x, typed `hx-request-*` attributes generate `hx-request` JSON:
-```razor
+```html
```
-With HTMX 4.x selected, the same Tag Helper generates `hx-config`. HTMX 4.x additionally supports `hx-request-cache`, `hx-request-redirect`, `hx-request-referrer`, `hx-request-integrity`, and `hx-request-validate`; `hx-request-no-headers` is limited to HTMX 1.9.x and 2.x.
+With HTMX 4.x selected, `HtmxRequestTagHelper` generates `hx-config` instead.
+HTMX 4.x additionally supports `hx-request-cache`, `hx-request-redirect`, `hx-request-referrer`, `hx-request-integrity`,
+and `hx-request-validate`; `hx-request-no-headers` is limited to HTMX 1.9.x and 2.x.
## Configuration
-Configure HTMX once during service registration. Only values you explicitly set are emitted, allowing HTMX defaults to remain in control:
+Configure HTMX once during service registration. Only explicitly configured values are emitted,
+so HTMX defaults remain in effect:
```csharp
builder.Services.AddHtmxToolkit(options =>
@@ -311,20 +312,24 @@ builder.Services.AddHtmxToolkit(options =>
Render ` ` in the document `` to produce the corresponding ` ` element.
-Select a supported HTMX major version with `UseHtmxV1`, `UseHtmxV2`, or `UseHtmxV4`:
+Select a supported HTMX version family with `UseHtmxV1`, `UseHtmxV2`, or `UseHtmxV4`:
```csharp
builder.Services.AddHtmxToolkit(options => options.UseHtmxV4());
```
-Configuration property names follow the selected HTMX release. For example, HTMX 1.9.x and 2.x use `DefaultSwapStyle` and `Timeout`, while HTMX 4.x uses `DefaultSwap` and `DefaultTimeout`.
+Configuration property names follow the selected HTMX version.
+For example, HTMX 1.9.x and 2.x use `DefaultSwapStyle` and `Timeout`,
+while HTMX 4.x uses `DefaultSwap` and `DefaultTimeout`.
> [!WARNING]
-> Select only one HTMX version. Selecting another version in the same configuration throws an exception.
+> Select only one HTMX version. Attempting to select a second version in the same configuration throws an exception.
### Response Handling
-HTMX 2.x can customize response handling by status code:
+The following configuration follows the
+[HTMX 2.x response-handling example](https://htmx.org/docs/#response-handling-examples), allowing `422` validation
+responses to swap while treating other `4xx` and `5xx` responses as errors:
```csharp
builder.Services.AddHtmxToolkit(options =>
@@ -343,7 +348,9 @@ builder.Services.AddHtmxToolkit(options =>
});
```
-HTMX 4.x replaces `responseHandling` with `noSwap`. Configure equivalent rules explicitly when migrating:
+HTMX 4.x replaces `responseHandling` with `noSwap` and swaps `4xx` and `5xx` responses by default. To restore the
+default HTMX 2.x behavior for those errors, the
+[HTMX 4.x migration guide](https://four.htmx.org/docs/#migrating-from-htmx-2x-to-4x) recommends:
```csharp
builder.Services.AddHtmxToolkit(options =>
@@ -355,12 +362,25 @@ builder.Services.AddHtmxToolkit(options =>
});
```
+This policy also prevents `422` responses from swapping. Omit or narrow the `4xx` pattern if those responses should
+continue to update the page.
+
## Antiforgery
-Antiforgery metadata is enabled by default. ` ` renders the current token and field or header names; the companion script attaches the token to non-GET HTMX requests and refreshes it after boosted navigation.
+Antiforgery metadata is enabled by default. ` ` renders the current token and field or header names;
+the companion script attaches the token to non-GET HTMX requests and refreshes it after boosted navigation.
> [!WARNING]
-> The companion script only sends the token. The application must still enable server-side antiforgery validation for the relevant endpoints.
+> The companion script sends the token but does not perform validation. The application must still enable server-side antiforgery validation for the relevant endpoints.
+
+For example, MVC applications can validate all unsafe actions globally:
+
+```csharp
+builder.Services.AddControllersWithViews(options =>
+{
+ options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
+});
+```
Disable the metadata when antiforgery is handled elsewhere:
@@ -373,13 +393,14 @@ builder.Services.AddHtmxToolkit(options =>
Instead of mapping an endpoint, the companion script can be embedded directly:
-```razor
+```html
```
-Pass `debug: true` to `HtmxToolkitScript` or `HtmxToolkitScriptPath` to use the readable script during development. A custom endpoint path is also supported:
+Pass `debug: true` to `HtmxToolkitScript` or `HtmxToolkitScriptPath` to use the readable script during development.
+A custom endpoint path is also supported:
```csharp
app.MapHtmxToolkitScript("/assets/htmx-toolkit.js");
@@ -390,7 +411,9 @@ app.MapHtmxToolkitScript("/assets/htmx-toolkit.js");
### Trigger Timing
> [!IMPORTANT]
-> HTMX 1.9.x and 2.x support `HX-Trigger`, `HX-Trigger-After-Swap`, and `HX-Trigger-After-Settle`. HTMX 4.x supports only `HX-Trigger`, so HtmxToolkit emits events requested for any `HtmxTriggerTiming` through that header rather than dropping them. The exact receive/settle timing cannot be preserved on HTMX 4.x.
+> HTMX 1.9.x and 2.x support `HX-Trigger`, `HX-Trigger-After-Swap`, and `HX-Trigger-After-Settle`.
+> HTMX 4.x supports only `HX-Trigger`. HtmxToolkit therefore emits events requested for any `HtmxTriggerTiming`
+> value through that header rather than dropping them. The `Receive` and `AfterSettle` timings cannot be preserved exactly.
### Polling
@@ -405,11 +428,53 @@ For server-controlled polling that works with every supported HTMX version, retu
```
-Return the same element with its request attributes to continue polling, or return it without `hx-get` and `hx-trigger` to stop. Status code `286` stops polling in HTMX 1.9.x and 2.x, but HTMX 4.x treats it as a regular successful response.
+Return the same element with its request attributes to continue polling,
+or return it without `hx-get` and `hx-trigger` to stop. Status code `286` stops polling in HTMX 1.9.x and 2.x,
+but HTMX 4.x treats it as a regular successful response.
+
+### Morph Swaps
+
+`HtmxSwap.InnerMorph` and `HtmxSwap.OuterMorph` use the native `innerMorph` and `outerMorph` swap styles in HTMX 4.x.
+No additional client-side dependency or configuration is required.
+
+With HTMX 1.9.x or 2.x, enable the `ramstack-morph` extension. To preserve morphing behavior, also load the optional
+Idiomorph library before the first morph swap:
+
+```html
+
+ Current profile
+
+
+ Refresh profile
+
+
+
+
+
+
+```
+
+The `/profile/morph` endpoint should return the replacement root, such as `Updated profile
`.
+
+The toolkit script does not bundle HTMX or Idiomorph and must be loaded after HTMX. Idiomorph remains an optional dependency
+and may be loaded before or after the toolkit script because the adapter resolves it when each morph swap runs. Do not enable
+the extension with HTMX 4.x, which handles these swap styles natively.
+
+If Idiomorph is unavailable, the adapter logs a warning and falls back from `innerMorph` to `innerHTML` and from `outerMorph`
+to `outerHTML`. With HTMX 1.9.x and 2.x, `outerSync` falls back to synchronizing the target's attributes and then replacing
+its children using `innerHTML`.
+
+`HtmxSwap.TextContent` is also handled by the `ramstack-morph` extension and does not require Idiomorph. It is supported natively
+by HTMX 2.x and 4.x; only HTMX 1.9.x needs the extension.
## Sample
-The [`samples/Ramstack.HtmxToolkit.Demo`](samples/Ramstack.HtmxToolkit.Demo) project demonstrates request detection, response headers, Tag Helpers, polling, boosted navigation, and antiforgery integration.
+The [`samples/Ramstack.HtmxToolkit.Demo`](samples/Ramstack.HtmxToolkit.Demo) project demonstrates request detection,
+response headers, Tag Helpers, polling, boosted navigation, and antiforgery integration.
Run it with:
diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js
index 05f6cdf..9225690 100644
--- a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js
+++ b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js
@@ -1,4 +1,61 @@
document._r_htmx ||= ((document, htmx) => {
+ const warn = message => console.warn(`ramstack.htmxtoolkit: ${message}`);
+
+ if (htmx.defineExtension) {
+ htmx.defineExtension("ramstack-morph", {
+ isInlineSwap(swap_style) {
+ return swap_style === "outerMorph" || swap_style === "outerSync";
+ },
+ handleSwap(swap_style, target, fragment) {
+ if (swap_style === "textContent") {
+ target.textContent = fragment.textContent;
+ return [target];
+ }
+
+ let morph_style =
+ swap_style === "innerMorph" ? "innerHTML" :
+ swap_style === "outerMorph" ? "outerHTML" : null;
+
+ if (morph_style) {
+ let idiomorph = globalThis.Idiomorph;
+ if (idiomorph?.morph) {
+ return idiomorph.morph(target, fragment.children, { morphStyle: morph_style });
+ }
+
+ warn(`Idiomorph is unavailable; falling back from ${swap_style} to ${morph_style}`);
+
+ let nodes = [...fragment.childNodes];
+ morph_style === "innerHTML"
+ ? target.replaceChildren(...nodes)
+ : target.replaceWith(...nodes);
+
+ return nodes;
+ }
+
+ if (swap_style === "outerSync") {
+ warn("outerSync requires HTMX 4.x; falling back to attribute sync and innerHTML");
+
+ let source = fragment.firstElementChild;
+ if (source) {
+ for (let attr of [...target.attributes]) {
+ source.hasAttribute(attr.name) || target.removeAttribute(attr.name);
+ }
+
+ for (let attr of source.attributes) {
+ target.setAttribute(attr.name, attr.value);
+ }
+
+ let nodes = source.childNodes;
+ target.replaceChildren(...nodes);
+
+ return nodes;
+ }
+
+ }
+ }
+ });
+ }
+
const listen = (type, listener) => {
document.addEventListener(type, listener);
};
diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js
index 62a98c8..a37d755 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=>{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
+document._r_htmx||=((e,t)=>{const r=e=>console.warn(`ramstack.htmxtoolkit: ${e}`);t.defineExtension&&t.defineExtension("ramstack-morph",{isInlineSwap:e=>"outerMorph"===e||"outerSync"===e,handleSwap(e,t,n){if("textContent"===e)return t.textContent=n.textContent,[t];let o="innerMorph"===e?"innerHTML":"outerMorph"===e?"outerHTML":null;if(o){let a=globalThis.Idiomorph;if(a?.morph)return a.morph(t,n.children,{morphStyle:o});r(`Idiomorph is unavailable; falling back from ${e} to ${o}`);let i=[...n.childNodes];return"innerHTML"===o?t.replaceChildren(...i):t.replaceWith(...i),i}if("outerSync"===e){r("outerSync requires HTMX 4.x; falling back to attribute sync and innerHTML");let e=n.firstElementChild;if(e){for(let r of[...t.attributes])e.hasAttribute(r.name)||t.removeAttribute(r.name);for(let r of e.attributes)t.setAttribute(r.name,r.value);let r=e.childNodes;return t.replaceChildren(...r),r}}}});const n=(t,r)=>{e.addEventListener(t,r)},o=e=>{let t=e.querySelector("meta[name='htmx-config']")?.dataset||{};return{headerName:t.antiforgeryHeaderName,formFieldName:t.antiforgeryFormFieldName,requestToken:t.antiforgeryRequestToken}};let a=o(e);const i=(e,t,r)=>{if(!/^get$/i.test(e)){const{headerName:e,formFieldName:n,requestToken:o}=a;o&&(r.has?.(n)||r[n]||(e?t[e]=o:r.set?r.set(n,o):r[n]=o))}},l=e=>{let t=(new DOMParser).parseFromString(e||"","text/html"),r=o(t);r&&(a=r)};return n("htmx:afterOnLoad",e=>{let t=e.detail;t.boosted&&l(t.xhr.responseText)}),n("htmx:after:request",e=>{let t=e.detail.ctx;t.boosted&&l(t.text)}),n("htmx:configRequest",e=>{let t=e.detail;i(t.verb,t.headers,t.parameters)}),n("htmx:config:request",e=>{let t=e.detail.ctx.request;i(t.method,t.headers,t.body)}),n("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/HtmxSwap.cs b/src/Ramstack.HtmxToolkit/HtmxSwap.cs
index ec6c78b..9d79d6a 100644
--- a/src/Ramstack.HtmxToolkit/HtmxSwap.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxSwap.cs
@@ -18,21 +18,59 @@ public enum HtmxSwap
///
/// Morphs the inner HTML of the target element.
///
+ ///
+ ///
+ /// Supported natively by HTMX 4.x.
+ ///
+ ///
+ /// With HTMX 1.9.x or 2.x, activate the ramstack-morph extension
+ /// and optionally load Idiomorph. Without Idiomorph, the extension
+ /// falls back to innerHTML .
+ ///
+ ///
InnerMorph,
///
/// Morphs the target element itself.
///
+ ///
+ ///
+ /// Supported natively by HTMX 4.x.
+ ///
+ ///
+ /// With HTMX 1.9.x or 2.x, activate the ramstack-morph extension
+ /// and optionally load Idiomorph. Without Idiomorph, the extension
+ /// falls back to outerHTML .
+ ///
+ ///
OuterMorph,
///
/// Synchronizes the target element with the response.
///
+ ///
+ ///
+ /// Supported natively by HTMX 4.x.
+ ///
+ ///
+ /// With HTMX 1.9.x or 2.x, activate the ramstack-morph extension
+ /// to fall back to attribute synchronization and innerHTML .
+ ///
+ ///
OuterSync,
///
/// Replaces the text content of the target element.
///
+ ///
+ ///
+ /// Supported natively by HTMX 2.x and 4.x.
+ ///
+ ///
+ /// With HTMX 1.9.x, activate the ramstack-morph extension.
+ /// Idiomorph is not required for this style.
+ ///
+ ///
TextContent,
///