Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 103 additions & 67 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,28 @@ use the following command
dotnet add package Ramstack.HtmxToolkit
```

Register the toolkit and select the HTMX version used by the application:

```csharp
builder.Services.AddHtmxToolkit(options =>
{
options.IncludeAntiforgeryToken = true;
options.UseHtmxV2(config =>
{
config.DefaultSwapStyle = HtmxSwap.OuterHtml;
config.Timeout = 5000;
config.GlobalViewTransitions = true;
});
});
```

HTMX 2.x is used by default. Calling `UseHtmxV2` is optional when no version-specific
settings are required:

```csharp
builder.Services.AddHtmxToolkit();
```

## HttpRequest

The library provides a set of classes for working with `HttpRequest`.
Expand Down Expand Up @@ -609,7 +631,7 @@ You can also provide the values as a dictionary with the `hx-all-vals` attribute

### HtmxRequestTagHelper

The `HtmxRequestTagHelper` configures the htmx request options supported by HTMX 1.x and 2.x.
The `HtmxRequestTagHelper` configures the htmx request options supported by HTMX 1.9.x and 2.x.
Use the typed `hx-request-*` attributes instead of writing JSON manually:

```html
Expand All @@ -632,99 +654,110 @@ The following HTML will be generated:

### HtmxConfigTagHelper

As with `hx-headers`, configuring `htmx` settings requires a JSON representation.
For working with configuration, the `HtmxConfigTagHelper` class is provided.
HTMX configuration is defined at application startup through `AddHtmxToolkit`.
The version-specific callback exposes only settings supported by the selected HTMX version:

```csharp
builder.Services.AddHtmxToolkit(options =>
{
options.IncludeAntiforgeryToken = true;
options.UseHtmxV2(config =>
{
config.DefaultSwapStyle = HtmxSwap.OuterHtml;
config.Timeout = 5000;
config.GlobalViewTransitions = true;
});
});
```

Use the tag helper as a marker where the configuration meta element should be rendered:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta htmx-config
default-swap-style="HtmxSwap.OuterHtml"
use-template-fragments="true"
scroll-behavior="HtmxScrollBehavior.Smooth"
include-antiforgery-token="true" />
<htmx-config />
</head>
```
The following code will be generated:

The following markup will be generated:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="htmx-config"
content='{"defaultSwapStyle":"outerHTML","useTemplateFragments":true,"scrollBehavior":"smooth","antiForgery":{"headerName":"RequestVerificationToken","formFieldName":"__RequestVerificationToken","requestToken":"..."}}' />
content='{"defaultSwapStyle":"outerHTML","timeout":5000,"globalViewTransitions":true}'
data-antiforgery-request-token="..."
data-antiforgery-header-name="RequestVerificationToken"
data-antiforgery-form-field-name="__RequestVerificationToken" />
</head>
```

If desired or for the purpose of semantics, you can use `htmx-config` as the standalone name of the element:
```html
<htmx-config default-swap-style="HtmxSwap.OuterHtml"
use-template-fragments="true"
scroll-behavior="HtmxScrollBehavior.Smooth"
include-antiforgery-token="true" />
```

#### Response Handling Configuration

