mirror of
https://github.com/danbulant/oxc
synced 2026-05-24 12:21:58 +00:00
feat(linter): eslint-plugin-jsx-a11y lang (#1812)
lang linter for #1141
This commit is contained in:
parent
601153fe8f
commit
d984d59e68
6 changed files with 214 additions and 10 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
159
crates/oxc_linter/src/rules/jsx_a11y/lang.rs
Normal file
159
crates/oxc_linter/src/rules/jsx_a11y/lang.rs
Normal file
|
|
@ -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 <html> 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
|
||||
/// <html lang="en">
|
||||
/// <html lang="en-US">
|
||||
/// ```
|
||||
///
|
||||
/// // bad
|
||||
/// ```javascript
|
||||
/// <html>
|
||||
/// <html lang="foo">
|
||||
/// ````
|
||||
///
|
||||
/// ### 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![
|
||||
("<div />;", None, None),
|
||||
("<div foo='bar' />;", None, None),
|
||||
("<div lang='foo' />;", None, None),
|
||||
("<html lang='en' />", None, None),
|
||||
("<html lang='en-US' />", None, None),
|
||||
("<html lang='zh-Hans' />", None, None),
|
||||
("<html lang='zh-Hant-HK' />", None, None),
|
||||
("<html lang='zh-yue-Hant' />", None, None),
|
||||
("<html lang='ja-Latn' />", None, None),
|
||||
("<html lang={foo} />", None, None),
|
||||
("<HTML lang='foo' />", None, None),
|
||||
("<Foo lang={undefined} />", None, None),
|
||||
("<Foo lang='en' />", None, Some(settings())),
|
||||
("<Box as='html' lang='en' />", None, Some(settings())),
|
||||
];
|
||||
|
||||
let fail = vec![
|
||||
("<html lang='foo' />", None, None),
|
||||
("<html lang='zz-LL' />", None, None),
|
||||
("<html lang={undefined} />", None, None),
|
||||
("<Foo lang={undefined} />", None, Some(settings())),
|
||||
// TODO: wait polymorphicPropName complete in next PR
|
||||
// ("<Box as='html' lang='foo' />", None, None),
|
||||
];
|
||||
|
||||
Tester::new_with_settings(Lang::NAME, pass, fail).test_and_snapshot();
|
||||
}
|
||||
34
crates/oxc_linter/src/snapshots/lang.snap
Normal file
34
crates/oxc_linter/src/snapshots/lang.snap
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
source: crates/oxc_linter/src/tester.rs
|
||||
assertion_line: 144
|
||||
expression: lang
|
||||
---
|
||||
⚠ eslint-plugin-jsx-a11y(lang): Lang attribute must have a valid value.
|
||||
╭─[lang.tsx:1:1]
|
||||
1 │ <html lang='foo' />
|
||||
· ──────────
|
||||
╰────
|
||||
help: Set a valid value for lang attribute.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(lang): Lang attribute must have a valid value.
|
||||
╭─[lang.tsx:1:1]
|
||||
1 │ <html lang='zz-LL' />
|
||||
· ────────────
|
||||
╰────
|
||||
help: Set a valid value for lang attribute.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(lang): Lang attribute must have a valid value.
|
||||
╭─[lang.tsx:1:1]
|
||||
1 │ <html lang={undefined} />
|
||||
· ────────────────
|
||||
╰────
|
||||
help: Set a valid value for lang attribute.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(lang): Lang attribute must have a valid value.
|
||||
╭─[lang.tsx:1:1]
|
||||
1 │ <Foo lang={undefined} />
|
||||
· ────────────────
|
||||
╰────
|
||||
help: Set a valid value for lang attribute.
|
||||
|
||||
|
||||
Loading…
Reference in a new issue