@intl-party/eslint-plugin 1.0.2 → 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/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@ interface PreferTranslationHooksOptions {
7
7
  interface NoMissingKeysOptions {
8
8
  translationFiles?: string[];
9
9
  defaultLocale?: string;
10
+ configPath?: string;
11
+ cacheTimeout?: number;
10
12
  }
11
13
 
12
14
  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,15 @@ 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"
491
+ },
492
+ cacheTimeout: {
493
+ type: "number",
494
+ default: 3e5,
495
+ description: "Cache timeout in milliseconds (default: 5 minutes)"
188
496
  }
189
497
  },
190
498
  additionalProperties: false
@@ -197,12 +505,23 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
197
505
  },
198
506
  defaultOptions: [{}],
199
507
  create(context, [options]) {
200
- const { translationFiles = [], defaultLocale = "en" } = options || {};
201
- const translationKeys = /* @__PURE__ */ new Set();
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();
202
521
  function isValidTranslationKey(key) {
203
- return /^[a-zA-Z][a-zA-Z0-9._-]*$/.test(key);
522
+ return translationUtils.isValidTranslationKey(key);
204
523
  }
205
- function checkTranslationCall(node) {
524
+ async function checkTranslationCall(node) {
206
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") {
207
526
  const key = node.arguments[0].value;
208
527
  if (!isValidTranslationKey(key)) {
@@ -213,16 +532,69 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
213
532
  });
214
533
  return;
215
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
+ }
216
552
  }
217
553
  }
218
554
  function checkUseTranslationsCall(node) {
219
- 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
+ }
220
589
  }
221
590
  }
222
591
  return {
223
592
  CallExpression(node) {
224
593
  checkTranslationCall(node);
225
594
  checkUseTranslationsCall(node);
595
+ },
596
+ TemplateLiteral(node) {
597
+ checkTemplateLiteral(node);
226
598
  }
227
599
  };
228
600
  }
@@ -231,7 +603,7 @@ var noMissingKeys = import_utils2.ESLintUtils.RuleCreator(
231
603
  // src/rules/prefer-translation-hooks.ts
232
604
  var import_utils3 = require("@typescript-eslint/utils");
233
605
  var preferTranslationHooks = import_utils3.ESLintUtils.RuleCreator(
234
- (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`
235
607
  )({
236
608
  name: "prefer-translation-hooks",
237
609
  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.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,9 +42,12 @@
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",