HTMX 2.x introduces the [`responseHandling`](https://htmx.org/docs/#response-handling) configuration option,
allowing you to define how htmx should handle responses based on HTTP status codes.
The library provides a child tag helper `<response-handling>` that can be placed inside `<htmx-config>`
to declaratively configure response handling rules.
The marker can also be written as a `meta` element:

```html
<htmx-config>
<!-- 204 No Content — do not swap, but not an error -->
<response-handling code="204" swap="false" />

<!-- 2xx & 3xx — swap into DOM -->
<response-handling code="[23].." swap="true" />
<meta htmx-config />
```

<!-- 422 Unprocessable Entity — swap (e.g. validation errors) -->
<response-handling code="422" swap="true" />
HTMX 2.x is selected by default. Use `UseHtmxV1`, `UseHtmxV2`, or `UseHtmxV4` to select a
version explicitly. Each configuration type follows the names used by that HTMX version, so HTMX 1.9.x
and 2.x expose `DefaultSwapStyle` and `Timeout`, while HTMX 4.x exposes `DefaultSwap` and
`DefaultTimeout`. Selecting different versions in the same configuration throws an exception.

<!-- 4xx & 5xx — do not swap, treat as error -->
<response-handling code="[45].." swap="false" error="true" />
HTMX 4.x is currently in beta. To target it, select it explicitly and use its version-specific
settings:

<!-- Catch-all for any other response code -->
<response-handling code="..." swap="true" />
</htmx-config>
```csharp
builder.Services.AddHtmxToolkit(options =>
{
options.UseHtmxV4(config =>
{
config.DefaultSwap = HtmxSwap.OuterHtml;
config.DefaultTimeout = 5000;
config.Transitions = true;
config.NoSwap = ["204", "304", "4xx", "5xx"];
});
});
```

The following code will be generated:
The configured values remain available through dependency injection:

```html
<meta name="htmx-config"
content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"422","swap":true},{"code":"[45]..","swap":false,"error":true},{"code":"...","swap":true}]}' />
```csharp
public sealed class ConfigurationInspector(IOptions<HtmxToolkitOptions> options)
{
public HtmxV2Config HtmxConfig =>
options.Value.GetHtmxConfig<HtmxV2Config>();
}
```

The `<response-handling>` element supports the following attributes:

| Attribute | Type | Description |
|-----------------|----------|------------------------------------------------------------------|
| `code` | `string` | Regular expression tested against response status codes |
| `swap` | `bool?` | Whether the response should be swapped into the DOM |
| `error` | `bool?` | Whether htmx should treat this response as an error |
| `ignore-title` | `bool?` | Whether to ignore title tags in the response |
| `select` | `string` | CSS selector to select content from the response |
| `target` | `string` | CSS selector specifying an alternative target for the response |
| `swap-override` | `string` | Alternative swap mechanism for the response |
#### Response Handling Configuration

Alternatively, you can set the entire response handling configuration directly as a Razor expression:
HTMX 2.x introduces the [`responseHandling`](https://htmx.org/docs/#response-handling) configuration option,
allowing you to define how htmx should handle responses based on HTTP status codes. Rules are
configured in order through `HtmxV2Config`:

```html
<htmx-config response-handling="@new [] {
new ResponseHandlingConfig { Code = "204", Swap = false },
new ResponseHandlingConfig { Code = "[23]..", Swap = true },
new ResponseHandlingConfig { Code = "[45]..", Swap = false, Error = true }
}" />
```csharp
builder.Services.AddHtmxToolkit(options =>
{
options.UseHtmxV2(config =>
{
config.ResponseHandling =
[
new() { Code = "204", Swap = false },
new() { Code = "[23]..", Swap = true },
new() { Code = "422", Swap = true },
new() { Code = "[45]..", Swap = false, Error = true },
new() { Code = "...", Swap = true }
];
});
});
```

HTMX 4.x removes `responseHandling`. To retain HTMX 2.x behavior that does not swap error
responses, configure `NoSwap` as shown in the HTMX 4.x example above.

## Toolkit Script

The toolkit script provides antiforgery support and HTMX compatibility behavior.
If you have enabled **Antiforgery** token generation in the configuration
(`include-antiforgery-token="true"`), include it to ensure the token is present in form
(`IncludeAntiforgeryToken = true`), include it to ensure the token is present in form
parameters or headers and refreshed in a timely manner.

To do this, you can directly include the contents of the script file on the page:
Expand Down Expand Up @@ -790,9 +823,12 @@ or the debug version of the script.

## Supported Versions

| | Version |
|------|----------------|
| .NET | 6, 7, 8, 9, 10 |
All releases in the following HTMX version lines are supported:

| | Version |
|------|----------------------------------|
| .NET | 6, 7, 8, 9, 10, 11 |
| HTMX | 1.9.x, 2.x (default), 4.x (beta) |

## Contributions

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
hx-page="/Examples/HtmxRequest"
hx-page-handler="Delayed"
hx-request-timeout="1000"
hx-on::timeout="document.querySelector('#timeout-result').textContent = 'Request timed out'"
hx-on::error="document.querySelector('#timeout-result').textContent = 'Request timed out'"
hx-target="#timeout-result">
Request with 1000 ms timeout
</button>
Expand Down
12 changes: 1 addition & 11 deletions samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,7 @@
<title>@(ViewData["Title"] ?? "Home") — Ramstack.HtmxToolkit</title>
<link rel="stylesheet" href="~/css/demo.css"/>

<htmx-config
include-antiforgery-token="true"
methods-that-use-url-params="[HttpVerb.Get, HttpVerb.Delete]"
default-swap-style="HtmxSwap.InnerHtml"
no-swap="@(["204", "304", "4xx", "5xx"])">
<response-handling code="204" swap="false"/>
<response-handling code="422" swap="true"/>
<response-handling code="[23].." swap="true"/>
<response-handling code="[45].." swap="false" error="true"/>
<response-handling code="..." swap="true"/>
</htmx-config>
<htmx-config />
</head>

