diff --git a/README.md b/README.md index fc70e19..1cd4ea6 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Make the Tag Helpers and toolkit types available to Razor views in `_ViewImports ```html @using Ramstack.HtmxToolkit +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, Ramstack.HtmxToolkit ``` @@ -67,21 +68,27 @@ Render the configuration metadata in the document ``: ``` -Map the companion script endpoint in `Program.cs`: +On ASP.NET Core 9 or later, enable static assets and associate them with the endpoints that render views in `Program.cs`: ```csharp -app.MapHtmxToolkitScript(); +app.MapStaticAssets(); +app.MapRazorPages().WithStaticAssets(); ``` -Load HTMX first, then the toolkit script in the layout: +For MVC, apply `.WithStaticAssets()` to the controller endpoint builder instead; a hybrid Razor Pages and MVC +application applies it to each endpoint set. On ASP.NET Core 6–8, enable static files: + +```csharp +app.UseStaticFiles(); +``` + +The NuGet package includes the toolkit script as a static web asset. Load it after HTMX in the layout: ```html - + ``` -The default script URL contains a content hash, so the script can be cached indefinitely. When the script changes, its URL changes automatically. - You can now generate an HTMX URL from ASP.NET Core route information: ```html @@ -444,20 +451,15 @@ builder.Services.AddHtmxToolkit(options => }); ``` -Instead of mapping an endpoint, the companion script can be embedded directly: +Use the readable script during development with: ```html - + ``` -Pass `debug: true` to `HtmxToolkitScript` or `HtmxToolkitScriptPath` to use the readable script during development. -A custom endpoint path is also supported: +Load HTMX before the toolkit; if deferring execution, apply `defer` to both scripts. -```csharp -app.MapHtmxToolkitScript("/assets/htmx-toolkit.js"); -``` +See [Antiforgery and Toolkit script](docs/articles/antiforgery.md) for static asset and caching details. ## Compatibility Notes @@ -506,9 +508,7 @@ Idiomorph library before the first morph swap: - + ``` diff --git a/docs/api-overwrites/tag-helpers.md b/docs/api-overwrites/tag-helpers.md index 45d38bd..eaf34eb 100644 --- a/docs/api-overwrites/tag-helpers.md +++ b/docs/api-overwrites/tag-helpers.md @@ -32,17 +32,3 @@ 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/articles/antiforgery.md b/docs/articles/antiforgery.md index 0ad69cf..e77b89c 100644 --- a/docs/articles/antiforgery.md +++ b/docs/articles/antiforgery.md @@ -16,17 +16,45 @@ Otherwise, it is added to the request parameters under the configured form-field ## Configure the layout -Map the script endpoint in `Program.cs`: +Enable static files in `Program.cs`: ```csharp -using Ramstack.HtmxToolkit.Hosting; - var app = builder.Build(); -app.MapHtmxToolkitScript(); +app.UseStaticFiles(); app.MapRazorPages(); ``` +On ASP.NET Core 9 or later, use `MapStaticAssets()` and associate the asset collection with the page endpoints +instead to enable build-time compression and fingerprinted URLs: + +```csharp +app.MapStaticAssets(); +app.MapRazorPages().WithStaticAssets(); +``` + +For MVC, apply `.WithStaticAssets()` to the controller endpoint builder, for example: + +```csharp +app.MapStaticAssets(); +app.MapDefaultControllerRoute().WithStaticAssets(); +``` + +A hybrid Razor Pages and MVC application applies it to each endpoint set that renders views: + +```csharp +app.MapStaticAssets(); +app.MapRazorPages().WithStaticAssets(); +app.MapControllers().WithStaticAssets(); +``` + +`MapControllers()` covers attribute-routed controllers; with conventional or area routing, apply +`.WithStaticAssets()` to each `MapControllerRoute` or `MapAreaControllerRoute` call. + +> [!NOTE] +> ASP.NET Core reads the asset collection from the current endpoint's metadata. An endpoint without it still +> renders a working URL: the resolver falls back to a `?v=...` version instead of a fingerprinted URL. + Render configuration metadata in ``, then load HTMX before the Toolkit script: ```html @@ -37,7 +65,7 @@ Render configuration metadata in ``, then load HTMX before the Toolkit scr @RenderBody() - + ``` @@ -79,42 +107,47 @@ No token input is required in this form because the layout metadata and Toolkit 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 +## Static web assets and caching -The default endpoint path contains a content hash: +The NuGet package includes both script variants as ASP.NET Core static web assets: ```text -/htmxtoolkit/{content-hash} +/_content/Ramstack.HtmxToolkit/htmx-toolkit.min.js +/_content/Ramstack.HtmxToolkit/htmx-toolkit.js ``` -It returns the minified script with `Cache-Control: public,max-age=31536000`. A new embedded script receives a new default URL. +Reference the minified file from the layout with an app-relative path: -Pass a custom path when routing conventions require one: - -```csharp -app.MapHtmxToolkitScript("/assets/htmx-toolkit.js"); +```html + ``` -When using a stable custom path, account for cache invalidation in deployment or proxy configuration. +This form requires `@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers` in `_ViewImports.cshtml`. +ASP.NET Core resolves the `~` path and applies content-based versioning: -Request the readable script while diagnosing browser behavior: +- On ASP.NET Core 9 or later, when the current endpoint's asset collection contains the script, the framework + selects the fingerprinted URL, such as `htmx-toolkit.min.{fingerprint}.js`. In production, `MapStaticAssets()` + serves fingerprinted assets with long-lived, immutable caching and supports precompressed Gzip and Brotli + representations. +- Otherwise, `asp-append-version="true"` appends a `?v=...` version computed and cached from the file content. + This includes ASP.NET Core 6–8 and applications using `UseStaticFiles()`. -```html - -``` +Both forms account for the application's path base. When the file changes, its versioned URL changes. Cache +headers are managed by the application's static asset or static file configuration; adding `?v=...` does not +itself set a cache lifetime. -The debug URL adds `?debug` and the endpoint returns the unminified asset. +Static web assets work with both project references and NuGet packages. On publish, ASP.NET Core copies +the scripts into the application's `wwwroot/_content/Ramstack.HtmxToolkit` directory. -## Inline the script - -Applications that cannot map the endpoint can render the embedded asset inside a script element: +Request the readable script while diagnosing browser behavior: ```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. +If you use `defer`, apply it to both HTMX and the Toolkit script so their execution order is preserved. ## Disable automatic antiforgery @@ -135,6 +168,6 @@ The Toolkit script can still be used for morph compatibility after antiforgery m - 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. +- The Toolkit static web asset works with a CSP 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/getting-started.md b/docs/articles/getting-started.md index b6f251b..6b0dccd 100644 --- a/docs/articles/getting-started.md +++ b/docs/articles/getting-started.md @@ -14,7 +14,7 @@ This example assumes HTMX 2.x, which is the toolkit default. ## 2. Register HtmxToolkit -Register Razor Pages and the toolkit in `Program.cs`, then map the companion script endpoint: +Register Razor Pages and the toolkit in `Program.cs`, and enable static files: ```csharp using Ramstack.HtmxToolkit.Hosting; @@ -27,7 +27,6 @@ builder.Services.AddHtmxToolkit(); var app = builder.Build(); app.UseStaticFiles(); -app.MapHtmxToolkitScript(); app.MapRazorPages(); app.Run(); @@ -36,17 +35,32 @@ app.Run(); No configuration delegate is required for HTMX 2.x. To use another major version, see [Choose an HTMX version](choosing-version.md). +The setup above works on ASP.NET Core 6 or later. On ASP.NET Core 9 or later, use the following instead of +`UseStaticFiles()` and `MapRazorPages()` to enable build-time compression and fingerprinted asset URLs: + +```csharp +app.MapStaticAssets(); +app.MapRazorPages().WithStaticAssets(); +``` + +> [!NOTE] +> In a hybrid Razor Pages and MVC application, call `.WithStaticAssets()` on every endpoint set that renders +> views, for example `app.MapControllers().WithStaticAssets()` as well. + ## 3. Enable the Razor helpers Add the namespace and Tag Helpers to `Pages/_ViewImports.cshtml`: ```html @using Ramstack.HtmxToolkit +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, Ramstack.HtmxToolkit ``` -The `@using` directive makes `Html.HtmxToolkitScriptPath()` and toolkit types available. -The `@addTagHelper` directive enables attributes such as `hx-page`, `hx-route-*`, and the `` element. +The `@using` directive makes toolkit types available. +The first `@addTagHelper` directive enables the standard ASP.NET Core Tag Helpers used by the layout snippet +(`~` path resolution and `asp-append-version`). The second enables attributes such as `hx-page` and `hx-route-*`, +as well as the `` element. ## 4. Configure the layout @@ -64,13 +78,15 @@ Render the configuration metadata in ``. Load HTMX first and the Toolkit s @RenderBody() - + ``` -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. +The Toolkit script is a static web asset supplied by the NuGet package. ASP.NET Core resolves the `~` path, and +`asp-append-version="true"` makes the URL content-based: on ASP.NET Core 9 or later the framework selects a +fingerprinted URL when available, otherwise it appends a `?v=...` version. Both forms account for the application's +path base. Cache headers are managed by the application's static asset or static file configuration. > [!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. diff --git a/docs/articles/overview.md b/docs/articles/overview.md index 92011d9..93f9482 100644 --- a/docs/articles/overview.md +++ b/docs/articles/overview.md @@ -15,7 +15,7 @@ The package targets .NET 6 and can be used by applications running on .NET 6 or | 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 | +| Assets | A companion static web asset referenced from the layout with automatic content-based URL versioning | 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. diff --git a/docs/articles/troubleshooting.md b/docs/articles/troubleshooting.md index 4b0b98f..3738fe2 100644 --- a/docs/articles/troubleshooting.md +++ b/docs/articles/troubleshooting.md @@ -28,7 +28,7 @@ Common causes are missing or invalid antiforgery data. 1. Inspect the document for `` and its `data-antiforgery-*` attributes. 2. Confirm HTMX loads before the Toolkit script. -3. Confirm the Toolkit script endpoint returns JavaScript rather than 404 or HTML. +3. Confirm the Toolkit script URL returns JavaScript rather than 404 or HTML. 4. Inspect the request for the antiforgery header or form value configured by ASP.NET Core. 5. After boosted navigation, confirm the returned full document also includes ``. @@ -36,15 +36,33 @@ See [Antiforgery and Toolkit script](antiforgery.md). ## Toolkit script returns 404 -Map the endpoint before the application finishes endpoint registration: +Enable static files and reference the script from the layout: ```csharp -app.MapHtmxToolkitScript(); +app.UseStaticFiles(); app.MapRazorPages(); ``` -Use `@Html.HtmxToolkitScriptPath()` instead of copying the default hash URL. If a custom path is passed -to `MapHtmxToolkitScript`, ensure the helper is rendered after that mapping is configured during application startup. +For ASP.NET Core 9 or later with `MapStaticAssets()`, configure the page endpoints with the asset collection: + +```csharp +app.MapStaticAssets(); +app.MapRazorPages().WithStaticAssets(); +``` + +```html + +``` + +The resolved URL points to `/_content/Ramstack.HtmxToolkit/htmx-toolkit.min.js` or its fingerprinted variant +under the application's path base. Ensure `_ViewImports.cshtml` registers the standard MVC Tag Helpers +(`@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers`) and that deployment includes the published +static assets and manifests. + +When running from build output in an environment other than `Development`, enable discovery of static web assets +with `builder.WebHost.UseStaticWebAssets()` before building the app. This extra call is unnecessary when running +from published output. ## Response has no HX headers diff --git a/docs/snippets/tag-helpers/MapToolkitScriptEndpoint.cs b/docs/snippets/tag-helpers/MapToolkitScriptEndpoint.cs deleted file mode 100644 index 59c452c..0000000 --- a/docs/snippets/tag-helpers/MapToolkitScriptEndpoint.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Ramstack.HtmxToolkit.Hosting; - -var app = builder.Build(); - -app.MapHtmxToolkitScript(); -app.MapRazorPages(); diff --git a/docs/snippets/tag-helpers/ToolkitScript.cshtml b/docs/snippets/tag-helpers/ToolkitScript.cshtml deleted file mode 100644 index bfcb1f3..0000000 --- a/docs/snippets/tag-helpers/ToolkitScript.cshtml +++ /dev/null @@ -1,3 +0,0 @@ -@using Ramstack.HtmxToolkit - - diff --git a/rollup.config.js b/rollup.config.js index 9978e9f..edf6bc3 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -15,12 +15,12 @@ function trim() { } export default { - input: "src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js", + input: "src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js", treeshake: "smallest", output: [{ - file: "src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js", + file: "src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js", }, { - file: "src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js", + file: "src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js", plugins: [terser({ output: { comments: false diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml index 9c240ad..fe6a591 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml @@ -51,7 +51,7 @@ - + diff --git a/samples/Ramstack.HtmxToolkit.Demo/Program.cs b/samples/Ramstack.HtmxToolkit.Demo/Program.cs index be91569..9aef3ed 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Program.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Program.cs @@ -17,10 +17,9 @@ if (!app.Environment.IsDevelopment()) app.UseExceptionHandler("/Error"); -app.UseStaticFiles(); +app.MapStaticAssets(); app.UseRouting(); -app.MapHtmxToolkitScript(); app.MapDefaultControllerRoute(); -app.MapRazorPages(); +app.MapRazorPages().WithStaticAssets(); app.Run(); diff --git a/src/Ramstack.HtmxToolkit/Hosting/EndpointRouteBuilderExtensions.cs b/src/Ramstack.HtmxToolkit/Hosting/EndpointRouteBuilderExtensions.cs deleted file mode 100644 index 2897402..0000000 --- a/src/Ramstack.HtmxToolkit/Hosting/EndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Html; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace Ramstack.HtmxToolkit.Hosting; - -/// -/// Provides HTMX Toolkit endpoint mappings for an . -/// -public static class EndpointRouteBuilderExtensions -{ - /// - /// Gets the current path to the HTMX Toolkit script asset. - /// - internal static string AssetPath { get; private set; } = $"/htmxtoolkit/{HtmxAssets.Hash}"; - - /// - /// Maps an endpoint that serves the HTMX Toolkit script at the default path. - /// - /// The endpoint route builder. - /// - /// The endpoint convention builder for the mapped script endpoint. - /// - public static IEndpointConventionBuilder MapHtmxToolkitScript(this IEndpointRouteBuilder builder) => - builder.MapHtmxToolkitScript(AssetPath); - - /// - /// Maps an endpoint that serves the HTMX Toolkit script at the specified path. - /// - /// The endpoint route builder. - /// The path at which to serve the script. - /// - /// The endpoint convention builder for the mapped script endpoint. - /// - public static IEndpointConventionBuilder MapHtmxToolkitScript(this IEndpointRouteBuilder builder, string path) - { - if (string.IsNullOrEmpty(path)) - throw new ArgumentException( - $"The '{nameof(path)}' parameter cannot be null or empty.", - nameof(path)); - - if (AssetPath != path) - { - if (path[0] != '/') - path = "/" + path; - - AssetPath = path; - HtmlHelperExtensions.Path = new HtmlString(path); - HtmlHelperExtensions.DebugPath = new HtmlString(path + "?debug"); - } - - return builder.MapGet(path, static context => - { - context.Response.ContentType = "text/javascript"; - context.Response.Headers.CacheControl = "public,max-age=31536000"; - - return context.Response.WriteAsync( - context.Request.QueryString.Value == "?debug" - ? HtmxAssets.DebugScript - : HtmxAssets.Script); - }); - } -} diff --git a/src/Ramstack.HtmxToolkit/HtmlHelperExtensions.cs b/src/Ramstack.HtmxToolkit/HtmlHelperExtensions.cs deleted file mode 100644 index 1f7cca2..0000000 --- a/src/Ramstack.HtmxToolkit/HtmlHelperExtensions.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.AspNetCore.Html; -using Microsoft.AspNetCore.Mvc.Rendering; - -using Ramstack.HtmxToolkit.Hosting; - -namespace Ramstack.HtmxToolkit; - -/// -/// Provides extension methods for the interface. -/// -public static class HtmlHelperExtensions -{ - private static readonly HtmlString s_script = new(HtmxAssets.Script); - private static readonly HtmlString s_debugScript = new(HtmxAssets.DebugScript); - - /// - /// Gets or sets the HTML string that represents the path to the minified script. - /// - internal static HtmlString Path { get; set; } = new(EndpointRouteBuilderExtensions.AssetPath); - - /// - /// Gets or sets the HTML string that represents the path to the debug script. - /// - internal static HtmlString DebugPath { get; set; } = new(EndpointRouteBuilderExtensions.AssetPath + "?debug"); - - /// - /// Returns the embedded HTMX Toolkit script. - /// - /// The HTML helper instance. - /// to return the debug script; - /// otherwise, . - /// - /// The embedded script content. - /// - public static IHtmlContent HtmxToolkitScript(this IHtmlHelper _, bool debug = false) => - debug ? s_debugScript : s_script; - - /// - /// Returns the path to the HTMX Toolkit script endpoint. - /// - /// The HTML helper instance. - /// to return the debug script path; - /// otherwise, . - /// - /// The HTMX Toolkit script endpoint path. - /// - public static IHtmlContent HtmxToolkitScriptPath(this IHtmlHelper _, bool debug = false) => - debug ? DebugPath : Path; -} diff --git a/src/Ramstack.HtmxToolkit/HtmxAssets.cs b/src/Ramstack.HtmxToolkit/HtmxAssets.cs deleted file mode 100644 index 4b7b712..0000000 --- a/src/Ramstack.HtmxToolkit/HtmxAssets.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace Ramstack.HtmxToolkit; - -/// -/// Provides access to the embedded HTMX Toolkit script assets. -/// -public static class HtmxAssets -{ - /// - /// The unminified HTMX Toolkit script. - /// - public static readonly string DebugScript = GetResourceContent("htmx-toolkit.js"); - - /// - /// The minified HTMX Toolkit script. - /// - public static readonly string Script = GetResourceContent("htmx-toolkit.min.js"); - - /// - /// The content hash of the HTMX Toolkit script. - /// - public static readonly string Hash = Convert - .ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(DebugScript)).AsSpan(0, 8)) - .ToLowerInvariant(); - - /// - /// Reads an embedded text resource. - /// - /// The manifest resource name. - /// - /// The resource contents. - /// - private static string GetResourceContent(string name) - { - using var stream = typeof(HtmxAssets).Assembly.GetManifestResourceStream(name)!; - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); - } -} diff --git a/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj b/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj index fbc3f99..fa92850 100644 --- a/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj +++ b/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj @@ -1,4 +1,4 @@ - + net6.0 Enables seamless integration of HTMX with ASP.NET Core (https://htmx.org). @@ -49,20 +49,10 @@ - - - - - - - - - - True diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js b/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js similarity index 100% rename from src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js rename to src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js b/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js similarity index 100% rename from src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js rename to src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js diff --git a/tests/Ramstack.HtmxToolkit.AotValidation/Program.cs b/tests/Ramstack.HtmxToolkit.AotValidation/Program.cs index fc53e7c..ca16e3c 100644 --- a/tests/Ramstack.HtmxToolkit.AotValidation/Program.cs +++ b/tests/Ramstack.HtmxToolkit.AotValidation/Program.cs @@ -1,3 +1,10 @@ +using Microsoft.AspNetCore.Http; using Ramstack.HtmxToolkit; -Console.WriteLine(HtmxAssets.Hash); +var request = new DefaultHttpContext().Request; +request.Headers[HtmxRequestHeaderNames.Request] = "true"; + +if (!request.IsHtmxRequest()) + throw new InvalidOperationException("HTMX request detection failed."); + +Console.WriteLine("HTMX request detected.");