mm_tpl 2.7.0 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README_EN.md CHANGED
@@ -19,10 +19,15 @@ Super Meimei Template Engine Module, based on art-template engine, provides powe
19
19
 
20
20
  - 🚀 High-performance template engine based on art-template
21
21
  - 🔧 Support for multiple template syntax rules
22
- - 🎨 Theme switching and template file priority search
23
- - ⚡ Built-in caching mechanism for performance optimization
24
- - 🔌 Powerful hook system support
25
- - 📁 Flexible directory structure support
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
+ - 🔌 Powerful hook system with both global and instance-level hooks
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
26
31
  - 🛡️ Comprehensive error handling mechanism
27
32
 
28
33
  ## Installation
@@ -44,16 +49,57 @@ npm install mm_tpl
44
49
  ```javascript
45
50
  const {Tpl} = require('mm_tpl');
46
51
 
52
+ // Directory layout under the application root (see doc/目录结构.md):
53
+ // app/shop/
54
+ // config.js # { defaults: { theme, style, lang } }
55
+ // static/ # static resource root: templates, styles, scripts, language packs and the override directory all live here
56
+ // diy/ # user override directory, highest priority, may hold templates, components and scripts
57
+ // component/ # components overriding every theme
58
+ // js/ # application-level scripts, shared by every theme
59
+ // lang/ # application-level language packs, filename is the language code
60
+ // template/{theme}/ # templates sit directly under the theme directory, organised by feature
61
+ // index.html # the home page template of the theme
62
+ // article/list.html # feature directory: one directory per feature, at any depth
63
+ // component/ # components: shared partials reusable by feature pages
64
+ // bar.html
65
+ // js/ # theme-level scripts, overriding same-named application-level scripts
66
+ // lang/ # theme-level language packs, overriding the application level
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
+
47
70
  // Create template instance
48
71
  const tpl = new Tpl({
49
- root: process.cwd(), // Root directory
50
- default_dir: "./template/", // Default template directory
51
- default_theme: 'default', // Default theme
52
- extname: ".html", // Template file extension
53
- cache_root: './cache', // Cache directory
54
- cache_extname: '.cache.html', // Cache file extension
55
- rules: [nativeRule, artRule, htmlRule, jsRule, pyRule, pyRule2] // Template rules
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
+ 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
56
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
57
103
  ```
58
104
 
59
105
  ### 3. Basic Template Rendering
@@ -137,8 +183,9 @@ tpl.set({
137
183
  keywords: "keywords"
138
184
  });
139
185
 
140
- // Render view file
141
- const html = tpl.view("./views/index.html", {
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", {
142
189
  user: { name: "John" }
143
190
  });
144
191
  // Output: Rendering result <div>Hello World</div>
@@ -150,23 +197,166 @@ const title = tpl.get("title");
150
197
  tpl.del("keywords");
151
198
  ```
152
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
+
153
238
  ### 6. Caching Mechanism
154
239
 
155
240
  ```javascript
156
- // Use cache for view rendering
157
- const html = tpl.view("./views/page", {
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", {
158
243
  data: someData
159
244
  }, {
160
- cache: true, // Whether to use cache
161
- cache_filename: "./cache" // Cache filename
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']
162
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:
163
336
 
164
- // Clear cache
165
- tpl.clearCache('./views/mall'); // Clear cache for specific directory
166
- // or
167
- tpl.clearCache(); // Clear all cache
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
+ });
168
354
  ```
169
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
+
170
360
  ### 7. Hook System
171
361
 
172
362
  #### Add Function Hook
@@ -223,17 +413,308 @@ $.hook.delFilter('content', 'contentFilter');
223
413
  $.hook.clear();
224
414
  ```
225
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 `Lang` class of mm_expand 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
+ static/lang/zh_cn.json ← application-level Chinese
525
+ static/lang/en_us.json ← application-level English
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
+
226
602
  ## Advanced Features
227
603
 
