locale-lint 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/example/locales/en.json +40 -0
- package/example/locales/pt.json +37 -0
- package/example/src/components/ProfileCard.tsx +30 -0
- package/example/src/i18n/translations/en.ts +18 -0
- package/example/src/i18n/translations/pt.ts +14 -0
- package/example/src/screens/HomeScreen.tsx +46 -0
- package/example/src/screens/LoginScreen.tsx +46 -0
- package/package.json +45 -0
- package/src/cli.ts +96 -0
- package/src/core/compareLocales.ts +114 -0
- package/src/core/detectHardcoded.ts +159 -0
- package/src/core/extractKeys.ts +138 -0
- package/src/core/loadLocales.ts +298 -0
- package/src/core/runner.ts +90 -0
- package/src/core/scanFiles.ts +35 -0
- package/src/types/index.ts +69 -0
- package/src/utils/config.ts +136 -0
- package/src/utils/flatten.ts +48 -0
- package/src/utils/logger.ts +112 -0
- package/tsconfig.json +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Francis Okocha-Ojeah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# locale-lint
|
|
2
|
+
|
|
3
|
+
> Zero-config i18n linter for React, React Native & Next.js.
|
|
4
|
+
> Catch missing, unused, and hardcoded strings before they ship.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
npx locale-lint check
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
locale-lint — scanning 3 files across 2 locales
|
|
12
|
+
────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
❌ Missing in pt (3)
|
|
15
|
+
· auth.login.noAccount
|
|
16
|
+
· auth.login.signupLink
|
|
17
|
+
· home.oldWidget
|
|
18
|
+
|
|
19
|
+
❌ Undefined keys — used in code, missing from all locales (2)
|
|
20
|
+
· auth.loginButton → src/screens/LoginScreen.tsx:35
|
|
21
|
+
· home.nonExistentKey → src/screens/HomeScreen.tsx:40
|
|
22
|
+
|
|
23
|
+
⚠️ Unused keys — defined but never used in code (8)
|
|
24
|
+
· auth.signup.title
|
|
25
|
+
· common.cancel
|
|
26
|
+
· home.oldWidget
|
|
27
|
+
|
|
28
|
+
🚨 Hardcoded text — raw strings in JSX (6)
|
|
29
|
+
· src/screens/LoginScreen.tsx:16 → "Welcome to our platform"
|
|
30
|
+
· src/screens/HomeScreen.tsx:23 → "Your Statistics"
|
|
31
|
+
|
|
32
|
+
❌ Interpolation mismatches (1)
|
|
33
|
+
· home.welcome
|
|
34
|
+
en: {name} → pt: {nome}
|
|
35
|
+
|
|
36
|
+
────────────────────────────────────────────────────────
|
|
37
|
+
20 issues found in 123ms
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## What it detects
|
|
43
|
+
|
|
44
|
+
| Check | Description |
|
|
45
|
+
|---|---|
|
|
46
|
+
| ❌ **Missing keys** | Keys in your base locale (`en`) that are missing from other locales |
|
|
47
|
+
| ❌ **Undefined keys** | Keys called via `t('key')` in code that don't exist in any locale file |
|
|
48
|
+
| ⚠️ **Unused keys** | Keys defined in locale files that are never used in code |
|
|
49
|
+
| 🚨 **Hardcoded text** | Raw visible strings in JSX that should be translated |
|
|
50
|
+
| ❌ **Interpolation mismatches** | `{{name}}` in EN but `{nome}` in PT — variables that don't match |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Run without installing (recommended)
|
|
58
|
+
npx locale-lint check
|
|
59
|
+
|
|
60
|
+
# Or install globally
|
|
61
|
+
npm install -g locale-lint
|
|
62
|
+
|
|
63
|
+
# Or as a dev dependency
|
|
64
|
+
npm install --save-dev locale-lint
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
### Zero-config (auto-detects everything)
|
|
72
|
+
|
|
73
|
+
Just run in your project root:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx locale-lint check
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
locale-lint automatically finds:
|
|
80
|
+
- **Locales**: looks for `locales/`, `translations/`, `i18n/`, `src/locales/`, `public/locales/`
|
|
81
|
+
- **Source files**: scans `src/`, `app/`, `pages/`, `components/`, `screens/`
|
|
82
|
+
|
|
83
|
+
### With options
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Specify paths explicitly
|
|
87
|
+
npx locale-lint check --src src --locales public/locales
|
|
88
|
+
|
|
89
|
+
# Different base locale
|
|
90
|
+
npx locale-lint check --base fr
|
|
91
|
+
|
|
92
|
+
# Skip unused key detection (useful during active development)
|
|
93
|
+
npx locale-lint check --ignore-unused
|
|
94
|
+
|
|
95
|
+
# Skip hardcoded string detection
|
|
96
|
+
npx locale-lint check --ignore-hardcoded
|
|
97
|
+
|
|
98
|
+
# JSON output for CI pipelines
|
|
99
|
+
npx locale-lint check --json
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Configuration
|
|
105
|
+
|
|
106
|
+
Run `npx locale-lint init` to create a `locale-lint.config.json`:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"src": ["src"],
|
|
111
|
+
"locales": "locales",
|
|
112
|
+
"baseLocale": "en",
|
|
113
|
+
"extensions": ["js", "ts", "jsx", "tsx"],
|
|
114
|
+
"minHardcodedLength": 3,
|
|
115
|
+
"ignoreKeys": ["common.appName"],
|
|
116
|
+
"exclude": ["node_modules", "dist", "build", ".next"]
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| Option | Type | Default | Description |
|
|
121
|
+
|---|---|---|---|
|
|
122
|
+
| `src` | `string[]` | auto-detect | Directories to scan for source code |
|
|
123
|
+
| `locales` | `string` | auto-detect | Directory containing locale files |
|
|
124
|
+
| `baseLocale` | `string` | `"en"` | The source-of-truth locale |
|
|
125
|
+
| `extensions` | `string[]` | `["js","ts","jsx","tsx"]` | File extensions to scan |
|
|
126
|
+
| `minHardcodedLength` | `number` | `3` | Min string length to flag as hardcoded |
|
|
127
|
+
| `ignoreKeys` | `string[]` | `[]` | Keys to exclude from all checks |
|
|
128
|
+
| `exclude` | `string[]` | `["node_modules","dist"]` | Glob patterns to exclude |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Translation file formats
|
|
133
|
+
|
|
134
|
+
### Flat JSON
|
|
135
|
+
```
|
|
136
|
+
locales/
|
|
137
|
+
en.json
|
|
138
|
+
pt.json
|
|
139
|
+
fr.json
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Namespaced (i18next style)
|
|
143
|
+
```
|
|
144
|
+
locales/
|
|
145
|
+
en/
|
|
146
|
+
common.json
|
|
147
|
+
auth.json
|
|
148
|
+
pt/
|
|
149
|
+
common.json
|
|
150
|
+
auth.json
|
|
151
|
+
```
|
|
152
|
+
Keys are automatically prefixed: `common.save`, `auth.login.title`
|
|
153
|
+
|
|
154
|
+
### TypeScript export
|
|
155
|
+
```ts
|
|
156
|
+
// locales/en.ts
|
|
157
|
+
export default {
|
|
158
|
+
home: {
|
|
159
|
+
title: "Dashboard",
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Library compatibility
|
|
167
|
+
|
|
168
|
+
locale-lint works with any library that uses `t('key')` style calls:
|
|
169
|
+
|
|
170
|
+
| Library | Works? | Notes |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| **react-i18next** | ✅ | Full support |
|
|
173
|
+
| **next-intl** | ✅ | Full support |
|
|
174
|
+
| **i18next** | ✅ | Full support |
|
|
175
|
+
| **i18n-js** | ✅ | Detects `i18n.t('key')` |
|
|
176
|
+
| **react-intl / FormatJS** | ✅ | Detects `intl.formatMessage({id: 'key'})` |
|
|
177
|
+
| **vue-i18n** | ✅ | Detects `$t('key')` |
|
|
178
|
+
| **Lingui** | ⚠️ | JSON catalogs work; `.po` files not yet supported |
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## CI Integration
|
|
183
|
+
|
|
184
|
+
locale-lint exits with **code 1** when issues are found, making it easy to gate PRs:
|
|
185
|
+
|
|
186
|
+
```yaml
|
|
187
|
+
# .github/workflows/i18n.yml
|
|
188
|
+
name: i18n check
|
|
189
|
+
on: [push, pull_request]
|
|
190
|
+
|
|
191
|
+
jobs:
|
|
192
|
+
locale-lint:
|
|
193
|
+
runs-on: ubuntu-latest
|
|
194
|
+
steps:
|
|
195
|
+
- uses: actions/checkout@v4
|
|
196
|
+
- uses: actions/setup-node@v4
|
|
197
|
+
with:
|
|
198
|
+
node-version: 20
|
|
199
|
+
- run: npx locale-lint check
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### JSON output for custom reporting
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
npx locale-lint check --json > i18n-report.json
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## add to package.json scripts
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"scripts": {
|
|
215
|
+
"i18n:check": "locale-lint check",
|
|
216
|
+
"i18n:check:ci": "locale-lint check --json"
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## What's ignored
|
|
224
|
+
|
|
225
|
+
- Dynamic keys: `` t(`key.${variable}`) `` — can't be statically resolved
|
|
226
|
+
- Numbers in JSX: `<Text>42</Text>`
|
|
227
|
+
- Whitespace-only text nodes
|
|
228
|
+
- Strings shorter than `minHardcodedLength` (default: 3 chars)
|
|
229
|
+
- Content inside `<code>`, `<pre>`, `<script>`, `<style>`, `<svg>`
|
|
230
|
+
- Punctuation clusters: `—`, `·`, `|`
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Project structure
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
src/
|
|
238
|
+
cli.ts # Commander CLI entry point
|
|
239
|
+
core/
|
|
240
|
+
runner.ts # Main lint pipeline orchestrator
|
|
241
|
+
loadLocales.ts # JSON + TS locale file parser
|
|
242
|
+
scanFiles.ts # Glob-based source file scanner
|
|
243
|
+
extractKeys.ts # AST-based t('key') extractor
|
|
244
|
+
detectHardcoded.ts # JSX hardcoded string detector
|
|
245
|
+
compareLocales.ts # Missing/unused/undefined key comparison
|
|
246
|
+
utils/
|
|
247
|
+
flatten.ts # Nested object → dot-notation flattener
|
|
248
|
+
logger.ts # Chalk-powered terminal output
|
|
249
|
+
config.ts # Config file + auto-detection
|
|
250
|
+
types/
|
|
251
|
+
index.ts # Shared TypeScript interfaces
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Roadmap
|
|
257
|
+
|
|
258
|
+
- [ ] `--fix` flag — auto-fill missing keys with `"TODO: translate"`
|
|
259
|
+
- [ ] Watch mode for development (`locale-lint watch`)
|
|
260
|
+
- [ ] YAML locale file support
|
|
261
|
+
- [ ] `.po` file support (Lingui)
|
|
262
|
+
- [ ] HTML report output
|
|
263
|
+
- [ ] Translation coverage percentage budgets
|
|
264
|
+
- [ ] Plural form validation
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": {
|
|
3
|
+
"save": "Save",
|
|
4
|
+
"cancel": "Cancel",
|
|
5
|
+
"loading": "Loading...",
|
|
6
|
+
"error": "Something went wrong"
|
|
7
|
+
},
|
|
8
|
+
"auth": {
|
|
9
|
+
"login": {
|
|
10
|
+
"title": "Welcome back",
|
|
11
|
+
"subtitle": "Sign in to your account",
|
|
12
|
+
"emailLabel": "Email address",
|
|
13
|
+
"passwordLabel": "Password",
|
|
14
|
+
"submitButton": "Sign in",
|
|
15
|
+
"forgotPassword": "Forgot your password?",
|
|
16
|
+
"noAccount": "Don't have an account?",
|
|
17
|
+
"signupLink": "Sign up"
|
|
18
|
+
},
|
|
19
|
+
"signup": {
|
|
20
|
+
"title": "Create account",
|
|
21
|
+
"submitButton": "Get started"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"home": {
|
|
25
|
+
"title": "Dashboard",
|
|
26
|
+
"welcome": "Hello {{name}}, welcome back!",
|
|
27
|
+
"lastSeen": "Last seen {{date}}",
|
|
28
|
+
"oldWidget": "This feature is deprecated",
|
|
29
|
+
"stats": {
|
|
30
|
+
"total": "Total items",
|
|
31
|
+
"active": "Active",
|
|
32
|
+
"pending": "Pending"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"profile": {
|
|
36
|
+
"title": "My Profile",
|
|
37
|
+
"editButton": "Edit profile",
|
|
38
|
+
"bio": "Bio"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": {
|
|
3
|
+
"save": "Salvar",
|
|
4
|
+
"cancel": "Cancelar",
|
|
5
|
+
"loading": "Carregando...",
|
|
6
|
+
"error": "Algo deu errado"
|
|
7
|
+
},
|
|
8
|
+
"auth": {
|
|
9
|
+
"login": {
|
|
10
|
+
"title": "Bem-vindo de volta",
|
|
11
|
+
"subtitle": "Entre na sua conta",
|
|
12
|
+
"emailLabel": "Endereço de email",
|
|
13
|
+
"passwordLabel": "Senha",
|
|
14
|
+
"submitButton": "Entrar",
|
|
15
|
+
"forgotPassword": "Esqueceu sua senha?"
|
|
16
|
+
},
|
|
17
|
+
"signup": {
|
|
18
|
+
"title": "Criar conta",
|
|
19
|
+
"submitButton": "Começar"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"home": {
|
|
23
|
+
"title": "Painel",
|
|
24
|
+
"welcome": "Olá {nome}, bem-vindo de volta!",
|
|
25
|
+
"lastSeen": "Visto por último em {{date}}",
|
|
26
|
+
"stats": {
|
|
27
|
+
"total": "Total de itens",
|
|
28
|
+
"active": "Ativo",
|
|
29
|
+
"pending": "Pendente"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"profile": {
|
|
33
|
+
"title": "Meu Perfil",
|
|
34
|
+
"editButton": "Editar perfil",
|
|
35
|
+
"bio": "Bio"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
|
|
4
|
+
interface ProfileProps {
|
|
5
|
+
name: string;
|
|
6
|
+
bio?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// home.oldWidget is defined in translations but never used anywhere in code
|
|
10
|
+
// profile keys are used here
|
|
11
|
+
|
|
12
|
+
export function ProfileCard({ name, bio }: ProfileProps) {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="profile-card">
|
|
17
|
+
<h2>{t("profile.title")}</h2>
|
|
18
|
+
<p className="name">{name}</p>
|
|
19
|
+
{bio && <p className="bio">{bio}</p>}
|
|
20
|
+
<button type="button">{t("profile.editButton")}</button>
|
|
21
|
+
|
|
22
|
+
{/* Hardcoded tooltip text 🚨 */}
|
|
23
|
+
<span title="Click to edit your profile details">
|
|
24
|
+
{t("profile.bio")}
|
|
25
|
+
</span>
|
|
26
|
+
|
|
27
|
+
{/* i18n.t() style — also detected */}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Pattern 1: Typed variable with indirect export — most common in typed RN projects
|
|
2
|
+
import type { TranslationSchema } from './types'
|
|
3
|
+
|
|
4
|
+
const en: TranslationSchema = {
|
|
5
|
+
common: {
|
|
6
|
+
save: "Save",
|
|
7
|
+
cancel: "Cancel",
|
|
8
|
+
},
|
|
9
|
+
home: {
|
|
10
|
+
title: "Dashboard",
|
|
11
|
+
welcome: "Hello {{name}}",
|
|
12
|
+
},
|
|
13
|
+
auth: {
|
|
14
|
+
login: "Sign in",
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default en
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Pattern 2: satisfies keyword (TS 4.9+)
|
|
2
|
+
export default {
|
|
3
|
+
common: {
|
|
4
|
+
save: "Salvar",
|
|
5
|
+
cancel: "Cancelar",
|
|
6
|
+
},
|
|
7
|
+
home: {
|
|
8
|
+
title: "Painel",
|
|
9
|
+
welcome: "Olá {{name}}",
|
|
10
|
+
},
|
|
11
|
+
auth: {
|
|
12
|
+
login: "Entrar",
|
|
13
|
+
}
|
|
14
|
+
} satisfies Record<string, unknown>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
|
|
4
|
+
interface HomeProps {
|
|
5
|
+
userName: string;
|
|
6
|
+
lastSeen: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function HomeScreen({ userName, lastSeen }: HomeProps) {
|
|
10
|
+
const { t } = useTranslation();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="home">
|
|
14
|
+
<header>
|
|
15
|
+
<h1>{t("home.title")}</h1>
|
|
16
|
+
{/* Correctly using interpolation */}
|
|
17
|
+
<p>{t("home.welcome", { name: userName })}</p>
|
|
18
|
+
<small>{t("home.lastSeen", { date: lastSeen })}</small>
|
|
19
|
+
</header>
|
|
20
|
+
|
|
21
|
+
<section className="stats">
|
|
22
|
+
{/* Hardcoded section header 🚨 */}
|
|
23
|
+
<h2>Your Statistics</h2>
|
|
24
|
+
|
|
25
|
+
<div className="stat-card">
|
|
26
|
+
<span>{t("home.stats.total")}</span>
|
|
27
|
+
<span>142</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div className="stat-card">
|
|
30
|
+
<span>{t("home.stats.active")}</span>
|
|
31
|
+
<span>98</span>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="stat-card">
|
|
34
|
+
<span>{t("home.stats.pending")}</span>
|
|
35
|
+
<span>44</span>
|
|
36
|
+
</div>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
{/* This key doesn't exist ❌ */}
|
|
40
|
+
<p>{t("home.nonExistentKey")}</p>
|
|
41
|
+
|
|
42
|
+
{/* Dynamic key — intentionally ignored by locale-lint */}
|
|
43
|
+
<p>{t(`home.${userName}`)}</p>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
|
|
4
|
+
// Example React Native / React screen
|
|
5
|
+
export function LoginScreen() {
|
|
6
|
+
const { t } = useTranslation();
|
|
7
|
+
const [email, setEmail] = useState("");
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="screen">
|
|
11
|
+
{/* Correctly translated */}
|
|
12
|
+
<h1>{t("auth.login.title")}</h1>
|
|
13
|
+
<p>{t("auth.login.subtitle")}</p>
|
|
14
|
+
|
|
15
|
+
{/* Hardcoded strings — should be flagged 🚨 */}
|
|
16
|
+
<div className="hero-banner">
|
|
17
|
+
Welcome to our platform
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<label htmlFor="email">{t("auth.login.emailLabel")}</label>
|
|
21
|
+
<input
|
|
22
|
+
id="email"
|
|
23
|
+
type="email"
|
|
24
|
+
placeholder="Enter your email"
|
|
25
|
+
value={email}
|
|
26
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
27
|
+
/>
|
|
28
|
+
|
|
29
|
+
<label htmlFor="password">{t("auth.login.passwordLabel")}</label>
|
|
30
|
+
<input id="password" type="password" placeholder="Password" />
|
|
31
|
+
|
|
32
|
+
<button type="button">{t("auth.login.submitButton")}</button>
|
|
33
|
+
|
|
34
|
+
{/* This key doesn't exist in translations — undefined key ❌ */}
|
|
35
|
+
<p>{t("auth.loginButton")}</p>
|
|
36
|
+
|
|
37
|
+
<a href="/forgot">{t("auth.login.forgotPassword")}</a>
|
|
38
|
+
|
|
39
|
+
<div className="footer">
|
|
40
|
+
{/* Hardcoded again 🚨 */}
|
|
41
|
+
<span>Already have an account?</span>
|
|
42
|
+
<a href="/login">{t("auth.login.signupLink")}</a>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "locale-lint",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-config i18n linter for React, React Native & Next.js — detects missing, unused, undefined keys and hardcoded strings",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"locale-lint": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "ts-node src/cli.ts",
|
|
12
|
+
"check": "ts-node src/cli.ts check",
|
|
13
|
+
"lint": "ts-node src/cli.ts check --src example/src --locales example/locales"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"i18n",
|
|
17
|
+
"internationalization",
|
|
18
|
+
"localization",
|
|
19
|
+
"lint",
|
|
20
|
+
"translation",
|
|
21
|
+
"react",
|
|
22
|
+
"react-native",
|
|
23
|
+
"nextjs",
|
|
24
|
+
"i18next",
|
|
25
|
+
"missing-keys",
|
|
26
|
+
"unused-keys",
|
|
27
|
+
"hardcoded-strings"
|
|
28
|
+
],
|
|
29
|
+
"author": "",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@babel/parser": "^7.23.0",
|
|
33
|
+
"@babel/traverse": "^7.23.0",
|
|
34
|
+
"@babel/types": "^7.23.0",
|
|
35
|
+
"chalk": "^4.1.2",
|
|
36
|
+
"commander": "^11.1.0",
|
|
37
|
+
"glob": "^10.3.10"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/babel__traverse": "^7.20.4",
|
|
41
|
+
"@types/node": "^20.10.0",
|
|
42
|
+
"ts-node": "^10.9.2",
|
|
43
|
+
"typescript": "^5.3.2"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { runLint, hasIssues } from "./core/runner";
|
|
7
|
+
import { resolveConfig } from "./utils/config";
|
|
8
|
+
import { printResult, printScanning } from "./utils/logger";
|
|
9
|
+
import type { OutputFormat } from "./types/index";
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name("locale-lint")
|
|
15
|
+
.description("Zero-config i18n linter for React, React Native & Next.js")
|
|
16
|
+
.version("1.0.0");
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command("check")
|
|
20
|
+
.description("Run all i18n checks on your codebase")
|
|
21
|
+
.option("--src <dir>", "Source directory to scan (default: auto-detect)")
|
|
22
|
+
.option("--locales <dir>", "Locales directory (default: auto-detect)")
|
|
23
|
+
.option("--base <locale>", "Base locale to compare against (default: en)")
|
|
24
|
+
.option("--json", "Output results as JSON (useful for CI pipelines)")
|
|
25
|
+
.option("--ignore-unused", "Skip unused key detection")
|
|
26
|
+
.option("--ignore-hardcoded", "Skip hardcoded string detection")
|
|
27
|
+
.action(async (options) => {
|
|
28
|
+
const cwd = process.cwd();
|
|
29
|
+
const format: OutputFormat = options.json ? "json" : "pretty";
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// Resolve configuration (file + CLI flags + auto-detect)
|
|
33
|
+
const config = resolveConfig(cwd, {
|
|
34
|
+
src: options.src,
|
|
35
|
+
locales: options.locales,
|
|
36
|
+
base: options.base,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (format === "pretty") {
|
|
40
|
+
printScanning(config.src.map((s) => path.relative(cwd, s) || s));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Run the lint pipeline
|
|
44
|
+
let result = await runLint(config, cwd);
|
|
45
|
+
|
|
46
|
+
// Apply CLI-level suppressions
|
|
47
|
+
if (options.ignoreUnused) result = { ...result, unusedKeys: [] };
|
|
48
|
+
if (options.ignoreHardcoded) result = { ...result, hardcodedStrings: [] };
|
|
49
|
+
|
|
50
|
+
// Print results
|
|
51
|
+
printResult(result, format);
|
|
52
|
+
|
|
53
|
+
// Exit with code 1 if any issues found (enables CI gating)
|
|
54
|
+
if (hasIssues(result)) {
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
console.error();
|
|
60
|
+
console.error(` ${chalk.red("Error:")} ${message}`);
|
|
61
|
+
console.error();
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── Extra command: init ────────────────────────────────────────────────────────
|
|
67
|
+
program
|
|
68
|
+
.command("init")
|
|
69
|
+
.description("Create a locale-lint.config.json in the current directory")
|
|
70
|
+
.action(() => {
|
|
71
|
+
const fs = require("fs");
|
|
72
|
+
const configPath = path.join(process.cwd(), "locale-lint.config.json");
|
|
73
|
+
|
|
74
|
+
if (fs.existsSync(configPath)) {
|
|
75
|
+
console.log(chalk.yellow(" locale-lint.config.json already exists."));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const defaultConfig = {
|
|
80
|
+
src: ["src"],
|
|
81
|
+
locales: "locales",
|
|
82
|
+
baseLocale: "en",
|
|
83
|
+
extensions: ["js", "ts", "jsx", "tsx"],
|
|
84
|
+
minHardcodedLength: 3,
|
|
85
|
+
ignoreKeys: [],
|
|
86
|
+
exclude: ["node_modules", "dist", "build", ".next", "coverage"],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
|
|
90
|
+
console.log();
|
|
91
|
+
console.log(` ${chalk.green("✅")} Created ${chalk.bold("locale-lint.config.json")}`);
|
|
92
|
+
console.log(` Edit it to customize your setup, then run ${chalk.cyan("locale-lint check")}`);
|
|
93
|
+
console.log();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
program.parse(process.argv);
|