i18next-cli 1.17.1 → 1.18.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +34 -3
  3. package/dist/cjs/cli.js +1 -1
  4. package/dist/cjs/extractor/core/key-finder.js +1 -1
  5. package/dist/cjs/extractor/core/translation-manager.js +1 -1
  6. package/dist/cjs/extractor/parsers/call-expression-handler.js +1 -1
  7. package/dist/cjs/extractor/parsers/comment-parser.js +1 -1
  8. package/dist/cjs/extractor/parsers/jsx-handler.js +1 -1
  9. package/dist/cjs/linter.js +1 -1
  10. package/dist/cjs/status.js +1 -1
  11. package/dist/esm/cli.js +1 -1
  12. package/dist/esm/extractor/core/key-finder.js +1 -1
  13. package/dist/esm/extractor/core/translation-manager.js +1 -1
  14. package/dist/esm/extractor/parsers/call-expression-handler.js +1 -1
  15. package/dist/esm/extractor/parsers/comment-parser.js +1 -1
  16. package/dist/esm/extractor/parsers/jsx-handler.js +1 -1
  17. package/dist/esm/linter.js +1 -1
  18. package/dist/esm/status.js +1 -1
  19. package/package.json +1 -1
  20. package/src/cli.ts +3 -3
  21. package/src/extractor/core/key-finder.ts +11 -0
  22. package/src/extractor/core/translation-manager.ts +94 -7
  23. package/src/extractor/parsers/call-expression-handler.ts +110 -0
  24. package/src/extractor/parsers/comment-parser.ts +12 -0
  25. package/src/extractor/parsers/jsx-handler.ts +18 -0
  26. package/src/linter.ts +93 -34
  27. package/src/status.ts +18 -10
  28. package/src/types.ts +3 -0
  29. package/types/extractor/core/key-finder.d.ts.map +1 -1
  30. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  31. package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
  32. package/types/extractor/parsers/jsx-handler.d.ts.map +1 -1
  33. package/types/linter.d.ts +46 -2
  34. package/types/linter.d.ts.map +1 -1
  35. package/types/types.d.ts +2 -0
  36. package/types/types.d.ts.map +1 -1
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@ import { runTypesGenerator } from './types-generator'
12
12
  import { runSyncer } from './syncer'
13
13
  import { runMigrator } from './migrator'
14
14
  import { runInit } from './init'
15
- import { runLinter } from './linter'
15
+ import { runLinterCli } from './linter'
16
16
  import { runStatus } from './status'
17
17
  import { runLocizeSync, runLocizeDownload, runLocizeMigrate } from './locize'
18
18
  import type { I18nextToolkitConfig } from './types'
@@ -22,7 +22,7 @@ const program = new Command()
22
22
  program
23
23
  .name('i18next-cli')
24
24
  .description('A unified, high-performance i18next CLI.')
25
- .version('1.17.1')
25
+ .version('1.18.0')
26
26
 
27
27
  // new: global config override option
28
28
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)')
@@ -174,7 +174,7 @@ program
174
174
  console.log(chalk.green('Project structure detected successfully!'))
175
175
  config = detected as I18nextToolkitConfig
176
176
  }
177
- await runLinter(config)
177
+ await runLinterCli(config)
178
178
  }
179
179
 
180
180
  // Run the linter once initially
@@ -100,6 +100,17 @@ export async function findKeys (
100
100
  await plugin.onEnd?.(allKeys)
101
101
  }
102
102
 
103
+ // Normalization: mark already-expanded plural keys
104
+ const pluralSeparator = otherConfig.extract?.pluralSeparator ?? '_'
105
+ const categories = ['zero', 'one', 'two', 'few', 'many', 'other']
106
+ for (const ek of allKeys.values()) {
107
+ const parts = String(ek.key).split(pluralSeparator)
108
+ const last = parts[parts.length - 1]
109
+ const isOrdinal = parts.length >= 3 && parts[parts.length - 2] === 'ordinal'
110
+ if (categories.includes(last) || (isOrdinal && categories.includes(last))) {
111
+ ;(ek as any).isExpandedPlural = true
112
+ }
113
+ }
103
114
  return { allKeys, objectKeys: astVisitors.objectKeys }
104
115
  }
105
116
 
