miki-template 1.3.3 → 1.3.7

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.
Files changed (115) hide show
  1. package/.eslintrc.json +16 -0
  2. package/.github/release-notes/v1.3.1.md +55 -55
  3. package/.github/release-notes/v1.3.3.md +77 -0
  4. package/.github/workflows/ci.yml +38 -54
  5. package/.github/workflows/release.yml +106 -0
  6. package/AGENT.md +71 -71
  7. package/API_REFERENCE.md +314 -314
  8. package/CHANGELOG.md +173 -169
  9. package/CODE_OF_CONDUCT.md +14 -14
  10. package/CONTRIBUTING.md +27 -27
  11. package/README.md +342 -321
  12. package/ROADMAP.md +40 -40
  13. package/benchmarks/report.json +16 -16
  14. package/benchmarks/run.js +49 -49
  15. package/benchmarks/stress.mjs +647 -647
  16. package/benchmarks/templates/large.dtpl +7 -7
  17. package/benchmarks/templates/medium.dtpl +3 -3
  18. package/benchmarks/templates/small.dtpl +7 -7
  19. package/context/component.md +109 -109
  20. package/context/prd.md +131 -131
  21. package/context/project-structure.md +33 -33
  22. package/dir/base.html +22 -22
  23. package/dir/cmpnt.html +10 -10
  24. package/dir/footer.html +2 -2
  25. package/dir/home.html +80 -80
  26. package/dir/index.html +80 -0
  27. package/dir/navbar.html +8 -8
  28. package/docs/README.md +18 -18
  29. package/docs/advanced_usage.md +71 -71
  30. package/docs/api.md +119 -119
  31. package/docs/filters.md +708 -708
  32. package/docs/installation.md +106 -106
  33. package/docs/overview.md +79 -57
  34. package/docs/partialdef.md +70 -70
  35. package/docs/security.md +27 -27
  36. package/docs/tags.md +673 -673
  37. package/docs/usage.md +646 -646
  38. package/eslint.config.mjs +42 -42
  39. package/ex.mjs +34 -32
  40. package/live-test/package-lock.json +915 -0
  41. package/live-test/package.json +9 -0
  42. package/live-test/server.js +14 -0
  43. package/live-test/views/base.html +8 -0
  44. package/live-test/views/child.html +7 -0
  45. package/live-test/views/index.html +1 -0
  46. package/miki-template-extension/.github/workflows/ci.yml +116 -116
  47. package/miki-template-extension/.vscodeignore +7 -7
  48. package/miki-template-extension/CHANGELOG.md +99 -99
  49. package/miki-template-extension/LICENSE +21 -21
  50. package/miki-template-extension/README.md +273 -273
  51. package/miki-template-extension/extension.js +1013 -1013
  52. package/miki-template-extension/icon.svg +10 -10
  53. package/miki-template-extension/package.json +280 -280
  54. package/miki-template-extension/snippets/miki-template.json +717 -717
  55. package/miki-template-extension/syntaxes/language-configuration.json +114 -114
  56. package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +355 -355
  57. package/miki-template-extension/tests/grammar-tests.json +162 -162
  58. package/miki-template-extension/tests/run-grammar-tests.js +82 -82
  59. package/package.json +40 -34
  60. package/sample-app/package-lock.json +901 -0
  61. package/sample-app/package.json +9 -0
  62. package/sample-app/server.js +14 -0
  63. package/sample-app/views/index.html +1 -0
  64. package/scripts/build-vsix.js +129 -129
  65. package/scripts/build-vsix.ps1 +15 -15
  66. package/snippets/miki-template.json +177 -177
  67. package/src/asyncRender.js +20 -20
  68. package/src/cache.js +80 -80
  69. package/src/context.js +126 -126
  70. package/src/context_processors.js +48 -48
  71. package/src/esm.mjs +89 -84
  72. package/src/filters.js +975 -975
  73. package/src/i18n.js +171 -171
  74. package/src/index.js +1112 -940
  75. package/src/lexer.js +114 -114
  76. package/src/libraries.js +371 -371
  77. package/src/parser.js +270 -270
  78. package/src/security.js +53 -53
  79. package/src/tags/control.js +719 -719
  80. package/src/tags/extra.js +154 -154
  81. package/src/tags/helpers.js +26 -26
  82. package/src/tags/i18n.js +256 -256
  83. package/src/tags/inheritance.js +335 -335
  84. package/src/tags/registry.js +18 -18
  85. package/src/tags/util.js +400 -400
  86. package/src/types.d.ts +107 -107
  87. package/syntaxes/language-configuration.json +26 -26
  88. package/syntaxes/miki-template.tmLanguage.json +146 -146
  89. package/tests/asyncRender.test.js +17 -17
  90. package/tests/base.html +6 -6
  91. package/tests/child.html +3 -3
  92. package/tests/context_processors.test.js +13 -13
  93. package/tests/esm.test.mjs +61 -61
  94. package/tests/filters.test.js +254 -254
  95. package/tests/finder-appdirs.test.js +19 -0
  96. package/tests/finder.test.js +17 -0
  97. package/tests/fixtures/views/nested/index.html +1 -0
  98. package/tests/fixtures/views/partial.html +1 -0
  99. package/tests/fixtures/views/sub/deepfile.html +1 -0
  100. package/tests/fixtures/views-appdirs/product/site/detail.html +1 -0
  101. package/tests/include_security.test.js +9 -9
  102. package/tests/integration/README.md +32 -32
  103. package/tests/integration/features.test.cjs +1681 -1681
  104. package/tests/integration/features.test.mjs +1697 -1697
  105. package/tests/integration/finder.esm.test.mjs +13 -0
  106. package/tests/integration/templates/base.miki +6 -6
  107. package/tests/integration/templates/child.miki +6 -6
  108. package/tests/integration/templates/index.html +17 -17
  109. package/tests/lexer.test.js +45 -45
  110. package/tests/parser.test.js +57 -57
  111. package/tests/partial.html +1 -1
  112. package/tests/partialdef.test.js +79 -79
  113. package/tests/production_checks.js +57 -57
  114. package/tests/security.test.js +28 -28
  115. package/tests/tags.test.js +233 -233
