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,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