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-prefixed-imports': 'error',
|
||||
'bsky-internal/lingui-msg-rule': 'error',
|
||||
|
||||
/**
|
||||
* 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'),
|
||||
'use-exact-imports': require('./use-exact-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}
|
||||
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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -40,11 +40,13 @@ export function LeaveConvoPrompt({
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={_(msg`Leave conversation`)}
|
||||
description={_(
|
||||
description={
|
||||
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`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={() => leaveConvo()}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -182,11 +182,15 @@ export function FeedSourceCardLoaded({
|
||||
return (
|
||||
<Link
|
||||
testID={`feed-${feed.displayName}`}
|
||||
label={_(
|
||||
label={
|
||||
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={{
|
||||
screen: feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList',
|
||||
params: {name: feed.creatorDid, rkey: new AtUri(feed.uri).rkey},
|
||||
|
||||
@@ -39,8 +39,10 @@ import {
|
||||
HomeOpen_Filled_Corner0_Rounded as HomeFilled,
|
||||
HomeOpen_Stoke2_Corner0_Rounded as Home,
|
||||
} from '#/components/icons/HomeOpen'
|
||||
import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass'
|
||||
import {
|
||||
MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled,
|
||||
MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass,
|
||||
} from '#/components/icons/MagnifyingGlass'
|
||||
import {
|
||||
Message_Stroke2_Corner0_Rounded as Message,
|
||||
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
|
||||
@@ -495,10 +497,10 @@ let NotificationsMenuItem = ({
|
||||
numUnreadNotifications === ''
|
||||
? ''
|
||||
: _(
|
||||
msg`${plural(numUnreadNotifications ?? 0, {
|
||||
plural(numUnreadNotifications ?? 0, {
|
||||
one: '# unread item',
|
||||
other: '# unread items',
|
||||
})}` || '',
|
||||
}),
|
||||
)
|
||||
}
|
||||
count={numUnreadNotifications}
|
||||
|
||||
@@ -219,10 +219,10 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
accessibilityHint={
|
||||
numUnreadMessages.count > 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',
|
||||
})}` || '',
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user