Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .github/workflows/bench-compare.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
# (github.head_ref is a branch name that only exists on the fork remote).
ref: ${{ github.event.pull_request.head.sha }}

- uses: wyvox/action-setup-pnpm@v3
- uses: wyvox/action-setup-pnpm@v4

- name: Run benchmark comparison
env:
Expand Down
25 changes: 18 additions & 7 deletions docs/rules/template-require-iframe-title.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

## `<iframe>`

`<iframe>` elements must have a unique title property to indicate its content to the user.

This rule takes no arguments.
`<iframe>` elements must have a unique title property so assistive
technology can convey their content to the user. The normative
requirement is [WCAG SC 4.1.2 (Name, Role, Value)](https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html);
the `title` attribute is _one sufficient technique_ for meeting it
(sufficient technique [H64](https://www.w3.org/WAI/WCAG21/Techniques/html/H64)).

## Examples

Expand All @@ -27,12 +29,21 @@ This rule **forbids** the following:
<template>
<iframe />
<iframe title='' />
<iframe title=' ' />
<iframe title={{null}} />
<iframe title={{undefined}} />
<iframe title={{true}} />
<iframe title={{false}} />
<iframe title={{42}} />
</template>
```

## References

- [Deque University](https://dequeuniversity.com/rules/axe/1.1/frame-title)
- [Technique H65: Using the title attribute of the frame and iframe elements](https://www.w3.org/TR/2014/NOTE-WCAG20-TECHS-20140408/H64)
- [WCAG Success Criterion 2.4.1 - Bypass Blocks](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-skip.html)
- [WCAG Success Criterion 4.1.2 - Name, Role, Value](https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html)
- [WCAG SC 4.1.2 — Name, Role, Value](https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html)
— the normative requirement.
- [WCAG Technique H64 — Using the title attribute of the iframe element](https://www.w3.org/WAI/WCAG21/Techniques/html/H64)
— a sufficient technique for SC 4.1.2, not itself normative.
- [WCAG Success Criterion 2.4.1 — Bypass Blocks](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-skip.html)
- [ACCNAME 1.2 — accessible-name computation](https://www.w3.org/TR/accname-1.2/)
- [axe-core rule `frame-title`](https://dequeuniversity.com/rules/axe/4.10/frame-title)
158 changes: 116 additions & 42 deletions lib/rules/template-require-iframe-title.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,43 @@
'use strict';

// Non-string literal AST nodes (boolean/null/undefined/number) don't represent
// a meaningful author-provided title. Even though they would coerce to strings
// at runtime (e.g. `true` → "true", `42` → "42"), those strings do not describe
// the frame's content — the rule rejects the literal forms.
const INVALID_LITERAL_TYPES = new Set([
'GlimmerBooleanLiteral',
'GlimmerNullLiteral',
'GlimmerUndefinedLiteral',
'GlimmerNumberLiteral',
]);

function isInvalidTitleLiteralPath(path) {
return INVALID_LITERAL_TYPES.has(path?.type);
}

function getInvalidLiteralType(path) {
if (!path) {
return undefined;
}
switch (path.type) {
case 'GlimmerBooleanLiteral': {
return 'boolean';
}
case 'GlimmerNullLiteral': {
return 'null';
}
case 'GlimmerUndefinedLiteral': {
return 'undefined';
}
case 'GlimmerNumberLiteral': {
return 'number';
}
default: {
return undefined;
}
}
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
Expand All @@ -8,13 +48,15 @@ module.exports = {
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-iframe-title.md',
templateMode: 'both',
},
fixable: null,
schema: [],
messages: {
// Four messageIds (missingTitle, emptyTitle, dynamicFalseTitle,
// duplicateTitle) for richer diagnostic detail.
// Five messageIds (missingTitle, emptyTitle, invalidTitleLiteral,
// duplicateTitleFirst, duplicateTitleOther) for richer diagnostic detail.
missingTitle: '<iframe> elements must have a unique title property.',
emptyTitle: '<iframe> elements must have a unique title property.',
dynamicFalseTitle: '<iframe> elements must have a unique title property.',
invalidTitleLiteral:
'<iframe title> must be a non-empty string. Got {{literalType}} literal, which does not describe the frame contents.',
duplicateTitleFirst: 'This title is not unique. #{{index}}',
duplicateTitleOther:
'<iframe> elements must have a unique title property. Value title="{{title}}" already used for different iframe. #{{index}}',
Expand All @@ -34,6 +76,40 @@ module.exports = {
const knownTitles = [];
let nextDuplicateIndex = 1;

// Process a statically-known title string (from a text node OR a
// mustache string literal OR a single-part concat). Handles the empty /
// whitespace / duplicate logic that's shared across those AST shapes.
function processStaticTitle(node, raw) {
const value = raw.trim();
if (value.length === 0) {
context.report({ node, messageId: 'emptyTitle' });
return;
}
// Duplicate check — reports BOTH the first and the current occurrence
// on every collision, sharing a `#N` index so users can correlate them.
// For three or more duplicates the first occurrence is therefore
// re-reported once per collision.
const existing = knownTitles.find((entry) => entry.value === value);
if (existing) {
if (existing.index === null) {
existing.index = nextDuplicateIndex++;
}
const index = existing.index;
context.report({
node: existing.node,
messageId: 'duplicateTitleFirst',
data: { index: String(index) },
});
context.report({
node,
messageId: 'duplicateTitleOther',
data: { title: raw, index: String(index) },
});
} else {
knownTitles.push({ value, node, index: null });
}
}

return {
GlimmerElementNode(node) {
if (node.tag !== 'iframe') {
Expand All @@ -57,57 +133,55 @@ module.exports = {
if (titleAttr.value) {
switch (titleAttr.value.type) {
case 'GlimmerTextNode': {
const value = titleAttr.value.chars.trim();
if (value.length === 0) {
context.report({ node, messageId: 'emptyTitle' });
} else {
// Check for duplicate titles. Reports BOTH the first and the
// current occurrence on every collision, sharing a `#N` index
// so users can correlate them. For three or more duplicates
// the first occurrence is therefore re-reported once per
// collision.
const existing = knownTitles.find((entry) => entry.value === value);
if (existing) {
if (existing.index === null) {
existing.index = nextDuplicateIndex++;
}
const index = existing.index;

// Report on the first occurrence on every collision.
context.report({
node: existing.node,
messageId: 'duplicateTitleFirst',
data: { index: String(index) },
});

// Report on the current (duplicate) occurrence.
context.report({
node,
messageId: 'duplicateTitleOther',
data: { title: titleAttr.value.chars, index: String(index) },
});
} else {
knownTitles.push({ value, node, index: null });
}
}
processStaticTitle(node, titleAttr.value.chars);
break;
}
case 'GlimmerMustacheStatement': {
// title={{false}} → BooleanLiteral false is invalid
if (titleAttr.value.path?.type === 'GlimmerBooleanLiteral') {
context.report({ node, messageId: 'dynamicFalseTitle' });
// Non-string literal mustaches — boolean / null / undefined /
// number — get a specific "invalidTitleLiteral" diagnostic
// because the literal coerces to a string at runtime that
// doesn't describe the frame contents.
if (isInvalidTitleLiteralPath(titleAttr.value.path)) {
context.report({
node,
messageId: 'invalidTitleLiteral',
data: { literalType: getInvalidLiteralType(titleAttr.value.path) },
});
break;
}
// String-literal mustaches resolve to their static value — a
// non-empty literal supplies an accessible name the same as a
// text node. Empty / whitespace literals are flagged the same
// way as `title=""` / `title=" "`.
if (titleAttr.value.path?.type === 'GlimmerStringLiteral') {
processStaticTitle(node, titleAttr.value.path.value);
}
break;
}
case 'GlimmerConcatStatement': {
// title="{{false}}" → ConcatStatement with single BooleanLiteral part
const parts = titleAttr.value.parts || [];
// Single-part concat wrapping a non-string literal — same
// diagnostic as the bare mustache form.
if (
parts.length === 1 &&
parts[0].type === 'GlimmerMustacheStatement' &&
isInvalidTitleLiteralPath(parts[0].path)
) {
context.report({
node,
messageId: 'invalidTitleLiteral',
data: { literalType: getInvalidLiteralType(parts[0].path) },
});
break;
}
// Single-part concat wrapping a string literal — resolve to
// the static value and apply the same checks as a text node.
if (
parts.length === 1 &&
parts[0].type === 'GlimmerMustacheStatement' &&
parts[0].path?.type === 'GlimmerBooleanLiteral'
parts[0].path?.type === 'GlimmerStringLiteral'
) {
context.report({ node, messageId: 'dynamicFalseTitle' });
processStaticTitle(node, parts[0].path.value);
}
break;
}
Expand Down
45 changes: 40 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,39 @@ 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.
* (Why a name is needed: WCAG SC 4.1.2 — "all user interface components have a
* name and role that can be programmatically determined.")
*
* 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 +199,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 +212,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 +224,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
Loading
Loading