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.
- package/.github/release-notes/v1.3.1.md +55 -0
- package/CHANGELOG.md +72 -0
- package/README.md +43 -26
- package/assets/banner.png +0 -0
- package/benchmarks/stress.mjs +647 -0
- package/dir/base.html +23 -0
- package/dir/cmpnt.html +11 -0
- package/dir/footer.html +3 -0
- package/dir/home.html +80 -0
- package/dir/navbar.html +9 -0
- package/docs/api.md +20 -3
- package/docs/filters.md +301 -133
- package/docs/partialdef.md +30 -1
- package/docs/tags.md +63 -0
- package/docs/usage.md +50 -3
- package/eslint.config.mjs +9 -1
- package/ex.mjs +33 -0
- package/miki-template-extension/.github/workflows/ci.yml +116 -0
- package/miki-template-extension/.vscodeignore +7 -0
- package/miki-template-extension/CHANGELOG.md +99 -0
- package/miki-template-extension/README.md +244 -53
- package/miki-template-extension/extension.js +1013 -0
- package/miki-template-extension/icon.png +0 -0
- package/miki-template-extension/miki-template-1.7.1.vsix +0 -0
- package/miki-template-extension/package.json +244 -10
- package/miki-template-extension/snippets/miki-template.json +612 -72
- package/miki-template-extension/syntaxes/language-configuration.json +101 -13
- package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +270 -61
- package/miki-template-extension/tests/grammar-tests.json +162 -0
- package/miki-template-extension/tests/run-grammar-tests.js +82 -0
- package/package.json +7 -4
- package/scripts/build-vsix.js +129 -0
- package/scripts/build-vsix.ps1 +15 -0
- package/src/cache.js +41 -2
- package/src/context.js +9 -5
- package/src/context_processors.js +9 -2
- package/src/esm.mjs +12 -0
- package/src/filters.js +472 -24
- package/src/index.js +571 -85
- package/src/lexer.js +76 -54
- package/src/libraries.js +134 -3
- package/src/parser.js +22 -2
- package/src/security.js +4 -2
- package/src/tags/control.js +150 -21
- package/src/tags/extra.js +154 -0
- package/src/tags/i18n.js +49 -23
- package/src/tags/inheritance.js +142 -23
- package/src/tags/util.js +102 -24
- package/tests/esm.test.mjs +37 -2
- package/tests/filters.test.js +155 -0
- package/tests/integration/README.md +32 -0
- package/tests/integration/features.test.cjs +1681 -0
- package/tests/integration/features.test.mjs +1697 -0
- package/tests/integration/templates/base.miki +6 -0
- package/tests/integration/templates/child.miki +6 -0
- package/tests/integration/templates/index.html +17 -0
- package/tests/parser.test.js +5 -3
- package/tests/partialdef.test.js +40 -1
- package/tests/tags.test.js +30 -0
- package/miki-template-1.2.0.vsix +0 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* benchmarks/stress.mjs
|
|
3
|
+
*
|
|
4
|
+
* Strict, opinionated stress + performance benchmark for miki-template.
|
|
5
|
+
*
|
|
6
|
+
* Dimensions measured:
|
|
7
|
+
* 1. CORRECTNESS — every render must produce byte-identical output to
|
|
8
|
+
* the first render (deterministic) and pass a fixed
|
|
9
|
+
* set of golden-string assertions.
|
|
10
|
+
* 2. COMPILE SPEED — templates/compile second, broken down by template
|
|
11
|
+
* size and template shape (flat, inheritance, for, partial).
|
|
12
|
+
* 3. RENDER SPEED — renders/second for small / medium / large / inheritance
|
|
13
|
+
* templates, both cold and warm.
|
|
14
|
+
* 4. CACHE BEHAVIOR — second compile of the same source must be a cache
|
|
15
|
+
* hit; partials included via {% include %} must NOT
|
|
16
|
+
* cause cross-template cache pollution.
|
|
17
|
+
* 5. SCALE — single template with N items rendered in a for loop.
|
|
18
|
+
* Linear-or-better growth vs N.
|
|
19
|
+
* 6. ENDURANCE — run 100k renders back-to-back. No memory growth
|
|
20
|
+
* reported by process.memoryUsage beyond a bounded
|
|
21
|
+
* factor (we measure delta RSS, not absolute RSS).
|
|
22
|
+
* 7. PARTIAL OVERHEAD — renderPartialFromSource vs render() overhead.
|
|
23
|
+
* 8. ASYNC OVERHEAD — asyncRender vs render() overhead.
|
|
24
|
+
* 9. CONCURRENCY — 50+ parallel asyncRender calls complete in bounded
|
|
25
|
+
* wall time and produce the same output as serial.
|
|
26
|
+
*
|
|
27
|
+
* The script exits with code 1 on any failure. All numbers are reported
|
|
28
|
+
* in a single human-readable summary.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { compile, render, asyncRender, renderPartialFromSource, clearCache } from '../src/esm.mjs';
|
|
32
|
+
import { performance } from 'node:perf_hooks';
|
|
33
|
+
import process from 'node:process';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
const RED = '\x1b[31m';
|
|
39
|
+
const GREEN = '\x1b[32m';
|
|
40
|
+
const YEL = '\x1b[33m';
|
|
41
|
+
const DIM = '\x1b[2m';
|
|
42
|
+
const RST = '\x1b[0m';
|
|
43
|
+
|
|
44
|
+
const results = [];
|
|
45
|
+
let failures = 0;
|
|
46
|
+
|
|
47
|
+
function record(name, ok, info) {
|
|
48
|
+
results.push({ name, ok, info });
|
|
49
|
+
if (!ok) failures++;
|
|
50
|
+
const tag = ok ? `${GREEN}PASS${RST}` : `${RED}FAIL${RST}`;
|
|
51
|
+
const extra = info ? ` ${DIM}${info}${RST}` : '';
|
|
52
|
+
console.log(` ${tag} ${name}${extra}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function section(title) {
|
|
56
|
+
console.log(`\n${YEL}== ${title} ==${RST}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function median(arr) {
|
|
60
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
61
|
+
const m = Math.floor(s.length / 2);
|
|
62
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fmt(n, unit = 'ms') {
|
|
66
|
+
if (n >= 1000) return (n / 1000).toFixed(2) + 'k ' + unit;
|
|
67
|
+
if (n < 10) return n.toFixed(3) + ' ' + unit;
|
|
68
|
+
if (n < 100) return n.toFixed(2) + ' ' + unit;
|
|
69
|
+
return n.toFixed(1) + ' ' + unit;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function bytes(n) {
|
|
73
|
+
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + ' MB';
|
|
74
|
+
if (n >= 1024) return (n / 1024).toFixed(2) + ' KB';
|
|
75
|
+
return n + ' B';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function rssMB() {
|
|
79
|
+
return process.memoryUsage().rss / 1024 / 1024;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ------------------------------------------------------------------
|
|
83
|
+
// Templates
|
|
84
|
+
// ------------------------------------------------------------------
|
|
85
|
+
const SMALL = `Hello {{ name|title }}, today is {{ date|default:"a day"|truncatewords:3 }}.`;
|
|
86
|
+
|
|
87
|
+
const MEDIUM = `<article>
|
|
88
|
+
<h1>{{ post.title|upper }}</h1>
|
|
89
|
+
<p class="meta">By {{ post.author.name }} on {{ post.date|date_format:"yyyy-MM-dd" }}</p>
|
|
90
|
+
{% if post.featured %}<span class="badge">Featured</span>{% endif %}
|
|
91
|
+
<div class="body">
|
|
92
|
+
{{ post.body|truncatewords:80 }}
|
|
93
|
+
</div>
|
|
94
|
+
<ul class="tags">
|
|
95
|
+
{% for tag in post.tags %}<li>{{ tag|lower }}</li>{% empty %}
|
|
96
|
+
<li>No tags</li>{% endfor %}
|
|
97
|
+
</ul>
|
|
98
|
+
<footer>
|
|
99
|
+
{% for c in post.comments %}{% if c.approved %}
|
|
100
|
+
<div class="comment">{{ c.author }}: {{ c.text|truncatewords:30 }}</div>
|
|
101
|
+
{% endif %}{% endfor %}
|
|
102
|
+
</footer>
|
|
103
|
+
</article>`;
|
|
104
|
+
|
|
105
|
+
const LARGE = `<!doctype html>
|
|
106
|
+
<html lang="en">
|
|
107
|
+
<head>
|
|
108
|
+
<meta charset="utf-8">
|
|
109
|
+
<title>{{ page.title|default:"Site"|title }}</title>
|
|
110
|
+
<meta name="description" content="{{ page.description|truncatewords:20 }}">
|
|
111
|
+
{% for css in page.stylesheets %}<link rel="stylesheet" href="{{ css }}">
|
|
112
|
+
{% endfor %}{% for js in page.scripts %}<script src="{{ js }}"></script>
|
|
113
|
+
{% endfor %}
|
|
114
|
+
</head>
|
|
115
|
+
<body class="{% cycle 'theme-a' 'theme-b' 'theme-c' %}">
|
|
116
|
+
<header>
|
|
117
|
+
<h1>{{ site.name }}</h1>
|
|
118
|
+
<nav>{% for item in nav %}<a href="{{ item.href }}">{{ item.label }}</a>{% endfor %}</nav>
|
|
119
|
+
</header>
|
|
120
|
+
<main>
|
|
121
|
+
{% for section in sections %}
|
|
122
|
+
<section id="{{ section.slug }}">
|
|
123
|
+
<h2>{{ section.title }}</h2>
|
|
124
|
+
{% if section.kind == 'grid' %}
|
|
125
|
+
<div class="grid">{% for item in section.items %}<div class="cell">
|
|
126
|
+
<h3>{{ item.title }}</h3>
|
|
127
|
+
<p>{{ item.summary|truncatewords:40 }}</p>
|
|
128
|
+
<span class="price">{{ item.price|floatformat:2 }}</span>
|
|
129
|
+
</div>{% endfor %}</div>
|
|
130
|
+
{% elif section.kind == 'list' %}
|
|
131
|
+
<ul>{% for item in section.items %}<li>
|
|
132
|
+
<a href="{{ item.href }}">{{ item.label }}</a>
|
|
133
|
+
<small>{{ item.note|default:""|truncatewords:5 }}</small>
|
|
134
|
+
</li>{% endfor %}</ul>
|
|
135
|
+
{% endif %}
|
|
136
|
+
</section>
|
|
137
|
+
{% endfor %}
|
|
138
|
+
</main>
|
|
139
|
+
<aside>
|
|
140
|
+
<h3>Recent</h3>
|
|
141
|
+
<ol>{% for r in recent %}<li>{{ r.title }}</li>{% endfor %}</ol>
|
|
142
|
+
</aside>
|
|
143
|
+
<footer>
|
|
144
|
+
<p>© {{ year }} {{ site.name }}. All rights reserved.</p>
|
|
145
|
+
{% if user %}<p>Signed in as {{ user.name }} ({{ user.email }})</p>{% endif %}
|
|
146
|
+
</footer>
|
|
147
|
+
</body>
|
|
148
|
+
</html>`;
|
|
149
|
+
|
|
150
|
+
const INHERIT_BASE = `<!doctype html>
|
|
151
|
+
<html><head><title>{% block title %}Default{% endblock %}</title></head>
|
|
152
|
+
<body>
|
|
153
|
+
<header>{% block header %}Default header{% endblock %}</header>
|
|
154
|
+
<main>{% block content %}Default content{% endblock %}</main>
|
|
155
|
+
<footer>{% block footer %}{{ copyright }}{% endblock %}</footer>
|
|
156
|
+
</body></html>`;
|
|
157
|
+
|
|
158
|
+
const INHERIT_CHILD = `{% extends "base.dtpl" %}
|
|
159
|
+
{% block title %}{{ super_title|default:"Page" }}{% endblock %}
|
|
160
|
+
{% block content %}
|
|
161
|
+
<h1>{{ heading }}</h1>
|
|
162
|
+
{% for item in items %}
|
|
163
|
+
<div class="item">
|
|
164
|
+
<h2>{{ item.name|title }}</h2>
|
|
165
|
+
<p>{{ item.body|truncatewords:20 }}</p>
|
|
166
|
+
{% if item.featured %}<span class="badge">Featured</span>{% endif %}
|
|
167
|
+
</div>
|
|
168
|
+
{% endfor %}
|
|
169
|
+
{% endblock %}`;
|
|
170
|
+
|
|
171
|
+
const PARTIAL_TPL = `{% partialdef card %}
|
|
172
|
+
<div class="card">
|
|
173
|
+
<h3>{{ title|default:"Untitled" }}</h3>
|
|
174
|
+
<p>{{ body|truncatewords:30 }}</p>
|
|
175
|
+
{% if featured %}<em>Featured</em>{% endif %}
|
|
176
|
+
</div>
|
|
177
|
+
{% endpartialdef %}
|
|
178
|
+
{% for entry in entries %}
|
|
179
|
+
{% partial card with title=entry.title body=entry.body featured=entry.featured %}
|
|
180
|
+
{% endfor %}`;
|
|
181
|
+
|
|
182
|
+
const templates = { SMALL, MEDIUM, LARGE, INHERIT_BASE, INHERIT_CHILD, PARTIAL_TPL };
|
|
183
|
+
|
|
184
|
+
// ------------------------------------------------------------------
|
|
185
|
+
// Data
|
|
186
|
+
// ------------------------------------------------------------------
|
|
187
|
+
function makeData(seed = 1) {
|
|
188
|
+
const tags = ['Node.js', 'Express', 'Django', 'Jinja', 'HTMX', 'Templates', 'Performance'];
|
|
189
|
+
const sections = [];
|
|
190
|
+
for (let s = 0; s < 6; s++) {
|
|
191
|
+
const items = [];
|
|
192
|
+
for (let i = 0; i < 12; i++) {
|
|
193
|
+
items.push({
|
|
194
|
+
title: `Item ${seed}-${s}-${i}`,
|
|
195
|
+
summary: `Summary text for item number ${i} in section ${s} with enough padding to make truncation interesting ${'.'.repeat(i)}`,
|
|
196
|
+
price: (i * 3.7 + s).toFixed(2),
|
|
197
|
+
href: `/items/${s}/${i}`,
|
|
198
|
+
label: `Section ${s} Item ${i}`,
|
|
199
|
+
note: i % 3 === 0 ? null : `note-${i}`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
sections.push({
|
|
203
|
+
slug: `section-${s}`,
|
|
204
|
+
title: `Section ${s}`,
|
|
205
|
+
kind: s % 2 === 0 ? 'grid' : 'list',
|
|
206
|
+
items,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
name: 'alice',
|
|
211
|
+
date: 'a wonderful day',
|
|
212
|
+
post: {
|
|
213
|
+
title: 'hello world',
|
|
214
|
+
author: { name: 'Alice' },
|
|
215
|
+
date: new Date(Date.UTC(2024, 5, 1, 12)),
|
|
216
|
+
featured: true,
|
|
217
|
+
body: '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>'.repeat(5),
|
|
218
|
+
tags: tags.slice(0, seed + 2),
|
|
219
|
+
comments: Array.from({ length: 6 }, (_, i) => ({
|
|
220
|
+
author: `user${i}`,
|
|
221
|
+
text: 'great post '.repeat(3 + (i % 4)),
|
|
222
|
+
approved: i % 2 === 0,
|
|
223
|
+
})),
|
|
224
|
+
},
|
|
225
|
+
page: {
|
|
226
|
+
title: 'home',
|
|
227
|
+
description: 'a'.repeat(120),
|
|
228
|
+
stylesheets: ['/static/main.css', '/static/theme.css'],
|
|
229
|
+
scripts: ['/static/app.js'],
|
|
230
|
+
},
|
|
231
|
+
site: { name: 'miki-template' },
|
|
232
|
+
nav: [
|
|
233
|
+
{ href: '/', label: 'Home' },
|
|
234
|
+
{ href: '/about', label: 'About' },
|
|
235
|
+
{ href: '/contact', label: 'Contact' },
|
|
236
|
+
],
|
|
237
|
+
sections,
|
|
238
|
+
recent: Array.from({ length: 5 }, (_, i) => ({ title: `Recent ${i}` })),
|
|
239
|
+
year: 2026,
|
|
240
|
+
user: { name: 'alice', email: 'alice@example.com' },
|
|
241
|
+
super_title: 'Inherit Test',
|
|
242
|
+
heading: 'Welcome',
|
|
243
|
+
items: Array.from({ length: 20 }, (_, i) => ({
|
|
244
|
+
name: `item ${i}`,
|
|
245
|
+
body: 'description '.repeat(8 + i) + 'end',
|
|
246
|
+
featured: i % 3 === 0,
|
|
247
|
+
})),
|
|
248
|
+
entries: Array.from({ length: 50 }, (_, i) => ({
|
|
249
|
+
title: `Entry ${i}`,
|
|
250
|
+
body: 'body text '.repeat(5 + (i % 7)),
|
|
251
|
+
featured: i % 4 === 0,
|
|
252
|
+
})),
|
|
253
|
+
copyright: '© 2026',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ------------------------------------------------------------------
|
|
258
|
+
// Set up an inheritance fixture on disk
|
|
259
|
+
// ------------------------------------------------------------------
|
|
260
|
+
const FIXTURES_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'miki-stress-'));
|
|
261
|
+
fs.writeFileSync(path.join(FIXTURES_DIR, 'base.dtpl'), INHERIT_BASE);
|
|
262
|
+
|
|
263
|
+
// ------------------------------------------------------------------
|
|
264
|
+
// 1. CORRECTNESS
|
|
265
|
+
// ------------------------------------------------------------------
|
|
266
|
+
section('1. CORRECTNESS');
|
|
267
|
+
|
|
268
|
+
function check(name, fn) {
|
|
269
|
+
try {
|
|
270
|
+
fn();
|
|
271
|
+
record(name, true, '');
|
|
272
|
+
} catch (e) {
|
|
273
|
+
record(name, false, typeof e === 'string' ? e : e.message);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function assert(cond, msg) {
|
|
278
|
+
if (!cond) throw new Error(msg || 'assertion failed');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
check('small template renders expected output', () => {
|
|
282
|
+
const c = compile(SMALL);
|
|
283
|
+
const out = c.render({ name: 'alice', date: 'today' });
|
|
284
|
+
assert(out.includes('Alice'), `expected 'Alice' in output, got: ${out}`);
|
|
285
|
+
assert(out.includes('today'), `expected 'today' in output, got: ${out}`);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
check('medium template renders expected output', () => {
|
|
289
|
+
const c = compile(MEDIUM);
|
|
290
|
+
const out = c.render(makeData());
|
|
291
|
+
assert(out.includes('HELLO WORLD'), 'missing upper-cased title');
|
|
292
|
+
assert(out.includes('Featured</span>'), 'missing Featured span');
|
|
293
|
+
const li = (out.match(/<li>/g) || []).length;
|
|
294
|
+
assert(li >= 3, `expected >=3 <li>, got ${li}`);
|
|
295
|
+
assert(out.includes('2024-'), 'missing date_format output');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
check('large template renders expected output', () => {
|
|
299
|
+
const c = compile(LARGE);
|
|
300
|
+
const out = c.render(makeData());
|
|
301
|
+
assert(out.includes('miki-template'), 'missing site name');
|
|
302
|
+
assert(out.match(/<section id="section-\d+">/), 'no section match');
|
|
303
|
+
const hrefs = (out.match(/href="\/items\//g) || []).length;
|
|
304
|
+
assert(hrefs === 36, `expected 36 item hrefs (3 list sections × 12), got ${hrefs}`);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
check('inheritance: child overrides title and content', () => {
|
|
308
|
+
const child = compile(INHERIT_CHILD, { views: FIXTURES_DIR });
|
|
309
|
+
const out = child.render(makeData());
|
|
310
|
+
assert(out.includes('Inherit Test'), `missing child title; output: ${out.slice(0, 200)}`);
|
|
311
|
+
// Items are rendered with |title so names are Title-Cased: 'item 1' -> 'Item 1'
|
|
312
|
+
assert(out.includes('Item 1'), 'no item loop content (expected "Item 1" from |title)');
|
|
313
|
+
assert(out.includes('Featured</span>'), 'no Featured span from child block');
|
|
314
|
+
assert(out.includes('© 2026'), 'no base footer block');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
check('partial: {% partial %} renders the body for every entry', () => {
|
|
318
|
+
const c = compile(PARTIAL_TPL);
|
|
319
|
+
const out = c.render(makeData());
|
|
320
|
+
const cardCount = (out.match(/class="card"/g) || []).length;
|
|
321
|
+
assert(cardCount === 50, `expected 50 cards, got ${cardCount}`);
|
|
322
|
+
assert((out.match(/<em>Featured<\/em>/g) || []).length > 0, 'no Featured span in partials');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
check('determinism: 1000 renders produce identical output', () => {
|
|
326
|
+
const c = compile(LARGE);
|
|
327
|
+
const data = makeData();
|
|
328
|
+
const first = c.render(data);
|
|
329
|
+
for (let i = 0; i < 1000; i++) {
|
|
330
|
+
const o = c.render(data);
|
|
331
|
+
assert(o === first, `render ${i} differs from first`);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
check('auto-escape: HTML in context is escaped', () => {
|
|
336
|
+
const c = compile('{{ html }}');
|
|
337
|
+
const out = c.render({ html: '<script>alert(1)</script>' });
|
|
338
|
+
assert(out === '<script>alert(1)</script>', `got: ${out}`);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
check('safe filter bypasses auto-escape', () => {
|
|
342
|
+
const c = compile('{{ html|safe }}');
|
|
343
|
+
const out = c.render({ html: '<b>ok</b>' });
|
|
344
|
+
assert(out === '<b>ok</b>', `got: ${out}`);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
check('date filter multi-token: yyyy-MM-dd renders correctly', () => {
|
|
348
|
+
const c = compile('{{ d|date:"yyyy-MM-dd" }}');
|
|
349
|
+
const d = new Date(Date.UTC(2024, 5, 15, 12, 0, 0));
|
|
350
|
+
const yyyy = String(d.getFullYear()).padStart(4, '0');
|
|
351
|
+
const MM = String(d.getMonth() + 1).padStart(2, '0');
|
|
352
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
353
|
+
const out = c.render({ d });
|
|
354
|
+
assert(out === `${yyyy}-${MM}-${dd}`, `got ${out}, expected ${yyyy}-${MM}-${dd}`);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
check('time filter multi-token: HH:mm:ss renders correctly', () => {
|
|
358
|
+
const c = compile('{{ d|time:"HH:mm:ss" }}');
|
|
359
|
+
const d = new Date(Date.UTC(2024, 5, 15, 9, 7, 3));
|
|
360
|
+
const HH = String(d.getHours()).padStart(2, '0');
|
|
361
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
362
|
+
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
363
|
+
const out = c.render({ d });
|
|
364
|
+
assert(out === `${HH}:${mm}:${ss}`, `got ${out}, expected ${HH}:${mm}:${ss}`);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
check('default vs default_if_none (Django parity)', () => {
|
|
368
|
+
const c = compile('{{ x|default:"D" }}|{{ x|default_if_none:"N" }}');
|
|
369
|
+
const r1 = c.render({ x: 'v' });
|
|
370
|
+
const r2 = c.render({ x: '' });
|
|
371
|
+
const r3 = c.render({ x: null });
|
|
372
|
+
const r4 = c.render({});
|
|
373
|
+
assert(r1 === 'v|v', `x=v: got ${r1}, expected v|v`);
|
|
374
|
+
assert(r2 === 'D|', `x=empty: got ${r2}, expected D|`); // default_if_none does NOT fallback on ''
|
|
375
|
+
assert(r3 === 'D|N', `x=null: got ${r3}, expected D|N`);
|
|
376
|
+
assert(r4 === 'D|N', `undef: got ${r4}, expected D|N`);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ------------------------------------------------------------------
|
|
380
|
+
// 2. COMPILE SPEED
|
|
381
|
+
// ------------------------------------------------------------------
|
|
382
|
+
section('2. COMPILE SPEED');
|
|
383
|
+
|
|
384
|
+
function benchCompile(name, tpl, runs = 200) {
|
|
385
|
+
for (let i = 0; i < 5; i++) compile(tpl);
|
|
386
|
+
clearCache();
|
|
387
|
+
const times = [];
|
|
388
|
+
for (let i = 0; i < runs; i++) {
|
|
389
|
+
const t0 = performance.now();
|
|
390
|
+
compile(tpl);
|
|
391
|
+
times.push(performance.now() - t0);
|
|
392
|
+
}
|
|
393
|
+
return { name, runs, medianMs: median(times), minMs: Math.min(...times), maxMs: Math.max(...times) };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const compileResults = {};
|
|
397
|
+
for (const [name, tpl] of Object.entries(templates)) {
|
|
398
|
+
const r = benchCompile(name, tpl, name === 'LARGE' ? 100 : 300);
|
|
399
|
+
compileResults[name] = r;
|
|
400
|
+
// Thresholds tuned to a normal 4-core Windows runner.
|
|
401
|
+
const limit = name === 'LARGE' ? 15 : (name.startsWith('INHERIT') ? 8 : 6);
|
|
402
|
+
record(`compile: ${name} (${bytes(tpl.length)})`, r.medianMs < limit,
|
|
403
|
+
`median ${fmt(r.medianMs)} (min ${fmt(r.minMs)}, max ${fmt(r.maxMs)})`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ------------------------------------------------------------------
|
|
407
|
+
// 3. RENDER SPEED
|
|
408
|
+
// ------------------------------------------------------------------
|
|
409
|
+
section('3. RENDER SPEED');
|
|
410
|
+
|
|
411
|
+
function benchRender(tpl, data, runs = 5000, opts = {}) {
|
|
412
|
+
const c = compile(tpl, opts);
|
|
413
|
+
for (let i = 0; i < 50; i++) c.render(data);
|
|
414
|
+
const times = [];
|
|
415
|
+
for (let i = 0; i < runs; i++) {
|
|
416
|
+
const t0 = performance.now();
|
|
417
|
+
c.render(data);
|
|
418
|
+
times.push(performance.now() - t0);
|
|
419
|
+
}
|
|
420
|
+
return { medianMs: median(times), minMs: Math.min(...times), runs };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function renderSpeedRecord(name, tpl, data, runs, thresholdRps, opts = {}) {
|
|
424
|
+
const r = benchRender(tpl, data, runs, opts);
|
|
425
|
+
const rps = 1000 / r.medianMs;
|
|
426
|
+
record(`${name} ≥ ${thresholdRps.toLocaleString()} rps`, rps >= thresholdRps,
|
|
427
|
+
`${Math.round(rps).toLocaleString()} rps (median ${fmt(r.medianMs)})`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const data = makeData();
|
|
431
|
+
renderSpeedRecord('render: small', SMALL, data, 20000, 50000);
|
|
432
|
+
renderSpeedRecord('render: medium', MEDIUM, data, 5000, 1000);
|
|
433
|
+
renderSpeedRecord('render: large', LARGE, data, 2000, 1000);
|
|
434
|
+
renderSpeedRecord('render: inherit child', INHERIT_CHILD, data, 2000, 1000, { views: FIXTURES_DIR });
|
|
435
|
+
renderSpeedRecord('render: partials (50 entries)', PARTIAL_TPL, data, 1000, 500);
|
|
436
|
+
|
|
437
|
+
// ------------------------------------------------------------------
|
|
438
|
+
// 4. CACHE BEHAVIOR
|
|
439
|
+
// ------------------------------------------------------------------
|
|
440
|
+
section('4. CACHE BEHAVIOR');
|
|
441
|
+
|
|
442
|
+
check('cache: second compile of same source hits cache', () => {
|
|
443
|
+
const t0 = performance.now();
|
|
444
|
+
compile(SMALL);
|
|
445
|
+
const first = performance.now() - t0;
|
|
446
|
+
const t1 = performance.now();
|
|
447
|
+
compile(SMALL);
|
|
448
|
+
const second = performance.now() - t1;
|
|
449
|
+
assert(second * 2 <= first, `expected cached compile ≥ 2× faster (first ${fmt(first)}, cached ${fmt(second)})`);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
check('cache: partial template does not pollute sibling cache', () => {
|
|
453
|
+
const a = compile(PARTIAL_TPL);
|
|
454
|
+
const b = compile(MEDIUM);
|
|
455
|
+
const aOut = a.render(makeData());
|
|
456
|
+
const bOut = b.render(makeData());
|
|
457
|
+
const a2 = compile(PARTIAL_TPL);
|
|
458
|
+
const b2 = compile(MEDIUM);
|
|
459
|
+
assert(a2.render(makeData()) === aOut, 'partial cache poisoned after medium compile');
|
|
460
|
+
assert(b2.render(makeData()) === bOut, 'medium cache poisoned after partial compile');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
check('cache: function-valued options do not poison cache key', () => {
|
|
464
|
+
const fn = () => '/';
|
|
465
|
+
const t1 = performance.now();
|
|
466
|
+
compile(SMALL, { urlHelper: fn });
|
|
467
|
+
const first = performance.now() - t1;
|
|
468
|
+
const t2 = performance.now();
|
|
469
|
+
compile(SMALL, { urlHelper: fn });
|
|
470
|
+
const second = performance.now() - t2;
|
|
471
|
+
assert(second <= first * 1.5, `cached compile should be ≤ 1.5× first (first ${fmt(first)}, cached ${fmt(second)})`);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// ------------------------------------------------------------------
|
|
475
|
+
// 5. SCALE
|
|
476
|
+
// ------------------------------------------------------------------
|
|
477
|
+
section('5. SCALE');
|
|
478
|
+
|
|
479
|
+
function scaleRender(tpl, itemCount) {
|
|
480
|
+
const c = compile(tpl);
|
|
481
|
+
const data = { items: Array.from({ length: itemCount }, (_, i) => ({ i, v: i * 2 })) };
|
|
482
|
+
for (let i = 0; i < 10; i++) c.render(data);
|
|
483
|
+
const times = [];
|
|
484
|
+
for (let i = 0; i < 50; i++) {
|
|
485
|
+
const t0 = performance.now();
|
|
486
|
+
c.render(data);
|
|
487
|
+
times.push(performance.now() - t0);
|
|
488
|
+
}
|
|
489
|
+
return { count: itemCount, medianMs: median(times) };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const scaleTpl = `{% for x in items %}<li>{{ x.i }}: {{ x.v|add:1 }}</li>{% endfor %}`;
|
|
493
|
+
const s100 = scaleRender(scaleTpl, 100);
|
|
494
|
+
const s1000 = scaleRender(scaleTpl, 1000);
|
|
495
|
+
const s10000 = scaleRender(scaleTpl, 10000);
|
|
496
|
+
const s50000 = scaleRender(scaleTpl, 50000);
|
|
497
|
+
|
|
498
|
+
const ratio = s10000.medianMs / s100.medianMs;
|
|
499
|
+
record(`scale: 10k items ≤ 200× time of 100 items`, ratio < 200,
|
|
500
|
+
`100→${fmt(s100.medianMs)}, 1k→${fmt(s1000.medianMs)}, 10k→${fmt(s10000.medianMs)}, 50k→${fmt(s50000.medianMs)} (10k/100 = ${ratio.toFixed(1)}x)`);
|
|
501
|
+
record(`scale: 50k items renders in < 2s`, s50000.medianMs < 2000,
|
|
502
|
+
`${fmt(s50000.medianMs)} median`);
|
|
503
|
+
|
|
504
|
+
// ------------------------------------------------------------------
|
|
505
|
+
// 6. ENDURANCE (100k renders, watch RSS delta)
|
|
506
|
+
// ------------------------------------------------------------------
|
|
507
|
+
section('6. ENDURANCE');
|
|
508
|
+
|
|
509
|
+
const beforeRss = rssMB();
|
|
510
|
+
const enduranceTpl = MEDIUM;
|
|
511
|
+
const enduranceData = makeData();
|
|
512
|
+
const endC = compile(enduranceTpl);
|
|
513
|
+
for (let i = 0; i < 1000; i++) endC.render(enduranceData);
|
|
514
|
+
|
|
515
|
+
const tStart = performance.now();
|
|
516
|
+
let endRssMax = beforeRss;
|
|
517
|
+
for (let i = 0; i < 100000; i++) {
|
|
518
|
+
endC.render(enduranceData);
|
|
519
|
+
if (i % 10000 === 0) {
|
|
520
|
+
const r = rssMB();
|
|
521
|
+
if (r > endRssMax) endRssMax = r;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const dur = performance.now() - tStart;
|
|
525
|
+
const afterRss = rssMB();
|
|
526
|
+
const rssDelta = endRssMax - beforeRss;
|
|
527
|
+
const endRps = 100000 / dur;
|
|
528
|
+
|
|
529
|
+
record('endurance: 100k renders complete in < 30s', dur < 30000,
|
|
530
|
+
`${dur.toFixed(0)} ms total, ${Math.round(endRps).toLocaleString()} rps avg`);
|
|
531
|
+
record('endurance: RSS growth < 50 MB', rssDelta < 50,
|
|
532
|
+
`before ${beforeRss.toFixed(1)} MB, peak ${endRssMax.toFixed(1)} MB (Δ +${rssDelta.toFixed(1)} MB)`);
|
|
533
|
+
record('endurance: output is still correct at end', (() => {
|
|
534
|
+
const expected = endC.render(enduranceData);
|
|
535
|
+
return expected.includes('HELLO WORLD') && expected.includes('Featured');
|
|
536
|
+
})());
|
|
537
|
+
|
|
538
|
+
// ------------------------------------------------------------------
|
|
539
|
+
// 7. PARTIAL OVERHEAD
|
|
540
|
+
// ------------------------------------------------------------------
|
|
541
|
+
section('7. PARTIAL RENDERING');
|
|
542
|
+
|
|
543
|
+
check('renderPartialFromSource: finds partial inside block in extends chain', () => {
|
|
544
|
+
const src = `{% extends "fakebase" %}{% block content %}{% partialdef greet %}<p>Hi {{ who }}</p>{% endpartialdef %}{% endblock %}`;
|
|
545
|
+
const out = renderPartialFromSource(src, 'greet', { who: 'World' });
|
|
546
|
+
assert(out.includes('Hi World'), `got: ${out}`);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
function partialSpeedCheck() {
|
|
550
|
+
const src = `{% partialdef c %}<li>{{ name }}: {{ score|add:1 }}</li>{% endpartialdef %}{% for x in xs %}{% partial c with name=x.name score=x.score %}{% endfor %}`;
|
|
551
|
+
const data = { xs: Array.from({ length: 20 }, (_, i) => ({ name: `n${i}`, score: i })) };
|
|
552
|
+
for (let i = 0; i < 20; i++) renderPartialFromSource(src, 'c', data);
|
|
553
|
+
const t0 = performance.now();
|
|
554
|
+
for (let i = 0; i < 500; i++) renderPartialFromSource(src, 'c', data);
|
|
555
|
+
const partialMs = (performance.now() - t0) / 500;
|
|
556
|
+
|
|
557
|
+
const c = compile(src);
|
|
558
|
+
for (let i = 0; i < 20; i++) c.render(data);
|
|
559
|
+
const t1 = performance.now();
|
|
560
|
+
for (let i = 0; i < 500; i++) c.render(data);
|
|
561
|
+
const fullMs = (performance.now() - t1) / 500;
|
|
562
|
+
|
|
563
|
+
const overhead = partialMs < Math.max(fullMs * 5, 5);
|
|
564
|
+
record('renderPartialFromSource < 5x render() median', overhead,
|
|
565
|
+
`partial ${fmt(partialMs)} / full ${fmt(fullMs)} (${(partialMs / fullMs).toFixed(2)}x)`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ------------------------------------------------------------------
|
|
569
|
+
// 8. ASYNC OVERHEAD
|
|
570
|
+
// ------------------------------------------------------------------
|
|
571
|
+
section('8. ASYNC RENDERING');
|
|
572
|
+
|
|
573
|
+
(async () => {
|
|
574
|
+
partialSpeedCheck();
|
|
575
|
+
|
|
576
|
+
const c = compile(MEDIUM);
|
|
577
|
+
const data = makeData();
|
|
578
|
+
for (let i = 0; i < 50; i++) { c.render(data); await asyncRender(MEDIUM, data); }
|
|
579
|
+
|
|
580
|
+
const syncTimes = [];
|
|
581
|
+
for (let i = 0; i < 1000; i++) {
|
|
582
|
+
const t0 = performance.now();
|
|
583
|
+
c.render(data);
|
|
584
|
+
syncTimes.push(performance.now() - t0);
|
|
585
|
+
}
|
|
586
|
+
const syncMed = median(syncTimes);
|
|
587
|
+
|
|
588
|
+
const asyncTimes = [];
|
|
589
|
+
for (let i = 0; i < 200; i++) {
|
|
590
|
+
const t0 = performance.now();
|
|
591
|
+
await asyncRender(MEDIUM, data);
|
|
592
|
+
asyncTimes.push(performance.now() - t0);
|
|
593
|
+
}
|
|
594
|
+
const asyncMed = median(asyncTimes);
|
|
595
|
+
|
|
596
|
+
record('asyncRender: serial overhead < 10x sync', asyncMed < Math.max(syncMed * 10, 5),
|
|
597
|
+
`sync ${fmt(syncMed)} / async ${fmt(asyncMed)}`);
|
|
598
|
+
|
|
599
|
+
const N = 50;
|
|
600
|
+
const tPar = performance.now();
|
|
601
|
+
const parResults = await Promise.all(
|
|
602
|
+
Array.from({ length: N }, (_, i) => asyncRender(MEDIUM, makeData(i + 1)))
|
|
603
|
+
);
|
|
604
|
+
const parDur = performance.now() - tPar;
|
|
605
|
+
record('asyncRender: 50 concurrent complete in < 1s', parDur < 1000,
|
|
606
|
+
`${parDur.toFixed(0)} ms for 50 parallel (${(parDur / N).toFixed(1)} ms/each)`);
|
|
607
|
+
record('asyncRender: concurrent outputs all match serial', (() => {
|
|
608
|
+
const ser = render(MEDIUM, makeData(1));
|
|
609
|
+
return parResults[0] === ser;
|
|
610
|
+
})());
|
|
611
|
+
|
|
612
|
+
// ------------------------------------------------------------------
|
|
613
|
+
// 9. CONCURRENCY
|
|
614
|
+
// ------------------------------------------------------------------
|
|
615
|
+
section('9. CONCURRENCY');
|
|
616
|
+
|
|
617
|
+
const tConc = performance.now();
|
|
618
|
+
const conc = await Promise.all(
|
|
619
|
+
Array.from({ length: 200 }, () => asyncRender(LARGE, data))
|
|
620
|
+
);
|
|
621
|
+
const concDur = performance.now() - tConc;
|
|
622
|
+
record('asyncRender: 200 concurrent LARGE in < 5s', concDur < 5000,
|
|
623
|
+
`${concDur.toFixed(0)} ms (${(concDur / 200).toFixed(1)} ms/each)`);
|
|
624
|
+
record('asyncRender: concurrent outputs are all identical', (() => {
|
|
625
|
+
const first = conc[0];
|
|
626
|
+
for (const o of conc) if (o !== first) return false;
|
|
627
|
+
return true;
|
|
628
|
+
})());
|
|
629
|
+
|
|
630
|
+
// ------------------------------------------------------------------
|
|
631
|
+
// SUMMARY
|
|
632
|
+
// ------------------------------------------------------------------
|
|
633
|
+
console.log(`\n${YEL}== SUMMARY ==${RST}`);
|
|
634
|
+
const pass = results.filter(r => r.ok).length;
|
|
635
|
+
const fail = results.length - pass;
|
|
636
|
+
console.log(` ${GREEN}PASS${RST}: ${pass}`);
|
|
637
|
+
console.log(` ${fail === 0 ? GREEN : RED}FAIL${RST}: ${fail}`);
|
|
638
|
+
console.log(` TOTAL: ${results.length}`);
|
|
639
|
+
if (fail > 0) {
|
|
640
|
+
console.log(`\n${RED}Failed checks:${RST}`);
|
|
641
|
+
for (const r of results) if (!r.ok) console.log(` - ${r.name}${r.info ? ' ' + DIM + r.info + RST : ''}`);
|
|
642
|
+
try { fs.rmSync(FIXTURES_DIR, { recursive: true, force: true }); } catch {}
|
|
643
|
+
process.exit(1);
|
|
644
|
+
}
|
|
645
|
+
console.log(`\n${GREEN}All checks passed.${RST}`);
|
|
646
|
+
try { fs.rmSync(FIXTURES_DIR, { recursive: true, force: true }); } catch {}
|
|
647
|
+
})();
|
package/dir/base.html
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>
|
|
7
|
+
|
|
8
|
+
{% block title %}
|
|
9
|
+
Document
|
|
10
|
+
{% endblock title %}
|
|
11
|
+
|
|
12
|
+
</title>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
{% include "navbar.html" %}
|
|
16
|
+
|
|
17
|
+
{% block content %}
|
|
18
|
+
|
|
19
|
+
{% endblock content %}
|
|
20
|
+
{% include "footer.html" %}
|
|
21
|
+
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
package/dir/cmpnt.html
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{% partialdef me %}
|
|
2
|
+
<h2>here is some content</h2>
|
|
3
|
+
{% endpartialdef %}
|
|
4
|
+
{% partialdef you %}
|
|
5
|
+
<h2>here is some content for you</h2>
|
|
6
|
+
{% endpartialdef %}
|
|
7
|
+
{% partialdef us inline %}
|
|
8
|
+
{% partial me%}
|
|
9
|
+
{% partial you%}
|
|
10
|
+
<h2>here is some content for us</h2>
|
|
11
|
+
{% endpartialdef %}
|
package/dir/footer.html
ADDED