228
- ### 1. Theme and Directory Support
604
+ ### 1. Theme, Style and Directory Support
229
605
  ```javascript
230
- // Set current theme
231
- tpl.current_theme = "dark";
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/)]
232
710
 
233
- // Template file search order (priority from high to low):
234
- // 1. diy directory (custom directory)
235
- // 2. Current theme directory
236
- // 3. Default theme directory
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
237
718
  ```
238
719
 
239
720
  ### 2. Template Rules
@@ -246,7 +727,7 @@ Built-in support for multiple template rules, including:
246
727
 
247
728
  ### 3. Error Handling
248
729
  ```javascript
249
- const html = tpl.view("./template.html", data);
730
+ const html = tpl.view("./index.html", data);
250
731
  if (tpl.error) {
251
732
  console.error("Rendering error:", tpl.error);
252
733
  }
@@ -272,22 +753,348 @@ if (global.$ && !$.Tpl) {
272
753
  }
273
754
  ```
274
755
 
756
+ ### 6. Using Styles and Languages Across Users
757
+
758
+ 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:
759
+
760
+ | | Passing options | Deriving instances |
761
+ | --- | --- | --- |
762
+ | Usage | `tpl.view(file, model, { style, lang })` | `tpl.fork({ style, lang })` |
763
+ | Instances | 1 | one per derived instance |
764
+ | Fits | most cases, no instance management | needs instance-level state such as `viewBag` |
765
+
766
+ #### Option 1: Pass It on Each Render (Recommended)
767
+
768
+ A single instance can serve every user; each render receives the style and language through `options`, and the instance state is restored afterwards:
769
+
770
+ ```javascript
771
+ const tpl = new Tpl({
772
+ root: './app/shop/',
773
+ default_theme: 'game', // theme fixed at deployment level, users do not pick it
774
+ lang_dir: './static/lang/',
775
+ cache_dims: ['style', 'lang'] // users are grouped by style + language
776
+ });
777
+
778
+ // User A: dark style + English
779
+ tpl.view('./index.html', { title: 'Home' }, { style: 'dark', lang: 'en_us', cache: true });
780
+
781
+ // User B: no style + Chinese
782
+ tpl.view('./index.html', { title: '首页' }, { lang: 'zh_cn', cache: true });
783
+
784
+ tpl.getStyle(); // "", unaffected by the two renders above
785
+ tpl.getLang(); // "zh_cn"
786
+ ```
787
+
788
+ 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`:
789
+
790
+ ```javascript
791
+ tpl.setUser(req.user_id, { style: 'dark', lang: 'en_us' });
792
+
793
+ // A registered user renders with the mapped style and language;
794
+ // an unregistered user falls back to the instance defaults
795
+ tpl.view('./index.html', { title: 'Home' }, tpl.userOptions(req.user_id, { cache: true }));
796
+ ```
797
+
798
+ See [User Preference Store](#user-preference-store) for the full API.
799
+
800
+ 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:
801
+
802
+ ```javascript
803
+ // getCacheFile accepts the same options, handy for inspecting or pre-generating
804
+ tpl.getCacheFile({ cache_filename: './index', style: 'dark', lang: 'en_us' });
805
+ // cache/dark/en_us/index.cache.html
806
+ ```
807
+
808
+ `view` renders synchronously: the options are applied on entry and restored on exit, so it is **safe under concurrent requests** and requests never mix.
809
+
810
+ #### Option 2: Derive Instances
811
+
812
+ 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:
813
+
814
+ ```javascript
815
+ const pool = new Map();
816
+
817
+ /**
818
+ * Get the instance for a style and language
819
+ * @param {string} style - Style, an empty string means none selected
820
+ * @param {string} lang - Language
821
+ * @returns {Tpl} Template instance of that combination
822
+ */
823
+ function getInstance(style, lang) {
824
+ const pool_key = [style || '', lang].join('|');
825
+ if (!pool.has(pool_key)) {
826
+ pool.set(pool_key, tpl.fork({ style: style, lang: lang }));
827
+ }
828
+ return pool.get(pool_key);
829
+ }
830
+
831
+ // user id → style+language mapping, kept in the built-in mapping table
832
+ // (or maintained by the upper layer if you prefer)
833
+ tpl.setUser(1001, { style: 'dark', lang: 'en_us' });
834
+ const pref = tpl.getUser(1001);
835
+ const instance = getInstance(pref.style, pref.lang);
836
+ const html = instance.view('./index.html', { title: 'Home' }, { cache: true });
837
+ ```
838
+
839
+ 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.
840
+
841
+ When each user needs a private `viewBag`, derive per user instead (the instance count then equals the user count):
842
+
843
+ ```javascript
844
+ const user_inst = tpl.fork({
845
+ user_id: req.user_id,
846
+ style: pref.style,
847
+ lang: pref.lang
848
+ });
849
+ ```
850
+
851
+ #### User Preference Store
852
+
853
+ 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`:
854
+
855
+ ```javascript
856
+ const {Tpl, Memory, Sqlite} = require('mm_tpl');
857
+
858
+ const tpl = new Tpl({ user_store: new Memory() }); // in-memory store (default)
859
+ const tpl2 = new Tpl({ user_store: new Sqlite('tpl.db') }); // persisted to a SQLite file
860
+
861
+ // Write: repeated writes for the same user merge by field, and a numeric and a
862
+ // string form of the same user_id are treated as the same user
863
+ tpl.setUser(1001, { style: 'dark', lang: 'en_us' });
864
+ tpl.setUser(1001, { lang: 'zh_cn' }); // only the language changes, the style stays dark
865
+
866
+ tpl.getUser(1001); // { style: 'dark', lang: 'zh_cn' }, or null when not registered
867
+ tpl.getUsers(); // ['1001'], the registered user ids
868
+ tpl.removeUser(1001); // remove, returns whether it existed
869
+
870
+ // Render options: a registered user maps to its style and language,
871
+ // an unregistered user falls back to the style and language of the current instance
872
+ tpl.userOptions(1001); // { style: 'dark', lang: 'zh_cn' }
873
+ tpl.userOptions(9999); // { style: <current style>, lang: <current language> }
874
+ tpl.userOptions(1001, { cache: true }); // other render options can be merged in
875
+ ```
876
+
877
+ A custom store can be injected as well; it only needs the six synchronous methods (the built-in `Memory` / `Sqlite` follow the same contract):
878
+
879
+ ```javascript
880
+ const tpl3 = new Tpl({
881
+ user_store: {
882
+ data: {},
883
+ getUser(user_id) {
884
+ return this.data[user_id] || null;
885
+ },
886
+ setUser(user_id, prefs) {
887
+ this.data[user_id] = Object.assign({}, this.data[user_id], prefs);
888
+ return this.data[user_id];
889
+ },
890
+ delUser(user_id) {
891
+ delete this.data[user_id];
892
+ return true;
893
+ },
894
+ hasUser(user_id) {
895
+ return this.data[user_id] !== undefined;
896
+ },
897
+ listUsers() {
898
+ return Object.keys(this.data);
899
+ },
900
+ clear() {
901
+ this.data = {};
902
+ return this;
903
+ }
904
+ }
905
+ });
906
+
907
+ tpl3.setUser(2001, { style: 'light' }); // write
908
+ tpl3.getStore(); // the store object
909
+ ```
910
+
911
+ `setUser` must merge by field itself and `clear` should return the store for chaining; normalising `user_id` is up to the implementation, while the built-in stores stringify it so that `1001` and `'1001'` are the same user.
912
+
913
+ Conventions to keep in mind:
914
+
915
+ - Only the `style` and `lang` fields are stored; anything else is ignored. The store performs no authorisation, so the caller must ensure `user_id` is trustworthy
916
+ - Derived instances share the same store: preferences belong to users, not to an instance, so `fork` reads and writes the same data (`tpl.getStore()` returns the store object)
917
+ - Storage is injected as an interface and defaults to the in-process `Memory`. Pass an object implementing the six synchronous methods `getUser` / `setUser` / `delUser` / `hasUser` / `listUsers` / `clear` via the `user_store` option to replace it with a file, a database or a cache service, or use the built-in `Memory` / `Sqlite` directly; an incomplete interface logs a warning and falls back to the in-memory store
918
+ - 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
919
+ - `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
920
+
921
+ #### viewBag Is Shared at Instance Level
922
+
923
+ `viewBag` lives on the instance and is shared by every request that uses it, so **never write user-level data into `viewBag`**.
924
+
925
+ **1. User data must be passed through `model`**
926
+
927
+ Data written into `viewBag` is left behind for later requests using the same instance:
928
+
929
+ ```javascript
930
+ // Wrong: the hook writes request data into viewBag, and later requests on the same instance read it
931
+ $.hook.addFunc('login', async function (m, req) {
932
+ m.user_id = req.user_id; // m is this.viewBag
933
+ });
934
+
935
+ // Right: pass it through model on each render
936
+ tpl.view('./index.html', { user_id: req.user_id, title: 'Home' });
937
+ ```
938
+
939
+ **2. Templates should read `${user_id}`, not `${viewBag.user_id}`**
940
+
941
+ They read from different sources: `model` belongs to the current request while `viewBag` is instance-level and shared:
942
+
943
+ ```html
944
+ <div>${user_id}</div> <!-- the current request's model, safe -->
945
+ <div>${viewBag.user_id}</div> <!-- instance-level shared data, leaks across users -->
946
+ ```
947
+
948
+ `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.
949
+
950
+ #### Why the Cache File Count Stays Bounded
951
+
952
+ 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:
953
+
954
+ ```
955
+ cache files ≤ (named styles + 1) × languages × cached templates
956
+ ```
957
+
958
+ 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.
959
+
960
+ 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**.
961
+
962
+ 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:
963
+
964
+ 1. Let the front end fetch user-specific regions asynchronously, and cache the page body by style and language;
965
+ 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;
966
+ 3. Do not cache at all.
967
+
968
+ #### Dimension Order Determines Clearing Granularity
969
+
970
+ 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}/`:
971
+
972
+ ```javascript
973
+ // Clear by style: removes the cache of every language under the dark style at once
974
+ tpl.clearCache('./dark');
975
+
976
+ // Users with no style keep their cache under the language directory at the root
977
+ tpl.clearCache('./zh_cn');
978
+ ```
979
+
980
+ ### 7. Template Function Table and Instance-level Hooks
981
+
982
+ #### Template Function Table
983
+
984
+ 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:
985
+
986
+ ```javascript
987
+ tpl.setFunc('$md', function (text) {
988
+ return text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
989
+ });
990
+
991
+ tpl.render('${$md(\'**bold**\')}'); // <b>bold</b>
992
+ ```
993
+
994
+ 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.
995
+
996
+ #### Instance-level Hooks
997
+
998
+ `$.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.
999
+
1000
+ ```javascript
1001
+ tpl.hook.addAction('header', function (m) { return '<header>instance-level</header>'; });
1002
+ ```
1003
+
275
1004
  ## API Reference
