mm_lang 1.0.1 → 2.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/README.md CHANGED
@@ -1,22 +1,29 @@
1
1
  # mm_lang
2
2
 
3
- 超级美眉多语言/语言包模块,提供 `Lang` 类作为国际化(i18n)核心能力:按语言字典取值、`a.b` 层级键解析、多级回退机制(当前语言 → 基础语言 → 默认语言)、复数形式、占位符参数替换,以及日期与数字的本地化格式化。行为对齐 mm_expand 的 Lang,但不依赖外部模块与全局变量。
3
+ 超级美眉多语言模块。融合了原 `mm_lang`(i18n 行为)与 `mm_langs`(语言存储)两个模块:`Lang` 负责翻译行为,`Langs` 负责键值存储与 JSON 文件持久化,两者组合实现"动作与数据分离"。
4
+
5
+ > **迁移提示**:`mm_langs` 已合并至本模块,其 `Langs` 类与 `langsAdmin` 从 `mm_lang` 直接导出,API 与报错文案保持不变。原 `require('mm_langs')` 改为 `require('mm_lang')` 即可。
4
6
 
5
7
  ## 项目信息
6
8
 
7
- - **当前版本**: 1.0.0
8
- - **更新日期**: 2026-10-04
9
+ - **当前版本**: 2.0.0
10
+ - **更新日期**: 2026-10-30
9
11
  - **Gitee地址**: https://gitee.com/qiuwenwu91/mm_lang
10
- - **依赖**: 无第三方依赖(纯 Node.js 实现)
12
+ - **依赖**: `mm_corejs`(管理器)、`mm_kv_store`(键值存储基类)
13
+
14
+ ## 模块结构
15
+
16
+ | 文件 | 职责 |
17
+ |---|---|
18
+ | `lib/lang.js` | 行为侧:取词、回退链、复数、格式化,**不做文件 IO** |
19
+ | `lib/langs.js` | 数据侧:string→string 键值、JSON 文件 save/load、值类型校验,**不感知词条嵌套语义** |
20
+ | `lib/store.js` | 适配层:嵌套词条字典 ↔ 扁平键值互转、存储实例装配 |
11
21
 
12
22
  ## 特点
13
23
 
14
- - 零依赖,内置 `zh_cn` 与 `en` 两种语言的默认词条容器
15
- - 支持 `a.b.c` 形式的层级键取值与写入
16
- - 带回退机制的取值链:当前语言 → 基础语言(如 `en-US` 回退到 `en`)→ 默认语言
17
- - 复数形式支持:`tc` 按数量选择词条数组索引,内置英文/中文复数规则,可通过 `registerPlural` 自定义
18
- - 占位符替换:`get`/`t` 支持 `{0}`、`{1}` 等;`tc` 支持 `{count}` 及 `{1}`、`{2}` 等
19
- - 附带日期格式化(`formatDate`)与数字格式化(`formatNumber`,可配置小数位数与千分位/小数分隔符)
24
+ - **行为侧**:零文件 IO,`zh_cn`/`en` 内置复数规则,`a.b.c` 层级键,回退链(当前语言 → 基础语言 → 默认语言),占位符替换,日期/数字本地化格式化
25
+ - **数据侧**:键和值都只能是字符串,支持覆盖、删除、判断存在、取副本、清空、JSON 文件持久化
26
+ - **组合使用**:`Lang` 配置 `file` 即获得持久化能力;也可注入自定义 `store` 实例替换存储后端
20
27
 
21
28
  ## 安装
22
29
 
@@ -24,35 +31,38 @@
24
31
  npm install mm_lang
25
32
  ```
26
33
 
27
- ## 基本使用
34
+ ## 快速开始
28
35
 
29
36
  ```javascript
30
- const { Lang } = require('mm_lang');
37
+ const { Lang, Langs, langAdmin } = require('mm_lang');
31
38
 
39
+ // 纯内存翻译
32
40
  const lang = new Lang({
33
- zh_cn: {
34
- common: { hello: '你好,{0}!' },
35
- apple: ['一个苹果', '{count}个苹果']
36
- },
37
- en: {
38
- common: { hello: 'Hello, {0}!' },
39
- apple: ['one apple', '{count} apples']
40
- },
41
- default: 'zh_cn',
42
- fallback: true
41
+ zh_cn: { common: { hello: '你好,{0}!' }, apple: ['一个苹果', '{count}个苹果'] },
42
+ en: { common: { hello: 'Hello, {0}!' }, apple: ['one apple', '{count} apples'] },
43
+ default: 'zh_cn'
43
44
  });
