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 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);