276
1005
 
277
1006
  ### Tpl Class Methods
278
1007
 
279
1008
  - `constructor(config)` - Constructor, create template instance
280
- - `setConfig(config)` - Set configuration parameters
281
- - `render(body, model, options)` - Render template string
1009
+ - `setConfig(config)` - Set configuration parameters (rebuilds the language instance)
1010
+ - `render(body, model, options)` - Render a template string; `options.style` / `options.lang` apply to this render only
282
1011
  - `compile(body, options)` - Compile template to function
283
- - `view(file, model, options)` - Render template file
1012
+ - `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)
1013
+ - `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
1014
+ - `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
1015
+ - `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
1016
+ - `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
1017
+ - `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`
1018
+ - `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
1019
+ - `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
1020
+ - `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
1021
+ - `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
284
1022
  - `set(obj)` - Set view data
285
1023
  - `get(key)` - Get view data
286
1024
  - `del(keys)` - Delete view data
287
- - `clearCache(path, file)` - Clear cache
288
- - `runFunc(name, req, ...args)` - Execute function hook
289
- - `runHook(name, req, ...args)` - Execute action hook (alias of runFunc)
1025
+ - `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
1026
+ - `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
1027
+ - `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
1028
+ - `runHook(name, req, ...args)` - Execute function hooks (alias of runFunc)
290
1029
  - `escape(body, restore)` - Escape/restore characters
