Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Alternatively, add `lingui` to the plugins section, and configure the rules you
✅ - Recommended

- ✅ [no-expression-in-message](docs/rules/no-expression-in-message.md)
- ✅ [no-macro-inside-macro](docs/rules/no-macro-inside-macro.md)
- ✅ [no-single-tag-to-translate](docs/rules/no-single-tag-to-translate.md)
- ✅ [no-single-variables-to-translate](docs/rules/no-single-variables-to-translate.md)
- ✅ [no-trans-inside-trans](docs/rules/no-trans-inside-trans.md)
Expand Down
4 changes: 4 additions & 0 deletions docs/rules/no-expression-in-message.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ t`Hello ${userName}` // => 'Hello {userName}'
msg`Hello ${userName}` // => 'Hello {userName}'
defineMessage`Hello ${userName}` // => 'Hello {userName}'
```

## Scope

This rule flags member expressions (`${obj.prop}`) and non-Lingui function calls (`${func()}`) interpolated into a message macro template. It does **not** flag nested Lingui macros — those are covered by [`no-macro-inside-macro`](./no-macro-inside-macro.md), which produces a targeted diagnostic. Enable both rules to get full coverage.
96 changes: 96 additions & 0 deletions docs/rules/no-macro-inside-macro.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# no-macro-inside-macro

Don't nest Lingui translation macros.

Each Lingui translation macro — `` t` ` ``, `` msg` ` ``, `` defineMessage` ` `` and their call forms, plus the components `<Trans>`, `<Plural>`, `<Select>`, `<SelectOrdinal>` — extracts as a **standalone translation unit**. A standalone unit can't be composed into another one: the inner macro becomes an opaque expression at extract time, the outer message falls back to positional placeholders (`{0}`, `{1}`), and the `.po` file ends up with useless (sometimes enormous) placeholder comments.

Concretely, this rule forbids a translation macro inside any of:

- another message macro's template literal or message body (`` t`…${inner}…` ``, `t({ message: `…${inner}…` })`)
- a `<Plural>` / `<Select>` / `<SelectOrdinal>` branch attribute (excluding `value` / `offset`)
- a `plural()` / `select()` / `selectOrdinal()` option value (excluding `value` / `offset`)

Two exceptions — both legitimate composition, not nesting:

1. **Choice calls as interpolation**: `` t`${plural(n, { one: '…', other: '…' })}` `` composes the ICU plural into the outer message.
2. **Descriptor passthrough**: `t(msg`…`)` passes a lazy `MessageDescriptor` as a direct argument for translation.

## Examples

### ❌ Incorrect

```jsx
// message macro inside message macro
t`outer ${t`inner`}`

// component macro inside message macro
t`outer ${<Trans>inner</Trans>}`
t`outer ${<Plural value={n} one="a" other="b" />}`

// message macro inside choice component branch
<Plural
value={count}
one={t`# unread message`}
other={t`# unread messages`}
/>

// component macro inside choice component branch
<Plural value={n} one={<Trans>a</Trans>} other="b" />

// message macro inside choice call option
plural(count, {
one: t`# unread message`,
other: t`# unread messages`,
})

// component macro inside choice call option
plural(count, {
one: <Trans>a</Trans>,
other: <Trans>b</Trans>,
})

// lazy macro interpolated (not a direct arg) still stringifies as [object Object]
t`Greeting: ${msg`Hello`}`
```

### ✅ Correct

```jsx
// Hoist and interpolate plain values
const inner = t`inner`
t`outer ${inner}`

// Branches extract themselves — use plain strings
<Plural
value={count}
one="# unread message"
other="# unread messages"
/>

plural(count, {
one: '# unread message',
other: '# unread messages',
})

// Choice calls *as interpolation* inside a message macro compose into ICU
t`You have ${plural(count, { one: '# unread message', other: '# unread messages' })}`

