feat(rome_js_analyze): rule `useAnchorContent`
MODIFY
crates/rome_diagnostics_categories/src/categories.rs
crates/rome_diagnostics_categories/src/categories.rs
@@ -53,6 +53,7 @@ define_dategories! {
"lint/nursery/noArrayIndexKey": "https://rome.tools/docs/lint/rules/noArrayIndexKey",
"lint/nursery/noDangerouslySetInnerHtmlWithChildren": "https://rome.tools/docs/lint/rules/noDangerouslySetInnerHtmlWithChildren",
"lint/nursery/noAutofocus": "https://rome.tools/docs/lint/rules/noAutofocus",
+ "lint/nursery/useAnchorContent": "https://rome.tools/docs/lint/rules/useAnchorContent",
"lint/nursery/useBlankTarget": "https://rome.tools/docs/lint/rules/useBlankTarget",
"lint/nursery/useValidAnchor": "https://rome.tools/docs/lint/rules/useValidAnchor",
"lint/nursery/noRestrictedGlobals": "https://rome.tools/docs/lint/rules/noRestrictedGlobals",

MODIFY
crates/rome_js_analyze/src/analyzers/nursery.rs
crates/rome_js_analyze/src/analyzers/nursery.rs
@@ -4,7 +4,8 @@ use rome_analyze::declare_group;
mod no_auto_focus;
mod no_new_symbol;
mod no_unreachable;
+mod use_anchor_content;
mod use_blank_target;
mod use_optional_chain;
mod use_valid_anchor;
-declare_group! { pub (crate) Nursery { name : "nursery" , rules : [self :: no_auto_focus :: NoAutoFocus , self :: no_new_symbol :: NoNewSymbol , self :: no_unreachable :: NoUnreachable , self :: use_blank_target :: UseBlankTarget , self :: use_optional_chain :: UseOptionalChain , self :: use_valid_anchor :: UseValidAnchor ,] } }
+declare_group! { pub (crate) Nursery { name : "nursery" , rules : [self :: no_auto_focus :: NoAutoFocus , self :: no_new_symbol :: NoNewSymbol , self :: no_unreachable :: NoUnreachable , self :: use_anchor_content :: UseAnchorContent , self :: use_blank_target :: UseBlankTarget , self :: use_optional_chain :: UseOptionalChain , self :: use_valid_anchor :: UseValidAnchor ,] } }

ADD
crates/rome_js_analyze/src/analyzers/nursery/use_anchor_content.rs
+use rome_analyze::context::RuleContext;
+use rome_analyze::{declare_rule, Ast, Rule, RuleDiagnostic};
+use rome_console::markup;
+use rome_js_syntax::{
+ JsAnyExpression, JsAnyLiteralExpression, JsAnyTemplateElement, JsxAnyAttributeValue,
+ JsxAnyChild, JsxAttribute, JsxElement, JsxReferenceIdentifier, JsxSelfClosingElement,
+};
+use rome_rowan::{declare_node_union, AstNode, AstNodeList};
+
+declare_rule! {
+ /// Enforce that anchor elements have content and that the content is accessible to screen readers.
+ ///
+ /// Accessible means that the content is not hidden using the `aria-hidden` attribute.
+ ///
+ /// ## Examples
+ ///
+ /// ### Invalid
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <a />
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <a></a>
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <a> </a>
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <a aria-hidden>content</a>
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <a><span aria-hidden="true">content</span></a
+ /// ```
+ ///
+ /// ## Valid
+ ///
+ /// ```jsx
+ /// <a>content</a>
+ /// ```
+ ///
+ /// ```jsx
+ /// <a dangerouslySetInnerHTML={{ __html: "foo" }} />
+ /// ```
+ ///
+ /// ```jsx
+ /// <a><TextWrapper aria-hidden={true} /> content</a>
+ /// ```
+ ///
+ /// ```jsx
+ /// <a><div aria-hidden="true"></div> content</a>
+ /// ```
+ pub(crate) UseAnchorContent {
+ version: "10.0.0",
+ name: "useAnchorContent",
+ recommended: false,
+ }
+}
+
+declare_node_union! {
+ pub(crate) UseAnchorContentNode = JsxElement | JsxSelfClosingElement
+}
+
+impl UseAnchorContentNode {
+ /// Check if the current element is an anchor
+ fn is_anchor(&self) -> Option<bool> {
+ Some(match self {
+ UseAnchorContentNode::JsxElement(element) => {
+ element.opening_element().ok()?.name().ok()?.text() == "a"
+ }
+ UseAnchorContentNode::JsxSelfClosingElement(element) => {
+ element.name().ok()?.text() == "a"
+ }
+ })
+ }
+
+ /// Check if the `a` element has the `aria-hidden` attribute set to true.
+ fn is_hidden_from_screen_reader(&self) -> bool {
+ match self {
+ UseAnchorContentNode::JsxElement(element) => {
+ if let Ok(opening_element) = element.opening_element() {
+ match opening_element.find_attribute_by_name("aria-hidden") {
+ Ok(Some(aria_hidden_attribute)) => {
+ is_aria_hidden_truthy(aria_hidden_attribute).unwrap_or(false)
+ }
+ _ => false,
+ }
+ } else {
+ false
+ }
+ }
+ UseAnchorContentNode::JsxSelfClosingElement(element) => {
+ match element.find_attribute_by_name("aria-hidden") {
+ Ok(Some(aria_hidden_attribute)) => {
+ is_aria_hidden_truthy(aria_hidden_attribute).unwrap_or(false)
+ }
+ _ => false,
+ }
+ }
+ }
+ }
+
+ /// Check if the `a` element has content accessible to screen readers.
+ /// Accessible means that the content is not hidden using the `aria-hidden` attribute.
+ fn has_accessible_child(&self) -> Option<bool> {
+ Some(match self {
+ UseAnchorContentNode::JsxElement(element) => element
+ .children()
+ .into_iter()
+ .any(|child| is_accessible_to_screen_reader(child).unwrap_or(true)),
+ UseAnchorContentNode::JsxSelfClosingElement(element) => element
+ .find_attribute_by_name("dangerouslySetInnerHTML")
+ .ok()?
+ .is_some(),
+ })
+ }
+}
+
+impl Rule for UseAnchorContent {
+ type Query = Ast<UseAnchorContentNode>;
+ type State = ();
+ type Signals = Option<Self::State>;
+ type Options = ();
+
+ fn run(ctx: &RuleContext<Self>) -> Self::Signals {
+ let node = ctx.query();
+ if !node.is_anchor()? {
+ return None;
+ }
+
+ // If there's no `aria-hidden` attribute on the `a` element,
+ // proceed to check the accessibility of its child elements
+ if !node.is_hidden_from_screen_reader() && node.has_accessible_child()? {
+ return None;
+ }
+
+ Some(())
+ }
+
+ fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
+ let node = ctx.query();
+ Some(RuleDiagnostic::new(
+ rule_category!(),
+ node.syntax().text_trimmed_range(),
+ markup! {
+ "Provide screen reader accessible content when using "<Emphasis>"`a`"</Emphasis>" elements."
+ }
+ ).footer_note(
+ markup! {
+ "All links on a page should have content that is accessible to screen readers."
+ }
+ ))
+ }
+}
+
+/// Check if the element is a text content for screen readers,
+/// or it is not hidden using the `aria-hidden` attribute
+fn is_accessible_to_screen_reader(element: JsxAnyChild) -> Option<bool> {
+ Some(match element {
+ JsxAnyChild::JsxText(text) => {
+ let value_token = text.value_token().ok()?;
+ value_token.text_trimmed().trim() != ""
+ }
+ JsxAnyChild::JsxElement(element) => {
+ let opening_element = element.opening_element().ok()?;
+
+ // We don't check if a component (e.g. <Text aria-hidden />) is using the `aria-hidden` property,
+ // since we don't have enough information about how the property is used.
+ let element_name = opening_element.name().ok()?;
+ if JsxReferenceIdentifier::can_cast(element_name.syntax().kind()) {
+ return None;
+ }
+
+ let aria_hidden_attribute = opening_element
+ .find_attribute_by_name("aria-hidden")
+ .ok()??;
+ !is_aria_hidden_truthy(aria_hidden_attribute)?
+ }
+ JsxAnyChild::JsxSelfClosingElement(element) => {
+ // We don't check if a component (e.g. <Text aria-hidden />) is using the `aria-hidden` property,
+ // since we don't have enough information about how the property is used.
+ let element_name = element.name().ok()?;
+ if JsxReferenceIdentifier::can_cast(element_name.syntax().kind()) {
+ return None;
+ }
+
+ let aria_hidden_attribute = element.find_attribute_by_name("aria-hidden").ok()??;
+ !is_aria_hidden_truthy(aria_hidden_attribute)?
+ }
+ JsxAnyChild::JsxExpressionChild(expression) => {
+ let expression = expression.expression()?;
+ match expression {
+ JsAnyExpression::JsAnyLiteralExpression(
+ JsAnyLiteralExpression::JsNullLiteralExpression(_),
+ ) => false,
+ JsAnyExpression::JsIdentifierExpression(identifier) => {
+ let text = identifier.name().ok()?.value_token().ok()?;
+ return Some(text.text_trimmed() != "undefined");
+ }
+ _ => true,
+ }
+ }
+ _ => true,
+ })
+}
+
+/// Check if the `aria-hidden` attribute is present or the value is true.
+fn is_aria_hidden_truthy(aria_hidden_attribute: JsxAttribute) -> Option<bool> {
+ let initializer = aria_hidden_attribute.initializer();
+ if initializer.is_none() {
+ return Some(true);
+ }
+ let attribute_value = initializer?.value().ok()?;
+ Some(match attribute_value {
+ JsxAnyAttributeValue::JsxExpressionAttributeValue(attribute_value) => {
+ let expression = attribute_value.expression().ok()?;
+ is_expression_truthy(expression)?
+ }
+ JsxAnyAttributeValue::JsxAnyTag(_) => false,
+ JsxAnyAttributeValue::JsxString(aria_hidden_string) => {
+ let aria_hidden_value = aria_hidden_string.inner_string_text().ok()?;
+ aria_hidden_value == "true"
+ }
+ })
+}
+
+/// Check if the expression contains only one boolean literal `true`
+/// or one string literal `"true"`
+fn is_expression_truthy(expression: JsAnyExpression) -> Option<bool> {
+ Some(match expression {
+ JsAnyExpression::JsAnyLiteralExpression(literal_expression) => {
+ if let JsAnyLiteralExpression::JsBooleanLiteralExpression(boolean_literal) =
+ literal_expression
+ {
+ let text = boolean_literal.value_token().ok()?;
+ text.text_trimmed() == "true"
+ } else if let JsAnyLiteralExpression::JsStringLiteralExpression(string_literal) =
+ literal_expression
+ {
+ let text = string_literal.inner_string_text().ok()?;
+ text == "true"
+ } else {
+ false
+ }
+ }
+ JsAnyExpression::JsTemplate(template) => {
+ let mut iter = template.elements().iter();
+ if iter.len() != 1 {
+ return None;
+ }
+ match iter.next() {
+ Some(JsAnyTemplateElement::JsTemplateChunkElement(element)) => {
+ let template_token = element.template_chunk_token().ok()?;
+ template_token.text_trimmed() == "true"
+ }
+ Some(JsAnyTemplateElement::JsTemplateElement(element)) => {
+ let expression = element.expression().ok()?;
+ is_expression_truthy(expression)?
+ }
+ _ => false,
+ }
+ }
+ _ => false,
+ })
+}

