Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2337221
fix(template-require-valid-alt-text): reject empty-string aria-label/…
johanrd Apr 21, 2026
7488d8d
chore: drop temporal 'previously accepted' comment
johanrd Apr 21, 2026
56567ef
test: add Phase 3 audit fixture translating alt-text peer cases
johanrd Apr 21, 2026
98e022d
fix+docs+test: trim whitespace, tighten JSDoc, add whitespace-only co…
johanrd Apr 22, 2026
82022bb
docs: correct audit-fixture CI-run claim (Copilot review)
johanrd Apr 22, 2026
bc64218
fix(template-require-valid-alt-text): address Copilot review — JSDoc …
johanrd Apr 23, 2026
e7c6a6a
fix(#56): address round-2 Copilot review (drop unused hasAnyAttr helper)
johanrd Apr 24, 2026
2abfbea
chore(alt-text/peer-parity): drop reference to non-existent docs/audi…
johanrd Apr 24, 2026
67de7c7
fix(template-require-valid-alt-text): unwrap mustache literals via sh…
johanrd Apr 24, 2026
6101f28
test(template-require-valid-alt-text): absorb audit-fixture cases, dr…
johanrd Apr 25, 2026
19e44c4
BUGFIX: template-require-iframe-title — flag invalid title literals +…
johanrd Apr 25, 2026
6cc136f
docs(template-require-valid-alt-text): add WCAG SC 4.1.2 citation to …
johanrd Apr 25, 2026
30b3fe9
chore(deps): update wyvox/action-setup-pnpm action to v4 (#2742)
renovate[bot] Apr 25, 2026
634af79
refactor(template-require-iframe-title): remove allowWhitespaceOnlyTi…
johanrd Apr 25, 2026
e170971
Merge pull request #2731 from johanrd/fix/iframe-title-value-checks
NullVoxPopuli Apr 25, 2026
5c900c3
Merge branch 'master' into fix/alt-text-empty-aria-label
johanrd Apr 25, 2026
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
43 changes: 38 additions & 5 deletions lib/rules/template-require-valid-alt-text.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { getStaticAttrValue } = require('../utils/static-attr-value');

const REDUNDANT_WORDS = ['image', 'photo', 'picture', 'logo', 'spacer'];

function findAttr(node, name) {
Expand All @@ -8,8 +10,37 @@ function hasAttr(node, name) {
return node.attributes?.some((a) => a.name === name);
}

function hasAnyAttr(node, names) {
return names.some((name) => hasAttr(node, name));
/**
* Returns true if the named attribute is present with a non-empty, non-whitespace
* static value, OR present with a dynamic (mustache/concat) value. Dynamic values
* are assumed to resolve to a meaningful name at runtime (we can't verify at lint
* time). Static empty-string / whitespace-only values return false — applied to
* alt / aria-label / aria-labelledby / title. The empty-name treatment aligns with
* ACCNAME 1.2 §4.3.2's aria-label step (2D), which normalizes empty/whitespace
* to "no name"; we apply the same normalization to the other fallbacks.
*
* NOTE: This does not validate that aria-labelledby IDREFs reference existing IDs.
* Rule consumers should layer that check separately if needed.
*/
function hasNonEmptyTextAttr(node, name) {
const attr = findAttr(node, name);
if (!attr?.value) {
return false;
}
// Resolve mustache-literal / single-part concat forms to their static
// string via the shared helper. `aria-label={{""}}` / `aria-label="{{""}}"`
// now normalise to the empty string and are treated the same as the
// text-node empty value.
const resolved = getStaticAttrValue(attr.value);
if (resolved === undefined) {
// Genuinely dynamic — assume truthy (can't verify at lint time).
return true;
}
return resolved.trim() !== '';
}
Comment thread
johanrd marked this conversation as resolved.

function hasAnyNonEmptyTextAttr(node, names) {
return names.some((name) => hasNonEmptyTextAttr(node, name));
}
Comment thread
johanrd marked this conversation as resolved.

function getTextValue(attr) {
Expand Down Expand Up @@ -166,7 +197,9 @@ module.exports = {
return;
}

if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
// Empty-string aria-label/aria-labelledby/alt provides no accessible
// name — require a non-empty fallback value.
if (!hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
context.report({ node, messageId: 'inputImage' });
}

Expand All @@ -177,7 +210,7 @@ module.exports = {
const roleValue = getTextValue(roleAttr);

if (
hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'title']) ||
hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'title']) ||
hasChildren(node) ||
(roleValue && ['presentation', 'none'].includes(roleValue))
) {
Expand All @@ -189,7 +222,7 @@ module.exports = {
break;
}
case 'area': {
if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
if (!hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) {
context.report({ node, messageId: 'areaMissing' });
}

Expand Down
64 changes: 64 additions & 0 deletions lib/utils/static-attr-value.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use strict';

/**
* Return the statically-known string value of a Glimmer attribute value node,
* or `undefined` when the value is dynamic (cannot be resolved at lint time).
*
* Unwraps:
* - GlimmerTextNode → chars
* - GlimmerMustacheStatement with a literal path (boolean/string/number) → stringified value
* - GlimmerConcatStatement whose parts are all statically resolvable → joined string
*
* A missing/undefined value (valueless attribute, e.g. `<input disabled>`)
* returns the empty string. Pass `attr.value` — not the attribute itself.
*/
function getStaticAttrValue(value) {
if (value === null || value === undefined) {
return '';
}
if (value.type === 'GlimmerTextNode') {
return value.chars;
}
if (value.type === 'GlimmerMustacheStatement') {
return extractLiteral(value.path);
}
if (value.type === 'GlimmerConcatStatement') {
const parts = value.parts || [];
let out = '';
for (const part of parts) {
if (part.type === 'GlimmerTextNode') {
out += part.chars;
continue;
}
if (part.type === 'GlimmerMustacheStatement') {
const literal = extractLiteral(part.path);
if (literal === undefined) {
return undefined;
}
out += literal;
continue;
}
return undefined;
}
return out;
}
return undefined;
}

function extractLiteral(path) {
if (!path) {
return undefined;
}
if (path.type === 'GlimmerBooleanLiteral') {
return path.value ? 'true' : 'false';
}
if (path.type === 'GlimmerStringLiteral') {
return path.value;
}
if (path.type === 'GlimmerNumberLiteral') {
return String(path.value);
}
return undefined;
}

module.exports = { getStaticAttrValue };
199 changes: 199 additions & 0 deletions tests/audit/alt-text/peer-parity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Audit fixture — translates peer-plugin test cases into assertions against
// our rule (`ember/template-require-valid-alt-text`). Runs as part of the
// default Vitest suite (via the `tests/**/*.js` include glob) and serves
// double-duty: (1) auditable record of peer-parity divergences,
Comment thread
johanrd marked this conversation as resolved.
Outdated
// (2) regression coverage pinning CURRENT behavior. Each case encodes what
// OUR rule does today; divergences from upstream plugins are annotated as
// `DIVERGENCE —`. Peer-only constructs that can't be translated to Ember
// templates (JSX spread props, Vue v-bind, Angular `$event`, undefined-handler
// expression analysis) are marked `AUDIT-SKIP`. Divergences are also captured
// in PR descriptions / commit messages; grepping the repo for `DIVERGENCE —`
// surfaces the full list.
//
// Source files:
// - context/eslint-plugin-jsx-a11y-main/__tests__/src/rules/alt-text-test.js
// - context/eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/alt-text.test.ts
// - context/eslint-plugin-lit-a11y/tests/lib/rules/alt-text.js

'use strict';

const rule = require('../../../lib/rules/template-require-valid-alt-text');
const RuleTester = require('eslint').RuleTester;

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

ruleTester.run('audit:alt-text (gts)', rule, {
valid: [
// === Upstream parity (valid in jsx-a11y + ours) ===
'<template><img alt="foo" /></template>',
'<template><img alt="" /></template>',
'<template><img alt=" " /></template>',
'<template><img alt="" role="presentation" /></template>',
'<template><img alt="" role="none" /></template>',
// DIVERGENCE — moved to invalid below:
// '<template><img alt="this is lit..." role="presentation" /></template>',
'<template><img alt={{@dynamicAlt}} /></template>',
// object with label/children
'<template><object aria-label="foo" /></template>',
'<template><object aria-labelledby="id1" /></template>',
'<template><object>Foo</object></template>',
'<template><object title="An object" /></template>',
// area with label
'<template><area aria-label="foo" /></template>',
'<template><area aria-labelledby="id1" /></template>',
'<template><area alt="foo" /></template>',
// input[type=image]
'<template><input type="image" alt="foo" /></template>',
'<template><input type="image" aria-label="foo" /></template>',
'<template><input type="image" aria-labelledby="id1" /></template>',

// === DIVERGENCE — aria-label/aria-labelledby on <img> without alt ===
// jsx-a11y: VALID — `<img aria-label="foo" />` is accepted.
// vue-a11y: VALID — same.
// Our rule: INVALID — requires `alt` attribute on <img>, full stop.
// Spec reading: the HTML spec mandates alt on <img>. WAI-ARIA accepts
// aria-label/aria-labelledby as alternative accessible-name sources. The
// two specs disagree; we side with HTML-strict.
// No valid test here — we flag; see invalid section.

// === Edge cases we handle ===
// alt === src (we flag)
// numeric alt (we flag)
// redundant words (we flag)
],
invalid: [
// === Upstream parity (invalid in jsx-a11y + ours) ===
{
code: '<template><img /></template>',
output: null,
errors: [{ messageId: 'imgMissing' }],
},
{
code: '<template><input type="image" /></template>',
output: null,
errors: [{ messageId: 'inputImage' }],
},
{
code: '<template><object /></template>',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<template><area /></template>',
output: null,
errors: [{ messageId: 'areaMissing' }],
},

// === DIVERGENCE — <img aria-label> without alt ===
// jsx-a11y: VALID. Ours: INVALID (imgMissing).
// Behavior captured here; potential false positive per WAI-ARIA.
{
code: '<template><img aria-label="foo" /></template>',
output: null,
errors: [{ messageId: 'imgMissing' }],
},
{
code: '<template><img aria-labelledby="id1" /></template>',
output: null,
errors: [{ messageId: 'imgMissing' }],
},

// === DIVERGENCE — non-empty alt with role=presentation on img ===
// jsx-a11y: VALID — accepts `<img alt="this is lit..." role="presentation" />`.
// Ours: INVALID — imgRolePresentation. We're spec-strict: if role is
// "none"/"presentation", the image is decorative and alt should be empty.
{
code: '<template><img alt="this is lit..." role="presentation" /></template>',
output: null,
errors: [{ messageId: 'imgRolePresentation' }],
},

// === Parity — empty-string aria-label/aria-labelledby ===
// jsx-a11y / vuejs-accessibility flag empty-string fallbacks on the
// "accessible-name-required" elements (<object>, <area>, <input
// type=image>). Our rule now reuses the existing messageIds.
{
code: '<template><object aria-label="" /></template>',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<template><object aria-labelledby="" /></template>',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<template><area aria-label="" /></template>',
output: null,
errors: [{ messageId: 'areaMissing' }],
},
{
code: '<template><area aria-labelledby="" /></template>',
output: null,
errors: [{ messageId: 'areaMissing' }],
},
{
code: '<template><input type="image" aria-label="" /></template>',
output: null,
errors: [{ messageId: 'inputImage' }],
},
{
code: '<template><input type="image" aria-labelledby="" /></template>',
output: null,
errors: [{ messageId: 'inputImage' }],
},
],
});

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

hbsRuleTester.run('audit:alt-text (hbs)', rule, {
valid: [
'<img alt="foo" />',
'<img alt="" />',
'<img alt="" role="presentation" />',
'<object aria-label="foo" />',
'<area aria-label="foo" />',
'<input type="image" aria-label="foo" />',
],
invalid: [
{
code: '<img />',
output: null,
errors: [{ messageId: 'imgMissing' }],
},
{
code: '<input type="image" />',
output: null,
errors: [{ messageId: 'inputImage' }],
},
{
code: '<object />',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
{
code: '<area />',
output: null,
errors: [{ messageId: 'areaMissing' }],
},
// DIVERGENCE captured — we flag img-with-aria-label (jsx-a11y/vue-a11y don't)
{
code: '<img aria-label="foo" />',
output: null,
errors: [{ messageId: 'imgMissing' }],
},
// Parity — empty-string label on accessible-name-required elements.
{
code: '<object aria-label="" />',
output: null,
errors: [{ messageId: 'objectMissing' }],
},
],
});
Loading
Loading