Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

Add ESLint rule to enforce Lingui msg usage (#9789)

* Add ESLint rule to enforce Lingui msg usage

Adds a custom ESLint rule 'lingui-msg-rule' that ensures the Lingui _()
function is called with msg`` template literals or plural/select macros,
preventing accidental misuse like _('string') which bypasses i18n.

https://claude.ai/code/session_01JMXXPUgAHiSBGmfGwUojKy

* Support msg({...}) descriptor form and add auto-fix

- Allow msg() function call form: _(msg({message: 'Hello'}))
- Add auto-fix for string literals: _('Bad') -> _(msg`Bad`)
- Add auto-fix for untagged templates: _(`Bad`) -> _(msg`Bad`)
- No auto-fix for variables/function calls (not safely fixable)

https://claude.ai/code/session_01JMXXPUgAHiSBGmfGwUojKy

* fix complex cases

* run autofix HELL YEAH

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by samuel.fm

Claude and committed by
GitHub
2cee96da 31553f0d

+331 -23
+1
eslint.config.mjs
··· 121 121 ], 122 122 'bsky-internal/use-exact-imports': 'error', 123 123 'bsky-internal/use-prefixed-imports': 'error', 124 + 'bsky-internal/lingui-msg-rule': 'error', 124 125 125 126 /** 126 127 * React & React Native
+188
eslint/__tests__/lingui-msg-rule.test.js
··· 1 + const {RuleTester} = require('eslint') 2 + const tseslint = require('typescript-eslint') 3 + const linguiMsgRule = require('../lingui-msg-rule') 4 + 5 + const ruleTester = new RuleTester({ 6 + languageOptions: { 7 + parser: tseslint.parser, 8 + parserOptions: { 9 + ecmaFeatures: { 10 + jsx: true, 11 + }, 12 + ecmaVersion: 'latest', 13 + sourceType: 'module', 14 + }, 15 + }, 16 + }) 17 + 18 + describe('lingui-msg-rule', () => { 19 + const tests = { 20 + valid: [ 21 + // msg template literal 22 + { 23 + code: ` 24 + const {_} = useLingui() 25 + const x = _(msg\`Hello\`) 26 + `, 27 + }, 28 + // msg template literal with interpolation 29 + { 30 + code: ` 31 + const {_} = useLingui() 32 + const name = 'World' 33 + const x = _(msg\`Hello \${name}\`) 34 + `, 35 + }, 36 + // plural macro 37 + { 38 + code: ` 39 + const {_} = useLingui() 40 + const count = 5 41 + const x = _(plural(count, {one: '# item', other: '# items'})) 42 + `, 43 + }, 44 + // select macro 45 + { 46 + code: ` 47 + const {_} = useLingui() 48 + const gender = 'female' 49 + const x = _(select(gender, {male: 'He', female: 'She', other: 'They'})) 50 + `, 51 + }, 52 + // selectOrdinal macro 53 + { 54 + code: ` 55 + const {_} = useLingui() 56 + const position = 1 57 + const x = _(selectOrdinal(position, {one: '#st', two: '#nd', few: '#rd', other: '#th'})) 58 + `, 59 + }, 60 + // msg function call with object (descriptor form) 61 + { 62 + code: ` 63 + const {_} = useLingui() 64 + const x = _(msg({message: 'Hello'})) 65 + `, 66 + }, 67 + // msg function call with object and context 68 + { 69 + code: ` 70 + const {_} = useLingui() 71 + const x = _(msg({message: 'Hello', context: 'greeting'})) 72 + `, 73 + }, 74 + ], 75 + invalid: [ 76 + // Plain string literal (single quotes) - with auto-fix 77 + { 78 + code: ` 79 + const {_} = useLingui() 80 + const x = _('Bad') 81 + `, 82 + output: ` 83 + const {_} = useLingui() 84 + const x = _(msg\`Bad\`) 85 + `, 86 + errors: [{messageId: 'missingMsg'}], 87 + }, 88 + // Plain string literal (double quotes) - with auto-fix 89 + { 90 + code: ` 91 + const {_} = useLingui() 92 + const x = _("Bad") 93 + `, 94 + output: ` 95 + const {_} = useLingui() 96 + const x = _(msg\`Bad\`) 97 + `, 98 + errors: [{messageId: 'missingMsg'}], 99 + }, 100 + // Template literal without msg tag - with auto-fix 101 + { 102 + code: ` 103 + const {_} = useLingui() 104 + const x = _(\`Bad\`) 105 + `, 106 + output: ` 107 + const {_} = useLingui() 108 + const x = _(msg\`Bad\`) 109 + `, 110 + errors: [{messageId: 'missingMsg'}], 111 + }, 112 + // Template literal with interpolation - with auto-fix 113 + { 114 + code: ` 115 + const {_} = useLingui() 116 + const name = 'World' 117 + const x = _(\`Hello \${name}\`) 118 + `, 119 + output: ` 120 + const {_} = useLingui() 121 + const name = 'World' 122 + const x = _(msg\`Hello \${name}\`) 123 + `, 124 + errors: [{messageId: 'missingMsg'}], 125 + }, 126 + // String with backticks that need escaping 127 + { 128 + code: ` 129 + const {_} = useLingui() 130 + const x = _('Use \\\`code\\\` here') 131 + `, 132 + output: ` 133 + const {_} = useLingui() 134 + const x = _(msg\`Use \\\`code\\\` here\`) 135 + `, 136 + errors: [{messageId: 'missingMsg'}], 137 + }, 138 + // Variable/identifier - no auto-fix possible 139 + { 140 + code: ` 141 + const {_} = useLingui() 142 + const message = 'Hello' 143 + const x = _(message) 144 + `, 145 + output: null, 146 + errors: [{messageId: 'missingMsg'}], 147 + }, 148 + // Arbitrary function call - no auto-fix possible 149 + { 150 + code: ` 151 + const {_} = useLingui() 152 + const x = _(getMessage()) 153 + `, 154 + output: null, 155 + errors: [{messageId: 'missingMsg'}], 156 + }, 157 + // Empty call - no auto-fix possible 158 + { 159 + code: ` 160 + const {_} = useLingui() 161 + const x = _() 162 + `, 163 + output: null, 164 + errors: [{messageId: 'missingMsg'}], 165 + }, 166 + // Tagged template with wrong tag - no auto-fix (would need to replace tag) 167 + { 168 + code: ` 169 + const {_} = useLingui() 170 + const x = _(html\`Hello\`) 171 + `, 172 + output: null, 173 + errors: [{messageId: 'missingMsg'}], 174 + }, 175 + // Number literal - no auto-fix possible 176 + { 177 + code: ` 178 + const {_} = useLingui() 179 + const x = _(123) 180 + `, 181 + output: null, 182 + errors: [{messageId: 'missingMsg'}], 183 + }, 184 + ], 185 + } 186 + 187 + ruleTester.run('lingui-msg-rule', linguiMsgRule, tests) 188 + })
+1
eslint/index.js
··· 9 9 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 10 10 'use-exact-imports': require('./use-exact-imports'), 11 11 'use-prefixed-imports': require('./use-prefixed-imports'), 12 + 'lingui-msg-rule': require('./lingui-msg-rule'), 12 13 }, 13 14 } 14 15
+110
eslint/lingui-msg-rule.js
··· 1 + 'use strict' 2 + 3 + /** 4 + * @type {import('eslint').Rule.RuleModule} 5 + */ 6 + module.exports = { 7 + meta: { 8 + type: 'problem', 9 + docs: { 10 + description: 11 + 'Enforce that Lingui _() function is called with msg`` template literal or plural/select macros', 12 + recommended: true, 13 + }, 14 + fixable: 'code', 15 + messages: { 16 + missingMsg: 17 + 'Lingui _() must be called with msg`...` or msg({...}) or plural/select/selectOrdinal. Example: _(msg`Hello`)', 18 + }, 19 + schema: [], 20 + }, 21 + 22 + create(context) { 23 + // Valid Lingui macro functions that can be passed to _() 24 + const VALID_MACRO_FUNCTIONS = new Set([ 25 + 'msg', 26 + 'plural', 27 + 'select', 28 + 'selectOrdinal', 29 + ]) 30 + 31 + /** 32 + * Escape backticks and backslashes for template literal 33 + */ 34 + function escapeForTemplateLiteral(str) { 35 + return str.replace(/\\`/g, '`').replace(/`/g, '\\`') 36 + } 37 + 38 + /** 39 + * Try to get a fixer for the given argument 40 + * Returns null if we can't safely fix it 41 + */ 42 + function getFixer(firstArg) { 43 + const sourceCode = context.sourceCode ?? context.getSourceCode() 44 + 45 + // Fix string literals: _('foo') -> _(msg`foo`) 46 + if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') { 47 + const escaped = escapeForTemplateLiteral(firstArg.value) 48 + return function (fixer) { 49 + return fixer.replaceText(firstArg, 'msg`' + escaped + '`') 50 + } 51 + } 52 + 53 + // Fix untagged template literals: _(`foo`) -> _(msg`foo`) 54 + if (firstArg.type === 'TemplateLiteral') { 55 + const text = sourceCode.getText(firstArg) 56 + return function (fixer) { 57 + return fixer.replaceText(firstArg, 'msg' + text) 58 + } 59 + } 60 + 61 + return null 62 + } 63 + 64 + return { 65 + CallExpression(node) { 66 + // Check if this is a call to _() 67 + if (node.callee.type !== 'Identifier' || node.callee.name !== '_') { 68 + return 69 + } 70 + 71 + // Must have at least one argument 72 + if (node.arguments.length === 0) { 73 + context.report({ 74 + node, 75 + messageId: 'missingMsg', 76 + }) 77 + return 78 + } 79 + 80 + const firstArg = node.arguments[0] 81 + 82 + // Valid: _(msg`...`) 83 + if ( 84 + firstArg.type === 'TaggedTemplateExpression' && 85 + firstArg.tag.type === 'Identifier' && 86 + firstArg.tag.name === 'msg' 87 + ) { 88 + return 89 + } 90 + 91 + // Valid: _(msg(...)), _(plural(...)), _(select(...)), _(selectOrdinal(...)) 92 + if ( 93 + firstArg.type === 'CallExpression' && 94 + firstArg.callee.type === 'Identifier' && 95 + VALID_MACRO_FUNCTIONS.has(firstArg.callee.name) 96 + ) { 97 + return 98 + } 99 + 100 + // Everything else is invalid 101 + const fix = getFixer(firstArg) 102 + context.report({ 103 + node, 104 + messageId: 'missingMsg', 105 + fix, 106 + }) 107 + }, 108 + } 109 + }, 110 + }
+5 -5
src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
··· 312 312 onPointerEnter={onPointerMoveEmptySpace} 313 313 onPointerMove={onPointerMoveEmptySpace} 314 314 onPointerLeave={onPointerLeaveEmptySpace} 315 - accessibilityLabel={_( 315 + accessibilityLabel={ 316 316 !focused 317 - ? msg`Unmute video` 317 + ? _(msg`Unmute video`) 318 318 : playing 319 - ? msg`Pause video` 320 - : msg`Play video`, 321 - )} 319 + ? _(msg`Pause video`) 320 + : _(msg`Play video`) 321 + } 322 322 accessibilityHint="" 323 323 style={[ 324 324 a.flex_1,
+1 -1
src/components/StarterPack/ProfileStarterPacks.tsx
··· 101 101 message={ 102 102 emptyStateMessage ?? 103 103 _( 104 - 'Starter packs let you share your favorite feeds and people with your friends.', 104 + msg`Starter packs let you share your favorite feeds and people with your friends.`, 105 105 ) 106 106 } 107 107 button={emptyStateButton}
+6 -4
src/components/dms/LeaveConvoPrompt.tsx
··· 40 40 <Prompt.Basic 41 41 control={control} 42 42 title={_(msg`Leave conversation`)} 43 - description={_( 43 + description={ 44 44 hasMessages 45 - ? msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.` 46 - : msg`Are you sure you want to leave this conversation?`, 47 - )} 45 + ? _( 46 + msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`, 47 + ) 48 + : _(msg`Are you sure you want to leave this conversation?`) 49 + } 48 50 confirmButtonCta={_(msg`Leave`)} 49 51 confirmButtonColor="negative" 50 52 onConfirm={() => leaveConvo()}
+1 -1
src/screens/VideoFeed/index.tsx
··· 1109 1109 isPlaying ? _(msg`Video is playing`) : _(msg`Video is paused`) 1110 1110 } 1111 1111 label={_( 1112 - `Video from ${sanitizeHandle( 1112 + msg`Video from ${sanitizeHandle( 1113 1113 post.author.handle, 1114 1114 '@', 1115 1115 )}. Tap to play or pause the video`,
+8 -4
src/view/com/feeds/FeedSourceCard.tsx
··· 182 182 return ( 183 183 <Link 184 184 testID={`feed-${feed.displayName}`} 185 - label={_( 185 + label={ 186 186 feed.type === 'feed' 187 - ? msg`${feed.displayName}, a feed by ${sanitizeHandle(feed.creatorHandle, '@')}, liked by ${feed.likeCount || 0}` 188 - : msg`${feed.displayName}, a list by ${sanitizeHandle(feed.creatorHandle, '@')}`, 189 - )} 187 + ? _( 188 + msg`${feed.displayName}, a feed by ${sanitizeHandle(feed.creatorHandle, '@')}, liked by ${feed.likeCount || 0}`, 189 + ) 190 + : _( 191 + msg`${feed.displayName}, a list by ${sanitizeHandle(feed.creatorHandle, '@')}`, 192 + ) 193 + } 190 194 to={{ 191 195 screen: feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList', 192 196 params: {name: feed.creatorDid, rkey: new AtUri(feed.uri).rkey},
+6 -4
src/view/shell/Drawer.tsx
··· 39 39 HomeOpen_Filled_Corner0_Rounded as HomeFilled, 40 40 HomeOpen_Stoke2_Corner0_Rounded as Home, 41 41 } from '#/components/icons/HomeOpen' 42 - import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 43 - import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass' 42 + import { 43 + MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled, 44 + MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass, 45 + } from '#/components/icons/MagnifyingGlass' 44 46 import { 45 47 Message_Stroke2_Corner0_Rounded as Message, 46 48 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, ··· 495 497 numUnreadNotifications === '' 496 498 ? '' 497 499 : _( 498 - msg`${plural(numUnreadNotifications ?? 0, { 500 + plural(numUnreadNotifications ?? 0, { 499 501 one: '# unread item', 500 502 other: '# unread items', 501 - })}` || '', 503 + }), 502 504 ) 503 505 } 504 506 count={numUnreadNotifications}
+4 -4
src/view/shell/bottom-bar/BottomBar.tsx
··· 219 219 accessibilityHint={ 220 220 numUnreadMessages.count > 0 221 221 ? _( 222 - msg`${plural(numUnreadMessages.numUnread ?? 0, { 222 + plural(numUnreadMessages.numUnread ?? 0, { 223 223 one: '# unread item', 224 224 other: '# unread items', 225 - })}` || '', 225 + }), 226 226 ) 227 227 : '' 228 228 } ··· 251 251 numUnreadNotifications === '' 252 252 ? '' 253 253 : _( 254 - msg`${plural(numUnreadNotifications ?? 0, { 254 + plural(numUnreadNotifications ?? 0, { 255 255 one: '# unread item', 256 256 other: '# unread items', 257 - })}` || '', 257 + }), 258 258 ) 259 259 } 260 260 />