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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/articles/migrate-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ These changes affect fewer applications:
- `HtmxHeaderTagHelper.Headers` changed from `Dictionary<string, string>` to `IDictionary<string, string>`,
and header names are compared without case.
- `hx-header-*` and `hx-all-headers` throw `InvalidOperationException` when the element also has a hand-written
`hx-headers`. In 1.x, the generated attribute silently replaced it.
`hx-headers` or `data-hx-headers`. In 1.x, the hand-written value was silently lost.
- `HtmxScrollBehavior` gained `Instant`.
- `Location`, `Redirect`, `Reswap(string)`, `Retarget`, and `Reselect` throw `ArgumentException` for a `null`
or empty value, and `Location(string, HtmxLocationOptions)` also throws for `null` options.
Expand Down
13 changes: 7 additions & 6 deletions docs/articles/tag-helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,10 @@ object entirely. Add `hx-vals-append="true"` to merge the child object instead:
The button emits `hx-vals:append='{"format":"summary"}'`.

An element with `hx-val-*`, `hx-all-vals`, `hx-vals-inherited`, or `hx-vals-append` cannot also have
a hand-written `hx-vals`, with or without modifiers. The Tag Helper throws `InvalidOperationException`
rather than replace it. To combine `hx-val-*` with values computed in JavaScript, put the hand-written
`hx-vals` on a parent element and rely on the inheritance rules above.
a hand-written `hx-vals` or `data-hx-vals`, with or without modifiers. HTMX reads both names as one attribute,
so one of the values would be lost; the Tag Helper throws `InvalidOperationException` instead.
For HTMX 4.x, `HtmxV4Config.Prefix` determines the second name. To combine `hx-val-*` with values computed
in JavaScript, put the hand-written `hx-vals` on a parent element and rely on the inheritance rules above.

## Send custom headers

Expand All @@ -229,7 +230,7 @@ For HTMX 4.x, use the Toolkit input `hx-headers-inherited="true"` on a parent el
This generates `hx-headers:inherited`.
Use `hx-headers-append="true"` on a child declaration to merge its headers into the inherited object.

As with `hx-vals`, an element with these inputs cannot also have a hand-written `hx-headers`,
As with `hx-vals`, an element with these inputs cannot also have a hand-written `hx-headers` or `data-hx-headers`,
with or without modifiers; the Tag Helper throws `InvalidOperationException`.

