miki-template 1.2.0 → 1.3.3

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 (60) hide show
  1. package/.github/release-notes/v1.3.1.md +55 -0
  2. package/CHANGELOG.md +72 -0
  3. package/README.md +43 -26
  4. package/assets/banner.png +0 -0
  5. package/benchmarks/stress.mjs +647 -0
  6. package/dir/base.html +23 -0
  7. package/dir/cmpnt.html +11 -0
  8. package/dir/footer.html +3 -0
  9. package/dir/home.html +80 -0
  10. package/dir/navbar.html +9 -0
  11. package/docs/api.md +20 -3
  12. package/docs/filters.md +301 -133
  13. package/docs/partialdef.md +30 -1
  14. package/docs/tags.md +63 -0
  15. package/docs/usage.md +50 -3
  16. package/eslint.config.mjs +9 -1
  17. package/ex.mjs +33 -0
  18. package/miki-template-extension/.github/workflows/ci.yml +116 -0
  19. package/miki-template-extension/.vscodeignore +7 -0
  20. package/miki-template-extension/CHANGELOG.md +99 -0
  21. package/miki-template-extension/README.md +244 -53
  22. package/miki-template-extension/extension.js +1013 -0
  23. package/miki-template-extension/icon.png +0 -0
  24. package/miki-template-extension/miki-template-1.7.1.vsix +0 -0
  25. package/miki-template-extension/package.json +244 -10
  26. package/miki-template-extension/snippets/miki-template.json +612 -72
  27. package/miki-template-extension/syntaxes/language-configuration.json +101 -13
  28. package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +270 -61
  29. package/miki-template-extension/tests/grammar-tests.json +162 -0
  30. package/miki-template-extension/tests/run-grammar-tests.js +82 -0
  31. package/package.json +7 -4
  32. package/scripts/build-vsix.js +129 -0
  33. package/scripts/build-vsix.ps1 +15 -0
  34. package/src/cache.js +41 -2
  35. package/src/context.js +9 -5
  36. package/src/context_processors.js +9 -2
  37. package/src/esm.mjs +12 -0
  38. package/src/filters.js +472 -24
  39. package/src/index.js +571 -85
  40. package/src/lexer.js +76 -54
  41. package/src/libraries.js +134 -3
  42. package/src/parser.js +22 -2
  43. package/src/security.js +4 -2
  44. package/src/tags/control.js +150 -21
  45. package/src/tags/extra.js +154 -0
  46. package/src/tags/i18n.js +49 -23
  47. package/src/tags/inheritance.js +142 -23
  48. package/src/tags/util.js +102 -24
  49. package/tests/esm.test.mjs +37 -2
  50. package/tests/filters.test.js +155 -0
  51. package/tests/integration/README.md +32 -0
  52. package/tests/integration/features.test.cjs +1681 -0
  53. package/tests/integration/features.test.mjs +1697 -0
  54. package/tests/integration/templates/base.miki +6 -0
  55. package/tests/integration/templates/child.miki +6 -0
  56. package/tests/integration/templates/index.html +17 -0
  57. package/tests/parser.test.js +5 -3
  58. package/tests/partialdef.test.js +40 -1
  59. package/tests/tags.test.js +30 -0
  60. package/miki-template-1.2.0.vsix +0 -0
package/src/index.js CHANGED
@@ -8,10 +8,10 @@ const { tokenize } = require('./lexer');
8
8
  const { Parser } = require('./parser');
9
9
  const { Context } = require('./context');
10
10
 
11
- const { registerContextProcessor, applyContextProcessors } = require('./context_processors');
11
+ const { registerContextProcessor, applyContextProcessors, clearContextProcessors } = require('./context_processors');
12
12
  const { registerFilter, getFilter } = require('./filters');
13
13
  const { SafeString, markSafe, isSafe, escapeHtml } = require('./security');
14
- const { getCompiled, clearCache } = require('./cache');
14
+ const { getCompiled, clearCache, getParentSource, hasParentSource } = require('./cache');
15
15
  const { registerHelper } = require('./tags/helpers');
