From 7144ab8e6c7b0a455c345687e44171e37a89758c Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 6 Sep 2026 19:07:48 +0500 Subject: [PATCH 1/2] Add DocFX --- .config/dotnet-tools.json | 12 + .github/workflows/docs.yml | 55 ++++ .gitignore | 4 + README.md | 20 +- docs/README.md | 31 ++ docs/api-overwrites/requests.md | 34 +++ docs/api-overwrites/responses.md | 48 +++ docs/api-overwrites/tag-helpers.md | 48 +++ docs/api/index.md | 3 + docs/articles/antiforgery.md | 140 +++++++++ docs/articles/choosing-version.md | 76 +++++ docs/articles/configuration-v1.md | 73 +++++ docs/articles/configuration-v2.md | 86 ++++++ docs/articles/configuration-v4.md | 119 ++++++++ docs/articles/configuration.md | 105 +++++++ docs/articles/getting-started.md | 131 ++++++++ docs/articles/index.md | 53 ++++ docs/articles/overview.md | 62 ++++ docs/articles/pages-and-fragments.md | 127 ++++++++ docs/articles/recipes.md | 283 ++++++++++++++++++ docs/articles/requests.md | 122 ++++++++ docs/articles/responses.md | 181 +++++++++++ docs/articles/tag-helpers.md | 220 ++++++++++++++ docs/articles/toc.yml | 44 +++ docs/articles/troubleshooting.md | 123 ++++++++ docs/articles/version-compatibility.md | 87 ++++++ docs/docfx.json | 46 +++ docs/index.md | 38 +++ .../BoostedRequestAttributeExample.cs | 3 + .../requests/HandleBoostedNavigation.cs | 4 + .../requests/HtmxRequestAttributeExample.cs | 10 + docs/snippets/requests/InspectHtmxHeaders.cs | 5 + .../snippets/requests/ReturnPartialForHtmx.cs | 9 + .../responses/ConfigureHttpResponse.cs | 8 + .../responses/HtmxResponseAttributeExample.cs | 11 + .../snippets/responses/LocationWithOptions.cs | 14 + docs/snippets/responses/RedirectAfterLogin.cs | 8 + .../responses/RetargetValidationErrors.cs | 5 + docs/snippets/responses/TriggerClientEvent.cs | 6 + .../tag-helpers/ConfigTagHelper.cshtml | 5 + .../tag-helpers/HeaderTagHelper.cshtml | 9 + .../tag-helpers/MapToolkitScriptEndpoint.cs | 6 + .../tag-helpers/RequestTagHelper.cshtml | 9 + .../snippets/tag-helpers/ToolkitScript.cshtml | 3 + docs/snippets/tag-helpers/UrlTagHelper.cshtml | 7 + .../snippets/tag-helpers/ValsTagHelper.cshtml | 10 + docs/template/ManagedReference.extension.js | 24 ++ docs/template/public/main.css | 137 +++++++++ docs/toc.yml | 4 + .../Configuration/HtmxHistoryMode.cs | 6 +- 50 files changed, 2672 insertions(+), 2 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 .github/workflows/docs.yml create mode 100644 docs/README.md create mode 100644 docs/api-overwrites/requests.md create mode 100644 docs/api-overwrites/responses.md create mode 100644 docs/api-overwrites/tag-helpers.md create mode 100644 docs/api/index.md create mode 100644 docs/articles/antiforgery.md create mode 100644 docs/articles/choosing-version.md create mode 100644 docs/articles/configuration-v1.md create mode 100644 docs/articles/configuration-v2.md create mode 100644 docs/articles/configuration-v4.md create mode 100644 docs/articles/configuration.md create mode 100644 docs/articles/getting-started.md create mode 100644 docs/articles/index.md create mode 100644 docs/articles/overview.md create mode 100644 docs/articles/pages-and-fragments.md create mode 100644 docs/articles/recipes.md create mode 100644 docs/articles/requests.md create mode 100644 docs/articles/responses.md create mode 100644 docs/articles/tag-helpers.md create mode 100644 docs/articles/toc.yml create mode 100644 docs/articles/troubleshooting.md create mode 100644 docs/articles/version-compatibility.md create mode 100644 docs/docfx.json create mode 100644 docs/index.md create mode 100644 docs/snippets/requests/BoostedRequestAttributeExample.cs create mode 100644 docs/snippets/requests/HandleBoostedNavigation.cs create mode 100644 docs/snippets/requests/HtmxRequestAttributeExample.cs create mode 100644 docs/snippets/requests/InspectHtmxHeaders.cs create mode 100644 docs/snippets/requests/ReturnPartialForHtmx.cs create mode 100644 docs/snippets/responses/ConfigureHttpResponse.cs create mode 100644 docs/snippets/responses/HtmxResponseAttributeExample.cs create mode 100644 docs/snippets/responses/LocationWithOptions.cs create mode 100644 docs/snippets/responses/RedirectAfterLogin.cs create mode 100644 docs/snippets/responses/RetargetValidationErrors.cs create mode 100644 docs/snippets/responses/TriggerClientEvent.cs create mode 100644 docs/snippets/tag-helpers/ConfigTagHelper.cshtml create mode 100644 docs/snippets/tag-helpers/HeaderTagHelper.cshtml create mode 100644 docs/snippets/tag-helpers/MapToolkitScriptEndpoint.cs create mode 100644 docs/snippets/tag-helpers/RequestTagHelper.cshtml create mode 100644 docs/snippets/tag-helpers/ToolkitScript.cshtml create mode 100644 docs/snippets/tag-helpers/UrlTagHelper.cshtml create mode 100644 docs/snippets/tag-helpers/ValsTagHelper.cshtml create mode 100644 docs/template/ManagedReference.extension.js create mode 100644 docs/template/public/main.css create mode 100644 docs/toc.yml diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..72bd4ed --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "docfx": { + "version": "2.78.5", + "commands": [ + "docfx" + ] + } + } +} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..f8c85ba --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,55 @@ +name: Publish documentation + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Configure GitHub Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Restore documentation tools + run: dotnet tool restore + + - name: Build documentation + run: dotnet docfx docs/docfx.json --basePath "${{ steps.pages.outputs.base_path }}" + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_site + + deploy: + name: Deploy documentation + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index e6527d5..f7554e5 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ node_modules/ /Directory.Build.targets +# Generated DocFX output and API metadata +/docs/_site/ +/docs/api/* +!/docs/api/index.md diff --git a/README.md b/README.md index d92a5e5..57f488a 100644 --- a/README.md +++ b/README.md @@ -483,7 +483,11 @@ 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 +## Running Locally + +Run the following commands from the repository root. + +### Demo The [`samples/Ramstack.HtmxToolkit.Demo`](samples/Ramstack.HtmxToolkit.Demo) project demonstrates request detection, response headers and events, MVC attributes, Tag Helpers, polling, boosted navigation, and antiforgery integration. @@ -494,6 +498,20 @@ Run it with: dotnet run --project samples/Ramstack.HtmxToolkit.Demo ``` +The application is available at and . + +### Documentation + +Restore the repository-local DocFX tool, then start the documentation preview server: + +```console +dotnet tool restore +dotnet docfx docs/docfx.json --serve +``` + +Open after DocFX finishes the initial build. See +[`docs/README.md`](docs/README.md) for standalone builds and information about API examples. + ## Contributing Bug reports and pull requests are welcome. To validate a change locally: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..97a0d68 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,31 @@ +# Building the documentation locally + +Restore the repository-local DocFX version: + +```console +dotnet tool restore +``` + +Build the static site: + +```console +dotnet docfx docs/docfx.json +``` + +Run a local preview server and open : + +```console +dotnet docfx docs/docfx.json --serve +``` + +DocFX writes generated HTML to `docs/_site` and generated API metadata to `docs/api`; +both directories are ignored by Git. + +## API examples + +API examples are maintained outside the library source code. Markdown overwrite files in +`docs/api-overwrites` attach an `example` to an API page by its DocFX `uid`, while the +referenced source snippets live in `docs/snippets`. + +To add an example, find the generated UID in `docs/api/*.yml`, add an overwrite section, +and reference a snippet with DocFX's `[!code-csharp[](...)]` or `[!code-razor[](...)]` syntax. diff --git a/docs/api-overwrites/requests.md b/docs/api-overwrites/requests.md new file mode 100644 index 0000000..86d0a26 --- /dev/null +++ b/docs/api-overwrites/requests.md @@ -0,0 +1,34 @@ +--- +uid: Ramstack.HtmxToolkit.HttpRequestExtensions.IsHtmxRequest(Microsoft.AspNetCore.Http.HttpRequest) +example: + - |- + [!code-csharp[](../snippets/requests/ReturnPartialForHtmx.cs)] +--- + +--- +uid: Ramstack.HtmxToolkit.HttpRequestExtensions.IsHtmxRequest(Microsoft.AspNetCore.Http.HttpRequest,Ramstack.HtmxToolkit.HtmxRequestHeaders@) +example: + - |- + [!code-csharp[](../snippets/requests/InspectHtmxHeaders.cs)] +--- + +--- +uid: Ramstack.HtmxToolkit.HttpRequestExtensions.IsHtmxBoosted(Microsoft.AspNetCore.Http.HttpRequest) +example: + - |- + [!code-csharp[](../snippets/requests/HandleBoostedNavigation.cs)] +--- + +--- +uid: Ramstack.HtmxToolkit.HtmxRequestAttribute +example: + - |- + [!code-csharp[](../snippets/requests/HtmxRequestAttributeExample.cs)] +--- + +--- +uid: Ramstack.HtmxToolkit.HtmxRequestAttribute.Boosted +example: + - |- + [!code-csharp[](../snippets/requests/BoostedRequestAttributeExample.cs)] +--- diff --git a/docs/api-overwrites/responses.md b/docs/api-overwrites/responses.md new file mode 100644 index 0000000..03d8cbd --- /dev/null +++ b/docs/api-overwrites/responses.md @@ -0,0 +1,48 @@ +--- +uid: Ramstack.HtmxToolkit.HttpResponseExtensions.Htmx(Microsoft.AspNetCore.Http.HttpResponse,System.Action{Ramstack.HtmxToolkit.HtmxResponse}) +example: + - |- + [!code-csharp[](../snippets/responses/ConfigureHttpResponse.cs)] +--- + +--- +uid: Ramstack.HtmxToolkit.HtmxResponse.Location(System.String,Ramstack.HtmxToolkit.HtmxLocationOptions) +example: + - |- + [!code-csharp[](../snippets/responses/LocationWithOptions.cs)] +--- + +--- +uid: Ramstack.HtmxToolkit.HtmxLocationOptions +example: + - |- + [!code-csharp[](../snippets/responses/LocationWithOptions.cs)] +--- + +--- +uid: Ramstack.HtmxToolkit.HtmxResponse.Redirect(System.String) +example: + - |- + [!code-csharp[](../snippets/responses/RedirectAfterLogin.cs)] +--- + +--- +uid: Ramstack.HtmxToolkit.HtmxResponse.Reswap(Ramstack.HtmxToolkit.HtmxSwap) +example: + - |- + [!code-csharp[](../snippets/responses/RetargetValidationErrors.cs)] +--- + +--- +uid: Ramstack.HtmxToolkit.HtmxResponse.TriggerEvent(System.String,System.Object,Ramstack.HtmxToolkit.HtmxTriggerTiming) +example: + - |- + [!code-csharp[](../snippets/responses/TriggerClientEvent.cs)] +--- + +--- +uid: Ramstack.HtmxToolkit.HtmxResponseAttribute +example: + - |- + [!code-csharp[](../snippets/responses/HtmxResponseAttributeExample.cs)] +--- diff --git a/docs/api-overwrites/tag-helpers.md b/docs/api-overwrites/tag-helpers.md new file mode 100644 index 0000000..45d38bd --- /dev/null +++ b/docs/api-overwrites/tag-helpers.md @@ -0,0 +1,48 @@ +--- +uid: Ramstack.HtmxToolkit.TagHelpers.HtmxUrlTagHelper +example: + - |- + [!code-razor[](../snippets/tag-helpers/UrlTagHelper.cshtml)] +--- + +--- +uid: Ramstack.HtmxToolkit.TagHelpers.HtmxHeaderTagHelper +example: + - |- + [!code-razor[](../snippets/tag-helpers/HeaderTagHelper.cshtml)] +--- + +--- +uid: Ramstack.HtmxToolkit.TagHelpers.HtmxValsTagHelper +example: + - |- + [!code-razor[](../snippets/tag-helpers/ValsTagHelper.cshtml)] +--- + +--- +uid: Ramstack.HtmxToolkit.TagHelpers.HtmxRequestTagHelper +example: + - |- + [!code-razor[](../snippets/tag-helpers/RequestTagHelper.cshtml)] +--- + +--- +uid: Ramstack.HtmxToolkit.TagHelpers.HtmxConfigTagHelper +example: + - |- + [!code-razor[](../snippets/tag-helpers/ConfigTagHelper.cshtml)] +--- + +--- +uid: Ramstack.HtmxToolkit.HtmlHelperExtensions.HtmxToolkitScriptPath(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,System.Boolean) +example: + - |- + [!code-razor[](../snippets/tag-helpers/ToolkitScript.cshtml)] +--- + +--- +uid: Ramstack.HtmxToolkit.Hosting.EndpointRouteBuilderExtensions.MapHtmxToolkitScript(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder) +example: + - |- + [!code-csharp[](../snippets/tag-helpers/MapToolkitScriptEndpoint.cs)] +--- diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..5e400c0 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,3 @@ +# API reference + +This reference is generated from the public API and XML documentation comments in `Ramstack.HtmxToolkit`. diff --git a/docs/articles/antiforgery.md b/docs/articles/antiforgery.md new file mode 100644 index 0000000..0ad69cf --- /dev/null +++ b/docs/articles/antiforgery.md @@ -0,0 +1,140 @@ +# Antiforgery and Toolkit script + +The companion Toolkit script integrates ASP.NET Core antiforgery tokens with HTMX requests and supplies compatibility swaps for older HTMX versions. +It is not the HTMX library itself. + +## How automatic antiforgery works + +1. `` asks ASP.NET Core for an antiforgery token. +2. The Tag Helper writes the request token, header name, and form-field name as data attributes on ``. +3. The Toolkit script reads those attributes during page load. +4. Before each non-GET HTMX request, the script adds the token unless the form data already contains the configured antiforgery form field. +5. ASP.NET Core validates the token normally. + +The token is sent as the configured antiforgery header when a header name is available. +Otherwise, it is added to the request parameters under the configured form-field name. + +## Configure the layout + +Map the script endpoint in `Program.cs`: + +```csharp +using Ramstack.HtmxToolkit.Hosting; + +var app = builder.Build(); + +app.MapHtmxToolkitScript(); +app.MapRazorPages(); +``` + +Render configuration metadata in ``, then load HTMX before the Toolkit script: + +```html + + + + + @RenderBody() + + + + +``` + +Both pieces are required for automatic antiforgery. The Tag Helper creates the token; the script attaches it to requests. + +## Submit a protected form + +Razor Pages validates non-GET handlers by default: + +```html +
+ + +
+ +
+``` + +```csharp +public IActionResult OnPostSave(string? displayName) +{ + if (string.IsNullOrWhiteSpace(displayName)) + return BadRequest("Display name is required."); + + return Content("Profile saved."); +} +``` + +No token input is required in this form because the layout metadata and Toolkit script supply it. + +## Token refresh after boosted navigation + +When a boosted navigation returns a new full document, the Toolkit script reads antiforgery metadata from that response +and updates the token used for later requests. Ensure the returned document contains ``. + +## Script endpoint and caching + +The default endpoint path contains a content hash: + +```text +/htmxtoolkit/{content-hash} +``` + +It returns the minified script with `Cache-Control: public,max-age=31536000`. A new embedded script receives a new default URL. + +Pass a custom path when routing conventions require one: + +```csharp +app.MapHtmxToolkitScript("/assets/htmx-toolkit.js"); +``` + +When using a stable custom path, account for cache invalidation in deployment or proxy configuration. + +Request the readable script while diagnosing browser behavior: + +```html + +``` + +The debug URL adds `?debug` and the endpoint returns the unminified asset. + +## Inline the script + +Applications that cannot map the endpoint can render the embedded asset inside a script element: + +```html + +``` + +Inlining removes a request but changes the content security policy and repeats the script in every full document. +Prefer the cacheable endpoint for most applications. + +## Disable automatic antiforgery + +Disable metadata only when another integration attaches a valid token: + +```csharp +builder.Services.AddHtmxToolkit(options => +{ + options.IncludeAntiforgeryToken = false; + options.UseHtmxV2(); +}); +``` + +The Toolkit script can still be used for morph compatibility after antiforgery metadata is disabled. + +## Security considerations + +- Antiforgery protects cookie-authenticated state-changing requests; it does not replace authentication or authorization. +- A custom `hx-header-*` value is client-controlled and must not be trusted as proof of identity. +- Cross-origin permissions still require correct ASP.NET Core CORS and credential configuration. +- An inline Toolkit script may require a CSP nonce or hash. The endpoint form works naturally with a policy that allows scripts from the application's origin. + +If a protected request returns 400, see [Troubleshooting](troubleshooting.md#post-returns-http-400). diff --git a/docs/articles/choosing-version.md b/docs/articles/choosing-version.md new file mode 100644 index 0000000..ab7e3e2 --- /dev/null +++ b/docs/articles/choosing-version.md @@ -0,0 +1,76 @@ +# Choose an HTMX version + +HtmxToolkit generates version-sensitive configuration, request options, request-header access, and compatibility behavior. +Configure the same major HTMX version that the browser loads. + +HTMX 2.x is selected when no version is specified. + +## Select a version + +```csharp +builder.Services.AddHtmxToolkit(options => +{ + options.UseHtmxV1(); +}); +``` + +```csharp +builder.Services.AddHtmxToolkit(options => +{ + options.UseHtmxV2(); +}); +``` + +```csharp +builder.Services.AddHtmxToolkit(options => +{ + options.UseHtmxV4(); +}); +``` + +The version can be selected only once. Repeated configuration for the same version updates the same version-specific object; +trying to select a different version causes startup validation to fail. + +> [!IMPORTANT] +> Selecting a target version does not download or serve HTMX. The ` + + + +``` + +The default Toolkit script URL includes a content hash and is served with a one-year cache lifetime. +When the embedded script changes, its default URL changes too. + +> [!IMPORTANT] +> `` and the Toolkit script work together to add ASP.NET Core antiforgery data to unsafe HTMX requests. Omitting either one disables that automatic behavior. + +## 5. Add an HTMX interaction + +Create `Pages/Products.cshtml`: + +```html +@page +@model ProductsModel + +

