Skip to content
Merged
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 @@ -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)
8 changes: 6 additions & 2 deletions docs/rules/require-explicit-id.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
# require-explicit-id

Enforce that `<Trans>` components and Lingui macro function calls (`t`, `msg`, `defineMessage`) have an explicit `id`.
Enforce that `<Trans>`, `<Plural>`, `<SelectOrdinal>`, `<Select>` components and Lingui macro function calls (`t`, `msg`, `defineMessage`) have an explicit `id`.

Providing an explicit `id` gives translators a stable, human-readable key and prevents auto-generated IDs from changing unexpectedly when the default message is updated.
Providing an explicit `id` gives translators a stable, human-readable key and prevents auto-generated IDs from changing unexpectedly when the default message is updated. See [Benefits of Explicit IDs](https://lingui.dev/guides/explicit-vs-generated-ids#benefits-of-explicit-ids) in the Lingui docs for more details.

Tagged template literals (`` t`Hello` ``) don't support `id` — use the function call form instead.

> **⚠️ Conflicts:** This rule directly conflicts with [`require-implicit-id`](./require-implicit-id.md). Do **not** enable both rules at the same time — they are mutually exclusive.

```jsx
// nope ⛔️
<Trans>Read the docs for more info.</Trans>
<Plural value={count} one="# book" other="# books" />
t`Hello`
t({ message: "Hello" })

// ok ✅
<Trans id="msg.docs">Read the docs for more info.</Trans>
<Plural id="msg.books" value={count} one="# book" other="# books" />
t({ id: "msg.hello", message: "Hello" })
```

Expand Down
20 changes: 20 additions & 0 deletions docs/rules/require-implicit-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# require-implicit-id

Enforce that `<Trans>`, `<Plural>`, `<SelectOrdinal>`, `<Select>` 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 ⛔️
<Trans id="msg.hello">Hello</Trans>
<Plural id="msg.books" value={count} one="# book" other="# books" />
t({ id: "msg.hello", message: "Hello" })

// ok ✅
<Trans>Hello</Trans>
<Plural value={count} one="# book" other="# books" />
t({ message: "Hello" })
t`Hello`
```
86 changes: 85 additions & 1 deletion src/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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('<Trans id="msg.hello">Hello</Trans>')
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('<Trans>Hello</Trans>')
const attr = findJSXAttribute(node, 'id')

expect(attr).toBeUndefined()
})

it('should find the correct attribute among several', () => {
const node = buildJSXElement('<Trans id="msg.hello" context="greeting">Hello</Trans>')

expect(findJSXAttribute(node, 'id')).toBeDefined()
expect(findJSXAttribute(node, 'context')).toBeDefined()
expect(findJSXAttribute(node, 'missing')).toBeUndefined()
})

it('should ignore JSX spread attributes', () => {
const node = buildJSXElement('<Trans {...props}>Hello</Trans>')

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) {
Expand Down
44 changes: 44 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export const LinguiCallExpressionQuery =
*/
export const LinguiTransQuery = 'JSXElement[openingElement.name.name=Trans]'

/**
* Queries for Lingui ICU components
* <Plural>, <SelectOrdinal>, <Select>
*/
export const LinguiIcuComponentQuery =
':matches(JSXElement[openingElement.name.name=Plural], JSXElement[openingElement.name.name=SelectOrdinal], JSXElement[openingElement.name.name=Select])'

/**
* Queries for plural CallExpression expressions and JSX elements
*
Expand Down Expand Up @@ -128,6 +135,43 @@ export function isJSXAttribute(node: TSESTree.Node | undefined): node is TSESTre
return (node as TSESTree.Node)?.type === TSESTree.AST_NODE_TYPES.JSXAttribute
}

/**
* Find a JSX attribute by name on a JSX element's opening tag.
*
* @example
* // Given `<Trans id="msg.hello" context="greeting">Hello</Trans>`
* findJSXAttribute(node, 'id') // → JSXAttribute node for `id="msg.hello"`
* findJSXAttribute(node, 'context') // → JSXAttribute node for `context="greeting"`
* findJSXAttribute(node, 'missing') // → undefined
*/
export function findJSXAttribute(node: TSESTree.JSXElement, attrName: string) {
return node.openingElement.attributes.find(
(attr): attr is TSESTree.JSXAttribute =>
attr.type === TSESTree.AST_NODE_TYPES.JSXAttribute &&
attr.name.type === TSESTree.AST_NODE_TYPES.JSXIdentifier &&
attr.name.name === attrName,
)
}

/**
* Find a property by name in an object expression.
* Handles both identifier keys (`{ id: '...' }`) and string-literal keys (`{ 'id': '...' }`).
*
* @example
* // Given `t({ id: "msg.hello", message: "Hello" })`
* findObjectProperty(objectNode, 'id') // → Property node for `id: "msg.hello"`
* findObjectProperty(objectNode, 'message') // → Property node for `message: "Hello"`
* findObjectProperty(objectNode, 'missing') // → undefined
*/
export function findObjectProperty(object: TSESTree.ObjectExpression, propName: string) {
return object.properties.find(
(prop): prop is TSESTree.Property =>
prop.type === TSESTree.AST_NODE_TYPES.Property &&
((prop.key.type === TSESTree.AST_NODE_TYPES.Identifier && prop.key.name === propName) ||
(prop.key.type === TSESTree.AST_NODE_TYPES.Literal && prop.key.value === propName)),
)
}

export function buildCalleePath(node: TSESTree.Expression) {
let current = node

Expand Down
2 changes: 2 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 requireImplicitIdRule from './rules/require-implicit-id'

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,
[requireImplicitIdRule.name]: requireImplicitIdRule.rule,
}

type RuleKey = keyof typeof rules
Expand Down
88 changes: 40 additions & 48 deletions src/rules/require-explicit-id.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { TSESTree } from '@typescript-eslint/utils'
import { createRule } from '../create-rule'
import {
findJSXAttribute,
findObjectProperty,
LinguiCallExpressionQuery,
LinguiIcuComponentQuery,
LinguiTaggedTemplateExpressionMessageQuery,
LinguiTransQuery,
} from '../helpers'
Expand All @@ -16,11 +19,12 @@ export const rule = createRule<Option[], string>({
name,
meta: {
docs: {
description: "enforce 'id' property or attribute for Lingui macros",
description: "enforce 'id' property or attribute for Lingui macros and components",
recommended: 'error',
},
messages: {
default: "Trans component requires an explicit 'id' attribute",
missingIdIcu: "Lingui ICU component requires an explicit 'id' attribute",
missingIdCall: "Macro function call requires an explicit 'id' property",
Comment thread
gwolpert marked this conversation as resolved.
noIdInTaggedTemplate:
"Tagged template literal doesn't support 'id'. Use {{ fn }}({ id: '...', message: '...' }) instead",
Expand Down Expand Up @@ -53,9 +57,8 @@ export const rule = createRule<Option[], string>({
options: [option],
} = context

const rulePatterns = option?.patterns?.map(
(pattern: string) => new RegExp(pattern, option?.flags),
)
const flags = option?.flags
const rulePatterns = option?.patterns?.map((pattern: string) => new RegExp(pattern, flags))

function validatePattern(node: TSESTree.Node, idValue: string) {
if (!rulePatterns?.length) {
Expand All @@ -71,52 +74,46 @@ export const rule = createRule<Option[], string>({
}
}

return {
[LinguiTransQuery](node: TSESTree.JSXElement) {
const idAttr = node.openingElement.attributes.find(
(attr): attr is TSESTree.JSXAttribute =>
attr.type === TSESTree.AST_NODE_TYPES.JSXAttribute &&
attr.name.type === TSESTree.AST_NODE_TYPES.JSXIdentifier &&
attr.name.name === 'id',
)

if (!idAttr) {
context.report({
node,
messageId: 'default',
})
return
}
function checkJSXId(node: TSESTree.JSXElement, missingMessageId: string) {
const idAttr = findJSXAttribute(node, 'id')

// Only validate string literal values; skip complex expressions silently
let idValue: string | null = null
if (
idAttr.value &&
idAttr.value.type === TSESTree.AST_NODE_TYPES.Literal &&
typeof idAttr.value.value === 'string'
) {
idValue = idAttr.value.value
} else if (
idAttr.value &&
idAttr.value.type === TSESTree.AST_NODE_TYPES.JSXExpressionContainer &&
idAttr.value.expression.type === TSESTree.AST_NODE_TYPES.Literal &&
typeof idAttr.value.expression.value === 'string'
) {
// Handle id={"msg.hello"} the same as id="msg.hello"
idValue = idAttr.value.expression.value
}
if (!idAttr) {
context.report({ node, messageId: missingMessageId })
return
}

if (idValue == null) {
return
let idValue: string | null = null
const attrVal = idAttr.value

if (attrVal?.type === TSESTree.AST_NODE_TYPES.Literal && typeof attrVal.value === 'string') {
idValue = attrVal.value
} else if (attrVal?.type === TSESTree.AST_NODE_TYPES.JSXExpressionContainer) {
const expr = attrVal.expression
if (expr.type === TSESTree.AST_NODE_TYPES.Literal && typeof expr.value === 'string') {
idValue = expr.value
}
}

if (idValue == null) {
return
}

validatePattern(idAttr, idValue)
}

return {
[LinguiTransQuery](node: TSESTree.JSXElement) {
checkJSXId(node, 'default')
},

validatePattern(idAttr, idValue)
[LinguiIcuComponentQuery](node: TSESTree.JSXElement) {
checkJSXId(node, 'missingIdIcu')
},

[LinguiTaggedTemplateExpressionMessageQuery](node: TSESTree.TemplateLiteral) {
const parent = node.parent as TSESTree.TaggedTemplateExpression
const fn =
parent.tag.type === TSESTree.AST_NODE_TYPES.Identifier ? parent.tag.name : 'function'
// The AST query guarantees tag is an Identifier (t, msg, defineMessage)
const fn = (parent.tag as TSESTree.Identifier).name

context.report({
node: parent,
Expand All @@ -132,12 +129,7 @@ export const rule = createRule<Option[], string>({
return
}

const idProp = arg.properties.find(
(prop): prop is TSESTree.Property =>
prop.type === TSESTree.AST_NODE_TYPES.Property &&
((prop.key.type === TSESTree.AST_NODE_TYPES.Identifier && prop.key.name === 'id') ||
(prop.key.type === TSESTree.AST_NODE_TYPES.Literal && prop.key.value === 'id')),
)
const idProp = findObjectProperty(arg, 'id')

if (!idProp) {
context.report({
Expand Down
Loading
Loading