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
77 changes: 77 additions & 0 deletions docs/rules/no-nested-trans.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# no-nested-trans

Disallow nested translation functions and components.

Translation functions and components should not be nested inside each other. This includes:

- Tagged template expressions: `t```, `msg``, `defineMessage``
- Function calls: `t()`, `msg()`, `defineMessage()`, `plural()`, `select()`, `selectOrdinal()`
- JSX components: `<Trans>`, `<Plural>`, `<Select>`, `<SelectOrdinal>`

## Rule Details

This rule prevents nesting any translation function or component inside another translation function or component. Nested translations can lead to unexpected behavior and make code harder to maintain.

❌ Examples of **incorrect** code for this rule:

```tsx
// Tagged templates inside components
<Trans>{t`Hello`}</Trans>
<Plural value={count} one="one" other={t`${count} items`} />

// Function calls inside components
<Trans>{plural(count, { one: "one", other: "many" })}</Trans>
<Select value={gender} male={t({ message: "He" })} other="They" />

// Components inside components
<Trans><Plural value={count} one="one" other="many" /></Trans>
<Plural value={count} one={<Trans>one item</Trans>} other="many" />

// Function calls inside function calls
plural(count, {
one: "one book",
other: t`There are ${count} books`
})

select(gender, {
male: plural(count, { one: "one", other: "many" }),
other: "items"
})

// Nested tagged templates
t`Hello ${t`world`}`
msg`Hello ${plural(count, { one: "one", other: "many" })}`
```

✅ Examples of **correct** code for this rule:

```tsx
// Standalone usage
const message = t`Hello`
const books = plural(count, { one: "one book", other: "many books" })
const greeting = select(gender, { male: "He", female: "She", other: "They" })

// Components with static content
<Trans>There are many books</Trans>
<Plural value={count} one="one book" other="many books" />
<Select value={gender} male="He" female="She" other="They" />

// Components with variables and expressions (non-translation)
<Trans>{userName} has {bookCount} books</Trans>
<Plural value={count} one="one book" other={`${count} books`} />

// Adjacent usage (not nested)
<div>
<Trans>Hello</Trans>
<Plural value={count} one="one" other="many" />
</div>
```

## When Not To Use It

If you need to compose translations in complex ways, you might want to disable this rule. However, it's generally recommended to keep translations simple and avoid nesting.

## Further Reading

