diff --git a/README.md b/README.md index 8862d9d..e66298d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/rules/no-expression-in-message.md b/docs/rules/no-expression-in-message.md index d0a79d2..cbec63d 100644 --- a/docs/rules/no-expression-in-message.md +++ b/docs/rules/no-expression-in-message.md @@ -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. diff --git a/docs/rules/no-macro-inside-macro.md b/docs/rules/no-macro-inside-macro.md new file mode 100644 index 0000000..4f01c31 --- /dev/null +++ b/docs/rules/no-macro-inside-macro.md @@ -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 ``, ``, `` / `` 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 ${inner}` +t`outer ${}` + +// message macro inside choice component branch + + +// component macro inside choice component branch +a} 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: a, + other: b, +}) + +// 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(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. ` 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) — `` inside ``. +- [`no-trans-inside-trans`](./no-trans-inside-trans.md) — `` inside ``. diff --git a/src/index.ts b/src/index.ts index ea53a81..1b15e61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' @@ -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 @@ -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` diff --git a/src/rules/no-expression-in-message.ts b/src/rules/no-expression-in-message.ts index d2446b9..b17fec2 100644 --- a/src/rules/no-expression-in-message.ts +++ b/src/rules/no-expression-in-message.ts @@ -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)) @@ -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, diff --git a/src/rules/no-macro-inside-macro.ts b/src/rules/no-macro-inside-macro.ts new file mode 100644 index 0000000..dc100d7 --- /dev/null +++ b/src/rules/no-macro-inside-macro.ts @@ -0,0 +1,229 @@ +import { TSESTree } from '@typescript-eslint/utils' +import { createRule } from '../create-rule' + +export const name = 'no-macro-inside-macro' + +// Eager message macros produce a translated string at the call site. +// Never safe to nest — the string can't be recomposed into another message. +const EAGER_MESSAGE_MACRO_NAMES = new Set(['t']) + +// Lazy message macros produce a MessageDescriptor value. +// Safe as a value passed to t() or interpolated into another message, but never safe +// where a plain string is required (choice branches / option values). +const LAZY_MESSAGE_MACRO_NAMES = new Set(['msg', 'defineMessage']) + +const ALL_MESSAGE_MACRO_NAMES = new Set([ + ...EAGER_MESSAGE_MACRO_NAMES, + ...LAZY_MESSAGE_MACRO_NAMES, +]) + +// JSX macro components each extract as their own standalone message unit — +// never safe to nest inside another macro. +const JSX_MACRO_COMPONENT_NAMES = new Set(['Trans', 'Plural', 'Select', 'SelectOrdinal']) + +const CHOICE_CALL_NAMES = new Set(['plural', 'select', 'selectOrdinal']) +const CHOICE_COMPONENT_NAMES = new Set(['Plural', 'Select', 'SelectOrdinal']) + +// Attributes / option keys on choice macros that are not branch values and may +// legitimately hold any expression. +const CHOICE_RESERVED_KEYS = new Set(['value', 'offset']) + +type InnerMacro = + | { kind: 'eager'; name: string } + | { kind: 'lazy'; name: string } + | { kind: 'component'; name: string } + +type BadContainer = + | { kind: 'messageMacro'; outer: string } + | { kind: 'choiceComponentBranch'; component: string; attr: string } + | { kind: 'choiceCallOption'; call: string; option: string } + +function getInnerMacro(node: TSESTree.Node): InnerMacro | null { + if (node.type === TSESTree.AST_NODE_TYPES.TaggedTemplateExpression) { + if (node.tag.type !== TSESTree.AST_NODE_TYPES.Identifier) return null + const name = node.tag.name + if (EAGER_MESSAGE_MACRO_NAMES.has(name)) return { kind: 'eager', name } + if (LAZY_MESSAGE_MACRO_NAMES.has(name)) return { kind: 'lazy', name } + return null + } + if (node.type === TSESTree.AST_NODE_TYPES.CallExpression) { + if (node.callee.type !== TSESTree.AST_NODE_TYPES.Identifier) return null + const name = node.callee.name + if (EAGER_MESSAGE_MACRO_NAMES.has(name)) return { kind: 'eager', name } + if (LAZY_MESSAGE_MACRO_NAMES.has(name)) return { kind: 'lazy', name } + return null + } + if (node.type === TSESTree.AST_NODE_TYPES.JSXElement) { + const tag = node.openingElement.name + if (tag.type !== TSESTree.AST_NODE_TYPES.JSXIdentifier) return null + if (JSX_MACRO_COMPONENT_NAMES.has(tag.name)) return { kind: 'component', name: tag.name } + return null + } + return null +} + +function isMessageMacroNode(node: TSESTree.Node): string | null { + if ( + node.type === TSESTree.AST_NODE_TYPES.TaggedTemplateExpression && + node.tag.type === TSESTree.AST_NODE_TYPES.Identifier && + ALL_MESSAGE_MACRO_NAMES.has(node.tag.name) + ) { + return node.tag.name + } + if ( + node.type === TSESTree.AST_NODE_TYPES.CallExpression && + node.callee.type === TSESTree.AST_NODE_TYPES.Identifier && + ALL_MESSAGE_MACRO_NAMES.has(node.callee.name) + ) { + return node.callee.name + } + return null +} + +function getPropertyKeyName(key: TSESTree.Node): string | null { + if (key.type === TSESTree.AST_NODE_TYPES.Identifier) return key.name + if ( + key.type === TSESTree.AST_NODE_TYPES.Literal && + (typeof key.value === 'string' || typeof key.value === 'number') + ) { + return String(key.value) + } + return null +} + +// Walk up from `node` to the nearest Lingui container in which the inner macro is illegal. +function findBadContainer(node: TSESTree.Node): BadContainer | null { + let current: TSESTree.Node | undefined = node.parent + while (current) { + const outerMessageMacro = isMessageMacroNode(current) + if (outerMessageMacro) { + return { kind: 'messageMacro', outer: outerMessageMacro } + } + + if (current.type === TSESTree.AST_NODE_TYPES.JSXAttribute) { + const opening = current.parent + if ( + opening?.type === TSESTree.AST_NODE_TYPES.JSXOpeningElement && + opening.name.type === TSESTree.AST_NODE_TYPES.JSXIdentifier && + CHOICE_COMPONENT_NAMES.has(opening.name.name) && + current.name.type === TSESTree.AST_NODE_TYPES.JSXIdentifier && + !CHOICE_RESERVED_KEYS.has(current.name.name) + ) { + return { + kind: 'choiceComponentBranch', + component: opening.name.name, + attr: current.name.name, + } + } + } + + if (current.type === TSESTree.AST_NODE_TYPES.Property) { + const obj = current.parent + const call = obj?.parent + if ( + obj?.type === TSESTree.AST_NODE_TYPES.ObjectExpression && + call?.type === TSESTree.AST_NODE_TYPES.CallExpression && + call.callee.type === TSESTree.AST_NODE_TYPES.Identifier && + CHOICE_CALL_NAMES.has(call.callee.name) && + call.arguments[1] === obj + ) { + const keyName = getPropertyKeyName(current.key) + if (keyName && !CHOICE_RESERVED_KEYS.has(keyName)) { + return { + kind: 'choiceCallOption', + call: call.callee.name, + option: keyName, + } + } + } + } + + current = current.parent + } + return null +} + +// The canonical descriptor-passthrough pattern: `t(msg`...`)` / `t(defineMessage(...))`. +// A lazy message macro is valid *only* as a direct call argument to another message macro call; +// anywhere else it's interpolated/stringified as `[object Object]` at runtime. +function isLazyPassthrough(node: TSESTree.Node, inner: InnerMacro): boolean { + if (inner.kind !== 'lazy') return false + const parent = node.parent + if (parent?.type !== TSESTree.AST_NODE_TYPES.CallExpression) return false + if (!parent.arguments.includes(node as TSESTree.CallExpressionArgument)) return false + return isMessageMacroNode(parent) !== null +} + +export const rule = createRule({ + name, + meta: { + docs: { + description: + 'Disallow nesting Lingui translation macros. Message macros (t, msg, defineMessage) and component macros (Trans, Plural, Select, SelectOrdinal) each extract as a standalone translation unit and cannot be composed through another macro.', + recommended: 'error', + }, + messages: { + insideMessageMacro: + 'Do not nest `{{inner}}` inside `{{outer}}`. Translation macros each extract as a standalone unit — use plain text or a variable interpolation instead.', + insideChoiceComponentBranch: + 'Do not use `{{inner}}` inside the `{{attr}}` branch of `<{{component}}>`. The branch is already the extracted string — use a plain string literal.', + insideChoiceCallOption: + 'Do not use `{{inner}}` inside the `{{option}}` option of `{{call}}()`. The option is already the extracted string — use a plain string literal.', + }, + schema: [], + type: 'problem' as const, + }, + + defaultOptions: [], + create(context) { + function check(node: TSESTree.Node) { + const inner = getInnerMacro(node) + if (!inner) return + if (isLazyPassthrough(node, inner)) return + const container = findBadContainer(node) + if (!container) return + + switch (container.kind) { + case 'messageMacro': + context.report({ + node, + messageId: 'insideMessageMacro', + data: { inner: inner.name, outer: container.outer }, + }) + return + case 'choiceComponentBranch': + context.report({ + node, + messageId: 'insideChoiceComponentBranch', + data: { inner: inner.name, component: container.component, attr: container.attr }, + }) + return + case 'choiceCallOption': + context.report({ + node, + messageId: 'insideChoiceCallOption', + data: { inner: inner.name, call: container.call, option: container.option }, + }) + return + } + } + + return { + ':matches(TaggedTemplateExpression[tag.name=t], TaggedTemplateExpression[tag.name=msg], TaggedTemplateExpression[tag.name=defineMessage])'( + node: TSESTree.TaggedTemplateExpression, + ) { + check(node) + }, + ':matches(CallExpression[callee.name=t], CallExpression[callee.name=msg], CallExpression[callee.name=defineMessage])'( + node: TSESTree.CallExpression, + ) { + check(node) + }, + ':matches(JSXElement[openingElement.name.name=Trans], JSXElement[openingElement.name.name=Plural], JSXElement[openingElement.name.name=Select], JSXElement[openingElement.name.name=SelectOrdinal])'( + node: TSESTree.JSXElement, + ) { + check(node) + }, + } + }, +}) diff --git a/tests/src/rules/no-expression-in-message.test.ts b/tests/src/rules/no-expression-in-message.test.ts index 85fe59d..fd623a1 100644 --- a/tests/src/rules/no-expression-in-message.test.ts +++ b/tests/src/rules/no-expression-in-message.test.ts @@ -99,6 +99,41 @@ ruleTester.run(name, rule, { { code: 'hello {ph({name: obj.prop})}', }, + // Nested Lingui macros inside a message macro template are handled by + // `no-macro-inside-macro` with a targeted message — this rule must not + // also fire on them or users would see duplicate diagnostics. + { + name: 'Nested t`` in t`` template: deferred to no-macro-inside-macro', + code: 't`Hello ${t`world`}`', + }, + { + name: 'Nested msg`` in t`` template: deferred to no-macro-inside-macro', + code: 't`Hello ${msg`world`}`', + }, + { + name: 'Nested defineMessage`` in t`` template: deferred to no-macro-inside-macro', + code: 't`Hello ${defineMessage`world`}`', + }, + { + name: 'Nested t() call in t`` template: deferred to no-macro-inside-macro', + code: 't`Hello ${t({ message: "world" })}`', + }, + { + name: 'JSX interpolated in t`` template: deferred to no-macro-inside-macro', + code: 't`Hello ${world}`', + }, + { + name: 'JSX interpolated in t`` template: deferred to no-macro-inside-macro', + code: 't`Hello ${}`', + }, + { + name: 'Nested t`` in msg`` template: deferred to no-macro-inside-macro', + code: 'msg`Hello ${t`world`}`', + }, + { + name: 'Nested t`` in t() message option template: deferred to no-macro-inside-macro', + code: 't({ message: `Hello ${t`world`}` })', + }, ], invalid: [ { diff --git a/tests/src/rules/no-macro-inside-macro.test.ts b/tests/src/rules/no-macro-inside-macro.test.ts new file mode 100644 index 0000000..f213b55 --- /dev/null +++ b/tests/src/rules/no-macro-inside-macro.test.ts @@ -0,0 +1,300 @@ +import { rule, name } from '../../../src/rules/no-macro-inside-macro' +import { RuleTester } from '@typescript-eslint/rule-tester' + +describe('', () => {}) + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}) + +ruleTester.run(name, rule, { + valid: [ + // ==================== Plain message macros ==================== + { + name: 'allows t`` with identifier interpolation', + code: 't`Hello ${name}`', + }, + { + name: 'allows msg`` with identifier interpolation', + code: 'msg`Hello ${name}`', + }, + { + name: 'allows defineMessage`` with identifier interpolation', + code: 'defineMessage`Hello ${name}`', + }, + { + name: 'allows t() with message option as template literal', + code: 't({ message: `Hello ${name}` })', + }, + + // ==================== Choice calls as template interpolation ==================== + // plural()/select()/selectOrdinal() inside a message macro template compose + // into ICU; they are not nested translation units. + { + name: 'allows plural() interpolated into t`` template', + code: 't`${plural(count, { one: "# book", other: "# books" })}`', + }, + { + name: 'allows select() interpolated into t`` template', + code: 't`${select(gender, { male: "he", female: "she", other: "they" })}`', + }, + { + name: 'allows selectOrdinal() interpolated into t`` template', + code: 't`${selectOrdinal(n, { one: "#st", other: "#th" })}`', + }, + + // ==================== Lazy descriptor passthrough ==================== + // msg/defineMessage produce MessageDescriptor values; passing them as a + // direct argument to t() is the canonical passthrough pattern. + { + name: 'allows msg`` as direct argument to t()', + code: 't(msg`Hello`)', + }, + { + name: 'allows defineMessage`` as direct argument to t()', + code: 't(defineMessage`Hello`)', + }, + { + name: 'allows msg() call form as direct argument to t()', + code: 't(msg({ id: "greeting", message: "Hello" }))', + }, + + // ==================== Plain choice components ==================== + { + name: 'allows with plain string branches', + code: '', + }, + { + name: 'allows ', + }, + { + name: 'allows with plain string branches', + code: '', + }, + + // ==================== Plain choice calls ==================== + { + name: 'allows plural() with plain string options', + code: 'plural(count, { one: "# book", other: "# books" })', + }, + { + name: 'allows select() with plain string options', + code: 'select(gender, { male: "he", female: "she", other: "they" })', + }, + { + name: 'allows selectOrdinal() with plain string options', + code: 'selectOrdinal(n, { one: "#st", other: "#th" })', + }, + + // ==================== value / offset positions are unrestricted ==================== + { + name: 'allows macro expression in value attribute', + code: '', + }, + { + name: 'allows any expression in plural() offset option', + code: 'plural(count, { offset: someFn(), one: "a", other: "b" })', + }, + + // ==================== Same name, not a macro ==================== + { + name: 'ignores member-access tagged template (obj.t`...`)', + code: 'obj.t`Hello`', + }, + { + name: 'ignores member-access plural() call', + code: 'obj.plural(n, { one: t`x`, other: t`y` })', + }, + { + name: 'ignores local identifier shadowing `t`', + code: 'const notMacro = t => t`ok`; notMacro()', + }, + + // ==================== Top-level component macros ==================== + { + name: 'allows top-level with identifier interpolation', + code: 'Hello {userName}', + }, + { + name: 'allows top-level ', + code: '', + }, + { + name: 'allows top-level ', + }, + { + name: 'allows top-level ', + code: '', + }, + ], + invalid: [ + // ==================== Eager message macro inside another message macro ==================== + { + name: 'flags t`` inside t`` template', + code: 't`outer ${t`inner`}`', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 't', outer: 't' } }], + }, + { + name: 'flags t`` inside msg`` template', + code: 'msg`outer ${t`inner`}`', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 't', outer: 'msg' } }], + }, + { + name: 'flags t`` inside defineMessage`` template', + code: 'defineMessage`outer ${t`inner`}`', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 't', outer: 'defineMessage' } }], + }, + { + name: 'flags t`` inside t() message option template', + code: 't({ message: `outer ${t`inner`}` })', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 't', outer: 't' } }], + }, + { + name: 'flags t() call inside t() message option template', + code: 't({ message: `outer ${t({ message: "inner" })}` })', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 't', outer: 't' } }], + }, + { + name: 'reports each nested macro independently', + code: 't`a ${t`b`} c ${t`d`}`', + errors: [ + { messageId: 'insideMessageMacro', data: { inner: 't', outer: 't' } }, + { messageId: 'insideMessageMacro', data: { inner: 't', outer: 't' } }, + ], + }, + + // ==================== Lazy message macro interpolated, not passed through ==================== + // Interpolation stringifies a MessageDescriptor as [object Object] at runtime. + { + name: 'flags msg`` interpolated into t`` template', + code: 't`Greeting: ${msg`Hello`}`', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 'msg', outer: 't' } }], + }, + { + name: 'flags defineMessage`` interpolated into msg`` template', + code: 'msg`Greeting: ${defineMessage`Hello`}`', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 'defineMessage', outer: 'msg' } }], + }, + { + name: 'flags msg`` as message property (not a direct argument)', + code: 't({ message: msg`Hello` })', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 'msg', outer: 't' } }], + }, + + // ==================== Component macro inside a message macro ==================== + { + name: 'flags interpolated into t`` template', + code: 't`outer ${inner}`', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 'Trans', outer: 't' } }], + }, + { + name: 'flags interpolated into t`` template', + code: 't`outer ${}`', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 'Plural', outer: 't' } }], + }, + { + name: 'flags }`', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 'Select', outer: 't' } }], + }, + { + name: 'flags interpolated into t`` template', + code: 't`outer ${}`', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 'SelectOrdinal', outer: 't' } }], + }, + { + name: 'flags interpolated into msg`` template', + code: 'msg`outer ${inner}`', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 'Trans', outer: 'msg' } }], + }, + { + name: 'flags interpolated into t() message option template', + code: 't({ message: `outer ${inner}` })', + errors: [{ messageId: 'insideMessageMacro', data: { inner: 'Trans', outer: 't' } }], + }, + + // ==================== Message macro inside a choice component branch ==================== + { + name: 'flags t`` in branches', + code: '', + errors: [ + { messageId: 'insideChoiceComponentBranch', data: { inner: 't', component: 'Plural', attr: 'one' } }, + { messageId: 'insideChoiceComponentBranch', data: { inner: 't', component: 'Plural', attr: 'other' } }, + ], + }, + { + name: 'flags msg`` in branches', + code: '', + errors: [ + { messageId: 'insideChoiceComponentBranch', data: { inner: 'msg', component: 'Plural', attr: 'one' } }, + { messageId: 'insideChoiceComponentBranch', data: { inner: 'msg', component: 'Plural', attr: 'other' } }, + ], + }, + { + name: 'flags msg`` in ', + errors: [ + { messageId: 'insideChoiceComponentBranch', data: { inner: 'msg', component: 'Select', attr: 'male' } }, + { messageId: 'insideChoiceComponentBranch', data: { inner: 'msg', component: 'Select', attr: 'female' } }, + { messageId: 'insideChoiceComponentBranch', data: { inner: 'msg', component: 'Select', attr: 'other' } }, + ], + }, + { + name: 'flags t`` in branches', + code: '', + errors: [ + { messageId: 'insideChoiceComponentBranch', data: { inner: 't', component: 'SelectOrdinal', attr: 'one' } }, + { messageId: 'insideChoiceComponentBranch', data: { inner: 't', component: 'SelectOrdinal', attr: 'other' } }, + ], + }, + + // ==================== Component macro inside a choice component branch ==================== + { + name: 'flags in a branch', + code: 'a} other="b" />', + errors: [{ messageId: 'insideChoiceComponentBranch', data: { inner: 'Trans', component: 'Plural', attr: 'one' } }], + }, + { + name: 'flags nested in a branch', + code: '} other="b" />', + errors: [{ messageId: 'insideChoiceComponentBranch', data: { inner: 'Plural', component: 'Plural', attr: 'one' } }], + }, + + // ==================== Message macro inside a choice call option ==================== + { + name: 'flags t`` in plural() options', + code: 'plural(count, { one: t`# book`, other: t`# books` })', + errors: [ + { messageId: 'insideChoiceCallOption', data: { inner: 't', call: 'plural', option: 'one' } }, + { messageId: 'insideChoiceCallOption', data: { inner: 't', call: 'plural', option: 'other' } }, + ], + }, + { + name: 'flags msg`` in select() options', + code: 'select(gender, { male: msg`he`, female: msg`she`, other: msg`they` })', + errors: [ + { messageId: 'insideChoiceCallOption', data: { inner: 'msg', call: 'select', option: 'male' } }, + { messageId: 'insideChoiceCallOption', data: { inner: 'msg', call: 'select', option: 'female' } }, + { messageId: 'insideChoiceCallOption', data: { inner: 'msg', call: 'select', option: 'other' } }, + ], + }, + + // ==================== Component macro inside a choice call option ==================== + { + name: 'flags in plural() options', + code: 'plural(count, { one: a, other: b })', + errors: [ + { messageId: 'insideChoiceCallOption', data: { inner: 'Trans', call: 'plural', option: 'one' } }, + { messageId: 'insideChoiceCallOption', data: { inner: 'Trans', call: 'plural', option: 'other' } }, + ], + }, + ], +})