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.
Files changed (89) hide show
  1. package/CONFIG.md +295 -187
  2. package/README.md +31 -4
  3. package/SPA.md +14 -14
  4. package/UTILS.md +39 -0
  5. package/dist/defaultConfig.js +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/render.js +2 -0
  8. package/dist/rescan.js +1 -0
  9. package/dist/router.js +1 -1
  10. package/dist/serveFile.js +1 -1
  11. package/dist/templating/index.js +1 -0
  12. package/dist/templating/parse.js +1 -0
  13. package/docs/dist/caching.html +324 -0
  14. package/docs/dist/cli-utils.html +175 -0
  15. package/docs/dist/configuration.html +414 -0
  16. package/docs/dist/examples.html +296 -0
  17. package/docs/dist/fs-utils.html +206 -0
  18. package/docs/dist/getting-started.html +167 -0
  19. package/docs/dist/index.html +183 -0
  20. package/docs/dist/middleware.html +237 -0
  21. package/docs/dist/request-response.html +200 -0
  22. package/docs/dist/routing.html +177 -0
  23. package/docs/dist/templating.html +292 -0
  24. package/docs/{theme.css → dist/theme.css} +1 -3
  25. package/docs/src/.config.js +11 -0
  26. package/docs/{caching.html → src/caching.page.html} +4 -19
  27. package/docs/{cli-utils.html → src/cli-utils.page.html} +4 -20
  28. package/docs/{configuration.html → src/configuration.page.html} +4 -18
  29. package/docs/src/default.template.html +35 -0
  30. package/docs/{examples.html → src/examples.page.html} +9 -18
  31. package/docs/{fs-utils.html → src/fs-utils.page.html} +4 -20
  32. package/docs/{getting-started.html → src/getting-started.page.html} +4 -18
  33. package/docs/src/index.page.html +79 -0
  34. package/docs/{middleware.html → src/middleware.page.html} +4 -18
  35. package/docs/src/nav.fragment.html +73 -0
  36. package/docs/{request-response.html → src/request-response.page.html} +4 -18
  37. package/docs/{routing.html → src/routing.page.html} +4 -18
  38. package/docs/src/templating.page.html +188 -0
  39. package/{llm.txt → llms.txt} +100 -30
  40. package/package.json +7 -3
  41. package/scripts/build.js +19 -11
  42. package/scripts/render.js +58 -0
  43. package/src/defaultConfig.js +14 -2
  44. package/src/index.js +1 -1
  45. package/src/rescan.js +14 -0
  46. package/src/router.js +82 -11
  47. package/src/serveFile.js +27 -0
  48. package/src/templating/index.js +132 -0
  49. package/src/templating/parse.js +285 -0
  50. package/tests/cacheConfig.node-test.js +2 -2
  51. package/tests/config-flag.node-test.js +61 -25
  52. package/tests/customRoute-outside-root.node-test.js +1 -1
  53. package/tests/rescan.node-test.js +69 -0
  54. package/tests/router-wildcard.node-test.js +47 -2
  55. package/tests/templating-parse.node-test.js +243 -0
  56. package/tests/templating-render.node-test.js +188 -0
  57. package/tests/utils/test-scenario.js +4 -4
  58. package/docs/.config.json.example +0 -29
  59. package/docs/api/_admin/cache/DELETE.js +0 -28
  60. package/docs/api/_admin/cache/GET.js +0 -53
  61. package/docs/api/user/[id]/GET.js +0 -15
  62. package/docs/api/user/[id]/[info]/DELETE.js +0 -12
  63. package/docs/api/user/[id]/[info]/GET.js +0 -17
  64. package/docs/api/user/[id]/[info]/POST.js +0 -18
  65. package/docs/api/user/[id]/[info]/PUT.js +0 -19
  66. package/docs/index.html +0 -88
  67. package/docs/init.js +0 -0
  68. package/docs/kempo.min.css +0 -1
  69. package/docs/nav.inc.html +0 -41
  70. package/docs/nav.inc.js +0 -16
  71. /package/docs/{manifest.json → dist/manifest.json} +0 -0
  72. /package/docs/{media → dist/media}/hexagon.svg +0 -0
  73. /package/docs/{media → dist/media}/icon-maskable.png +0 -0
  74. /package/docs/{media → dist/media}/icon.svg +0 -0
  75. /package/docs/{media → dist/media}/icon128.png +0 -0
  76. /package/docs/{media → dist/media}/icon144.png +0 -0
  77. /package/docs/{media → dist/media}/icon152.png +0 -0
  78. /package/docs/{media → dist/media}/icon16-48.svg +0 -0
  79. /package/docs/{media → dist/media}/icon16.png +0 -0
  80. /package/docs/{media → dist/media}/icon192.png +0 -0
  81. /package/docs/{media → dist/media}/icon256.png +0 -0
  82. /package/docs/{media → dist/media}/icon32.png +0 -0
  83. /package/docs/{media → dist/media}/icon384.png +0 -0
  84. /package/docs/{media → dist/media}/icon48.png +0 -0
  85. /package/docs/{media → dist/media}/icon512.png +0 -0
  86. /package/docs/{media → dist/media}/icon64.png +0 -0
  87. /package/docs/{media → dist/media}/icon72.png +0 -0
  88. /package/docs/{media → dist/media}/icon96.png +0 -0
  89. /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.json'
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.json') {
26
- return fail('default config should be .config.json');
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.json'];
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.json'
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.json') {
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.json'];
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.json'
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.json') {
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.json (default name)
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.json', JSON.stringify(customConfig));
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.json'};
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.json', JSON.stringify(customConfig));
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.json'};
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.json', JSON.stringify(customConfig));
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 default config when custom config file missing': async ({pass, fail}) => {
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
- // Point to non-existent config file
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
- // Create malformed JSON config
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.json'};
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.json', JSON.stringify(partialConfig));
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.json'};
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.json', '{"allowedMimes": {"test": "application/test"}}');
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.json'};
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.json'), JSON.stringify(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
+ };