ADD
crates/rome_js_analyze/tests/specs/nursery/useAnchorContent.jsx
+<>
+ {/* invalid */}
+ <a />
+ <a></a>
+ <a> </a>
+ <a>{null}</a>
+ <a>{undefined}</a>
+ <a aria-hidden>content</a>
+ <a><span aria-hidden="true">content</span></a>
+ <a><span aria-hidden={true}>content</span></a>
+ <a><span aria-hidden={"true"}>content</span></a>
+ <a><span aria-hidden={`true`}>content</span></a>
+ <a><span aria-hidden={`${true}`}>content</span></a>
+ {/* valid */}
+ <a>content</a>
+ <a><TextWrapper /></a>
+ <a><TextWrapper aria-hidden /></a>
+ <a><TextWrapper aria-hidden={true} /></a>
+ <a><TextWrapper aria-hidden={false} /></a>
+ <a dangerouslySetInnerHTML={{ __html: "foo" }} />
+ <a><div aria-hidden="true"></div>content</a>
+ <a><span aria-hidden="false">content</span></a>
+ <a>{content}</a>
+</>

MODIFY
crates/rome_service/src/configuration/linter/rules.rs
crates/rome_service/src/configuration/linter/rules.rs
@@ -401,6 +401,7 @@ struct NurserySchema {
no_unused_variables: Option<RuleConfiguration>,
no_useless_fragments: Option<RuleConfiguration>,
no_void_elements_with_children: Option<RuleConfiguration>,
+ use_anchor_content: Option<RuleConfiguration>,
use_blank_target: Option<RuleConfiguration>,
use_button_type: Option<RuleConfiguration>,
use_camel_case: Option<RuleConfiguration>,
@@ -426,6 +427,7 @@ impl Nursery {
"noUnusedVariables",
"noUselessFragments",
"noVoidElementsWithChildren",
+ "useAnchorContent",
"useBlankTarget",
"useButtonType",
"useCamelCase",

MODIFY
editors/vscode/configuration_schema.json
editors/vscode/configuration_schema.json
"null"
]
},
+ "useAnchorContent": {
+ "anyOf": [
+ {
+ "$ref": "#/definitions/RuleConfiguration"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"useBlankTarget": {
"anyOf": [
{

MODIFY
npm/backend-jsonrpc/src/workspace.ts
npm/backend-jsonrpc/src/workspace.ts
@@ -174,6 +174,7 @@ export interface Nursery {
* It enables the recommended rules for this group
*/
recommended?: boolean;
+ useAnchorContent?: RuleConfiguration;
useBlankTarget?: RuleConfiguration;
useButtonType?: RuleConfiguration;
useCamelCase?: RuleConfiguration;
@@ -313,6 +314,7 @@ export type Category =
| "lint/nursery/noArrayIndexKey"
| "lint/nursery/noDangerouslySetInnerHtmlWithChildren"
| "lint/nursery/noAutofocus"
+ | "lint/nursery/useAnchorContent"
| "lint/nursery/useBlankTarget"
| "lint/nursery/useValidAnchor"
| "lint/nursery/noRestrictedGlobals"

MODIFY
website/src/docs/lint/rules/index.md
website/src/docs/lint/rules/index.md
@@ -360,6 +360,13 @@ Disallow unnecessary fragments
This rules prevents void elements (AKA self-closing elements) from having children.
</div>
<div class="rule">
+<h3 data-toc-exclude id="useAnchorContent">
+ <a href="/docs/lint/rules/useAnchorContent">useAnchorContent (since v10.0.0)</a>
+ <a class="header-anchor" href="#useAnchorContent"></a>
+</h3>
+Enforce that anchor elements have content and that the content is accessible to screen readers.
+</div>
+<div class="rule">
<h3 data-toc-exclude id="useBlankTarget">
<a href="/docs/lint/rules/useBlankTarget">useBlankTarget (since v10.0.0)</a>
<a class="header-anchor" href="#useBlankTarget"></a>

feat(rome_js_analyze): rule `useAnchorContent` (#3370)