Skip to content

Commit cf973aa

Browse files
committed
status: introduce fallbackNS option, to prevent missing key warnings #158
1 parent b226ce0 commit cf973aa

File tree

5 files changed

+118
-4
lines changed

5 files changed

+118
-4
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.36.0](https://github.com/i18next/i18next-cli/compare/v1.35.0...v1.36.0) - 2026-01-14
9+
10+
- status: introduce fallbackNS option, to prevent missing key warnings [#158](https://github.com/i18next/i18next-cli/issues/158)
11+
812
## [1.35.0](https://github.com/i18next/i18next-cli/compare/v1.34.1...v1.35.0) - 2026-01-12
913

1014
- extractor: Allow extraction from new expressions [#157](https://github.com/i18next/i18next-cli/pull/157)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ export default defineConfig({
379379
380380
// Namespace and key configuration
381381
defaultNS: 'translation', // If set to false it will not generate any namespace, useful if i.e. the output is a single language json with 1 namespace (and no nesting).
382+
fallbackNS: 'fallback', // Namespace to use as fallback when a key is missing in the current namespace for a locale. (default undefined)
382383
nsSeparator: ':',
383384
keySeparator: '.', // Or `false` to disable nesting and use flat keys
384385
contextSeparator: '_',

src/status.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ async function generateStatusReport (config: I18nextToolkitConfig): Promise<Stat
101101
config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
102102

103103
const { allKeys: allExtractedKeys } = await findKeys(config)
104-
const { secondaryLanguages, keySeparator = '.', defaultNS = 'translation', mergeNamespaces = false, pluralSeparator = '_' } = config.extract
104+
const { secondaryLanguages, keySeparator = '.', defaultNS = 'translation', mergeNamespaces = false, pluralSeparator = '_', fallbackNS } = config.extract
105105

106106
const keysByNs = new Map<string, ExtractedKey[]>()
107107
for (const key of allExtractedKeys.values()) {
@@ -144,6 +144,14 @@ async function generateStatusReport (config: I18nextToolkitConfig): Promise<Stat
144144
? (mergedTranslations?.[ns] ?? mergedTranslations ?? {})
145145
: await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, locale, ns))) || {}
146146

147+
// Load fallbackNS translations if configured
148+
let fallbackTranslations: any
149+
if (fallbackNS && ns !== fallbackNS) {
150+
fallbackTranslations = await loadTranslationFile(
151+
resolve(process.cwd(), getOutputPath(config.extract.output, locale, fallbackNS))
152+
) || {}
153+
}
154+
147155
let translatedInNs = 0
148156
let totalInNs = 0
149157
const keyDetails: Array<{ key: string; isTranslated: boolean }> = []
@@ -179,7 +187,11 @@ async function generateStatusReport (config: I18nextToolkitConfig): Promise<Stat
179187
// Only count this key if it's a plural form used by this locale
180188
if (localePluralCategories.includes(category)) {
181189
totalInNs++
182-
const value = getNestedValue(translationsForNs, baseKey, keySeparator ?? '.')
190+
let value = getNestedValue(translationsForNs, baseKey, keySeparator ?? '.')
191+
// Fallback lookup
192+
if (!value && fallbackTranslations) {
193+
value = getNestedValue(fallbackTranslations, baseKey, keySeparator ?? '.')
194+
}
183195
const isTranslated = !!value
184196
if (isTranslated) translatedInNs++
185197
keyDetails.push({ key: baseKey, isTranslated })
@@ -194,7 +206,11 @@ async function generateStatusReport (config: I18nextToolkitConfig): Promise<Stat
194206
const pluralKey = isOrdinal
195207
? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
196208
: `${baseKey}${pluralSeparator}${category}`
197-
const value = getNestedValue(translationsForNs, pluralKey, keySeparator ?? '.')
209+
let value = getNestedValue(translationsForNs, pluralKey, keySeparator ?? '.')
210+
// Fallback lookup
211+
if (!value && fallbackTranslations) {
212+
value = getNestedValue(fallbackTranslations, pluralKey, keySeparator ?? '.')
213+
}
198214
const isTranslated = !!value
199215
if (isTranslated) translatedInNs++
200216
keyDetails.push({ key: pluralKey, isTranslated })
@@ -203,7 +219,11 @@ async function generateStatusReport (config: I18nextToolkitConfig): Promise<Stat
203219
} else {
204220
// It's a simple key
205221
totalInNs++
206-
const value = getNestedValue(translationsForNs, baseKey, keySeparator ?? '.')
222+
let value = getNestedValue(translationsForNs, baseKey, keySeparator ?? '.')
223+
// Fallback lookup
224+
if (!value && fallbackTranslations) {
225+
value = getNestedValue(fallbackTranslations, baseKey, keySeparator ?? '.')
226+
}
207227
const isTranslated = !!value
208228
if (isTranslated) translatedInNs++
209229
keyDetails.push({ key: baseKey, isTranslated })

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export interface I18nextToolkitConfig {
4242
*/
4343
defaultNS?: string | false;
4444

45+
/** Namespace to use as fallback when a key is missing in the current namespace for a locale. */
46+
fallbackNS?: string;
47+
4548
/** Separator for nested keys, or false for flat keys (default: '.') */
4649
keySeparator?: string | false | null;
4750

test/status.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,3 +672,89 @@ describe('status (plurals)', () => {
672672
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('100% (3/3 keys)'))
673673
})
674674
})
675+
676+
describe('status (fallbackNS)', () => {
677+
let consoleLogSpy: any
678+
// let consoleErrorSpy: any
679+
let processExitSpy: any
680+
681+
beforeEach(async () => {
682+
vol.reset()
683+
vi.clearAllMocks()
684+
const { glob } = await import('glob')
685+
vi.mocked(glob).mockImplementation(async (pattern: any, options?: any) => {
686+
return Object.keys(vol.toJSON()).filter(p => p.includes('/src/'))
687+
})
688+
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
689+
// consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
690+
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
691+
throw new Error('Process exit called')
692+
})
693+
})
694+
695+
afterEach(() => {
696+
vi.restoreAllMocks()
697+
})
698+
699+
it('should not flag keys as missing if present in fallbackNS', async () => {
700+
// Simulate the scenario from the bug report
701+
vol.fromJSON({
702+
[resolve(process.cwd(), 'src/app.tsx')]: `
703+
import { useTranslation } from 'react-i18next'
704+
export default function App() {
705+
const { t } = useTranslation('feature')
706+
return (
707+
<div>
708+
<p>{t('feature-specific')}</p>
709+
<p>{t('ok')}</p>
710+
<p>{t('cancel')}</p>
711+
</div>
712+
)
713+
}
714+
`,
715+
[resolve(process.cwd(), 'locales/en/shared.json')]: JSON.stringify({
716+
ok: 'This is a common string that can be used anywhere',
717+
cancel: 'This is a common string',
718+
}),
719+
[resolve(process.cwd(), 'locales/en/feature.json')]: JSON.stringify({
720+
'feature-specific': 'This is a feature-specific string',
721+
cancel: 'This is an override of a common string',
722+
}),
723+
[resolve(process.cwd(), 'locales/de/shared.json')]: JSON.stringify({
724+
ok: 'Das ist ein common text der überall verwendet werden kann',
725+
cancel: 'Das ist ein common text',
726+
}),
727+
[resolve(process.cwd(), 'locales/de/feature.json')]: JSON.stringify({
728+
'feature-specific': 'Das ist ein feature-speziefischer text',
729+
cancel: 'Das sit ein überschriebener text gegenüber common',
730+
}),
731+
})
732+
733+
const config: I18nextToolkitConfig = {
734+
locales: ['en', 'de'],
735+
extract: {
736+
input: ['src/'],
737+
output: 'locales/{{language}}/{{namespace}}.json',
738+
fallbackNS: 'shared',
739+
},
740+
}
741+
742+
await runStatus(config)
743+
744+
// const errorLogCalls = consoleErrorSpy.mock.calls.map((call: any[]) => call[0])
745+
746+
// Should not flag 'ok' as missing for feature namespace, since it's present in fallbackNS
747+
const logCalls = consoleLogSpy.mock.calls.map((call: any[]) => call[0])
748+
// Should include the expected summary lines
749+
expect(logCalls).toContain('\ni18next Project Status')
750+
expect(logCalls).toContain('------------------------')
751+
expect(logCalls).toContain('🔑 Keys Found: 3')
752+
expect(logCalls).toContain('📚 Namespaces Found: 1')
753+
expect(logCalls).toContain('🌍 Locales: en, de')
754+
expect(logCalls).toContain('✅ Primary Language: en')
755+
expect(logCalls).toContain('\nTranslation Progress:')
756+
// Should include the correct progress line for de (all keys present via fallbackNS)
757+
expect(logCalls).toContain('- de: [■■■■■■■■■■■■■■■■■■■■] 100% (3/3 keys)')
758+
expect(processExitSpy).not.toHaveBeenCalled()
759+
})
760+
})

0 commit comments

Comments
 (0)