@mirta/i18n 0.0.1 → 0.4.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/LICENSE +24 -0
- package/README.md +224 -27
- package/README.ru.md +240 -0
- package/dist/index.d.mts +353 -0
- package/dist/index.mjs +815 -0
- package/package.json +51 -7
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import { resolve, join, basename } from 'node:path';
|
|
2
|
+
import { readFile, glob } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Имя текущего пакета в формате, используемом в npm-реестре.
|
|
6
|
+
*
|
|
7
|
+
* @since 0.4.0
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*
|
|
11
|
+
**/
|
|
12
|
+
const THIS_PACKAGE_NAME = '@mirta/i18n';
|
|
13
|
+
/**
|
|
14
|
+
* Локаль по умолчанию (fallback), если параметр `options.fallbackLocale` не указан.
|
|
15
|
+
*
|
|
16
|
+
* @since 0.4.0
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*
|
|
20
|
+
**/
|
|
21
|
+
const DEFAULT_FALLBACK_LOCALE = 'en-US';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Специализированный класс для обработки ошибок, связанных с работой локализации.
|
|
25
|
+
*
|
|
26
|
+
* Предоставляет структурированные и типизированные ошибки с использованием кодов, что упрощает
|
|
27
|
+
* программную обработку исключений в инструментах, работающих с пакетами.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* throw LocalizationError.get('fallback.LoadFailed', 'en-US')
|
|
32
|
+
* ```
|
|
33
|
+
* @since 0.4.0
|
|
34
|
+
*
|
|
35
|
+
**/
|
|
36
|
+
class LocalizationError extends Error {
|
|
37
|
+
/**
|
|
38
|
+
* Код ошибки для программной идентификации.
|
|
39
|
+
*
|
|
40
|
+
* Позволяет точно определить причину ошибки в обработчиках `try/catch`.
|
|
41
|
+
*
|
|
42
|
+
**/
|
|
43
|
+
code;
|
|
44
|
+
/**
|
|
45
|
+
* Приватный конструктор, используемый только внутри
|
|
46
|
+
* класса для создания экземпляров ошибки.
|
|
47
|
+
*
|
|
48
|
+
* @param message - Полное сообщение об ошибке.
|
|
49
|
+
* @param code - Код ошибки для идентификации.
|
|
50
|
+
* @param scope - Пространство имён или модуль, в котором возникла ошибка.
|
|
51
|
+
* По умолчанию — {@link THIS_PACKAGE_NAME}.
|
|
52
|
+
*
|
|
53
|
+
**/
|
|
54
|
+
constructor(message, code, scope) {
|
|
55
|
+
super(`[${scope ?? THIS_PACKAGE_NAME}] ${message}`);
|
|
56
|
+
this.name = 'LocalizationError';
|
|
57
|
+
this.code = code;
|
|
58
|
+
// Захватываем стек вызовов, исключая фабричный метод `get`,
|
|
59
|
+
// чтобы улучшить читаемость трассировки.
|
|
60
|
+
//
|
|
61
|
+
if ('captureStackTrace' in Error)
|
|
62
|
+
Error.captureStackTrace(this, scope
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
64
|
+
? LocalizationError.getScoped
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
66
|
+
: LocalizationError.get);
|
|
67
|
+
}
|
|
68
|
+
/** Карта кодов ошибок с соответствующими сообщениями. */
|
|
69
|
+
static codeMappings = {
|
|
70
|
+
'strict.invalidPluralValue': (variable, value) => `Expected number for "${variable}", got ${typeof value} (${JSON.stringify(value)})`,
|
|
71
|
+
'fallback.loadFailed': (locale) => `Failed to load fallback locale "${locale}"`,
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Фабричный метод для создания экземпляра ошибки по её коду.
|
|
75
|
+
*
|
|
76
|
+
* Автоматически подставляет сообщение из `codeMappings` и формирует ошибку с заданными параметрами.
|
|
77
|
+
*
|
|
78
|
+
* @template T - Ограниченный ключами `codeMappings` тип, гарантирующий корректность кода.
|
|
79
|
+
* @param code - Код ошибки (например, `'fallback.loadFailed'`).
|
|
80
|
+
* @param args - Аргументы, соответствующие параметрам функции сообщения из `codeMappings`.
|
|
81
|
+
* @returns Новый экземпляр {@link LocalizationError} с шаблонным сообщением.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const error = LocalizationError.get('fallback.loadFailed', 'en-US')
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
static get(code, ...args) {
|
|
89
|
+
const messageFn = this.codeMappings[code];
|
|
90
|
+
const message = messageFn(...args);
|
|
91
|
+
return new LocalizationError(message, code);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Фабричный метод, аналогичный `get`, но с возможностью указать
|
|
95
|
+
* пользовательское пространство имён (scope).
|
|
96
|
+
*
|
|
97
|
+
* Полезно при использовании в других модулях, где нужно указать
|
|
98
|
+
* иной контекст ошибки.
|
|
99
|
+
*
|
|
100
|
+
* @template TKey - Тип кода ошибки, аналогично `get`.
|
|
101
|
+
*
|
|
102
|
+
* @param scope - Пространство имён ошибки (например, `'@mirta/store'`).
|
|
103
|
+
* @param code - Код ошибки.
|
|
104
|
+
* @param args - Аргументы для формирования сообщения.
|
|
105
|
+
*
|
|
106
|
+
* @returns Новый экземпляр {@link LocalizationError} с пользовательским
|
|
107
|
+
* префиксом и шаблонным сообщением.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
*
|
|
111
|
+
* ```ts
|
|
112
|
+
* const error = LocalizationError.getScoped('@mirta/cli', 'fallback.loadFailed', 'en-US')
|
|
113
|
+
* ```
|
|
114
|
+
**/
|
|
115
|
+
static getScoped(scope, code, ...args) {
|
|
116
|
+
const messageFn = this.codeMappings[code];
|
|
117
|
+
const message = messageFn(...args);
|
|
118
|
+
return new LocalizationError(message, code, scope);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Специализированный класс для обработки ошибок, связанных с работой локализации.
|
|
124
|
+
*
|
|
125
|
+
* Предоставляет структурированные и типизированные ошибки с использованием кодов, что упрощает
|
|
126
|
+
* программную обработку исключений в инструментах, работающих с пакетами.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```ts
|
|
130
|
+
* throw SourceError.get('file.accessDenied', '/path/to/file')
|
|
131
|
+
* ```
|
|
132
|
+
* @since 0.4.0
|
|
133
|
+
*
|
|
134
|
+
**/
|
|
135
|
+
class SourceError extends Error {
|
|
136
|
+
/**
|
|
137
|
+
* Код ошибки для программной идентификации.
|
|
138
|
+
*
|
|
139
|
+
* Позволяет точно определить причину ошибки в обработчиках `try/catch`.
|
|
140
|
+
*
|
|
141
|
+
**/
|
|
142
|
+
code;
|
|
143
|
+
/**
|
|
144
|
+
* Приватный конструктор, используемый только внутри
|
|
145
|
+
* класса для создания экземпляров ошибки.
|
|
146
|
+
*
|
|
147
|
+
* @param message - Полное сообщение об ошибке.
|
|
148
|
+
* @param code - Код ошибки для идентификации.
|
|
149
|
+
* @param scope - Пространство имён или модуль, в котором возникла ошибка.
|
|
150
|
+
* По умолчанию — {@link THIS_PACKAGE_NAME}.
|
|
151
|
+
*
|
|
152
|
+
**/
|
|
153
|
+
constructor(message, code, scope) {
|
|
154
|
+
super(`[${scope ?? THIS_PACKAGE_NAME}] ${message}`);
|
|
155
|
+
this.name = 'SourceError';
|
|
156
|
+
this.code = code;
|
|
157
|
+
// Захватываем стек вызовов, исключая фабричный метод `get`,
|
|
158
|
+
// чтобы улучшить читаемость трассировки.
|
|
159
|
+
//
|
|
160
|
+
if ('captureStackTrace' in Error)
|
|
161
|
+
Error.captureStackTrace(this, scope
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
163
|
+
? SourceError.getScoped
|
|
164
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
165
|
+
: SourceError.get);
|
|
166
|
+
}
|
|
167
|
+
/** Карта кодов ошибок с соответствующими сообщениями. */
|
|
168
|
+
static codeMappings = {
|
|
169
|
+
'file.notFound': (filePath) => `Locale file not found: "${filePath}"`,
|
|
170
|
+
'file.accessDenied': (filePath) => `Access denied to "${filePath}"`,
|
|
171
|
+
'file.failedToRead': (filePath, reason) => `Failed to read "${filePath}": ${reason ?? 'unknown reason'}`,
|
|
172
|
+
'file.nonCanonicalName': (filePath, locale) => `Locale file name "${filePath}" is not in canonical form. Expected "${locale}.json"`,
|
|
173
|
+
/**
|
|
174
|
+
* Ошибка парсинга JSON в файле локали.
|
|
175
|
+
*
|
|
176
|
+
* @param filePath - Путь к файлу с некорректным JSON.
|
|
177
|
+
* @param message - Сообщение об ошибке от парсера (например, `Unexpected token }`).
|
|
178
|
+
*
|
|
179
|
+
**/
|
|
180
|
+
'parse.invalidJson': (filePath, message) => `Invalid JSON in file "${filePath}": ${message}`,
|
|
181
|
+
/**
|
|
182
|
+
* Ошибка, когда корневой элемент JSON не является объектом (`{}`).
|
|
183
|
+
*
|
|
184
|
+
**/
|
|
185
|
+
'parse.invalidJsonRoot': () => 'Invalid JSON: root must be an object, not an array or primitive value',
|
|
186
|
+
};
|
|
187
|
+
static isFileError(error) {
|
|
188
|
+
return error instanceof SourceError && error.code.startsWith('file.');
|
|
189
|
+
}
|
|
190
|
+
static isParseError(error) {
|
|
191
|
+
return error instanceof SourceError && error.code.startsWith('parse.');
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Фабричный метод для создания экземпляра ошибки по её коду.
|
|
195
|
+
*
|
|
196
|
+
* Автоматически подставляет сообщение из `codeMappings` и формирует ошибку с заданными параметрами.
|
|
197
|
+
*
|
|
198
|
+
* @template T - Ограниченный ключами `codeMappings` тип, гарантирующий корректность кода.
|
|
199
|
+
* @param code - Код ошибки (например, `'alreadyDefined'`).
|
|
200
|
+
* @param args - Аргументы, соответствующие параметрам функции сообщения из `codeMappings`.
|
|
201
|
+
* @returns Новый экземпляр {@link SourceError} с шаблонным сообщением.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```ts
|
|
205
|
+
* const error = SourceError.get('file.notFound', '/path/to/locale.json')
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
static get(code, ...args) {
|
|
209
|
+
const messageFn = this.codeMappings[code];
|
|
210
|
+
const message = messageFn(...args);
|
|
211
|
+
return new SourceError(message, code);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Фабричный метод, аналогичный `get`, но с возможностью указать
|
|
215
|
+
* пользовательское пространство имён (scope).
|
|
216
|
+
*
|
|
217
|
+
* Полезно при использовании в других модулях, где нужно указать
|
|
218
|
+
* иной контекст ошибки.
|
|
219
|
+
*
|
|
220
|
+
* @template TKey - Тип кода ошибки, аналогично `get`.
|
|
221
|
+
*
|
|
222
|
+
* @param scope - Пространство имён ошибки (например, `'@mirta/store'`).
|
|
223
|
+
* @param code - Код ошибки.
|
|
224
|
+
* @param args - Аргументы для формирования сообщения.
|
|
225
|
+
*
|
|
226
|
+
* @returns Новый экземпляр {@link SourceError} с пользовательским
|
|
227
|
+
* префиксом и шаблонным сообщением.
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
*
|
|
231
|
+
* ```ts
|
|
232
|
+
* const error = SourceError.getScoped('@mirta/bot-remote', 'file.accessDenied', '/path/to/file')
|
|
233
|
+
* ```
|
|
234
|
+
**/
|
|
235
|
+
static getScoped(scope, code, ...args) {
|
|
236
|
+
const messageFn = this.codeMappings[code];
|
|
237
|
+
const message = messageFn(...args);
|
|
238
|
+
return new SourceError(message, code, scope);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Кэш загруженных сообщений по локалям.
|
|
244
|
+
*
|
|
245
|
+
* Хранит:
|
|
246
|
+
* - `object` — успешно загруженные и замороженные сообщения
|
|
247
|
+
* - `null` — признак того, что загрузка для этой локали уже провалилась (избежание повторных попыток)
|
|
248
|
+
*
|
|
249
|
+
* @private
|
|
250
|
+
*
|
|
251
|
+
**/
|
|
252
|
+
const loadedMessages = new Map();
|
|
253
|
+
/**
|
|
254
|
+
* Парсит JSON и возвращает объект.
|
|
255
|
+
* Ожидает валидный JSON с корневым объектом.
|
|
256
|
+
*
|
|
257
|
+
* @param content - JSON-строка
|
|
258
|
+
* @returns Объект сообщений
|
|
259
|
+
* @throws {SourceError} Если JSON — не объект
|
|
260
|
+
*
|
|
261
|
+
* @since 0.4.0
|
|
262
|
+
*
|
|
263
|
+
**/
|
|
264
|
+
function parseLocaleJson(content) {
|
|
265
|
+
const parsed = JSON.parse(content);
|
|
266
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
267
|
+
throw SourceError.get('parse.invalidJsonRoot');
|
|
268
|
+
return parsed;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Читает и парсит JSON-файл с сообщениями локали.
|
|
272
|
+
*
|
|
273
|
+
* Ожидает валидный UTF-8 и корректный JSON.
|
|
274
|
+
* Результат замораживается для защиты от изменений.
|
|
275
|
+
*
|
|
276
|
+
* @param filePath - Путь к файлу `.json`.
|
|
277
|
+
* @returns Объект сообщений.
|
|
278
|
+
* @throws {SourceError} С кодами `file.notFound`, `file.accessDenied`, `parse.invalidJson` и др.
|
|
279
|
+
*
|
|
280
|
+
**/
|
|
281
|
+
async function readLocaleFileAsync(filePath) {
|
|
282
|
+
try {
|
|
283
|
+
const content = await readFile(filePath, 'utf-8');
|
|
284
|
+
return Object.freeze(parseLocaleJson(content));
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
if (e instanceof SourceError)
|
|
288
|
+
throw e;
|
|
289
|
+
if (e instanceof SyntaxError)
|
|
290
|
+
throw SourceError.get('parse.invalidJson', filePath, e.message);
|
|
291
|
+
if (e && typeof e === 'object' && 'code' in e) {
|
|
292
|
+
switch (e.code) {
|
|
293
|
+
case 'ENOENT':
|
|
294
|
+
throw SourceError.get('file.notFound', filePath);
|
|
295
|
+
case 'EACCES':
|
|
296
|
+
case 'EPERM':
|
|
297
|
+
throw SourceError.get('file.accessDenied', filePath);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const message = e instanceof Error
|
|
301
|
+
? e.message
|
|
302
|
+
: String(e);
|
|
303
|
+
throw SourceError.get('file.failedToRead', filePath, message);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Загружает сообщения для указанной локали.
|
|
308
|
+
*
|
|
309
|
+
* Использует кэш: повторные вызовы не читают файл.
|
|
310
|
+
* При неудаче кэшируется `null` — попытки не повторяются.
|
|
311
|
+
*
|
|
312
|
+
* @param locale - Локаль в формате `xx-XX` (например, `ru-RU`).
|
|
313
|
+
* @param cwd - Базовая директория (обычно `process.cwd()`).
|
|
314
|
+
* @returns Сообщения или `null`, если загрузить не удалось.
|
|
315
|
+
*
|
|
316
|
+
* @remarks
|
|
317
|
+
* Ошибки, связанные с отсутствием файла (`file.notFound`), перехватываются.
|
|
318
|
+
*
|
|
319
|
+
* Остальные ошибки (невалидный JSON, нет доступа) пробрасываются,
|
|
320
|
+
* поскольку указывают на критические проблемы.
|
|
321
|
+
*
|
|
322
|
+
* @since 0.4.0
|
|
323
|
+
*
|
|
324
|
+
**/
|
|
325
|
+
async function loadMessagesAsync(locale, cwd) {
|
|
326
|
+
let messages = loadedMessages.get(locale);
|
|
327
|
+
if (messages || messages === null)
|
|
328
|
+
return messages;
|
|
329
|
+
const filePath = resolve(cwd, './locales', `${locale}.json`);
|
|
330
|
+
try {
|
|
331
|
+
messages = await readLocaleFileAsync(filePath);
|
|
332
|
+
loadedMessages.set(locale, messages);
|
|
333
|
+
return messages;
|
|
334
|
+
}
|
|
335
|
+
catch (e) {
|
|
336
|
+
if (SourceError.isFileError(e) && e.code === 'file.notFound') {
|
|
337
|
+
loadedMessages.set(locale, null);
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
throw e;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Сканирует директорию `localesDir` и возвращает множество доступных локалей.
|
|
346
|
+
* Игнорирует отсутствие директории или файлов. Проверяет валидность локалей через `resolveLocale`.
|
|
347
|
+
*
|
|
348
|
+
* @param localesDir - Путь к папке с локалями (обычно `./locales`)
|
|
349
|
+
* @returns Множество валидных локалей (например, `Set { 'en-US', 'ru-RU' }`)
|
|
350
|
+
*
|
|
351
|
+
* @since 0.4.0
|
|
352
|
+
*
|
|
353
|
+
**/
|
|
354
|
+
async function resolveSupportedLocalesAsync(localesDir) {
|
|
355
|
+
const pattern = join(localesDir, '*.json');
|
|
356
|
+
const locales = new Set();
|
|
357
|
+
try {
|
|
358
|
+
for await (const filePath of glob(pattern)) {
|
|
359
|
+
const localeSource = basename(filePath, '.json');
|
|
360
|
+
const locale = resolveLocale(localeSource);
|
|
361
|
+
if (!locale)
|
|
362
|
+
continue;
|
|
363
|
+
if (locale !== localeSource)
|
|
364
|
+
throw SourceError.get('file.nonCanonicalName', filePath, locale);
|
|
365
|
+
locales.add(locale);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT')
|
|
370
|
+
return locales;
|
|
371
|
+
throw e;
|
|
372
|
+
}
|
|
373
|
+
return locales;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Извлекает языковой код из локали (например, 'ru' из 'ru-RU').
|
|
377
|
+
*
|
|
378
|
+
* @param locale - Локаль в формате xx-XX
|
|
379
|
+
* @returns Языковой код (xx)
|
|
380
|
+
*
|
|
381
|
+
* @since 0.4.0
|
|
382
|
+
*
|
|
383
|
+
**/
|
|
384
|
+
function getLang(locale) {
|
|
385
|
+
return locale.split('-')[0];
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Загружает ассет локали: сообщения, язык и локаль.
|
|
389
|
+
* Возвращает `undefined`, если сообщения не найдены.
|
|
390
|
+
* Результат замораживается для неизменяемости.
|
|
391
|
+
*
|
|
392
|
+
* @template TShape - Интерфейс локали
|
|
393
|
+
*
|
|
394
|
+
* @param locale - Локаль (например, `'en-US'`)
|
|
395
|
+
* @param cwd - Рабочая директория
|
|
396
|
+
*
|
|
397
|
+
* @returns Ассет локали или `undefined`
|
|
398
|
+
*
|
|
399
|
+
* @since 0.4.0
|
|
400
|
+
*
|
|
401
|
+
**/
|
|
402
|
+
async function loadAssetAsync(locale, cwd) {
|
|
403
|
+
const messages = await loadMessagesAsync(locale, cwd);
|
|
404
|
+
if (!messages)
|
|
405
|
+
return;
|
|
406
|
+
return Object.freeze({
|
|
407
|
+
locale,
|
|
408
|
+
lang: getLang(locale),
|
|
409
|
+
messages,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
function resolveLocale(input, defaultLocale) {
|
|
413
|
+
if (!input || typeof input !== 'string')
|
|
414
|
+
return defaultLocale;
|
|
415
|
+
// Специальный случай в Unix-подобных системах (POSIX)
|
|
416
|
+
if (input === 'C')
|
|
417
|
+
return defaultLocale;
|
|
418
|
+
// Приведение к нижнему регистру для сравнения.
|
|
419
|
+
const normalizedInput = input
|
|
420
|
+
.trim()
|
|
421
|
+
.toLowerCase();
|
|
422
|
+
if (normalizedInput === 'en' || normalizedInput.startsWith('en-'))
|
|
423
|
+
return 'en-US';
|
|
424
|
+
if (normalizedInput === 'ru' || normalizedInput.startsWith('ru-'))
|
|
425
|
+
return 'ru-RU';
|
|
426
|
+
try {
|
|
427
|
+
// Защитная попытка нормализовать через Intl.
|
|
428
|
+
return Intl.getCanonicalLocales(input.trim())[0];
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return defaultLocale;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Асинхронно устанавливает локаль в контексте.
|
|
436
|
+
* Если сообщения для локали недоступны, используется fallback.
|
|
437
|
+
*
|
|
438
|
+
* @param locale - Целевая локаль
|
|
439
|
+
* @param context - Контекст локализации
|
|
440
|
+
*
|
|
441
|
+
* @since 0.4.0
|
|
442
|
+
*
|
|
443
|
+
**/
|
|
444
|
+
async function setLocaleAsync(locale, context) {
|
|
445
|
+
const fallbackAsset = context.fallbackAsset;
|
|
446
|
+
const targetLocale = resolveLocale(locale, fallbackAsset.locale);
|
|
447
|
+
// TODO: Добавить debug-логирование при переходе к fallbackAsset.
|
|
448
|
+
// Защищает от попыток загрузки и кэширования несуществующих ассетов.
|
|
449
|
+
const effectiveAsset = context.supportedLocales.has(targetLocale)
|
|
450
|
+
? await loadAssetAsync(targetLocale, context.cwd) ?? fallbackAsset
|
|
451
|
+
: fallbackAsset;
|
|
452
|
+
context.locale = effectiveAsset.locale;
|
|
453
|
+
context.lang = effectiveAsset.lang;
|
|
454
|
+
context.messages = effectiveAsset.messages;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Получает системную локаль из переменных окружения или Intl.
|
|
458
|
+
*
|
|
459
|
+
* @returns Локаль в формате xx-XX (например, 'ru-RU')
|
|
460
|
+
*
|
|
461
|
+
* @since 0.4.0
|
|
462
|
+
*
|
|
463
|
+
**/
|
|
464
|
+
function getSystemLocale() {
|
|
465
|
+
const rawLocale = process.env.LC_ALL
|
|
466
|
+
|| process.env.LC_MESSAGES
|
|
467
|
+
|| process.env.LANG
|
|
468
|
+
|| Intl.DateTimeFormat().resolvedOptions().locale;
|
|
469
|
+
return rawLocale.split('.')[0].replaceAll('_', '-');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Определяет форму множественного числа для заданного языка и числа.
|
|
474
|
+
*
|
|
475
|
+
* Поддерживает:
|
|
476
|
+
* - Русский (`ru`): `one`, `few`, `many`
|
|
477
|
+
* - Английский и прочие: `one` (если 1), иначе `other`
|
|
478
|
+
*
|
|
479
|
+
* @param lang - Языковой код (например, 'ru', 'en')
|
|
480
|
+
* @param count - Число, для которого определяется форма
|
|
481
|
+
*
|
|
482
|
+
* @returns Одна из форм множественного числа (см. {@link PluralForm})
|
|
483
|
+
*
|
|
484
|
+
* @since 0.4.0
|
|
485
|
+
*
|
|
486
|
+
**/
|
|
487
|
+
function getPluralForm(lang, count) {
|
|
488
|
+
if (lang === 'ru') {
|
|
489
|
+
// Если дробное — всегда 'few' (родительный падеж ед. числа)
|
|
490
|
+
if (!Number.isInteger(count))
|
|
491
|
+
return 'few';
|
|
492
|
+
const absCount = Math.floor(Math.abs(count));
|
|
493
|
+
const mod10 = absCount % 10;
|
|
494
|
+
const mod100 = absCount % 100;
|
|
495
|
+
if (mod10 === 1 && mod100 !== 11)
|
|
496
|
+
return 'one';
|
|
497
|
+
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20))
|
|
498
|
+
return 'few';
|
|
499
|
+
return 'many';
|
|
500
|
+
}
|
|
501
|
+
return count === 1 ? 'one' : 'other';
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Извлекает сбалансированный блок `{...}` с учётом вложенности.
|
|
505
|
+
*
|
|
506
|
+
* @param message - Строка для поиска.
|
|
507
|
+
* @param index - Позиция, с которой начинается блок `{`.
|
|
508
|
+
* @param limit - Ограничение по длине строки.
|
|
509
|
+
*
|
|
510
|
+
* @returns Объект с `content` (содержимым) и `end` (индексом после `}`) или `null`, если блок несбалансирован.
|
|
511
|
+
*
|
|
512
|
+
* @example
|
|
513
|
+
* extractBalanced('{hello}', 0, 7) // → { content: 'hello', end: 7 }
|
|
514
|
+
*
|
|
515
|
+
* @example
|
|
516
|
+
* extractBalanced('{a{b}c}', 0, 7) // → { content: 'a{b}c', end: 7 }
|
|
517
|
+
*
|
|
518
|
+
* @example
|
|
519
|
+
* extractBalanced('{unclosed', 0, 9) // → null
|
|
520
|
+
*
|
|
521
|
+
* @since 0.4.0
|
|
522
|
+
*
|
|
523
|
+
**/
|
|
524
|
+
function extractBalanced(message, index, limit) {
|
|
525
|
+
if (message[index] !== '{')
|
|
526
|
+
return null;
|
|
527
|
+
let depth = 1;
|
|
528
|
+
let i = index + 1;
|
|
529
|
+
while (i < limit && depth > 0) {
|
|
530
|
+
if (message[i] === '{')
|
|
531
|
+
depth++;
|
|
532
|
+
else if (message[i] === '}')
|
|
533
|
+
depth--;
|
|
534
|
+
i++;
|
|
535
|
+
}
|
|
536
|
+
if (depth !== 0)
|
|
537
|
+
return null;
|
|
538
|
+
return {
|
|
539
|
+
end: i,
|
|
540
|
+
content: message.slice(index + 1, i - 1),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Подставляет значения переменных в строку вида `{ключ}`.
|
|
545
|
+
*
|
|
546
|
+
* @param text - Строка с плейсхолдерами.
|
|
547
|
+
* @param variables - Объект с данными для подстановки.
|
|
548
|
+
*
|
|
549
|
+
* @returns Строка с заменёнными значениями или оригинальные плейсхолдеры, если значения не найдены.
|
|
550
|
+
*
|
|
551
|
+
* @example
|
|
552
|
+
* interpolate('Привет, {name}!', { name: 'Мира' }) // → 'Привет, Мира!'
|
|
553
|
+
*
|
|
554
|
+
* @example
|
|
555
|
+
* interpolate('Значение: {missing}', {}) // → 'Значение: {missing}'
|
|
556
|
+
*
|
|
557
|
+
* @since 0.4.0
|
|
558
|
+
*
|
|
559
|
+
**/
|
|
560
|
+
function interpolate(text, variables) {
|
|
561
|
+
let result = '';
|
|
562
|
+
let pos = 0;
|
|
563
|
+
let start = 0;
|
|
564
|
+
const len = text.length;
|
|
565
|
+
while (pos < len) {
|
|
566
|
+
if (text[pos] === '{') {
|
|
567
|
+
result += text.slice(start, pos);
|
|
568
|
+
const block = extractBalanced(text, pos, len);
|
|
569
|
+
if (!block) {
|
|
570
|
+
result += '{';
|
|
571
|
+
pos++;
|
|
572
|
+
start = pos;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const { content, end } = block;
|
|
576
|
+
const value = variables?.[content];
|
|
577
|
+
result += value != null ? String(value) : `{${content}}`;
|
|
578
|
+
pos = end;
|
|
579
|
+
start = pos;
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
pos++;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
result += text.slice(start);
|
|
586
|
+
return result;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Разбирает часть сообщения с формами множественного числа (например, `one{...} other{...}`).
|
|
590
|
+
*
|
|
591
|
+
* Извлекает ключи (`one`, `other`, `=0` и т.д.) и соответствующие им содержимое,
|
|
592
|
+
* корректно обрабатывая вложенность фигурных скобок. Поддерживает точные значения (`=n`) и
|
|
593
|
+
* преобразует `two` в `few` в соответствии с правилами ICU.
|
|
594
|
+
*
|
|
595
|
+
* @param formsPart - Строка, содержащая последовательность `ключ{...}`.
|
|
596
|
+
*
|
|
597
|
+
* @returns Объект с двумя полями:
|
|
598
|
+
* - `exactForms` — карта точных значений (`=n`) и их содержимого;
|
|
599
|
+
* - `commonForms` — карта именованных форм (`one`, `other` и др.).
|
|
600
|
+
*
|
|
601
|
+
* @since 0.4.0
|
|
602
|
+
*
|
|
603
|
+
**/
|
|
604
|
+
function parseFormsPart(formsPart) {
|
|
605
|
+
const exactForms = {};
|
|
606
|
+
const commonForms = {};
|
|
607
|
+
let pos = 0;
|
|
608
|
+
let keyBuffer = ''; // будем набирать ключ
|
|
609
|
+
const len = formsPart.length;
|
|
610
|
+
while (pos < len) {
|
|
611
|
+
const char = formsPart[pos];
|
|
612
|
+
if (char === '{') {
|
|
613
|
+
// Встретили `{` → текущий буфер — это ключ
|
|
614
|
+
const key = keyBuffer.trim();
|
|
615
|
+
keyBuffer = '';
|
|
616
|
+
if (!key) {
|
|
617
|
+
// Пустой ключ — пропускаем
|
|
618
|
+
pos++;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
// Извлекаем сбалансированное тело
|
|
622
|
+
const block = extractBalanced(formsPart, pos, len);
|
|
623
|
+
if (!block) {
|
|
624
|
+
pos++;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
// Сохраняем
|
|
628
|
+
if (key.startsWith('=')) {
|
|
629
|
+
const num = parseInt(key.slice(1), 10);
|
|
630
|
+
exactForms[num] = block.content;
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
// ICU: 'two' → treat as 'few'
|
|
634
|
+
const formKey = key === 'two' ? 'few' : key;
|
|
635
|
+
commonForms[formKey] = block.content;
|
|
636
|
+
}
|
|
637
|
+
// Продолжаем после блока
|
|
638
|
+
pos = block.end;
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
// Накапливаем символы в буфере
|
|
642
|
+
keyBuffer += char;
|
|
643
|
+
pos++;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return { exactForms, commonForms };
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Обрабатывает конструкцию `plural` в формате ICU.
|
|
650
|
+
*
|
|
651
|
+
* @param content - Содержимое блока, например: `count, plural, one{...} other{...}`.
|
|
652
|
+
* @param variables - Переменные для подстановки значений.
|
|
653
|
+
* @param context - Контекст локализации (язык, strict-режим).
|
|
654
|
+
*
|
|
655
|
+
* @returns Обработанную строку с нужной формой и подставленным числом, или ошибку в strict-режиме.
|
|
656
|
+
*
|
|
657
|
+
* @since 0.4.0
|
|
658
|
+
*
|
|
659
|
+
**/
|
|
660
|
+
function parsePlural(content, variables, context) {
|
|
661
|
+
// Парсим: {count, plural, offset:1, one{...} other{...}}
|
|
662
|
+
const match = /([^}]+),\s*plural,\s*(?:offset:(\d+)\s*)?(.+)/g.exec(content);
|
|
663
|
+
if (!match) {
|
|
664
|
+
if (context.strict)
|
|
665
|
+
throw new Error('Invalid plural format');
|
|
666
|
+
return '';
|
|
667
|
+
}
|
|
668
|
+
const [, variable, offsetPart, formsPart] = match;
|
|
669
|
+
const offset = offsetPart ? parseInt(offsetPart, 10) : 0;
|
|
670
|
+
const originalValue = Number(variables?.[variable]);
|
|
671
|
+
if (isNaN(originalValue)) {
|
|
672
|
+
if (context.strict)
|
|
673
|
+
throw LocalizationError.get('strict.invalidPluralValue', variable, variables?.[variable]);
|
|
674
|
+
return 'NaN';
|
|
675
|
+
}
|
|
676
|
+
const value = originalValue - offset;
|
|
677
|
+
const { exactForms, commonForms } = parseFormsPart(formsPart);
|
|
678
|
+
// Обрабатываем =0{...}, =1{...}
|
|
679
|
+
if (value in exactForms)
|
|
680
|
+
return exactForms[value].replace(/#/g, String(value));
|
|
681
|
+
// Обрабатываем one{...}, few{...}, other{...}
|
|
682
|
+
const form = getPluralForm(context.lang, value);
|
|
683
|
+
const formText = commonForms[form] ?? commonForms.other ?? '';
|
|
684
|
+
return formText.replace(/#/g, String(value));
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Создаёт функцию перевода на основе текущего контекста локализации.
|
|
688
|
+
*
|
|
689
|
+
* Поддерживает:
|
|
690
|
+
* - Подстановку переменных: `{name}`
|
|
691
|
+
* - Множественные формы: `{count, plural, one{...} other{...}}`
|
|
692
|
+
* - Смещение (offset): `{count, plural, offset:1, one{...} other{...}}`
|
|
693
|
+
* - Точные значения: `=0{...}`
|
|
694
|
+
*
|
|
695
|
+
* @param context - Контекст локализации с сообщениями и языком
|
|
696
|
+
* @returns Функция перевода `translate(key, variables?)`
|
|
697
|
+
*
|
|
698
|
+
* @since 0.4.0
|
|
699
|
+
*
|
|
700
|
+
**/
|
|
701
|
+
function createTranslator(context) {
|
|
702
|
+
const translate = (key, variables) => {
|
|
703
|
+
const message = context.messages[key] ?? context.fallbackAsset.messages[key];
|
|
704
|
+
if (!message)
|
|
705
|
+
return `{{${key}}}`;
|
|
706
|
+
let result = '';
|
|
707
|
+
let pos = 0;
|
|
708
|
+
let start = 0;
|
|
709
|
+
const len = message.length;
|
|
710
|
+
// Основной цикл
|
|
711
|
+
while (pos < len) {
|
|
712
|
+
if (message[pos] === '{') {
|
|
713
|
+
// Добавить текст до {
|
|
714
|
+
result += message.slice(start, pos);
|
|
715
|
+
const block = extractBalanced(message, pos, len);
|
|
716
|
+
if (!block) {
|
|
717
|
+
result += '{';
|
|
718
|
+
pos++;
|
|
719
|
+
start = pos;
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
const { content, end } = block;
|
|
723
|
+
if (/^\s*[a-zA-Z0-9_.-]+\s*,\s*plural\b/.test(content)) {
|
|
724
|
+
result += interpolate(parsePlural(content, variables, context), variables);
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
// Простая переменная: {name}
|
|
728
|
+
const value = variables?.[content];
|
|
729
|
+
result += value != null ? String(value) : `{${content}}`;
|
|
730
|
+
}
|
|
731
|
+
pos = end;
|
|
732
|
+
start = pos;
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
pos++;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// Добавить остаток
|
|
739
|
+
result += message.slice(start);
|
|
740
|
+
return result;
|
|
741
|
+
};
|
|
742
|
+
translate.plain = (key, fallbackValue) => {
|
|
743
|
+
const message = context.messages[key]
|
|
744
|
+
?? context.fallbackAsset.messages[key]
|
|
745
|
+
?? fallbackValue
|
|
746
|
+
?? `{{${key}}}`;
|
|
747
|
+
return message;
|
|
748
|
+
};
|
|
749
|
+
return translate;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Инициализирует систему локализации, загружает сообщения для системной и fallback-локали.
|
|
754
|
+
*
|
|
755
|
+
* @template TShape - Интерфейс локали, описывающий структуру сообщений и переменных.
|
|
756
|
+
* Если не указан, применяется тип {@link GenericShape}, разрешающий использовать любые строки как ключи.
|
|
757
|
+
* Для включения строгой типизации передайте сгенерированный или явно определённый интерфейс `LocaleShape`.
|
|
758
|
+
*
|
|
759
|
+
* @param options - Опции инициализации локализации.
|
|
760
|
+
* @param options.cwd - Рабочая директория, откуда загружаются локали (по умолчанию: `process.cwd()`).
|
|
761
|
+
* @param options.fallbackLocale - Локаль по умолчанию, если другие не найдены (по умолчанию: `'en-US'`).
|
|
762
|
+
*
|
|
763
|
+
* @returns Промис, разрешающийся в объект `Localization<TShape>`, содержащий:
|
|
764
|
+
* - `getLocale()` — текущую локаль
|
|
765
|
+
* - `setLocaleAsync(locale)` — переключение локали
|
|
766
|
+
* - `t(key, vars)` — функция перевода
|
|
767
|
+
*
|
|
768
|
+
* @throws Ошибка, если не удалось загрузить fallback-локаль.
|
|
769
|
+
*
|
|
770
|
+
* @example
|
|
771
|
+
* ```ts
|
|
772
|
+
* // Без LocaleShape — нет строгой проверки ключей
|
|
773
|
+
* const { t } = await initLocalizationAsync()
|
|
774
|
+
* t('non.existing.key') // OK (нет ошибки на уровне типов)
|
|
775
|
+
* ```
|
|
776
|
+
*
|
|
777
|
+
* @example
|
|
778
|
+
* ```ts
|
|
779
|
+
* // С интерфейсом — полная типизация
|
|
780
|
+
* const { t } = await initLocalizationAsync<MyLocaleShape>()
|
|
781
|
+
* t('non.existing.key') // Ошибка компиляции
|
|
782
|
+
* ```
|
|
783
|
+
*
|
|
784
|
+
* @since 0.4.0
|
|
785
|
+
*
|
|
786
|
+
**/
|
|
787
|
+
async function initLocalizationAsync(options = {}) {
|
|
788
|
+
const cwd = options.cwd ?? process.cwd();
|
|
789
|
+
const fallbackLocale = resolveLocale(options.fallbackLocale, DEFAULT_FALLBACK_LOCALE);
|
|
790
|
+
const fallbackAsset = await loadAssetAsync(fallbackLocale, cwd);
|
|
791
|
+
if (!fallbackAsset)
|
|
792
|
+
throw LocalizationError.get('fallback.loadFailed', fallbackLocale);
|
|
793
|
+
const systemLocale = resolveLocale(getSystemLocale(), fallbackLocale);
|
|
794
|
+
const effectiveAsset = await loadAssetAsync(systemLocale, cwd) ?? fallbackAsset;
|
|
795
|
+
const localesDir = join(cwd, 'locales');
|
|
796
|
+
const supportedLocales = await resolveSupportedLocalesAsync(localesDir);
|
|
797
|
+
const context = {
|
|
798
|
+
strict: options.strict ?? false,
|
|
799
|
+
cwd,
|
|
800
|
+
fallbackAsset,
|
|
801
|
+
supportedLocales,
|
|
802
|
+
lang: effectiveAsset.lang,
|
|
803
|
+
locale: effectiveAsset.locale,
|
|
804
|
+
messages: effectiveAsset.messages,
|
|
805
|
+
};
|
|
806
|
+
return {
|
|
807
|
+
getLocale: () => context.locale,
|
|
808
|
+
setLocaleAsync: async (locale) => {
|
|
809
|
+
await setLocaleAsync(locale, context);
|
|
810
|
+
},
|
|
811
|
+
t: createTranslator(context),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export { LocalizationError, SourceError, initLocalizationAsync };
|