44
-
45
- console.log(lang.get('common.hello', '美眉')); // 你好,美眉!
46
- console.log(lang.setLang('en')); // en
47
- console.log(lang.t('common.hello', 'MM')); // Hello, MM!
48
- console.log(lang.tc('apple', 3)); // 3 apples
45
+ console.log(lang.get('common.hello', '美眉')); // 你好,美眉!
46
+ lang.setLang('en');
47
+ console.log(lang.t('common.hello', 'MM')); // Hello, MM!
48
+ console.log(lang.tc('apple', 3)); // 3 apples
49
+
50
+ // 带持久化:配置 file 后自动创建 Langs 存储实例
51
+ const persisted = langAdmin('shop', { file: './data/lang.json', default: 'zh_cn' });
52
+ persisted.load(); // 启动时从文件恢复词条
53
+ persisted.set('nav.home', '首页');
54
+ persisted.save(); // 扁平化为 "zh_cn.nav.home": "首页" 落盘
55
+
56
+ // 只用存储(原 mm_langs 用法)
57
+ const langs = new Langs({ file: './data/map.json' });
58
+ langs.set('en', 'english').save();
49
59
  ```
50
60
 
51
61
  ## API 说明
52
62
 
53
- ### 构造 `new Lang(dict)`
63
+ ### 行为侧 `Lang`
54
64
 
55
- `dict` 为语言包字典对象,各字段与默认值:
65
+ #### 构造 `new Lang(config)`
56
66
 
57
67
  | 配置项 | 类型 | 默认值 | 说明 |
58
68
  | --- | --- | --- | --- |
@@ -60,37 +70,77 @@ console.log(lang.tc('apple', 3)); // 3 apples
60
70
  | `en` | `Object` | `{}` | 英文词条字典 |
61
71
  | `default` | `String` | `'zh_cn'` | 默认语言代码,同时作为初始当前语言 |
62
72
  | `fallback` | `Boolean` | `true` | 是否启用回退机制 |
63
- | `locale` | `Object` | `{ zh: 'zh_cn', en: 'en' }` | 完整语言代码到基础语言代码的映射(与默认值合并) |
73
+ | `locale` | `Object` | `{ zh: 'zh_cn', en: 'en' }` | 完整语言代码到基础语言代码的映射 |
74
+ | `file` | `String` | `''` | 持久化文件路径,配置后自动创建 Langs 存储实例 |
75
+ | `store` | `Langs` | `undefined` | 注入的存储实例(鸭子类型),优先于 `file` |
76
+ | `silent` | `Boolean` | `false` | 是否静默缺失词条/存储异常日志(上层自带日志体系时开启,如 mm_tpl) |
64
77
 
65
- 实例属性:`config`(语言包配置)、`rules`(复数规则表)、`now`(当前语言代码)、`locale`(区域映射表)。
78
+ 实例属性:`config`(词条字典)、`rules`(复数规则)、`now`(当前语言)、`locale`(区域映射)、`file`(持久化路径)、`store`(存储实例或 `null`)。
66
79
 
67
- ### 取值与翻译
80
+ #### 取值与翻译
68
81
 
69
82
  | 方法 | 签名 | 返回值 | 说明 |
70
83
  | --- | --- | --- | --- |
71
- | get | `get(key, ...param)` | `String` | 获取翻译文本,支持 `a.b` 层级键;`param` 依次替换 `{0}`、`{1}` 等占位符;`key` 非字符串时抛 `TypeError` |
84
+ | get | `get(key, ...param)` | `String` | 取翻译文本,支持 `a.b` 层级键;`param` 依次替换 `{0}`、`{1}` 等 |
72
85
  | t | `t(key, ...param)` | `String` | `get` 的别名 |
73
- | tc | `tc(key, count, ...param)` | `String` | 翻译带复数形式的文本:词条值为数组时按当前语言复数规则取索引,先替换 `{count}`,再用 `param` 依次替换 `{1}`、`{2}` 等;`key` 非字符串或 `count` 非数字时抛 `TypeError` |
86
+ | tc | `tc(key, count, ...param)` | `String` | 复数形式取词:数组词条按当前语言规则取索引,先替换 `{count}`,再替换 `{1}`、`{2}` 等 |
74
87
 
75
- ### 语言管理
88
+ #### 语言管理
76
89
 
77
90
  | 方法 | 签名 | 返回值 | 说明 |
78
91
  | --- | --- | --- | --- |
79
- | setLang | `setLang(lang)` | `String` | 切换语言;找不到时先尝试基础语言映射,仍失败则输出错误日志并返回当前语言 |
80
- | getLangs | `getLangs()` | `Array<String>` | 获取支持的语言代码列表(排除 `default`、`fallback`、`options` 等非词条键) |
81
- | isSupported | `isSupported(lang)` | `Boolean` | 检查语言是否支持(含基础语言映射) |
82
- | del | `del(lang)` | `Boolean` | 删除指定语言配置,删除成功返回 `true` |
83
- | set | `set(key, val)` | `any` | 向当前语言写入键值,支持 `a.b` 层级键(中间层自动创建),返回设置的值 |
84
- | registerPlural | `registerPlural(lang, rule)` | `Boolean` | 注册语言复数规则;`rule(count)` 需返回形式索引(数字),参数类型不符时抛 `TypeError` |
92
+ | setLang | `setLang(lang)` | `String` | 切换语言;找不到时先尝试基础语言映射,仍失败记日志并保持当前语言 |
93
+ | getLangs | `getLangs()` | `Array<String>` | 支持的语言列表(排除 `default`、`fallback`、`locale`、`options`、`file`、`store`) |
94
+ | isSupported | `isSupported(lang)` | `Boolean` | 语言是否支持(含基础语言映射,控制键恒为 false) |
95
+ | del | `del(lang)` | `Boolean` | 删除指定语言配置 |
96
+ | set | `set(key, val)` | `any` | 向当前语言写入词条,支持 `a.b` 层级键 |
97
+ | registerPlural | `registerPlural(lang, rule)` | `Boolean` | 注册复数规则,`rule(count)` 返回形式索引 |
85
98
 
86
- ### 格式化
99
+ #### 持久化
87
100
 
88
101
  | 方法 | 签名 | 返回值 | 说明 |
89
102
  | --- | --- | --- | --- |
90
- | formatDate | `formatDate(dt, format)` | `String` | 格式化日期;`dt` 为 `Date` 或时间戳,`format` 默认 `YYYY-MM-DD hh:mm:ss`,支持 `YYYY/yyyy`、`MM`、`DD/dd`、`HH/hh`、`mm`、`ss` 标记;`dt` 为空或无效日期时抛 `TypeError` |
91
- | formatNumber | `formatNumber(num, options)` | `String` | 格式化数字;`options` 支持 `decimals`(小数位数,默认 2)、`decimal_separator`(小数分隔符,默认 `.`)、`thousands_separator`(千分位分隔符,默认 `,`);`num` 非有效数字时抛 `TypeError` |
103
+ | save | `save(file?)` | `Boolean` | 词条扁平化后经存储实例落盘;无存储实例返回 `false`(不抛异常) |
104
+ | load | `load(file?)` | `Boolean` | 从存储实例读取并还原为嵌套字典合并进 `config`,文件中的词条优先;失败返回 `false` 且不改动内存词条 |
105
+
106
+ 落盘格式为 `语言代码.层级键 → 文本` 的 string→string 键值,例如:
92
107
 
93
- > 另有 `_getVal`、`_getFrom`、`_getBase`、`_set`、`_log` 等下划线前缀的内部私有方法,不建议外部调用。
108
+ ```json
109
+ {
110
+ "zh_cn.nav.home": "首页",
111
+ "zh_cn.apple": "\u0001[\"一个苹果\",\"{count}个苹果\"]"
112
+ }
113
+ ```
114
+
115
+ > 复数等数组词条以 `\u0001` 前缀 + JSON 文本编码,仍满足存储的 string→string 约束,加载时自动还原为数组。`default`、`fallback`、`locale` 等控制键不参与持久化。
116
+
117
+ #### 格式化
118
+
119
+ | 方法 | 签名 | 返回值 | 说明 |
120
+ | --- | --- | --- | --- |
121
+ | formatDate | `formatDate(dt, format)` | `String` | 默认格式 `YYYY-MM-DD hh:mm:ss`,支持 `YYYY/yyyy`、`MM`、`DD/dd`、`HH/hh`、`mm`、`ss` |
122
+ | formatNumber | `formatNumber(num, options)` | `String` | `options` 支持 `decimals`(默认 2)、`decimal_separator`、`thousands_separator` |
123
+
124
+ ### 数据侧 `Langs`
125
+
126
+ | 方法 | 说明 | 参数 | 返回值 |
127
+ |---|---|---|---|
128
+ | set | 设置键值(值必须为字符串) | key, value | Langs |
129
+ | get | 获取值 | key | string\|undefined |
130
+ | del | 删除键 | key | boolean |
131
+ | has | 判断键是否存在 | key | boolean |
132
+ | list | 获取键值表副本 | - | object |
133
+ | clear | 清空所有键 | - | Langs |
134
+ | save | 保存到持久化文件 | - | boolean |
135
+ | load | 从持久化文件加载(逐项校验值类型) | - | boolean |
136
+
137
+ ### 管理器
138
+
139
+ | 方法 | 说明 |
140
+ |---|---|
141
+ | langAdmin(scope, config) | 按作用域获取 `Lang` 实例(相同作用域返回同一实例),缺省 `os` |
142
+ | langsAdmin(scope, config) | 按作用域获取 `Langs` 实例,缺省 `os` |
143
+ | admin / langs_admin | 对应的 `Admin` 实例 |
94
144
 
95
145
  ## 使用示例
96
146
 
@@ -102,8 +152,7 @@ const { Lang } = require('mm_lang');
102
152
  const lang = new Lang({
103
153
  zh_cn: { nav: { home: '首页' } },
104
154
  en: { nav: { home: 'Home', about: 'About' } },
105
- default: 'en',
106
- fallback: true
155
+ default: 'en'
107
156
  });
108
157
 
109
158
  lang.setLang('zh_cn');
@@ -115,12 +164,7 @@ console.log(lang.get('nav.about')); // 中文缺失,回退到默认语言 en
115
164
  ```javascript
