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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@ Antiforgery metadata is enabled by default. `<htmx-config />` renders the curren
the companion script attaches the token to non-GET HTMX requests and refreshes it after boosted navigation.

> [!WARNING]
> The companion script sends the token but does not perform validation. The application must still enable server-side antiforgery validation for the relevant endpoints.
> The companion script sends the token but does not validate it. Razor Pages validates unsafe HTTP methods automatically.
> MVC applications must enable server-side antiforgery validation for the relevant actions.

For example, MVC applications can validate all unsafe actions globally:

Expand Down Expand Up @@ -485,7 +486,7 @@ 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.
response headers and events, MVC attributes, Tag Helpers, polling, boosted navigation, and antiforgery integration.

Run it with:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Mvc;

namespace Ramstack.HtmxToolkit.Demo.Controllers;

public sealed class InventoryController : Controller
{
[HttpGet]
[HtmxRequest]
[HtmxResponse(Reswap = HtmxSwap.OuterHtml)]
public IActionResult Status(string sku)
{
var content = sku == "BOOK-42"
? "BOOK-42: Hypermedia Systems — 3 copies in stock."
: "No inventory record was found for that SKU.";

return Content($"<div id='inventory-status' class='result'>{content}</div>", "text/html");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,32 @@

<article class="example-page">
<header class="page-heading">
<p class="eyebrow">Example 10</p>
<h1>Antiforgery forms</h1>
<p>Submit HTMX forms while the toolkit automatically supplies the verification token</p>
<p class="eyebrow">Example 12</p>
<h1>Protect form submissions</h1>
<p>Send the antiforgery token expected by Razor Pages with an HTMX form</p>
</header>

<section class="demo-card">
<div class="demo-card__header">
<h2><code>hx-post</code> with antiforgery</h2>
<p>The layout configures token inclusion and the toolkit script attaches it to every HTMX request.</p>
<p>Razor Pages validates the POST automatically. <code>&lt;htmx-config /&gt;</code> renders the token and the toolkit script attaches it to the request.</p>
</div>

<form class="demo-form"
hx-post
hx-page="/Examples/Antiforgery"
hx-page-handler="FormSubmit"
hx-page-handler="Save"
hx-target="#form-result">
<label>
Name
<input type="text" name="name" placeholder="John Doe"/>
Display name
<input type="text" name="displayName" value="Ada" required/>
</label>

<label>
Email
<input type="email" name="email" placeholder="john@example.com"/>
</label>

<label>
Message
<textarea name="message" rows="4" placeholder="Your message"></textarea>
</label>

<button type="submit">Submit form</button>
<button type="submit">Save settings</button>
</form>

<div id="form-result" class="result">
Submit the form to see the posted values.
No settings have been saved.
</div>
</section>
</article>
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
using System.Text.Encodings.Web;

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace Ramstack.HtmxToolkit.Demo.Pages.Examples;

[ValidateAntiForgeryToken]
public class AntiforgeryModel : PageModel
{
public IActionResult OnPostFormSubmit(ContactForm form) =>
Content($"""
<b>Form submitted successfully!</b><br/>
<b>Name:</b> {form.Name}<br/>
<b>Email:</b> {form.Email}<br/>
<b>Message:</b> {form.Message}
""");

public class ContactForm
public IActionResult OnPostSave(string? displayName)
{
public string? Name { get; set; }
public string? Email { get; set; }
public string? Message { get; set; }
if (string.IsNullOrWhiteSpace(displayName))
return BadRequest("Display name is required.");

return Content($"Settings saved for {HtmlEncoder.Default.Encode(displayName)}.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@

<article class="example-page">
<header class="page-heading">
<p class="eyebrow">Example 08</p>
<p class="eyebrow">Example 07</p>
<h1>Detect boosted navigation</h1>
<p>Identify a request initiated by an <code>hx-boost</code> link</p>
</header>

<section class="demo-card">
<div class="demo-card__header">
<h2><code>IsHtmxBoosted()</code></h2>
<p>The link is progressively enhanced and its response is placed into the result panel.</p>
<p>HTMX enhances a regular link; without JavaScript, the handler redirects back to this page.</p>
</div>

<div class="demo-actions">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ namespace Ramstack.HtmxToolkit.Demo.Pages.Examples;
public class BoostedModel : PageModel
{
public IActionResult OnGetBoostedCheck() =>
Content(
Request.IsHtmxBoosted()
? "Boosted HTMX request detected!"
: "Non-boosted HTMX request.");
Request.IsHtmxBoosted()
? Content("Boosted HTMX request detected.")
: RedirectToPage();
}
Original file line number Diff line number Diff line change
@@ -1,44 +1,46 @@
@page
@model FluentResponseModel
@{
ViewData["Title"] = "Fluent response API";
ViewData["Title"] = "Response control";
}

<article class="example-page">
<header class="page-heading">
<p class="eyebrow">Example 04</p>
<h1>Fluent response API</h1>
<p>Set HTMX response headers from the page handler with <code>Response.Htmx()</code></p>
<p class="eyebrow">Example 09</p>
<h1>Control the response target</h1>
<p>Let the server redirect a response to a more appropriate element</p>
</header>

<section class="demo-card">
<div class="demo-card__header">
<h2>Retarget and reswap</h2>
<p>The response changes the target or swap mode chosen by the triggering element.</p>
<h2><code>Response.Htmx(h => h.Retarget(...))</code></h2>
<p>A successful lookup updates the product panel; a missing product is retargeted to the notice.</p>
</div>

<div class="demo-actions">
<button
hx-get
hx-page="/Examples/FluentResponse"
hx-page-handler="Reswap"
hx-target="#fluent-result"
hx-swap="outerHTML">
Reswap after begin
<button hx-get
hx-page="/Examples/FluentResponse"
hx-page-handler="Product"
hx-route-sku="BOOK-42"
hx-target="#product-result">
Find BOOK-42
</button>
<button
class="button-secondary"
hx-get hx-page="/Examples/FluentResponse"
hx-page-handler="Retarget"
hx-target="#ignored-target">
Retarget response
<button class="button-secondary"
hx-get
hx-page="/Examples/FluentResponse"
hx-page-handler="Product"
hx-route-sku="BOOK-00"
hx-target="#product-result">
Find BOOK-00
</button>
</div>

<span id="ignored-target" hidden></span>
<div id="product-notice" class="result">
No lookup errors.
</div>

<div id="fluent-result" class="result">
Try either response directive.
<div id="product-result" class="result">
Choose a product.
</div>
</section>
</article>
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,12 @@ namespace Ramstack.HtmxToolkit.Demo.Pages.Examples;

public class FluentResponseModel : PageModel
{
public IActionResult OnGetReswap()
public IActionResult OnGetProduct(string sku)
{
Response.Htmx(h => h.Reswap(HtmxSwap.AfterBegin));
return Content("<p>Prepended to top with AfterBegin swap!</p>");
}
if (sku == "BOOK-42")
return Content("BOOK-42: Hypermedia Systems — in stock.");

public IActionResult OnGetRetarget()
{
Response.Htmx(h => h.Retarget("#fluent-result"));
return Content("<b>Retargeted to #fluent-result!</b>");
Response.Htmx(htmx => htmx.Retarget("#product-notice"));
return Content("No product found for that SKU.");
}
}
11 changes: 5 additions & 6 deletions samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,27 @@

<article class="example-page">
<header class="page-heading">
<p class="eyebrow">Example 03</p>
<p class="eyebrow">Example 04</p>
<h1>Request headers</h1>
<p>Define custom HTMX headers declaratively in Razor</p>
</header>

<section class="demo-card">
<div class="demo-card__header">
<h2><code>hx-header-*</code></h2>
<p>The handler reads the header and triggers two client events.</p>
<p>The toolkit serializes the attribute as <code>hx-headers</code>; the handler reads it normally.</p>
</div>
<div class="demo-actions">
<button
hx-get
hx-page="/Examples/Headers"
hx-page-handler="CustomHeader"
hx-header-Custom-Header="MyValue"
hx-page-handler="Show"
hx-header-X-Report-Format="summary"
hx-target="#header-result">
Send custom header
Request summary report
</button>
</div>

<div id="header-result" class="result">No request has been sent.</div>
<div id="event-log" class="event-log"></div>
</section>
</article>
14 changes: 4 additions & 10 deletions samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
using System.Text.Encodings.Web;

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace Ramstack.HtmxToolkit.Demo.Pages.Examples;

public class HeadersModel : PageModel
{
public IActionResult OnGetCustomHeader()
{
Response.Htmx(h => h
.TriggerEvent("customEvent", new { message = "#1 Fired from server!" })
.TriggerEvent("logEvent", new { message = $"Custom-Header = {Request.Headers["Custom-Header"]}" })
.TriggerEvent("customEvent", new { message = "#2 Fired from server!" })
.TriggerEvent("customEvent", new { message = "#3 Fired from server!" }));

return Content("<b>Custom headers sent!</b>");
}
public IActionResult OnGetShow() =>
Content($"X-Report-Format: {HtmlEncoder.Default.Encode(Request.Headers["X-Report-Format"].ToString())}");
}
52 changes: 11 additions & 41 deletions samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -8,65 +8,35 @@
<header class="page-heading">
<p class="eyebrow">Example 06</p>
<h1>Detect HTMX requests</h1>
<p>Return an appropriate representation when the handler is called asynchronously or directly</p>
<p>Return a fragment to HTMX and inspect its strongly typed request metadata</p>
</header>

<section class="demo-card">
<div class="demo-card__header">
<h2><code>Request.IsHtmxRequest()</code></h2>
<p>Compare a normal browser navigation with an HTMX request to the same handler.</p>
<h2><code>Request.IsHtmxRequest(out var headers)</code></h2>
<p>Both controls request the same URL; the HTMX response also reports its source, target, and request type.</p>
</div>

<div class="demo-actions">
<button
id="check-order"
hx-get
hx-page="/Examples/HtmxRequest"
hx-page-handler="PartialOrFull"
hx-target="#partial-result">
Fetch via HTMX
hx-route-id="1042"
hx-target="#order-status">
Check inline
</button>
<a
class="button button-secondary"
asp-page="/Examples/HtmxRequest"
asp-page-handler="PartialOrFull"
asp-route-id="1042"
target="_blank">
Open full request
Open the same URL normally
</a>
</div>

<div id="partial-result" class="result">
Choose a request type.
</div>
</section>

<section class="demo-card">
<div class="demo-card__header">
<h2><code>hx-request-timeout</code></h2>
<p>The handler responds after 1200 ms. The first request times out after 1000 ms; the second completes normally.</p>
</div>

<div class="demo-actions">
<button
hx-get
hx-page="/Examples/HtmxRequest"
hx-page-handler="Delayed"
hx-request-timeout="1000"
hx-on::error="document.querySelector('#timeout-result').textContent = 'Request timed out'"
hx-target="#timeout-result">
Request with 1000 ms timeout
</button>
<button
class="button button-secondary"
hx-get
hx-page="/Examples/HtmxRequest"
hx-page-handler="Delayed"
hx-target="#timeout-result">
Request without timeout
</button>
</div>

<div id="timeout-result" class="result">
Choose a request mode.
<div id="order-status" class="result">
@(Model.OrderStatus ?? "Order status has not been requested.")
</div>
</section>
</article>
Loading