@gravito/cosmos 3.0.1 → 3.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/README.md +105 -45
- package/README.zh-TW.md +102 -22
- package/docs/plans/01-performance.md +187 -0
- package/docs/plans/02-architecture.md +309 -0
- package/docs/plans/03-api-enhancement.md +345 -0
- package/docs/plans/04-testing.md +431 -0
- package/docs/plans/README.md +47 -0
- package/ion/src/index.js +1179 -1138
- package/package.json +3 -2
- package/src/I18nService.ts +575 -91
- package/src/index.ts +25 -6
- package/src/loader.ts +45 -12
- package/tests/helpers/factory.ts +41 -0
- package/tests/performance/translate.bench.ts +27 -0
- package/tests/unit/api.test.ts +37 -0
- package/tests/unit/detector.test.ts +70 -0
- package/tests/unit/edge.test.ts +100 -0
- package/tests/unit/fallback.test.ts +66 -0
- package/tests/unit/lazy.test.ts +35 -0
- package/tests/{loader.test.ts → unit/loader.test.ts} +1 -1
- package/tests/{manager.test.ts → unit/manager.test.ts} +1 -1
- package/tests/unit/plural.test.ts +58 -0
- package/tests/{service.test.ts → unit/service.test.ts} +2 -2
- package/tsconfig.json +12 -24
- package/.turbo/turbo-build.log +0 -20
- package/.turbo/turbo-test$colon$ci.log +0 -35
- package/.turbo/turbo-test$colon$coverage.log +0 -35
- package/.turbo/turbo-test.log +0 -27
- package/.turbo/turbo-typecheck.log +0 -2
- package/dist/index.cjs +0 -309
- package/dist/index.d.cts +0 -274
- package/dist/index.d.ts +0 -274
- package/dist/index.js +0 -277
package/src/index.ts
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/src/index.ts
|
|
3
|
+
* @module @gravito/cosmos
|
|
4
|
+
* @description Entry point for the Gravito Cosmos internationalization module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
import type { GravitoMiddleware, GravitoOrbit, PlanetCore } from '@gravito/core'
|
|
2
8
|
import { type I18nConfig, I18nManager, type I18nService, localeMiddleware } from './I18nService'
|
|
3
9
|
|
|
4
10
|
declare module '@gravito/core' {
|
|
5
11
|
interface GravitoVariables {
|
|
6
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* The request-scoped I18n service instance.
|
|
14
|
+
* Provides translation and locale management for the current request.
|
|
15
|
+
*/
|
|
7
16
|
i18n: I18nService
|
|
8
17
|
}
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
/**
|
|
12
|
-
* OrbitCosmos provides internationalization (i18n) support for Gravito.
|
|
13
|
-
*
|
|
21
|
+
* OrbitCosmos provides internationalization (i18n) support for the Gravito framework.
|
|
22
|
+
*
|
|
23
|
+
* It manages global translation resources, handles locale detection through middleware,
|
|
24
|
+
* and provides request-scoped i18n instances.
|
|
14
25
|
*
|
|
15
26
|
* @example
|
|
16
27
|
* ```typescript
|
|
28
|
+
* import { OrbitCosmos } from '@gravito/cosmos'
|
|
29
|
+
*
|
|
17
30
|
* const cosmos = new OrbitCosmos({
|
|
18
31
|
* defaultLocale: 'en',
|
|
19
32
|
* supportedLocales: ['en', 'zh-TW'],
|
|
@@ -25,19 +38,25 @@ declare module '@gravito/core' {
|
|
|
25
38
|
* core.addOrbit(cosmos);
|
|
26
39
|
* ```
|
|
27
40
|
* @public
|
|
41
|
+
* @since 3.0.0
|
|
28
42
|
*/
|
|
29
43
|
export class OrbitCosmos implements GravitoOrbit {
|
|
30
44
|
/**
|
|
31
45
|
* Create a new OrbitCosmos instance.
|
|
32
|
-
*
|
|
46
|
+
*
|
|
47
|
+
* @param config - The i18n configuration options including locales and translations.
|
|
33
48
|
*/
|
|
34
49
|
constructor(private config: I18nConfig) {}
|
|
35
50
|
|
|
36
51
|
/**
|
|
37
52
|
* Install the i18n service into PlanetCore.
|
|
38
|
-
* Registers the I18nManager and sets up the locale middleware.
|
|
39
53
|
*
|
|
40
|
-
*
|
|
54
|
+
* This method:
|
|
55
|
+
* 1. Initializes the central `I18nManager`.
|
|
56
|
+
* 2. Registers the manager in the IoC container as 'i18n'.
|
|
57
|
+
* 3. Injects the `localeMiddleware` into the adapter for all routes.
|
|
58
|
+
*
|
|
59
|
+
* @param core - The PlanetCore instance to install into.
|
|
41
60
|
*/
|
|
42
61
|
install(core: PlanetCore): void {
|
|
43
62
|
const i18nManager = new I18nManager(this.config)
|
package/src/loader.ts
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/src/loader.ts
|
|
3
|
+
* @module @gravito/cosmos/loader
|
|
4
|
+
* @description Utilities for loading translation files from the filesystem.
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
import { readdir, readFile } from 'node:fs/promises'
|
|
2
8
|
import { join, parse } from 'node:path'
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
|
-
* Load translations from a directory
|
|
6
|
-
*
|
|
11
|
+
* Load translations from a directory.
|
|
12
|
+
*
|
|
13
|
+
* Scans the specified directory for JSON files and loads them as translation bundles.
|
|
14
|
+
* The filename (without extension) is used as the locale key.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```
|
|
7
18
|
* /lang
|
|
8
|
-
* /en.json
|
|
9
|
-
* /zh.json -> { "welcome": "
|
|
10
|
-
*
|
|
19
|
+
* /en.json -> { "welcome": "Hello" }
|
|
20
|
+
* /zh-TW.json -> { "welcome": "你好" }
|
|
21
|
+
* ```
|
|
11
22
|
*
|
|
12
|
-
*
|
|
23
|
+
* @param directory - The absolute path to the translations directory.
|
|
24
|
+
* @returns A promise that resolves to a map of locale -> translations.
|
|
25
|
+
* @public
|
|
13
26
|
*/
|
|
14
27
|
export async function loadTranslations(
|
|
15
28
|
directory: string
|
|
@@ -25,12 +38,9 @@ export async function loadTranslations(
|
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
const locale = parse(file).name // 'en' from 'en.json'
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
translations[locale] = JSON.parse(content)
|
|
32
|
-
} catch (e) {
|
|
33
|
-
console.error(`[Orbit-I18n] Failed to parse translation file: ${file}`, e)
|
|
41
|
+
const translationsForLocale = await loadLocale(directory, locale)
|
|
42
|
+
if (translationsForLocale) {
|
|
43
|
+
translations[locale] = translationsForLocale
|
|
34
44
|
}
|
|
35
45
|
}
|
|
36
46
|
} catch (_e) {
|
|
@@ -41,3 +51,26 @@ export async function loadTranslations(
|
|
|
41
51
|
|
|
42
52
|
return translations
|
|
43
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load translations for a specific locale from a directory.
|
|
57
|
+
*
|
|
58
|
+
* Expects a file named `{locale}.json` in the given directory.
|
|
59
|
+
*
|
|
60
|
+
* @param directory - The directory containing translation files.
|
|
61
|
+
* @param locale - The locale string to load.
|
|
62
|
+
* @returns A promise that resolves to the translations map, or null if the file could not be read.
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
export async function loadLocale(
|
|
66
|
+
directory: string,
|
|
67
|
+
locale: string
|
|
68
|
+
): Promise<Record<string, string> | null> {
|
|
69
|
+
const filePath = join(directory, `${locale}.json`)
|
|
70
|
+
try {
|
|
71
|
+
const content = await readFile(filePath, 'utf-8')
|
|
72
|
+
return JSON.parse(content)
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type I18nConfig, I18nManager, type TranslationMap } from '../../src/I18nService'
|
|
2
|
+
|
|
3
|
+
export function createTestManager(overrides?: Partial<I18nConfig>): I18nManager {
|
|
4
|
+
return new I18nManager({
|
|
5
|
+
defaultLocale: 'en',
|
|
6
|
+
supportedLocales: ['en', 'zh-TW'],
|
|
7
|
+
translations: {
|
|
8
|
+
en: {
|
|
9
|
+
common: {
|
|
10
|
+
welcome: 'Welcome',
|
|
11
|
+
goodbye: 'Goodbye',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
'zh-TW': {
|
|
15
|
+
common: {
|
|
16
|
+
welcome: '歡迎',
|
|
17
|
+
goodbye: '再見',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
...overrides,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function generateLargeTranslations(
|
|
26
|
+
locales = 5,
|
|
27
|
+
keys = 1000
|
|
28
|
+
): Record<string, TranslationMap> {
|
|
29
|
+
const result: Record<string, TranslationMap> = {}
|
|
30
|
+
|
|
31
|
+
for (let l = 0; l < locales; l++) {
|
|
32
|
+
const locale = `locale${l}`
|
|
33
|
+
result[locale] = {}
|
|
34
|
+
|
|
35
|
+
for (let k = 0; k < keys; k++) {
|
|
36
|
+
result[locale][`key${k}`] = `Translation ${k} for ${locale}`
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result
|
|
41
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { bench, run } from 'mitata'
|
|
2
|
+
import { I18nManager } from '../../src/I18nService'
|
|
3
|
+
import { generateLargeTranslations } from '../helpers/factory'
|
|
4
|
+
|
|
5
|
+
const manager = new I18nManager({
|
|
6
|
+
defaultLocale: 'en',
|
|
7
|
+
supportedLocales: ['en', 'zh-TW'],
|
|
8
|
+
translations: generateLargeTranslations(),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
// Warmup
|
|
12
|
+
manager.translate('locale0', 'key0')
|
|
13
|
+
|
|
14
|
+
bench('Simple translation (cache hit)', () => {
|
|
15
|
+
manager.translate('locale0', 'key0')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
bench('Batch translation (100 keys)', () => {
|
|
19
|
+
const keys = Array.from({ length: 100 }, (_, i) => `key${i}`)
|
|
20
|
+
manager.tMany(keys)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
bench('Fallback chain (missing key)', () => {
|
|
24
|
+
manager.translate('locale0', 'missing.key')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
await run()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { I18nManager } from '../../src/I18nService'
|
|
3
|
+
|
|
4
|
+
describe('I18nManager API', () => {
|
|
5
|
+
it('supports multiple translations', () => {
|
|
6
|
+
const manager = new I18nManager({
|
|
7
|
+
defaultLocale: 'en',
|
|
8
|
+
supportedLocales: ['en'],
|
|
9
|
+
translations: {
|
|
10
|
+
en: {
|
|
11
|
+
hello: 'Hello',
|
|
12
|
+
goodbye: 'Goodbye',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const translations = manager.tMany(['hello', 'goodbye', 'missing'])
|
|
18
|
+
|
|
19
|
+
expect(translations.hello).toBe('Hello')
|
|
20
|
+
expect(translations.goodbye).toBe('Goodbye')
|
|
21
|
+
expect(translations.missing).toBe('missing')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('reports loaded locales', async () => {
|
|
25
|
+
const manager = new I18nManager({
|
|
26
|
+
defaultLocale: 'en',
|
|
27
|
+
supportedLocales: ['en', 'fr'],
|
|
28
|
+
translations: {
|
|
29
|
+
en: { hello: 'Hello' },
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
expect(manager.getLocales()).toContain('en')
|
|
34
|
+
expect(manager.isLocaleLoaded('en')).toBe(true)
|
|
35
|
+
expect(manager.isLocaleLoaded('fr')).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
DefaultDetectors,
|
|
4
|
+
type I18nService,
|
|
5
|
+
type LocaleDetector,
|
|
6
|
+
localeMiddleware,
|
|
7
|
+
} from '../../src/I18nService'
|
|
8
|
+
|
|
9
|
+
describe('Locale Detectors', () => {
|
|
10
|
+
const mockContext = (params: any = {}) => ({
|
|
11
|
+
req: {
|
|
12
|
+
param: (key: string) => params.param?.[key],
|
|
13
|
+
query: (key: string) => params.query?.[key],
|
|
14
|
+
header: (key: string) => params.header?.[key],
|
|
15
|
+
},
|
|
16
|
+
set: jest.fn(),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('detects from route param', async () => {
|
|
20
|
+
const ctx = mockContext({ param: { locale: 'fr' } })
|
|
21
|
+
const detector = DefaultDetectors.find((d) => d.name === 'routeParam')!
|
|
22
|
+
expect(await detector.detect(ctx)).toBe('fr')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('detects from query', async () => {
|
|
26
|
+
const ctx = mockContext({ query: { lang: 'de' } })
|
|
27
|
+
const detector = DefaultDetectors.find((d) => d.name === 'query')!
|
|
28
|
+
expect(await detector.detect(ctx)).toBe('de')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('detects from header', async () => {
|
|
32
|
+
const ctx = mockContext({ header: { 'Accept-Language': 'es-ES,es;q=0.9' } })
|
|
33
|
+
const detector = DefaultDetectors.find((d) => d.name === 'header')!
|
|
34
|
+
expect(await detector.detect(ctx)).toBe('es-ES')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('Locale Middleware', () => {
|
|
39
|
+
it('uses detectors in order', async () => {
|
|
40
|
+
const mockManager = {
|
|
41
|
+
ensureLocale: jest.fn(),
|
|
42
|
+
clone: jest.fn().mockReturnValue({}),
|
|
43
|
+
} as unknown as I18nService
|
|
44
|
+
|
|
45
|
+
const middleware = localeMiddleware(mockManager, DefaultDetectors)
|
|
46
|
+
|
|
47
|
+
const ctx1 = {
|
|
48
|
+
req: {
|
|
49
|
+
param: () => 'fr',
|
|
50
|
+
query: () => 'de',
|
|
51
|
+
header: () => 'es',
|
|
52
|
+
},
|
|
53
|
+
set: jest.fn(),
|
|
54
|
+
}
|
|
55
|
+
await middleware(ctx1 as any, async () => {})
|
|
56
|
+
expect(mockManager.ensureLocale).toHaveBeenCalledWith('fr')
|
|
57
|
+
expect(mockManager.clone).toHaveBeenCalledWith('fr')
|
|
58
|
+
|
|
59
|
+
const ctx2 = {
|
|
60
|
+
req: {
|
|
61
|
+
param: () => undefined,
|
|
62
|
+
query: () => 'de',
|
|
63
|
+
header: () => 'es',
|
|
64
|
+
},
|
|
65
|
+
set: jest.fn(),
|
|
66
|
+
}
|
|
67
|
+
await middleware(ctx2 as any, async () => {})
|
|
68
|
+
expect(mockManager.ensureLocale).toHaveBeenCalledWith('de')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { I18nManager } from '../../src/I18nService'
|
|
3
|
+
|
|
4
|
+
describe('I18nManager - Edge Cases', () => {
|
|
5
|
+
describe('Empty Translations', () => {
|
|
6
|
+
it('should handle empty translation object', () => {
|
|
7
|
+
const manager = new I18nManager({
|
|
8
|
+
defaultLocale: 'en',
|
|
9
|
+
supportedLocales: ['en'],
|
|
10
|
+
translations: {},
|
|
11
|
+
})
|
|
12
|
+
expect(manager.translate('en', 'any.key')).toBe('any.key')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should handle empty string translation', () => {
|
|
16
|
+
const manager = new I18nManager({
|
|
17
|
+
defaultLocale: 'en',
|
|
18
|
+
supportedLocales: ['en'],
|
|
19
|
+
translations: { en: { empty: '' } },
|
|
20
|
+
})
|
|
21
|
+
expect(manager.translate('en', 'empty')).toBe('')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('Special Characters', () => {
|
|
26
|
+
it('should handle keys with special characters', () => {
|
|
27
|
+
const manager = new I18nManager({
|
|
28
|
+
defaultLocale: 'en',
|
|
29
|
+
supportedLocales: ['en'],
|
|
30
|
+
translations: { en: { 'key.with.dots': 'value' } },
|
|
31
|
+
})
|
|
32
|
+
expect(manager.translate('en', 'key.with.dots')).toBe('key.with.dots')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should handle unicode keys', () => {
|
|
36
|
+
const manager = new I18nManager({
|
|
37
|
+
defaultLocale: 'zh-TW',
|
|
38
|
+
supportedLocales: ['zh-TW'],
|
|
39
|
+
translations: { 'zh-TW': { 歡迎: '歡迎訊息' } },
|
|
40
|
+
})
|
|
41
|
+
expect(manager.translate('zh-TW', '歡迎')).toBe('歡迎訊息')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should handle HTML in translations', () => {
|
|
45
|
+
const manager = new I18nManager({
|
|
46
|
+
defaultLocale: 'en',
|
|
47
|
+
supportedLocales: ['en'],
|
|
48
|
+
translations: { en: { html: '<strong>Bold</strong>' } },
|
|
49
|
+
})
|
|
50
|
+
expect(manager.translate('en', 'html')).toBe('<strong>Bold</strong>')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('Deep Nesting', () => {
|
|
55
|
+
it('should handle deeply nested keys (10 levels)', () => {
|
|
56
|
+
const translations = {
|
|
57
|
+
en: {
|
|
58
|
+
l1: { l2: { l3: { l4: { l5: { l6: { l7: { l8: { l9: { l10: 'deep' } } } } } } } } },
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
const manager = new I18nManager({
|
|
62
|
+
defaultLocale: 'en',
|
|
63
|
+
supportedLocales: ['en'],
|
|
64
|
+
translations,
|
|
65
|
+
})
|
|
66
|
+
expect(manager.translate('en', 'l1.l2.l3.l4.l5.l6.l7.l8.l9.l10')).toBe('deep')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('Parameter Replacement', () => {
|
|
71
|
+
it('should handle missing parameters', () => {
|
|
72
|
+
const manager = new I18nManager({
|
|
73
|
+
defaultLocale: 'en',
|
|
74
|
+
supportedLocales: ['en'],
|
|
75
|
+
translations: { en: { greeting: 'Hello :name!' } },
|
|
76
|
+
})
|
|
77
|
+
expect(manager.translate('en', 'greeting', {})).toBe('Hello :name!')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should handle extra parameters', () => {
|
|
81
|
+
const manager = new I18nManager({
|
|
82
|
+
defaultLocale: 'en',
|
|
83
|
+
supportedLocales: ['en'],
|
|
84
|
+
translations: { en: { greeting: 'Hello :name!' } },
|
|
85
|
+
})
|
|
86
|
+
expect(manager.translate('en', 'greeting', { name: 'Carl', extra: 'ignored' })).toBe(
|
|
87
|
+
'Hello Carl!'
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should handle parameter with special regex characters', () => {
|
|
92
|
+
const manager = new I18nManager({
|
|
93
|
+
defaultLocale: 'en',
|
|
94
|
+
supportedLocales: ['en'],
|
|
95
|
+
translations: { en: { msg: 'Value: :value' } },
|
|
96
|
+
})
|
|
97
|
+
expect(manager.translate('en', 'msg', { value: '$100.00' })).toBe('Value: $100.00')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { I18nManager } from '../../src/I18nService'
|
|
3
|
+
|
|
4
|
+
describe('Fallback Strategies', () => {
|
|
5
|
+
it('uses default fallback chain (defaultLocale)', () => {
|
|
6
|
+
const manager = new I18nManager({
|
|
7
|
+
defaultLocale: 'en',
|
|
8
|
+
supportedLocales: ['en', 'fr'],
|
|
9
|
+
translations: {
|
|
10
|
+
en: { hello: 'Hello' },
|
|
11
|
+
fr: {},
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
expect(manager.translate('fr', 'hello')).toBe('Hello')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('uses configured fallback chain', () => {
|
|
18
|
+
const manager = new I18nManager({
|
|
19
|
+
defaultLocale: 'en',
|
|
20
|
+
supportedLocales: ['en', 'es', 'pt'],
|
|
21
|
+
translations: {
|
|
22
|
+
en: { hello: 'Hello' },
|
|
23
|
+
es: { hello: 'Hola' },
|
|
24
|
+
pt: {},
|
|
25
|
+
},
|
|
26
|
+
fallback: {
|
|
27
|
+
fallbackChain: {
|
|
28
|
+
pt: ['es', 'en'],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
expect(manager.translate('pt', 'hello')).toBe('Hola')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('handles missing key strategies', () => {
|
|
36
|
+
const manager = new I18nManager({
|
|
37
|
+
defaultLocale: 'en',
|
|
38
|
+
supportedLocales: ['en'],
|
|
39
|
+
translations: { en: {} },
|
|
40
|
+
fallback: {
|
|
41
|
+
onMissingKey: 'empty',
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
expect(manager.translate('en', 'missing')).toBe('')
|
|
45
|
+
|
|
46
|
+
const managerThrow = new I18nManager({
|
|
47
|
+
defaultLocale: 'en',
|
|
48
|
+
supportedLocales: ['en'],
|
|
49
|
+
translations: { en: {} },
|
|
50
|
+
fallback: {
|
|
51
|
+
onMissingKey: 'throw',
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
expect(() => managerThrow.translate('en', 'missing')).toThrow()
|
|
55
|
+
|
|
56
|
+
const managerCustom = new I18nManager({
|
|
57
|
+
defaultLocale: 'en',
|
|
58
|
+
supportedLocales: ['en'],
|
|
59
|
+
translations: { en: {} },
|
|
60
|
+
fallback: {
|
|
61
|
+
onMissingKey: (key, locale) => `Missing ${key} in ${locale}`,
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
expect(managerCustom.translate('en', 'missing')).toBe('Missing missing in en')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { I18nManager } from '../../src/I18nService'
|
|
7
|
+
|
|
8
|
+
describe('Lazy Loading', () => {
|
|
9
|
+
it('loads translations on demand', async () => {
|
|
10
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'gravito-i18n-lazy-'))
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
writeFileSync(join(tmpDir, 'fr.json'), JSON.stringify({ greeting: 'Bonjour' }))
|
|
14
|
+
|
|
15
|
+
const manager = new I18nManager({
|
|
16
|
+
defaultLocale: 'en',
|
|
17
|
+
supportedLocales: ['en', 'fr'],
|
|
18
|
+
lazyLoad: {
|
|
19
|
+
baseDir: tmpDir,
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
expect(manager.translate('fr', 'greeting')).toBe('greeting')
|
|
24
|
+
|
|
25
|
+
await manager.ensureLocale('fr')
|
|
26
|
+
|
|
27
|
+
expect(manager.translate('fr', 'greeting')).toBe('Bonjour')
|
|
28
|
+
|
|
29
|
+
await manager.ensureLocale('fr')
|
|
30
|
+
expect(manager.translate('fr', 'greeting')).toBe('Bonjour')
|
|
31
|
+
} finally {
|
|
32
|
+
await rm(tmpDir, { force: true, recursive: true })
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -3,7 +3,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
|
3
3
|
import { rm } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
-
import { loadTranslations } from '
|
|
6
|
+
import { loadTranslations } from '../../src/loader'
|
|
7
7
|
|
|
8
8
|
describe('loadTranslations', () => {
|
|
9
9
|
it('loads json translation files from a directory', async () => {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { I18nManager } from '../../src/I18nService'
|
|
3
|
+
|
|
4
|
+
describe('Pluralization', () => {
|
|
5
|
+
it('handles plural forms correctly', () => {
|
|
6
|
+
const translations = {
|
|
7
|
+
en: {
|
|
8
|
+
items: {
|
|
9
|
+
zero: 'No items',
|
|
10
|
+
one: '1 item',
|
|
11
|
+
other: ':count items',
|
|
12
|
+
},
|
|
13
|
+
apples: {
|
|
14
|
+
one: 'an apple',
|
|
15
|
+
other: ':count apples',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
fr: {
|
|
19
|
+
// French treats 0 and 1 as singular (usually)
|
|
20
|
+
items: {
|
|
21
|
+
one: 'un élément', // used for 0 and 1 in standard FR rule? Wait.
|
|
22
|
+
other: ':count éléments',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const manager = new I18nManager({
|
|
28
|
+
defaultLocale: 'en',
|
|
29
|
+
supportedLocales: ['en', 'fr'],
|
|
30
|
+
translations,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// English
|
|
34
|
+
expect(manager.translate('en', 'items', { count: 0 })).toBe('No items') // explicit zero
|
|
35
|
+
expect(manager.translate('en', 'items', { count: 1 })).toBe('1 item')
|
|
36
|
+
expect(manager.translate('en', 'items', { count: 2 })).toBe('2 items')
|
|
37
|
+
expect(manager.translate('en', 'items', { count: 10 })).toBe('10 items')
|
|
38
|
+
|
|
39
|
+
// Missing zero key uses 'other' or 'one' based on rules?
|
|
40
|
+
// English rules: one (n=1), other (n!=1). 0 falls to other.
|
|
41
|
+
expect(manager.translate('en', 'apples', { count: 0 })).toBe('0 apples')
|
|
42
|
+
expect(manager.translate('en', 'apples', { count: 1 })).toBe('an apple')
|
|
43
|
+
|
|
44
|
+
// French
|
|
45
|
+
// French rules: one (n>=0 && n<2), other (everything else)
|
|
46
|
+
// So 0 is one. 1 is one. 2 is other.
|
|
47
|
+
// Wait, let's verify node/bun Intl behavior.
|
|
48
|
+
|
|
49
|
+
// In our code, we check 'zero' key FIRST if count === 0.
|
|
50
|
+
// But 'fr' translations above don't have 'zero' key.
|
|
51
|
+
// So it should fall to `pluralMap[form]`.
|
|
52
|
+
// For fr, 0 -> 'one'.
|
|
53
|
+
|
|
54
|
+
expect(manager.translate('fr', 'items', { count: 0 })).toBe('un élément')
|
|
55
|
+
expect(manager.translate('fr', 'items', { count: 1 })).toBe('un élément')
|
|
56
|
+
expect(manager.translate('fr', 'items', { count: 2 })).toBe('2 éléments')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, jest } from 'bun:test'
|
|
2
|
-
import { I18nInstance, I18nManager, localeMiddleware } from '
|
|
3
|
-
import { I18nOrbit, OrbitCosmos } from '
|
|
2
|
+
import { I18nInstance, I18nManager, localeMiddleware } from '../../src/I18nService'
|
|
3
|
+
import { I18nOrbit, OrbitCosmos } from '../../src/index'
|
|
4
4
|
|
|
5
5
|
describe('I18nManager and I18nInstance', () => {
|
|
6
6
|
const baseConfig = {
|
package/tsconfig.json
CHANGED
|
@@ -1,26 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
],
|
|
10
|
-
"@gravito/*": [
|
|
11
|
-
"../../packages/*/src/index.ts"
|
|
12
|
-
]
|
|
13
|
-
},
|
|
14
|
-
"types": [
|
|
15
|
-
"bun-types"
|
|
16
|
-
]
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"baseUrl": ".",
|
|
6
|
+
"paths": {
|
|
7
|
+
"@gravito/core": ["../../packages/core/src/index.ts"],
|
|
8
|
+
"@gravito/*": ["../../packages/*/src/index.ts"]
|
|
17
9
|
},
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"dist",
|
|
24
|
-
"**/*.test.ts"
|
|
25
|
-
]
|
|
26
|
-
}
|
|
10
|
+
"types": ["bun-types"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*"],
|
|
13
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
14
|
+
}
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
[0m[2m[35m$[0m [2m[1mbun run build.ts[0m
|
|
3
|
-
Building @gravito/cosmos...
|
|
4
|
-
[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K⠸[1G[0K⠼[1G[0K⠴[1G[0K⠦[1G[0K⠧[1G[0K⠇[1G[0K⠏[1G[0K⠋[1G[0K⠙[1G[0K⠹[1G[0K[34mCLI[39m Building entry: src/index.ts
|
|
5
|
-
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
6
|
-
[34mCLI[39m tsup v8.5.1
|
|
7
|
-
[34mCLI[39m Target: esnext
|
|
8
|
-
[34mESM[39m Build start
|
|
9
|
-
[34mCJS[39m Build start
|
|
10
|
-
[33mESM[39m [33mYou have emitDecoratorMetadata enabled but @swc/core was not installed, skipping swc plugin[39m
|
|
11
|
-
[33mCJS[39m [33mYou have emitDecoratorMetadata enabled but @swc/core was not installed, skipping swc plugin[39m
|
|
12
|
-
[32mESM[39m [1mdist/index.js [22m[32m6.93 KB[39m
|
|
13
|
-
[32mESM[39m ⚡️ Build success in 72ms
|
|
14
|
-
[32mCJS[39m [1mdist/index.cjs [22m[32m8.20 KB[39m
|
|
15
|
-
[32mCJS[39m ⚡️ Build success in 72ms
|
|
16
|
-
DTS Build start
|
|
17
|
-
DTS ⚡️ Build success in 7986ms
|
|
18
|
-
DTS dist/index.d.ts 7.87 KB
|
|
19
|
-
DTS dist/index.d.cts 7.87 KB
|
|
20
|
-
[1G[0K⠙[1G[0K✅ Build complete!
|