@@ -8,31 +8,33 @@ import { isAddCommandCall } from '../utils/commands';
88import { getObjectProperties } from '../utils/plugin-utils' ;
99import { createRule } from '../utils/create-rule' ;
1010
11- /**
12- * Returns true if the node is a non-empty raw string literal that should be
13- * wrapped in a translation call. Handles:
14- * - String Literal: 'string' or "string"
15- * - TemplateLiteral with no expressions: `string`
16- * - Concise ArrowFunctionExpression whose body is one of the above
17- */
18- function isRawStringNode ( node : TSESTree . Node ) : boolean {
19- if ( node . type === 'Literal' ) {
20- return typeof node . value === 'string' && node . value . length > 0 ;
11+ function hasLetters ( str : string ) : boolean {
12+ return / \p{ L} / u. test ( str ) ;
13+ }
14+
15+ function getRawStringValue ( node : TSESTree . Node ) : string | null {
16+ if ( node . type === 'Literal' && typeof node . value === 'string' ) {
17+ return node . value ;
2118 }
22- if ( node . type === 'TemplateLiteral' ) {
23- if ( node . expressions . length > 0 ) {
24- return false ;
25- }
26- const cooked = node . quasis . map ( q => q . value . cooked ?? '' ) . join ( '' ) ;
27- return cooked . length > 0 ;
19+ if ( node . type === 'TemplateLiteral' && node . expressions . length === 0 ) {
20+ return node . quasis . map ( q => q . value . cooked ?? '' ) . join ( '' ) ;
2821 }
2922 if (
3023 node . type === 'ArrowFunctionExpression' &&
3124 node . body . type !== 'BlockStatement'
3225 ) {
33- return isRawStringNode ( node . body ) ;
26+ return getRawStringValue ( node . body ) ;
3427 }
35- return false ;
28+ return null ;
29+ }
30+
31+ /**
32+ * Returns true if the node is a non-empty raw string literal that should be
33+ * wrapped in a translation call.
34+ */
35+ function isRawStringNode ( node : TSESTree . Node ) : boolean {
36+ const rawValue = getRawStringValue ( node ) ;
37+ return rawValue !== null && rawValue . trim ( ) . length > 0 ;
3638}
3739
3840function isSetAttributeCall ( node : TSESTree . CallExpression ) : boolean {
@@ -71,9 +73,11 @@ function isDialogButtonCall(node: TSESTree.CallExpression): boolean {
7173}
7274
7375const MONITORED_COMMAND_PROPS = [ 'label' , 'caption' , 'usage' ] ;
74- const MONITORED_SET_ATTRIBUTE_ATTRS = [ 'aria-label' , 'aria-description' , 'title' ] ;
76+ const MONITORED_A11Y_ATTRS = [ 'aria-label' , 'aria-description' , 'title' ] ;
77+ const MONITORED_SET_ATTRIBUTE_ATTRS = MONITORED_A11Y_ATTRS ;
7578const MONITORED_ASSIGNMENT_PROPS = [ 'title' , 'ariaLabel' ] ;
7679const MONITORED_DIALOG_PROPS = [ 'title' , 'body' ] ;
80+ const MONITORED_JSX_ATTRS = MONITORED_A11Y_ATTRS ;
7781
7882const noUntranslatedString = createRule ( {
7983 name : 'no-untranslated-string' ,
@@ -100,11 +104,23 @@ const noUntranslatedString = createRule({
100104 untranslatedJsxText :
101105 'JSX text content has an untranslated string literal. Wrap it with {trans.__(...)}'
102106 } ,
103- schema : [ ]
107+ schema : [
108+ {
109+ type : 'object' ,
110+ properties : {
111+ enforcePunctuation : { type : 'boolean' }
112+ } ,
113+ additionalProperties : false
114+ }
115+ ]
104116 } ,
105- defaultOptions : [ ] ,
117+ defaultOptions : [ { enforcePunctuation : false } ] ,
106118
107119 create ( context ) {
120+ const enforcePunctuation =
121+ ( context . options [ 0 ] as { enforcePunctuation ?: boolean } )
122+ ?. enforcePunctuation ?? false ;
123+
108124 return {
109125 CallExpression ( node ) {
110126 // Branch A: commands.addCommand(id, { label, caption, usage })
@@ -265,9 +281,27 @@ const noUntranslatedString = createRule({
265281 }
266282 } ,
267283
284+ // Accessibility attribute with a plain string: <span aria-label="text" />
285+ JSXAttribute ( node ) {
286+ if ( ! node . value || node . value . type === 'JSXExpressionContainer' ) {
287+ return ;
288+ }
289+ const attrName =
290+ node . name . type === 'JSXIdentifier' ? node . name . name : null ;
291+ if ( ! attrName || ! MONITORED_JSX_ATTRS . includes ( attrName ) ) {
292+ return ;
293+ }
294+ if ( isRawStringNode ( node . value ) ) {
295+ context . report ( {
296+ node : node . value ,
297+ messageId : 'untranslatedJsxText'
298+ } ) ;
299+ }
300+ } ,
301+
268302 // Raw text between JSX tags: <span>Untranslated text</span>
269303 JSXText ( node ) {
270- if ( node . value . trim ( ) . length > 0 ) {
304+ if ( node . value . trim ( ) . length > 0 && ( enforcePunctuation || hasLetters ( node . value ) ) ) {
271305 context . report ( {
272306 node,
273307 messageId : 'untranslatedJsxText'
@@ -280,11 +314,30 @@ const noUntranslatedString = createRule({
280314 if ( node . expression . type === 'JSXEmptyExpression' ) {
281315 return ;
282316 }
317+ if ( node . parent . type === 'JSXAttribute' ) {
318+ const attrName =
319+ node . parent . name . type === 'JSXIdentifier'
320+ ? node . parent . name . name
321+ : null ;
322+ if ( ! attrName || ! MONITORED_JSX_ATTRS . includes ( attrName ) ) {
323+ return ;
324+ }
325+ if ( isRawStringNode ( node . expression ) ) {
326+ context . report ( {
327+ node : node . expression ,
328+ messageId : 'untranslatedJsxText'
329+ } ) ;
330+ }
331+ return ;
332+ }
283333 if ( isRawStringNode ( node . expression ) ) {
284- context . report ( {
285- node : node . expression ,
286- messageId : 'untranslatedJsxText'
287- } ) ;
334+ const value = getRawStringValue ( node . expression ) ;
335+ if ( value !== null && ( enforcePunctuation ? value . trim ( ) . length > 0 : hasLetters ( value ) ) ) {
336+ context . report ( {
337+ node : node . expression ,
338+ messageId : 'untranslatedJsxText'
339+ } ) ;
340+ }
288341 }
289342 }
290343 } ;
0 commit comments