diff --git a/docs/rules/no-nested-trans.md b/docs/rules/no-nested-trans.md new file mode 100644 index 0000000..7c37c44 --- /dev/null +++ b/docs/rules/no-nested-trans.md @@ -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: ``, ``, ` + +// Components inside components + +one item} 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 +There are many books + + + * + */ +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) } diff --git a/src/index.ts b/src/index.ts index 6e19e62..f365332 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' @@ -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 diff --git a/src/rules/no-nested-trans.ts b/src/rules/no-nested-trans.ts new file mode 100644 index 0000000..0b53210 --- /dev/null +++ b/src/rules/no-nested-trans.ts @@ -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 diff --git a/tests/src/rules/no-nested-trans.test.ts b/tests/src/rules/no-nested-trans.test.ts new file mode 100644 index 0000000..7071284 --- /dev/null +++ b/tests/src/rules/no-nested-trans.test.ts @@ -0,0 +1,175 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { rule, name } from '../../../src/rules/no-nested-trans' + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}) + +ruleTester.run(name, rule, { + valid: [ + // Valid standalone usage + { + code: `const message = t\`Hello\``, + }, + { + code: `const message = t({ message: "Hello" })`, + }, + { + code: `const message = msg\`Hello\``, + }, + { + code: `const message = defineMessage\`Hello\``, + }, + { + code: `plural(count, { one: "one book", other: "There are many books" });`, + }, + { + code: `select(value, { male: "He", female: "She", other: "They" });`, + }, + { + code: `selectOrdinal(value, { one: "1st", two: "2nd", other: "#th" });`, + }, + { + code: `There are many books`, + }, + { + code: ``, + }, + { + code: ``, + errors: [{ messageId: 'default', data: { childType: 't``', parentType: 'He} other="They" />`, + errors: [{ messageId: 'default', data: { childType: '', parentType: '