diff --git a/crates/rome_diagnostics_categories/src/categories.rs b/crates/rome_diagnostics_categories/src/categories.rs index aa9fc176812..9e74bf52969 100644 --- a/crates/rome_diagnostics_categories/src/categories.rs +++ b/crates/rome_diagnostics_categories/src/categories.rs @@ -105,6 +105,7 @@ define_dategories! { "lint/nursery/noGlobalObjectCalls": "https://docs.rome.tools/lint/rules/noGlobalObjectCalls", "lint/nursery/noPrototypeBuiltins": "https://docs.rome.tools/lint/rules/noPrototypeBuiltins", "lint/nursery/noSelfAssignment": "https://docs.rome.tools/lint/rules/noSelfAssignment", + "lint/nursery/useHeadingContent": "https://docs.rome.tools/lint/rules/useHeadingContent", // Insert new nursery rule here // performance diff --git a/crates/rome_js_analyze/src/analyzers/a11y/use_anchor_content.rs b/crates/rome_js_analyze/src/analyzers/a11y/use_anchor_content.rs index 4770cb28dfe..14406b7d864 100644 --- a/crates/rome_js_analyze/src/analyzers/a11y/use_anchor_content.rs +++ b/crates/rome_js_analyze/src/analyzers/a11y/use_anchor_content.rs @@ -1,11 +1,10 @@ use rome_analyze::context::RuleContext; use rome_analyze::{declare_rule, Ast, Rule, RuleDiagnostic}; use rome_console::markup; -use rome_js_syntax::{ - AnyJsExpression, AnyJsLiteralExpression, AnyJsTemplateElement, AnyJsxAttributeValue, - AnyJsxChild, JsxAttribute, JsxElement, JsxReferenceIdentifier, JsxSelfClosingElement, -}; -use rome_rowan::{declare_node_union, AstNode, AstNodeList}; +use rome_js_syntax::{JsxElement, JsxSelfClosingElement}; +use rome_rowan::{declare_node_union, AstNode}; + +use crate::aria::{is_accessible_to_screen_reader, is_aria_hidden_truthy}; declare_rule! { /// Enforce that anchor elements have content and that the content is accessible to screen readers. @@ -87,7 +86,7 @@ impl UseAnchorContentNode { 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) + is_aria_hidden_truthy(&aria_hidden_attribute).unwrap_or(false) } _ => false, } @@ -98,7 +97,7 @@ impl UseAnchorContentNode { 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) + is_aria_hidden_truthy(&aria_hidden_attribute).unwrap_or(false) } _ => false, } @@ -113,7 +112,7 @@ impl UseAnchorContentNode { UseAnchorContentNode::JsxElement(element) => element .children() .into_iter() - .any(|child| is_accessible_to_screen_reader(child).unwrap_or(true)), + .any(|child| is_accessible_to_screen_reader(&child).unwrap_or(true)), UseAnchorContentNode::JsxSelfClosingElement(element) => element .find_attribute_by_name("dangerouslySetInnerHTML") .ok()? @@ -158,114 +157,3 @@ impl Rule for UseAnchorContent { )) } } - -/// 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: AnyJsxChild) -> Option { - Some(match element { - AnyJsxChild::JsxText(text) => { - let value_token = text.value_token().ok()?; - value_token.text_trimmed().trim() != "" - } - AnyJsxChild::JsxElement(element) => { - let opening_element = element.opening_element().ok()?; - - // We don't check if a component (e.g. ) 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)? - } - AnyJsxChild::JsxSelfClosingElement(element) => { - // We don't check if a component (e.g. ) 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)? - } - AnyJsxChild::JsxExpressionChild(expression) => { - let expression = expression.expression()?; - match expression { - AnyJsExpression::AnyJsLiteralExpression( - AnyJsLiteralExpression::JsNullLiteralExpression(_), - ) => false, - AnyJsExpression::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 { - let initializer = aria_hidden_attribute.initializer(); - if initializer.is_none() { - return Some(true); - } - let attribute_value = initializer?.value().ok()?; - Some(match attribute_value { - AnyJsxAttributeValue::JsxExpressionAttributeValue(attribute_value) => { - let expression = attribute_value.expression().ok()?; - is_expression_truthy(expression)? - } - AnyJsxAttributeValue::AnyJsxTag(_) => false, - AnyJsxAttributeValue::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: AnyJsExpression) -> Option { - Some(match expression { - AnyJsExpression::AnyJsLiteralExpression(literal_expression) => { - if let AnyJsLiteralExpression::JsBooleanLiteralExpression(boolean_literal) = - literal_expression - { - let text = boolean_literal.value_token().ok()?; - text.text_trimmed() == "true" - } else if let AnyJsLiteralExpression::JsStringLiteralExpression(string_literal) = - literal_expression - { - let text = string_literal.inner_string_text().ok()?; - text == "true" - } else { - false - } - } - AnyJsExpression::JsTemplateExpression(template) => { - let mut iter = template.elements().iter(); - if iter.len() != 1 { - return None; - } - match iter.next() { - Some(AnyJsTemplateElement::JsTemplateChunkElement(element)) => { - let template_token = element.template_chunk_token().ok()?; - template_token.text_trimmed() == "true" - } - Some(AnyJsTemplateElement::JsTemplateElement(element)) => { - let expression = element.expression().ok()?; - is_expression_truthy(expression)? - } - _ => false, - } - } - _ => false, - }) -} diff --git a/crates/rome_js_analyze/src/analyzers/nursery.rs b/crates/rome_js_analyze/src/analyzers/nursery.rs index 76fce3e3d5c..8d130b12f37 100644 --- a/crates/rome_js_analyze/src/analyzers/nursery.rs +++ b/crates/rome_js_analyze/src/analyzers/nursery.rs @@ -45,8 +45,9 @@ mod use_default_parameter_last; mod use_default_switch_clause_last; mod use_enum_initializers; mod use_exponentiation_operator; +mod use_heading_content; mod use_is_nan; mod use_media_caption; mod use_numeric_literals; mod use_yield; -declare_group! { pub (crate) Nursery { name : "nursery" , rules : [self :: no_access_key :: NoAccessKey , self :: no_assign_in_expressions :: NoAssignInExpressions , self :: no_banned_types :: NoBannedTypes , self :: no_comma_operator :: NoCommaOperator , self :: no_confusing_labels :: NoConfusingLabels , self :: no_const_enum :: NoConstEnum , self :: no_constructor_return :: NoConstructorReturn , self :: no_distracting_elements :: NoDistractingElements , self :: no_duplicate_case :: NoDuplicateCase , self :: no_duplicate_class_members :: NoDuplicateClassMembers , self :: no_duplicate_jsx_props :: NoDuplicateJsxProps , self :: no_duplicate_object_keys :: NoDuplicateObjectKeys , self :: no_empty_interface :: NoEmptyInterface , self :: no_extra_labels :: NoExtraLabels , self :: no_extra_non_null_assertion :: NoExtraNonNullAssertion , self :: no_extra_semicolons :: NoExtraSemicolons , self :: no_global_object_calls :: NoGlobalObjectCalls , self :: no_header_scope :: NoHeaderScope , self :: no_inferrable_types :: NoInferrableTypes , self :: no_inner_declarations :: NoInnerDeclarations , self :: no_invalid_constructor_super :: NoInvalidConstructorSuper , self :: no_non_null_assertion :: NoNonNullAssertion , self :: no_parameter_properties :: NoParameterProperties , self :: no_precision_loss :: NoPrecisionLoss , self :: no_prototype_builtins :: NoPrototypeBuiltins , self :: no_redundant_alt :: NoRedundantAlt , self :: no_redundant_use_strict :: NoRedundantUseStrict , self :: no_self_assignment :: NoSelfAssignment , self :: no_self_compare :: NoSelfCompare , self :: no_setter_return :: NoSetterReturn , self :: no_string_case_mismatch :: NoStringCaseMismatch , self :: no_switch_declarations :: NoSwitchDeclarations , self :: no_unreachable_super :: NoUnreachableSuper , self :: no_unsafe_finally :: NoUnsafeFinally , self :: no_unsafe_optional_chaining :: NoUnsafeOptionalChaining , self :: no_unused_labels :: NoUnusedLabels , self :: no_useless_rename :: NoUselessRename , self :: no_useless_switch_case :: NoUselessSwitchCase , self :: no_void_type_return :: NoVoidTypeReturn , self :: no_with :: NoWith , self :: use_default_parameter_last :: UseDefaultParameterLast , self :: use_default_switch_clause_last :: UseDefaultSwitchClauseLast , self :: use_enum_initializers :: UseEnumInitializers , self :: use_exponentiation_operator :: UseExponentiationOperator , self :: use_is_nan :: UseIsNan , self :: use_media_caption :: UseMediaCaption , self :: use_numeric_literals :: UseNumericLiterals , self :: use_yield :: UseYield ,] } } +declare_group! { pub (crate) Nursery { name : "nursery" , rules : [self :: no_access_key :: NoAccessKey , self :: no_assign_in_expressions :: NoAssignInExpressions , self :: no_banned_types :: NoBannedTypes , self :: no_comma_operator :: NoCommaOperator , self :: no_confusing_labels :: NoConfusingLabels , self :: no_const_enum :: NoConstEnum , self :: no_constructor_return :: NoConstructorReturn , self :: no_distracting_elements :: NoDistractingElements , self :: no_duplicate_case :: NoDuplicateCase , self :: no_duplicate_class_members :: NoDuplicateClassMembers , self :: no_duplicate_jsx_props :: NoDuplicateJsxProps , self :: no_duplicate_object_keys :: NoDuplicateObjectKeys , self :: no_empty_interface :: NoEmptyInterface , self :: no_extra_labels :: NoExtraLabels , self :: no_extra_non_null_assertion :: NoExtraNonNullAssertion , self :: no_extra_semicolons :: NoExtraSemicolons , self :: no_global_object_calls :: NoGlobalObjectCalls , self :: no_header_scope :: NoHeaderScope , self :: no_inferrable_types :: NoInferrableTypes , self :: no_inner_declarations :: NoInnerDeclarations , self :: no_invalid_constructor_super :: NoInvalidConstructorSuper , self :: no_non_null_assertion :: NoNonNullAssertion , self :: no_parameter_properties :: NoParameterProperties , self :: no_precision_loss :: NoPrecisionLoss , self :: no_prototype_builtins :: NoPrototypeBuiltins , self :: no_redundant_alt :: NoRedundantAlt , self :: no_redundant_use_strict :: NoRedundantUseStrict , self :: no_self_assignment :: NoSelfAssignment , self :: no_self_compare :: NoSelfCompare , self :: no_setter_return :: NoSetterReturn , self :: no_string_case_mismatch :: NoStringCaseMismatch , self :: no_switch_declarations :: NoSwitchDeclarations , self :: no_unreachable_super :: NoUnreachableSuper , self :: no_unsafe_finally :: NoUnsafeFinally , self :: no_unsafe_optional_chaining :: NoUnsafeOptionalChaining , self :: no_unused_labels :: NoUnusedLabels , self :: no_useless_rename :: NoUselessRename , self :: no_useless_switch_case :: NoUselessSwitchCase , self :: no_void_type_return :: NoVoidTypeReturn , self :: no_with :: NoWith , self :: use_default_parameter_last :: UseDefaultParameterLast , self :: use_default_switch_clause_last :: UseDefaultSwitchClauseLast , self :: use_enum_initializers :: UseEnumInitializers , self :: use_exponentiation_operator :: UseExponentiationOperator , self :: use_heading_content :: UseHeadingContent , self :: use_is_nan :: UseIsNan , self :: use_media_caption :: UseMediaCaption , self :: use_numeric_literals :: UseNumericLiterals , self :: use_yield :: UseYield ,] } } diff --git a/crates/rome_js_analyze/src/analyzers/nursery/use_heading_content.rs b/crates/rome_js_analyze/src/analyzers/nursery/use_heading_content.rs new file mode 100644 index 00000000000..dc5b02cbc39 --- /dev/null +++ b/crates/rome_js_analyze/src/analyzers/nursery/use_heading_content.rs @@ -0,0 +1,186 @@ +use rome_analyze::{context::RuleContext, declare_rule, Ast, Rule, RuleDiagnostic}; +use rome_console::markup; +use rome_js_syntax::{AnyJsxAttribute, JsxAttribute, JsxElement, JsxSelfClosingElement}; +use rome_rowan::{declare_node_union, AstNode}; + +use crate::aria::{is_accessible_to_screen_reader, is_aria_hidden_truthy}; + +declare_rule! { + /// Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. + /// Accessible means that it is not hidden using the aria-hidden prop. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```jsx,expect_diagnostic + ///

+ /// ``` + /// + /// ```jsx,expect_diagnostic + ///

+ /// ``` + /// + /// ```jsx,expect_diagnostic + ///

+ /// ``` + /// + /// ## Valid + /// + /// ```jsx + ///

heading

+ /// ``` + /// + /// ```jsx + ///

visible content

+ /// ``` + /// + /// ```jsx + ///

+ /// ``` + /// + /// ```jsx + ///

visible content

+ /// ``` + /// + pub(crate) UseHeadingContent { + version: "next", + name: "useHeadingContent", + recommended: false, + } +} + +declare_node_union! { + pub(crate) UseHeadingContentNode = JsxElement | JsxSelfClosingElement +} + +const HEADING_ELEMENTS: [&str; 6] = ["h1", "h2", "h3", "h4", "h5", "h6"]; + +impl Rule for UseHeadingContent { + type Query = Ast; + type State = (); + type Signals = Option; + type Options = (); + + fn run(ctx: &RuleContext) -> Self::Signals { + let node = ctx.query(); + + if node.is_heading_element()? { + if node.has_truthy_aria_hidden_attribute()? { + return Some(()); + } + + if node.has_valid_children_attribute()? || node.has_spread_prop()? { + return None; + } + + if !node.has_dangerously_set_inner_html_attribute() { + match node { + UseHeadingContentNode::JsxElement(element) => { + if !element.children().into_iter().any(|child_node| { + is_accessible_to_screen_reader(&child_node) != Some(false) + }) { + return Some(()); + } + } + UseHeadingContentNode::JsxSelfClosingElement(_) => { + return Some(()); + } + } + } + } + + None + } + + fn diagnostic(ctx: &RuleContext, _: &Self::State) -> Option { + let range = ctx.query().syntax().text_trimmed_range(); + Some(RuleDiagnostic::new( + rule_category!(), + range, + markup! { + "Provide screen reader accessible content when using ""heading"" elements." + }, + ).note( + "All headings on a page should have content that is accessible to screen readers." + )) + } +} + +impl UseHeadingContentNode { + fn is_heading_element(&self) -> Option { + let name_node = match self { + UseHeadingContentNode::JsxElement(element) => { + element.opening_element().ok()?.name().ok()? + } + UseHeadingContentNode::JsxSelfClosingElement(element) => element.name().ok()?, + }; + Some( + HEADING_ELEMENTS.contains(&name_node.as_jsx_name()?.value_token().ok()?.text_trimmed()), + ) + } + + fn find_attribute_by_name(&self, name: &str) -> Option { + match self { + UseHeadingContentNode::JsxElement(element) => { + let opening_element = element.opening_element().ok()?; + opening_element.find_attribute_by_name(name).ok()? + } + UseHeadingContentNode::JsxSelfClosingElement(element) => { + element.find_attribute_by_name(name).ok()? + } + } + } + + fn has_dangerously_set_inner_html_attribute(&self) -> bool { + self.find_attribute_by_name("dangerouslySetInnerHTML") + .is_some() + } + + fn has_truthy_aria_hidden_attribute(&self) -> Option { + if let Some(attribute) = self.find_attribute_by_name("aria-hidden") { + Some(!self.has_trailing_spread_prop(&attribute)? && is_aria_hidden_truthy(&attribute)?) + } else { + Some(false) + } + } + + fn has_valid_children_attribute(&self) -> Option { + if let Some(attribute) = self.find_attribute_by_name("children") { + if attribute.initializer().is_some() + && !(attribute.is_value_undefined_or_null() || attribute.is_value_empty_string()) + { + return Some(true); + } + } + + Some(false) + } + + fn has_trailing_spread_prop(&self, current_attribute: &JsxAttribute) -> Option { + match self { + UseHeadingContentNode::JsxElement(element) => { + let opening_element = element.opening_element().ok()?; + Some(opening_element.has_trailing_spread_prop(current_attribute.clone())) + } + UseHeadingContentNode::JsxSelfClosingElement(element) => { + Some(element.has_trailing_spread_prop(current_attribute.clone())) + } + } + } + + fn has_spread_prop(&self) -> Option { + let attrs = match self { + UseHeadingContentNode::JsxElement(element) => { + element.opening_element().ok()?.attributes() + } + UseHeadingContentNode::JsxSelfClosingElement(element) => element.attributes(), + }; + + Some( + attrs + .into_iter() + .any(|attribute| matches!(attribute, AnyJsxAttribute::JsxSpreadAttribute(_))), + ) + } +} diff --git a/crates/rome_js_analyze/src/aria.rs b/crates/rome_js_analyze/src/aria.rs new file mode 100644 index 00000000000..aa5ccbf6205 --- /dev/null +++ b/crates/rome_js_analyze/src/aria.rs @@ -0,0 +1,122 @@ +use rome_js_syntax::{ + AnyJsExpression, AnyJsLiteralExpression, AnyJsTemplateElement, AnyJsxAttributeValue, + AnyJsxChild, JsxAttribute, JsxReferenceIdentifier, +}; +use rome_rowan::{AstNode, AstNodeList}; + +/// Check if the element is a text content for screen readers, +/// or it is not hidden using the `aria-hidden` attribute +pub fn is_accessible_to_screen_reader(element: &AnyJsxChild) -> Option { + Some(match element { + AnyJsxChild::JsxText(text) => { + let value_token = text.value_token().ok()?; + value_token.text_trimmed().trim() != "" + } + AnyJsxChild::JsxElement(element) => { + let opening_element = element.opening_element().ok()?; + + // We don't check if a component (e.g. ) 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()??; + opening_element.has_trailing_spread_prop(aria_hidden_attribute.clone()) + || !is_aria_hidden_truthy(&aria_hidden_attribute)? + } + AnyJsxChild::JsxSelfClosingElement(element) => { + // We don't check if a component (e.g. ) 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()??; + element.has_trailing_spread_prop(aria_hidden_attribute.clone()) + || !is_aria_hidden_truthy(&aria_hidden_attribute)? + } + AnyJsxChild::JsxExpressionChild(expression) => { + let expression = expression.expression()?; + match expression { + AnyJsExpression::AnyJsLiteralExpression( + AnyJsLiteralExpression::JsNullLiteralExpression(_), + ) => false, + AnyJsExpression::JsIdentifierExpression(identifier) => { + let text = identifier.name().ok()?.value_token().ok()?; + return Some(text.text_trimmed() != "undefined"); + } + _ => true, + } + } + AnyJsxChild::JsxFragment(fragment) => fragment + .children() + .iter() + .any(|child| is_accessible_to_screen_reader(&child).unwrap_or(false)), + _ => true, + }) +} + +/// Check if the `aria-hidden` attribute is present or the value is true. +pub fn is_aria_hidden_truthy(aria_hidden_attribute: &JsxAttribute) -> Option { + let initializer = aria_hidden_attribute.initializer(); + if initializer.is_none() { + return Some(true); + } + let attribute_value = initializer?.value().ok()?; + Some(match attribute_value { + AnyJsxAttributeValue::JsxExpressionAttributeValue(attribute_value) => { + let expression = attribute_value.expression().ok()?; + is_expression_truthy(&expression)? + } + AnyJsxAttributeValue::AnyJsxTag(_) => false, + AnyJsxAttributeValue::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: &AnyJsExpression) -> Option { + Some(match expression { + AnyJsExpression::AnyJsLiteralExpression(literal_expression) => { + if let AnyJsLiteralExpression::JsBooleanLiteralExpression(boolean_literal) = + literal_expression + { + let text = boolean_literal.value_token().ok()?; + text.text_trimmed() == "true" + } else if let AnyJsLiteralExpression::JsStringLiteralExpression(string_literal) = + literal_expression + { + let text = string_literal.inner_string_text().ok()?; + text == "true" + } else { + false + } + } + AnyJsExpression::JsTemplateExpression(template) => { + let mut iter = template.elements().iter(); + if iter.len() != 1 { + return None; + } + match iter.next() { + Some(AnyJsTemplateElement::JsTemplateChunkElement(element)) => { + let template_token = element.template_chunk_token().ok()?; + template_token.text_trimmed() == "true" + } + Some(AnyJsTemplateElement::JsTemplateElement(element)) => { + let expression = element.expression().ok()?; + is_expression_truthy(&expression)? + } + _ => false, + } + } + _ => false, + }) +} diff --git a/crates/rome_js_analyze/src/lib.rs b/crates/rome_js_analyze/src/lib.rs index 6a55132660d..4d95b1b5e8a 100644 --- a/crates/rome_js_analyze/src/lib.rs +++ b/crates/rome_js_analyze/src/lib.rs @@ -13,6 +13,7 @@ use std::sync::Arc; use std::{borrow::Cow, error::Error}; mod analyzers; +mod aria; mod aria_analyzers; mod aria_services; mod assists; diff --git a/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx new file mode 100644 index 00000000000..932f08a07b4 --- /dev/null +++ b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx @@ -0,0 +1,18 @@ +<> +

+

+
+

+

+

+ <> +

+

content

+

+

{undefined}

+

+

+

+

+

+; diff --git a/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx.snap b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx.snap new file mode 100644 index 00000000000..621a4ba1844 --- /dev/null +++ b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx.snap @@ -0,0 +1,238 @@ +--- +source: crates/rome_js_analyze/tests/spec_tests.rs +expression: invalid.jsx +--- +# Input +```js +<> +

+

+
+

+

+

+ <> +

+

content

+

+

{undefined}

+

+

+

+

+

+; + +``` + +# Diagnostics +``` +invalid.jsx:2:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 1 │ <> + > 2 │

+ │ ^^^^^^ + 3 │

+ 4 │
+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:3:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 1 │ <> + 2 │

+ > 3 │

+ │ ^^^^ + > 4 │
+ > 5 │

+ │ ^^^^^ + 6 │

+ 7 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:6:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 4 │
+ 5 │

+ > 6 │

+ │ ^^^^^^^^^ + 7 │

+ 8 │ <> + + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:7:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 5 │

+ 6 │

+ > 7 │

+ │ ^^^^ + > 8 │ <> + > 9 │

+ │ ^^^^^ + 10 │

content

+ 11 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:10:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 8 │ <> + 9 │

+ > 10 │

content

+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 11 │

+ 12 │

{undefined}

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:11:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 9 │

+ 10 │

content

+ > 11 │

+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 12 │

{undefined}

+ 13 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:12:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 10 │

content

+ 11 │

+ > 12 │

{undefined}

+ │ ^^^^^^^^^^^^^^^^^^^^ + 13 │

+ 14 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:13:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 11 │

+ 12 │

{undefined}

+ > 13 │

+ │ ^^^^^^^^^^^^^^^ + 14 │

+ 15 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:14:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 12 │

{undefined}

+ 13 │

+ > 14 │

+ │ ^^^^^^^^^^^^^^^^^^^^ + 15 │

+ 16 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:15:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 13 │

+ 14 │

+ > 15 │

+ │ ^^^^^^^^^^^^^^^^^^^^^^ + 16 │

+ 17 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:16:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 14 │

+ 15 │

+ > 16 │

+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 17 │

+ 18 │ ; + + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:17:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 15 │

+ 16 │

+ > 17 │

+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 18 │ ; + 19 │ + + i All headings on a page should have content that is accessible to screen readers. + + +``` + + diff --git a/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx new file mode 100644 index 00000000000..16eaf885c19 --- /dev/null +++ b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx @@ -0,0 +1,49 @@ +<> +
+

heading

+

heading

+

heading

+

heading

+
heading
+
heading
' +

+ +

+

+ +

+

+ +

+

+ +

+

{foo}

+

{foo.bar}

+

+ visible content +

+

+ +

+

+

+

+

+
+ visible content +

+

+

+

+

+

+

+

+ visible content +

+

+ <>heading +

+; diff --git a/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx.snap b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx.snap new file mode 100644 index 00000000000..a8678f5d3b7 --- /dev/null +++ b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx.snap @@ -0,0 +1,59 @@ +--- +source: crates/rome_js_analyze/tests/spec_tests.rs +expression: valid.jsx +--- +# Input +```js +<> +
+

heading

+

heading

+

heading

+

heading

+
heading
+
heading
' +

+ +

+

+ +

+

+ +

+

+ +

+

{foo}

+

{foo.bar}

+

+ visible content +

+

+ +

+

+

+

+

+
+ visible content +

+

+

+

+

+

+

+

+ visible content +

+

+ <>heading +

+; + +``` + + diff --git a/crates/rome_js_syntax/src/jsx_ext.rs b/crates/rome_js_syntax/src/jsx_ext.rs index 9f468b8e391..52cca53d7ae 100644 --- a/crates/rome_js_syntax/src/jsx_ext.rs +++ b/crates/rome_js_syntax/src/jsx_ext.rs @@ -409,6 +409,12 @@ impl JsxAttribute { }) .unwrap_or(false) } + + pub fn is_value_empty_string(&self) -> bool { + self.initializer() + .and_then(|x| Some(x.value().ok()?.inner_text_value().ok()??.is_empty())) + .unwrap_or(false) + } } impl AnyJsxAttributeValue { diff --git a/crates/rome_service/src/configuration/linter/rules.rs b/crates/rome_service/src/configuration/linter/rules.rs index c4a3f79753d..0555b4c9a6c 100644 --- a/crates/rome_service/src/configuration/linter/rules.rs +++ b/crates/rome_service/src/configuration/linter/rules.rs @@ -1092,6 +1092,9 @@ pub struct Nursery { #[doc = "Disallow the use of Math.pow in favor of the ** operator."] #[serde(skip_serializing_if = "Option::is_none")] pub use_exponentiation_operator: Option, + #[doc = "Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. Accessible means that it is not hidden using the aria-hidden prop."] + #[serde(skip_serializing_if = "Option::is_none")] + pub use_heading_content: Option, #[doc = "Enforce that all React hooks are being called from the Top Level component functions."] #[serde(skip_serializing_if = "Option::is_none")] pub use_hook_at_top_level: Option, @@ -1119,7 +1122,7 @@ pub struct Nursery { } impl Nursery { const GROUP_NAME: &'static str = "nursery"; - pub(crate) const GROUP_RULES: [&'static str; 61] = [ + pub(crate) const GROUP_RULES: [&'static str; 62] = [ "noAccessKey", "noAssignInExpressions", "noBannedTypes", @@ -1173,6 +1176,7 @@ impl Nursery { "useEnumInitializers", "useExhaustiveDependencies", "useExponentiationOperator", + "useHeadingContent", "useHookAtTopLevel", "useIframeTitle", "useIsNan", @@ -1278,13 +1282,13 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[49]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[50]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[51]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[57]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[58]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[59]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[60]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[61]), ]; pub(crate) fn is_recommended(&self) -> bool { !matches!(self.recommended, Some(false)) } pub(crate) fn get_enabled_rules(&self) -> IndexSet { @@ -1554,46 +1558,51 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[52])); } } - if let Some(rule) = self.use_hook_at_top_level.as_ref() { + if let Some(rule) = self.use_heading_content.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[53])); } } - if let Some(rule) = self.use_iframe_title.as_ref() { + if let Some(rule) = self.use_hook_at_top_level.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54])); } } - if let Some(rule) = self.use_is_nan.as_ref() { + if let Some(rule) = self.use_iframe_title.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55])); } } - if let Some(rule) = self.use_media_caption.as_ref() { + if let Some(rule) = self.use_is_nan.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56])); } } - if let Some(rule) = self.use_numeric_literals.as_ref() { + if let Some(rule) = self.use_media_caption.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[57])); } } - if let Some(rule) = self.use_valid_aria_props.as_ref() { + if let Some(rule) = self.use_numeric_literals.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[58])); } } - if let Some(rule) = self.use_valid_lang.as_ref() { + if let Some(rule) = self.use_valid_aria_props.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[59])); } } - if let Some(rule) = self.use_yield.as_ref() { + if let Some(rule) = self.use_valid_lang.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[60])); } } + if let Some(rule) = self.use_yield.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[61])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> IndexSet { @@ -1863,46 +1872,51 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[52])); } } - if let Some(rule) = self.use_hook_at_top_level.as_ref() { + if let Some(rule) = self.use_heading_content.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[53])); } } - if let Some(rule) = self.use_iframe_title.as_ref() { + if let Some(rule) = self.use_hook_at_top_level.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54])); } } - if let Some(rule) = self.use_is_nan.as_ref() { + if let Some(rule) = self.use_iframe_title.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55])); } } - if let Some(rule) = self.use_media_caption.as_ref() { + if let Some(rule) = self.use_is_nan.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56])); } } - if let Some(rule) = self.use_numeric_literals.as_ref() { + if let Some(rule) = self.use_media_caption.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[57])); } } - if let Some(rule) = self.use_valid_aria_props.as_ref() { + if let Some(rule) = self.use_numeric_literals.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[58])); } } - if let Some(rule) = self.use_valid_lang.as_ref() { + if let Some(rule) = self.use_valid_aria_props.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[59])); } } - if let Some(rule) = self.use_yield.as_ref() { + if let Some(rule) = self.use_valid_lang.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[60])); } } + if let Some(rule) = self.use_yield.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[61])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -1971,6 +1985,7 @@ impl Nursery { "useEnumInitializers" => self.use_enum_initializers.as_ref(), "useExhaustiveDependencies" => self.use_exhaustive_dependencies.as_ref(), "useExponentiationOperator" => self.use_exponentiation_operator.as_ref(), + "useHeadingContent" => self.use_heading_content.as_ref(), "useHookAtTopLevel" => self.use_hook_at_top_level.as_ref(), "useIframeTitle" => self.use_iframe_title.as_ref(), "useIsNan" => self.use_is_nan.as_ref(), diff --git a/crates/rome_service/src/configuration/parse/json/rules.rs b/crates/rome_service/src/configuration/parse/json/rules.rs index 76a52bf80bd..af463bcbca0 100644 --- a/crates/rome_service/src/configuration/parse/json/rules.rs +++ b/crates/rome_service/src/configuration/parse/json/rules.rs @@ -756,6 +756,7 @@ impl VisitNode for Nursery { "useEnumInitializers", "useExhaustiveDependencies", "useExponentiationOperator", + "useHeadingContent", "useHookAtTopLevel", "useIframeTitle", "useIsNan", @@ -1734,6 +1735,24 @@ impl VisitNode for Nursery { )); } }, + "useHeadingContent" => match value { + AnyJsonValue::JsonStringValue(_) => { + let mut configuration = RuleConfiguration::default(); + self.map_to_known_string(&value, name_text, &mut configuration, diagnostics)?; + self.use_heading_content = Some(configuration); + } + AnyJsonValue::JsonObjectValue(_) => { + let mut configuration = RuleConfiguration::default(); + self.map_to_object(&value, name_text, &mut configuration, diagnostics)?; + self.use_heading_content = Some(configuration); + } + _ => { + diagnostics.push(DeserializationDiagnostic::new_incorrect_type( + "object or string", + value.range(), + )); + } + }, "useHookAtTopLevel" => match value { AnyJsonValue::JsonStringValue(_) => { let mut configuration = RuleConfiguration::default(); diff --git a/editors/vscode/configuration_schema.json b/editors/vscode/configuration_schema.json index 4485c53933d..6092dd6fe0c 100644 --- a/editors/vscode/configuration_schema.json +++ b/editors/vscode/configuration_schema.json @@ -764,6 +764,13 @@ { "type": "null" } ] }, + "useHeadingContent": { + "description": "Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. Accessible means that it is not hidden using the aria-hidden prop.", + "anyOf": [ + { "$ref": "#/definitions/RuleConfiguration" }, + { "type": "null" } + ] + }, "useHookAtTopLevel": { "description": "Enforce that all React hooks are being called from the Top Level component functions.", "anyOf": [ diff --git a/npm/backend-jsonrpc/src/workspace.ts b/npm/backend-jsonrpc/src/workspace.ts index 7dcebf231bf..748934e7db8 100644 --- a/npm/backend-jsonrpc/src/workspace.ts +++ b/npm/backend-jsonrpc/src/workspace.ts @@ -500,6 +500,10 @@ export interface Nursery { * Disallow the use of Math.pow in favor of the ** operator. */ useExponentiationOperator?: RuleConfiguration; + /** + * Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. Accessible means that it is not hidden using the aria-hidden prop. + */ + useHeadingContent?: RuleConfiguration; /** * Enforce that all React hooks are being called from the Top Level component functions. */ @@ -864,6 +868,7 @@ export type Category = | "lint/nursery/noGlobalObjectCalls" | "lint/nursery/noPrototypeBuiltins" | "lint/nursery/noSelfAssignment" + | "lint/nursery/useHeadingContent" | "lint/performance/noDelete" | "lint/security/noDangerouslySetInnerHtml" | "lint/security/noDangerouslySetInnerHtmlWithChildren" diff --git a/npm/rome/configuration_schema.json b/npm/rome/configuration_schema.json index 4485c53933d..6092dd6fe0c 100644 --- a/npm/rome/configuration_schema.json +++ b/npm/rome/configuration_schema.json @@ -764,6 +764,13 @@ { "type": "null" } ] }, + "useHeadingContent": { + "description": "Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. Accessible means that it is not hidden using the aria-hidden prop.", + "anyOf": [ + { "$ref": "#/definitions/RuleConfiguration" }, + { "type": "null" } + ] + }, "useHookAtTopLevel": { "description": "Enforce that all React hooks are being called from the Top Level component functions.", "anyOf": [ diff --git a/website/src/pages/lint/rules/index.mdx b/website/src/pages/lint/rules/index.mdx index 2b1d82b75fc..a9a3045d559 100644 --- a/website/src/pages/lint/rules/index.mdx +++ b/website/src/pages/lint/rules/index.mdx @@ -806,6 +806,13 @@ Enforce all dependencies are correctly specified. Disallow the use of Math.pow in favor of the ** operator.
+

+ useHeadingContent +

+Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. +Accessible means that it is not hidden using the aria-hidden prop. +
+

useHookAtTopLevel

diff --git a/website/src/pages/lint/rules/useHeadingContent.md b/website/src/pages/lint/rules/useHeadingContent.md new file mode 100644 index 00000000000..ab8a596606e --- /dev/null +++ b/website/src/pages/lint/rules/useHeadingContent.md @@ -0,0 +1,84 @@ +--- +title: Lint Rule useHeadingContent +parent: lint/rules/index +--- + +# useHeadingContent (since vnext) + +Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. +Accessible means that it is not hidden using the aria-hidden prop. + +## Examples + +### Invalid + +```jsx +

+``` + +
nursery/useHeadingContent.js:1:1 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+   Provide screen reader accessible content when using heading  elements.
+  
+  > 1 │ <h1 />
+   ^^^^^^
+    2 │ 
+  
+   All headings on a page should have content that is accessible to screen readers.
+  
+
+ +```jsx +

+``` + +
nursery/useHeadingContent.js:1:1 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+   Provide screen reader accessible content when using heading  elements.
+  
+  > 1 │ <h1><div aria-hidden /></h1>
+   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+    2 │ 
+  
+   All headings on a page should have content that is accessible to screen readers.
+  
+
+ +```jsx +

+``` + +
nursery/useHeadingContent.js:1:1 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+   Provide screen reader accessible content when using heading  elements.
+  
+  > 1 │ <h1></h1>
+   ^^^^^^^^^
+    2 │ 
+  
+   All headings on a page should have content that is accessible to screen readers.
+  
+
+ +## Valid + +```jsx +

heading

+``` + +```jsx +

visible content

+``` + +```jsx +

+``` + +```jsx +

visible content

+``` + +## Related links + +- [Disable a rule](/linter/#disable-a-lint-rule) +- [Rule options](/linter/#rule-options)