feat(rome_js_analyze): `useMediaCaption` rule
Closes
MODIFY
crates/rome_diagnostics_categories/src/categories.rs
crates/rome_diagnostics_categories/src/categories.rs
@@ -81,6 +81,7 @@ define_dategories! {
"lint/nursery/useExhaustiveDependencies": "https://docs.rome.tools/lint/rules/useExhaustiveDependencies",
"lint/nursery/useExponentiationOperator": "https://docs.rome.tools/lint/rules/useExponentiationOperator",
"lint/nursery/useIsNan": "https://docs.rome.tools/lint/rules/useIsNan",
+ "lint/nursery/useMediaCaption": "https://docs.rome.tools/lint/rules/useMediaCaption",
"lint/nursery/useNumericLiterals": "https://docs.rome.tools/lint/rules/useNumericLiterals",
"lint/nursery/useValidForDirection": "https://docs.rome.tools/lint/rules/useValidForDirection",
"lint/nursery/useHookAtTopLevel": "https://docs.rome.tools/lint/rules/useHookAtTopLevel",

MODIFY
crates/rome_js_analyze/src/analyzers/nursery.rs
crates/rome_js_analyze/src/analyzers/nursery.rs
@@ -31,5 +31,6 @@ mod use_default_switch_clause_last;
mod use_enum_initializers;
mod use_exponentiation_operator;
mod use_is_nan;
+mod use_media_caption;
mod use_numeric_literals;
-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_const_enum :: NoConstEnum , self :: no_constructor_return :: NoConstructorReturn , self :: no_distracting_elements :: NoDistractingElements , self :: no_duplicate_case :: NoDuplicateCase , self :: no_duplicate_object_keys :: NoDuplicateObjectKeys , self :: no_empty_interface :: NoEmptyInterface , self :: no_extra_non_null_assertion :: NoExtraNonNullAssertion , self :: no_extra_semicolons :: NoExtraSemicolons , self :: no_header_scope :: NoHeaderScope , self :: no_invalid_constructor_super :: NoInvalidConstructorSuper , self :: no_non_null_assertion :: NoNonNullAssertion , self :: no_precision_loss :: NoPrecisionLoss , self :: no_redundant_alt :: NoRedundantAlt , self :: no_redundant_use_strict :: NoRedundantUseStrict , self :: no_self_compare :: NoSelfCompare , self :: no_setter_return :: NoSetterReturn , self :: no_string_case_mismatch :: NoStringCaseMismatch , self :: no_unsafe_finally :: NoUnsafeFinally , 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_numeric_literals :: UseNumericLiterals ,] } }
+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_const_enum :: NoConstEnum , self :: no_constructor_return :: NoConstructorReturn , self :: no_distracting_elements :: NoDistractingElements , self :: no_duplicate_case :: NoDuplicateCase , self :: no_duplicate_object_keys :: NoDuplicateObjectKeys , self :: no_empty_interface :: NoEmptyInterface , self :: no_extra_non_null_assertion :: NoExtraNonNullAssertion , self :: no_extra_semicolons :: NoExtraSemicolons , self :: no_header_scope :: NoHeaderScope , self :: no_invalid_constructor_super :: NoInvalidConstructorSuper , self :: no_non_null_assertion :: NoNonNullAssertion , self :: no_precision_loss :: NoPrecisionLoss , self :: no_redundant_alt :: NoRedundantAlt , self :: no_redundant_use_strict :: NoRedundantUseStrict , self :: no_self_compare :: NoSelfCompare , self :: no_setter_return :: NoSetterReturn , self :: no_string_case_mismatch :: NoStringCaseMismatch , self :: no_unsafe_finally :: NoUnsafeFinally , 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 ,] } }

