@quantabit/i18n-sdk 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QuantaBit Team
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,22 @@
1
+ # @quantabit/i18n-sdk
2
+
3
+ > 轻量国际化引擎:Provider/Hook/语言切换器
4
+
5
+ - I18nProvider — React Context 提供者
6
+ - useI18n / useTranslation — 翻译 Hook
7
+ - LanguageSwitcher — 语言切换 UI (dropdown/pills)
8
+ - I18nManager — 纯 JS 管理器(非 React 场景可用)
9
+
10
+ ## License
11
+ MIT
12
+
13
+
14
+
15
+ ---
16
+
17
+ ## 🌐 Brand & Links
18
+ - Official Mainnet: [QuantaBit Chain](https://qbitchain.io/)
19
+ - Developer Platform: [Developer Platform](https://developer.quantabit.io/)
20
+ - Open Platform: [Open Platform](https://open.quantabit.io/)
21
+ - Payment Platform: [Pay Platform](https://pay.qbitwallet.io/)
22
+ - Feedback: [Feedback](https://xwin.live/qbit)
package/dist/index.cjs ADDED
@@ -0,0 +1,620 @@
1
+ 'use strict';
2
+
3
+ var React = require('react');
4
+
5
+ /**
6
+ * @quantabit/i18n-sdk - I18nManager
7
+ *
8
+ * 轻量级国际化管理器
9
+ * 特性:
10
+ * - 多语言消息管理与切换
11
+ * - 嵌套键路径支持 (e.g. 'nav.home')
12
+ * - 参数插值 ({name} → 'Tom')
13
+ * - 复数形式支持
14
+ * - 回退语言链
15
+ * - 语言自动检测 (浏览器偏好)
16
+ * - 懒加载语言包
17
+ * - 变更事件监听
18
+ */
19
+ class I18nManager {
20
+ constructor(config = {}) {
21
+ this.locale = config.defaultLocale || 'en';
22
+ this._messages = config.messages || {};
23
+ this._listeners = [];
24
+ this._fallback = config.fallbackLocale || 'en';
25
+ this._loading = new Set(); // 正在加载的语言包
26
+ this._loaders = {}; // 语言包加载器
27
+ this._dateFormats = {}; // 日期格式化配置
28
+ this._numberFormats = {}; // 数字格式化配置
29
+ this._debug = config.debug || false;
30
+
31
+ // 自动检测浏览器语言
32
+ if (config.autoDetect !== false && typeof navigator !== 'undefined') {
33
+ const browserLang = this._detectBrowserLanguage();
34
+ if (browserLang && this._messages[browserLang]) {
35
+ this.locale = browserLang;
36
+ }
37
+ }
38
+ }
39
+
40
+ // ============ 基础 API ============
41
+
42
+ /** 当前语言代码 */
43
+ get currentLocale() {
44
+ return this.locale;
45
+ }
46
+
47
+ /** 可用语言列表 */
48
+ get availableLocales() {
49
+ return Object.keys(this._messages);
50
+ }
51
+
52
+ /**
53
+ * 切换语言
54
+ * @param {string} locale - 语言代码
55
+ * @returns {Promise<boolean>} 是否成功
56
+ */
57
+ async setLocale(locale) {
58
+ // 如果语言包不存在且有加载器,尝试懒加载
59
+ if (!this._messages[locale] && this._loaders[locale]) {
60
+ await this.loadLocale(locale);
61
+ }
62
+ if (this._messages[locale]) {
63
+ const oldLocale = this.locale;
64
+ this.locale = locale;
65
+
66
+ // 保存到浏览器
67
+ if (typeof localStorage !== 'undefined') {
68
+ try {
69
+ localStorage.setItem('qbit_locale', locale);
70
+ } catch {}
71
+ }
72
+
73
+ // 设置 HTML lang 属性
74
+ if (typeof document !== 'undefined') {
75
+ document.documentElement.lang = locale;
76
+ // RTL 语言支持
77
+ const rtlLocales = ['ar', 'he', 'fa', 'ur'];
78
+ document.documentElement.dir = rtlLocales.includes(locale) ? 'rtl' : 'ltr';
79
+ }
80
+
81
+ // 通知监听器
82
+ this._listeners.forEach(fn => fn(locale, oldLocale));
83
+ return true;
84
+ }
85
+ if (this._debug) {
86
+ console.warn(`[i18n] 语言 "${locale}" 不可用。可用语言: ${this.availableLocales.join(', ')}`);
87
+ }
88
+ return false;
89
+ }
90
+
91
+ /**
92
+ * 添加语言消息
93
+ * @param {string} locale - 语言代码
94
+ * @param {Object} messages - 消息对象(支持嵌套)
95
+ */
96
+ addMessages(locale, messages) {
97
+ this._messages[locale] = this._deepMerge(this._messages[locale] || {}, messages);
98
+ }
99
+
100
+ /**
101
+ * 翻译 — 核心 API
102
+ * @param {string} key - 翻译键(支持嵌套 'nav.home')
103
+ * @param {Object} params - 插值参数
104
+ * @returns {string} 翻译结果
105
+ *
106
+ * @example
107
+ * i18n.t('greeting', { name: 'Tom' }) // "Hello, Tom!"
108
+ * i18n.t('items', { count: 3 }) // "3 items" (复数)
109
+ * i18n.t('nav.settings.privacy') // 嵌套键路径
110
+ */
111
+ t(key, params = {}) {
112
+ // 1. 查找当前语言
113
+ let msg = this._getNestedValue(this._messages[this.locale], key);
114
+
115
+ // 2. 回退到默认语言
116
+ if (msg === undefined || msg === null) {
117
+ msg = this._getNestedValue(this._messages[this._fallback], key);
118
+ }
119
+
120
+ // 3. 都找不到,返回键名本身(开发友好)
121
+ if (msg === undefined || msg === null) {
122
+ if (this._debug) {
123
+ console.warn(`[i18n] 缺失翻译: "${key}" (${this.locale})`);
124
+ }
125
+ return key;
126
+ }
127
+
128
+ // 4. 复数处理
129
+ if (typeof msg === 'object' && params.count !== undefined) {
130
+ msg = this._resolvePlural(msg, params.count);
131
+ }
132
+
133
+ // 5. 参数插值
134
+ if (typeof msg === 'string' && params) {
135
+ return this._interpolate(msg, params);
136
+ }
137
+ return msg;
138
+ }
139
+
140
+ /**
141
+ * 检查翻译键是否存在
142
+ * @param {string} key - 翻译键
143
+ * @returns {boolean}
144
+ */
145
+ exists(key) {
146
+ return this._getNestedValue(this._messages[this.locale], key) !== undefined;
147
+ }
148
+
149
+ // ============ 语言包管理 ============
150
+
151
+ /**
152
+ * 注册语言包懒加载器
153
+ * @param {string} locale - 语言代码
154
+ * @param {Function} loader - 异步加载函数,返回 Promise<messages>
155
+ *
156
+ * @example
157
+ * i18n.registerLoader('ja', () => import('./locales/ja.json'))
158
+ */
159
+ registerLoader(locale, loader) {
160
+ this._loaders[locale] = loader;
161
+ }
162
+
163
+ /**
164
+ * 懒加载语言包
165
+ * @param {string} locale - 语言代码
166
+ */
167
+ async loadLocale(locale) {
168
+ if (this._messages[locale] || this._loading.has(locale)) return;
169
+ const loader = this._loaders[locale];
170
+ if (!loader) {
171
+ if (this._debug) console.warn(`[i18n] 无 "${locale}" 加载器`);
172
+ return;
173
+ }
174
+ this._loading.add(locale);
175
+ try {
176
+ const messages = await loader();
177
+ // 兼顾 ES Module default export
178
+ this.addMessages(locale, messages.default || messages);
179
+ } catch (err) {
180
+ console.error(`[i18n] 加载 "${locale}" 失败:`, err);
181
+ } finally {
182
+ this._loading.delete(locale);
183
+ }
184
+ }
185
+
186
+ // ============ 格式化 ============
187
+
188
+ /**
189
+ * 格式化日期
190
+ * @param {Date|number|string} date - 日期
191
+ * @param {string} format - 格式名 ('short', 'long', 'full') 或自定义 Intl options
192
+ */
193
+ formatDate(date, format = 'short') {
194
+ const d = date instanceof Date ? date : new Date(date);
195
+ const presets = {
196
+ short: {
197
+ year: 'numeric',
198
+ month: '2-digit',
199
+ day: '2-digit'
200
+ },
201
+ long: {
202
+ year: 'numeric',
203
+ month: 'long',
204
+ day: 'numeric'
205
+ },
206
+ full: {
207
+ year: 'numeric',
208
+ month: 'long',
209
+ day: 'numeric',
210
+ weekday: 'long'
211
+ },
212
+ time: {
213
+ hour: '2-digit',
214
+ minute: '2-digit'
215
+ },
216
+ datetime: {
217
+ year: 'numeric',
218
+ month: 'short',
219
+ day: 'numeric',
220
+ hour: '2-digit',
221
+ minute: '2-digit'
222
+ }
223
+ };
224
+ const options = typeof format === 'string' ? presets[format] || presets.short : format;
225
+ return new Intl.DateTimeFormat(this.locale, options).format(d);
226
+ }
227
+
228
+ /**
229
+ * 格式化数字
230
+ * @param {number} num - 数字
231
+ * @param {string|Object} format - 格式名 ('decimal', 'currency', 'percent') 或自定义
232
+ */
233
+ formatNumber(num, format = 'decimal') {
234
+ const presets = {
235
+ decimal: {},
236
+ currency: {
237
+ style: 'currency',
238
+ currency: 'USD'
239
+ },
240
+ percent: {
241
+ style: 'percent',
242
+ minimumFractionDigits: 1
243
+ },
244
+ compact: {
245
+ notation: 'compact'
246
+ }
247
+ };
248
+ const options = typeof format === 'string' ? presets[format] || {} : format;
249
+ return new Intl.NumberFormat(this.locale, options).format(num);
250
+ }
251
+
252
+ /**
253
+ * 相对时间格式化
254
+ * @param {Date|number} date - 目标时间
255
+ */
256
+ formatRelative(date) {
257
+ const d = date instanceof Date ? date : new Date(date);
258
+ const now = Date.now();
259
+ const diffMs = d.getTime() - now;
260
+ const absDiff = Math.abs(diffMs);
261
+ const units = [{
262
+ unit: 'year',
263
+ ms: 31536000000
264
+ }, {
265
+ unit: 'month',
266
+ ms: 2592000000
267
+ }, {
268
+ unit: 'week',
269
+ ms: 604800000
270
+ }, {
271
+ unit: 'day',
272
+ ms: 86400000
273
+ }, {
274
+ unit: 'hour',
275
+ ms: 3600000
276
+ }, {
277
+ unit: 'minute',
278
+ ms: 60000
279
+ }, {
280
+ unit: 'second',
281
+ ms: 1000
282
+ }];
283
+ for (const {
284
+ unit,
285
+ ms
286
+ } of units) {
287
+ if (absDiff >= ms) {
288
+ const value = Math.round(diffMs / ms);
289
+ try {
290
+ return new Intl.RelativeTimeFormat(this.locale, {
291
+ numeric: 'auto'
292
+ }).format(value, unit);
293
+ } catch {
294
+ // Fallback: 简单文本
295
+ const absVal = Math.abs(value);
296
+ return diffMs < 0 ? `${absVal} ${unit}(s) ago` : `in ${absVal} ${unit}(s)`;
297
+ }
298
+ }
299
+ }
300
+ return this.locale.startsWith('zh') ? '刚刚' : 'just now';
301
+ }
302
+
303
+ // ============ 事件监听 ============
304
+
305
+ /**
306
+ * 监听语言变更
307
+ * @param {Function} callback - (newLocale, oldLocale) => void
308
+ * @returns {Function} 取消监听函数
309
+ */
310
+ onChange(callback) {
311
+ this._listeners.push(callback);
312
+ return () => {
313
+ this._listeners = this._listeners.filter(f => f !== callback);
314
+ };
315
+ }
316
+
317
+ // ============ 内部方法 ============
318
+
319
+ /** 检测浏览器语言偏好 */
320
+ _detectBrowserLanguage() {
321
+ // 先查 localStorage
322
+ try {
323
+ const saved = localStorage.getItem('qbit_locale');
324
+ if (saved) return saved;
325
+ } catch {}
326
+
327
+ // 浏览器语言
328
+ const lang = navigator.language || navigator.userLanguage;
329
+ return lang;
330
+ }
331
+
332
+ /** 嵌套键路径取值 (e.g. 'nav.home' → messages.nav.home) */
333
+ _getNestedValue(obj, key) {
334
+ if (!obj || !key) return undefined;
335
+
336
+ // 先精确匹配
337
+ if (obj[key] !== undefined) return obj[key];
338
+
339
+ // 嵌套路径
340
+ const parts = key.split('.');
341
+ let current = obj;
342
+ for (const part of parts) {
343
+ if (current === null || current === undefined || typeof current !== 'object') {
344
+ return undefined;
345
+ }
346
+ current = current[part];
347
+ }
348
+ return current;
349
+ }
350
+
351
+ /** 参数插值 {name} → value */
352
+ _interpolate(template, params) {
353
+ return template.replace(/\{(\w+)\}/g, (match, key) => {
354
+ return params[key] !== undefined ? params[key] : match;
355
+ });
356
+ }
357
+
358
+ /** 复数解析 */
359
+ _resolvePlural(obj, count) {
360
+ if (count === 0 && obj.zero) return obj.zero;
361
+ if (count === 1 && obj.one) return obj.one;
362
+ if (obj.other) return obj.other;
363
+ // 中文通常不区分单复数
364
+ return obj.other || obj.one || Object.values(obj)[0] || '';
365
+ }
366
+
367
+ /** 深合并对象 */
368
+ _deepMerge(target, source) {
369
+ const result = {
370
+ ...target
371
+ };
372
+ for (const key of Object.keys(source)) {
373
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
374
+ result[key] = this._deepMerge(result[key], source[key]);
375
+ } else {
376
+ result[key] = source[key];
377
+ }
378
+ }
379
+ return result;
380
+ }
381
+
382
+ /** 重置 */
383
+ reset() {
384
+ this._messages = {};
385
+ this._listeners = [];
386
+ this._loading.clear();
387
+ this.locale = this._fallback;
388
+ }
389
+
390
+ // ============ 隐私合规 ============
391
+
392
+ /**
393
+ * 清除用户语言偏好 — GDPR 本地数据清理
394
+ * 仅清除 localStorage 中的语言偏好记录
395
+ */
396
+ clearUserPreference() {
397
+ if (typeof localStorage !== 'undefined') {
398
+ localStorage.removeItem('qbit_locale');
399
+ }
400
+ this.locale = this._fallback;
401
+ // 恢复 HTML 属性
402
+ if (typeof document !== 'undefined') {
403
+ document.documentElement.lang = this._fallback;
404
+ document.documentElement.dir = 'ltr';
405
+ }
406
+ return true;
407
+ }
408
+
409
+ /**
410
+ * 获取隐私数据声明
411
+ */
412
+ getDataDisclosure() {
413
+ return {
414
+ sdk: '@quantabit/i18n-sdk',
415
+ privacyLevel: 'essential',
416
+ consentRequired: false,
417
+ collected: [{
418
+ type: 'locale_preference',
419
+ description: 'User language choice (e.g. "en", "zh")',
420
+ retention: 'Until cleared',
421
+ storage: 'localStorage'
422
+ }],
423
+ note: 'Language preference is essential for UI rendering and does not contain PII.',
424
+ gdprCapabilities: ['delete']
425
+ };
426
+ }
427
+ }
428
+
429
+ /**
430
+ * I18n SDK - 国际化
431
+ */
432
+
433
+ const zh = {
434
+ i18n: {
435
+ title: '语言设置',
436
+ currentLanguage: '当前语言',
437
+ languages: {
438
+ zh: '简体中文',
439
+ en: 'English',
440
+ ja: '日本語',
441
+ ko: '한국어'
442
+ },
443
+ actions: {
444
+ change: '切换语言',
445
+ save: '保存'
446
+ },
447
+ messages: {
448
+ changed: '语言已切换'
449
+ }
450
+ }
451
+ };
452
+ const en = {
453
+ i18n: {
454
+ title: 'Language Settings',
455
+ currentLanguage: 'Current Language',
456
+ languages: {
457
+ zh: '简体中文',
458
+ en: 'English',
459
+ ja: '日本語',
460
+ ko: '한국어'
461
+ },
462
+ actions: {
463
+ change: 'Change Language',
464
+ save: 'Save'
465
+ },
466
+ messages: {
467
+ changed: 'Language changed'
468
+ }
469
+ }
470
+ };
471
+ var i18nMessages = {
472
+ zh,
473
+ en
474
+ };
475
+
476
+ const I18nContext = /*#__PURE__*/React.createContext(null);
477
+ function I18nProvider({
478
+ messages = {},
479
+ defaultLocale = 'en',
480
+ fallbackLocale = 'en',
481
+ children
482
+ }) {
483
+ const manager = React.useMemo(() => new I18nManager({
484
+ messages,
485
+ defaultLocale,
486
+ fallbackLocale
487
+ }), []);
488
+ const [locale, setLocaleState] = React.useState(defaultLocale);
489
+ React.useEffect(() => {
490
+ return manager.onChange(l => setLocaleState(l));
491
+ }, [manager]);
492
+ const setLocale = React.useCallback(l => manager.setLocale(l), [manager]);
493
+ const t = React.useCallback((key, params) => manager.t(key, params), [locale, manager]);
494
+ const value = React.useMemo(() => ({
495
+ locale,
496
+ setLocale,
497
+ t,
498
+ availableLocales: manager.availableLocales,
499
+ manager
500
+ }), [locale, setLocale, t, manager]);
501
+ return /*#__PURE__*/React.createElement(I18nContext.Provider, {
502
+ value: value
503
+ }, children);
504
+ }
505
+ function useI18n() {
506
+ return React.useContext(I18nContext);
507
+ }
508
+ function useTranslation() {
509
+ const ctx = React.useContext(I18nContext);
510
+ return ctx?.t || (k => k);
511
+ }
512
+
513
+ const FLAGS = {
514
+ en: '🇬🇧',
515
+ zh: '🇨🇳',
516
+ ja: '🇯🇵',
517
+ ko: '🇰🇷',
518
+ es: '🇪🇸',
519
+ fr: '🇫🇷',
520
+ de: '🇩🇪',
521
+ pt: '🇧🇷',
522
+ ru: '🇷🇺',
523
+ ar: '🇸🇦'
524
+ };
525
+ function LanguageSwitcher({
526
+ variant = 'dropdown',
527
+ showFlag = true,
528
+ showLabel = true,
529
+ className = ''
530
+ }) {
531
+ const {
532
+ locale,
533
+ setLocale,
534
+ availableLocales
535
+ } = useI18n() || {};
536
+ if (!availableLocales) return null;
537
+ if (variant === 'pills') {
538
+ return /*#__PURE__*/React.createElement("div", {
539
+ className: `qi18n-pills ${className}`,
540
+ style: {
541
+ display: 'flex',
542
+ gap: 4,
543
+ background: 'rgba(128,128,128,0.06)',
544
+ borderRadius: 8,
545
+ padding: 3
546
+ }
547
+ }, availableLocales.map(l => /*#__PURE__*/React.createElement("button", {
548
+ key: l,
549
+ onClick: () => setLocale(l),
550
+ style: {
551
+ padding: '4px 12px',
552
+ borderRadius: 6,
553
+ border: 'none',
554
+ background: l === locale ? '#fff' : 'transparent',
555
+ color: l === locale ? '#18181b' : '#71717a',
556
+ fontSize: 13,
557
+ fontWeight: l === locale ? 600 : 400,
558
+ cursor: 'pointer',
559
+ boxShadow: l === locale ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
560
+ transition: 'all 0.2s'
561
+ }
562
+ }, showFlag && FLAGS[l] && /*#__PURE__*/React.createElement("span", {
563
+ style: {
564
+ marginRight: 4
565
+ }
566
+ }, FLAGS[l]), showLabel && l.toUpperCase())));
567
+ }
568
+ return /*#__PURE__*/React.createElement("select", {
569
+ value: locale,
570
+ onChange: e => setLocale(e.target.value),
571
+ className: `qi18n-select ${className}`,
572
+ style: {
573
+ padding: '6px 12px',
574
+ borderRadius: 8,
575
+ border: '1px solid #e4e4e7',
576
+ fontSize: 13,
577
+ cursor: 'pointer',
578
+ background: '#fff'
579
+ }
580
+ }, availableLocales.map(l => /*#__PURE__*/React.createElement("option", {
581
+ key: l,
582
+ value: l
583
+ }, FLAGS[l] || '', " ", l.toUpperCase())));
584
+ }
585
+
586
+ // SUPPORTED_LANGUAGES
587
+ const SUPPORTED_LANGUAGES = ['en', 'zh', 'ja', 'ko'];
588
+
589
+ // Singleton Instance
590
+ const i18n = new I18nManager({
591
+ defaultLocale: 'zh',
592
+ fallbackLocale: 'en',
593
+ messages: i18nMessages
594
+ });
595
+
596
+ // Shortcut methods wrapped safely
597
+ const t = (key, params) => i18n.t(key, params);
598
+ const setLanguage = locale => i18n.setLocale(locale);
599
+ const getLanguage = () => i18n.currentLocale;
600
+ const formatDate = (date, format) => i18n.formatDate(date, format);
601
+ const formatNumber = (num, format) => i18n.formatNumber(num, format);
602
+ const formatCurrency = (num, currency = 'USD') => i18n.formatNumber(num, {
603
+ style: 'currency',
604
+ currency
605
+ });
606
+
607
+ exports.I18nManager = I18nManager;
608
+ exports.I18nProvider = I18nProvider;
609
+ exports.LanguageSwitcher = LanguageSwitcher;
610
+ exports.SUPPORTED_LANGUAGES = SUPPORTED_LANGUAGES;
611
+ exports.formatCurrency = formatCurrency;
612
+ exports.formatDate = formatDate;
613
+ exports.formatNumber = formatNumber;
614
+ exports.getLanguage = getLanguage;
615
+ exports.i18n = i18n;
616
+ exports.setLanguage = setLanguage;
617
+ exports.t = t;
618
+ exports.useI18n = useI18n;
619
+ exports.useTranslation = useTranslation;
620
+ //# sourceMappingURL=index.cjs.map