miki-template 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +17 -5
  2. package/package.json +45 -1
  3. package/.eslintrc.json +0 -16
  4. package/.github/release-notes/v1.3.1.md +0 -55
  5. package/.github/release-notes/v1.3.3.md +0 -77
  6. package/.github/release-notes/v1.3.7.md +0 -20
  7. package/.github/workflows/ci.yml +0 -42
  8. package/.github/workflows/docs.yml +0 -107
  9. package/.github/workflows/release.yml +0 -107
  10. package/AGENT.md +0 -71
  11. package/API_REFERENCE.md +0 -341
  12. package/CHANGELOG.md +0 -173
  13. package/CODE_OF_CONDUCT.md +0 -14
  14. package/CONTRIBUTING.md +0 -27
  15. package/ROADMAP.md +0 -40
  16. package/assets/banner.png +0 -0
  17. package/assets/logo.png +0 -0
  18. package/benchmarks/ejs-results.json +0 -17
  19. package/benchmarks/ejs.js +0 -38
  20. package/benchmarks/handlebars-results.json +0 -17
  21. package/benchmarks/handlebars.js +0 -45
  22. package/benchmarks/miki-results.json +0 -17
  23. package/benchmarks/miki.js +0 -39
  24. package/benchmarks/pug-results.json +0 -17
  25. package/benchmarks/pug.js +0 -38
  26. package/benchmarks/report.json +0 -17
  27. package/benchmarks/run.js +0 -81
  28. package/benchmarks/stress.mjs +0 -647
  29. package/benchmarks/templates/large.dtpl +0 -7
  30. package/benchmarks/templates/medium.dtpl +0 -3
  31. package/benchmarks/templates/small.dtpl +0 -7
  32. package/context/component.md +0 -109
  33. package/context/prd.md +0 -131
  34. package/context/project-structure.md +0 -33
  35. package/dir/base.html +0 -23
  36. package/dir/cmpnt.html +0 -11
  37. package/dir/footer.html +0 -3
  38. package/dir/home.html +0 -80
  39. package/dir/index.html +0 -80
  40. package/dir/navbar.html +0 -9
  41. package/docs/api/async-render.md +0 -170
  42. package/docs/api/cache.md +0 -174
  43. package/docs/api/compile.md +0 -256
  44. package/docs/api/context-processors.md +0 -154
  45. package/docs/api/filters.md +0 -437
  46. package/docs/api/finder.md +0 -188
  47. package/docs/api/helpers.md +0 -106
  48. package/docs/api/i18n.md +0 -314
  49. package/docs/api/index.md +0 -108
  50. package/docs/api/libraries.md +0 -414
  51. package/docs/api/render-partial.md +0 -162
  52. package/docs/api/render.md +0 -184
  53. package/docs/api/security.md +0 -290
  54. package/docs/api/setup-express.md +0 -152
  55. package/docs/api/tags.md +0 -268
  56. package/docs/assets/banner.png +0 -0
  57. package/docs/assets/logo.png +0 -0
  58. package/docs/filter.md +0 -0
  59. package/docs/guide/advanced-usage.md +0 -794
  60. package/docs/guide/async-rendering.md +0 -616
  61. package/docs/guide/context-processors.md +0 -514
  62. package/docs/guide/custom-filters.md +0 -622
  63. package/docs/guide/custom-tags.md +0 -542
  64. package/docs/guide/filters.md +0 -1314
  65. package/docs/guide/getting-started.md +0 -204
  66. package/docs/guide/installation.md +0 -190
  67. package/docs/guide/partial-templates.md +0 -734
  68. package/docs/guide/quick-start.md +0 -444
  69. package/docs/guide/security.md +0 -690
  70. package/docs/guide/tags.md +0 -1566
  71. package/docs/guide/template-discovery.md +0 -340
  72. package/docs/guide/template-inheritance.md +0 -546
  73. package/docs/guide/what-is-miki-template.md +0 -28
  74. package/docs/guide/why-miki-template.md +0 -75
  75. package/docs/index.md +0 -86
  76. package/docs/integrations/elysia.md +0 -80
  77. package/docs/integrations/express.md +0 -219
  78. package/docs/integrations/fastify.md +0 -79
  79. package/docs/integrations/hono.md +0 -80
  80. package/docs/integrations/index.md +0 -68
  81. package/docs/integrations/koa.md +0 -90
  82. package/docs/integrations/nestjs.md +0 -80
  83. package/docs/integrations/tsed.md +0 -83
  84. package/docs/performance.md +0 -74
  85. package/eslint.config.mjs +0 -42
  86. package/ex.mjs +0 -36
  87. package/live-test/integrations/elysia-example.js +0 -16
  88. package/live-test/integrations/express-example.js +0 -24
  89. package/live-test/integrations/fastify-example.js +0 -20
  90. package/live-test/integrations/hono-example.js +0 -16
  91. package/live-test/integrations/koa-example.js +0 -30
  92. package/live-test/integrations/nestjs-example.js +0 -25
  93. package/live-test/integrations/smoke-test.js +0 -166
  94. package/live-test/integrations/tsed-example.js +0 -23
  95. package/live-test/package-lock.json +0 -1150
  96. package/live-test/package.json +0 -13
  97. package/live-test/packages/product/templates/product/detail.html +0 -7
  98. package/live-test/server.js +0 -38
  99. package/live-test/templates/app_templates/detail.html +0 -6
  100. package/live-test/views/base.html +0 -8
  101. package/live-test/views/child.html +0 -7
  102. package/live-test/views/home.html +0 -17
  103. package/live-test/views/index.html +0 -1
  104. package/miki-template-extension/.github/workflows/ci.yml +0 -116
  105. package/miki-template-extension/.vscodeignore +0 -7
  106. package/miki-template-extension/CHANGELOG.md +0 -99
  107. package/miki-template-extension/LICENSE +0 -21
  108. package/miki-template-extension/README.md +0 -273
  109. package/miki-template-extension/extension.js +0 -1013
  110. package/miki-template-extension/icon.png +0 -0
  111. package/miki-template-extension/icon.svg +0 -10
  112. package/miki-template-extension/miki-template-1.7.1.vsix +0 -0
  113. package/miki-template-extension/package.json +0 -280
  114. package/miki-template-extension/snippets/miki-template.json +0 -717
  115. package/miki-template-extension/syntaxes/language-configuration.json +0 -114
  116. package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +0 -355
  117. package/miki-template-extension/tests/grammar-tests.json +0 -162
  118. package/miki-template-extension/tests/run-grammar-tests.js +0 -82
  119. package/mkdocs.yml +0 -195
  120. package/overrides/main.html +0 -26
  121. package/requirements-docs.txt +0 -2
  122. package/sample-app/package-lock.json +0 -901
  123. package/sample-app/package.json +0 -9
  124. package/sample-app/server.js +0 -14
  125. package/sample-app/views/index.html +0 -1
  126. package/scripts/build-vsix.js +0 -129
  127. package/scripts/build-vsix.ps1 +0 -15
  128. package/snippets/miki-template.json +0 -177
  129. package/src/asyncRender.js +0 -21
  130. package/src/types.d.ts +0 -107
  131. package/syntaxes/language-configuration.json +0 -26
  132. package/syntaxes/miki-template.tmLanguage.json +0 -146
  133. package/tests/asyncRender.test.js +0 -17
  134. package/tests/base.html +0 -6
  135. package/tests/child.html +0 -3
  136. package/tests/context_processors.test.js +0 -13
  137. package/tests/esm.test.mjs +0 -61
  138. package/tests/filters.test.js +0 -263
  139. package/tests/finder-appdirs.test.js +0 -19
  140. package/tests/finder.test.js +0 -17
  141. package/tests/fixtures/views/nested/index.html +0 -1
  142. package/tests/fixtures/views/partial.html +0 -1
  143. package/tests/fixtures/views/sub/deepfile.html +0 -1
  144. package/tests/fixtures/views-appdirs/product/site/detail.html +0 -1
  145. package/tests/include_security.test.js +0 -9
  146. package/tests/integration/README.md +0 -32
  147. package/tests/integration/features.test.cjs +0 -1681
  148. package/tests/integration/features.test.mjs +0 -1697
  149. package/tests/integration/finder.esm.test.mjs +0 -13
  150. package/tests/integration/partial-render.test.cjs +0 -13
  151. package/tests/integration/templates/base.miki +0 -6
  152. package/tests/integration/templates/child.miki +0 -6
  153. package/tests/integration/templates/index.html +0 -17
  154. package/tests/lexer.test.js +0 -45
  155. package/tests/parser.test.js +0 -57
  156. package/tests/partial.html +0 -1
  157. package/tests/partialdef.test.js +0 -79
  158. package/tests/production_checks.js +0 -57
  159. package/tests/security.test.js +0 -28
  160. package/tests/tags.test.js +0 -233
