Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-use-media-caption-html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": minor
---

Added the rule [`useMediaCaption`](https://biomejs.dev/linter/rules/use-media-caption/) to the HTML language. Enforces that `audio` and `video` elements have a `track` element with `kind="captions"` for accessibility. Muted videos are allowed without captions.
3 changes: 2 additions & 1 deletion crates/biome_html_analyze/src/lint/a11y.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod use_aria_props_for_role;
pub mod use_button_type;
pub mod use_html_lang;
pub mod use_iframe_title;
pub mod use_media_caption;
pub mod use_valid_aria_role;
pub mod use_valid_lang;
declare_lint_group! { pub A11y { name : "a11y" , rules : [self :: no_access_key :: NoAccessKey , self :: no_autofocus :: NoAutofocus , self :: no_distracting_elements :: NoDistractingElements , self :: no_header_scope :: NoHeaderScope , self :: no_positive_tabindex :: NoPositiveTabindex , self :: no_redundant_alt :: NoRedundantAlt , self :: no_svg_without_title :: NoSvgWithoutTitle , self :: use_alt_text :: UseAltText , self :: use_aria_props_for_role :: UseAriaPropsForRole , self :: use_button_type :: UseButtonType , self :: use_html_lang :: UseHtmlLang , self :: use_iframe_title :: UseIframeTitle , self :: use_valid_aria_role :: UseValidAriaRole , self :: use_valid_lang :: UseValidLang ,] } }
declare_lint_group! { pub A11y { name : "a11y" , rules : [self :: no_access_key :: NoAccessKey , self :: no_autofocus :: NoAutofocus , self :: no_distracting_elements :: NoDistractingElements , self :: no_header_scope :: NoHeaderScope , self :: no_positive_tabindex :: NoPositiveTabindex , self :: no_redundant_alt :: NoRedundantAlt , self :: no_svg_without_title :: NoSvgWithoutTitle , self :: use_alt_text :: UseAltText , self :: use_aria_props_for_role :: UseAriaPropsForRole , self :: use_button_type :: UseButtonType , self :: use_html_lang :: UseHtmlLang , self :: use_iframe_title :: UseIframeTitle , self :: use_media_caption :: UseMediaCaption , self :: use_valid_aria_role :: UseValidAriaRole , self :: use_valid_lang :: UseValidLang ,] } }
160 changes: 160 additions & 0 deletions crates/biome_html_analyze/src/lint/a11y/use_media_caption.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
use biome_analyze::{
Ast, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_diagnostics::Severity;
use biome_html_syntax::{AnyHtmlElement, HtmlElementList, HtmlFileSource};
use biome_rowan::AstNode;

declare_lint_rule! {
/// Enforces that `audio` and `video` elements must have a `track` for captions.
///
/// 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.
///
/// :::note
/// In `.html` files, this rule matches element names case-insensitively (e.g., `<VIDEO>`, `<video>`).
///
/// In component-based frameworks (Vue, Svelte, Astro), only lowercase element names are checked.
/// PascalCase variants like `<Video>` are assumed to be custom components and are ignored.
/// :::
///
/// ## Examples
///
/// ### Invalid
///
/// ```html,expect_diagnostic
/// <video src="video.mp4"></video>
/// ```
///
/// ```html,expect_diagnostic
/// <audio src="audio.mp3">
/// <source src="audio.ogg" type="audio/ogg" />
/// </audio>
/// ```
///
/// ### Valid
///
/// ```html
/// <video src="video.mp4">
/// <track kind="captions" src="captions.vtt" />
/// </video>
/// ```
///
/// ```html
/// <audio src="audio.mp3">
/// <track kind="captions" src="captions.vtt" />
/// </audio>
/// ```
///
/// ```html
/// <video muted src="video.mp4"></video>
/// ```
///
/// ## Accessibility guidelines
///
/// - [WCAG 1.2.2](https://www.w3.org/WAI/WCAG21/Understanding/captions-prerecorded)
/// - [WCAG 1.2.3](https://www.w3.org/WAI/WCAG21/Understanding/audio-description-or-media-alternative-prerecorded)
///
pub UseMediaCaption {
version: "next",
name: "useMediaCaption",
language: "html",
sources: &[RuleSource::EslintJsxA11y("media-has-caption").same()],
recommended: true,
severity: Severity::Error,
}
}

impl Rule for UseMediaCaption {
type Query = Ast<AnyHtmlElement>;
type State = ();
type Signals = Option<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
let source_type = ctx.source_type::<HtmlFileSource>();

// Check if element is audio or video
let element_name = node.name()?;
let is_audio = if source_type.is_html() {
element_name.text().eq_ignore_ascii_case("audio")
} else {
element_name.text() == "audio"
};
let is_video = if source_type.is_html() {
element_name.text().eq_ignore_ascii_case("video")
} else {
element_name.text() == "video"
};

if !is_audio && !is_video {
return None;
}

// Muted videos don't need captions (audio still requires captions)
if is_video && node.find_attribute_by_name("muted").is_some() {
return None;
}

// Check for track element with kind="captions" in children
let html_element = node.as_html_element()?;
// Skip analysis if we can't fully parse the element to avoid false positives
if html_element.opening_element().is_err() {
return None;
}
if has_caption_track(&html_element.children(), *source_type) {
return None;
}

// No muted attribute and no caption track found - emit diagnostic
Some(())
}

fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
let node = ctx.query();
let diagnostic = RuleDiagnostic::new(
rule_category!(),
node.syntax().text_trimmed_range(),
markup! {
"Provide a "<Emphasis>"track"</Emphasis>" for captions when using "<Emphasis>"audio"</Emphasis>" or "<Emphasis>"video"</Emphasis>" elements."
},
)
.note(markup! {
"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)
}
}

/// Checks if the given `HtmlElementList` has a `track` element with `kind="captions"`.
fn has_caption_track(html_child_list: &HtmlElementList, source_type: HtmlFileSource) -> bool {
html_child_list
.into_iter()
.find_map(|child| {
let name = child.name()?;
let is_track = if source_type.is_html() {
name.text().eq_ignore_ascii_case("track")
} else {
name.text() == "track"
};
if !is_track {
return None;
}

let kind_attr = child.find_attribute_by_name("kind")?;
let initializer = kind_attr.initializer()?;
let value = initializer.value().ok()?;
let string_value = value.string_value()?;

if string_value.eq_ignore_ascii_case("captions") {
Some(())
} else {
None
}
})
.is_some()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!-- should generate diagnostics -->
<video></video>
<audio></audio>
<video>
<source src="video.webm" type="video/webm" />
</video>
<audio>
<source src="audio.ogg" type="audio/ogg" />
</audio>
<!-- track without kind="captions" -->
<video><track kind="subtitles" /></video>
<!-- track with missing kind attribute -->
<video><track src="captions.vtt" /></video>
<!-- track with empty kind attribute -->
<video><track kind="" /></video>
<!-- muted audio still requires captions -->
<audio muted></audio>
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
---
source: crates/biome_html_analyze/tests/spec_tests.rs
expression: invalid.astro
---
# Input
```html
<!-- should generate diagnostics -->
<video></video>
<audio></audio>
<video>
<source src="video.webm" type="video/webm" />
</video>
<audio>
<source src="audio.ogg" type="audio/ogg" />
</audio>
<!-- track without kind="captions" -->
<video><track kind="subtitles" /></video>
<!-- track with missing kind attribute -->
<video><track src="captions.vtt" /></video>
<!-- track with empty kind attribute -->
<video><track kind="" /></video>
<!-- muted audio still requires captions -->
<audio muted></audio>

```

# Diagnostics
```
invalid.astro:2:1 lint/a11y/useMediaCaption ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Provide a track for captions when using audio or video elements.

1 │ <!-- should generate diagnostics -->
> 2 │ <video></video>
│ ^^^^^^^^^^^^^^^
3 │ <audio></audio>
4 │ <video>

i 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.


```

```
invalid.astro:3:1 lint/a11y/useMediaCaption ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Provide a track for captions when using audio or video elements.

1 │ <!-- should generate diagnostics -->
2 │ <video></video>
> 3 │ <audio></audio>
│ ^^^^^^^^^^^^^^^
4 │ <video>
5 │ <source src="video.webm" type="video/webm" />

i 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.


```

```
invalid.astro:4:1 lint/a11y/useMediaCaption ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Provide a track for captions when using audio or video elements.

2 │ <video></video>
3 │ <audio></audio>
> 4 │ <video>
│ ^^^^^^^
> 5 │ <source src="video.webm" type="video/webm" />
> 6 │ </video>
│ ^^^^^^^^
7 │ <audio>
8 │ <source src="audio.ogg" type="audio/ogg" />

i 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.


```

```
invalid.astro:7:1 lint/a11y/useMediaCaption ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Provide a track for captions when using audio or video elements.

5 │ <source src="video.webm" type="video/webm" />
6 │ </video>
> 7 │ <audio>
│ ^^^^^^^
> 8 │ <source src="audio.ogg" type="audio/ogg" />
> 9 │ </audio>
│ ^^^^^^^^
10 │ <!-- track without kind="captions" -->
11 │ <video><track kind="subtitles" /></video>

i 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.


```

```
invalid.astro:11:1 lint/a11y/useMediaCaption ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Provide a track for captions when using audio or video elements.

9 │ </audio>
10 │ <!-- track without kind="captions" -->
> 11 │ <video><track kind="subtitles" /></video>
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
12 │ <!-- track with missing kind attribute -->
13 │ <video><track src="captions.vtt" /></video>

i 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.


```

```
invalid.astro:13:1 lint/a11y/useMediaCaption ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Provide a track for captions when using audio or video elements.

11 │ <video><track kind="subtitles" /></video>
12 │ <!-- track with missing kind attribute -->
> 13 │ <video><track src="captions.vtt" /></video>
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
14 │ <!-- track with empty kind attribute -->
15 │ <video><track kind="" /></video>

i 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.


```

```
invalid.astro:15:1 lint/a11y/useMediaCaption ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Provide a track for captions when using audio or video elements.

13 │ <video><track src="captions.vtt" /></video>
14 │ <!-- track with empty kind attribute -->
> 15 │ <video><track kind="" /></video>
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
16 │ <!-- muted audio still requires captions -->
17 │ <audio muted></audio>

i 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.


```

```
invalid.astro:17:1 lint/a11y/useMediaCaption ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Provide a track for captions when using audio or video elements.

15 │ <video><track kind="" /></video>
16 │ <!-- muted audio still requires captions -->
> 17 │ <audio muted></audio>
│ ^^^^^^^^^^^^^^^^^^^^^
18 │

i 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.


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!-- should not generate diagnostics -->
<video><track kind="captions" /></video>
<audio><track kind="captions" /></audio>
<!-- muted videos don't need captions -->
<video muted></video>
<video muted>
<source src="video.webm" type="video/webm" />
</video>
<!-- case insensitive kind check -->
<video><track kind="Captions" /></video>
<!-- custom components should be ignored -->
<Video />
<Audio />
<VideoPlayer />
<AudioPlayer />
Loading