feat(rome_js_analyzer): rule `useHtmlLang`
Closes
MODIFY
crates/rome_diagnostics_categories/src/categories.rs
crates/rome_diagnostics_categories/src/categories.rs
@@ -19,6 +19,7 @@ define_dategories! {
"lint/a11y/useKeyWithClickEvents": "https://docs.rome.tools/lint/rules/useKeyWithClickEvents",
"lint/a11y/useKeyWithMouseEvents": "https://docs.rome.tools/lint/rules/useKeyWithMouseEvents",
"lint/a11y/useValidAnchor": "https://docs.rome.tools/lint/rules/useValidAnchor",
+ "lint/a11y/useHtmlLang": "https://docs.rome.tools/lint/rules/useHtmlLang",
// complexity
"lint/complexity/noExtraBooleanCast": "https://docs.rome.tools/lint/rules/noExtraBooleanCast",

MODIFY
crates/rome_js_analyze/src/analyzers/a11y.rs
crates/rome_js_analyze/src/analyzers/a11y.rs
@@ -5,7 +5,8 @@ mod no_auto_focus;
mod no_blank_target;
mod use_alt_text;
mod use_anchor_content;
+mod use_html_lang;
mod use_key_with_click_events;
mod use_key_with_mouse_events;
mod use_valid_anchor;
-declare_group! { pub (crate) A11y { name : "a11y" , rules : [self :: no_auto_focus :: NoAutoFocus , self :: no_blank_target :: NoBlankTarget , self :: use_alt_text :: UseAltText , self :: use_anchor_content :: UseAnchorContent , self :: use_key_with_click_events :: UseKeyWithClickEvents , self :: use_key_with_mouse_events :: UseKeyWithMouseEvents , self :: use_valid_anchor :: UseValidAnchor ,] } }
+declare_group! { pub (crate) A11y { name : "a11y" , rules : [self :: no_auto_focus :: NoAutoFocus , self :: no_blank_target :: NoBlankTarget , self :: use_alt_text :: UseAltText , self :: use_anchor_content :: UseAnchorContent , self :: use_html_lang :: UseHtmlLang , self :: use_key_with_click_events :: UseKeyWithClickEvents , self :: use_key_with_mouse_events :: UseKeyWithMouseEvents , self :: use_valid_anchor :: UseValidAnchor ,] } }

ADD
crates/rome_js_analyze/src/analyzers/a11y/use_html_lang.rs
+use rome_analyze::{context::RuleContext, declare_rule, Ast, Rule, RuleDiagnostic};
+use rome_console::markup;
+use rome_js_syntax::{
+ jsx_ext::AnyJsxElement, AnyJsxAttribute, AnyJsxAttributeValue, JsxAttribute, TextRange,
+};
+use rome_rowan::AstNode;
+
+declare_rule! {
+ /// Enforce that `html` element has `lang` attribute.
+ /// This allows users to choose a language other than the default.
+ /// ## Examples
+ ///
+ /// ### Invalid
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <html></html>
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <html lang={""}></html>
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <html lang={null}></html>
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <html lang={undefined}></html>
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <html lang={true}></html>
+ /// ```
+ ///
+ /// ### Valid
+ ///
+ /// ```jsx
+ /// <html lang="en"></html>
+ /// ```
+ ///
+ /// ```jsx
+ /// <html lang={language}></html>
+ /// ```
+ ///
+ /// ```jsx
+ /// <html {...props}></html>
+ /// ```
+ ///
+ /// ```jsx
+ /// <html lang={""} {...props}></html>
+ /// ```
+ ///
+ /// ## Accessibility guidelines
+ ///
+ /// [WCAG 3.1.1](https://www.w3.org/WAI/WCAG21/Understanding/language-of-page)
+ pub(crate) UseHtmlLang {
+ version: "12.0.0",
+ name: "useHtmlLang",
+ recommended: true,
+ }
+}
+
+impl Rule for UseHtmlLang {
+ type Query = Ast<AnyJsxElement>;
+ type State = TextRange;
+ type Signals = Option<Self::State>;
+ type Options = ();
+
+ fn run(ctx: &RuleContext<Self>) -> Self::Signals {
+ let element = ctx.query();
+ let name = element.name().ok()?;
+ let name = name.as_jsx_name()?.value_token().ok()?;
+ let name_trimmed = name.text_trimmed();
+ if name_trimmed == "html" {
+ if let Some(lang_attribute) = element.find_attribute_by_name("lang") {
+ if element.has_trailing_spread_prop(lang_attribute.clone())
+ || is_valid_lang_attribute(lang_attribute).is_some()
+ {
+ return None;
+ }
+ return Some(element.syntax().text_trimmed_range());
+ }
+ if !has_spread_prop(element) {
+ return Some(element.syntax().text_trimmed_range());
+ }
+ }
+ None
+ }
+
+ fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
+ Some(RuleDiagnostic::new(
+ rule_category!(),
+ state,
+ markup! {
+ "Provide a "<Emphasis>"lang"</Emphasis>" attribute when using the "<Emphasis>"html"</Emphasis>" element."
+ }
+ ).note(
+ markup! {
+ "Setting a "<Emphasis>"lang"</Emphasis>" attribute on HTML document elements configures the language
+used by screen readers when no user default is specified."
+ }
+ ))
+ }
+}
+
+fn is_valid_lang_attribute(attr: JsxAttribute) -> Option<()> {
+ if attr.is_value_undefined_or_null() {
+ return None;
+ }
+
+ let attribute_value = attr.initializer()?.value().ok()?;
+
+ if let AnyJsxAttributeValue::JsxExpressionAttributeValue(expression) = attribute_value {
+ let expression = expression.expression().ok()?;
+
+ if expression.as_js_identifier_expression().is_some() {
+ return Some(());
+ }
+
+ if let Some(template_expression) = expression.as_js_template_expression() {
+ let template_element = template_expression
+ .elements()
+ .into_iter()
+ .find(|element| element.as_js_template_chunk_element().is_some());
+
+ if template_element.is_some() {
+ return Some(());
+ };
+ }
+
+ expression
+ .as_any_js_literal_expression()?
+ .as_js_boolean_literal_expression();
+
+ let string_expression = expression
+ .as_any_js_literal_expression()?
+ .as_js_string_literal_expression()?;
+ let string_expression_text = string_expression.inner_string_text().ok()?;
+
+ if string_expression_text.is_empty() {
+ return None;
+ }
+
+ return Some(());
+ }
+ let string_text = attribute_value.as_jsx_string()?.inner_string_text().ok()?;
+ if string_text.is_empty() {
+ return None;
+ }
+
+ Some(())
+}
+
+fn has_spread_prop(element: &AnyJsxElement) -> bool {
+ element
+ .attributes()
+ .into_iter()
+ .any(|attribute| matches!(attribute, AnyJsxAttribute::JsxSpreadAttribute(_)))
+}