@@ -123,18 +123,26 @@ function buildNewTranslationsForNs (
123
123
 
124
124
  // Get the plural categories for the target language
125
125
  const targetLanguagePluralCategories = new Set<string>()
126
+ // Track cardinal plural categories separately so we can special-case single-"other" languages
127
+ let cardinalCategories: string[] = []
128
+ let ordinalCategories: string[] = []
126
129
  try {
127
130
  const cardinalRules = new Intl.PluralRules(locale, { type: 'cardinal' })
128
131
  const ordinalRules = new Intl.PluralRules(locale, { type: 'ordinal' })
129
132
 
130
- cardinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(cat))
133
+ cardinalCategories = cardinalRules.resolvedOptions().pluralCategories
134
+ ordinalCategories = ordinalRules.resolvedOptions().pluralCategories
135
+ cardinalCategories.forEach(cat => targetLanguagePluralCategories.add(cat))
131
136
  ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`))
132
137
  } catch (e) {
133
- // Fallback to English if locale is invalid
134
- const cardinalRules = new Intl.PluralRules(primaryLanguage || 'en', { type: 'cardinal' })
135
- const ordinalRules = new Intl.PluralRules(primaryLanguage || 'en', { type: 'ordinal' })
136
-
137
- cardinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(cat))
138
+ // Fallback to primaryLanguage (or English) if locale is invalid
139
+ const fallbackLang = primaryLanguage || 'en'
140
+ const cardinalRules = new Intl.PluralRules(fallbackLang, { type: 'cardinal' })
141
+ const ordinalRules = new Intl.PluralRules(fallbackLang, { type: 'ordinal' })
142
+
143
+ cardinalCategories = cardinalRules.resolvedOptions().pluralCategories
144
+ ordinalCategories = ordinalRules.resolvedOptions().pluralCategories
145
+ cardinalCategories.forEach(cat => targetLanguagePluralCategories.add(cat))
138
146
  ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`))
139
147
  }
140
148
 