@@ -1,1697 +0,0 @@
1
- /**
2
- * Real-world ESM integration test for miki-template.
3
- *
4
- * This file uses ACTUAL `import` statements (not dynamic import workarounds)
5
- * to validate every public feature of the engine through a real Express
6
- * HTTP server.
7
- *
8
- * Coverage:
9
- * 1. Named imports + default import
10
- * 2. All built-in control tags
11
- * 3. All built-in utility tags
12
- * 4. i18n tags
13
- * 5. Inheritance (extends, block, block.super)
14
- * 6. Includes (with partial selector support)
15
- * 7. Partials (partialdef, partial with `with`, inline, renderPartial)
16
- * 8. Block rendering (renderBlock)
17
- * 9. Every built-in filter
18
- * 10. Context processors
19
- * 11. Custom tags, filters, helpers
20
- * 12. Libraries
21
- * 13. Async rendering
22
- * 14. Cache
23
- * 15. Edge cases (errors, security, scoping)
24
- * 16. Comprehensive `with` tag (all forms)
25
- * 17. Comprehensive partial tag (all forms, inline, with-args, default rendering)
26
- * 18. Comprehensive include with #partial selector
27
- * 19. URL tag (deep routing, kwargs, mapped to real Express routes)
28
- * 20. Real-world dir/home.html end-to-end over HTTP
29
- * 21. Real-world combined scenarios
30
- */
31
-
32
- import test from 'node:test';
33
- import assert from 'node:assert/strict';
34
- import http from 'node:http';
35
- import path from 'node:path';
36
- import fs from 'node:fs';
37
- import os from 'node:os';
38
- import { fileURLToPath } from 'node:url';
39
- import express from 'express';
40
-
41
- const __filename = fileURLToPath(import.meta.url);
42
- const __dirname = path.dirname(__filename);
43
-
44
- // Named imports + default import — the two patterns users will write
45
- import mikiTemplate, {
46
- __express as renderDtpl,
47
- __expressAsync as renderDtplAsync,
48
- render,
49
- compile,
50
- asyncRender,
51
- renderPartialFromFile,
52
- expressPartialRenderer,
53
- setupExpress,
54
- registerContextProcessor,
55
- clearContextProcessors,
56
- registerFilter,
57
- registerTag,
58
- registerHelper,
59
- registerLibrary,
60
- setLanguage,
61
- setFallbackLanguage,
62
- registerTranslation,
63
- unregisterTranslation,
64
- getAvailableLanguages,
65
- SafeString,
66
- markSafe,
67
- isSafe,
68
- escapeHtml,
69
- clearCache
70
- } from 'miki-template';
71
-
72
- // Ensure no context processors from another test file leak in
73
- clearContextProcessors();
74
-
75
- const TMP_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'miki-mjs-'));
76
-
77
- function makeViewDir(name, files) {
78
- const dir = path.join(TMP_ROOT, name);
79
- fs.mkdirSync(dir, { recursive: true });
80
- for (const [rel, content] of Object.entries(files)) {
81
- const full = path.join(dir, rel);
82
- fs.mkdirSync(path.dirname(full), { recursive: true });
83
- fs.writeFileSync(full, content);
84
- }
85
- return dir;
86
- }
87
-
88
- function startApp(app) {
89
- return new Promise((resolve, reject) => {
90
- const server = app.listen(0, '127.0.0.1', () => {
91
- const port = server.address().port;
92
- resolve({
93
- port,
94
- close: () => new Promise((r) => server.close(() => r()))
95
- });
96
- });
97
- server.on('error', reject);
98
- });
99
- }
100
-
101
- function get(port, urlPath) {
102
- return new Promise((resolve, reject) => {
103
- const req = http.get({ host: '127.0.0.1', port, path: urlPath }, (res) => {
104
- let data = '';
105
- res.setEncoding('utf8');
106
- res.on('data', (c) => (data += c));
107
- res.on('end', () =>
108
- resolve({ status: res.statusCode, headers: res.headers, body: data })
109
- );
110
- });
111
- req.on('error', reject);
112
- req.setTimeout(10000, () => req.destroy(new Error('HTTP timeout')));
113
- });
114
- }
115
-
116
- function renderString(tpl, ctx = {}, options = {}) {
117
- return render(tpl, ctx, options);
118
- }
119
-
120
- // ============================================================================
121
- // 1. IMPORT SHAPES
122
- // ============================================================================
123
-
124
- test('ESM: named imports expose the full public API', () => {
125
- assert.equal(typeof renderDtpl, 'function');
126
- assert.equal(typeof renderDtplAsync, 'function');
127
- assert.equal(typeof render, 'function');
128
- assert.equal(typeof compile, 'function');
129
- assert.equal(typeof registerContextProcessor, 'function');
130
- assert.equal(typeof registerFilter, 'function');
131
- assert.equal(typeof registerTag, 'function');
132
- assert.equal(typeof registerHelper, 'function');
133
- assert.equal(typeof SafeString, 'function');
134
- assert.equal(typeof markSafe, 'function');
135
- assert.equal(typeof isSafe, 'function');
136
- assert.equal(typeof escapeHtml, 'function');
137
- assert.equal(typeof clearCache, 'function');
138
- });
139
-
140
- test('ESM: default import is the same module surface as named imports', () => {
141
- assert.equal(typeof mikiTemplate, 'object');
142
- assert.equal(mikiTemplate.__express, renderDtpl);
143
- assert.equal(mikiTemplate.render, render);
144
- assert.equal(mikiTemplate.compile, compile);
145
- assert.equal(mikiTemplate.registerFilter, registerFilter);
146
- assert.equal(mikiTemplate.registerTag, registerTag);
147
- assert.equal(mikiTemplate.SafeString, SafeString);
148
- });
149
-
150
- test('ESM: real-world pattern `import mikiTemplate from "miki-template"`', () => {
151
- // The exact pattern the README documents.
152
- const renderDtpl2 = mikiTemplate.__express;
153
- assert.equal(typeof renderDtpl2, 'function');
154
- assert.equal(renderDtpl2, renderDtpl);
155
- });
156
-
157
- // ============================================================================
158
- // 2. CONTROL TAGS
159
- // ============================================================================
160
-
161
- test('TAG if/elif/else: all branches over real HTTP', async () => {
162
- const dir = makeViewDir('if-branches-mjs', {
163
- 'tpl.html': `{% if role == "admin" %}A{% elif role == "user" %}U{% else %}G{% endif %}`
164
- });
165
- const app = express();
166
- app.engine('html', renderDtpl);
167
- app.set('view engine', 'html');
168
- app.set('views', dir);
169
-
170
- for (const role of ['admin', 'user', 'guest']) {
171
- app.get('/' + role, (req, res) => res.render('tpl', { role }));
172
- }
173
- const { port, close } = await startApp(app);
174
- try {
175
- assert.equal((await get(port, '/admin')).body, 'A');
176
- assert.equal((await get(port, '/user')).body, 'U');
177
- assert.equal((await get(port, '/guest')).body, 'G');
178
- } finally {
179
- await close();
180
- }
181
- });
182
-
183
- test('TAG if: all comparison operators (== != < <= > >= in not in and or not)', () => {
184
- assert.equal(renderString('{% if 5 == 5 %}Y{% endif %}'), 'Y');
185
- assert.equal(renderString('{% if 5 != 6 %}Y{% endif %}'), 'Y');
186
- assert.equal(renderString('{% if 5 < 6 %}Y{% endif %}'), 'Y');
187
- assert.equal(renderString('{% if 5 <= 5 %}Y{% endif %}'), 'Y');
188
- assert.equal(renderString('{% if 6 > 5 %}Y{% endif %}'), 'Y');
189
- assert.equal(renderString('{% if 6 >= 6 %}Y{% endif %}'), 'Y');
190
- assert.equal(renderString('{% if 3 in nums %}Y{% endif %}', { nums: [1, 2, 3] }), 'Y');
191
- assert.equal(renderString('{% if 4 not in nums %}Y{% endif %}', { nums: [1, 2, 3] }), 'Y');
192
- assert.equal(renderString('{% if true and 1 %}Y{% endif %}'), 'Y');
193
- assert.equal(renderString('{% if false or 1 %}Y{% endif %}'), 'Y');
194
- assert.equal(renderString('{% if not false %}Y{% endif %}'), 'Y');
195
- });
196
-
197
- test('TAG for: empty and non-empty bodies', () => {
198
- assert.equal(renderString('{% for x in xs %}{{ x }}{% empty %}EMPTY{% endfor %}', { xs: [] }), 'EMPTY');
199
- assert.equal(renderString('{% for x in xs %}{{ x }}{% empty %}EMPTY{% endfor %}', { xs: [1, 2, 3] }), '123');
200
- });
201
-
202
- test('TAG for: forloop metadata (counter, counter0, revcounter, first, last, parentloop)', () => {
203
- const out = renderString(
204
- '{% for x in outer %}{% for y in inner %}{{ forloop.parentloop.counter }}.{{ forloop.counter }}|{{ y }}{% endfor %}{% endfor %}',
205
- { outer: [1, 2], inner: ['a', 'b', 'c'] }
206
- );
207
- assert.equal(out, '1.1|a1.2|b1.3|c2.1|a2.2|b2.3|c');
208
- });
209
-
210
- test('TAG for: dictionary iteration with key,value unpack', () => {
211
- const out = renderString('{% for k,v in d %}{{ k }}={{ v }};{% endfor %}', { d: { a: 1, b: 2 } });
212
- assert.match(out, /a=1;/);
213
- assert.match(out, /b=2;/);
214
- });
215
-
216
- test('TAG with: scopes a single variable via `as`', () => {
217
- const out = renderString('{% with user.name as n %}Hi {{ n }}{% endwith %}', { user: { name: 'Bob' } });
218
- assert.equal(out, 'Hi Bob');
219
- });
220
-
221
- test('TAG with: multi-assignment key=value with string literals', () => {
222
- const out = renderString('{% with greeting="Hello", who="World" %}{{ greeting }} {{ who }}{% endwith %}');
223
- assert.equal(out, 'Hello World');
224
- });
225
-
226
- test('TAG with: multi-assignment with quoted value containing comma', () => {
227
- const out = renderString('{% with msg="Hello, World" %}{{ msg }}{% endwith %}');
228
- assert.equal(out, 'Hello, World');
229
- });
230
-
231
- test('TAG with: numeric and boolean literals', () => {
232
- const out = renderString('{% with n=42, flag=true %}{{ n }}-{{ flag }}{% endwith %}');
233
- assert.equal(out, '42-true');
234
- });
235
-
236
- test('TAG cycle: rotates through values', () => {
237
- const out = renderString('{% for x in xs %}{% cycle "a" "b" "c" %}{% endfor %}', { xs: [1, 2, 3, 4, 5] });
238
- assert.equal(out, 'abcab');
239
- });
240
-
241
- test('TAG cycle: `as` form stores the value but emits nothing (Django semantics)', () => {
242
- const out = renderString('{% for x in xs %}{% cycle "A" "B" as c %}{{ c }}{% endfor %}', { xs: [1, 2, 3, 4] });
243
- assert.equal(out, 'ABAB');
244
- });
245
-
246
- test('TAG firstof: returns first truthy value', () => {
247
- assert.equal(renderString('{% firstof a b c %}', { a: '', b: null, c: 'C' }), 'C');
248
- assert.equal(renderString('{% firstof a b c %}', { a: 'A' }), 'A');
249
- assert.equal(renderString('{% firstof a b c %}', {}), '');
250
- });
251
-
252
- test('TAG comment: block comments are stripped', () => {
253
- assert.equal(renderString('before{% comment %}INNER{% endcomment %}after'), 'beforeafter');
254
- });
255
-
256
- test('TAG autoescape: controls escaping for a block', () => {
257
- assert.equal(renderString('{% autoescape off %}{{ x }}{% endautoescape %}', { x: '<a>' }), '<a>');
258
- assert.equal(renderString('{% autoescape on %}{{ x }}{% endautoescape %}', { x: '<a>' }), '&lt;a&gt;');
259
- });
260
-
261
- // ============================================================================
262
- // 3. UTILITY TAGS
263
- // ============================================================================
264
-
265
- test('TAG static: builds /static/<path> with default and custom prefix', () => {
266
- assert.equal(renderString('{% static "css/style.css" %}'), '/static/css/style.css');
267
- assert.equal(renderString('{% static "img.png" %}', {}, { staticUrl: '/cdn/' }), '/cdn/img.png');
268
- });
269
-
270
- test('TAG url: fallback URL builder + urlHelper override with literal args', () => {
271
- // Dotted route names are converted to deep paths in the fallback
272
- // builder (e.g. "user.show" -> "/user/show").
273
- assert.equal(renderString('{% url "user.show" "42" %}'), '/user/show/42');
274
- // urlHelper override receives positional args
275
- const out2 = renderString('{% url "user.show" "42" %}', {}, {
276
- urlHelper: (name, id) => `/users/${id}`
277
- });
278
- assert.equal(out2, '/users/42');
279
- // Numeric literal also resolves
280
- const out3 = renderString('{% url "user.show" 42 %}', {}, {
281
- urlHelper: (name, id) => `/users/${id}`
282
- });
283
- assert.equal(out3, '/users/42');
284
- });
285
-
286
- test('TAG url: deep routing (dotted route names become nested paths)', () => {
287
- // "user.profile.posts.show" -> "/user/profile/posts/show"
288
- assert.equal(
289
- renderString('{% url "user.profile.posts.show" %}'),
290
- '/user/profile/posts/show'
291
- );
292
- // With positional args
293
- assert.equal(
294
- renderString('{% url "user.profile.posts.show" 34 12 %}'),
295
- '/user/profile/posts/show/34/12'
296
- );
297
- // With kwargs (appended as query string in fallback)
298
- assert.equal(
299
- renderString('{% url "user.show" userId=34 tab="posts" %}'),
300
- '/user/show?userId=34&tab=posts'
301
- );
302
- // With both positional and kwargs
303
- assert.equal(
304
- renderString('{% url "user.show" 34 tab="posts" %}'),
305
- '/user/show/34?tab=posts'
306
- );
307
- // urlHelper receives kwargs as last arg when present
308
- const out = renderString('{% url "user.show" 34 tab="posts" %}', {}, {
309
- urlHelper: (name, id, kw) => `/${name}/${id}?tab=${kw.tab}`
310
- });
311
- assert.equal(out, '/user.show/34?tab=posts');
312
- });
313
-
314
- test('TAG regroup: groups list by attribute', () => {
315
- const out = renderString(
316
- '{% regroup people by gender as groups %}{% for g in groups %}{{ g.grouper }}:{{ g.list|length }};{% endfor %}',
317
- { people: [{ gender: 'm' }, { gender: 'f' }, { gender: 'm' }] }
318
- );
319
- assert.equal(out, 'm:2;f:1;');
320
- });
321
-
322
- test('TAG spaceless: strips whitespace between tags', () => {
323
- const out = renderString('{% spaceless %}<div> <span> x </span> </div>{% endspaceless %}');
324
- assert.equal(out, '<div><span> x </span></div>');
325
- });
326
-
327
- test('TAG csrf_token: emits hidden input with csrf_token from context', () => {
328
- const out = renderString('{% csrf_token %}', { csrf_token: 'abc123' });
329
- assert.match(out, /type="hidden"/);
330
- assert.match(out, /name="csrfmiddlewaretoken"/);
331
- assert.match(out, /value="abc123"/);
332
- });
333
-
334
- test('TAG csp_nonce_attr: emits nonce attr when csp_nonce in context', () => {
335
- const withNonce = renderString('<script {% csp_nonce_attr %}></script>', { csp_nonce: 'xyz' });
336
- assert.match(withNonce, /nonce="xyz"/);
337
- const withoutNonce = renderString('<script {% csp_nonce_attr %}></script>', {});
338
- assert.ok(!withoutNonce.includes('nonce='));
339
- });
340
-
341
- test('TAG widthratio: calculates ratio * maxWidth', () => {
342
- assert.equal(renderString('{% widthratio 50 200 100 %}'), '25');
343
- assert.equal(renderString('{% widthratio 0 200 100 %}'), '0');
344
- assert.equal(renderString('{% widthratio 200 200 100 %}'), '100');
345
- });
346
-
347
- test('TAG debug: dumps context scopes as <pre>JSON (HTML-escaped)', () => {
348
- const out = renderString('{% debug %}', { foo: 'bar', n: 1 });
349
- assert.match(out, /<pre>/);
350
- assert.match(out, /&quot;foo&quot;/);
351
- assert.ok(out.includes('foo'));
352
- assert.ok(out.includes('bar'));
353
- });
354
-
355
- test('TAG templatetag: returns the literal template tag token', () => {
356
- assert.equal(renderString('{% templatetag openblock %}'), '{%');
357
- assert.equal(renderString('{% templatetag closeblock %}'), '%}');
358
- assert.equal(renderString('{% templatetag openvariable %}'), '{{');
359
- assert.equal(renderString('{% templatetag closevariable %}'), '}}');
360
- assert.equal(renderString('{% templatetag opencomment %}'), '{#');
361
- assert.equal(renderString('{% templatetag closecomment %}'), '#}');
362
- });
363
-
364
- test('TAG load: activates a registered library and makes its tags available', () => {
365
- registerLibrary('mylib-mjs', {
366
- tags: { greet: () => ({ render: () => 'Hi-from-lib' }) }
367
- });
368
- assert.equal(renderString('{% load mylib-mjs %}{% greet %}'), 'Hi-from-lib');
369
- });
370
-
371
- // ============================================================================
372
- // 4. INHERITANCE
373
- // ============================================================================
374
-
375
- test('INHERITANCE: extends + block + block.super over real HTTP', async () => {
376
- const dir = makeViewDir('inherit-mjs', {
377
- 'base.html': `<html>{% block head %}default-head{% endblock %}<body>{% block body %}default-body{% endblock %}</body></html>`,
378
- 'child.html': `{% extends "base.html" %}{% block head %}CHILD-HEAD{% endblock %}{% block body %}{{ block.super }} + CHILD-BODY{% endblock %}`
379
- });
380
- const app = express();
381
- app.engine('html', renderDtpl);
382
- app.set('view engine', 'html');
383
- app.set('views', dir);
384
- app.get('/', (req, res) => res.render('child'));
385
- const { port, close } = await startApp(app);
386
- try {
387
- const r = await get(port, '/');
388
- assert.match(r.body, /<html>CHILD-HEAD/);
389
- assert.match(r.body, /default-body \+ CHILD-BODY/);
390
- } finally {
391
- await close();
392
- }
393
- });
394
-
395
- test('INHERITANCE: 3-level chain (grandchild → child → base)', () => {
396
- const dir = makeViewDir('inherit-3-mjs', {
397
- 'base.html': `[BASE-{% block a %}a{% endblock %}]`,
398
- 'mid.html': `{% extends "base.html" %}{% block a %}MID-{{ block.super }}-MID{% endblock %}`,
399
- 'leaf.html': `{% extends "mid.html" %}{% block a %}LEAF-{{ block.super }}-LEAF{% endblock %}`
400
- });
401
- assert.equal(renderString('{% extends "leaf.html" %}', {}, { views: dir }), '[BASE-LEAF-MID-a-MID-LEAF]');
402
- });
403
-
404
- test('INHERITANCE: block default body used when child does not override', () => {
405
- const dir = makeViewDir('inherit-default-mjs', {
406
- 'base.html': `{% block x %}DEFAULT{% endblock %}`
407
- });
408
- assert.equal(renderString('{% extends "base.html" %}', {}, { views: dir }), 'DEFAULT');
409
- });
410
-
411
- test('INHERITANCE: block.super works recursively across the chain', () => {
412
- const dir = makeViewDir('super-chain-mjs', {
413
- 'a.html': `{% block x %}A{% endblock %}`,
414
- 'b.html': `{% extends "a.html" %}{% block x %}B({{ block.super }}){% endblock %}`,
415
- 'c.html': `{% extends "b.html" %}{% block x %}C({{ block.super }}){% endblock %}`
416
- });
417
- assert.equal(renderString('{% extends "c.html" %}', {}, { views: dir }), 'C(B(A))');
418
- });
419
-
420
- test('INHERITANCE: path traversal in extends is REJECTED', () => {
421
- const dir = makeViewDir('inherit-traversal-mjs', {
422
- 'child.html': `{% extends "../../../etc/passwd" %}{% block x %}X{% endblock %}`
423
- });
424
- assert.throws(
425
- () => renderString('{% extends "child.html" %}', {}, { views: dir }),
426
- /path traversal/i
427
- );
428
- });
429
-
430
- test('INHERITANCE: include with `with` binds extra vars', () => {
431
- const dir = makeViewDir('include-with-mjs', {
432
- 'frag.html': `[{{ name }}]`
433
- });
434
- assert.equal(renderString('{% include "frag.html" with name="Alice" %}', {}, { views: dir }), '[Alice]');
435
- });
436
-
437
- test('INHERITANCE: include receives parent context variables', () => {
438
- const dir = makeViewDir('include-ctx-mjs', {
439
- 'frag.html': `Hi {{ user }}!`
440
- });
441
- assert.equal(renderString('{% include "frag.html" %}', { user: 'Bob' }, { views: dir }), 'Hi Bob!');
442
- });
443
-
444
- test('INHERITANCE: include path traversal is REJECTED', () => {
445
- const dir = makeViewDir('include-traversal-mjs', {});
446
- assert.throws(
447
- () => renderString('{% include "../escape.html" %}', {}, { views: dir }),
448
- /path traversal/i
449
- );
450
- });
451
-
452
- // ============================================================================
453
- // 5. PARTIALS
454
- // ============================================================================
455
-
456
- test('PARTIAL: partialdef + partial reference renders the body', () => {
457
- const tpl = `{% partialdef card %}Hello {{ name }}{% endpartialdef %}{% partial card %}`;
458
- assert.equal(renderString(tpl, { name: 'World' }), 'Hello World');
459
- });
460
-
461
- test('PARTIAL: partialdef inline renders inline AND registers the partial', () => {
462
- const tpl = `{% partialdef card inline %}INLINE-{{ name }}{% endpartialdef %}|{% partial card %}`;
463
- assert.equal(renderString(tpl, { name: 'X' }), 'INLINE-X|INLINE-X');
464
- });
465
-
466
- test('PARTIAL: renderPartial API renders a named partial directly', () => {
467
- const c = compile('{% partialdef greeting %}Hi {{ name }}{% endpartialdef %}');
468
- assert.equal(c.renderPartial('greeting', { name: 'Alice' }), 'Hi Alice');
469
- });
470
-
471
- test('PARTIAL: renderPartial with unknown name throws', () => {
472
- const c = compile('{% partialdef greeting %}Hi{% endpartialdef %}');
473
- assert.throws(() => c.renderPartial('doesnotexist', {}), /Partial 'doesnotexist' not found/);
474
- });
475
-
476
- test('PARTIAL: partial referencing an undefined partial throws', () => {
477
- assert.throws(() => renderString('{% partial missing %}'), /Partial 'missing' not found/);
478
- });
479
-
480
- test('PARTIAL: block.super works inside partials that override blocks', () => {
481
- const dir = makeViewDir('partial-block-mjs', {
482
- 'base.html': `{% block x %}BASE{% endblock %}`,
483
- 'p.html': `{% partialdef mypartial %}{% extends "base.html" %}{% block x %}PARTIAL-{{ block.super }}{% endblock %}{% endpartialdef %}{% partial mypartial %}`
484
- });
485
- assert.equal(renderString('{% extends "p.html" %}', {}, { views: dir }), 'PARTIAL-BASE');
486
- });
487
-
488
- // ============================================================================
489
- // 6. RENDERING A SINGLE BLOCK
490
- // ============================================================================
491
-
492
- test('BLOCK: compile().renderBlock("name", ctx) renders only that block', () => {
493
- const c = compile(`<html>{% block content %}HEAD {{ x }}{% endblock %}<footer>F</footer></html>`);
494
- assert.equal(c.renderBlock('content', { x: 'X' }), 'HEAD X');
495
- });
496
-
497
- test('BLOCK: renderBlock on a missing block throws', () => {
498
- const c = compile('{% block a %}A{% endblock %}');
499
- assert.throws(() => c.renderBlock('nope', {}), /Block 'nope' not found in template/);
500
- });
501
-
502
- // ============================================================================
503
- // 7. FILTERS
504
- // ============================================================================
505
-
506
- test('FILTER text: upper, lower, title, capfirst, slugify, wordcount, striptags, linebreaks, linebreaksbr, truncatewords, truncatechars', () => {
507
- assert.equal(renderString('{{ s|upper }}', { s: 'hello' }), 'HELLO');
508
- assert.equal(renderString('{{ s|lower }}', { s: 'HELLO' }), 'hello');
509
- assert.equal(renderString('{{ s|title }}', { s: 'hello world' }), 'Hello World');
510
- assert.equal(renderString('{{ s|capfirst }}', { s: 'hello' }), 'Hello');
511
- assert.equal(renderString('{{ s|slugify }}', { s: 'Hello World 123!' }), 'hello-world-123');
512
- assert.equal(renderString('{{ s|wordcount }}', { s: 'one two three' }), '3');
513
- assert.equal(renderString('{{ s|striptags }}', { s: '<b>hi</b> <i>there</i>' }), 'hi there');
514
- assert.equal(renderString('{{ s|linebreaks }}', { s: 'a\nb' }), '<p>a<br>b</p>');
515
- assert.equal(renderString('{{ s|linebreaksbr }}', { s: 'a\nb' }), 'a<br>b');
516
- assert.equal(renderString('{{ s|truncatewords:2 }}', { s: 'a b c d' }), 'a b ...');
517
- assert.equal(renderString('{{ s|truncatechars:5 }}', { s: 'abcdefgh' }), 'ab...');
518
- });
519
-
520
- test('FILTER html: safe, escape, escapeHtml (exposed), markSafe/isSafe', () => {
521
- assert.equal(renderString('{{ x|safe }}', { x: '<a>' }), '<a>');
522
- assert.equal(renderString('{{ x|escape }}', { x: '<a>' }), '&lt;a&gt;');
523
- assert.equal(renderString('{{ x }}', { x: '<a>' }), '&lt;a&gt;');
524
- assert.ok(isSafe(markSafe('<b>')));
525
- assert.equal(escapeHtml('<x>'), '&lt;x&gt;');
526
- assert.ok(isSafe(new SafeString('<b>')));
527
- });
528
-
529
- test('FILTER list: length, join, slice, dictsort, dictsortreversed, length_is', () => {
530
- assert.equal(renderString('{{ xs|length }}', { xs: [1, 2, 3] }), '3');
531
- assert.equal(renderString('{{ xs|join:"-" }}', { xs: ['a', 'b', 'c'] }), 'a-b-c');
532
- assert.equal(renderString('{{ xs|slice:"1:3" }}', { xs: ['a', 'b', 'c', 'd'] }), 'b,c');
533
- const sorted = renderString('{{ rows|dictsort:"age"|length }}', { rows: [{ age: 3 }, { age: 1 }, { age: 2 }] });
534
- assert.equal(sorted, '3');
535
- const sortedRev = renderString('{{ rows|dictsortreversed:"age"|length }}', { rows: [{ age: 3 }, { age: 1 }, { age: 2 }] });
536
- assert.equal(sortedRev, '3');
537
- assert.equal(renderString('{{ xs|length_is:3 }}', { xs: [1, 2, 3] }), 'true');
538
- assert.equal(renderString('{{ xs|length_is:3 }}', { xs: [1, 2] }), 'false');
539
- });
540
-
541
- test('FILTER default: returns fallback for empty string or null/undefined', () => {
542
- assert.equal(renderString('{{ x|default:"fb" }}', { x: '' }), 'fb');
543
- assert.equal(renderString('{{ x|default:"fb" }}', { x: 'X' }), 'X');
544
- assert.equal(renderString('{{ x|default:"fb" }}', {}), 'fb');
545
- });
546
-
547
- test('FILTER default_if_none: returns fallback ONLY for null/undefined, not empty string', () => {
548
- assert.equal(renderString('{{ x|default_if_none:"fb" }}', { x: null }), 'fb');
549
- assert.equal(renderString('{{ x|default_if_none:"fb" }}', {}), 'fb');
550
- assert.equal(renderString('{{ x|default_if_none:"fb" }}', { x: '' }), '');
551
- assert.equal(renderString('{{ x|default_if_none:"fb" }}', { x: 'X' }), 'X');
552
- });
553
-
554
- test('FILTER date: date_format/strftime with yyyy lowercase token', () => {
555
- // Use a TZ-stable date and derive the expected day from local
556
- // components so the test is independent of the host timezone.
557
- const d = new Date(Date.UTC(2024, 2, 15, 12, 0, 0));
558
- const out = renderString('{{ d|date_format:"yyyy-MM-dd" }}', { d });
559
- const year = d.getFullYear();
560
- const month = String(d.getMonth() + 1).padStart(2, '0');
561
- const day = String(d.getDate()).padStart(2, '0');
562
- assert.equal(out, `${year}-${month}-${day}`);
563
- });
564
-
565
- test('FILTER timesince/timeuntil return non-empty strings', () => {
566
- const past = new Date(Date.now() - 60 * 60 * 1000);
567
- const future = new Date(Date.now() + 60 * 60 * 1000);
568
- assert.ok(renderString('{{ d|timesince }}', { d: past }).length > 0);
569
- assert.ok(renderString('{{ d|timeuntil }}', { d: future }).length > 0);
570
- });
571
-
572
- test('FILTER numeric: add, divisibleby, floatformat', () => {
573
- assert.equal(renderString('{{ n|add:5 }}', { n: 3 }), '8');
574
- assert.equal(renderString('{{ n|divisibleby:2 }}', { n: 4 }), 'true');
575
- assert.equal(renderString('{{ n|divisibleby:2 }}', { n: 5 }), 'false');
576
- assert.ok(/1\.23/.test(renderString('{{ n|floatformat:2 }}', { n: 1.23456 })));
577
- });
578
-
579
- test('FILTER misc: yesno, pluralize, filesizeformat, urlencode, stringformat, cut, addslashes, removetags', () => {
580
- assert.equal(renderString('{{ v|yesno:"yes,no,maybe" }}', { v: true }), 'yes');
581
- assert.equal(renderString('{{ v|yesno:"yes,no,maybe" }}', { v: false }), 'no');
582
- assert.equal(renderString('{{ n|pluralize }}', { n: 1 }), '');
583
- assert.equal(renderString('{{ n|pluralize }}', { n: 2 }), 's');
584
- assert.equal(renderString('{{ n|pluralize:"es" }}', { n: 2 }), 'es');
585
- assert.match(renderString('{{ s|filesizeformat }}', { s: 1024 }), /\d/);
586
- assert.equal(renderString('{{ s|urlencode }}', { s: 'a b/c' }), 'a+b%2Fc');
587
- assert.equal(renderString('{{ n|stringformat:"%.2f" }}', { n: 1.5 }), '1.50');
588
- assert.equal(renderString('{{ s|cut:"x" }}', { s: 'axbxcx' }), 'abc');
589
- // removetags (now correctly removes tags, not HTML-escapes)
590
- assert.equal(renderString('{{ s|removetags:"b i" }}', { s: '<a><b>x</b><i>y</i></a>' }), '<a>xy</a>');
591
- assert.equal(renderString('{{ s|removetags:"b,i" }}', { s: '<a><b>x</b><i>y</i></a>' }), '<a>xy</a>');
592
- assert.equal(renderString('{{ s|removetags:"a" }}', { s: '<a href="x">link</a>' }), 'link');
593
- });
594
-
595
- test('FILTER trans: filter form returns translation', () => {
596
- setLanguage('en');
597
- setFallbackLanguage('en');
598
- registerTranslation('en', { 'greet': 'Hello' });
599
- assert.equal(renderString('{{ "greet"|trans }}'), 'Hello');
600
- unregisterTranslation('en');
601
- });
602
-
603
- // ============================================================================
604
- // 8. I18N TAGS
605
- // ============================================================================
606
-
607
- test('I18N trans: looks up a translation key', () => {
608
- setLanguage('en');
609
- setFallbackLanguage('en');
610
- registerTranslation('en', { 'welcome': 'Welcome!' });
611
- assert.equal(renderString('{% trans "welcome" %}'), 'Welcome!');
612
- unregisterTranslation('en');
613
- });
614
-
615
- test('I18N trans: variable substitution via args', () => {
616
- setLanguage('en');
617
- setFallbackLanguage('en');
618
- registerTranslation('en', { 'greet': 'Hello, %s!' });
619
- assert.equal(
620
- renderString('{% trans "greet" name=user.name %}', { user: { name: 'Alice' } }),
621
- 'Hello, Alice!'
622
- );
623
- unregisterTranslation('en');
624
- });
625
-
626
- test('I18N language block: temporarily switches language for body', () => {
627
- setLanguage('en');
628
- setFallbackLanguage('en');
629
- registerTranslation('en', { 'hi': 'Hello' });
630
- registerTranslation('fr', { 'hi': 'Bonjour' });
631
- assert.equal(renderString('{% language "fr" %}{% trans "hi" %}{% endlanguage %}'), 'Bonjour');
632
- assert.equal(renderString('{% trans "hi" %}'), 'Hello');
633
- unregisterTranslation('en');
634
- unregisterTranslation('fr');
635
- });
636
-
637
- test('I18N blocktrans: text with embedded variable', () => {
638
- setLanguage('en');
639
- setFallbackLanguage('en');
640
- registerTranslation('en', { 'Hello, :name!': 'Hello, :name!' });
641
- assert.equal(
642
- renderString('{% blocktrans %}Hello, {{ name }}!{% endblocktrans %}', { name: 'Bob' }),
643
- 'Hello, Bob!'
644
- );
645
- unregisterTranslation('en');
646
- });
647
-
648
- test('I18N blocktrans: with explicit `with name=var`', () => {
649
- setLanguage('en');
650
- setFallbackLanguage('en');
651
- registerTranslation('en', { 'Hi :name!': 'Hi :name!' });
652
- assert.equal(
653
- renderString('{% blocktrans with name=user.name %}Hi {{ name }}!{% endblocktrans %}', { user: { name: 'Carol' } }),
654
- 'Hi Carol!'
655
- );
656
- unregisterTranslation('en');
657
- });
658
-
659
- test('I18N getAvailableLanguages lists registered languages', () => {
660
- setLanguage('en');
661
- registerTranslation('en', { 'a': 'A' });
662
- registerTranslation('de', { 'a': 'A' });
663
- const langs = getAvailableLanguages();
664
- assert.ok(langs.includes('en'));
665
- assert.ok(langs.includes('de'));
666
- unregisterTranslation('en');
667
- unregisterTranslation('de');
668
- });
669
-
670
- // ============================================================================
671
- // 9. SECURITY
672
- // ============================================================================
673
-
674
- test('SECURITY: variables are auto-escaped by default (XSS prevention)', () => {
675
- const out = renderString('{{ x }}', { x: '<script>alert(1)</script>' });
676
- assert.ok(!out.includes('<script>'));
677
- assert.match(out, /&lt;script&gt;/);
678
- });
679
-
680
- test('SECURITY: SafeString / markSafe / |safe bypass escaping', () => {
681
- assert.equal(renderString('{{ x }}', { x: new SafeString('<b>safe</b>') }), '<b>safe</b>');
682
- assert.equal(renderString('{{ x }}', { x: markSafe('<i>ms</i>') }), '<i>ms</i>');
683
- assert.equal(renderString('{{ x|safe }}', { x: '<u>s</u>' }), '<u>s</u>');
684
- });
685
-
686
- test('SECURITY: |escape re-escapes even a SafeString (Django semantics)', () => {
687
- assert.equal(
688
- renderString('{{ x|escape }}', { x: new SafeString('<b>safe</b>') }),
689
- '&lt;b&gt;safe&lt;/b&gt;'
690
- );
691
- assert.equal(
692
- renderString('{{ x|escape }}', { x: '<i>x</i>' }),
693
- '&lt;i&gt;x&lt;/i&gt;'
694
- );
695
- });
696
-
697
- test('SECURITY: csrf_token escapes the token value to prevent attribute injection', () => {
698
- // The token is interpolated into an HTML attribute, so it MUST be
699
- // HTML-escaped. A malicious or malformed token containing `"` or
700
- // `>` could otherwise break out of the attribute and inject HTML.
701
- const out = renderString('{% csrf_token %}', { csrf_token: '<hack>' });
702
- assert.match(out, /value="&lt;hack&gt;"/);
703
- // Quote injection: the input has TWO `"` which would normally
704
- // close the value attribute and create a new one. After escaping,
705
- // they become `&quot;` and the attribute stays well-formed.
706
- const out2 = renderString('{% csrf_token %}', { csrf_token: '" onerror="alert(1)' });
707
- const attrCount = (out2.match(/=\s*"/g) || []).length;
708
- assert.equal(attrCount, 3, `expected 3 attrs (type/name/value), got ${attrCount}: ${out2}`);
709
- assert.match(out2, />$/);
710
- assert.match(out2, /^<input /);
711
- assert.match(out2, /value="&quot; onerror=&quot;alert\(1\)"/);
712
- // Output is still marked safe (no double-escape by the var node)
713
- const out3 = renderString('{% csrf_token %}', { csrf_token: 'normal-token-123' });
714
- assert.match(out3, /value="normal-token-123"/);
715
- });
716
-
717
- // ============================================================================
718
- // 10. CONTEXT PROCESSORS
719
- // ============================================================================
720
-
721
- test('PROCESSOR: context processor injects vars into every render over HTTP', async () => {
722
- const dir = makeViewDir('processor-mjs', {
723
- 'tpl.html': `{{ siteName }}|{{ processorTag }}`
724
- });
725
- const TAG = 'PROC-' + Date.now();
726
- registerContextProcessor(() => ({ siteName: 'ProcSite', processorTag: TAG }));
727
- const app = express();
728
- app.engine('html', renderDtpl);
729
- app.set('view engine', 'html');
730
- app.set('views', dir);
731
- app.get('/', (req, res) => res.render('tpl'));
732
- const { port, close } = await startApp(app);
733
- try {
734
- const r = await get(port, '/');
735
- assert.match(r.body, /ProcSite/);
736
- assert.match(r.body, new RegExp(TAG));
737
- } finally {
738
- await close();
739
- }
740
- });
741
-
742
- test('PROCESSOR: default-import registerContextProcessor also works', async () => {
743
- clearContextProcessors();
744
- const dir = makeViewDir('processor-mjs-default', {
745
- 'tpl.html': `{{ siteName }}|{{ processorTag }}`
746
- });
747
- const TAG = 'ESM-PROC-DEFAULT-' + Date.now();
748
- mikiTemplate.registerContextProcessor(() => ({ siteName: 'EsmProc', processorTag: TAG }));
749
- const app = express();
750
- app.engine('html', mikiTemplate.__express);
751
- app.set('view engine', 'html');
752
- app.set('views', dir);
753
- app.get('/', (req, res) => res.render('tpl'));
754
- const { port, close } = await startApp(app);
755
- try {
756
- const r = await get(port, '/');
757
- assert.match(r.body, /EsmProc/);
758
- assert.match(r.body, new RegExp(TAG));
759
- } finally {
760
- await close();
761
- clearContextProcessors();
762
- }
763
- });
764
-
765
- // ============================================================================
766
- // 11. CUSTOM TAGS, FILTERS, HELPERS
767
- // ============================================================================
768
-
769
- test('CUSTOM filter: registerFilter is callable from templates', () => {
770
- registerFilter('shout', (val) => String(val).toUpperCase() + '!');
771
- assert.equal(renderString('{{ x|shout }}', { x: 'hi' }), 'HI!');
772
- });
773
-
774
- test('CUSTOM tag: registerTag makes a block-less tag available', () => {
775
- registerTag('today', () => ({ render: () => '2024-01-01' }));
776
- assert.equal(renderString('{% today %}'), '2024-01-01');
777
- });
778
-
779
- test('CUSTOM helper: registerHelper provides a block-level wrapper', () => {
780
- registerHelper('upperwrap', (inner) => `<U>${inner.toUpperCase()}</U>`);
781
- assert.equal(renderString('{% upperwrap %}hello{% endupperwrap %}'), '<U>HELLO</U>');
782
- });
783
-
784
- test('CUSTOM tag via default import works', () => {
785
- mikiTemplate.registerTag('esmtag', () => ({ render: () => 'ESM-TAG-OK' }));
786
- assert.equal(renderString('{% esmtag %}'), 'ESM-TAG-OK');
787
- });
788
-
789
- // ============================================================================
790
- // 12. LIBRARIES
791
- // ============================================================================
792
-
793
- test('LIBRARY: hasLibrary + getLibraryNames + getLibrary', () => {
794
- registerLibrary('present-lib-mjs', {});
795
- assert.ok(mikiTemplate.getLibraryNames().includes('present-lib-mjs'));
796
- assert.equal(mikiTemplate.hasLibrary('present-lib-mjs'), true);
797
- assert.equal(mikiTemplate.hasLibrary('absent-lib-xyz-mjs'), false);
798
- const def = { tags: { xtag: () => ({ render: () => 'X' }) } };
799
- registerLibrary('withdef-mjs', def);
800
- const got = mikiTemplate.getLibrary('withdef-mjs');
801
- assert.equal(got.tags.xtag, def.tags.xtag);
802
- });
803
-
804
- test('LIBRARY: activateLibrary programmatically (same path as {% load %})', () => {
805
- const libName = 'act-mjs-' + Date.now();
806
- registerLibrary(libName, {
807
- tags: { myt: () => ({ render: () => 'OK' }) }
808
- });
809
- mikiTemplate.activateLibrary(libName);
810
- assert.equal(renderString('{% myt %}'), 'OK');
811
- mikiTemplate.unregisterLibrary(libName);
812
- assert.equal(mikiTemplate.hasLibrary(libName), false);
813
- });
814
-
815
- // ============================================================================
816
- // 13. ASYNC RENDERING
817
- // ============================================================================
818
-
819
- test('ASYNC: asyncRender resolves a promise', async () => {
820
- assert.equal(await asyncRender('Hello {{ name }}', { name: 'async' }), 'Hello async');
821
- });
822
-
823
- test('ASYNC: __expressAsync returns a promise of rendered HTML', async () => {
824
- const dir = makeViewDir('async-direct-mjs', {
825
- 'tpl.html': 'Async-{{ x }}'
826
- });
827
- const filePath = path.join(dir, 'tpl.html');
828
- const out = await renderDtplAsync(filePath, { x: 'Y', settings: { views: dir } });
829
- assert.equal(out, 'Async-Y');
830
- });
831
-
832
- // ============================================================================
833
- // 14. CACHE
834
- // ============================================================================
835
-
836
- test('CACHE: clearCache forces a recompile (does not affect behavior)', () => {
837
- const tpl = 'Hello {{ name }}';
838
- const c1 = compile(tpl);
839
- assert.equal(c1.render({ name: 'A' }), 'Hello A');
840
- clearCache();
841
- const c2 = compile(tpl);
842
- assert.equal(c2.render({ name: 'A' }), 'Hello A');
843
- });
844
-
845
- // ============================================================================
846
- // 15. EDGE CASES
847
- // ============================================================================
848
-
849
- test('EDGE: unclosed if throws a clear error', () => {
850
- assert.throws(
851
- () => renderString('{% if true %}never closed'),
852
- /endif|unclosed|Unknown template tag/i
853
- );
854
- });
855
-
856
- test('EDGE: unclosed for throws', () => {
857
- assert.throws(
858
- () => renderString('{% for x in xs %}oops'),
859
- /endfor|unclosed|Unknown template tag/i
860
- );
861
- });
862
-
863
- test('EDGE: unclosed with throws', () => {
864
- assert.throws(
865
- () => renderString('{% with x=1 %}oops'),
866
- /endwith|unclosed|Unknown template tag/i
867
- );
868
- });
869
-
870
- test('EDGE: missing parent template in extends throws', () => {
871
- const dir = makeViewDir('missing-parent-mjs', {});
872
- assert.throws(
873
- () => renderString('{% extends "nope.html" %}', {}, { views: dir }),
874
- /Template not found/
875
- );
876
- });
877
-
878
- test('EDGE: dotted lookup on nested object', () => {
879
- assert.equal(renderString('{{ a.b.c }}', { a: { b: { c: 'deep' } } }), 'deep');
880
- });
881
-
882
- test('EDGE: function value is auto-called', () => {
883
- assert.equal(renderString('{{ fn }}', { fn: () => 'called' }), 'called');
884
- });
885
-
886
- test('EDGE: array index by integer', () => {
887
- assert.equal(renderString('{{ items.1 }}', { items: ['a', 'b', 'c'] }), 'b');
888
- });
889
-
890
- test('EDGE: filter chaining', () => {
891
- assert.equal(renderString('{{ s|upper|truncatechars:3 }}', { s: 'hello world' }), '...');
892
- });
893
-
894
- test('EDGE: multiple variables on one line', () => {
895
- assert.equal(renderString('{{ a }}-{{ b }}-{{ a }}', { a: 'X', b: 'Y' }), 'X-Y-X');
896
- });
897
-
898
- test('EDGE: nested for loops with outer scope accessible', () => {
899
- const out = renderString(
900
- '{% for x in xs %}{% for y in ys %}{{ x }}{{ y }};{% endfor %}{% endfor %}',
901
- { xs: [1, 2], ys: ['a', 'b'] }
902
- );
903
- assert.equal(out, '1a;1b;2a;2b;');
904
- });
905
-
906
- test('EDGE: empty template renders empty string', () => {
907
- assert.equal(renderString(''), '');
908
- assert.equal(renderString(' '), ' ');
909
- });
910
-
911
- test('EDGE: template with only a comment renders empty', () => {
912
- assert.equal(renderString('{# full-line comment #}'), '');
913
- });
914
-
915
- test('EDGE: unknown filter in variable throws', () => {
916
- assert.throws(() => renderString('{{ x|nosuchfilter }}', { x: 'a' }), /Unknown filter/i);
917
- });
918
-
919
- test('EDGE: unknown tag throws', () => {
920
- assert.throws(() => renderString('{% nosuchtag %}'), /Unknown template tag/i);
921
- });
922
-
923
- test('EDGE: for with iterable filter (regroup via for tag)', () => {
924
- const out = renderString(
925
- '{% for g in people|regroup:"gender" %}{{ g.grouper }};{% endfor %}',
926
- { people: [{ gender: 'm' }, { gender: 'f' }, { gender: 'm' }] }
927
- );
928
- assert.equal(out, 'm;f;');
929
- });
930
-
931
- test('EDGE: variable not defined renders as empty string', () => {
932
- assert.equal(renderString('Hello [{{ undef }}]'), 'Hello []');
933
- });
934
-
935
- test('EDGE: null variable with default_if_none returns fallback', () => {
936
- assert.equal(renderString('{{ x|default_if_none:"fb" }}', { x: null }), 'fb');
937
- });
938
-
939
- test('EDGE: undefined variable with default_if_none returns fallback', () => {
940
- assert.equal(renderString('{{ x|default_if_none:"fb" }}', {}), 'fb');
941
- });
942
-
943
- // ============================================================================
944
- // 16. COMPREHENSIVE `with` TAG — ALL FORMS
945
- // ============================================================================
946
-
947
- test('WITH: `value as name` — alias a single expression', () => {
948
- assert.equal(
949
- renderString('{% with user.name as n %}Hi {{ n }}{% endwith %}', { user: { name: 'Bob' } }),
950
- 'Hi Bob'
951
- );
952
- });
953
-
954
- test('WITH: `key=val` — bind a single pair', () => {
955
- assert.equal(
956
- renderString('{% with greeting="Hello" %}{{ greeting }}{% endwith %}'),
957
- 'Hello'
958
- );
959
- });
960
-
961
- test('WITH: `key=val, key=val` — bind multiple pairs', () => {
962
- assert.equal(
963
- renderString('{% with a=5, b=10 %}{{ a }}+{{ b }}=15? {{ a|add:b }}{% endwith %}'),
964
- '5+10=15? 15'
965
- );
966
- });
967
-
968
- test('WITH: `key=val as alias` — pair followed by alias (the user-reported case)', () => {
969
- // {% with a=5 as answ %} binds a=5, then aliases a to answ.
970
- // The user's example: {{ answ|add:5 }} should yield 10.
971
- assert.equal(
972
- renderString('{% with a=5 as answ %}{{ answ|add:5 }}{% endwith %}'),
973
- '10'
974
- );
975
- });
976
-
977
- test('WITH: multi-pair + final alias', () => {
978
- assert.equal(
979
- renderString(
980
- '{% with first="A", second="B" as last %}{{ first }}{{ last }}{% endwith %}'
981
- ),
982
- 'AB'
983
- );
984
- });
985
-
986
- test('WITH: literal numeric and boolean pairs', () => {
987
- assert.equal(
988
- renderString('{% with n=42, flag=true, missing=null %}{{ n }}-{{ flag }}-{{ missing|default:"x" }}{% endwith %}'),
989
- '42-true-x'
990
- );
991
- });
992
-
993
- test('WITH: pair value can be a context variable', () => {
994
- assert.equal(
995
- renderString(
996
- '{% with alias=user.name %}Hello {{ alias }}{% endwith %}',
997
- { user: { name: 'Carol' } }
998
- ),
999
- 'Hello Carol'
1000
- );
1001
- });
1002
-
1003
- test('WITH: quoted value containing a comma', () => {
1004
- assert.equal(
1005
- renderString('{% with msg="Hello, World" %}{{ msg }}{% endwith %}'),
1006
- 'Hello, World'
1007
- );
1008
- });
1009
-
1010
- test('WITH: scope does not leak to outer context', () => {
1011
- assert.equal(
1012
- renderString('{% with x=1 %}inner={{ x }}{% endwith %} outer={{ x|default:"none" }}'),
1013
- 'inner=1 outer=none'
1014
- );
1015
- });
1016
-
1017
- // ============================================================================
1018
- // 17. COMPREHENSIVE PARTIAL TAG — ALL FORMS
1019
- // ============================================================================
1020
-
1021
- test('PARTIAL: partialdef + partial (no args, uses context)', () => {
1022
- const tpl = '{% partialdef card %}<div>{{ title|default:"No title" }}</div>{% endpartialdef %}{% partial card %}';
1023
- assert.equal(renderString(tpl, { title: 'FromCtx' }), '<div>FromCtx</div>');
1024
- });
1025
-
1026
- test('PARTIAL: partial with single `with` argument', () => {
1027
- const tpl = '{% partialdef card %}<div>{{ title }}</div>{% endpartialdef %}{% partial card with title="Hello" %}';
1028
- assert.equal(renderString(tpl), '<div>Hello</div>');
1029
- });
1030
-
1031
- test('PARTIAL: partial with multiple `with` arguments (the user-reported case)', () => {
1032
- const tpl = '{% partialdef card %}<div class="card"><h3>{{ title|default:"No title" }}</h3><p>{{ description|default:"empty" }}</p></div>{% endpartialdef %}{% partial card with title="Hello" description="World" %}';
1033
- assert.equal(
1034
- renderString(tpl),
1035
- '<div class="card"><h3>Hello</h3><p>World</p></div>'
1036
- );
1037
- });
1038
-
1039
- test('PARTIAL: `with` args override context values', () => {
1040
- const tpl = '{% partialdef card %}<div>{{ title }}</div>{% endpartialdef %}{% partial card with title="Override" %}';
1041
- assert.equal(renderString(tpl, { title: 'Original' }), '<div>Override</div>');
1042
- });
1043
-
1044
- test('PARTIAL: partial inline=true renders the body at the definition site', () => {
1045
- const tpl = '{% partialdef card inline %}<div>{{ title }}</div>{% endpartialdef %}|END';
1046
- assert.equal(renderString(tpl, { title: 'Inline' }), '<div>Inline</div>|END');
1047
- });
1048
-
1049
- test('PARTIAL: partial inline renders the body AND registers the partial', () => {
1050
- const tpl = '{% partialdef card inline %}<div>{{ title }}</div>{% endpartialdef %}|{% partial card with title="B" %}';
1051
- assert.equal(renderString(tpl, { title: 'A' }), '<div>A</div>|<div>B</div>');
1052
- });
1053
-
1054
- test('PARTIAL: partial `with` args can use quoted strings with spaces and special chars', () => {
1055
- const tpl = '{% partialdef card %}<div>{{ msg }}</div>{% endpartialdef %}{% partial card with msg="Hello, World!" %}';
1056
- assert.equal(renderString(tpl), '<div>Hello, World!</div>');
1057
- });
1058
-
1059
- test('PARTIAL: `with` args can be context variables', () => {
1060
- const tpl = '{% partialdef card %}<div>{{ title }}</div>{% endpartialdef %}{% partial card with title=user.name %}';
1061
- assert.equal(
1062
- renderString(tpl, { user: { name: 'Dana' } }),
1063
- '<div>Dana</div>'
1064
- );
1065
- });
1066
-
1067
- test('PARTIAL: default filter with `{% lorem %}` fallback renders the tag', () => {
1068
- // The user reported `{{ description|default:"{% lorem %}" }}` —
1069
- // the default filter renders the fallback as a mini-template,
1070
- // producing lorem text when description is empty.
1071
- const tpl = '{% partialdef card %}<p>{{ description|default:"{% lorem 3 w %}" }}</p>{% endpartialdef %}{% partial card %}';
1072
- const out = renderString(tpl);
1073
- // `{% lorem 3 w %}` produces exactly 3 words (first is "Lorem").
1074
- assert.match(out, /^<p>[A-Za-z]+(?: [a-z]+){2}<\/p>$/);
1075
- assert.ok(!out.includes('{% lorem'), 'no raw lorem tag leaked into output');
1076
- });
1077
-
1078
- test('PARTIAL: default filter with `{{ var|filter }}` fallback renders the expression', () => {
1079
- // Nested {{ ... }} inside a default filter arg is rendered.
1080
- const tpl = '{{ x|default:"{{ y|upper }}" }}';
1081
- assert.equal(renderString(tpl, { y: 'hello' }), 'HELLO');
1082
- });
1083
-
1084
- test('PARTIAL: nested partialdef + partial (partial calling a partial)', () => {
1085
- const tpl = '{% partialdef inner %}<i>{{ x }}</i>{% endpartialdef %}{% partialdef outer %}<b>{% partial inner with x="nested" %}</b>{% endpartialdef %}{% partial outer %}';
1086
- assert.equal(renderString(tpl), '<b><i>nested</i></b>');
1087
- });
1088
-
1089
- test('PARTIAL: partial inside a for loop with `with` args from loop variable', () => {
1090
- const tpl = '{% partialdef item %}<li>{{ name }}</li>{% endpartialdef %}{% for u in users %}{% partial item with name=u.name %}{% endfor %}';
1091
- const out = renderString(tpl, { users: [{ name: 'A' }, { name: 'B' }] });
1092
- assert.equal(out, '<li>A</li><li>B</li>');
1093
- });
1094
-
1095
- test('PARTIAL: renderPartial API with extra context', () => {
1096
- const c = compile('{% partialdef card %}<div>{{ title }} - {{ body }}</div>{% endpartialdef %}');
1097
- assert.equal(
1098
- c.renderPartial('card', { title: 'T', body: 'B' }),
1099
- '<div>T - B</div>'
1100
- );
1101
- });
1102
-
1103
- // ============================================================================
1104
- // 18. COMPREHENSIVE `include` TAG — PARTIAL SELECTOR
1105
- // ============================================================================
1106
-
1107
- test('INCLUDE: `{% include "file.html" %}` renders the full file', () => {
1108
- const dir = makeViewDir('include-full-mjs', {
1109
- 'frag.html': 'Hello {{ name }}!'
1110
- });
1111
- assert.equal(
1112
- renderString('{% include "frag.html" %}', { name: 'World' }, { views: dir }),
1113
- 'Hello World!'
1114
- );
1115
- });
1116
-
1117
- test('INCLUDE: `{% include "file.html#partial" %}` renders only the named partial', () => {
1118
- const dir = makeViewDir('include-partial-mjs', {
1119
- 'frag.html': `BEFORE{% partialdef card %}<div>CARD:{{ title }}</div>{% endpartialdef %}AFTER`
1120
- });
1121
- const out = renderString(
1122
- '{% include "frag.html#card" %}',
1123
- { title: 'Hello' },
1124
- { views: dir }
1125
- );
1126
- assert.equal(out, '<div>CARD:Hello</div>');
1127
- });
1128
-
1129
- test('INCLUDE: partial selector uses caller context for variables', () => {
1130
- const dir = makeViewDir('include-partial-ctx-mjs', {
1131
- 'frag.html': `{% partialdef card %}<div>{{ user.name }}</div>{% endpartialdef %}`
1132
- });
1133
- assert.equal(
1134
- renderString('{% include "frag.html#card" %}', { user: { name: 'Eve' } }, { views: dir }),
1135
- '<div>Eve</div>'
1136
- );
1137
- });
1138
-
1139
- test('INCLUDE: partial selector with `with` clause overrides partial scope', () => {
1140
- const dir = makeViewDir('include-partial-with-mjs', {
1141
- 'frag.html': `{% partialdef card %}<div>{{ title }}</div>{% endpartialdef %}`
1142
- });
1143
- assert.equal(
1144
- renderString('{% include "frag.html#card" with title="Override" %}', {}, { views: dir }),
1145
- '<div>Override</div>'
1146
- );
1147
- });
1148
-
1149
- test('INCLUDE: `include "file.html#missing"` throws with a clear error', () => {
1150
- const dir = makeViewDir('include-missing-partial-mjs', {
1151
- 'frag.html': `{% partialdef card %}<div>X</div>{% endpartialdef %}`
1152
- });
1153
- assert.throws(
1154
- () => renderString('{% include "frag.html#nope" %}', {}, { views: dir }),
1155
- /Partial 'nope' not found in template 'frag.html'/
1156
- );
1157
- });
1158
-
1159
- test('INCLUDE: `include "file.html"` path traversal is REJECTED even with #partial', () => {
1160
- const dir = makeViewDir('include-traversal-hash-mjs', {});
1161
- assert.throws(
1162
- () => renderString('{% include "../escape.html#frag" %}', {}, { views: dir }),
1163
- /path traversal/i
1164
- );
1165
- });
1166
-
1167
- test('INCLUDE: `include` with multiple `with` kwargs', () => {
1168
- const dir = makeViewDir('include-multi-with-mjs', {
1169
- 'frag.html': `{{ a }}-{{ b }}-{{ c }}`
1170
- });
1171
- assert.equal(
1172
- renderString(
1173
- '{% include "frag.html" with a=1, b="two", c=x %}',
1174
- { x: 'three' },
1175
- { views: dir }
1176
- ),
1177
- '1-two-three'
1178
- );
1179
- });
1180
-
1181
- // ============================================================================
1182
- // 19. URL TAG — DEEP ROUTING AND MULTI-PARAMS (mapped to real Express routes)
1183
- // ============================================================================
1184
-
1185
- test('URL: many positional params', () => {
1186
- assert.equal(
1187
- renderString('{% url "user.show" 34 "email" "phone" %}'),
1188
- '/user/show/34/email/phone'
1189
- );
1190
- });
1191
-
1192
- test('URL: deep routing with dots converted to slashes', () => {
1193
- assert.equal(
1194
- renderString('{% url "user.profile.posts.show" %}'),
1195
- '/user/profile/posts/show'
1196
- );
1197
- assert.equal(
1198
- renderString('{% url "a.b.c.d.e" %}'),
1199
- '/a/b/c/d/e'
1200
- );
1201
- });
1202
-
1203
- test('URL: kwargs rendered as query string in fallback', () => {
1204
- assert.equal(
1205
- renderString('{% url "user.show" userId=34 tab="posts" %}'),
1206
- '/user/show?userId=34&tab=posts'
1207
- );
1208
- });
1209
-
1210
- test('URL: mixed positional + kwargs', () => {
1211
- assert.equal(
1212
- renderString('{% url "user.show" 34 tab="posts" page=2 %}'),
1213
- '/user/show/34?tab=posts&page=2'
1214
- );
1215
- });
1216
-
1217
- test('URL: urlHelper receives kwargs as last arg when present', () => {
1218
- const out = renderString('{% url "user.show" 34 tab="posts" %}', {}, {
1219
- urlHelper: (name, id, kw) => `/${name}/${id}?tab=${kw.tab}`
1220
- });
1221
- assert.equal(out, '/user.show/34?tab=posts');
1222
- });
1223
-
1224
- test('URL: urlHelper called with just positional when no kwargs', () => {
1225
- const out = renderString('{% url "user.show" 42 %}', {}, {
1226
- urlHelper: (name, id) => `/users/${id}`
1227
- });
1228
- assert.equal(out, '/users/42');
1229
- });
1230
-
1231
- test('URL: mapped to a REAL Express route via urlHelper', async () => {
1232
- // Real-world: wire {% url %} to a helper that builds Express-style
1233
- // paths, then make a real HTTP request to verify the URL actually
1234
- // works.
1235
- const app = express();
1236
- // Map a few "named routes" to Express handlers. Each handler
1237
- // receives the positional args spread, then a kwargs object as the
1238
- // last argument (if any were given).
1239
- const routes = {
1240
- 'user.show': (id) => `/api/users/${id}`,
1241
- 'user.profile.show': (id) => `/api/users/${id}/profile`,
1242
- 'user.posts.list': (id, kw) => {
1243
- const qs = kw
1244
- ? Object.entries(kw).map(([k, v]) => `${k}=${v}`).join('&')
1245
- : '';
1246
- return `/api/users/${id}/posts${qs ? '?' + qs : ''}`;
1247
- },
1248
- 'post.show': (slug) => `/api/posts/${slug}`
1249
- };
1250
- const urlHelper = (routeName, ...args) => {
1251
- const builder = routes[routeName];
1252
- if (!builder) throw new Error(`No route '${routeName}'`);
1253
- return builder(...args);
1254
- };
1255
-
1256
- fs.mkdirSync(TMP_ROOT + '/url-express-mjs', { recursive: true });
1257
- fs.writeFileSync(
1258
- TMP_ROOT + '/url-express-mjs/list.html',
1259
- '<ul><li><a href="{% url "user.show" 1 %}">User 1</a></li>' +
1260
- '<li><a href="{% url "user.show" 2 %}">User 2</a></li>' +
1261
- '<li><a href="{% url "user.profile.show" 1 %}">Profile</a></li>' +
1262
- '<li><a href="{% url "user.posts.list" 1 tab="published" page=2 %}">Posts</a></li></ul>'
1263
- );
1264
-
1265
- app.engine('html', renderDtpl);
1266
- app.set('view engine', 'html');
1267
- app.set('views', TMP_ROOT + '/url-express-mjs');
1268
-
1269
- // Pass urlHelper via res.render locals so the engine sees it in
1270
- // its options.
1271
- app.get('/', (req, res) => res.render('list', { urlHelper }));
1272
-
1273
- // Register the real Express endpoints that {% url %} points to
1274
- app.get('/api/users/:id', (req, res) => res.json({ id: req.params.id, type: 'user' }));
1275
- app.get('/api/users/:id/profile', (req, res) => res.json({ id: req.params.id, type: 'profile' }));
1276
- app.get('/api/users/:id/posts', (req, res) => res.json({ id: req.params.id, tab: req.query.tab, page: req.query.page, type: 'posts' }));
1277
-
1278
- const { port, close } = await startApp(app);
1279
- try {
1280
- // 1. Fetch the HTML and verify {% url %} produced real links
1281
- const html = await get(port, '/');
1282
- assert.match(html.body, /href="\/api\/users\/1"/);
1283
- assert.match(html.body, /href="\/api\/users\/2"/);
1284
- assert.match(html.body, /href="\/api\/users\/1\/profile"/);
1285
- assert.match(html.body, /href="\/api\/users\/1\/posts\?tab=published&page=2"/);
1286
-
1287
- // 2. Actually follow the links to verify the routes exist
1288
- const r1 = await get(port, '/api/users/1');
1289
- assert.equal(r1.status, 200);
1290
- assert.match(r1.body, /"id":"1"/);
1291
-
1292
- const r2 = await get(port, '/api/users/2/profile');
1293
- assert.equal(r2.status, 200);
1294
- assert.match(r2.body, /"type":"profile"/);
1295
-
1296
- const r3 = await get(port, '/api/users/1/posts?tab=published&page=2');
1297
- assert.equal(r3.status, 200);
1298
- assert.match(r3.body, /"tab":"published"/);
1299
- assert.match(r3.body, /"page":"2"/);
1300
- } finally {
1301
- await close();
1302
- }
1303
- });
1304
-
1305
- // ============================================================================
1306
- // 20. REAL-WORLD `dir/home.html` END-TO-END OVER HTTP
1307
- // ============================================================================
1308
-
1309
- test('REAL-WORLD: full dir/home.html template renders over real HTTP', async () => {
1310
- const dir = path.join(__dirname, '../../dir');
1311
- const app = express();
1312
- app.engine('html', renderDtpl);
1313
- app.engine('miki', renderDtpl);
1314
- app.set('view engine', 'html');
1315
- app.set('views', dir);
1316
-
1317
- app.get('/', (req, res) => res.render('home', {
1318
- siteName: 'My Test Site',
1319
- name: 'World',
1320
- login: { name: 'Alice', email: 'alice@example.com' },
1321
- users: [
1322
- { name: 'bob', email: 'bob@example.com' },
1323
- { name: 'carol', email: 'carol@example.com' }
1324
- ],
1325
- data: [
1326
- { name: 'Item 1', email: 'i1@x.com' },
1327
- { name: 'Item 2', email: 'i2@x.com' }
1328
- ]
1329
- }));
1330
-
1331
- const { port, close } = await startApp(app);
1332
- try {
1333
- const r = await get(port, '/');
1334
- assert.equal(r.status, 200);
1335
- assert.match(r.body, /My Test Site/);
1336
- assert.match(r.body, /some new stuff with with tag\s*:\s*10/);
1337
- assert.match(r.body, /<h3>Hello<\/h3>/);
1338
- // The home.html applies |upper after the default filter, so the
1339
- // description comes out uppercased: <p>WORLD</p>
1340
- assert.match(r.body, /<p>WORLD<\/p>/);
1341
- assert.match(r.body, /<td>Bob<\/td>/);
1342
- assert.match(r.body, /<td>Carol<\/td>/);
1343
- assert.match(r.body, /<li>Item 1<\/li>/);
1344
- assert.match(r.body, /<li>Item 2<\/li>/);
1345
- assert.match(r.body, /<li>bob<\/li>/);
1346
- assert.match(r.body, /<li>carol<\/li>/);
1347
- assert.match(r.body, /footer/);
1348
- } finally {
1349
- await close();
1350
- }
1351
- });
1352
-
1353
- // ============================================================================
1354
- // 21. DJANGO-COMPATIBLE `{% lorem %}` TAG
1355
- // ============================================================================
1356
-
1357
- test('LOREM: `{% lorem %}` (no args) outputs the common Lorem ipsum paragraph (method=b default)', () => {
1358
- const out = renderString('{% lorem %}');
1359
- assert.match(out, /^Lorem ipsum dolor sit amet/);
1360
- assert.ok(out.split('\n\n').length === 1, `expected 1 block, got: ${out.split('\n\n').length}`);
1361
- });
1362
-
1363
- test('LOREM: `{% lorem N w %}` outputs N words, first word is "Lorem" (non-random)', () => {
1364
- const out = renderString('{% lorem 5 w %}');
1365
- const words = out.split(' ');
1366
- assert.equal(words.length, 5);
1367
- assert.equal(words[0], 'Lorem');
1368
- });
1369
-
1370
- test('LOREM: `{% lorem N w random %}` outputs N random words (no Lorem start)', () => {
1371
- const out = renderString('{% lorem 7 w random %}');
1372
- const words = out.split(' ');
1373
- assert.equal(words.length, 7);
1374
- assert.notEqual(words[0], 'Lorem');
1375
- assert.notEqual(words[0], 'lorem');
1376
- });
1377
-
1378
- test('LOREM: `{% lorem N p %}` outputs N HTML paragraphs (each wrapped in <p>)', () => {
1379
- const out = renderString('{% lorem 3 p %}');
1380
- const ps = out.match(/<p>[\s\S]*?<\/p>/g) || [];
1381
- assert.equal(ps.length, 3);
1382
- assert.match(ps[0], /^<p>Lorem ipsum dolor sit amet/);
1383
- });
1384
-
1385
- test('LOREM: `{% lorem N b %}` outputs N plain-text blocks separated by blank lines', () => {
1386
- const out = renderString('{% lorem 2 b %}');
1387
- const blocks = out.split('\n\n');
1388
- assert.equal(blocks.length, 2);
1389
- assert.match(blocks[0], /^Lorem ipsum dolor sit amet/);
1390
- });
1391
-
1392
- test('LOREM: `{% lorem N p random %}` outputs N random HTML paragraphs (no Lorem start)', () => {
1393
- const out = renderString('{% lorem 2 p random %}');
1394
- const ps = out.match(/<p>[\s\S]*?<\/p>/g) || [];
1395
- assert.equal(ps.length, 2);
1396
- assert.doesNotMatch(ps[0], /^<p>Lorem ipsum/);
1397
- });
1398
-
1399
- test('LOREM: `{% lorem %}` does NOT require `{% load lorem %}` (built-in)', () => {
1400
- const out = renderString('before {% lorem 3 w %} after');
1401
- assert.match(out, /before .+ after/);
1402
- assert.ok(!out.includes('{% lorem'));
1403
- });
1404
-
1405
- test('LOREM: count argument can be a single digit or multi-digit', () => {
1406
- assert.equal(renderString('{% lorem 1 w %}').split(' ').length, 1);
1407
- assert.equal(renderString('{% lorem 10 w %}').split(' ').length, 10);
1408
- });
1409
-
1410
- test('LOREM: invalid method falls back to "b" (plain text block)', () => {
1411
- const out = renderString('{% lorem 2 x %}');
1412
- assert.ok(out.includes('\n\n'), 'expected blank-line separated blocks');
1413
- });
1414
-
1415
- test('LOREM: works inside a partial definition (real-world)', () => {
1416
- const tpl = '{% partialdef card %}<div class="card">{% lorem 5 w %}</div>{% endpartialdef %}{% partial card %}';
1417
- const out = renderString(tpl);
1418
- assert.match(out, /<div class="card">\w+( \w+){0,4}<\/div>/);
1419
- });
1420
-
1421
- // ============================================================================
1422
- // 22. `render("file#partial")` API FOR HTMX PARTIAL RESPONSES
1423
- // ============================================================================
1424
-
1425
- test('RENDER#PARTIAL: `render("home#card", ctx, { views })` loads file and renders only the named partial', () => {
1426
- const dir = makeViewDir('render-hash-mjs', {
1427
- 'home.html': `BEFORE{% partialdef card %}<div class="card">Hi {{ user }}</div>{% endpartialdef %}AFTER`
1428
- });
1429
- const out = render('home#card', { user: 'Alice' }, { views: dir });
1430
- assert.equal(out, '<div class="card">Hi Alice</div>');
1431
- });
1432
-
1433
- test('RENDER#PARTIAL: `render("home.html#card", ...)` works with explicit extension', () => {
1434
- const dir = makeViewDir('render-hash-ext-mjs', {
1435
- 'home.html': `{% partialdef card %}<div>{{ x }}</div>{% endpartialdef %}`
1436
- });
1437
- assert.equal(render('home.html#card', { x: 'Y' }, { views: dir }), '<div>Y</div>');
1438
- });
1439
-
1440
- test('RENDER#PARTIAL: `render("home.miki#card", ...)` works with .miki extension', () => {
1441
- const dir = makeViewDir('render-hash-miki-mjs', {
1442
- 'home.miki': `{% partialdef card %}<div>{{ x }}</div>{% endpartialdef %}`
1443
- });
1444
- assert.equal(render('home.miki#card', { x: 'Z' }, { views: dir }), '<div>Z</div>');
1445
- });
1446
-
1447
- test('RENDER#PARTIAL: `render("home#card", ...)` with no extension tries .html then .miki', () => {
1448
- const dir = makeViewDir('render-hash-noext-mjs', {
1449
- 'home.miki': `{% partialdef card %}<div>{{ x }}</div>{% endpartialdef %}`
1450
- });
1451
- assert.equal(render('home#card', { x: 'W' }, { views: dir }), '<div>W</div>');
1452
- });
1453
-
1454
- test('RENDER#PARTIAL: missing partial throws a clear error', () => {
1455
- const dir = makeViewDir('render-hash-missing-mjs', {
1456
- 'home.html': `{% partialdef other %}<div>X</div>{% endpartialdef %}`
1457
- });
1458
- assert.throws(
1459
- () => render('home#card', {}, { views: dir }),
1460
- /Partial 'card' not found in template 'home'/
1461
- );
1462
- });
1463
-
1464
- test('RENDER#PARTIAL: missing file throws "Template not found"', () => {
1465
- const dir = makeViewDir('render-hash-nofile-mjs', {});
1466
- assert.throws(
1467
- () => render('nope#card', {}, { views: dir }),
1468
- /Template not found: 'nope'/
1469
- );
1470
- });
1471
-
1472
- test('RENDER#PARTIAL: template strings with `{{` or `{%` are NOT treated as file paths', () => {
1473
- const dir = makeViewDir('render-hash-template-mjs', {
1474
- 'frag.html': `{% partialdef card %}<div>C</div>{% endpartialdef %}`
1475
- });
1476
- const tpl = '{% include "frag.html#card" %}';
1477
- const out = render(tpl, {}, { views: dir });
1478
- assert.equal(out, '<div>C</div>');
1479
- });
1480
-
1481
- test('RENDER#PARTIAL: HTMX-style partial response over real HTTP', async () => {
1482
- const dir = makeViewDir('render-hash-htmx-mjs', {
1483
- 'dashboard.html': `{% partialdef stats %}<div id="stats">Users: {{ users }}</div>{% endpartialdef %}{% partialdef greeting %}<h1>Hello {{ name }}</h1>{% endpartialdef %}`
1484
- });
1485
- const app = express();
1486
- app.engine('html', renderDtpl);
1487
- app.set('view engine', 'html');
1488
- app.set('views', dir);
1489
-
1490
- app.get('/partials/:name', (req, res) => {
1491
- try {
1492
- const html = renderPartialFromFile(
1493
- 'dashboard',
1494
- req.params.name,
1495
- { users: 42, name: 'Alice' },
1496
- { views: dir }
1497
- );
1498
- res.send(html);
1499
- } catch (e) {
1500
- res.status(404).send(e.message);
1501
- }
1502
- });
1503
-
1504
- const { port, close } = await startApp(app);
1505
- try {
1506
- const stats = await get(port, '/partials/stats');
1507
- assert.equal(stats.status, 200);
1508
- assert.equal(stats.body, '<div id="stats">Users: 42</div>');
1509
-
1510
- const greeting = await get(port, '/partials/greeting');
1511
- assert.equal(greeting.status, 200);
1512
- assert.equal(greeting.body, '<h1>Hello Alice</h1>');
1513
-
1514
- const missing = await get(port, '/partials/nope');
1515
- assert.equal(missing.status, 404);
1516
- } finally {
1517
- await close();
1518
- }
1519
- });
1520
-
1521
- test('RENDER#PARTIAL: `expressPartialRenderer()` middleware adds res.renderPartial()', async () => {
1522
- const dir = makeViewDir('render-hash-mw-mjs', {
1523
- 'card.html': `{% partialdef card %}<div class="card">{{ title }}</div>{% endpartialdef %}`
1524
- });
1525
- const app = express();
1526
- app.engine('html', renderDtpl);
1527
- app.set('view engine', 'html');
1528
- app.set('views', dir);
1529
- app.use(expressPartialRenderer());
1530
-
1531
- app.get('/card', (req, res) => res.renderPartial('card#card', { title: 'Hello' }));
1532
-
1533
- const { port, close } = await startApp(app);
1534
- try {
1535
- const r = await get(port, '/card');
1536
- assert.equal(r.status, 200);
1537
- assert.equal(r.body, '<div class="card">Hello</div>');
1538
- } finally {
1539
- await close();
1540
- }
1541
- });
1542
-
1543
- test('RENDER#PARTIAL: `res.render("view#partial")` works with normal Express via setupExpress()', async () => {
1544
- const dir = makeViewDir('render-hash-normal-mjs', {
1545
- 'dashboard.html': `{% partialdef stats %}<div id="stats">Users: {{ users }}</div>{% endpartialdef %}{% partialdef greeting %}<h1>Hello {{ name }}</h1>{% endpartialdef %}`,
1546
- 'page.html': `<!DOCTYPE html><html><body><main>Full page {{ name }}</main></body></html>`
1547
- });
1548
- const app = express();
1549
- mikiTemplate.setupExpress(app, { extension: 'html', views: dir });
1550
-
1551
- app.get('/page', (req, res) => res.render('page', { name: 'World' }));
1552
-
1553
- app.get('/partials/:name', (req, res) =>
1554
- res.render(`dashboard#${req.params.name}`, { users: 42, name: 'Alice' })
1555
- );
1556
-
1557
- const { port, close } = await startApp(app);
1558
- try {
1559
- const full = await get(port, '/page');
1560
- assert.equal(full.status, 200);
1561
- assert.match(full.body, /<main>Full page World<\/main>/);
1562
-
1563
- const stats = await get(port, '/partials/stats');
1564
- assert.equal(stats.status, 200);
1565
- assert.equal(stats.body, '<div id="stats">Users: 42</div>');
1566
-
1567
- const greeting = await get(port, '/partials/greeting');
1568
- assert.equal(greeting.status, 200);
1569
- assert.equal(greeting.body, '<h1>Hello Alice</h1>');
1570
- } finally {
1571
- await close();
1572
- }
1573
- });
1574
-
1575
- test('RENDER#PARTIAL: `res.render("view#partial")` works with callback signature', async () => {
1576
- const dir = makeViewDir('render-hash-callback-mjs', {
1577
- 'card.html': `{% partialdef card %}<div class="card">{{ title }}</div>{% endpartialdef %}`
1578
- });
1579
- const app = express();
1580
- mikiTemplate.setupExpress(app, { extension: 'html', views: dir });
1581
-
1582
- app.get('/card', (req, res) => {
1583
- res.render('card#card', { title: 'CB' }, (err, html) => {
1584
- if (err) return res.status(500).send(err.message);
1585
- res.send(html);
1586
- });
1587
- });
1588
-
1589
- const { port, close } = await startApp(app);
1590
- try {
1591
- const r = await get(port, '/card');
1592
- assert.equal(r.status, 200);
1593
- assert.equal(r.body, '<div class="card">CB</div>');
1594
- } finally {
1595
- await close();
1596
- }
1597
- });
1598
-
1599
- test('RENDER#PARTIAL: asyncRender supports #partial file paths', async () => {
1600
- const dir = makeViewDir('render-hash-async-mjs', {
1601
- 'a.html': `{% partialdef p %}<span>{{ x }}</span>{% endpartialdef %}`
1602
- });
1603
- const out = await asyncRender('a#p', { x: 'async-ok' }, { views: dir });
1604
- assert.equal(out, '<span>async-ok</span>');
1605
- });
1606
-
1607
- // ============================================================================
1608
- // 23. SECURITY TAGS — CSRF AND CSP NONCE ESCAPING
1609
- // ============================================================================
1610
-
1611
- test('SECURITY: csp_nonce_attr escapes the nonce value', () => {
1612
- const out = renderString('<script {% csp_nonce_attr %}></script>', {
1613
- csp_nonce: '"><script>alert(1)</script>'
1614
- });
1615
- assert.match(out, /nonce="&quot;&gt;&lt;script&gt;alert\(1\)&lt;\/script&gt;"/);
1616
- const safe = renderString('<script {% csp_nonce_attr %}></script>', {
1617
- csp_nonce: 'abc123'
1618
- });
1619
- assert.match(safe, /nonce="abc123"/);
1620
- });
1621
-
1622
- test('SECURITY: csp_nonce_attr emits nothing when csp_nonce is missing', () => {
1623
- const out = renderString('<script {% csp_nonce_attr %}></script>', {});
1624
- assert.equal(out, '<script ></script>');
1625
- });
1626
-
1627
- test('SECURITY: csrf_token output is still marked safe (no double-escape)', () => {
1628
- const out = renderString('{% csrf_token %}', { csrf_token: 'tok"en' });
1629
- assert.match(out, /value="tok&quot;en"/);
1630
- assert.doesNotMatch(out, /&amp;quot;/);
1631
- });
1632
-
1633
- // ============================================================================
1634
- // 21. REAL-WORLD COMBINED SCENARIOS
1635
- // ============================================================================
1636
-
1637
- test('REAL-WORLD: blog post with inheritance, for loop, filter, escaping, partial', () => {
1638
- const dir = makeViewDir('blog-mjs', {
1639
- 'base.html': `<article><h1>{% block title %}Default{% endblock %}</h1><div>{% block body %}{% endblock %}</div><footer>{% block footer %}copyright{% endblock %}</footer></article>`,
1640
- 'post.html': `{% extends "base.html" %}{% block title %}{{ post.title|safe }}{% endblock %}{% block body %}{% partialdef tags %}<span class="tag">{{ tag }}</span>{% endpartialdef %}{% for tag in post.tags %}{% partial tags %}{% endfor %}{% endblock %}`
1641
- });
1642
- const out = renderString(
1643
- '{% extends "post.html" %}',
1644
- { post: { title: 'My <Post>', tags: ['js', 'tpl', 'esm'] } },
1645
- { views: dir }
1646
- );
1647
- assert.match(out, /<article><h1>My <Post><\/h1>/);
1648
- assert.match(out, /<span class="tag">js<\/span>/);
1649
- assert.match(out, /<span class="tag">tpl<\/span>/);
1650
- assert.match(out, /<span class="tag">esm<\/span>/);
1651
- assert.match(out, /<footer>copyright<\/footer>/);
1652
- });
1653
-
1654
- test('REAL-WORLD: full Express flow with default import + .html + .miki + view engine', async () => {
1655
- const dir = makeViewDir('realworld-mjs', {
1656
- 'hello.html': `Hello {{ name|upper }}!`,
1657
- 'page.miki': `[Miki-{{ msg }}]`
1658
- });
1659
- const app = express();
1660
- app.engine('html', mikiTemplate.__express);
1661
- app.engine('miki', mikiTemplate.__express);
1662
- app.set('view engine', 'miki');
1663
- app.set('views', dir);
1664
- app.get('/h', (req, res) => res.render('hello.html', { name: 'alice' }));
1665
- app.get('/p', (req, res) => res.render('page', { msg: 'World' }));
1666
- const { port, close } = await startApp(app);
1667
- try {
1668
- assert.equal((await get(port, '/h')).body, 'Hello ALICE!');
1669
- assert.equal((await get(port, '/p')).body, '[Miki-World]');
1670
- } finally {
1671
- await close();
1672
- }
1673
- });
1674
-
1675
- test('REAL-WORLD: full Express flow with context processor, csrf, and safe HTML', async () => {
1676
- const dir = makeViewDir('realworld-form-mjs', {
1677
- 'form.html': `<form>{% csrf_token %}<input name="bio" value="{{ profile.bio }}">{% if profile.bio_raw %}<div>{{ profile.bio_raw|safe }}</div>{% endif %}</form>`
1678
- });
1679
- const TAG = 'SITE-' + Date.now();
1680
- registerContextProcessor(() => ({ siteName: 'RealSite', csrf_token: 'tok-1', processorTag: TAG }));
1681
- const app = express();
1682
- app.engine('html', renderDtpl);
1683
- app.set('view engine', 'html');
1684
- app.set('views', dir);
1685
- app.get('/form', (req, res) =>
1686
- res.render('form', { profile: { bio: '<script>alert(1)</script>', bio_raw: '<b>html</b>' } })
1687
- );
1688
- const { port, close } = await startApp(app);
1689
- try {
1690
- const r = await get(port, '/form');
1691
- assert.match(r.body, /value="&lt;script&gt;alert\(1\)&lt;\/script&gt;"/);
1692
- assert.match(r.body, /<b>html<\/b>/);
1693
- assert.match(r.body, /value="tok-1"/);
1694
- } finally {
1695
- await close();
1696
- }
1697
- });