Skip to content

Commit a3d95dd

Browse files
committed
feat(rules): add valid-t-call-location rule
Enforces that t`...` calls are inside functions, not at module top-level. Configurable via allowTopLevel option.
1 parent 5425591 commit a3d95dd

File tree

5 files changed

+304
-3
lines changed

5 files changed

+304
-3
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ export default [
5656
| [no-nested-macros](docs/rules/no-nested-macros.md) | Disallow nesting Lingui macros ||
5757
| [no-single-tag-message](docs/rules/no-single-tag-message.md) | Disallow messages with only a single markup tag ||
5858
| [no-single-variable-message](docs/rules/no-single-variable-message.md) | Disallow messages that consist only of a single variable ||
59+
| [valid-t-call-location](docs/rules/valid-t-call-location.md) | Enforce `t` calls inside functions ||
5960

6061
### Planned Rules
6162

62-
- `valid-t-call-location` — Enforce valid locations for `t` macro calls
6363
- `no-unlocalized-strings` — Detect user-visible strings not wrapped in Lingui
6464
- `text-restrictions` — Enforce project-specific text restrictions
6565
- `consistent-plural-format` — Ensure consistent plural usage
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# valid-t-call-location
2+
3+
Enforce that `t` macro calls are inside functions, not at module top-level.
4+
5+
## Why?
6+
7+
Using `t` at module top-level causes issues:
8+
9+
- **Evaluation order**: The message is evaluated when the module loads, before the i18n context may be ready
10+
- **Static extraction**: Some extraction tools expect messages inside functions
11+
- **Hot reloading**: Top-level values don't update when locale changes
12+
- **Testing**: Harder to mock or test translations
13+
14+
## Rule Details
15+
16+
This rule reports `t` tagged template expressions that are not inside a function, arrow function, or method.
17+
18+
### ❌ Invalid
19+
20+
```tsx
21+
// Top-level variable
22+
const msg = t`Hello`
23+
24+
// Top-level export
25+
export const greeting = t`Welcome`
26+
27+
// Class property
28+
class MyClass {
29+
message = t`Hello`
30+
}
31+
32+
// Top-level object
33+
const config = {
34+
message: t`Hello`
35+
}
36+
```
37+
38+
### ✅ Valid
39+
40+
```tsx
41+
// Inside function
42+
function Component() {
43+
const msg = t`Hello`
44+
return msg
45+
}
46+
47+
// Inside arrow function
48+
const getGreeting = () => t`Welcome`
49+
50+
// Inside method
51+
class MyClass {
52+
getMessage() {
53+
return t`Hello`
54+
}
55+
}
56+
57+
// Inside React component
58+
function App() {
59+
return <div>{t`Welcome`}</div>
60+
}
61+
62+
// Inside callback
63+
items.map(() => t`Item`)
64+
65+
// Inside hook
66+
function useTranslation() {
67+
return { message: t`Hello` }
68+
}
69+
```
70+
71+
## Options
72+
73+
### `allowTopLevel`
74+
75+
When `true`, allows `t` at module top-level. Default: `false`
76+
77+
Use this if you have a build setup that handles top-level translations correctly.
78+
79+
```ts
80+
{
81+
"lingui-ts/valid-t-call-location": ["error", {
82+
"allowTopLevel": true
83+
}]
84+
}
85+
```
86+
87+
## When Not To Use It
88+
89+
If your build setup correctly handles top-level `t` calls (e.g., with compile-time extraction), you can disable this rule or set `allowTopLevel: true`.
90+

src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { noComplexExpressionsInMessage } from "./rules/no-complex-expressions-in
88
import { noNestedMacros } from "./rules/no-nested-macros.js"
99
import { noSingleTagMessage } from "./rules/no-single-tag-message.js"
1010
import { noSingleVariableMessage } from "./rules/no-single-variable-message.js"
11+
import { validTCallLocation } from "./rules/valid-t-call-location.js"
1112

1213
const plugin = {
1314
meta: {
@@ -18,7 +19,8 @@ const plugin = {
1819
"no-complex-expressions-in-message": noComplexExpressionsInMessage,
1920
"no-nested-macros": noNestedMacros,
2021
"no-single-tag-message": noSingleTagMessage,
21-
"no-single-variable-message": noSingleVariableMessage
22+
"no-single-variable-message": noSingleVariableMessage,
23+
"valid-t-call-location": validTCallLocation
2224
},
2325
configs: {} as Record<string, unknown>
2426
}
@@ -33,7 +35,8 @@ plugin.configs = {
3335
"lingui-ts/no-complex-expressions-in-message": "error",
3436
"lingui-ts/no-nested-macros": "error",
3537
"lingui-ts/no-single-tag-message": "error",
36-
"lingui-ts/no-single-variable-message": "error"
38+
"lingui-ts/no-single-variable-message": "error",
39+
"lingui-ts/valid-t-call-location": "error"
3740
}
3841
}
3942
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { RuleTester } from "@typescript-eslint/rule-tester"
2+
import { afterAll, describe, it } from "vitest"
3+
4+
import { validTCallLocation } from "./valid-t-call-location.js"
5+
6+
RuleTester.afterAll = afterAll
7+
RuleTester.describe = describe
8+
RuleTester.it = it
9+
10+
const ruleTester = new RuleTester({
11+
languageOptions: {
12+
parserOptions: {
13+
ecmaVersion: 2022,
14+
sourceType: "module",
15+
ecmaFeatures: {
16+
jsx: true
17+
}
18+
}
19+
}
20+
})
21+
22+
ruleTester.run("valid-t-call-location", validTCallLocation, {
23+
valid: [
24+
// Inside function declaration
25+
`function Component() {
26+
const msg = t\`Hello\`
27+
return msg
28+
}`,
29+
30+
// Inside arrow function
31+
`const Component = () => {
32+
const msg = t\`Hello\`
33+
return msg
34+
}`,
35+
36+
// Inside function expression
37+
`const getMsg = function() {
38+
return t\`Hello\`
39+
}`,
40+
41+
// Inside method
42+
`class MyClass {
43+
getMessage() {
44+
return t\`Hello\`
45+
}
46+
}`,
47+
48+
// Inside React component
49+
`function App() {
50+
return <div>{t\`Welcome\`}</div>
51+
}`,
52+
53+
// Inside callback
54+
`items.map(() => t\`Item\`)`,
55+
56+
// Inside event handler
57+
`function Component() {
58+
const handleClick = () => {
59+
alert(t\`Clicked\`)
60+
}
61+
}`,
62+
63+
// Inside hook
64+
`function useTranslation() {
65+
return { message: t\`Hello\` }
66+
}`,
67+
68+
// Non-t tagged templates at top level are fine
69+
`const styles = css\`color: red\``,
70+
`const html = html\`<div></div>\``,
71+
72+
// Allow top level when configured
73+
{
74+
code: `const msg = t\`Hello\``,
75+
options: [{ allowTopLevel: true }]
76+
}
77+
],
78+
invalid: [
79+
// Top-level variable declaration
80+
{
81+
code: `const msg = t\`Hello\``,
82+
errors: [{ messageId: "topLevelNotAllowed" }]
83+
},
84+
85+
// Top-level export
86+
{
87+
code: `export const msg = t\`Welcome\``,
88+
errors: [{ messageId: "topLevelNotAllowed" }]
89+
},
90+
91+
// Multiple top-level usages
92+
{
93+
code: `
94+
const greeting = t\`Hello\`
95+
const farewell = t\`Goodbye\`
96+
`,
97+
errors: [
98+
{ messageId: "topLevelNotAllowed" },
99+
{ messageId: "topLevelNotAllowed" }
100+
]
101+
},
102+
103+
// Inside class property (not method)
104+
{
105+
code: `
106+
class MyClass {
107+
message = t\`Hello\`
108+
}
109+
`,
110+
errors: [{ messageId: "topLevelNotAllowed" }]
111+
},
112+
113+
// Inside object at top level
114+
{
115+
code: `
116+
const config = {
117+
message: t\`Hello\`
118+
}
119+
`,
120+
errors: [{ messageId: "topLevelNotAllowed" }]
121+
}
122+
]
123+
})
124+

src/rules/valid-t-call-location.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils"
2+
3+
import { createRule } from "../utils/create-rule.js"
4+
5+
type MessageId = "topLevelNotAllowed"
6+
7+
interface Options {
8+
allowTopLevel: boolean
9+
}
10+
11+
/**
12+
* Checks if a node is inside a function, arrow function, or method.
13+
*/
14+
function isInsideFunction(node: TSESTree.Node): boolean {
15+
let current: TSESTree.Node | undefined = node.parent ?? undefined
16+
17+
while (current !== undefined) {
18+
switch (current.type) {
19+
case AST_NODE_TYPES.FunctionDeclaration:
20+
case AST_NODE_TYPES.FunctionExpression:
21+
case AST_NODE_TYPES.ArrowFunctionExpression:
22+
case AST_NODE_TYPES.MethodDefinition:
23+
return true
24+
default:
25+
break
26+
}
27+
current = current.parent ?? undefined
28+
}
29+
30+
return false
31+
}
32+
33+
export const validTCallLocation = createRule<[Options], MessageId>({
34+
name: "valid-t-call-location",
35+
meta: {
36+
type: "problem",
37+
docs: {
38+
description:
39+
"Enforce that t macro calls are inside functions, not at module top-level"
40+
},
41+
messages: {
42+
topLevelNotAllowed:
43+
"t`...` should not be used at module top-level. Move it inside a function or component."
44+
},
45+
schema: [
46+
{
47+
type: "object",
48+
properties: {
49+
allowTopLevel: {
50+
type: "boolean",
51+
default: false
52+
}
53+
},
54+
additionalProperties: false
55+
}
56+
]
57+
},
58+
defaultOptions: [
59+
{
60+
allowTopLevel: false
61+
}
62+
],
63+
create(context, [options]) {
64+
if (options.allowTopLevel) {
65+
return {}
66+
}
67+
68+
return {
69+
TaggedTemplateExpression(node): void {
70+
if (node.tag.type !== AST_NODE_TYPES.Identifier || node.tag.name !== "t") {
71+
return
72+
}
73+
74+
if (!isInsideFunction(node)) {
75+
context.report({
76+
node,
77+
messageId: "topLevelNotAllowed"
78+
})
79+
}
80+
}
81+
}
82+
}
83+
})
84+

0 commit comments

Comments
 (0)