16
16
  const { registerTag, getTagRegistry } = require('./tags/registry');
17
17
 
@@ -39,52 +39,62 @@ for (const [name, parserFn] of Object.entries(i18nTags.parsers)) {
39
39
  registerTag(name, parserFn);
40
40
  }
41
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
+
42
48
  // i18n module
43
49
  const i18n = require('./i18n');
44
50
 
45
51
  // Plugin/filter library system
46
52
  const libraries = require('./libraries');
47
53
 
48
- // Re-export the library module APIs
49
- module.exports = {
50
- compile,
51
- render,
52
- asyncRender,
53
- __express,
54
- __expressAsync,
55
- stripExpressContext,
56
- clearCache,
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({
57
58
  registerTag,
58
59
  registerFilter,
59
- getFilter,
60
- registerHelper,
61
- registerContextProcessor,
62
- SafeString,
63
- markSafe,
64
- isSafe,
65
- escapeHtml,
66
- // i18n
67
- registerTranslation: i18n.registerTranslation,
68
- unregisterTranslation: i18n.unregisterTranslation,
69
- setLanguage: i18n.setLanguage,
70
- getLanguage: i18n.getLanguage,
71
- setFallbackLanguage: i18n.setFallbackLanguage,
72
- getFallbackLanguage: i18n.getFallbackLanguage,
73
- getAvailableLanguages: i18n.getAvailableLanguages,
74
- // Plugin/filter libraries
75
- registerLibrary: libraries.registerLibrary,
76
- unregisterLibrary: libraries.unregisterLibrary,
77
- getLibrary: libraries.getLibrary,
78
- getLibraryNames: libraries.getLibraryNames,
79
- hasLibrary: libraries.hasLibrary,
80
- registerLibraryFromPath: libraries.registerLibraryFromPath,
81
- activateLibrary: libraries.activateLibrary
82
- };
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
+ }
83
69
 
84
70
  /**
85
71
  * Render an AST recursively to resolve inheritance chain.
86
72
  * Async-aware: awaits Promises from any node.
87
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
+
88
98
  async function renderASTAsync(nodes, context) {
89
99
  context.parentTemplate = null;
90
100
  const parts = [];
@@ -107,28 +117,7 @@ async function renderASTAsync(nodes, context) {
107
117
  viewsDirs = Array.isArray(views) ? views : [views];
108
118
  }
109
119
 
110
- let fileContent = '';
111
- let loaded = false;
112
- for (const dir of viewsDirs) {
113
- try {
114
- const fullPath = path.resolve(dir, parentName);
115
- const relative = path.relative(path.resolve(dir), fullPath);
116
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
117
- throw new Error(`Extends tag attempted path traversal outside allowed views: '${parentName}'`);
118
- }
119
- fileContent = fs.readFileSync(fullPath, 'utf8');
120
- loaded = true;
121
- break;
122
- } catch (e) {
123
- if (e.message && e.message.startsWith('Extends tag attempted path traversal')) {
124
- throw e;
125
- }
126
- }
127
- }
128
-
129
- if (!loaded) {
130
- throw new Error(`Template not found: '${parentName}' in directories ${JSON.stringify(viewsDirs)}`);
131
- }
120
+ const fileContent = readParentSource(parentName, viewsDirs);
132
121
 
133
122
  const parentTokens = tokenize(fileContent);
134
123
  const parentParser = new Parser(parentTokens, getTagRegistry());
@@ -179,28 +168,7 @@ function renderAST(nodes, context) {
179
168
  viewsDirs = Array.isArray(views) ? views : [views];
180
169
  }
181
170
 
182
- let fileContent = '';
183
- let loaded = false;
184
- for (const dir of viewsDirs) {
185
- try {
186
- const fullPath = path.resolve(dir, parentName);
187
- const relative = path.relative(path.resolve(dir), fullPath);
188
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
189
- throw new Error(`Extends tag attempted path traversal outside allowed views: '${parentName}'`);
190
- }
191
- fileContent = fs.readFileSync(fullPath, 'utf8');
192
- loaded = true;
193
- break;
194
- } catch (e) {
195
- if (e.message && e.message.startsWith('Extends tag attempted path traversal')) {
196
- throw e;
197
- }
198
- }
199
- }
200
-
201
- if (!loaded) {
202
- throw new Error(`Template not found: '${parentName}' in directories ${JSON.stringify(viewsDirs)}`);
203
- }
171
+ const fileContent = readParentSource(parentName, viewsDirs);
204
172
 
205
173
  const parentTokens = tokenize(fileContent);
206
174
  const parentParser = new Parser(parentTokens, getTagRegistry());
@@ -272,6 +240,21 @@ function compile(templateStr, options = {}) {
272
240
  }
273
241
  return renderAST(nodes, context);
274
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
+ },
275
258
  asyncRender: async (contextObj = {}) => {
276
259
  const processedContextObj = applyContextProcessors({ ...contextObj });
277
260
  const context = new Context(processedContextObj, opts);
@@ -286,6 +269,21 @@ function compile(templateStr, options = {}) {
286
269
  }
287
270
  return await renderASTAsync(nodes, context);
288
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
+ },
289
287
  renderBlock: (blockName, contextObj = {}) => {
290
288
  const processedContextObj = applyContextProcessors({ ...contextObj });
291
289
  const context = new Context(processedContextObj, opts);
@@ -337,32 +335,171 @@ function compile(templateStr, options = {}) {
337
335
 
338
336
  /**
339
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).
340
350
  */
341
351
  function render(templateStr, contextObj = {}, options = {}) {
342
- return compile(templateStr, options).render(contextObj);
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('');
343
443
  }
344
444
 
345
445
  /**
346
446
  * Async rendering function – returns a Promise.
347
447
  */
348
448
  function asyncRender(templateStr, contextObj = {}, options = {}) {
349
- return compile(templateStr, options).asyncRender(contextObj);
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);
350
467
  }