package/src/index.js CHANGED
@@ -1,940 +1,1112 @@
1
- /**
2
- * Django-Style Template Engine for Node.js/Express
3
- * Main entrypoint.
4
- */
5
- const fs = require('fs');
6
- const path = require('path');
7
- const { tokenize } = require('./lexer');
8
- const { Parser } = require('./parser');
9
- const { Context } = require('./context');
10
-
11
- const { registerContextProcessor, applyContextProcessors, clearContextProcessors } = require('./context_processors');
12
- const { registerFilter, getFilter } = require('./filters');
13
- const { SafeString, markSafe, isSafe, escapeHtml } = require('./security');
14
- const { getCompiled, clearCache, getParentSource, hasParentSource } = require('./cache');
15
- const { registerHelper } = require('./tags/helpers');
16
- const { registerTag, getTagRegistry } = require('./tags/registry');
17
-
18
- // Load control tags
19
- const controlTags = require('./tags/control');
20
- for (const [name, parserFn] of Object.entries(controlTags.parsers)) {
21
- registerTag(name, parserFn);
22
- }
23
-
24
- // Load inheritance tags
25
- const inheritanceTags = require('./tags/inheritance');
26
- for (const [name, parserFn] of Object.entries(inheritanceTags.parsers)) {
27
- registerTag(name, parserFn);
28
- }
29
-
30
- // Load utility tags
31
- const utilTags = require('./tags/util');
32
- for (const [name, parserFn] of Object.entries(utilTags.parsers)) {
33
- registerTag(name, parserFn);
34
- }
35
-
36
- // Load i18n tags
37
- const i18nTags = require('./tags/i18n');
38
- for (const [name, parserFn] of Object.entries(i18nTags.parsers)) {
39
- registerTag(name, parserFn);
40
- }
41
-
42
- // Load extra utility tags
43
- const extraTags = require('./tags/extra');
44
- for (const [name, parserFn] of Object.entries(extraTags.parsers)) {
45
- registerTag(name, parserFn);
46
- }
47
-
48
- // i18n module
49
- const i18n = require('./i18n');
50
-
51
- // Plugin/filter library system
52
- const libraries = require('./libraries');
53
-
54
- // Inject the registration functions so libraries can activate without
55
- // triggering a circular require. This must happen BEFORE the
56
- // auto-activation loop below.
57
- libraries.setRegistrationFunctions({
58
- registerTag,
59
- registerFilter,
60
- registerHelper
61
- });
62
-
63
- // Auto-activate the built-in libraries so their filters/tags/helpers
64
- // are available out of the box. Users can still opt out by calling
65
- // `unregisterLibrary` or by re-registering without activating.
66
- for (const libName of libraries.getLibraryNames()) {
67
- libraries.activateLibrary(libName);
68
- }
69
-
70
- /**
71
- * Render an AST recursively to resolve inheritance chain.
72
- * Async-aware: awaits Promises from any node.
73
- */
74
- function readParentSource(parentName, viewsDirs) {
75
- for (const dir of viewsDirs) {
76
- const key = dir + '\0' + parentName;
77
- if (hasParentSource(key)) {
78
- return getParentSource(key, () => null);
79
- }
80
- try {
81
- const fullPath = path.resolve(dir, parentName);
82
- const relative = path.relative(path.resolve(dir), fullPath);
83
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
84
- throw new Error(`Extends tag attempted path traversal outside allowed views: '${parentName}'`);
85
- }
86
- const fileContent = fs.readFileSync(fullPath, 'utf8');
87
- // Populate the LRU cache and return the value
88
- return getParentSource(key, () => fileContent);
89
- } catch (e) {
90
- if (e.message && e.message.startsWith('Extends tag attempted path traversal')) {
91
- throw e;
92
- }
93
- }
94
- }
95
- throw new Error(`Template not found: '${parentName}' in directories ${JSON.stringify(viewsDirs)}`);
96
- }
97
-
98
- async function renderASTAsync(nodes, context) {
99
- context.parentTemplate = null;
100
- const parts = [];
101
- for (const node of nodes) {
102
- const result = node.render(context);
103
- parts.push(result instanceof Promise ? await result : result);
104
- }
105
- let output = parts.join('');
106
-
107
- if (context.parentTemplate) {
108
- const parentName = context.parentTemplate;
109
- context.parentTemplate = null;
110
-
111
- let viewsDirs = ['.'];
112
- if (context.options && context.options.settings && context.options.settings.views) {
113
- const views = context.options.settings.views;
114
- viewsDirs = Array.isArray(views) ? views : [views];
115
- } else if (context.options && context.options.views) {
116
- const views = context.options.views;
117
- viewsDirs = Array.isArray(views) ? views : [views];
118
- }
119
-
120
- const fileContent = readParentSource(parentName, viewsDirs);
121
-
122
- const parentTokens = tokenize(fileContent);
123
- const parentParser = new Parser(parentTokens, getTagRegistry());
124
- const parentNodes = parentParser.parse();
125
-
126
- if (parentParser.blocks) {
127
- for (const [name, blockList] of Object.entries(parentParser.blocks)) {
128
- if (!context.blocks[name]) {
129
- context.blocks[name] = [];
130
- }
131
- for (const blockNode of blockList) {
132
- if (!context.blocks[name].includes(blockNode)) {
133
- context.blocks[name].push(blockNode);
134
- }
135
- }
136
- }
137
- }
138
-
139
- return await renderASTAsync(parentNodes, context);
140
- }
141
-
142
- return output;
143
- }
144
-
145
- /**
146
- * Synchronous renderAST - throws if any node returns a Promise.
147
- */
148
- function renderAST(nodes, context) {
149
- context.parentTemplate = null;
150
- const output = nodes.map(node => {
151
- const result = node.render(context);
152
- if (result instanceof Promise) {
153
- throw new Error('Async node encountered during sync render. Use asyncRender() instead.');
154
- }
155
- return result;
156
- }).join('');
157
-
158
- if (context.parentTemplate) {
159
- const parentName = context.parentTemplate;
160
- context.parentTemplate = null;
161
-
162
- let viewsDirs = ['.'];
163
- if (context.options && context.options.settings && context.options.settings.views) {
164
- const views = context.options.settings.views;
165
- viewsDirs = Array.isArray(views) ? views : [views];
166
- } else if (context.options && context.options.views) {
167
- const views = context.options.views;
168
- viewsDirs = Array.isArray(views) ? views : [views];
169
- }
170
-
171
- const fileContent = readParentSource(parentName, viewsDirs);
172
-
173
- const parentTokens = tokenize(fileContent);
174
- const parentParser = new Parser(parentTokens, getTagRegistry());
175
- const parentNodes = parentParser.parse();
176
-
177
- if (parentParser.blocks) {
178
- for (const [name, blockList] of Object.entries(parentParser.blocks)) {
179
- if (!context.blocks[name]) {
180
- context.blocks[name] = [];
181
- }
182
- for (const blockNode of blockList) {
183
- if (!context.blocks[name].includes(blockNode)) {
184
- context.blocks[name].push(blockNode);
185
- }
186
- }
187
- }
188
- }
189
-
190
- return renderAST(parentNodes, context);
191
- }
192
-
193
- return output;
194
- }
195
-
196
- /**
197
- * Compiles a template string into a renderable object.
198
- */
199
- function compile(templateStr, options = {}) {
200
- return getCompiled(templateStr, options, (tmpl, opts) => {
201
- const tokens = tokenize(tmpl);
202
- const parser = new Parser(tokens, getTagRegistry());
203
- const nodes = parser.parse();
204
-
205
- // Collect partial definitions at compile time
206
- const partialDefs = {};
207
- function collectPartials(nodeList) {
208
- for (const node of nodeList) {
209
- if (node.constructor.name === 'PartialDefNode') {
210
- partialDefs[node.name] = node;
211
- }
212
- if (node.body) {
213
- collectPartials(node.body);
214
- }
215
- if (node.elifBranches) {
216
- for (const branch of node.elifBranches) {
217
- collectPartials(branch.body);
218
- }
219
- }
220
- if (node.elseBody) {
221
- collectPartials(node.elseBody);
222
- }
223
- }
224
- }
225
- collectPartials(nodes);
226
-
227
- return {
228
- render: (contextObj = {}) => {
229
- const processedContextObj = applyContextProcessors({ ...contextObj });
230
- const context = new Context(processedContextObj, opts);
231
- context.reset();
232
- if (parser.blocks) {
233
- for (const [name, blockList] of Object.entries(parser.blocks)) {
234
- context.blocks[name] = [...blockList];
235
- }
236
- }
237
- // Register partial definitions from compile-time
238
- for (const [name, partial] of Object.entries(partialDefs)) {
239
- context.registerPartial(name, partial);
240
- }
241
- return renderAST(nodes, context);
242
- },
243
- renderWith: (contextObj = {}, callOptions = {}) => {
244
- const processedContextObj = applyContextProcessors({ ...contextObj });
245
- const mergedOpts = { ...opts, ...callOptions };
246
- const context = new Context(processedContextObj, mergedOpts);
247
- context.reset();
248
- if (parser.blocks) {
249
- for (const [name, blockList] of Object.entries(parser.blocks)) {
250
- context.blocks[name] = [...blockList];
251
- }
252
- }
253
- for (const [name, partial] of Object.entries(partialDefs)) {
254
- context.registerPartial(name, partial);
255
- }
256
- return renderAST(nodes, context);
257
- },
258
- asyncRender: async (contextObj = {}) => {
259
- const processedContextObj = applyContextProcessors({ ...contextObj });
260
- const context = new Context(processedContextObj, opts);
261
- context.blocks = {};
262
- if (parser.blocks) {
263
- for (const [name, blockList] of Object.entries(parser.blocks)) {
264
- context.blocks[name] = [...blockList];
265
- }
266
- }
267
- for (const [name, partial] of Object.entries(partialDefs)) {
268
- context.registerPartial(name, partial);
269
- }
270
- return await renderASTAsync(nodes, context);
271
- },
272
- asyncRenderWith: async (contextObj = {}, callOptions = {}) => {
273
- const processedContextObj = applyContextProcessors({ ...contextObj });
274
- const mergedOpts = { ...opts, ...callOptions };
275
- const context = new Context(processedContextObj, mergedOpts);
276
- context.blocks = {};
277
- if (parser.blocks) {
278
- for (const [name, blockList] of Object.entries(parser.blocks)) {
279
- context.blocks[name] = [...blockList];
280
- }
281
- }
282
- for (const [name, partial] of Object.entries(partialDefs)) {
283
- context.registerPartial(name, partial);
284
- }
285
- return await renderASTAsync(nodes, context);
286
- },
287
- renderBlock: (blockName, contextObj = {}) => {
288
- const processedContextObj = applyContextProcessors({ ...contextObj });
289
- const context = new Context(processedContextObj, opts);
290
- context.blocks = {};
291
- if (parser.blocks) {
292
- for (const [name, blockList] of Object.entries(parser.blocks)) {
293
- context.blocks[name] = [...blockList];
294
- }
295
- }
296
- for (const [name, partial] of Object.entries(partialDefs)) {
297
- context.registerPartial(name, partial);
298
- }
299
- renderAST(nodes, context);
300
- const blockStack = context.blocks[blockName];
301
- if (!blockStack || blockStack.length === 0) {
302
- throw new Error(`Block '${blockName}' not found in template`);
303
- }
304
- if (!context.blockRenderIndices) {
305
- context.blockRenderIndices = {};
306
- }
307
- context.blockRenderIndices[blockName] = 0;
308
- let superVal = '';
309
- if (blockStack.length > 1) {
310
- context.blockRenderIndices[blockName] = 1;
311
- superVal = blockStack[1].render(context);
312
- }
313
- context.push({ block: { super: superVal } });
314
- context.blockRenderIndices[blockName] = 0;
315
- const result = blockStack[0].body.map(n => n.render(context)).join('');
316
- context.pop();
317
- context.blockRenderIndices[blockName] = -1;
318
- return result;
319
- },
320
- renderPartial: (partialName, contextObj = {}) => {
321
- const processedContextObj = applyContextProcessors({ ...contextObj });
322
- const context = new Context(processedContextObj, opts);
323
- for (const [name, partial] of Object.entries(partialDefs)) {
324
- context.registerPartial(name, partial);
325
- }
326
- const partial = context.getPartial(partialName);
327
- if (!partial) {
328
- throw new Error(`Partial '${partialName}' not found`);
329
- }
330
- return partial.body.map(n => n.render(context)).join('');
331
- }
332
- };
333
- });
334
- }
335
-
336
- /**
337
- * Convenience rendering function.
338
- *
339
- * If `templateStr` looks like a file path with a `#partialName` suffix
340
- * (e.g. `"home.html#card"`) AND `options.views` is set, the file is
341
- * loaded from the views dir(s) and only the named partial is rendered.
342
- * This is the engine's idiomatic way to serve HTMX partial responses.
343
- *
344
- * If the string contains template syntax (`{{` or `{%`), it is always
345
- * treated as a template string (not a file path), so existing call
346
- * sites that pass template source are unaffected.
347
- *
348
- * Without a `#` suffix, the whole template is rendered (existing
349
- * behavior).
350
- */
351
- function render(templateStr, contextObj = {}, options = {}) {
352
- // Only treat the input as a file path when:
353
- // 1. It has a `#` and a partial name
354
- // 2. options.views is configured
355
- // 3. The string does NOT contain template syntax
356
- const hashIdx = templateStr.indexOf('#');
357
- const hasTemplateSyntax = /\{[{%]/.test(templateStr);
358
- if (hashIdx >= 0 && !hasTemplateSyntax && options && options.views) {
359
- const fileName = templateStr.slice(0, hashIdx);
360
- const partialName = templateStr.slice(hashIdx + 1);
361
- return renderPartialFromFile(fileName, partialName, contextObj, options);
362
- }
363
-
364
- const compiled = compile(templateStr, options);
365
- if (options && Object.keys(options).length > 0) {
366
- return compiled.renderWith(contextObj, options);
367
- }
368
- return compiled.render(contextObj);
369
- }
370
-
371
- /**
372
- * Load a template file from the configured views dir(s), register its
373
- * partials, and render only the named partial. This is the
374
- * implementation behind `render("file.html#partial", ...)`.
375
- */
376
- function renderPartialFromFile(fileName, partialName, contextObj, options) {
377
- const { Context } = require('./context');
378
- const { tokenize } = require('./lexer');
379
- const { Parser } = require('./parser');
380
- const { getTagRegistry } = require('./tags/registry');
381
- const { applyContextProcessors } = require('./context_processors');
382
-
383
- let viewsDirs = ['.'];
384
- if (options.settings && options.settings.views) {
385
- viewsDirs = Array.isArray(options.settings.views) ? options.settings.views : [options.settings.views];
386
- } else if (options.views) {
387
- viewsDirs = Array.isArray(options.views) ? options.views : [options.views];
388
- }
389
-
390
- let fileContent = null;
391
- let loaded = false;
392
- // Try the literal name first, then with each supported extension appended.
393
- const candidates = [];
394
- if (/\.[a-z0-9]+$/i.test(fileName)) {
395
- candidates.push(fileName);
396
- } else {
397
- candidates.push(fileName + '.html', fileName + '.miki');
398
- }
399
- for (const dir of viewsDirs) {
400
- for (const candidate of candidates) {
401
- try {
402
- const fullPath = path.resolve(dir, candidate);
403
- const relative = path.relative(path.resolve(dir), fullPath);
404
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
405
- continue;
406
- }
407
- fileContent = fs.readFileSync(fullPath, 'utf8');
408
- loaded = true;
409
- break;
410
- } catch {
411
- // try next candidate
412
- }
413
- }
414
- if (loaded) break;
415
- }
416
- if (!loaded) {
417
- throw new Error(
418
- `Template not found: '${fileName}' in directories ${JSON.stringify(viewsDirs)}`
419
- );
420
- }
421
-
422
- const tokens = tokenize(fileContent);
423
- const parser = new Parser(tokens, getTagRegistry());
424
- const nodes = parser.parse();
425
-
426
- // Build a context to register the partials. We share the partial
427
- // registry with a fresh context for the actual render.
428
- const tempContext = new Context({}, options);
429
- for (const node of nodes) {
430
- node.render(tempContext);
431
- }
432
- const partial = tempContext.getPartial(partialName);
433
- if (!partial) {
434
- throw new Error(
435
- `Partial '${partialName}' not found in template '${fileName}'`
436
- );
437
- }
438
-
439
- const processedContextObj = applyContextProcessors({ ...contextObj });
440
- const context = new Context(processedContextObj, options);
441
- context.partialDefs = tempContext.partialDefs;
442
- return partial.body.map(n => n.render(context)).join('');
443
- }
444
-
445
- /**
446
- * Async rendering function – returns a Promise.
447
- */
448
- function asyncRender(templateStr, contextObj = {}, options = {}) {
449
- const hashIdx = templateStr.indexOf('#');
450
- const hasTemplateSyntax = /\{[{%]/.test(templateStr);
451
- if (hashIdx >= 0 && !hasTemplateSyntax && options && options.views) {
452
- const fileName = templateStr.slice(0, hashIdx);
453
- const partialName = templateStr.slice(hashIdx + 1);
454
- return new Promise((resolve, reject) => {
455
- try {
456
- resolve(renderPartialFromFile(fileName, partialName, contextObj, options));
457
- } catch (e) {
458
- reject(e);
459
- }
460
- });
461
- }
462
- const compiled = compile(templateStr, options);
463
- if (options && Object.keys(options).length > 0) {
464
- return compiled.asyncRenderWith(contextObj, options);
465
- }
466
- return compiled.asyncRender(contextObj);
467
- }
468
-
469
- /**
470
- * Express adapter engine (synchronous callback form).
471
- * Strips Express framework keys from the context so they don't leak
472
- * into the template scope.
473
- *
474
- * If `filePath` contains a `#partialName` suffix, only the named
475
- * partial is rendered. This enables HTMX-style partial responses:
476
- *
477
- * res.render('home#card', { ... });
478
- */
479
- function __express(filePath, options, callback) {
480
- if (typeof callback !== 'function') {
481
- return __expressAsync(filePath, options);
482
- }
483
-
484
- // Detect "file#partial" form
485
- const hashIdx = filePath.lastIndexOf('#');
486
- if (hashIdx > 0) {
487
- const realFilePath = filePath.slice(0, hashIdx);
488
- const partialName = filePath.slice(hashIdx + 1);
489
- try {
490
- const fileContent = fs.readFileSync(realFilePath, 'utf8');
491
- const renderOptions = {
492
- views: realFilePath,
493
- ...(options || {})
494
- };
495
- const ctx = stripExpressContext(options);
496
- const result = renderPartialFromSource(fileContent, partialName, ctx, renderOptions, realFilePath);
497
- return callback(null, result);
498
- } catch (err) {
499
- return callback(err);
500
- }
501
- }
502
-
503
- try {
504
- const fileContent = fs.readFileSync(filePath, 'utf8');
505
- const renderOptions = {
506
- views: options && options.settings ? options.settings.views : path.dirname(filePath),
507
- ...(options || {})
508
- };
509
- // Strip Express framework keys from the context
510
- const ctx = stripExpressContext(options);
511
- const result = render(fileContent, ctx, renderOptions);
512
- return callback(null, result);
513
- } catch (err) {
514
- return callback(err);
515
- }
516
- }
517
-
518
- /**
519
- * Compile a template source string and render only the named partial
520
- * from it. Used by __express when a view name carries a `#partial`
521
- * suffix.
522
- *
523
- * The challenge: when a template uses `{% extends 'parent' %}`,
524
- * the partials are typically defined inside `{% block ... %}` tags.
525
- * The top-level nodes are [ExtendsNode, BlockNode, ...], not the
526
- * blocks themselves. We need to register all partials regardless of
527
- * whether their enclosing for/if blocks have data to iterate.
528
- */
529
- function renderPartialFromSource(fileContent, partialName, contextObj, options, filePath) {
530
- const { Context } = require('./context');
531
- const { tokenize } = require('./lexer');
532
- const { Parser } = require('./parser');
533
- const { getTagRegistry } = require('./tags/registry');
534
- const { applyContextProcessors } = require('./context_processors');
535
-
536
- const tokens = tokenize(fileContent);
537
- const parser = new Parser(tokens, getTagRegistry());
538
- const nodes = parser.parse();
539
-
540
- // Build a context with the caller's data so for-loops and other
541
- // constructs iterate properly when collecting partials.
542
- const processedContextObj = applyContextProcessors({ ...contextObj });
543
- const tempContext = new Context(processedContextObj, options);
544
-
545
- // Walk the ENTIRE AST and register every PartialDefNode we find,
546
- // regardless of whether its enclosing for/if has data. This ensures
547
- // partials are always available by name even when the caller
548
- // didn't provide the data the template would need to render them
549
- // in context.
550
- function registerAllPartials(nodeList) {
551
- for (const node of nodeList) {
552
- if (node.constructor.name === 'PartialDefNode') {
553
- tempContext.registerPartial(node.name, node);
554
- }
555
- if (node.body) registerAllPartials(node.body);
556
- if (node.elifBranches) for (const b of node.elifBranches) registerAllPartials(b.body);
557
- if (node.elseBody) registerAllPartials(node.elseBody);
558
- }
559
- }
560
- registerAllPartials(nodes);
561
-
562
- // Also try rendering top-level nodes so partials defined via
563
- // {% load %} or other dynamic mechanisms get a chance to register.
564
- // Errors are swallowed — we only care about partial registration.
565
- for (const node of nodes) {
566
- try {
567
- node.render(tempContext);
568
- } catch {
569
- // Ignore
570
- }
571
- }
572
-
573
- // If the template extends a parent, also collect partials from
574
- // the parent. This handles the common case where partials are
575
- // defined inside blocks that are part of an extended template.
576
- if (tempContext.parentTemplate) {
577
- try {
578
- const parentName = tempContext.parentTemplate;
579
- let viewsDirs = ['.'];
580
- if (options && options.settings && options.settings.views) {
581
- const v = options.settings.views;
582
- viewsDirs = Array.isArray(v) ? v : [v];
583
- } else if (options && options.views) {
584
- const v = options.views;
585
- viewsDirs = Array.isArray(v) ? v : [v];
586
- }
587
- for (const dir of viewsDirs) {
588
- const parentPath = require('path').resolve(dir, parentName);
589
- const relative = require('path').relative(require('path').resolve(dir), parentPath);
590
- if (relative.startsWith('..') || require('path').isAbsolute(relative)) continue;
591
- if (!require('fs').existsSync(parentPath)) continue;
592
- const parentContent = require('fs').readFileSync(parentPath, 'utf8');
593
- const pTokens = tokenize(parentContent);
594
- const pParser = new Parser(pTokens, getTagRegistry());
595
- const pNodes = pParser.parse();
596
- registerAllPartials(pNodes);
597
- for (const pNode of pNodes) {
598
- try { pNode.render(tempContext); } catch {}
599
- }
600
- break;
601
- }
602
- } catch {
603
- // Ignore parent resolution errors
604
- }
605
- }
606
-
607
- const partial = tempContext.getPartial(partialName);
608
- if (!partial) {
609
- throw new Error(
610
- `Partial '${partialName}' not found in template '${filePath || 'inline'}'`
611
- );
612
- }
613
-
614
- // Build a fresh context for the actual render using the caller's
615
- // data. Share the partial registry from the temp context.
616
- const context = new Context(processedContextObj, options);
617
- context.partialDefs = tempContext.partialDefs;
618
- return partial.body.map(n => n.render(context)).join('');
619
- }
620
-
621
- /**
622
- * Strip Express-specific framework keys from a context object.
623
- * Internal keys (those starting with `_`), `settings`, and `cache` are removed.
624
- */
625
- function stripExpressContext(options) {
626
- if (!options) return {};
627
- const ctx = {};
628
- for (const [k, v] of Object.entries(options)) {
629
- if (!k.startsWith('_') && k !== 'settings' && k !== 'cache') {
630
- ctx[k] = v;
631
- }
632
- }
633
- return ctx;
634
- }
635
-
636
- /**
637
- * Async view engine for Express 5+. Returns a Promise that resolves
638
- * to the rendered HTML. Use this when your templates have async helpers.
639
- *
640
- * app.engine('html', miki.__expressAsync);
641
- */
642
- function __expressAsync(filePath, options) {
643
- return new Promise((resolve, reject) => {
644
- // Detect "file#partial" form
645
- const hashIdx = filePath.lastIndexOf('#');
646
- if (hashIdx > 0) {
647
- const realFilePath = filePath.slice(0, hashIdx);
648
- const partialName = filePath.slice(hashIdx + 1);
649
- try {
650
- const fileContent = fs.readFileSync(realFilePath, 'utf8');
651
- const renderOptions = {
652
- views: realFilePath,
653
- ...(options || {})
654
- };
655
- const ctx = stripExpressContext(options);
656
- resolve(renderPartialFromSource(fileContent, partialName, ctx, renderOptions, realFilePath));
657
- } catch (err) {
658
- reject(err);
659
- }
660
- return;
661
- }
662
-
663
- try {
664
- const fileContent = fs.readFileSync(filePath, 'utf8');
665
- const renderOptions = {
666
- views: options && options.settings ? options.settings.views : path.dirname(filePath),
667
- ...(options || {})
668
- };
669
- const ctx = stripExpressContext(options);
670
- // Use asyncRender so async helpers are awaited
671
- asyncRender(fileContent, ctx, renderOptions)
672
- .then(resolve)
673
- .catch(reject);
674
- } catch (err) {
675
- reject(err);
676
- }
677
- });
678
- }
679
-
680
- /**
681
- * Express integration helper. Returns a function suitable for
682
- * `app.engine(ext, fn)` that makes `res.render('view#partial', ...)`
683
- * just work without any extra middleware.
684
- *
685
- * Usage:
686
- *
687
- * const miki = require('miki-template');
688
- * const app = express();
689
- * app.engine('html', miki.express());
690
- * app.set('view engine', 'html');
691
- * app.set('views', './views');
692
- *
693
- * // Full page:
694
- * app.get('/', (req, res) => res.render('home', { user }));
695
- *
696
- * // HTMX partial — just one named partial from a template:
697
- * app.get('/partials/:name', (req, res) =>
698
- * res.render(`home#${req.params.name}`, { user })
699
- * );
700
- *
701
- * The wrapper detects a `#partialName` suffix in the view name BEFORE
702
- * Express's view lookup runs, so it never tries to open a file like
703
- * `home#card.html`. It resolves the real file, calls the partial
704
- * renderer, and sends the result.
705
- */
706
- function express(options = {}) {
707
- const baseEngine = options.async ? __expressAsync : __express;
708
- return function mikiViewEngine(filePath, engineOptions, callback) {
709
- // Detect the partial selector in the view name. Express passes
710
- // the resolved file path here — if the user wrote
711
- // `res.render('home#card', ...)`, Express will have already
712
- // tried (and failed) to resolve `home#card.html`. To support
713
- // partials, we need to intercept BEFORE Express resolves the
714
- // view. We do that by hooking `res.render` when this engine is
715
- // installed.
716
- // For the direct path (when called from `res.renderPartial` or
717
- // from our own `res.render` shim), we honor the `#partial`
718
- // suffix here.
719
- if (typeof callback !== 'function') {
720
- return Promise.reject(
721
- new Error('miki.express() engine must be called via res.render() with a callback')
722
- );
723
- }
724
- return baseEngine(filePath, engineOptions, callback);
725
- };
726
- }
727
-
728
- /**
729
- * One-shot Express setup. Wires `app.engine('html', ...)` and
730
- * installs a `res.render` shim so that `res.render('view#partial')`
731
- * works without any extra middleware. This is the recommended way
732
- * to integrate miki-template with Express.
733
- *
734
- * Usage:
735
- *
736
- * const miki = require('miki-template');
737
- * const app = express();
738
- * miki.setupExpress(app, { extension: 'html', views: './views' });
739
- *
740
- * app.get('/partials/:name', (req, res) =>
741
- * res.render(`home#${req.params.name}`, { user: req.user })
742
- * );
743
- */
744
- function setupExpress(app, opts = {}) {
745
- const ext = (opts.extension || 'html').replace(/^\.+/, '');
746
- const async = !!opts.async;
747
-
748
- // Set view engine if not already set
749
- if (!app.get('view engine')) {
750
- app.set('view engine', ext);
751
- }
752
- // If opts.views is provided, always set it (so users can set
753
- // views via setupExpress without an extra app.set call).
754
- if (opts.views) {
755
- app.set('views', opts.views);
756
- }
757
-
758
- // Install the raw engine so Express can use it
759
- app.engine(ext, async ? __expressAsync : __express);
760
-
761
- // Capture config in a closure so patchedRender can use it even
762
- // when called before the request handler runs.
763
- const configExt = ext;
764
- const configViews = opts.views;
765
-
766
- // Capture the original res.render so we can dispatch on #partial
767
- const originalRender = app.response.render;
768
- app.response.render = function patchedRender(view, locals, callback) {
769
- // Normalize arguments: (view, callback) or (view, locals, callback)
770
- let cb = callback;
771
- let opts = locals;
772
- if (typeof locals === 'function') {
773
- cb = locals;
774
- opts = {};
775
- }
776
- opts = opts || {};
777
- // Inject settings so the engine can find the views dir
778
- if (!opts.settings) {
779
- opts.settings = this.req && this.req.app ? this.req.app.settings : {};
780
- }
781
-
782
- // If the view name has a `#partial` suffix, handle it ourselves
783
- // and never delegate to Express's view lookup.
784
- if (typeof view === 'string' && view.includes('#')) {
785
- const hashIdx = view.lastIndexOf('#');
786
- const fileName = view.slice(0, hashIdx);
787
- const partialName = view.slice(hashIdx + 1);
788
-
789
- // Resolve the real file path. Prefer opts.views (set by
790
- // setupExpress), then app.get('views'), then the default.
791
- const extname = require('path').extname(fileName);
792
- const candidates = extname
793
- ? [fileName]
794
- : [fileName + '.' + configExt, fileName + '.miki'];
795
- const viewsDir = configViews
796
- || (this.req && this.req.app ? this.req.app.get('views') : null)
797
- || process.cwd() + '/views';
798
- let filePath = null;
799
- for (const cand of candidates) {
800
- const p = require('path').resolve(viewsDir, cand);
801
- if (require('fs').existsSync(p)) {
802
- filePath = p;
803
- break;
804
- }
805
- }
806
- if (!filePath) {
807
- const err = new Error(
808
- `Failed to lookup view "${view}" in views directory "${viewsDir}"`
809
- );
810
- if (typeof cb === 'function') return cb(err);
811
- throw err;
812
- }
813
-
814
- const fileContent = require('fs').readFileSync(filePath, 'utf8');
815
- try {
816
- const html = renderPartialFromSource(
817
- fileContent,
818
- partialName,
819
- stripExpressContext(opts),
820
- Object.assign({ views: viewsDir }, opts),
821
- fileName
822
- );
823
- if (typeof cb === 'function') {
824
- return cb(null, html);
825
- }
826
- this.send(html);
827
- return;
828
- } catch (e) {
829
- if (typeof cb === 'function') return cb(e);
830
- throw e;
831
- }
832
- }
833
-
834
- // No partial selector: behave exactly like the original res.render
835
- if (cb) {
836
- return originalRender.call(this, view, opts, cb);
837
- }
838
- return originalRender.call(this, view, opts);
839
- };
840
- }
841
-
842
- /**
843
- * Express middleware helper. Adds a `res.renderPartial(view, locals)`
844
- * method that renders only the named partial (after a `#`) from a
845
- * view file. The view name follows the same syntax as `render()`:
846
- *
847
- * app.use(miki.expressPartialRenderer());
848
- * app.get('/card', (req, res) => res.renderPartial('home#card', { user }));
849
- */
850
- function expressPartialRenderer() {
851
- return function (req, res, next) {
852
- res.renderPartial = function (view, locals = {}) {
853
- // Compose an Express-shaped options object so the engine can
854
- // find the view file. We mirror what res.render provides.
855
- const opts = Object.assign({}, res.locals, locals, {
856
- settings: req.app.settings
857
- });
858
- const hashIdx = view.lastIndexOf('#');
859
- if (hashIdx < 0) {
860
- // No partial selector: just delegate to res.render
861
- return res.render(view, locals);
862
- }
863
- const fileName = view.slice(0, hashIdx);
864
- const partialName = view.slice(hashIdx + 1);
865
- // Find the actual file the way Express would
866
- const ext = require('path').extname(fileName);
867
- const candidates = ext
868
- ? [fileName]
869
- : [fileName + '.html', fileName + '.miki'];
870
- const viewsDir = req.app.get('views');
871
- let filePath = null;
872
- for (const cand of candidates) {
873
- try {
874
- filePath = require('path').resolve(viewsDir, cand);
875
- if (require('fs').existsSync(filePath)) break;
876
- filePath = null;
877
- } catch { filePath = null; }
878
- }
879
- if (!filePath) {
880
- return res.status(404).send(
881
- `Template not found: '${fileName}' in '${viewsDir}'`
882
- );
883
- }
884
- try {
885
- const html = renderPartialFromSource(
886
- require('fs').readFileSync(filePath, 'utf8'),
887
- partialName,
888
- stripExpressContext(opts),
889
- { views: viewsDir, ...opts },
890
- fileName
891
- );
892
- res.send(html);
893
- } catch (err) {
894
- res.status(500).send(err.message);
895
- }
896
- };
897
- next();
898
- };
899
- }
900
-
901
- module.exports = {
902
- compile,
903
- render,
904
- asyncRender,
905
- renderPartialFromFile,
906
- renderPartialFromSource,
907
- __express,
908
- __expressAsync,
909
- express,
910
- setupExpress,
911
- expressPartialRenderer,
912
- stripExpressContext,
913
- clearCache,
914
- registerTag,
915
- registerFilter,
916
- getFilter,
917
- registerHelper,
918
- registerContextProcessor,
919
- clearContextProcessors,
920
- SafeString,
921
- markSafe,
922
- isSafe,
923
- escapeHtml,
924
- // i18n
925
- registerTranslation: i18n.registerTranslation,
926
- unregisterTranslation: i18n.unregisterTranslation,
927
- setLanguage: i18n.setLanguage,
928
- getLanguage: i18n.getLanguage,
929
- setFallbackLanguage: i18n.setFallbackLanguage,
930
- getFallbackLanguage: i18n.getFallbackLanguage,
931
- getAvailableLanguages: i18n.getAvailableLanguages,
932
- // Plugin/filter libraries
933
- registerLibrary: libraries.registerLibrary,
934
- unregisterLibrary: libraries.unregisterLibrary,
935
- getLibrary: libraries.getLibrary,
936
- getLibraryNames: libraries.getLibraryNames,
937
- hasLibrary: libraries.hasLibrary,
938
- registerLibraryFromPath: libraries.registerLibraryFromPath,
939
- activateLibrary: libraries.activateLibrary
940
- };
1
+ /**
2
+ * Django-Style Template Engine for Node.js/Express
3
+ * Main entrypoint.
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { tokenize } = require('./lexer');
8
+ const { Parser } = require('./parser');
9
+ const { Context } = require('./context');
10
+
11
+ const { registerContextProcessor, applyContextProcessors, clearContextProcessors } = require('./context_processors');
12
+ const { registerFilter, getFilter } = require('./filters');
13
+ const { SafeString, markSafe, isSafe, escapeHtml } = require('./security');
14
+ const { getCompiled, clearCache, getParentSource, hasParentSource } = require('./cache');
15
+ const { registerHelper } = require('./tags/helpers');
16
+ const { registerTag, getTagRegistry } = require('./tags/registry');
17
+
18
+ // Load control tags
19
+ const controlTags = require('./tags/control');
20
+ for (const [name, parserFn] of Object.entries(controlTags.parsers)) {
21
+ registerTag(name, parserFn);
22
+ }
23
+
24
+ // Load inheritance tags
25
+ const inheritanceTags = require('./tags/inheritance');
26
+ for (const [name, parserFn] of Object.entries(inheritanceTags.parsers)) {
27
+ registerTag(name, parserFn);
28
+ }
29
+
30
+ // Load utility tags
31
+ const utilTags = require('./tags/util');
32
+ for (const [name, parserFn] of Object.entries(utilTags.parsers)) {
33
+ registerTag(name, parserFn);
34
+ }
35
+
36
+ // Load i18n tags
37
+ const i18nTags = require('./tags/i18n');
38
+ for (const [name, parserFn] of Object.entries(i18nTags.parsers)) {
39
+ registerTag(name, parserFn);
40
+ }
41
+
42
+ // Load extra utility tags
43
+ const extraTags = require('./tags/extra');
44
+ for (const [name, parserFn] of Object.entries(extraTags.parsers)) {
45
+ registerTag(name, parserFn);
46
+ }
47
+
48
+ // i18n module
49
+ const i18n = require('./i18n');
50
+
51
+ // Plugin/filter library system
52
+ const libraries = require('./libraries');
53
+
54
+ // Normalize views entries: if a views entry points to a file,
55
+ // return its directory so lookups work regardless of whether the
56
+ // caller passed a file path or a directory.
57
+ function normalizeViews(views) {
58
+ if (!views) return ['.'];
59
+ const arr = Array.isArray(views) ? views.slice() : [views];
60
+ return arr.map(v => {
61
+ try {
62
+ const st = fs.statSync(v);
63
+ if (st.isFile()) return path.dirname(v);
64
+ return v;
65
+ } catch (e) {
66
+ // If the path doesn't exist or can't be stat'd, return as-is.
67
+ return v;
68
+ }
69
+ });
70
+ }
71
+
72
+ // Find a template file by name in the provided views directories.
73
+ // Supports searching recursively through subdirectories when the
74
+ // template name is a bare name (no path separators). Returns the
75
+ // absolute path to the first matching file, or null if not found.
76
+ function findTemplateInViews(templateName, viewsDirs) {
77
+ if (!templateName) return null;
78
+ const hasExt = /\.[a-z0-9]+$/i.test(templateName);
79
+ // Candidate basenames to look for when doing recursive search
80
+ const candidateBasenames = hasExt ? [path.basename(templateName)] : [path.basename(templateName) + '.html', path.basename(templateName) + '.miki'];
81
+
82
+ // Normalize templateName's separators to the platform so direct
83
+ // resolves work when callers use forward slashes on Windows.
84
+ const templateNameNorm = templateName.replace(/\//g, path.sep);
85
+
86
+ for (const dir of viewsDirs) {
87
+ // Also consider app-style 'templates' directories nested inside
88
+ // the views root (e.g. project/app/templates/...)
89
+ const appTemplateDirs = findTemplatesDirsUnder(dir);
90
+ const searchDirs = [dir, ...appTemplateDirs];
91
+ for (const sdir of searchDirs) {
92
+ // Try direct resolution: if caller provided a path (like "nested/index")
93
+ // resolve it relative to the search dir and try supported extensions.
94
+ const basePath = path.resolve(sdir, templateNameNorm);
95
+ if (hasExt) {
96
+ try { if (fs.existsSync(basePath)) return basePath; } catch {}
97
+ } else {
98
+ try { if (fs.existsSync(basePath + '.html')) return basePath + '.html'; } catch {}
99
+ try { if (fs.existsSync(basePath + '.miki')) return basePath + '.miki'; } catch {}
100
+ }
101
+
102
+ // If templateName is a bare name (no path separators), search
103
+ // recursively under the search dir for matching filenames.
104
+ if (!templateName.includes('/') && !templateName.includes(path.sep)) {
105
+ const stack = [sdir];
106
+ while (stack.length) {
107
+ const cur = stack.pop();
108
+ let entries;
109
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch (e) { continue; }
110
+ for (const ent of entries) {
111
+ const p = path.join(cur, ent.name);
112
+ if (ent.isDirectory()) {
113
+ stack.push(p);
114
+ continue;
115
+ }
116
+ if (!ent.isFile()) continue;
117
+ const relative = path.relative(path.resolve(sdir), p);
118
+ if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
119
+ for (const candBasename of candidateBasenames) {
120
+ if (ent.name === candBasename) return p;
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+
130
+ // Search for "app-style" templates directories under the provided
131
+ // views directories. Many projects place templates inside an app
132
+ // submodule under `appname/templates/...`. This helper will locate
133
+ // any `templates` directories and search them for candidates.
134
+ let appTemplateDirNames = ['templates'];
135
+
136
+ function setAppTemplateDirNames(names) {
137
+ if (!names) return;
138
+ if (Array.isArray(names)) appTemplateDirNames = names.slice();
139
+ else if (typeof names === 'string') appTemplateDirNames = [names];
140
+ }
141
+
142
+ function getAppTemplateDirNames() {
143
+ return appTemplateDirNames.slice();
144
+ }
145
+
146
+ function findTemplatesDirsUnder(dir) {
147
+ const results = [];
148
+ const stack = [dir];
149
+ while (stack.length) {
150
+ const cur = stack.pop();
151
+ let entries;
152
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch (e) { continue; }
153
+ for (const ent of entries) {
154
+ const p = path.join(cur, ent.name);
155
+ if (!ent.isDirectory()) continue;
156
+ if (appTemplateDirNames.includes(ent.name)) results.push(p);
157
+ stack.push(p);
158
+ }
159
+ }
160
+ return results;
161
+ }
162
+
163
+ // Inject the registration functions so libraries can activate without
164
+ // triggering a circular require. This must happen BEFORE the
165
+ // auto-activation loop below.
166
+ libraries.setRegistrationFunctions({
167
+ registerTag,
168
+ registerFilter,
169
+ registerHelper
170
+ });
171
+
172
+ // Auto-activate the built-in libraries so their filters/tags/helpers
173
+ // are available out of the box. Users can still opt out by calling
174
+ // `unregisterLibrary` or by re-registering without activating.
175
+ for (const libName of libraries.getLibraryNames()) {
176
+ libraries.activateLibrary(libName);
177
+ }
178
+
179
+ /**
180
+ * Render an AST recursively to resolve inheritance chain.
181
+ * Async-aware: awaits Promises from any node.
182
+ */
183
+ function readParentSource(parentName, viewsDirs) {
184
+ for (const dir of viewsDirs) {
185
+ const key = dir + '\0' + parentName;
186
+ if (hasParentSource(key)) {
187
+ return getParentSource(key, () => null);
188
+ }
189
+ try {
190
+ const fullPath = path.resolve(dir, parentName);
191
+ const relative = path.relative(path.resolve(dir), fullPath);
192
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
193
+ throw new Error(`Extends tag attempted path traversal outside allowed views: '${parentName}'`);
194
+ }
195
+ if (fs.existsSync(fullPath)) {
196
+ const fileContent = fs.readFileSync(fullPath, 'utf8');
197
+ // Populate the LRU cache and return the value
198
+ return getParentSource(key, () => fileContent);
199
+ }
200
+ } catch (e) {
201
+ if (e.message && e.message.startsWith('Extends tag attempted path traversal')) {
202
+ throw e;
203
+ }
204
+ }
205
+ }
206
+
207
+ // Fallback: try recursive search through subfolders in the provided
208
+ // views directories. This enables Django-like behavior where a
209
+ // template may be placed in a nested folder and referenced by name.
210
+ const found = findTemplateInViews(parentName, viewsDirs);
211
+ if (found) {
212
+ const key = path.dirname(found) + '\0' + parentName;
213
+ const fileContent = fs.readFileSync(found, 'utf8');
214
+ return getParentSource(key, () => fileContent);
215
+ }
216
+
217
+ throw new Error(`Template not found: '${parentName}' in directories ${JSON.stringify(viewsDirs)}`);
218
+ }
219
+
220
+ async function renderASTAsync(nodes, context) {
221
+ context.parentTemplate = null;
222
+ const parts = [];
223
+ for (const node of nodes) {
224
+ const result = node.render(context);
225
+ parts.push(result instanceof Promise ? await result : result);
226
+ }
227
+ let output = parts.join('');
228
+
229
+ if (context.parentTemplate) {
230
+ const parentName = context.parentTemplate;
231
+ context.parentTemplate = null;
232
+
233
+ let viewsDirs = ['.'];
234
+ if (context.options && context.options.settings && context.options.settings.views) {
235
+ viewsDirs = normalizeViews(context.options.settings.views);
236
+ } else if (context.options && context.options.views) {
237
+ viewsDirs = normalizeViews(context.options.views);
238
+ }
239
+
240
+ const fileContent = readParentSource(parentName, viewsDirs);
241
+
242
+ // If parent not found by direct resolution, try recursive search
243
+ // (search subfolders) using the new helper. This ensures extends
244
+ // can locate parent templates placed in nested directories.
245
+ if (!fileContent) {
246
+ const found = findTemplateInViews(parentName, viewsDirs);
247
+ if (found) {
248
+ const fileContent2 = fs.readFileSync(found, 'utf8');
249
+ const key = path.dirname(found) + '\0' + parentName;
250
+ return getParentSource(key, () => fileContent2);
251
+ }
252
+ }
253
+
254
+ const parentTokens = tokenize(fileContent);
255
+ const parentParser = new Parser(parentTokens, getTagRegistry());
256
+ const parentNodes = parentParser.parse();
257
+
258
+ if (parentParser.blocks) {
259
+ for (const [name, blockList] of Object.entries(parentParser.blocks)) {
260
+ if (!context.blocks[name]) {
261
+ context.blocks[name] = [];
262
+ }
263
+ for (const blockNode of blockList) {
264
+ if (!context.blocks[name].includes(blockNode)) {
265
+ context.blocks[name].push(blockNode);
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ return await renderASTAsync(parentNodes, context);
272
+ }
273
+
274
+ return output;
275
+ }
276
+
277
+ /**
278
+ * Synchronous renderAST - throws if any node returns a Promise.
279
+ */
280
+ function renderAST(nodes, context) {
281
+ context.parentTemplate = null;
282
+ const output = nodes.map(node => {
283
+ const result = node.render(context);
284
+ if (result instanceof Promise) {
285
+ throw new Error('Async node encountered during sync render. Use asyncRender() instead.');
286
+ }
287
+ return result;
288
+ }).join('');
289
+
290
+ if (context.parentTemplate) {
291
+ const parentName = context.parentTemplate;
292
+ context.parentTemplate = null;
293
+
294
+ let viewsDirs = ['.'];
295
+ if (context.options && context.options.settings && context.options.settings.views) {
296
+ viewsDirs = normalizeViews(context.options.settings.views);
297
+ } else if (context.options && context.options.views) {
298
+ viewsDirs = normalizeViews(context.options.views);
299
+ }
300
+
301
+ const fileContent = readParentSource(parentName, viewsDirs);
302
+
303
+ const parentTokens = tokenize(fileContent);
304
+ const parentParser = new Parser(parentTokens, getTagRegistry());
305
+ const parentNodes = parentParser.parse();
306
+
307
+ if (parentParser.blocks) {
308
+ for (const [name, blockList] of Object.entries(parentParser.blocks)) {
309
+ if (!context.blocks[name]) {
310
+ context.blocks[name] = [];
311
+ }
312
+ for (const blockNode of blockList) {
313
+ if (!context.blocks[name].includes(blockNode)) {
314
+ context.blocks[name].push(blockNode);
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ return renderAST(parentNodes, context);
321
+ }
322
+
323
+ return output;
324
+ }
325
+
326
+ /**
327
+ * Compiles a template string into a renderable object.
328
+ */
329
+ function compile(templateStr, options = {}) {
330
+ return getCompiled(templateStr, options, (tmpl, opts) => {
331
+ const tokens = tokenize(tmpl);
332
+ const parser = new Parser(tokens, getTagRegistry());
333
+ const nodes = parser.parse();
334
+
335
+ // Collect partial definitions at compile time
336
+ const partialDefs = {};
337
+ function collectPartials(nodeList) {
338
+ for (const node of nodeList) {
339
+ if (node.constructor.name === 'PartialDefNode') {
340
+ partialDefs[node.name] = node;
341
+ }
342
+ if (node.body) {
343
+ collectPartials(node.body);
344
+ }
345
+ if (node.elifBranches) {
346
+ for (const branch of node.elifBranches) {
347
+ collectPartials(branch.body);
348
+ }
349
+ }
350
+ if (node.elseBody) {
351
+ collectPartials(node.elseBody);
352
+ }
353
+ }
354
+ }
355
+ collectPartials(nodes);
356
+
357
+ return {
358
+ render: (contextObj = {}) => {
359
+ const processedContextObj = applyContextProcessors({ ...contextObj });
360
+ const context = new Context(processedContextObj, opts);
361
+ context.reset();
362
+ if (parser.blocks) {
363
+ for (const [name, blockList] of Object.entries(parser.blocks)) {
364
+ context.blocks[name] = [...blockList];
365
+ }
366
+ }
367
+ // Register partial definitions from compile-time
368
+ for (const [name, partial] of Object.entries(partialDefs)) {
369
+ context.registerPartial(name, partial);
370
+ }
371
+ return renderAST(nodes, context);
372
+ },
373
+ renderWith: (contextObj = {}, callOptions = {}) => {
374
+ const processedContextObj = applyContextProcessors({ ...contextObj });
375
+ const mergedOpts = { ...opts, ...callOptions };
376
+ const context = new Context(processedContextObj, mergedOpts);
377
+ context.reset();
378
+ if (parser.blocks) {
379
+ for (const [name, blockList] of Object.entries(parser.blocks)) {
380
+ context.blocks[name] = [...blockList];
381
+ }
382
+ }
383
+ for (const [name, partial] of Object.entries(partialDefs)) {
384
+ context.registerPartial(name, partial);
385
+ }
386
+ return renderAST(nodes, context);
387
+ },
388
+ asyncRender: async (contextObj = {}) => {
389
+ const processedContextObj = applyContextProcessors({ ...contextObj });
390
+ const context = new Context(processedContextObj, opts);
391
+ context.blocks = {};
392
+ if (parser.blocks) {
393
+ for (const [name, blockList] of Object.entries(parser.blocks)) {
394
+ context.blocks[name] = [...blockList];
395
+ }
396
+ }
397
+ for (const [name, partial] of Object.entries(partialDefs)) {
398
+ context.registerPartial(name, partial);
399
+ }
400
+ return await renderASTAsync(nodes, context);
401
+ },
402
+ asyncRenderWith: async (contextObj = {}, callOptions = {}) => {
403
+ const processedContextObj = applyContextProcessors({ ...contextObj });
404
+ const mergedOpts = { ...opts, ...callOptions };
405
+ const context = new Context(processedContextObj, mergedOpts);
406
+ context.blocks = {};
407
+ if (parser.blocks) {
408
+ for (const [name, blockList] of Object.entries(parser.blocks)) {
409
+ context.blocks[name] = [...blockList];
410
+ }
411
+ }
412
+ for (const [name, partial] of Object.entries(partialDefs)) {
413
+ context.registerPartial(name, partial);
414
+ }
415
+ return await renderASTAsync(nodes, context);
416
+ },
417
+ renderBlock: (blockName, contextObj = {}) => {
418
+ const processedContextObj = applyContextProcessors({ ...contextObj });
419
+ const context = new Context(processedContextObj, opts);
420
+ context.blocks = {};
421
+ if (parser.blocks) {
422
+ for (const [name, blockList] of Object.entries(parser.blocks)) {
423
+ context.blocks[name] = [...blockList];
424
+ }
425
+ }
426
+ for (const [name, partial] of Object.entries(partialDefs)) {
427
+ context.registerPartial(name, partial);
428
+ }
429
+ renderAST(nodes, context);
430
+ const blockStack = context.blocks[blockName];
431
+ if (!blockStack || blockStack.length === 0) {
432
+ throw new Error(`Block '${blockName}' not found in template`);
433
+ }
434
+ if (!context.blockRenderIndices) {
435
+ context.blockRenderIndices = {};
436
+ }
437
+ context.blockRenderIndices[blockName] = 0;
438
+ let superVal = '';
439
+ if (blockStack.length > 1) {
440
+ context.blockRenderIndices[blockName] = 1;
441
+ superVal = blockStack[1].render(context);
442
+ }
443
+ context.push({ block: { super: superVal } });
444
+ context.blockRenderIndices[blockName] = 0;
445
+ const result = blockStack[0].body.map(n => n.render(context)).join('');
446
+ context.pop();
447
+ context.blockRenderIndices[blockName] = -1;
448
+ return result;
449
+ },
450
+ renderPartial: (partialName, contextObj = {}) => {
451
+ const processedContextObj = applyContextProcessors({ ...contextObj });
452
+ const context = new Context(processedContextObj, opts);
453
+ for (const [name, partial] of Object.entries(partialDefs)) {
454
+ context.registerPartial(name, partial);
455
+ }
456
+ const partial = context.getPartial(partialName);
457
+ if (!partial) {
458
+ throw new Error(`Partial '${partialName}' not found`);
459
+ }
460
+ return partial.body.map(n => n.render(context)).join('');
461
+ }
462
+ };
463
+ });
464
+ }
465
+
466
+ /**
467
+ * Convenience rendering function.
468
+ *
469
+ * If `templateStr` looks like a file path with a `#partialName` suffix
470
+ * (e.g. `"home.html#card"`) AND `options.views` is set, the file is
471
+ * loaded from the views dir(s) and only the named partial is rendered.
472
+ * This is the engine's idiomatic way to serve HTMX partial responses.
473
+ *
474
+ * If the string contains template syntax (`{{` or `{%`), it is always
475
+ * treated as a template string (not a file path), so existing call
476
+ * sites that pass template source are unaffected.
477
+ *
478
+ * Without a `#` suffix, the whole template is rendered (existing
479
+ * behavior).
480
+ */
481
+ function render(templateStr, contextObj = {}, options = {}) {
482
+ // Only treat the input as a file path when:
483
+ // 1. It has a `#` and a partial name
484
+ // 2. options.views is configured
485
+ // 3. The string does NOT contain template syntax
486
+ const hashIdx = templateStr.indexOf('#');
487
+ const hasTemplateSyntax = /\{[{%]/.test(templateStr);
488
+ if (hashIdx >= 0 && !hasTemplateSyntax && options && options.views) {
489
+ const fileName = templateStr.slice(0, hashIdx);
490
+ const partialName = templateStr.slice(hashIdx + 1);
491
+ return renderPartialFromFile(fileName, partialName, contextObj, options);
492
+ }
493
+
494
+ const compiled = compile(templateStr, options);
495
+ if (options && Object.keys(options).length > 0) {
496
+ return compiled.renderWith(contextObj, options);
497
+ }
498
+ return compiled.render(contextObj);
499
+ }
500
+
501
+ /**
502
+ * Load a template file from the configured views dir(s), register its
503
+ * partials, and render only the named partial. This is the
504
+ * implementation behind `render("file.html#partial", ...)`.
505
+ */
506
+ function renderPartialFromFile(fileName, partialName, contextObj, options) {
507
+ const { Context } = require('./context');
508
+ const { tokenize } = require('./lexer');
509
+ const { Parser } = require('./parser');
510
+ const { getTagRegistry } = require('./tags/registry');
511
+ const { applyContextProcessors } = require('./context_processors');
512
+
513
+ let viewsDirs = ['.'];
514
+ if (options.settings && options.settings.views) {
515
+ viewsDirs = normalizeViews(options.settings.views);
516
+ } else if (options.views) {
517
+ viewsDirs = normalizeViews(options.views);
518
+ }
519
+
520
+ let fileContent = null;
521
+ let loaded = false;
522
+ // Try the literal name first, then with each supported extension appended.
523
+ const candidates = [];
524
+ if (/\.[a-z0-9]+$/i.test(fileName)) {
525
+ candidates.push(fileName);
526
+ } else {
527
+ candidates.push(fileName + '.html', fileName + '.miki');
528
+ }
529
+ for (const dir of viewsDirs) {
530
+ for (const candidate of candidates) {
531
+ try {
532
+ const fullPath = path.resolve(dir, candidate);
533
+ const relative = path.relative(path.resolve(dir), fullPath);
534
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
535
+ continue;
536
+ }
537
+ fileContent = fs.readFileSync(fullPath, 'utf8');
538
+ loaded = true;
539
+ break;
540
+ } catch {
541
+ // try next candidate
542
+ }
543
+ }
544
+ if (loaded) break;
545
+ }
546
+ // Fallback: try recursive search if not loaded
547
+ if (!loaded) {
548
+ const found = findTemplateInViews(fileName, viewsDirs);
549
+ if (found) {
550
+ fileContent = fs.readFileSync(found, 'utf8');
551
+ loaded = true;
552
+ }
553
+ }
554
+ if (!loaded) {
555
+ throw new Error(
556
+ `Template not found: '${fileName}' in directories ${JSON.stringify(viewsDirs)}`
557
+ );
558
+ }
559
+
560
+ const tokens = tokenize(fileContent);
561
+ const parser = new Parser(tokens, getTagRegistry());
562
+ const nodes = parser.parse();
563
+
564
+ // Build a context to register the partials. We share the partial
565
+ // registry with a fresh context for the actual render.
566
+ const tempContext = new Context({}, options);
567
+ for (const node of nodes) {
568
+ node.render(tempContext);
569
+ }
570
+ const partial = tempContext.getPartial(partialName);
571
+ if (!partial) {
572
+ throw new Error(
573
+ `Partial '${partialName}' not found in template '${fileName}'`
574
+ );
575
+ }
576
+
577
+ const processedContextObj = applyContextProcessors({ ...contextObj });
578
+ const context = new Context(processedContextObj, options);
579
+ context.partialDefs = tempContext.partialDefs;
580
+ return partial.body.map(n => n.render(context)).join('');
581
+ }
582
+
583
+ /**
584
+ * Async rendering function – returns a Promise.
585
+ */
586
+ function asyncRender(templateStr, contextObj = {}, options = {}) {
587
+ const hashIdx = templateStr.indexOf('#');
588
+ const hasTemplateSyntax = /\{[{%]/.test(templateStr);
589
+ if (hashIdx >= 0 && !hasTemplateSyntax && options && options.views) {
590
+ const fileName = templateStr.slice(0, hashIdx);
591
+ const partialName = templateStr.slice(hashIdx + 1);
592
+ return new Promise((resolve, reject) => {
593
+ try {
594
+ resolve(renderPartialFromFile(fileName, partialName, contextObj, options));
595
+ } catch (e) {
596
+ reject(e);
597
+ }
598
+ });
599
+ }
600
+ const compiled = compile(templateStr, options);
601
+ if (options && Object.keys(options).length > 0) {
602
+ return compiled.asyncRenderWith(contextObj, options);
603
+ }
604
+ return compiled.asyncRender(contextObj);
605
+ }
606
+
607
+ /**
608
+ * Express adapter engine (synchronous callback form).
609
+ * Strips Express framework keys from the context so they don't leak
610
+ * into the template scope.
611
+ *
612
+ * If `filePath` contains a `#partialName` suffix, only the named
613
+ * partial is rendered. This enables HTMX-style partial responses:
614
+ *
615
+ * res.render('home#card', { ... });
616
+ */
617
+ function __express(filePath, options, callback) {
618
+ if (typeof callback !== 'function') {
619
+ return __expressAsync(filePath, options);
620
+ }
621
+
622
+ // Detect "file#partial" form
623
+ const hashIdx = filePath.lastIndexOf('#');
624
+ if (hashIdx > 0) {
625
+ const realFilePath = filePath.slice(0, hashIdx);
626
+ const partialName = filePath.slice(hashIdx + 1);
627
+ try {
628
+ const fileContent = fs.readFileSync(realFilePath, 'utf8');
629
+ const renderOptions = {
630
+ views: path.dirname(realFilePath),
631
+ ...(options || {})
632
+ };
633
+ const ctx = stripExpressContext(options);
634
+ const result = renderPartialFromSource(fileContent, partialName, ctx, renderOptions, realFilePath);
635
+ return callback(null, result);
636
+ } catch (err) {
637
+ return callback(err);
638
+ }
639
+ }
640
+
641
+ try {
642
+ const fileContent = fs.readFileSync(filePath, 'utf8');
643
+ const renderOptions = {
644
+ views: options && options.settings ? options.settings.views : path.dirname(filePath),
645
+ ...(options || {})
646
+ };
647
+ // Strip Express framework keys from the context
648
+ const ctx = stripExpressContext(options);
649
+ const result = render(fileContent, ctx, renderOptions);
650
+ return callback(null, result);
651
+ } catch (err) {
652
+ return callback(err);
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Compile a template source string and render only the named partial
658
+ * from it. Used by __express when a view name carries a `#partial`
659
+ * suffix.
660
+ *
661
+ * The challenge: when a template uses `{% extends 'parent' %}`,
662
+ * the partials are typically defined inside `{% block ... %}` tags.
663
+ * The top-level nodes are [ExtendsNode, BlockNode, ...], not the
664
+ * blocks themselves. We need to register all partials regardless of
665
+ * whether their enclosing for/if blocks have data to iterate.
666
+ */
667
+ function renderPartialFromSource(fileContent, partialName, contextObj, options, filePath) {
668
+ const { Context } = require('./context');
669
+ const { tokenize } = require('./lexer');
670
+ const { Parser } = require('./parser');
671
+ const { getTagRegistry } = require('./tags/registry');
672
+ const { applyContextProcessors } = require('./context_processors');
673
+
674
+ const tokens = tokenize(fileContent);
675
+ const parser = new Parser(tokens, getTagRegistry());
676
+ const nodes = parser.parse();
677
+
678
+ // Build a context with the caller's data so for-loops and other
679
+ // constructs iterate properly when collecting partials.
680
+ const processedContextObj = applyContextProcessors({ ...contextObj });
681
+ const tempContext = new Context(processedContextObj, options);
682
+
683
+ // Walk the ENTIRE AST and register every PartialDefNode we find,
684
+ // regardless of whether its enclosing for/if has data. This ensures
685
+ // partials are always available by name even when the caller
686
+ // didn't provide the data the template would need to render them
687
+ // in context.
688
+ function registerAllPartials(nodeList) {
689
+ for (const node of nodeList) {
690
+ if (node.constructor.name === 'PartialDefNode') {
691
+ tempContext.registerPartial(node.name, node);
692
+ }
693
+ if (node.body) registerAllPartials(node.body);
694
+ if (node.elifBranches) for (const b of node.elifBranches) registerAllPartials(b.body);
695
+ if (node.elseBody) registerAllPartials(node.elseBody);
696
+ }
697
+ }
698
+ registerAllPartials(nodes);
699
+
700
+ // Also try rendering top-level nodes so partials defined via
701
+ // {% load %} or other dynamic mechanisms get a chance to register.
702
+ // Errors are swallowed — we only care about partial registration.
703
+ for (const node of nodes) {
704
+ try {
705
+ node.render(tempContext);
706
+ } catch {
707
+ // Ignore
708
+ }
709
+ }
710
+
711
+ // If the template extends a parent, also collect partials from
712
+ // the parent. This handles the common case where partials are
713
+ // defined inside blocks that are part of an extended template.
714
+ if (tempContext.parentTemplate) {
715
+ try {
716
+ const parentName = tempContext.parentTemplate;
717
+ let viewsDirs = ['.'];
718
+ if (options && options.settings && options.settings.views) {
719
+ viewsDirs = normalizeViews(options.settings.views);
720
+ } else if (options && options.views) {
721
+ viewsDirs = normalizeViews(options.views);
722
+ }
723
+ for (const dir of viewsDirs) {
724
+ const parentPath = require('path').resolve(dir, parentName);
725
+ const relative = require('path').relative(require('path').resolve(dir), parentPath);
726
+ if (relative.startsWith('..') || require('path').isAbsolute(relative)) continue;
727
+ if (!require('fs').existsSync(parentPath)) continue;
728
+ const parentContent = require('fs').readFileSync(parentPath, 'utf8');
729
+ const pTokens = tokenize(parentContent);
730
+ const pParser = new Parser(pTokens, getTagRegistry());
731
+ const pNodes = pParser.parse();
732
+ registerAllPartials(pNodes);
733
+ for (const pNode of pNodes) {
734
+ try { pNode.render(tempContext); } catch {}
735
+ }
736
+ break;
737
+ }
738
+ } catch {
739
+ // Ignore parent resolution errors
740
+ }
741
+ }
742
+
743
+ const partial = tempContext.getPartial(partialName);
744
+ if (!partial) {
745
+ throw new Error(
746
+ `Partial '${partialName}' not found in template '${filePath || 'inline'}'`
747
+ );
748
+ }
749
+
750
+ // Build a fresh context for the actual render using the caller's
751
+ // data. Share the partial registry from the temp context.
752
+ const context = new Context(processedContextObj, options);
753
+ context.partialDefs = tempContext.partialDefs;
754
+ return partial.body.map(n => n.render(context)).join('');
755
+ }
756
+
757
+ /**
758
+ * Strip Express-specific framework keys from a context object.
759
+ * Internal keys (those starting with `_`), `settings`, and `cache` are removed.
760
+ */
761
+ function stripExpressContext(options) {
762
+ if (!options) return {};
763
+ const ctx = {};
764
+ for (const [k, v] of Object.entries(options)) {
765
+ if (!k.startsWith('_') && k !== 'settings' && k !== 'cache') {
766
+ ctx[k] = v;
767
+ }
768
+ }
769
+ return ctx;
770
+ }
771
+
772
+ /**
773
+ * Async view engine for Express 5+. Returns a Promise that resolves
774
+ * to the rendered HTML. Use this when your templates have async helpers.
775
+ *
776
+ * app.engine('html', miki.__expressAsync);
777
+ */
778
+ function __expressAsync(filePath, options) {
779
+ return new Promise((resolve, reject) => {
780
+ // Detect "file#partial" form
781
+ const hashIdx = filePath.lastIndexOf('#');
782
+ if (hashIdx > 0) {
783
+ const realFilePath = filePath.slice(0, hashIdx);
784
+ const partialName = filePath.slice(hashIdx + 1);
785
+ try {
786
+ const fileContent = fs.readFileSync(realFilePath, 'utf8');
787
+ const renderOptions = {
788
+ views: path.dirname(realFilePath),
789
+ ...(options || {})
790
+ };
791
+ const ctx = stripExpressContext(options);
792
+ resolve(renderPartialFromSource(fileContent, partialName, ctx, renderOptions, realFilePath));
793
+ } catch (err) {
794
+ reject(err);
795
+ }
796
+ return;
797
+ }
798
+
799
+ try {
800
+ const fileContent = fs.readFileSync(filePath, 'utf8');
801
+ const renderOptions = {
802
+ views: options && options.settings ? options.settings.views : path.dirname(filePath),
803
+ ...(options || {})
804
+ };
805
+ const ctx = stripExpressContext(options);
806
+ // Use asyncRender so async helpers are awaited
807
+ asyncRender(fileContent, ctx, renderOptions)
808
+ .then(resolve)
809
+ .catch(reject);
810
+ } catch (err) {
811
+ reject(err);
812
+ }
813
+ });
814
+ }
815
+
816
+ /**
817
+ * Express integration helper. Returns a function suitable for
818
+ * `app.engine(ext, fn)` that makes `res.render('view#partial', ...)`
819
+ * just work without any extra middleware.
820
+ *
821
+ * Usage:
822
+ *
823
+ * const miki = require('miki-template');
824
+ * const app = express();
825
+ * app.engine('html', miki.express());
826
+ * app.set('view engine', 'html');
827
+ * app.set('views', './views');
828
+ *
829
+ * // Full page:
830
+ * app.get('/', (req, res) => res.render('home', { user }));
831
+ *
832
+ * // HTMX partial — just one named partial from a template:
833
+ * app.get('/partials/:name', (req, res) =>
834
+ * res.render(`home#${req.params.name}`, { user })
835
+ * );
836
+ *
837
+ * The wrapper detects a `#partialName` suffix in the view name BEFORE
838
+ * Express's view lookup runs, so it never tries to open a file like
839
+ * `home#card.html`. It resolves the real file, calls the partial
840
+ * renderer, and sends the result.
841
+ */
842
+ function express(options = {}) {
843
+ const baseEngine = options.async ? __expressAsync : __express;
844
+ return function mikiViewEngine(filePath, engineOptions, callback) {
845
+ // Detect the partial selector in the view name. Express passes
846
+ // the resolved file path here — if the user wrote
847
+ // `res.render('home#card', ...)`, Express will have already
848
+ // tried (and failed) to resolve `home#card.html`. To support
849
+ // partials, we need to intercept BEFORE Express resolves the
850
+ // view. We do that by hooking `res.render` when this engine is
851
+ // installed.
852
+ // For the direct path (when called from `res.renderPartial` or
853
+ // from our own `res.render` shim), we honor the `#partial`
854
+ // suffix here.
855
+ if (typeof callback !== 'function') {
856
+ return Promise.reject(
857
+ new Error('miki.express() engine must be called via res.render() with a callback')
858
+ );
859
+ }
860
+ return baseEngine(filePath, engineOptions, callback);
861
+ };
862
+ }
863
+
864
+ /**
865
+ * One-shot Express setup. Wires `app.engine('html', ...)` and
866
+ * installs a `res.render` shim so that `res.render('view#partial')`
867
+ * works without any extra middleware. This is the recommended way
868
+ * to integrate miki-template with Express.
869
+ *
870
+ * Usage:
871
+ *
872
+ * const miki = require('miki-template');
873
+ * const app = express();
874
+ * miki.setupExpress(app, { extension: 'html', views: './views' });
875
+ *
876
+ * app.get('/partials/:name', (req, res) =>
877
+ * res.render(`home#${req.params.name}`, { user: req.user })
878
+ * );
879
+ */
880
+ function setupExpress(app, opts = {}) {
881
+ const ext = (opts.extension || 'html').replace(/^\.+/, '');
882
+ const async = !!opts.async;
883
+
884
+ // Set view engine if not already set
885
+ if (!app.get('view engine')) {
886
+ app.set('view engine', ext);
887
+ }
888
+ // If opts.views is provided, always set it (so users can set
889
+ // views via setupExpress without an extra app.set call).
890
+ if (opts.views) {
891
+ app.set('views', opts.views);
892
+ }
893
+
894
+ // Install the raw engine so Express can use it
895
+ app.engine(ext, async ? __expressAsync : __express);
896
+
897
+ // Capture config in a closure so patchedRender can use it even
898
+ // when called before the request handler runs.
899
+ const configExt = ext;
900
+ const configViews = opts.views;
901
+
902
+ // Capture the original res.render so we can dispatch on #partial
903
+ const originalRender = app.response.render;
904
+ app.response.render = function patchedRender(view, locals, callback) {
905
+ // Normalize arguments: (view, callback) or (view, locals, callback)
906
+ let cb = callback;
907
+ let opts = locals;
908
+ if (typeof locals === 'function') {
909
+ cb = locals;
910
+ opts = {};
911
+ }
912
+ opts = opts || {};
913
+ // Inject settings so the engine can find the views dir
914
+ if (!opts.settings) {
915
+ opts.settings = this.req && this.req.app ? this.req.app.settings : {};
916
+ }
917
+
918
+ // If the view name has a `#partial` suffix, handle it ourselves
919
+ // and never delegate to Express's view lookup.
920
+ if (typeof view === 'string' && view.includes('#')) {
921
+ const hashIdx = view.lastIndexOf('#');
922
+ const fileName = view.slice(0, hashIdx);
923
+ const partialName = view.slice(hashIdx + 1);
924
+
925
+ // Resolve the real file path. Prefer opts.views (set by
926
+ // setupExpress), then app.get('views'), then the default.
927
+ const extname = require('path').extname(fileName);
928
+ const candidates = extname
929
+ ? [fileName]
930
+ : [fileName + '.' + configExt, fileName + '.miki'];
931
+ let viewsDir = configViews
932
+ || (this.req && this.req.app ? this.req.app.get('views') : null)
933
+ || process.cwd() + '/views';
934
+ // Normalize arrays or accidental file paths to directories
935
+ if (Array.isArray(viewsDir)) {
936
+ const arr = normalizeViews(viewsDir);
937
+ viewsDir = arr.length > 0 ? arr[0] : viewsDir[0];
938
+ } else {
939
+ try {
940
+ const st = fs.statSync(viewsDir);
941
+ if (st.isFile()) viewsDir = path.dirname(viewsDir);
942
+ } catch (e) {
943
+ // ignore
944
+ }
945
+ }
946
+ let filePath = null;
947
+ for (const cand of candidates) {
948
+ const p = require('path').resolve(viewsDir, cand);
949
+ if (require('fs').existsSync(p)) {
950
+ filePath = p;
951
+ break;
952
+ }
953
+ }
954
+ // Fallback: recursive search for templates in subfolders
955
+ if (!filePath) {
956
+ const found = findTemplateInViews(fileName, [viewsDir]);
957
+ if (found) filePath = found;
958
+ }
959
+ if (!filePath) {
960
+ const err = new Error(
961
+ `Failed to lookup view "${view}" in views directory "${viewsDir}"`
962
+ );
963
+ if (typeof cb === 'function') return cb(err);
964
+ throw err;
965
+ }
966
+
967
+ const fileContent = require('fs').readFileSync(filePath, 'utf8');
968
+ try {
969
+ const html = renderPartialFromSource(
970
+ fileContent,
971
+ partialName,
972
+ stripExpressContext(opts),
973
+ Object.assign({ views: viewsDir }, opts),
974
+ fileName
975
+ );
976
+ if (typeof cb === 'function') {
977
+ return cb(null, html);
978
+ }
979
+ this.send(html);
980
+ return;
981
+ } catch (e) {
982
+ if (typeof cb === 'function') return cb(e);
983
+ throw e;
984
+ }
985
+ }
986
+
987
+ // No partial selector: behave exactly like the original res.render
988
+ if (cb) {
989
+ return originalRender.call(this, view, opts, cb);
990
+ }
991
+ return originalRender.call(this, view, opts);
992
+ };
993
+ }
994
+
995
+ /**
996
+ * Express middleware helper. Adds a `res.renderPartial(view, locals)`
997
+ * method that renders only the named partial (after a `#`) from a
998
+ * view file. The view name follows the same syntax as `render()`:
999
+ *
1000
+ * app.use(miki.expressPartialRenderer());
1001
+ * app.get('/card', (req, res) => res.renderPartial('home#card', { user }));
1002
+ */
1003
+ function expressPartialRenderer() {
1004
+ return function (req, res, next) {
1005
+ res.renderPartial = function (view, locals = {}) {
1006
+ // Compose an Express-shaped options object so the engine can
1007
+ // find the view file. We mirror what res.render provides.
1008
+ const opts = Object.assign({}, res.locals, locals, {
1009
+ settings: req.app.settings
1010
+ });
1011
+ const hashIdx = view.lastIndexOf('#');
1012
+ if (hashIdx < 0) {
1013
+ // No partial selector: just delegate to res.render
1014
+ return res.render(view, locals);
1015
+ }
1016
+ const fileName = view.slice(0, hashIdx);
1017
+ const partialName = view.slice(hashIdx + 1);
1018
+ // Find the actual file the way Express would
1019
+ const ext = require('path').extname(fileName);
1020
+ const candidates = ext
1021
+ ? [fileName]
1022
+ : [fileName + '.html', fileName + '.miki'];
1023
+ let viewsDir = req.app.get('views');
1024
+ // Normalize viewsDir to a directory if needed
1025
+ if (Array.isArray(viewsDir)) {
1026
+ const arr = normalizeViews(viewsDir);
1027
+ viewsDir = arr.length > 0 ? arr[0] : viewsDir[0];
1028
+ } else {
1029
+ try {
1030
+ const st = fs.statSync(viewsDir);
1031
+ if (st.isFile()) viewsDir = path.dirname(viewsDir);
1032
+ } catch (e) {}
1033
+ }
1034
+ let filePath = null;
1035
+ for (const cand of candidates) {
1036
+ try {
1037
+ filePath = require('path').resolve(viewsDir, cand);
1038
+ if (require('fs').existsSync(filePath)) break;
1039
+ filePath = null;
1040
+ } catch { filePath = null; }
1041
+ }
1042
+ // Fallback: recursive search for templates in subfolders
1043
+ if (!filePath) {
1044
+ const found = findTemplateInViews(fileName, Array.isArray(viewsDir) ? viewsDir : [viewsDir]);
1045
+ if (found) filePath = found;
1046
+ }
1047
+ if (!filePath) {
1048
+ return res.status(404).send(
1049
+ `Template not found: '${fileName}' in '${viewsDir}'`
1050
+ );
1051
+ }
1052
+ try {
1053
+ const html = renderPartialFromSource(
1054
+ require('fs').readFileSync(filePath, 'utf8'),
1055
+ partialName,
1056
+ stripExpressContext(opts),
1057
+ { views: viewsDir, ...opts },
1058
+ fileName
1059
+ );
1060
+ res.send(html);
1061
+ } catch (err) {
1062
+ res.status(500).send(err.message);
1063
+ }
1064
+ };
1065
+ next();
1066
+ };
1067
+ }
1068
+
1069
+ module.exports = {
1070
+ // Export the finder to allow unit tests to call it directly
1071
+ findTemplateInViews,
1072
+ setAppTemplateDirNames,
1073
+ getAppTemplateDirNames,
1074
+ compile,
1075
+ render,
1076
+ asyncRender,
1077
+ renderPartialFromFile,
1078
+ renderPartialFromSource,
1079
+ __express,
1080
+ __expressAsync,
1081
+ express,
1082
+ setupExpress,
1083
+ expressPartialRenderer,
1084
+ stripExpressContext,
1085
+ clearCache,
1086
+ registerTag,
1087
+ registerFilter,
1088
+ getFilter,
1089
+ registerHelper,
1090
+ registerContextProcessor,
1091
+ clearContextProcessors,
1092
+ SafeString,
1093
+ markSafe,
1094
+ isSafe,
1095
+ escapeHtml,
1096
+ // i18n
1097
+ registerTranslation: i18n.registerTranslation,
1098
+ unregisterTranslation: i18n.unregisterTranslation,
1099
+ setLanguage: i18n.setLanguage,
1100
+ getLanguage: i18n.getLanguage,
1101
+ setFallbackLanguage: i18n.setFallbackLanguage,
1102
+ getFallbackLanguage: i18n.getFallbackLanguage,
1103
+ getAvailableLanguages: i18n.getAvailableLanguages,
1104
+ // Plugin/filter libraries
1105
+ registerLibrary: libraries.registerLibrary,
1106
+ unregisterLibrary: libraries.unregisterLibrary,
1107
+ getLibrary: libraries.getLibrary,
1108
+ getLibraryNames: libraries.getLibraryNames,
1109
+ hasLibrary: libraries.hasLibrary,
1110
+ registerLibraryFromPath: libraries.registerLibraryFromPath,
1111
+ activateLibrary: libraries.activateLibrary
1112
+ };