From 2cee96da8e7a81dbd672ff83d261d8b3f8e1ebaa Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 29 Jan 2026 21:05:49 +0200 Subject: [PATCH] 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 --- eslint.config.mjs | 1 + eslint/__tests__/lingui-msg-rule.test.js | 188 ++++++++++++++++++ eslint/index.js | 1 + eslint/lingui-msg-rule.js | 110 ++++++++++ .../web-controls/VideoControls.tsx | 10 +- .../StarterPack/ProfileStarterPacks.tsx | 2 +- src/components/dms/LeaveConvoPrompt.tsx | 10 +- src/screens/VideoFeed/index.tsx | 2 +- src/view/com/feeds/FeedSourceCard.tsx | 12 +- src/view/shell/Drawer.tsx | 10 +- src/view/shell/bottom-bar/BottomBar.tsx | 8 +- 11 files changed, 331 insertions(+), 23 deletions(-) create mode 100644 eslint/__tests__/lingui-msg-rule.test.js create mode 100644 eslint/lingui-msg-rule.js diff --git a/eslint.config.mjs b/eslint.config.mjs index e9001882a6..7a06ebdb2e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -121,6 +121,7 @@ export default defineConfig( ], 'bsky-internal/use-exact-imports': 'error', 'bsky-internal/use-prefixed-imports': 'error', + 'bsky-internal/lingui-msg-rule': 'error', /** * React & React Native diff --git a/eslint/__tests__/lingui-msg-rule.test.js b/eslint/__tests__/lingui-msg-rule.test.js new file mode 100644 index 0000000000..74c94f4142 --- /dev/null +++ b/eslint/__tests__/lingui-msg-rule.test.js @@ -0,0 +1,188 @@ +const {RuleTester} = require('eslint') +const tseslint = require('typescript-eslint') +const linguiMsgRule = require('../lingui-msg-rule') + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, +}) + +describe('lingui-msg-rule', () => { + const tests = { + valid: [ + // msg template literal + { + code: ` +const {_} = useLingui() +const x = _(msg\`Hello\`) + `, + }, + // msg template literal with interpolation + { + code: ` +const {_} = useLingui() +const name = 'World' +const x = _(msg\`Hello \${name}\`) + `, + }, + // plural macro + { + code: ` +const {_} = useLingui() +const count = 5 +const x = _(plural(count, {one: '# item', other: '# items'})) + `, + }, + // select macro + { + code: ` +const {_} = useLingui() +const gender = 'female' +const x = _(select(gender, {male: 'He', female: 'She', other: 'They'})) + `, + }, + // selectOrdinal macro + { + code: ` +const {_} = useLingui() +const position = 1 +const x = _(selectOrdinal(position, {one: '#st', two: '#nd', few: '#rd', other: '#th'})) + `, + }, + // msg function call with object (descriptor form) + { + code: ` +const {_} = useLingui() +const x = _(msg({message: 'Hello'})) + `, + }, + // msg function call with object and context + { + code: ` +const {_} = useLingui() +const x = _(msg({message: 'Hello', context: 'greeting'})) + `, + }, + ], + invalid: [ + // Plain string literal (single quotes) - with auto-fix + { + code: ` +const {_} = useLingui() +const x = _('Bad') + `, + output: ` +const {_} = useLingui() +const x = _(msg\`Bad\`) + `, + errors: [{messageId: 'missingMsg'}], + }, + // Plain string literal (double quotes) - with auto-fix + { + code: ` +const {_} = useLingui() +const x = _("Bad") + `, + output: ` +const {_} = useLingui() +const x = _(msg\`Bad\`) + `, + errors: [{messageId: 'missingMsg'}], + }, + // Template literal without msg tag - with auto-fix + { + code: ` +const {_} = useLingui() +const x = _(\`Bad\`) + `, + output: ` +const {_} = useLingui() +const x = _(msg\`Bad\`) + `, + errors: [{messageId: 'missingMsg'}], + }, + // Template literal with interpolation - with auto-fix + { + code: ` +const {_} = useLingui() +const name = 'World' +const x = _(\`Hello \${name}\`) + `, + output: ` +const {_} = useLingui() +const name = 'World' +const x = _(msg\`Hello \${name}\`) + `, + errors: [{messageId: 'missingMsg'}], + }, + // String with backticks that need escaping + { + code: ` +const {_} = useLingui() +const x = _('Use \\\`code\\\` here') + `, + output: ` +const {_} = useLingui() +const x = _(msg\`Use \\\`code\\\` here\`) + `, + errors: [{messageId: 'missingMsg'}], + }, + // Variable/identifier - no auto-fix possible + { + code: ` +const {_} = useLingui() +const message = 'Hello' +const x = _(message) + `, + output: null, + errors: [{messageId: 'missingMsg'}], + }, + // Arbitrary function call - no auto-fix possible + { + code: ` +const {_} = useLingui() +const x = _(getMessage()) + `, + output: null, + errors: [{messageId: 'missingMsg'}], + }, + // Empty call - no auto-fix possible + { + code: ` +const {_} = useLingui() +const x = _() + `, + output: null, + errors: [{messageId: 'missingMsg'}], + }, + // Tagged template with wrong tag - no auto-fix (would need to replace tag) + { + code: ` +const {_} = useLingui() +const x = _(html\`Hello\`) + `, + output: null, + errors: [{messageId: 'missingMsg'}], + }, + // Number literal - no auto-fix possible + { + code: ` +const {_} = useLingui() +const x = _(123) + `, + output: null, + errors: [{messageId: 'missingMsg'}], + }, + ], + } + + ruleTester.run('lingui-msg-rule', linguiMsgRule, tests) +}) diff --git a/eslint/index.js b/eslint/index.js index 4d59c8c1d0..1df4590245 100644 --- a/eslint/index.js +++ b/eslint/index.js @@ -9,6 +9,7 @@ const plugin = { 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 'use-exact-imports': require('./use-exact-imports'), 'use-prefixed-imports': require('./use-prefixed-imports'), + 'lingui-msg-rule': require('./lingui-msg-rule'), }, } diff --git a/eslint/lingui-msg-rule.js b/eslint/lingui-msg-rule.js new file mode 100644 index 0000000000..27bd7a115d --- /dev/null +++ b/eslint/lingui-msg-rule.js @@ -0,0 +1,110 @@ +'use strict' + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Enforce that Lingui _() function is called with msg`` template literal or plural/select macros', + recommended: true, + }, + fixable: 'code', + messages: { + missingMsg: + 'Lingui _() must be called with msg`...` or msg({...}) or plural/select/selectOrdinal. Example: _(msg`Hello`)', + }, + schema: [], + }, + + create(context) { + // Valid Lingui macro functions that can be passed to _() + const VALID_MACRO_FUNCTIONS = new Set([ + 'msg', + 'plural', + 'select', + 'selectOrdinal', + ]) + + /** + * Escape backticks and backslashes for template literal + */ + function escapeForTemplateLiteral(str) { + return str.replace(/\\`/g, '`').replace(/`/g, '\\`') + } + + /** + * Try to get a fixer for the given argument + * Returns null if we can't safely fix it + */ + function getFixer(firstArg) { + const sourceCode = context.sourceCode ?? context.getSourceCode() + + // Fix string literals: _('foo') -> _(msg`foo`) + if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') { + const escaped = escapeForTemplateLiteral(firstArg.value) + return function (fixer) { + return fixer.replaceText(firstArg, 'msg`' + escaped + '`') + } + } + + // Fix untagged template literals: _(`foo`) -> _(msg`foo`) + if (firstArg.type === 'TemplateLiteral') { + const text = sourceCode.getText(firstArg) + return function (fixer) { + return fixer.replaceText(firstArg, 'msg' + text) + } + } + + return null + } + + return { + CallExpression(node) { + // Check if this is a call to _() + if (node.callee.type !== 'Identifier' || node.callee.name !== '_') { + return + } + + // Must have at least one argument + if (node.arguments.length === 0) { + context.report({ + node, + messageId: 'missingMsg', + }) + return + } + + const firstArg = node.arguments[0] + + // Valid: _(msg`...`) + if ( + firstArg.type === 'TaggedTemplateExpression' && + firstArg.tag.type === 'Identifier' && + firstArg.tag.name === 'msg' + ) { + return + } + + // Valid: _(msg(...)), _(plural(...)), _(select(...)), _(selectOrdinal(...)) + if ( + firstArg.type === 'CallExpression' && + firstArg.callee.type === 'Identifier' && + VALID_MACRO_FUNCTIONS.has(firstArg.callee.name) + ) { + return + } + + // Everything else is invalid + const fix = getFixer(firstArg) + context.report({ + node, + messageId: 'missingMsg', + fix, + }) + }, + } + }, +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx index 80af61b9c0..d918d3f99c 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx @@ -312,13 +312,13 @@ export function Controls({ onPointerEnter={onPointerMoveEmptySpace} onPointerMove={onPointerMoveEmptySpace} onPointerLeave={onPointerLeaveEmptySpace} - accessibilityLabel={_( + accessibilityLabel={ !focused - ? msg`Unmute video` + ? _(msg`Unmute video`) : playing - ? msg`Pause video` - : msg`Play video`, - )} + ? _(msg`Pause video`) + : _(msg`Play video`) + } accessibilityHint="" style={[ a.flex_1, diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index 981a850a97..9c35aae317 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -101,7 +101,7 @@ export function ProfileStarterPacks({ message={ emptyStateMessage ?? _( - 'Starter packs let you share your favorite feeds and people with your friends.', + msg`Starter packs let you share your favorite feeds and people with your friends.`, ) } button={emptyStateButton} diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx index 38293c498c..1e101473a9 100644 --- a/src/components/dms/LeaveConvoPrompt.tsx +++ b/src/components/dms/LeaveConvoPrompt.tsx @@ -40,11 +40,13 @@ export function LeaveConvoPrompt({ leaveConvo()} diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index 81242e4a64..f9e91d2296 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -1109,7 +1109,7 @@ function PlayPauseTapArea({ isPlaying ? _(msg`Video is playing`) : _(msg`Video is paused`) } label={_( - `Video from ${sanitizeHandle( + msg`Video from ${sanitizeHandle( post.author.handle, '@', )}. Tap to play or pause the video`, diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 453ff6982c..e7c74167c5 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -182,11 +182,15 @@ export function FeedSourceCardLoaded({ return ( 0 ? _( - msg`${plural(numUnreadMessages.numUnread ?? 0, { + plural(numUnreadMessages.numUnread ?? 0, { one: '# unread item', other: '# unread items', - })}` || '', + }), ) : '' } @@ -251,10 +251,10 @@ export function BottomBar({navigation}: BottomTabBarProps) { numUnreadNotifications === '' ? '' : _( - msg`${plural(numUnreadNotifications ?? 0, { + plural(numUnreadNotifications ?? 0, { one: '# unread item', other: '# unread items', - })}` || '', + }), ) } />