Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
b86ca20
SONARHTML-395 Handle template-generated labels
erwan-leforestier-sonarsource Jun 24, 2026
cb4d0a5
SONARHTML-395 Reuse Thymeleaf empty-value helper in label check
erwan-leforestier-sonarsource Jun 24, 2026
b651402
SONARHTML-395 Share Thymeleaf non-empty attribute helper across checks
erwan-leforestier-sonarsource Jun 24, 2026
b333b41
SONARHTML-395 Share template-text attribute set across accessibility …
erwan-leforestier-sonarsource Jun 24, 2026
eb28d9e
SONARHTML-395 Skip S6853 on fragment-rendered labels (Thymeleaf and R…
erwan-leforestier-sonarsource Jun 24, 2026
c85d40e
SONARHTML-395 Recognize Angular content-binding properties on labels
erwan-leforestier-sonarsource Jun 24, 2026
ce050da
SONARHTML-395 Pin empty for= behavior with regression tests
erwan-leforestier-sonarsource Jun 24, 2026
c9475dc
SONARHTML-395 Stop treating HTML comments as label body content
erwan-leforestier-sonarsource Jun 24, 2026
d4ea12d
SONARHTML-395 Drop noisy Javadoc on label-check private helpers
erwan-leforestier-sonarsource Jun 24, 2026
5b93748
SONARHTML-395 Cover v-bind:for + v-text and standalone v-text labels
erwan-leforestier-sonarsource Jun 24, 2026
07070e8
SONARHTML-395 Accept th:attr-generated aria-label/aria-labelledby/alt…
erwan-leforestier-sonarsource Jun 24, 2026
e260469
SONARHTML-395 Gate Razor fragment detection in label text to Razor files
erwan-leforestier-sonarsource Jun 24, 2026
4e89f8e
SONARHTML-395 Annotate Thymeleaf.isEmptyValue parameter as @Nullable
erwan-leforestier-sonarsource Jun 24, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import javax.annotation.Nullable;
import org.sonar.plugins.html.node.Attribute;
import org.sonar.plugins.html.node.TagNode;

/**
Expand All @@ -32,9 +36,29 @@
*/
public final class Thymeleaf {

/**
* Attributes that ask Thymeleaf to render an external fragment in place of (or inside) the
* current element. Statically, the resulting markup is opaque — the rendered fragment can
* supply text, controls, or both — so checks should treat these as "do not flag".
*/
public static final Set<String> FRAGMENT_INSERTION_ATTRIBUTES = Set.of("th:insert", "th:include", "th:replace");

private Thymeleaf() {
}

/**
* Returns whether the node carries any Thymeleaf fragment-insertion attribute
* ({@code th:insert}, {@code th:include}, {@code th:replace}).
*/
public static boolean hasFragmentInsertion(TagNode node) {
for (Attribute attribute : node.getAttributes()) {
if (FRAGMENT_INSERTION_ATTRIBUTES.contains(attribute.getName().toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}

/**
* Returns whether {@code th:attr} on the node assigns a value to the given attribute.
* Presence check only — the assigned value may itself be empty.
Expand Down Expand Up @@ -84,6 +108,33 @@ public static boolean isEmptyAssignmentValue(String value) {
return false;
}

/**
* Returns whether the raw attribute value is absent, blank, or a quoted literal that resolves
* to whitespace. Combines a null/blank check with {@link #isEmptyAssignmentValue(String)} so
* callers do not have to trim and null-check separately.
*/
public static boolean isEmptyValue(@Nullable String value) {
if (value == null) {
return true;
}
String trimmed = value.trim();
return trimmed.isEmpty() || isEmptyAssignmentValue(trimmed);
}

/**
* Returns whether the node sets {@code attributeName} via either the literal {@code th:NAME}
* attribute or a {@code th:attr=NAME=...} assignment, with a non-empty value. This is the
* Thymeleaf-only counterpart used to enrich attribute-presence checks; combine it with a
* plain property lookup at the call site when both forms should be accepted.
*/
public static boolean hasNonEmptyThymeleafAttribute(TagNode node, String attributeName) {
String literalValue = node.getAttribute("th:" + attributeName.toLowerCase(Locale.ROOT));
if (!isEmptyValue(literalValue)) {
return true;
}
return !isEmptyValue(getAttrAssignmentValue(node, attributeName));
}

private static List<String> splitAssignments(String thAttrValue) {
List<String> assignments = new ArrayList<>();
StringBuilder currentAssignment = new StringBuilder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,40 @@
*/
package org.sonar.plugins.html.api.accessibility;

import java.util.Set;
import org.sonar.plugins.html.api.Thymeleaf;
import org.sonar.plugins.html.node.TagNode;

import static org.sonar.plugins.html.api.HtmlConstants.isInteractiveElement;

public class AccessibilityUtils {

/**
* Attributes that template engines use to inject text content into an element at render time:
* Thymeleaf {@code th:text}/{@code th:utext} and Vue {@code v-text}/{@code v-html}. Centralized
* here so accessibility checks that ask "does this element get its text from a template?" all
* see the same definition.
*/
public static final Set<String> TEMPLATE_TEXT_ATTRIBUTES = Set.of("th:text", "th:utext", "v-text", "v-html");

private AccessibilityUtils() {
// utility class
}

/**
* Returns whether {@code element} carries any template-text attribute with a usable value,
* including {@code th:attr} assignments to {@code text} or {@code utext}.
*/
public static boolean hasNonEmptyTemplateTextAttribute(TagNode element) {
for (String attributeName : TEMPLATE_TEXT_ATTRIBUTES) {
if (!Thymeleaf.isEmptyValue(element.getAttribute(attributeName))) {
return true;
}
}
return Thymeleaf.hasNonEmptyThymeleafAttribute(element, "text")
|| Thymeleaf.hasNonEmptyThymeleafAttribute(element, "utext");
}

public static boolean isHiddenFromScreenReader(TagNode element) {
return (
(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.sonar.plugins.html.node.TagNode;
import org.sonar.plugins.html.node.TextNode;

import static org.sonar.plugins.html.api.accessibility.AccessibilityUtils.hasNonEmptyTemplateTextAttribute;
import static org.sonar.plugins.html.api.accessibility.AccessibilityUtils.isHiddenFromScreenReader;

@Rule(key = "S6827")
Expand Down Expand Up @@ -113,7 +114,7 @@ private static boolean hasContent(TagNode element) {
return true;
}
}
return element.hasProperty("title") || element.hasProperty("aria-label") || element.hasAttribute("th:text")
|| element.hasAttribute("th:utext") || element.hasAttribute("v-text") || element.hasAttribute("v-html");
return element.hasProperty("title") || element.hasProperty("aria-label")
|| hasNonEmptyTemplateTextAttribute(element);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.sonar.check.Rule;
import org.sonar.plugins.html.api.Helpers;
import org.sonar.plugins.html.api.BufferStack;
import org.sonar.plugins.html.api.accessibility.AccessibilityUtils;
import org.sonar.plugins.html.checks.AbstractPageCheck;
import org.sonar.plugins.html.node.DirectiveNode;
import org.sonar.plugins.html.node.ExpressionNode;
Expand All @@ -38,11 +39,6 @@ public class HeadingHasAccessibleContentCheck extends AbstractPageCheck {
"aria-hidden"
);

private final List<String> vueJsContentLikeAttributes = List.of(
"v-html",
"v-text"
);

private final BufferStack bufferStack = new BufferStack();

private final Deque<TagNode> openingHeadingTags = new ArrayDeque<>();
Expand All @@ -65,8 +61,8 @@ public void startElement(TagNode node) {
}
}

// vueJS attributes that maps to content are considered as content
vueJsContentLikeAttributes.forEach(attributeName -> {
// template-text attributes (Thymeleaf th:text/th:utext, Vue v-text/v-html) are content
AccessibilityUtils.TEMPLATE_TEXT_ATTRIBUTES.forEach(attributeName -> {
String nodeAttribute = node.getAttribute(attributeName);

if (nodeAttribute != null && !nodeAttribute.isBlank() && bufferStack.getLevel() > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@
import java.util.Set;
import java.util.regex.Pattern;
import org.sonar.check.Rule;
import org.sonar.plugins.html.api.Helpers;
import org.sonar.plugins.html.api.Thymeleaf;
import org.sonar.plugins.html.api.accessibility.AccessibilityUtils;
import org.sonar.plugins.html.checks.AbstractPageCheck;
import org.sonar.plugins.html.node.CommentNode;
import org.sonar.plugins.html.node.Attribute;
import org.sonar.plugins.html.node.DirectiveNode;
import org.sonar.plugins.html.node.ExpressionNode;
import org.sonar.plugins.html.node.Node;
Expand Down Expand Up @@ -59,42 +62,68 @@ public void startDocument(List<Node> nodes) {
public void startElement(TagNode node) {
if (isLabel(node)) {
label = node;
foundControl = hasForAttribute(label);
foundAccessibleLabel = false;
foundControl = hasControlAssociationHint(label);
foundAccessibleLabel = hasAccessibleTextHint(label);
foundLabelBodyContent = false;
// A fragment-rendered label is opaque to static analysis — accept both axes.
if (Thymeleaf.hasFragmentInsertion(label)) {
foundControl = true;
foundAccessibleLabel = true;
}
} else {
if (label != null) {
foundLabelBodyContent = true;
}
if (isControl(node)) {
foundControl = true;
}
}
if (hasAccessibleLabel(node)) {
foundAccessibleLabel = true;
if (label != null && hasAccessibleTextHint(node)) {
foundAccessibleLabel = true;
}
// Razor view-component or <partial> child can supply both text and control.
if (label != null && Helpers.isRazorFragmentTagHelper(node)) {
foundControl = true;
foundAccessibleLabel = true;
}
}
}

private static boolean hasForAttribute(TagNode label) {
return label.hasProperty("for") ||
label.hasProperty("htmlFor") ||
// Angular binding
label.hasProperty("[for]") ||
// Vue shorthand binding
label.hasProperty(":for") ||
// Vue full binding syntax
label.hasProperty("v-bind:for") ||
// ASP.NET Core Tag Helper
label.hasProperty("asp-for");
private static boolean hasControlAssociationHint(TagNode label) {
return hasPropertyHint(label, "for")
|| hasPropertyHint(label, "htmlFor")
|| hasAttributeHint(label, "asp-for")
|| Thymeleaf.hasNonEmptyThymeleafAttribute(label, "for");
}

private static boolean hasAccessibleLabel(TagNode node) {
return
node.hasProperty("alt") ||
node.hasProperty("aria-labelledby") ||
node.hasProperty("aria-label") ||
private static boolean hasAccessibleTextHint(TagNode node) {
return hasPropertyHint(node, "alt")
|| hasPropertyHint(node, "aria-labelledby")
|| hasPropertyHint(node, "aria-label")
// Angular [innerText]/[innerHTML]/[textContent] write text content at runtime.
|| hasPropertyHint(node, "innerText")
|| hasPropertyHint(node, "innerHTML")
|| hasPropertyHint(node, "textContent")
// Thymeleaf th:aria-label / th:attr="aria-label=..." (and aria-labelledby/alt variants).
|| Thymeleaf.hasNonEmptyThymeleafAttribute(node, "aria-label")
|| Thymeleaf.hasNonEmptyThymeleafAttribute(node, "aria-labelledby")
|| Thymeleaf.hasNonEmptyThymeleafAttribute(node, "alt")
|| AccessibilityUtils.hasNonEmptyTemplateTextAttribute(node)
// see https://sonarsource.github.io/rspec/#/rspec/S1926
"FMT:MESSAGE".equalsIgnoreCase(node.getNodeName());
|| "FMT:MESSAGE".equalsIgnoreCase(node.getNodeName());
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.

// Property lookup that accepts Angular/Vue binding forms even with empty value — the binding name itself is the hint.
private static boolean hasPropertyHint(TagNode node, String propertyName) {
Attribute property = node.getProperty(propertyName);
return property != null && (isBindingForm(property, propertyName) || !Thymeleaf.isEmptyValue(property.getValue()));
}

private static boolean hasAttributeHint(TagNode node, String attributeName) {
return !Thymeleaf.isEmptyValue(node.getAttribute(attributeName));
}

private static boolean isBindingForm(Attribute attribute, String canonicalName) {
return !canonicalName.equalsIgnoreCase(attribute.getName());
}

private static boolean isLabel(TagNode node) {
Expand All @@ -116,13 +145,11 @@ public void characters(TextNode textNode) {
if (RAZOR_CONTROL_PATTERN.matcher(textNode.getCode()).find()) {
foundControl = true;
}
}
}

@Override
public void comment(CommentNode node) {
if (label != null) {
foundLabelBodyContent = true;
// Razor fragment rendering (@Html.PartialAsync, @RenderBody, ...) is opaque.
if (Helpers.isRazorFile(getHtmlSourceCode()) && Helpers.containsRazorFragmentRendering(textNode.getCode())) {
foundControl = true;
foundAccessibleLabel = true;
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
}
}

Expand All @@ -146,7 +173,7 @@ public void expression(ExpressionNode node) {
@Override
public void endElement(TagNode node) {
if (isLabel(node)) {
if (label != null && label.hasProperty("asp-for") && !foundLabelBodyContent) {
if (label != null && hasAttributeHint(label, "asp-for") && !foundLabelBodyContent) {
foundAccessibleLabel = true;
}
if ((!foundAccessibleLabel || !foundControl) && label != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
*/
package org.sonar.plugins.html.checks.sonar;

import java.util.Locale;
import org.sonar.check.Rule;
import org.sonar.plugins.html.api.Thymeleaf;
import org.sonar.plugins.html.checks.AbstractPageCheck;
Expand Down Expand Up @@ -99,14 +98,7 @@ private static boolean hasNonEmptyAttribute(TagNode node, String attributeName)
if (value != null && !value.trim().isEmpty()) {
return true;
}

String thymeleafValue = node.getAttribute("th:" + attributeName.toLowerCase(Locale.ROOT));
if (thymeleafValue != null) {
return !Thymeleaf.isEmptyAssignmentValue(thymeleafValue.trim());
}

String thymeleafAssignedValue = Thymeleaf.getAttrAssignmentValue(node, attributeName);
return thymeleafAssignedValue != null && !Thymeleaf.isEmptyAssignmentValue(thymeleafAssignedValue);
return Thymeleaf.hasNonEmptyThymeleafAttribute(node, attributeName);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import javax.annotation.Nullable;
import org.sonar.check.Rule;
import org.sonar.plugins.html.api.Helpers;
import org.sonar.plugins.html.api.Thymeleaf;
import org.sonar.plugins.html.checks.AbstractPageCheck;
import org.sonar.plugins.html.node.Attribute;
import org.sonar.plugins.html.node.Node;
Expand All @@ -32,8 +33,6 @@
@Rule(key = "S5256")
public class TableWithoutHeaderCheck extends AbstractPageCheck {

private static final Set<String> THYMELEAF_FRAGMENT_INSERTION_KEYWORDS = Set.of("th:insert", "th:include", "th:replace");

private final Set<TagNode> tablesWithRazorFragmentRendering = new HashSet<>();

@Override
Expand Down Expand Up @@ -127,7 +126,7 @@ private static boolean hasThymeleafFragmentInsertionFromTableAttribute(List<Attr

private static boolean hasThymeleafFragmentInsertionFromTableChildren(List<TagNode> nodes) {
for (TagNode node : nodes) {
if (node.getAttributes().stream().map(Attribute::getName).anyMatch(THYMELEAF_FRAGMENT_INSERTION_KEYWORDS::contains)
if (node.getAttributes().stream().map(Attribute::getName).anyMatch(Thymeleaf.FRAGMENT_INSERTION_ATTRIBUTES::contains)
|| hasThymeleafFragmentInsertionFromTableChildren(node.getChildren())) {
return true;
}
Expand Down
Loading
Loading