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
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,139 @@
|
|||||||
|
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'}))
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
// Different function named _ (not from useLingui context, but rule doesn't track that)
|
||||||
|
// This is fine - the rule just checks the pattern
|
||||||
|
{
|
||||||
|
code: `
|
||||||
|
const _ = (x) => x
|
||||||
|
const x = _(someValue)
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invalid: [
|
||||||
|
// Plain string literal (single quotes)
|
||||||
|
{
|
||||||
|
code: `
|
||||||
|
const {_} = useLingui()
|
||||||
|
const x = _('Bad')
|
||||||
|
`,
|
||||||
|
errors: [{messageId: 'missingMsg'}],
|
||||||
|
},
|
||||||
|
// Plain string literal (double quotes)
|
||||||
|
{
|
||||||
|
code: `
|
||||||
|
const {_} = useLingui()
|
||||||
|
const x = _("Bad")
|
||||||
|
`,
|
||||||
|
errors: [{messageId: 'missingMsg'}],
|
||||||
|
},
|
||||||
|
// Template literal without msg tag
|
||||||
|
{
|
||||||
|
code: `
|
||||||
|
const {_} = useLingui()
|
||||||
|
const x = _(\`Bad\`)
|
||||||
|
`,
|
||||||
|
errors: [{messageId: 'missingMsg'}],
|
||||||
|
},
|
||||||
|
// Variable/identifier
|
||||||
|
{
|
||||||
|
code: `
|
||||||
|
const {_} = useLingui()
|
||||||
|
const message = 'Hello'
|
||||||
|
const x = _(message)
|
||||||
|
`,
|
||||||
|
errors: [{messageId: 'missingMsg'}],
|
||||||
|
},
|
||||||
|
// Arbitrary function call
|
||||||
|
{
|
||||||
|
code: `
|
||||||
|
const {_} = useLingui()
|
||||||
|
const x = _(getMessage())
|
||||||
|
`,
|
||||||
|
errors: [{messageId: 'missingMsg'}],
|
||||||
|
},
|
||||||
|
// Empty call
|
||||||
|
{
|
||||||
|
code: `
|
||||||
|
const {_} = useLingui()
|
||||||
|
const x = _()
|
||||||
|
`,
|
||||||
|
errors: [{messageId: 'missingMsg'}],
|
||||||
|
},
|
||||||
|
// Tagged template with wrong tag
|
||||||
|
{
|
||||||
|
code: `
|
||||||
|
const {_} = useLingui()
|
||||||
|
const x = _(html\`Hello\`)
|
||||||
|
`,
|
||||||
|
errors: [{messageId: 'missingMsg'}],
|
||||||
|
},
|
||||||
|
// Number literal
|
||||||
|
{
|
||||||
|
code: `
|
||||||
|
const {_} = useLingui()
|
||||||
|
const x = _(123)
|
||||||
|
`,
|
||||||
|
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,73 @@
|
|||||||
|
'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,
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
missingMsg:
|
||||||
|
'Lingui _() must be called with msg`...` template literal 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([
|
||||||
|
'plural',
|
||||||
|
'select',
|
||||||
|
'selectOrdinal',
|
||||||
|
])
|
||||||
|
|
||||||
|
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: _(plural(...)), _(select(...)), _(selectOrdinal(...))
|
||||||
|
if (
|
||||||
|
firstArg.type === 'CallExpression' &&
|
||||||
|
firstArg.callee.type === 'Identifier' &&
|
||||||
|
VALID_MACRO_FUNCTIONS.has(firstArg.callee.name)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else is invalid
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'missingMsg',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user