mm_tpl 2.7.3 → 2.8.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_EN.md CHANGED
@@ -1,56 +1,56 @@
1
- # mm_tpl
2
-
3
- Super Meimei Template Engine Module, based on art-template engine, provides powerful MVC pattern view rendering functionality with hook system, theme switching, and caching mechanism.
4
-
5
- [中文文档](README.md) | [English Documentation](README_EN.md)
6
-
7
- ## Table of Contents
8
-
9
- - [Features](#features)
10
- - [Installation](#installation)
11
- - [Quick Start](#quick-start)
12
- - [Advanced Features](#advanced-features)
13
- - [API Reference](#api-reference)
14
- - [Notes](#notes)
15
- - [Development](#development)
16
- - [License](#license)
17
-
18
- ## Features
19
-
20
- - 🚀 High-performance template engine based on art-template
21
- - 🔧 Support for multiple template syntax rules
22
- - 🎨 Deployment-level themes with user-level style and language switching; templates live under the theme directory organised by feature, styles are layered by style inside the theme
23
- - 🧩 Components in a dedicated directory referenced by `$component`, keeping shared partials apart from feature pages, with theme fallback and diy override
24
- - 📜 Application-level + theme-level scripts in two tiers, not layered by style, with application-level shared scripts running first
25
- - 🌐 Multi-user support: each user picks its own style and language, and shares cache by that combination
26
- - 🗂️ JSON language packs at application level and theme level, merged by directory priority and reloadable at runtime
27
- - ⚡ Built-in caching with physical files or process memory, invalidated automatically when templates change
1
+ # mm_tpl
2
+
3
+ Super Meimei Template Engine Module, based on art-template engine, provides powerful MVC pattern view rendering functionality with hook system, theme switching, and caching mechanism.
4
+
5
+ [中文文档](README.md) | [English Documentation](README_EN.md)
6
+
7
+ ## Table of Contents
8
+
9
+ - [Features](#features)
10
+ - [Installation](#installation)
11
+ - [Quick Start](#quick-start)
12
+ - [Advanced Features](#advanced-features)
13
+ - [API Reference](#api-reference)
14
+ - [Notes](#notes)
15
+ - [Development](#development)
16
+ - [License](#license)
17
+
18
+ ## Features
19
+
20
+ - 🚀 High-performance template engine based on art-template
21
+ - 🔧 Support for multiple template syntax rules
22
+ - 🎨 Deployment-level themes with user-level style and language switching; templates live under the theme directory organised by feature, styles are layered by style inside the theme
23
+ - 🧩 Components in a dedicated directory referenced by `$component`, keeping shared partials apart from feature pages, with theme fallback and diy override
24
+ - 📜 Application-level + theme-level scripts in two tiers, not layered by style, with application-level shared scripts running first
25
+ - 🌐 Multi-user support: each user picks its own style and language, and shares cache by that combination
26
+ - 🗂️ JSON language packs at application level and theme level, merged by directory priority and reloadable at runtime
27
+ - ⚡ Built-in caching with physical files or process memory, invalidated automatically when templates change
28
28
  - 🔌 Powerful hook system with both global and instance-level hooks
29
29
  - 🧩 Template function table: register with `setFunc` and call directly inside templates, inherited automatically by derived instances
30
- - 📁 Flexible directory structure; the `static/diy/` directory overrides same-named templates, styles and scripts of themes
31
- - 🛡️ Comprehensive error handling mechanism
32
-
33
- ## Installation
34
-
35
- ```bash
36
- npm install mm_tpl
37
- ```
38
-
39
- ## Quick Start
40
-
41
- ### 1. Install Dependencies
42
-
43
- ```bash
44
- npm install mm_tpl
45
- ```
46
-
47
- ### 2. Initialize Template Engine
48
-
49
- ```javascript
50
- const {Tpl} = require('mm_tpl');
51
-
52
- // Directory layout under the application root (see doc/目录结构.md):
53
- // app/shop/
30
+ - 📁 Flexible directory structure; the `static/diy/` directory overrides same-named templates, styles and scripts of themes
31
+ - 🛡️ Comprehensive error handling mechanism
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ npm install mm_tpl
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ### 1. Install Dependencies
42
+
43
+ ```bash
44
+ npm install mm_tpl
45
+ ```
46
+
47
+ ### 2. Initialize Template Engine
48
+
49
+ ```javascript
50
+ const {Tpl} = require('mm_tpl');
51
+
52
+ // Directory layout under the application root (see doc/目录结构.md):
53
+ // app/shop/
54
54
  // config.js # { defaults: { theme, style, lang } }
55
55
  // static/ # static resource root: templates, styles, scripts, language packs and the override directory all live here
56
56
  // diy/ # user override directory, highest priority, may hold templates, components and scripts
@@ -65,795 +65,795 @@ const {Tpl} = require('mm_tpl');
65
65
  // js/ # theme-level scripts, overriding same-named application-level scripts
66
66
  // lang/ # theme-level language packs, overriding the application level
67
67
  // style/{style}/ # styles only, no templates; styles are layered by style inside the theme
68
- // style/base/ # base style: common styles shared by every style
69
-
70
- // Create template instance
71
- const tpl = new Tpl({
72
- root: process.cwd(), // application root directory
73
- theme_dir: './static/template/', // theme directory, relative to the application root
74
- style_dir: './style/', // style directory, relative to the theme directory
75
- component_dir: './component/', // component directory, relative to the theme directory, holds shared partials; empty disables components
76
- diy_dir: './static/diy/', // user override directory, relative to the application root, highest priority
77
- lang_dir: './static/lang/', // application-level language pack directory, filename is the language code, empty to disable
78
- theme_lang_dir: './lang/', // theme-level language pack directory, relative to the theme directory, overrides same keys
79
- js_dir: './static/js/', // application-level script directory, relative to the application root, holds shared scripts
80
- theme_js_dir: './js/', // theme-level script directory, relative to the theme directory; scripts are not layered by style
81
- default_theme: 'default', // default theme, a deployment-level setting users do not pick
68
+ // style/base/ # base style: common styles shared by every style
69
+
70
+ // Create template instance
71
+ const tpl = new Tpl({
72
+ root: process.cwd(), // application root directory
73
+ theme_dir: './static/template/', // theme directory, relative to the application root
74
+ style_dir: './style/', // style directory, relative to the theme directory
75
+ component_dir: './component/', // component directory, relative to the theme directory, holds shared partials; empty disables components
76
+ diy_dir: './static/diy/', // user override directory, relative to the application root, highest priority
77
+ lang_dir: './static/lang/', // application-level language pack directory, filename is the language code, empty to disable
78
+ theme_lang_dir: './lang/', // theme-level language pack directory, relative to the theme directory, overrides same keys
79
+ js_dir: './static/js/', // application-level script directory, relative to the application root, holds shared scripts
80
+ theme_js_dir: './js/', // theme-level script directory, relative to the theme directory; scripts are not layered by style
81
+ default_theme: 'default', // default theme, a deployment-level setting users do not pick
82
82
  default_style: 'light', // default style, a variant inside a theme, user-selectable
83
- base_style: 'base', // base style directory holding styles shared by all styles; empty disables it
84
- default_lang: 'zh_cn', // default language, user-selectable
85
- langs: {}, // in-memory language packs, take precedence over files
86
- cache_dims: ['style', 'lang'], // cache layering dimensions; only user-selectable ones, users share cache by this
87
- cache_type: 'file', // cache type: file for physical files, memory for process memory
88
- cache_max: 1000, // max entries of the in-memory cache
89
- extname: ".html", // template file extension, configurable (e.g. ".art"); appended automatically when omitted
90
- cache_root: './cache', // cache directory
91
- cache_extname: '.cache.html', // cache file extension
92
- plural_rules: { // plural rules keyed by language code
93
- 'en_us': function (count) { return count === 1 ? 0 : 1; }
94
- },
95
- rules: [nativeRule, artRule, htmlRule, jsRule, pyRule, pyRule2] // template rules
96
- });
97
-
98
- // You can also read the application config file directly; defaults is expanded into default_theme / default_style / default_lang
99
- const app_config = require('./app/shop/config.js');
100
- const tpl_shop = new Tpl(Object.assign({ root: './app/shop/' }, app_config));
101
- // config.js content: module.exports = { defaults: { theme: 'default', style: 'light', lang: 'zh_cn' } }
102
- // An explicit default_* wins over defaults, and defaults is not kept in config, so fork() never expands it twice
103
- ```
104
-
105
- ### 3. Basic Template Rendering
106
-
107
- ```javascript
108
- // Simple variable substitution
109
- const result = tpl.render("<div>${message}</div>", {
110
- message: "Hello World"
111
- });
112
- // Output: <div>Hello World</div>
113
-
114
- // Compile template function
115
- const compiled = tpl.compile("<div>${name} - ${age}</div>");
116
- const result2 = compiled({ name: "John", age: 25 });
117
- // Output: <div>John - 25</div>
118
- ```
119
-
120
- ### 4. Loop Rendering
121
-
122
- ```javascript
123
- // Array loop
124
- const arrayResult = tpl.render(`
125
- <ul>
126
- ${loop arr value idx}
127
- <li>${idx}: ${value}</li>
128
- ${/loop}
129
- </ul>
130
- `, {
131
- arr: [123, 234]
132
- });
133
-
134
- // Object loop
135
- const objectResult = tpl.render(`
136
- <dl>
137
- ${loop obj value key}
138
- <dt>${key}</dt>
139
- <dd>${value}</dd>
140
- ${/loop}
141
- </dl>
142
- `, {
143
- obj: { name: "John", age: 25 }
144
- });
145
-
146
- // Traditional for loop
147
- const forResult = tpl.render(`
148
- <ul>
149
- <!--[ for(var i = 0; i < arr.length; i++){ ]-->
150
- <li>${arr[i]}</li>
151
- <!--[ } ]-->
152
- </ul>
153
- `, {
154
- arr: [123, 234]
155
- });
156
-
157
- // Each loop
158
- const eachResult = tpl.render(`
159
- <ul>
160
- ${each arr}
161
- <li>${$value}</li>
162
- ${/each}
163
- </ul>
164
- `, {
165
- arr: [123, 234]
166
- });
167
- // Output:
168
- // <ul>
169
- // <li>123</li>
170
- // <li>234</li>
171
- // </ul>
172
- ```
173
-
174
- ### 5. View Rendering
175
-
176
- ```javascript
177
- // Set global data
178
- Tpl.global_bag.siteName = "My Website";
179
-
180
- // Set view variables
181
- tpl.set({
182
- title: "Homepage",
183
- keywords: "keywords"
184
- });
185
-
186
- // Render view file; the path is relative to the theme directory, e.g. static/template/{theme}/index.html,
187
- // and may include feature directories such as article/list.html
188
- const html = tpl.view("./index.html", {
189
- user: { name: "John" }
190
- });
191
- // Output: Rendering result <div>Hello World</div>
192
-
193
- // Get view variable
194
- const title = tpl.get("title");
195
-
196
- // Delete view variable
197
- tpl.del("keywords");
198
- ```
199
-
200
- #### Rendering Components
201
-
202
- Shared partials (headers, sidebars, ad slots and so on) reusable by several pages live in the theme's `component/` directory, and pages reference them with `$component` using just the component name, without any directory prefix or extension:
203
-
204
- ```html
205
- <!-- A page references a component: the ./component/ prefix and the .html suffix are omitted -->
206
- <div>${@$component('bar')}</div>
207
-
208
- <!-- A component can reference other components, nesting is supported -->
209
- <!-- static/template/default/component/bar.html -->
210
- <div class="ad">${@$component('ad')}</div>
211
-
212
- <!-- Passing data to a component: the component reads the field directly -->
213
- ${@$component('user-card', { user: item })}
214
-
215
- <!-- A component inside a sub-directory -->
216
- ${@$component('article/card')}
217
- ```
218
-
219
- The server-side equivalent:
220
-
221
- ```javascript
222
- // Render a component, returns the rendered string
223
- tpl.component('bar', { message: 'hello' });
224
-
225
- // Return the full path of the hit component file without rendering
226
- tpl.getComponentFile('bar');
227
- ```
228
-
229
- A component accepts a model, which is merged with the caller's data: the caller's data and template helpers such as `$view` / `$css` / `$t` are passed down to the component, and fields in the passed model override the caller's. The model may be an object literal or a variable, the two are equivalent:
230
-
231
- ```html
232
- ${@$component('user-card', { user: item })}
233
- ${@$component('user-card', card_data)}
234
- ```
235
-
236
- A component name is resolved relative to the component directories, **not relative to the file that calls it**, so the same name resolves to the same component everywhere. When a component is not found, an empty string is returned and `tpl.error` is set (`code: 10000`) instead of throwing. See [Advanced Features](#1-theme-style-and-directory-support) for the full directory chain.
237
-
238
- ### 6. Caching Mechanism
239
-
240
- ```javascript
241
- // Use cache for view rendering: cache: true is enough, the cache filename is derived from the template path
242
- const html = tpl.view("./index.html", {
243
- data: someData
244
- }, {
245
- cache: true
246
- });
247
- // Cache file: cache/{style}/{lang}/index.cache.html (cache_dims defaults to ['style', 'lang'])
248
- // e.g. cache/light/zh_cn/index.cache.html, cache/dark/en_us/index.cache.html
249
-
250
- // You can also pass an explicit cache filename
251
- tpl.view("./index.html", { data: someData }, {
252
- cache: true,
253
- cache_filename: "./index" // Same as ./index.cache.html
254
- });
255
-
256
- // cache: false only writes the cache without reading it, useful for a forced refresh
257
- tpl.view("./index.html", { data: someData }, {
258
- cache: false,
259
- cache_filename: "./index"
260
- });
261
-
262
- // Clear cache: without arguments only the cache directory of the current instance is cleared
263
- tpl.clearCache();
264
- // Clear a specific directory, relative to cache_root
265
- tpl.clearCache('./game');
266
- // Clear all cache
267
- tpl.clearCache('./');
268
-
269
- // Use getCacheFile to inspect the cache path of the current instance; a string is accepted
270
- tpl.getCacheFile('./index');
271
- ```
272
-
273
- #### Cache Count Is Driven by cache_dims
274
-
275
- `cache_dims` should only contain the dimensions **users can choose**. The division of responsibility in this module is:
276
-
277
- | Dimension | Level | User-selectable | In cache layering |
278
- | --- | --- | --- | --- |
279
- | Theme `theme` | Deployment | No | No, uniform across the site |
280
- | Style `style` | User | Yes | Yes |
281
- | Language `lang` | User | Yes | Yes |
282
-
283
- The default is therefore `['style', 'lang']`: users are grouped by "style + language", and users in the same combination share the same cache.
284
-
285
- The upper bound on the number of cache files is:
286
-
287
- ```
288
- cache files ≤ (named styles + 1) × languages × cached templates
289
- ```
290
-
291
- The `+ 1` stands for **no style selected** — it is a distinct combination, but the empty string does not occupy a directory level, so its cache sits directly under the language directory (that combination only appears when `default_style` is empty too):
292
-
293
- ```
294
- cache/dark/zh_cn/test.cache.html ← dark style selected, one extra style level
295
- cache/zh_cn/test.cache.html ← no style, lang sits directly under the root
296
- ```
297
-
298
- This is an **upper bound, not a fixed count**: only combinations and templates that are actually rendered produce a cache file, and a combination that is never requested has no file at all. With 2 named styles (dark, light, plus no style = 3 combinations), 2 languages and 20 templates:
299
-
300
- | `cache_dims` | Levels | Files (upper bound) |
301
- | --- | --- | --- |
302
- | `['style', 'lang']` (default) | 3 combinations × 2 languages × 20 templates | 120 |
303
- | `['lang']` | no style dimension, 2 languages × 20 templates | 40 |
304
- | `[]` | no layering, 20 templates | 20 |
305
-
306
- The dimensions only take **finite enumerations** such as style and language; a user merely falls into one of those combinations, so this count is **completely independent of the number of users**. With 5,000 users, 1 named style (dark, plus no style = 2 combinations), 2 languages and 2 templates, even when all 4 combinations are used only 8 cache files are produced:
307
-
308
- ```
309
- cache/zh_cn/test.cache.html ← no style + Chinese
310
- cache/en_us/test.cache.html ← no style + English
311
- cache/dark/zh_cn/test.cache.html ← dark style + Chinese
312
- cache/dark/en_us/test.cache.html ← dark style + English
313
- ```
314
-
315
- The module provides no per-user dimension: each entry would serve exactly one user and only hit when "the same user revisits the same page while the data stays unchanged", yet user data (balance, orders, messages) usually changes on every request, so the hit rate approaches zero and stale content may be served. **Pages that contain user data are not suitable for full-page caching**; fetch those regions asynchronously, or use the in-memory cache described below.
316
-
317
- #### Theme Does Not Participate in Cache Layering
318
-
319
- A theme is a deployment-level setting, uniform for every user on the site and not switched at runtime, so `theme` is not part of `cache_dims` by default. This comes with one constraint: **switching the theme at runtime reuses the previous theme's cache**. The module logs a warning from `setTheme`; when a switch really is needed, clear the cache or include `theme` in the dimensions:
320
-
321
- ```javascript
322
- // Option 1: clear the cache after switching the theme
323
- tpl.setTheme('default');
324
- tpl.clearCache('./');
325
-
326
- // Option 2: include the theme when multiple themes coexist long term
327
- const tpl_multi = new Tpl({
328
- default_theme: 'game',
329
- cache_dims: ['theme', 'style', 'lang']
330
- });
331
- ```
332
-
333
- #### Automatic Cache Invalidation
334
-
335
- After a template file or a language pack file is modified, the old cache is invalidated and rebuilt on the next render — **no manual clearing needed**. The check compares file modification times, not file contents:
336
-
337
- ```javascript
338
- // Edit a template during development and it takes effect on refresh, no clearCache needed
339
- // Editing static/lang/zh_cn.json rebuilds the Chinese cache the same way
340
- ```
341
-
342
- Language pack changes are only checked when the cache is layered by language (`cache_dims` contains `lang`); otherwise only the template file is checked.
343
-
344
- #### In-memory Cache
345
-
346
- When you want the cache to stay off disk and live only inside the process, use the in-memory cache:
347
-
348
- ```javascript
349
- const tpl = new Tpl({
350
- default_theme: 'game',
351
- cache_type: 'memory', // file (default) for physical files, memory for process memory
352
- cache_max: 1000 // Max entries in memory; the earliest written entry is evicted beyond it, 0 means unlimited
353
- });
354
- ```
355
-
356
- In-memory entries live in `Tpl.cache_store` keyed by the full cache file path and use least-recently-used eviction: a hit moves the entry to the tail, and the earliest written entry is evicted beyond `cache_max`. The cache is cleared on process restart, which means each process keeps its own copy in a multi-instance deployment.
357
-
358
- `clearCache` clears both physical files and in-memory entries, and its scope is likewise limited by `cache_dims`.
359
-
360
- ### 7. Hook System
361
-
362
- #### Add Function Hook
363
- ```javascript
364
- Tpl.global_hook.addFunc('list', async function(viewBag, req, type) {
365
- // Process list logic
366
- var query = req.query;
367
- if (query.user_id) {
368
- viewBag.user_id = query.user_id;
369
- }
370
- return someData;
371
- }, 100, 'listFunc'); // sort: priority, alias: alias
372
- ```
373
-
374
- #### Add Action Hook
375
- ```javascript
376
- Tpl.global_hook.addAction('header', function(viewBag, param) {
377
- return "<header>Website Header</header>";
378
- }, 1, "headerAction");
379
- ```
380
-
381
- #### Add Filter Hook
382
- ```javascript
383
- Tpl.global_hook.addFilter('content', function(viewBag, content, param) {
384
- return content.replace("old text", "new text");
385
- }, 1, "contentFilter");
386
- ```
387
-
388
- #### Execute Hooks
389
- ```javascript
390
- // Execute function hook
391
- const result = await tpl.runFunc('list', { query: { page: 1 } }, 'article');
392
-
393
- // Execute action hook
394
- const actionResult = tpl.runHook('header', { param: 'value' });
395
-
396
- // Use hooks in template
397
- const html = tpl.render(`
398
- <div>
399
- ${hookAction('header')}
400
- ${hookFilter('content', 'original content')}
401
- </div>
402
- `, data);
403
- ```
404
-
405
- #### Delete Hooks
406
- ```javascript
407
- // Delete specific hook
408
- Tpl.global_hook.delFunc('list', 'listFunc');
409
- Tpl.global_hook.delAction('header', 'headerAction');
410
- Tpl.global_hook.delFilter('content', 'contentFilter');
411
-
412
- // Clear all hooks
413
- Tpl.global_hook.clear();
414
- ```
415
-
416
- ### 8. Multi-user Styles and Languages
417
-
418
- The module works only on the parameters it receives and does not care where they come from. Configuration has two levels:
419
-
420
- - **Deployment-level**: theme, template directory, cache directory, language pack directory and so on — uniform across the site and held by the master instance
421
- - **User-level**: style and language, chosen by each user
422
-
423
- There are two ways to use them: **passing options** (a single instance, with `style` / `lang` passed in the `view` options, no derived instance needed) and **deriving instances** (`fork`). The former is recommended; the following uses `fork` so each option's effect is easy to see. See [Using Styles and Languages Across Users](#6-using-styles-and-languages-across-users) for the trade-offs.
424
-
425
- ```javascript
426
- // Master instance: holds the deployment-level configuration
427
- const tpl = new Tpl({
428
- root: './app/shop/', // application root directory
429
- default_theme: 'game', // theme is fixed at deployment level, users do not pick it
430
- default_style: '', // default style when the user picks none
431
- default_lang: 'zh_cn', // default language when the user picks none
432
- lang_dir: './static/lang/', // application-level language pack directory, filename is the language code
433
- langs: {}, // optional in-memory language packs, take precedence over files
434
- // cache is layered by the user-selectable dimensions: style + language
435
- cache_dims: ['style', 'lang']
436
- });
437
-
438
- // User A: no style + Chinese
439
- const tpl_a = tpl.fork();
440
- // User B: dark style + English
441
- const tpl_b = tpl.fork({ style: 'dark', lang: 'en_us' });
442
-
443
- tpl_a.getTheme(); // "game", inherited from the master instance
444
- tpl_a.getStyle(); // ""
445
- tpl_a.getLang(); // "zh_cn"
446
-
447
- tpl_b.getTheme(); // "game"
448
- tpl_b.getStyle(); // "dark"
449
- tpl_b.getLang(); // "en_us"
450
-
451
- // Render separately; the language and the cache differ per configuration,
452
- // while template lookup stays identical because templates are not layered by style
453
- tpl_a.view('./index.html', { title: '首页' });
454
- tpl_b.view('./index.html', { title: 'Home' });
455
- ```
456
-
457
- Template directories are resolved by priority — `static/diy/` → current theme → default theme — so both user A and user B resolve to `static/template/game/index.html`; the style takes no part in template lookup (see [Advanced Features](#1-theme-style-and-directory-support) for the full order). With `default_theme: 'game'` above, the current theme is also the site default theme, so the chain has just the first two levels; under another current theme the default theme is the third:
458
-
459
- ```
460
- static/diy/index.html ← user override directory, highest priority, shared by all instances
461
- static/template/game/index.html ← current theme == default theme: user A and user B hit this very same file
462
- ```
463
-
464
- Only the theme falls back, never the style. What the style changes is the stylesheet list (and the cache path, since `cache_dims` contains `style`), not the template:
465
-
466
- ```
467
- user A (light style): static/template/game/style/light/light.css + the shared static/template/game/style/base/common.css
468
- user B (dark style): static/template/game/style/dark/dark.css + the shared static/template/game/style/base/common.css
469
- ```
470
-
471
- Styles are layered by style and collect every css along their own chain, keeping only the highest-priority copy per filename, so dark and light each read their own directory while their shared styles live in the base style directory instead of being duplicated.
472
-
473
- `$t` is bound to the language of the current instance and can be used in both rendered strings and template files:
474
-
475
- ```html
476
- <!-- Rendered string -->
477
- <p>${$t('hello', 'User')}</p>
478
- <!-- User A (zh_cn) output: <p>你好,User!</p> -->
479
- <!-- User B (en_us) output: <p>Hello, User!</p> -->
480
-
481
- <!-- Named interpolation: write {name} in the entry and pass an object -->
482
- <p>${$t('welcome', { name: user.name })}</p>
483
- ```
484
-
485
- Templates can also use `$view` for sub-templates, `$component` for components, `$css` for stylesheets and `$js` for scripts; all of them resolve through their own directory priority chain. The `$css` / `$js` name supports `*` wildcards and returns an array when one is used:
486
-
487
- ```html
488
- <div>${@$component('bar')}</div>
489
- <link rel="stylesheet" href="${$css('common.css')}">
490
- ${each $css('*.css') value}
491
- <link rel="stylesheet" href="${value}">
492
- ${/each}
493
-
494
- <!-- every script; app* or *promo* narrows by name, and the order is the execution order -->
495
- ${each $js('*.js') value}
496
- <script src="${value}"></script>
497
- ${/each}
498
- ```
499
-
500
- In server-side code use `t` / `tc`, which are equivalent to `$t` in templates:
501
-
502
- ```javascript
503
- tpl.t('hello', 'User'); // "你好,User!", replaces {0}, {1} ... placeholders in order
504
- tpl.t('welcome', { name: '张三' }); // "欢迎,张三!", an object argument fills {name} placeholders
505
- tpl.t('bar.nav'); // "游戏导航", supports a.b style nested keys
506
- tpl.tc('items', 1); // Plural lookup, picks the form by language rule: "共 1 项" in Chinese
507
- tpl.tc('items', 5); // "1 item" / "5 items" in English
508
- ```
509
-
510
- Plural entries are arrays in the language pack, `{count}` is the count placeholder:
511
-
512
- ```json
513
- // static/lang/en_us.json
514
- { "items": ["{count} item", "{count} items"] }
515
- ```
516
-
517
- The plural form is decided by the plural rule of the language, registered by language code in the `plural_rules` option. Language codes are lowercase with underscores; the built-in language core already ships `zh_cn` and `en` rules, so those two work directly, while other codes such as `en_us` should be registered explicitly in `plural_rules`. When a code is not registered, `tc()` falls back to the built-in `en` rule. Codes match language-pack filenames and are easier to reference from the browser.
518
-
519
- #### Language Pack Files
520
-
521
- Keep copy in JSON language packs and let template files call `$t` only; do not hardcode copy in templates. The filename is the language code:
522
-
523
- ```
83
+ base_style: 'base', // base style directory holding styles shared by all styles; empty disables it
84
+ default_lang: 'zh_cn', // default language, user-selectable
85
+ langs: {}, // in-memory language packs, take precedence over files
86
+ cache_dims: ['style', 'lang'], // cache layering dimensions; only user-selectable ones, users share cache by this
87
+ cache_type: 'file', // cache type: file for physical files, memory for process memory
88
+ cache_max: 1000, // max entries of the in-memory cache
89
+ extname: ".html", // template file extension, configurable (e.g. ".art"); appended automatically when omitted
90
+ cache_root: './cache', // cache directory
91
+ cache_extname: '.cache.html', // cache file extension
92
+ plural_rules: { // plural rules keyed by language code
93
+ 'en_us': function (count) { return count === 1 ? 0 : 1; }
94
+ },
95
+ rules: [nativeRule, artRule, htmlRule, jsRule, pyRule, pyRule2] // template rules
96
+ });
97
+
98
+ // You can also read the application config file directly; defaults is expanded into default_theme / default_style / default_lang
99
+ const app_config = require('./app/shop/config.js');
100
+ const tpl_shop = new Tpl(Object.assign({ root: './app/shop/' }, app_config));
101
+ // config.js content: module.exports = { defaults: { theme: 'default', style: 'light', lang: 'zh_cn' } }
102
+ // An explicit default_* wins over defaults, and defaults is not kept in config, so fork() never expands it twice
103
+ ```
104
+
105
+ ### 3. Basic Template Rendering
106
+
107
+ ```javascript
108
+ // Simple variable substitution
109
+ const result = tpl.render("<div>${message}</div>", {
110
+ message: "Hello World"
111
+ });
112
+ // Output: <div>Hello World</div>
113
+
114
+ // Compile template function
115
+ const compiled = tpl.compile("<div>${name} - ${age}</div>");
116
+ const result2 = compiled({ name: "John", age: 25 });
117
+ // Output: <div>John - 25</div>
118
+ ```
119
+
120
+ ### 4. Loop Rendering
121
+
122
+ ```javascript
123
+ // Array loop
124
+ const arrayResult = tpl.render(`
125
+ <ul>
126
+ ${loop arr value idx}
127
+ <li>${idx}: ${value}</li>
128
+ ${/loop}
129
+ </ul>
130
+ `, {
131
+ arr: [123, 234]
132
+ });
133
+
134
+ // Object loop
135
+ const objectResult = tpl.render(`
136
+ <dl>
137
+ ${loop obj value key}
138
+ <dt>${key}</dt>
139
+ <dd>${value}</dd>
140
+ ${/loop}
141
+ </dl>
142
+ `, {
143
+ obj: { name: "John", age: 25 }
144
+ });
145
+
146
+ // Traditional for loop
147
+ const forResult = tpl.render(`
148
+ <ul>
149
+ <!--[ for(var i = 0; i < arr.length; i++){ ]-->
150
+ <li>${arr[i]}</li>
151
+ <!--[ } ]-->
152
+ </ul>
153
+ `, {
154
+ arr: [123, 234]
155
+ });
156
+
157
+ // Each loop
158
+ const eachResult = tpl.render(`
159
+ <ul>
160
+ ${each arr}
161
+ <li>${$value}</li>
162
+ ${/each}
163
+ </ul>
164
+ `, {
165
+ arr: [123, 234]
166
+ });
167
+ // Output:
168
+ // <ul>
169
+ // <li>123</li>
170
+ // <li>234</li>
171
+ // </ul>
172
+ ```
173
+
174
+ ### 5. View Rendering
175
+
176
+ ```javascript
177
+ // Set global data
178
+ Tpl.global_bag.siteName = "My Website";
179
+
180
+ // Set view variables
181
+ tpl.set({
182
+ title: "Homepage",
183
+ keywords: "keywords"
184
+ });
185
+
186
+ // Render view file; the path is relative to the theme directory, e.g. static/template/{theme}/index.html,
187
+ // and may include feature directories such as article/list.html
188
+ const html = tpl.view("./index.html", {
189
+ user: { name: "John" }
190
+ });
191
+ // Output: Rendering result <div>Hello World</div>
192
+
193
+ // Get view variable
194
+ const title = tpl.get("title");
195
+
196
+ // Delete view variable
197
+ tpl.del("keywords");
198
+ ```
199
+
200
+ #### Rendering Components
201
+
202
+ Shared partials (headers, sidebars, ad slots and so on) reusable by several pages live in the theme's `component/` directory, and pages reference them with `$component` using just the component name, without any directory prefix or extension:
203
+
204
+ ```html
205
+ <!-- A page references a component: the ./component/ prefix and the .html suffix are omitted -->
206
+ <div>${@$component('bar')}</div>
207
+
208
+ <!-- A component can reference other components, nesting is supported -->
209
+ <!-- static/template/default/component/bar.html -->
210
+ <div class="ad">${@$component('ad')}</div>
211
+
212
+ <!-- Passing data to a component: the component reads the field directly -->
213
+ ${@$component('user-card', { user: item })}
214
+
215
+ <!-- A component inside a sub-directory -->
216
+ ${@$component('article/card')}
217
+ ```
218
+
219
+ The server-side equivalent:
220
+
221
+ ```javascript
222
+ // Render a component, returns the rendered string
223
+ tpl.component('bar', { message: 'hello' });
224
+
225
+ // Return the full path of the hit component file without rendering
226
+ tpl.getComponentFile('bar');
227
+ ```
228
+
229
+ A component accepts a model, which is merged with the caller's data: the caller's data and template helpers such as `$view` / `$css` / `$t` are passed down to the component, and fields in the passed model override the caller's. The model may be an object literal or a variable, the two are equivalent:
230
+
231
+ ```html
232
+ ${@$component('user-card', { user: item })}
233
+ ${@$component('user-card', card_data)}
234
+ ```
235
+
236
+ A component name is resolved relative to the component directories, **not relative to the file that calls it**, so the same name resolves to the same component everywhere. When a component is not found, an empty string is returned and `tpl.error` is set (`code: 10000`) instead of throwing. See [Advanced Features](#1-theme-style-and-directory-support) for the full directory chain.
237
+
238
+ ### 6. Caching Mechanism
239
+
240
+ ```javascript
241
+ // Use cache for view rendering: cache: true is enough, the cache filename is derived from the template path
242
+ const html = tpl.view("./index.html", {
243
+ data: someData
244
+ }, {
245
+ cache: true
246
+ });
247
+ // Cache file: cache/{style}/{lang}/index.cache.html (cache_dims defaults to ['style', 'lang'])
248
+ // e.g. cache/light/zh_cn/index.cache.html, cache/dark/en_us/index.cache.html
249
+
250
+ // You can also pass an explicit cache filename
251
+ tpl.view("./index.html", { data: someData }, {
252
+ cache: true,
253
+ cache_filename: "./index" // Same as ./index.cache.html
254
+ });
255
+
256
+ // cache: false only writes the cache without reading it, useful for a forced refresh
257
+ tpl.view("./index.html", { data: someData }, {
258
+ cache: false,
259
+ cache_filename: "./index"
260
+ });
261
+
262
+ // Clear cache: without arguments only the cache directory of the current instance is cleared
263
+ tpl.clearCache();
264
+ // Clear a specific directory, relative to cache_root
265
+ tpl.clearCache('./game');
266
+ // Clear all cache
267
+ tpl.clearCache('./');
268
+
269
+ // Use getCacheFile to inspect the cache path of the current instance; a string is accepted
270
+ tpl.getCacheFile('./index');
271
+ ```
272
+
273
+ #### Cache Count Is Driven by cache_dims
274
+
275
+ `cache_dims` should only contain the dimensions **users can choose**. The division of responsibility in this module is:
276
+
277
+ | Dimension | Level | User-selectable | In cache layering |
278
+ | --- | --- | --- | --- |
279
+ | Theme `theme` | Deployment | No | No, uniform across the site |
280
+ | Style `style` | User | Yes | Yes |
281
+ | Language `lang` | User | Yes | Yes |
282
+
283
+ The default is therefore `['style', 'lang']`: users are grouped by "style + language", and users in the same combination share the same cache.
284
+
285
+ The upper bound on the number of cache files is:
286
+
287
+ ```
288
+ cache files ≤ (named styles + 1) × languages × cached templates
289
+ ```
290
+
291
+ The `+ 1` stands for **no style selected** — it is a distinct combination, but the empty string does not occupy a directory level, so its cache sits directly under the language directory (that combination only appears when `default_style` is empty too):
292
+
293
+ ```
294
+ cache/dark/zh_cn/test.cache.html ← dark style selected, one extra style level
295
+ cache/zh_cn/test.cache.html ← no style, lang sits directly under the root
296
+ ```
297
+
298
+ This is an **upper bound, not a fixed count**: only combinations and templates that are actually rendered produce a cache file, and a combination that is never requested has no file at all. With 2 named styles (dark, light, plus no style = 3 combinations), 2 languages and 20 templates:
299
+
300
+ | `cache_dims` | Levels | Files (upper bound) |
301
+ | --- | --- | --- |
302
+ | `['style', 'lang']` (default) | 3 combinations × 2 languages × 20 templates | 120 |
303
+ | `['lang']` | no style dimension, 2 languages × 20 templates | 40 |
304
+ | `[]` | no layering, 20 templates | 20 |
305
+
306
+ The dimensions only take **finite enumerations** such as style and language; a user merely falls into one of those combinations, so this count is **completely independent of the number of users**. With 5,000 users, 1 named style (dark, plus no style = 2 combinations), 2 languages and 2 templates, even when all 4 combinations are used only 8 cache files are produced:
307
+
308
+ ```
309
+ cache/zh_cn/test.cache.html ← no style + Chinese
310
+ cache/en_us/test.cache.html ← no style + English
311
+ cache/dark/zh_cn/test.cache.html ← dark style + Chinese
312
+ cache/dark/en_us/test.cache.html ← dark style + English
313
+ ```
314
+
315
+ The module provides no per-user dimension: each entry would serve exactly one user and only hit when "the same user revisits the same page while the data stays unchanged", yet user data (balance, orders, messages) usually changes on every request, so the hit rate approaches zero and stale content may be served. **Pages that contain user data are not suitable for full-page caching**; fetch those regions asynchronously, or use the in-memory cache described below.
316
+
317
+ #### Theme Does Not Participate in Cache Layering
318
+
319
+ A theme is a deployment-level setting, uniform for every user on the site and not switched at runtime, so `theme` is not part of `cache_dims` by default. This comes with one constraint: **switching the theme at runtime reuses the previous theme's cache**. The module logs a warning from `setTheme`; when a switch really is needed, clear the cache or include `theme` in the dimensions:
320
+
321
+ ```javascript
322
+ // Option 1: clear the cache after switching the theme
323
+ tpl.setTheme('default');
324
+ tpl.clearCache('./');
325
+
326
+ // Option 2: include the theme when multiple themes coexist long term
327
+ const tpl_multi = new Tpl({
328
+ default_theme: 'game',
329
+ cache_dims: ['theme', 'style', 'lang']
330
+ });
331
+ ```
332
+
333
+ #### Automatic Cache Invalidation
334
+
335
+ After a template file or a language pack file is modified, the old cache is invalidated and rebuilt on the next render — **no manual clearing needed**. The check compares file modification times, not file contents:
336
+
337
+ ```javascript
338
+ // Edit a template during development and it takes effect on refresh, no clearCache needed
339
+ // Editing static/lang/zh_cn.json rebuilds the Chinese cache the same way
340
+ ```
341
+
342
+ Language pack changes are only checked when the cache is layered by language (`cache_dims` contains `lang`); otherwise only the template file is checked.
343
+
344
+ #### In-memory Cache
345
+
346
+ When you want the cache to stay off disk and live only inside the process, use the in-memory cache:
347
+
348
+ ```javascript
349
+ const tpl = new Tpl({
350
+ default_theme: 'game',
351
+ cache_type: 'memory', // file (default) for physical files, memory for process memory
352
+ cache_max: 1000 // Max entries in memory; the earliest written entry is evicted beyond it, 0 means unlimited
353
+ });
354
+ ```
355
+
356
+ In-memory entries live in `Tpl.cache_store` keyed by the full cache file path and use least-recently-used eviction: a hit moves the entry to the tail, and the earliest written entry is evicted beyond `cache_max`. The cache is cleared on process restart, which means each process keeps its own copy in a multi-instance deployment.
357
+
358
+ `clearCache` clears both physical files and in-memory entries, and its scope is likewise limited by `cache_dims`.
359
+
360
+ ### 7. Hook System
361
+
362
+ #### Add Function Hook
363
+ ```javascript
364
+ Tpl.global_hook.addFunc('list', async function(viewBag, req, type) {
365
+ // Process list logic
366
+ var query = req.query;
367
+ if (query.user_id) {
368
+ viewBag.user_id = query.user_id;
369
+ }
370
+ return someData;
371
+ }, 100, 'listFunc'); // sort: priority, alias: alias
372
+ ```
373
+
374
+ #### Add Action Hook
375
+ ```javascript
376
+ Tpl.global_hook.addAction('header', function(viewBag, param) {
377
+ return "<header>Website Header</header>";
378
+ }, 1, "headerAction");
379
+ ```
380
+
381
+ #### Add Filter Hook
382
+ ```javascript
383
+ Tpl.global_hook.addFilter('content', function(viewBag, content, param) {
384
+ return content.replace("old text", "new text");
385
+ }, 1, "contentFilter");
386
+ ```
387
+
388
+ #### Execute Hooks
389
+ ```javascript
390
+ // Execute function hook
391
+ const result = await tpl.runFunc('list', { query: { page: 1 } }, 'article');
392
+
393
+ // Execute action hook
394
+ const actionResult = tpl.runHook('header', { param: 'value' });
395
+
396
+ // Use hooks in template
397
+ const html = tpl.render(`
398
+ <div>
399
+ ${hookAction('header')}
400
+ ${hookFilter('content', 'original content')}
401
+ </div>
402
+ `, data);
403
+ ```
404
+
405
+ #### Delete Hooks
406
+ ```javascript
407
+ // Delete specific hook
408
+ Tpl.global_hook.delFunc('list', 'listFunc');
409
+ Tpl.global_hook.delAction('header', 'headerAction');
410
+ Tpl.global_hook.delFilter('content', 'contentFilter');
411
+
412
+ // Clear all hooks
413
+ Tpl.global_hook.clear();
414
+ ```
415
+
416
+ ### 8. Multi-user Styles and Languages
417
+
418
+ The module works only on the parameters it receives and does not care where they come from. Configuration has two levels:
419
+
420
+ - **Deployment-level**: theme, template directory, cache directory, language pack directory and so on — uniform across the site and held by the master instance
421
+ - **User-level**: style and language, chosen by each user
422
+
423
+ There are two ways to use them: **passing options** (a single instance, with `style` / `lang` passed in the `view` options, no derived instance needed) and **deriving instances** (`fork`). The former is recommended; the following uses `fork` so each option's effect is easy to see. See [Using Styles and Languages Across Users](#6-using-styles-and-languages-across-users) for the trade-offs.
424
+
425
+ ```javascript
426
+ // Master instance: holds the deployment-level configuration
427
+ const tpl = new Tpl({
428
+ root: './app/shop/', // application root directory
429
+ default_theme: 'game', // theme is fixed at deployment level, users do not pick it
430
+ default_style: '', // default style when the user picks none
431
+ default_lang: 'zh_cn', // default language when the user picks none
432
+ lang_dir: './static/lang/', // application-level language pack directory, filename is the language code
433
+ langs: {}, // optional in-memory language packs, take precedence over files
434
+ // cache is layered by the user-selectable dimensions: style + language
435
+ cache_dims: ['style', 'lang']
436
+ });
437
+
438
+ // User A: no style + Chinese
439
+ const tpl_a = tpl.fork();
440
+ // User B: dark style + English
441
+ const tpl_b = tpl.fork({ style: 'dark', lang: 'en_us' });
442
+
443
+ tpl_a.getTheme(); // "game", inherited from the master instance
444
+ tpl_a.getStyle(); // ""
445
+ tpl_a.getLang(); // "zh_cn"
446
+
447
+ tpl_b.getTheme(); // "game"
448
+ tpl_b.getStyle(); // "dark"
449
+ tpl_b.getLang(); // "en_us"
450
+
451
+ // Render separately; the language and the cache differ per configuration,
452
+ // while template lookup stays identical because templates are not layered by style
453
+ tpl_a.view('./index.html', { title: '首页' });
454
+ tpl_b.view('./index.html', { title: 'Home' });
455
+ ```
456
+
457
+ Template directories are resolved by priority — `static/diy/` → current theme → default theme — so both user A and user B resolve to `static/template/game/index.html`; the style takes no part in template lookup (see [Advanced Features](#1-theme-style-and-directory-support) for the full order). With `default_theme: 'game'` above, the current theme is also the site default theme, so the chain has just the first two levels; under another current theme the default theme is the third:
458
+
459
+ ```
460
+ static/diy/index.html ← user override directory, highest priority, shared by all instances
461
+ static/template/game/index.html ← current theme == default theme: user A and user B hit this very same file
462
+ ```
463
+
464
+ Only the theme falls back, never the style. What the style changes is the stylesheet list (and the cache path, since `cache_dims` contains `style`), not the template:
465
+
466
+ ```
467
+ user A (light style): static/template/game/style/light/light.css + the shared static/template/game/style/base/common.css
468
+ user B (dark style): static/template/game/style/dark/dark.css + the shared static/template/game/style/base/common.css
469
+ ```
470
+
471
+ Styles are layered by style and collect every css along their own chain, keeping only the highest-priority copy per filename, so dark and light each read their own directory while their shared styles live in the base style directory instead of being duplicated.
472
+
473
+ `$t` is bound to the language of the current instance and can be used in both rendered strings and template files:
474
+
475
+ ```html
476
+ <!-- Rendered string -->
477
+ <p>${$t('hello', 'User')}</p>
478
+ <!-- User A (zh_cn) output: <p>你好,User!</p> -->
479
+ <!-- User B (en_us) output: <p>Hello, User!</p> -->
480
+
481
+ <!-- Named interpolation: write {name} in the entry and pass an object -->
482
+ <p>${$t('welcome', { name: user.name })}</p>
483
+ ```
484
+
485
+ Templates can also use `$view` for sub-templates, `$component` for components, `$css` for stylesheets and `$js` for scripts; all of them resolve through their own directory priority chain. The `$css` / `$js` name supports `*` wildcards and returns an array when one is used:
486
+
487
+ ```html
488
+ <div>${@$component('bar')}</div>
489
+ <link rel="stylesheet" href="${$css('common.css')}">
490
+ ${each $css('*.css') value}
491
+ <link rel="stylesheet" href="${value}">
492
+ ${/each}
493
+
494
+ <!-- every script; app* or *promo* narrows by name, and the order is the execution order -->
495
+ ${each $js('*.js') value}
496
+ <script src="${value}"></script>
497
+ ${/each}
498
+ ```
499
+
500
+ In server-side code use `t` / `tc`, which are equivalent to `$t` in templates:
501
+
502
+ ```javascript
503
+ tpl.t('hello', 'User'); // "你好,User!", replaces {0}, {1} ... placeholders in order
504
+ tpl.t('welcome', { name: '张三' }); // "欢迎,张三!", an object argument fills {name} placeholders
505
+ tpl.t('bar.nav'); // "游戏导航", supports a.b style nested keys
506
+ tpl.tc('items', 1); // Plural lookup, picks the form by language rule: "共 1 项" in Chinese
507
+ tpl.tc('items', 5); // "1 item" / "5 items" in English
508
+ ```
509
+
510
+ Plural entries are arrays in the language pack, `{count}` is the count placeholder:
511
+
512
+ ```json
513
+ // static/lang/en_us.json
514
+ { "items": ["{count} item", "{count} items"] }
515
+ ```
516
+
517
+ The plural form is decided by the plural rule of the language, registered by language code in the `plural_rules` option. Language codes are lowercase with underscores; the built-in language core already ships `zh_cn` and `en` rules, so those two work directly, while other codes such as `en_us` should be registered explicitly in `plural_rules`. When a code is not registered, `tc()` falls back to the built-in `en` rule. Codes match language-pack filenames and are easier to reference from the browser.
518
+
519
+ #### Language Pack Files
520
+
521
+ Keep copy in JSON language packs and let template files call `$t` only; do not hardcode copy in templates. The filename is the language code:
522
+
523
+ ```
524
524
  static/lang/zh_cn.json ← application-level Chinese
525
525
  static/lang/en_us.json ← application-level English
526
526
  static/template/game/lang/zh_cn.json ← Chinese of the game theme, overrides the same keys
527
- static/template/game/lang/en_us.json ← English of the game theme, overrides the same keys
528
- ```
529
-
530
- ```json
531
- // static/lang/zh_cn.json
532
- {
533
- "hello": "你好,{0}!",
534
- "theme": { "game": "游戏主题" },
535
- "bar": { "nav": "导航栏", "user_id": "用户ID" }
536
- }
537
- ```
538
-
539
- ```json
540
- // static/template/game/lang/zh_cn.json: only the keys to override
541
- {
542
- "bar": { "nav": "游戏导航" }
543
- }
544
- ```
545
-
546
- ```html
547
- <!-- static/template/game/test.html: templates only resolve entries -->
548
- <div>${$t('theme.game')}</div>
549
- <!-- zh_cn output: <div>游戏主题</div> -->
550
- <!-- en_us output: <div>Game Theme</div> -->
551
- ```
552
-
553
- Language packs are merged in the following order, each overriding the previous one: application level `lang_dir/{lang}.json` → default theme `{theme_dir}/{default theme}/{theme_lang_dir}/{lang}.json` → current theme `{theme_dir}/{current theme}/{theme_lang_dir}/{lang}.json` → the `langs` option. Entries of the same language are merged by key, and higher priority overrides same-named keys, so the same template can produce different copy per theme.
554
-
555
- Entries are resolved in this order: current language → default language. When both miss, an error is logged and `undefined` is returned, rendering as an empty string. To get started quickly you may also write copy directly in templates and extract it into language packs later.
556
-
557
- Language pack files are read when an instance is created and then cached by a file fingerprint (path + mtime): while the files are unchanged, new instances and `fork` reuse the parsed result; after a file is added, edited or removed, the next read reloads automatically. Language packs of already created instances are not refreshed automatically — call `reloadLangs()` for that.
558
-
559
- ```javascript
560
- tpl.getLangs(); // ['zh_cn', 'en_us']; languages without entries are filtered out
561
- tpl.reloadLangs(); // Reload by file fingerprint, keeping the current language; returns the language list
562
- tpl.reloadLangs(true); // Skip the cache and force re-reading the language files
563
- tpl.loadLang('en_us'); // Reload and return the entries of one language
564
- ```
565
-
566
- Switch at runtime (only affects the current instance). Style and language are user-selectable; the theme is a deployment-level setting that users should not switch at runtime:
567
-
568
- ```javascript
569
- tpl_b.setStyle('light'); // Returns the applied style name
570
- tpl_b.setLang('zh_cn'); // Returns the applied language code
571
- ```
572
-
573
- Theme and style setters validate that the directory exists. When it does not, a warning is logged and the **previous value is kept**, so a user never believes the switch succeeded while the default value is still in use:
574
-
575
- ```javascript
576
- // There is no not_exist theme or style directory under static/template
577
- tpl_b.setStyle('not_exist'); // Returns the old style "dark" and logs a warning
578
- tpl_b.setTheme('not_exist'); // Returns the old theme "game" and logs a warning (theme validation works the same way)
579
-
580
- // Skip validation when the directory has not been created yet
581
- tpl_b.setStyle('not_exist', true); // Returns "not_exist"
582
-
583
- // An empty string means no style is set; lookup and cache layering fall back to the default style
584
- tpl_b.setStyle('');
585
- ```
586
-
587
- The cache is layered by the dimensions in `cache_dims`, and users are grouped by "style + language" so that users in the same combination share the cache:
588
-
589
- ```
590
- cache/zh_cn/test.cache.html ← user A (no style + Chinese)
591
- cache/en_us/test.cache.html ← user A switched to English
592
- cache/dark/zh_cn/test.cache.html ← user B (dark style + Chinese)
593
- ```
594
-
595
- ```javascript
596
- // Get the cache file path of the current instance
597
- tpl_a.getCacheFile({ cache_filename: './test' });
598
- ```
599
-
600
- Cache files are grouped by "style + language", so users sharing a combination share the same cache file and the count is independent of the user count — see [Cache Count Is Driven by cache_dims](#cache-count-is-driven-by-cache_dims).
601
-
602
- ## Advanced Features
603
-
604
- ### 1. Theme, Style and Directory Support
605
- ```javascript
606
- // Set current style (a variant inside a theme, user-selectable; keeps the old style and logs a warning when missing)
607
- tpl.setStyle("dark");
608
- // Clear the style
609
- tpl.setStyle("");
610
-
611
- // Set current theme (a deployment-level setting users should not switch at runtime)
612
- // Keeps the old theme and logs a warning when the directory is missing; clear the cache after switching,
613
- // see "Theme Does Not Participate in Cache Layering"
614
- tpl.setTheme("default");
615
- // Skip directory validation and set anyway
616
- tpl.setTheme("default", true);
617
-
618
- // Or assign directly (no validation)
619
- tpl.current_theme = "default";
620
- tpl.current_style = "dark";
621
-
622
- // Template directory search order (priority from high to low; {dir} is this.dir, the application root):
623
- // 1. {dir}/static/diy/ user override directory
624
- // 2. {dir}/static/template/{current theme}/ templates organised by feature directly under the theme directory
625
- // 3. {dir}/static/template/{default theme}/
626
- //
627
- // Templates are layered by theme only, never by style: a style is a visual variant and must not change the HTML structure,
628
- // so switching styles keeps resolving the very same template file
629
- // The path is relative to the theme directory and may include any feature directory, e.g. ./index.html, ./article/list.html, ./article/detail.html
630
- // The extension is appended automatically when omitted (extname, '.html' by default), so
631
- // view('./article/list') and view('./article/list.html') are equivalent
632
- // Templates override by filename, and the highest-priority file wins (getFile); the style takes no part in this lookup
633
- // A style that needs a different look changes css; different copy or structure is handled with model variables
634
-
635
- // Component directory search order (priority from high to low; components use a chain of their own,
636
- // isomorphic to the template chain with one extra fixed component/ prefix on every level):
637
- // 1. {dir}/static/diy/component/ user override directory
638
- // 2. {dir}/static/template/{current theme}/component/
639
- // 3. {dir}/static/template/{default theme}/component/
640
- //
641
- // A component name is resolved relative to the component directories, not relative to the calling file,
642
- // so the same name resolves identically wherever it is used
643
- // Names may include sub-directories such as article/card, and the extension is appended automatically (getComponentFile)
644
- // A component missing from the current theme falls back to the default theme's component directory,
645
- // and static/diy/component/ overrides every theme globally
646
- // Components are not cached by default; pass cache and cache_filename explicitly through the options when needed
647
-
648
- // Style directory search order (priority from high to low): styles are layered by theme and by style,
649
- // so they carry two more levels (the current style and the base style) than templates
650
- // 1. {dir}/static/diy/ user override directory
651
- // 2. {dir}/static/template/{current theme}/style/{current style}/
652
- // 3. {dir}/static/template/{current theme}/style/{base style}/
653
- // 4. {dir}/static/template/{default theme}/style/{current style}/
654
- // 5. {dir}/static/template/{default theme}/style/{base style}/
655
- //
656
- // Only the theme falls back, never the style: files of other style directories are not inherited
657
- // Shared styles belong in the base style directory (base_style, 'base' by default),
658
- // each style directory only holds its own differences
659
- // The style directory holds css only and never contains templates
660
- // Styles collect every css of the directories above, keeping only the highest-priority copy per filename (getCssFiles)
661
- // With no style set that level uses the default style; when the default style is empty too, the style directory of the theme itself is used
662
-
663
- // Script directory search order (priority from high to low): layered by theme only, no style or base style level
664
- // 1. {dir}/static/diy/ user override directory
665
- // 2. {dir}/static/template/{current theme}/js/
666
- // 3. {dir}/static/template/{default theme}/js/
667
- // 4. {dir}/static/js/ application-level shared scripts
668
- //
669
- // Scripts are not layered by style: a style is a visual variant, scripts do not change with colour or lightness,
670
- // so switching styles should not switch scripts
671
- // Shared scripts belong in the application-level static/js/, and keep being provided there when the theme changes
672
-
673
- // Style files are returned by ascending priority: the base style comes first as the foundation,
674
- // the current style overrides after it (a later-loaded rule is the one that takes effect)
675
- tpl.getCssFiles(); // ['.../static/template/default/style/base/common.css', '.../static/template/default/style/light/light.css', ...]
676
-
677
- // Scripts are ascending too: application-level shared scripts come first, theme scripts come after (dependency order then holds)
678
- tpl.getJsFiles(); // ['.../static/js/utils.js', '.../static/template/default/js/app.js', ...]
679
-
680
- // Single style file: resolved along the same chain, returns its full path or null
681
- tpl.getCssFile('common.css'); // hits the base style common.css
682
- tpl.getCssFile('dark.css', 'default', 'dark'); // explicitly read another theme and style
683
-
684
- // Names support * wildcards: *suffix matches the end, prefix* the start, *infix* anywhere
685
- tpl.getCssFile('*.css'); // returns an array, same as getCssFiles()
686
- tpl.matchCssFiles('light*'); // array: only styles named light*
687
- tpl.matchCssFiles('*common*'); // array: shared styles across directories
688
- // In templates:
689
- // <link rel="stylesheet" href="${$css('common.css')}"> exact reference
690
- // ${each $css('*.css') value}<link href="${value}">${/each} wildcard batch
691
- // How many to include and in which order is up to the template; the engine only returns paths
692
-
693
- // Scripts work the same way as styles, just without the style argument; names also support * wildcards
694
- tpl.getJsFile('utils.js'); // hits the application-level shared script
695
- tpl.getJsFile('app*'); // an array when a wildcard is used, as above
696
- tpl.getJsFile('promo.js', 'festival'); // explicitly read a script of one theme, null when absent
697
- tpl.matchJsFiles('*.js'); // every script, an array, the order is the execution order
698
- tpl.matchJsFiles('app*'); // prefix match: only scripts starting with app
699
- tpl.matchJsFiles('*promo*'); // infix match: same-name family across directories
700
- tpl.matchJsFiles('nope*'); // no match: an empty array [], not null
701
- tpl.matchJsFiles('promo*', 'festival'); // specify a theme
702
- // In templates:
703
- // <script src="${$js('utils.js')}"></script> exact reference
704
- // ${each $js('*.js') value}<script src="${value}"></script>${/each} wildcard batch
705
- // ${each $js('app*') value}<script src="${value}"></script>${/each} group by module prefix
706
- // ${if $js('chart.js')}<script src="${$js('chart.js')}"></script>${/if} skip when absent, avoiding a 404
707
- //
708
- // With the site default theme default and the current theme festival, the hit order of $js('*.js') mirrors the layering:
709
- // ['utils.js' (application static/js/), 'app.js' (default theme, hit by fallback since festival has none), 'promo.js' (current theme static/template/festival/js/), 'patch.js' (static/diy/)]
710
-
711
- // Resolved theme, template, style and script directories
712
- tpl.getThemeDir('default'); // {dir}/static/template/default/
713
- tpl.getTemplateDirs(); // full paths of the template directories, high to low: static/diy/ → current theme → default theme
714
- tpl.getComponentDirs(); // full paths of the component directories, high to low: static/diy/component → current theme/component → default theme/component
715
- tpl.getStyleDir('default', 'dark');// {dir}/static/template/default/style/dark/
716
- tpl.getStyleDirs(); // full paths of the style directories above in priority order; styles only, not used for templates (duplicates are removed when the current and default theme match)
717
- tpl.getJsDirs(); // full paths of the script directories, high to low: static/diy/ → current theme → default theme → application level
718
- ```
719
-
720
- ### 2. Template Rules
721
- Built-in support for multiple template rules, including:
722
- - nativeRule: Native syntax rules
723
- - artRule: art-template syntax rules
724
- - htmlRule: HTML comment syntax rules
725
- - jsRule: JavaScript syntax rules
726
- - pyRule: Python-style syntax rules
727
-
728
- ### 3. Error Handling
729
- ```javascript
730
- const html = tpl.view("./index.html", data);
731
- if (tpl.error) {
732
- console.error("Rendering error:", tpl.error);
733
- }
734
- ```
735
-
736
- ### 4. Character Escaping
737
- ```javascript
738
- // Automatically escape special characters
739
- const escaped = tpl.escape("<div>${content}</div>");
740
- // Output: <div>·$·{content}</div>
741
-
742
- // Restore escaped characters
743
- const restored = tpl.escape(escaped, true);
744
- // Output: <div>${content}</div>
745
- ```
746
-
747
- ### 5. Global Static Properties
748
-
749
- The module mounts no global variables; state shared across instances lives on static properties of the main class:
750
-
751
- ```javascript
752
- // Global hook singleton; hooks added here affect every instance
753
- Tpl.global_hook.addAction('header', function (m) { return '<header>Header</header>'; });
754
-
755
- // Global data bag, injected into the model's globalBag field at render time
756
- Tpl.global_bag.siteName = 'My Website';
757
-
758
- // Global language dictionary, used as a deep-copied fallback when both lang_dir and langs are empty
759
- Tpl.global_dict = { zh_cn: { name: 'MM Girl' }, en: { name: 'MM' } };
760
- ```
761
-
762
- ### 6. Using Styles and Languages Across Users
763
-
764
- The theme is a deployment-level setting while the style and language are chosen by each user. There are two ways to use them, chosen by whether instance-level state is needed:
765
-
766
- | | Passing options | Deriving instances |
767
- | --- | --- | --- |
768
- | Usage | `tpl.view(file, model, { style, lang })` | `tpl.fork({ style, lang })` |
769
- | Instances | 1 | one per derived instance |
770
- | Fits | most cases, no instance management | needs instance-level state such as `viewBag` |
771
-
772
- #### Option 1: Pass It on Each Render (Recommended)
773
-
774
- A single instance can serve every user; each render receives the style and language through `options`, and the instance state is restored afterwards:
775
-
776
- ```javascript
777
- const tpl = new Tpl({
778
- root: './app/shop/',
779
- default_theme: 'game', // theme fixed at deployment level, users do not pick it
780
- lang_dir: './static/lang/',
781
- cache_dims: ['style', 'lang'] // users are grouped by style + language
782
- });
783
-
784
- // User A: dark style + English
785
- tpl.view('./index.html', { title: 'Home' }, { style: 'dark', lang: 'en_us', cache: true });
786
-
787
- // User B: no style + Chinese
788
- tpl.view('./index.html', { title: '首页' }, { lang: 'zh_cn', cache: true });
789
-
790
- tpl.getStyle(); // "", unaffected by the two renders above
791
- tpl.getLang(); // "zh_cn"
792
- ```
793
-
794
- User preferences (a `user_id` → `{style, lang}` mapping) are kept by a store object, exposed on the instance as `store`. The module ships an in-memory store `Memory` and a SQLite store `Sqlite`:
795
-
796
- ```javascript
797
- tpl.setUser(req.user_id, { style: 'dark', lang: 'en_us' });
798
-
799
- // A registered user renders with the mapped style and language;
800
- // an unregistered user falls back to the instance defaults
801
- tpl.view('./index.html', { title: 'Home' }, tpl.userOptions(req.user_id, { cache: true }));
802
- ```
803
-
804
- See [User Preference Store](#user-preference-store) for the full API.
805
-
806
- Cache paths and language follow the passed options, and the cache path is layered by the combination too; template lookup follows the instance theme, since templates are not layered by style:
807
-
808
- ```javascript
809
- // getCacheFile accepts the same options, handy for inspecting or pre-generating
810
- tpl.getCacheFile({ cache_filename: './index', style: 'dark', lang: 'en_us' });
811
- // cache/dark/en_us/index.cache.html
812
- ```
813
-
814
- `view` renders synchronously: the options are applied on entry and restored on exit, so it is **safe under concurrent requests** and requests never mix.
815
-
816
- #### Option 2: Derive Instances
817
-
818
- When instance-level state is needed (for example a template reading `${viewBag.xxx}`), derive instances with `fork`. Deriving by "style + language" lets instances be reused, with the count equal to the number of combinations:
819
-
820
- ```javascript
821
- const pool = new Map();
822
-
823
- /**
824
- * Get the instance for a style and language
825
- * @param {string} style - Style, an empty string means none selected
826
- * @param {string} lang - Language
827
- * @returns {Tpl} Template instance of that combination
828
- */
829
- function getInstance(style, lang) {
830
- const pool_key = [style || '', lang].join('|');
831
- if (!pool.has(pool_key)) {
832
- pool.set(pool_key, tpl.fork({ style: style, lang: lang }));
833
- }
834
- return pool.get(pool_key);
835
- }
836
-
837
- // user id → style+language mapping, kept in the built-in mapping table
838
- // (or maintained by the upper layer if you prefer)
839
- tpl.setUser(1001, { style: 'dark', lang: 'en_us' });
840
- const pref = tpl.getUser(1001);
841
- const instance = getInstance(pref.style, pref.lang);
842
- const html = instance.view('./index.html', { title: 'Home' }, { cache: true });
843
- ```
844
-
845
- The instance count is decoupled from the user count and equals the number of combinations actually used; users in the same combination share one instance, so the template lookup result and the cache file are identical.
846
-
847
- When each user needs a private `viewBag`, derive per user instead (the instance count then equals the user count):
848
-
849
- ```javascript
850
- const user_inst = tpl.fork({
851
- user_id: req.user_id,
852
- style: pref.style,
853
- lang: pref.lang
854
- });
855
- ```
856
-
527
+ static/template/game/lang/en_us.json ← English of the game theme, overrides the same keys
528
+ ```
529
+
530
+ ```json
531
+ // static/lang/zh_cn.json
532
+ {
533
+ "hello": "你好,{0}!",
534
+ "theme": { "game": "游戏主题" },
535
+ "bar": { "nav": "导航栏", "user_id": "用户ID" }
536
+ }
537
+ ```
538
+
539
+ ```json
540
+ // static/template/game/lang/zh_cn.json: only the keys to override
541
+ {
542
+ "bar": { "nav": "游戏导航" }
543
+ }
544
+ ```
545
+
546
+ ```html
547
+ <!-- static/template/game/test.html: templates only resolve entries -->
548
+ <div>${$t('theme.game')}</div>
549
+ <!-- zh_cn output: <div>游戏主题</div> -->
550
+ <!-- en_us output: <div>Game Theme</div> -->
551
+ ```
552
+
553
+ Language packs are merged in the following order, each overriding the previous one: application level `lang_dir/{lang}.json` → default theme `{theme_dir}/{default theme}/{theme_lang_dir}/{lang}.json` → current theme `{theme_dir}/{current theme}/{theme_lang_dir}/{lang}.json` → the `langs` option. Entries of the same language are merged by key, and higher priority overrides same-named keys, so the same template can produce different copy per theme.
554
+
555
+ Entries are resolved in this order: current language → default language. When both miss, an error is logged and `undefined` is returned, rendering as an empty string. To get started quickly you may also write copy directly in templates and extract it into language packs later.
556
+
557
+ Language pack files are read when an instance is created and then cached by a file fingerprint (path + mtime): while the files are unchanged, new instances and `fork` reuse the parsed result; after a file is added, edited or removed, the next read reloads automatically. Language packs of already created instances are not refreshed automatically — call `reloadLangs()` for that.
558
+
559
+ ```javascript
560
+ tpl.getLangs(); // ['zh_cn', 'en_us']; languages without entries are filtered out
561
+ tpl.reloadLangs(); // Reload by file fingerprint, keeping the current language; returns the language list
562
+ tpl.reloadLangs(true); // Skip the cache and force re-reading the language files
563
+ tpl.loadLang('en_us'); // Reload and return the entries of one language
564
+ ```
565
+
566
+ Switch at runtime (only affects the current instance). Style and language are user-selectable; the theme is a deployment-level setting that users should not switch at runtime:
567
+
568
+ ```javascript
569
+ tpl_b.setStyle('light'); // Returns the applied style name
570
+ tpl_b.setLang('zh_cn'); // Returns the applied language code
571
+ ```
572
+
573
+ Theme and style setters validate that the directory exists. When it does not, a warning is logged and the **previous value is kept**, so a user never believes the switch succeeded while the default value is still in use:
574
+
575
+ ```javascript
576
+ // There is no not_exist theme or style directory under static/template
577
+ tpl_b.setStyle('not_exist'); // Returns the old style "dark" and logs a warning
578
+ tpl_b.setTheme('not_exist'); // Returns the old theme "game" and logs a warning (theme validation works the same way)
579
+
580
+ // Skip validation when the directory has not been created yet
581
+ tpl_b.setStyle('not_exist', true); // Returns "not_exist"
582
+
583
+ // An empty string means no style is set; lookup and cache layering fall back to the default style
584
+ tpl_b.setStyle('');
585
+ ```
586
+
587
+ The cache is layered by the dimensions in `cache_dims`, and users are grouped by "style + language" so that users in the same combination share the cache:
588
+
589
+ ```
590
+ cache/zh_cn/test.cache.html ← user A (no style + Chinese)
591
+ cache/en_us/test.cache.html ← user A switched to English
592
+ cache/dark/zh_cn/test.cache.html ← user B (dark style + Chinese)
593
+ ```
594
+
595
+ ```javascript
596
+ // Get the cache file path of the current instance
597
+ tpl_a.getCacheFile({ cache_filename: './test' });
598
+ ```
599
+
600
+ Cache files are grouped by "style + language", so users sharing a combination share the same cache file and the count is independent of the user count — see [Cache Count Is Driven by cache_dims](#cache-count-is-driven-by-cache_dims).
601
+
602
+ ## Advanced Features
603
+
604
+ ### 1. Theme, Style and Directory Support
605
+ ```javascript
606
+ // Set current style (a variant inside a theme, user-selectable; keeps the old style and logs a warning when missing)
607
+ tpl.setStyle("dark");
608
+ // Clear the style
609
+ tpl.setStyle("");
610
+
611
+ // Set current theme (a deployment-level setting users should not switch at runtime)
612
+ // Keeps the old theme and logs a warning when the directory is missing; clear the cache after switching,
613
+ // see "Theme Does Not Participate in Cache Layering"
614
+ tpl.setTheme("default");
615
+ // Skip directory validation and set anyway
616
+ tpl.setTheme("default", true);
617
+
618
+ // Or assign directly (no validation)
619
+ tpl.current_theme = "default";
620
+ tpl.current_style = "dark";
621
+
622
+ // Template directory search order (priority from high to low; {dir} is this.dir, the application root):
623
+ // 1. {dir}/static/diy/ user override directory
624
+ // 2. {dir}/static/template/{current theme}/ templates organised by feature directly under the theme directory
625
+ // 3. {dir}/static/template/{default theme}/
626
+ //
627
+ // Templates are layered by theme only, never by style: a style is a visual variant and must not change the HTML structure,
628
+ // so switching styles keeps resolving the very same template file
629
+ // The path is relative to the theme directory and may include any feature directory, e.g. ./index.html, ./article/list.html, ./article/detail.html
630
+ // The extension is appended automatically when omitted (extname, '.html' by default), so
631
+ // view('./article/list') and view('./article/list.html') are equivalent
632
+ // Templates override by filename, and the highest-priority file wins (getFile); the style takes no part in this lookup
633
+ // A style that needs a different look changes css; different copy or structure is handled with model variables
634
+
635
+ // Component directory search order (priority from high to low; components use a chain of their own,
636
+ // isomorphic to the template chain with one extra fixed component/ prefix on every level):
637
+ // 1. {dir}/static/diy/component/ user override directory
638
+ // 2. {dir}/static/template/{current theme}/component/
639
+ // 3. {dir}/static/template/{default theme}/component/
640
+ //
641
+ // A component name is resolved relative to the component directories, not relative to the calling file,
642
+ // so the same name resolves identically wherever it is used
643
+ // Names may include sub-directories such as article/card, and the extension is appended automatically (getComponentFile)
644
+ // A component missing from the current theme falls back to the default theme's component directory,
645
+ // and static/diy/component/ overrides every theme globally
646
+ // Components are not cached by default; pass cache and cache_filename explicitly through the options when needed
647
+
648
+ // Style directory search order (priority from high to low): styles are layered by theme and by style,
649
+ // so they carry two more levels (the current style and the base style) than templates
650
+ // 1. {dir}/static/diy/ user override directory
651
+ // 2. {dir}/static/template/{current theme}/style/{current style}/
652
+ // 3. {dir}/static/template/{current theme}/style/{base style}/
653
+ // 4. {dir}/static/template/{default theme}/style/{current style}/
654
+ // 5. {dir}/static/template/{default theme}/style/{base style}/
655
+ //
656
+ // Only the theme falls back, never the style: files of other style directories are not inherited
657
+ // Shared styles belong in the base style directory (base_style, 'base' by default),
658
+ // each style directory only holds its own differences
659
+ // The style directory holds css only and never contains templates
660
+ // Styles collect every css of the directories above, keeping only the highest-priority copy per filename (getCssFiles)
661
+ // With no style set that level uses the default style; when the default style is empty too, the style directory of the theme itself is used
662
+
663
+ // Script directory search order (priority from high to low): layered by theme only, no style or base style level
664
+ // 1. {dir}/static/diy/ user override directory
665
+ // 2. {dir}/static/template/{current theme}/js/
666
+ // 3. {dir}/static/template/{default theme}/js/
667
+ // 4. {dir}/static/js/ application-level shared scripts
668
+ //
669
+ // Scripts are not layered by style: a style is a visual variant, scripts do not change with colour or lightness,
670
+ // so switching styles should not switch scripts
671
+ // Shared scripts belong in the application-level static/js/, and keep being provided there when the theme changes
672
+
673
+ // Style files are returned by ascending priority: the base style comes first as the foundation,
674
+ // the current style overrides after it (a later-loaded rule is the one that takes effect)
675
+ tpl.getCssFiles(); // ['.../static/template/default/style/base/common.css', '.../static/template/default/style/light/light.css', ...]
676
+
677
+ // Scripts are ascending too: application-level shared scripts come first, theme scripts come after (dependency order then holds)
678
+ tpl.getJsFiles(); // ['.../static/js/utils.js', '.../static/template/default/js/app.js', ...]
679
+
680
+ // Single style file: resolved along the same chain, returns its full path or null
681
+ tpl.getCssFile('common.css'); // hits the base style common.css
682
+ tpl.getCssFile('dark.css', 'default', 'dark'); // explicitly read another theme and style
683
+
684
+ // Names support * wildcards: *suffix matches the end, prefix* the start, *infix* anywhere
685
+ tpl.getCssFile('*.css'); // returns an array, same as getCssFiles()
686
+ tpl.matchCssFiles('light*'); // array: only styles named light*
687
+ tpl.matchCssFiles('*common*'); // array: shared styles across directories
688
+ // In templates:
689
+ // <link rel="stylesheet" href="${$css('common.css')}"> exact reference
690
+ // ${each $css('*.css') value}<link href="${value}">${/each} wildcard batch
691
+ // How many to include and in which order is up to the template; the engine only returns paths
692
+
693
+ // Scripts work the same way as styles, just without the style argument; names also support * wildcards
694
+ tpl.getJsFile('utils.js'); // hits the application-level shared script
695
+ tpl.getJsFile('app*'); // an array when a wildcard is used, as above
696
+ tpl.getJsFile('promo.js', 'festival'); // explicitly read a script of one theme, null when absent
697
+ tpl.matchJsFiles('*.js'); // every script, an array, the order is the execution order
698
+ tpl.matchJsFiles('app*'); // prefix match: only scripts starting with app
699
+ tpl.matchJsFiles('*promo*'); // infix match: same-name family across directories
700
+ tpl.matchJsFiles('nope*'); // no match: an empty array [], not null
701
+ tpl.matchJsFiles('promo*', 'festival'); // specify a theme
702
+ // In templates:
703
+ // <script src="${$js('utils.js')}"></script> exact reference
704
+ // ${each $js('*.js') value}<script src="${value}"></script>${/each} wildcard batch
705
+ // ${each $js('app*') value}<script src="${value}"></script>${/each} group by module prefix
706
+ // ${if $js('chart.js')}<script src="${$js('chart.js')}"></script>${/if} skip when absent, avoiding a 404
707
+ //
708
+ // With the site default theme default and the current theme festival, the hit order of $js('*.js') mirrors the layering:
709
+ // ['utils.js' (application static/js/), 'app.js' (default theme, hit by fallback since festival has none), 'promo.js' (current theme static/template/festival/js/), 'patch.js' (static/diy/)]
710
+
711
+ // Resolved theme, template, style and script directories
712
+ tpl.getThemeDir('default'); // {dir}/static/template/default/
713
+ tpl.getTemplateDirs(); // full paths of the template directories, high to low: static/diy/ → current theme → default theme
714
+ tpl.getComponentDirs(); // full paths of the component directories, high to low: static/diy/component → current theme/component → default theme/component
715
+ tpl.getStyleDir('default', 'dark');// {dir}/static/template/default/style/dark/
716
+ tpl.getStyleDirs(); // full paths of the style directories above in priority order; styles only, not used for templates (duplicates are removed when the current and default theme match)
717
+ tpl.getJsDirs(); // full paths of the script directories, high to low: static/diy/ → current theme → default theme → application level
718
+ ```
719
+
720
+ ### 2. Template Rules
721
+ Built-in support for multiple template rules, including:
722
+ - nativeRule: Native syntax rules
723
+ - artRule: art-template syntax rules
724
+ - htmlRule: HTML comment syntax rules
725
+ - jsRule: JavaScript syntax rules
726
+ - pyRule: Python-style syntax rules
727
+
728
+ ### 3. Error Handling
729
+ ```javascript
730
+ const html = tpl.view("./index.html", data);
731
+ if (tpl.error) {
732
+ console.error("Rendering error:", tpl.error);
733
+ }
734
+ ```
735
+
736
+ ### 4. Character Escaping
737
+ ```javascript
738
+ // Automatically escape special characters
739
+ const escaped = tpl.escape("<div>${content}</div>");
740
+ // Output: <div>·$·{content}</div>
741
+
742
+ // Restore escaped characters
743
+ const restored = tpl.escape(escaped, true);
744
+ // Output: <div>${content}</div>
745
+ ```
746
+
747
+ ### 5. Global Static Properties
748
+
749
+ The module mounts no global variables; state shared across instances lives on static properties of the main class:
750
+
751
+ ```javascript
752
+ // Global hook singleton; hooks added here affect every instance
753
+ Tpl.global_hook.addAction('header', function (m) { return '<header>Header</header>'; });
754
+
755
+ // Global data bag, injected into the model's globalBag field at render time
756
+ Tpl.global_bag.siteName = 'My Website';
757
+
758
+ // Global language dictionary, used as a deep-copied fallback when both lang_dir and langs are empty
759
+ Tpl.global_dict = { zh_cn: { name: 'MM Girl' }, en: { name: 'MM' } };
760
+ ```
761
+
762
+ ### 6. Using Styles and Languages Across Users
763
+
764
+ The theme is a deployment-level setting while the style and language are chosen by each user. There are two ways to use them, chosen by whether instance-level state is needed:
765
+
766
+ | | Passing options | Deriving instances |
767
+ | --- | --- | --- |
768
+ | Usage | `tpl.view(file, model, { style, lang })` | `tpl.fork({ style, lang })` |
769
+ | Instances | 1 | one per derived instance |
770
+ | Fits | most cases, no instance management | needs instance-level state such as `viewBag` |
771
+
772
+ #### Option 1: Pass It on Each Render (Recommended)
773
+
774
+ A single instance can serve every user; each render receives the style and language through `options`, and the instance state is restored afterwards:
775
+
776
+ ```javascript
777
+ const tpl = new Tpl({
778
+ root: './app/shop/',
779
+ default_theme: 'game', // theme fixed at deployment level, users do not pick it
780
+ lang_dir: './static/lang/',
781
+ cache_dims: ['style', 'lang'] // users are grouped by style + language
782
+ });
783
+
784
+ // User A: dark style + English
785
+ tpl.view('./index.html', { title: 'Home' }, { style: 'dark', lang: 'en_us', cache: true });
786
+
787
+ // User B: no style + Chinese
788
+ tpl.view('./index.html', { title: '首页' }, { lang: 'zh_cn', cache: true });
789
+
790
+ tpl.getStyle(); // "", unaffected by the two renders above
791
+ tpl.getLang(); // "zh_cn"
792
+ ```
793
+
794
+ User preferences (a `user_id` → `{style, lang}` mapping) are kept by a store object, exposed on the instance as `store`. The module ships an in-memory store `Memory` and a SQLite store `Sqlite`:
795
+
796
+ ```javascript
797
+ tpl.setUser(req.user_id, { style: 'dark', lang: 'en_us' });
798
+
799
+ // A registered user renders with the mapped style and language;
800
+ // an unregistered user falls back to the instance defaults
801
+ tpl.view('./index.html', { title: 'Home' }, tpl.userOptions(req.user_id, { cache: true }));
802
+ ```
803
+
804
+ See [User Preference Store](#user-preference-store) for the full API.
805
+
806
+ Cache paths and language follow the passed options, and the cache path is layered by the combination too; template lookup follows the instance theme, since templates are not layered by style:
807
+
808
+ ```javascript
809
+ // getCacheFile accepts the same options, handy for inspecting or pre-generating
810
+ tpl.getCacheFile({ cache_filename: './index', style: 'dark', lang: 'en_us' });
811
+ // cache/dark/en_us/index.cache.html
812
+ ```
813
+
814
+ `view` renders synchronously: the options are applied on entry and restored on exit, so it is **safe under concurrent requests** and requests never mix.
815
+
816
+ #### Option 2: Derive Instances
817
+
818
+ When instance-level state is needed (for example a template reading `${viewBag.xxx}`), derive instances with `fork`. Deriving by "style + language" lets instances be reused, with the count equal to the number of combinations:
819
+
820
+ ```javascript
821
+ const pool = new Map();
822
+
823
+ /**
824
+ * Get the instance for a style and language
825
+ * @param {string} style - Style, an empty string means none selected
826
+ * @param {string} lang - Language
827
+ * @returns {Tpl} Template instance of that combination
828
+ */
829
+ function getInstance(style, lang) {
830
+ const pool_key = [style || '', lang].join('|');
831
+ if (!pool.has(pool_key)) {
832
+ pool.set(pool_key, tpl.fork({ style: style, lang: lang }));
833
+ }
834
+ return pool.get(pool_key);
835
+ }
836
+
837
+ // user id → style+language mapping, kept in the built-in mapping table
838
+ // (or maintained by the upper layer if you prefer)
839
+ tpl.setUser(1001, { style: 'dark', lang: 'en_us' });
840
+ const pref = tpl.getUser(1001);
841
+ const instance = getInstance(pref.style, pref.lang);
842
+ const html = instance.view('./index.html', { title: 'Home' }, { cache: true });
843
+ ```
844
+
845
+ The instance count is decoupled from the user count and equals the number of combinations actually used; users in the same combination share one instance, so the template lookup result and the cache file are identical.
846
+
847
+ When each user needs a private `viewBag`, derive per user instead (the instance count then equals the user count):
848
+
849
+ ```javascript
850
+ const user_inst = tpl.fork({
851
+ user_id: req.user_id,
852
+ style: pref.style,
853
+ lang: pref.lang
854
+ });
855
+ ```
856
+
857
857
  #### User Preference Store
858
858
 
859
859
  The store maps a `user_id` to the user's style and language. It is exposed on the instance as `store`, and the module exports two built-in implementations, `Memory` and `Sqlite`:
@@ -924,138 +924,138 @@ Conventions to keep in mind:
924
924
  - The in-memory store dies with the process, so inject shared storage (such as `Sqlite`) for multi-process or multi-machine deployments, otherwise each process sees only its own data
925
925
  - `Sqlite` relies on the built-in `node:sqlite` (available since Node 22.5); on older versions the module simply does not export `Sqlite` and everything else keeps working
926
926
 
927
- #### viewBag Is Shared at Instance Level
928
-
929
- `viewBag` lives on the instance and is shared by every request that uses it, so **never write user-level data into `viewBag`**.
930
-
931
- **1. User data must be passed through `model`**
932
-
933
- Data written into `viewBag` is left behind for later requests using the same instance:
934
-
935
- ```javascript
936
- // Wrong: the hook writes request data into viewBag, and later requests on the same instance read it
937
- Tpl.global_hook.addFunc('login', async function (m, req) {
938
- m.user_id = req.user_id; // m is this.viewBag
939
- });
940
-
941
- // Right: pass it through model on each render
942
- tpl.view('./index.html', { user_id: req.user_id, title: 'Home' });
943
- ```
944
-
945
- **2. Templates should read `${user_id}`, not `${viewBag.user_id}`**
946
-
947
- They read from different sources: `model` belongs to the current request while `viewBag` is instance-level and shared:
948
-
949
- ```html
950
- <div>${user_id}</div> <!-- the current request's model, safe -->
951
- <div>${viewBag.user_id}</div> <!-- instance-level shared data, leaks across users -->
952
- ```
953
-
954
- `viewBag` is only suitable for site-level data that is identical for every user (such as the system name). This holds both for passing options and for deriving instances by combination; only when one separate instance is derived per user is `viewBag` private to that user, and only then does the "hook writes into `viewBag`" data flow work.
955
-
956
- #### Why the Cache File Count Stays Bounded
957
-
958
- Styles and languages are finite enumerations and users simply fall into one combination, so the cache file count is independent of the number of users:
959
-
960
- ```
961
- cache files ≤ (named styles + 1) × languages × cached templates
962
- ```
963
-
964
- With 1 named style (dark, plus no style = 2 combinations), 2 languages and 20 templates, the upper bound is 80 files (80 = 2 combinations × 2 languages × 20 templates). Whether the site has 10 users or 10,000, that number does not change, because users merely fall into those combinations; combinations that are never requested produce no file.
965
-
966
- Conversely, a cache must **never hold data that varies per user**: caching data that changes means a user who tops up their balance and refreshes still sees the old value — **that is a correctness problem, not a performance one**.
967
-
968
- So the rule is simple: **when the rendered result depends on user data, full-page caching should not be done at all**. There are three directions instead:
969
-
970
- 1. Let the front end fetch user-specific regions asynchronously, and cache the page body by style and language;
971
- 2. If per-user separation is genuinely required, pass an explicit `cache_filename` (such as `'u_' + user_id + '_index'`), or switch to the in-memory cache (`cache_type: 'memory'`) with a small `cache_max` and a short lifetime;
972
- 3. Do not cache at all.
973
-
974
- #### Dimension Order Determines Clearing Granularity
975
-
976
- The order of `cache_dims` is the directory order, which in turn determines which dimension you can clear in batch. With the default `['style', 'lang']` the directory is `cache/{style}/{lang}/`:
977
-
978
- ```javascript
979
- // Clear by style: removes the cache of every language under the dark style at once
980
- tpl.clearCache('./dark');
981
-
982
- // Users with no style keep their cache under the language directory at the root
983
- tpl.clearCache('./zh_cn');
984
- ```
985
-
986
- ### 7. Template Function Table and Instance-level Hooks
987
-
988
- #### Template Function Table
989
-
990
- Register template functions with `setFunc`; they are injected into the model on every render and can be called by name inside templates. `removeFunc` deletes one and `getFuncs` returns them all:
991
-
992
- ```javascript
993
- tpl.setFunc('$md', function (text) {
994
- return text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
995
- });
996
-
997
- tpl.render('${$md(\'**bold**\')}'); // <b>bold</b>
998
- ```
999
-
1000
- Precedence: **a field of the same name in this call's `model` > built-in functions (`$view` / `$t` / `$css` / `$js` / `$component` / `hookAction` / `hookFilter`) > registered functions** — a registered function can neither override a built-in function nor shadow data passed in with the current request. `fork` copies the table to the derived instance.
1001
-
1002
- #### Instance-level Hooks
1003
-
1004
- `Tpl.global_hook` is a **global singleton**, and hooks added to it affect every instance; `tpl.hook` is an **instance-level hook** affecting only the current instance and the instances derived from it. Both levels run during rendering, **global first, instance-level second**: for action hooks the global output comes first, and for filter hooks the global return value is passed on to the instance level.
1005
-
1006
- ```javascript
1007
- tpl.hook.addAction('header', function (m) { return '<header>instance-level</header>'; });
1008
- ```
1009
-
1010
- ## API Reference
1011
-
1012
- ### Tpl Class Methods
1013
-
1014
- - `constructor(config)` - Constructor, create template instance
1015
- - `setConfig(config)` - Set configuration parameters (rebuilds the language instance)
1016
- - `render(body, model, options)` - Render a template string; `options.style` / `options.lang` apply to this render only
1017
- - `compile(body, options)` - Compile template to function
1018
- - `view(file, model, options)` - Render template file; `options.style` / `options.lang` apply to this render only and are restored afterwards; `options.dir` restricts the lookup to that directory (for templates outside the theme system such as mail or print templates)
1019
- - `getFile(file, options)` - Find a template file by directory priority, returns the full path of the hit; `options.dir` restricts the lookup to that directory
1020
- - `getComponentFile(name, options)` - Find a component file along the component directory chain, returns the full path of the hit; `name` is the component name and may include sub-directories such as `article/card`, with the extension appended from `extname` when omitted; `options.dir` restricts the lookup to that directory
1021
- - `component(name, model, options)` - Render a component and return the rendered string; the component name is resolved relative to the component directories, not relative to the calling file; when the component is missing an empty string is returned and `error` is set (`code: 10000`), and when `component_dir` is empty `code: 10001` is set — neither throws. `$component(name, model?)` in templates is equivalent
1022
- - `getCssFiles()` - Get the style files of the current style as an array of full paths in ascending priority order, keeping only the highest-priority copy per filename; the base style comes first as the foundation, the current style and `static/diy/` override after it
1023
- - `matchCssFiles(pattern, theme, style)` - Match style files by wildcard and return an array of full paths; `pattern` supports `*.css`, `blue*`, `*common*`, and defaults to `*.css`
1024
- - `getCssFile(name, theme, style)` - Find style files by directory priority; returns an array when `name` contains `*` (empty array when nothing matches), otherwise a single path or `null`. `theme` / `style` default to the current values, so styles of another theme or style can be referenced
1025
- - `getJsFiles()` - Get the script files as an array of full paths in ascending priority order, keeping only the highest-priority copy per filename; the application level comes first, the theme level and `static/diy/` after it
1026
- - `matchJsFiles(pattern, theme)` - Match script files by wildcard and return an array of full paths; `pattern` supports `*.js`, `app*`, `*promo*`, and defaults to `*.js`; an empty array when nothing matches
1027
- - `getJsFile(name, theme)` - Find script files by directory priority, returning the full path; returns an array when `name` contains `*`, otherwise a single path or `null` when absent
1028
- - `set(obj)` - Set view data
1029
- - `get(key)` - Get view data
1030
- - `del(keys)` - Delete view data
1031
- - `clearCache(path, file)` - Clear cache; `path` is relative to `cache_root`, omit it to clear only the dimension directory of the current instance, pass `'./'` to clear everything
1032
- - `getCacheFile(options)` - Get the layered cache file path; the argument may be a config object or a cache filename string, and `options.style` / `options.lang` make it compute for that combination
1033
- - `runFunc(name, req, ...args)` - Execute function hooks; returns a Promise. Both global and instance-level hooks run, with the instance-level return value taking precedence
1034
- - `runHook(name, req, ...args)` - Execute function hooks (alias of runFunc)
1035
- - `escape(body, restore)` - Escape/restore characters
1036
- - `setFunc(name, func)` - Register a template function injected into the model on every render for templates to call; built-in functions and fields of the same name in this call's `model` take precedence
1037
- - `removeFunc(name)` - Delete a registered template function, returns whether it existed and was removed
1038
- - `getFuncs()` - Get the template function table
1039
-
1040
- ### Tpl Multi-user Methods
1041
-
1042
- - `fork(config)` - Derive an instance; the user-level parameters are `{style, lang}`, while deployment-level settings (theme, directories, language packs, etc.) are inherited from the master instance; when only style and language are needed, pass them in the `view` options instead of deriving an instance
1043
- - `getTheme()` - Get the current theme
1044
- - `setTheme(theme, force)` - Set the current theme (a deployment-level setting users should not switch at runtime); keeps the old value and logs a warning when the directory is missing, `force: true` skips validation; also warns when `cache_dims` excludes `theme`
1045
- - `getStyle()` - Get the current style, returns an empty string when unset
1046
- - `setStyle(style, force)` - Set the current style (user-selectable); an empty string means no style is set (lookup and cache fall back to the default style); keeps the old value and logs a warning when the directory is missing, `force: true` skips validation
1047
- - `getThemeDir(theme)` - Get the full path of a theme directory
1048
- - `getStyleDir(theme, style)` - Get the full path of a style directory under a theme; when `style` is empty the style directory of that theme is returned
927
+ #### viewBag Is Shared at Instance Level
928
+
929
+ `viewBag` lives on the instance and is shared by every request that uses it, so **never write user-level data into `viewBag`**.
930
+
931
+ **1. User data must be passed through `model`**
932
+
933
+ Data written into `viewBag` is left behind for later requests using the same instance:
934
+
935
+ ```javascript
936
+ // Wrong: the hook writes request data into viewBag, and later requests on the same instance read it
937
+ Tpl.global_hook.addFunc('login', async function (m, req) {
938
+ m.user_id = req.user_id; // m is this.viewBag
939
+ });
940
+
941
+ // Right: pass it through model on each render
942
+ tpl.view('./index.html', { user_id: req.user_id, title: 'Home' });
943
+ ```
944
+
945
+ **2. Templates should read `${user_id}`, not `${viewBag.user_id}`**
946
+
947
+ They read from different sources: `model` belongs to the current request while `viewBag` is instance-level and shared:
948
+
949
+ ```html
950
+ <div>${user_id}</div> <!-- the current request's model, safe -->
951
+ <div>${viewBag.user_id}</div> <!-- instance-level shared data, leaks across users -->
952
+ ```
953
+
954
+ `viewBag` is only suitable for site-level data that is identical for every user (such as the system name). This holds both for passing options and for deriving instances by combination; only when one separate instance is derived per user is `viewBag` private to that user, and only then does the "hook writes into `viewBag`" data flow work.
955
+
956
+ #### Why the Cache File Count Stays Bounded
957
+
958
+ Styles and languages are finite enumerations and users simply fall into one combination, so the cache file count is independent of the number of users:
959
+
960
+ ```
961
+ cache files ≤ (named styles + 1) × languages × cached templates
962
+ ```
963
+
964
+ With 1 named style (dark, plus no style = 2 combinations), 2 languages and 20 templates, the upper bound is 80 files (80 = 2 combinations × 2 languages × 20 templates). Whether the site has 10 users or 10,000, that number does not change, because users merely fall into those combinations; combinations that are never requested produce no file.
965
+
966
+ Conversely, a cache must **never hold data that varies per user**: caching data that changes means a user who tops up their balance and refreshes still sees the old value — **that is a correctness problem, not a performance one**.
967
+
968
+ So the rule is simple: **when the rendered result depends on user data, full-page caching should not be done at all**. There are three directions instead:
969
+
970
+ 1. Let the front end fetch user-specific regions asynchronously, and cache the page body by style and language;
971
+ 2. If per-user separation is genuinely required, pass an explicit `cache_filename` (such as `'u_' + user_id + '_index'`), or switch to the in-memory cache (`cache_type: 'memory'`) with a small `cache_max` and a short lifetime;
972
+ 3. Do not cache at all.
973
+
974
+ #### Dimension Order Determines Clearing Granularity
975
+
976
+ The order of `cache_dims` is the directory order, which in turn determines which dimension you can clear in batch. With the default `['style', 'lang']` the directory is `cache/{style}/{lang}/`:
977
+
978
+ ```javascript
979
+ // Clear by style: removes the cache of every language under the dark style at once
980
+ tpl.clearCache('./dark');
981
+
982
+ // Users with no style keep their cache under the language directory at the root
983
+ tpl.clearCache('./zh_cn');
984
+ ```
985
+
986
+ ### 7. Template Function Table and Instance-level Hooks
987
+
988
+ #### Template Function Table
989
+
990
+ Register template functions with `setFunc`; they are injected into the model on every render and can be called by name inside templates. `removeFunc` deletes one and `getFuncs` returns them all:
991
+
992
+ ```javascript
993
+ tpl.setFunc('$md', function (text) {
994
+ return text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
995
+ });
996
+
997
+ tpl.render('${$md(\'**bold**\')}'); // <b>bold</b>
998
+ ```
999
+
1000
+ Precedence: **a field of the same name in this call's `model` > built-in functions (`$view` / `$t` / `$css` / `$js` / `$component` / `hookAction` / `hookFilter`) > registered functions** — a registered function can neither override a built-in function nor shadow data passed in with the current request. `fork` copies the table to the derived instance.
1001
+
1002
+ #### Instance-level Hooks
1003
+
1004
+ `Tpl.global_hook` is a **global singleton**, and hooks added to it affect every instance; `tpl.hook` is an **instance-level hook** affecting only the current instance and the instances derived from it. Both levels run during rendering, **global first, instance-level second**: for action hooks the global output comes first, and for filter hooks the global return value is passed on to the instance level.
1005
+
1006
+ ```javascript
1007
+ tpl.hook.addAction('header', function (m) { return '<header>instance-level</header>'; });
1008
+ ```
1009
+
1010
+ ## API Reference
1011
+
1012
+ ### Tpl Class Methods
1013
+
1014
+ - `constructor(config)` - Constructor, create template instance
1015
+ - `setConfig(config)` - Set configuration parameters (rebuilds the language instance)
1016
+ - `render(body, model, options)` - Render a template string; `options.style` / `options.lang` apply to this render only
1017
+ - `compile(body, options)` - Compile template to function
1018
+ - `view(file, model, options)` - Render template file; `options.style` / `options.lang` apply to this render only and are restored afterwards; `options.dir` restricts the lookup to that directory (for templates outside the theme system such as mail or print templates)
1019
+ - `getFile(file, options)` - Find a template file by directory priority, returns the full path of the hit; `options.dir` restricts the lookup to that directory
1020
+ - `getComponentFile(name, options)` - Find a component file along the component directory chain, returns the full path of the hit; `name` is the component name and may include sub-directories such as `article/card`, with the extension appended from `extname` when omitted; `options.dir` restricts the lookup to that directory
1021
+ - `component(name, model, options)` - Render a component and return the rendered string; the component name is resolved relative to the component directories, not relative to the calling file; when the component is missing an empty string is returned and `error` is set (`code: 10000`), and when `component_dir` is empty `code: 10001` is set — neither throws. `$component(name, model?)` in templates is equivalent
1022
+ - `getCssFiles()` - Get the style files of the current style as an array of full paths in ascending priority order, keeping only the highest-priority copy per filename; the base style comes first as the foundation, the current style and `static/diy/` override after it
1023
+ - `matchCssFiles(pattern, theme, style)` - Match style files by wildcard and return an array of full paths; `pattern` supports `*.css`, `blue*`, `*common*`, and defaults to `*.css`
1024
+ - `getCssFile(name, theme, style)` - Find style files by directory priority; returns an array when `name` contains `*` (empty array when nothing matches), otherwise a single path or `null`. `theme` / `style` default to the current values, so styles of another theme or style can be referenced
1025
+ - `getJsFiles()` - Get the script files as an array of full paths in ascending priority order, keeping only the highest-priority copy per filename; the application level comes first, the theme level and `static/diy/` after it
1026
+ - `matchJsFiles(pattern, theme)` - Match script files by wildcard and return an array of full paths; `pattern` supports `*.js`, `app*`, `*promo*`, and defaults to `*.js`; an empty array when nothing matches
1027
+ - `getJsFile(name, theme)` - Find script files by directory priority, returning the full path; returns an array when `name` contains `*`, otherwise a single path or `null` when absent
1028
+ - `set(obj)` - Set view data
1029
+ - `get(key)` - Get view data
1030
+ - `del(keys)` - Delete view data
1031
+ - `clearCache(path, file)` - Clear cache; `path` is relative to `cache_root`, omit it to clear only the dimension directory of the current instance, pass `'./'` to clear everything
1032
+ - `getCacheFile(options)` - Get the layered cache file path; the argument may be a config object or a cache filename string, and `options.style` / `options.lang` make it compute for that combination
1033
+ - `runFunc(name, req, ...args)` - Execute function hooks; returns a Promise. Both global and instance-level hooks run, with the instance-level return value taking precedence
1034
+ - `runHook(name, req, ...args)` - Execute function hooks (alias of runFunc)
1035
+ - `escape(body, restore)` - Escape/restore characters
1036
+ - `setFunc(name, func)` - Register a template function injected into the model on every render for templates to call; built-in functions and fields of the same name in this call's `model` take precedence
1037
+ - `removeFunc(name)` - Delete a registered template function, returns whether it existed and was removed
1038
+ - `getFuncs()` - Get the template function table
1039
+
1040
+ ### Tpl Multi-user Methods
1041
+
1042
+ - `fork(config)` - Derive an instance; the user-level parameters are `{style, lang}`, while deployment-level settings (theme, directories, language packs, etc.) are inherited from the master instance; when only style and language are needed, pass them in the `view` options instead of deriving an instance
1043
+ - `getTheme()` - Get the current theme
1044
+ - `setTheme(theme, force)` - Set the current theme (a deployment-level setting users should not switch at runtime); keeps the old value and logs a warning when the directory is missing, `force: true` skips validation; also warns when `cache_dims` excludes `theme`
1045
+ - `getStyle()` - Get the current style, returns an empty string when unset
1046
+ - `setStyle(style, force)` - Set the current style (user-selectable); an empty string means no style is set (lookup and cache fall back to the default style); keeps the old value and logs a warning when the directory is missing, `force: true` skips validation
1047
+ - `getThemeDir(theme)` - Get the full path of a theme directory
1048
+ - `getStyleDir(theme, style)` - Get the full path of a style directory under a theme; when `style` is empty the style directory of that theme is returned
1049
1049
  - `getTemplateDirs(theme)` - Get the template directory list (full paths) in priority order: `static/diy/` → the current theme → the default theme; `theme` defaults to the current value. Templates live directly under the theme directory organised by feature, and are not layered by style
1050
1050
  - `getComponentDirs(theme)` - Get the component directory list (full paths) in priority order: `static/diy/component/`, the current theme's `component/`, the default theme's `component/`; isomorphic to the template chain with one extra fixed `component/` prefix on every level; `theme` defaults to the current value, and an empty `component_dir` yields an empty array
1051
- - `getStyleDirs(theme, style)` - Get the style directory list (full paths) in priority order; styles are layered by theme and by style, so this covers **styles only** (`static/template/{theme}/style/{style}/`, including the style and `base` levels) and is not used for templates; `theme` / `style` default to the current values
1052
- - `getJsDirs(theme)` - Get the script directory list (full paths) in descending priority: `static/diy/`, the current theme, the default theme, the application level
1053
- - `getLang()` - Get the current language code
1054
- - `setLang(lang)` - Set the current language, returns the applied language code
1055
- - `t(key, ...args)` - Get translated text, equivalent to `$t` in templates, supports `a.b` nested keys; a single object argument fills `{name}` placeholders, otherwise `{0}`, `{1}` ... are replaced in order
1056
- - `tc(key, count, ...args)` - Get plural translated text, `{count}` is the count placeholder
1057
- - `getLangs()` - Get the supported language list
1058
- - `reloadLangs(force)` - Reload language packs, keeping the current language; returns the language list. Uses the file fingerprint by default, `force: true` re-reads the files
1051
+ - `getStyleDirs(theme, style)` - Get the style directory list (full paths) in priority order; styles are layered by theme and by style, so this covers **styles only** (`static/template/{theme}/style/{style}/`, including the style and `base` levels) and is not used for templates; `theme` / `style` default to the current values
1052
+ - `getJsDirs(theme)` - Get the script directory list (full paths) in descending priority: `static/diy/`, the current theme, the default theme, the application level
1053
+ - `getLang()` - Get the current language code
1054
+ - `setLang(lang)` - Set the current language, returns the applied language code
1055
+ - `t(key, ...args)` - Get translated text, equivalent to `$t` in templates, supports `a.b` nested keys; a single object argument fills `{name}` placeholders, otherwise `{0}`, `{1}` ... are replaced in order
1056
+ - `tc(key, count, ...args)` - Get plural translated text, `{count}` is the count placeholder
1057
+ - `getLangs()` - Get the supported language list
1058
+ - `reloadLangs(force)` - Reload language packs, keeping the current language; returns the language list. Uses the file fingerprint by default, `force: true` re-reads the files
1059
1059
  - `loadLang(lang)` - Reload and return the entries of one language, `null` when absent
1060
1060
  - `setUser(user_id, prefs)` - Write user preferences; `prefs` is shaped like `{style, lang}` and only those two fields are stored; repeated writes for the same user merge by field and the saved preferences are returned
1061
1061
  - `getUser(user_id)` - Get the preferences of one user, `null` when not registered
@@ -1063,117 +1063,116 @@ tpl.hook.addAction('header', function (m) { return '<header>instance-level</head
1063
1063
  - `getUsers()` - Get the list of registered user ids
1064
1064
  - `userOptions(user_id, options)` - Get render options for a user: a registered user yields the mapped `style` / `lang`, an unregistered user falls back to the style and language of the current instance; the result can be passed straight to `view` / `render`, and `options` may carry other render options
1065
1065
  - `setStore(store)` - Set the user preference store; an empty or incomplete one falls back to the built-in in-memory `Memory`; returns the current instance
1066
- - `getStore()` - Get the user preference store (`Memory` / `Sqlite` or a custom store object)
1067
-
1068
- ### Tpl Instance Properties
1069
-
1070
- - `config` - Configuration object
1071
- - `viewBag` - View data
1072
- - `current_theme` - Current theme
1073
- - `current_style` - Current style
1066
+ - `getStore()` - Get the user preference store (`Memory` / `Sqlite` or a custom store object)
1067
+
1068
+ ### Tpl Instance Properties
1069
+
1070
+ - `config` - Configuration object
1071
+ - `viewBag` - View data
1072
+ - `current_theme` - Current theme
1073
+ - `current_style` - Current style
1074
1074
  - `lang` - Current language instance (`Lang`); the language pack dictionary is shared, while the current language is independent
1075
1075
  - `store` - User preference store (`Memory` or `Sqlite`) mapping a `user_id` to the user's style and language; derived instances share the same store
1076
1076
  - `funcs` - Instance-level template function table, written by `setFunc`
1077
- - `hook` - Instance-level hooks (`Hook`) affecting only the current instance and the instances derived from it; they run together with the global `Tpl.global_hook`, global first
1078
- - `dir` - Application root directory, i.e. `config.root`
1079
- - `error` - Error message
1080
-
1081
- ### Configuration Options
1082
-
1083
- - `root` - Application root directory, defaults to the current working directory; themes, language packs and the override directory are resolved relative to it
1084
- - `theme_dir` - Theme directory, relative to the application root, defaults to `'./static/template/'`
1085
- - `style_dir` - Style directory, relative to the theme directory, defaults to `'./style/'`
1086
- - `component_dir` - Component directory, relative to the theme directory (the sub-directory of the same name under `static/diy/`), defaults to `'./component/'`, holding shared partials reusable by feature pages; empty disables components, so `$component` in templates finds no file
1087
- - `diy_dir` - User override directory, relative to the application root, defaults to `'./static/diy/'`, highest priority
1088
- - `lang_dir` - Application-level language pack directory, relative to the application root, filename is the language code, defaults to `'./static/lang/'`; empty disables file language packs
1089
- - `theme_lang_dir` - Theme-level language pack directory, relative to the theme directory, defaults to `'./lang/'`, overriding the same keys of the application level
1090
- - `js_dir` - Application-level script directory, relative to the application root, defaults to `'./static/js/'`, holding the shared scripts used by every theme
1091
- - `theme_js_dir` - Theme-level script directory, relative to the theme directory, defaults to `'./js/'`, overriding same-named scripts of the application-level directory. Scripts are not layered by style (a style is a visual variant and scripts do not change with colour or lightness), so there are no matching style levels
1092
- - `default_theme` - Default theme, defaults to `'default'`
1077
+ - `hook` - Instance-level hooks (`Hook`) affecting only the current instance and the instances derived from it; they run together with the global `Tpl.global_hook`, global first
1078
+ - `dir` - Application root directory, i.e. `config.root`
1079
+ - `error` - Error message
1080
+
1081
+ ### Configuration Options
1082
+
1083
+ - `root` - Application root directory, defaults to the current working directory; themes, language packs and the override directory are resolved relative to it
1084
+ - `theme_dir` - Theme directory, relative to the application root, defaults to `'./static/template/'`
1085
+ - `style_dir` - Style directory, relative to the theme directory, defaults to `'./style/'`
1086
+ - `component_dir` - Component directory, relative to the theme directory (the sub-directory of the same name under `static/diy/`), defaults to `'./component/'`, holding shared partials reusable by feature pages; empty disables components, so `$component` in templates finds no file
1087
+ - `diy_dir` - User override directory, relative to the application root, defaults to `'./static/diy/'`, highest priority
1088
+ - `lang_dir` - Application-level language pack directory, relative to the application root, filename is the language code, defaults to `'./static/lang/'`; empty disables file language packs
1089
+ - `theme_lang_dir` - Theme-level language pack directory, relative to the theme directory, defaults to `'./lang/'`, overriding the same keys of the application level
1090
+ - `js_dir` - Application-level script directory, relative to the application root, defaults to `'./static/js/'`, holding the shared scripts used by every theme
1091
+ - `theme_js_dir` - Theme-level script directory, relative to the theme directory, defaults to `'./js/'`, overriding same-named scripts of the application-level directory. Scripts are not layered by style (a style is a visual variant and scripts do not change with colour or lightness), so there are no matching style levels
1092
+ - `default_theme` - Default theme, defaults to `'default'`
1093
1093
  - `default_style` - Default style (a variant inside a theme), defaults to `'light'`; empty means no style layering
1094
- - `base_style` - Base style directory name, defaults to `'base'`, acting as the shared stylesheet layer for all styles: it ranks below the current style and above the default theme, so shared styles placed there are reused by every style; the layer is skipped when it equals the current style, and an empty value disables it
1095
- - `default_lang` - Default language code, defaults to `'zh_cn'`
1096
- - `defaults` - Shorthand for the `config.js` file, shaped as `{theme, style, lang}`, expanded into `default_theme` / `default_style` / `default_lang` when the instance is created; an explicit `default_*` wins, and `defaults` is not kept in `config`, so `fork()` never expands it twice
1097
- - `extname` - Extension of template and component files, defaults to `'.html'` and is configurable (e.g. `'.art'`); `getFile`, `getComponentFile`, `view` and `component` append it automatically when the argument omits the extension, so `view('./article/list')` and `view('./article/list.html')` are equivalent
1098
- - `plural_rules` - Plural rules keyed by language code, registering `'en_us'` by default; the built-in language core already ships `zh_cn` and `en` rules, so other language codes are registered here
1094
+ - `base_style` - Base style directory name, defaults to `'base'`, acting as the shared stylesheet layer for all styles: it ranks below the current style and above the default theme, so shared styles placed there are reused by every style; the layer is skipped when it equals the current style, and an empty value disables it
1095
+ - `default_lang` - Default language code, defaults to `'zh_cn'`
1096
+ - `defaults` - Shorthand for the `config.js` file, shaped as `{theme, style, lang}`, expanded into `default_theme` / `default_style` / `default_lang` when the instance is created; an explicit `default_*` wins, and `defaults` is not kept in `config`, so `fork()` never expands it twice
1097
+ - `extname` - Extension of template and component files, defaults to `'.html'` and is configurable (e.g. `'.art'`); `getFile`, `getComponentFile`, `view` and `component` append it automatically when the argument omits the extension, so `view('./article/list')` and `view('./article/list.html')` are equivalent
1098
+ - `plural_rules` - Plural rules keyed by language code, registering `'en_us'` by default; the built-in language core already ships `zh_cn` and `en` rules, so other language codes are registered here
1099
1099
  - `langs` - In-memory language pack dictionary, e.g. `{'zh_cn': {...}, 'en_us': {...}}`, takes precedence over language files; when both it and `lang_dir` are empty, the global `Tpl.global_dict` is used
1100
- - `user_store` - User preference store, `null` by default (an in-process `Memory`); pass an object implementing the six synchronous methods `getUser` / `setUser` / `delUser` / `hasUser` / `listUsers` / `clear` to replace it with a file, a database or a cache service, or use the built-in `Memory` / `Sqlite` directly; derived instances reuse the same store
1101
- - `cache_dims` - Cache layering dimensions, defaults to `['style', 'lang']`; values may be `theme`, `style`, `lang`. The order is the directory order, and it should only contain **user-selectable** dimensions
1102
- - `cache_type` - Cache type, defaults to `'file'` (physical files); `'memory'` keeps the cache in process memory
1103
- - `cache_max` - Max entries of the in-memory cache, defaults to `1000`; the earliest written entry is evicted beyond it, `0` means unlimited
1104
-
1105
- ### Hook Class Methods
1106
-
1107
- - `addFunc(name, func, sort, alias)` - Add function hook
1108
- - `addAction(name, func, sort, alias)` - Add action hook
1109
- - `addFilter(name, func, sort, alias)` - Add filter hook
1110
- - `runFunc(name, model, req, ...args)` - Execute function hook
1111
- - `runAction(name, model, ...args)` - Execute action hook
1112
- - `runFilter(name, model, ret, ...args)` - Execute filter hook
1113
- - `delFunc(name, alias)` - Delete function hook
1114
- - `delAction(name, alias)` - Delete action hook
1115
- - `delFilter(name, alias)` - Delete filter hook
1100
+ - `user_store` - User preference store, `null` by default (an in-process `Memory`); pass an object implementing the six synchronous methods `getUser` / `setUser` / `delUser` / `hasUser` / `listUsers` / `clear` to replace it with a file, a database or a cache service, or use the built-in `Memory` / `Sqlite` directly; derived instances reuse the same store
1101
+ - `cache_dims` - Cache layering dimensions, defaults to `['style', 'lang']`; values may be `theme`, `style`, `lang`. The order is the directory order, and it should only contain **user-selectable** dimensions
1102
+ - `cache_type` - Cache type, defaults to `'file'` (physical files); `'memory'` keeps the cache in process memory
1103
+ - `cache_max` - Max entries of the in-memory cache, defaults to `1000`; the earliest written entry is evicted beyond it, `0` means unlimited
1104
+
1105
+ ### Hook Class Methods
1106
+
1107
+ - `addFunc(name, func, sort, alias)` - Add function hook
1108
+ - `addAction(name, func, sort, alias)` - Add action hook
1109
+ - `addFilter(name, func, sort, alias)` - Add filter hook
1110
+ - `runFunc(name, model, req, ...args)` - Execute function hook
1111
+ - `runAction(name, model, ...args)` - Execute action hook
1112
+ - `runFilter(name, model, ret, ...args)` - Execute filter hook
1113
+ - `delFunc(name, alias)` - Delete function hook
1114
+ - `delAction(name, alias)` - Delete action hook
1115
+ - `delFilter(name, alias)` - Delete filter hook
1116
1116
  - `clear(name)` - Clear hooks
1117
1117
 
1118
- The methods above behave identically on the global `Tpl.global_hook` singleton and on the instance-level `tpl.hook`. They differ in scope: `Tpl.global_hook` affects every instance, while `tpl.hook` affects only the current instance and the instances derived from it; both levels run during rendering, global first and instance-level second.
1119
-
1120
- ## Notes
1121
-
1122
- 1. **Dependencies**: Only `art-template` at runtime; path, language and logging capabilities are built in, with no other external modules or global variables required
1123
- 2. **Directory Permissions**: Ensure template directories have read/write permissions
1124
- 3. **Cache Management**: The cache is layered by `cache_dims`, mind the directory levels when clearing; `clearCache` without a path clears only the cache directory of the current instance, pass `'./'` to clear everything. Template or language pack changes invalidate the cache automatically, so manual clearing is usually unnecessary
1125
- 4. **Hook Priority**: Lower numerical values indicate higher priority and earlier execution
1126
- 5. **Async Processing**: Hook functions support async operations, pay attention to error handling
1127
- 6. **File Search and Output Order**: Each kind of resource has its own directory chain. Templates use **three** levels, `static/diy/` → current theme → default theme, and are **not layered by style**, so a style never changes which template is used; the path is relative to the theme directory and may include feature directories such as `article/list.html`. Components use **three** levels of their own, isomorphic to the template chain with one extra fixed `component/` prefix on every level: `static/diy/component/` → current theme `component/` → default theme `component/`; a component name is not resolved relative to the calling file, and a component missing from the current theme falls back to the default theme. Styles use **five** levels: `static/diy/` → current theme + current style → current theme + base style → default theme + current style → default theme + base style. Scripts use **four** levels: `static/diy/` → current theme → default theme → application-level `static/js/`. For templates the highest-priority same-named file wins, while styles and scripts keep only the highest-priority copy per filename. Styles and scripts are "reference many" resources, so `getCssFiles` / `getJsFiles` and the like return them in **ascending** priority order (lower priority first, higher priority after), because a later-loaded CSS rule is the one that takes effect and the order of JS decides execution; language packs are the opposite, merged in descending priority order
1128
- 7. **Character Escaping**: Automatic handling of template syntax characters to avoid conflicts
1129
- 8. **Multi-user Isolation**: The theme is a deployment-level setting, uniform for every user on the site and not user-selectable; style and language are user-level. Rendering can use **passing options** (a single instance, with `style` / `lang` in the `view` options, restored afterwards) or **deriving instances** (`fork`); see [Using Styles and Languages Across Users](#6-using-styles-and-languages-across-users). **`viewBag` is shared at instance level**: user-level data must be passed through `model` (templates read `${user_id}`) and never written into `viewBag` (templates read `${viewBag.user_id}`); only when one separate instance is derived per user is `viewBag` private to that user. Do not `set` user-level data into a shared instance's `viewBag`, and do not let multiple users share one instance and switch `current_style` / `lang` on it, otherwise they will pollute each other
1130
- 9. **Shared Language Packs**: Language objects already present in the `langs` option are shared across instances and are meant for "picking" only; writing custom entries via `lang.set()` on an instance may pollute other users — deep-copy `langs` after `fork` if you really need to write. Language pack files are re-read into new objects, so they are not affected; the global `Tpl.global_dict` used as a fallback when both `lang_dir` and `langs` are empty is also copied deeply, so writing on an instance never pollutes the global one
1131
- 10. **Cache Count**: Cache files ≤ (named styles + 1) × languages × cached templates, **independent of the number of users**; "no style selected" counts as a distinct combination but occupies no directory level (it only appears when `default_style` is empty too), and combinations that are never requested produce no file. `cache_dims` should only contain **user-selectable** dimensions (style and language by default), since every extra dimension multiplies the count; the accepted values are `theme`, `style` and `lang`, and `user_id` is no longer supported — passing it logs a warning and the dimension is ignored
1132
- 11. **Correctness Risk of Per-user Caching**: When cached content depends on user data, such content is served stale for as long as the cache lives (a user who tops up still sees the old balance). Such pages are not suitable for full-page caching; fetch the user-specific regions asynchronously. When per-user separation is genuinely required, pass an explicit `cache_filename`, or use the in-memory cache with a small `cache_max`
1133
- 12. **Theme Not in Cache Layering**: The theme is a deployment-level setting and is excluded from `cache_dims` by default. Switching the theme at runtime reuses the previous theme's cache and `setTheme` logs a warning; call `clearCache()` after switching, or include `theme` in the dimensions
1134
- 13. **Theme and Style Validation**: `setTheme` / `setStyle` validate the directory by default, keeping the old value and logging a warning when it is missing; pass `force` to skip validation before the directory is created. An empty style string means no style is set, and lookup and cache fall back to the default style
1135
- 14. **Style and Theme Fallback**: `setStyle` only validates whether the style directory exists under the current or default theme, keeping the old value and logging a warning when it is missing; assigning `current_style` directly skips validation. Style lookup **only falls back across themes** (current theme + current style → current theme + base style → default theme + current style → default theme + base style) and **never across styles**, so files of other style directories are not inherited. Put shared styles in the base style directory (`base_style`, `base` by default) and keep only differences in each style directory; when one style needs its own version of a shared file, place a same-named file in that style directory to override the base style. This concerns stylesheets only: templates are not layered by style and always resolve along `static/diy/` → current theme → default theme
1136
- 15. **Translation Lookup**: Template files must call `$t('key')` to emit multilingual content; hard-coded text does not follow the language switch. Keep copy in JSON language packs under the language pack directories (the filename is the language code, e.g. `zh_cn.json`, `en_us.json`) and let templates only resolve entries
1137
- 16. **Language Cache Cost**: While `lang` stays in `cache_dims`, each language keeps its own copy of the cache for the same template. Drop the `lang` dimension if the templates are not actually multilingual
1138
- 17. **Language File Changes**: Language files are read only when an instance is created, and already created instances are not refreshed automatically; new instances and `fork` decide by file fingerprint whether to re-read. Call `reloadLangs()` or `loadLang(lang)` to apply changes to an existing instance. `setTheme` reloads automatically so theme language packs take effect
1139
- 18. **Language Pack Cache**: Language packs are cached in the static property `Tpl.lang_cache` keyed by "directory set + file fingerprint", so `fork` does not re-parse unchanged files. The cache is memory-only and is re-read after a process restart. The fingerprint relies on mtime, so a change within the same time granularity may go unnoticed — use `reloadLangs(true)` to force a refresh
1140
- 19. **Cache Filename**: `cache_filename` is always normalised to end with `cache_extname`, so `./index` and `./index.html` both become `index.cache.html`, which lets `clearCache` match by extension; when `view` receives `cache` without a `cache_filename`, the name is derived from the template path automatically
1141
- 20. **Automatic Invalidation**: The cache is rebuilt when the template file or a language pack file is newer than the cache, judged by modification time. Relying on timestamps means a change within the same time granularity may go unnoticed; language pack changes are not checked when `cache_dims` excludes `lang`
1142
- 21. **In-memory Cache**: With `cache_type: 'memory'` no physical files are produced and entries live in `Tpl.cache_store` with least-recently-used eviction. The cache is cleared on process restart, so in a multi-instance deployment each process keeps its own copy and they do not share invalidation state
1143
- 22. **In-memory and File Caches Are Separate**: The two modes read and write different locations, so switching `cache_type` leaves the old cache behind — call `clearCache()` as needed
1118
+ The methods above behave identically on the global `Tpl.global_hook` singleton and on the instance-level `tpl.hook`. They differ in scope: `Tpl.global_hook` affects every instance, while `tpl.hook` affects only the current instance and the instances derived from it; both levels run during rendering, global first and instance-level second.
1119
+
1120
+ ## Notes
1121
+
1122
+ 1. **Dependencies**: `art-template` and `mm_lang` at runtime (the language instance comes from `mm_lang`'s `Lang`; this module no longer ships its own `BaseLang`); path and logging capabilities are built in, with no other external modules or global variables required
1123
+ 2. **Directory Permissions**: Ensure template directories have read/write permissions
1124
+ 3. **Cache Management**: The cache is layered by `cache_dims`, mind the directory levels when clearing; `clearCache` without a path clears only the cache directory of the current instance, pass `'./'` to clear everything. Template or language pack changes invalidate the cache automatically, so manual clearing is usually unnecessary
1125
+ 4. **Hook Priority**: Lower numerical values indicate higher priority and earlier execution
1126
+ 5. **Async Processing**: Hook functions support async operations, pay attention to error handling
1127
+ 6. **File Search and Output Order**: Each kind of resource has its own directory chain. Templates use **three** levels, `static/diy/` → current theme → default theme, and are **not layered by style**, so a style never changes which template is used; the path is relative to the theme directory and may include feature directories such as `article/list.html`. Components use **three** levels of their own, isomorphic to the template chain with one extra fixed `component/` prefix on every level: `static/diy/component/` → current theme `component/` → default theme `component/`; a component name is not resolved relative to the calling file, and a component missing from the current theme falls back to the default theme. Styles use **five** levels: `static/diy/` → current theme + current style → current theme + base style → default theme + current style → default theme + base style. Scripts use **four** levels: `static/diy/` → current theme → default theme → application-level `static/js/`. For templates the highest-priority same-named file wins, while styles and scripts keep only the highest-priority copy per filename. Styles and scripts are "reference many" resources, so `getCssFiles` / `getJsFiles` and the like return them in **ascending** priority order (lower priority first, higher priority after), because a later-loaded CSS rule is the one that takes effect and the order of JS decides execution; language packs are the opposite, merged in descending priority order
1128
+ 7. **Character Escaping**: Automatic handling of template syntax characters to avoid conflicts
1129
+ 8. **Multi-user Isolation**: The theme is a deployment-level setting, uniform for every user on the site and not user-selectable; style and language are user-level. Rendering can use **passing options** (a single instance, with `style` / `lang` in the `view` options, restored afterwards) or **deriving instances** (`fork`); see [Using Styles and Languages Across Users](#6-using-styles-and-languages-across-users). **`viewBag` is shared at instance level**: user-level data must be passed through `model` (templates read `${user_id}`) and never written into `viewBag` (templates read `${viewBag.user_id}`); only when one separate instance is derived per user is `viewBag` private to that user. Do not `set` user-level data into a shared instance's `viewBag`, and do not let multiple users share one instance and switch `current_style` / `lang` on it, otherwise they will pollute each other
1130
+ 9. **Shared Language Packs**: Language objects already present in the `langs` option are shared across instances and are meant for "picking" only; writing custom entries via `lang.set()` on an instance may pollute other users — deep-copy `langs` after `fork` if you really need to write. Language pack files are re-read into new objects, so they are not affected; the global `Tpl.global_dict` used as a fallback when both `lang_dir` and `langs` are empty is also copied deeply, so writing on an instance never pollutes the global one
1131
+ 10. **Cache Count**: Cache files ≤ (named styles + 1) × languages × cached templates, **independent of the number of users**; "no style selected" counts as a distinct combination but occupies no directory level (it only appears when `default_style` is empty too), and combinations that are never requested produce no file. `cache_dims` should only contain **user-selectable** dimensions (style and language by default), since every extra dimension multiplies the count; the accepted values are `theme`, `style` and `lang`, and `user_id` is no longer supported — passing it logs a warning and the dimension is ignored
1132
+ 11. **Correctness Risk of Per-user Caching**: When cached content depends on user data, such content is served stale for as long as the cache lives (a user who tops up still sees the old balance). Such pages are not suitable for full-page caching; fetch the user-specific regions asynchronously. When per-user separation is genuinely required, pass an explicit `cache_filename`, or use the in-memory cache with a small `cache_max`
1133
+ 12. **Theme Not in Cache Layering**: The theme is a deployment-level setting and is excluded from `cache_dims` by default. Switching the theme at runtime reuses the previous theme's cache and `setTheme` logs a warning; call `clearCache()` after switching, or include `theme` in the dimensions
1134
+ 13. **Theme and Style Validation**: `setTheme` / `setStyle` validate the directory by default, keeping the old value and logging a warning when it is missing; pass `force` to skip validation before the directory is created. An empty style string means no style is set, and lookup and cache fall back to the default style
1135
+ 14. **Style and Theme Fallback**: `setStyle` only validates whether the style directory exists under the current or default theme, keeping the old value and logging a warning when it is missing; assigning `current_style` directly skips validation. Style lookup **only falls back across themes** (current theme + current style → current theme + base style → default theme + current style → default theme + base style) and **never across styles**, so files of other style directories are not inherited. Put shared styles in the base style directory (`base_style`, `base` by default) and keep only differences in each style directory; when one style needs its own version of a shared file, place a same-named file in that style directory to override the base style. This concerns stylesheets only: templates are not layered by style and always resolve along `static/diy/` → current theme → default theme
1136
+ 15. **Translation Lookup**: Template files must call `$t('key')` to emit multilingual content; hard-coded text does not follow the language switch. Keep copy in JSON language packs under the language pack directories (the filename is the language code, e.g. `zh_cn.json`, `en_us.json`) and let templates only resolve entries
1137
+ 16. **Language Cache Cost**: While `lang` stays in `cache_dims`, each language keeps its own copy of the cache for the same template. Drop the `lang` dimension if the templates are not actually multilingual
1138
+ 17. **Language File Changes**: Language files are read only when an instance is created, and already created instances are not refreshed automatically; new instances and `fork` decide by file fingerprint whether to re-read. Call `reloadLangs()` or `loadLang(lang)` to apply changes to an existing instance. `setTheme` reloads automatically so theme language packs take effect
1139
+ 18. **Language Pack Cache**: Language packs are cached in the static property `Tpl.lang_cache` keyed by "directory set + file fingerprint", so `fork` does not re-parse unchanged files. The cache is memory-only and is re-read after a process restart. The fingerprint relies on mtime, so a change within the same time granularity may go unnoticed — use `reloadLangs(true)` to force a refresh
1140
+ 19. **Cache Filename**: `cache_filename` is always normalised to end with `cache_extname`, so `./index` and `./index.html` both become `index.cache.html`, which lets `clearCache` match by extension; when `view` receives `cache` without a `cache_filename`, the name is derived from the template path automatically
1141
+ 20. **Automatic Invalidation**: The cache is rebuilt when the template file or a language pack file is newer than the cache, judged by modification time. Relying on timestamps means a change within the same time granularity may go unnoticed; language pack changes are not checked when `cache_dims` excludes `lang`
1142
+ 21. **In-memory Cache**: With `cache_type: 'memory'` no physical files are produced and entries live in `Tpl.cache_store` with least-recently-used eviction. The cache is cleared on process restart, so in a multi-instance deployment each process keeps its own copy and they do not share invalidation state
1143
+ 22. **In-memory and File Caches Are Separate**: The two modes read and write different locations, so switching `cache_type` leaves the old cache behind — call `clearCache()` as needed
1144
1144
  23. **Temporary Options of Passing-Render**: `options.style` and `options.lang` on `view` / `render` / `getCacheFile` apply to that call only and are restored afterwards without changing the instance state; `view` renders synchronously, so it is safe under concurrent requests. Keep them distinct from `setStyle` / `setLang`, which change the instance state persistently
1145
1145
  24. **User Preference Store**: `user_store` defaults to an in-process `Memory` and dies with the process; inject shared storage (the built-in `Sqlite`, or an object implementing the six synchronous methods `getUser` / `setUser` / `delUser` / `hasUser` / `listUsers` / `clear`) for multi-process or multi-machine deployments, otherwise each process sees only its own data. The store performs no authorisation, so the caller must ensure `user_id` is trustworthy
1146
1146
  25. **Hook Scope**: `Tpl.global_hook` is a global singleton whose hooks affect every instance, while `tpl.hook` affects only the current instance and the instances derived from it; both levels run during rendering (global first, instance-level second). Template function precedence is "a same-named field in this call's `model` > built-in functions > registered functions"
1147
1147
  26. **Object Literals Inside Expressions**: An object literal inside `${ }` is parsed in full, so `${@$component('bar', { id: 1 })}` and `${$t('welcome', { name: 'Tom' })}` can be written directly without assigning to a variable first. Only **non-nested** literals are supported: nested forms such as `{a:{b:1}}` are not recognised as an expression and are emitted as plain text (no error is thrown) — use the comment-style `<!--{@ }-->` delimiter or assign to a variable instead
1148
- 27. **Component Model and Inheritance**: The `$component` model is merged with the caller's data (`Object.assign`); the caller's data and template helpers such as `$view` / `$css` / `$t` are all passed down to the component, and fields in the passed model override the caller's
1149
-
1150
- ## Development
1151
-
1152
- Module layout:
1153
-
1154
- - `index.js` - Module entry, defines the public main class `Tpl` and exports `Engine` / `Hook` plus the store classes `Memory` / `Sqlite`, mounting no global variables
1155
- - `lib/engine.js` - `Engine`, a single engine instance: rendering, caching, the theme/style/language orchestration and instance derivation (`fork`), plus `getThemeDir` / `getStyleDir` / `getTemplateDirs` / `getComponentDirs` / `getComponentFile` / `getStyleDirs` / `getJsDirs` / `getCssFiles` / `getJsFiles`
1156
- - `lib/template.js` - `Template`, template file lookup: resolves a template under the theme directory by feature path (`static/diy/` → current theme → default theme), and also resolves components along their own directory chain (`getComponent`)
1157
- - `lib/asset.js` - `Asset`, the asset loading base class for styles and scripts: collecting by directory priority, de-duplicating by filename and emitting in ascending priority order
1158
- - `lib/css.js` - `Css`, style file collection (extends `Asset`)
1159
- - `lib/js.js` - `Js`, script file collection (extends `Asset`)
1160
- - `lib/lang.js` - `Lang`, language pack merging and caching
1148
+ 27. **Component Model and Inheritance**: The `$component` model is merged with the caller's data (`Object.assign`); the caller's data and template helpers such as `$view` / `$css` / `$t` are all passed down to the component, and fields in the passed model override the caller's
1149
+
1150
+ ## Development
1151
+
1152
+ Module layout:
1153
+
1154
+ - `index.js` - Module entry, defines the public main class `Tpl` and exports `Engine` / `Hook` plus the store classes `Memory` / `Sqlite`, mounting no global variables
1155
+ - `lib/engine.js` - `Engine`, a single engine instance: rendering, caching, the theme/style/language orchestration and instance derivation (`fork`), plus `getThemeDir` / `getStyleDir` / `getTemplateDirs` / `getComponentDirs` / `getComponentFile` / `getStyleDirs` / `getJsDirs` / `getCssFiles` / `getJsFiles`
1156
+ - `lib/template.js` - `Template`, template file lookup: resolves a template under the theme directory by feature path (`static/diy/` → current theme → default theme), and also resolves components along their own directory chain (`getComponent`)
1157
+ - `lib/asset.js` - `Asset`, the asset loading base class for styles and scripts: collecting by directory priority, de-duplicating by filename and emitting in ascending priority order
1158
+ - `lib/css.js` - `Css`, style file collection (extends `Asset`)
1159
+ - `lib/js.js` - `Js`, script file collection (extends `Asset`)
1160
+ - `lib/lang.js` - `Lang`, language pack merging and caching; the language instance (lookup, fallback, plural, formatting) comes from `mm_lang`'s `Lang`
1161
1161
  - `lib/store.js` - `Memory`, the in-memory store: `user_id` → `{style, lang}`
1162
- - `lib/sqlite.js` - `Sqlite`, the SQLite store: same contract as `Memory`, persisting to a file (relies on `node:sqlite`)
1163
- - `lib/util.js` - `TplUtil`, pure static helper methods (deep merge, deep clone, md5, text between markers, etc.)
1164
- - `lib/base.js` - `Base`, minimal base class: merges static and instance config, logs output
1165
- - `lib/file.js` - `FileUtil`, built-in path and file utilities: full-path resolution, directory/file checks, file reading
1166
- - `lib/base_lang.js` - `BaseLang`, built-in language core: word lookup, plural rules, date/number formatting
1167
- - `lib/hook.js`, `lib/tpl_rule.js` - hooks and template syntax rules
1168
-
1169
- ```bash
1170
- # Run tests
1171
- npm test
1172
-
1173
- # Check code coverage
1174
- npm run coverage
1175
- ```
1176
-
1177
- ## License
1178
-
1162
+ - `lib/sqlite.js` - `Sqlite`, the SQLite store: same contract as `Memory`, persisting to a file (relies on `node:sqlite`)
1163
+ - `lib/util.js` - `TplUtil`, pure static helper methods (deep merge, deep clone, md5, text between markers, etc.)
1164
+ - `lib/base.js` - `Base`, minimal base class: merges static and instance config, logs output
1165
+ - `lib/file.js` - `FileUtil`, built-in path and file utilities: full-path resolution, directory/file checks, file reading
1166
+ - `lib/hook.js`, `lib/tpl_rule.js` - hooks and template syntax rules
1167
+
1168
+ ```bash
1169
+ # Run tests
1170
+ npm test
1171
+
1172
+ # Check code coverage
1173
+ npm run coverage
1174
+ ```
1175
+
1176
+ ## License
1177
+
1179
1178
  MIT License