From 3846188d7e7dae623b894dc5d928d0408dabc9d5 Mon Sep 17 00:00:00 2001 From: yassin-kammoun-sonarsource Date: Wed, 14 Feb 2024 10:27:50 +0100 Subject: [PATCH] SONARHTML-207 Create rule S6827: Anchors should contain accessible content --- .../test/resources/expected/Web-S6827.json | 549 ++++++++++++++++++ .../AnchorsHaveContentCheck.java | 114 ++++ .../plugins/html/rules/CheckClasses.java | 3 + .../org/sonar/l10n/web/rules/Web/S6827.html | 32 + .../org/sonar/l10n/web/rules/Web/S6827.json | 24 + .../l10n/web/rules/Web/Sonar_way_profile.json | 1 + .../AnchorsHaveContentCheckTest.java | 56 ++ .../checks/AnchorsHaveContentCheck.html | 15 + .../checks/AnchorsHaveContentCheck.php | 2 + 9 files changed, 796 insertions(+) create mode 100644 its/ruling/src/test/resources/expected/Web-S6827.json create mode 100644 sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheck.java create mode 100644 sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6827.html create mode 100644 sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6827.json create mode 100644 sonar-html-plugin/src/test/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheckTest.java create mode 100644 sonar-html-plugin/src/test/resources/checks/AnchorsHaveContentCheck.html create mode 100644 sonar-html-plugin/src/test/resources/checks/AnchorsHaveContentCheck.php diff --git a/its/ruling/src/test/resources/expected/Web-S6827.json b/its/ruling/src/test/resources/expected/Web-S6827.json new file mode 100644 index 000000000..7901f1c17 --- /dev/null +++ b/its/ruling/src/test/resources/expected/Web-S6827.json @@ -0,0 +1,549 @@ +{ +"project:Silverpeas-Core-master/lib-core/src/test/resources/com/silverpeas/wysiwyg/dynamicvalue/test-without_keys.html": [ +9, +16, +30 +], +"project:Silverpeas-Core-master/lib-core/src/test/resources/com/silverpeas/wysiwyg/dynamicvalue/test.html": [ +9, +16, +30 +], +"project:Silverpeas-Core-master/src/site/resources/legal/agpl-3.0-standalone.html": [ +17, +68, +70, +110, +152, +177, +193, +206, +247, +349, +417, +445, +456, +481, +550, +562, +583, +609, +620, +632, +643 +], +"project:Silverpeas-Core-master/war-core/src/main/webapp/admin/jsp/TopBarSilverpeasV5.jsp": [ +156 +], +"project:Silverpeas-Core-master/war-core/src/main/webapp/attachment/jsp/displayAttachedFiles.jsp": [ +286 +], +"project:Silverpeas-Core-master/war-core/src/main/webapp/form/jsp/editor.jsp": [ +378 +], +"project:external_webkit-jb-mr1/LayoutTests/fast/dom/DOMImplementation/detached-doctype.html": [ +1 +], +"project:external_webkit-jb-mr1/LayoutTests/fast/encoding/high-bit-latin1.html": [ +9 +], +"project:external_webkit-jb-mr1/LayoutTests/fast/encoding/url-host-name-non-ascii.html": [ +5 +], +"project:external_webkit-jb-mr1/LayoutTests/http/tests/ssl/referer-301.html": [ +2 +], +"project:external_webkit-jb-mr1/LayoutTests/http/tests/ssl/referer-303.html": [ +2 +], +"project:external_webkit-jb-mr1/PerformanceTests/Parser/resources/html5.html": [ +195, +195, +198, +200, +208, +213, +237, +1252, +1253, +1254, +1255, +1259, +1260, +1262, +1263, +1264, +83770, +83775, +83780, +83794, +83801, +83805, +83810, +83815, +83841, +83845, +83850, +83859, +83864, +83868, +83872, +83876, +83897, +83924, +83963, +84004, +84018, +84040, +84064, +84220, +84240, +84254, +84259, +84278, +84283, +84292, +84304, +84308, +84312, +84321, +84325, +84359, +84363, +84368, +84372, +84900 +], +"project:external_webkit-jb-mr1/PerformanceTests/XSSFilter/resources/target-for-large-post-many-inline-scripts-and-events.html": [ +7, +11, +15, +19, +23, +27, +31, +35, +39, +43, +47, +51, +55, +59, +63, +67, +71, +75, +79, +83, +87, +91, +95, +99, +103, +107, +111, +115, +119, +123, +127, +131, +135, +139, +143, +147, +151, +155, +159, +163, +167, +171, +175, +179, +183, +187, +191, +195, +199, +203, +207, +211, +215, +219, +223, +227, +231, +235, +239, +243, +247, +251, +255, +259, +263, +267, +271, +275, +279, +283, +287, +291, +295, +299, +303, +307, +311, +315, +319, +323, +327, +331, +335, +339, +343, +347, +351, +355, +359, +363, +367, +371, +375, +379, +383, +387, +391, +395, +399, +403, +407, +411, +415, +419, +423, +427, +431, +435, +439, +443, +447, +451, +455, +459, +463, +467, +471, +475, +479, +483, +487, +491, +495, +499, +503, +507, +511, +515, +519, +523, +527, +531, +535, +539, +543, +547, +551, +555, +559, +563, +567, +571, +575, +579, +583, +587, +591, +595, +599, +603, +607, +611, +615, +619, +623, +627, +631, +635, +639, +643, +647, +651, +655, +659, +663, +667, +671, +675, +679, +683, +687, +691, +695, +699, +703, +707, +711, +715, +719, +723, +727, +731, +735, +739, +743, +747, +751, +755, +759, +763, +767, +771, +775, +779, +783, +787, +791, +795, +799, +803, +807, +811, +815, +819, +823, +827, +831, +835, +839, +843, +847, +851, +855, +859, +863, +867, +871, +875, +879, +883, +887, +891, +895, +899, +903, +907, +911, +915, +919, +923, +927, +931, +935, +939, +943, +947, +951, +955, +959, +963, +967, +971, +975, +979, +983, +987, +991, +995, +999, +1003, +1007, +1011, +1015, +1019, +1023, +1027, +1031, +1035, +1039, +1043, +1047, +1051, +1055, +1059, +1063, +1067, +1071, +1075, +1079, +1083, +1087, +1091, +1095, +1099, +1103, +1107, +1111, +1115, +1119, +1123, +1127, +1131, +1135, +1139, +1143, +1147, +1151, +1155, +1159, +1163, +1167, +1171, +1175, +1179, +1183, +1187, +1191, +1195, +1199, +1203, +1207, +1211, +1215, +1219, +1223, +1227, +1231, +1235, +1239, +1243, +1247, +1251, +1255, +1259, +1263, +1267, +1271, +1275, +1279, +1283 +], +"project:external_webkit-jb-mr1/Source/JavaScriptCore/tests/mozilla/README-jsDriver.html": [ +147 +], +"project:external_webkit-jb-mr1/Source/JavaScriptCore/tests/mozilla/expected.html": [ +5, +17, +19, +26, +32, +40, +48, +54, +62, +69, +76, +84, +91, +101, +115, +122, +129, +139, +149, +155, +161, +168, +175, +183, +192, +198, +206, +215, +222, +231, +237, +243, +249, +255, +263, +269, +275, +281, +287, +293, +299, +305, +313, +324, +330, +336, +342, +393, +399, +411, +422, +428, +434, +447 +], +"project:external_webkit-jb-mr1/Source/JavaScriptCore/tests/mozilla/importList.html": [ +63 +], +"project:external_webkit-jb-mr1/Source/JavaScriptCore/tests/mozilla/menufoot.html": [ +5 +], +"project:external_webkit-jb-mr1/Source/JavaScriptCore/tests/mozilla/menuhead.html": [ +132 +], +"project:external_webkit-jb-mr1/Source/WebCore/manual-tests/empty-link-target.html": [ +25 +], +"project:external_webkit-jb-mr1/Source/WebCore/manual-tests/linkjump-2.html": [ +28, +32 +], +"project:external_webkit-jb-mr1/Source/WebCore/manual-tests/linkjump-3.html": [ +50 +], +"project:external_webkit-jb-mr1/Source/WebCore/manual-tests/linkjump-4.html": [ +109 +], +"project:sonar-master/sonar-server/src/main/webapp/WEB-INF/app/views/manual_measures/index.html.erb": [ +44 +], +"project:sonar-master/sonar-server/src/main/webapp/WEB-INF/app/views/profiles/compare.html.erb": [ +46, +73, +97 +], +"project:sonar-master/sonar-server/src/main/webapp/WEB-INF/app/views/rules_configuration/_rule.html.erb": [ +48 +], +"project:sonar-master/sonar-server/src/main/webapp/WEB-INF/app/views/shared/_source_issue_form.html.erb": [ +1 +], +"project:voten/resources/assets/js/components/auth/LeftSidebar.vue": [ +12, +27, +78, +92, +106, +120, +134, +151, +164, +177 +], +"project:voten/resources/views/backend/server-controls.blade.php": [ +150 +] +} diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheck.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheck.java new file mode 100644 index 000000000..2edf139fd --- /dev/null +++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheck.java @@ -0,0 +1,114 @@ +/* + * SonarSource HTML analyzer :: Sonar Plugin + * Copyright (c) 2010-2024 SonarSource SA and Matthijs Galesloot + * sonarqube@googlegroups.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sonar.plugins.html.checks.accessibility; + +import java.util.ArrayDeque; +import java.util.Deque; +import org.sonar.check.Rule; +import org.sonar.plugins.html.checks.AbstractPageCheck; +import org.sonar.plugins.html.node.DirectiveNode; +import org.sonar.plugins.html.node.ExpressionNode; +import org.sonar.plugins.html.node.TagNode; +import org.sonar.plugins.html.node.TextNode; + +@Rule(key = "S6827") +public class AnchorsHaveContentCheck extends AbstractPageCheck { + + private static final String MESSAGE = "Anchors must have content and the content must be accessible by a screen reader."; + + private Deque anchors = new ArrayDeque<>(); + + private static class Anchor { + final TagNode node; + boolean hasContent; + + private Anchor(TagNode node, boolean hasContent) { + this.node = node; + this.hasContent = hasContent; + } + } + + @Override + public void startElement(TagNode element) { + if (isAnchor(element)) { + anchors.push(new Anchor(element, hasContent(element))); + } + } + + @Override + public void endElement(TagNode element) { + if (isAnchor(element) && !anchors.isEmpty()) { + var anchor = anchors.pop(); + if (!anchor.hasContent) { + createViolation(anchor.node.getStartLinePosition(), MESSAGE); + } + } + } + + @Override + public void endDocument() { + anchors.clear(); + } + + @Override + public void characters(TextNode node) { + if (!anchors.isEmpty()) { + var anchor = anchors.peek(); + anchor.hasContent = anchor.hasContent || !node.isBlank(); + } + } + + @Override + public void directive(DirectiveNode node) { + if (!anchors.isEmpty()) { + var anchor = anchors.peek(); + anchor.hasContent = anchor.hasContent || "?php".equals(node.getNodeName()); + } + } + + @Override + public void expression(ExpressionNode node) { + if (!anchors.isEmpty()) { + var anchor = anchors.peek(); + anchor.hasContent = true; + } + } + + private static boolean isAnchor(TagNode element) { + return "a".equalsIgnoreCase(element.getNodeName()); + } + + private static boolean hasContent(TagNode element) { + var children = element.getChildren(); + for (TagNode child : children) { + if (!isHidden(child)) { + return true; + } + } + if (element.hasProperty("title") || element.hasProperty("aria-label")) { + return true; + } + return false; + } + + private static boolean isHidden(TagNode element) { + return ("input".equalsIgnoreCase(element.getNodeName()) + && "hidden".equalsIgnoreCase(element.getPropertyValue("type"))) + || "true".equalsIgnoreCase(element.getPropertyValue("aria-hidden")); + } +} diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/rules/CheckClasses.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/rules/CheckClasses.java index 70ebab226..0b61e3461 100644 --- a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/rules/CheckClasses.java +++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/rules/CheckClasses.java @@ -18,6 +18,8 @@ package org.sonar.plugins.html.rules; import java.util.List; + +import org.sonar.plugins.html.checks.accessibility.AnchorsHaveContentCheck; import org.sonar.plugins.html.checks.attributes.IllegalAttributeCheck; import org.sonar.plugins.html.checks.attributes.RequiredAttributeCheck; import org.sonar.plugins.html.checks.coding.ComplexityCheck; @@ -89,6 +91,7 @@ public final class CheckClasses { private static final List> CLASSES = List.of( AbsoluteURICheck.class, + AnchorsHaveContentCheck.class, AvoidHtmlCommentCheck.class, ChildElementRequiredCheck.class, ComplexityCheck.class, diff --git a/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6827.html b/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6827.html new file mode 100644 index 000000000..54d48caad --- /dev/null +++ b/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6827.html @@ -0,0 +1,32 @@ +

Why is this an issue?

+

Anchors, represented by the a tag in HTML, usually contain a hyperlink that users can click to navigate to different sections of a +website or different websites altogether.

+

However, when anchors do not have content or when the content is hidden from screen readers using the aria-hidden property, it creates +a significant accessibility issue. If an anchor’s content is hidden or non-existent, visually impaired users may not be able to understand the purpose +of the anchor or navigate the website effectively.

+

This rule checks that anchors do not use the aria-hidden property and have content provided either between the tags or as +aria-label or title property.

+

How to fix it

+

Ensure that anchors either have content or an aria-label or title attribute, and they should not use the +aria-hidden property.

+

Code examples

+

Noncompliant code example

+
+<a aria-hidden>link to my site</a>
+
+

Compliant solution

+
+<a>link to my site</a>
+
+

Resources

+

Documentation

+ +

Standards

+ + diff --git a/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6827.json b/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6827.json new file mode 100644 index 000000000..2b0acc1bf --- /dev/null +++ b/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6827.json @@ -0,0 +1,24 @@ +{ + "title": "Anchors should contain accessible content", + "type": "CODE_SMELL", + "status": "ready", + "remediation": { + "func": "Constant\/Issue", + "constantCost": "5min" + }, + "tags": [ + "accessibility" + ], + "defaultSeverity": "Minor", + "ruleSpecification": "RSPEC-6827", + "sqKey": "S6827", + "scope": "All", + "quickfix": "infeasible", + "code": { + "impacts": { + "MAINTAINABILITY": "LOW", + "RELIABILITY": "LOW" + }, + "attribute": "CONVENTIONAL" + } +} diff --git a/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/Sonar_way_profile.json b/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/Sonar_way_profile.json index f594a0308..5c1ef369c 100644 --- a/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/Sonar_way_profile.json +++ b/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/Sonar_way_profile.json @@ -26,6 +26,7 @@ "S5260", "S5264", "S5725", + "S6827", "ServerSideImageMapsCheck", "TableHeaderHasIdOrScopeCheck", "TableWithoutCaptionCheck", diff --git a/sonar-html-plugin/src/test/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheckTest.java b/sonar-html-plugin/src/test/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheckTest.java new file mode 100644 index 000000000..dfe64488d --- /dev/null +++ b/sonar-html-plugin/src/test/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheckTest.java @@ -0,0 +1,56 @@ +/* + * SonarSource HTML analyzer :: Sonar Plugin + * Copyright (c) 2010-2024 SonarSource SA and Matthijs Galesloot + * sonarqube@googlegroups.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sonar.plugins.html.checks.accessibility; + +import java.io.File; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.sonar.plugins.html.checks.CheckMessagesVerifierRule; +import org.sonar.plugins.html.checks.TestHelper; +import org.sonar.plugins.html.visitor.HtmlSourceCode; + +class AnchorsHaveContentCheckTest { + + @RegisterExtension + public CheckMessagesVerifierRule checkMessagesVerifier = new CheckMessagesVerifierRule(); + + @Test + void html() throws Exception { + HtmlSourceCode sourceCode = TestHelper.scan( + new File("src/test/resources/checks/AnchorsHaveContentCheck.html"), + new AnchorsHaveContentCheck()); + + checkMessagesVerifier.verify(sourceCode.getIssues()) + .next().atLine(1).withMessage("Anchors must have content and the content must be accessible by a screen reader.") + .next().atLine(2) + .next().atLine(3) + .next().atLine(4) + .noMore(); + } + + @Test + void php() throws Exception { + HtmlSourceCode sourceCode = TestHelper.scan( + new File("src/test/resources/checks/AnchorsHaveContentCheck.php"), + new AnchorsHaveContentCheck()); + + checkMessagesVerifier.verify(sourceCode.getIssues()) + .next().atLine(1) + .noMore(); + } +} diff --git a/sonar-html-plugin/src/test/resources/checks/AnchorsHaveContentCheck.html b/sonar-html-plugin/src/test/resources/checks/AnchorsHaveContentCheck.html new file mode 100644 index 000000000..7dfdd7a0c --- /dev/null +++ b/sonar-html-plugin/src/test/resources/checks/AnchorsHaveContentCheck.html @@ -0,0 +1,15 @@ + + + + + +Click me + + + + + + +<% "hello" %> +
+ diff --git a/sonar-html-plugin/src/test/resources/checks/AnchorsHaveContentCheck.php b/sonar-html-plugin/src/test/resources/checks/AnchorsHaveContentCheck.php new file mode 100644 index 000000000..25a385834 --- /dev/null +++ b/sonar-html-plugin/src/test/resources/checks/AnchorsHaveContentCheck.php @@ -0,0 +1,2 @@ + +