mm_tpl 2.7.1 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/engine.js ADDED
@@ -0,0 +1,1256 @@
1
+ const engine = require('art-template');
2
+ const {
3
+ Base
4
+ } = require('mm_expand');
5
+ const Hook = require('./hook.js');
6
+ const {
7
+ nativeRule,
8
+ artRule,
9
+ htmlRule,
10
+ jsRule,
11
+ pyRule,
12
+ pyRule2
13
+ } = require('./tpl_rule.js');
14
+ const {
15
+ TplUtil
16
+ } = require('./util.js');
17
+ const {
18
+ Template
19
+ } = require('./template.js');
20
+ const {
21
+ Css
22
+ } = require('./css.js');
23
+ const {
24
+ Js
25
+ } = require('./js.js');
26
+ const {
27
+ Lang
28
+ } = require('./lang.js');
29
+ const {
30
+ Memory
31
+ } = require('./memory.js');
32
+
33
+ /**
34
+ * 模板引擎类
35
+ * 单个引擎实例的渲染封装,负责渲染以及主题、风格、语言的编排
36
+ * 渲染结果不做页面级缓存,页面缓存交由 web 中间件按完整请求上下文负责
37
+ * @class
38
+ */
39
+ class Engine extends Base {
40
+ /**
41
+ * 配置参数
42
+ */
43
+ static config = {
44
+ /**
45
+ * 应用根目录,目录结构见 doc/目录结构.md
46
+ */
47
+ root: $.run_path + '',
48
+ /**
49
+ * 主题目录,相对应用根目录
50
+ */
51
+ theme_dir: './static/template/',
52
+ /**
53
+ * 风格目录,相对主题目录
54
+ */
55
+ style_dir: './style/',
56
+ /**
57
+ * 组件目录,相对主题目录,存放可被功能页面复用的公共片段
58
+ * 为空则不启用组件,模板内 $component 将取不到文件
59
+ */
60
+ component_dir: './component/',
61
+ /**
62
+ * 用户自定义覆盖目录,相对应用根目录,优先级最高
63
+ */
64
+ diy_dir: './static/diy/',
65
+ /**
66
+ * 应用级语言包目录,相对应用根目录,按文件名识别语言,为空则不启用文件语言包
67
+ */
68
+ lang_dir: './static/lang/',
69
+ /**
70
+ * 主题级语言包目录,相对主题目录,覆盖应用级语言包的相同词条
71
+ */
72
+ theme_lang_dir: './lang/',
73
+ /**
74
+ * 应用级脚本目录,相对应用根目录,存放各主题共用的公共脚本
75
+ */
76
+ js_dir: './static/js/',
77
+ /**
78
+ * 主题级脚本目录,相对主题目录,覆盖应用级脚本目录的同名脚本
79
+ * 脚本不按风格分层:风格是视觉变体,脚本不因颜色明暗而变
80
+ */
81
+ theme_js_dir: './js/',
82
+ /**
83
+ * 默认主题
84
+ */
85
+ default_theme: 'default',
86
+ /**
87
+ * 默认风格,为主题内的变体
88
+ */
89
+ default_style: 'light',
90
+ /**
91
+ * 基础风格目录名,作为各风格共享的公共样式层
92
+ * 仅作用于样式:优先级低于当前风格、高于默认主题,公共样式放此处即可被各风格复用;为空则不启用
93
+ */
94
+ base_style: 'base',
95
+ /**
96
+ * 默认语言,语言代码统一用小写下划线,与语言包文件名一致
97
+ */
98
+ default_lang: 'zh_cn',
99
+ /**
100
+ * 共享语言包字典,优先级高于语言包文件
101
+ */
102
+ langs: {},
103
+ /**
104
+ * 用户偏好存储,留空则用进程内存的 Memory
105
+ * 通过 user_id 映射用户的风格与语言,需实现 getUser / setUser / delUser / hasUser / listUsers / clear
106
+ * 六个同步方法,便于替换为文件、数据库或缓存服务;派生实例会复用同一存储
107
+ */
108
+ user_store: null,
109
+ /**
110
+ * 模板文件扩展名,可自行设定,如 .html、.art
111
+ */
112
+ extname: '.html',
113
+ /**
114
+ * 模板规则
115
+ */
116
+ rules: [nativeRule, artRule, htmlRule, jsRule, pyRule, pyRule2],
117
+ /**
118
+ * 复数规则,键为语言代码,语言代码统一用小写下划线(如 zh_cn、en_us)
119
+ * mm_expand 的 Lang 已内置 zh_cn、en 规则,其他语言代码需在此登记才能按语言取复数形式
120
+ */
121
+ plural_rules: {
122
+ 'en_us': function (count) {
123
+ return count === 1 ? 0 : 1;
124
+ }
125
+ },
126
+ /**
127
+ * 变换字符串
128
+ */
129
+ change_str: ['\\#', '\\<', '\\${', '\\*', '\\"""'],
130
+ };
131
+ /**
132
+ * 语言包文件缓存
133
+ * 按语言包目录组缓存,形如 {目录组: {fingerprint, dict}},文件未变化时复用解析结果
134
+ */
135
+ static lang_cache = {};
136
+ /**
137
+ * 创建引擎实例
138
+ * @param {Object} config - 配置选项
139
+ */
140
+ constructor(config) {
141
+ super(config);
142
+
143
+ /**
144
+ * 模板引擎
145
+ */
146
+ this.engine = engine;
147
+
148
+ /**
149
+ * 视图数据
150
+ */
151
+ this.viewBag = {};
152
+
153
+ /**
154
+ * 模板根目录,即应用根目录
155
+ */
156
+ this.dir = this.config.root;
157
+
158
+ /**
159
+ * 当前主题
160
+ */
161
+ this.current_theme = this.config.default_theme;
162
+
163
+ /**
164
+ * 当前风格
165
+ */
166
+ this.current_style = this.config.default_style;
167
+
168
+ /**
169
+ * 当前语言实例
170
+ */
171
+ this.lang = null;
172
+
173
+ /**
174
+ * 错误提示
175
+ */
176
+ this.error = null;
177
+
178
+ /**
179
+ * 字符转义规则
180
+ */
181
+ this.escape_rule = TplUtil.buildEscapeRule(this.config.change_str);
182
+
183
+ /**
184
+ * 模板文件加载器
185
+ */
186
+ this.template = new Template(this);
187
+
188
+ /**
189
+ * 样式文件加载器
190
+ */
191
+ this.css = new Css(this);
192
+
193
+ /**
194
+ * 脚本文件加载器
195
+ */
196
+ this.js = new Js(this);
197
+
198
+ /**
199
+ * 语言包加载器
200
+ */
201
+ this.lang_loader = new Lang(this);
202
+
203
+ /**
204
+ * 用户偏好存储
205
+ */
206
+ this.store = null;
207
+
208
+ /**
209
+ * 初始化用户偏好存储
210
+ */
211
+ this.setStore(this.config.user_store);
212
+
213
+ /**
214
+ * 实例级模板函数表,由 setFunc 注册,渲染时注入模型
215
+ */
216
+ this.funcs = {};
217
+
218
+ /**
219
+ * 实例级钩子,仅作用于当前实例;与全局 $.hook 一起参与执行,全局先执行、实例级后执行
220
+ */
221
+ this.hook = new Hook();
222
+
223
+ /**
224
+ * 初始化
225
+ */
226
+ this._init();
227
+ }
228
+ }
229
+
230
+ /**
231
+ * 初始化模板
232
+ */
233
+ Engine.prototype._initTpl = function () {
234
+ var cg = this.config;
235
+ // 引擎默认值为全局配置,仅主实例写入,避免多实例互相覆盖
236
+ if (!cg.is_fork) {
237
+ for (var key in cg) {
238
+ this.engine.defaults[key] = cg[key];
239
+ }
240
+ }
241
+ };
242
+
243
+ /**
244
+ * 设置配置参数
245
+ * 支持 config.js 的 { defaults: { theme, style, lang } } 写法
246
+ * @param {Object} config - 配置参数
247
+ */
248
+ Engine.prototype._setConfig = function (config) {
249
+ if (!config) {
250
+ return;
251
+ }
252
+ Object.assign(this.config, this._normalizeConfig(config));
253
+ };
254
+
255
+ /**
256
+ * 规范化配置
257
+ * 将 defaults 展开为 default_* 配置项,并移除 defaults,避免派生实例重复展开
258
+ * 显式传入的 default_* 配置优先于 defaults
259
+ * @param {Object} config - 配置参数
260
+ * @returns {Object} 规范化后的配置
261
+ */
262
+ Engine.prototype._normalizeConfig = function (config) {
263
+ if (!config || typeof (config.defaults) !== 'object' || !config.defaults) {
264
+ return config;
265
+ }
266
+ var d = config.defaults;
267
+ var cg = Object.assign({}, config);
268
+ if (cg.default_theme === undefined && d.theme !== undefined) {
269
+ cg.default_theme = d.theme;
270
+ }
271
+ if (cg.default_style === undefined && d.style !== undefined) {
272
+ cg.default_style = d.style;
273
+ }
274
+ if (cg.default_lang === undefined && d.lang !== undefined) {
275
+ cg.default_lang = d.lang;
276
+ }
277
+ delete cg.defaults;
278
+ return cg;
279
+ };
280
+
281
+ /**
282
+ * 获取主题根目录
283
+ * @param {string} theme - 主题名称
284
+ * @returns {string} 主题根目录全路径
285
+ */
286
+ Engine.prototype.getThemeDir = function (theme) {
287
+ var cg = this.config;
288
+ return theme.fullname(cg.theme_dir.fullname(this.dir));
289
+ };
290
+
291
+ /**
292
+ * 获取风格目录
293
+ * @param {string} theme - 主题名称
294
+ * @param {string} style - 风格名称,为空时取主题下的风格目录本身
295
+ * @returns {string} 风格目录全路径
296
+ */
297
+ Engine.prototype.getStyleDir = function (theme, style) {
298
+ var dir = this.config.style_dir.fullname(this.getThemeDir(theme));
299
+ if (!style) {
300
+ return dir;
301
+ }
302
+ return style.fullname(dir);
303
+ };
304
+
305
+ /**
306
+ * 获取模板目录列表
307
+ * 优先级从高到低:自定义覆盖目录、当前主题、默认主题
308
+ * 模板按功能划分目录,直接放主题目录下(如 static/template/{主题}/article/list.html)
309
+ * 模板不按风格分层:风格是视觉变体,只影响样式,不改变模板结构
310
+ * @param {string} theme - 主题名称,不传时取当前主题
311
+ * @returns {Array<string>} 模板目录列表
312
+ */
313
+ Engine.prototype.getTemplateDirs = function (theme) {
314
+ var cg = this.config;
315
+ var cur_theme = theme || this.current_theme;
316
+ var dirs = [];
317
+ if (cg.diy_dir) {
318
+ dirs.push(cg.diy_dir.fullname(this.dir));
319
+ }
320
+ if (cur_theme) {
321
+ dirs.push(this.getThemeDir(cur_theme));
322
+ }
323
+ if (cg.default_theme && cg.default_theme !== cur_theme) {
324
+ dirs.push(this.getThemeDir(cg.default_theme));
325
+ }
326
+ return dirs;
327
+ };
328
+
329
+ /**
330
+ * 获取组件目录列表
331
+ * 优先级从高到低:自定义覆盖目录下的组件目录、当前主题组件目录、默认主题组件目录
332
+ * 与模板链同构,只是每级都多一个固定的组件子目录前缀
333
+ * @param {string} theme - 主题名称,不传时取当前主题
334
+ * @returns {Array<string>} 组件目录列表
335
+ */
336
+ Engine.prototype.getComponentDirs = function (theme) {
337
+ var cg = this.config;
338
+ if (!cg.component_dir) {
339
+ return [];
340
+ }
341
+ var cur_theme = theme || this.current_theme;
342
+ var dirs = [];
343
+ if (cg.diy_dir) {
344
+ dirs.push(cg.component_dir.fullname(cg.diy_dir.fullname(this.dir)));
345
+ }
346
+ if (cur_theme) {
347
+ dirs.push(cg.component_dir.fullname(this.getThemeDir(cur_theme)));
348
+ }
349
+ if (cg.default_theme && cg.default_theme !== cur_theme) {
350
+ dirs.push(cg.component_dir.fullname(this.getThemeDir(cg.default_theme)));
351
+ }
352
+ return dirs;
353
+ };
354
+
355
+ /**
356
+ * 获取样式目录列表
357
+ * 优先级从高到低:自定义覆盖目录、(当前主题、默认主题)各自按「当前风格、基础风格」两级展开
358
+ * 即:diy → 当前主题+当前风格 → 当前主题+基础风格 → 默认主题+当前风格 → 默认主题+基础风格
359
+ * 样式按风格分层,因此比模板多了风格与基础风格两级;只回退主题,不回退风格
360
+ * @param {string} theme - 主题名称,不传时取当前主题
361
+ * @param {string} style - 风格名称,不传时取当前风格;显式传空字符串表示用主题下的风格目录本身
362
+ * @returns {Array<string>} 样式目录列表
363
+ */
364
+ Engine.prototype.getStyleDirs = function (theme, style) {
365
+ var cg = this.config;
366
+ var cur_theme = theme || this.current_theme;
367
+ // 风格取自入参或当前值,为空时以默认风格兜底,不做跨风格回退
368
+ var cur_style = (style === undefined || style === null) ? (this.current_style || cg.default_style || '') : style;
369
+ var themes = [];
370
+ if (cur_theme) {
371
+ themes.push(cur_theme);
372
+ }
373
+ if (cg.default_theme && cg.default_theme !== cur_theme) {
374
+ themes.push(cg.default_theme);
375
+ }
376
+ // 基础风格作为公共层,与当前风格重合时无需重复
377
+ var base = cg.base_style || '';
378
+ var dirs = [];
379
+ if (cg.diy_dir) {
380
+ dirs.push(cg.diy_dir.fullname(this.dir));
381
+ }
382
+ for (var i = 0; i < themes.length; i++) {
383
+ dirs.push(this.getStyleDir(themes[i], cur_style));
384
+ if (base && base !== cur_style) {
385
+ dirs.push(this.getStyleDir(themes[i], base));
386
+ }
387
+ }
388
+ return dirs;
389
+ };
390
+
391
+ /**
392
+ * 获取脚本目录列表
393
+ * 优先级从高到低:自定义覆盖目录、当前主题、默认主题、应用级
394
+ * 脚本不按风格分层,因此没有风格与基础风格两级
395
+ * @param {string} theme - 主题名称,不传时取当前主题
396
+ * @returns {Array<string>} 脚本目录列表
397
+ */
398
+ Engine.prototype.getJsDirs = function (theme) {
399
+ var cg = this.config;
400
+ var cur_theme = theme || this.current_theme;
401
+ var dirs = [];
402
+ if (cg.diy_dir) {
403
+ dirs.push(cg.diy_dir.fullname(this.dir));
404
+ }
405
+ if (cur_theme && cg.theme_js_dir) {
406
+ dirs.push(cg.theme_js_dir.fullname(this.getThemeDir(cur_theme)));
407
+ }
408
+ if (cg.default_theme && cg.default_theme !== cur_theme && cg.theme_js_dir) {
409
+ dirs.push(cg.theme_js_dir.fullname(this.getThemeDir(cg.default_theme)));
410
+ }
411
+ if (cg.js_dir) {
412
+ dirs.push(cg.js_dir.fullname(this.dir));
413
+ }
414
+ return dirs;
415
+ };
416
+
417
+ /**
418
+ * 预设值
419
+ * @param {boolean} force - 是否跳过语言包缓存强制重新加载
420
+ */
421
+ Engine.prototype._preset = function (force) {
422
+ // 基类构造期间会触发一次预设,此时加载器尚未就绪
423
+ if (!this.lang_loader) {
424
+ return;
425
+ }
426
+ this.lang = this.lang_loader.create(this.config, force);
427
+ };
428
+
429
+ /**
430
+ * 获取当前语言
431
+ * @returns {string} 语言代码
432
+ */
433
+ Engine.prototype.getLang = function () {
434
+ if (this.lang) {
435
+ return this.lang.now;
436
+ }
437
+ return this.config.default_lang;
438
+ };
439
+
440
+ /**
441
+ * 设置当前语言
442
+ * @param {string} lang - 语言代码
443
+ * @returns {string} 设置后的语言代码
444
+ */
445
+ Engine.prototype.setLang = function (lang) {
446
+ if (typeof (lang) !== 'string' || !lang) {
447
+ this.log('warn', '语言参数无效,保持当前语言:' + this.getLang());
448
+ return this.getLang();
449
+ }
450
+ return this.lang.setLang(lang);
451
+ };
452
+
453
+ /**
454
+ * 获取翻译文本
455
+ * 与模板中的 $t 等价,供服务端代码取词
456
+ * 参数为单个对象时按 {name} 命名占位符替换,否则按 {0}、{1} 位置占位符替换
457
+ * @param {string} key - 词条键名,支持 a.b 形式的层级键
458
+ * @param {...any} args - 替换参数,对象按命名占位符,其余按位置占位符
459
+ * @returns {string} 翻译后的文本
460
+ */
461
+ Engine.prototype.t = function (key, ...args) {
462
+ return this._translate(key, args);
463
+ };
464
+
465
+ /**
466
+ * 翻译词条
467
+ * @param {string} key - 词条键名
468
+ * @param {Array<any>} args - 替换参数
469
+ * @returns {string} 翻译后的文本
470
+ */
471
+ Engine.prototype._translate = function (key, args) {
472
+ var val = this.lang.get(key);
473
+ if (typeof (val) !== 'string' || !args.length) {
474
+ return val;
475
+ }
476
+ var first = args[0];
477
+ // 参数为普通对象时按命名占位符替换,可读性优于位置参数
478
+ if (first && typeof (first) === 'object' && !Array.isArray(first)) {
479
+ return this._fillNamed(val, first);
480
+ }
481
+ return this.lang.get(key, ...args);
482
+ };
483
+
484
+ /**
485
+ * 按命名占位符填充文本
486
+ * 值为空时保留占位符原样,便于发现漏配的词条
487
+ * @param {string} text - 原始文本
488
+ * @param {Object} data - 键值对
489
+ * @returns {string} 填充后的文本
490
+ */
491
+ Engine.prototype._fillNamed = function (text, data) {
492
+ for (var key in data) {
493
+ var val = data[key];
494
+ if (val === undefined || val === null) {
495
+ continue;
496
+ }
497
+ // 键名来自调用方,转义后构造正则,避免特殊字符影响匹配
498
+ var rx = new RegExp('\\{' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\}', 'g');
499
+ text = text.replace(rx, val);
500
+ }
501
+ return text;
502
+ };
503
+
504
+ /**
505
+ * 获取带复数形式的翻译文本
506
+ * @param {string} key - 词条键名,支持 a.b 形式的层级键
507
+ * @param {number} count - 数量,用于确定复数形式
508
+ * @param {...any} args - 额外替换参数,依次替换 {1}、{2} 等占位符
509
+ * @returns {string} 翻译后的文本
510
+ */
511
+ Engine.prototype.tc = function (key, count, ...args) {
512
+ return this.lang.tc(key, count, ...args);
513
+ };
514
+
515
+ /**
516
+ * 获取支持的语言列表
517
+ * 过滤掉无词条的语言,Lang 内置的 zh_cn、en 空字典不计入
518
+ * @returns {Array<string>} 语言代码数组
519
+ */
520
+ Engine.prototype.getLangs = function () {
521
+ if (!this.lang) {
522
+ return [];
523
+ }
524
+ var langs = this.lang.getLangs();
525
+ var list = [];
526
+ for (var i = 0; i < langs.length; i++) {
527
+ var dict = this.lang.config[langs[i]];
528
+ if (dict && Object.keys(dict).length) {
529
+ list.push(langs[i]);
530
+ }
531
+ }
532
+ return list;
533
+ };
534
+
535
+ /**
536
+ * 重新加载全部语言包
537
+ * 语言包文件新增或修改后调用,重建语言包并保持当前语言
538
+ * 默认按文件指纹判断是否需要重新读取,force 为 true 时强制读取
539
+ * @param {boolean} force - 是否跳过缓存强制重新读取语言包文件
540
+ * @returns {Array<string>} 重新加载后的语言列表
541
+ */
542
+ Engine.prototype.reloadLangs = function (force) {
543
+ var lang = this.getLang();
544
+ this._preset(force);
545
+ this.setLang(lang);
546
+ return this.getLangs();
547
+ };
548
+
549
+ /**
550
+ * 重新加载指定语言的语言包
551
+ * @param {string} lang - 语言代码
552
+ * @returns {Object|null} 语言包对象,不存在时返回null
553
+ */
554
+ Engine.prototype.loadLang = function (lang) {
555
+ if (typeof (lang) !== 'string' || !lang) {
556
+ this.log('warn', '语言参数无效:' + lang);
557
+ return null;
558
+ }
559
+ this.reloadLangs();
560
+ var dict = this.lang ? this.lang.config[lang] : null;
561
+ if (!dict) {
562
+ this.log('warn', '语言包不存在:' + lang);
563
+ return null;
564
+ }
565
+ return dict;
566
+ };
567
+
568
+ /**
569
+ * 设置用户偏好存储
570
+ * 传入对象需实现 getUser / setUser / delUser / hasUser / listUsers / clear 六个同步方法
571
+ * 留空或接口不完整时回退为内置内存存储(Memory)
572
+ * @param {Object} store - 存储对象
573
+ * @returns {Engine} 当前实例
574
+ */
575
+ Engine.prototype.setStore = function (store) {
576
+ if (!store || !this._isStore(store)) {
577
+ if (store) {
578
+ this.log('warn', 'user_store 接口不完整,回退为内置内存存储');
579
+ }
580
+ this.store = new Memory();
581
+ return this;
582
+ }
583
+ this.store = store;
584
+ return this;
585
+ };
586
+
587
+ /**
588
+ * 获取用户偏好存储
589
+ * 派生实例复用该对象即可共享同一份用户偏好
590
+ * @returns {Object} 存储对象
591
+ */
592
+ Engine.prototype.getStore = function () {
593
+ return this.store;
594
+ };
595
+
596
+ /**
597
+ * 判断是否为可用的用户偏好存储
598
+ * @param {Object} store - 存储对象
599
+ * @returns {boolean} 六个同步方法是否齐备
600
+ */
601
+ Engine.prototype._isStore = function (store) {
602
+ var methods = ['getUser', 'setUser', 'delUser', 'hasUser', 'listUsers', 'clear'];
603
+ for (var i = 0; i < methods.length; i++) {
604
+ if (typeof (store[methods[i]]) !== 'function') {
605
+ return false;
606
+ }
607
+ }
608
+ return true;
609
+ };
610
+
611
+ /**
612
+ * 规范化用户偏好
613
+ * 只保留 style 与 lang 两个字段,其余字段不写入存储
614
+ * @param {Object} prefs - 用户偏好,形如 { style, lang }
615
+ * @returns {Object} 规范化后的偏好
616
+ */
617
+ Engine.prototype._prefs = function (prefs) {
618
+ if (!prefs || typeof (prefs) !== 'object') {
619
+ throw new TypeError('用户偏好必须是对象');
620
+ }
621
+ var obj = {};
622
+ if (prefs.style !== undefined) {
623
+ if (typeof (prefs.style) !== 'string') {
624
+ throw new TypeError('风格必须是字符串');
625
+ }
626
+ obj.style = prefs.style;
627
+ }
628
+ if (prefs.lang !== undefined) {
629
+ if (typeof (prefs.lang) !== 'string') {
630
+ throw new TypeError('语言必须是字符串');
631
+ }
632
+ obj.lang = prefs.lang;
633
+ }
634
+ return obj;
635
+ };
636
+
637
+ /**
638
+ * 写入用户偏好
639
+ * 通过 user_id 映射用户的风格与语言,同一用户重复写入时按字段合并
640
+ * @param {string|number} user_id - 用户标识
641
+ * @param {Object} prefs - 用户偏好,形如 { style, lang },只取这两个字段
642
+ * @returns {Object} 保存后的用户偏好
643
+ */
644
+ Engine.prototype.setUser = function (user_id, prefs) {
645
+ return this.store.setUser(user_id, this._prefs(prefs));
646
+ };
647
+
648
+ /**
649
+ * 获取用户偏好
650
+ * @param {string|number} user_id - 用户标识
651
+ * @returns {Object|null} 用户偏好,未登记时返回 null
652
+ */
653
+ Engine.prototype.getUser = function (user_id) {
654
+ return this.store.getUser(user_id);
655
+ };
656
+
657
+ /**
658
+ * 删除用户偏好
659
+ * @param {string|number} user_id - 用户标识
660
+ * @returns {boolean} 是否删除成功
661
+ */
662
+ Engine.prototype.removeUser = function (user_id) {
663
+ return this.store.delUser(user_id);
664
+ };
665
+
666
+ /**
667
+ * 获取已登记的用户标识列表
668
+ * @returns {Array<string>} 用户标识数组
669
+ */
670
+ Engine.prototype.getUsers = function () {
671
+ return this.store.listUsers();
672
+ };
673
+
674
+ /**
675
+ * 获取用户的渲染参数
676
+ * 已登记的用户取其映射的风格与语言,未登记的用户回退到当前实例的风格与语言
677
+ * 结果可直接传给 view / render,实现单实例按用户渲染
678
+ * @param {string|number} user_id - 用户标识
679
+ * @param {Object} options - 其他渲染参数,可省略
680
+ * @returns {Object} 渲染参数,含 style 与 lang
681
+ */
682
+ Engine.prototype.userOptions = function (user_id, options) {
683
+ var opt = Object.assign({}, options);
684
+ var record = this.store.getUser(user_id);
685
+ opt.style = (record && record.style !== undefined) ? record.style : this.current_style;
686
+ opt.lang = (record && record.lang !== undefined) ? record.lang : this.getLang();
687
+ return opt;
688
+ };
689
+
690
+ /**
691
+ * 执行函数钩子
692
+ * 全局钩子与实例级钩子都执行,实例级排在后面且返回值优先
693
+ * @param {string} name - 钩子名称
694
+ * @param {Object} req - 请求参数
695
+ * @param {...any} args - 附加参数
696
+ * @returns {Promise<any>} 执行结果
697
+ */
698
+ Engine.prototype.runFunc = async function (name, req, ...args) {
699
+ var ret = await $.hook.runFunc(name, this.viewBag, req, ...args);
700
+ var ret_inst = await this.hook.runFunc(name, this.viewBag, req, ...args);
701
+ return ret_inst || ret;
702
+ };
703
+
704
+ /**
705
+ * 执行函数钩子
706
+ * @param {string} name - 钩子名称
707
+ * @param {Object} req - 请求参数
708
+ * @param {...any} args - 附加参数
709
+ * @returns {Promise<any>} 执行结果
710
+ */
711
+ Engine.prototype.runHook = Engine.prototype.runFunc;
712
+
713
+ /**
714
+ * 初始化前处理
715
+ * @param {Object} config - 配置参数
716
+ */
717
+ Engine.prototype._initBefore = function (config) { };
718
+
719
+ /**
720
+ * 初始化主处理
721
+ * @param {Object} config - 配置参数
722
+ */
723
+ Engine.prototype._initMain = function (config) {
724
+ this.setConfig(config);
725
+ this._initTpl();
726
+ };
727
+
728
+ /**
729
+ * 初始化后处理
730
+ * 构建字符转义规则,供渲染前后转义与还原使用
731
+ * @param {Object} config - 配置参数
732
+ */
733
+ Engine.prototype._initAfter = function (config) {
734
+ this.escape_rule = TplUtil.buildEscapeRule(this.config.change_str);
735
+ };
736
+
737
+ /**
738
+ * 初始化模板
739
+ * @param {Object} config - 配置参数
740
+ */
741
+ Engine.prototype._init = function (config) {
742
+ this._initBefore(config);
743
+ this._initMain(config);
744
+ this._initAfter(config);
745
+ };
746
+
747
+ /**
748
+ * 获取当前主题
749
+ * @returns {string} 主题名称
750
+ */
751
+ Engine.prototype.getTheme = function () {
752
+ return this.current_theme;
753
+ };
754
+
755
+ /**
756
+ * 设置当前主题
757
+ * 主题为部署级配置,正常运行中不应由用户切换
758
+ * 主题变更后会重新加载语言包,使主题级语言包生效
759
+ * @param {string} theme - 主题名称
760
+ * @param {boolean} force - 是否跳过目录校验
761
+ * @returns {string} 设置后的主题名称
762
+ */
763
+ Engine.prototype.setTheme = function (theme, force) {
764
+ if (typeof (theme) !== 'string' || !theme) {
765
+ this.log('warn', '主题参数无效,保持当前主题:' + this.current_theme);
766
+ return this.current_theme;
767
+ }
768
+ if (!force && !this.template.hasTheme(theme)) {
769
+ this.log('warn', '主题目录不存在,保持当前主题:' + theme);
770
+ return this.current_theme;
771
+ }
772
+ if (theme === this.current_theme) {
773
+ return this.current_theme;
774
+ }
775
+ this.current_theme = theme;
776
+ // 主题级语言包随主题变化,重新加载语言包并保持当前语言
777
+ this.reloadLangs();
778
+ return this.current_theme;
779
+ };
780
+
781
+ /**
782
+ * 获取当前风格
783
+ * @returns {string} 风格名称,未设置时返回空字符串
784
+ */
785
+ Engine.prototype.getStyle = function () {
786
+ return this.current_style;
787
+ };
788
+
789
+ /**
790
+ * 设置当前风格
791
+ * @param {string} style - 风格名称,空字符串表示不启用风格
792
+ * @param {boolean} force - 是否跳过目录校验
793
+ * @returns {string} 设置后的风格名称
794
+ */
795
+ Engine.prototype.setStyle = function (style, force) {
796
+ if (typeof (style) !== 'string') {
797
+ this.log('warn', '风格参数无效,保持当前风格:' + this.current_style);
798
+ return this.current_style;
799
+ }
800
+ if (style && !force && !this.template.hasStyle(style)) {
801
+ this.log('warn', '风格目录不存在,保持当前风格:' + style);
802
+ return this.current_style;
803
+ }
804
+ this.current_style = style;
805
+ return this.current_style;
806
+ };
807
+
808
+ /**
809
+ * 转义字符
810
+ * @param {string} body 字符串
811
+ * @param {boolean} restore 是否还原转义字符
812
+ * @returns {string} 转义后的字符串
813
+ */
814
+ Engine.prototype.escape = function (body, restore) {
815
+ return TplUtil.escape(body, this.escape_rule, restore);
816
+ };
817
+
818
+ /**
819
+ * 注入全局函数前处理
820
+ * @param {Object} m - 模型对象
821
+ * @returns {Object} 处理后的模型
822
+ */
823
+ Engine.prototype._loadFuncBefore = function (m) {
824
+ // m.$i18n = function(key) {
825
+ // return $.i18n(key);
826
+ // }
827
+ return m;
828
+ };
829
+
830
+ /**
831
+ * 加载函数到模型
832
+ * @param {Object} m 模型对象
833
+ * @returns {Object} 处理后的模型
834
+ */
835
+ Engine.prototype.loadFunc = function (m) {
836
+ this._loadFuncBefore(m);
837
+ var _this = this;
838
+ m.view = function (file, model, options) {
839
+ return _this.view(file, Object.assign({}, m, model), options);
840
+ };
841
+ m.render = function (body, model, options) {
842
+ return _this.render(body, Object.assign({}, m, model), options);
843
+ };
844
+ // 同时提供 $ 前缀别名,避免与用户 model 中的同名字段冲突
845
+ m.$view = m.view;
846
+ m.$render = m.render;
847
+ m.component = function (name, model, options) {
848
+ return _this.component(name, Object.assign({}, m, model), options);
849
+ };
850
+ m.$component = m.component;
851
+ m.$css = function (name, theme, style) {
852
+ return _this.getCssFile(name, theme, style);
853
+ };
854
+ m.$js = function (name, theme) {
855
+ return _this.getJsFile(name, theme);
856
+ };
857
+ m.hookAction = function (name, ...args) {
858
+ // 全局动作钩子先输出,实例级动作钩子后输出
859
+ return $.hook.runAction(name, m, ...args) + _this.hook.runAction(name, m, ...args);
860
+ };
861
+ m.hookFilter = function (name, content, ...args) {
862
+ if (!content) {
863
+ content = '';
864
+ }
865
+ var ret = $.hook.runFilter(name, m, content, ...args);
866
+ return _this.hook.runFilter(name, m, ret, ...args);
867
+ };
868
+ if (this.lang) {
869
+ m.$t = function (key, ...args) {
870
+ return _this.t(key, ...args);
871
+ };
872
+ }
873
+ // 合并已注册的模板函数,内置函数与本次 model 中的同名字段优先
874
+ for (var key in this.funcs) {
875
+ if (m[key] === undefined) {
876
+ m[key] = this.funcs[key];
877
+ }
878
+ }
879
+ return m;
880
+ };
881
+
882
+ /**
883
+ * 设置模板函数
884
+ * 注册后每次渲染都注入模型,供模板内直接调用,如 ${$markdown(text)}
885
+ * 内置函数与本次 model 中的同名字段优先,注册的函数无法覆盖内置函数
886
+ * @param {string} name - 函数名,建议以 $ 开头以避免与 model 字段冲突
887
+ * @param {Function} func - 函数
888
+ * @returns {boolean} 是否设置成功
889
+ */
890
+ Engine.prototype.setFunc = function (name, func) {
891
+ if (typeof (name) !== 'string' || !name) {
892
+ this.log('warn', '模板函数名无效:' + name);
893
+ return false;
894
+ }
895
+ if (typeof (func) !== 'function') {
896
+ this.log('warn', '模板函数必须是函数:' + name);
897
+ return false;
898
+ }
899
+ this.funcs[name] = func;
900
+ return true;
901
+ };
902
+
903
+ /**
904
+ * 删除模板函数
905
+ * @param {string} name - 函数名
906
+ * @returns {boolean} 是否存在并删除成功
907
+ */
908
+ Engine.prototype.removeFunc = function (name) {
909
+ if (this.funcs[name] === undefined) {
910
+ return false;
911
+ }
912
+ delete this.funcs[name];
913
+ return true;
914
+ };
915
+
916
+ /**
917
+ * 获取模板函数表
918
+ * @returns {Object} 模板函数表
919
+ */
920
+ Engine.prototype.getFuncs = function () {
921
+ return this.funcs;
922
+ };
923
+
924
+ /**
925
+ * 渲染主处理
926
+ * @param {string} body - 模板内容
927
+ * @param {Object} model - 数据模型
928
+ * @param {Object} options - 配置参数
929
+ * @returns {string} 渲染结果
930
+ */
931
+ Engine.prototype._renderMain = function (body, model, options) {
932
+ var ret = '';
933
+ if (body) {
934
+ var m = Object.assign({}, {
935
+ viewBag: this.viewBag,
936
+ globalBag: $.globalBag
937
+ }, model);
938
+ this.loadFunc(m);
939
+ ret = this.engine.render(body, m, options);
940
+ }
941
+ return ret;
942
+ };
943
+
944
+ /**
945
+ * 套用本次渲染的临时选项
946
+ * options.style 与 options.lang 只对当前这次渲染生效,由 _restoreTempOptions 恢复
947
+ * 适用于单实例按传参渲染不同组合,无需为每个组合派生实例
948
+ * @param {Object} options - 渲染参数
949
+ * @returns {Object|null} 原风格与原语言,未涉及临时选项时返回 null
950
+ */
951
+ Engine.prototype._applyTempOptions = function (options) {
952
+ if (!options) {
953
+ return null;
954
+ }
955
+ var has_style = options.style !== undefined;
956
+ var has_lang = typeof (options.lang) === 'string' && options.lang !== '';
957
+ if (!has_style && !has_lang) {
958
+ return null;
959
+ }
960
+ var prev = {
961
+ style: this.current_style,
962
+ lang: this.getLang()
963
+ };
964
+ if (has_style) {
965
+ this.current_style = typeof (options.style) === 'string' ? options.style : '';
966
+ }
967
+ if (has_lang && this.lang) {
968
+ this.lang.setLang(options.lang);
969
+ }
970
+ return prev;
971
+ };
972
+
973
+ /**
974
+ * 恢复临时选项
975
+ * @param {Object|null} prev - _applyTempOptions 返回的原值
976
+ */
977
+ Engine.prototype._restoreTempOptions = function (prev) {
978
+ if (!prev) {
979
+ return;
980
+ }
981
+ this.current_style = prev.style;
982
+ if (this.lang) {
983
+ this.lang.now = prev.lang;
984
+ }
985
+ };
986
+
987
+ /**
988
+ * 渲染模板
989
+ * options.style 与 options.lang 仅对本次渲染生效,渲染结束后自动恢复
990
+ * @param {string} body - 模板内容
991
+ * @param {Object} model - 数据模型
992
+ * @param {Object} options - 配置参数
993
+ * @returns {string} 渲染结果
994
+ */
995
+ Engine.prototype.render = function (body, model, options) {
996
+ var prev = this._applyTempOptions(options);
997
+ try {
998
+ body = this.escape(body);
999
+ body = this._renderMain(body, model, options);
1000
+ return this.escape(body, true);
1001
+ } finally {
1002
+ this._restoreTempOptions(prev);
1003
+ }
1004
+ };
1005
+
1006
+ /**
1007
+ * 编译模板
1008
+ * @param {string} body - 模板内容
1009
+ * @param {Object} options - 配置参数
1010
+ * @returns {Function} 渲染函数
1011
+ */
1012
+ Engine.prototype.compile = function (body, options) {
1013
+ return this.engine.compile(body, options);
1014
+ };
1015
+
1016
+ /**
1017
+ * 获取模板文件
1018
+ * @param {string} file - 模板文件路径
1019
+ * @param {Object} options - 配置参数,dir 为指定的查找目录
1020
+ * @returns {string} 模板文件路径
1021
+ */
1022
+ Engine.prototype.getFile = function (file, options) {
1023
+ return this.template.getFile(file, options);
1024
+ };
1025
+
1026
+ /**
1027
+ * 获取样式文件路径列表
1028
+ * 按目录优先级收集各级目录下的全部 css,同名文件只保留优先级最高的一份
1029
+ * 结果按优先级升序排列:基础风格在前打底,当前风格与自定义目录在后覆盖
1030
+ * @returns {Array<string>} 样式文件全路径列表
1031
+ */
1032
+ Engine.prototype.getCssFiles = function () {
1033
+ return this.css.getFiles();
1034
+ };
1035
+
1036
+ /**
1037
+ * 匹配样式文件
1038
+ * 与 getCssFile 传入通配符等价,语义更明确
1039
+ * @param {string} pattern - 样式文件名通配符,如 *.css、blue*,为空时等同于 *.css
1040
+ * @param {string} theme - 主题名称,不传时取当前主题
1041
+ * @param {string} style - 风格名称,不传时取当前风格
1042
+ * @returns {Array<string>} 命中的样式文件全路径列表,顺序即引用顺序
1043
+ */
1044
+ Engine.prototype.matchCssFiles = function (pattern, theme, style) {
1045
+ return this.css.matchFiles(pattern, theme, style);
1046
+ };
1047
+
1048
+ /**
1049
+ * 获取样式文件路径
1050
+ * 按目录优先级查找,名称支持 * 通配,用于模板中引用样式
1051
+ * 含通配符时返回命中的全部路径数组,否则返回第一个命中的路径
1052
+ * @param {string} name - 样式文件名或通配符,如 main.css、*.css、blue*
1053
+ * @param {string} theme - 主题名称,不传时取当前主题
1054
+ * @param {string} style - 风格名称,不传时取当前风格
1055
+ * @returns {string|Array<string>|null} 样式文件全路径;无通配符且不存在时返回 null
1056
+ */
1057
+ Engine.prototype.getCssFile = function (name, theme, style) {
1058
+ if (!name || typeof (name) !== 'string') {
1059
+ return null;
1060
+ }
1061
+ var list = this.css.matchFiles(name, theme, style);
1062
+ // 通配符用于批量引用,单名用于精确引用
1063
+ if (name.indexOf('*') !== -1) {
1064
+ return list;
1065
+ }
1066
+ return list.length ? list[0] : null;
1067
+ };
1068
+
1069
+ /**
1070
+ * 按通配符匹配脚本文件
1071
+ * @param {string} pattern - 脚本文件名通配符,如 *.js、app*,为空时等同于 *.js
1072
+ * @param {string} theme - 主题名称,不传时取当前主题
1073
+ * @returns {Array<string>} 命中的脚本文件全路径列表,顺序即执行顺序
1074
+ */
1075
+ Engine.prototype.matchJsFiles = function (pattern, theme) {
1076
+ return this.js.matchFiles(pattern, theme);
1077
+ };
1078
+
1079
+ /**
1080
+ * 获取全部脚本文件路径列表
1081
+ * 按目录优先级收集各级目录下的全部 js,同名文件只保留优先级最高的一份
1082
+ * 结果按优先级升序排列:应用级公共脚本在前,主题脚本与自定义目录在后
1083
+ * @returns {Array<string>} 脚本文件全路径列表
1084
+ */
1085
+ Engine.prototype.getJsFiles = function () {
1086
+ return this.js.getFiles();
1087
+ };
1088
+
1089
+ /**
1090
+ * 获取脚本文件路径
1091
+ * 按目录优先级查找,名称支持 * 通配,用于模板中引用脚本
1092
+ * 含通配符时返回命中的全部路径数组,否则返回第一个命中的路径
1093
+ * @param {string} name - 脚本文件名或通配符,如 app.js、*.js、app*
1094
+ * @param {string} theme - 主题名称,不传时取当前主题
1095
+ * @returns {string|Array<string>|null} 脚本文件全路径;无通配符且不存在时返回 null
1096
+ */
1097
+ Engine.prototype.getJsFile = function (name, theme) {
1098
+ if (!name || typeof (name) !== 'string') {
1099
+ return null;
1100
+ }
1101
+ var list = this.js.matchFiles(name, theme);
1102
+ // 通配符用于批量引用,单名用于精确引用
1103
+ if (name.indexOf('*') !== -1) {
1104
+ return list;
1105
+ }
1106
+ return list.length ? list[0] : null;
1107
+ };
1108
+
1109
+ /**
1110
+ * 获取组件文件
1111
+ * @param {string} name - 组件名称,如 bar、article/card
1112
+ * @param {Object} options - 配置参数,dir 为指定的查找目录
1113
+ * @returns {string} 组件文件路径
1114
+ */
1115
+ Engine.prototype.getComponentFile = function (name, options) {
1116
+ return this.template.getComponent(name, options);
1117
+ };
1118
+
1119
+ /**
1120
+ * 渲染组件
1121
+ * 组件沿组件目录链查找:static/diy/component → 当前主题/component → 默认主题/component
1122
+ * 组件名相对组件目录解析,不带 ./component/ 前缀,省略扩展名时自动补全
1123
+ * @param {string} name - 组件名称,如 bar、article/card
1124
+ * @param {Object} model - 数据模型
1125
+ * @param {Object} options - 配置参数
1126
+ * @returns {string} 渲染结果
1127
+ */
1128
+ Engine.prototype.component = function (name, model, options) {
1129
+ var dirs = this.getComponentDirs();
1130
+ if (!dirs.length) {
1131
+ this.error = {
1132
+ code: 10001,
1133
+ message: '组件目录未启用!'
1134
+ };
1135
+ return '';
1136
+ }
1137
+ var f = this.getComponentFile(name, options);
1138
+ if (!f.hasFile()) {
1139
+ this.error = {
1140
+ code: 10000,
1141
+ message: f + '组件不存在!'
1142
+ };
1143
+ return '';
1144
+ }
1145
+ var body = f.loadText();
1146
+ if (!body) {
1147
+ return '';
1148
+ }
1149
+ body = this.escape(body);
1150
+ body = this._renderMain(body, model, options);
1151
+ return this.escape(body, true);
1152
+ };
1153
+
1154
+ /**
1155
+ * 渲染模板文件
1156
+ * options.style 与 options.lang 仅对本次渲染生效,渲染结束后自动恢复
1157
+ * options.dir 指定时只在该目录查找模板
1158
+ * @param {string} file - 模板文件路径
1159
+ * @param {Object} model - 数据模型
1160
+ * @param {Object} options - 配置参数
1161
+ * @returns {string} 渲染结果
1162
+ */
1163
+ Engine.prototype.view = function (file, model, options) {
1164
+ var prev = this._applyTempOptions(options);
1165
+ try {
1166
+ var f = this.getFile(file, options);
1167
+ if (!f.hasFile()) {
1168
+ this.error = {
1169
+ code: 10000,
1170
+ message: f + '文件不存在!'
1171
+ };
1172
+ }
1173
+ else {
1174
+ var body = f.loadText();
1175
+ if (body) {
1176
+ body = this.escape(body);
1177
+ body = this._renderMain(body, model, options);
1178
+ body = this.escape(body, true);
1179
+ return body;
1180
+ }
1181
+ }
1182
+ return '';
1183
+ } finally {
1184
+ this._restoreTempOptions(prev);
1185
+ }
1186
+ };
1187
+
1188
+ /**
1189
+ * 设置视图数据
1190
+ * @param {Object} obj - 要设置的数据对象
1191
+ */
1192
+ Engine.prototype.set = function (obj) {
1193
+ $.push(this.viewBag, obj, true);
1194
+ };
1195
+
1196
+ /**
1197
+ * 删除视图数据
1198
+ * @param {Array|string} keys - 要删除的键或键数组
1199
+ */
1200
+ Engine.prototype.del = function (keys) {
1201
+ if (typeof (keys) == 'string') {
1202
+ delete this.viewBag[keys];
1203
+ } else {
1204
+ keys.map(function (k) {
1205
+ delete this.viewBag[k];
1206
+ });
1207
+ }
1208
+ };
1209
+
1210
+ /**
1211
+ * 获取视图数据
1212
+ * @param {string} key - 数据键名
1213
+ * @returns {any} 数据值
1214
+ */
1215
+ Engine.prototype.get = function (key) {
1216
+ return this.viewBag[key];
1217
+ };
1218
+
1219
+ /**
1220
+ * 派生引擎实例
1221
+ * 复用当前实例的配置,主题、风格、语言、视图数据各自独立
1222
+ * 派生实例不登记到任何列表,由调用方自行管理
1223
+ * @param {Object} config - 派生配置
1224
+ * @param {string} config.theme - 主题名称
1225
+ * @param {string} config.style - 风格名称
1226
+ * @param {string} config.lang - 语言代码
1227
+ * @returns {Engine} 派生出的引擎实例
1228
+ */
1229
+ Engine.prototype.fork = function (config) {
1230
+ var cg = config || {};
1231
+ var lang = (cg.lang && this.lang.isSupported(cg.lang)) ? cg.lang : this.getLang();
1232
+ var cfg = Object.assign({}, this.config, {
1233
+ is_fork: true,
1234
+ default_theme: this.current_theme,
1235
+ default_style: this.current_style,
1236
+ default_lang: lang,
1237
+ // 用户偏好属于用户而非实例,派生实例复用同一份存储
1238
+ user_store: this.getStore()
1239
+ });
1240
+ var tpl = new Engine(cfg);
1241
+ tpl.dir = this.dir;
1242
+ if (cg.theme) {
1243
+ tpl.setTheme(cg.theme);
1244
+ }
1245
+ if (cg.style) {
1246
+ tpl.setStyle(cg.style);
1247
+ }
1248
+ $.push(tpl.viewBag, this.viewBag, true);
1249
+ // 模板函数属于站点级扩展,派生实例继承同一份
1250
+ tpl.funcs = Object.assign({}, this.funcs);
1251
+ return tpl;
1252
+ };
1253
+
1254
+ module.exports = {
1255
+ Engine
1256
+ };