Skip to content

Commit e170971

Browse files
Merge pull request #2731 from johanrd/fix/iframe-title-value-checks
BUGFIX: template-require-iframe-title — flag title={{null|undefined|number}}
2 parents 30b3fe9 + 634af79 commit e170971

3 files changed

Lines changed: 247 additions & 53 deletions

File tree

docs/rules/template-require-iframe-title.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
## `<iframe>`
88

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

1315
## Examples
1416

@@ -27,12 +29,21 @@ This rule **forbids** the following:
2729
<template>
2830
<iframe />
2931
<iframe title='' />
32+
<iframe title=' ' />
33+
<iframe title={{null}} />
34+
<iframe title={{undefined}} />
35+
<iframe title={{true}} />
36+
<iframe title={{false}} />
37+
<iframe title={{42}} />
3038
</template>
3139
```
3240

3341
## References
3442

35-
- [Deque University](https://dequeuniversity.com/rules/axe/1.1/frame-title)
36-
- [Technique H65: Using the title attribute of the frame and iframe elements](https://www.w3.org/TR/2014/NOTE-WCAG20-TECHS-20140408/H64)
37-
- [WCAG Success Criterion 2.4.1 - Bypass Blocks](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-skip.html)
38-
- [WCAG Success Criterion 4.1.2 - Name, Role, Value](https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html)
43+
- [WCAG SC 4.1.2 — Name, Role, Value](https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html)
44+
— the normative requirement.
45+
- [WCAG Technique H64 — Using the title attribute of the iframe element](https://www.w3.org/WAI/WCAG21/Techniques/html/H64)
46+
— a sufficient technique for SC 4.1.2, not itself normative.
47+
- [WCAG Success Criterion 2.4.1 — Bypass Blocks](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-skip.html)
48+
- [ACCNAME 1.2 — accessible-name computation](https://www.w3.org/TR/accname-1.2/)
49+
- [axe-core rule `frame-title`](https://dequeuniversity.com/rules/axe/4.10/frame-title)

lib/rules/template-require-iframe-title.js

Lines changed: 116 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,43 @@
1+
'use strict';
2+
3+
// Non-string literal AST nodes (boolean/null/undefined/number) don't represent
4+
// a meaningful author-provided title. Even though they would coerce to strings
5+
// at runtime (e.g. `true` → "true", `42` → "42"), those strings do not describe
6+
// the frame's content — the rule rejects the literal forms.
7+
const INVALID_LITERAL_TYPES = new Set([
8+
'GlimmerBooleanLiteral',
9+
'GlimmerNullLiteral',
10+
'GlimmerUndefinedLiteral',
11+
'GlimmerNumberLiteral',
12+
]);
13+
14+
function isInvalidTitleLiteralPath(path) {
15+
return INVALID_LITERAL_TYPES.has(path?.type);
16+
}
17+
18+
function getInvalidLiteralType(path) {
19+
if (!path) {
20+
return undefined;
21+
}
22+
switch (path.type) {
23+
case 'GlimmerBooleanLiteral': {
24+
return 'boolean';
25+
}
26+
case 'GlimmerNullLiteral': {
27+
return 'null';
28+
}
29+
case 'GlimmerUndefinedLiteral': {
30+
return 'undefined';
31+
}
32+
case 'GlimmerNumberLiteral': {
33+
return 'number';
34+
}
35+
default: {
36+
return undefined;
37+
}
38+
}
39+
}
40+
141
/** @type {import('eslint').Rule.RuleModule} */
242
module.exports = {
343
meta: {
@@ -8,13 +48,15 @@ module.exports = {
848
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-iframe-title.md',
949
templateMode: 'both',
1050
},
51+
fixable: null,
1152
schema: [],
1253
messages: {
13-
// Four messageIds (missingTitle, emptyTitle, dynamicFalseTitle,
14-
// duplicateTitle) for richer diagnostic detail.
54+
// Five messageIds (missingTitle, emptyTitle, invalidTitleLiteral,
55+
// duplicateTitleFirst, duplicateTitleOther) for richer diagnostic detail.
1556
missingTitle: '<iframe> elements must have a unique title property.',
1657
emptyTitle: '<iframe> elements must have a unique title property.',
17-
dynamicFalseTitle: '<iframe> elements must have a unique title property.',
58+
invalidTitleLiteral:
59+
'<iframe title> must be a non-empty string. Got {{literalType}} literal, which does not describe the frame contents.',
1860
duplicateTitleFirst: 'This title is not unique. #{{index}}',
1961
duplicateTitleOther:
2062
'<iframe> elements must have a unique title property. Value title="{{title}}" already used for different iframe. #{{index}}',
@@ -34,6 +76,40 @@ module.exports = {
3476
const knownTitles = [];
3577
let nextDuplicateIndex = 1;
3678

79+
// Process a statically-known title string (from a text node OR a
80+
// mustache string literal OR a single-part concat). Handles the empty /
81+
// whitespace / duplicate logic that's shared across those AST shapes.
82+
function processStaticTitle(node, raw) {
83+
const value = raw.trim();
84+
if (value.length === 0) {
85+
context.report({ node, messageId: 'emptyTitle' });
86+
return;
87+
}
88+
// Duplicate check — reports BOTH the first and the current occurrence
89+
// on every collision, sharing a `#N` index so users can correlate them.
90+
// For three or more duplicates the first occurrence is therefore
91+
// re-reported once per collision.
92+
const existing = knownTitles.find((entry) => entry.value === value);
93+
if (existing) {
94+
if (existing.index === null) {
95+
existing.index = nextDuplicateIndex++;
96+
}
97+
const index = existing.index;
98+
context.report({
99+
node: existing.node,
100+
messageId: 'duplicateTitleFirst',
101+
data: { index: String(index) },
102+
});
103+
context.report({
104+
node,
105+
messageId: 'duplicateTitleOther',
106+
data: { title: raw, index: String(index) },
107+
});
108+
} else {
109+
knownTitles.push({ value, node, index: null });
110+
}
111+
}
112+
37113
return {
38114
GlimmerElementNode(node) {
39115
if (node.tag !== 'iframe') {
@@ -57,57 +133,55 @@ module.exports = {
57133
if (titleAttr.value) {
58134
switch (titleAttr.value.type) {
59135
case 'GlimmerTextNode': {
60-
const value = titleAttr.value.chars.trim();
61-
if (value.length === 0) {
62-
context.report({ node, messageId: 'emptyTitle' });
63-
} else {
64-
// Check for duplicate titles. Reports BOTH the first and the
65-
// current occurrence on every collision, sharing a `#N` index
66-
// so users can correlate them. For three or more duplicates
67-
// the first occurrence is therefore re-reported once per
68-
// collision.
69-
const existing = knownTitles.find((entry) => entry.value === value);
70-
if (existing) {
71-
if (existing.index === null) {
72-
existing.index = nextDuplicateIndex++;
73-
}
74-
const index = existing.index;
75-
76-
// Report on the first occurrence on every collision.
77-
context.report({
78-
node: existing.node,
79-
messageId: 'duplicateTitleFirst',
80-
data: { index: String(index) },
81-
});
82-
83-
// Report on the current (duplicate) occurrence.
84-
context.report({
85-
node,
86-
messageId: 'duplicateTitleOther',
87-
data: { title: titleAttr.value.chars, index: String(index) },
88-
});
89-
} else {
90-
knownTitles.push({ value, node, index: null });
91-
}
92-
}
136+
processStaticTitle(node, titleAttr.value.chars);
93137
break;
94138
}
95139
case 'GlimmerMustacheStatement': {
96-
// title={{false}} → BooleanLiteral false is invalid
97-
if (titleAttr.value.path?.type === 'GlimmerBooleanLiteral') {
98-
context.report({ node, messageId: 'dynamicFalseTitle' });
140+
// Non-string literal mustaches — boolean / null / undefined /
141+
// number — get a specific "invalidTitleLiteral" diagnostic
142+
// because the literal coerces to a string at runtime that
143+
// doesn't describe the frame contents.
144+
if (isInvalidTitleLiteralPath(titleAttr.value.path)) {
145+
context.report({
146+
node,
147+
messageId: 'invalidTitleLiteral',
148+
data: { literalType: getInvalidLiteralType(titleAttr.value.path) },
149+
});
150+
break;
151+
}
152+
// String-literal mustaches resolve to their static value — a
153+
// non-empty literal supplies an accessible name the same as a
154+
// text node. Empty / whitespace literals are flagged the same
155+
// way as `title=""` / `title=" "`.
156+
if (titleAttr.value.path?.type === 'GlimmerStringLiteral') {
157+
processStaticTitle(node, titleAttr.value.path.value);
99158
}
100159
break;
101160
}
102161
case 'GlimmerConcatStatement': {
103-
// title="{{false}}" → ConcatStatement with single BooleanLiteral part
104162
const parts = titleAttr.value.parts || [];
163+
// Single-part concat wrapping a non-string literal — same
164+
// diagnostic as the bare mustache form.
165+
if (
166+
parts.length === 1 &&
167+
parts[0].type === 'GlimmerMustacheStatement' &&
168+
isInvalidTitleLiteralPath(parts[0].path)
169+
) {
170+
context.report({
171+
node,
172+
messageId: 'invalidTitleLiteral',
173+
data: { literalType: getInvalidLiteralType(parts[0].path) },
174+
});
175+
break;
176+
}
177+
// Single-part concat wrapping a string literal — resolve to
178+
// the static value and apply the same checks as a text node.
105179
if (
106180
parts.length === 1 &&
107181
parts[0].type === 'GlimmerMustacheStatement' &&
108-
parts[0].path?.type === 'GlimmerBooleanLiteral'
182+
parts[0].path?.type === 'GlimmerStringLiteral'
109183
) {
110-
context.report({ node, messageId: 'dynamicFalseTitle' });
184+
processStaticTitle(node, parts[0].path.value);
111185
}
112186
break;
113187
}

0 commit comments

Comments
 (0)