1030
+ - `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
1031
+ - `removeFunc(name)` - Delete a registered template function, returns whether it existed and was removed
1032
+ - `getFuncs()` - Get the template function table
1033
+
1034
+ ### Tpl Multi-user Methods
1035
+
1036
+ - `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
1037
+ - `getTheme()` - Get the current theme
1038
+ - `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`
1039
+ - `getStyle()` - Get the current style, returns an empty string when unset
1040
+ - `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
1041
+ - `getThemeDir(theme)` - Get the full path of a theme directory
1042
+ - `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
1043
+ - `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
1044
+ - `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
1045
+ - `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
1046
+ - `getJsDirs(theme)` - Get the script directory list (full paths) in descending priority: `static/diy/`, the current theme, the default theme, the application level
1047
+ - `getLang()` - Get the current language code
1048
+ - `setLang(lang)` - Set the current language, returns the applied language code
1049
+ - `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
1050
+ - `tc(key, count, ...args)` - Get plural translated text, `{count}` is the count placeholder
1051
+ - `getLangs()` - Get the supported language list
1052
+ - `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
1053
+ - `loadLang(lang)` - Reload and return the entries of one language, `null` when absent
1054
+ - `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
1055
+ - `getUser(user_id)` - Get the preferences of one user, `null` when not registered
1056
+ - `removeUser(user_id)` - Remove the preferences of one user, returns whether it existed
1057
+ - `getUsers()` - Get the list of registered user ids
1058
+ - `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
1059
+ - `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
1060
+ - `getStore()` - Get the user preference store (`Memory` / `Sqlite` or a custom store object)
1061
+
1062
+ ### Tpl Instance Properties
1063
+
1064
+ - `config` - Configuration object
1065
+ - `viewBag` - View data
1066
+ - `current_theme` - Current theme
1067
+ - `current_style` - Current style
1068
+ - `lang` - Current language instance (`Lang`); the language pack dictionary is shared, while the current language is independent
1069
+ - `store` - User preference store (`Memory` or `Sqlite`) mapping a `user_id` to the user's style and language; derived instances share the same store
1070
+ - `funcs` - Instance-level template function table, written by `setFunc`
1071
+ - `hook` - Instance-level hooks (`Hook`) affecting only the current instance and the instances derived from it; they run together with the global `$.hook`, global first
1072
+ - `dir` - Application root directory, i.e. `config.root`
1073
+ - `error` - Error message
1074
+
1075
+ ### Configuration Options
1076
+
1077
+ - `root` - Application root directory, defaults to `$.run_path`; themes, language packs and the override directory are resolved relative to it
1078
+ - `theme_dir` - Theme directory, relative to the application root, defaults to `'./static/template/'`
1079
+ - `style_dir` - Style directory, relative to the theme directory, defaults to `'./style/'`
1080
+ - `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
1081
+ - `diy_dir` - User override directory, relative to the application root, defaults to `'./static/diy/'`, highest priority
1082
+ - `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
1083
+ - `theme_lang_dir` - Theme-level language pack directory, relative to the theme directory, defaults to `'./lang/'`, overriding the same keys of the application level
1084
+ - `js_dir` - Application-level script directory, relative to the application root, defaults to `'./static/js/'`, holding the shared scripts used by every theme
1085
+ - `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
1086
+ - `default_theme` - Default theme, defaults to `'default'`
1087
+ - `default_style` - Default style (a variant inside a theme), defaults to `'light'`; empty means no style layering
1088
+ - `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
1089
+ - `default_lang` - Default language code, defaults to `'zh_cn'`
1090
+ - `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
1091
+ - `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
1092
+ - `plural_rules` - Plural rules keyed by language code, registering `'en_us'` by default; the `Lang` class of mm_expand already ships `zh_cn` and `en` rules, so other language codes are registered here
1093
+ - `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 `$.lang.config` is used
1094
+ - `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
1095
+ - `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
1096
+ - `cache_type` - Cache type, defaults to `'file'` (physical files); `'memory'` keeps the cache in process memory
1097
+ - `cache_max` - Max entries of the in-memory cache, defaults to `1000`; the earliest written entry is evicted beyond it, `0` means unlimited
291
1098
 
292
1099
  ### Hook Class Methods
293
1100
 
@@ -300,20 +1107,56 @@ if (global.$ && !$.Tpl) {
300
1107
  - `delFunc(name, alias)` - Delete function hook
301
1108
  - `delAction(name, alias)` - Delete action hook
302
1109
  - `delFilter(name, alias)` - Delete filter hook
303
- - `clear(name)` - Clear hooks
1110
+ - `clear(name)` - Clear hooks
1111
+
1112
+ The methods above behave identically on the global `$.hook` singleton and on the instance-level `tpl.hook`. They differ in scope: `$.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.
304
1113
 