// Descriptor passthrough: lazy macro as a direct argument to t()
const greeting = msg`Hello`
t(greeting)
t(msg`Hello`)
```

## When Not To Use It

There's no legitimate reason to nest translation macros outside of the two composition patterns above — leave the rule enabled.

## Performance

The rule fires only on nodes named `t` / `msg` / `defineMessage` (tagged-template or call) and JSX elements named `Trans` / `Plural` / `Select` / `SelectOrdinal` — name-filtered at the selector level, so non-matches never enter the handler. For each match the rule walks up the AST to the nearest containing Lingui macro (typically 1–5 hops) or to the program root. Linear in AST depth, no memoization, no quadratic paths. IIFE-bridged nesting (e.g. `<Plural one={(() => t`…`)()}`) is intentionally detected — the walk does not stop at function boundaries.

## Related

- [`no-expression-in-message`](./no-expression-in-message.md) — member expressions and function calls inside message interpolations.
- [`no-plural-inside-trans`](./no-plural-inside-trans.md) — `<Plural>` inside `<Trans>`.
- [`no-trans-inside-trans`](./no-trans-inside-trans.md) — `<Trans>` inside `<Trans>`.
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as noTransInsideTransRule from './rules/no-trans-inside-trans'
import * as consistentPluralFormatRule from './rules/consistent-plural-format'
import * as noPluralInsideTransRule from './rules/no-plural-inside-trans'
import * as requireExplicitIdRule from './rules/require-explicit-id'
import * as noMacroInsideMacroRule from './rules/no-macro-inside-macro'

import { ESLint, Linter } from 'eslint'
import { FlatConfig, RuleModule } from '@typescript-eslint/utils/ts-eslint'
Expand All @@ -23,6 +24,7 @@ const rules = {
[consistentPluralFormatRule.name]: consistentPluralFormatRule.rule,
[noPluralInsideTransRule.name]: noPluralInsideTransRule.rule,
[requireExplicitIdRule.name]: requireExplicitIdRule.rule,
[noMacroInsideMacroRule.name]: noMacroInsideMacroRule.rule,
}

type RuleKey = keyof typeof rules
Expand All @@ -49,6 +51,7 @@ const recommendedRules: { [K in RuleKey as `lingui/${K}`]?: FlatConfig.RuleLevel
'lingui/no-single-variables-to-translate': 'warn',
'lingui/no-trans-inside-trans': 'warn',
'lingui/no-expression-in-message': 'warn',
'lingui/no-macro-inside-macro': 'warn',
}

// Assign configs here so we can reference `plugin`
Expand Down
33 changes: 32 additions & 1 deletion src/rules/no-expression-in-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,34 @@ export const rule = createRule({
defaultOptions: [],
create: function (context) {
const linguiMacroFunctionNames = ['plural', 'select', 'selectOrdinal', 'ph']
const nestedMessageMacroNames = ['t', 'msg', 'defineMessage']
const nestedComponentMacroNames = ['Trans', 'Plural', 'Select', 'SelectOrdinal']

// Nested Lingui macros (message macros or JSX component macros) are the
// domain of `no-macro-inside-macro`, which reports them with a targeted
// message. Skip them here so users get one clear diagnostic instead of two.
function isNestedLinguiMacro(expression: TSESTree.Expression): boolean {
if (expression.type === TSESTree.AST_NODE_TYPES.TaggedTemplateExpression) {
return (
expression.tag.type === TSESTree.AST_NODE_TYPES.Identifier &&
nestedMessageMacroNames.includes(expression.tag.name)
)
}
if (expression.type === TSESTree.AST_NODE_TYPES.CallExpression) {
return (
expression.callee.type === TSESTree.AST_NODE_TYPES.Identifier &&
nestedMessageMacroNames.includes(expression.callee.name)
)
}
if (expression.type === TSESTree.AST_NODE_TYPES.JSXElement) {
const tag = expression.openingElement.name
return (
tag.type === TSESTree.AST_NODE_TYPES.JSXIdentifier &&
nestedComponentMacroNames.includes(tag.name)
)
}
return false
}

function checkExpressionsInTplLiteral(node: TSESTree.TemplateLiteral) {
node.expressions.forEach((expression) => checkExpression(expression))
Expand Down Expand Up @@ -79,7 +107,10 @@ export const rule = createRule({
return
}

checkExpressionsInTplLiteral(node)
node.expressions.forEach((expression) => {
if (isNestedLinguiMacro(expression)) return
checkExpression(expression)
})
},
[`${LinguiTransQuery} JSXExpressionContainer:not([parent.type=JSXAttribute]) > :expression`](
node: TSESTree.Expression,
Expand Down
Loading