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