diff --git a/README.md b/README.md
index d75e6ad..edcb775 100644
--- a/README.md
+++ b/README.md
@@ -1,662 +1,273 @@
# HtmxToolkit
-[](https://nuget.org/packages/Ramstack.HtmxToolkit)
-[](https://github.com/rameel/ramstack.htmxtoolkit/blob/main/LICENSE)
-
-Provides HTMX integration for ASP.NET Core applications.
-
-
-* [HtmxToolkit](#htmxtoolkit)
- * [Getting Started](#getting-started)
- * [HttpRequest](#httprequest)
- * [HtmxRequestAttribute](#htmxrequestattribute)
- * [HttpResponse](#httpresponse)
- * [The declarative way of setting response headers](#the-declarative-way-of-setting-response-headers)
- * [Polling](#polling)
- * [Tag Helpers](#tag-helpers)
- * [HtmxUrlTagHelper](#htmxurltaghelper)
- * [HtmxHeaderTagHelper](#htmxheadertaghelper)
- * [HtmxValsTagHelper](#htmxvalstaghelper)
- * [HtmxRequestTagHelper](#htmxrequesttaghelper)
- * [HtmxConfigTagHelper](#htmxconfigtaghelper)
- * [Response Handling Configuration](#response-handling-configuration)
- * [Toolkit Script](#toolkit-script)
- * [Supported Versions](#supported-versions)
- * [Contributions](#contributions)
- * [License](#license)
-
-
-## Getting Started
-
-Add the [`Ramstack.HtmxToolkit` NuGet package](https://www.nuget.org/packages/Ramstack.HtmxToolkit/)
-to your project with the following command:
+
+[](https://www.nuget.org/packages/Ramstack.HtmxToolkit/)
+[](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.
+
+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.
+- 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.
+
+## Designed for Low Overhead
+
+HtmxToolkit is designed to make HTMX integration inexpensive on the application's request 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.
+
+## Installation
```console
dotnet add package Ramstack.HtmxToolkit
```
-Register the toolkit. HTMX 2.x is used by default:
+Register HtmxToolkit in `Program.cs`:
```csharp
+using Ramstack.HtmxToolkit.Hosting;
+
+var builder = WebApplication.CreateBuilder(args);
+
builder.Services.AddHtmxToolkit();
```
-To select another major version or override HTMX defaults, see
-[`HtmxConfigTagHelper`](#htmxconfigtaghelper).
+> [!IMPORTANT]
+> HtmxToolkit does not bundle HTMX itself. Add a supported HTMX release to the application separately.
-## HttpRequest
+## Quick Start
-The library provides the `HttpRequestExtensions` class for working with `HttpRequest`.
+Make the Tag Helpers and toolkit types available to Razor views in `_ViewImports.cshtml`:
-```csharp
-///
-/// Provides extension methods for the class.
-///
-public static class HttpRequestExtensions
-{
- ///
- /// Determines whether the specified HTTP request is an HTMX request.
- ///
- /// The HTTP request.
- ///
- /// if the specified HTTP request is an HTMX request;
- /// otherwise, .
- ///
- public static bool IsHtmxRequest(this HttpRequest request);
-
- ///
- /// Determines whether the specified HTTP request is an HTMX request.
- ///
- /// The HTTP request.
- /// When this method returns, contains the
- /// that provides access to well-known HTMX headers.
- ///
- /// if the specified HTTP request is an HTMX request; otherwise, .
- ///
- public static bool IsHtmxRequest(this HttpRequest request, out HtmxRequestHeaders headers);
-
- ///
- /// Determines whether the specified HTTP request was made using AJAX
- /// instead of a normal navigation.
- ///
- /// The HTTP request.
- ///
- /// if the specified HTTP request is boosted; otherwise, .
- ///
- public static bool IsHtmxBoosted(this HttpRequest request);
-
- ///
- /// Determines whether the specified HTTP request was made using AJAX
- /// instead of a normal navigation.
- ///
- /// The HTTP request.
- /// When this method returns, contains the
- /// that provides access to well-known HTMX headers.
- ///
- /// if the specified HTTP request is boosted; otherwise, .
- ///
- public static bool IsHtmxBoosted(this HttpRequest request, out HtmxRequestHeaders headers);
-
- ///
- /// Returns a strongly typed view of the HTMX request headers.
- ///
- /// The HTTP request.
- ///
- /// The .
- ///
- public static HtmxRequestHeaders GetHtmxHeaders(this HttpRequest request);
-}
+```razor
+@using Ramstack.HtmxToolkit
+@addTagHelper *, Ramstack.HtmxToolkit
```
-Use `IsHtmxRequest` to determine whether the current request was issued by HTMX.
+Render the configuration metadata in the document `
`:
-```csharp
-HttpContext.Request.IsHtmxRequest()
+```razor
+
+
+
```
-You can then handle HTMX and regular requests differently, for example:
+Map the companion script endpoint in `Program.cs`:
```csharp
-if (Request.IsHtmxRequest())
- return PartialView();
-
-return View();
+app.MapHtmxToolkitScript();
```
-The overloads with an `out` parameter also provide access to strongly typed headers set by HTMX:
+Load HTMX first, then the toolkit script in the layout:
-```csharp
-if (Request.IsHtmxRequest(out var headers))
-{
- if (headers.HistoryRestoreRequest)
- {
- ...
- }
-}
+```razor
+
+
```
-You can also access strongly typed headers by calling `GetHtmxHeaders`:
+The default script URL contains a content hash, so it can be cached indefinitely and is invalidated automatically when the script changes.
-```csharp
-var headers = Request.GetHtmxHeaders();
-```
+You can now generate an HTMX URL from ASP.NET Core route information:
-The complete set of request header properties is shown below:
+```razor
+
-```csharp
-///
-/// Represents strongly typed HTMX request headers.
-///
-public readonly struct HtmxRequestHeaders
-{
- ///
- /// Gets a value indicating whether the request was made using AJAX instead of a normal navigation.
- ///
- public bool Boosted { get; }
-
- ///
- /// Gets the current URL of the browser.
- ///
- public string? CurrentUrl { get; }
-
- ///
- /// Gets a value indicating whether the request restores history after a miss in the local history cache.
- ///
- public bool HistoryRestoreRequest { get; }
-
- ///
- /// Gets the user's response to an hx-prompt on the client.
- ///
- public string? Prompt { get; }
-
- ///
- /// Gets a value indicating whether the current request is an HTMX request.
- ///
- public bool Request { get; }
-
- ///
- /// Gets the ID of the target element, if present.
- ///
- public string? Target { get; }
-
- ///
- /// Gets the name of the triggered element, if present.
- ///
- public string? TriggerName { get; }
-
- ///
- /// Gets the ID of the triggered element, if present.
- ///
- public string? Trigger { get; }
-}
+
```
-For example:
+If no HTTP method is specified, the URL Tag Helper emits `hx-get`. Use `hx-post`, `hx-put`, `hx-patch`, or `hx-delete` to select another method.
-```csharp
-if (Request.GetHtmxHeaders().HistoryRestoreRequest)
-{
- ...
-}
-```
+## Requests
-The `HtmxRequestHeaderNames` class also provides constants for well-known request header names,
-so you do not have to remember their exact spelling.
+Use `IsHtmxRequest()` when an endpoint should return a partial response to HTMX and a complete page to a normal navigation:
```csharp
-///
-/// Defines constants for the well-known names of HTMX request headers.
-///
-///
-/// For more information, see HTMX Request Headers Reference.
-///
-public static class HtmxRequestHeaderNames
+using Ramstack.HtmxToolkit;
+
+public IActionResult Details(int id)
{
- ///
- /// The HX-Boosted header indicates whether the request was made using AJAX
- /// instead of a normal navigation.
- ///
- public const string Boosted = "HX-Boosted";
-
- ///
- /// The HX-Current-URL header contains the current URL of the browser.
- ///
- public const string CurrentUrl = "HX-Current-URL";
-
- ...
- // The list of other constants is omitted for brevity
+ var model = repository.Find(id);
+
+ return Request.IsHtmxRequest()
+ ? PartialView("_Details", model)
+ : View(model);
}
```
-### HtmxRequestAttribute
-
-To route HTMX requests to a specific controller action, apply the `HtmxRequestAttribute`
-action constraint to that action:
+The overload with an `out` parameter returns a strongly typed view of the request headers:
```csharp
-public class UserController : ControllerBase
+if (Request.IsHtmxRequest(out var htmx) && htmx.HistoryRestoreRequest)
{
- [HtmxRequest]
- public IActionResult UpdateProfile(UserProfile profile)
- {
- ...
- }
+ // Handle a history cache miss.
}
```
-To match only boosted requests, set the `Boosted` property to `true`:
+Call `Request.GetHtmxHeaders()` when request detection and header access do not need to happen together. Available properties include:
-```csharp
-public class UserController : ControllerBase
-{
- ...
- [HtmxRequest(Boosted = true)]
- public IActionResult UpdateProfile(UserProfile profile)
- {
- ...
- }
-}
-```
+- `Boosted`
+- `CurrentUrl`
+- `HistoryRestoreRequest`
+- `Prompt`
+- `Request`
+- `Target`
+- `Trigger`
+- `TriggerName`
-## HttpResponse
+`HtmxRequestHeaderNames` exposes the corresponding header-name constants for lower-level APIs.
-For working with response headers, the library provides the `HttpResponseExtensions` class:
+Use `Request.IsHtmxBoosted()` when only boosted navigation matters. It also has an overload that returns the typed headers.
-```csharp
-///
-/// Provides extension methods for the class.
-///
-public static class HttpResponseExtensions
-{
- ///
- /// Returns a strongly typed view of the HTMX response headers.
- ///
- /// The HTTP response.
- ///
- /// The .
- ///
- public static HtmxResponseHeaders GetHtmxHeaders(this HttpResponse response);
-
- ///
- /// Configures the HTMX response headers.
- ///
- /// The HTTP response to configure.
- /// The delegate that configures the HTMX response headers.
- public static void Htmx(this HttpResponse response, Action configure);
-
- ///
- /// Configures the HTMX response headers.
- ///
- /// The HTTP response to configure.
- /// The delegate that configures the HTMX response headers
- /// using .
- /// The state passed to .
- public static void Htmx(this HttpResponse response, Action configure, TState state);
-}
-```
+### MVC Action Selection
-The `GetHtmxHeaders` method provides access to strongly typed response headers
-that control HTMX behavior.
+Apply `[HtmxRequest]` to route only HTMX requests to an action:
```csharp
-///
-/// Represents strongly typed HTMX response headers.
-///
-public readonly struct HtmxResponseHeaders
+[HtmxRequest]
+public IActionResult UpdateProfile(ProfileInput input)
{
- ///
- /// Gets or sets the value of the HX-Location header, which performs
- /// a client-side redirect without a full-page reload.
- ///
- [MaybeNull]
- public string Location { get; set; }
-
- ///
- /// Gets or sets the value of the HX-Push-Url header, which pushes a new URL
- /// onto the browser's history stack.
- ///
- [MaybeNull]
- public string PushUrl { get; set; }
-
- ...
- // The remaining properties are omitted for brevity
+ var profile = repository.Update(input);
+ return PartialView("_Profile", profile);
}
```
-Just as `HtmxRequestHeaderNames` defines constants for HTMX request headers,
-`HtmxResponseHeaderNames` defines constants for HTMX response headers.
+Set `Boosted` to distinguish boosted and non-boosted HTMX requests:
```csharp
-///
-/// Defines constants for the well-known names of HTMX response headers.
-///
-///
-/// For more information, see HTMX Response Headers Reference.
-///
-public static class HtmxResponseHeaderNames
+[HtmxRequest(Boosted = true)]
+public IActionResult BoostedNavigation()
{
- ///
- /// The HX-Location header performs a client-side redirect without a full-page reload.
- ///
- public const string Location = "HX-Location";
-
- ///
- /// The HX-Push-Url header pushes a new URL onto the browser's history stack.
- ///
- public const string PushUrl = "HX-Push-Url";
-
- ///
- /// The HX-Redirect header performs a client-side redirect to a new location.
- ///
- public const string Redirect = "HX-Redirect";
-
- ...
- // The list of other constants is omitted for brevity
+ return PartialView("_Navigation");
}
```
-The most convenient approach is to use one of the `HttpResponse.Htmx` extension methods.
-Its callback receives an `HtmxResponse`, allowing you to configure response headers in a fluent style:
+## Responses
+
+Configure HTMX response headers through `Response.Htmx(...)`:
```csharp
-Response.Htmx(h => h
- .TriggerEvent(
- eventName: "process",
- detail: new { Value = ... }));
+Response.Htmx(htmx => htmx
+ .Retarget("#profile")
+ .Reswap(HtmxSwap.OuterHtml)
+ .TriggerEvent("profile-updated", new { id = profile.Id }));
```
-`TriggerEvent` and `TriggerEvents` accept an optional `HtmxTriggerTiming` value.
-In HTMX 1.x and 2.x, the value selects `HX-Trigger`, `HX-Trigger-After-Swap`, or
-`HX-Trigger-After-Settle`. HTMX 4.x supports only `HX-Trigger`, so the toolkit
-emits events requested for any timing through that header rather than dropping
-them. These events run after the swap; in particular, the 1.x/2.x `Receive` and
-`AfterSettle` timings cannot be preserved. See
-[htmx pull request #3900](https://github.com/bigskysoftware/htmx/pull/3900) for
-the upstream timing change.
+> [!NOTE]
+> The callback runs only for an HTMX request, so regular requests avoid unnecessary response work.
-:bulb: The generic overload accepts an additional state parameter to avoid closure allocations:
+The fluent API supports:
-```csharp
-Response.Htmx(
- static (h, value) => h
- .TriggerEvent(
- eventName: "process",
- detail: new { Value = value }),
- ProcessValue);
-```
+- Client navigation with `Location`, `Redirect`, `PushUrl`, and `ReplaceUrl`.
+- Swap control with `Reswap`, `Retarget`, and `Reselect`.
+- Page refresh with `Refresh`.
+- Client events with `TriggerEvent` and `TriggerEvents`.
-:bulb: The same API works in Minimal API handlers by binding `HttpResponse`:
+The same API works in Minimal API handlers:
```csharp
app.MapGet("/profile", (HttpResponse response) =>
{
- response.Htmx(h => h.Retarget("#profile"));
+ response.Htmx(htmx => htmx.Retarget("#profile"));
return TypedResults.Content("
Profile
", "text/html");
});
```
-In all these examples, headers are set only for an HTMX request. For a regular request,
-the callback passed to `Htmx` is not executed, avoiding unnecessary work.
-
-### The declarative way of setting response headers
-
-Some response headers can be set declaratively by applying `HtmxResponseAttribute`
-to a controller or action. For example, an action that renders one new comment can append
-it to the element targeted by the request:
+> [!TIP]
+> For a callback that captures state, use the generic overload to avoid a closure allocation.
```csharp
-public class CommentController : Controller
-{
- [HtmxRequest]
- [HtmxResponse(Reswap = HtmxSwap.BeforeEnd)]
- public IActionResult Add(CommentInput input)
- {
- var comment = ...;
- return PartialView("_Comment", comment);
- }
-}
-```
-
-:bulb: For a more complex swap expression, such as `innerHTML show:#result:top`,
-use the `Reswap` overload that accepts a string.
-
-```csharp
-///
-/// Sets the HX-Reswap header to specify how the response will be swapped.
-///
-/// The swap style to assign to the header.
-///
-/// The current instance.
-///
-public HtmxResponse Reswap(HtmxSwap value);
-
-///
-/// Sets the HX-Reswap header to specify how the response will be swapped.
-///
-/// The header value to set.
-///
-/// The current instance.
-///
-public HtmxResponse Reswap(string value);
-```
-
-For declarative configuration, `HtmxResponseAttribute` provides the `ReswapExpression` property:
-
-```csharp
-///
-/// Gets or sets the complete HX-Reswap header value, including any swap modifiers.
-///
-[MaybeNull]
-public string ReswapExpression { get; set; }
-
-///
-/// Gets or sets the swap style to specify in the HX-Reswap header.
-///
-public HtmxSwap Reswap { get; set; }
-```
-
-Use `ReswapExpression` when the strongly typed `Reswap` property is not flexible enough.
-
-## Polling
-
-For server-controlled polling that works in every supported HTMX version, return
-the polling element itself and replace it with `outerHTML`:
-
-```html
-
- Polling...
-
+Response.Htmx(
+ static (htmx, id) => htmx.TriggerEvent("profile-updated", new { id }),
+ profile.Id);
```
-While polling should continue, return the same element with its request
-attributes. To stop, return the element without `hx-get` and `hx-trigger`:
-
-```html
-
- Polling stopped!
-
-```
+Call `Response.GetHtmxHeaders()` for direct strongly typed access, or use `HtmxResponseHeaderNames` with lower-level APIs.
-This load-polling pattern gives the server control over every next request and
-works with HTMX 1.9.x, 2.x, and 4.x. For an indefinitely updated status, use
-`hx-trigger="every 1s"` instead.
+### Declarative Responses
-HTMX 1.9.x and 2.x also recognize HTTP status code `286` as a fixed-rate polling
-stop signal. Applications that target only those versions can opt into that legacy
-behavior directly:
+Controllers can set common response headers declaratively:
```csharp
-Response.StatusCode = 286;
+[HtmxRequest]
+[HtmxResponse(
+ Retarget = "#comments",
+ Reswap = HtmxSwap.BeforeEnd)]
+public IActionResult AddComment(CommentInput input)
+{
+ var comment = repository.Add(input);
+ return PartialView("_Comment", comment);
+}
```
-HTMX 4.x treats `286` as a regular successful response, so it is not exposed as a
-toolkit API.
+`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
-The library provides five tag helpers:
-
-* `HtmxUrlTagHelper`
-* `HtmxHeaderTagHelper`
-* `HtmxValsTagHelper`
-* `HtmxRequestTagHelper`
-* `HtmxConfigTagHelper`
-
-To make them available in your project, add the `@addTagHelper` directive to a Razor view:
-
-```razor
-@addTagHelper *, Ramstack.HtmxToolkit
-```
-
-To make the tag helpers available throughout the application, add this line to
-`_ViewImports.cshtml`, which is inherited by Razor views by default.
-
-Import the toolkit namespace there as well if a view refers to toolkit types:
-
-```razor
-@using Ramstack.HtmxToolkit
-```
-
-### HtmxUrlTagHelper
-
-The `HtmxUrlTagHelper` generates URLs for HTMX requests in much the same way that
-the built-in ASP.NET Core tag helpers generate links. In most cases, replace the `asp-` prefix
-with `hx-`:
-
-```razor
-
-
-
-```
-
-The following code will be generated:
-
-```html
-
-
-
-```
-
-If no HTMX method is specified, the tag helper uses `hx-get`. You can select a method with
-`hx-get`, `hx-post`, `hx-put`, `hx-delete`, or `hx-patch`.
-
-For instance, in the following example, we use `hx-post`:
-
-```razor
-
-
-
-```
+HtmxToolkit includes five Tag Helpers:
-In this case, the following code will be generated:
+| 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. |
-```html
-
-
-
-```
+### URL Generation
-Use `hx-page` and `hx-page-handler` to generate a URL for a Razor Page handler:
+Controller and action:
```razor
-
-
-
-```
-
-The following code will be generated:
-
-```html
-
-
-
+
```
-The `hx-all-route-data` attribute accepts an `IDictionary` containing
-additional route values:
+Razor Page handler:
```razor
-@{
- var parameters = new Dictionary
- {
- ["category"] = "science",
- ["pdf"] = "true"
- };
-}
-
-
-```
-
-The following code will be generated:
-
-```html
-
+
```
-The following URL-generation attributes are also available:
-
-* `hx-host`
-* `hx-protocol`
-* `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`.
-### HtmxHeaderTagHelper
+### Headers And Values
-HTMX lets you add custom request headers through a JSON-valued attribute. Because writing and
-escaping that JSON manually can be inconvenient, `HtmxHeaderTagHelper` provides a clearer format:
+Create `hx-headers` without manually escaping JSON:
```razor
-
- Get some HTML and include custom headers in the request
-
-```
-
-The following code will be generated:
-
-```html
-
- Get some HTML and include custom headers in the request
-
-```
-
-You can also assign an `IDictionary` to `hx-all-headers`:
-
-```razor
-@{
- var headers = new Dictionary
- {
- ["Key-1"] = "Value-1",
- ["Key-2"] = "Value-2"
- };
-}
-
-
- Get some HTML and include custom headers in the request
-
+
```
-`HtmxHeaderTagHelper` handles JSON serialization and escaping.
-
-### HtmxValsTagHelper
-
-The `HtmxValsTagHelper` adds values that HTMX includes with a request. Use `hx-val-*`
-attributes instead of writing JSON manually:
+Add request values in the same way:
```razor