Skip to content

Commit fa96cf3

Browse files
committed
refactor(no-unlocalized-strings): simplify to TypeScript-only
BREAKING: This rule now requires TypeScript type information. - Remove manual INTL_METHODS fallback list - Use TypeChecker for all technical string detection - Update README to require TypeScript - Update docs to reflect type-aware detection
1 parent 05d5d5c commit fa96cf3

File tree

4 files changed

+160
-176
lines changed

4 files changed

+160
-176
lines changed

README.md

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ ESLint plugin for [Lingui](https://lingui.dev/) with TypeScript type-aware rules
1111
- 🔍 Detects incorrect usage of Lingui translation macros
1212
- 📝 Enforces simple, safe expressions inside translated messages
1313
- 🎯 Detects missing localization of user-visible text
14-
- 🧠 Respects TypeScript types to avoid false positives (e.g. string literal unions)
14+
- 🧠 Uses TypeScript types to distinguish UI text from technical strings
1515

1616
## Requirements
1717

1818
- Node.js ≥ 24
1919
- ESLint ≥ 9
20-
- TypeScript ≥ 5 (optional, for type-aware rules)
20+
- TypeScript ≥ 5
21+
- `typescript-eslint` with type-aware linting enabled
2122

2223
## Installation
2324

@@ -27,27 +28,42 @@ npm install --save-dev eslint-plugin-lingui-typescript
2728

2829
## Usage
2930

30-
### ESLint Flat Config (eslint.config.ts)
31+
This plugin requires TypeScript and type-aware linting. Configure your `eslint.config.ts`:
3132

3233
```ts
34+
import eslint from "@eslint/js"
35+
import tseslint from "typescript-eslint"
3336
import linguiPlugin from "eslint-plugin-lingui-typescript"
3437

3538
export default [
36-
// Use recommended config
39+
eslint.configs.recommended,
40+
...tseslint.configs.strictTypeChecked,
3741
linguiPlugin.configs["flat/recommended"],
38-
39-
// Or configure rules manually
4042
{
41-
plugins: {
42-
"lingui-ts": linguiPlugin
43-
},
44-
rules: {
45-
"lingui-ts/no-single-variable-message": "error"
43+
languageOptions: {
44+
parserOptions: {
45+
projectService: true,
46+
tsconfigRootDir: import.meta.dirname
47+
}
4648
}
4749
}
4850
]
4951
```
5052

53+
Or configure rules manually:
54+
55+
```ts
56+
{
57+
plugins: {
58+
"lingui-ts": linguiPlugin
59+
},
60+
rules: {
61+
"lingui-ts/no-unlocalized-strings": "warn",
62+
"lingui-ts/no-single-variable-message": "error"
63+
}
64+
}
65+
```
66+
5167
## Rules
5268

5369
| Rule | Description | Recommended |
@@ -57,7 +73,7 @@ export default [
5773
| [no-nested-macros](docs/rules/no-nested-macros.md) | Disallow nesting Lingui macros ||
5874
| [no-single-tag-message](docs/rules/no-single-tag-message.md) | Disallow messages with only a single markup tag ||
5975
| [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) | ⚠️ |
76+
| [no-unlocalized-strings](docs/rules/no-unlocalized-strings.md) | Detect unlocalized user-visible strings | ⚠️ |
6177
| [valid-t-call-location](docs/rules/valid-t-call-location.md) | Enforce `t` calls inside functions ||
6278

6379
### Optional Rules

docs/rules/no-unlocalized-strings.md

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
Detect user-visible strings not wrapped in Lingui translation macros.
44

5+
**This rule requires TypeScript type information.**
6+
57
## Why?
68

79
Unlocalized strings can lead to:
810
- Incomplete translations
911
- Poor user experience for non-English speakers
1012
- Difficulty tracking what needs translation
1113

12-
This rule helps catch user-visible text that should be wrapped in `t`, `<Trans>`, or other Lingui macros.
14+
This rule uses TypeScript's type system to intelligently distinguish between user-visible text and technical strings.
1315

1416
## Rule Details
1517

@@ -52,21 +54,41 @@ const x = "CONSTANT_VALUE"
5254
const url = "https://example.com"
5355
const path = "/api/users"
5456

55-
// TypeScript union types (type-aware)
57+
// TypeScript union types (automatically detected)
5658
type Status = "idle" | "loading" | "error"
59+
const [status, setStatus] = useState<Status>("idle")
60+
61+
// Intl methods (type-aware detection)
62+
date.toLocaleDateString("de-DE", { weekday: "long" })
63+
new Intl.DateTimeFormat("en", { dateStyle: "full" })
5764
```
5865

5966
## TypeScript Type-Aware Detection
6067

61-
When TypeScript type information is available, this rule can detect technical strings based on their types:
68+
This rule leverages TypeScript's type system to automatically detect technical strings:
69+
70+
### String Literal Unions
6271

6372
```tsx
64-
// These are NOT reported (recognized as technical strings)
65-
type Status = "idle" | "loading" | "error"
66-
const [status, setStatus] = useState<Status>("idle")
73+
type Variant = "primary" | "secondary" | "danger"
74+
const variant: Variant = "primary" // ✅ Not reported
75+
76+
function setAlign(align: "left" | "center" | "right") {}
77+
setAlign("center") // ✅ Not reported
78+
```
6779

68-
const action: Action = { type: "save" } // discriminated union
69-
const ACTION_SAVE = "save" as const
80+
### Intl-Related Types
81+
82+
```tsx
83+
// Intl.LocalesArgument, DateTimeFormatOptions, etc.
84+
date.toLocaleDateString("de-DE", { weekday: "long" }) // ✅ Not reported
85+
new Intl.NumberFormat("en-US", { style: "currency" }) // ✅ Not reported
86+
```
87+
88+
### Discriminated Unions
89+
90+
```tsx
91+
const action = { type: "save" } // ✅ Not reported (type/kind properties)
7092
```
7193

7294
## Options
@@ -80,7 +102,7 @@ Default: `["console.log", "console.warn", "console.error", "console.info", "cons
80102
```ts
81103
{
82104
"lingui-ts/no-unlocalized-strings": ["warn", {
83-
"ignoreFunctions": ["console.log", "logger.debug", "t"]
105+
"ignoreFunctions": ["console.log", "logger.debug"]
84106
}]
85107
}
86108
```
@@ -119,21 +141,6 @@ Default: `null`
119141
}
120142
```
121143

122-
## Native Intl Methods
123-
124-
Strings passed to native JavaScript Intl methods are automatically ignored since they're technical locale/format values:
125-
126-
```tsx
127-
// All ignored - locale and option strings are technical
128-
date.toLocaleDateString("de-DE", { weekday: "long" })
129-
new Intl.DateTimeFormat("en", { dateStyle: "full" })
130-
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
131-
number.toLocaleString("en-US")
132-
"text".localeCompare("other", "de")
133-
```
134-
135-
Supported methods: `toLocaleString`, `toLocaleDateString`, `toLocaleTimeString`, `toLocaleUpperCase`, `toLocaleLowerCase`, `localeCompare`, and all `Intl.*` constructors.
136-
137144
## Heuristics
138145

139146
The rule uses heuristics to determine if a string looks like UI text:
@@ -152,9 +159,8 @@ The rule uses heuristics to determine if a string looks like UI text:
152159

153160
## When Not To Use It
154161

155-
If your project doesn't need localization or you handle string detection differently, you can disable this rule.
162+
This rule requires TypeScript. If your project doesn't use TypeScript or doesn't need localization, you can disable this rule.
156163

157164
## Severity
158165

159166
This rule defaults to `"warn"` in the recommended config since it may have false positives. Adjust the severity based on your project's needs.
160-

src/rules/no-unlocalized-strings.test.ts

Lines changed: 63 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,121 +10,131 @@ RuleTester.it = it
1010
const ruleTester = new RuleTester({
1111
languageOptions: {
1212
parserOptions: {
13-
ecmaVersion: 2022,
14-
sourceType: "module",
15-
ecmaFeatures: {
16-
jsx: true
17-
}
13+
projectService: {
14+
allowDefaultProject: ["*.ts", "*.tsx"]
15+
},
16+
tsconfigRootDir: import.meta.dirname
1817
}
1918
}
2019
})
2120

2221
ruleTester.run("no-unlocalized-strings", noUnlocalizedStrings, {
2322
valid: [
2423
// Inside t``
25-
"t`Hello World`",
26-
"t`Save changes`",
24+
{ code: "t`Hello World`", filename: "test.tsx" },
25+
{ code: "t`Save changes`", filename: "test.tsx" },
2726

2827
// Inside <Trans>
29-
"<Trans>Hello World</Trans>",
30-
"<Trans>Save changes</Trans>",
28+
{ code: "<Trans>Hello World</Trans>", filename: "test.tsx" },
29+
{ code: "<Trans>Save changes</Trans>", filename: "test.tsx" },
3130

3231
// Inside msg/defineMessage
33-
'msg({ message: "Hello World" })',
34-
'defineMessage({ message: "Save changes" })',
32+
{ code: 'msg({ message: "Hello World" })', filename: "test.tsx" },
33+
{ code: 'defineMessage({ message: "Save changes" })', filename: "test.tsx" },
3534

3635
// Console/debug (default ignored functions)
37-
'console.log("Hello World")',
38-
'console.error("Something went wrong")',
36+
{ code: 'console.log("Hello World")', filename: "test.tsx" },
37+
{ code: 'console.error("Something went wrong")', filename: "test.tsx" },
3938

4039
// 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" />',
40+
{ code: '<div className="my-class" />', filename: "test.tsx" },
41+
{ code: '<input type="text" />', filename: "test.tsx" },
42+
{ code: '<div id="my-id" />', filename: "test.tsx" },
43+
{ code: '<div data-testid="test-button" />', filename: "test.tsx" },
44+
{ code: '<a href="/path/to/page" />', filename: "test.tsx" },
4645

4746
// Object properties with ignored keys
48-
'({ type: "button" })',
49-
'({ className: "my-class" })',
47+
{ code: '({ type: "button" })', filename: "test.tsx" },
48+
{ code: '({ className: "my-class" })', filename: "test.tsx" },
5049

5150
// Technical strings (no spaces, identifiers)
52-
'const x = "myIdentifier"',
53-
'const x = "my-css-class"',
54-
'const x = "CONSTANT_VALUE"',
51+
{ code: 'const x = "myIdentifier"', filename: "test.tsx" },
52+
{ code: 'const x = "my-css-class"', filename: "test.tsx" },
53+
{ code: 'const x = "CONSTANT_VALUE"', filename: "test.tsx" },
5554

5655
// URLs and paths
57-
'const url = "https://example.com"',
58-
'const path = "/api/users"',
59-
'const mailto = "mailto:test@example.com"',
56+
{ code: 'const url = "https://example.com"', filename: "test.tsx" },
57+
{ code: 'const path = "/api/users"', filename: "test.tsx" },
58+
{ code: 'const mailto = "mailto:test@example.com"', filename: "test.tsx" },
6059

6160
// Single characters
62-
'const sep = "-"',
63-
'const x = "."',
61+
{ code: 'const sep = "-"', filename: "test.tsx" },
62+
{ code: 'const x = "."', filename: "test.tsx" },
6463

6564
// Empty strings
66-
'const x = ""',
67-
'const x = " "',
65+
{ code: 'const x = ""', filename: "test.tsx" },
66+
{ code: 'const x = " "', filename: "test.tsx" },
6867

6968
// Type contexts
70-
'type Status = "loading" | "error"',
71-
"interface Props { variant: 'primary' | 'secondary' }",
72-
73-
// Native Intl methods - locale strings
74-
'date.toLocaleDateString("de-DE")',
75-
'date.toLocaleTimeString("en-US")',
76-
'number.toLocaleString("de-DE")',
77-
'"hello".localeCompare("world", "de")',
78-
79-
// Native Intl methods - option values
80-
'date.toLocaleDateString("de-DE", { weekday: "long" })',
81-
'date.toLocaleDateString("de-DE", { month: "short", year: "numeric" })',
82-
'new Intl.DateTimeFormat("en", { dateStyle: "full" })',
83-
'new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })',
84-
'new Intl.RelativeTimeFormat("en", { numeric: "auto" })',
69+
{ code: 'type Status = "loading" | "error"', filename: "test.tsx" },
70+
{ code: "interface Props { variant: 'primary' | 'secondary' }", filename: "test.tsx" },
8571

8672
// Ignore pattern
8773
{
8874
code: 'const x = "test_id_123"',
75+
filename: "test.tsx",
8976
options: [{ ignoreFunctions: [], ignoreProperties: [], ignoreNames: [], ignorePattern: "^test_" }]
9077
},
9178

9279
// Non-UI looking text
93-
'const x = "myFunction"',
94-
'const x = "onClick"'
80+
{ code: 'const x = "myFunction"', filename: "test.tsx" },
81+
{ code: 'const x = "onClick"', filename: "test.tsx" },
82+
83+
// TypeScript: String literal union types are recognized as technical
84+
{
85+
code: `
86+
type Align = "left" | "center" | "right"
87+
const align: Align = "center"
88+
`,
89+
filename: "test.tsx"
90+
},
91+
{
92+
code: `
93+
function setMode(mode: "dark" | "light") {}
94+
setMode("dark")
95+
`,
96+
filename: "test.tsx"
97+
}
9598
],
9699
invalid: [
97100
// Plain string that looks like UI text
98101
{
99102
code: 'const label = "Save changes"',
103+
filename: "test.tsx",
100104
errors: [{ messageId: "unlocalizedString" }]
101105
},
102106
{
103107
code: 'const msg = "Something went wrong!"',
108+
filename: "test.tsx",
104109
errors: [{ messageId: "unlocalizedString" }]
105110
},
106111
{
107112
code: 'const title = "Welcome to the app"',
113+
filename: "test.tsx",
108114
errors: [{ messageId: "unlocalizedString" }]
109115
},
110116

111117
// JSX text
112118
{
113119
code: "<button>Save changes</button>",
120+
filename: "test.tsx",
114121
errors: [{ messageId: "unlocalizedString" }]
115122
},
116123
{
117124
code: "<div>Something went wrong!</div>",
125+
filename: "test.tsx",
118126
errors: [{ messageId: "unlocalizedString" }]
119127
},
120128
{
121129
code: "<p>Please try again.</p>",
130+
filename: "test.tsx",
122131
errors: [{ messageId: "unlocalizedString" }]
123132
},
124133

125-
// JSX with title/aria-label that's not in default ignore list
134+
// JSX with title (not in default ignore list)
126135
{
127136
code: '<button title="Click here">X</button>',
137+
filename: "test.tsx",
128138
errors: [{ messageId: "unlocalizedString" }]
129139
},
130140

@@ -134,27 +144,27 @@ ruleTester.run("no-unlocalized-strings", noUnlocalizedStrings, {
134144
const a = "Hello World"
135145
const b = "Goodbye World"
136146
`,
137-
errors: [
138-
{ messageId: "unlocalizedString" },
139-
{ messageId: "unlocalizedString" }
140-
]
147+
filename: "test.tsx",
148+
errors: [{ messageId: "unlocalizedString" }, { messageId: "unlocalizedString" }]
141149
},
142150

143151
// Function return value
144152
{
145153
code: 'function getLabel() { return "Click me" }',
154+
filename: "test.tsx",
146155
errors: [{ messageId: "unlocalizedString" }]
147156
},
148157

149158
// Object property (non-ignored key)
150159
{
151160
code: '({ label: "Save changes" })',
161+
filename: "test.tsx",
152162
errors: [{ messageId: "unlocalizedString" }]
153163
},
154164
{
155165
code: '({ message: "Error occurred!" })',
166+
filename: "test.tsx",
156167
errors: [{ messageId: "unlocalizedString" }]
157168
}
158169
]
159170
})
160-

0 commit comments

Comments
 (0)