305
1114
  ## Notes
306
1115
 
307
1116
  1. **Dependencies**: Requires `art-template` and `mm_expand` dependency packages
308
1117
  2. **Directory Permissions**: Ensure template directories have read/write permissions
309
- 3. **Cache Management**: Clean expired cache promptly to avoid memory leaks
1118
+ 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
310
1119
  4. **Hook Priority**: Lower numerical values indicate higher priority and earlier execution
311
1120
  5. **Async Processing**: Hook functions support async operations, pay attention to error handling
312
- 6. **File Search**: Template files are searched in priority order, ensure files exist
1121
+ 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
313
1122
  7. **Character Escaping**: Automatic handling of template syntax characters to avoid conflicts
1123
+ 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
1124
+ 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 `$.lang.config` 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
1125
+ 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
1126
+ 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`
1127
+ 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
1128
+ 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
1129
+ 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
1130
+ 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
1131
+ 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
1132
+ 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
1133
+ 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
1134
+ 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
1135
+ 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`
1136
+ 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
1137
+ 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
1138
+ 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
1139
+ 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
1140
+ 25. **Hook Scope**: `$.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"
1141
+ 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
1142
+ 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
314
1143
 
315
1144
  ## Development
316
1145
 
1146
+ Module layout:
1147
+
1148
+ - `index.js` - Module entry, defines the public main class `Tpl` and exports the store classes `Memory` / `Sqlite`
1149
+ - `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`
1150
+ - `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`)
1151
+ - `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
1152
+ - `lib/css.js` - `Css`, style file collection (extends `Asset`)
1153
+ - `lib/js.js` - `Js`, script file collection (extends `Asset`)
1154
+ - `lib/lang.js` - `Lang`, language pack merging and caching
1155
+ - `lib/store.js` - `Memory`, the in-memory store: `user_id` → `{style, lang}`
1156
+ - `lib/sqlite.js` - `Sqlite`, the SQLite store: same contract as `Memory`, persisting to a file (relies on `node:sqlite`)
1157
+ - `lib/util.js` - `TplUtil`, pure static helper methods
1158
+ - `hook.js`, `tpl_rule.js` - hooks and template syntax rules
1159
+
317
1160
  ```bash
318
1161
  # Run tests
319
1162
  npm test