Products

+ + + +
+ Select a product. +
+``` + +Because an `hx-page` is present and no method was specified, `HtmxUrlTagHelper` generates an `hx-get` URL +for the Razor Page handler. + +Add the handler to `Pages/Products.cshtml.cs`: + +```csharp +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +public sealed class ProductsModel : PageModel +{ + public IActionResult OnGetDetails(int id) => + Content($"

Product #{id}

In stock.

", "text/html"); +} +``` + +Run the application and select **View product**. In browser developer tools, the request contains HTMX headers +and the returned `
` is inserted into `#product-details`. + +For a real application, prefer a partial view over assembling HTML in a string: + +```csharp +public IActionResult OnGetDetails(int id) => + Partial("_ProductDetails", catalog.Get(id)); +``` + +## Next steps + +- [Full pages and fragments](pages-and-fragments.md) makes one URL work with and without HTMX. +- [Read HTMX requests](requests.md) explains request detection and request headers. +- [Control HTMX responses](responses.md) lets the server change the target, swap, URL, or client behavior. +- [Antiforgery and Toolkit script](antiforgery.md) explains protected POST requests. diff --git a/docs/articles/index.md b/docs/articles/index.md new file mode 100644 index 0000000..ca8fa3d --- /dev/null +++ b/docs/articles/index.md @@ -0,0 +1,53 @@ +
+

ASP.NET Core + HTMX

+

HtmxToolkit guides

+

Build server-rendered interactions with typed requests, fluent responses, Razor Tag Helpers, version-aware configuration, and automatic antiforgery.

+ +
+ +These guides focus on complete application tasks; the API reference documents individual types and members. + +## Start here + + + +## Build an integration + + + +## Configure and operate + + + +> [!TIP] +> If an application already uses HTMX, begin with [Choose an HTMX version](choosing-version.md), then add only the toolkit features it needs. diff --git a/docs/articles/overview.md b/docs/articles/overview.md new file mode 100644 index 0000000..92011d9 --- /dev/null +++ b/docs/articles/overview.md @@ -0,0 +1,62 @@ +# Overview + +Ramstack.HtmxToolkit connects server-rendered ASP.NET Core applications to HTMX. It removes stringly typed header +handling and manual JSON or URL construction while preserving the normal HTMX programming model. + +The package targets .NET 6 and can be used by applications running on .NET 6 or later. It supports HTMX 1.9.x, 2.x, and 4.x. + +## What the toolkit provides + +| Area | Toolkit feature | +|---|---| +| Incoming requests | `IsHtmxRequest`, `IsHtmxBoosted`, and strongly typed request headers | +| Outgoing responses | Fluent `Response.Htmx(...)` API and strongly typed response headers | +| MVC | `[HtmxRequest]` action selection and `[HtmxResponse]` response configuration | +| Razor | Tag Helpers for routes, values, headers, and per-request options | +| Configuration | Version-specific HTMX configuration rendered by `` | +| Security | Automatic ASP.NET Core antiforgery headers for unsafe HTMX requests | +| Assets | A small companion script with a cacheable endpoint or inline rendering | + +HtmxToolkit does not include the HTMX library and does not replace HTMX attributes such as `hx-target`, `hx-trigger`, +or `hx-swap`. Add a supported HTMX release to the application separately. + +## Request lifecycle + +1. Razor renders an element with ordinary HTMX attributes and, optionally, HtmxToolkit Tag Helper attributes. +2. HTMX sends a request with its `HX-*` headers. +3. ASP.NET Core detects the HTMX request and returns a server-rendered fragment. +4. The endpoint can add response instructions such as `HX-Retarget`, `HX-Redirect`, or `HX-Trigger`. +5. HTMX swaps the fragment and applies those response instructions in the browser. + +```html + + +
+``` + +```csharp +public IActionResult OnGetDetails(int id) +{ + var product = catalog.Get(id); + return Partial("_ProductDetails", product); +} +``` + +The Tag Helper generates the handler URL; HTMX performs the request and swap; ASP.NET Core renders the fragment. + +## Choose the integration style + +Use a shared endpoint when the full page and fragment represent the same resource. Check `Request.IsHtmxRequest()` +and select the appropriate result. + +Use a dedicated handler when a fragment is an independent operation, such as validation, polling, or an inline edit. + +Use `[HtmxRequest]` when MVC should choose between actions based on whether the request came from HTMX. +This is an action constraint, so it is intended for controllers rather than Razor Page handlers or Minimal APIs. + +Continue with [Getting started](getting-started.md) for a complete setup. diff --git a/docs/articles/pages-and-fragments.md b/docs/articles/pages-and-fragments.md new file mode 100644 index 0000000..e2c8fb0 --- /dev/null +++ b/docs/articles/pages-and-fragments.md @@ -0,0 +1,127 @@ +# Full pages and fragments + +An HTMX application usually returns a small HTML fragment for an HTMX request and a complete document +for normal browser navigation. Supporting both paths keeps links usable without JavaScript +and gives history restoration a full-page fallback. + +## Use one URL for both representations + +An MVC action can select the result from the request: + +```csharp +public IActionResult Details(int id) +{ + var product = catalog.Get(id); + + if (Request.IsHtmxRequest(out var htmx) && !htmx.HistoryRestoreRequest) + return PartialView("_ProductDetails", product); + + return View(product); +} +``` + +The Razor Page equivalent assigns data for a normal page request and returns a partial for HTMX: + +```csharp +public IActionResult OnGet(int id) +{ + Product = catalog.Get(id); + + if (Request.IsHtmxRequest(out var htmx) && !htmx.HistoryRestoreRequest) + return Partial("_ProductDetails", Product); + + return Page(); +} +``` + +When HTMX marks a request with `HX-History-Restore-Request`, return the complete document it expects to restore. +In HTMX 1.x and 2.x this commonly follows a miss in the local snapshot cache. +HTMX 4.x normally retrieves history entries from the server rather than keeping local DOM snapshots; +the optional `hx-history-cache` extension restores snapshot caching. + +## Use a dedicated fragment endpoint + +A dedicated endpoint is appropriate when the fragment is an operation rather than another representation of a page: + +```csharp +public IActionResult OnGetInventory(int productId) => + Partial("_Inventory", inventory.Get(productId)); +``` + +```html + +``` + +This style works well for validation, polling, autocomplete, dialogs, and inline editing. + +## Avoid rendering a layout into a target + +If a response unexpectedly inserts navigation, ``, or the whole page into a component, +the endpoint returned `View()` or `Page()` instead of a partial. Inspect the response body in browser developer tools +before changing `hx-target` or `hx-swap`. + +Keep the full page and partial view separate: + +```text +Pages/Products/Details.cshtml +Pages/Products/_ProductDetails.cshtml +``` + +The page owns the document layout. The partial owns only the element intended for the target. + +## Preserve progressive enhancement + +Prefer real links and forms when there is a meaningful non-HTMX behavior: + +```html + + @product.Name + +``` + +Without HTMX, the browser follows the `href`. With HTMX, the link is boosted and the server can detect `IsHtmxBoosted()`. + +Buttons that exist only to update a local widget can use generated `hx-*` URLs without a navigation fallback. + +## MVC action selection + +Controllers can expose separate actions for the same route and constrain one to HTMX requests: + +```csharp +[HttpGet("/products/{id:int}")] +public IActionResult Details(int id) => + View(catalog.Get(id)); + +[HttpGet("/products/{id:int}")] +[HtmxRequest] +public IActionResult DetailsFragment(int id) => + PartialView("_ProductDetails", catalog.Get(id)); +``` + +`[HtmxRequest]` participates in MVC action selection. It does not reject requests inside an already selected action +and it does not apply to Razor Page handlers or Minimal API delegates. + +## Minimal APIs + +Minimal APIs can make the same decision and return HTML explicitly: + +```csharp +app.MapGet("/products/{id:int}", (int id, HttpRequest request) => +{ + var product = catalog.Get(id); + var html = render.Product(product, includeLayout: !request.IsHtmxRequest()); + return Results.Content(html, "text/html"); +}); +``` + +HtmxToolkit detects and configures requests and responses, but it does not provide an HTML renderer for Minimal APIs. + +Continue with [Read HTMX requests](requests.md) and [Control HTMX responses](responses.md). diff --git a/docs/articles/recipes.md b/docs/articles/recipes.md new file mode 100644 index 0000000..c5052b7 --- /dev/null +++ b/docs/articles/recipes.md @@ -0,0 +1,283 @@ +# Common recipes + +These recipes combine ordinary HTMX behavior with HtmxToolkit's routing, request, response, and security APIs. +Adapt partial names and persistence code to the application. + +## Return validation errors to another target + +Post a form whose normal target is the saved profile: + +```html +
+
+ + +
+ +
+``` + +Retarget only the invalid response: + +```csharp +public IActionResult OnPostSave() +{ + if (!ModelState.IsValid) + { + Response.Htmx(htmx => htmx + .Retarget("#validation-errors") + .Reswap(HtmxSwap.InnerHtml)); + + return Partial("_ValidationSummary", ModelState); + } + + profiles.Save(Input); + return Partial("_Profile", Input); +} +``` + +Automatic antiforgery requires the layout setup from [Antiforgery and Toolkit script](antiforgery.md). + +## Refresh another component after saving + +The server can dispatch a named event without custom JavaScript: + +```csharp +public IActionResult OnPostSave(ProfileInput input) +{ + profiles.Save(input); + + Response.Htmx(htmx => htmx.TriggerEvent( + "profile-saved", + new { input.Id }, + HtmxTriggerTiming.AfterSwap)); + + return Partial("_SaveResult", input); +} +``` + +Another element listens for the event and reloads itself: + +```html + +``` + +This keeps the server response in control while allowing independently targeted components to stay synchronized. + +## Poll a background operation + +Return markup that contains the next poll while work is incomplete: + +```html +@model ProgressState + +@if (Model.Completed) +{ +
Complete
+} +else +{ +
+ @Model.Percent% +
+} +``` + +```csharp +public IActionResult OnGetProgress(int progress) +{ + var next = Math.Clamp(progress + 10, 0, 100); + return Partial("_Progress", new ProgressState(next)); +} +``` + +Polling stops naturally because completed markup no longer contains `hx-trigger="every ..."`. + +## Handle boosted navigation progressively + +Start with a real link so navigation works without JavaScript: + +```html + + Books + +``` + +Return a fragment for the boosted request and a page otherwise: + +```csharp +public IActionResult OnGet(string category) +{ + Products = catalog.List(category); + + if (Request.IsHtmxBoosted()) + return Partial("_Catalog", Products); + + return Page(); +} +``` + +If the same URL participates in history restoration, use the fuller check from [Full pages and fragments](pages-and-fragments.md). + +## Redirect after authentication + +HTMX does not automatically turn an ordinary server redirect into a full browser navigation in every workflow. +Send `HX-Redirect` for the HTMX path and preserve a normal redirect fallback: + +```csharp +public IActionResult SignIn(LoginInput input) +{ + if (!auth.TrySignIn(input)) + return Unauthorized(); + + if (Request.IsHtmxRequest()) + { + Response.Htmx(htmx => htmx.Redirect("/dashboard")); + return Ok(); + } + + return Redirect("/dashboard"); +} +``` + +The HTMX response uses status 200 so HTMX can process `HX-Redirect`. +A 3xx response would be followed by the browser internally, hiding the intermediate `HX-Redirect` header from HTMX. +The normal request still uses the conventional ASP.NET Core redirect. + +## Load more items + +Append a fragment and let the returned markup contain the next cursor: + +```html + +``` + +The returned partial should contain only new rows. Replace or remove the button separately, +for example with an out-of-band element, when there is no next page. + +## Search while typing + +Use an input event with a delay so the server receives a request after the user pauses: + +```html + + + +
+``` + +```csharp +public IActionResult OnGetSearch(string? query) +{ + IReadOnlyList matches = string.IsNullOrWhiteSpace(query) + ? [] + : catalog.Search(query); + + return Partial("_SearchResults", matches); +} +``` + +The URL and timeout are generated by HtmxToolkit; the trigger and request synchronization behavior belong to HTMX. +For expensive searches, add cancellation and query limits on the server even when the client uses a delay. + +## Edit a table row inline + +Load an edit partial into the row itself: + +```html + + @product.Name + @product.Price + + + + +``` + +The edit partial posts back to another handler with the same target: + +```html +@model ProductInput + + + +
+ + + + +
+ + +``` + +On invalid input, return the edit partial with validation messages. On success, return the display-row partial. +Keep authorization and concurrency checks in both handlers; route values and hidden inputs are client-controlled. + +## Update related elements out of band + +An endpoint can return its normal target plus another element marked for an out-of-band swap: + +```html +@model AddToCartResult + +
+ Added to cart. +
+ + + @Model.CartCount + +``` + +This is useful when one operation changes a row and a page-level count. For HTMX 2.x, `AllowNestedOobSwaps` controls +whether nested out-of-band elements are processed. HTMX 4.x also has `AllowEmptySwapAfterOob` for responses +that contain only out-of-band content. + +## Return an error without replacing content + +Choose the behavior at the HTMX configuration level when it should apply to the whole application. +HTMX 2.x uses `ResponseHandling`; HTMX 4.x uses `NoSwap`. For an endpoint-specific validation response, `Retarget` +and `Reswap` usually make the intent clearer. + +See the [HTMX 2.x](configuration-v2.md) and [HTMX 4.x](configuration-v4.md) configuration guides. diff --git a/docs/articles/requests.md b/docs/articles/requests.md new file mode 100644 index 0000000..34d9663 --- /dev/null +++ b/docs/articles/requests.md @@ -0,0 +1,122 @@ +# HTMX requests + +Use `HttpRequest` extensions to distinguish HTMX requests from regular navigation and to read HTMX request metadata +without comparing header strings. + +## Detect a request + +```csharp +public IActionResult Details(int id) +{ + var product = catalog.Get(id); + + if (Request.IsHtmxRequest()) + return PartialView("_ProductDetails", product); + + return View(product); +} +``` + +The overload with `out HtmxRequestHeaders` combines detection and strongly typed header access: + +```csharp +if (Request.IsHtmxRequest(out var htmx)) +{ + logger.LogDebug( + "HTMX request for {Target} from {Source}", + htmx.Target, + htmx.Source ?? htmx.Trigger); +} +``` + +Call `GetHtmxHeaders()` when request detection and header access happen in separate code: + +```csharp +var htmx = Request.GetHtmxHeaders(); +``` + +## Request header properties + +| Property | Meaning | +|---|---| +| `Request` | Whether `HX-Request` is `true` | +| `Boosted` | Whether a regular navigation was enhanced by `hx-boost` | +| `CurrentUrl` | Current browser URL when the request was sent | +| `HistoryRestoreRequest` | Whether HTMX marked the request for history restoration | +| `Target` | Target element; its format differs in HTMX 4 | +| `Prompt` | Result of `hx-prompt` in HTMX 1.x and 2.x | +| `Trigger`, `TriggerName` | Triggering element ID and name in HTMX 1.x and 2.x | +| `Source` | Triggering `tag#id` in HTMX 4.x | +| `RequestType` | `partial` or `full` in HTMX 4.x | + +Missing or version-inapplicable string headers return `null`. +Boolean properties return `false` when the header is absent or is not exactly `true`. + +See [Version compatibility](version-compatibility.md) before using version-specific metadata in shared code. + +## Handle history restoration + +When `HX-History-Restore-Request` is present, return the full document rather than a target fragment: + +```csharp +if (Request.IsHtmxRequest(out var htmx) && !htmx.HistoryRestoreRequest) + return PartialView("_ProductDetails", product); + +return View(product); +``` + +This prevents a fragment from being treated as the document during history recovery. +HTMX 1.x and 2.x use local snapshots and can send this request after a cache miss. +HTMX 4.x normally requests history URLs from the server and does not keep local DOM snapshots +unless its optional history-cache extension is enabled. + +## Detect boosted navigation + +`IsHtmxBoosted()` identifies requests originating from boosted links or forms: + +```csharp +public IActionResult Navigation() +{ + if (Request.IsHtmxBoosted()) + return PartialView("_Navigation"); + + return RedirectToAction("Index"); +} +``` + +An overload also returns `HtmxRequestHeaders` when both the classification and metadata are needed. + +## Select MVC actions declaratively + +Apply `[HtmxRequest]` to reserve a controller action for HTMX requests: + +```csharp +[HttpGet("/inventory/{sku}")] +[HtmxRequest] +public IActionResult InventoryFragment(string sku) => + PartialView("_Inventory", inventory.Find(sku)); +``` + +Set `Boosted` to accept only boosted or non-boosted requests: + +```csharp +[HtmxRequest(Boosted = true)] +public IActionResult BoostedNavigation() => + PartialView("_Navigation"); + +[HtmxRequest(Boosted = false)] +public IActionResult ComponentRequest() => + PartialView("_Component"); +``` + +`Boosted = null`, the default, accepts either kind of HTMX request. If no action satisfies all routing +and action constraints, MVC reports that no endpoint matched; the attribute does not automatically fall back +to a different URL. + +## Use raw header names only when required + +`HtmxRequestHeaderNames` contains constants for integration with middleware or APIs that require a header name. +Application endpoints should normally prefer `HtmxRequestHeaders` so version differences remain visible +in the type's documentation. + +Next, see [Full pages and fragments](pages-and-fragments.md) or [Control HTMX responses](responses.md). diff --git a/docs/articles/responses.md b/docs/articles/responses.md new file mode 100644 index 0000000..5fa4d5e --- /dev/null +++ b/docs/articles/responses.md @@ -0,0 +1,181 @@ +# HTMX responses + +HTMX response headers let the server change the target, swap strategy, browser URL, navigation, +or client-side events for a particular response. + +## Configure a response fluently + +```csharp +Response.Htmx(response => response + .Retarget("#content") + .Reswap(HtmxSwap.InnerHtml) + .TriggerEvent("content-updated")); + +return View(); +``` + +The callback runs only for an HTMX request. This makes it safe for an action that also handles normal navigation: + +```csharp +if (Request.IsHtmxRequest()) +{ + Response.Htmx(htmx => htmx.Redirect("/dashboard")); + return Ok(); +} + +return Redirect("/dashboard"); +``` + +An ordinary browser request receives the normal ASP.NET Core redirect. +An HTMX request receives a 200 response with `HX-Redirect`. + +> [!IMPORTANT] +> Do not return an ASP.NET Core 3xx redirect with an `HX-*` response header. The browser follows the redirect internally, +> so HTMX receives the final response and cannot process headers from the intermediate 3xx response. +> Return a 2xx response such as `Ok()` for the HTMX path. + +The API is available from any `HttpResponse`, including Minimal API handlers: + +```csharp +app.MapPost("/account/sign-in", (HttpResponse response) => +{ + response.Htmx(htmx => htmx.Redirect("/dashboard")); + return Results.Ok(); +}); +``` + +## Choose a response instruction + +| Goal | API | Header | +|---|---|---| +| Request another URL through HTMX | `Location` | `HX-Location` | +| Navigate with a full page load | `Redirect` | `HX-Redirect` | +| Reload the current page | `Refresh` | `HX-Refresh` | +| Add a browser history entry | `PushUrl` | `HX-Push-Url` | +| Replace the current history entry | `ReplaceUrl` | `HX-Replace-Url` | +| Change the swap target | `Retarget` | `HX-Retarget` | +| Change the swap strategy | `Reswap` | `HX-Reswap` | +| Select part of the response | `Reselect` | `HX-Reselect` | +| Dispatch client events | `TriggerEvent(s)` | `HX-Trigger*` | + +### Location or redirect + +Use `Location` to issue another HTMX request and swap its response without a full-page reload. +Supply `HtmxLocationOptions` when the follow-up request needs a target, swap, selection, +values, headers, or history behavior: + +```csharp +Response.Htmx(htmx => htmx.Location( + $"/products/{product.Id}", + new HtmxLocationOptions + { + Source = "#save-product", + Target = "#product-details", + Swap = HtmxSwap.OuterHtml, + Select = "#product-details", + Push = $"/products/{product.Id}" + })); +``` + +Use `Redirect` when the browser must perform a normal navigation, for example after authentication +or when the next page depends on a full document load. The response carrying `HX-Redirect` +must use a non-redirect status such as 200. + +### History updates + +```csharp +Response.Htmx(htmx => htmx.PushUrl($"/products/{product.Id}")); +``` + +`PushUrl` adds a history entry; `ReplaceUrl` changes the current one. `PreventPushUrl()` +and `PreventReplaceUrl()` emit `false` to suppress a client history update requested elsewhere. + +### Targets, selection, and swaps + +The server can send validation errors to a different target than the successful response: + +```csharp +if (!ModelState.IsValid) +{ + Response.Htmx(htmx => htmx + .Retarget("#validation-errors") + .Reswap(HtmxSwap.InnerHtml)); + + return PartialView("_ValidationSummary", ModelState); +} +``` + +Use the typed `HtmxSwap` overload for a swap style. Use the string overload for a full expression containing modifiers: + +```csharp +Response.Htmx(htmx => htmx.Reswap("innerHTML show:#result:top")); +``` + +`Reselect("#result")` selects only the matching part of a larger response before it is swapped. + +## Trigger client events + +Dispatch an event after saving so another element can refresh itself: + +```csharp +Response.Htmx(htmx => htmx.TriggerEvent( + "product-saved", + new { product.Id }, + HtmxTriggerTiming.AfterSwap)); +``` + +```html + +``` + +Use `TriggerEvents` to add several event/detail pairs. Multiple Toolkit calls for the same response accumulate pending events. + +HTMX 1.x and 2.x distinguish receive, after-swap, and after-settle response headers. +HTMX 4.x delivers these Toolkit events through `HX-Trigger` when the request completes, after the swap when one occurs. +See [Version compatibility](version-compatibility.md). + +## Configure declaratively in MVC + +`HtmxResponseAttribute` is convenient when response behavior is constant: + +```csharp +[HtmxRequest] +[HtmxResponse( + Retarget = "#inventory-status", + Reswap = HtmxSwap.OuterHtml)] +public IActionResult Inventory(string sku) => + PartialView("_Inventory", inventory.Find(sku)); +``` + +The attribute supports `Refresh`, `Reswap`, `ReswapExpression`, `Retarget`, and `Reselect`. +Use the fluent API when values depend on runtime state or when triggering events and navigation. + +## Access headers directly + +Use `Response.GetHtmxHeaders()` when middleware or shared infrastructure needs property access instead of fluent chaining: + +```csharp +var htmx = Response.GetHtmxHeaders(); +htmx.Retarget = "#notice"; +htmx.Reswap = HtmxSwap.OuterHtml; +``` + +Unlike `Response.Htmx(...)`, direct header access is not conditional: the caller decides whether the headers should be written. + +`HtmxResponseHeaderNames` exposes header-name constants for lower-level integrations. + +## Avoid closures in a hot path + +The generic overload passes state explicitly: + +```csharp +Response.Htmx( + static (htmx, id) => htmx.TriggerEvent("product-saved", new { id }), + product.Id); +``` + +Use it when allocation measurements justify the extra syntax; the ordinary overload is clearer for most endpoints. + +See [Common recipes](recipes.md) for validation, redirects, events, and polling in context. diff --git a/docs/articles/tag-helpers.md b/docs/articles/tag-helpers.md new file mode 100644 index 0000000..57d5489 --- /dev/null +++ b/docs/articles/tag-helpers.md @@ -0,0 +1,220 @@ +# Tag Helpers + +HtmxToolkit Tag Helpers generate URLs and JSON attributes through ASP.NET Core routing and Razor encoding. +They complement ordinary HTMX attributes such as `hx-target`, `hx-trigger`, and `hx-swap`. + +Register the namespace and Tag Helpers in `_ViewImports.cshtml`: + +```html +@using Ramstack.HtmxToolkit +@addTagHelper *, Ramstack.HtmxToolkit +``` + +## Generate request URLs + +### Razor Pages + +```html + +``` + +This generates `hx-get` because no HTMX method attribute was supplied. +Add an empty `hx-post`, `hx-put`, `hx-patch`, or `hx-delete` to select another method: + +```html + +``` + +### MVC controllers + +```html + +``` + +### Named routes and route dictionaries + +```html + + Product details + +``` + +Use `hx-all-route-data` for a dictionary and `hx-route-*` for individual values. + +> [!NOTE] +> `hx-fragment` is an input of `HtmxUrlTagHelper`, analogous to ASP.NET Core's `asp-fragment`. +> It adds `#reviews` to the generated URL; it is not an HTMX client attribute. + +`hx-route`, controller/action, and page/handler identify mutually exclusive routing modes. +Supplying more than one mode throws an `InvalidOperationException`. +Supplying more than one HTMX method is also invalid. + +## Send additional values + +Use `hx-val-*` when fixed values should be included as request parameters: + +```html + +``` + +The Tag Helper emits an encoded `hx-vals` JSON object. A dictionary can be supplied through `hx-all-vals`: + +```html + +``` + +HTMX 1.x and 2.x inherit the generated `hx-vals` from parent elements automatically. +For HTMX 4.x, add `hx-vals-inherited="true"` to the Razor element to generate the explicit inheritance modifier: + +```razor +
+ ... +
+``` + +With the default HTMX 4 meta character, this produces `hx-vals:inherited='{"tenant":"..."}'`. + +HTMX 1.x and 2.x merge inherited values: a child value overrides a value with the same name +while other inherited values remain. HTMX 4.x behaves differently — a plain child `hx-vals` replaces +the inherited object entirely. Merging the child object into the inherited values +requires the HTMX 4 `:append` modifier, which HtmxToolkit does not currently generate. + +## Send custom headers + +```html +
+ +
+``` + +`hx-header-*` and `hx-all-headers` generate `hx-headers` JSON. Header names are compared without case. +HTMX 1.x and 2.x inherit the generated attribute automatically. +For HTMX 4.x, use the Toolkit input `hx-headers-inherited="true"` on a parent element: + +```razor +
+ ... +
+``` + +This generates `hx-headers:inherited` when the default HTMX 4 meta character is used. + +Do not use custom client headers as proof of identity or authorization; clients can modify them. +Authenticate and authorize on the server. + +## Configure one request + +Use `hx-request-*` attributes rather than hand-writing version-specific JSON: + +```html + +``` + +The output depends on the configured HTMX target: + +```html + + diff --git a/docs/snippets/tag-helpers/MapToolkitScriptEndpoint.cs b/docs/snippets/tag-helpers/MapToolkitScriptEndpoint.cs new file mode 100644 index 0000000..59c452c --- /dev/null +++ b/docs/snippets/tag-helpers/MapToolkitScriptEndpoint.cs @@ -0,0 +1,6 @@ +using Ramstack.HtmxToolkit.Hosting; + +var app = builder.Build(); + +app.MapHtmxToolkitScript(); +app.MapRazorPages(); diff --git a/docs/snippets/tag-helpers/RequestTagHelper.cshtml b/docs/snippets/tag-helpers/RequestTagHelper.cshtml new file mode 100644 index 0000000..98d84b8 --- /dev/null +++ b/docs/snippets/tag-helpers/RequestTagHelper.cshtml @@ -0,0 +1,9 @@ +@addTagHelper *, Ramstack.HtmxToolkit + + diff --git a/docs/snippets/tag-helpers/ToolkitScript.cshtml b/docs/snippets/tag-helpers/ToolkitScript.cshtml new file mode 100644 index 0000000..bfcb1f3 --- /dev/null +++ b/docs/snippets/tag-helpers/ToolkitScript.cshtml @@ -0,0 +1,3 @@ +@using Ramstack.HtmxToolkit + + diff --git a/docs/snippets/tag-helpers/UrlTagHelper.cshtml b/docs/snippets/tag-helpers/UrlTagHelper.cshtml new file mode 100644 index 0000000..3dddac0 --- /dev/null +++ b/docs/snippets/tag-helpers/UrlTagHelper.cshtml @@ -0,0 +1,7 @@ +@addTagHelper *, Ramstack.HtmxToolkit + + diff --git a/docs/snippets/tag-helpers/ValsTagHelper.cshtml b/docs/snippets/tag-helpers/ValsTagHelper.cshtml new file mode 100644 index 0000000..40c6aed --- /dev/null +++ b/docs/snippets/tag-helpers/ValsTagHelper.cshtml @@ -0,0 +1,10 @@ +@addTagHelper *, Ramstack.HtmxToolkit + + diff --git a/docs/template/ManagedReference.extension.js b/docs/template/ManagedReference.extension.js new file mode 100644 index 0000000..1415ed2 --- /dev/null +++ b/docs/template/ManagedReference.extension.js @@ -0,0 +1,24 @@ +// The modern template renders enum-field remarks immediately after their +// summaries without a distinct container. Wrap them so they can be styled +// consistently with remarks on types and other members. +exports.preTransform = function (model) { + if (model.type?.toLowerCase() !== "enum") { + return model; + } + + for (const child of model.children ?? []) { + if (child.remarks) { + child.remarks = ` + `; + } + } + + return model; +}; + +exports.postTransform = function (model) { + return model; +}; diff --git a/docs/template/public/main.css b/docs/template/public/main.css new file mode 100644 index 0000000..9b8ca8b --- /dev/null +++ b/docs/template/public/main.css @@ -0,0 +1,137 @@ +#logo { + height: 30px; +} + +.navbar .navbar-brand { + gap: 0.5rem; +} + +.markdown.remarks, +.api-remarks { + margin-block: 0.75rem 1.25rem; + padding: 0.875rem 1rem; + border: 1px solid var(--bs-border-color); + border-inline-start: 0.25rem solid var(--bs-info); + border-radius: var(--bs-border-radius); + background: var(--bs-tertiary-bg); +} + +.markdown.remarks > :last-child, +.api-remarks > :last-child { + margin-bottom: 0; +} + +.api-remarks__title { + margin-bottom: 0.375rem; + color: var(--bs-info-text-emphasis); + font-weight: 600; +} + +.docs-hero { + margin-block: 0.5rem 2rem; + padding: clamp(1.5rem, 4vw, 3rem); + overflow: hidden; + border: 1px solid var(--bs-border-color); + border-radius: 1rem; + background: + radial-gradient(circle at 90% 10%, rgba(13, 110, 253, 0.18), transparent 35%), + var(--bs-tertiary-bg); +} + +.docs-hero h1 { + margin-block: 0.15rem 0.75rem; + font-size: clamp(2rem, 5vw, 3.25rem); +} + +.docs-hero > p:not(.docs-eyebrow) { + max-width: 48rem; + margin-bottom: 1.25rem; + color: var(--bs-secondary-color); + font-size: 1.1rem; +} + +.docs-eyebrow, +.guide-card__label { + color: var(--bs-primary); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.docs-hero__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.docs-button { + padding: 0.6rem 1rem; + border: 1px solid var(--bs-primary); + border-radius: 0.5rem; + background: var(--bs-primary); + color: white; + font-weight: 600; + text-decoration: none; +} + +.docs-button:hover { + color: white; + filter: brightness(0.92); +} + +.docs-button--secondary { + background: transparent; + color: var(--bs-primary); +} + +.docs-button--secondary:hover { + color: var(--bs-primary); +} + +.guide-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-block: 1rem 2rem; +} + +.guide-grid--two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.guide-card { + display: flex; + min-height: 8rem; + flex-direction: column; + gap: 0.45rem; + padding: 1.1rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: var(--bs-body-bg); + color: var(--bs-body-color); + text-decoration: none; + transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease; +} + +.guide-card strong { + font-size: 1.05rem; +} + +.guide-card span:last-child { + color: var(--bs-secondary-color); +} + +.guide-card:hover { + border-color: var(--bs-primary); + color: var(--bs-body-color); + transform: translateY(-2px); + box-shadow: 0 0.5rem 1.25rem rgba(0, 0, 0, 0.08); +} + +@media (max-width: 767.98px) { + .guide-grid, + .guide-grid--two { + grid-template-columns: 1fr; + } +} diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..88032de --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,4 @@ +- name: Guides + href: articles/ +- name: API Reference + href: api/ diff --git a/src/Ramstack.HtmxToolkit/Configuration/HtmxHistoryMode.cs b/src/Ramstack.HtmxToolkit/Configuration/HtmxHistoryMode.cs index 4e26a2a..1e42c7c 100644 --- a/src/Ramstack.HtmxToolkit/Configuration/HtmxHistoryMode.cs +++ b/src/Ramstack.HtmxToolkit/Configuration/HtmxHistoryMode.cs @@ -6,8 +6,12 @@ namespace Ramstack.HtmxToolkit.Configuration; public enum HtmxHistoryMode { /// - /// Enables history snapshots and restoration. + /// Enables HTMX history handling. /// + /// + /// In HTMX 4.x, history navigation requests the URL from the server and swaps + /// the response. Local DOM snapshots require the optional HTMX history-cache extension. + /// Enabled, /// From 9d1a38f993ca58580861720cb81128737947651b Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 6 Sep 2026 21:42:26 +0500 Subject: [PATCH 2/2] Update documentation for HTMX 4 attribute modifiers --- docs/articles/configuration-v4.md | 35 +++++++++++++++++------ docs/articles/tag-helpers.md | 39 ++++++++++++++++++-------- docs/articles/version-compatibility.md | 15 +++++++--- 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/docs/articles/configuration-v4.md b/docs/articles/configuration-v4.md index 8f50eab..6ea1039 100644 --- a/docs/articles/configuration-v4.md +++ b/docs/articles/configuration-v4.md @@ -24,7 +24,7 @@ builder.Services.AddHtmxToolkit(options => | Purpose | Properties | |---|---| -| Diagnostics and syntax | `LogAll`, `Prefix`, `MetaCharacter` | +| Diagnostics and attribute prefix | `LogAll`, `Prefix` | | History | `History` | | Swaps and morphing | `DefaultSwap`, `AllowEmptySwapAfterOob`, `DefaultSettleDelay`, `MorphIgnore`, `MorphSkip`, `MorphSkipChildren`, `MorphScanLimit` | | Indicators and lifecycle CSS | `IncludeIndicatorCss`, `IndicatorClass`, `RequestClass` | @@ -62,24 +62,40 @@ Unlike HTMX 1.x and 2.x, HTMX 4.x does not keep local DOM snapshots in `sessionS The optional HTMX 4 `hx-history-cache` extension restores local snapshot behavior; HtmxToolkit does not enable that extension automatically. -## Attribute inheritance +## Attribute modifiers -HTMX 4.x requires explicit attribute inheritance by default. HtmxToolkit provides Razor-friendly boolean inputs that generate the HTMX 4 modifier: +HTMX 4.x requires explicit attribute inheritance by default and replaces an inherited object when a child +declares the same attribute. HtmxToolkit provides Razor-friendly boolean inputs for the `inherited` and +`append` modifiers: ```razor
- ... +
``` -With the default `MetaCharacter`, these become `hx-config:inherited` and `hx-vals:inherited`. -`hx-headers-inherited="true"` provides the same behavior for generated request headers. +The parent inputs become `hx-config:inherited` and `hx-vals:inherited`. The button emits +`hx-vals:append`, which merges its values into the inherited object instead of replacing it. +The same inputs are available for all three generated JSON attributes: -When a custom `MetaCharacter` is configured, the generated attribute uses that character instead of `:`. -For example, `MetaCharacter = "-"` produces `hx-vals-inherited` in the output HTML. +| Razor Tag Helper input | Generated HTMX 4 attribute | +|---|---| +| `hx-request-inherited="true"` | `hx-config:inherited` | +| `hx-request-append="true"` | `hx-config:append` | +| `hx-vals-inherited="true"` | `hx-vals:inherited` | +| `hx-vals-append="true"` | `hx-vals:append` | +| `hx-headers-inherited="true"` | `hx-headers:inherited` | +| `hx-headers-append="true"` | `hx-headers:append` | + +Setting both inputs for one attribute emits a combined modifier, for example +`hx-headers:inherited:append`. Alternatively, enable the HTMX 1.x/2.x inheritance behavior globally: @@ -88,7 +104,8 @@ config.ImplicitInheritance = true; ``` Leave it unset or `false` when inheritance is selected per element with `hx-request-inherited`, `hx-vals-inherited`, -or `hx-headers-inherited`. +or `hx-headers-inherited`. Global inheritance removes the need for those `*-inherited` inputs, but child +declarations can still use `*-append` to merge with inherited objects. ## Prevent swaps for status codes diff --git a/docs/articles/tag-helpers.md b/docs/articles/tag-helpers.md index 57d5489..90a1512 100644 --- a/docs/articles/tag-helpers.md +++ b/docs/articles/tag-helpers.md @@ -102,12 +102,24 @@ For HTMX 4.x, add `hx-vals-inherited="true"` to the Razor element to generate th ``` -With the default HTMX 4 meta character, this produces `hx-vals:inherited='{"tenant":"..."}'`. +This produces `hx-vals:inherited='{"tenant":"..."}'`. HTMX 1.x and 2.x merge inherited values: a child value overrides a value with the same name -while other inherited values remain. HTMX 4.x behaves differently — a plain child `hx-vals` replaces -the inherited object entirely. Merging the child object into the inherited values -requires the HTMX 4 `:append` modifier, which HtmxToolkit does not currently generate. +while other inherited values remain. In HTMX 4.x, a plain child `hx-vals` replaces the inherited +object entirely. Add `hx-vals-append="true"` to merge the child object instead: + +```razor +
+ +
+``` + +The button emits `hx-vals:append='{"format":"summary"}'`. ## Send custom headers @@ -128,7 +140,8 @@ For HTMX 4.x, use the Toolkit input `hx-headers-inherited="true"` on a parent el ``` -This generates `hx-headers:inherited` when the default HTMX 4 meta character is used. +This generates `hx-headers:inherited`. +Use `hx-headers-append="true"` on a child declaration to merge its headers into the inherited object. Do not use custom client headers as proof of identity or authorization; clients can modify them. Authenticate and authorize on the server. @@ -171,7 +184,7 @@ The output depends on the configured HTMX target: Unsupported properties are omitted from the generated JSON. In particular, `HtmxRequestCredentials.Omit` cannot be represented by HTMX 1.x or 2.x and is omitted for those targets. -## Inherit generated attributes +## Apply attribute modifiers HTMX 4.x requires attribute inheritance to be explicit by default. Razor cannot use the colon-form HTMX modifier as a bound Tag Helper input, @@ -180,8 +193,11 @@ so HtmxToolkit provides hyphenated boolean inputs and generates the correct clie | Razor Tag Helper input | Generated HTMX 4 attribute | |---|---| | `hx-request-inherited="true"` | `hx-config:inherited` | +| `hx-request-append="true"` | `hx-config:append` | | `hx-vals-inherited="true"` | `hx-vals:inherited` | +| `hx-vals-append="true"` | `hx-vals:append` | | `hx-headers-inherited="true"` | `hx-headers:inherited` | +| `hx-headers-append="true"` | `hx-headers:append` | For example, inherit per-request timeout configuration from a parent: @@ -192,15 +208,16 @@ For example, inherit per-request timeout configuration from a parent: ``` +Use `inherited` on a parent declaration to make it available to descendants, and `append` on a child +declaration to merge rather than replace the inherited object. When both inputs are `true` on the same +element, HtmxToolkit emits one combined attribute such as `hx-config:inherited:append`. + For HTMX 1.x and 2.x, these boolean inputs do not change the generated names because `hx-request`, `hx-vals`, and `hx-headers` are already merge-inherited automatically. -If `HtmxV4Config.MetaCharacter` is configured, HtmxToolkit uses that character instead of `:` in the generated attribute name. Alternatively, set `HtmxV4Config.ImplicitInheritance` to `true` to enable inheritance globally; -the `*-inherited` inputs are then unnecessary. - -HtmxToolkit does not currently expose an `*-append` Tag Helper input. -The table above documents only modifiers the Toolkit can generate. +the `*-inherited` inputs are then unnecessary. Child declarations can still use the corresponding +`*-append` input when they need to merge with inherited values. ## Render global configuration diff --git a/docs/articles/version-compatibility.md b/docs/articles/version-compatibility.md index dddb2dd..8b3206f 100644 --- a/docs/articles/version-compatibility.md +++ b/docs/articles/version-compatibility.md @@ -38,19 +38,26 @@ if (Request.IsHtmxRequest(out var htmx)) | `hx-request-no-headers` | Supported | Removed | | Cache, redirect, referrer, integrity, validation | Not emitted | Supported | -## Explicit attribute inheritance +## Attribute modifiers HTMX 1.x and 2.x merge-inherit request configuration, values, and headers automatically. -For HTMX 4.x, HtmxToolkit maps Razor-friendly inputs to the explicit modifier required by the client: +For HTMX 4.x, HtmxToolkit maps Razor-friendly inputs to the explicit inheritance and append modifiers: | Razor input | HTMX 1.x / 2.x output | HTMX 4.x output | |---|---|---| | `hx-request-inherited="true"` | `hx-request` | `hx-config:inherited` | +| `hx-request-append="true"` | `hx-request` | `hx-config:append` | | `hx-vals-inherited="true"` | `hx-vals` | `hx-vals:inherited` | +| `hx-vals-append="true"` | `hx-vals` | `hx-vals:append` | | `hx-headers-inherited="true"` | `hx-headers` | `hx-headers:inherited` | +| `hx-headers-append="true"` | `hx-headers` | `hx-headers:append` | -The V4 output uses `HtmxV4Config.MetaCharacter` instead of `:` when a custom meta character is configured. -`HtmxV4Config.ImplicitInheritance = true` is the global alternative. +In V4, `append` merges a child declaration into the inherited object rather than replacing it. +Setting both inputs for the same attribute produces one combined name such as +`hx-vals:inherited:append`. In V1 and V2, both inputs leave the ordinary merge-inherited attribute name unchanged. + +`HtmxV4Config.ImplicitInheritance = true` is the global alternative to per-element `*-inherited` inputs. +It does not replace `*-append` when a child declaration must merge with inherited values. ## Global configuration concepts