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 } */
242module . exports = {
343 meta : {
@@ -8,13 +48,25 @@ 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 } ,
11- schema : [ ] ,
51+ fixable : null ,
52+ schema : [
53+ {
54+ type : 'object' ,
55+ properties : {
56+ allowWhitespaceOnlyTitle : {
57+ type : 'boolean' ,
58+ } ,
59+ } ,
60+ additionalProperties : false ,
61+ } ,
62+ ] ,
1263 messages : {
13- // Four messageIds (missingTitle, emptyTitle, dynamicFalseTitle ,
14- // duplicateTitle ) for richer diagnostic detail.
64+ // Five messageIds (missingTitle, emptyTitle, invalidTitleLiteral ,
65+ // duplicateTitleFirst, duplicateTitleOther ) for richer diagnostic detail.
1566 missingTitle : '<iframe> elements must have a unique title property.' ,
1667 emptyTitle : '<iframe> elements must have a unique title property.' ,
17- dynamicFalseTitle : '<iframe> elements must have a unique title property.' ,
68+ invalidTitleLiteral :
69+ '<iframe title> must be a non-empty string. Got {{literalType}} literal, which does not describe the frame contents.' ,
1870 duplicateTitleFirst : 'This title is not unique. #{{index}}' ,
1971 duplicateTitleOther :
2072 '<iframe> elements must have a unique title property. Value title="{{title}}" already used for different iframe. #{{index}}' ,
@@ -27,13 +79,59 @@ module.exports = {
2779 } ,
2880 } ,
2981 create ( context ) {
82+ // Whitespace-only `title=" "` is technically spec-compliant: ACCNAME
83+ // 1.2 step 2I (Tooltip) does not whitespace-trim like step 2D
84+ // (aria-label) does, so a 3-space accessible name is assigned. That is
85+ // useless in practice but not a spec violation. Default behavior flags
86+ // it as authoring hygiene; set `allowWhitespaceOnlyTitle: true` to
87+ // align with spec/peer behavior.
88+ const allowWhitespaceOnlyTitle = Boolean ( context . options [ 0 ] ?. allowWhitespaceOnlyTitle ) ;
89+
3090 // Each entry: { value, node, index }
3191 // - value: trimmed title string
3292 // - node: original element node for the first occurrence
3393 // - index: duplicate-group index (1-based), assigned lazily on collision
3494 const knownTitles = [ ] ;
3595 let nextDuplicateIndex = 1 ;
3696
97+ // Process a statically-known title string (from a text node OR a
98+ // mustache string literal OR a single-part concat). Handles the empty /
99+ // whitespace / duplicate logic that's shared across those AST shapes.
100+ function processStaticTitle ( node , raw ) {
101+ const value = raw . trim ( ) ;
102+ if ( value . length === 0 ) {
103+ // Empty-string title always fails: no accessible name for screen readers.
104+ // Whitespace-only titles are controlled by `allowWhitespaceOnlyTitle`.
105+ if ( raw . length === 0 || ! allowWhitespaceOnlyTitle ) {
106+ context . report ( { node, messageId : 'emptyTitle' } ) ;
107+ }
108+ return ;
109+ }
110+ // Duplicate check — reports BOTH the first and the current occurrence
111+ // on every collision, sharing a `#N` index so users can correlate them.
112+ // For three or more duplicates the first occurrence is therefore
113+ // re-reported once per collision.
114+ const existing = knownTitles . find ( ( entry ) => entry . value === value ) ;
115+ if ( existing ) {
116+ if ( existing . index === null ) {
117+ existing . index = nextDuplicateIndex ++ ;
118+ }
119+ const index = existing . index ;
120+ context . report ( {
121+ node : existing . node ,
122+ messageId : 'duplicateTitleFirst' ,
123+ data : { index : String ( index ) } ,
124+ } ) ;
125+ context . report ( {
126+ node,
127+ messageId : 'duplicateTitleOther' ,
128+ data : { title : raw , index : String ( index ) } ,
129+ } ) ;
130+ } else {
131+ knownTitles . push ( { value, node, index : null } ) ;
132+ }
133+ }
134+
37135 return {
38136 GlimmerElementNode ( node ) {
39137 if ( node . tag !== 'iframe' ) {
@@ -57,57 +155,55 @@ module.exports = {
57155 if ( titleAttr . value ) {
58156 switch ( titleAttr . value . type ) {
59157 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- }
158+ processStaticTitle ( node , titleAttr . value . chars ) ;
93159 break ;
94160 }
95161 case 'GlimmerMustacheStatement' : {
96- // title={{false}} → BooleanLiteral false is invalid
97- if ( titleAttr . value . path ?. type === 'GlimmerBooleanLiteral' ) {
98- context . report ( { node, messageId : 'dynamicFalseTitle' } ) ;
162+ // Non-string literal mustaches — boolean / null / undefined /
163+ // number — get a specific "invalidTitleLiteral" diagnostic
164+ // because the literal coerces to a string at runtime that
165+ // doesn't describe the frame contents.
166+ if ( isInvalidTitleLiteralPath ( titleAttr . value . path ) ) {
167+ context . report ( {
168+ node,
169+ messageId : 'invalidTitleLiteral' ,
170+ data : { literalType : getInvalidLiteralType ( titleAttr . value . path ) } ,
171+ } ) ;
172+ break ;
173+ }
174+ // String-literal mustaches resolve to their static value — a
175+ // non-empty literal supplies an accessible name the same as a
176+ // text node. Empty / whitespace literals are flagged the same
177+ // way as `title=""` / `title=" "`.
178+ if ( titleAttr . value . path ?. type === 'GlimmerStringLiteral' ) {
179+ processStaticTitle ( node , titleAttr . value . path . value ) ;
99180 }
100181 break ;
101182 }
102183 case 'GlimmerConcatStatement' : {
103- // title="{{false}}" → ConcatStatement with single BooleanLiteral part
104184 const parts = titleAttr . value . parts || [ ] ;
185+ // Single-part concat wrapping a non-string literal — same
186+ // diagnostic as the bare mustache form.
187+ if (
188+ parts . length === 1 &&
189+ parts [ 0 ] . type === 'GlimmerMustacheStatement' &&
190+ isInvalidTitleLiteralPath ( parts [ 0 ] . path )
191+ ) {
192+ context . report ( {
193+ node,
194+ messageId : 'invalidTitleLiteral' ,
195+ data : { literalType : getInvalidLiteralType ( parts [ 0 ] . path ) } ,
196+ } ) ;
197+ break ;
198+ }
199+ // Single-part concat wrapping a string literal — resolve to
200+ // the static value and apply the same checks as a text node.
105201 if (
106202 parts . length === 1 &&
107203 parts [ 0 ] . type === 'GlimmerMustacheStatement' &&
108- parts [ 0 ] . path ?. type === 'GlimmerBooleanLiteral '
204+ parts [ 0 ] . path ?. type === 'GlimmerStringLiteral '
109205 ) {
110- context . report ( { node, messageId : 'dynamicFalseTitle' } ) ;
206+ processStaticTitle ( node , parts [ 0 ] . path . value ) ;
111207 }
112208 break ;
113209 }
0 commit comments