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.
- package/README.md +17 -5
- package/package.json +45 -1
- package/.eslintrc.json +0 -16
- package/.github/release-notes/v1.3.1.md +0 -55
- package/.github/release-notes/v1.3.3.md +0 -77
- package/.github/release-notes/v1.3.7.md +0 -20
- package/.github/workflows/ci.yml +0 -42
- package/.github/workflows/docs.yml +0 -107
- package/.github/workflows/release.yml +0 -107
- package/AGENT.md +0 -71
- package/API_REFERENCE.md +0 -341
- package/CHANGELOG.md +0 -173
- package/CODE_OF_CONDUCT.md +0 -14
- package/CONTRIBUTING.md +0 -27
- package/ROADMAP.md +0 -40
- package/assets/banner.png +0 -0
- package/assets/logo.png +0 -0
- package/benchmarks/ejs-results.json +0 -17
- package/benchmarks/ejs.js +0 -38
- package/benchmarks/handlebars-results.json +0 -17
- package/benchmarks/handlebars.js +0 -45
- package/benchmarks/miki-results.json +0 -17
- package/benchmarks/miki.js +0 -39
- package/benchmarks/pug-results.json +0 -17
- package/benchmarks/pug.js +0 -38
- package/benchmarks/report.json +0 -17
- package/benchmarks/run.js +0 -81
- package/benchmarks/stress.mjs +0 -647
- package/benchmarks/templates/large.dtpl +0 -7
- package/benchmarks/templates/medium.dtpl +0 -3
- package/benchmarks/templates/small.dtpl +0 -7
- package/context/component.md +0 -109
- package/context/prd.md +0 -131
- package/context/project-structure.md +0 -33
- package/dir/base.html +0 -23
- package/dir/cmpnt.html +0 -11
- package/dir/footer.html +0 -3
- package/dir/home.html +0 -80
- package/dir/index.html +0 -80
- package/dir/navbar.html +0 -9
- package/docs/api/async-render.md +0 -170
- package/docs/api/cache.md +0 -174
- package/docs/api/compile.md +0 -256
- package/docs/api/context-processors.md +0 -154
- package/docs/api/filters.md +0 -437
- package/docs/api/finder.md +0 -188
- package/docs/api/helpers.md +0 -106
- package/docs/api/i18n.md +0 -314
- package/docs/api/index.md +0 -108
- package/docs/api/libraries.md +0 -414
- package/docs/api/render-partial.md +0 -162
- package/docs/api/render.md +0 -184
- package/docs/api/security.md +0 -290
- package/docs/api/setup-express.md +0 -152
- package/docs/api/tags.md +0 -268
- package/docs/assets/banner.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/filter.md +0 -0
- package/docs/guide/advanced-usage.md +0 -794
- package/docs/guide/async-rendering.md +0 -616
- package/docs/guide/context-processors.md +0 -514
- package/docs/guide/custom-filters.md +0 -622
- package/docs/guide/custom-tags.md +0 -542
- package/docs/guide/filters.md +0 -1314
- package/docs/guide/getting-started.md +0 -204
- package/docs/guide/installation.md +0 -190
- package/docs/guide/partial-templates.md +0 -734
- package/docs/guide/quick-start.md +0 -444
- package/docs/guide/security.md +0 -690
- package/docs/guide/tags.md +0 -1566
- package/docs/guide/template-discovery.md +0 -340
- package/docs/guide/template-inheritance.md +0 -546
- package/docs/guide/what-is-miki-template.md +0 -28
- package/docs/guide/why-miki-template.md +0 -75
- package/docs/index.md +0 -86
- package/docs/integrations/elysia.md +0 -80
- package/docs/integrations/express.md +0 -219
- package/docs/integrations/fastify.md +0 -79
- package/docs/integrations/hono.md +0 -80
- package/docs/integrations/index.md +0 -68
- package/docs/integrations/koa.md +0 -90
- package/docs/integrations/nestjs.md +0 -80
- package/docs/integrations/tsed.md +0 -83
- package/docs/performance.md +0 -74
- package/eslint.config.mjs +0 -42
- package/ex.mjs +0 -36
- package/live-test/integrations/elysia-example.js +0 -16
- package/live-test/integrations/express-example.js +0 -24
- package/live-test/integrations/fastify-example.js +0 -20
- package/live-test/integrations/hono-example.js +0 -16
- package/live-test/integrations/koa-example.js +0 -30
- package/live-test/integrations/nestjs-example.js +0 -25
- package/live-test/integrations/smoke-test.js +0 -166
- package/live-test/integrations/tsed-example.js +0 -23
- package/live-test/package-lock.json +0 -1150
- package/live-test/package.json +0 -13
- package/live-test/packages/product/templates/product/detail.html +0 -7
- package/live-test/server.js +0 -38
- package/live-test/templates/app_templates/detail.html +0 -6
- package/live-test/views/base.html +0 -8
- package/live-test/views/child.html +0 -7
- package/live-test/views/home.html +0 -17
- package/live-test/views/index.html +0 -1
- package/miki-template-extension/.github/workflows/ci.yml +0 -116
- package/miki-template-extension/.vscodeignore +0 -7
- package/miki-template-extension/CHANGELOG.md +0 -99
- package/miki-template-extension/LICENSE +0 -21
- package/miki-template-extension/README.md +0 -273
- package/miki-template-extension/extension.js +0 -1013
- package/miki-template-extension/icon.png +0 -0
- package/miki-template-extension/icon.svg +0 -10
- package/miki-template-extension/miki-template-1.7.1.vsix +0 -0
- package/miki-template-extension/package.json +0 -280
- package/miki-template-extension/snippets/miki-template.json +0 -717
- package/miki-template-extension/syntaxes/language-configuration.json +0 -114
- package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +0 -355
- package/miki-template-extension/tests/grammar-tests.json +0 -162
- package/miki-template-extension/tests/run-grammar-tests.js +0 -82
- package/mkdocs.yml +0 -195
- package/overrides/main.html +0 -26
- package/requirements-docs.txt +0 -2
- package/sample-app/package-lock.json +0 -901
- package/sample-app/package.json +0 -9
- package/sample-app/server.js +0 -14
- package/sample-app/views/index.html +0 -1
- package/scripts/build-vsix.js +0 -129
- package/scripts/build-vsix.ps1 +0 -15
- package/snippets/miki-template.json +0 -177
- package/src/asyncRender.js +0 -21
- package/src/types.d.ts +0 -107
- package/syntaxes/language-configuration.json +0 -26
- package/syntaxes/miki-template.tmLanguage.json +0 -146
- package/tests/asyncRender.test.js +0 -17
- package/tests/base.html +0 -6
- package/tests/child.html +0 -3
- package/tests/context_processors.test.js +0 -13
- package/tests/esm.test.mjs +0 -61
- package/tests/filters.test.js +0 -263
- package/tests/finder-appdirs.test.js +0 -19
- package/tests/finder.test.js +0 -17
- package/tests/fixtures/views/nested/index.html +0 -1
- package/tests/fixtures/views/partial.html +0 -1
- package/tests/fixtures/views/sub/deepfile.html +0 -1
- package/tests/fixtures/views-appdirs/product/site/detail.html +0 -1
- package/tests/include_security.test.js +0 -9
- package/tests/integration/README.md +0 -32
- package/tests/integration/features.test.cjs +0 -1681
- package/tests/integration/features.test.mjs +0 -1697
- package/tests/integration/finder.esm.test.mjs +0 -13
- package/tests/integration/partial-render.test.cjs +0 -13
- package/tests/integration/templates/base.miki +0 -6
- package/tests/integration/templates/child.miki +0 -6
- package/tests/integration/templates/index.html +0 -17
- package/tests/lexer.test.js +0 -45
- package/tests/parser.test.js +0 -57
- package/tests/partial.html +0 -1
- package/tests/partialdef.test.js +0 -79
- package/tests/production_checks.js +0 -57
- package/tests/security.test.js +0 -28
- 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>' }), '<a>');
|
|
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, /"foo"/);
|
|
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>' }), '<a>');
|
|
523
|
-
assert.equal(renderString('{{ x }}', { x: '<a>' }), '<a>');
|
|
524
|
-
assert.ok(isSafe(markSafe('<b>')));
|
|
525
|
-
assert.equal(escapeHtml('<x>'), '<x>');
|
|
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, /<script>/);
|
|
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
|
-
'<b>safe</b>'
|
|
690
|
-
);
|
|
691
|
-
assert.equal(
|
|
692
|
-
renderString('{{ x|escape }}', { x: '<i>x</i>' }),
|
|
693
|
-
'<i>x</i>'
|
|
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="<hack>"/);
|
|
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 `"` 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="" onerror="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=""><script>alert\(1\)<\/script>"/);
|
|
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"en"/);
|
|
1630
|
-
assert.doesNotMatch(out, /&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="<script>alert\(1\)<\/script>"/);
|
|
1692
|
-
assert.match(r.body, /<b>html<\/b>/);
|
|
1693
|
-
assert.match(r.body, /value="tok-1"/);
|
|
1694
|
-
} finally {
|
|
1695
|
-
await close();
|
|
1696
|
-
}
|
|
1697
|
-
});
|