Skip to content

Commit 1df1ebc

Browse files
committed
feat(rules): add no-unlocalized-strings rule (TypeScript-aware)
- Detect user-visible strings not wrapped in Lingui macros - Use TypeScript type information to ignore technical strings: - String literal union types - Discriminated union properties (type/kind) - Heuristics for UI text detection (spaces, punctuation, etc.) - Configurable ignore patterns for functions, properties, names - Default ignores: console.*, className, type, href, etc. - Add to recommended config as warning (may have false positives)
1 parent 5730759 commit 1df1ebc

10 files changed

+694
-10
lines changed

README.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export default [
5757
| [no-nested-macros](docs/rules/no-nested-macros.md) | Disallow nesting Lingui macros ||
5858
| [no-single-tag-message](docs/rules/no-single-tag-message.md) | Disallow messages with only a single markup tag ||
5959
| [no-single-variable-message](docs/rules/no-single-variable-message.md) | Disallow messages that consist only of a single variable ||
60+
| [no-unlocalized-strings](docs/rules/no-unlocalized-strings.md) | Detect unlocalized user-visible strings (TypeScript-aware) | ⚠️ |
6061
| [valid-t-call-location](docs/rules/valid-t-call-location.md) | Enforce `t` calls inside functions ||
6162

6263
### Optional Rules
@@ -65,11 +66,6 @@ export default [
6566
|------|-------------|
6667
| [text-restrictions](docs/rules/text-restrictions.md) | Enforce project-specific text restrictions |
6768

68-
### Planned Rules
69-
70-
- `no-unlocalized-strings` — Detect user-visible strings not wrapped in Lingui (TypeScript type-aware)
71-
7269
## License
7370

7471
[MIT](LICENSE)
75-
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# no-unlocalized-strings
2+
3+
Detect user-visible strings not wrapped in Lingui translation macros.
4+
5+
## Why?
6+
7+
Unlocalized strings can lead to:
8+
- Incomplete translations
9+
- Poor user experience for non-English speakers
10+
- Difficulty tracking what needs translation
11+
12+
This rule helps catch user-visible text that should be wrapped in `t`, `<Trans>`, or other Lingui macros.
13+
14+
## Rule Details
15+
16+
This rule reports string literals and JSX text that appear to be user-visible UI text but are not wrapped in Lingui translation macros.
17+
18+
### ❌ Invalid
19+
20+
```tsx
21+
// Plain strings that look like UI text
22+
const label = "Save changes"
23+
const msg = "Something went wrong!"
24+
25+
// JSX text
26+
<button>Save changes</button>
27+
<p>Please try again.</p>
28+
29+
// Object properties
30+
{ label: "Click here" }
31+
{ message: "Error occurred!" }
32+
```
33+
34+
### ✅ Valid
35+
36+
```tsx
37+
// Wrapped in Lingui macros
38+
t`Save changes`
39+
<Trans>Something went wrong!</Trans>
40+
msg({ message: "Click here" })
41+
42+
// Technical/ignored strings
43+
<div className="my-class" />
44+
<input type="text" />
45+
console.log("Debug message")
46+
47+
// Technical identifiers
48+
const x = "myIdentifier"
49+
const x = "CONSTANT_VALUE"
50+
51+
// URLs and paths
52+
const url = "https://example.com"
53+
const path = "/api/users"
54+
55+
// TypeScript union types (type-aware)
56+
type Status = "idle" | "loading" | "error"
57+
```
58+
59+
## TypeScript Type-Aware Detection
60+
61+
When TypeScript type information is available, this rule can detect technical strings based on their types:
62+
63+
```tsx
64+
// These are NOT reported (recognized as technical strings)
65+
type Status = "idle" | "loading" | "error"
66+
const [status, setStatus] = useState<Status>("idle")
67+
68+
const action: Action = { type: "save" } // discriminated union
69+
const ACTION_SAVE = "save" as const
70+
```
71+
72+
## Options
73+
74+
### `ignoreFunctions`
75+
76+
Array of function names whose string arguments should be ignored.
77+
78+
Default: `["console.log", "console.warn", "console.error", "console.info", "console.debug", "require", "import"]`
79+
80+
```ts
81+
{
82+
"lingui-ts/no-unlocalized-strings": ["warn", {
83+
"ignoreFunctions": ["console.log", "logger.debug", "t"]
84+
}]
85+
}
86+
```
87+
88+
### `ignoreProperties`
89+
90+
Array of property/attribute names whose string values should be ignored.
91+
92+
Default: `["className", "styleName", "style", "type", "id", "key", "name", "testID", "data-testid", "href", "src", "role", "aria-label", "aria-describedby", "aria-labelledby"]`
93+
94+
```ts
95+
{
96+
"lingui-ts/no-unlocalized-strings": ["warn", {
97+
"ignoreProperties": ["className", "type", "variant", "testId"]
98+
}]
99+
}
100+
```
101+
102+
### `ignoreNames`
103+
104+
Array of variable names to ignore.
105+
106+
Default: `["__DEV__", "NODE_ENV"]`
107+
108+
### `ignorePattern`
109+
110+
Regex pattern for strings to ignore.
111+
112+
Default: `null`
113+
114+
```ts
115+
{
116+
"lingui-ts/no-unlocalized-strings": ["warn", {
117+
"ignorePattern": "^(test_|mock_|__)"
118+
}]
119+
}
120+
```
121+
122+
## Heuristics
123+
124+
The rule uses heuristics to determine if a string looks like UI text:
125+
126+
**Reported as potential UI text:**
127+
- Contains letters and spaces (e.g., "Save changes")
128+
- Starts with uppercase followed by lowercase (e.g., "Hello")
129+
- Contains punctuation like `.!?:,`
130+
131+
**Not reported (likely technical):**
132+
- Empty or whitespace only
133+
- Single characters
134+
- ALL_CAPS_WITH_UNDERSCORES
135+
- URLs and paths (`/`, `https://`, `mailto:`)
136+
- Identifiers without spaces (`myFunction`, `my-css-class`)
137+
138+
## When Not To Use It
139+
140+
If your project doesn't need localization or you handle string detection differently, you can disable this rule.
141+
142+
## Severity
143+
144+
This rule defaults to `"warn"` in the recommended config since it may have false positives. Adjust the severity based on your project's needs.
145+

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { noComplexExpressionsInMessage } from "./rules/no-complex-expressions-in
99
import { noNestedMacros } from "./rules/no-nested-macros.js"
1010
import { noSingleTagMessage } from "./rules/no-single-tag-message.js"
1111
import { noSingleVariableMessage } from "./rules/no-single-variable-message.js"
12+
import { noUnlocalizedStrings } from "./rules/no-unlocalized-strings.js"
1213
import { textRestrictions } from "./rules/text-restrictions.js"
1314
import { validTCallLocation } from "./rules/valid-t-call-location.js"
1415

@@ -23,6 +24,7 @@ const plugin = {
2324
"no-nested-macros": noNestedMacros,
2425
"no-single-tag-message": noSingleTagMessage,
2526
"no-single-variable-message": noSingleVariableMessage,
27+
"no-unlocalized-strings": noUnlocalizedStrings,
2628
"text-restrictions": textRestrictions,
2729
"valid-t-call-location": validTCallLocation
2830
},
@@ -41,6 +43,7 @@ plugin.configs = {
4143
"lingui-ts/no-nested-macros": "error",
4244
"lingui-ts/no-single-tag-message": "error",
4345
"lingui-ts/no-single-variable-message": "error",
46+
"lingui-ts/no-unlocalized-strings": "warn",
4447
"lingui-ts/valid-t-call-location": "error"
4548
// text-restrictions not in recommended (requires configuration)
4649
}

src/rules/consistent-plural-format.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createRule } from "../utils/create-rule.js"
44

55
type MessageId = "missingPluralKey"
66

7-
interface Options {
7+
export interface Options {
88
requiredKeys: string[]
99
}
1010

src/rules/no-complex-expressions-in-message.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createRule } from "../utils/create-rule.js"
44

55
type MessageId = "complexExpression"
66

7-
interface Options {
7+
export interface Options {
88
allowedCallees: string[]
99
allowMemberExpressions: boolean
1010
maxExpressionDepth: number | null

src/rules/no-nested-macros.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createRule } from "../utils/create-rule.js"
44

55
type MessageId = "nestedMacro"
66

7-
interface Options {
7+
export interface Options {
88
macros: string[]
99
allowDifferentMacros: boolean
1010
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { RuleTester } from "@typescript-eslint/rule-tester"
2+
import { afterAll, describe, it } from "vitest"
3+
4+
import { noUnlocalizedStrings } from "./no-unlocalized-strings.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("no-unlocalized-strings", noUnlocalizedStrings, {
23+
valid: [
24+
// Inside t``
25+
"t`Hello World`",
26+
"t`Save changes`",
27+
28+
// Inside <Trans>
29+
"<Trans>Hello World</Trans>",
30+
"<Trans>Save changes</Trans>",
31+
32+
// Inside msg/defineMessage
33+
'msg({ message: "Hello World" })',
34+
'defineMessage({ message: "Save changes" })',
35+
36+
// Console/debug (default ignored functions)
37+
'console.log("Hello World")',
38+
'console.error("Something went wrong")',
39+
40+
// Ignored properties (className, type, etc.)
41+
'<div className="my-class" />',
42+
'<input type="text" />',
43+
'<div id="my-id" />',
44+
'<div data-testid="test-button" />',
45+
'<a href="/path/to/page" />',
46+
47+
// Object properties with ignored keys
48+
'({ type: "button" })',
49+
'({ className: "my-class" })',
50+
51+
// Technical strings (no spaces, identifiers)
52+
'const x = "myIdentifier"',
53+
'const x = "my-css-class"',
54+
'const x = "CONSTANT_VALUE"',
55+
56+
// URLs and paths
57+
'const url = "https://example.com"',
58+
'const path = "/api/users"',
59+
'const mailto = "mailto:test@example.com"',
60+
61+
// Single characters
62+
'const sep = "-"',
63+
'const x = "."',
64+
65+
// Empty strings
66+
'const x = ""',
67+
'const x = " "',
68+
69+
// Type contexts
70+
'type Status = "loading" | "error"',
71+
"interface Props { variant: 'primary' | 'secondary' }",
72+
73+
// Ignore pattern
74+
{
75+
code: 'const x = "test_id_123"',
76+
options: [{ ignoreFunctions: [], ignoreProperties: [], ignoreNames: [], ignorePattern: "^test_" }]
77+
},
78+
79+
// Non-UI looking text
80+
'const x = "myFunction"',
81+
'const x = "onClick"'
82+
],
83+
invalid: [
84+
// Plain string that looks like UI text
85+
{
86+
code: 'const label = "Save changes"',
87+
errors: [{ messageId: "unlocalizedString" }]
88+
},
89+
{
90+
code: 'const msg = "Something went wrong!"',
91+
errors: [{ messageId: "unlocalizedString" }]
92+
},
93+
{
94+
code: 'const title = "Welcome to the app"',
95+
errors: [{ messageId: "unlocalizedString" }]
96+
},
97+
98+
// JSX text
99+
{
100+
code: "<button>Save changes</button>",
101+
errors: [{ messageId: "unlocalizedString" }]
102+
},
103+
{
104+
code: "<div>Something went wrong!</div>",
105+
errors: [{ messageId: "unlocalizedString" }]
106+
},
107+
{
108+
code: "<p>Please try again.</p>",
109+
errors: [{ messageId: "unlocalizedString" }]
110+
},
111+
112+
// JSX with title/aria-label that's not in default ignore list
113+
{
114+
code: '<button title="Click here">X</button>',
115+
errors: [{ messageId: "unlocalizedString" }]
116+
},
117+
118+
// Multiple violations
119+
{
120+
code: `
121+
const a = "Hello World"
122+
const b = "Goodbye World"
123+
`,
124+
errors: [
125+
{ messageId: "unlocalizedString" },
126+
{ messageId: "unlocalizedString" }
127+
]
128+
},
129+
130+
// Function return value
131+
{
132+
code: 'function getLabel() { return "Click me" }',
133+
errors: [{ messageId: "unlocalizedString" }]
134+
},
135+
136+
// Object property (non-ignored key)
137+
{
138+
code: '({ label: "Save changes" })',
139+
errors: [{ messageId: "unlocalizedString" }]
140+
},
141+
{
142+
code: '({ message: "Error occurred!" })',
143+
errors: [{ messageId: "unlocalizedString" }]
144+
}
145+
]
146+
})
147+

0 commit comments

Comments
 (0)