kempo-server 2.1.1 → 3.0.0
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/CONFIG.md +295 -187
- package/README.md +31 -4
- package/SPA.md +14 -14
- package/UTILS.md +39 -0
- package/dist/defaultConfig.js +1 -1
- package/dist/index.js +1 -1
- package/dist/render.js +2 -0
- package/dist/rescan.js +1 -0
- package/dist/router.js +1 -1
- package/dist/serveFile.js +1 -1
- package/dist/templating/index.js +1 -0
- package/dist/templating/parse.js +1 -0
- package/docs/dist/caching.html +324 -0
- package/docs/dist/cli-utils.html +175 -0
- package/docs/dist/configuration.html +414 -0
- package/docs/dist/examples.html +296 -0
- package/docs/dist/fs-utils.html +206 -0
- package/docs/dist/getting-started.html +167 -0
- package/docs/dist/index.html +183 -0
- package/docs/dist/middleware.html +237 -0
- package/docs/dist/request-response.html +200 -0
- package/docs/dist/routing.html +177 -0
- package/docs/dist/templating.html +292 -0
- package/docs/{theme.css → dist/theme.css} +1 -3
- package/docs/src/.config.js +11 -0
- package/docs/{caching.html → src/caching.page.html} +4 -19
- package/docs/{cli-utils.html → src/cli-utils.page.html} +4 -20
- package/docs/{configuration.html → src/configuration.page.html} +4 -18
- package/docs/src/default.template.html +35 -0
- package/docs/{examples.html → src/examples.page.html} +9 -18
- package/docs/{fs-utils.html → src/fs-utils.page.html} +4 -20
- package/docs/{getting-started.html → src/getting-started.page.html} +4 -18
- package/docs/src/index.page.html +79 -0
- package/docs/{middleware.html → src/middleware.page.html} +4 -18
- package/docs/src/nav.fragment.html +73 -0
- package/docs/{request-response.html → src/request-response.page.html} +4 -18
- package/docs/{routing.html → src/routing.page.html} +4 -18
- package/docs/src/templating.page.html +188 -0
- package/{llm.txt → llms.txt} +100 -30
- package/package.json +7 -3
- package/scripts/build.js +19 -11
- package/scripts/render.js +58 -0
- package/src/defaultConfig.js +14 -2
- package/src/index.js +1 -1
- package/src/rescan.js +14 -0
- package/src/router.js +82 -11
- package/src/serveFile.js +27 -0
- package/src/templating/index.js +132 -0
- package/src/templating/parse.js +285 -0
- package/tests/cacheConfig.node-test.js +2 -2
- package/tests/config-flag.node-test.js +61 -25
- package/tests/customRoute-outside-root.node-test.js +1 -1
- package/tests/rescan.node-test.js +69 -0
- package/tests/router-wildcard.node-test.js +47 -2
- package/tests/templating-parse.node-test.js +243 -0
- package/tests/templating-render.node-test.js +188 -0
- package/tests/utils/test-scenario.js +4 -4
- package/docs/.config.json.example +0 -29
- package/docs/api/_admin/cache/DELETE.js +0 -28
- package/docs/api/_admin/cache/GET.js +0 -53
- package/docs/api/user/[id]/GET.js +0 -15
- package/docs/api/user/[id]/[info]/DELETE.js +0 -12
- package/docs/api/user/[id]/[info]/GET.js +0 -17
- package/docs/api/user/[id]/[info]/POST.js +0 -18
- package/docs/api/user/[id]/[info]/PUT.js +0 -19
- package/docs/index.html +0 -88
- package/docs/init.js +0 -0
- package/docs/kempo.min.css +0 -1
- package/docs/nav.inc.html +0 -41
- package/docs/nav.inc.js +0 -16
- /package/docs/{manifest.json → dist/manifest.json} +0 -0
- /package/docs/{media → dist/media}/hexagon.svg +0 -0
- /package/docs/{media → dist/media}/icon-maskable.png +0 -0
- /package/docs/{media → dist/media}/icon.svg +0 -0
- /package/docs/{media → dist/media}/icon128.png +0 -0
- /package/docs/{media → dist/media}/icon144.png +0 -0
- /package/docs/{media → dist/media}/icon152.png +0 -0
- /package/docs/{media → dist/media}/icon16-48.svg +0 -0
- /package/docs/{media → dist/media}/icon16.png +0 -0
- /package/docs/{media → dist/media}/icon192.png +0 -0
- /package/docs/{media → dist/media}/icon256.png +0 -0
- /package/docs/{media → dist/media}/icon32.png +0 -0
- /package/docs/{media → dist/media}/icon384.png +0 -0
- /package/docs/{media → dist/media}/icon48.png +0 -0
- /package/docs/{media → dist/media}/icon512.png +0 -0
- /package/docs/{media → dist/media}/icon64.png +0 -0
- /package/docs/{media → dist/media}/icon72.png +0 -0
- /package/docs/{media → dist/media}/icon96.png +0 -0
- /package/docs/{media → dist/media}/kempo-fist.svg +0 -0
|
@@ -14,7 +14,7 @@ export default {
|
|
|
14
14
|
port: 3000,
|
|
15
15
|
logging: 2,
|
|
16
16
|
root: './',
|
|
17
|
-
config: '.config.
|
|
17
|
+
config: '.config.js'
|
|
18
18
|
}, {
|
|
19
19
|
p: 'port',
|
|
20
20
|
l: 'logging',
|
|
@@ -22,8 +22,8 @@ export default {
|
|
|
22
22
|
c: 'config'
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
if (flags.config !== '.config.
|
|
26
|
-
return fail('default config should be .config.
|
|
25
|
+
if (flags.config !== '.config.js') {
|
|
26
|
+
return fail('default config should be .config.js');
|
|
27
27
|
}
|
|
28
28
|
if (flags.port !== '8080') {
|
|
29
29
|
return fail('other flags should still work');
|
|
@@ -36,12 +36,12 @@ export default {
|
|
|
36
36
|
},
|
|
37
37
|
|
|
38
38
|
'getFlags parses custom config flag with long form': async ({pass, fail}) => {
|
|
39
|
-
const args = ['--root', 'public', '--config', 'dev.config.
|
|
39
|
+
const args = ['--root', 'public', '--config', 'dev.config.js'];
|
|
40
40
|
const flags = getFlags(args, {
|
|
41
41
|
port: 3000,
|
|
42
42
|
logging: 2,
|
|
43
43
|
root: './',
|
|
44
|
-
config: '.config.
|
|
44
|
+
config: '.config.js'
|
|
45
45
|
}, {
|
|
46
46
|
p: 'port',
|
|
47
47
|
l: 'logging',
|
|
@@ -49,7 +49,7 @@ export default {
|
|
|
49
49
|
c: 'config'
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
if (flags.config !== 'dev.config.
|
|
52
|
+
if (flags.config !== 'dev.config.js') {
|
|
53
53
|
return fail('should parse custom config file');
|
|
54
54
|
}
|
|
55
55
|
if (flags.root !== 'public') {
|
|
@@ -60,12 +60,12 @@ export default {
|
|
|
60
60
|
},
|
|
61
61
|
|
|
62
62
|
'getFlags parses custom config flag with short form': async ({pass, fail}) => {
|
|
63
|
-
const args = ['--root', 'public', '-c', 'production.config.
|
|
63
|
+
const args = ['--root', 'public', '-c', 'production.config.js'];
|
|
64
64
|
const flags = getFlags(args, {
|
|
65
65
|
port: 3000,
|
|
66
66
|
logging: 2,
|
|
67
67
|
root: './',
|
|
68
|
-
config: '.config.
|
|
68
|
+
config: '.config.js'
|
|
69
69
|
}, {
|
|
70
70
|
p: 'port',
|
|
71
71
|
l: 'logging',
|
|
@@ -73,7 +73,7 @@ export default {
|
|
|
73
73
|
c: 'config'
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
if (flags.config !== 'production.config.
|
|
76
|
+
if (flags.config !== 'production.config.js') {
|
|
77
77
|
return fail('should parse short form config flag');
|
|
78
78
|
}
|
|
79
79
|
if (flags.root !== 'public') {
|
|
@@ -85,19 +85,19 @@ export default {
|
|
|
85
85
|
|
|
86
86
|
'router uses default config file when none specified': async ({pass, fail}) => {
|
|
87
87
|
await withTempDir(async (dir) => {
|
|
88
|
-
// Create a custom config file as .config.
|
|
88
|
+
// Create a custom config file as .config.js (default name)
|
|
89
89
|
const customConfig = {
|
|
90
90
|
allowedMimes: {
|
|
91
91
|
html: { mime: "text/html", encoding: "utf8" },
|
|
92
92
|
custom: { mime: "text/custom", encoding: "utf8" }
|
|
93
93
|
}
|
|
94
94
|
};
|
|
95
|
-
await write(dir, '.config.
|
|
95
|
+
await write(dir, '.config.js', `export default ${JSON.stringify(customConfig)}`);
|
|
96
96
|
await write(dir, 'test.custom', 'custom content');
|
|
97
97
|
|
|
98
98
|
const prev = process.cwd();
|
|
99
99
|
process.chdir(dir);
|
|
100
|
-
const flags = {root: '.', logging: 0, rescan: false, config: '.config.
|
|
100
|
+
const flags = {root: '.', logging: 0, rescan: false, config: '.config.js'};
|
|
101
101
|
const logFn = () => {};
|
|
102
102
|
const handler = await router(flags, logFn);
|
|
103
103
|
const server = http.createServer(handler);
|
|
@@ -131,12 +131,12 @@ export default {
|
|
|
131
131
|
special: { mime: "text/special", encoding: "utf8" }
|
|
132
132
|
}
|
|
133
133
|
};
|
|
134
|
-
await write(dir, 'dev.config.
|
|
134
|
+
await write(dir, 'dev.config.js', `export default ${JSON.stringify(customConfig)}`);
|
|
135
135
|
await write(dir, 'test.special', 'special content');
|
|
136
136
|
|
|
137
137
|
const prev = process.cwd();
|
|
138
138
|
process.chdir(dir);
|
|
139
|
-
const flags = {root: '.', logging: 0, rescan: false, config: 'dev.config.
|
|
139
|
+
const flags = {root: '.', logging: 0, rescan: false, config: 'dev.config.js'};
|
|
140
140
|
const logFn = () => {};
|
|
141
141
|
const handler = await router(flags, logFn);
|
|
142
142
|
const server = http.createServer(handler);
|
|
@@ -171,7 +171,7 @@ export default {
|
|
|
171
171
|
absolute: { mime: "text/absolute", encoding: "utf8" }
|
|
172
172
|
}
|
|
173
173
|
};
|
|
174
|
-
const configPath = await write(configDir, 'prod.config.
|
|
174
|
+
const configPath = await write(configDir, 'prod.config.js', `export default ${JSON.stringify(customConfig)}`);
|
|
175
175
|
await write(dir, 'test.absolute', 'absolute content');
|
|
176
176
|
|
|
177
177
|
const prev = process.cwd();
|
|
@@ -201,14 +201,51 @@ export default {
|
|
|
201
201
|
});
|
|
202
202
|
},
|
|
203
203
|
|
|
204
|
-
'router falls back to
|
|
204
|
+
'router falls back to .config.json when .config.js is missing': async ({pass, fail}) => {
|
|
205
|
+
await withTempDir(async (dir) => {
|
|
206
|
+
const customConfig = {
|
|
207
|
+
allowedMimes: {
|
|
208
|
+
html: { mime: "text/html", encoding: "utf8" },
|
|
209
|
+
jsononly: { mime: "text/jsononly", encoding: "utf8" }
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
await write(dir, '.config.json', JSON.stringify(customConfig));
|
|
213
|
+
await write(dir, 'test.jsononly', 'json fallback content');
|
|
214
|
+
|
|
215
|
+
const prev = process.cwd();
|
|
216
|
+
process.chdir(dir);
|
|
217
|
+
const flags = {root: '.', logging: 0, rescan: false, config: '.config.js'};
|
|
218
|
+
const logFn = () => {};
|
|
219
|
+
const handler = await router(flags, logFn);
|
|
220
|
+
const server = http.createServer(handler);
|
|
221
|
+
const port = randomPort();
|
|
222
|
+
|
|
223
|
+
await new Promise(r => server.listen(port, r));
|
|
224
|
+
await new Promise(r => setTimeout(r, 50));
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const response = await httpGet(`http://localhost:${port}/test.jsononly`);
|
|
228
|
+
if (response.res.statusCode !== 200) {
|
|
229
|
+
return fail('should fall back to .config.json and serve custom mime');
|
|
230
|
+
}
|
|
231
|
+
if (response.res.headers['content-type'] !== 'text/jsononly; charset=utf-8') {
|
|
232
|
+
return fail('should use config from JSON fallback');
|
|
233
|
+
}
|
|
234
|
+
pass('falls back to .config.json when .config.js missing');
|
|
235
|
+
} finally {
|
|
236
|
+
server.close();
|
|
237
|
+
process.chdir(prev);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
'router falls back to default config when no config file exists': async ({pass, fail}) => {
|
|
205
243
|
await withTempDir(async (dir) => {
|
|
206
244
|
await write(dir, 'index.html', '<h1>Home</h1>');
|
|
207
245
|
|
|
208
246
|
const prev = process.cwd();
|
|
209
247
|
process.chdir(dir);
|
|
210
|
-
|
|
211
|
-
const flags = {root: '.', logging: 0, rescan: false, config: 'nonexistent.config.json'};
|
|
248
|
+
const flags = {root: '.', logging: 0, rescan: false, config: 'nonexistent.config.js'};
|
|
212
249
|
const logFn = () => {};
|
|
213
250
|
const handler = await router(flags, logFn);
|
|
214
251
|
const server = http.createServer(handler);
|
|
@@ -235,13 +272,12 @@ export default {
|
|
|
235
272
|
|
|
236
273
|
'router handles malformed config file gracefully': async ({pass, fail}) => {
|
|
237
274
|
await withTempDir(async (dir) => {
|
|
238
|
-
|
|
239
|
-
await write(dir, 'bad.config.json', '{ invalid json }');
|
|
275
|
+
await write(dir, 'bad.config.js', 'this is not valid javascript export default {');
|
|
240
276
|
await write(dir, 'index.html', '<h1>Home</h1>');
|
|
241
277
|
|
|
242
278
|
const prev = process.cwd();
|
|
243
279
|
process.chdir(dir);
|
|
244
|
-
const flags = {root: '.', logging: 0, rescan: false, config: 'bad.config.
|
|
280
|
+
const flags = {root: '.', logging: 0, rescan: false, config: 'bad.config.js'};
|
|
245
281
|
const logFn = () => {};
|
|
246
282
|
const handler = await router(flags, logFn);
|
|
247
283
|
const server = http.createServer(handler);
|
|
@@ -275,13 +311,13 @@ export default {
|
|
|
275
311
|
},
|
|
276
312
|
maxRescanAttempts: 5
|
|
277
313
|
};
|
|
278
|
-
await write(dir, 'partial.config.
|
|
314
|
+
await write(dir, 'partial.config.js', `export default ${JSON.stringify(partialConfig)}`);
|
|
279
315
|
await write(dir, 'test.js', 'console.log("test");'); // JS should still work from default config
|
|
280
316
|
await write(dir, 'test.custom', 'custom content');
|
|
281
317
|
|
|
282
318
|
const prev = process.cwd();
|
|
283
319
|
process.chdir(dir);
|
|
284
|
-
const flags = {root: '.', logging: 0, rescan: false, config: 'partial.config.
|
|
320
|
+
const flags = {root: '.', logging: 0, rescan: false, config: 'partial.config.js'};
|
|
285
321
|
const logFn = () => {};
|
|
286
322
|
const handler = await router(flags, logFn);
|
|
287
323
|
const server = http.createServer(handler);
|
|
@@ -320,7 +356,7 @@ export default {
|
|
|
320
356
|
await withTempDir(async (dir) => {
|
|
321
357
|
// Create a config file outside the server root
|
|
322
358
|
const configDir = path.join(dir, '..', 'config-outside-root');
|
|
323
|
-
const configFilePath = await write(configDir, 'outside.config.
|
|
359
|
+
const configFilePath = await write(configDir, 'outside.config.js', `export default {allowedMimes: {test: "application/test"}}`);
|
|
324
360
|
|
|
325
361
|
// Create a file in the server root to verify it doesn't start
|
|
326
362
|
await write(dir, 'index.html', '<h1>Home</h1>');
|
|
@@ -330,7 +366,7 @@ export default {
|
|
|
330
366
|
|
|
331
367
|
try {
|
|
332
368
|
// Try to use config file outside server root with relative path
|
|
333
|
-
const flags = {root: '.', logging: 0, rescan: false, config: '../config-outside-root/outside.config.
|
|
369
|
+
const flags = {root: '.', logging: 0, rescan: false, config: '../config-outside-root/outside.config.js'};
|
|
334
370
|
|
|
335
371
|
log('Test setup:');
|
|
336
372
|
log('dir: ' + dir);
|
|
@@ -44,7 +44,7 @@ export default {
|
|
|
44
44
|
'/src/file.txt': '../src/file.txt'
|
|
45
45
|
}
|
|
46
46
|
};
|
|
47
|
-
await writeFile(path.join(rootDir, '.config.
|
|
47
|
+
await writeFile(path.join(rootDir, '.config.js'), `export default ${JSON.stringify(config)}`);
|
|
48
48
|
log('Config written: ' + JSON.stringify(config));
|
|
49
49
|
|
|
50
50
|
// Set working directory to temp dir so relative paths resolve correctly
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import {withTestDir} from './utils/test-dir.js';
|
|
3
|
+
import {write} from './utils/file-writer.js';
|
|
4
|
+
import {randomPort} from './utils/port.js';
|
|
5
|
+
import {httpGet} from './utils/http.js';
|
|
6
|
+
import router from '../src/router.js';
|
|
7
|
+
import rescan from '../src/rescan.js';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
'rescan() triggers file rescan and returns file count': async ({pass, fail}) => {
|
|
11
|
+
await withTestDir(async dir => {
|
|
12
|
+
const prev = process.cwd();
|
|
13
|
+
process.chdir(dir);
|
|
14
|
+
const flags = {root: '.', logging: 0};
|
|
15
|
+
const logFn = () => {};
|
|
16
|
+
|
|
17
|
+
await write(dir, '.config.json', JSON.stringify({
|
|
18
|
+
maxRescanAttempts: 0
|
|
19
|
+
}));
|
|
20
|
+
await write(dir, 'index.html', '<h1>Home</h1>');
|
|
21
|
+
|
|
22
|
+
const handler = await router(flags, logFn);
|
|
23
|
+
const server = http.createServer(handler);
|
|
24
|
+
const port = randomPort();
|
|
25
|
+
await new Promise(r => server.listen(port, r));
|
|
26
|
+
await new Promise(r => setTimeout(r, 50));
|
|
27
|
+
|
|
28
|
+
const miss = await httpGet(`http://localhost:${port}/added.html`);
|
|
29
|
+
if(miss.res.statusCode !== 404) {
|
|
30
|
+
server.close();
|
|
31
|
+
process.chdir(prev);
|
|
32
|
+
return fail('should 404 before file exists');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await write(dir, 'added.html', '<h1>Added</h1>');
|
|
36
|
+
|
|
37
|
+
const stillMiss = await httpGet(`http://localhost:${port}/added.html`);
|
|
38
|
+
if(stillMiss.res.statusCode !== 404) {
|
|
39
|
+
server.close();
|
|
40
|
+
process.chdir(prev);
|
|
41
|
+
return fail('should still 404 with maxRescanAttempts=0');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const count = await rescan();
|
|
45
|
+
if(typeof count !== 'number' || count < 2) {
|
|
46
|
+
server.close();
|
|
47
|
+
process.chdir(prev);
|
|
48
|
+
return fail(`rescan should return file count, got: ${count}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hit = await httpGet(`http://localhost:${port}/added.html`);
|
|
52
|
+
if(hit.res.statusCode !== 200) {
|
|
53
|
+
server.close();
|
|
54
|
+
process.chdir(prev);
|
|
55
|
+
return fail('should serve file after rescan()');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if(!hit.body.toString().includes('Added')) {
|
|
59
|
+
server.close();
|
|
60
|
+
process.chdir(prev);
|
|
61
|
+
return fail('should serve correct content after rescan');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
server.close();
|
|
65
|
+
process.chdir(prev);
|
|
66
|
+
});
|
|
67
|
+
pass('rescan() works from imported function');
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -172,6 +172,51 @@ export default {
|
|
|
172
172
|
pass('wildcard routes correctly override static files');
|
|
173
173
|
} catch(e){
|
|
174
174
|
fail(e.message);
|
|
175
|
-
}
|
|
176
|
-
|
|
175
|
+
} },
|
|
176
|
+
|
|
177
|
+
'wildcard routes without leading slash match requests': async ({pass, fail, log}) => {
|
|
178
|
+
try {
|
|
179
|
+
await withTempDir(async (dir) => {
|
|
180
|
+
await write(dir, 'media/icon.png', 'icon-data');
|
|
181
|
+
await write(dir, 'media/logo.svg', '<svg/>');
|
|
182
|
+
|
|
183
|
+
const prev = process.cwd();
|
|
184
|
+
process.chdir(dir);
|
|
185
|
+
|
|
186
|
+
const flags = {root: 'docs', logging: 0};
|
|
187
|
+
|
|
188
|
+
// Keys without leading slash — the bug caused these to never match
|
|
189
|
+
const config = {
|
|
190
|
+
customRoutes: {
|
|
191
|
+
'media/*': '../media/*'
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
await write(dir, 'docs/.config.json', JSON.stringify(config));
|
|
196
|
+
const handler = await router(flags, () => {});
|
|
197
|
+
const server = http.createServer(handler);
|
|
198
|
+
const port = randomPort();
|
|
199
|
+
|
|
200
|
+
await new Promise(r => server.listen(port, r));
|
|
201
|
+
await new Promise(r => setTimeout(r, 50));
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const r1 = await httpGet(`http://localhost:${port}/media/icon.png`);
|
|
205
|
+
log('png status: ' + r1.res.statusCode);
|
|
206
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200 for /media/icon.png');
|
|
207
|
+
if(r1.body.toString() !== 'icon-data') throw new Error('wrong content for icon.png');
|
|
208
|
+
|
|
209
|
+
const r2 = await httpGet(`http://localhost:${port}/media/logo.svg`);
|
|
210
|
+
log('svg status: ' + r2.res.statusCode);
|
|
211
|
+
if(r2.res.statusCode !== 200) throw new Error('expected 200 for /media/logo.svg');
|
|
212
|
+
if(r2.body.toString() !== '<svg/>') throw new Error('wrong content for logo.svg');
|
|
213
|
+
} finally {
|
|
214
|
+
await new Promise(r => server.close(r));
|
|
215
|
+
process.chdir(prev);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
pass('wildcard routes without leading slash match requests correctly');
|
|
219
|
+
} catch(e){
|
|
220
|
+
fail(e.message);
|
|
221
|
+
} }
|
|
177
222
|
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
extractAttrs,
|
|
3
|
+
extractContentBlocks,
|
|
4
|
+
replaceLocations,
|
|
5
|
+
stripFragmentWrapper,
|
|
6
|
+
resolveVars,
|
|
7
|
+
resolveIfs,
|
|
8
|
+
resolveForeach,
|
|
9
|
+
resolveFragmentTags,
|
|
10
|
+
evalCondition,
|
|
11
|
+
resolvePath
|
|
12
|
+
} from '../src/templating/parse.js';
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
'extractAttrs parses double-quoted attributes': ({pass, fail}) => {
|
|
16
|
+
const result = extractAttrs('template="default" title="Hello"');
|
|
17
|
+
if(result.template !== 'default') return fail('template wrong');
|
|
18
|
+
if(result.title !== 'Hello') return fail('title wrong');
|
|
19
|
+
pass();
|
|
20
|
+
},
|
|
21
|
+
'extractAttrs parses single-quoted attributes': ({pass, fail}) => {
|
|
22
|
+
const result = extractAttrs("name='test'");
|
|
23
|
+
if(result.name !== 'test') return fail('name wrong');
|
|
24
|
+
pass();
|
|
25
|
+
},
|
|
26
|
+
'extractContentBlocks extracts named blocks': ({pass, fail}) => {
|
|
27
|
+
const xml = '<content location="main">Hello</content><content location="sidebar">World</content>';
|
|
28
|
+
const blocks = extractContentBlocks(xml);
|
|
29
|
+
if(blocks.main !== 'Hello') return fail('main wrong');
|
|
30
|
+
if(blocks.sidebar !== 'World') return fail('sidebar wrong');
|
|
31
|
+
pass();
|
|
32
|
+
},
|
|
33
|
+
'replaceLocations fills named locations': ({pass, fail}) => {
|
|
34
|
+
const html = '<location name="main" />';
|
|
35
|
+
const result = replaceLocations(html, {main: '<p>Hi</p>'});
|
|
36
|
+
if(result !== '<p>Hi</p>') return fail(`got: ${result}`);
|
|
37
|
+
pass();
|
|
38
|
+
},
|
|
39
|
+
'replaceLocations uses fallback content': ({pass, fail}) => {
|
|
40
|
+
const html = '<location name="main">fallback</location>';
|
|
41
|
+
const result = replaceLocations(html, {});
|
|
42
|
+
if(result !== 'fallback') return fail(`got: ${result}`);
|
|
43
|
+
pass();
|
|
44
|
+
},
|
|
45
|
+
'replaceLocations uses content over fallback': ({pass, fail}) => {
|
|
46
|
+
const html = '<location name="main">fallback</location>';
|
|
47
|
+
const result = replaceLocations(html, {main: 'real'});
|
|
48
|
+
if(result !== 'real') return fail(`got: ${result}`);
|
|
49
|
+
pass();
|
|
50
|
+
},
|
|
51
|
+
'stripFragmentWrapper removes wrapping fragment tag': ({pass, fail}) => {
|
|
52
|
+
const result = stripFragmentWrapper('<fragment name="nav"><nav>Hi</nav></fragment>');
|
|
53
|
+
if(result !== '<nav>Hi</nav>') return fail(`got: ${result}`);
|
|
54
|
+
pass();
|
|
55
|
+
},
|
|
56
|
+
'stripFragmentWrapper returns content unchanged if no wrapper': ({pass, fail}) => {
|
|
57
|
+
const result = stripFragmentWrapper('<nav>Hi</nav>');
|
|
58
|
+
if(result !== '<nav>Hi</nav>') return fail(`got: ${result}`);
|
|
59
|
+
pass();
|
|
60
|
+
},
|
|
61
|
+
'resolvePath navigates dot path': ({pass, fail}) => {
|
|
62
|
+
const result = resolvePath({a: {b: {c: 42}}}, 'a.b.c');
|
|
63
|
+
if(result !== 42) return fail(`got: ${result}`);
|
|
64
|
+
pass();
|
|
65
|
+
},
|
|
66
|
+
'resolvePath returns undefined for missing path': ({pass, fail}) => {
|
|
67
|
+
const result = resolvePath({a: 1}, 'b.c');
|
|
68
|
+
if(result !== undefined) return fail(`got: ${result}`);
|
|
69
|
+
pass();
|
|
70
|
+
},
|
|
71
|
+
'resolveVars replaces simple variables': ({pass, fail}) => {
|
|
72
|
+
const result = resolveVars('Hello {{name}}!', {name: 'World'});
|
|
73
|
+
if(result !== 'Hello World!') return fail(`got: ${result}`);
|
|
74
|
+
pass();
|
|
75
|
+
},
|
|
76
|
+
'resolveVars replaces dot-path variables': ({pass, fail}) => {
|
|
77
|
+
const result = resolveVars('{{user.name}}', {user: {name: 'Bob'}});
|
|
78
|
+
if(result !== 'Bob') return fail(`got: ${result}`);
|
|
79
|
+
pass();
|
|
80
|
+
},
|
|
81
|
+
'resolveVars calls function values': ({pass, fail}) => {
|
|
82
|
+
const result = resolveVars('{{fn}}', {fn: () => 'called'});
|
|
83
|
+
if(result !== 'called') return fail(`got: ${result}`);
|
|
84
|
+
pass();
|
|
85
|
+
},
|
|
86
|
+
'resolveVars replaces missing vars with empty string': ({pass, fail}) => {
|
|
87
|
+
const result = resolveVars('{{missing}}', {});
|
|
88
|
+
if(result !== '') return fail(`got: ${result}`);
|
|
89
|
+
pass();
|
|
90
|
+
},
|
|
91
|
+
'resolveIfs keeps content when condition is true': ({pass, fail}) => {
|
|
92
|
+
const result = resolveIfs('<if condition="show">visible</if>', {show: true});
|
|
93
|
+
if(result !== 'visible') return fail(`got: ${result}`);
|
|
94
|
+
pass();
|
|
95
|
+
},
|
|
96
|
+
'resolveIfs removes content when condition is false': ({pass, fail}) => {
|
|
97
|
+
const result = resolveIfs('<if condition="show">visible</if>', {show: false});
|
|
98
|
+
if(result !== '') return fail(`got: ${result}`);
|
|
99
|
+
pass();
|
|
100
|
+
},
|
|
101
|
+
'resolveIfs handles comparison operators': ({pass, fail}) => {
|
|
102
|
+
const result = resolveIfs('<if condition="count > 5">big</if>', {count: 10});
|
|
103
|
+
if(result !== 'big') return fail(`got: ${result}`);
|
|
104
|
+
pass();
|
|
105
|
+
},
|
|
106
|
+
'resolveIfs handles nested ifs': ({pass, fail}) => {
|
|
107
|
+
const html = '<if condition="a"><if condition="b">inner</if></if>';
|
|
108
|
+
const result = resolveIfs(html, {a: true, b: true});
|
|
109
|
+
if(result !== 'inner') return fail(`got: ${result}`);
|
|
110
|
+
pass();
|
|
111
|
+
},
|
|
112
|
+
'resolveForeach iterates arrays': ({pass, fail}) => {
|
|
113
|
+
const html = '<foreach in="items" as="item">{{item}},</foreach>';
|
|
114
|
+
const result = resolveForeach(html, {items: ['a', 'b', 'c']});
|
|
115
|
+
if(result !== 'a,b,c,') return fail(`got: ${result}`);
|
|
116
|
+
pass();
|
|
117
|
+
},
|
|
118
|
+
'resolveForeach handles empty array': ({pass, fail}) => {
|
|
119
|
+
const html = '<foreach in="items" as="item">{{item}}</foreach>';
|
|
120
|
+
const result = resolveForeach(html, {items: []});
|
|
121
|
+
if(result !== '') return fail(`got: ${result}`);
|
|
122
|
+
pass();
|
|
123
|
+
},
|
|
124
|
+
'resolveForeach handles missing var': ({pass, fail}) => {
|
|
125
|
+
const html = '<foreach in="nope" as="item">{{item}}</foreach>';
|
|
126
|
+
const result = resolveForeach(html, {});
|
|
127
|
+
if(result !== '') return fail(`got: ${result}`);
|
|
128
|
+
pass();
|
|
129
|
+
},
|
|
130
|
+
'resolveForeach handles object items with dot paths': ({pass, fail}) => {
|
|
131
|
+
const html = '<foreach in="users" as="u">{{u.name}}</foreach>';
|
|
132
|
+
const result = resolveForeach(html, {users: [{name: 'Alice'}, {name: 'Bob'}]});
|
|
133
|
+
if(result !== 'AliceBob') return fail(`got: ${result}`);
|
|
134
|
+
pass();
|
|
135
|
+
},
|
|
136
|
+
'resolveFragmentTags inlines fragment content': ({pass, fail}) => {
|
|
137
|
+
const html = '<fragment name="nav" />';
|
|
138
|
+
const finder = name => name === 'nav' ? '<nav>Link</nav>' : null;
|
|
139
|
+
const result = resolveFragmentTags(html, finder, 0, 10);
|
|
140
|
+
if(result !== '<nav>Link</nav>') return fail(`got: ${result}`);
|
|
141
|
+
pass();
|
|
142
|
+
},
|
|
143
|
+
'resolveFragmentTags uses fallback when fragment not found': ({pass, fail}) => {
|
|
144
|
+
const html = '<fragment name="missing">fallback</fragment>';
|
|
145
|
+
const finder = () => null;
|
|
146
|
+
const result = resolveFragmentTags(html, finder, 0, 10);
|
|
147
|
+
if(result !== 'fallback') return fail(`got: ${result}`);
|
|
148
|
+
pass();
|
|
149
|
+
},
|
|
150
|
+
'resolveFragmentTags throws on max depth': ({pass, fail}) => {
|
|
151
|
+
const html = '<fragment name="loop" />';
|
|
152
|
+
const finder = () => '<fragment name="loop" />';
|
|
153
|
+
try {
|
|
154
|
+
resolveFragmentTags(html, finder, 0, 3);
|
|
155
|
+
fail('should have thrown');
|
|
156
|
+
} catch(e){
|
|
157
|
+
if(!e.message.includes('depth exceeded')) return fail(`wrong error: ${e.message}`);
|
|
158
|
+
pass();
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
'evalCondition: truthy identifier': ({pass, fail}) => {
|
|
162
|
+
if(!evalCondition('active', {active: true})) return fail('should be true');
|
|
163
|
+
pass();
|
|
164
|
+
},
|
|
165
|
+
'evalCondition: falsy identifier': ({pass, fail}) => {
|
|
166
|
+
if(evalCondition('active', {active: false})) return fail('should be false');
|
|
167
|
+
pass();
|
|
168
|
+
},
|
|
169
|
+
'evalCondition: string equality': ({pass, fail}) => {
|
|
170
|
+
if(!evalCondition('env === "prod"', {env: 'prod'})) return fail('should be true');
|
|
171
|
+
pass();
|
|
172
|
+
},
|
|
173
|
+
'evalCondition: string inequality': ({pass, fail}) => {
|
|
174
|
+
if(!evalCondition('env !== "dev"', {env: 'prod'})) return fail('should be true');
|
|
175
|
+
pass();
|
|
176
|
+
},
|
|
177
|
+
'evalCondition: numeric comparison': ({pass, fail}) => {
|
|
178
|
+
if(!evalCondition('count >= 10', {count: 10})) return fail('should be true');
|
|
179
|
+
if(evalCondition('count > 10', {count: 10})) return fail('should be false');
|
|
180
|
+
pass();
|
|
181
|
+
},
|
|
182
|
+
'evalCondition: logical AND': ({pass, fail}) => {
|
|
183
|
+
if(!evalCondition('a && b', {a: true, b: true})) return fail('should be true');
|
|
184
|
+
if(evalCondition('a && b', {a: true, b: false})) return fail('should be false');
|
|
185
|
+
pass();
|
|
186
|
+
},
|
|
187
|
+
'evalCondition: logical OR': ({pass, fail}) => {
|
|
188
|
+
if(!evalCondition('a || b', {a: false, b: true})) return fail('should be true');
|
|
189
|
+
if(evalCondition('a || b', {a: false, b: false})) return fail('should be false');
|
|
190
|
+
pass();
|
|
191
|
+
},
|
|
192
|
+
'evalCondition: NOT operator': ({pass, fail}) => {
|
|
193
|
+
if(!evalCondition('!hidden', {hidden: false})) return fail('should be true');
|
|
194
|
+
if(evalCondition('!hidden', {hidden: true})) return fail('should be false');
|
|
195
|
+
pass();
|
|
196
|
+
},
|
|
197
|
+
'evalCondition: parenthesized expression': ({pass, fail}) => {
|
|
198
|
+
if(!evalCondition('(a || b) && c', {a: false, b: true, c: true})) return fail('should be true');
|
|
199
|
+
if(evalCondition('(a || b) && c', {a: false, b: true, c: false})) return fail('should be false');
|
|
200
|
+
pass();
|
|
201
|
+
},
|
|
202
|
+
'evalCondition: dot-path variable': ({pass, fail}) => {
|
|
203
|
+
if(!evalCondition('user.admin', {user: {admin: true}})) return fail('should be true');
|
|
204
|
+
pass();
|
|
205
|
+
},
|
|
206
|
+
'evalCondition: boolean literals': ({pass, fail}) => {
|
|
207
|
+
if(!evalCondition('true', {})) return fail('true should be true');
|
|
208
|
+
if(evalCondition('false', {})) return fail('false should be false');
|
|
209
|
+
pass();
|
|
210
|
+
},
|
|
211
|
+
'extractContentBlocks defaults location to default': ({pass, fail}) => {
|
|
212
|
+
const blocks = extractContentBlocks('<content>Hello</content>');
|
|
213
|
+
if(blocks.default !== 'Hello') return fail(`got: ${blocks.default}`);
|
|
214
|
+
pass();
|
|
215
|
+
},
|
|
216
|
+
'extractContentBlocks concatenates multiple contents to same location': ({pass, fail}) => {
|
|
217
|
+
const xml = '<content location="main">A</content><content location="main">B</content>';
|
|
218
|
+
const blocks = extractContentBlocks(xml);
|
|
219
|
+
if(blocks.main !== 'AB') return fail(`got: ${blocks.main}`);
|
|
220
|
+
pass();
|
|
221
|
+
},
|
|
222
|
+
'extractContentBlocks concatenates default contents': ({pass, fail}) => {
|
|
223
|
+
const xml = '<content>A</content><content>B</content>';
|
|
224
|
+
const blocks = extractContentBlocks(xml);
|
|
225
|
+
if(blocks.default !== 'AB') return fail(`got: ${blocks.default}`);
|
|
226
|
+
pass();
|
|
227
|
+
},
|
|
228
|
+
'replaceLocations defaults nameless location to default': ({pass, fail}) => {
|
|
229
|
+
const result = replaceLocations('<location />', {default: 'Hi'});
|
|
230
|
+
if(result !== 'Hi') return fail(`got: ${result}`);
|
|
231
|
+
pass();
|
|
232
|
+
},
|
|
233
|
+
'replaceLocations defaults nameless block location to default': ({pass, fail}) => {
|
|
234
|
+
const result = replaceLocations('<location>fallback</location>', {default: 'Hi'});
|
|
235
|
+
if(result !== 'Hi') return fail(`got: ${result}`);
|
|
236
|
+
pass();
|
|
237
|
+
},
|
|
238
|
+
'replaceLocations uses fallback for nameless location': ({pass, fail}) => {
|
|
239
|
+
const result = replaceLocations('<location>fallback</location>', {});
|
|
240
|
+
if(result !== 'fallback') return fail(`got: ${result}`);
|
|
241
|
+
pass();
|
|
242
|
+
}
|
|
243
|
+
};
|