- [LinguiJS Translation Components](https://lingui.js.org/tutorials/react.html#rendering-translations)
- [LinguiJS Functions](https://lingui.js.org/ref/macro.html)
21 changes: 21 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ export const LinguiCallExpressionPluralQuery = 'CallExpression[callee.name=plura

export const LinguiPluralComponentQuery = 'JSXElement[openingElement.name.name=Plural]'

/**
* Queries for select and selectOrdinal CallExpression expressions
*
* CallExpression: select(value, { one: "# item", other: "# items" });
* CallExpression: selectOrdinal(value, { one: "1st", other: "#th" });
*/
export const LinguiCallExpressionSelectQuery = 'CallExpression[callee.name=select]'

export const LinguiCallExpressionSelectOrdinalQuery = 'CallExpression[callee.name=selectOrdinal]'

/**
* Queries for Select and SelectOrdinal JSX components
*
* <Select value={value} one="# item" other="# items" />
* <SelectOrdinal value={value} one="1st" other="#th" />
*/
export const LinguiSelectComponentQuery = 'JSXElement[openingElement.name.name=Select]'

export const LinguiSelectOrdinalComponentQuery =
'JSXElement[openingElement.name.name=SelectOrdinal]'

export function isNativeDOMTag(str: string) {
return DOM_TAGS.includes(str)
}
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as tCallInFunctionRule from './rules/t-call-in-function'
import * as textRestrictionsRule from './rules/text-restrictions'
import * as noTransInsideTransRule from './rules/no-trans-inside-trans'
import * as consistentPluralFormatRule from './rules/consistent-plural-format'

import * as noNestedTransRule from './rules/no-nested-trans'
import { ESLint, Linter } from 'eslint'
import { FlatConfig, RuleModule } from '@typescript-eslint/utils/ts-eslint'

Expand All @@ -19,6 +19,7 @@ const rules = {
[textRestrictionsRule.name]: textRestrictionsRule.rule,
[noTransInsideTransRule.name]: noTransInsideTransRule.rule,
[consistentPluralFormatRule.name]: consistentPluralFormatRule.rule,
[noNestedTransRule.name]: noNestedTransRule.rule,
}

type RuleKey = keyof typeof rules
Expand Down
139 changes: 139 additions & 0 deletions src/rules/no-nested-trans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { TSESTree } from '@typescript-eslint/utils'
import { createRule } from '../create-rule'
import {
LinguiTransQuery,
LinguiCallExpressionPluralQuery,
LinguiPluralComponentQuery,
LinguiCallExpressionSelectQuery,
LinguiCallExpressionSelectOrdinalQuery,
LinguiSelectComponentQuery,
LinguiSelectOrdinalComponentQuery,
} from '../helpers'

export const name = 'no-nested-trans'
export const rule = createRule({
name,
meta: {
docs: {
description: 'Disallow nested translation functions and components',
recommended: 'error',
},
messages: {
default:
'Translation functions and components cannot be nested inside each other. Found {{childType}} inside {{parentType}}.',
},
schema: [
{
type: 'object',
properties: {},
additionalProperties: false,
},
],
type: 'problem' as const,
},
defaultOptions: [],

create: (context) => {
// All Lingui translation functions and components
const allLinguiQueries = [
LinguiTransQuery,
LinguiPluralComponentQuery,
LinguiSelectComponentQuery,
LinguiSelectOrdinalComponentQuery,
LinguiCallExpressionPluralQuery,
LinguiCallExpressionSelectQuery,
LinguiCallExpressionSelectOrdinalQuery,
'TaggedTemplateExpression[tag.name=t]',
'TaggedTemplateExpression[tag.name=msg]',
'TaggedTemplateExpression[tag.name=defineMessage]',
'CallExpression[callee.name=t]',
'CallExpression[callee.name=msg]',
'CallExpression[callee.name=defineMessage]',
].join(', ')

function getNodeType(node: TSESTree.Node): string {
if (node.type === 'JSXElement') {
const jsxNode = node as TSESTree.JSXElement
if (jsxNode.openingElement.name.type === 'JSXIdentifier') {
return `<${jsxNode.openingElement.name.name}>`
}
} else if (node.type === 'TaggedTemplateExpression') {
const taggedNode = node as TSESTree.TaggedTemplateExpression
if (taggedNode.tag.type === 'Identifier') {
return `${taggedNode.tag.name}\`\``
}
} else if (node.type === 'CallExpression') {
const callNode = node as TSESTree.CallExpression
if (callNode.callee.type === 'Identifier') {
return `${callNode.callee.name}()`
}
}
return 'translation function'
}

function findParentTranslationFunction(node: TSESTree.Node): TSESTree.Node | null {
let parent = node.parent
while (parent) {
// Check for JSX elements (Trans, Plural, Select, SelectOrdinal)
if (parent.type === 'JSXElement') {
const jsxParent = parent as TSESTree.JSXElement
if (jsxParent.openingElement.name.type === 'JSXIdentifier') {
const tagName = jsxParent.openingElement.name.name
if (['Trans', 'Plural', 'Select', 'SelectOrdinal'].includes(tagName)) {
return parent
}
}
}

// Check for function calls (plural, select, selectOrdinal, t, msg, defineMessage)
if (parent.type === 'CallExpression') {
const callParent = parent as TSESTree.CallExpression
if (callParent.callee.type === 'Identifier') {
const funcName = callParent.callee.name
if (
['plural', 'select', 'selectOrdinal', 't', 'msg', 'defineMessage'].includes(funcName)
) {
return parent
}
}
}

// Check for tagged template expressions (t``, msg``, defineMessage``)
if (parent.type === 'TaggedTemplateExpression') {
const taggedParent = parent as TSESTree.TaggedTemplateExpression
if (taggedParent.tag.type === 'Identifier') {
const tagName = taggedParent.tag.name
if (['t', 'msg', 'defineMessage'].includes(tagName)) {
return parent
}
}
}

parent = parent.parent
}
return null
}

return {
[`${allLinguiQueries}`](node: TSESTree.Node) {
const parentTranslationFunction = findParentTranslationFunction(node)
if (parentTranslationFunction) {
const childType = getNodeType(node)
const parentType = getNodeType(parentTranslationFunction)

context.report({
node,
messageId: 'default',
data: {
childType,
parentType,
},
})
}
},
}
},
})

// Export as default for compatibility with test
export default rule
Loading