@intl-party/eslint-plugin 1.0.2 → 1.2.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/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ interface PreferTranslationHooksOptions {
7
7
  interface NoMissingKeysOptions {
8
8
  translationFiles?: string[];
9
9
  defaultLocale?: string;
10
+ configPath?: string;
10
11
  }
11
12
 
12
13
  interface NoHardcodedStringsOptions {
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,7 +37,7 @@ 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: {
@@ -162,8 +172,297 @@ var noHardcodedStrings = import_utils.ESLintUtils.RuleCreator(
162
172
 
163
173
  // src/rules/no-missing-keys.ts
164
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
165
464
  var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
166
- (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`
167
466
  )({
168
467
  name: "no-missing-keys",
169
468
  meta: {
@@ -185,6 +484,10 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
185
484
  type: "string",
186
485
  default: "en",
187
486
  description: "Default locale to check keys against"
487
+ },
488
+ configPath: {
489
+ type: "string",
490
+ description: "Path to intl-party configuration file"
188
491
  }
189
492
  },
190
493
  additionalProperties: false
@@ -197,12 +500,20 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
197
500
  },
198
501
  defaultOptions: [{}],
199
502
  create(context, [options]) {
200
- const { translationFiles = [], defaultLocale = "en" } = options || {};
201
- const translationKeys = /* @__PURE__ */ new Set();
503
+ const {
504
+ translationFiles = [],
505
+ defaultLocale = "en",
506
+ configPath
507
+ } = options || {};
508
+ const translationUtils = new TranslationUtils({
509
+ translationFiles,
510
+ defaultLocale,
511
+ configPath
512
+ });
202
513
  function isValidTranslationKey(key) {
203
- return /^[a-zA-Z][a-zA-Z0-9._-]*$/.test(key);
514
+ return translationUtils.isValidTranslationKey(key);
204
515
  }
205
- function checkTranslationCall(node) {
516
+ async function checkTranslationCall(node) {
206
517
  if (node.callee.type === "Identifier" && node.callee.name === "t" && node.arguments.length > 0 && node.arguments[0].type === "Literal" && typeof node.arguments[0].value === "string") {
207
518
  const key = node.arguments[0].value;
208
519
  if (!isValidTranslationKey(key)) {
@@ -213,16 +524,69 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
213
524
  });
214
525
  return;
215
526
  }
527
+ try {
528
+ const namespace = translationUtils.extractNamespace(key);
529
+ const baseKey = translationUtils.getBaseKey(key);
530
+ const hasKey = await translationUtils.hasTranslationKey(
531
+ defaultLocale,
532
+ namespace ? baseKey : key,
533
+ namespace || void 0
534
+ );
535
+ if (!hasKey) {
536
+ context.report({
537
+ node: node.arguments[0],
538
+ messageId: "missingTranslationKey",
539
+ data: { key }
540
+ });
541
+ }
542
+ } catch (error) {
543
+ }
216
544
  }
217
545
  }
218
546
  function checkUseTranslationsCall(node) {
219
- if (node.callee.type === "Identifier" && node.callee.name === "useTranslations") {
547
+ if (node.callee.type === "Identifier" && node.callee.name === "useTranslations" && node.arguments.length > 0 && node.arguments[0].type === "Literal" && typeof node.arguments[0].value === "string") {
548
+ const namespace = node.arguments[0].value;
549
+ translationUtils.getNamespaces(defaultLocale).then((namespaces) => {
550
+ if (!namespaces.includes(namespace)) {
551
+ context.report({
552
+ node: node.arguments[0],
553
+ messageId: "missingTranslationKey",
554
+ data: { key: namespace }
555
+ });
556
+ }
557
+ }).catch(() => {
558
+ });
559
+ }
560
+ }
561
+ function checkTemplateLiteral(node) {
562
+ for (const quasi of node.quasis) {
563
+ const text = quasi.value.raw;
564
+ const keyMatches = text.match(/[a-zA-Z][a-zA-Z0-9._-]*/g);
565
+ if (keyMatches) {
566
+ for (const potentialKey of keyMatches) {
567
+ if (isValidTranslationKey(potentialKey)) {
568
+ translationUtils.hasTranslationKey(defaultLocale, potentialKey).then((hasKey) => {
569
+ if (!hasKey) {
570
+ context.report({
571
+ node: quasi,
572
+ messageId: "missingTranslationKey",
573
+ data: { key: potentialKey }
574
+ });
575
+ }
576
+ }).catch(() => {
577
+ });
578
+ }
579
+ }
580
+ }
220
581
  }
221
582
  }
222
583
  return {
223
584
  CallExpression(node) {
224
585
  checkTranslationCall(node);
225
586
  checkUseTranslationsCall(node);
587
+ },
588
+ TemplateLiteral(node) {
589
+ checkTemplateLiteral(node);
226
590
  }
227
591
  };
228
592
  }
@@ -231,7 +595,7 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
231
595
  // src/rules/prefer-translation-hooks.ts
232
596
  var import_utils3 = require("@typescript-eslint/utils");
233
597
  var preferTranslationHooks = import_utils3.ESLintUtils.RuleCreator(
234
- (name) => `https://github.com/intl-party/intl-party/blob/main/packages/eslint-plugin/docs/rules/${name}.md`
598
+ (name) => `https://github.com/RodrigoEspinosa/intl-party/blob/main/packages/eslint-plugin/docs/rules/${name}.md`
235
599
  )({
236
600
  name: "prefer-translation-hooks",
237
601
  meta: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intl-party/eslint-plugin",
3
- "version": "1.0.2",
3
+ "version": "1.2.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.3.4"
22
27
  },
23
28
  "devDependencies": {
24
29
  "@types/eslint": "^8.56.0",
@@ -28,18 +33,21 @@
28
33
  "@typescript-eslint/rule-tester": "^6.15.0",
29
34
  "eslint": "^8.55.0",
30
35
  "jsdom": "^23.0.1",
31
- "tsup": "^8.0.1",
36
+ "tsup": "^8.5.1",
32
37
  "typescript": "^5.3.0",
33
- "vitest": "^1.0.0"
38
+ "vitest": "^3.2.4"
34
39
  },
35
40
  "peerDependencies": {
36
41
  "eslint": ">=8.0.0"
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",