ADD
crates/rome_js_analyze/src/analyzers/nursery/use_media_caption.rs
+use rome_analyze::context::RuleContext;
+use rome_analyze::{declare_rule, Ast, Rule, RuleDiagnostic};
+use rome_console::markup;
+use rome_js_syntax::jsx_ext::AnyJsxElement;
+use rome_js_syntax::{AnyJsxChild, JsxElement, TextRange};
+use rome_rowan::AstNode;
+
+declare_rule! {
+ /// Enforces that `audio` and `video` elements must have a `track` for captions.
+ ///
+ /// **ESLint Equivalent:** [media-has-caption](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/media-has-caption.md)
+ ///
+ /// ## Examples
+ ///
+ /// ### Invalid
+ /// ```jsx,expect_diagnostic
+ /// <video />
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <audio>child</audio>
+ /// ```
+ ///
+ /// ### Valid
+ ///
+ /// ```jsx
+ /// <audio>
+ /// <track kind="captions" {...props} />
+ /// </audio>
+ /// ```
+ ///
+ /// ```jsx
+ /// <video muted {...props}></video>
+ /// ```
+ pub(crate) UseMediaCaption {
+ version: "next",
+ name: "useMediaCaption",
+ recommended: true,
+ }
+}
+
+impl Rule for UseMediaCaption {
+ type Query = Ast<AnyJsxElement>;
+ type State = TextRange;
+ type Signals = Option<Self::State>;
+ type Options = ();
+
+ fn run(ctx: &RuleContext<Self>) -> Self::Signals {
+ let node = ctx.query();
+
+ let has_audio_or_video =
+ matches!(node.name_value_token()?.text_trimmed(), "video" | "audio");
+ let has_muted = node.find_attribute_by_name("muted").is_some();
+ let has_spread_prop = node
+ .attributes()
+ .into_iter()
+ .any(|attr| attr.as_jsx_spread_attribute().is_some());
+
+ if !has_audio_or_video || has_muted || has_spread_prop {
+ return None;
+ }
+
+ match node {
+ AnyJsxElement::JsxOpeningElement(_) => {
+ let jsx_element = node.parent::<JsxElement>()?;
+ let has_track = jsx_element
+ .children()
+ .into_iter()
+ .filter_map(|child| {
+ let any_jsx = match child {
+ AnyJsxChild::JsxElement(element) => {
+ Some(AnyJsxElement::from(element.opening_element().ok()?))
+ }
+ AnyJsxChild::JsxSelfClosingElement(element) => {
+ Some(AnyJsxElement::from(element))
+ }
+ _ => None,
+ }?;
+
+ let has_track = any_jsx.name_value_token()?.text_trimmed() == "track";
+ let has_valid_kind = &any_jsx
+ .find_attribute_by_name("kind")?
+ .initializer()?
+ .value()
+ .ok()?
+ .as_jsx_string()?
+ .inner_string_text()
+ .ok()?
+ .to_lowercase()
+ == "captions";
+
+ Some(has_track && has_valid_kind)
+ })
+ .any(|is_valid| is_valid);
+
+ if !has_track {
+ return Some(jsx_element.range());
+ }
+ }
+ _ => return Some(node.range()),
+ }
+
+ None
+ }
+
+ fn diagnostic(_: &RuleContext<Self>, range: &Self::State) -> Option<RuleDiagnostic> {
+ let diagnostic = RuleDiagnostic::new(
+ rule_category!(),
+ range,
+ markup! {"Provide a "<Emphasis>"track"</Emphasis>" for captions when using "<Emphasis>"audio"</Emphasis>" or "<Emphasis>"video"</Emphasis>" elements."}.to_owned(),
+ )
+ .note("Captions support users with hearing-impairments. They should be a transcription or translation of the dialogue, sound effects, musical cues, and other relevant audio information.");
+
+ Some(diagnostic)
+ }
+}

ADD
crates/rome_js_analyze/tests/specs/nursery/useMediaCaption/invalid.jsx
+<>
+ <audio><track /></audio>
+ <audio><track kind="subtitles" /></audio>
+ <audio />
+ <video><track /></video>
+ <video><track kind="subtitles" /></video>
+ <video />
+ <audio>Foo</audio>
+ <video>Foo</video>
+</>

ADD
crates/rome_js_analyze/tests/specs/nursery/useMediaCaption/valid.jsx
+<>
+ <div />
+ <MyDiv />
+ <audio><track kind="captions" /></audio>
+ <audio><track kind="Captions" /></audio>
+ <audio><track kind="Captions" /><track kind="subtitles" /></audio>
+ <video><track kind="captions" /></video>
+ <video><track kind="Captions" /></video>
+ <video><track kind="Captions" /><track kind="subtitles" /></video>
+ <audio muted={true}></audio>
+ <video muted={true}></video>
+ <video muted></video>
+ <audio {...props} />
+ <video {...props} />
+</>

ADD
crates/rome_js_analyze/tests/specs/nursery/useMediaCaption/valid.jsx.snap
+---
+source: crates/rome_js_analyze/tests/spec_tests.rs
+assertion_line: 92
+expression: valid.jsx
+---
+# Input
+```js
+<>
+ <div />
+ <MyDiv />
+ <audio><track kind="captions" /></audio>
+ <audio><track kind="Captions" /></audio>
+ <audio><track kind="Captions" /><track kind="subtitles" /></audio>
+ <video><track kind="captions" /></video>
+ <video><track kind="Captions" /></video>
+ <video><track kind="Captions" /><track kind="subtitles" /></video>
+ <audio muted={true}></audio>
+ <video muted={true}></video>
+ <video muted></video>
+ <audio {...props} />
+ <video {...props} />
+</>
+
+```
+
+

