@coherent.js/i18n 1.0.0-beta.2
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 +21 -0
- package/README.md +82 -0
- package/dist/index.js +725 -0
- package/package.json +42 -0
- package/types/index.d.ts +128 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Thomas Drouvin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @coherent.js/i18n
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@coherent.js/i18n)
|
|
4
|
+
[](../../LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
Internationalization utilities for Coherent.js applications.
|
|
8
|
+
|
|
9
|
+
- ESM-only, Node 20+
|
|
10
|
+
- Translator + locale management
|
|
11
|
+
- Date/number/currency/list formatters
|
|
12
|
+
|
|
13
|
+
For a high-level overview and repository-wide instructions, see the root README: ../../README.md
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @coherent.js/i18n
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
JavaScript (ESM):
|
|
24
|
+
```js
|
|
25
|
+
import { createTranslator } from '@coherent.js/i18n/translator';
|
|
26
|
+
|
|
27
|
+
const t = createTranslator({
|
|
28
|
+
en: { hello: 'Hello, {name}!' },
|
|
29
|
+
fr: { hello: 'Bonjour, {name} !' }
|
|
30
|
+
}, { locale: 'en' });
|
|
31
|
+
|
|
32
|
+
console.log(t('hello', { name: 'Coherent' }));
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
TypeScript:
|
|
36
|
+
```ts
|
|
37
|
+
import { createTranslator } from '@coherent.js/i18n/translator';
|
|
38
|
+
|
|
39
|
+
type Messages = {
|
|
40
|
+
hello: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const t = createTranslator<{ en: Messages; fr: Messages }>({
|
|
44
|
+
en: { hello: 'Hello, {name}!' },
|
|
45
|
+
fr: { hello: 'Bonjour, {name} !' }
|
|
46
|
+
}, { locale: 'en' });
|
|
47
|
+
|
|
48
|
+
console.log(t('hello', { name: 'TS' }));
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Formatters and locale
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
import { createFormatters } from '@coherent.js/i18n/formatters';
|
|
55
|
+
import { createLocaleManager } from '@coherent.js/i18n/locale';
|
|
56
|
+
|
|
57
|
+
const locale = createLocaleManager('en-US');
|
|
58
|
+
const fmt = createFormatters(locale.current());
|
|
59
|
+
|
|
60
|
+
fmt.date(new Date());
|
|
61
|
+
fmt.number(12345.678);
|
|
62
|
+
fmt.currency(1999.99, 'USD');
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Exports
|
|
66
|
+
|
|
67
|
+
- `@coherent.js/i18n` (index)
|
|
68
|
+
- `@coherent.js/i18n/translator`
|
|
69
|
+
- `@coherent.js/i18n/formatters`
|
|
70
|
+
- `@coherent.js/i18n/locale`
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pnpm --filter @coherent.js/i18n run test
|
|
76
|
+
pnpm --filter @coherent.js/i18n run test:watch
|
|
77
|
+
pnpm --filter @coherent.js/i18n run typecheck
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT © Coherent.js Team
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
// src/translator.js
|
|
2
|
+
var Translator = class {
|
|
3
|
+
constructor(options = {}) {
|
|
4
|
+
this.options = {
|
|
5
|
+
defaultLocale: "en",
|
|
6
|
+
fallbackLocale: "en",
|
|
7
|
+
missingKeyHandler: null,
|
|
8
|
+
interpolation: {
|
|
9
|
+
prefix: "{{",
|
|
10
|
+
suffix: "}}"
|
|
11
|
+
},
|
|
12
|
+
...options
|
|
13
|
+
};
|
|
14
|
+
this.translations = /* @__PURE__ */ new Map();
|
|
15
|
+
this.currentLocale = this.options.defaultLocale;
|
|
16
|
+
this.loadedLocales = /* @__PURE__ */ new Set();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Add translations for a locale
|
|
20
|
+
*
|
|
21
|
+
* @param {string} locale - Locale code (e.g., 'en', 'fr', 'es')
|
|
22
|
+
* @param {Object} translations - Translation object
|
|
23
|
+
*/
|
|
24
|
+
addTranslations(locale, translations) {
|
|
25
|
+
if (!this.translations.has(locale)) {
|
|
26
|
+
this.translations.set(locale, {});
|
|
27
|
+
}
|
|
28
|
+
const existing = this.translations.get(locale);
|
|
29
|
+
this.translations.set(locale, this.deepMerge(existing, translations));
|
|
30
|
+
this.loadedLocales.add(locale);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Deep merge objects
|
|
34
|
+
*/
|
|
35
|
+
deepMerge(target, source) {
|
|
36
|
+
const result = { ...target };
|
|
37
|
+
for (const [key, value] of Object.entries(source)) {
|
|
38
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
39
|
+
result[key] = this.deepMerge(result[key] || {}, value);
|
|
40
|
+
} else {
|
|
41
|
+
result[key] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Set current locale
|
|
48
|
+
*
|
|
49
|
+
* @param {string} locale - Locale code
|
|
50
|
+
*/
|
|
51
|
+
setLocale(locale) {
|
|
52
|
+
if (!this.loadedLocales.has(locale)) {
|
|
53
|
+
console.warn(`Locale ${locale} not loaded, using fallback`);
|
|
54
|
+
this.currentLocale = this.options.fallbackLocale;
|
|
55
|
+
} else {
|
|
56
|
+
this.currentLocale = locale;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Get current locale
|
|
61
|
+
*
|
|
62
|
+
* @returns {string} Current locale code
|
|
63
|
+
*/
|
|
64
|
+
getLocale() {
|
|
65
|
+
return this.currentLocale;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Translate a key
|
|
69
|
+
*
|
|
70
|
+
* @param {string} key - Translation key (supports dot notation)
|
|
71
|
+
* @param {Object} [params] - Interpolation parameters
|
|
72
|
+
* @param {string} [locale] - Override locale
|
|
73
|
+
* @returns {string} Translated string
|
|
74
|
+
*/
|
|
75
|
+
t(key, params = {}, locale = null) {
|
|
76
|
+
const targetLocale = locale || this.currentLocale;
|
|
77
|
+
let translation = this.getTranslation(key, targetLocale);
|
|
78
|
+
if (translation === null && targetLocale !== this.options.fallbackLocale) {
|
|
79
|
+
translation = this.getTranslation(key, this.options.fallbackLocale);
|
|
80
|
+
}
|
|
81
|
+
if (translation === null) {
|
|
82
|
+
if (this.options.missingKeyHandler) {
|
|
83
|
+
return this.options.missingKeyHandler(key, targetLocale);
|
|
84
|
+
}
|
|
85
|
+
return key;
|
|
86
|
+
}
|
|
87
|
+
if (typeof translation === "object" && params.count !== void 0) {
|
|
88
|
+
translation = this.selectPlural(translation, params.count, targetLocale);
|
|
89
|
+
}
|
|
90
|
+
if (typeof translation === "string") {
|
|
91
|
+
return this.interpolate(translation, params);
|
|
92
|
+
}
|
|
93
|
+
return String(translation);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Get translation from nested object
|
|
97
|
+
*/
|
|
98
|
+
getTranslation(key, locale) {
|
|
99
|
+
const translations = this.translations.get(locale);
|
|
100
|
+
if (!translations) return null;
|
|
101
|
+
const keys = key.split(".");
|
|
102
|
+
let value = translations;
|
|
103
|
+
for (const k of keys) {
|
|
104
|
+
if (value && typeof value === "object" && k in value) {
|
|
105
|
+
value = value[k];
|
|
106
|
+
} else {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Select plural form
|
|
114
|
+
*/
|
|
115
|
+
selectPlural(pluralObject, count, locale) {
|
|
116
|
+
if (count === 0 && pluralObject.zero) {
|
|
117
|
+
return pluralObject.zero;
|
|
118
|
+
}
|
|
119
|
+
if (typeof Intl !== "undefined" && Intl.PluralRules) {
|
|
120
|
+
const rules = new Intl.PluralRules(locale);
|
|
121
|
+
const rule = rules.select(count);
|
|
122
|
+
if (pluralObject[rule]) {
|
|
123
|
+
return pluralObject[rule];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (count === 1 && pluralObject.one) {
|
|
127
|
+
return pluralObject.one;
|
|
128
|
+
} else if (pluralObject.other) {
|
|
129
|
+
return pluralObject.other;
|
|
130
|
+
}
|
|
131
|
+
return pluralObject.one || pluralObject.other || "";
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Interpolate parameters into string
|
|
135
|
+
*/
|
|
136
|
+
interpolate(str, params) {
|
|
137
|
+
const { prefix, suffix } = this.options.interpolation;
|
|
138
|
+
let result = str;
|
|
139
|
+
for (const [key, value] of Object.entries(params)) {
|
|
140
|
+
const placeholder = `${prefix}${key}${suffix}`;
|
|
141
|
+
result = result.replace(new RegExp(placeholder, "g"), String(value));
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Check if translation exists
|
|
147
|
+
*
|
|
148
|
+
* @param {string} key - Translation key
|
|
149
|
+
* @param {string} [locale] - Locale to check
|
|
150
|
+
* @returns {boolean} True if translation exists
|
|
151
|
+
*/
|
|
152
|
+
has(key, locale = null) {
|
|
153
|
+
const targetLocale = locale || this.currentLocale;
|
|
154
|
+
return this.getTranslation(key, targetLocale) !== null;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Get all translations for current locale
|
|
158
|
+
*
|
|
159
|
+
* @returns {Object} All translations
|
|
160
|
+
*/
|
|
161
|
+
getTranslations(locale = null) {
|
|
162
|
+
const targetLocale = locale || this.currentLocale;
|
|
163
|
+
return this.translations.get(targetLocale) || {};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Get all loaded locales
|
|
167
|
+
*
|
|
168
|
+
* @returns {Array<string>} Array of locale codes
|
|
169
|
+
*/
|
|
170
|
+
getLoadedLocales() {
|
|
171
|
+
return Array.from(this.loadedLocales);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Remove translations for a locale
|
|
175
|
+
*
|
|
176
|
+
* @param {string} locale - Locale code
|
|
177
|
+
*/
|
|
178
|
+
removeLocale(locale) {
|
|
179
|
+
this.translations.delete(locale);
|
|
180
|
+
this.loadedLocales.delete(locale);
|
|
181
|
+
if (this.currentLocale === locale) {
|
|
182
|
+
this.currentLocale = this.options.defaultLocale;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Clear all translations
|
|
187
|
+
*/
|
|
188
|
+
clear() {
|
|
189
|
+
this.translations.clear();
|
|
190
|
+
this.loadedLocales.clear();
|
|
191
|
+
this.currentLocale = this.options.defaultLocale;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
function createTranslator(options = {}) {
|
|
195
|
+
return new Translator(options);
|
|
196
|
+
}
|
|
197
|
+
function createScopedTranslator(translator, namespace) {
|
|
198
|
+
return {
|
|
199
|
+
t: (key, params, locale) => {
|
|
200
|
+
return translator.t(`${namespace}.${key}`, params, locale);
|
|
201
|
+
},
|
|
202
|
+
has: (key, locale) => {
|
|
203
|
+
return translator.has(`${namespace}.${key}`, locale);
|
|
204
|
+
},
|
|
205
|
+
getLocale: () => translator.getLocale(),
|
|
206
|
+
setLocale: (locale) => translator.setLocale(locale)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/formatters.js
|
|
211
|
+
var DateFormatter = class {
|
|
212
|
+
constructor(locale = "en") {
|
|
213
|
+
this.locale = locale;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Format a date
|
|
217
|
+
*
|
|
218
|
+
* @param {Date|string|number} date - Date to format
|
|
219
|
+
* @param {Object} [options] - Intl.DateTimeFormat options
|
|
220
|
+
* @returns {string} Formatted date
|
|
221
|
+
*/
|
|
222
|
+
format(date, options = {}) {
|
|
223
|
+
const dateObj = date instanceof Date ? date : new Date(date);
|
|
224
|
+
if (typeof Intl !== "undefined" && Intl.DateTimeFormat) {
|
|
225
|
+
const formatter = new Intl.DateTimeFormat(this.locale, options);
|
|
226
|
+
return formatter.format(dateObj);
|
|
227
|
+
}
|
|
228
|
+
return dateObj.toLocaleDateString();
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Format date as short (e.g., 1/1/2024)
|
|
232
|
+
*/
|
|
233
|
+
short(date) {
|
|
234
|
+
return this.format(date, {
|
|
235
|
+
year: "numeric",
|
|
236
|
+
month: "numeric",
|
|
237
|
+
day: "numeric"
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Format date as medium (e.g., Jan 1, 2024)
|
|
242
|
+
*/
|
|
243
|
+
medium(date) {
|
|
244
|
+
return this.format(date, {
|
|
245
|
+
year: "numeric",
|
|
246
|
+
month: "short",
|
|
247
|
+
day: "numeric"
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Format date as long (e.g., January 1, 2024)
|
|
252
|
+
*/
|
|
253
|
+
long(date) {
|
|
254
|
+
return this.format(date, {
|
|
255
|
+
year: "numeric",
|
|
256
|
+
month: "long",
|
|
257
|
+
day: "numeric"
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Format date as full (e.g., Monday, January 1, 2024)
|
|
262
|
+
*/
|
|
263
|
+
full(date) {
|
|
264
|
+
return this.format(date, {
|
|
265
|
+
weekday: "long",
|
|
266
|
+
year: "numeric",
|
|
267
|
+
month: "long",
|
|
268
|
+
day: "numeric"
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Format time
|
|
273
|
+
*/
|
|
274
|
+
time(date, options = {}) {
|
|
275
|
+
return this.format(date, {
|
|
276
|
+
hour: "numeric",
|
|
277
|
+
minute: "numeric",
|
|
278
|
+
...options
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Format date and time
|
|
283
|
+
*/
|
|
284
|
+
dateTime(date, options = {}) {
|
|
285
|
+
return this.format(date, {
|
|
286
|
+
year: "numeric",
|
|
287
|
+
month: "short",
|
|
288
|
+
day: "numeric",
|
|
289
|
+
hour: "numeric",
|
|
290
|
+
minute: "numeric",
|
|
291
|
+
...options
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Format relative time (e.g., "2 days ago")
|
|
296
|
+
*/
|
|
297
|
+
relative(date) {
|
|
298
|
+
const dateObj = date instanceof Date ? date : new Date(date);
|
|
299
|
+
const now = /* @__PURE__ */ new Date();
|
|
300
|
+
const diffMs = now - dateObj;
|
|
301
|
+
const diffSec = Math.floor(diffMs / 1e3);
|
|
302
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
303
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
304
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
305
|
+
if (typeof Intl !== "undefined" && Intl.RelativeTimeFormat) {
|
|
306
|
+
const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: "auto" });
|
|
307
|
+
if (diffDay > 0) {
|
|
308
|
+
return rtf.format(-diffDay, "day");
|
|
309
|
+
} else if (diffHour > 0) {
|
|
310
|
+
return rtf.format(-diffHour, "hour");
|
|
311
|
+
} else if (diffMin > 0) {
|
|
312
|
+
return rtf.format(-diffMin, "minute");
|
|
313
|
+
} else {
|
|
314
|
+
return rtf.format(-diffSec, "second");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (diffDay > 0) {
|
|
318
|
+
return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
|
|
319
|
+
} else if (diffHour > 0) {
|
|
320
|
+
return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`;
|
|
321
|
+
} else if (diffMin > 0) {
|
|
322
|
+
return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
|
|
323
|
+
} else {
|
|
324
|
+
return "just now";
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
var NumberFormatter = class {
|
|
329
|
+
constructor(locale = "en") {
|
|
330
|
+
this.locale = locale;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Format a number
|
|
334
|
+
*
|
|
335
|
+
* @param {number} value - Number to format
|
|
336
|
+
* @param {Object} [options] - Intl.NumberFormat options
|
|
337
|
+
* @returns {string} Formatted number
|
|
338
|
+
*/
|
|
339
|
+
format(value, options = {}) {
|
|
340
|
+
if (typeof Intl !== "undefined" && Intl.NumberFormat) {
|
|
341
|
+
const formatter = new Intl.NumberFormat(this.locale, options);
|
|
342
|
+
return formatter.format(value);
|
|
343
|
+
}
|
|
344
|
+
return value.toLocaleString();
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Format as decimal
|
|
348
|
+
*/
|
|
349
|
+
decimal(value, decimals = 2) {
|
|
350
|
+
return this.format(value, {
|
|
351
|
+
minimumFractionDigits: decimals,
|
|
352
|
+
maximumFractionDigits: decimals
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Format as percentage
|
|
357
|
+
*/
|
|
358
|
+
percent(value, decimals = 0) {
|
|
359
|
+
return this.format(value, {
|
|
360
|
+
style: "percent",
|
|
361
|
+
minimumFractionDigits: decimals,
|
|
362
|
+
maximumFractionDigits: decimals
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Format as compact (e.g., 1.2K, 3.4M)
|
|
367
|
+
*/
|
|
368
|
+
compact(value) {
|
|
369
|
+
if (typeof Intl !== "undefined" && Intl.NumberFormat) {
|
|
370
|
+
try {
|
|
371
|
+
const formatter = new Intl.NumberFormat(this.locale, {
|
|
372
|
+
notation: "compact",
|
|
373
|
+
compactDisplay: "short"
|
|
374
|
+
});
|
|
375
|
+
return formatter.format(value);
|
|
376
|
+
} catch {
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (value >= 1e9) {
|
|
380
|
+
return `${(value / 1e9).toFixed(1)}B`;
|
|
381
|
+
} else if (value >= 1e6) {
|
|
382
|
+
return `${(value / 1e6).toFixed(1)}M`;
|
|
383
|
+
} else if (value >= 1e3) {
|
|
384
|
+
return `${(value / 1e3).toFixed(1)}K`;
|
|
385
|
+
}
|
|
386
|
+
return String(value);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Format with units
|
|
390
|
+
*/
|
|
391
|
+
unit(value, unit, options = {}) {
|
|
392
|
+
if (typeof Intl !== "undefined" && Intl.NumberFormat) {
|
|
393
|
+
try {
|
|
394
|
+
const formatter = new Intl.NumberFormat(this.locale, {
|
|
395
|
+
style: "unit",
|
|
396
|
+
unit,
|
|
397
|
+
...options
|
|
398
|
+
});
|
|
399
|
+
return formatter.format(value);
|
|
400
|
+
} catch {
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return `${value} ${unit}`;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
var CurrencyFormatter = class {
|
|
407
|
+
constructor(locale = "en", defaultCurrency = "USD") {
|
|
408
|
+
this.locale = locale;
|
|
409
|
+
this.defaultCurrency = defaultCurrency;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Format a currency value
|
|
413
|
+
*
|
|
414
|
+
* @param {number} value - Amount to format
|
|
415
|
+
* @param {string} [currency] - Currency code (e.g., 'USD', 'EUR')
|
|
416
|
+
* @param {Object} [options] - Additional options
|
|
417
|
+
* @returns {string} Formatted currency
|
|
418
|
+
*/
|
|
419
|
+
format(value, currency = null, options = {}) {
|
|
420
|
+
const currencyCode = currency || this.defaultCurrency;
|
|
421
|
+
if (typeof Intl !== "undefined" && Intl.NumberFormat) {
|
|
422
|
+
const formatter = new Intl.NumberFormat(this.locale, {
|
|
423
|
+
style: "currency",
|
|
424
|
+
currency: currencyCode,
|
|
425
|
+
...options
|
|
426
|
+
});
|
|
427
|
+
return formatter.format(value);
|
|
428
|
+
}
|
|
429
|
+
return `${currencyCode} ${value.toFixed(2)}`;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Format without decimal places
|
|
433
|
+
*/
|
|
434
|
+
whole(value, currency = null) {
|
|
435
|
+
return this.format(value, currency, {
|
|
436
|
+
minimumFractionDigits: 0,
|
|
437
|
+
maximumFractionDigits: 0
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Format with symbol only (no code)
|
|
442
|
+
*/
|
|
443
|
+
symbol(value, currency = null) {
|
|
444
|
+
return this.format(value, currency, {
|
|
445
|
+
currencyDisplay: "symbol"
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Format with narrow symbol
|
|
450
|
+
*/
|
|
451
|
+
narrowSymbol(value, currency = null) {
|
|
452
|
+
return this.format(value, currency, {
|
|
453
|
+
currencyDisplay: "narrowSymbol"
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Format with code (e.g., USD 100.00)
|
|
458
|
+
*/
|
|
459
|
+
code(value, currency = null) {
|
|
460
|
+
return this.format(value, currency, {
|
|
461
|
+
currencyDisplay: "code"
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
var ListFormatter = class {
|
|
466
|
+
constructor(locale = "en") {
|
|
467
|
+
this.locale = locale;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Format a list
|
|
471
|
+
*
|
|
472
|
+
* @param {Array} items - Items to format
|
|
473
|
+
* @param {Object} [options] - Formatting options
|
|
474
|
+
* @returns {string} Formatted list
|
|
475
|
+
*/
|
|
476
|
+
format(items, options = {}) {
|
|
477
|
+
if (typeof Intl !== "undefined" && Intl.ListFormat) {
|
|
478
|
+
const formatter = new Intl.ListFormat(this.locale, options);
|
|
479
|
+
return formatter.format(items);
|
|
480
|
+
}
|
|
481
|
+
if (items.length === 0) return "";
|
|
482
|
+
if (items.length === 1) return items[0];
|
|
483
|
+
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
|
484
|
+
const last = items[items.length - 1];
|
|
485
|
+
const rest = items.slice(0, -1);
|
|
486
|
+
return `${rest.join(", ")}, and ${last}`;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Format as conjunction (and)
|
|
490
|
+
*/
|
|
491
|
+
and(items) {
|
|
492
|
+
return this.format(items, { type: "conjunction" });
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Format as disjunction (or)
|
|
496
|
+
*/
|
|
497
|
+
or(items) {
|
|
498
|
+
return this.format(items, { type: "disjunction" });
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Format as unit list
|
|
502
|
+
*/
|
|
503
|
+
unit(items) {
|
|
504
|
+
return this.format(items, { type: "unit" });
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
function createFormatters(locale = "en", options = {}) {
|
|
508
|
+
return {
|
|
509
|
+
date: new DateFormatter(locale),
|
|
510
|
+
number: new NumberFormatter(locale),
|
|
511
|
+
currency: new CurrencyFormatter(locale, options.defaultCurrency),
|
|
512
|
+
list: new ListFormatter(locale)
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/locale.js
|
|
517
|
+
function detectLocale() {
|
|
518
|
+
if (typeof navigator !== "undefined") {
|
|
519
|
+
if (navigator.language) {
|
|
520
|
+
return normalizeLocale(navigator.language);
|
|
521
|
+
}
|
|
522
|
+
if (navigator.languages && navigator.languages.length > 0) {
|
|
523
|
+
return normalizeLocale(navigator.languages[0]);
|
|
524
|
+
}
|
|
525
|
+
if (navigator.userLanguage) {
|
|
526
|
+
return normalizeLocale(navigator.userLanguage);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return "en";
|
|
530
|
+
}
|
|
531
|
+
function normalizeLocale(locale, keepRegion = false) {
|
|
532
|
+
if (!locale) return "en";
|
|
533
|
+
let normalized = locale.toLowerCase().replace("_", "-");
|
|
534
|
+
if (!keepRegion && normalized.includes("-")) {
|
|
535
|
+
normalized = normalized.split("-")[0];
|
|
536
|
+
}
|
|
537
|
+
return normalized;
|
|
538
|
+
}
|
|
539
|
+
function parseLocale(locale) {
|
|
540
|
+
const normalized = locale.replace("_", "-");
|
|
541
|
+
const parts = normalized.split("-");
|
|
542
|
+
return {
|
|
543
|
+
language: parts[0]?.toLowerCase() || "en",
|
|
544
|
+
region: parts[1]?.toUpperCase() || null,
|
|
545
|
+
script: parts.length > 2 ? parts[1] : null,
|
|
546
|
+
full: normalized
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
function getLocaleDirection(locale) {
|
|
550
|
+
const rtlLocales = ["ar", "he", "fa", "ur", "yi"];
|
|
551
|
+
const language = parseLocale(locale).language;
|
|
552
|
+
return rtlLocales.includes(language) ? "rtl" : "ltr";
|
|
553
|
+
}
|
|
554
|
+
function isRTL(locale) {
|
|
555
|
+
return getLocaleDirection(locale) === "rtl";
|
|
556
|
+
}
|
|
557
|
+
function getLocaleDisplayName(locale, displayLocale = "en") {
|
|
558
|
+
if (typeof Intl !== "undefined" && Intl.DisplayNames) {
|
|
559
|
+
try {
|
|
560
|
+
const displayNames = new Intl.DisplayNames([displayLocale], { type: "language" });
|
|
561
|
+
return displayNames.of(locale);
|
|
562
|
+
} catch {
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return locale;
|
|
566
|
+
}
|
|
567
|
+
function matchLocale(requestedLocale, availableLocales, defaultLocale = "en") {
|
|
568
|
+
const normalized = normalizeLocale(requestedLocale);
|
|
569
|
+
if (availableLocales.includes(normalized)) {
|
|
570
|
+
return normalized;
|
|
571
|
+
}
|
|
572
|
+
const withRegion = normalizeLocale(requestedLocale, true);
|
|
573
|
+
if (availableLocales.includes(withRegion)) {
|
|
574
|
+
return withRegion;
|
|
575
|
+
}
|
|
576
|
+
const language = parseLocale(requestedLocale).language;
|
|
577
|
+
const languageMatch = availableLocales.find(
|
|
578
|
+
(locale) => parseLocale(locale).language === language
|
|
579
|
+
);
|
|
580
|
+
if (languageMatch) {
|
|
581
|
+
return languageMatch;
|
|
582
|
+
}
|
|
583
|
+
return availableLocales.includes(defaultLocale) ? defaultLocale : availableLocales[0];
|
|
584
|
+
}
|
|
585
|
+
function getSupportedLocales() {
|
|
586
|
+
if (typeof navigator !== "undefined" && navigator.languages) {
|
|
587
|
+
return navigator.languages.map((locale) => normalizeLocale(locale));
|
|
588
|
+
}
|
|
589
|
+
return [detectLocale()];
|
|
590
|
+
}
|
|
591
|
+
var LocaleManager = class {
|
|
592
|
+
constructor(options = {}) {
|
|
593
|
+
this.options = {
|
|
594
|
+
defaultLocale: "en",
|
|
595
|
+
availableLocales: ["en"],
|
|
596
|
+
storageKey: "coherent-locale",
|
|
597
|
+
autoDetect: true,
|
|
598
|
+
...options
|
|
599
|
+
};
|
|
600
|
+
this.currentLocale = this.options.defaultLocale;
|
|
601
|
+
this.listeners = [];
|
|
602
|
+
if (this.options.autoDetect) {
|
|
603
|
+
this.currentLocale = this.detectAndMatch();
|
|
604
|
+
}
|
|
605
|
+
this.loadFromStorage();
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Detect and match best locale
|
|
609
|
+
*/
|
|
610
|
+
detectAndMatch() {
|
|
611
|
+
const detected = detectLocale();
|
|
612
|
+
return matchLocale(
|
|
613
|
+
detected,
|
|
614
|
+
this.options.availableLocales,
|
|
615
|
+
this.options.defaultLocale
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Get current locale
|
|
620
|
+
*/
|
|
621
|
+
getLocale() {
|
|
622
|
+
return this.currentLocale;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Set locale
|
|
626
|
+
*/
|
|
627
|
+
setLocale(locale) {
|
|
628
|
+
const matched = matchLocale(
|
|
629
|
+
locale,
|
|
630
|
+
this.options.availableLocales,
|
|
631
|
+
this.options.defaultLocale
|
|
632
|
+
);
|
|
633
|
+
if (matched !== this.currentLocale) {
|
|
634
|
+
const oldLocale = this.currentLocale;
|
|
635
|
+
this.currentLocale = matched;
|
|
636
|
+
this.saveToStorage();
|
|
637
|
+
this.notifyListeners(oldLocale, matched);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Add locale change listener
|
|
642
|
+
*/
|
|
643
|
+
onChange(listener) {
|
|
644
|
+
this.listeners.push(listener);
|
|
645
|
+
return () => {
|
|
646
|
+
const index = this.listeners.indexOf(listener);
|
|
647
|
+
if (index > -1) {
|
|
648
|
+
this.listeners.splice(index, 1);
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Notify listeners of locale change
|
|
654
|
+
*/
|
|
655
|
+
notifyListeners(oldLocale, newLocale) {
|
|
656
|
+
this.listeners.forEach((listener) => {
|
|
657
|
+
try {
|
|
658
|
+
listener(newLocale, oldLocale);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
console.error("Error in locale change listener:", error);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Save locale to storage
|
|
666
|
+
*/
|
|
667
|
+
saveToStorage() {
|
|
668
|
+
if (typeof localStorage !== "undefined") {
|
|
669
|
+
try {
|
|
670
|
+
localStorage.setItem(this.options.storageKey, this.currentLocale);
|
|
671
|
+
} catch {
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Load locale from storage
|
|
677
|
+
*/
|
|
678
|
+
loadFromStorage() {
|
|
679
|
+
if (typeof localStorage !== "undefined") {
|
|
680
|
+
try {
|
|
681
|
+
const stored = localStorage.getItem(this.options.storageKey);
|
|
682
|
+
if (stored) {
|
|
683
|
+
this.setLocale(stored);
|
|
684
|
+
}
|
|
685
|
+
} catch {
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Get available locales
|
|
691
|
+
*/
|
|
692
|
+
getAvailableLocales() {
|
|
693
|
+
return [...this.options.availableLocales];
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Check if locale is available
|
|
697
|
+
*/
|
|
698
|
+
isAvailable(locale) {
|
|
699
|
+
return this.options.availableLocales.includes(locale);
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
function createLocaleManager(options = {}) {
|
|
703
|
+
return new LocaleManager(options);
|
|
704
|
+
}
|
|
705
|
+
export {
|
|
706
|
+
CurrencyFormatter,
|
|
707
|
+
DateFormatter,
|
|
708
|
+
ListFormatter,
|
|
709
|
+
LocaleManager,
|
|
710
|
+
NumberFormatter,
|
|
711
|
+
Translator,
|
|
712
|
+
createFormatters,
|
|
713
|
+
createLocaleManager,
|
|
714
|
+
createScopedTranslator,
|
|
715
|
+
createTranslator,
|
|
716
|
+
detectLocale,
|
|
717
|
+
getLocaleDirection,
|
|
718
|
+
getLocaleDisplayName,
|
|
719
|
+
getSupportedLocales,
|
|
720
|
+
isRTL,
|
|
721
|
+
matchLocale,
|
|
722
|
+
normalizeLocale,
|
|
723
|
+
parseLocale
|
|
724
|
+
};
|
|
725
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coherent.js/i18n",
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
4
|
+
"description": "Internationalization support for Coherent.js applications",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js",
|
|
9
|
+
"./translator": "./dist/translator.js",
|
|
10
|
+
"./formatters": "./dist/formatters.js",
|
|
11
|
+
"./locale": "./dist/locale.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"coherent",
|
|
15
|
+
"i18n",
|
|
16
|
+
"internationalization",
|
|
17
|
+
"localization",
|
|
18
|
+
"translation"
|
|
19
|
+
],
|
|
20
|
+
"author": "Coherent.js Team",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@coherent.js/core": "1.0.0-beta.2"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/Tomdrouv1/coherent.js.git"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"types": "./types/index.d.ts",
|
|
33
|
+
"files": [
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"README.md",
|
|
36
|
+
"types/"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "node build.mjs",
|
|
40
|
+
"clean": "rm -rf dist"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coherent.js I18n TypeScript Definitions
|
|
3
|
+
* @module @coherent.js/i18n
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ===== Translator Types =====
|
|
7
|
+
|
|
8
|
+
export interface TranslationMessages {
|
|
9
|
+
[key: string]: string | TranslationMessages;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TranslatorOptions {
|
|
13
|
+
locale: string;
|
|
14
|
+
messages: TranslationMessages;
|
|
15
|
+
fallbackLocale?: string;
|
|
16
|
+
fallbackMessages?: TranslationMessages;
|
|
17
|
+
interpolation?: {
|
|
18
|
+
prefix?: string;
|
|
19
|
+
suffix?: string;
|
|
20
|
+
};
|
|
21
|
+
pluralization?: boolean;
|
|
22
|
+
contextSeparator?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class Translator {
|
|
26
|
+
constructor(options: TranslatorOptions);
|
|
27
|
+
t(key: string, params?: Record<string, any>): string;
|
|
28
|
+
translate(key: string, params?: Record<string, any>): string;
|
|
29
|
+
has(key: string): boolean;
|
|
30
|
+
setLocale(locale: string): void;
|
|
31
|
+
getLocale(): string;
|
|
32
|
+
addMessages(messages: TranslationMessages, locale?: string): void;
|
|
33
|
+
removeMessages(keys: string[], locale?: string): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createTranslator(options: TranslatorOptions): Translator;
|
|
37
|
+
export function createScopedTranslator(translator: Translator, scope: string): Translator;
|
|
38
|
+
|
|
39
|
+
// ===== Formatters Types =====
|
|
40
|
+
|
|
41
|
+
export interface DateFormatterOptions {
|
|
42
|
+
locale?: string;
|
|
43
|
+
timeZone?: string;
|
|
44
|
+
dateStyle?: 'full' | 'long' | 'medium' | 'short';
|
|
45
|
+
timeStyle?: 'full' | 'long' | 'medium' | 'short';
|
|
46
|
+
format?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class DateFormatter {
|
|
50
|
+
constructor(locale?: string, options?: DateFormatterOptions);
|
|
51
|
+
format(date: Date | number | string): string;
|
|
52
|
+
formatRelative(date: Date | number | string): string;
|
|
53
|
+
formatDistance(date: Date | number | string, baseDate?: Date | number): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface NumberFormatterOptions {
|
|
57
|
+
locale?: string;
|
|
58
|
+
style?: 'decimal' | 'currency' | 'percent' | 'unit';
|
|
59
|
+
currency?: string;
|
|
60
|
+
minimumFractionDigits?: number;
|
|
61
|
+
maximumFractionDigits?: number;
|
|
62
|
+
useGrouping?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class NumberFormatter {
|
|
66
|
+
constructor(locale?: string, options?: NumberFormatterOptions);
|
|
67
|
+
format(value: number): string;
|
|
68
|
+
formatCompact(value: number): string;
|
|
69
|
+
formatPercent(value: number): string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CurrencyFormatterOptions {
|
|
73
|
+
locale?: string;
|
|
74
|
+
currency: string;
|
|
75
|
+
display?: 'symbol' | 'code' | 'name';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class CurrencyFormatter {
|
|
79
|
+
constructor(locale?: string, options?: CurrencyFormatterOptions);
|
|
80
|
+
format(value: number): string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ListFormatterOptions {
|
|
84
|
+
locale?: string;
|
|
85
|
+
type?: 'conjunction' | 'disjunction' | 'unit';
|
|
86
|
+
style?: 'long' | 'short' | 'narrow';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class ListFormatter {
|
|
90
|
+
constructor(locale?: string, options?: ListFormatterOptions);
|
|
91
|
+
format(list: string[]): string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface Formatters {
|
|
95
|
+
date: DateFormatter;
|
|
96
|
+
number: NumberFormatter;
|
|
97
|
+
currency: CurrencyFormatter;
|
|
98
|
+
list: ListFormatter;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createFormatters(locale: string): Formatters;
|
|
102
|
+
|
|
103
|
+
// ===== Locale Manager Types =====
|
|
104
|
+
|
|
105
|
+
export interface LocaleConfig {
|
|
106
|
+
code: string;
|
|
107
|
+
name: string;
|
|
108
|
+
nativeName: string;
|
|
109
|
+
direction?: 'ltr' | 'rtl';
|
|
110
|
+
dateFormat?: string;
|
|
111
|
+
timeFormat?: string;
|
|
112
|
+
firstDayOfWeek?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class LocaleManager {
|
|
116
|
+
constructor(locales: LocaleConfig[]);
|
|
117
|
+
addLocale(locale: LocaleConfig): void;
|
|
118
|
+
removeLocale(code: string): void;
|
|
119
|
+
getLocale(code: string): LocaleConfig | undefined;
|
|
120
|
+
getAllLocales(): LocaleConfig[];
|
|
121
|
+
setCurrentLocale(code: string): void;
|
|
122
|
+
getCurrentLocale(): LocaleConfig;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createLocaleManager(locales: LocaleConfig[]): LocaleManager;
|
|
126
|
+
export function detectLocale(): string;
|
|
127
|
+
export function normalizeLocale(locale: string): string;
|
|
128
|
+
export function isRTL(locale: string): boolean;
|