diff --git a/README.md b/README.md index 3dbd829..2a5990c 100644 --- a/README.md +++ b/README.md @@ -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] ``` diff --git a/src/types-generator.ts b/src/types-generator.ts index 1a0bf40..e8da40e 100644 --- a/src/types-generator.ts +++ b/src/types-generator.ts @@ -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) diff --git a/src/types.ts b/src/types.ts index 0fd9d12..3b2c65d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/test/types-generator-ts.test.ts b/test/types-generator-ts.test.ts index a87bd2c..3b37f8b 100644 --- a/test/types-generator-ts.test.ts +++ b/test/types-generator-ts.test.ts @@ -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]) @@ -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') @@ -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';") }) })