Skip to content
Closed
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
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}} />
Comment thread
johanrd marked this conversation as resolved.
</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)
11 changes: 10 additions & 1 deletion lib/rules/template-no-yield-only.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
function isEmptyNode(node) {
return (
node.type === 'GlimmerMustacheCommentStatement' ||
node.type === 'GlimmerCommentStatement' ||
(node.type === 'GlimmerTextNode' && !node.chars.trim())
);
}

function isYieldOnly(node) {
return (
node.type === 'GlimmerMustacheStatement' &&
Expand Down Expand Up @@ -44,7 +52,8 @@ module.exports = {
? node.body[0].children
: node.body;

if (templateNodes.length === 1 && isYieldOnly(templateNodes[0])) {
const nonEmptyNodes = templateNodes.filter((n) => !isEmptyNode(n));
if (nonEmptyNodes.length === 1 && isYieldOnly(nonEmptyNodes[0])) {
isOnlyYield = true;
}
},
Expand Down
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.
Comment thread
johanrd marked this conversation as resolved.
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
10 changes: 10 additions & 0 deletions tests/lib/rules/template-no-yield-only.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ const invalidHbs = [
output: null,
errors: [{ messageId: 'noYieldOnly' }],
},
{
code: '{{!-- long-form comment --}}{{yield}}',
output: null,
errors: [{ messageId: 'noYieldOnly' }],
},
{
code: '<!-- html comment -->{{yield}}',
output: null,
errors: [{ messageId: 'noYieldOnly' }],
},
];

function wrapTemplate(entry) {
Expand Down
Loading
Loading