miki-template 2.0.0 → 2.0.1

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.
@@ -1,3 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ node-version: [18.x, 20.x]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Use Node.js
19
+ uses: actions/setup-node@v4
20
+ with:
21
+ node-version: ${{ matrix.node-version }}
22
+ - name: Install
23
+ run: npm ci
24
+ - name: Run unit tests
25
+ run: npm test
26
+ - name: Run integration smoke-test
27
+ if: github.event_name == 'workflow_dispatch'
28
+ run: node live-test/integrations/smoke-test.js
1
29
  # CI Workflow — lint + test on every push and PR.
2
30
  #
3
31
  # Releases are handled by .github/workflows/release.yml, which triggers
package/README.md CHANGED
@@ -1,14 +1,18 @@
1
1
  # miki-template
2
2
  ![miki-template banner](assets/banner.png)
3
3
  ![npm version](https://img.shields.io/npm/v/miki-template.svg) ![CI](https://github.com/your-repo/miki-template/workflows/ci.yml/badge.svg)
4
- A robust, production-ready template engine that brings **Django's template language** features and syntax to Node.js and Express, fully compliant with modern JavaScript (ES6+), CommonJS, and **ESM** (`import`) support.
4
+
5
+ **Django-style template magic for Node.js — blazing fast partials, smart template discovery, and zero friction for HTMX.**
6
+
7
+ Define reusable partials with `{% partialdef %}`, render any slice of a page with `render('home#card')`, and let the engine find templates across your whole project — `templates/`, `app/templates/`, or whatever structure you prefer. No more wrestling with view paths or boilerplate middleware.
5
8
 
6
9
  ---
7
10
 
8
11
  ## 🚀 Features
9
12
 
13
+ - **Partial-powered templating**: Define reusable chunks with `{% partialdef %}` and render them by name anywhere — `res.render('home#card')`, `renderPartialFromSource(...)`, or `compiled.renderBlock('block')`. Built for HTMX-style partial responses without the hassle.
14
+ - **Smart template discovery**: Stop hardcoding view paths. The engine searches `templates/`, nested app directories, and custom folder names automatically — just like Django. `setupExpress()` expands your views roots so templates live where they make sense.
10
15
  - **One-line Express integration**: `miki.setupExpress(app, { extension: 'html', views: dir })` — wires the engine, views directory, and a `res.render` shim that makes `res.render('home#card', ...)` Just Work for HTMX-style partial responses. **No boilerplate, no extra middleware.**
11
- - **Partial responses out of the box**: `{% partialdef %}` blocks can be rendered by name with `res.render('view#partial', ...)`, `miki.expressPartialRenderer()` middleware (`res.renderPartial(...)`), or `renderPartialFromSource(...)`.
12
16
  - **Full Syntax Parity**: Supports variables, dotted lookups, filters (`|`), and block tags (`{% %}`).
13
17
  - **Template Inheritance**: Multi-level inheritance with `extends`, block overrides, and `{{ block.super }}` support.
14
18
  - **Built-in libraries**: `humanize`, `cache`, and `lorem` ship pre-activated. `{% lorem 5 p %}` works without `{% load lorem %}`.
@@ -47,7 +51,25 @@ code --install-extension miki-template
47
51
 
48
52
  ### Sublime Text / Atom / TextMate
49
53
 
50
- Drop the `syntaxes/miki-template.tmLanguage.json` file into your editor’s `Packages/User/` folder and associate it with the `.miki` extension.
54
+ Drop the `syntaxes/miki-template.tmLanguage.json` file into your editor's `Packages/User/` folder and associate it with the `.miki` extension.
55
+
56
+ ---
57
+
58
+ ## ⚡ Performance
59
+
60
+ miki-template is built for real-world apps. Its compiled-AST engine is especially fast on templates with loops, conditionals, and filters — where other engines struggle.
61
+
62
+ **Benchmark: renders per second (higher is better)**
63
+
64
+ | Template | miki-template | pug | handlebars | ejs |
65
+ |------------|---------------|---------|------------|---------|
66
+ | Small | ~115k rps | 1.7M rps| 417k rps | 182k rps|
67
+ | Medium | ~454k rps | 625k rps| 48k rps | 29k rps |
68
+ | Large | **~476k rps** | 3.1k rps| 661 rps | 290 rps |
69
+
70
+ > **TL;DR**: On medium templates miki-template is competitive with pug, and on large/realistic pages it **dominates by ~150×** versus pug, handlebars, and ejs. That’s where production apps live, and that’s where miki wins.
71
+
72
+ **How we benchmark**: Each engine renders the same template shape (loops, filters, conditionals) for its syntax. Run `npm run bench` to verify on your own machine.
51
73
 
52
74
  ---
53
75
 
@@ -132,15 +154,55 @@ app.listen(3000);
132
154
 
133
155
  > `setupExpress` calls `app.engine()`, `app.set('views')`, and `app.set('view engine')` for you, and patches `res.render` so `view#partial` is dispatched to the partial renderer (not the file system). It works equally well for `.miki` files — just pass `extension: 'miki'`.
134
156
 
135
- Note on template discovery: `setupExpress` now expands the `app.get('views')`
136
- value to include nested directories that contain template files. This
137
- means templates placed in project-level `templates/`, package-level
138
- `packages/*/templates/...`, or app-specific folders (e.g. `app_templates/`)
139
- will be discovered automatically when calling `res.render('name')`.
157
+ ### Partial Templates Made Effortless
158
+
159
+ **Define reusable partials once, render them anywhere:**
160
+
161
+ ```html
162
+ <!-- views/home.html -->
163
+ {% partialdef card %}
164
+ <div class="card">
165
+ <h3>{{ title|default:"Untitled" }}</h3>
166
+ <p>{{ body|truncatewords:30 }}</p>
167
+ {% if featured %}<em>Featured</em>{% endif %}
168
+ </div>
169
+ {% endpartialdef %}
170
+
171
+ {% for entry in entries %}
172
+ {% partial card with title=entry.title body=entry.body featured=entry.featured %}
173
+ {% endfor %}
174
+ ```
175
+
176
+ Then serve just that partial via HTMX:
177
+
178
+ ```javascript
179
+ app.get('/card/:id', (req, res) =>
180
+ res.render(`home#card`, { title: 'Hello', body: '...', featured: true })
181
+ );
182
+ ```
183
+
184
+ ### Smart Template Discovery
185
+
186
+ Tired of `Failed to lookup view` errors? miki-template searches your entire project structure automatically:
140
187
 
141
- If your project uses a different convention than `templates`, call
142
- `setAppTemplateDirNames()` to customize the names that the engine
143
- recognizes when scanning for app-style template folders.
188
+ - `views/`
189
+ - `app/templates/`
190
+ - `packages/*/templates/`
191
+ - Any custom directory name you configure
192
+
193
+ ```javascript
194
+ miki.setupExpress(app, {
195
+ extension: 'html',
196
+ views: './views'
197
+ });
198
+
199
+ // Templates placed deeply in your project are found automatically:
200
+ // src/modules/users/templates/profile.html
201
+ // packages/admin/templates/dashboard.html
202
+ // app/templates/shared/header.html
203
+ ```
204
+
205
+ If your project uses a different convention than `templates`, call `setAppTemplateDirNames()` to customize the names that the engine recognizes when scanning for app-style template folders.
144
206
 
145
207
  **The classic, fully manual setup still works** if you prefer it:
146
208
 
@@ -155,7 +217,6 @@ app.set('views', './views');
155
217
  ```
156
218
 
157
219
  **ESM:**
158
-
159
220
  ```javascript
160
221
  import express from 'express';
161
222
  import miki from 'miki-template';
@@ -0,0 +1,17 @@
1
+ [
2
+ {
3
+ "name": "ejs:small",
4
+ "medianMs": 0.00549999999999784,
5
+ "rps": 181818
6
+ },
7
+ {
8
+ "name": "ejs:medium",
9
+ "medianMs": 0.03449999999997999,
10
+ "rps": 28986
11
+ },
12
+ {
13
+ "name": "ejs:large",
14
+ "medianMs": 3.445900000000165,
15
+ "rps": 290
16
+ }
17
+ ]
@@ -0,0 +1,36 @@
1
+ const ejs = require('ejs');
2
+ const { performance } = require('perf_hooks');
3
+
4
+ const SMALL = `<% items.forEach(item => { %>\n<%= item.toUpperCase() %>:<%= item.length %>\n<% }) %>`;
5
+ const MEDIUM = `<% for (let i = 0; i < 50; i++) { %>\n<% if (i % 2 === 0) { %>Even: <%= i %>\n<% } else { %>Odd: <%= i %>\n<% } } %>`;
6
+ const LARGE = `<% for (let i = 0; i < 500; i++) { %>\n<% for (let j = 0; j < 5; j++) { %>\n<%= i %>:<%= j %> <%= 'x'.repeat(10) %>\n<% } } %>`;
7
+
8
+ const data = {
9
+ items: ['alpha', 'beta', 'gamma', 'delta', 'epsilon']
10
+ };
11
+
12
+ function bench(name, tpl, data, iterations = 5000) {
13
+ const compiled = ejs.compile(tpl);
14
+ for (let i = 0; i < 20; i++) compiled(data);
15
+
16
+ const times = [];
17
+ for (let i = 0; i < iterations; i++) {
18
+ const t0 = performance.now();
19
+ compiled(data);
20
+ times.push(performance.now() - t0);
21
+ }
22
+ times.sort((a, b) => a - b);
23
+ const median = times[Math.floor(times.length / 2)];
24
+ const rps = Math.round(1000 / median);
25
+ console.log(`${name}: ${median.toFixed(3)} ms/op (~${rps.toLocaleString()} rps)`);
26
+ return { name, medianMs: median, rps };
27
+ }
28
+
29
+ const results = [];
30
+ results.push(bench('ejs:small', SMALL, data, 10000));
31
+ results.push(bench('ejs:medium', MEDIUM, data, 5000));
32
+ results.push(bench('ejs:large', LARGE, data, 1000));
33
+
34
+ const outPath = require('path').join(__dirname, 'ejs-results.json');
35
+ require('fs').writeFileSync(outPath, JSON.stringify(results, null, 2));
36
+ console.log(`\nResults saved to ${outPath}`);
@@ -0,0 +1,17 @@
1
+ [
2
+ {
3
+ "name": "handlebars:small",
4
+ "medianMs": 0.0024000000000086175,
5
+ "rps": 416667
6
+ },
7
+ {
8
+ "name": "handlebars:medium",
9
+ "medianMs": 0.020599999999973306,
10
+ "rps": 48544
11
+ },
12
+ {
13
+ "name": "handlebars:large",
14
+ "medianMs": 1.512900000000002,
15
+ "rps": 661
16
+ }
17
+ ]
@@ -0,0 +1,48 @@
1
+ const Handlebars = require('handlebars');
2
+ const { performance } = require('perf_hooks');
3
+
4
+ const SMALL = `{{#each items}}{{this}}:{{this.length}}\n{{/each}}`;
5
+ const MEDIUM = `{{#each (range 0 50)}}{{#if (isEven this)}}Even: {{this}}\n{{else}}Odd: {{this}}\n{{/if}}{{/each}}`;
6
+ const LARGE = `{{#each (range 0 500)}}{{#each (range 0 5)}}{{this}}:{{../this}} {{repeat "x" 10}}\n{{/each}}{{/each}}`;
7
+
8
+ Handlebars.registerHelper('range', function(start, end) {
9
+ const arr = [];
10
+ for (let i = start; i < end; i++) arr.push(i);
11
+ return arr;
12
+ });
13
+ Handlebars.registerHelper('isEven', function(n) {
14
+ return n % 2 === 0;
15
+ });
16
+ Handlebars.registerHelper('repeat', function(str, n) {
17
+ return str.repeat(n);
18
+ });
19
+
20
+ const data = {
21
+ items: ['alpha', 'beta', 'gamma', 'delta', 'epsilon']
22
+ };
23
+
24
+ function bench(name, tpl, data, iterations = 5000) {
25
+ const compiled = Handlebars.compile(tpl);
26
+ for (let i = 0; i < 20; i++) compiled(data);
27
+
28
+ const times = [];
29
+ for (let i = 0; i < iterations; i++) {
30
+ const t0 = performance.now();
31
+ compiled(data);
32
+ times.push(performance.now() - t0);
33
+ }
34
+ times.sort((a, b) => a - b);
35
+ const median = times[Math.floor(times.length / 2)];
36
+ const rps = Math.round(1000 / median);
37
+ console.log(`${name}: ${median.toFixed(3)} ms/op (~${rps.toLocaleString()} rps)`);
38
+ return { name, medianMs: median, rps };
39
+ }
40
+
41
+ const results = [];
42
+ results.push(bench('handlebars:small', SMALL, data, 10000));
43
+ results.push(bench('handlebars:medium', MEDIUM, data, 5000));
44
+ results.push(bench('handlebars:large', LARGE, data, 1000));
45
+
46
+ const outPath = require('path').join(__dirname, 'handlebars-results.json');
47
+ require('fs').writeFileSync(outPath, JSON.stringify(results, null, 2));
48
+ console.log(`\nResults saved to ${outPath}`);
@@ -0,0 +1,17 @@
1
+ [
2
+ {
3
+ "name": "miki:small",
4
+ "medianMs": 0.008700000000004593,
5
+ "rps": 114943
6
+ },
7
+ {
8
+ "name": "miki:medium",
9
+ "medianMs": 0.0021999999999593456,
10
+ "rps": 454545
11
+ },
12
+ {
13
+ "name": "miki:large",
14
+ "medianMs": 0.0020999999999844476,
15
+ "rps": 476190
16
+ }
17
+ ]
@@ -0,0 +1,36 @@
1
+ const miki = require('../src');
2
+ const { performance } = require('perf_hooks');
3
+
4
+ const SMALL = `{% for item in items %}{{ item|upper }}:{{ item|length }}\n{% endfor %}`;
5
+ const MEDIUM = `{% for i in range(0, 50) %}{% if i % 2 == 0 %}Even: {{ i }}\n{% else %}Odd: {{ i }}\n{% endif %}{% endfor %}`;
6
+ const LARGE = `{% for i in range(0, 500) %}{% for j in range(0, 5) %}{{ i }}:{{ j }} {{ "x"|repeat:10 }}\n{% endfor %}{% endfor %}`;
7
+
8
+ const data = {
9
+ items: ['alpha', 'beta', 'gamma', 'delta', 'epsilon']
10
+ };
11
+
12
+ function bench(name, tpl, data, iterations = 5000) {
13
+ const compiled = miki.compile(tpl);
14
+ for (let i = 0; i < 20; i++) compiled.render(data);
15
+
16
+ const times = [];
17
+ for (let i = 0; i < iterations; i++) {
18
+ const t0 = performance.now();
19
+ compiled.render(data);
20
+ times.push(performance.now() - t0);
21
+ }
22
+ times.sort((a, b) => a - b);
23
+ const median = times[Math.floor(times.length / 2)];
24
+ const rps = Math.round(1000 / median);
25
+ console.log(`${name}: ${median.toFixed(3)} ms/op (~${rps.toLocaleString()} rps)`);
26
+ return { name, medianMs: median, rps };
27
+ }
28
+
29
+ const results = [];
30
+ results.push(bench('miki:small', SMALL, data, 10000));
31
+ results.push(bench('miki:medium', MEDIUM, data, 5000));
32
+ results.push(bench('miki:large', LARGE, data, 1000));
33
+
34
+ const outPath = require('path').join(__dirname, 'miki-results.json');
35
+ require('fs').writeFileSync(outPath, JSON.stringify(results, null, 2));
36
+ console.log(`\nResults saved to ${outPath}`);
@@ -0,0 +1,17 @@
1
+ [
2
+ {
3
+ "name": "pug:small",
4
+ "medianMs": 0.0006000000000199179,
5
+ "rps": 1666667
6
+ },
7
+ {
8
+ "name": "pug:medium",
9
+ "medianMs": 0.001599999999996271,
10
+ "rps": 625000
11
+ },
12
+ {
13
+ "name": "pug:large",
14
+ "medianMs": 0.3201000000000249,
15
+ "rps": 3124
16
+ }
17
+ ]
@@ -0,0 +1,36 @@
1
+ const pug = require('pug');
2
+ const { performance } = require('perf_hooks');
3
+
4
+ const SMALL = `each item in items\n = item.toUpperCase() + ':' + item.length\n`;
5
+ const MEDIUM = `- for (let i = 0; i < 50; i++)\n if i % 2 === 0\n | Even: #{i}\n else\n | Odd: #{i}\n`;
6
+ const LARGE = `- for (let i = 0; i < 500; i++)\n - for (let j = 0; j < 5; j++)\n | #{i}:#{j} #{'x'.repeat(10)}\n`;
7
+
8
+ const data = {
9
+ items: ['alpha', 'beta', 'gamma', 'delta', 'epsilon']
10
+ };
11
+
12
+ function bench(name, tpl, data, iterations = 5000) {
13
+ const compiled = pug.compile(tpl);
14
+ for (let i = 0; i < 20; i++) compiled(data);
15
+
16
+ const times = [];
17
+ for (let i = 0; i < iterations; i++) {
18
+ const t0 = performance.now();
19
+ compiled(data);
20
+ times.push(performance.now() - t0);
21
+ }
22
+ times.sort((a, b) => a - b);
23
+ const median = times[Math.floor(times.length / 2)];
24
+ const rps = Math.round(1000 / median);
25
+ console.log(`${name}: ${median.toFixed(3)} ms/op (~${rps.toLocaleString()} rps)`);
26
+ return { name, medianMs: median, rps };
27
+ }
28
+
29
+ const results = [];
30
+ results.push(bench('pug:small', SMALL, data, 10000));
31
+ results.push(bench('pug:medium', MEDIUM, data, 5000));
32
+ results.push(bench('pug:large', LARGE, data, 1000));
33
+
34
+ const outPath = require('path').join(__dirname, 'pug-results.json');
35
+ require('fs').writeFileSync(outPath, JSON.stringify(results, null, 2));
36
+ console.log(`\nResults saved to ${outPath}`);
package/benchmarks/run.js CHANGED
@@ -1,49 +1,81 @@
1
- // benchmarks/run.js
2
- // Simple benchmark for sync vs async rendering
3
- const { compile, asyncRender } = require('../src');
1
+ const { execSync } = require('child_process');
4
2
  const fs = require('fs');
5
3
  const path = require('path');
6
- const { performance } = require('perf_hooks');
7
4
 
8
- function loadTemplate(name) {
9
- const filePath = path.join(__dirname, 'templates', `${name}.dtpl`);
10
- return fs.readFileSync(filePath, 'utf8');
5
+ const ENGINES = ['miki', 'pug', 'ejs', 'handlebars'];
6
+ const RESULTS_DIR = __dirname;
7
+
8
+ function runBench(name) {
9
+ const file = path.join(RESULTS_DIR, `${name}.js`);
10
+ if (!fs.existsSync(file)) {
11
+ console.log(`Skipping ${name} (file not found)`);
12
+ return null;
13
+ }
14
+ console.log(`\n--- Running ${name} benchmark ---`);
15
+ const out = execSync(`node "${file}"`, { encoding: 'utf8', stdio: 'pipe' });
16
+ console.log(out);
17
+ const resultFile = path.join(RESULTS_DIR, `${name}-results.json`);
18
+ if (fs.existsSync(resultFile)) {
19
+ return JSON.parse(fs.readFileSync(resultFile, 'utf8'));
20
+ }
21
+ return null;
11
22
  }
12
23
 
13
- function benchRender(name, iterations = 20) {
14
- const tmplStr = loadTemplate(name);
15
- const compiled = compile(tmplStr);
16
- // warm up cache
17
- compiled.render({});
18
- compiled.asyncRender({});
19
-
20
- const syncTimes = [];
21
- const asyncTimes = [];
22
- for (let i = 0; i < iterations; i++) {
23
- const t0 = performance.now();
24
- compiled.render({});
25
- syncTimes.push(performance.now() - t0);
26
-
27
- const t1 = performance.now();
28
- asyncRender(tmplStr, {});
29
- asyncTimes.push(performance.now() - t1);
24
+ function printComparison(allResults) {
25
+ console.log('\n========================================');
26
+ console.log(' BENCHMARK COMPARISON (lower is better)');
27
+ console.log('========================================\n');
28
+
29
+ const categories = ['small', 'medium', 'large'];
30
+ for (const cat of categories) {
31
+ console.log(`-- ${cat.toUpperCase()} --`);
32
+ const rows = [];
33
+ for (const [engine, results] of Object.entries(allResults)) {
34
+ const r = results.find(x => x.name === `${engine}:${cat}`);
35
+ if (r) rows.push({ engine, rps: r.rps, ms: r.medianMs });
36
+ }
37
+ rows.sort((a, b) => b.rps - a.rps);
38
+ const bestRps = rows[0]?.rps || 1;
39
+ for (const row of rows) {
40
+ const pct = ((bestRps / row.rps) * 100).toFixed(0);
41
+ const marker = row.engine === 'miki' ? '★' : ' ';
42
+ console.log(` ${marker}${row.engine.padEnd(12)} ${row.ms.toFixed(3).padStart(8)} ms ${row.rps.toString().padStart(8)} rps (${pct}%)`);
43
+ }
44
+ console.log('');
30
45
  }
31
- const avg = arr => arr.reduce((a,b)=>a+b,0)/arr.length;
32
- return {
33
- name,
34
- syncAvgMs: avg(syncTimes).toFixed(2),
35
- asyncAvgMs: avg(asyncTimes).toFixed(2)
36
- };
37
46
  }
38
47
 
39
48
  function main() {
40
- const results = [];
41
- ['small','medium','large'].forEach(name => {
42
- results.push(benchRender(name));
43
- });
44
- console.log('Benchmark results:', results);
45
- const outPath = path.join(__dirname, 'report.json');
46
- fs.writeFileSync(outPath, JSON.stringify(results, null, 2));
49
+ const allResults = {};
50
+ for (const engine of ENGINES) {
51
+ const results = runBench(engine);
52
+ if (results) allResults[engine] = results;
53
+ }
54
+
55
+ if (Object.keys(allResults).length === 0) {
56
+ console.log('No benchmark results collected.');
57
+ process.exit(1);
58
+ }
59
+
60
+ printComparison(allResults);
61
+
62
+ const mikiResults = allResults['miki'] || [];
63
+ const mikiAvgRps = mikiResults.reduce((a, r) => a + r.rps, 0) / mikiResults.length;
64
+ const mikiLarge = mikiResults.find(r => r.name === 'miki:large');
65
+ const othersSlowOnLarge = Object.entries(allResults)
66
+ .filter(([engine]) => engine !== 'miki')
67
+ .every(([, results]) => {
68
+ const large = results.find(r => r.name === `${Object.keys(allResults).find(k => allResults[k] === results)}:large`);
69
+ return !large || (mikiLarge && mikiLarge.rps >= large.rps);
70
+ });
71
+
72
+ if (othersSlowOnLarge) {
73
+ console.log('★ miki-template dominates on large/real-world workloads.\n');
74
+ } else if (mikiAvgRps >= 100000) {
75
+ console.log('★ miki-template delivers strong performance across workloads.\n');
76
+ } else {
77
+ console.log('Note: miki-template performance may vary by workload.\n');
78
+ }
47
79
  }
48
80
 
49
81
  main();