Do not use custom client headers as proof of identity or authorization; clients can modify them.
Expand Down Expand Up @@ -275,8 +276,8 @@ Unsupported properties are omitted from the generated JSON. In particular, `Htmx
cannot be represented by HTMX 1.9.x or 2.x and is omitted for those targets.

An element with `hx-request-*` attributes cannot also have a hand-written `hx-request` for HTMX 1.9.x and 2.x
or `hx-config` for HTMX 4.x, with or without modifiers. The Tag Helper throws `InvalidOperationException`
rather than replace it.
or `hx-config` for HTMX 4.x, with or without modifiers or the `data-hx-` prefix.
The Tag Helper throws `InvalidOperationException` rather than lose one of the values.

Use `HtmxRequestCache` and `HtmxRequestRedirect` for the HTMX 4.x Fetch options:

Expand Down
5 changes: 5 additions & 0 deletions src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ public bool? LogAll
/// Gets or sets the secondary attribute prefix recognized alongside <c>hx-*</c>.
/// The HTMX default is <c>data-hx-</c>.
/// </summary>
/// <remarks>
/// An empty string disables the secondary prefix, so HTMX reads only <c>hx-*</c>. This helps when the application
/// renders user-supplied HTML through a sanitizer that allows <c>data-*</c> attributes,
/// which would otherwise let <c>data-hx-*</c> attributes reach HTMX.
/// </remarks>
public string? Prefix
{
get;
Expand Down
41 changes: 37 additions & 4 deletions src/Ramstack.HtmxToolkit/Internal/TagHelperOutputExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,36 +1,69 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;

using Microsoft.AspNetCore.Razor.TagHelpers;

using Ramstack.HtmxToolkit.Configuration;

namespace Ramstack.HtmxToolkit.Internal;

/// <summary>
/// Provides extension methods for <see cref="TagHelperOutput" />.
/// </summary>
internal static class TagHelperOutputExtensions
{
private const string AttributePrefix = "hx-";
private const string DefaultAliasPrefix = "data-hx-";

/// <summary>
/// Throws an exception if the element already has the attribute that the tag helper renders,
/// or the alias that HTMX reads as the same attribute, such as <c>data-hx-vals</c>,
/// with or without HTMX 4.x modifiers, such as <c>hx-vals:inherited</c>.
/// </summary>
/// <param name="output">The tag helper output to inspect.</param>
/// <param name="attributeName">The name of the attribute that the tag helper renders, without modifiers.</param>
/// <param name="attributeName">The <c>hx-*</c> name of the attribute that the tag helper renders, without modifiers.</param>
/// <param name="sourceAttributes">The tag helper attributes that render <paramref name="attributeName" />,
/// as listed in the exception message.</param>
public static void ThrowIfAttributeDeclared(this TagHelperOutput output, string attributeName, string sourceAttributes)
/// <param name="options">The options that determine the alias prefix.</param>
public static void ThrowIfAttributeDeclared(this TagHelperOutput output, string attributeName, string sourceAttributes, HtmxToolkitOptions options)
{
Debug.Assert(attributeName is "hx-vals" or "hx-headers" or "hx-request" or "hx-config");

// HTMX 1.9.x and 2.x always read data-hx-*. HTMX 4.x reads the configured prefix.
var aliasPrefix = (options.HtmxConfig as HtmxV4Config)?.Prefix ?? DefaultAliasPrefix;
var suffix = attributeName.AsSpan(AttributePrefix.Length);

var attributes = output.Attributes;
var count = attributes.Count;

for (var i = 0; i < count; i++)
{
var name = attributes[i].Name;
if (name.StartsWith(attributeName, StringComparison.OrdinalIgnoreCase)
&& (name.Length == attributeName.Length || name[attributeName.Length] == ':'))
if (IsDeclared(name, AttributePrefix, suffix) || IsDeclared(name, aliasPrefix, suffix))
Error_AttributeDeclared(name, attributeName, sourceAttributes);
}
}

/// <summary>
/// Determines whether the attribute name consists of the prefix and the suffix, optionally followed by modifiers.
/// An empty prefix never matches: for HTMX 4.x, it means that there is no alias, not that the bare suffix is one.
/// </summary>
/// <param name="name">The attribute name to check.</param>
/// <param name="prefix">The expected prefix.</param>
/// <param name="suffix">The expected attribute name without the prefix and modifiers.</param>
/// <returns>
/// <see langword="true" /> if the attribute name matches; otherwise, <see langword="false" />.
/// </returns>
private static bool IsDeclared(string name, string prefix, ReadOnlySpan<char> suffix)
{
if (prefix.Length == 0 || !name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return false;

var rest = name.AsSpan(prefix.Length);
return rest.StartsWith(suffix, StringComparison.OrdinalIgnoreCase)
&& (rest.Length == suffix.Length || rest[suffix.Length] == ':');
}

[DoesNotReturn]
private static void Error_AttributeDeclared(string declaredName, string attributeName, string sourceAttributes)
{
Expand Down
6 changes: 4 additions & 2 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// </list>
/// <para>
/// An <see cref="InvalidOperationException" /> is thrown when the element also has an <c>hx-headers</c>
/// attribute, with or without HTMX 4.x modifiers, rather than replacing it.
/// attribute, or the <c>data-hx-headers</c> alias that HTMX reads as the same attribute, with or without
/// HTMX 4.x modifiers, rather than losing one of the values. For HTMX 4.x,
/// <see cref="HtmxV4Config.Prefix" /> determines the alias.
/// </para>
/// </remarks>
[HtmlTargetElement(Attributes = InheritedAttributeName)]
Expand Down Expand Up @@ -87,7 +89,7 @@ public IDictionary<string, string> Headers
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
const string SourceAttributes = $"{HeadersPrefix}*, {HeadersDictionaryName}, {InheritedAttributeName}, or {AppendAttributeName}";
output.ThrowIfAttributeDeclared("hx-headers", SourceAttributes);
output.ThrowIfAttributeDeclared("hx-headers", SourceAttributes, options.Value);

if (Headers is { Count: > 0 })
{
Expand Down
7 changes: 4 additions & 3 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// </para>
/// <para>
/// An <see cref="InvalidOperationException" /> is thrown when the element also has the attribute
/// that the tag helper renders for the selected version, with or without HTMX 4.x modifiers,
/// rather than replacing it.
/// that the tag helper renders for the selected version, or its <c>data-hx-*</c> alias that HTMX reads
/// as the same attribute, with or without HTMX 4.x modifiers, rather than losing one of the values.
/// For HTMX 4.x, <see cref="HtmxV4Config.Prefix" /> determines the alias.
/// </para>
/// </remarks>
[HtmlTargetElement(Attributes = RequestInheritedAttributeName)]
Expand Down Expand Up @@ -205,7 +206,7 @@ public bool? Validate
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var attributeName = options.Value.TargetVersion == HtmxTargetVersion.V4 ? "hx-config" : "hx-request";
output.ThrowIfAttributeDeclared(attributeName, "hx-request-* attributes");
output.ThrowIfAttributeDeclared(attributeName, "hx-request-* attributes", options.Value);

var json = options.Value.TargetVersion == HtmxTargetVersion.V4
? JsonSerializer.Serialize(new HtmxRequestDataV4(_request), HtmxRequestJsonSerializerContext.Default.HtmxRequestDataV4)
Expand Down
6 changes: 4 additions & 2 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// </list>
/// <para>
/// An <see cref="InvalidOperationException" /> is thrown when the element also has an <c>hx-vals</c>
/// attribute, with or without HTMX 4.x modifiers, rather than replacing it.
/// attribute, or the <c>data-hx-vals</c> alias that HTMX reads as the same attribute, with or without
/// HTMX 4.x modifiers, rather than losing one of the values. For HTMX 4.x,
/// <see cref="HtmxV4Config.Prefix" /> determines the alias.
/// </para>
/// </remarks>
[HtmlTargetElement(Attributes = InheritedAttributeName)]
Expand Down Expand Up @@ -87,7 +89,7 @@ public IDictionary<string, string> Values
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
const string SourceAttributes = $"{ValuesPrefix}*, {ValuesDictionaryName}, {InheritedAttributeName}, or {AppendAttributeName}";
output.ThrowIfAttributeDeclared("hx-vals", SourceAttributes);
output.ThrowIfAttributeDeclared("hx-vals", SourceAttributes, options.Value);

if (Values is { Count: > 0 } values)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTa

[TestCase("hx-headers")]
[TestCase("hx-headers:inherited:append")]
[TestCase("data-hx-headers")]
[TestCase("data-hx-headers:inherited")]
public void ProcessAsync_DeclaredHxHeaders_Throws(string name)
{
var output = TestHelper.CreateTagHelperOutput(attributes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,10 @@ public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTa
[TestCase(HtmxTargetVersion.V2, "hx-request")]
[TestCase(HtmxTargetVersion.V4, "hx-config")]
[TestCase(HtmxTargetVersion.V4, "hx-config:inherited")]
[TestCase(HtmxTargetVersion.V1, "data-hx-request")]
[TestCase(HtmxTargetVersion.V2, "data-hx-request")]
[TestCase(HtmxTargetVersion.V4, "data-hx-config")]
[TestCase(HtmxTargetVersion.V4, "data-hx-config:inherited")]
public void ProcessAsync_DeclaredRenderedAttribute_Throws(HtmxTargetVersion version, string name)
{
var output = TestHelper.CreateTagHelperOutput(attributes:
Expand All @@ -400,6 +404,8 @@ public void ProcessAsync_DeclaredRenderedAttribute_Throws(HtmxTargetVersion vers

[TestCase(HtmxTargetVersion.V2, "hx-config", "hx-request")]
[TestCase(HtmxTargetVersion.V4, "hx-request", "hx-config")]
[TestCase(HtmxTargetVersion.V2, "data-hx-config", "hx-request")]
[TestCase(HtmxTargetVersion.V4, "data-hx-request", "hx-config")]
public async Task ProcessAsync_AttributeOfOtherVersion_IsKept(HtmxTargetVersion version, string name, string rendered)
{
var output = TestHelper.CreateTagHelperOutput(attributes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,63 @@ public void ProcessAsync_DeclaredHxVals_Throws(string name)
Assert.That(exception!.Message, Does.StartWith($"The {name} attribute"));
}

[TestCase(HtmxTargetVersion.V1, "data-hx-vals")]
[TestCase(HtmxTargetVersion.V2, "data-hx-vals")]
[TestCase(HtmxTargetVersion.V4, "data-hx-vals")]
[TestCase(HtmxTargetVersion.V4, "DATA-HX-VALS")]
[TestCase(HtmxTargetVersion.V4, "data-hx-vals:inherited")]
public void ProcessAsync_DeclaredAlias_Throws(HtmxTargetVersion version, string name)
{
var output = TestHelper.CreateTagHelperOutput(attributes:
[
new TagHelperAttribute(name, new HtmlString("{\"a\":\"1\"}"))
]);
var helper = CreateHelper(version);
helper.Values["b"] = "2";

var exception = Assert.ThrowsAsync<InvalidOperationException>(
async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output));

Assert.That(exception!.Message, Does.StartWith($"The {name} attribute"));
}

[TestCase("x-vals")]
[TestCase("x-vals:append")]
public void ProcessAsync_DeclaredAliasWithCustomPrefix_Throws(string name)
{
var output = TestHelper.CreateTagHelperOutput(attributes:
[
new TagHelperAttribute(name, new HtmlString("{\"a\":\"1\"}"))
]);
var helper = CreateHelper(HtmxTargetVersion.V4, prefix: "x-");
helper.Values["b"] = "2";

var exception = Assert.ThrowsAsync<InvalidOperationException>(
async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output));

Assert.That(exception!.Message, Does.StartWith($"The {name} attribute"));
}

[TestCase("x-", "data-hx-vals")]
[TestCase("", "data-hx-vals")]
[TestCase("", "vals")]
[TestCase(null, "x-vals")]
[TestCase(null, "data-hx-vals-extra")]
public async Task ProcessAsync_AttributeThatIsNotAlias_IsKept(string? prefix, string name)
{
var output = TestHelper.CreateTagHelperOutput(attributes:
[
new TagHelperAttribute(name, "x")
]);
var helper = CreateHelper(HtmxTargetVersion.V4, prefix);
helper.Values["b"] = "2";

await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);

Assert.That(output.Attributes[name]!.Value, Is.EqualTo("x"));
Assert.That(output.Attributes["hx-vals"]!.Value.ToString(), Is.EqualTo("{\"b\":\"2\"}"));
}

[Test]
public void ProcessAsync_DeclaredHxVals_WithEmptyDictionary_Throws()
{
Expand Down Expand Up @@ -204,7 +261,7 @@ public async Task ProcessAsync_AttributeStartingWithHxVals_IsKept()
Assert.That(output.Attributes["hx-vals"]!.Value.ToString(), Is.EqualTo("{\"b\":\"2\"}"));
}

private static HtmxValsTagHelper CreateHelper(HtmxTargetVersion version = HtmxTargetVersion.V2)
private static HtmxValsTagHelper CreateHelper(HtmxTargetVersion version = HtmxTargetVersion.V2, string? prefix = null)
{
var options = new HtmxToolkitOptions();

Expand All @@ -217,7 +274,7 @@ private static HtmxValsTagHelper CreateHelper(HtmxTargetVersion version = HtmxTa
options.UseHtmxV2();
break;
case HtmxTargetVersion.V4:
options.UseHtmxV4();
options.UseHtmxV4(config => config.Prefix = prefix);
break;
default:
throw new ArgumentOutOfRangeException(nameof(version));
Expand Down
Loading