@hazeljs/i18n 0.2.0-beta.57
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/LICENSE +192 -0
- package/README.md +292 -0
- package/dist/decorators/lang.decorator.d.ts +23 -0
- package/dist/decorators/lang.decorator.d.ts.map +1 -0
- package/dist/decorators/lang.decorator.js +40 -0
- package/dist/decorators/lang.decorator.test.d.ts +2 -0
- package/dist/decorators/lang.decorator.test.d.ts.map +1 -0
- package/dist/decorators/lang.decorator.test.js +61 -0
- package/dist/i18n.interceptor.d.ts +29 -0
- package/dist/i18n.interceptor.d.ts.map +1 -0
- package/dist/i18n.interceptor.js +52 -0
- package/dist/i18n.interceptor.test.d.ts +2 -0
- package/dist/i18n.interceptor.test.d.ts.map +1 -0
- package/dist/i18n.interceptor.test.js +126 -0
- package/dist/i18n.middleware.d.ts +55 -0
- package/dist/i18n.middleware.d.ts.map +1 -0
- package/dist/i18n.middleware.js +120 -0
- package/dist/i18n.middleware.test.d.ts +2 -0
- package/dist/i18n.middleware.test.d.ts.map +1 -0
- package/dist/i18n.middleware.test.js +239 -0
- package/dist/i18n.module.d.ts +73 -0
- package/dist/i18n.module.d.ts.map +1 -0
- package/dist/i18n.module.js +154 -0
- package/dist/i18n.module.test.d.ts +2 -0
- package/dist/i18n.module.test.d.ts.map +1 -0
- package/dist/i18n.module.test.js +162 -0
- package/dist/i18n.service.d.ts +112 -0
- package/dist/i18n.service.d.ts.map +1 -0
- package/dist/i18n.service.js +228 -0
- package/dist/i18n.service.test.d.ts +2 -0
- package/dist/i18n.service.test.d.ts.map +1 -0
- package/dist/i18n.service.test.js +297 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/translation.loader.d.ts +16 -0
- package/dist/translation.loader.d.ts.map +1 -0
- package/dist/translation.loader.js +47 -0
- package/dist/translation.loader.test.d.ts +2 -0
- package/dist/translation.loader.test.d.ts.map +1 -0
- package/dist/translation.loader.test.js +85 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +51 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.I18nFormatter = exports.I18nService = void 0;
|
|
13
|
+
const core_1 = require("@hazeljs/core");
|
|
14
|
+
/**
|
|
15
|
+
* The central translation and formatting service.
|
|
16
|
+
*
|
|
17
|
+
* Inject this service into any controller or provider to translate keys,
|
|
18
|
+
* apply interpolation and pluralization, and format numbers / dates / currency
|
|
19
|
+
* using the native Intl API — with zero external dependencies.
|
|
20
|
+
*/
|
|
21
|
+
let I18nService = class I18nService {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.store = new Map();
|
|
24
|
+
this.format = new I18nFormatter(this);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Called by I18nModule after translations have been loaded from disk.
|
|
28
|
+
*/
|
|
29
|
+
initialize(store, options) {
|
|
30
|
+
this.store = store;
|
|
31
|
+
this.options = options;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Translate a dot-notation key for the given locale.
|
|
35
|
+
*
|
|
36
|
+
* @param key Dot-separated translation key, e.g. "errors.notFound".
|
|
37
|
+
* @param opts Optional count (plural), vars (interpolation) and locale override.
|
|
38
|
+
* @returns The translated string, or the key itself if not found.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* i18n.t('welcome', { vars: { name: 'Alice' } })
|
|
42
|
+
* // → "Welcome, Alice!" (from en.json: { "welcome": "Welcome, {name}!" })
|
|
43
|
+
*
|
|
44
|
+
* i18n.t('items', { count: 3, vars: { count: '3' } })
|
|
45
|
+
* // → "3 items" (from en.json: { "items": { "one": "1 item", "other": "{count} items" } })
|
|
46
|
+
*/
|
|
47
|
+
t(key, opts = {}) {
|
|
48
|
+
const locale = opts.locale ?? this.getCurrentLocale();
|
|
49
|
+
const raw = this.resolve(key, locale);
|
|
50
|
+
if (raw === null)
|
|
51
|
+
return key;
|
|
52
|
+
const selected = this.selectPluralForm(raw, opts.count);
|
|
53
|
+
return this.interpolate(selected, opts.vars);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Returns the current request-scoped locale, falling back to the default.
|
|
57
|
+
* When used with the LocaleMiddleware the locale is stored on the current
|
|
58
|
+
* async context; outside a request context the default locale is used.
|
|
59
|
+
*/
|
|
60
|
+
getCurrentLocale() {
|
|
61
|
+
return this.options?.defaultLocale ?? 'en';
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Returns all translation keys available for a given locale (flattened to
|
|
65
|
+
* dot-notation paths), useful for debugging and tooling.
|
|
66
|
+
*/
|
|
67
|
+
getKeys(locale) {
|
|
68
|
+
const targetLocale = locale ?? this.getCurrentLocale();
|
|
69
|
+
const map = this.store.get(targetLocale);
|
|
70
|
+
if (!map)
|
|
71
|
+
return [];
|
|
72
|
+
return this.flattenKeys(map);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Returns all loaded locale codes.
|
|
76
|
+
*/
|
|
77
|
+
getLocales() {
|
|
78
|
+
return Array.from(this.store.keys());
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Checks whether a key exists for the given locale.
|
|
82
|
+
*/
|
|
83
|
+
has(key, locale) {
|
|
84
|
+
return this.resolve(key, locale ?? this.getCurrentLocale()) !== null;
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Private helpers
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
/**
|
|
90
|
+
* Traverses the translation map using a dot-notation key.
|
|
91
|
+
* Falls back to the fallbackLocale when the key is missing.
|
|
92
|
+
*/
|
|
93
|
+
resolve(key, locale) {
|
|
94
|
+
const value = this.resolveInMap(key, this.store.get(locale));
|
|
95
|
+
if (value !== null)
|
|
96
|
+
return value;
|
|
97
|
+
// Try the fallback locale
|
|
98
|
+
const fallback = this.options?.fallbackLocale;
|
|
99
|
+
if (fallback && fallback !== locale) {
|
|
100
|
+
return this.resolveInMap(key, this.store.get(fallback));
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
resolveInMap(key, map) {
|
|
105
|
+
if (!map)
|
|
106
|
+
return null;
|
|
107
|
+
const parts = key.split('.');
|
|
108
|
+
let node = map;
|
|
109
|
+
for (const part of parts) {
|
|
110
|
+
if (node === null || node === undefined || typeof node !== 'object' || Array.isArray(node)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const record = node;
|
|
114
|
+
node = record[part];
|
|
115
|
+
}
|
|
116
|
+
return node !== undefined ? node : null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Given a translation value that may be a plain string or a plural object,
|
|
120
|
+
* returns the appropriate form.
|
|
121
|
+
*
|
|
122
|
+
* Uses the native Intl.PluralRules API, defaulting to "other" when
|
|
123
|
+
* the value is not a plural object.
|
|
124
|
+
*/
|
|
125
|
+
selectPluralForm(value, count) {
|
|
126
|
+
if (typeof value === 'string')
|
|
127
|
+
return value;
|
|
128
|
+
if (typeof value === 'object' && ('one' in value || 'other' in value)) {
|
|
129
|
+
const pluralObj = value;
|
|
130
|
+
if (count === undefined) {
|
|
131
|
+
return pluralObj['other'] ?? pluralObj['one'] ?? String(value);
|
|
132
|
+
}
|
|
133
|
+
const locale = this.getCurrentLocale();
|
|
134
|
+
let rule;
|
|
135
|
+
try {
|
|
136
|
+
rule = new Intl.PluralRules(locale).select(count);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
rule = 'other';
|
|
140
|
+
}
|
|
141
|
+
return pluralObj[rule] ?? pluralObj['other'] ?? pluralObj['one'] ?? String(value);
|
|
142
|
+
}
|
|
143
|
+
// Nested object used as a namespace — return stringified (edge case)
|
|
144
|
+
return JSON.stringify(value);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Replaces {placeholder} tokens in a string with values from vars.
|
|
148
|
+
*/
|
|
149
|
+
interpolate(template, vars) {
|
|
150
|
+
if (!vars)
|
|
151
|
+
return template;
|
|
152
|
+
return template.replace(/\{(\w+)\}/g, (_, key) => {
|
|
153
|
+
const val = vars[key];
|
|
154
|
+
return val !== undefined ? String(val) : `{${key}}`;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
flattenKeys(map, prefix = '') {
|
|
158
|
+
const keys = [];
|
|
159
|
+
for (const [k, v] of Object.entries(map)) {
|
|
160
|
+
const fullKey = prefix ? `${prefix}.${k}` : k;
|
|
161
|
+
if (typeof v === 'object' && v !== null && !('one' in v) && !('other' in v)) {
|
|
162
|
+
keys.push(...this.flattenKeys(v, fullKey));
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
keys.push(fullKey);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return keys;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
exports.I18nService = I18nService;
|
|
172
|
+
exports.I18nService = I18nService = __decorate([
|
|
173
|
+
(0, core_1.Service)(),
|
|
174
|
+
__metadata("design:paramtypes", [])
|
|
175
|
+
], I18nService);
|
|
176
|
+
/**
|
|
177
|
+
* Exposes Intl-backed formatters, accessible as `i18nService.format.*`.
|
|
178
|
+
*/
|
|
179
|
+
class I18nFormatter {
|
|
180
|
+
constructor(service) {
|
|
181
|
+
this.service = service;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Format a number using Intl.NumberFormat.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* i18n.format.number(1234567.89, 'de', { maximumFractionDigits: 2 })
|
|
188
|
+
* // → "1.234.567,89"
|
|
189
|
+
*/
|
|
190
|
+
number(value, locale, opts) {
|
|
191
|
+
const loc = locale ?? this.service.getCurrentLocale();
|
|
192
|
+
return new Intl.NumberFormat(loc, opts).format(value);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Format a date using Intl.DateTimeFormat.
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* i18n.format.date(new Date(), 'fr', { dateStyle: 'long' })
|
|
199
|
+
* // → "4 mars 2026"
|
|
200
|
+
*/
|
|
201
|
+
date(value, locale, opts) {
|
|
202
|
+
const loc = locale ?? this.service.getCurrentLocale();
|
|
203
|
+
return new Intl.DateTimeFormat(loc, opts).format(value);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Format a monetary value using Intl.NumberFormat with style 'currency'.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* i18n.format.currency(49.99, 'en', 'USD')
|
|
210
|
+
* // → "$49.99"
|
|
211
|
+
*/
|
|
212
|
+
currency(value, locale, currency = 'USD') {
|
|
213
|
+
const loc = locale ?? this.service.getCurrentLocale();
|
|
214
|
+
return new Intl.NumberFormat(loc, { style: 'currency', currency }).format(value);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Format a relative time using Intl.RelativeTimeFormat.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* i18n.format.relative(-3, 'day', 'en')
|
|
221
|
+
* // → "3 days ago"
|
|
222
|
+
*/
|
|
223
|
+
relative(value, unit, locale, opts) {
|
|
224
|
+
const loc = locale ?? this.service.getCurrentLocale();
|
|
225
|
+
return new Intl.RelativeTimeFormat(loc, opts).format(value, unit);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
exports.I18nFormatter = I18nFormatter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.service.test.d.ts","sourceRoot":"","sources":["../src/i18n.service.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
jest.mock('@hazeljs/core', () => ({
|
|
4
|
+
__esModule: true,
|
|
5
|
+
Service: () => () => undefined,
|
|
6
|
+
}));
|
|
7
|
+
const i18n_service_1 = require("./i18n.service");
|
|
8
|
+
const DEFAULT_OPTIONS = {
|
|
9
|
+
defaultLocale: 'en',
|
|
10
|
+
fallbackLocale: 'en',
|
|
11
|
+
translationsPath: './translations',
|
|
12
|
+
detection: ['query', 'cookie', 'header'],
|
|
13
|
+
queryParam: 'lang',
|
|
14
|
+
cookieName: 'locale',
|
|
15
|
+
isGlobal: true,
|
|
16
|
+
};
|
|
17
|
+
function makeStore(entries) {
|
|
18
|
+
const store = new Map();
|
|
19
|
+
for (const [locale, map] of Object.entries(entries)) {
|
|
20
|
+
store.set(locale, map);
|
|
21
|
+
}
|
|
22
|
+
return store;
|
|
23
|
+
}
|
|
24
|
+
describe('I18nService', () => {
|
|
25
|
+
let service;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
service = new i18n_service_1.I18nService();
|
|
28
|
+
});
|
|
29
|
+
describe('getCurrentLocale()', () => {
|
|
30
|
+
it('returns "en" when not initialized', () => {
|
|
31
|
+
expect(service.getCurrentLocale()).toBe('en');
|
|
32
|
+
});
|
|
33
|
+
it('returns the configured defaultLocale', () => {
|
|
34
|
+
service.initialize(new Map(), { ...DEFAULT_OPTIONS, defaultLocale: 'fr' });
|
|
35
|
+
expect(service.getCurrentLocale()).toBe('fr');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('getLocales()', () => {
|
|
39
|
+
it('returns empty array when no translations loaded', () => {
|
|
40
|
+
service.initialize(new Map(), DEFAULT_OPTIONS);
|
|
41
|
+
expect(service.getLocales()).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
it('returns all locale codes from the store', () => {
|
|
44
|
+
const store = makeStore({ en: {}, fr: {}, de: {} });
|
|
45
|
+
service.initialize(store, DEFAULT_OPTIONS);
|
|
46
|
+
expect(service.getLocales()).toEqual(expect.arrayContaining(['en', 'fr', 'de']));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('getKeys()', () => {
|
|
50
|
+
it('returns empty array for unknown locale', () => {
|
|
51
|
+
service.initialize(new Map(), DEFAULT_OPTIONS);
|
|
52
|
+
expect(service.getKeys('xx')).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
it('returns flat keys for simple map', () => {
|
|
55
|
+
const store = makeStore({ en: { hello: 'Hello', bye: 'Goodbye' } });
|
|
56
|
+
service.initialize(store, DEFAULT_OPTIONS);
|
|
57
|
+
expect(service.getKeys('en')).toEqual(expect.arrayContaining(['hello', 'bye']));
|
|
58
|
+
});
|
|
59
|
+
it('returns dot-notation keys for nested map', () => {
|
|
60
|
+
const store = makeStore({ en: { errors: { notFound: 'Not found', invalid: 'Invalid' } } });
|
|
61
|
+
service.initialize(store, DEFAULT_OPTIONS);
|
|
62
|
+
expect(service.getKeys('en')).toEqual(expect.arrayContaining(['errors.notFound', 'errors.invalid']));
|
|
63
|
+
});
|
|
64
|
+
it('does not flatten plural objects (one/other keys)', () => {
|
|
65
|
+
const store = makeStore({ en: { items: { one: '1 item', other: '{count} items' } } });
|
|
66
|
+
service.initialize(store, DEFAULT_OPTIONS);
|
|
67
|
+
expect(service.getKeys('en')).toContain('items');
|
|
68
|
+
});
|
|
69
|
+
it('uses getCurrentLocale() when no locale arg given', () => {
|
|
70
|
+
const store = makeStore({ en: { hi: 'Hi' } });
|
|
71
|
+
service.initialize(store, { ...DEFAULT_OPTIONS, defaultLocale: 'en' });
|
|
72
|
+
expect(service.getKeys()).toContain('hi');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('has()', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
const store = makeStore({ en: { greeting: 'Hello', errors: { notFound: 'Not found' } } });
|
|
78
|
+
service.initialize(store, DEFAULT_OPTIONS);
|
|
79
|
+
});
|
|
80
|
+
it('returns true for existing key', () => {
|
|
81
|
+
expect(service.has('greeting')).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
it('returns true for nested key', () => {
|
|
84
|
+
expect(service.has('errors.notFound')).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
it('returns false for missing key', () => {
|
|
87
|
+
expect(service.has('nonexistent')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
it('returns false for a locale with no translations when fallback is the same', () => {
|
|
90
|
+
// fallbackLocale matches the requested locale → no cross-locale fallback
|
|
91
|
+
const svc = new i18n_service_1.I18nService();
|
|
92
|
+
const s = makeStore({ en: { greeting: 'Hello' } });
|
|
93
|
+
svc.initialize(s, { ...DEFAULT_OPTIONS, defaultLocale: 'fr', fallbackLocale: 'fr' });
|
|
94
|
+
expect(svc.has('greeting', 'fr')).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('t()', () => {
|
|
98
|
+
describe('basic translation', () => {
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
const store = makeStore({
|
|
101
|
+
en: {
|
|
102
|
+
welcome: 'Welcome!',
|
|
103
|
+
nested: { key: 'Nested value' },
|
|
104
|
+
},
|
|
105
|
+
fr: {
|
|
106
|
+
welcome: 'Bienvenue !',
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
service.initialize(store, DEFAULT_OPTIONS);
|
|
110
|
+
});
|
|
111
|
+
it('returns translated string', () => {
|
|
112
|
+
expect(service.t('welcome')).toBe('Welcome!');
|
|
113
|
+
});
|
|
114
|
+
it('returns the key when not found', () => {
|
|
115
|
+
expect(service.t('missing.key')).toBe('missing.key');
|
|
116
|
+
});
|
|
117
|
+
it('translates a nested key', () => {
|
|
118
|
+
expect(service.t('nested.key')).toBe('Nested value');
|
|
119
|
+
});
|
|
120
|
+
it('uses locale override from opts', () => {
|
|
121
|
+
expect(service.t('welcome', { locale: 'fr' })).toBe('Bienvenue !');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('interpolation', () => {
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
const store = makeStore({
|
|
127
|
+
en: {
|
|
128
|
+
greeting: 'Hello, {name}!',
|
|
129
|
+
multi: '{a} and {b}',
|
|
130
|
+
missing_var: 'Hello, {unknown}!',
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
service.initialize(store, DEFAULT_OPTIONS);
|
|
134
|
+
});
|
|
135
|
+
it('replaces placeholders with vars', () => {
|
|
136
|
+
expect(service.t('greeting', { vars: { name: 'Alice' } })).toBe('Hello, Alice!');
|
|
137
|
+
});
|
|
138
|
+
it('replaces multiple placeholders', () => {
|
|
139
|
+
expect(service.t('multi', { vars: { a: 'foo', b: 'bar' } })).toBe('foo and bar');
|
|
140
|
+
});
|
|
141
|
+
it('leaves unmatched placeholders as-is', () => {
|
|
142
|
+
expect(service.t('missing_var', { vars: {} })).toBe('Hello, {unknown}!');
|
|
143
|
+
});
|
|
144
|
+
it('accepts numeric var values', () => {
|
|
145
|
+
expect(service.t('greeting', { vars: { name: 42 } })).toBe('Hello, 42!');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('pluralization', () => {
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
const store = makeStore({
|
|
151
|
+
en: {
|
|
152
|
+
items: { one: '1 item', other: '{count} items' },
|
|
153
|
+
cats: { one: 'one cat', other: 'many cats' },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
service.initialize(store, { ...DEFAULT_OPTIONS, defaultLocale: 'en' });
|
|
157
|
+
});
|
|
158
|
+
it('selects "one" form for count=1', () => {
|
|
159
|
+
expect(service.t('items', { count: 1, vars: { count: '1' } })).toBe('1 item');
|
|
160
|
+
});
|
|
161
|
+
it('selects "other" form for count > 1', () => {
|
|
162
|
+
expect(service.t('items', { count: 3, vars: { count: '3' } })).toBe('3 items');
|
|
163
|
+
});
|
|
164
|
+
it('selects "other" form when count is undefined', () => {
|
|
165
|
+
expect(service.t('cats')).toBe('many cats');
|
|
166
|
+
});
|
|
167
|
+
it('falls back to "one" if "other" missing and count undefined', () => {
|
|
168
|
+
const store = makeStore({ en: { only_one: { one: 'just one' } } });
|
|
169
|
+
service.initialize(store, DEFAULT_OPTIONS);
|
|
170
|
+
expect(service.t('only_one')).toBe('just one');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
describe('fallback locale', () => {
|
|
174
|
+
it('falls back to fallbackLocale when key is missing in requested locale', () => {
|
|
175
|
+
const store = makeStore({
|
|
176
|
+
en: { only_en: 'English only' },
|
|
177
|
+
fr: {},
|
|
178
|
+
});
|
|
179
|
+
service.initialize(store, {
|
|
180
|
+
...DEFAULT_OPTIONS,
|
|
181
|
+
defaultLocale: 'fr',
|
|
182
|
+
fallbackLocale: 'en',
|
|
183
|
+
});
|
|
184
|
+
expect(service.t('only_en')).toBe('English only');
|
|
185
|
+
});
|
|
186
|
+
it('returns key when neither locale nor fallback has the key', () => {
|
|
187
|
+
const store = makeStore({ en: {}, fr: {} });
|
|
188
|
+
service.initialize(store, {
|
|
189
|
+
...DEFAULT_OPTIONS,
|
|
190
|
+
defaultLocale: 'fr',
|
|
191
|
+
fallbackLocale: 'en',
|
|
192
|
+
});
|
|
193
|
+
expect(service.t('missing')).toBe('missing');
|
|
194
|
+
});
|
|
195
|
+
it('does not recurse when fallback equals locale', () => {
|
|
196
|
+
const store = makeStore({ en: {} });
|
|
197
|
+
service.initialize(store, {
|
|
198
|
+
...DEFAULT_OPTIONS,
|
|
199
|
+
defaultLocale: 'en',
|
|
200
|
+
fallbackLocale: 'en',
|
|
201
|
+
});
|
|
202
|
+
expect(service.t('missing')).toBe('missing');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
describe('edge cases', () => {
|
|
206
|
+
it('returns JSON.stringify for a nested object value (namespace path)', () => {
|
|
207
|
+
const store = makeStore({ en: { ns: { sub: 'value' } } });
|
|
208
|
+
service.initialize(store, DEFAULT_OPTIONS);
|
|
209
|
+
// Requesting 'ns' returns the object itself, which gets JSON.stringified
|
|
210
|
+
const result = service.t('ns');
|
|
211
|
+
expect(result).toBe(JSON.stringify({ sub: 'value' }));
|
|
212
|
+
});
|
|
213
|
+
it('returns key when traversal hits a null node', () => {
|
|
214
|
+
const store = makeStore({ en: { parent: 'string_not_object' } });
|
|
215
|
+
service.initialize(store, DEFAULT_OPTIONS);
|
|
216
|
+
expect(service.t('parent.child')).toBe('parent.child');
|
|
217
|
+
});
|
|
218
|
+
it('handles invalid Intl locale gracefully (falls back to "other")', () => {
|
|
219
|
+
const store = makeStore({ xx: { items: { one: '1 item', other: 'many items' } } });
|
|
220
|
+
service.initialize(store, { ...DEFAULT_OPTIONS, defaultLocale: 'xx' });
|
|
221
|
+
expect(service.t('items', { count: 1 })).toBeTruthy();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
describe('I18nFormatter', () => {
|
|
227
|
+
let service;
|
|
228
|
+
let formatter;
|
|
229
|
+
beforeEach(() => {
|
|
230
|
+
service = new i18n_service_1.I18nService();
|
|
231
|
+
service.initialize(new Map(), { ...DEFAULT_OPTIONS, defaultLocale: 'en' });
|
|
232
|
+
formatter = service.format;
|
|
233
|
+
});
|
|
234
|
+
describe('number()', () => {
|
|
235
|
+
it('formats a number using default locale', () => {
|
|
236
|
+
const result = formatter.number(1234);
|
|
237
|
+
expect(result).toMatch(/1[,.]?234/);
|
|
238
|
+
});
|
|
239
|
+
it('accepts an explicit locale', () => {
|
|
240
|
+
const result = formatter.number(1234.5, 'de');
|
|
241
|
+
expect(typeof result).toBe('string');
|
|
242
|
+
});
|
|
243
|
+
it('accepts Intl.NumberFormatOptions', () => {
|
|
244
|
+
const result = formatter.number(1234.567, 'en', { maximumFractionDigits: 2 });
|
|
245
|
+
expect(result).toMatch(/1,234\.57/);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
describe('date()', () => {
|
|
249
|
+
it('formats a Date object', () => {
|
|
250
|
+
const d = new Date('2026-03-04T00:00:00Z');
|
|
251
|
+
const result = formatter.date(d, 'en');
|
|
252
|
+
expect(typeof result).toBe('string');
|
|
253
|
+
expect(result.length).toBeGreaterThan(0);
|
|
254
|
+
});
|
|
255
|
+
it('formats a timestamp number', () => {
|
|
256
|
+
const ts = new Date('2026-01-01').getTime();
|
|
257
|
+
const result = formatter.date(ts, 'en');
|
|
258
|
+
expect(typeof result).toBe('string');
|
|
259
|
+
});
|
|
260
|
+
it('uses default locale when none provided', () => {
|
|
261
|
+
const result = formatter.date(new Date());
|
|
262
|
+
expect(typeof result).toBe('string');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
describe('currency()', () => {
|
|
266
|
+
it('formats USD by default', () => {
|
|
267
|
+
const result = formatter.currency(49.99, 'en');
|
|
268
|
+
expect(result).toContain('49.99');
|
|
269
|
+
});
|
|
270
|
+
it('formats EUR with explicit currency code', () => {
|
|
271
|
+
const result = formatter.currency(10, 'de', 'EUR');
|
|
272
|
+
expect(typeof result).toBe('string');
|
|
273
|
+
});
|
|
274
|
+
it('uses default locale when none provided', () => {
|
|
275
|
+
const result = formatter.currency(100);
|
|
276
|
+
expect(typeof result).toBe('string');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
describe('relative()', () => {
|
|
280
|
+
it('formats a relative time', () => {
|
|
281
|
+
const result = formatter.relative(-3, 'day', 'en');
|
|
282
|
+
expect(result).toMatch(/3 days? ago/);
|
|
283
|
+
});
|
|
284
|
+
it('formats future time', () => {
|
|
285
|
+
const result = formatter.relative(2, 'hour', 'en');
|
|
286
|
+
expect(result).toMatch(/2 hours?/);
|
|
287
|
+
});
|
|
288
|
+
it('uses default locale when none provided', () => {
|
|
289
|
+
const result = formatter.relative(1, 'month');
|
|
290
|
+
expect(typeof result).toBe('string');
|
|
291
|
+
});
|
|
292
|
+
it('accepts Intl.RelativeTimeFormatOptions', () => {
|
|
293
|
+
const result = formatter.relative(-1, 'day', 'en', { numeric: 'auto' });
|
|
294
|
+
expect(typeof result).toBe('string');
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hazeljs/i18n — Internationalization module for HazelJS
|
|
3
|
+
*
|
|
4
|
+
* Quick start:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { I18nModule, I18nService, Lang } from '@hazeljs/i18n';
|
|
8
|
+
*
|
|
9
|
+
* \@HazelModule({
|
|
10
|
+
* imports: [
|
|
11
|
+
* I18nModule.forRoot({
|
|
12
|
+
* defaultLocale: 'en',
|
|
13
|
+
* translationsPath: './translations',
|
|
14
|
+
* }),
|
|
15
|
+
* ],
|
|
16
|
+
* })
|
|
17
|
+
* export class AppModule {}
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export { I18nModule } from './i18n.module';
|
|
21
|
+
export { I18nService, I18nFormatter } from './i18n.service';
|
|
22
|
+
export { LocaleMiddleware, getLocaleFromRequest, LOCALE_KEY } from './i18n.middleware';
|
|
23
|
+
export { I18nInterceptor } from './i18n.interceptor';
|
|
24
|
+
export { TranslationLoader } from './translation.loader';
|
|
25
|
+
export { Lang, extractLang } from './decorators/lang.decorator';
|
|
26
|
+
export type { I18nOptions, ResolvedI18nOptions, TranslateOptions, TranslationMap, TranslationValue, LocaleStore, LocaleDetectionStrategy, } from './types';
|
|
27
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACvF,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAChE,YAAY,EACV,WAAW,EACX,mBAAmB,EACnB,gBAAgB,EAChB,cAAc,EACd,gBAAgB,EAChB,WAAW,EACX,uBAAuB,GACxB,MAAM,SAAS,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @hazeljs/i18n — Internationalization module for HazelJS
|
|
4
|
+
*
|
|
5
|
+
* Quick start:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { I18nModule, I18nService, Lang } from '@hazeljs/i18n';
|
|
9
|
+
*
|
|
10
|
+
* \@HazelModule({
|
|
11
|
+
* imports: [
|
|
12
|
+
* I18nModule.forRoot({
|
|
13
|
+
* defaultLocale: 'en',
|
|
14
|
+
* translationsPath: './translations',
|
|
15
|
+
* }),
|
|
16
|
+
* ],
|
|
17
|
+
* })
|
|
18
|
+
* export class AppModule {}
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.extractLang = exports.Lang = exports.TranslationLoader = exports.I18nInterceptor = exports.LOCALE_KEY = exports.getLocaleFromRequest = exports.LocaleMiddleware = exports.I18nFormatter = exports.I18nService = exports.I18nModule = void 0;
|
|
23
|
+
var i18n_module_1 = require("./i18n.module");
|
|
24
|
+
Object.defineProperty(exports, "I18nModule", { enumerable: true, get: function () { return i18n_module_1.I18nModule; } });
|
|
25
|
+
var i18n_service_1 = require("./i18n.service");
|
|
26
|
+
Object.defineProperty(exports, "I18nService", { enumerable: true, get: function () { return i18n_service_1.I18nService; } });
|
|
27
|
+
Object.defineProperty(exports, "I18nFormatter", { enumerable: true, get: function () { return i18n_service_1.I18nFormatter; } });
|
|
28
|
+
var i18n_middleware_1 = require("./i18n.middleware");
|
|
29
|
+
Object.defineProperty(exports, "LocaleMiddleware", { enumerable: true, get: function () { return i18n_middleware_1.LocaleMiddleware; } });
|
|
30
|
+
Object.defineProperty(exports, "getLocaleFromRequest", { enumerable: true, get: function () { return i18n_middleware_1.getLocaleFromRequest; } });
|
|
31
|
+
Object.defineProperty(exports, "LOCALE_KEY", { enumerable: true, get: function () { return i18n_middleware_1.LOCALE_KEY; } });
|
|
32
|
+
var i18n_interceptor_1 = require("./i18n.interceptor");
|
|
33
|
+
Object.defineProperty(exports, "I18nInterceptor", { enumerable: true, get: function () { return i18n_interceptor_1.I18nInterceptor; } });
|
|
34
|
+
var translation_loader_1 = require("./translation.loader");
|
|
35
|
+
Object.defineProperty(exports, "TranslationLoader", { enumerable: true, get: function () { return translation_loader_1.TranslationLoader; } });
|
|
36
|
+
var lang_decorator_1 = require("./decorators/lang.decorator");
|
|
37
|
+
Object.defineProperty(exports, "Lang", { enumerable: true, get: function () { return lang_decorator_1.Lang; } });
|
|
38
|
+
Object.defineProperty(exports, "extractLang", { enumerable: true, get: function () { return lang_decorator_1.extractLang; } });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { LocaleStore } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Loads JSON translation files from a directory and returns a locale store.
|
|
4
|
+
*
|
|
5
|
+
* Each file must be named <locale>.json (e.g. en.json, fr.json, zh-TW.json).
|
|
6
|
+
* Nested objects in the JSON are kept as-is; key lookup via dot-notation is
|
|
7
|
+
* resolved at translation time inside I18nService.
|
|
8
|
+
*/
|
|
9
|
+
export declare class TranslationLoader {
|
|
10
|
+
/**
|
|
11
|
+
* Read all *.json files from the given directory.
|
|
12
|
+
* Returns a Map keyed by locale code (the filename without extension).
|
|
13
|
+
*/
|
|
14
|
+
static load(translationsPath: string): Promise<LocaleStore>;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=translation.loader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"translation.loader.d.ts","sourceRoot":"","sources":["../src/translation.loader.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkB,WAAW,EAAE,MAAM,SAAS,CAAC;AAEtD;;;;;;GAMG;AACH,qBAAa,iBAAiB;IAC5B;;;OAGG;WACU,IAAI,CAAC,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;CAmClE"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TranslationLoader = void 0;
|
|
4
|
+
const promises_1 = require("fs/promises");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
/**
|
|
7
|
+
* Loads JSON translation files from a directory and returns a locale store.
|
|
8
|
+
*
|
|
9
|
+
* Each file must be named <locale>.json (e.g. en.json, fr.json, zh-TW.json).
|
|
10
|
+
* Nested objects in the JSON are kept as-is; key lookup via dot-notation is
|
|
11
|
+
* resolved at translation time inside I18nService.
|
|
12
|
+
*/
|
|
13
|
+
class TranslationLoader {
|
|
14
|
+
/**
|
|
15
|
+
* Read all *.json files from the given directory.
|
|
16
|
+
* Returns a Map keyed by locale code (the filename without extension).
|
|
17
|
+
*/
|
|
18
|
+
static async load(translationsPath) {
|
|
19
|
+
const store = new Map();
|
|
20
|
+
let entries;
|
|
21
|
+
try {
|
|
22
|
+
entries = await (0, promises_1.readdir)(translationsPath);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Directory does not exist — return an empty store so the service starts
|
|
26
|
+
// gracefully without translations (it will return keys as-is).
|
|
27
|
+
return store;
|
|
28
|
+
}
|
|
29
|
+
const jsonFiles = entries.filter((file) => (0, path_1.extname)(file) === '.json');
|
|
30
|
+
await Promise.all(jsonFiles.map(async (file) => {
|
|
31
|
+
const locale = (0, path_1.basename)(file, '.json');
|
|
32
|
+
const filePath = (0, path_1.join)(translationsPath, file);
|
|
33
|
+
try {
|
|
34
|
+
const raw = await (0, promises_1.readFile)(filePath, 'utf-8');
|
|
35
|
+
const translations = JSON.parse(raw);
|
|
36
|
+
store.set(locale, translations);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
// Skip malformed files rather than crashing the application.
|
|
40
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
41
|
+
process.stderr.write(`[@hazeljs/i18n] Failed to load translation file "${filePath}": ${message}\n`);
|
|
42
|
+
}
|
|
43
|
+
}));
|
|
44
|
+
return store;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.TranslationLoader = TranslationLoader;
|