diff --git a/Cargo.lock b/Cargo.lock index b9df198a9..d579f0873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1098,6 +1098,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.4.0" @@ -1682,6 +1688,7 @@ dependencies = [ "dashmap", "insta", "itertools 0.12.0", + "language-tags", "lazy_static", "memchr", "miette", diff --git a/Cargo.toml b/Cargo.toml index 2456b2d3e..c5594d22a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,6 +135,7 @@ trustfall = { version = "0.6.1" } insta = { version = "1.34.0", features = ["glob"] } codspeed-criterion-compat = { version = "2.3.3", default-features = false } glob = { version = "0.3.1" } +language-tags = { version = "0.3.2" } [profile.release.package.oxc_wasm] opt-level = 'z' diff --git a/crates/oxc_linter/Cargo.toml b/crates/oxc_linter/Cargo.toml index 475e34d25..1ddf5b32e 100644 --- a/crates/oxc_linter/Cargo.toml +++ b/crates/oxc_linter/Cargo.toml @@ -31,16 +31,17 @@ oxc_formatter = { workspace = true } oxc_index = { workspace = true } oxc_resolver = { version = "1.0.1" } -rayon = { workspace = true } -lazy_static = { workspace = true } # used in oxc_macros -serde_json = { workspace = true } -regex = { workspace = true } -rustc-hash = { workspace = true } -phf = { workspace = true, features = ["macros"] } -num-traits = { workspace = true } -itertools = { workspace = true } -dashmap = { workspace = true } -convert_case = { workspace = true } +rayon = { workspace = true } +lazy_static = { workspace = true } # used in oxc_macros +serde_json = { workspace = true } +regex = { workspace = true } +rustc-hash = { workspace = true } +phf = { workspace = true, features = ["macros"] } +num-traits = { workspace = true } +itertools = { workspace = true } +dashmap = { workspace = true } +convert_case = { workspace = true } +language-tags = { workspace = true } rust-lapper = "1.1.0" once_cell = "1.19.0" diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index cfbe06b39..80b54221d 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -236,6 +236,7 @@ mod jsx_a11y { pub mod html_has_lang; pub mod iframe_has_title; pub mod img_redundant_alt; + pub mod lang; pub mod no_access_key; pub mod no_aria_hidden_on_focusable; pub mod no_autofocus; @@ -457,6 +458,7 @@ oxc_macros::declare_all_lint_rules! { jsx_a11y::aria_props, jsx_a11y::heading_has_content, jsx_a11y::html_has_lang, + jsx_a11y::lang, jsx_a11y::iframe_has_title, jsx_a11y::img_redundant_alt, jsx_a11y::no_access_key, diff --git a/crates/oxc_linter/src/rules/jsx_a11y/lang.rs b/crates/oxc_linter/src/rules/jsx_a11y/lang.rs new file mode 100644 index 000000000..f2b4e107d --- /dev/null +++ b/crates/oxc_linter/src/rules/jsx_a11y/lang.rs @@ -0,0 +1,159 @@ +use language_tags::LanguageTag; +use oxc_ast::{ + ast::{ + JSXAttributeItem, JSXAttributeValue, JSXElementName, JSXExpression, JSXExpressionContainer, + }, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{ + context::LintContext, + rule::Rule, + utils::{get_element_type, has_jsx_prop_lowercase}, + AstNode, +}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-jsx-a11y(lang): Lang attribute must have a valid value.")] +#[diagnostic(severity(warning), help("Set a valid value for lang attribute."))] +struct LangDiagnostic(#[label] pub Span); + +#[derive(Debug, Default, Clone)] +pub struct Lang; + +declare_oxc_lint!( + /// ### What it does + /// + /// The lang prop on the element must be a valid IETF's BCP 47 language tag. + /// + /// ### Why is this bad? + /// + /// If the language of a webpage is not specified as valid, + /// the screen reader assumes the default language set by the user. + /// Language settings become an issue for users who speak multiple languages + /// and access website in more than one language. + /// + /// + /// ### Example + /// + /// // good + /// ```javascript + /// + /// + /// ``` + /// + /// // bad + /// ```javascript + /// + /// + /// ```` + /// + /// ### Resources + /// - [eslint-plugin-jsx-a11y/lang](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/lang.md) + /// - [IANA Language Subtag Registry](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry) + Lang, + correctness +); + +impl Rule for Lang { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(jsx_el) = node.kind() else { + return; + }; + + let Some(element_type) = get_element_type(ctx, jsx_el) else { + return; + }; + + if element_type != "html" { + return; + } + + let JSXElementName::Identifier(identifier) = &jsx_el.name else { + return; + }; + + has_jsx_prop_lowercase(jsx_el, "lang").map_or_else( + || ctx.diagnostic(LangDiagnostic(identifier.span)), + |lang_prop| { + if !is_valid_lang_prop(lang_prop) { + if let JSXAttributeItem::Attribute(attr) = lang_prop { + ctx.diagnostic(LangDiagnostic(attr.span)); + } + } + }, + ); + } +} + +fn get_prop_value<'a, 'b>(item: &'b JSXAttributeItem<'a>) -> Option<&'b JSXAttributeValue<'a>> { + if let JSXAttributeItem::Attribute(attr) = item { + attr.0.value.as_ref() + } else { + None + } +} + +fn is_valid_lang_prop(item: &JSXAttributeItem) -> bool { + match get_prop_value(item) { + Some(JSXAttributeValue::ExpressionContainer(JSXExpressionContainer { + expression: JSXExpression::Expression(expr), + .. + })) => !expr.is_undefined(), + Some(JSXAttributeValue::StringLiteral(str)) => { + let language_tag = LanguageTag::parse(str.value.as_str()).unwrap(); + language_tag.is_valid() + } + _ => true, + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + fn settings() -> serde_json::Value { + serde_json::json!({ + "jsx-a11y": { + "polymorphicPropName": "as", + "components": { + "Foo": "html", + } + } + }) + } + + let pass = vec![ + ("
;", None, None), + (";", None, None), + (";", None, None), + ("