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>
This commit is contained in:
@@ -121,6 +121,7 @@ export default defineConfig(
|
|||||||
],
|
],
|
||||||
'bsky-internal/use-exact-imports': 'error',
|
'bsky-internal/use-exact-imports': 'error',
|
||||||
'bsky-internal/use-prefixed-imports': 'error',
|
'bsky-internal/use-prefixed-imports': 'error',
|
||||||
|
'bsky-internal/lingui-msg-rule': 'error',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React & React Native
|
* React & React Native
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -9,6 +9,7 @@ const plugin = {
|
|||||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||||
'use-exact-imports': require('./use-exact-imports'),
|
'use-exact-imports': require('./use-exact-imports'),
|
||||||
'use-prefixed-imports': require('./use-prefixed-imports'),
|
'use-prefixed-imports': require('./use-prefixed-imports'),
|
||||||
|
'lingui-msg-rule': require('./lingui-msg-rule'),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -312,13 +312,13 @@ export function Controls({
|
|||||||
onPointerEnter={onPointerMoveEmptySpace}
|
onPointerEnter={onPointerMoveEmptySpace}
|
||||||
onPointerMove={onPointerMoveEmptySpace}
|
onPointerMove={onPointerMoveEmptySpace}
|
||||||
onPointerLeave={onPointerLeaveEmptySpace}
|
onPointerLeave={onPointerLeaveEmptySpace}
|
||||||
accessibilityLabel={_(
|
accessibilityLabel={
|
||||||
!focused
|
!focused
|
||||||
? msg`Unmute video`
|
? _(msg`Unmute video`)
|
||||||
: playing
|
: playing
|
||||||
? msg`Pause video`
|
? _(msg`Pause video`)
|
||||||
: msg`Play video`,
|
: _(msg`Play video`)
|
||||||
)}
|
}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function ProfileStarterPacks({
|
|||||||
message={
|
message={
|
||||||
emptyStateMessage ??
|
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}
|
button={emptyStateButton}
|
||||||
|
|||||||
@@ -40,11 +40,13 @@ export function LeaveConvoPrompt({
|
|||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
control={control}
|
control={control}
|
||||||
title={_(msg`Leave conversation`)}
|
title={_(msg`Leave conversation`)}
|
||||||
description={_(
|
description={
|
||||||
hasMessages
|
hasMessages
|
||||||
? msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
|
? _(
|
||||||
: msg`Are you sure you want to leave this conversation?`,
|
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`,
|
||||||
)}
|
)
|
||||||
|
: _(msg`Are you sure you want to leave this conversation?`)
|
||||||
|
}
|
||||||
confirmButtonCta={_(msg`Leave`)}
|
confirmButtonCta={_(msg`Leave`)}
|
||||||
confirmButtonColor="negative"
|
confirmButtonColor="negative"
|
||||||
onConfirm={() => leaveConvo()}
|
onConfirm={() => leaveConvo()}
|
||||||
|
|||||||
@@ -1109,7 +1109,7 @@ function PlayPauseTapArea({
|
|||||||
isPlaying ? _(msg`Video is playing`) : _(msg`Video is paused`)
|
isPlaying ? _(msg`Video is playing`) : _(msg`Video is paused`)
|
||||||
}
|
}
|
||||||
label={_(
|
label={_(
|
||||||
`Video from ${sanitizeHandle(
|
msg`Video from ${sanitizeHandle(
|
||||||
post.author.handle,
|
post.author.handle,
|
||||||
'@',
|
'@',
|
||||||
)}. Tap to play or pause the video`,
|
)}. Tap to play or pause the video`,
|
||||||
|
|||||||
@@ -182,11 +182,15 @@ export function FeedSourceCardLoaded({
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
testID={`feed-${feed.displayName}`}
|
testID={`feed-${feed.displayName}`}
|
||||||
label={_(
|
label={
|
||||||
feed.type === 'feed'
|
feed.type === 'feed'
|
||||||
? msg`${feed.displayName}, a feed by ${sanitizeHandle(feed.creatorHandle, '@')}, liked by ${feed.likeCount || 0}`
|
? _(
|
||||||
: msg`${feed.displayName}, a list by ${sanitizeHandle(feed.creatorHandle, '@')}`,
|
msg`${feed.displayName}, a feed by ${sanitizeHandle(feed.creatorHandle, '@')}, liked by ${feed.likeCount || 0}`,
|
||||||
)}
|
)
|
||||||
|
: _(
|
||||||
|
msg`${feed.displayName}, a list by ${sanitizeHandle(feed.creatorHandle, '@')}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
to={{
|
to={{
|
||||||
screen: feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList',
|
screen: feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList',
|
||||||
params: {name: feed.creatorDid, rkey: new AtUri(feed.uri).rkey},
|
params: {name: feed.creatorDid, rkey: new AtUri(feed.uri).rkey},
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ import {
|
|||||||
HomeOpen_Filled_Corner0_Rounded as HomeFilled,
|
HomeOpen_Filled_Corner0_Rounded as HomeFilled,
|
||||||
HomeOpen_Stoke2_Corner0_Rounded as Home,
|
HomeOpen_Stoke2_Corner0_Rounded as Home,
|
||||||
} from '#/components/icons/HomeOpen'
|
} from '#/components/icons/HomeOpen'
|
||||||
import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass'
|
import {
|
||||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass'
|
MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled,
|
||||||
|
MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass,
|
||||||
|
} from '#/components/icons/MagnifyingGlass'
|
||||||
import {
|
import {
|
||||||
Message_Stroke2_Corner0_Rounded as Message,
|
Message_Stroke2_Corner0_Rounded as Message,
|
||||||
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
|
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
|
||||||
@@ -495,10 +497,10 @@ let NotificationsMenuItem = ({
|
|||||||
numUnreadNotifications === ''
|
numUnreadNotifications === ''
|
||||||
? ''
|
? ''
|
||||||
: _(
|
: _(
|
||||||
msg`${plural(numUnreadNotifications ?? 0, {
|
plural(numUnreadNotifications ?? 0, {
|
||||||
one: '# unread item',
|
one: '# unread item',
|
||||||
other: '# unread items',
|
other: '# unread items',
|
||||||
})}` || '',
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
count={numUnreadNotifications}
|
count={numUnreadNotifications}
|
||||||
|
|||||||
@@ -219,10 +219,10 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
|||||||
accessibilityHint={
|
accessibilityHint={
|
||||||
numUnreadMessages.count > 0
|
numUnreadMessages.count > 0
|
||||||
? _(
|
? _(
|
||||||
msg`${plural(numUnreadMessages.numUnread ?? 0, {
|
plural(numUnreadMessages.numUnread ?? 0, {
|
||||||
one: '# unread item',
|
one: '# unread item',
|
||||||
other: '# unread items',
|
other: '# unread items',
|
||||||
})}` || '',
|
}),
|
||||||
)
|
)
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
@@ -251,10 +251,10 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
|||||||
numUnreadNotifications === ''
|
numUnreadNotifications === ''
|
||||||
? ''
|
? ''
|
||||||
: _(
|
: _(
|
||||||
msg`${plural(numUnreadNotifications ?? 0, {
|
plural(numUnreadNotifications ?? 0, {
|
||||||
one: '# unread item',
|
one: '# unread item',
|
||||||
other: '# unread items',
|
other: '# unread items',
|
||||||
})}` || '',
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user