MODIFY
crates/rome_service/src/configuration/linter/rules.rs
crates/rome_service/src/configuration/linter/rules.rs
@@ -801,6 +801,8 @@ struct NurserySchema {
use_hook_at_top_level: Option<RuleConfiguration>,
#[doc = "Require calls to isNaN() when checking for NaN."]
use_is_nan: Option<RuleConfiguration>,
+ #[doc = "Enforces that audio and video elements must have a track for captions."]
+ use_media_caption: Option<RuleConfiguration>,
#[doc = "Disallow parseInt() and Number.parseInt() in favor of binary, octal, and hexadecimal literals"]
use_numeric_literals: Option<RuleConfiguration>,
#[doc = "Ensure that the attribute passed to the lang attribute is a correct ISO language and/or country."]
@@ -808,7 +810,7 @@ struct NurserySchema {
}
impl Nursery {
const CATEGORY_NAME: &'static str = "nursery";
- pub(crate) const CATEGORY_RULES: [&'static str; 41] = [
+ pub(crate) const CATEGORY_RULES: [&'static str; 42] = [
"noAccessKey",
"noAssignInExpressions",
"noBannedTypes",
@@ -848,10 +850,11 @@ impl Nursery {
"useExponentiationOperator",
"useHookAtTopLevel",
"useIsNan",
+ "useMediaCaption",
"useNumericLiterals",
"useValidLang",
];
- const RECOMMENDED_RULES: [&'static str; 32] = [
+ const RECOMMENDED_RULES: [&'static str; 33] = [
"noAssignInExpressions",
"noBannedTypes",
"noClassAssign",
@@ -882,10 +885,11 @@ impl Nursery {
"useEnumInitializers",
"useExhaustiveDependencies",
"useIsNan",
+ "useMediaCaption",
"useNumericLiterals",
"useValidLang",
];
- const RECOMMENDED_RULES_AS_FILTERS: [RuleFilter<'static>; 32] = [
+ const RECOMMENDED_RULES_AS_FILTERS: [RuleFilter<'static>; 33] = [
RuleFilter::Rule("nursery", Self::CATEGORY_RULES[1]),
RuleFilter::Rule("nursery", Self::CATEGORY_RULES[2]),
RuleFilter::Rule("nursery", Self::CATEGORY_RULES[3]),
@@ -918,6 +922,7 @@ impl Nursery {
RuleFilter::Rule("nursery", Self::CATEGORY_RULES[38]),
RuleFilter::Rule("nursery", Self::CATEGORY_RULES[39]),
RuleFilter::Rule("nursery", Self::CATEGORY_RULES[40]),
+ RuleFilter::Rule("nursery", Self::CATEGORY_RULES[41]),
];
pub(crate) fn is_recommended(&self) -> bool { !matches!(self.recommended, Some(false)) }
pub(crate) fn get_enabled_rules(&self) -> IndexSet<RuleFilter> {
@@ -944,7 +949,7 @@ impl Nursery {
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>; 32] {
+ pub(crate) fn recommended_rules_as_filters() -> [RuleFilter<'static>; 33] {
Self::RECOMMENDED_RULES_AS_FILTERS
}
}

MODIFY
editors/vscode/configuration_schema.json
editors/vscode/configuration_schema.json
}
]
},
+ "useMediaCaption": {
+ "description": "Enforces that audio and video elements must have a track for captions.",
+ "anyOf": [
+ {
+ "$ref": "#/definitions/RuleConfiguration"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"useNumericLiterals": {
"description": "Disallow parseInt() and Number.parseInt() in favor of binary, octal, and hexadecimal literals",
"anyOf": [

MODIFY
npm/backend-jsonrpc/src/workspace.ts
npm/backend-jsonrpc/src/workspace.ts
@@ -445,6 +445,10 @@ export interface Nursery {
* Require calls to isNaN() when checking for NaN.
*/
useIsNan?: RuleConfiguration;
+ /**
+ * Enforces that audio and video elements must have a track for captions.
+ */
+ useMediaCaption?: RuleConfiguration;
/**
* Disallow parseInt() and Number.parseInt() in favor of binary, octal, and hexadecimal literals
*/
@@ -761,6 +765,7 @@ export type Category =
| "lint/nursery/useExhaustiveDependencies"
| "lint/nursery/useExponentiationOperator"
| "lint/nursery/useIsNan"
+ | "lint/nursery/useMediaCaption"
| "lint/nursery/useNumericLiterals"
| "lint/nursery/useValidForDirection"
| "lint/nursery/useHookAtTopLevel"

MODIFY
npm/rome/configuration_schema.json
npm/rome/configuration_schema.json
}
]
},
+ "useMediaCaption": {
+ "description": "Enforces that audio and video elements must have a track for captions.",
+ "anyOf": [
+ {
+ "$ref": "#/definitions/RuleConfiguration"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"useNumericLiterals": {
"description": "Disallow parseInt() and Number.parseInt() in favor of binary, octal, and hexadecimal literals",
"anyOf": [

MODIFY
website/src/pages/lint/rules/index.mdx
website/src/pages/lint/rules/index.mdx
@@ -713,6 +713,12 @@ component functions.
Require calls to <code>isNaN()</code> when checking for <code>NaN</code>.
</section>
<section class="rule">
+<h3 data-toc-exclude id="useMediaCaption">
+ <a href="/lint/rules/useMediaCaption">useMediaCaption</a>
+</h3>
+Enforces that <code>audio</code> and <code>video</code> elements must have a <code>track</code> for captions.
+</section>
+<section class="rule">
<h3 data-toc-exclude id="useNumericLiterals">
<a href="/lint/rules/useNumericLiterals">useNumericLiterals</a>
</h3>

feat(rome_js_analyze): `useMediaCaption` rule (#4032)

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