Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ Namespace Progress: [■■■■■■■■■■■■■■■■□□□
### `types`
Generates TypeScript definitions from your translation files for full type-safety and autocompletion.

> **Note:** When `extract.defaultNS` is set to `false`, the generated `defaultNS` is derived from your resource files (i18next's type system cannot express `defaultNS: false`). Adjust the generated `i18next.d.ts` if your runtime i18next config uses a different default namespace.

```bash
npx i18next-cli types [options]
```
Expand Down
25 changes: 24 additions & 1 deletion src/types-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,30 @@ ${mergeResourcesAsInterface(resources, { optimize: !!enableSelector, indentation
const importPath = relative(dirname(outputPath), resourcesOutputPath)
.replace(/\\/g, '/').replace(/\.d\.ts$/, '') // Make it a valid module path

const defaultNS = config.extract.defaultNS === false ? 'false' : `'${config.extract.defaultNS || 'translation'}'`
// i18next's type system cannot resolve keys for `defaultNS: false` —
// `t()` would silently accept any string. When `extract.defaultNS` is
// disabled, derive the default namespace from the generated resources
// instead, so the generated types stay self-consistent.
let defaultNS = `'${config.extract.defaultNS || 'translation'}'`
if (config.extract.defaultNS === false) {
const resourceNamespaces = [...new Set(resources.map(r => r.name))].filter(ns => !ns.startsWith('..')).sort()
// Files named after locales (e.g. locales/en.json) carry no namespace
// information. A single one is still a usable default, but multiple
// would turn sibling languages into namespaces — keep `false` then.
const onlyLocaleNames = resourceNamespaces.length > 1 && resourceNamespaces.every(ns => config.locales.includes(ns))
if (resourceNamespaces.length === 0) {
defaultNS = 'false'
} else if (onlyLocaleNames) {
internalLogger.warn(styleText('yellow', 'Warning: extract.defaultNS is disabled and your resource files are named after locales, so no namespace could be derived for the generated definitions — defaultNS stays false and t() will not be type-checked. Consider adjusting types.input or types.basePath.'))
defaultNS = 'false'
} else {
// Unprefixed keys resolve against a single default namespace, so a
// single name is emitted; prefer the conventional 'translation'.
const effectiveDefault = resourceNamespaces.includes('translation') ? 'translation' : resourceNamespaces[0]
internalLogger.warn(styleText('yellow', `Warning: extract.defaultNS is disabled, so defaultNS was derived from your resource files ('${effectiveDefault}'). Adjust the generated definitions if your runtime i18next config uses a different default namespace.`))
defaultNS = `'${effectiveDefault}'`
}
}
const fallbackNS = config.extract.fallbackNS === false
? 'false'
: Array.isArray(config.extract.fallbackNS)
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface I18nextToolkitConfig {
/**
* Default namespace when none is specified (default: 'translation').
* Set to false will not generate any namespace, useful if i.e. the output is a single language json with 1 namespace (and no nesting).
* When false, the `types` command derives `defaultNS` from the generated resource namespaces, since i18next's type system cannot express `defaultNS: false`.
*/
defaultNS?: string | false;

Expand Down
163 changes: 161 additions & 2 deletions test/types-generator-ts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export default {
})
})

it('should handle defaultNS: false correctly', async () => {
it('should derive defaultNS from the resource namespace when defaultNS is false', async () => {
const { glob } = await import('glob')
const filename = '/locales/en.ts'
;(glob as any).mockResolvedValue([filename])
Expand All @@ -153,10 +153,163 @@ export default {
const outputPath = resolve(process.cwd(), config.types.output)
const content = await vol.promises.readFile(outputPath, 'utf-8')

expect(content).toContain('defaultNS: false;')
expect(content).toContain("defaultNS: 'en';")
expect(content).not.toContain("defaultNS: 'translation';")
})

it('should dedupe and sort namespaces derived from multiple resource files', async () => {
const { glob } = await import('glob')
;(glob as any).mockResolvedValue([
'/locales/en/common.json',
'/locales/de/common.json',
'/locales/en/app.json',
])

vol.fromJSON({
'/locales/en/common.json': JSON.stringify({ hello: 'world' }),
'/locales/de/common.json': JSON.stringify({ hello: 'welt' }),
'/locales/en/app.json': JSON.stringify({ title: 'App' }),
})

const config = {
locales: ['en', 'de'],
extract: {
defaultNS: false,
},
types: {
input: ['locales/**/*.json'],
output: 'src/types/i18next.d.ts',
resourcesFile: 'src/types/resources.d.ts',
},
}

await runTypesGenerator(config as any)

const outputPath = resolve(process.cwd(), config.types.output)
const content = await vol.promises.readFile(outputPath, 'utf-8')

expect(content).toContain("defaultNS: 'app';")
})

it('should derive a single namespace from a namespaced layout', async () => {
const { glob } = await import('glob')
;(glob as any).mockResolvedValue(['/locales/en/common.json'])

vol.fromJSON({
'/locales/en/common.json': JSON.stringify({ hello: 'world' }),
})

const config = {
locales: ['en'],
extract: {
defaultNS: false,
},
types: {
input: ['locales/en/*.json'],
output: 'src/types/i18next.d.ts',
resourcesFile: 'src/types/resources.d.ts',
},
}

await runTypesGenerator(config as any)

const outputPath = resolve(process.cwd(), config.types.output)
const content = await vol.promises.readFile(outputPath, 'utf-8')

expect(content).toContain("defaultNS: 'common';")
})

it('should keep defaultNS: false when resources are keyed per language file', async () => {
const { glob } = await import('glob')
;(glob as any).mockResolvedValue(['/locales/de.json', '/locales/en.json'])

vol.fromJSON({
'/locales/de.json': JSON.stringify({ hello: 'welt' }),
'/locales/en.json': JSON.stringify({ hello: 'world' }),
})

const config = {
locales: ['en', 'de'],
extract: {
defaultNS: false,
},
types: {
input: ['locales/*.json'],
output: 'src/types/i18next.d.ts',
resourcesFile: 'src/types/resources.d.ts',
},
}

await runTypesGenerator(config as any)

const outputPath = resolve(process.cwd(), config.types.output)
const content = await vol.promises.readFile(outputPath, 'utf-8')

expect(content).toContain('defaultNS: false;')
})

it('should warn about the derived defaultNS only when the output file is created', async () => {
const { glob } = await import('glob')
;(glob as any).mockResolvedValue(['/locales/en/common.json'])

const outputPath = resolve(process.cwd(), 'src/types/i18next.d.ts')
vol.fromJSON({
'/locales/en/common.json': JSON.stringify({ hello: 'world' }),
[outputPath]: '// user-adjusted\ndefaultNS: false;',
})

const config = {
locales: ['en'],
extract: {
defaultNS: false,
},
types: {
input: ['locales/en/*.json'],
output: 'src/types/i18next.d.ts',
resourcesFile: 'src/types/resources.d.ts',
},
}

const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
await runTypesGenerator(config as any, { logger } as any)

// An existing output file is left untouched, so no derivation happens
const content = await vol.promises.readFile(outputPath, 'utf-8')
expect(content).toContain('// user-adjusted')
expect(logger.warn).not.toHaveBeenCalled()

await vol.promises.rm(outputPath)
await runTypesGenerator(config as any, { logger } as any)

const regenerated = await vol.promises.readFile(outputPath, 'utf-8')
expect(regenerated).toContain("defaultNS: 'common';")
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('extract.defaultNS is disabled'))
})

it('should keep defaultNS: false when no resource files are found', async () => {
const { glob } = await import('glob')
;(glob as any).mockResolvedValue([])

const config = {
locales: ['en'],
extract: {
defaultNS: false,
},
types: {
input: ['locales/*.ts'],
output: 'src/types/i18next.d.ts',
resourcesFile: 'src/types/resources.d.ts',
},
}

await runTypesGenerator(config as any)

const outputPath = resolve(process.cwd(), config.types.output)
const content = await vol.promises.readFile(outputPath, 'utf-8')

expect(content).toContain('defaultNS: false;')
})

it('should treat top-level keys as namespaces when mergeNamespaces: true and file matches locale', async () => {
const { glob } = await import('glob')
const { mergeResourcesAsInterface } = await import('i18next-resources-for-ts')
Expand Down Expand Up @@ -230,5 +383,11 @@ export default {
expect(resourcesFileContent).toContain('"titles": {')
expect(resourcesFileContent).toContain('"home": "en#stacks.titles.home"')
expect(resourcesFileContent).toContain('"login": "en#stacks.titles.login"')

// `defaultNS: false` would leave `t()` untyped, so the derived namespaces
// are emitted instead to keep the generated types self-consistent.
const outputPath2 = resolve(process.cwd(), config.types.output)
const outputContent = await vol.promises.readFile(outputPath2, 'utf-8')
expect(outputContent).toContain("defaultNS: 'hello';")
})
})