116
165
  const { Lang } = require('mm_lang');
117
166
 
118
- const lang = new Lang({
119
- ru: { apple: ['яблоко', 'яблока', 'яблок'] },
120
- default: 'ru'
121
- });
122
-
123
- // 俄语复数规则:按数量返回三种形式之一的索引
167
+ const lang = new Lang({ ru: { apple: ['яблоко', 'яблока', 'яблок'] }, default: 'ru' });
124
168
  lang.registerPlural('ru', function (count) {
125
169
  const n10 = count % 10, n100 = count % 100;
126
170
  if (n10 === 1 && n100 !== 11) return 0;
@@ -133,16 +177,32 @@ console.log(lang.tc('apple', 3)); // яблока
133
177
  console.log(lang.tc('apple', 11)); // яблок
134
178
  ```
135
179
 
136
- ### 场景三:本地化格式化
180
+ ### 场景三:注入自定义存储
137
181
 
138
182
  ```javascript
139
- const { Lang } = require('mm_lang');
183
+ const { Lang, Langs } = require('mm_lang');
140
184
 
141
- const lang = new Lang();
142
- console.log(lang.formatDate(new Date(2026, 9, 4), 'YYYY年MM月DD日')); // 2026年10月04日
143
- console.log(lang.formatNumber(1234567.891, {
144
- decimals: 2,
145
- decimal_separator: ',',
146
- thousands_separator: '.'
147
- })); // 1.234.567,89
185
+ // 任何具备 set/get/list/save/load 的对象都可作为存储
186
+ const store = new Langs({ file: './data/custom.json' });
187
+ const lang = new Lang({ store, zh_cn: { hello: '你好' } });
188
+ lang.save();
148
189
  ```
190
+
191
+ ## 目录结构
192
+
193
+ ```
194
+ mm_lang/
195
+ ├── index.js 入口:导出 Lang / Langs / admin / langs_admin / langAdmin / langsAdmin
196
+ ├── lib/
197
+ │ ├── lang.js 行为侧
198
+ │ ├── langs.js 数据侧
199
+ │ └── store.js 适配层
200
+ ├── example/
201
+ │ └── index.js
202
+ ├── test.js
203
+ └── package.json
204
+ ```
205
+
206
+ ## 许可证
207
+
208
+ ISC
package/index.js CHANGED
@@ -1,24 +1,52 @@
1
- const Lang = require('./lib/lang');
2
- const Admin = require('mm_corejs').Admin;
3
-
4
- let admin = new Admin();
5
- admin.setClass(Lang);
6
-
7
- /**
8
- * 获取语言实例
9
- * @param {string} scope 作用域
10
- * @param {Object} config 语言配置
11
- * @returns {Lang} 语言实例
12
- */
13
- function langAdmin(scope, config) {
14
- if (!scope) {
15
- scope = 'os';
16
- }
17
- return admin.get(scope, config);
18
- }
19
-
20
- module.exports = {
21
- Lang,
22
- admin,
23
- langAdmin
24
- };
1
+ const Lang = require('./lib/lang');
2
+ const Langs = require('./lib/langs');
3
+ const Admin = require('mm_corejs').Admin;
4
+
5
+ /**
6
+ * 语言实例管理器(行为侧)
7
+ * @type {Admin}
8
+ */
9
+ let admin = new Admin();
10
+ admin.setClass(Lang);
11
+
12
+ /**
13
+ * 语言存储实例管理器(数据侧)
14
+ * @type {Admin}
15
+ */
16
+ let langs_admin = new Admin();
17
+ langs_admin.setClass(Langs);
18
+
19
+ /**
20
+ * 获取语言实例
21
+ * @param {string} scope 作用域
22
+ * @param {Object} config 语言配置,可含 file 持久化路径与 store 存储实例
23
+ * @returns {Lang} 语言实例
24
+ */
25
+ function langAdmin(scope, config) {
26
+ if (!scope) {
27
+ scope = 'os';
28
+ }
29
+ return admin.get(scope, config);
30
+ }
31
+
32
+ /**
33
+ * 获取语言存储实例,不存在时创建
34
+ * @param {string} [scope] 作用域名称,缺省时使用 os
35
+ * @param {Object} [config] 配置对象,含 file 持久化文件路径
36
+ * @returns {Langs} 语言存储实例
37
+ */
38
+ function langsAdmin(scope, config) {
39
+ if (!scope) {
40
+ scope = 'os';
41
+ }
42
+ return langs_admin.get(scope, config);
43
+ }
44
+
45
+ module.exports = {
46
+ Lang,
47
+ Langs,
48
+ admin,
49
+ langs_admin,
50
+ langAdmin,
51
+ langsAdmin
52
+ };
package/lib/lang.js CHANGED
@@ -1,15 +1,34 @@
1
+ /**
2
+ * 持久化适配:嵌套词条字典与 Langs 扁平键值互转、存储实例装配
3
+ */
4
+ const {
5
+ flatten,
6
+ unflatten,
7
+ assignDeep,
8
+ isControlKey,
9
+ createStore
10
+ } = require('./store.js');
11
+
1
12
  /**
2
13
  * 内置语言类
3
14
  * 国际化(i18n)核心:按语言字典取值、层级键解析、回退机制、复数形式与格式化
4
15
  * 行为对齐 mm_expand 的 Lang,但不依赖外部模块与全局变量
16
+ * 持久化能力通过组合的 Langs 存储实例(this.store)提供,本类不做文件 IO
5
17
  * @class
6
18
  */
7
19
  class Lang {
8
20
  /**
9
21
  * 创建语言实例
10
- * @param {Object} config - 语言包配置,键为语言代码,值可为词条对象;可含 default 与 fallback
22
+ * @param {Object} config - 语言包配置,键为语言代码,值可为词条对象;可含 default、fallback、locale、file、store
11
23
  */
12
24
  constructor(config = {}) {
25
+ var cg = Object.assign({}, config);
26
+ var file = cg.file || '';
27
+ var injected = cg.store;
28
+ // file / store 属于存储配置,不进入词条字典,避免被当作语言代码
29
+ delete cg.file;
30
+ delete cg.store;
31
+
13
32
  /**
14
33
  * 语言包配置,语言代码对应词条字典
15
34
  * @type {Object}
@@ -19,7 +38,7 @@ class Lang {
19
38
  en: {},
20
39
  default: 'zh_cn',
21
40
  fallback: true
22
- }, config);
41
+ }, cg);
23
42
 
24
43
  /**
25
44
  * 复数规则表,键为语言代码,值为按数量返回形式的索引的函数
@@ -48,6 +67,18 @@ class Lang {
48
67
  zh: 'zh_cn',
49
68
  en: 'en'
50
69
  }, this.config.locale);
70
+
71
+ /**
72
+ * 持久化文件路径
73
+ * @type {string}
74
+ */
75
+ this.file = file;
76
+
77
+ /**
78
+ * 语言存储实例(数据侧,来自 langs.js),未配置 file 且未注入时为 null
79
+ * @type {Langs|null}
80
+ */
81
+ this.store = createStore(injected, file);
51
82
  }
52
83
  }
53
84
 
@@ -190,6 +221,10 @@ Lang.prototype.setLang = function (lang) {
190
221
  if (!lang || typeof (lang) !== 'string') {
191
222
  throw new TypeError('lang必须是字符串');
192
223
  }
224
+ if (isControlKey(lang)) {
225
+ this._log('error', '语言未找到: ' + lang);
226
+ return this.now;
227
+ }
193
228
  if (this.config[lang]) {
194
229
  this.now = lang;
195
230
  return this.now;
@@ -333,36 +368,117 @@ Lang.prototype.registerPlural = function (lang, rule) {
333
368
 
334
369
  /**
335
370
  * 获取支持的语言列表
371
+ * 过滤掉 default、fallback、locale、options、file、store 等非词条键
336
372
  * @returns {Array<string>} 语言代码数组
337
373
  */
338
374
  Lang.prototype.getLangs = function () {
339
375
  var langs = [];
340
376
  for (var key in this.config) {
341
- if (key !== 'default' && key !== 'fallback' && key !== 'options'
342
- && typeof (this.config[key]) === 'object') {
377
+ if (!isControlKey(key) && typeof (this.config[key]) === 'object') {
343
378
  langs.push(key);
344
379
  }
345
380
  }
346
381
  return langs;
347
382
  };
348
383
 
384
+ /**
385
+ * 获取或初始化存储实例 - 内部方法
386
+ * 已注入存储实例时优先复用;仅有文件路径时按路径新建并缓存
387
+ * @param {string} [file] 本次调用的持久化文件路径
388
+ * @returns {Langs|null} 存储实例
389
+ * @private
390
+ */
391
+ Lang.prototype._getStore = function (file) {
392
+ if (this.store) {
393
+ if (file && this.store.config && !this.store.config.file) {
394
+ this.store.config.file = file;
395
+ }
396
+ return this.store;
397
+ }
398
+ var target = file || this.file;
399
+ if (target) {
400
+ this.store = createStore(null, target);
401
+ this.file = target;
402
+ return this.store;
403
+ }
404
+ return null;
405
+ };
406
+
407
+ /**
408
+ * 保存语言包到持久化文件
409
+ * 词条扁平化为 `语言代码.层级键 → 文本` 后写入存储实例,再由存储实例落盘
410
+ * @param {string} [file] 持久化文件路径,缺省时用实例的 file 配置
411
+ * @returns {boolean} 是否保存成功
412
+ */
413
+ Lang.prototype.save = function (file) {
414
+ var store = this._getStore(file);
415
+ if (!store) {
416
+ this._log('error', '未配置持久化文件路径或存储实例,无法保存语言包');
417
+ return false;
418
+ }
419
+ var flat = flatten(this.config);
420
+ try {
421
+ store.clear();
422
+ for (var key in flat) {
423
+ store.set(key, flat[key]);
424
+ }
425
+ return store.save() !== false;
426
+ } catch (e) {
427
+ this._log('error', '语言包保存失败: ' + e.message);
428
+ return false;
429
+ }
430
+ };
431
+
432
+ /**
433
+ * 从持久化文件加载语言包
434
+ * 扁平键值还原为嵌套字典后合并进当前配置,文件中的词条优先
435
+ * @param {string} [file] 持久化文件路径,缺省时用实例的 file 配置
436
+ * @returns {boolean} 是否加载成功
437
+ */
438
+ Lang.prototype.load = function (file) {
439
+ var store = this._getStore(file);
440
+ if (!store || typeof (store.load) !== 'function') {
441
+ this._log('error', '未配置持久化文件路径或存储实例,无法加载语言包');
442
+ return false;
443
+ }
444
+ var ok = false;
445
+ try {
446
+ ok = store.load();
447
+ } catch (e) {
448
+ this._log('error', '语言包加载失败: ' + e.message);
449
+ return false;
450
+ }
451
+ if (!ok) {
452
+ return false;
453
+ }
454
+ assignDeep(this.config, unflatten(store.list()));
455
+ return true;
456
+ };
457
+
349
458
  /**
350
459
  * 检查语言是否支持
351
460
  * @param {string} lang - 语言代码
352
461
  * @returns {boolean} 是否支持
353
462
  */
354
463
  Lang.prototype.isSupported = function (lang) {
464
+ if (!lang || typeof (lang) !== 'string' || isControlKey(lang)) {
465
+ return false;
466
+ }
355
467
  return !!this.config[lang] || !!this.config[this._getBase(lang)];
356
468
  };
357
469
 
358
470
  /**
359
471
  * 输出日志 - 内部方法,使用 console
472
+ * 配置 silent 为 true 时静默(供上层自带日志体系的场景使用,如 mm_tpl)
360
473
  * @param {string} level - 日志级别
361
474
  * @param {string} message - 日志消息
362
475
  * @param {...any} args - 附加参数
363
476
  * @private
364
477
  */
365
478
  Lang.prototype._log = function (level, message, ...args) {
479
+ if (this.config.silent) {
480
+ return;
481
+ }
366
482
  var fn = console[level] || console.log;
367
483
  if (typeof (fn) === 'function') {
368
484
  fn.call(console, message, ...args);
package/lib/langs.js ADDED
@@ -0,0 +1,32 @@
1
+ const KvStore = require('mm_kv_store').KvStore;
2
+
3
+ /**
4
+ * 语言存储类,键和值都只能是字符串
5
+ * 由原 mm_langs 模块融合而来,作为多语言模块的数据侧:只负责键值管理与持久化,
6
+ * 不感知语言词条的嵌套语义(嵌套结构的适配由 lib/store.js 承担)
7
+ * @class
8
+ * @augments {KvStore}
9
+ */
10
+ class Langs extends KvStore {
11
+ /**
12
+ * 获取存储项名称,用于覆盖冲突提示
13
+ * @returns {string} 存储项名称
14
+ */
15
+ _getName() {
16
+ return 'langs';
17
+ }
18
+
19
+ /**
20
+ * 校验值是否为字符串
21
+ * @param {*} value 值
22
+ * @returns {void}
23
+ * @throws {TypeError} 值不是字符串时抛出
24
+ */
25
+ _checkValue(value) {
26
+ if (typeof value !== 'string') {
27
+ throw new TypeError('langs 的值必须是字符串');
28
+ }
29
+ }
30
+ }
31
+
32
+ module.exports = Langs;
package/lib/store.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * 语言词条持久化适配层
3
+ * @description 承担"动作侧适配"职责:把 Lang 的嵌套词条字典与 Langs 的
4
+ * string→string 扁平键值互相转换。文件读写、键值校验等存储细节全部由 Langs 负责,
5
+ * 本文件不触碰 fs。
6
+ */
7
+
8
+ const Langs = require('./langs');
9
+
10
+ /**
11
+ * 非词条的控制键,不参与扁平化与持久化
12
+ * @type {Array<string>}
13
+ */
14
+ const CONTROL_KEYS = ['default', 'fallback', 'locale', 'options', 'file', 'store', 'silent'];
15
+
16
+ /**
17
+ * 非字符串词条(复数数组等)的标记前缀
18
+ * 落盘后仍是字符串,满足 Langs 的 string→string 约束;读取时按前缀还原
19
+ * @type {string}
20
+ */
21
+ const MARK = '\u0001';
22
+
23
+ /**
24
+ * 判断是否为控制键
25
+ * @param {string} key 键名
26
+ * @returns {boolean} 是控制键返回 true
27
+ */
28
+ function isControlKey(key) {
29
+ return CONTROL_KEYS.indexOf(key) !== -1;
30
+ }
31
+
32
+ /**
33
+ * 将词条值序列化为存储字符串
34
+ * @param {*} val 词条值
35
+ * @returns {string|null} 存储字符串,不可序列化的值返回 null
36
+ */
37
+ function encodeValue(val) {
38
+ if (typeof (val) === 'string') {
39
+ return val;
40
+ }
41
+ if (Array.isArray(val)) {
42
+ return MARK + JSON.stringify(val);
43
+ }
44
+ if (typeof (val) === 'number' || typeof (val) === 'boolean') {
45
+ return String(val);
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * 将存储字符串还原为词条值
52
+ * @param {string} val 存储字符串
53
+ * @returns {*} 词条值
54
+ */
55
+ function decodeValue(val) {
56
+ if (typeof (val) !== 'string') {
57
+ return val;
58
+ }
59
+ if (val.charAt(0) === MARK) {
60
+ try {
61
+ return JSON.parse(val.slice(1));
62
+ } catch (e) {
63
+ return val;
64
+ }
65
+ }
66
+ return val;
67
+ }
68
+
69
+ /**
70
+ * 嵌套词条字典扁平化为 string→string 键值
71
+ * 键形如 `语言代码.层级键`,复数数组等非字符串值按 MARK 前缀编码
72
+ * @param {Object} dict 嵌套词条字典
73
+ * @param {string} [prefix] 键前缀
74
+ * @param {Object} [out] 输出容器
75
+ * @returns {Object} 扁平键值表
76
+ */
77
+ function flatten(dict, prefix, out) {
78
+ var flat = out || {};
79
+ if (!dict || typeof (dict) !== 'object') {
80
+ return flat;
81
+ }
82
+ for (var key in dict) {
83
+ if (!Object.prototype.hasOwnProperty.call(dict, key)) {
84
+ continue;
85
+ }
86
+ if (!prefix && isControlKey(key)) {
87
+ continue;
88
+ }
89
+ var val = dict[key];
90
+ var path = prefix ? prefix + '.' + key : key;
91
+ if (val && typeof (val) === 'object' && !Array.isArray(val)) {
92
+ flatten(val, path, flat);
93
+ } else {
94
+ var str = encodeValue(val);
95
+ if (str !== null) {
96
+ flat[path] = str;
97
+ }
98
+ }
99
+ }
100
+ return flat;
101
+ }
102
+
103
+ /**
104
+ * 按点号路径向对象写入值
105
+ * @param {Object} obj 目标对象
106
+ * @param {string} path 点号路径
107
+ * @param {*} val 值
108
+ * @returns {void}
109
+ */
110
+ function setByPath(obj, path, val) {
111
+ var keys = path.split('.');
112
+ var cur = obj;
113
+ for (var i = 0; i < keys.length - 1; i++) {
114
+ if (!cur[keys[i]] || typeof (cur[keys[i]]) !== 'object') {
115
+ cur[keys[i]] = {};
116
+ }
117
+ cur = cur[keys[i]];
118
+ }
119
+ cur[keys[keys.length - 1]] = val;
120
+ }
121
+
122
+ /**
123
+ * 扁平键值还原为嵌套词条字典
124
+ * @param {Object} flat 扁平键值表
125
+ * @returns {Object} 嵌套词条字典
126
+ */
127
+ function unflatten(flat) {
128
+ var dict = {};
129
+ if (!flat || typeof (flat) !== 'object') {
130
+ return dict;
131
+ }
132
+ for (var path in flat) {
133
+ if (!Object.prototype.hasOwnProperty.call(flat, path)) {
134
+ continue;
135
+ }
136
+ setByPath(dict, path, decodeValue(flat[path]));
137
+ }
138
+ return dict;
139
+ }
140
+
141
+ /**
142
+ * 深度合并词条字典,来源值覆盖目标值
143
+ * @param {Object} target 目标字典
144
+ * @param {Object} source 来源字典
145
+ * @returns {Object} 合并后的目标字典
146
+ */
147
+ function assignDeep(target, source) {
148
+ for (var key in source) {
149
+ if (!Object.prototype.hasOwnProperty.call(source, key)) {
150
+ continue;
151
+ }
152
+ var val = source[key];
153
+ if (val && typeof (val) === 'object' && !Array.isArray(val)
154
+ && target[key] && typeof (target[key]) === 'object' && !Array.isArray(target[key])) {
155
+ assignDeep(target[key], val);
156
+ } else {
157
+ target[key] = val;
158
+ }
159
+ }
160
+ return target;
161
+ }
162
+
163
+ /**
164
+ * 创建语言存储实例
165
+ * 优先复用外部注入的实例(动作与数据解耦),其次按文件路径新建,都没有则返回 null
166
+ * @param {Langs} [injected] 注入的存储实例
167
+ * @param {string} [file] 持久化文件路径
168
+ * @returns {Langs|null} 存储实例
169
+ */
170
+ function createStore(injected, file) {
171
+ if (injected && typeof (injected.set) === 'function' && typeof (injected.save) === 'function') {
172
+ return injected;
173
+ }
174
+ if (file) {
175
+ return new Langs({ file: file });
176
+ }
177
+ return null;
178
+ }
179
+
180
+ module.exports = {
181
+ CONTROL_KEYS,
182
+ MARK,
183
+ isControlKey,
184
+ encodeValue,
185
+ decodeValue,
186
+ flatten,
187
+ unflatten,
188
+ assignDeep,
189
+ createStore
190
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mm_lang",
3
- "version": "1.0.1",
4
- "description": "超级美眉多语言/语言包模块",
3
+ "version": "2.0.0",
4
+ "description": "超级美眉多语言模块:融合 i18n 行为(Lang)与语言存储持久化(Langs),支持层级键、回退链、复数、格式化与 JSON 文件落盘",
5
5
  "main": "index.js",
6
6
  "repository": {
7
7
  "type": "git",
@@ -10,13 +10,31 @@
10
10
  "keywords": [
11
11
  "mm_mod",
12
12
  "mm_infra",
13
- "mm_common"
13
+ "mm_data",
14
+ "lang",
15
+ "i18n",
16
+ "locale",
17
+ "langs",
18
+ "多语言",
19
+ "语言包"
14
20
  ],
15
21
  "layer": "infra",
16
- "group": "common",
22
+ "group": "data",
17
23
  "author": "邱文武",
18
24
  "license": "ISC",
19
25
  "scripts": {
20
- "test": "node test.js"
21
- }
26
+ "test": "node test.js",
27
+ "example": "node example/index.js"
28
+ },
29
+ "dependencies": {
30
+ "mm_corejs": "^1.1.6",
31
+ "mm_kv_store": "^1.0.1"
32
+ },
33
+ "files": [
34
+ "index.js",
35
+ "lib",
36
+ "example",
37
+ "README.md",
38
+ "package.json"
39
+ ]
22
40
  }
package/test.js DELETED
@@ -1,18 +0,0 @@
1
- 'use strict';
2
- const assert = require('assert');
3
- const mod = require('./index.js');
4
-
5
- // 验证导出结构
6
- assert.ok(mod.Lang, '应导出 Lang');
7
- assert.ok(mod.admin, '应导出 admin');
8
- assert.strictEqual(typeof mod.langAdmin, 'function', '应导出 langAdmin 函数');
9
-
10
- // 验证通过工厂函数获取实例
11
- const inst = mod.langAdmin('test');
12
- assert.ok(inst instanceof mod.Lang, 'langAdmin 返回的实例应为 Lang 类型');
13
-
14
- // 验证通过 admin 获取实例
15
- const inst2 = mod.admin.get('test2');
16
- assert.ok(inst2 instanceof mod.Lang, 'admin.get 返回的实例应为 Lang 类型');
17
-
18
- console.log('mm_lang 模块测试通过');