@fluojs/i18n 1.0.0-beta.1
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.ko.md +537 -0
- package/README.md +537 -0
- package/dist/adapters.d.ts +180 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +266 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +19 -0
- package/dist/http.d.ts +120 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +179 -0
- package/dist/icu.d.ts +59 -0
- package/dist/icu.d.ts.map +1 -0
- package/dist/icu.js +142 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/loaders/fs.d.ts +43 -0
- package/dist/loaders/fs.d.ts.map +1 -0
- package/dist/loaders/fs.js +79 -0
- package/dist/loaders/remote.d.ts +146 -0
- package/dist/loaders/remote.d.ts.map +1 -0
- package/dist/loaders/remote.js +268 -0
- package/dist/loaders/shared.d.ts +54 -0
- package/dist/loaders/shared.d.ts.map +1 -0
- package/dist/loaders/shared.js +89 -0
- package/dist/locale-resolution.d.ts +86 -0
- package/dist/locale-resolution.d.ts.map +1 -0
- package/dist/locale-resolution.js +201 -0
- package/dist/module.d.ts +22 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +60 -0
- package/dist/options.d.ts +9 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +169 -0
- package/dist/service.d.ts +104 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +348 -0
- package/dist/typegen.d.ts +60 -0
- package/dist/typegen.d.ts.map +1 -0
- package/dist/typegen.js +215 -0
- package/dist/types.d.ts +154 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/validation.d.ts +74 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +123 -0
- package/package.json +97 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { snapshotI18nModuleOptions } from './options.js';
|
|
2
|
+
import { I18nError } from './errors.js';
|
|
3
|
+
function hasOwn(value, key) {
|
|
4
|
+
return typeof value === 'object' && value !== null && Object.hasOwn(value, key);
|
|
5
|
+
}
|
|
6
|
+
function isPlainObject(value) {
|
|
7
|
+
if (typeof value !== 'object' || value === null) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const prototype = Object.getPrototypeOf(value);
|
|
11
|
+
return prototype === Object.prototype || prototype === null;
|
|
12
|
+
}
|
|
13
|
+
function normalizeTranslationKey(key, namespace) {
|
|
14
|
+
if (typeof key !== 'string') {
|
|
15
|
+
throw new I18nError('Translation key must be a string.', 'I18N_INVALID_OPTIONS');
|
|
16
|
+
}
|
|
17
|
+
if (key.trim() === '') {
|
|
18
|
+
throw new I18nError('Translation key must be a non-empty string.', 'I18N_INVALID_OPTIONS');
|
|
19
|
+
}
|
|
20
|
+
if (namespace === undefined) {
|
|
21
|
+
return key;
|
|
22
|
+
}
|
|
23
|
+
if (typeof namespace !== 'string') {
|
|
24
|
+
throw new I18nError('Translation namespace must be a string when provided.', 'I18N_INVALID_OPTIONS');
|
|
25
|
+
}
|
|
26
|
+
if (namespace.trim() === '') {
|
|
27
|
+
throw new I18nError('Translation namespace must be a non-empty string when provided.', 'I18N_INVALID_OPTIONS');
|
|
28
|
+
}
|
|
29
|
+
return `${namespace}.${key}`;
|
|
30
|
+
}
|
|
31
|
+
function assertInterpolationValues(values) {
|
|
32
|
+
if (values !== undefined && !isPlainObject(values)) {
|
|
33
|
+
throw new I18nError('Translation values must be a plain object when provided.', 'I18N_INVALID_OPTIONS');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function assertDefaultValue(defaultValue) {
|
|
37
|
+
if (defaultValue !== undefined && typeof defaultValue !== 'string') {
|
|
38
|
+
throw new I18nError('Translation defaultValue must be a string when provided.', 'I18N_INVALID_OPTIONS');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function assertFormatterOptions(options, label) {
|
|
42
|
+
if (!isPlainObject(options)) {
|
|
43
|
+
throw new I18nError(`${label} options are required.`, 'I18N_INVALID_OPTIONS');
|
|
44
|
+
}
|
|
45
|
+
if (typeof options.locale !== 'string' || options.locale.trim() === '') {
|
|
46
|
+
throw new I18nError(`${label} locale must be a non-empty string.`, 'I18N_INVALID_OPTIONS');
|
|
47
|
+
}
|
|
48
|
+
if (options.format !== undefined && (typeof options.format !== 'string' || options.format.trim() === '')) {
|
|
49
|
+
throw new I18nError(`${label} format must be a non-empty string when provided.`, 'I18N_INVALID_OPTIONS');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function assertIntlOptionBag(options, label) {
|
|
53
|
+
if (options !== undefined && !isPlainObject(options)) {
|
|
54
|
+
throw new I18nError(`${label} Intl options must be a plain object when provided.`, 'I18N_INVALID_OPTIONS');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function assertCurrency(currency) {
|
|
58
|
+
if (typeof currency !== 'string' || currency.trim() === '') {
|
|
59
|
+
throw new I18nError('Currency code must be a non-empty string.', 'I18N_INVALID_OPTIONS');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function assertListValues(values) {
|
|
63
|
+
if (!Array.isArray(values) || values.some(value => typeof value !== 'string')) {
|
|
64
|
+
throw new I18nError('List values must be an array of strings.', 'I18N_INVALID_OPTIONS');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function formatWithIntlErrorBoundary(label, action) {
|
|
68
|
+
try {
|
|
69
|
+
return action();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof RangeError || error instanceof TypeError) {
|
|
72
|
+
throw new I18nError(`${label} Intl options are invalid.`, 'I18N_INVALID_OPTIONS');
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function interpolate(message, values) {
|
|
78
|
+
if (values === undefined) {
|
|
79
|
+
return message;
|
|
80
|
+
}
|
|
81
|
+
return message.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (placeholder, name) => {
|
|
82
|
+
if (!Object.hasOwn(values, name)) {
|
|
83
|
+
return placeholder;
|
|
84
|
+
}
|
|
85
|
+
const value = values[name];
|
|
86
|
+
return value === undefined || value === null ? '' : String(value);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function resolveMessage(tree, key) {
|
|
90
|
+
if (tree === undefined) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (hasOwn(tree, key)) {
|
|
94
|
+
const direct = tree[key];
|
|
95
|
+
return typeof direct === 'string' ? direct : undefined;
|
|
96
|
+
}
|
|
97
|
+
let current = tree;
|
|
98
|
+
for (const part of key.split('.')) {
|
|
99
|
+
if (!hasOwn(current, part)) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
current = current[part];
|
|
103
|
+
}
|
|
104
|
+
return typeof current === 'string' ? current : undefined;
|
|
105
|
+
}
|
|
106
|
+
function pushUnique(locales, locale) {
|
|
107
|
+
if (locale !== undefined && !locales.includes(locale)) {
|
|
108
|
+
locales.push(locale);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function isFallbackMap(value) {
|
|
112
|
+
return !Array.isArray(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Framework-agnostic core translation service backed by locale-scoped message catalogs.
|
|
117
|
+
*
|
|
118
|
+
* @remarks
|
|
119
|
+
* Locale selection is explicit per call. The core service performs deterministic catalog lookup and string
|
|
120
|
+
* interpolation only; request locale detection, loaders, ICU/messageformat, and framework adapters remain out of scope.
|
|
121
|
+
*/
|
|
122
|
+
export class I18nService {
|
|
123
|
+
options;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Creates a service with a detached options snapshot.
|
|
127
|
+
*
|
|
128
|
+
* @param options Root i18n options captured at the application boundary.
|
|
129
|
+
*/
|
|
130
|
+
constructor(options = {}) {
|
|
131
|
+
this.options = snapshotI18nModuleOptions(options);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns a detached copy of the root i18n options snapshot.
|
|
136
|
+
*
|
|
137
|
+
* @returns The captured i18n module options without exposing mutable service internals.
|
|
138
|
+
*/
|
|
139
|
+
snapshotOptions() {
|
|
140
|
+
return snapshotI18nModuleOptions(this.options);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Returns the deterministic locale chain used for a translation request.
|
|
145
|
+
*
|
|
146
|
+
* @param locale Explicit caller locale to resolve first.
|
|
147
|
+
* @returns Locale chain ordered as requested locale, configured fallback chain, then default locale.
|
|
148
|
+
* @throws {I18nError} When the locale is invalid or outside `supportedLocales`.
|
|
149
|
+
*/
|
|
150
|
+
resolveLocales(locale) {
|
|
151
|
+
if (typeof locale !== 'string' || locale.trim() === '') {
|
|
152
|
+
throw new I18nError('Translation locale must be a non-empty string.', 'I18N_INVALID_OPTIONS');
|
|
153
|
+
}
|
|
154
|
+
if (this.options.supportedLocales !== undefined && !this.options.supportedLocales.includes(locale)) {
|
|
155
|
+
throw new I18nError(`Unsupported i18n locale: ${locale}`, 'I18N_INVALID_OPTIONS');
|
|
156
|
+
}
|
|
157
|
+
const chain = [];
|
|
158
|
+
pushUnique(chain, locale);
|
|
159
|
+
if (Array.isArray(this.options.fallbackLocales)) {
|
|
160
|
+
for (const fallbackLocale of this.options.fallbackLocales) {
|
|
161
|
+
pushUnique(chain, fallbackLocale);
|
|
162
|
+
}
|
|
163
|
+
} else if (this.options.fallbackLocales !== undefined && isFallbackMap(this.options.fallbackLocales)) {
|
|
164
|
+
for (const fallbackLocale of this.options.fallbackLocales[locale] ?? []) {
|
|
165
|
+
pushUnique(chain, fallbackLocale);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
pushUnique(chain, this.options.defaultLocale);
|
|
169
|
+
return Object.freeze(chain);
|
|
170
|
+
}
|
|
171
|
+
resolveNamedOptions(formats, name, label) {
|
|
172
|
+
if (name === undefined) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const options = formats?.[name];
|
|
176
|
+
if (options === undefined) {
|
|
177
|
+
throw new I18nError(`Unknown ${label} format: ${name}`, 'I18N_INVALID_OPTIONS');
|
|
178
|
+
}
|
|
179
|
+
return options;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Formats a date or timestamp for an explicit locale using `Intl.DateTimeFormat`.
|
|
184
|
+
*
|
|
185
|
+
* @param value Date instance or epoch timestamp accepted by `Intl.DateTimeFormat`.
|
|
186
|
+
* @param options Explicit locale, optional named format, and inline date/time options.
|
|
187
|
+
* @returns Locale-formatted date/time text from the host standard `Intl` implementation.
|
|
188
|
+
* @throws {I18nError} When options are invalid or a named date/time format is missing.
|
|
189
|
+
*/
|
|
190
|
+
formatDateTime(value, options) {
|
|
191
|
+
assertFormatterOptions(options, 'Date/time formatter');
|
|
192
|
+
assertIntlOptionBag(options.options, 'Date/time formatter');
|
|
193
|
+
this.resolveLocales(options.locale);
|
|
194
|
+
const namedOptions = this.resolveNamedOptions(this.options.formats?.dateTime, options.format, 'dateTime');
|
|
195
|
+
return formatWithIntlErrorBoundary('Date/time formatter', () => new Intl.DateTimeFormat(options.locale, {
|
|
196
|
+
...namedOptions,
|
|
197
|
+
...options.options
|
|
198
|
+
}).format(value));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Formats a number for an explicit locale using `Intl.NumberFormat`.
|
|
203
|
+
*
|
|
204
|
+
* @param value Number value passed to `Intl.NumberFormat`.
|
|
205
|
+
* @param options Explicit locale, optional named format, and inline number options.
|
|
206
|
+
* @returns Locale-formatted number text from the host standard `Intl` implementation.
|
|
207
|
+
* @throws {I18nError} When options are invalid or a named number format is missing.
|
|
208
|
+
*/
|
|
209
|
+
formatNumber(value, options) {
|
|
210
|
+
assertFormatterOptions(options, 'Number formatter');
|
|
211
|
+
assertIntlOptionBag(options.options, 'Number formatter');
|
|
212
|
+
this.resolveLocales(options.locale);
|
|
213
|
+
const namedOptions = this.resolveNamedOptions(this.options.formats?.number, options.format, 'number');
|
|
214
|
+
return formatWithIntlErrorBoundary('Number formatter', () => new Intl.NumberFormat(options.locale, {
|
|
215
|
+
...namedOptions,
|
|
216
|
+
...options.options
|
|
217
|
+
}).format(value));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Formats a currency amount for an explicit locale using `Intl.NumberFormat`.
|
|
222
|
+
*
|
|
223
|
+
* @param value Currency amount passed to `Intl.NumberFormat`.
|
|
224
|
+
* @param options Explicit locale, ISO 4217 currency code, optional named format, and inline number options.
|
|
225
|
+
* @returns Locale-formatted currency text from the host standard `Intl` implementation.
|
|
226
|
+
* @throws {I18nError} When options are invalid or a named number format is missing.
|
|
227
|
+
*/
|
|
228
|
+
formatCurrency(value, options) {
|
|
229
|
+
assertFormatterOptions(options, 'Currency formatter');
|
|
230
|
+
assertCurrency(options.currency);
|
|
231
|
+
assertIntlOptionBag(options.options, 'Currency formatter');
|
|
232
|
+
this.resolveLocales(options.locale);
|
|
233
|
+
const namedOptions = this.resolveNamedOptions(this.options.formats?.number, options.format, 'number');
|
|
234
|
+
return formatWithIntlErrorBoundary('Currency formatter', () => new Intl.NumberFormat(options.locale, {
|
|
235
|
+
...namedOptions,
|
|
236
|
+
...options.options,
|
|
237
|
+
currency: options.currency,
|
|
238
|
+
style: 'currency'
|
|
239
|
+
}).format(value));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Formats a ratio as a percent for an explicit locale using `Intl.NumberFormat`.
|
|
244
|
+
*
|
|
245
|
+
* @param value Ratio value passed to `Intl.NumberFormat` with `style: 'percent'`.
|
|
246
|
+
* @param options Explicit locale, optional named format, and inline number options.
|
|
247
|
+
* @returns Locale-formatted percent text from the host standard `Intl` implementation.
|
|
248
|
+
* @throws {I18nError} When options are invalid or a named number format is missing.
|
|
249
|
+
*/
|
|
250
|
+
formatPercent(value, options) {
|
|
251
|
+
assertFormatterOptions(options, 'Percent formatter');
|
|
252
|
+
assertIntlOptionBag(options.options, 'Percent formatter');
|
|
253
|
+
this.resolveLocales(options.locale);
|
|
254
|
+
const namedOptions = this.resolveNamedOptions(this.options.formats?.number, options.format, 'number');
|
|
255
|
+
return formatWithIntlErrorBoundary('Percent formatter', () => new Intl.NumberFormat(options.locale, {
|
|
256
|
+
...namedOptions,
|
|
257
|
+
...options.options,
|
|
258
|
+
style: 'percent'
|
|
259
|
+
}).format(value));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Formats a string list for an explicit locale using `Intl.ListFormat`.
|
|
264
|
+
*
|
|
265
|
+
* @param values List item strings passed to `Intl.ListFormat`.
|
|
266
|
+
* @param options Explicit locale, optional named format, and inline list options.
|
|
267
|
+
* @returns Locale-formatted list text from the host standard `Intl` implementation.
|
|
268
|
+
* @throws {I18nError} When options are invalid or a named list format is missing.
|
|
269
|
+
*/
|
|
270
|
+
formatList(values, options) {
|
|
271
|
+
assertListValues(values);
|
|
272
|
+
assertFormatterOptions(options, 'List formatter');
|
|
273
|
+
assertIntlOptionBag(options.options, 'List formatter');
|
|
274
|
+
this.resolveLocales(options.locale);
|
|
275
|
+
const namedOptions = this.resolveNamedOptions(this.options.formats?.list, options.format, 'list');
|
|
276
|
+
return formatWithIntlErrorBoundary('List formatter', () => new Intl.ListFormat(options.locale, {
|
|
277
|
+
...namedOptions,
|
|
278
|
+
...options.options
|
|
279
|
+
}).format(values));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Formats a relative time value for an explicit locale using `Intl.RelativeTimeFormat`.
|
|
284
|
+
*
|
|
285
|
+
* @param value Numeric offset passed to `Intl.RelativeTimeFormat`.
|
|
286
|
+
* @param unit Relative time unit such as `day`, `hour`, or `minute`.
|
|
287
|
+
* @param options Explicit locale, optional named format, and inline relative time options.
|
|
288
|
+
* @returns Locale-formatted relative time text from the host standard `Intl` implementation.
|
|
289
|
+
* @throws {I18nError} When options are invalid or a named relative time format is missing.
|
|
290
|
+
*/
|
|
291
|
+
formatRelativeTime(value, unit, options) {
|
|
292
|
+
assertFormatterOptions(options, 'Relative time formatter');
|
|
293
|
+
assertIntlOptionBag(options.options, 'Relative time formatter');
|
|
294
|
+
this.resolveLocales(options.locale);
|
|
295
|
+
const namedOptions = this.resolveNamedOptions(this.options.formats?.relativeTime, options.format, 'relativeTime');
|
|
296
|
+
return formatWithIntlErrorBoundary('Relative time formatter', () => new Intl.RelativeTimeFormat(options.locale, {
|
|
297
|
+
...namedOptions,
|
|
298
|
+
...options.options
|
|
299
|
+
}).format(value, unit));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Resolves and interpolates a catalog message for an explicit locale.
|
|
304
|
+
*
|
|
305
|
+
* @param key Dot-path catalog key, optionally prefixed by `options.namespace`.
|
|
306
|
+
* @param options Per-call locale, interpolation values, and default value.
|
|
307
|
+
* @returns The resolved catalog message, default value, or missing-message hook result.
|
|
308
|
+
* @throws {I18nError} When options are invalid or the message cannot be resolved.
|
|
309
|
+
*/
|
|
310
|
+
translate(key, options) {
|
|
311
|
+
if (options === undefined || typeof options !== 'object' || options === null) {
|
|
312
|
+
throw new I18nError('Translation options are required.', 'I18N_INVALID_OPTIONS');
|
|
313
|
+
}
|
|
314
|
+
assertInterpolationValues(options.values);
|
|
315
|
+
assertDefaultValue(options.defaultValue);
|
|
316
|
+
const resolvedKey = normalizeTranslationKey(key, options.namespace);
|
|
317
|
+
const locales = this.resolveLocales(options.locale);
|
|
318
|
+
for (const locale of locales) {
|
|
319
|
+
const message = resolveMessage(this.options.catalogs?.[locale], resolvedKey);
|
|
320
|
+
if (message !== undefined) {
|
|
321
|
+
return interpolate(message, options.values);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (options.defaultValue !== undefined) {
|
|
325
|
+
return interpolate(options.defaultValue, options.values);
|
|
326
|
+
}
|
|
327
|
+
const missing = this.options.missingMessage?.({
|
|
328
|
+
attemptedLocales: locales,
|
|
329
|
+
key: resolvedKey,
|
|
330
|
+
locale: options.locale,
|
|
331
|
+
values: options.values
|
|
332
|
+
});
|
|
333
|
+
if (missing !== undefined) {
|
|
334
|
+
return interpolate(missing, options.values);
|
|
335
|
+
}
|
|
336
|
+
throw new I18nError(`Missing i18n message: ${resolvedKey}`, 'I18N_MISSING_MESSAGE');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Creates a standalone i18n service without registering a fluo module.
|
|
342
|
+
*
|
|
343
|
+
* @param options Root i18n options for the standalone service instance.
|
|
344
|
+
* @returns An `I18nService` configured with a detached options snapshot.
|
|
345
|
+
*/
|
|
346
|
+
export function createI18n(options = {}) {
|
|
347
|
+
return new I18nService(options);
|
|
348
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { I18nLocale, I18nMessageTree, I18nTranslationKey } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Catalog input consumed by the i18n catalog type generator.
|
|
4
|
+
*/
|
|
5
|
+
export interface I18nCatalogTypegenInput {
|
|
6
|
+
/** Locale that owns this catalog tree. */
|
|
7
|
+
readonly locale: I18nLocale;
|
|
8
|
+
/** Optional filesystem or remote loader namespace path, for example `common` or `admin/common`. */
|
|
9
|
+
readonly namespace?: I18nTranslationKey;
|
|
10
|
+
/** Nested message tree whose string leaves become generated translation keys. */
|
|
11
|
+
readonly messages: I18nMessageTree;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Options for rendering TypeScript declarations from catalog inputs.
|
|
15
|
+
*/
|
|
16
|
+
export interface I18nCatalogTypegenOptions {
|
|
17
|
+
/** Banner written at the top of the generated declaration output. */
|
|
18
|
+
readonly banner?: string;
|
|
19
|
+
/** Exported union type name for fully qualified translation keys. */
|
|
20
|
+
readonly keyTypeName?: string;
|
|
21
|
+
/** Exported union type name for discovered namespace paths. */
|
|
22
|
+
readonly namespaceTypeName?: string;
|
|
23
|
+
/** Exported mapped type name that stores leaf keys by namespace path. */
|
|
24
|
+
readonly keyByNamespaceTypeName?: string;
|
|
25
|
+
/** Exported helper type name that resolves leaf keys for a namespace path. */
|
|
26
|
+
readonly namespaceKeyTypeName?: string;
|
|
27
|
+
/** Exported options type name for fully qualified typed translation calls. */
|
|
28
|
+
readonly typedTranslateOptionsTypeName?: string;
|
|
29
|
+
/** Exported callable type name for fully qualified key translation. */
|
|
30
|
+
readonly typedTranslateTypeName?: string;
|
|
31
|
+
/** Exported facade type name for opt-in typed translation callsites. */
|
|
32
|
+
readonly typedServiceTypeName?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Options for loading JSON catalog files from a locale/namespace directory tree before type generation.
|
|
36
|
+
*/
|
|
37
|
+
export interface I18nCatalogTypegenDirectoryOptions extends I18nCatalogTypegenOptions {
|
|
38
|
+
/** Root directory containing locale directories and nested JSON namespace files. */
|
|
39
|
+
readonly rootDir: string;
|
|
40
|
+
/** Optional locale allow-list. When omitted, all immediate directories under `rootDir` are scanned. */
|
|
41
|
+
readonly locales?: readonly I18nLocale[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Generates deterministic TypeScript translation-key declarations from catalog message trees.
|
|
45
|
+
*
|
|
46
|
+
* @param inputs Locale and optional namespace catalog trees to inspect.
|
|
47
|
+
* @param options Optional exported type names and output banner controls.
|
|
48
|
+
* @returns TypeScript declaration text containing key and namespace helper types.
|
|
49
|
+
* @throws {I18nError} When catalog inputs, namespaces, or output type names are invalid.
|
|
50
|
+
*/
|
|
51
|
+
export declare function generateI18nCatalogTypes(inputs: readonly I18nCatalogTypegenInput[], options?: I18nCatalogTypegenOptions): string;
|
|
52
|
+
/**
|
|
53
|
+
* Reads locale/namespace JSON catalogs from disk and generates deterministic TypeScript key declarations.
|
|
54
|
+
*
|
|
55
|
+
* @param options Directory scanning and output rendering options.
|
|
56
|
+
* @returns TypeScript declaration text containing key and namespace helper types.
|
|
57
|
+
* @throws {I18nError} When directory options, catalog JSON, namespaces, or message trees are invalid.
|
|
58
|
+
*/
|
|
59
|
+
export declare function generateI18nCatalogTypesFromDirectory(options: I18nCatalogTypegenDirectoryOptions): Promise<string>;
|
|
60
|
+
//# sourceMappingURL=typegen.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"typegen.d.ts","sourceRoot":"","sources":["../src/typegen.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAkBlF;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACtC,0CAA0C;IAC1C,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,mGAAmG;IACnG,QAAQ,CAAC,SAAS,CAAC,EAAE,kBAAkB,CAAC;IACxC,iFAAiF;IACjF,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,yBAAyB;IACxC,qEAAqE;IACrE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,qEAAqE;IACrE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,+DAA+D;IAC/D,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,yEAAyE;IACzE,QAAQ,CAAC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IACzC,8EAA8E;IAC9E,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,8EAA8E;IAC9E,QAAQ,CAAC,6BAA6B,CAAC,EAAE,MAAM,CAAC;IAChD,uEAAuE;IACvE,QAAQ,CAAC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IACzC,wEAAwE;IACxE,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;CACxC;AAED;;GAEG;AACH,MAAM,WAAW,kCAAmC,SAAQ,yBAAyB;IACnF,oFAAoF;IACpF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uGAAuG;IACvG,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;CAC1C;AA+JD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,SAAS,uBAAuB,EAAE,EAC1C,OAAO,GAAE,yBAA8B,GACtC,MAAM,CA8ER;AAED;;;;;;GAMG;AACH,wBAAsB,qCAAqC,CAAC,OAAO,EAAE,kCAAkC,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBxH"}
|
package/dist/typegen.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, join, relative, sep } from 'node:path';
|
|
3
|
+
import { I18nError } from './errors.js';
|
|
4
|
+
import { isPlainObject, snapshotLoaderMessageTree, validateLoaderLocale, validateLoaderNamespace } from './loaders/shared.js';
|
|
5
|
+
const DEFAULT_BANNER = '/* Generated by @fluojs/i18n/typegen. Do not edit manually. */';
|
|
6
|
+
const DEFAULT_KEY_TYPE_NAME = 'I18nCatalogKey';
|
|
7
|
+
const DEFAULT_NAMESPACE_TYPE_NAME = 'I18nCatalogNamespace';
|
|
8
|
+
const DEFAULT_KEY_BY_NAMESPACE_TYPE_NAME = 'I18nCatalogKeyByNamespace';
|
|
9
|
+
const DEFAULT_NAMESPACE_KEY_TYPE_NAME = 'I18nCatalogNamespaceKey';
|
|
10
|
+
const DEFAULT_TYPED_TRANSLATE_OPTIONS_TYPE_NAME = 'I18nCatalogTypedTranslateOptions';
|
|
11
|
+
const DEFAULT_TYPED_TRANSLATE_TYPE_NAME = 'I18nCatalogTypedTranslate';
|
|
12
|
+
const DEFAULT_TYPED_SERVICE_TYPE_NAME = 'I18nCatalogTypedService';
|
|
13
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Catalog input consumed by the i18n catalog type generator.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Options for rendering TypeScript declarations from catalog inputs.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Options for loading JSON catalog files from a locale/namespace directory tree before type generation.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function assertIdentifier(value, fallback, label) {
|
|
28
|
+
const name = value ?? fallback;
|
|
29
|
+
if (!IDENTIFIER_PATTERN.test(name)) {
|
|
30
|
+
throw new I18nError(`${label} must be a valid TypeScript identifier.`, 'I18N_INVALID_OPTIONS');
|
|
31
|
+
}
|
|
32
|
+
return name;
|
|
33
|
+
}
|
|
34
|
+
function normalizeBanner(value) {
|
|
35
|
+
if (value === undefined) {
|
|
36
|
+
return DEFAULT_BANNER;
|
|
37
|
+
}
|
|
38
|
+
if (typeof value !== 'string') {
|
|
39
|
+
throw new I18nError('Catalog typegen banner must be a string when provided.', 'I18N_INVALID_OPTIONS');
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function assertDirectoryOptions(options) {
|
|
44
|
+
if (!isPlainObject(options) || typeof options.rootDir !== 'string' || options.rootDir.trim() === '') {
|
|
45
|
+
throw new I18nError('Catalog typegen rootDir must be a non-empty string.', 'I18N_INVALID_OPTIONS');
|
|
46
|
+
}
|
|
47
|
+
if (options.locales !== undefined) {
|
|
48
|
+
if (!Array.isArray(options.locales)) {
|
|
49
|
+
throw new I18nError('Catalog typegen locales must be an array when provided.', 'I18N_INVALID_OPTIONS');
|
|
50
|
+
}
|
|
51
|
+
for (const locale of options.locales) {
|
|
52
|
+
validateLoaderLocale(locale, 'Catalog typegen');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function normalizeInput(input) {
|
|
57
|
+
if (!isPlainObject(input)) {
|
|
58
|
+
throw new I18nError('Catalog typegen input must be a plain object.', 'I18N_INVALID_OPTIONS');
|
|
59
|
+
}
|
|
60
|
+
validateLoaderLocale(input.locale, 'Catalog typegen');
|
|
61
|
+
if (input.namespace !== undefined) {
|
|
62
|
+
validateLoaderNamespace(input.namespace, 'Catalog typegen');
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
locale: input.locale,
|
|
66
|
+
messages: snapshotLoaderMessageTree(input.messages, `catalogs.${input.locale}${input.namespace === undefined ? '' : `.${input.namespace}`}`),
|
|
67
|
+
namespace: input.namespace
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function collectLeafKeys(tree, prefix = '') {
|
|
71
|
+
const keys = [];
|
|
72
|
+
for (const key of Object.keys(tree).sort()) {
|
|
73
|
+
const value = tree[key];
|
|
74
|
+
const nextPrefix = prefix === '' ? key : `${prefix}.${key}`;
|
|
75
|
+
if (typeof value === 'string') {
|
|
76
|
+
keys.push(nextPrefix);
|
|
77
|
+
} else {
|
|
78
|
+
keys.push(...collectLeafKeys(value, nextPrefix));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return keys;
|
|
82
|
+
}
|
|
83
|
+
function unionLiteral(values) {
|
|
84
|
+
if (values.length === 0) {
|
|
85
|
+
return 'never';
|
|
86
|
+
}
|
|
87
|
+
return values.map(value => JSON.stringify(value)).join(' | ');
|
|
88
|
+
}
|
|
89
|
+
function renderNamespaceMap(typeName, entries) {
|
|
90
|
+
if (entries.size === 0) {
|
|
91
|
+
return [`export type ${typeName} = Record<never, never>;`];
|
|
92
|
+
}
|
|
93
|
+
const lines = [`export interface ${typeName} {`];
|
|
94
|
+
for (const namespace of [...entries.keys()].sort()) {
|
|
95
|
+
lines.push(` readonly ${JSON.stringify(namespace)}: ${unionLiteral(entries.get(namespace) ?? [])};`);
|
|
96
|
+
}
|
|
97
|
+
lines.push('}');
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
function renderTypedHelpers(names) {
|
|
101
|
+
return [`export type ${names.namespaceKeyTypeName}<Namespace extends ${names.namespaceTypeName}> = ${names.keyByNamespaceTypeName}[Namespace];`, `export type ${names.typedTranslateOptionsTypeName} = Omit<import('@fluojs/i18n').I18nTranslateOptions, 'namespace'>;`, `export type ${names.typedTranslateTypeName} = <Key extends ${names.keyTypeName}>(`, ' key: Key,', ` options: ${names.typedTranslateOptionsTypeName},`, ') => string;', `export interface ${names.typedServiceTypeName} {`, ` readonly translate: ${names.typedTranslateTypeName};`, ' readonly translateInNamespace: <', ` Namespace extends ${names.namespaceTypeName},`, ` Key extends ${names.namespaceKeyTypeName}<Namespace>,`, ' >(', ' namespace: Namespace,', ' key: Key,', " options: Omit<import('@fluojs/i18n').I18nTranslateOptions, 'namespace'>,", ' ) => string;', '}'];
|
|
102
|
+
}
|
|
103
|
+
function relativeJsonNamespace(localeDir, filePath) {
|
|
104
|
+
const relativePath = relative(localeDir, filePath).split(sep).join('/');
|
|
105
|
+
return relativePath.slice(0, -extname(relativePath).length);
|
|
106
|
+
}
|
|
107
|
+
async function collectCatalogFiles(directory) {
|
|
108
|
+
const entries = await readdir(directory, {
|
|
109
|
+
withFileTypes: true
|
|
110
|
+
});
|
|
111
|
+
const files = [];
|
|
112
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
113
|
+
const entryPath = join(directory, entry.name);
|
|
114
|
+
if (entry.isDirectory()) {
|
|
115
|
+
files.push(...(await collectCatalogFiles(entryPath)));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
119
|
+
files.push(entryPath);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return files;
|
|
123
|
+
}
|
|
124
|
+
async function readCatalogFile(locale, namespace, filePath) {
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
128
|
+
} catch {
|
|
129
|
+
throw new I18nError(`Malformed i18n catalog JSON: ${locale}/${namespace}.json`, 'I18N_INVALID_CATALOG');
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
locale,
|
|
133
|
+
messages: snapshotLoaderMessageTree(parsed, `catalogs.${locale}.${namespace}`),
|
|
134
|
+
namespace
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Generates deterministic TypeScript translation-key declarations from catalog message trees.
|
|
140
|
+
*
|
|
141
|
+
* @param inputs Locale and optional namespace catalog trees to inspect.
|
|
142
|
+
* @param options Optional exported type names and output banner controls.
|
|
143
|
+
* @returns TypeScript declaration text containing key and namespace helper types.
|
|
144
|
+
* @throws {I18nError} When catalog inputs, namespaces, or output type names are invalid.
|
|
145
|
+
*/
|
|
146
|
+
export function generateI18nCatalogTypes(inputs, options = {}) {
|
|
147
|
+
if (!Array.isArray(inputs)) {
|
|
148
|
+
throw new I18nError('Catalog typegen inputs must be an array.', 'I18N_INVALID_OPTIONS');
|
|
149
|
+
}
|
|
150
|
+
const keyTypeName = assertIdentifier(options.keyTypeName, DEFAULT_KEY_TYPE_NAME, 'Catalog typegen keyTypeName');
|
|
151
|
+
const namespaceTypeName = assertIdentifier(options.namespaceTypeName, DEFAULT_NAMESPACE_TYPE_NAME, 'Catalog typegen namespaceTypeName');
|
|
152
|
+
const keyByNamespaceTypeName = assertIdentifier(options.keyByNamespaceTypeName, DEFAULT_KEY_BY_NAMESPACE_TYPE_NAME, 'Catalog typegen keyByNamespaceTypeName');
|
|
153
|
+
const namespaceKeyTypeName = assertIdentifier(options.namespaceKeyTypeName, DEFAULT_NAMESPACE_KEY_TYPE_NAME, 'Catalog typegen namespaceKeyTypeName');
|
|
154
|
+
const typedTranslateTypeName = assertIdentifier(options.typedTranslateTypeName, DEFAULT_TYPED_TRANSLATE_TYPE_NAME, 'Catalog typegen typedTranslateTypeName');
|
|
155
|
+
const typedTranslateOptionsTypeName = assertIdentifier(options.typedTranslateOptionsTypeName, DEFAULT_TYPED_TRANSLATE_OPTIONS_TYPE_NAME, 'Catalog typegen typedTranslateOptionsTypeName');
|
|
156
|
+
const typedServiceTypeName = assertIdentifier(options.typedServiceTypeName, DEFAULT_TYPED_SERVICE_TYPE_NAME, 'Catalog typegen typedServiceTypeName');
|
|
157
|
+
const keys = new Set();
|
|
158
|
+
const namespaces = new Set();
|
|
159
|
+
const keysByNamespace = new Map();
|
|
160
|
+
for (const input of inputs.map(normalizeInput)) {
|
|
161
|
+
const leafKeys = collectLeafKeys(input.messages);
|
|
162
|
+
const namespace = input.namespace;
|
|
163
|
+
if (namespace !== undefined) {
|
|
164
|
+
namespaces.add(namespace);
|
|
165
|
+
const namespaceKeys = keysByNamespace.get(namespace) ?? new Set();
|
|
166
|
+
for (const leafKey of leafKeys) {
|
|
167
|
+
namespaceKeys.add(leafKey);
|
|
168
|
+
keys.add(`${namespace}.${leafKey}`);
|
|
169
|
+
}
|
|
170
|
+
keysByNamespace.set(namespace, namespaceKeys);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
for (const leafKey of leafKeys) {
|
|
174
|
+
keys.add(leafKey);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const sortedNamespaceMap = new Map();
|
|
178
|
+
for (const namespace of [...keysByNamespace.keys()].sort()) {
|
|
179
|
+
sortedNamespaceMap.set(namespace, [...(keysByNamespace.get(namespace) ?? [])].sort());
|
|
180
|
+
}
|
|
181
|
+
return [normalizeBanner(options.banner), '', `export type ${keyTypeName} = ${unionLiteral([...keys].sort())};`, `export type ${namespaceTypeName} = ${unionLiteral([...namespaces].sort())};`, ...renderNamespaceMap(keyByNamespaceTypeName, sortedNamespaceMap), ...renderTypedHelpers({
|
|
182
|
+
keyByNamespaceTypeName,
|
|
183
|
+
keyTypeName,
|
|
184
|
+
namespaceKeyTypeName,
|
|
185
|
+
namespaceTypeName,
|
|
186
|
+
typedServiceTypeName,
|
|
187
|
+
typedTranslateOptionsTypeName,
|
|
188
|
+
typedTranslateTypeName
|
|
189
|
+
}), ''].join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Reads locale/namespace JSON catalogs from disk and generates deterministic TypeScript key declarations.
|
|
194
|
+
*
|
|
195
|
+
* @param options Directory scanning and output rendering options.
|
|
196
|
+
* @returns TypeScript declaration text containing key and namespace helper types.
|
|
197
|
+
* @throws {I18nError} When directory options, catalog JSON, namespaces, or message trees are invalid.
|
|
198
|
+
*/
|
|
199
|
+
export async function generateI18nCatalogTypesFromDirectory(options) {
|
|
200
|
+
assertDirectoryOptions(options);
|
|
201
|
+
const localeNames = options.locales === undefined ? (await readdir(options.rootDir, {
|
|
202
|
+
withFileTypes: true
|
|
203
|
+
})).filter(entry => entry.isDirectory()).map(entry => entry.name) : [...options.locales];
|
|
204
|
+
const inputs = [];
|
|
205
|
+
for (const locale of localeNames.sort()) {
|
|
206
|
+
validateLoaderLocale(locale, 'Catalog typegen');
|
|
207
|
+
const localeDir = join(options.rootDir, locale);
|
|
208
|
+
for (const filePath of await collectCatalogFiles(localeDir)) {
|
|
209
|
+
const namespace = relativeJsonNamespace(localeDir, filePath);
|
|
210
|
+
validateLoaderNamespace(namespace, 'Catalog typegen');
|
|
211
|
+
inputs.push(await readCatalogFile(locale, namespace, filePath));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return generateI18nCatalogTypes(inputs, options);
|
|
215
|
+
}
|