Skip to content

Commit

Permalink
SONARHTML-191 Create rule S6850: Heading elements should have accessi…
Browse files Browse the repository at this point in the history
…ble content (#269)

* Create rule S6850: Heading elements should have accessible content

* Fix formatting according to SonarQube Quality Gate

* Support descendants content

* Fix violations

* Improve code coverage

* Add updated rule manifest

* Remove calls to parent implementations

* Fix issues detected by PR review
  • Loading branch information
ericmorand-sonarsource authored Feb 16, 2024
1 parent 4de7dfe commit b913dbc
Show file tree
Hide file tree
Showing 14 changed files with 641 additions and 0 deletions.
16 changes: 16 additions & 0 deletions its/ruling/src/test/resources/expected/Web-S6850.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"project:voten/resources/assets/js/components/pages/Credits.vue": [
30,
44,
62,
80,
98,
134,
148,
165,
182
],
"project:voten/resources/views/backend/server-controls.blade.php": [
142
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* SonarSource HTML analyzer :: Sonar Plugin
* Copyright (c) 2010-2024 SonarSource SA and Matthijs Galesloot
* [email protected]
*
* 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.api;

import java.util.ArrayDeque;
import java.util.Deque;

public class BufferStack {
Deque<StringBuffer> buffers = new ArrayDeque<>();

public int getLevel() {
return buffers.size();
}

public void start() {
buffers.push(new StringBuffer());
}

public void write(String data) {
if (!buffers.isEmpty()) {
StringBuffer active = buffers.getFirst();

active.append(data);
}
}

public String getAndFlush() {
String content = getContents();

endAndFlush();

return content;
}

public String getContents() {
StringBuffer active = buffers.getFirst();

return active != null ? active.toString() : "";
}

public void endAndFlush() {
flush();

buffers.pop();
}

public void flush() {
StringBuffer active = buffers.pop();

write(active.toString());

buffers.push(new StringBuffer());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* SonarSource HTML analyzer :: Sonar Plugin
* Copyright (c) 2010-2024 SonarSource SA and Matthijs Galesloot
* [email protected]
*
* 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.api;

import org.sonar.plugins.html.node.TagNode;

public class Helpers {
private Helpers() {
}

public static boolean isHeadingTag(TagNode node) {
return node.getNodeName().length() == 2 &&
Character.toUpperCase(node.getNodeName().charAt(0)) == 'H' &&
node.getNodeName().charAt(1) >= '1' &&
node.getNodeName().charAt(1) <= '6';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
*/
package org.sonar.plugins.html.api;

import java.util.List;

public class HtmlConstants {

/** The language key. */
Expand All @@ -33,6 +35,119 @@ public class HtmlConstants {
public static final String FILE_EXTENSIONS_DEF_VALUE = ".html,.xhtml,.cshtml,.vbhtml,.aspx,.ascx,.rhtml,.erb,.shtm,.shtml,.cmp,.twig";
public static final String JSP_FILE_EXTENSIONS_PROP_KEY = "sonar.jsp.file.suffixes";
public static final String JSP_FILE_EXTENSIONS_DEF_VALUE = ".jsp,.jspf,.jspx";
public static final List<String> KNOWN_HTML_TAGS = List.of(
"a",
"area",
"abbr",
"address",
"article",
"aside",
"audio",
"b",
"base",
"blockquote",
"body",
"br",
"button",
"canvas",
"caption",
"cite",
"code",
"col",
"colgroup",
"data",
"datalist",
"dd",
"del",
"details",
"dfn",
"dialog",
"div",
"dl",
"dt",
"em",
"embed",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"head",
"header",
"hgroup",
"hr",
"i",
"iframe",
"img",
"input",
"ins",
"kbd",
"label",
"legend",
"li",
"link",
"main",
"map",
"mark",
"menu",
"meta",
"meter",
"nav",
"noscript",
"object",
"ol",
"optgroup",
"option",
"output",
"p",
"param",
"picture",
"pre",
"progress",
"q",
"rp",
"rt",
"ruby",
"s",
"samp",
"script",
"search",
"section",
"select",
"small",
"source",
"span",
"strong",
"style",
"sub",
"summary",
"sup",
"svg",
"table",
"tbody",
"td",
"template",
"textarea",
"tfoot",
"th",
"thead",
"time",
"title",
"tr",
"track",
"u",
"ul",
"var",
"video",
"wbr"
);

private HtmlConstants() {
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* SonarSource HTML analyzer :: Sonar Plugin
* Copyright (c) 2010-2024 SonarSource SA and Matthijs Galesloot
* [email protected]
*
* 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 org.sonar.check.Rule;
import org.sonar.plugins.html.api.Helpers;
import org.sonar.plugins.html.api.BufferStack;
import org.sonar.plugins.html.api.HtmlConstants;
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;
import org.sonar.plugins.html.node.Attribute;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;

@Rule(key = "S6850")
public class HeadingHasAccessibleContentCheck extends AbstractPageCheck {
private final List<String> invalidAttributes = List.of(
"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<>();

@Override
public void startElement(TagNode node) {
if (Helpers.isHeadingTag(node)) {
bufferStack.start();
openingHeadingTags.push(node);

if (hasAnInvalidAttribute(node)) {
createViolation(node);
}
} else {
String nodeName = node.getNodeName();

// tags that are not part of the known HTML tags list are considered as content
if (!HtmlConstants.KNOWN_HTML_TAGS.contains(nodeName)) {
bufferStack.write(nodeName);
}
}

// vueJS attributes that maps to content are considered as content
vueJsContentLikeAttributes.forEach(attributeName -> {
String nodeAttribute = node.getAttribute(attributeName);

if (nodeAttribute != null && !nodeAttribute.isBlank() && bufferStack.getLevel() > 0) {
bufferStack.write(nodeAttribute);
}
});
}

@Override
public void endElement(TagNode node) {
if (Helpers.isHeadingTag(node) && !openingHeadingTags.isEmpty()) {
String content = bufferStack.getAndFlush();
TagNode openingTag = openingHeadingTags.pop();

if (content.isBlank()) {
createViolation(openingTag);
}
}
}

@Override
public void endDocument() {
openingHeadingTags.clear();
}

@Override
public void characters(TextNode textNode) {
if (bufferStack.getLevel() > 0) {
bufferStack.write(textNode.toString());
}
}

@Override
public void expression(ExpressionNode expressionNode) {
if (bufferStack.getLevel() > 0) {
bufferStack.write(expressionNode.toString());
}
}

@Override
public void directive(DirectiveNode directiveNode) {
if (bufferStack.getLevel() > 0) {
bufferStack.write(directiveNode.toString());
}
}

private boolean hasAnInvalidAttribute(TagNode node) {
return node.getAttributes().stream()
.map(Attribute::getName)
.anyMatch(invalidAttributes::contains);
}

private void createViolation(TagNode node) {
super.createViolation(node.getStartLinePosition(), "Headings must have content and the content must be accessible by a screen reader.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import org.sonar.plugins.html.checks.sonar.FieldsetWithoutLegendCheck;
import org.sonar.plugins.html.checks.sonar.FlashUsesBothObjectAndEmbedCheck;
import org.sonar.plugins.html.checks.sonar.FrameWithoutTitleCheck;
import org.sonar.plugins.html.checks.accessibility.HeadingHasAccessibleContentCheck;
import org.sonar.plugins.html.checks.sonar.ImgWithoutAltCheck;
import org.sonar.plugins.html.checks.sonar.ImgWithoutWidthOrHeightCheck;
import org.sonar.plugins.html.checks.sonar.IndistinguishableSimilarElementsCheck;
Expand Down Expand Up @@ -103,6 +104,7 @@ public final class CheckClasses {
DoubleQuotesCheck.class,
DynamicJspIncludeCheck.class,
FileLengthCheck.class,
HeadingHasAccessibleContentCheck.class,
IllegalElementCheck.class,
IllegalTabCheck.class,
IllegalTagLibsCheck.class,
Expand Down
Loading

0 comments on commit b913dbc

Please sign in to comment.