@@ -153,6 +161,22 @@ function buildNewTranslationsForNs (
153
161
  // For plural keys, check if this specific plural form is needed for the target language
154
162
  const keyParts = key.split(pluralSeparator)
155
163
 
164
+ // If this is a base plural key (no plural suffix), keep it so that the
165
+ // builder can expand it to the target locale's plural forms.
166
+ if (hasCount && keyParts.length === 1) {
167
+ return true
168
+ }
169
+
170
+ // Special-case single-cardinal-"other" languages (ja/zh/ko etc.):
171
+ // when the target language's cardinal categories are exactly ['other'],
172
+ // the extractor may have emitted the base key (no "_other" suffix).
173
+ // Accept the base key in that situation, while still accepting explicit *_other variants.
174
+ if (cardinalCategories.length === 1 && cardinalCategories[0] === 'other') {
175
+ // If this is a plain/base key (no plural suffix), include it.
176
+ if (keyParts.length === 1) return true
177
+ // Otherwise fall through and check the explicit suffix as before.
178
+ }
179
+
156
180
  if (isOrdinal && keyParts.includes('ordinal')) {
157
181
  // For ordinal plurals: key_context_ordinal_category or key_ordinal_category
158
182
  const lastPart = keyParts[keyParts.length - 1]
@@ -166,6 +190,24 @@ function buildNewTranslationsForNs (
166
190
  return true
167
191
  })
168
192
 
193
+ // NEW: detect bases that already have expanded plural variants extracted.
194
+ // If a base has explicit expanded variants (e.g. key_one, key_other or key_ordinal_one),
195
+ // we should avoid generating/expanding the base plural key for that base to prevent
196
+ // double-generation / duplicate counting.
197
+ const expandedBases = new Set<string>()
198
+ for (const ek of filteredKeys) {
199
+ if (ek.isExpandedPlural) {
200
+ const parts = String(ek.key).split(pluralSeparator)
201
+ // If ordinal form like "key_ordinal_one" -> base should strip "_ordinal_<cat>"
202
+ if (parts.length >= 3 && parts[parts.length - 2] === 'ordinal') {
203
+ expandedBases.add(parts.slice(0, -2).join(pluralSeparator))
204
+ } else {
205
+ // strip single trailing category
206
+ expandedBases.add(parts.slice(0, -1).join(pluralSeparator))
207
+ }
208
+ }
209
+ }
210
+
169
211
  // If `removeUnusedKeys` is true, start with an empty object. Otherwise, start with a clone of the existing translations.
170
212
  let newTranslations: Record<string, any> = removeUnusedKeys
171
213
  ? {}
@@ -208,7 +250,52 @@ function buildNewTranslationsForNs (
208
250
  }
209
251
 
210
252
  // 1. Build the object first, without any sorting.
211
- for (const { key, defaultValue, explicitDefault } of filteredKeys) {
253
+ for (const { key, defaultValue, explicitDefault, hasCount, isExpandedPlural, isOrdinal } of filteredKeys) {
254
+ // If this is a base plural key (hasCount true but not an already-expanded variant)
255
+ // and we detected explicit expanded variants for this base, skip expanding the base.
256
+ if (hasCount && !isExpandedPlural) {
257
+ const parts = String(key).split(pluralSeparator)
258
+ let base = key
259
+ if (parts.length >= 3 && parts[parts.length - 2] === 'ordinal') {
260
+ base = parts.slice(0, -2).join(pluralSeparator)
261
+ } else if (parts.length >= 2) {
262
+ base = parts.slice(0, -1).join(pluralSeparator)
263
+ }
264
+ if (expandedBases.has(base)) {
265
+ // Skip generating/expanding this base key because explicit expanded forms exist.
266
+ continue
267
+ }
268
+ }
269
+
270
+ // If this is a base plural key (no explicit suffix) and the locale is NOT the primary,
271
+ // expand it into locale-specific plural variants (e.g. key_one, key_other).
272
+ // Use the extracted defaultValue (fallback to base) for variant values.
273
+ if (hasCount && !isExpandedPlural) {
274
+ const parts = String(key).split(pluralSeparator)
275
+ const isBaseKey = parts.length === 1
276
+ if (isBaseKey && locale !== primaryLanguage) {
277
+ // If explicit expanded variants exist, do not expand the base.
278
+ const base = key
279
+ if (expandedBases.has(base)) {
280
+ // Skip expansion when explicit variants were provided
281
+ } else {
282
+ // choose categories based on ordinal flag
283
+ const categories = isOrdinal ? ordinalCategories : cardinalCategories
284
+ for (const category of categories) {
285
+ const finalKey = isOrdinal
286
+ ? `${base}${pluralSeparator}ordinal${pluralSeparator}${category}`
287
+ : `${base}${pluralSeparator}${category}`
288
+
289
+ // For secondary locales, prefer the extracted defaultValue (fallback to base) as test expects.
290
+ const valueToSet = (typeof defaultValue === 'string' && defaultValue !== undefined) ? defaultValue : base
291
+ setNestedValue(newTranslations, finalKey, valueToSet, keySeparator ?? '.')
292
+ }
293
+ }
294
+ // We've expanded variants for this base key; skip the normal single-key handling.
295
+ continue
296
+ }
297
+ }
298
+
212
299
  const existingValue = getNestedValue(existingTranslations, key, keySeparator ?? '.')
213
300
  const isLeafInNewKeys = !filteredKeys.some(otherKey => otherKey.key.startsWith(`${key}${keySeparator}`) && otherKey.key !== key)
214
301
 
@@ -261,6 +261,67 @@ export class CallExpressionHandler {
261
261
  return false
262
262
  })()
263
263
  if (hasCount || isOrdinalByKey) {
264
+ // QUICK PATH: If ALL target locales only have the "other" category,
265
+ // emit base/context keys directly (avoid generating *_other). This
266
+ // mirrors the special-case in handlePluralKeys but is placed here as a
267
+ // defensive guard to ensure keys are always emitted.
268
+ try {
269
+ const typeForCheck = isOrdinalByKey ? 'ordinal' : 'cardinal'
270
+ // Prefer the configured primaryLanguage as the deciding signal for
271
+ // "single-other" languages (ja/zh/ko). Fall back to union of locales.
272
+ const primaryLang = this.config.extract?.primaryLanguage || (Array.isArray(this.config.locales) ? this.config.locales[0] : undefined) || 'en'
273
+ let isSingleOther = false
274
+ try {
275
+ const primaryCategories = new Intl.PluralRules(primaryLang, { type: typeForCheck }).resolvedOptions().pluralCategories
276
+ if (primaryCategories.length === 1 && primaryCategories[0] === 'other') {
277
+ isSingleOther = true
278
+ }
279
+ } catch {
280
+ // ignore and fall back to union-of-locales check below
281
+ }
282
+
283
+ if (!isSingleOther) {
284
+ const allPluralCategoriesCheck = new Set<string>()
285
+ for (const locale of this.config.locales) {
286
+ try {
287
+ const rules = new Intl.PluralRules(locale, { type: typeForCheck })
288
+ rules.resolvedOptions().pluralCategories.forEach(c => allPluralCategoriesCheck.add(c))
289
+ } catch {
290
+ new Intl.PluralRules('en', { type: typeForCheck }).resolvedOptions().pluralCategories.forEach(c => allPluralCategoriesCheck.add(c))
291
+ }
292
+ }
293
+ const pluralCategoriesCheck = Array.from(allPluralCategoriesCheck).sort()
294
+ if (pluralCategoriesCheck.length === 1 && pluralCategoriesCheck[0] === 'other') {
295
+ isSingleOther = true
296
+ }
297
+ }
298
+
299
+ if (isSingleOther) {
300
+ // Emit only base/context keys (no _other) and skip the heavy plural path.
301
+ if (keysWithContext.length > 0) {
302
+ for (const k of keysWithContext) {
303
+ this.pluginContext.addKey({
304
+ key: k.key,
305
+ ns: k.ns,
306
+ defaultValue: k.defaultValue,
307
+ hasCount: true,
308
+ isOrdinal: isOrdinalByKey
309
+ })
310
+ }
311
+ } else {
312
+ this.pluginContext.addKey({
313
+ key: finalKey,
314
+ ns,
315
+ defaultValue: dv,
316
+ hasCount: true,
317
+ isOrdinal: isOrdinalByKey
318
+ })
319
+ }
320
+ continue
321
+ }
322
+ } catch (e) {
323
+ // Ignore Intl failures here and fall through to normal logic
324
+ }
264
325
  // Check if plurals are disabled
265
326
  if (this.config.extract.disablePlurals) {
266
327
  // When plurals are disabled, treat count as a regular option (for interpolation only)
@@ -480,6 +541,55 @@ export class CallExpressionHandler {
480
541
  keysToGenerate.push({ key })
481
542
  }
482
543
 
544
+ // If the only plural category across configured locales is "other",
545
+ // prefer the base key (no "_other" suffix) as it's more natural for languages
546
+ // with no grammatical plural forms (ja/zh/ko).
547
+ // Prefer the configured primaryLanguage as signal for single-"other" languages.
548
+ // If primaryLanguage indicates single-"other", treat as that case; otherwise
549
+ // fall back to earlier union-of-locales check that produced `pluralCategories`.
550
+ const primaryLang = this.config.extract?.primaryLanguage || (Array.isArray(this.config.locales) ? this.config.locales[0] : undefined) || 'en'
551
+ let primaryIsSingleOther = false
552
+ try {
553
+ const primaryCats = new Intl.PluralRules(primaryLang, { type }).resolvedOptions().pluralCategories
554
+ if (primaryCats.length === 1 && primaryCats[0] === 'other') primaryIsSingleOther = true
555
+ } catch {
556
+ primaryIsSingleOther = false
557
+ }
558
+
559
+ if (primaryIsSingleOther || (pluralCategories.length === 1 && pluralCategories[0] === 'other')) {
560
+ for (const { key: baseKey, context } of keysToGenerate) {
561
+ const specificOther = getObjectPropValue(options, `defaultValue${pluralSeparator}other`)
562
+ // Final default resolution:
563
+ // 1) plural-specific defaultValue_other
564
+ // 2) general defaultValue (from options)
565
+ // 3) defaultValueFromCall (string arg)
566
+ // 4) fallback to key (or context-key for context variants)
567
+ let finalDefaultValue: string | undefined
568
+ if (typeof specificOther === 'string') {
569
+ finalDefaultValue = specificOther
570
+ } else if (typeof defaultValue === 'string') {
571
+ finalDefaultValue = defaultValue
572
+ } else if (typeof defaultValueFromCall === 'string') {
573
+ finalDefaultValue = defaultValueFromCall
574
+ } else {
575
+ finalDefaultValue = context ? `${baseKey}_${context}` : baseKey
576
+ }
577
+
578
+ const ctxSep = this.config.extract.contextSeparator ?? '_'
579
+ const finalKey = context ? `${baseKey}${ctxSep}${context}` : baseKey
580
+
581
+ this.pluginContext.addKey({
582
+ key: finalKey,
583
+ ns,
584
+ defaultValue: finalDefaultValue,
585
+ hasCount: true,
586
+ isOrdinal,
587
+ explicitDefault: Boolean(explicitDefaultFromSource || typeof specificOther === 'string')
588
+ })
589
+ }
590
+ return
591
+ }
592
+
483
593
  // Generate plural forms for each key variant
484
594
  for (const { key: baseKey, context } of keysToGenerate) {
485
595
  for (const category of pluralCategories) {
@@ -185,6 +185,18 @@ function generatePluralKeys (
185
185
  const pluralCategories = Array.from(allPluralCategories).sort()
186
186
  const pluralSeparator = config.extract.pluralSeparator ?? '_'
187
187
 
188
+ // If the only plural category is "other", prefer emitting the base key instead of "key_other"
189
+ if (pluralCategories.length === 1 && pluralCategories[0] === 'other') {
190
+ // Emit base key only
191
+ pluginContext.addKey({
192
+ key,
193
+ ns,
194
+ defaultValue,
195
+ hasCount: true
196
+ })
197
+ return
198
+ }
199
+
188
200
  // Generate keys for each plural category
189
201
  for (const category of pluralCategories) {
190
202
  const finalKey = isOrdinal
@@ -286,6 +286,24 @@ export class JSXHandler {
286
286
  ordinalOtherDefault = getObjectPropValue(optionsNode, `defaultValue${pluralSeparator}ordinal${pluralSeparator}other`) as string | undefined
287
287
  }
288
288
 
289
+ // Special-case single-"other" languages: generate base key (or context variant) instead of key_other
290
+ if (pluralCategories.length === 1 && pluralCategories[0] === 'other') {
291
+ // Determine final default for the base/other form
292
+ const specificDefault = optionsNode ? getObjectPropValue(optionsNode, `defaultValue${pluralSeparator}other`) as string | undefined : undefined
293
+ const finalDefault = typeof specificDefault === 'string' ? specificDefault : (typeof defaultValue === 'string' ? defaultValue : key)
294
+
295
+ // add base key (no suffix)
296
+ this.pluginContext.addKey({
297
+ key,
298
+ ns,
299
+ defaultValue: finalDefault,
300
+ hasCount: true,
301
+ isOrdinal,
302
+ explicitDefault: Boolean(explicitDefaultFromSource || typeof specificDefault === 'string' || typeof otherDefault === 'string')
303
+ })
304
+ return
305
+ }
306
+
289
307
  for (const category of pluralCategories) {
290
308
  // Look for the most specific default value (e.g., defaultValue_ordinal_one)
291
309
  const specificDefaultKey = isOrdinal ? `defaultValue${pluralSeparator}ordinal${pluralSeparator}${category}` : `defaultValue${pluralSeparator}${category}`
package/src/linter.ts CHANGED
@@ -1,10 +1,87 @@
1
1
  import { glob } from 'glob'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { parse } from '@swc/core'
4
+ import { EventEmitter } from 'node:events'
4
5
  import chalk from 'chalk'
5
6
  import ora from 'ora'
6
7
  import type { I18nextToolkitConfig } from './types'
7
8
 
9
+ type LinterEventMap = {
10
+ progress: [{
11
+ message: string;
12
+ }];
13
+ done: [{
14
+ success: boolean;
15
+ message: string;
16
+ files: Record<string, HardcodedString[]>;
17
+ }];
18
+ error: [error: Error];
19
+ }
20
+
21
+ export class Linter extends EventEmitter<LinterEventMap> {
22
+ private config: I18nextToolkitConfig
23
+
24
+ constructor (config: I18nextToolkitConfig) {
25
+ super({ captureRejections: true })
26
+ this.config = config
27
+ }
28
+
29
+ wrapError (error: unknown) {
30
+ const prefix = 'Linter failed to run: '
31
+ if (error instanceof Error) {
32
+ if (error.message.startsWith(prefix)) {
33
+ return error
34
+ }
35
+ const wrappedError = new Error(`${prefix}${error.message}`)
36
+ wrappedError.stack = error.stack
37
+ return wrappedError
38
+ }
39
+ return new Error(`${prefix}${String(error)}`)
40
+ }
41
+
42
+ async run () {
43
+ const { config } = this
44
+ try {
45
+ this.emit('progress', { message: 'Finding source files to analyze...' })
46
+ const defaultIgnore = ['node_modules/**']
47
+ const userIgnore = Array.isArray(config.extract.ignore)
48
+ ? config.extract.ignore
49
+ : config.extract.ignore ? [config.extract.ignore] : []
50
+
51
+ const sourceFiles = await glob(config.extract.input, {
52
+ ignore: [...defaultIgnore, ...userIgnore]
53
+ })
54
+ this.emit('progress', { message: `Analyzing ${sourceFiles.length} source files...` })
55
+ let totalIssues = 0
56
+ const issuesByFile = new Map<string, HardcodedString[]>()
57
+
58
+ for (const file of sourceFiles) {
59
+ const code = await readFile(file, 'utf-8')
60
+ const ast = await parse(code, {
61
+ syntax: 'typescript',
62
+ tsx: true,
63
+ decorators: true
64
+ })
65
+ const hardcodedStrings = findHardcodedStrings(ast, code, config)
66
+
67
+ if (hardcodedStrings.length > 0) {
68
+ totalIssues += hardcodedStrings.length
69
+ issuesByFile.set(file, hardcodedStrings)
70
+ }
71
+ }
72
+
73
+ const files = Object.fromEntries(issuesByFile.entries())
74
+ const data = { success: totalIssues === 0, message: totalIssues > 0 ? `Linter found ${totalIssues} potential issues.` : 'No issues found.', files }
75
+ this.emit('done', data)
76
+ return data
77
+ } catch (error) {
78
+ const wrappedError = this.wrapError(error)
79
+ this.emit('error', wrappedError)
80
+ throw wrappedError
81
+ }
82
+ }
83
+ }
84
+
8
85
  /**
9
86
  * Runs the i18next linter to detect hardcoded strings and other potential issues.
10
87
  *
@@ -32,44 +109,25 @@ import type { I18nextToolkitConfig } from './types'
32
109
  *
33
110
  * await runLinter(config)
34
111
  * // Outputs issues found or success message
35
- * // Exits with code 1 if issues found, 0 if clean
36
112
  * ```
37
113
  */
38
114
  export async function runLinter (config: I18nextToolkitConfig) {
39
- const spinner = ora('Analyzing source files...\n').start()
115
+ return new Linter(config).run()
116
+ }
40
117
 
118
+ export async function runLinterCli (config: I18nextToolkitConfig) {
119
+ const linter = new Linter(config)
120
+ const spinner = ora().start()
121
+ linter.on('progress', (event) => {
122
+ spinner.text = event.message
123
+ })
41
124
  try {
42
- const defaultIgnore = ['node_modules/**']
43
- const userIgnore = Array.isArray(config.extract.ignore)
44
- ? config.extract.ignore
45
- : config.extract.ignore ? [config.extract.ignore] : []
46
-
47
- const sourceFiles = await glob(config.extract.input, {
48
- ignore: [...defaultIgnore, ...userIgnore]
49
- })
50
- let totalIssues = 0
51
- const issuesByFile = new Map<string, HardcodedString[]>()
52
-
53
- for (const file of sourceFiles) {
54
- const code = await readFile(file, 'utf-8')
55
- const ast = await parse(code, {
56
- syntax: 'typescript',
57
- tsx: true,
58
- decorators: true
59
- })
60
- const hardcodedStrings = findHardcodedStrings(ast, code, config)
61
-
62
- if (hardcodedStrings.length > 0) {
63
- totalIssues += hardcodedStrings.length
64
- issuesByFile.set(file, hardcodedStrings)
65
- }
66
- }
67
-
68
- if (totalIssues > 0) {
69
- spinner.fail(chalk.red.bold(`Linter found ${totalIssues} potential issues.`))
125
+ const { success, message, files } = await linter.run()
126
+ if (!success) {
127
+ spinner.fail(chalk.red.bold(message))
70
128
 
71
129
  // Print detailed report after spinner fails
72
- for (const [file, issues] of issuesByFile.entries()) {
130
+ for (const [file, issues] of Object.entries(files)) {
73
131
  console.log(chalk.yellow(`\n${file}`))
74
132
  issues.forEach(({ text, line }) => {
75
133
  console.log(` ${chalk.gray(`${line}:`)} ${chalk.red('Error:')} Found hardcoded string: "${text}"`)
@@ -77,11 +135,12 @@ export async function runLinter (config: I18nextToolkitConfig) {
77
135
  }
78
136
  process.exit(1)
79
137
  } else {
80
- spinner.succeed(chalk.green.bold('No issues found.'))
138
+ spinner.succeed(chalk.green.bold(message))
81
139
  }
82
140
  } catch (error) {
83
- spinner.fail(chalk.red('Linter failed to run.'))
84
- console.error(error)
141
+ const wrappedError = linter.wrapError(error)
142
+ spinner.fail(wrappedError.message)
143
+ console.error(wrappedError)
85
144
  process.exit(1)
86
145
  }
87
146
  }
package/src/status.ts CHANGED
@@ -124,20 +124,28 @@ async function generateStatusReport (config: I18nextToolkitConfig): Promise<Stat
124
124
  const keyDetails: Array<{ key: string; isTranslated: boolean }> = []
125
125
 
126
126
  // This is the new, language-aware logic loop
127
- for (const { key: baseKey, hasCount, isOrdinal } of keysInNs) {
127
+ for (const { key: baseKey, hasCount, isOrdinal, isExpandedPlural } of keysInNs) {
128
128
  if (hasCount) {
129
- const type = isOrdinal ? 'ordinal' : 'cardinal'
130
- // It's a plural key: expand it based on the current locale's rules
131
- const pluralCategories = new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories
132
- for (const category of pluralCategories) {
129
+ // Rely only on the extractor-provided flag; extractor must set isExpandedPlural
130
+ if (isExpandedPlural) {
133
131
  totalInNs++
134
- const pluralKey = isOrdinal
135
- ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
136
- : `${baseKey}${pluralSeparator}${category}`
137
- const value = getNestedValue(translationsForNs, pluralKey, keySeparator ?? '.')
132
+ const value = getNestedValue(translationsForNs, baseKey, keySeparator ?? '.')
138
133
  const isTranslated = !!value
139
134
  if (isTranslated) translatedInNs++
140
- keyDetails.push({ key: pluralKey, isTranslated })
135
+ keyDetails.push({ key: baseKey, isTranslated })
136
+ } else {
137
+ const type = isOrdinal ? 'ordinal' : 'cardinal'
138
+ const pluralCategories = new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories
139
+ for (const category of pluralCategories) {
140
+ totalInNs++
141
+ const pluralKey = isOrdinal
142
+ ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
143
+ : `${baseKey}${pluralSeparator}${category}`
144
+ const value = getNestedValue(translationsForNs, pluralKey, keySeparator ?? '.')
145
+ const isTranslated = !!value
146
+ if (isTranslated) translatedInNs++
147
+ keyDetails.push({ key: pluralKey, isTranslated })
148
+ }
141
149
  }
142
150
  } else {
143
151
  // It's a simple key
package/src/types.ts CHANGED
@@ -319,6 +319,9 @@ export interface ExtractedKey {
319
319
 
320
320
  /** Whether the defaultValue was explicitly provided in source code (vs derived from children/key) */
321
321
  explicitDefault?: boolean;
322
+
323
+ /** True when the extractor returned an already-expanded plural form (e.g. "key_one") */
324
+ isExpandedPlural?: boolean
322
325
  }
323
326
 
324
327
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"key-finder.d.ts","sourceRoot":"","sources":["../../../src/extractor/core/key-finder.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,oBAAoB,EAAmB,MAAM,aAAa,CAAA;AAM9F;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,oBAAoB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC;IAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAAC,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC,CAgE1E"}
1
+ {"version":3,"file":"key-finder.d.ts","sourceRoot":"","sources":["../../../src/extractor/core/key-finder.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,oBAAoB,EAAmB,MAAM,aAAa,CAAA;AAM9F;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,oBAAoB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC;IAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAAC,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC,CA2E1E"}
@@ -1 +1 @@
1
- {"version":3,"file":"translation-manager.d.ts","sourceRoot":"","sources":["../../../src/extractor/core/translation-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AA2UnF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,EAC/B,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,EACvB,MAAM,EAAE,oBAAoB,EAC5B,EAAE,uBAA+B,EAAE,GAAE;IAAE,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAAO,GAC9E,OAAO,CAAC,iBAAiB,EAAE,CAAC,CA6F9B"}
1
+ {"version":3,"file":"translation-manager.d.ts","sourceRoot":"","sources":["../../../src/extractor/core/translation-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AAkanF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,EAC/B,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,EACvB,MAAM,EAAE,oBAAoB,EAC5B,EAAE,uBAA+B,EAAE,GAAE;IAAE,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAAO,GAC9E,OAAO,CAAC,iBAAiB,EAAE,CAAC,CA6F9B"}
@@ -1 +1 @@
1
- {"version":3,"file":"call-expression-handler.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/call-expression-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAA6C,MAAM,WAAW,CAAA;AAC1F,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,EAAgB,SAAS,EAAE,MAAM,aAAa,CAAA;AACvG,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAG1D,qBAAa,qBAAqB;IAChC,OAAO,CAAC,aAAa,CAAe;IACpC,OAAO,CAAC,MAAM,CAAuC;IACrD,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,kBAAkB,CAAoB;IACvC,UAAU,cAAoB;gBAGnC,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,MAAM,EACd,kBAAkB,EAAE,kBAAkB;IAQxC;;;;;;;;;;;;;OAaG;IACH,oBAAoB,CAAE,IAAI,EAAE,cAAc,EAAE,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,SAAS,GAAG,SAAS,GAAG,IAAI;IA8QxG;;;;;;OAMG;IACH,OAAO,CAAC,4BAA4B;IA8BpC;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,sBAAsB;IA2C9B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,gBAAgB;IA0IxB;;;;;;;;;OASG;IACH,OAAO,CAAC,eAAe;CA2BxB"}
1
+ {"version":3,"file":"call-expression-handler.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/call-expression-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAA6C,MAAM,WAAW,CAAA;AAC1F,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,EAAgB,SAAS,EAAE,MAAM,aAAa,CAAA;AACvG,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAG1D,qBAAa,qBAAqB;IAChC,OAAO,CAAC,aAAa,CAAe;IACpC,OAAO,CAAC,MAAM,CAAuC;IACrD,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,kBAAkB,CAAoB;IACvC,UAAU,cAAoB;gBAGnC,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,MAAM,EACd,kBAAkB,EAAE,kBAAkB;IAQxC;;;;;;;;;;;;;OAaG;IACH,oBAAoB,CAAE,IAAI,EAAE,cAAc,EAAE,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,SAAS,GAAG,SAAS,GAAG,IAAI;IA2UxG;;;;;;OAMG;IACH,OAAO,CAAC,4BAA4B;IA8BpC;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,sBAAsB;IA2C9B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,gBAAgB;IA2LxB;;;;;;;;;OASG;IACH,OAAO,CAAC,eAAe;CA2BxB"}
@@ -1 +1 @@
1
- {"version":3,"file":"jsx-handler.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/jsx-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAoB,MAAM,WAAW,CAAA;AAC7D,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAgB,MAAM,aAAa,CAAA;AACpF,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAI1D,qBAAa,UAAU;IACrB,OAAO,CAAC,MAAM,CAAuC;IACrD,OAAO,CAAC,aAAa,CAAe;IACpC,OAAO,CAAC,kBAAkB,CAAoB;gBAG5C,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,aAAa,EAAE,aAAa,EAC5B,kBAAkB,EAAE,kBAAkB;IAOxC;;;;;;;;OAQG;IACH,gBAAgB,CAAE,IAAI,EAAE,UAAU,EAAE,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,GAAG,IAAI;IA0OjI;;;;;;;;OAQG;IACH,OAAO,CAAC,0BAA0B;IAgElC;;;;;;;;;OASG;IACH,OAAO,CAAC,cAAc;CAevB"}
1
+ {"version":3,"file":"jsx-handler.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/jsx-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAoB,MAAM,WAAW,CAAA;AAC7D,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAgB,MAAM,aAAa,CAAA;AACpF,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAI1D,qBAAa,UAAU;IACrB,OAAO,CAAC,MAAM,CAAuC;IACrD,OAAO,CAAC,aAAa,CAAe;IACpC,OAAO,CAAC,kBAAkB,CAAoB;gBAG5C,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,aAAa,EAAE,aAAa,EAC5B,kBAAkB,EAAE,kBAAkB;IAOxC;;;;;;;;OAQG;IACH,gBAAgB,CAAE,IAAI,EAAE,UAAU,EAAE,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,GAAG,IAAI;IA0OjI;;;;;;;;OAQG;IACH,OAAO,CAAC,0BAA0B;IAkFlC;;;;;;;;;OASG;IACH,OAAO,CAAC,cAAc;CAevB"}
package/types/linter.d.ts CHANGED
@@ -1,4 +1,32 @@
1
+ import { EventEmitter } from 'node:events';
1
2
  import type { I18nextToolkitConfig } from './types';
3
+ type LinterEventMap = {
4
+ progress: [
5
+ {
6
+ message: string;
7
+ }
8
+ ];
9
+ done: [
10
+ {
11
+ success: boolean;
12
+ message: string;
13
+ files: Record<string, HardcodedString[]>;
14
+ }
15
+ ];
16
+ error: [error: Error];
17
+ };
18
+ export declare class Linter extends EventEmitter<LinterEventMap> {
19
+ private config;
20
+ constructor(config: I18nextToolkitConfig);
21
+ wrapError(error: unknown): Error;
22
+ run(): Promise<{
23
+ success: boolean;
24
+ message: string;
25
+ files: {
26
+ [k: string]: HardcodedString[];
27
+ };
28
+ }>;
29
+ }
2
30
  /**
3
31
  * Runs the i18next linter to detect hardcoded strings and other potential issues.
4
32
  *
@@ -26,8 +54,24 @@ import type { I18nextToolkitConfig } from './types';
26
54
  *
27
55
  * await runLinter(config)
28
56
  * // Outputs issues found or success message
29
- * // Exits with code 1 if issues found, 0 if clean
30
57
  * ```
31
58
  */
32
- export declare function runLinter(config: I18nextToolkitConfig): Promise<void>;
59
+ export declare function runLinter(config: I18nextToolkitConfig): Promise<{
60
+ success: boolean;
61
+ message: string;
62
+ files: {
63
+ [k: string]: HardcodedString[];
64
+ };
65
+ }>;
66
+ export declare function runLinterCli(config: I18nextToolkitConfig): Promise<void>;
67
+ /**
68
+ * Represents a found hardcoded string with its location information.
69
+ */
70
+ interface HardcodedString {
71
+ /** The hardcoded text content */
72
+ text: string;
73
+ /** Line number where the string was found */
74
+ line: number;
75
+ }
76
+ export {};
33
77
  //# sourceMappingURL=linter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"linter.d.ts","sourceRoot":"","sources":["../src/linter.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB,iBAiD5D"}
1
+ {"version":3,"file":"linter.d.ts","sourceRoot":"","sources":["../src/linter.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAG1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAEnD,KAAK,cAAc,GAAG;IACpB,QAAQ,EAAE;QAAC;YACT,OAAO,EAAE,MAAM,CAAC;SACjB;KAAC,CAAC;IACH,IAAI,EAAE;QAAC;YACL,OAAO,EAAE,OAAO,CAAC;YACjB,OAAO,EAAE,MAAM,CAAC;YAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,EAAE,CAAC,CAAC;SAC1C;KAAC,CAAC;IACH,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAA;AAED,qBAAa,MAAO,SAAQ,YAAY,CAAC,cAAc,CAAC;IACtD,OAAO,CAAC,MAAM,CAAsB;gBAEvB,MAAM,EAAE,oBAAoB;IAKzC,SAAS,CAAE,KAAK,EAAE,OAAO;IAanB,GAAG;;;;;;;CAyCV;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB;;;;;;GAE5D;AAED,wBAAsB,YAAY,CAAE,MAAM,EAAE,oBAAoB,iBA4B/D;AAED;;GAEG;AACH,UAAU,eAAe;IACvB,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAC;CACd"}
package/types/types.d.ts CHANGED
@@ -262,6 +262,8 @@ export interface ExtractedKey {
262
262
  contextExpression?: Expression;
263
263
  /** Whether the defaultValue was explicitly provided in source code (vs derived from children/key) */
264
264
  explicitDefault?: boolean;
265
+ /** True when the extractor returned an already-expanded plural form (e.g. "key_one") */
266
+ isExpandedPlural?: boolean;
265
267
  }
266
268
  /**
267
269
  * Result of processing translation files for a specific locale and namespace.