miki-template 2.3.1 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +1 -1
  2. package/package.json +24 -2
  3. package/src/codegen.js +140 -9
  4. package/src/parser.js +69 -1
  5. package/src/tags/control.js +112 -51
  6. package/src/tags/extra.js +4 -17
  7. package/src/tags/i18n.js +6 -17
  8. package/src/tags/inheritance.js +6 -20
  9. package/src/tags/util.js +5 -20
  10. package/.eslintrc.json +0 -16
  11. package/.github/release-notes/v1.3.1.md +0 -55
  12. package/.github/release-notes/v1.3.3.md +0 -77
  13. package/.github/release-notes/v1.3.7.md +0 -20
  14. package/.github/workflows/ci.yml +0 -42
  15. package/.github/workflows/docs.yml +0 -107
  16. package/.github/workflows/release.yml +0 -107
  17. package/AGENT.md +0 -71
  18. package/API_REFERENCE.md +0 -341
  19. package/CHANGELOG.md +0 -173
  20. package/CODE_OF_CONDUCT.md +0 -14
  21. package/CONTRIBUTING.md +0 -27
  22. package/ROADMAP.md +0 -40
  23. package/assets/banner.png +0 -0
  24. package/assets/logo.png +0 -0
  25. package/benchmarks/ejs-results.json +0 -17
  26. package/benchmarks/ejs.js +0 -38
  27. package/benchmarks/handlebars-results.json +0 -17
  28. package/benchmarks/handlebars.js +0 -45
  29. package/benchmarks/miki-results.json +0 -17
  30. package/benchmarks/miki.js +0 -39
  31. package/benchmarks/pug-results.json +0 -17
  32. package/benchmarks/pug.js +0 -38
  33. package/benchmarks/report.json +0 -17
  34. package/benchmarks/run.js +0 -81
  35. package/benchmarks/stress.mjs +0 -647
  36. package/benchmarks/templates/large.dtpl +0 -7
  37. package/benchmarks/templates/medium.dtpl +0 -3
  38. package/benchmarks/templates/small.dtpl +0 -7
  39. package/context/component.md +0 -109
  40. package/context/prd.md +0 -131
  41. package/context/project-structure.md +0 -33
  42. package/dir/base.html +0 -23
  43. package/dir/cmpnt.html +0 -11
  44. package/dir/footer.html +0 -3
  45. package/dir/home.html +0 -80
  46. package/dir/index.html +0 -80
  47. package/dir/navbar.html +0 -9
  48. package/docs/api/async-render.md +0 -170
  49. package/docs/api/cache.md +0 -174
  50. package/docs/api/compile.md +0 -256
  51. package/docs/api/context-processors.md +0 -154
  52. package/docs/api/filters.md +0 -437
  53. package/docs/api/finder.md +0 -188
  54. package/docs/api/helpers.md +0 -106
  55. package/docs/api/i18n.md +0 -314
  56. package/docs/api/index.md +0 -108
  57. package/docs/api/libraries.md +0 -414
  58. package/docs/api/render-partial.md +0 -162
  59. package/docs/api/render.md +0 -184
  60. package/docs/api/security.md +0 -290
  61. package/docs/api/setup-express.md +0 -152
  62. package/docs/api/tags.md +0 -268
  63. package/docs/assets/banner.png +0 -0
  64. package/docs/assets/logo.png +0 -0
  65. package/docs/filter.md +0 -0
  66. package/docs/guide/advanced-usage.md +0 -794
  67. package/docs/guide/async-rendering.md +0 -616
  68. package/docs/guide/context-processors.md +0 -514
  69. package/docs/guide/custom-filters.md +0 -622
  70. package/docs/guide/custom-tags.md +0 -542
  71. package/docs/guide/filters.md +0 -1314
  72. package/docs/guide/getting-started.md +0 -204
  73. package/docs/guide/installation.md +0 -190
  74. package/docs/guide/partial-templates.md +0 -734
  75. package/docs/guide/quick-start.md +0 -444
  76. package/docs/guide/security.md +0 -690
  77. package/docs/guide/tags.md +0 -1566
  78. package/docs/guide/template-discovery.md +0 -340
  79. package/docs/guide/template-inheritance.md +0 -546
  80. package/docs/guide/what-is-miki-template.md +0 -28
  81. package/docs/guide/why-miki-template.md +0 -75
  82. package/docs/index.md +0 -86
  83. package/docs/integrations/elysia.md +0 -80
  84. package/docs/integrations/express.md +0 -219
  85. package/docs/integrations/fastify.md +0 -79
  86. package/docs/integrations/hono.md +0 -80
  87. package/docs/integrations/index.md +0 -68
  88. package/docs/integrations/koa.md +0 -90
  89. package/docs/integrations/nestjs.md +0 -80
  90. package/docs/integrations/tsed.md +0 -83
  91. package/docs/performance.md +0 -74
  92. package/eslint.config.mjs +0 -42
  93. package/ex.mjs +0 -36
  94. package/live-test/integrations/elysia-example.js +0 -16
  95. package/live-test/integrations/express-example.js +0 -24
  96. package/live-test/integrations/fastify-example.js +0 -20
  97. package/live-test/integrations/hono-example.js +0 -16
  98. package/live-test/integrations/koa-example.js +0 -30
  99. package/live-test/integrations/nestjs-example.js +0 -25
  100. package/live-test/integrations/smoke-test.js +0 -166
  101. package/live-test/integrations/tsed-example.js +0 -23
  102. package/live-test/package-lock.json +0 -1150
  103. package/live-test/package.json +0 -13
  104. package/live-test/packages/product/templates/product/detail.html +0 -7
  105. package/live-test/server.js +0 -38
  106. package/live-test/templates/app_templates/detail.html +0 -6
  107. package/live-test/views/base.html +0 -8
  108. package/live-test/views/child.html +0 -7
  109. package/live-test/views/home.html +0 -17
  110. package/live-test/views/index.html +0 -1
  111. package/miki-template-extension/.github/workflows/ci.yml +0 -116
  112. package/miki-template-extension/.vscodeignore +0 -7
  113. package/miki-template-extension/CHANGELOG.md +0 -99
  114. package/miki-template-extension/LICENSE +0 -21
  115. package/miki-template-extension/README.md +0 -273
  116. package/miki-template-extension/extension.js +0 -1013
  117. package/miki-template-extension/icon.png +0 -0
  118. package/miki-template-extension/icon.svg +0 -10
  119. package/miki-template-extension/miki-template-1.7.1.vsix +0 -0
  120. package/miki-template-extension/package.json +0 -280
  121. package/miki-template-extension/snippets/miki-template.json +0 -717
  122. package/miki-template-extension/syntaxes/language-configuration.json +0 -114
  123. package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +0 -355
  124. package/miki-template-extension/tests/grammar-tests.json +0 -162
  125. package/miki-template-extension/tests/run-grammar-tests.js +0 -82
  126. package/mkdocs.yml +0 -195
  127. package/overrides/main.html +0 -26
  128. package/requirements-docs.txt +0 -2
  129. package/sample-app/package-lock.json +0 -901
  130. package/sample-app/package.json +0 -9
  131. package/sample-app/server.js +0 -14
  132. package/sample-app/views/index.html +0 -1
  133. package/scripts/build-vsix.js +0 -129
  134. package/scripts/build-vsix.ps1 +0 -15
  135. package/snippets/miki-template.json +0 -177
  136. package/src/asyncRender.js +0 -21
  137. package/src/types.d.ts +0 -107
  138. package/syntaxes/language-configuration.json +0 -26
  139. package/syntaxes/miki-template.tmLanguage.json +0 -146
  140. package/tests/asyncRender.test.js +0 -17
  141. package/tests/base.html +0 -6
  142. package/tests/child.html +0 -3
  143. package/tests/context_processors.test.js +0 -13
  144. package/tests/esm.test.mjs +0 -61
  145. package/tests/filters.test.js +0 -263
  146. package/tests/finder-appdirs.test.js +0 -19
  147. package/tests/finder.test.js +0 -17
  148. package/tests/fixtures/views/nested/index.html +0 -1
  149. package/tests/fixtures/views/partial.html +0 -1
  150. package/tests/fixtures/views/sub/deepfile.html +0 -1
  151. package/tests/fixtures/views-appdirs/product/site/detail.html +0 -1
  152. package/tests/include_security.test.js +0 -9
  153. package/tests/integration/README.md +0 -32
  154. package/tests/integration/features.test.cjs +0 -1681
  155. package/tests/integration/features.test.mjs +0 -1697
  156. package/tests/integration/finder.esm.test.mjs +0 -13
  157. package/tests/integration/partial-render.test.cjs +0 -13
  158. package/tests/integration/templates/base.miki +0 -6
  159. package/tests/integration/templates/child.miki +0 -6
  160. package/tests/integration/templates/index.html +0 -17
  161. package/tests/lexer.test.js +0 -45
  162. package/tests/parser.test.js +0 -57
  163. package/tests/partial.html +0 -1
  164. package/tests/partialdef.test.js +0 -79
  165. package/tests/production_checks.js +0 -57
  166. package/tests/security.test.js +0 -28
  167. 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
- });