ADD
crates/rome_js_analyze/tests/specs/a11y/useHtmlLang.jsx
+<>
+ {/* invalid */}
+ <html />
+ <html></html>
+ <html lang=""></html>
+ <html lang={""}></html>
+ <html lang={``}></html>
+ <html lang={true}></html>
+ <html lang={false}></html>
+ <html lang={undefined}></html>
+ <html lang={null}></html>
+ <html {...props} lang=""></html>
+ {/* valid */}
+ <html lang="en"></html>
+ <html lang={"en"}></html>
+ <html lang={`en`}></html>
+ <html lang={lang}></html>
+ <html {...props}></html>
+ <html lang="" {...props}></html>
+</>
\ No newline at end of file

MODIFY
crates/rome_service/src/configuration/linter/rules.rs
crates/rome_service/src/configuration/linter/rules.rs
@@ -377,6 +377,8 @@ struct A11ySchema {
use_anchor_content: Option<RuleConfiguration>,
#[doc = "Enforces the usage of the attribute type for the element button"]
use_button_type: Option<RuleConfiguration>,
+ #[doc = "Enforce that html element has lang attribute. This allows users to choose a language other than the default."]
+ use_html_lang: Option<RuleConfiguration>,
#[doc = "Enforce to have the onClick mouse event with the onKeyUp, the onKeyDown, or the onKeyPress keyboard event."]
use_key_with_click_events: Option<RuleConfiguration>,
#[doc = "Enforce that onMouseOver/onMouseOut are accompanied by onFocus/onBlur for keyboard-only users. It is important to take into account users with physical disabilities who cannot use a mouse, who use assistive technology or screenreader."]
@@ -386,29 +388,31 @@ struct A11ySchema {
}
impl A11y {
const CATEGORY_NAME: &'static str = "a11y";
- pub(crate) const CATEGORY_RULES: [&'static str; 9] = [
+ pub(crate) const CATEGORY_RULES: [&'static str; 10] = [
"noAutofocus",
"noBlankTarget",
"noPositiveTabindex",
"useAltText",
"useAnchorContent",
"useButtonType",
+ "useHtmlLang",
"useKeyWithClickEvents",
"useKeyWithMouseEvents",
"useValidAnchor",
];
- const RECOMMENDED_RULES: [&'static str; 9] = [
+ const RECOMMENDED_RULES: [&'static str; 10] = [
"noAutofocus",
"noBlankTarget",
"noPositiveTabindex",
"useAltText",
"useAnchorContent",
"useButtonType",
+ "useHtmlLang",
"useKeyWithClickEvents",
"useKeyWithMouseEvents",
"useValidAnchor",
];
- const RECOMMENDED_RULES_AS_FILTERS: [RuleFilter<'static>; 9] = [
+ const RECOMMENDED_RULES_AS_FILTERS: [RuleFilter<'static>; 10] = [
RuleFilter::Rule("a11y", Self::CATEGORY_RULES[0]),
RuleFilter::Rule("a11y", Self::CATEGORY_RULES[1]),
RuleFilter::Rule("a11y", Self::CATEGORY_RULES[2]),
@@ -418,6 +422,7 @@ impl A11y {
RuleFilter::Rule("a11y", Self::CATEGORY_RULES[6]),
RuleFilter::Rule("a11y", Self::CATEGORY_RULES[7]),
RuleFilter::Rule("a11y", Self::CATEGORY_RULES[8]),
+ RuleFilter::Rule("a11y", Self::CATEGORY_RULES[9]),
];
pub(crate) fn is_recommended(&self) -> bool { !matches!(self.recommended, Some(false)) }
pub(crate) fn get_enabled_rules(&self) -> IndexSet<RuleFilter> {
@@ -444,7 +449,7 @@ impl A11y {
pub(crate) fn is_recommended_rule(rule_name: &str) -> bool {
Self::RECOMMENDED_RULES.contains(&rule_name)
}
- pub(crate) fn recommended_rules_as_filters() -> [RuleFilter<'static>; 9] {
+ pub(crate) fn recommended_rules_as_filters() -> [RuleFilter<'static>; 10] {
Self::RECOMMENDED_RULES_AS_FILTERS
}
}

MODIFY
editors/vscode/configuration_schema.json
editors/vscode/configuration_schema.json
}
]
},
+ "useHtmlLang": {
+ "description": "Enforce that html element has lang attribute. This allows users to choose a language other than the default.",
+ "anyOf": [
+ {
+ "$ref": "#/definitions/RuleConfiguration"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"useKeyWithClickEvents": {
"description": "Enforce to have the onClick mouse event with the onKeyUp, the onKeyDown, or the onKeyPress keyboard event.",
"anyOf": [

MODIFY
npm/backend-jsonrpc/src/workspace.ts
npm/backend-jsonrpc/src/workspace.ts
@@ -182,6 +182,10 @@ export interface A11y {
* Enforces the usage of the attribute type for the element button
*/
useButtonType?: RuleConfiguration;
+ /**
+ * Enforce that html element has lang attribute. This allows users to choose a language other than the default.
+ */
+ useHtmlLang?: RuleConfiguration;
/**
* Enforce to have the onClick mouse event with the onKeyUp, the onKeyDown, or the onKeyPress keyboard event.
*/
@@ -725,6 +729,7 @@ export type Category =
| "lint/a11y/useKeyWithClickEvents"
| "lint/a11y/useKeyWithMouseEvents"
| "lint/a11y/useValidAnchor"
+ | "lint/a11y/useHtmlLang"
| "lint/complexity/noExtraBooleanCast"
| "lint/complexity/noMultipleSpacesInRegularExpressionLiterals"
| "lint/complexity/noUselessFragments"

MODIFY
npm/rome/configuration_schema.json
npm/rome/configuration_schema.json
}
]
},
+ "useHtmlLang": {
+ "description": "Enforce that html element has lang attribute. This allows users to choose a language other than the default.",
+ "anyOf": [
+ {
+ "$ref": "#/definitions/RuleConfiguration"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"useKeyWithClickEvents": {
"description": "Enforce to have the onClick mouse event with the onKeyUp, the onKeyDown, or the onKeyPress keyboard event.",
"anyOf": [

MODIFY
website/src/pages/lint/rules/index.mdx
website/src/pages/lint/rules/index.mdx
@@ -57,6 +57,14 @@ Enforce that anchor elements have content and that the content is accessible to
Enforces the usage of the attribute <code>type</code> for the element <code>button</code>
</section>
<section class="rule">
+<h3 data-toc-exclude id="useHtmlLang">
+ <a href="/lint/rules/useHtmlLang">useHtmlLang</a>
+ <span class="recommended">recommended</span>
+</h3>
+Enforce that <code>html</code> element has <code>lang</code> attribute.
+This allows users to choose a language other than the default.
+</section>
+<section class="rule">
<h3 data-toc-exclude id="useKeyWithClickEvents">
<a href="/lint/rules/useKeyWithClickEvents">useKeyWithClickEvents</a>
<span class="recommended">recommended</span>

feat(rome_js_analyzer): rule `useHtmlLang` (#4052)

Closes https://github.com/rome/tools/issues/3944