351
468
 
352
469
  /**
353
470
  * Express adapter engine (synchronous callback form).
354
471
  * Strips Express framework keys from the context so they don't leak
355
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', { ... });
356
478
  */
357
479
  function __express(filePath, options, callback) {
358
- // Detect Express 5+ async view engine signature:
359
- // Express 5 calls engine(path, options) and awaits the return value when
360
- // the engine returns a Promise. We support BOTH signatures.
361
480
  if (typeof callback !== 'function') {
362
- // Express 5 async signature: return a Promise
363
481
  return __expressAsync(filePath, options);
364
482
  }
365
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
+
366
503
  try {
367
504
  const fileContent = fs.readFileSync(filePath, 'utf8');
368
505
  const renderOptions = {
@@ -378,6 +515,109 @@ function __express(filePath, options, callback) {
378
515
  }
379
516
  }
380
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
+
381
621
  /**
382
622
  * Strip Express-specific framework keys from a context object.
383
623
  * Internal keys (those starting with `_`), `settings`, and `cache` are removed.
@@ -401,6 +641,25 @@ function stripExpressContext(options) {
401
641
  */
402
642
  function __expressAsync(filePath, options) {
403
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
+
404
663
  try {
405
664
  const fileContent = fs.readFileSync(filePath, 'utf8');
406
665
  const renderOptions = {
@@ -418,12 +677,238 @@ function __expressAsync(filePath, options) {
418
677
  });
419
678
  }
420
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
+
421
901
  module.exports = {
422
902
  compile,
423
903
  render,
424
904
  asyncRender,
905
+ renderPartialFromFile,
906
+ renderPartialFromSource,
425
907
  __express,
426
908
  __expressAsync,
909
+ express,
910
+ setupExpress,
911
+ expressPartialRenderer,
427
912
  stripExpressContext,
428
913
  clearCache,
429
914
  registerTag,
@@ -431,6 +916,7 @@ module.exports = {
431
916
  getFilter,
432
917
  registerHelper,
433
918
  registerContextProcessor,
919
+ clearContextProcessors,
434
920
  SafeString,
435
921
  markSafe,
436
922
  isSafe,