<body>
Expand Down
18 changes: 18 additions & 0 deletions samples/Ramstack.HtmxToolkit.Demo/Program.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
using Ramstack.HtmxToolkit;
using Ramstack.HtmxToolkit.Builder;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddHtmxToolkit(options =>
{
options.IncludeAntiforgeryToken = true;
options.UseHtmxV2(config =>
{
config.DefaultSwapStyle = HtmxSwap.InnerHtml;
config.MethodsThatUseUrlParams = [HttpVerb.Get, HttpVerb.Delete];
config.ResponseHandling =
[
new() { Code = "204", Swap = false },
new() { Code = "422", Swap = true },
new() { Code = "[23]..", Swap = true },
new() { Code = "[45]..", Swap = false, Error = true },
new() { Code = "...", Swap = true }
];
});
});

var app = builder.Build();

Expand Down
20 changes: 10 additions & 10 deletions src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ internal readonly struct AjaxContextWrapper
{
private readonly AjaxContext _context;

[JsonPropertyName("path")] public string? Path => _context.Path;
[JsonPropertyName("source")] public string? Source => _context.Source;
[JsonPropertyName("event")] public string? Event => _context.Event;
[JsonPropertyName("handler")] public string? Handler => _context.Handler;
[JsonPropertyName("target")] public string? Target => _context.Target;
[JsonPropertyName("swap")] public string? Swap => _context.Swap.GetSwapValue();
[JsonPropertyName("values")] public object? Values => _context.Values;
[JsonPropertyName("headers")] public IDictionary<string, string>? Headers => _context.Headers;
[JsonPropertyName("select")] public string? Select => _context.Select;

/// <summary>
/// Initializes a new instance of the <see cref="AjaxContextWrapper"/>.
/// </summary>
Expand All @@ -21,14 +31,4 @@ public AjaxContextWrapper(string path, AjaxContext context)
context.Path = path;
_context = context;
}

[JsonPropertyName("path")] public string? Path => _context.Path;
[JsonPropertyName("source")] public string? Source => _context.Source;
[JsonPropertyName("event")] public string? Event => _context.Event;
[JsonPropertyName("handler")] public string? Handler => _context.Handler;
[JsonPropertyName("target")] public string? Target => _context.Target;
[JsonPropertyName("swap")] public string? Swap => _context.Swap.GetSwapValue();
[JsonPropertyName("values")] public object? Values => _context.Values;
[JsonPropertyName("headers")] public IDictionary<string, string>? Headers => _context.Headers;
[JsonPropertyName("select")] public string? Select => _context.Select;
}
19 changes: 15 additions & 4 deletions src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,24 @@ document._r_htmx ||= ((document, htmx) => {
document.addEventListener(type, listener);
};

const read_antiforgery = doc => {
let data = doc.querySelector("meta[name='htmx-config']")?.dataset || {};
return {
headerName: data.antiforgeryHeaderName,
formFieldName: data.antiforgeryFormFieldName,
requestToken: data.antiforgeryRequestToken
};
};

let antiforgery = read_antiforgery(document);

const add_antiforgery = (method, headers, parameters) => {
if (!/^get$/i.test(method)) {
const {
headerName,
formFieldName,
requestToken
} = htmx.config.antiForgery ?? {};
} = antiforgery;

if (requestToken) {
if (!parameters.has?.(formFieldName) && !parameters[formFieldName])
Expand All @@ -29,9 +40,9 @@ document._r_htmx ||= ((document, htmx) => {
};

const update_antiforgery = content => {
let html = new DOMParser().parseFromString(content || "", "text/html");
let meta = html.querySelector("meta[name='htmx-config']");
meta && (htmx.config.antiForgery = JSON.parse(meta.content).antiForgery);
let doc = new DOMParser().parseFromString(content || "", "text/html");
let val = read_antiforgery(doc);
val && (antiforgery = val);
};

listen("htmx:afterOnLoad", e => {
Expand Down
2 changes: 1 addition & 1 deletion src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ public static IEndpointConventionBuilder MapHtmxToolkitScript(this IEndpointRout
/// </returns>
public static IEndpointConventionBuilder MapHtmxToolkitScript(this IEndpointRouteBuilder builder, string path)
{
if (path.Length == 0)
if (string.IsNullOrEmpty(path))
throw new ArgumentException(
"The 'path' parameter cannot be null or empty.", nameof(path));
$"The '{nameof(path)}' parameter cannot be null or empty.",
nameof(path));

if (AssetPath != path)
{
Expand All @@ -49,10 +50,10 @@ public static IEndpointConventionBuilder MapHtmxToolkitScript(this IEndpointRout
HtmlHelperExtensions.DebugPath = new HtmlString(path + "?debug");
}

return builder.MapGet(path, context =>
return builder.MapGet(path, static context =>
{
context.Response.ContentType = "text/javascript";
context.Response.Headers["Cache-Control"] = "public,max-age=31536000";
context.Response.Headers.CacheControl = "public,max-age=31536000";

return context.Response.WriteAsync(
context.Request.QueryString.Value == "?debug"
Expand Down
Loading
Loading