diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxUrlTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxUrlTagHelper.cs
index 5054225..da5d69d 100644
--- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxUrlTagHelper.cs
+++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxUrlTagHelper.cs
@@ -8,63 +8,135 @@
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Options;
using Ramstack.HtmxToolkit.Collections;
+using Ramstack.HtmxToolkit.Configuration;
namespace Ramstack.HtmxToolkit.TagHelpers;
///
-/// Generates URLs for HTMX request attributes on matching elements.
+/// Generates URLs for HTMX request attributes from the standard ASP.NET Core asp-* routing attributes.
///
///
The factory used to create URL helpers.
+///
The configured HtmxToolkit options.
///
///
-/// The URL is written to the existing hx-get , hx-post , hx-put ,
-/// hx-patch , or hx-delete attribute. If none of them is present,
-/// the tag helper adds hx-get .
+/// The tag helper applies to elements that have an hx-get , hx-post , hx-put ,
+/// hx-patch , hx-delete , or HTMX 4.x hx-action attribute together with
+/// at least one of the asp-action , asp-controller , asp-area , asp-page ,
+/// asp-page-handler , asp-route , asp-route-* , asp-all-route-data ,
+/// asp-host , asp-protocol , or asp-fragment attributes.
+/// The generated URL is written to the HTMX attribute, which must not have a value.
///
///
-/// An is thrown when more than one of these
-/// attributes is present, or when attributes from more than one of the following groups
-/// are specified: hx-route ; hx-controller and hx-action ;
-/// hx-page and hx-page-handler .
+/// On <a> , <form> , <button> , and submit or image
+/// <input> elements, the built-in ASP.NET Core tag helpers process the same
+/// asp-* attributes and also render href , action , or formaction .
+///
+///
+/// An is thrown when more than one HTMX request attribute
+/// is present, when the HTMX request attribute has a value, even an empty one, when hx-action is used
+/// with a version other than HTMX 4.x, or when attributes from more than one of the following
+/// groups are specified: asp-route ; asp-controller and asp-action ;
+/// asp-page and asp-page-handler .
///
///
-[HtmlTargetElement(Attributes = ActionAttributeName)]
-[HtmlTargetElement(Attributes = ControllerAttributeName)]
-[HtmlTargetElement(Attributes = AreaAttributeName)]
-[HtmlTargetElement(Attributes = PageAttributeName)]
-[HtmlTargetElement(Attributes = PageHandlerAttributeName)]
-[HtmlTargetElement(Attributes = RouteAttributeName)]
-[HtmlTargetElement(Attributes = RouteValuesDictionaryName)]
-[HtmlTargetElement(Attributes = RouteValuesPrefix + "*")]
-[HtmlTargetElement(Attributes = HostAttributeName)]
-[HtmlTargetElement(Attributes = ProtocolAttributeName)]
-[HtmlTargetElement(Attributes = FragmentAttributeName)]
+[HtmlTargetElement(Attributes = "hx-get," + ActionAttributeName)]
+[HtmlTargetElement(Attributes = "hx-get," + ControllerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-get," + AreaAttributeName)]
+[HtmlTargetElement(Attributes = "hx-get," + PageAttributeName)]
+[HtmlTargetElement(Attributes = "hx-get," + PageHandlerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-get," + RouteAttributeName)]
+[HtmlTargetElement(Attributes = "hx-get," + RouteValuesDictionaryName)]
+[HtmlTargetElement(Attributes = "hx-get," + RouteValuesPrefix + "*")]
+[HtmlTargetElement(Attributes = "hx-get," + HostAttributeName)]
+[HtmlTargetElement(Attributes = "hx-get," + ProtocolAttributeName)]
+[HtmlTargetElement(Attributes = "hx-get," + FragmentAttributeName)]
+[HtmlTargetElement(Attributes = "hx-post," + ActionAttributeName)]
+[HtmlTargetElement(Attributes = "hx-post," + ControllerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-post," + AreaAttributeName)]
+[HtmlTargetElement(Attributes = "hx-post," + PageAttributeName)]
+[HtmlTargetElement(Attributes = "hx-post," + PageHandlerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-post," + RouteAttributeName)]
+[HtmlTargetElement(Attributes = "hx-post," + RouteValuesDictionaryName)]
+[HtmlTargetElement(Attributes = "hx-post," + RouteValuesPrefix + "*")]
+[HtmlTargetElement(Attributes = "hx-post," + HostAttributeName)]
+[HtmlTargetElement(Attributes = "hx-post," + ProtocolAttributeName)]
+[HtmlTargetElement(Attributes = "hx-post," + FragmentAttributeName)]
+[HtmlTargetElement(Attributes = "hx-put," + ActionAttributeName)]
+[HtmlTargetElement(Attributes = "hx-put," + ControllerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-put," + AreaAttributeName)]
+[HtmlTargetElement(Attributes = "hx-put," + PageAttributeName)]
+[HtmlTargetElement(Attributes = "hx-put," + PageHandlerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-put," + RouteAttributeName)]
+[HtmlTargetElement(Attributes = "hx-put," + RouteValuesDictionaryName)]
+[HtmlTargetElement(Attributes = "hx-put," + RouteValuesPrefix + "*")]
+[HtmlTargetElement(Attributes = "hx-put," + HostAttributeName)]
+[HtmlTargetElement(Attributes = "hx-put," + ProtocolAttributeName)]
+[HtmlTargetElement(Attributes = "hx-put," + FragmentAttributeName)]
+[HtmlTargetElement(Attributes = "hx-patch," + ActionAttributeName)]
+[HtmlTargetElement(Attributes = "hx-patch," + ControllerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-patch," + AreaAttributeName)]
+[HtmlTargetElement(Attributes = "hx-patch," + PageAttributeName)]
+[HtmlTargetElement(Attributes = "hx-patch," + PageHandlerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-patch," + RouteAttributeName)]
+[HtmlTargetElement(Attributes = "hx-patch," + RouteValuesDictionaryName)]
+[HtmlTargetElement(Attributes = "hx-patch," + RouteValuesPrefix + "*")]
+[HtmlTargetElement(Attributes = "hx-patch," + HostAttributeName)]
+[HtmlTargetElement(Attributes = "hx-patch," + ProtocolAttributeName)]
+[HtmlTargetElement(Attributes = "hx-patch," + FragmentAttributeName)]
+[HtmlTargetElement(Attributes = "hx-delete," + ActionAttributeName)]
+[HtmlTargetElement(Attributes = "hx-delete," + ControllerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-delete," + AreaAttributeName)]
+[HtmlTargetElement(Attributes = "hx-delete," + PageAttributeName)]
+[HtmlTargetElement(Attributes = "hx-delete," + PageHandlerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-delete," + RouteAttributeName)]
+[HtmlTargetElement(Attributes = "hx-delete," + RouteValuesDictionaryName)]
+[HtmlTargetElement(Attributes = "hx-delete," + RouteValuesPrefix + "*")]
+[HtmlTargetElement(Attributes = "hx-delete," + HostAttributeName)]
+[HtmlTargetElement(Attributes = "hx-delete," + ProtocolAttributeName)]
+[HtmlTargetElement(Attributes = "hx-delete," + FragmentAttributeName)]
+[HtmlTargetElement(Attributes = "hx-action," + ActionAttributeName)]
+[HtmlTargetElement(Attributes = "hx-action," + ControllerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-action," + AreaAttributeName)]
+[HtmlTargetElement(Attributes = "hx-action," + PageAttributeName)]
+[HtmlTargetElement(Attributes = "hx-action," + PageHandlerAttributeName)]
+[HtmlTargetElement(Attributes = "hx-action," + RouteAttributeName)]
+[HtmlTargetElement(Attributes = "hx-action," + RouteValuesDictionaryName)]
+[HtmlTargetElement(Attributes = "hx-action," + RouteValuesPrefix + "*")]
+[HtmlTargetElement(Attributes = "hx-action," + HostAttributeName)]
+[HtmlTargetElement(Attributes = "hx-action," + ProtocolAttributeName)]
+[HtmlTargetElement(Attributes = "hx-action," + FragmentAttributeName)]
[RequiresUnreferencedCode("Razor Page URL generation is not compatible with trimming.")]
-public sealed class HtmxUrlTagHelper(IUrlHelperFactory factory) : TagHelper
+public sealed class HtmxUrlTagHelper(IUrlHelperFactory factory, IOptions
options) : TagHelper
{
- private const string ActionAttributeName = "hx-action";
- private const string ControllerAttributeName = "hx-controller";
- private const string AreaAttributeName = "hx-area";
- private const string PageAttributeName = "hx-page";
- private const string PageHandlerAttributeName = "hx-page-handler";
- private const string FragmentAttributeName = "hx-fragment";
- private const string HostAttributeName = "hx-host";
- private const string ProtocolAttributeName = "hx-protocol";
- private const string RouteAttributeName = "hx-route";
- private const string RouteValuesDictionaryName = "hx-all-route-data";
- private const string RouteValuesPrefix = "hx-route-";
-
- private static readonly string[] s_methods = ["hx-get", "hx-post", "hx-delete", "hx-put", "hx-patch"];
+ private const string ActionAttributeName = "asp-action";
+ private const string ControllerAttributeName = "asp-controller";
+ private const string AreaAttributeName = "asp-area";
+ private const string PageAttributeName = "asp-page";
+ private const string PageHandlerAttributeName = "asp-page-handler";
+ private const string FragmentAttributeName = "asp-fragment";
+ private const string HostAttributeName = "asp-host";
+ private const string ProtocolAttributeName = "asp-protocol";
+ private const string RouteAttributeName = "asp-route";
+ private const string RouteValuesDictionaryName = "asp-all-route-data";
+ private const string RouteValuesPrefix = "asp-route-";
+ private const string HxActionAttributeName = "hx-action";
+
+ private static readonly string[] s_requestAttributes = ["hx-get", "hx-post", "hx-delete", "hx-put", "hx-patch", "hx-action"];
private IDictionary? _routeValues;
///
///
- /// Runs before tag helpers with the default order, so that they see the generated URL attribute.
+ /// Runs after the built-in ASP.NET Core tag helpers that process the same asp-* attributes (-1000)
+ /// and before tag helpers with the default order, so that they see the generated URL attribute.
+ /// The value lies between -1000 and the built-in
+ /// (-900),
+ /// leaving room for tag helpers that need to run before or after this one.
///
- public override int Order => -1000;
+ public override int Order => -950;
///
/// Gets or sets the name of the route.
@@ -169,6 +241,10 @@ public IDictionary RouteValues
///
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
+ var name = GetRequestAttributeName(output);
+ if (name is null)
+ return Task.CompletedTask;
+
var routeLink = Route != null;
var actionLink = Controller != null || Action != null;
var pageLink = Page != null || PageHandler != null;
@@ -182,7 +258,7 @@ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput outp
if (Area is not null)
{
- // Unconditionally replace any existing area route value with the value from hx-area.
+ // Unconditionally replace any existing area route value with the value from asp-area.
routeValues ??= new RouteValueDictionary();
routeValues["area"] = Area;
}
@@ -220,23 +296,43 @@ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput outp
fragment: Fragment);
}
- string? definedMethod = null;
+ output.Attributes.SetAttribute(name, url);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Returns the name of the HTMX request attribute that receives the generated URL.
+ ///
+ /// The tag helper output to inspect.
+ ///
+ /// The name of the HTMX request attribute, or if the element has none.
+ ///
+ private string? GetRequestAttributeName(TagHelperOutput output)
+ {
+ string? name = null;
- foreach (var method in s_methods)
+ foreach (var attributeName in s_requestAttributes)
{
- if (output.Attributes[method] is null)
+ var attribute = output.Attributes[attributeName];
+ if (attribute is null)
continue;
- if (definedMethod is not null)
- Error_AmbiguousMethods();
+ if (name is not null)
+ Error_AmbiguousRequestAttributes();
+
+ // Accept only an attribute written without a value, such as .
+ // Any value, even an empty one, is rejected rather than guessed to mean "no URL".
+ if (attribute.ValueStyle != HtmlAttributeValueStyle.Minimized)
+ Error_RequestAttributeHasValue(attributeName);
- definedMethod = method;
+ name = attributeName;
}
- var attribute = new TagHelperAttribute(definedMethod ?? "hx-get", url);
- output.Attributes.SetAttribute(attribute);
+ if (name == HxActionAttributeName && options.Value.TargetVersion != HtmxTargetVersion.V4)
+ Error_HxActionRequiresHtmx4();
- return Task.CompletedTask;
+ return name;
}
///
@@ -256,13 +352,36 @@ private static void Error_CannotDetermineUrl()
}
///
- /// Throws an exception indicating that multiple HTMX method attributes were specified.
+ /// Throws an exception indicating that multiple HTMX request attributes were specified.
+ ///
+ /// Always thrown.
+ [DoesNotReturn]
+ private static void Error_AmbiguousRequestAttributes()
+ {
+ const string Message = "Ambiguous HTMX request. Only one of the following attributes is allowed: hx-get, hx-post, hx-delete, hx-put, hx-patch, hx-action";
+ throw new InvalidOperationException(Message);
+ }
+
+ ///
+ /// Throws an exception indicating that the HTMX request attribute has a value.
+ ///
+ /// The name of the HTMX request attribute.
+ /// Always thrown.
+ [DoesNotReturn]
+ private static void Error_RequestAttributeHasValue(string attributeName)
+ {
+ var message = $"The {attributeName} attribute must be written without a value to receive the URL generated from the asp-* attributes. Remove its value, or remove the asp-* attributes.";
+ throw new InvalidOperationException(message);
+ }
+
+ ///
+ /// Throws an exception indicating that hx-action is used with HTMX 1.9.x or 2.x.
///
/// Always thrown.
[DoesNotReturn]
- private static void Error_AmbiguousMethods()
+ private static void Error_HxActionRequiresHtmx4()
{
- const string Message = "Ambiguous HTMX method. Only one of the following methods is allowed: hx-get, hx-post, hx-delete, hx-put, hx-patch";
+ const string Message = "The hx-action attribute is supported only in HTMX 4.x. Use hx-get, hx-post, hx-delete, hx-put, or hx-patch instead.";
throw new InvalidOperationException(Message);
}
}
diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxUrlTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxUrlTagHelperTests.cs
index c3a9ad1..5771711 100644
--- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxUrlTagHelperTests.cs
+++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxUrlTagHelperTests.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
@@ -5,6 +6,9 @@
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Options;
+
+using Ramstack.HtmxToolkit.Configuration;
namespace Ramstack.HtmxToolkit.Tests.TagHelpers;
@@ -107,6 +111,11 @@ public async Task ProcessAsync_UsesExistingHtmxMethod()
[TestCase("hx-delete", "hx-put")]
[TestCase("hx-delete", "hx-patch")]
[TestCase("hx-put", "hx-patch")]
+ [TestCase("hx-get", "hx-action")]
+ [TestCase("hx-post", "hx-action")]
+ [TestCase("hx-delete", "hx-action")]
+ [TestCase("hx-put", "hx-action")]
+ [TestCase("hx-patch", "hx-action")]
public void ProcessAsync_MultipleMethods_Throws(string method1, string method2)
{
var attributes = new TagHelperAttributeList
@@ -115,10 +124,66 @@ public void ProcessAsync_MultipleMethods_Throws(string method1, string method2)
new TagHelperAttribute(method2)
};
+ Assert.ThrowsAsync(
+ async () => await RunAsync(attributes: attributes, version: HtmxTargetVersion.V4));
+ }
+
+ [Test]
+ public void ProcessAsync_EmptyMethodValue_Throws()
+ {
+ var attributes = new TagHelperAttributeList
+ {
+ new TagHelperAttribute("hx-post", new HtmlString(""))
+ };
+
Assert.ThrowsAsync(
async () => await RunAsync(attributes: attributes));
}
+ [Test]
+ public void ProcessAsync_MethodWithUrl_Throws()
+ {
+ var attributes = new TagHelperAttributeList
+ {
+ new TagHelperAttribute("hx-post", new HtmlString("/explicit"))
+ };
+
+ var exception = Assert.ThrowsAsync(
+ async () => await RunAsync(attributes: attributes));
+
+ Assert.That(exception!.Message, Does.Contain("hx-post"));
+ }
+
+ [Test]
+ public async Task ProcessAsync_HxAction_WritesUrl_ForHtmx4()
+ {
+ var attributes = new TagHelperAttributeList
+ {
+ new TagHelperAttribute("hx-action"),
+ new TagHelperAttribute("hx-method", new HtmlString("post"))
+ };
+
+ var (_, output) = await RunAsync(attributes: attributes, version: HtmxTargetVersion.V4);
+
+ Assert.That(output.Attributes["hx-action"]!.Value, Is.EqualTo(TestUrlHelper.ActionUrl));
+ Assert.That(output.Attributes["hx-get"], Is.Null);
+ }
+
+ [TestCase(HtmxTargetVersion.V1)]
+ [TestCase(HtmxTargetVersion.V2)]
+ public void ProcessAsync_HxAction_Throws_BeforeHtmx4(HtmxTargetVersion version)
+ {
+ var attributes = new TagHelperAttributeList
+ {
+ new TagHelperAttribute("hx-action")
+ };
+
+ var exception = Assert.ThrowsAsync(
+ async () => await RunAsync(attributes: attributes, version: version));
+
+ Assert.That(exception!.Message, Does.Contain("HTMX 4.x"));
+ }
+
[TestCase("Route", "Action")]
[TestCase("Route", "Controller")]
[TestCase("Route", "Page")]
@@ -149,24 +214,46 @@ static void SetLinkType(HtmxUrlTagHelper tagHelper, string linkType)
}
}
- private static async Task<(TestUrlHelper UrlHelper, TagHelperOutput Output)> RunAsync(Action? configure = null, TagHelperAttributeList? attributes = null)
+ private static async Task<(TestUrlHelper UrlHelper, TagHelperOutput Output)> RunAsync(Action? configure = null, TagHelperAttributeList? attributes = null, HtmxTargetVersion version = HtmxTargetVersion.V2)
{
var urlHelper = new TestUrlHelper();
var factory = new TestUrlHelperFactory(urlHelper);
- var tagHelper = new HtmxUrlTagHelper(factory)
+ var tagHelper = new HtmxUrlTagHelper(factory, Options.Create(CreateOptions(version)))
{
ViewContext = new ViewContext()
};
configure?.Invoke(tagHelper);
- var output = TestHelper.CreateTagHelperOutput("div", attributes);
+ var output = TestHelper.CreateTagHelperOutput("div", attributes ?? [new TagHelperAttribute("hx-get")]);
await tagHelper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
return (urlHelper, output);
}
+ private static HtmxToolkitOptions CreateOptions(HtmxTargetVersion version)
+ {
+ var options = new HtmxToolkitOptions();
+
+ switch (version)
+ {
+ case HtmxTargetVersion.V1:
+ options.UseHtmxV1();
+ break;
+ case HtmxTargetVersion.V2:
+ options.UseHtmxV2();
+ break;
+ case HtmxTargetVersion.V4:
+ options.UseHtmxV4();
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(version));
+ }
+
+ return options;
+ }
+
private sealed class TestUrlHelperFactory(IUrlHelper helper) : IUrlHelperFactory
{