diff --git a/README.md b/README.md index 8862d9d..b525ca2 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,4 @@ Alternatively, add `lingui` to the plugins section, and configure the rules you - [consistent-plural-format](docs/rules/consistent-plural-format.md) - [no-plural-inside-trans](docs/rules/no-plural-inside-trans.md) - [require-explicit-id](docs/rules/require-explicit-id.md) +- [require-implicit-id](docs/rules/require-implicit-id.md) diff --git a/docs/rules/require-explicit-id.md b/docs/rules/require-explicit-id.md index b58b62b..00df2ad 100644 --- a/docs/rules/require-explicit-id.md +++ b/docs/rules/require-explicit-id.md @@ -1,19 +1,23 @@ # require-explicit-id -Enforce that `` components and Lingui macro function calls (`t`, `msg`, `defineMessage`) have an explicit `id`. +Enforce that ``, ``, ``, `` components and Lingui macro function calls (`t`, `msg`, `defineMessage`) do not have an explicit `id`. + +Relying on auto-generated IDs eliminates the need to maintain a separate mapping of IDs to messages and ensures the catalog stays in sync with the source code. See [Benefits of Generated IDs](https://lingui.dev/guides/explicit-vs-generated-ids#benefits-of-generated-ids) in the Lingui docs for more details. + +> **⚠️ Conflicts:** This rule directly conflicts with [`require-explicit-id`](./require-explicit-id.md). Do **not** enable both rules at the same time — they are mutually exclusive. + +```jsx +// nope ⛔️ +Hello + +t({ id: "msg.hello", message: "Hello" }) + +// ok ✅ +Hello + +t({ message: "Hello" }) +t`Hello` +``` diff --git a/src/helpers.test.ts b/src/helpers.test.ts index 2237258..65b4289 100644 --- a/src/helpers.test.ts +++ b/src/helpers.test.ts @@ -1,6 +1,90 @@ import { parse } from '@typescript-eslint/parser' import { TSESTree } from '@typescript-eslint/utils' -import { buildCalleePath } from './helpers' +import { buildCalleePath, findJSXAttribute, findObjectProperty } from './helpers' + +describe('findJSXAttribute', () => { + function buildJSXElement(code: string) { + const t = parse(code, { jsx: true }) + return (t.body[0] as TSESTree.ExpressionStatement).expression as TSESTree.JSXElement + } + + it('should find an attribute by name', () => { + const node = buildJSXElement('Hello') + const attr = findJSXAttribute(node, 'id') + + expect(attr).toBeDefined() + expect(attr!.name.name).toBe('id') + }) + + it('should return undefined when attribute is missing', () => { + const node = buildJSXElement('Hello') + const attr = findJSXAttribute(node, 'id') + + expect(attr).toBeUndefined() + }) + + it('should find the correct attribute among several', () => { + const node = buildJSXElement('Hello') + + expect(findJSXAttribute(node, 'id')).toBeDefined() + expect(findJSXAttribute(node, 'context')).toBeDefined() + expect(findJSXAttribute(node, 'missing')).toBeUndefined() + }) + + it('should ignore JSX spread attributes', () => { + const node = buildJSXElement('Hello') + + expect(findJSXAttribute(node, 'id')).toBeUndefined() + }) +}) + +describe('findObjectProperty', () => { + function buildObjectExpression(code: string) { + const t = parse(code) + return (t.body[0] as TSESTree.ExpressionStatement).expression as TSESTree.ObjectExpression + } + + it('should find a property with an identifier key', () => { + const obj = buildObjectExpression('({ id: "msg.hello", message: "Hello" })') + const prop = findObjectProperty(obj, 'id') + + expect(prop).toBeDefined() + expect((prop!.key as TSESTree.Identifier).name).toBe('id') + }) + + it('should find a property with a string literal key', () => { + const obj = buildObjectExpression("({ 'id': 'msg.hello', message: 'Hello' })") + const prop = findObjectProperty(obj, 'id') + + expect(prop).toBeDefined() + expect((prop!.key as TSESTree.Literal).value).toBe('id') + }) + + it('should return undefined when property is missing', () => { + const obj = buildObjectExpression('({ message: "Hello" })') + const prop = findObjectProperty(obj, 'id') + + expect(prop).toBeUndefined() + }) + + it('should find the correct property among several', () => { + const obj = buildObjectExpression( + '({ id: "msg.hello", message: "Hello", context: "greeting" })', + ) + + expect(findObjectProperty(obj, 'id')).toBeDefined() + expect(findObjectProperty(obj, 'message')).toBeDefined() + expect(findObjectProperty(obj, 'context')).toBeDefined() + expect(findObjectProperty(obj, 'missing')).toBeUndefined() + }) + + it('should ignore spread elements', () => { + const obj = buildObjectExpression('({ ...defaults, message: "Hello" })') + + expect(findObjectProperty(obj, 'id')).toBeUndefined() + expect(findObjectProperty(obj, 'message')).toBeDefined() + }) +}) describe('buildCalleePath', () => { function buildCallExp(code: string) { diff --git a/src/helpers.ts b/src/helpers.ts index f50d8af..5845f64 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -42,6 +42,13 @@ export const LinguiCallExpressionQuery = */ export const LinguiTransQuery = 'JSXElement[openingElement.name.name=Trans]' +/** + * Queries for Lingui ICU components + * , , ', + }, + // ICU component with patterns — id matches + { + code: '', + options: [{ patterns: ['^msg\\.'] }], + }, + // ICU component with expression id and patterns — silently skipped + { + code: '', + options: [{ patterns: ['^msg\\.'] }], + }, ], invalid: [ // Trans — missing id @@ -203,5 +234,25 @@ ruleTester.run(name, rule, { options: [{ patterns: ['^msg\\.'] }], errors: [{ messageId: 'invalidPattern' }], }, + + // ICU components — missing id + { + code: '', + errors: [{ messageId: 'missingIdIcu' }], + }, + { + code: '', + errors: [{ messageId: 'missingIdIcu' }], + }, + { + code: '', + }, + ], + invalid: [ + // Trans with id — forbidden + { + code: 'Hello', + errors: [{ messageId: 'forbiddenIdTrans' }], + }, + { + code: 'Hello World', + errors: [{ messageId: 'forbiddenIdTrans' }], + }, + { + code: 'Hello', + errors: [{ messageId: 'forbiddenIdTrans' }], + }, + { + code: 'Hello', + errors: [{ messageId: 'forbiddenIdTrans' }], + }, + + // Call expressions with id — forbidden + { + code: 't({ id: "msg.hello", message: "Hello" })', + errors: [{ messageId: 'forbiddenIdCall' }], + }, + { + code: 'msg({ id: "msg.hello", message: "Hello" })', + errors: [{ messageId: 'forbiddenIdCall' }], + }, + { + code: 'defineMessage({ id: "msg.hello", message: "Hello" })', + errors: [{ messageId: 'forbiddenIdCall' }], + }, + // String literal key 'id' — still forbidden + { + code: "t({ 'id': 'msg.hello', message: 'Hello' })", + errors: [{ messageId: 'forbiddenIdCall' }], + }, + // Expression id — still forbidden + { + code: 't({ id: someVar, message: "Hello" })', + errors: [{ messageId: 'forbiddenIdCall' }], + }, + + // ICU components with id — forbidden + { + code: '', + errors: [{ messageId: 'forbiddenIdIcu' }], + }, + { + code: '', + errors: [{ messageId: 'forbiddenIdIcu' }], + }, + { + code: '