@intl-party/eslint-plugin 1.0.0 → 1.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 IntlParty Team
3
+ Copyright (c) 2025-2026 IntlParty Team
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
18
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,450 @@
1
+ # @intl-party/eslint-plugin
2
+
3
+ ESLint plugin for IntlParty - enforce best practices and catch common i18n issues in your code.
4
+
5
+ ## Features
6
+
7
+ - 🚫 **No hardcoded strings** - Detect untranslated user-facing text
8
+ - 🔍 **Missing translation keys** - Catch references to non-existent translation keys
9
+ - ⚛️ **React hooks enforcement** - Prefer translation hooks over direct i18n usage
10
+ - 📝 **Consistent patterns** - Enforce consistent translation patterns across your codebase
11
+ - ⚙️ **Configurable rules** - Customize rules to fit your project needs
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install --save-dev @intl-party/eslint-plugin
17
+ # or
18
+ pnpm add -D @intl-party/eslint-plugin
19
+ # or
20
+ yarn add --dev @intl-party/eslint-plugin
21
+ ```
22
+
23
+ ## Configuration
24
+
25
+ ### Basic Setup
26
+
27
+ Add the plugin to your ESLint configuration:
28
+
29
+ ```javascript
30
+ // .eslintrc.js
31
+ module.exports = {
32
+ plugins: ["@intl-party"],
33
+ extends: ["@intl-party/recommended"],
34
+ };
35
+ ```
36
+
37
+ ### Manual Configuration
38
+
39
+ ```javascript
40
+ // .eslintrc.js
41
+ module.exports = {
42
+ plugins: ["@intl-party"],
43
+ rules: {
44
+ "@intl-party/no-hardcoded-strings": "error",
45
+ "@intl-party/no-missing-keys": "error",
46
+ "@intl-party/prefer-translation-hooks": "warn",
47
+ },
48
+ };
49
+ ```
50
+
51
+ ### TypeScript Configuration
52
+
53
+ ```javascript
54
+ // .eslintrc.js
55
+ module.exports = {
56
+ extends: ["@intl-party/recommended", "@intl-party/typescript"],
57
+ parserOptions: {
58
+ project: "./tsconfig.json",
59
+ },
60
+ };
61
+ ```
62
+
63
+ ## Rules
64
+
65
+ ### `@intl-party/no-hardcoded-strings`
66
+
67
+ Prevents hardcoded user-facing strings that should be translated.
68
+
69
+ #### ❌ Incorrect
70
+
71
+ ```jsx
72
+ function Welcome() {
73
+ return <h1>Welcome to our app!</h1>; // Hardcoded string
74
+ }
75
+
76
+ function Button() {
77
+ return <button>Click here</button>; // Hardcoded string
78
+ }
79
+
80
+ const message = "Hello world"; // Hardcoded string
81
+ ```
82
+
83
+ #### ✅ Correct
84
+
85
+ ```jsx
86
+ function Welcome() {
87
+ const t = useTranslations("common");
88
+ return <h1>{t("welcome")}</h1>;
89
+ }
90
+
91
+ function Button() {
92
+ const t = useTranslations("common");
93
+ return <button>{t("clickHere")}</button>;
94
+ }
95
+
96
+ const message = t("hello");
97
+ ```
98
+
99
+ #### Configuration
100
+
101
+ ```javascript
102
+ {
103
+ "@intl-party/no-hardcoded-strings": ["error", {
104
+ "ignorePatterns": [
105
+ "^\\d+$", // Numbers
106
+ "^[A-Z_]+$", // Constants
107
+ "^https?://", // URLs
108
+ "className", // CSS classes
109
+ "data-*" // Data attributes
110
+ ],
111
+ "ignoreElements": ["script", "style"],
112
+ "ignoreAttributes": ["className", "id", "data-testid"]
113
+ }]
114
+ }
115
+ ```
116
+
117
+ ### `@intl-party/no-missing-keys`
118
+
119
+ Catches references to translation keys that don't exist in your translation files.
120
+
121
+ #### ❌ Incorrect
122
+
123
+ ```jsx
124
+ function Component() {
125
+ const t = useTranslations("common");
126
+ return <h1>{t("nonExistentKey")}</h1>; // Key doesn't exist
127
+ }
128
+ ```
129
+
130
+ #### ✅ Correct
131
+
132
+ ```jsx
133
+ function Component() {
134
+ const t = useTranslations("common");
135
+ return <h1>{t("welcome")}</h1>; // Key exists in common namespace
136
+ }
137
+ ```
138
+
139
+ #### Configuration
140
+
141
+ ```javascript
142
+ {
143
+ "@intl-party/no-missing-keys": ["error", {
144
+ "translationsPath": "./messages",
145
+ "defaultLocale": "en",
146
+ "namespaces": ["common", "navigation"],
147
+ "checkDynamicKeys": false
148
+ }]
149
+ }
150
+ ```
151
+
152
+ ### `@intl-party/prefer-translation-hooks`
153
+
154
+ Encourages using translation hooks instead of direct i18n instance usage in React components.
155
+
156
+ #### ❌ Incorrect
157
+
158
+ ```jsx
159
+ function Component() {
160
+ const { i18n } = useI18nContext();
161
+ return <h1>{i18n.t("welcome")}</h1>; // Direct i18n usage
162
+ }
163
+ ```
164
+
165
+ #### ✅ Correct
166
+
167
+ ```jsx
168
+ function Component() {
169
+ const t = useTranslations("common");
170
+ return <h1>{t("welcome")}</h1>; // Using translation hook
171
+ }
172
+ ```
173
+
174
+ #### Configuration
175
+
176
+ ```javascript
177
+ {
178
+ "@intl-party/prefer-translation-hooks": ["warn", {
179
+ "allowedMethods": ["formatDate", "formatNumber"],
180
+ "ignoreServerComponents": true
181
+ }]
182
+ }
183
+ ```
184
+
185
+ ## Configuration Presets
186
+
187
+ ### Recommended Preset
188
+
189
+ ```javascript
190
+ // Balanced rules for most projects
191
+ {
192
+ "extends": ["@intl-party/recommended"]
193
+ }
194
+ ```
195
+
196
+ Includes:
197
+
198
+ - `@intl-party/no-hardcoded-strings`: `error`
199
+ - `@intl-party/no-missing-keys`: `error`
200
+ - `@intl-party/prefer-translation-hooks`: `warn`
201
+
202
+ ### Strict Preset
203
+
204
+ ```javascript
205
+ // Stricter rules for high-quality i18n
206
+ {
207
+ "extends": ["@intl-party/strict"]
208
+ }
209
+ ```
210
+
211
+ Includes all recommended rules plus:
212
+
213
+ - Stricter hardcoded string detection
214
+ - Enforcement of namespace consistency
215
+ - Required translation comments
216
+
217
+ ### TypeScript Preset
218
+
219
+ ```javascript
220
+ // Additional rules for TypeScript projects
221
+ {
222
+ "extends": [
223
+ "@intl-party/recommended",
224
+ "@intl-party/typescript"
225
+ ]
226
+ }
227
+ ```
228
+
229
+ Includes type-aware rules and TypeScript-specific checks.
230
+
231
+ ## Advanced Configuration
232
+
233
+ ### Project-Specific Settings
234
+
235
+ ```javascript
236
+ // .eslintrc.js
237
+ module.exports = {
238
+ plugins: ["@intl-party"],
239
+ settings: {
240
+ "intl-party": {
241
+ // Path to translation files
242
+ translationsPath: "./src/locales",
243
+
244
+ // Default locale for key validation
245
+ defaultLocale: "en",
246
+
247
+ // Available namespaces
248
+ namespaces: ["common", "navigation", "forms"],
249
+
250
+ // Translation file pattern
251
+ filePattern: "{locale}/{namespace}.json",
252
+
253
+ // Ignore patterns for hardcoded strings
254
+ ignorePatterns: [
255
+ "^[A-Z_]+$", // Constants
256
+ "^\\d+$", // Numbers
257
+ "^https?://", // URLs
258
+ "^mailto:", // Email links
259
+ "^tel:", // Phone links
260
+ ],
261
+
262
+ // Elements to ignore for hardcoded strings
263
+ ignoreElements: ["script", "style", "code", "pre"],
264
+
265
+ // Attributes to ignore
266
+ ignoreAttributes: ["className", "id", "data-*", "aria-*"],
267
+ },
268
+ },
269
+ rules: {
270
+ "@intl-party/no-hardcoded-strings": "error",
271
+ "@intl-party/no-missing-keys": "error",
272
+ "@intl-party/prefer-translation-hooks": "warn",
273
+ },
274
+ };
275
+ ```
276
+
277
+ ### Framework-Specific Configuration
278
+
279
+ #### Next.js
280
+
281
+ ```javascript
282
+ // .eslintrc.js
283
+ module.exports = {
284
+ extends: ["next/core-web-vitals", "@intl-party/recommended"],
285
+ settings: {
286
+ "intl-party": {
287
+ translationsPath: "./messages",
288
+ framework: "nextjs",
289
+ },
290
+ },
291
+ };
292
+ ```
293
+
294
+ #### React
295
+
296
+ ```javascript
297
+ // .eslintrc.js
298
+ module.exports = {
299
+ extends: ["react-app", "@intl-party/recommended"],
300
+ settings: {
301
+ "intl-party": {
302
+ translationsPath: "./src/translations",
303
+ framework: "react",
304
+ },
305
+ },
306
+ };
307
+ ```
308
+
309
+ ## Integration with Build Tools
310
+
311
+ ### CI/CD Integration
312
+
313
+ ```yaml
314
+ # .github/workflows/lint.yml
315
+ name: Lint
316
+ on: [push, pull_request]
317
+
318
+ jobs:
319
+ lint:
320
+ runs-on: ubuntu-latest
321
+ steps:
322
+ - uses: actions/checkout@v3
323
+ - uses: actions/setup-node@v3
324
+ - run: npm ci
325
+ - run: npm run lint
326
+ - run: npm run lint:i18n # Custom script for i18n-specific linting
327
+ ```
328
+
329
+ ### Pre-commit Hooks
330
+
331
+ ```json
332
+ // package.json
333
+ {
334
+ "husky": {
335
+ "hooks": {
336
+ "pre-commit": "lint-staged"
337
+ }
338
+ },
339
+ "lint-staged": {
340
+ "**/*.{js,jsx,ts,tsx}": [
341
+ "eslint --fix",
342
+ "eslint --ext .js,.jsx,.ts,.tsx --config .eslintrc.i18n.js"
343
+ ]
344
+ }
345
+ }
346
+ ```
347
+
348
+ ### Custom Scripts
349
+
350
+ ```json
351
+ // package.json
352
+ {
353
+ "scripts": {
354
+ "lint": "eslint src/",
355
+ "lint:i18n": "eslint src/ --config .eslintrc.i18n.js",
356
+ "lint:fix": "eslint src/ --fix"
357
+ }
358
+ }
359
+ ```
360
+
361
+ ## Troubleshooting
362
+
363
+ ### Common Issues
364
+
365
+ 1. **False positives for hardcoded strings**
366
+ - Add patterns to `ignorePatterns` in rule configuration
367
+ - Use `eslint-disable-next-line` comments for specific cases
368
+
369
+ 2. **Missing key errors for dynamic keys**
370
+ - Set `checkDynamicKeys: false` in rule configuration
371
+ - Use template strings for predictable patterns
372
+
373
+ 3. **Performance issues with large translation files**
374
+ - Use `translationsPath` setting to optimize file loading
375
+ - Consider splitting large translation files
376
+
377
+ ### Debug Mode
378
+
379
+ Enable debug logging to troubleshoot rule issues:
380
+
381
+ ```bash
382
+ DEBUG=eslint-plugin-intl-party eslint src/
383
+ ```
384
+
385
+ ### Custom Rule Configuration
386
+
387
+ ```javascript
388
+ // For specific files or patterns
389
+ {
390
+ "overrides": [
391
+ {
392
+ "files": ["**/*.test.{js,jsx,ts,tsx}"],
393
+ "rules": {
394
+ "@intl-party/no-hardcoded-strings": "off"
395
+ }
396
+ },
397
+ {
398
+ "files": ["**/admin/**"],
399
+ "rules": {
400
+ "@intl-party/no-missing-keys": "warn"
401
+ }
402
+ }
403
+ ]
404
+ }
405
+ ```
406
+
407
+ ## Examples
408
+
409
+ ### Real-world Configuration
410
+
411
+ ```javascript
412
+ // .eslintrc.js for a Next.js project
413
+ module.exports = {
414
+ extends: ["next/core-web-vitals", "@intl-party/recommended"],
415
+ settings: {
416
+ "intl-party": {
417
+ translationsPath: "./messages",
418
+ defaultLocale: "en",
419
+ namespaces: ["common", "navigation", "forms", "errors"],
420
+ ignorePatterns: [
421
+ "^[A-Z_]+$",
422
+ "^\\d+(\\.\\d+)?$",
423
+ "^#[0-9a-fA-F]{3,6}$",
424
+ "^rgb\\(",
425
+ "^https?://",
426
+ "^mailto:",
427
+ "^\\+\\d",
428
+ ],
429
+ },
430
+ },
431
+ overrides: [
432
+ {
433
+ files: ["**/*.test.{js,jsx,ts,tsx}", "**/*.stories.{js,jsx,ts,tsx}"],
434
+ rules: {
435
+ "@intl-party/no-hardcoded-strings": "off",
436
+ },
437
+ },
438
+ {
439
+ files: ["**/admin/**", "**/cms/**"],
440
+ rules: {
441
+ "@intl-party/no-hardcoded-strings": "warn",
442
+ },
443
+ },
444
+ ],
445
+ };
446
+ ```
447
+
448
+ ## License
449
+
450
+ MIT © IntlParty
package/dist/index.d.ts CHANGED
@@ -1,14 +1,32 @@
1
1
  import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
2
2
 
3
+ interface PreferTranslationHooksOptions {
4
+ allowDirectUsage?: boolean;
5
+ }
6
+
7
+ interface NoMissingKeysOptions {
8
+ translationFiles?: string[];
9
+ defaultLocale?: string;
10
+ configPath?: string;
11
+ cacheTimeout?: number;
12
+ }
13
+
14
+ interface NoHardcodedStringsOptions {
15
+ attributes?: string[];
16
+ ignorePattern?: string;
17
+ minLength?: number;
18
+ allowedStrings?: string[];
19
+ }
20
+
3
21
  declare const plugin: {
4
22
  meta: {
5
23
  name: string;
6
24
  version: string;
7
25
  };
8
26
  rules: {
9
- "no-hardcoded-strings": _typescript_eslint_utils_ts_eslint.RuleModule<"hardcodedString" | "hardcodedStringInAttribute", [], _typescript_eslint_utils_ts_eslint.RuleListener>;
10
- "no-missing-keys": _typescript_eslint_utils_ts_eslint.RuleModule<"missingTranslationKey" | "invalidTranslationKey", [], _typescript_eslint_utils_ts_eslint.RuleListener>;
11
- "prefer-translation-hooks": _typescript_eslint_utils_ts_eslint.RuleModule<"preferUseTranslations" | "preferScopedTranslations", [], _typescript_eslint_utils_ts_eslint.RuleListener>;
27
+ "no-hardcoded-strings": _typescript_eslint_utils_ts_eslint.RuleModule<"hardcodedString" | "hardcodedStringInAttribute", [NoHardcodedStringsOptions], _typescript_eslint_utils_ts_eslint.RuleListener>;
28
+ "no-missing-keys": _typescript_eslint_utils_ts_eslint.RuleModule<"missingTranslationKey" | "invalidTranslationKey", [NoMissingKeysOptions], _typescript_eslint_utils_ts_eslint.RuleListener>;
29
+ "prefer-translation-hooks": _typescript_eslint_utils_ts_eslint.RuleModule<"preferUseTranslations" | "preferScopedTranslations", [PreferTranslationHooksOptions], _typescript_eslint_utils_ts_eslint.RuleListener>;
12
30
  };
13
31
  configs: {
14
32
  recommended: {
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -27,14 +37,14 @@ module.exports = __toCommonJS(index_exports);
27
37
  // src/rules/no-hardcoded-strings.ts
28
38
  var import_utils = require("@typescript-eslint/utils");
29
39
  var noHardcodedStrings = import_utils.ESLintUtils.RuleCreator(
30
- (name) => `https://github.com/intl-party/intl-party/blob/main/packages/eslint-plugin/docs/rules/${name}.md`
40
+ (name) => `https://github.com/RodrigoEspinosa/intl-party/blob/main/packages/eslint-plugin/docs/rules/${name}.md`
31
41
  )({
32
42
  name: "no-hardcoded-strings",
33
43
  meta: {
34
44
  type: "problem",
35
45
  docs: {
36
46
  description: "Disallow hardcoded strings in JSX elements and specific attributes",
37
- recommended: "warn"
47
+ recommended: "recommended"
38
48
  },
39
49
  fixable: "code",
40
50
  schema: [
@@ -77,7 +87,7 @@ var noHardcodedStrings = import_utils.ESLintUtils.RuleCreator(
77
87
  }
78
88
  },
79
89
  defaultOptions: [{}],
80
- create(context, [options = {}]) {
90
+ create(context, [options]) {
81
91
  const {
82
92
  attributes = [
83
93
  "placeholder",
@@ -89,7 +99,7 @@ var noHardcodedStrings = import_utils.ESLintUtils.RuleCreator(
89
99
  ignorePattern,
90
100
  minLength = 3,
91
101
  allowedStrings = []
92
- } = options;
102
+ } = options || {};
93
103
  const ignoreRegex = ignorePattern ? new RegExp(ignorePattern) : null;
94
104
  function isHardcodedString(value) {
95
105
  if (value.length < minLength) return false;
@@ -117,17 +127,18 @@ var noHardcodedStrings = import_utils.ESLintUtils.RuleCreator(
117
127
  }
118
128
  function checkJSXAttribute(node) {
119
129
  if (node.name.type === "JSXIdentifier" && attributes.includes(node.name.name) && node.value?.type === "Literal" && typeof node.value.value === "string" && isHardcodedString(node.value.value)) {
130
+ const literalValue = node.value.value;
120
131
  context.report({
121
132
  node: node.value,
122
133
  messageId: "hardcodedStringInAttribute",
123
134
  data: {
124
- text: node.value.value,
135
+ text: literalValue,
125
136
  attribute: node.name.name
126
137
  },
127
138
  fix(fixer) {
128
139
  return fixer.replaceText(
129
140
  node.value,
130
- `{t('${generateTranslationKey(node.value.value)}')}`
141
+ `{t('${generateTranslationKey(literalValue)}')}`
131
142
  );
132
143
  }
133
144
  });
@@ -161,15 +172,304 @@ var noHardcodedStrings = import_utils.ESLintUtils.RuleCreator(
161
172
 
162
173
  // src/rules/no-missing-keys.ts
163
174
  var import_utils2 = require("@typescript-eslint/utils");
175
+
176
+ // src/utils/translation-utils.ts
177
+ var import_promises = __toESM(require("fs/promises"));
178
+ var import_node_path = __toESM(require("path"));
179
+ var translationCache = /* @__PURE__ */ new Map();
180
+ var TranslationUtils = class {
181
+ constructor(options = {}) {
182
+ this.options = {
183
+ defaultLocale: "en",
184
+ cacheTimeout: 5 * 60 * 1e3,
185
+ // 5 minutes
186
+ ...options
187
+ };
188
+ this.cacheTimeout = this.options.cacheTimeout;
189
+ }
190
+ /**
191
+ * Load translations from configuration or provided files
192
+ */
193
+ async loadTranslations() {
194
+ const cacheKey = this.getCacheKey();
195
+ const now = Date.now();
196
+ const cached = translationCache.get(cacheKey);
197
+ if (cached && now - cached.timestamp < this.cacheTimeout) {
198
+ return cached.translations;
199
+ }
200
+ let translations;
201
+ try {
202
+ translations = await this.loadFromConfig();
203
+ } catch {
204
+ translations = await this.loadFromFiles();
205
+ }
206
+ translationCache.set(cacheKey, {
207
+ translations,
208
+ timestamp: now,
209
+ locales: Object.keys(translations),
210
+ namespaces: this.extractNamespaces(translations)
211
+ });
212
+ return translations;
213
+ }
214
+ /**
215
+ * Get all available translation keys for a specific locale and namespace
216
+ */
217
+ async getTranslationKeys(locale, namespace) {
218
+ const translations = await this.loadTranslations();
219
+ const keys = /* @__PURE__ */ new Set();
220
+ if (namespace) {
221
+ const namespaceTranslations = translations[locale]?.[namespace] || {};
222
+ this.collectKeys(namespaceTranslations, "", keys);
223
+ } else {
224
+ const localeTranslations = translations[locale] || {};
225
+ for (const nsTranslations of Object.values(localeTranslations)) {
226
+ this.collectKeys(nsTranslations, "", keys);
227
+ }
228
+ }
229
+ return keys;
230
+ }
231
+ /**
232
+ * Check if a translation key exists
233
+ */
234
+ async hasTranslationKey(locale, key, namespace) {
235
+ const keys = await this.getTranslationKeys(locale, namespace);
236
+ return keys.has(key);
237
+ }
238
+ /**
239
+ * Get all available locales
240
+ */
241
+ async getLocales() {
242
+ const translations = await this.loadTranslations();
243
+ return Object.keys(translations);
244
+ }
245
+ /**
246
+ * Get all available namespaces for a locale
247
+ */
248
+ async getNamespaces(locale) {
249
+ const translations = await this.loadTranslations();
250
+ return Object.keys(translations[locale] || {});
251
+ }
252
+ /**
253
+ * Validate translation key format
254
+ */
255
+ isValidTranslationKey(key) {
256
+ return /^[a-zA-Z][a-zA-Z0-9._-]*$/.test(key);
257
+ }
258
+ /**
259
+ * Extract namespace from a translation key
260
+ */
261
+ extractNamespace(key) {
262
+ if (key.includes(".")) {
263
+ return key.split(".")[0];
264
+ }
265
+ return null;
266
+ }
267
+ /**
268
+ * Get the base key (without namespace) from a translation key
269
+ */
270
+ getBaseKey(key) {
271
+ if (key.includes(".")) {
272
+ return key.split(".").slice(1).join(".");
273
+ }
274
+ return key;
275
+ }
276
+ getCacheKey() {
277
+ return `${this.options.configPath || "default"}-${this.options.defaultLocale}`;
278
+ }
279
+ async loadFromConfig() {
280
+ const configFiles = [
281
+ this.options.configPath,
282
+ "intl-party.config.js",
283
+ "intl-party.config.ts",
284
+ "intl-party.config.json"
285
+ ].filter(Boolean);
286
+ for (const configFile of configFiles) {
287
+ if (configFile && await this.pathExists(configFile)) {
288
+ try {
289
+ let config;
290
+ if (configFile.endsWith(".json")) {
291
+ const content = await import_promises.default.readFile(configFile, "utf-8");
292
+ config = JSON.parse(content);
293
+ } else {
294
+ delete require.cache[import_node_path.default.resolve(configFile)];
295
+ config = require(import_node_path.default.resolve(configFile));
296
+ if (config.default) {
297
+ config = config.default;
298
+ }
299
+ }
300
+ return await this.loadFromConfigObject(config);
301
+ } catch (error) {
302
+ continue;
303
+ }
304
+ }
305
+ }
306
+ throw new Error("No valid configuration found");
307
+ }
308
+ async loadFromConfigObject(config) {
309
+ const {
310
+ locales = ["en"],
311
+ defaultLocale = "en",
312
+ messages = "./messages"
313
+ } = config;
314
+ const translations = {};
315
+ for (const locale of locales) {
316
+ translations[locale] = {};
317
+ const messagesPath = typeof messages === "string" ? messages : "./messages";
318
+ if (await this.pathExists(messagesPath)) {
319
+ const localePath = import_node_path.default.join(messagesPath, locale);
320
+ if (await this.pathExists(localePath)) {
321
+ const files = await import_promises.default.readdir(localePath);
322
+ for (const file of files) {
323
+ if (file.endsWith(".json")) {
324
+ const namespace = import_node_path.default.basename(file, ".json");
325
+ const filePath = import_node_path.default.join(localePath, file);
326
+ try {
327
+ const content = await this.readJson(filePath);
328
+ translations[locale][namespace] = content;
329
+ } catch {
330
+ translations[locale][namespace] = {};
331
+ }
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+ return translations;
338
+ }
339
+ async loadFromFiles() {
340
+ const { translationFiles = [], defaultLocale = "en" } = this.options;
341
+ if (translationFiles.length === 0) {
342
+ return await this.autoDetectTranslations();
343
+ }
344
+ const translations = {};
345
+ for (const filePath of translationFiles) {
346
+ try {
347
+ const content = await this.readJson(filePath);
348
+ const { locale, namespace } = this.parseFilePath(filePath);
349
+ if (!translations[locale]) {
350
+ translations[locale] = {};
351
+ }
352
+ translations[locale][namespace] = content;
353
+ } catch {
354
+ continue;
355
+ }
356
+ }
357
+ return translations;
358
+ }
359
+ async autoDetectTranslations() {
360
+ const translations = {};
361
+ const commonPaths = [
362
+ "messages",
363
+ "locales",
364
+ "i18n",
365
+ "public/locales",
366
+ "src/locales",
367
+ "src/translations"
368
+ ];
369
+ for (const basePath of commonPaths) {
370
+ if (await this.pathExists(basePath)) {
371
+ try {
372
+ const entries = await import_promises.default.readdir(basePath);
373
+ for (const entry of entries) {
374
+ const entryPath = import_node_path.default.join(basePath, entry);
375
+ const stat = await import_promises.default.stat(entryPath);
376
+ if (stat.isDirectory()) {
377
+ const locale = entry;
378
+ translations[locale] = {};
379
+ const files = await import_promises.default.readdir(entryPath);
380
+ for (const file of files) {
381
+ if (file.endsWith(".json")) {
382
+ const namespace = import_node_path.default.basename(file, ".json");
383
+ const filePath = import_node_path.default.join(entryPath, file);
384
+ try {
385
+ const content = await this.readJson(filePath);
386
+ translations[locale][namespace] = content;
387
+ } catch {
388
+ translations[locale][namespace] = {};
389
+ }
390
+ }
391
+ }
392
+ }
393
+ }
394
+ if (Object.keys(translations).length > 0) {
395
+ break;
396
+ }
397
+ } catch {
398
+ continue;
399
+ }
400
+ }
401
+ }
402
+ return translations;
403
+ }
404
+ parseFilePath(filePath) {
405
+ const parts = filePath.split(import_node_path.default.sep);
406
+ const fileName = parts[parts.length - 1];
407
+ const namespace = import_node_path.default.basename(fileName, ".json");
408
+ let locale = this.options.defaultLocale;
409
+ for (let i = parts.length - 2; i >= 0; i--) {
410
+ const part = parts[i];
411
+ if (/^[a-z]{2}(-[A-Z]{2})?$/.test(part)) {
412
+ locale = part;
413
+ break;
414
+ }
415
+ }
416
+ return { locale, namespace };
417
+ }
418
+ collectKeys(obj, prefix, keys) {
419
+ for (const [key, value] of Object.entries(obj)) {
420
+ const fullKey = prefix ? `${prefix}.${key}` : key;
421
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
422
+ this.collectKeys(value, fullKey, keys);
423
+ } else {
424
+ keys.add(fullKey);
425
+ }
426
+ }
427
+ }
428
+ extractNamespaces(translations) {
429
+ const namespaces = /* @__PURE__ */ new Set();
430
+ for (const localeTranslations of Object.values(translations)) {
431
+ for (const namespace of Object.keys(localeTranslations)) {
432
+ namespaces.add(namespace);
433
+ }
434
+ }
435
+ return Array.from(namespaces);
436
+ }
437
+ /**
438
+ * Clear the translation cache
439
+ */
440
+ clearCache() {
441
+ translationCache.clear();
442
+ }
443
+ /**
444
+ * Check if a path exists (replacement for fs-extra's pathExists)
445
+ */
446
+ async pathExists(filePath) {
447
+ try {
448
+ await import_promises.default.access(filePath);
449
+ return true;
450
+ } catch {
451
+ return false;
452
+ }
453
+ }
454
+ /**
455
+ * Read and parse JSON file (replacement for fs-extra's readJson)
456
+ */
457
+ async readJson(filePath) {
458
+ const content = await import_promises.default.readFile(filePath, "utf-8");
459
+ return JSON.parse(content);
460
+ }
461
+ };
462
+
463
+ // src/rules/no-missing-keys.ts
164
464
  var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
165
- (name) => `https://github.com/intl-party/intl-party/blob/main/packages/eslint-plugin/docs/rules/${name}.md`
465
+ (name) => `https://github.com/RodrigoEspinosa/intl-party/blob/main/packages/eslint-plugin/docs/rules/${name}.md`
166
466
  )({
167
467
  name: "no-missing-keys",
168
468
  meta: {
169
469
  type: "problem",
170
470
  docs: {
171
471
  description: "Ensure all translation keys exist in translation files",
172
- recommended: "error"
472
+ recommended: "recommended"
173
473
  },
174
474
  schema: [
175
475
  {
@@ -184,6 +484,15 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
184
484
  type: "string",
185
485
  default: "en",
186
486
  description: "Default locale to check keys against"
487
+ },
488
+ configPath: {
489
+ type: "string",
490
+ description: "Path to intl-party configuration file"
491
+ },
492
+ cacheTimeout: {
493
+ type: "number",
494
+ default: 3e5,
495
+ description: "Cache timeout in milliseconds (default: 5 minutes)"
187
496
  }
188
497
  },
189
498
  additionalProperties: false
@@ -195,13 +504,24 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
195
504
  }
196
505
  },
197
506
  defaultOptions: [{}],
198
- create(context, [options = {}]) {
199
- const { translationFiles = [], defaultLocale = "en" } = options;
200
- const translationKeys = /* @__PURE__ */ new Set();
507
+ create(context, [options]) {
508
+ const {
509
+ translationFiles = [],
510
+ defaultLocale = "en",
511
+ configPath,
512
+ cacheTimeout = 3e5
513
+ } = options || {};
514
+ const translationUtils = new TranslationUtils({
515
+ translationFiles,
516
+ defaultLocale,
517
+ configPath,
518
+ cacheTimeout
519
+ });
520
+ const fileTranslationCache = /* @__PURE__ */ new Map();
201
521
  function isValidTranslationKey(key) {
202
- return /^[a-zA-Z][a-zA-Z0-9._-]*$/.test(key);
522
+ return translationUtils.isValidTranslationKey(key);
203
523
  }
204
- function checkTranslationCall(node) {
524
+ async function checkTranslationCall(node) {
205
525
  if (node.callee.type === "Identifier" && node.callee.name === "t" && node.arguments.length > 0 && node.arguments[0].type === "Literal" && typeof node.arguments[0].value === "string") {
206
526
  const key = node.arguments[0].value;
207
527
  if (!isValidTranslationKey(key)) {
@@ -212,16 +532,69 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
212
532
  });
213
533
  return;
214
534
  }
535
+ try {
536
+ const namespace = translationUtils.extractNamespace(key);
537
+ const baseKey = translationUtils.getBaseKey(key);
538
+ const hasKey = await translationUtils.hasTranslationKey(
539
+ defaultLocale,
540
+ namespace ? baseKey : key,
541
+ namespace || void 0
542
+ );
543
+ if (!hasKey) {
544
+ context.report({
545
+ node: node.arguments[0],
546
+ messageId: "missingTranslationKey",
547
+ data: { key }
548
+ });
549
+ }
550
+ } catch (error) {
551
+ }
215
552
  }
216
553
  }
217
554
  function checkUseTranslationsCall(node) {
218
- if (node.callee.type === "Identifier" && node.callee.name === "useTranslations") {
555
+ if (node.callee.type === "Identifier" && node.callee.name === "useTranslations" && node.arguments.length > 0 && node.arguments[0].type === "Literal" && typeof node.arguments[0].value === "string") {
556
+ const namespace = node.arguments[0].value;
557
+ translationUtils.getNamespaces(defaultLocale).then((namespaces) => {
558
+ if (!namespaces.includes(namespace)) {
559
+ context.report({
560
+ node: node.arguments[0],
561
+ messageId: "missingTranslationKey",
562
+ data: { key: namespace }
563
+ });
564
+ }
565
+ }).catch(() => {
566
+ });
567
+ }
568
+ }
569
+ function checkTemplateLiteral(node) {
570
+ for (const quasi of node.quasis) {
571
+ const text = quasi.value.raw;
572
+ const keyMatches = text.match(/[a-zA-Z][a-zA-Z0-9._-]*/g);
573
+ if (keyMatches) {
574
+ for (const potentialKey of keyMatches) {
575
+ if (isValidTranslationKey(potentialKey)) {
576
+ translationUtils.hasTranslationKey(defaultLocale, potentialKey).then((hasKey) => {
577
+ if (!hasKey) {
578
+ context.report({
579
+ node: quasi,
580
+ messageId: "missingTranslationKey",
581
+ data: { key: potentialKey }
582
+ });
583
+ }
584
+ }).catch(() => {
585
+ });
586
+ }
587
+ }
588
+ }
219
589
  }
220
590
  }
221
591
  return {
222
592
  CallExpression(node) {
223
593
  checkTranslationCall(node);
224
594
  checkUseTranslationsCall(node);
595
+ },
596
+ TemplateLiteral(node) {
597
+ checkTemplateLiteral(node);
225
598
  }
226
599
  };
227
600
  }
@@ -230,14 +603,14 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
230
603
  // src/rules/prefer-translation-hooks.ts
231
604
  var import_utils3 = require("@typescript-eslint/utils");
232
605
  var preferTranslationHooks = import_utils3.ESLintUtils.RuleCreator(
233
- (name) => `https://github.com/intl-party/intl-party/blob/main/packages/eslint-plugin/docs/rules/${name}.md`
606
+ (name) => `https://github.com/RodrigoEspinosa/intl-party/blob/main/packages/eslint-plugin/docs/rules/${name}.md`
234
607
  )({
235
608
  name: "prefer-translation-hooks",
236
609
  meta: {
237
610
  type: "suggestion",
238
611
  docs: {
239
612
  description: "Prefer using translation hooks over direct i18n instance usage in React components",
240
- recommended: "warn"
613
+ recommended: "recommended"
241
614
  },
242
615
  fixable: "code",
243
616
  schema: [
@@ -259,8 +632,8 @@ var preferTranslationHooks = import_utils3.ESLintUtils.RuleCreator(
259
632
  }
260
633
  },
261
634
  defaultOptions: [{}],
262
- create(context, [options = {}]) {
263
- const { allowDirectUsage = false } = options;
635
+ create(context, [options]) {
636
+ const { allowDirectUsage = false } = options || {};
264
637
  function checkMemberExpression(node) {
265
638
  if (node.object.type === "Identifier" && node.object.name === "i18n" && node.property.type === "Identifier" && node.property.name === "t" && !allowDirectUsage) {
266
639
  context.report({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intl-party/eslint-plugin",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "ESLint plugin for IntlParty - detect hardcoded strings and enforce i18n best practices",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,10 +15,15 @@
15
15
  "typescript",
16
16
  "hardcoded-strings"
17
17
  ],
18
- "author": "IntlParty Team",
18
+ "author": "RodrigoEspinosa",
19
19
  "license": "MIT",
20
+ "homepage": "https://github.com/RodrigoEspinosa/intl-party#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/RodrigoEspinosa/intl-party/issues"
23
+ },
20
24
  "dependencies": {
21
- "@typescript-eslint/utils": "^6.15.0"
25
+ "@typescript-eslint/utils": "^6.15.0",
26
+ "fs-extra": "^11.2.0"
22
27
  },
23
28
  "devDependencies": {
24
29
  "@types/eslint": "^8.56.0",
@@ -37,14 +42,17 @@
37
42
  },
38
43
  "repository": {
39
44
  "type": "git",
40
- "url": "https://github.com/intl-party/intl-party.git",
45
+ "url": "https://github.com/RodrigoEspinosa/intl-party.git",
41
46
  "directory": "packages/eslint-plugin"
42
47
  },
48
+ "engines": {
49
+ "node": ">=18.0.0"
50
+ },
43
51
  "scripts": {
44
52
  "build": "tsup src/index.ts --format cjs --dts",
45
53
  "dev": "tsup src/index.ts --format cjs --dts --watch",
46
- "test": "vitest",
47
- "test:watch": "vitest --watch",
54
+ "test": "vitest --run",
55
+ "test:watch": "vitest",
48
56
  "lint": "eslint src --ext .ts",
49
57
  "typecheck": "tsc --noEmit",
50
58
  "clean": "rm -rf dist"