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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
| [template-require-media-caption](docs/rules/template-require-media-caption.md) | require captions for audio and video elements | | | |
| [template-require-presentational-children](docs/rules/template-require-presentational-children.md) | require presentational elements to only contain presentational children | | | |
| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | | | |
| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | |
Expand Down
63 changes: 63 additions & 0 deletions docs/rules/template-require-media-caption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# ember/template-require-media-caption

<!-- end auto-generated rule header -->

Captions provide a text version of the spoken and non-spoken audio information
for media. They are essential for making audio and video content accessible for
users who are deaf as well as those for whom the media is unavailable (similar
to `alt` text on an image when it is unable to load).

Captions should contain all relevant information needed to help users
understand the media content, which may include a transcription of the dialogue
and descriptions of meaningful sound effects. They are synchronized with the
media to allow users access to the portion of the content conveyed via the
audio track. Note that when audio or video components include the `muted`
attribute, however, captions are _not_ necessary.

## Examples

This rule **forbids** the following:

```gjs
<template>
<audio></audio>
</template>
```

```gjs
<template>
<video><track /></video>
</template>
```

```gjs
<template>
<video><track kind="descriptions" /></video>
</template>
```

This rule **allows** the following:

```gjs
<template>
<audio><track kind="captions"></audio>
</template>
```

```gjs
<template>
<video muted="true"></video>
</template>
```

```gjs
<template>
<video><track kind="captions" /><track kind="descriptions" /></video>
</template>
```

## References

- [Captions*Subtitles * Web Accessibility Initiative (WAI) \_ W3C](https://www.w3.org/WAI/media/av/captions/)
- [Understanding Success Criterion 1.2.2: Captions (Prerecorded)](https://www.w3.org/WAI/WCAG21/Understanding/captions-prerecorded.html)
- [media-has-caption - eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/media-has-caption.md)
85 changes: 85 additions & 0 deletions lib/rules/template-require-media-caption.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require captions for audio and video elements',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-media-caption.md',
templateMode: 'both',
},
fixable: null,
schema: [],
messages: {
missingTrack: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/require-media-caption.js',
docs: 'docs/rule/require-media-caption.md',
tests: 'test/unit/rules/require-media-caption-test.js',
},
},

create(context) {
return {
GlimmerElementNode(node) {
if (node.tag !== 'audio' && node.tag !== 'video') {
return;
}

// Check if the element has a muted attribute that exempts it
const mutedAttr = node.attributes?.find((a) => a.name === 'muted');
if (mutedAttr) {
// muted with no value (boolean attribute like <video muted>) → valid
if (!mutedAttr.value) {
return;
}

const value = mutedAttr.value;

// muted="true" or any string other than "false" → valid
if (value.type === 'GlimmerTextNode' && value.chars !== 'false') {
return;
}

// muted={{expr}} → valid (dynamic), unless it's a literal false (muted=false)
if (value.type === 'GlimmerMustacheStatement') {
const expr = value.path;
// muted=false → BooleanLiteral(false) → NOT muted, continue checking
if (expr?.type === 'GlimmerBooleanLiteral' && expr.value === false) {
// fall through to caption check
} else {
return;
}
}
}

// Check if there's a track element with kind="captions" as a child
const hasCaption = node.children?.some((child) => {
if (child.type !== 'GlimmerElementNode' || child.tag !== 'track') {
return false;
}

const kindAttr = child.attributes?.find((a) => a.name === 'kind');
if (!kindAttr) {
return false;
}

if (kindAttr.value?.type === 'GlimmerTextNode') {
return kindAttr.value.chars === 'captions';
}

return false;
});

if (!hasCaption) {
context.report({
node,
messageId: 'missingTrack',
});
}
},
};
},
};
209 changes: 209 additions & 0 deletions tests/lib/rules/template-require-media-caption.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const rule = require('../../../lib/rules/template-require-media-caption');
const RuleTester = require('eslint').RuleTester;

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

ruleTester.run('template-require-media-caption', rule, {
valid: [
`<template>
<video>
<track kind="captions" src="captions.vtt" />
</video>
</template>`,
`<template>
<audio>
<track kind="captions" src="captions.vtt" />
</audio>
</template>`,
`<template>
<div>No media elements</div>
</template>`,

'<template><video><track kind="captions" /></video></template>',
'<template><audio muted="true"></audio></template>',
'<template><video muted></video></template>',
'<template><audio muted={{this.muted}}></audio></template>',
'<template><video><track kind="captions" /><track kind="descriptions" /></video></template>',
],

invalid: [
{
code: `<template>
<video src="movie.mp4"></video>
</template>`,
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
type: 'GlimmerElementNode',
},
],
},
{
code: `<template>
<audio src="audio.mp3"></audio>
</template>`,
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
type: 'GlimmerElementNode',
},
],
},
{
code: `<template>
<video>
<track kind="subtitles" src="subs.vtt" />
</video>
</template>`,
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
type: 'GlimmerElementNode',
},
],
},

{
code: '<template><video></video></template>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
{
code: '<template><audio><track /></audio></template>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
{
code: '<template><video><track kind="subtitles" /></video></template>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
{
code: '<template><audio muted="false"></audio></template>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
{
code: '<template><audio muted="false"><track kind="descriptions" /></audio></template>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
{
code: '<template><video muted=false></video></template>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
],
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});

hbsRuleTester.run('template-require-media-caption', rule, {
valid: [
'<video><track kind="captions" /></video>',
'<audio muted="true"></audio>',
'<video muted></video>',
'<audio muted={{this.muted}}></audio>',
'<video><track kind="captions" /><track kind="descriptions" /></video>',
],
invalid: [
{
code: '<video></video>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
{
code: '<audio><track /></audio>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
{
code: '<video><track kind="subtitles" /></video>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
{
code: '<audio muted="false"></audio>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
{
code: '<audio muted="false"><track kind="descriptions" /></audio>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
{
code: '<video muted=false></video>',
output: null,
errors: [
{
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
},
],
},
],
});
Loading