kempo-server 3.0.3 → 3.0.5

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.
@@ -0,0 +1,189 @@
1
+ import http from 'http';
2
+ import path from 'path';
3
+ import {withTempDir} from './utils/temp-dir.js';
4
+ import {write} from './utils/file-writer.js';
5
+ import {randomPort} from './utils/port.js';
6
+ import {httpGet} from './utils/http.js';
7
+ import router from '../src/router.js';
8
+
9
+ const startServer = async (dir, flags, config) => {
10
+ await write(dir, `${flags.root}/.config.json`, JSON.stringify(config));
11
+ const handler = await router({...flags, logging: 0}, () => {});
12
+ const server = http.createServer(handler);
13
+ const port = randomPort();
14
+ await new Promise(r => server.listen(port, r));
15
+ await new Promise(r => setTimeout(r, 30));
16
+ return {server, port};
17
+ };
18
+
19
+ export default {
20
+ 'wildcard custom route renders .page.html via SSR': async ({pass, fail}) => {
21
+ try {
22
+ await withTempDir(async (dir) => {
23
+ const template = '<html><body><location name="main" /></body></html>';
24
+ const page = '<page template="default"><content location="main"><h1>Admin Page</h1></content></page>';
25
+
26
+ await write(dir, 'admin/default.template.html', template);
27
+ await write(dir, 'admin/dashboard.page.html', page);
28
+ await write(dir, 'public/index.html', '<h1>root</h1>');
29
+
30
+ const prev = process.cwd();
31
+ process.chdir(dir);
32
+ const {server, port} = await startServer(dir, {root: 'public'}, {
33
+ customRoutes: {'/admin/**': '../admin/**'},
34
+ templating: {ssr: true}
35
+ });
36
+
37
+ try {
38
+ const r = await httpGet(`http://localhost:${port}/admin/dashboard`);
39
+ if(r.res.statusCode !== 200) throw new Error(`expected 200, got ${r.res.statusCode}`);
40
+ const body = r.body.toString();
41
+ if(!body.includes('<h1>Admin Page</h1>')) throw new Error(`missing page content: ${body}`);
42
+ if(!body.includes('<html>')) throw new Error(`missing template wrapper: ${body}`);
43
+ } finally {
44
+ server.close();
45
+ process.chdir(prev);
46
+ }
47
+ });
48
+ pass('wildcard custom route renders .page.html via SSR');
49
+ } catch(e) {
50
+ fail(e.message);
51
+ }
52
+ },
53
+
54
+ 'wildcard custom route renders index.page.html for directory path': async ({pass, fail}) => {
55
+ try {
56
+ await withTempDir(async (dir) => {
57
+ const template = '<html><body><location name="main" /></body></html>';
58
+ const page = '<page template="default"><content location="main"><h1>Section Index</h1></content></page>';
59
+
60
+ await write(dir, 'admin/default.template.html', template);
61
+ await write(dir, 'admin/users/index.page.html', page);
62
+ await write(dir, 'public/index.html', '<h1>root</h1>');
63
+
64
+ const prev = process.cwd();
65
+ process.chdir(dir);
66
+ const {server, port} = await startServer(dir, {root: 'public'}, {
67
+ customRoutes: {'/admin/**': '../admin/**'},
68
+ templating: {ssr: true}
69
+ });
70
+
71
+ try {
72
+ const r = await httpGet(`http://localhost:${port}/admin/users`);
73
+ if(r.res.statusCode !== 200) throw new Error(`expected 200, got ${r.res.statusCode}`);
74
+ const body = r.body.toString();
75
+ if(!body.includes('<h1>Section Index</h1>')) throw new Error(`missing page content: ${body}`);
76
+ } finally {
77
+ server.close();
78
+ process.chdir(prev);
79
+ }
80
+ });
81
+ pass('wildcard custom route renders index.page.html for directory path');
82
+ } catch(e) {
83
+ fail(e.message);
84
+ }
85
+ },
86
+
87
+ 'SSR not attempted for custom route when templating.ssr is false': async ({pass, fail}) => {
88
+ try {
89
+ await withTempDir(async (dir) => {
90
+ const template = '<html><body><location name="main" /></body></html>';
91
+ const page = '<page template="default"><content location="main"><h1>Should Not Render</h1></content></page>';
92
+
93
+ await write(dir, 'admin/default.template.html', template);
94
+ await write(dir, 'admin/dashboard.page.html', page);
95
+ await write(dir, 'public/index.html', '<h1>root</h1>');
96
+
97
+ const prev = process.cwd();
98
+ process.chdir(dir);
99
+ const {server, port} = await startServer(dir, {root: 'public'}, {
100
+ customRoutes: {'/admin/**': '../admin/**'},
101
+ templating: {ssr: false}
102
+ });
103
+
104
+ try {
105
+ const r = await httpGet(`http://localhost:${port}/admin/dashboard`);
106
+ if(r.res.statusCode !== 404) throw new Error(`expected 404, got ${r.res.statusCode}`);
107
+ } finally {
108
+ server.close();
109
+ process.chdir(prev);
110
+ }
111
+ });
112
+ pass('SSR not attempted when templating.ssr is false');
113
+ } catch(e) {
114
+ fail(e.message);
115
+ }
116
+ },
117
+
118
+ 'wildcard custom route SSR uses custom root for template/fragment lookup': async ({pass, fail}) => {
119
+ try {
120
+ await withTempDir(async (dir) => {
121
+ const template = '<html><fragment name="nav" /><location name="main" /></html>';
122
+ const fragment = '<nav>Custom Nav</nav>';
123
+ const page = '<page template="default"><content location="main"><p>Content</p></content></page>';
124
+
125
+ await write(dir, 'admin/default.template.html', template);
126
+ await write(dir, 'admin/nav.fragment.html', fragment);
127
+ await write(dir, 'admin/about.page.html', page);
128
+ await write(dir, 'public/index.html', '<h1>root</h1>');
129
+
130
+ const prev = process.cwd();
131
+ process.chdir(dir);
132
+ const {server, port} = await startServer(dir, {root: 'public'}, {
133
+ customRoutes: {'/admin/**': '../admin/**'},
134
+ templating: {ssr: true}
135
+ });
136
+
137
+ try {
138
+ const r = await httpGet(`http://localhost:${port}/admin/about`);
139
+ if(r.res.statusCode !== 200) throw new Error(`expected 200, got ${r.res.statusCode}`);
140
+ const body = r.body.toString();
141
+ if(!body.includes('<nav>Custom Nav</nav>')) throw new Error(`fragment not resolved: ${body}`);
142
+ if(!body.includes('<p>Content</p>')) throw new Error(`page content missing: ${body}`);
143
+ } finally {
144
+ server.close();
145
+ process.chdir(prev);
146
+ }
147
+ });
148
+ pass('custom route SSR uses custom root for template/fragment lookup');
149
+ } catch(e) {
150
+ fail(e.message);
151
+ }
152
+ },
153
+
154
+ 'preRender renders .page.html files in wildcard custom route directory': async ({pass, fail}) => {
155
+ try {
156
+ await withTempDir(async (dir) => {
157
+ const template = '<html><body><location name="main" /></body></html>';
158
+ const page = '<page template="default"><content location="main"><h1>Pre-rendered</h1></content></page>';
159
+
160
+ await write(dir, 'admin/default.template.html', template);
161
+ await write(dir, 'admin/info.page.html', page);
162
+ await write(dir, 'public/index.html', '<h1>root</h1>');
163
+
164
+ const prev = process.cwd();
165
+ process.chdir(dir);
166
+ const {server, port} = await startServer(dir, {root: 'public'}, {
167
+ customRoutes: {'/admin/**': '../admin/**'},
168
+ templating: {preRender: true}
169
+ });
170
+
171
+ try {
172
+ // preRender should have written admin/info.html
173
+ const {readFile} = await import('fs/promises');
174
+ const rendered = await readFile(path.join(dir, 'admin', 'info.html'), 'utf8');
175
+ if(!rendered.includes('<h1>Pre-rendered</h1>')) throw new Error(`missing content: ${rendered}`);
176
+ // Serving the static pre-rendered file should also work
177
+ const r = await httpGet(`http://localhost:${port}/admin/info.html`);
178
+ if(r.res.statusCode !== 200) throw new Error(`expected 200, got ${r.res.statusCode}`);
179
+ } finally {
180
+ server.close();
181
+ process.chdir(prev);
182
+ }
183
+ });
184
+ pass('preRender renders .page.html files in wildcard custom route directory');
185
+ } catch(e) {
186
+ fail(e.message);
187
+ }
188
+ }
189
+ };
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  extractAttrs,
3
3
  extractContentBlocks,
4
+ mergeContentBlocks,
4
5
  replaceLocations,
5
6
  stripFragmentWrapper,
6
7
  resolveVars,
@@ -26,13 +27,33 @@ export default {
26
27
  'extractContentBlocks extracts named blocks': ({pass, fail}) => {
27
28
  const xml = '<content location="main">Hello</content><content location="sidebar">World</content>';
28
29
  const blocks = extractContentBlocks(xml);
29
- if(blocks.main !== 'Hello') return fail('main wrong');
30
- if(blocks.sidebar !== 'World') return fail('sidebar wrong');
30
+ if(!Array.isArray(blocks.main) || blocks.main[0].html !== 'Hello') return fail('main wrong');
31
+ if(!Array.isArray(blocks.sidebar) || blocks.sidebar[0].html !== 'World') return fail('sidebar wrong');
32
+ pass();
33
+ },
34
+ 'extractContentBlocks captures priority': ({pass, fail}) => {
35
+ const xml = '<content location="main" priority="5">hi</content>';
36
+ const blocks = extractContentBlocks(xml);
37
+ if(blocks.main[0].priority !== 5) return fail(`priority wrong: ${blocks.main[0].priority}`);
38
+ pass();
39
+ },
40
+ 'extractContentBlocks defaults priority to 0': ({pass, fail}) => {
41
+ const xml = '<content location="main">hi</content>';
42
+ const blocks = extractContentBlocks(xml);
43
+ if(blocks.main[0].priority !== 0) return fail(`priority wrong: ${blocks.main[0].priority}`);
44
+ pass();
45
+ },
46
+ 'mergeContentBlocks combines maps': ({pass, fail}) => {
47
+ const a = {main: [{html: 'A', priority: 0}]};
48
+ const b = {main: [{html: 'B', priority: 0}], sidebar: [{html: 'S', priority: 0}]};
49
+ const merged = mergeContentBlocks(a, b);
50
+ if(merged.main.length !== 2) return fail(`main length wrong: ${merged.main.length}`);
51
+ if(!merged.sidebar) return fail('sidebar missing');
31
52
  pass();
32
53
  },
33
54
  'replaceLocations fills named locations': ({pass, fail}) => {
34
55
  const html = '<location name="main" />';
35
- const result = replaceLocations(html, {main: '<p>Hi</p>'});
56
+ const result = replaceLocations(html, {main: [{html: '<p>Hi</p>', priority: 0}]});
36
57
  if(result !== '<p>Hi</p>') return fail(`got: ${result}`);
37
58
  pass();
38
59
  },
@@ -44,10 +65,21 @@ export default {
44
65
  },
45
66
  'replaceLocations uses content over fallback': ({pass, fail}) => {
46
67
  const html = '<location name="main">fallback</location>';
47
- const result = replaceLocations(html, {main: 'real'});
68
+ const result = replaceLocations(html, {main: [{html: 'real', priority: 0}]});
48
69
  if(result !== 'real') return fail(`got: ${result}`);
49
70
  pass();
50
71
  },
72
+ 'replaceLocations orders by priority descending': ({pass, fail}) => {
73
+ const html = '<location name="scripts" />';
74
+ const entries = [
75
+ {html: 'low', priority: 1},
76
+ {html: 'high', priority: 10},
77
+ {html: 'mid', priority: 5}
78
+ ];
79
+ const result = replaceLocations(html, {scripts: entries});
80
+ if(result !== 'highmidlow') return fail(`got: ${result}`);
81
+ pass();
82
+ },
51
83
  'stripFragmentWrapper removes wrapping fragment tag': ({pass, fail}) => {
52
84
  const result = stripFragmentWrapper('<fragment name="nav"><nav>Hi</nav></fragment>');
53
85
  if(result !== '<nav>Hi</nav>') return fail(`got: ${result}`);
@@ -210,28 +242,30 @@ export default {
210
242
  },
211
243
  'extractContentBlocks defaults location to default': ({pass, fail}) => {
212
244
  const blocks = extractContentBlocks('<content>Hello</content>');
213
- if(blocks.default !== 'Hello') return fail(`got: ${blocks.default}`);
245
+ if(!Array.isArray(blocks.default) || blocks.default[0].html !== 'Hello') return fail(`got: ${JSON.stringify(blocks.default)}`);
214
246
  pass();
215
247
  },
216
248
  'extractContentBlocks concatenates multiple contents to same location': ({pass, fail}) => {
217
249
  const xml = '<content location="main">A</content><content location="main">B</content>';
218
250
  const blocks = extractContentBlocks(xml);
219
- if(blocks.main !== 'AB') return fail(`got: ${blocks.main}`);
251
+ if(blocks.main.length !== 2) return fail(`expected 2 entries, got: ${JSON.stringify(blocks.main)}`);
252
+ if(blocks.main[0].html !== 'A' || blocks.main[1].html !== 'B') return fail(`got: ${JSON.stringify(blocks.main)}`);
220
253
  pass();
221
254
  },
222
255
  'extractContentBlocks concatenates default contents': ({pass, fail}) => {
223
256
  const xml = '<content>A</content><content>B</content>';
224
257
  const blocks = extractContentBlocks(xml);
225
- if(blocks.default !== 'AB') return fail(`got: ${blocks.default}`);
258
+ if(blocks.default.length !== 2) return fail(`expected 2 entries, got: ${JSON.stringify(blocks.default)}`);
259
+ if(blocks.default[0].html !== 'A' || blocks.default[1].html !== 'B') return fail(`got: ${JSON.stringify(blocks.default)}`);
226
260
  pass();
227
261
  },
228
262
  'replaceLocations defaults nameless location to default': ({pass, fail}) => {
229
- const result = replaceLocations('<location />', {default: 'Hi'});
263
+ const result = replaceLocations('<location />', {default: [{html: 'Hi', priority: 0}]});
230
264
  if(result !== 'Hi') return fail(`got: ${result}`);
231
265
  pass();
232
266
  },
233
267
  'replaceLocations defaults nameless block location to default': ({pass, fail}) => {
234
- const result = replaceLocations('<location>fallback</location>', {default: 'Hi'});
268
+ const result = replaceLocations('<location>fallback</location>', {default: [{html: 'Hi', priority: 0}]});
235
269
  if(result !== 'Hi') return fail(`got: ${result}`);
236
270
  pass();
237
271
  },
@@ -184,5 +184,96 @@ export default {
184
184
  if(!html.includes(String(new Date().getFullYear()))) return fail(`year missing: ${html}`);
185
185
  pass();
186
186
  });
187
+ },
188
+ 'renderPage injects global content into template location': async ({pass, fail}) => {
189
+ await withTempDir(async dir => {
190
+ await setupFiles(dir, {
191
+ 'default.template.html': '<head><location name="head" /></head><body><location name="main" /></body>',
192
+ 'site.global.html': '<content location="head"><meta charset="utf-8"></content>',
193
+ 'index.page.html': '<page template="default"><content location="main">hello</content></page>'
194
+ });
195
+ const html = await renderPage(path.join(dir, 'index.page.html'), dir);
196
+ if(!html.includes('<meta charset="utf-8">')) return fail(`global head missing: ${html}`);
197
+ if(!html.includes('hello')) return fail(`page content missing: ${html}`);
198
+ pass();
199
+ });
200
+ },
201
+ 'renderPage merges page and global content into same location': async ({pass, fail}) => {
202
+ await withTempDir(async dir => {
203
+ await setupFiles(dir, {
204
+ 'default.template.html': '<location name="scripts" />',
205
+ 'site.global.html': '<content location="scripts"><script src="analytics.js"></script></content>',
206
+ 'index.page.html': '<page template="default"><content location="scripts"><script src="page.js"></script></content></page>'
207
+ });
208
+ const html = await renderPage(path.join(dir, 'index.page.html'), dir);
209
+ if(!html.includes('analytics.js')) return fail(`global script missing: ${html}`);
210
+ if(!html.includes('page.js')) return fail(`page script missing: ${html}`);
211
+ pass();
212
+ });
213
+ },
214
+ 'renderPage respects priority ordering — higher number first': async ({pass, fail}) => {
215
+ await withTempDir(async dir => {
216
+ await setupFiles(dir, {
217
+ 'default.template.html': '<location name="scripts" />',
218
+ 'site.global.html': '<content location="scripts" priority="10">FIRST</content><content location="scripts" priority="1">LAST</content>',
219
+ 'index.page.html': '<page template="default"></page>'
220
+ });
221
+ const html = await renderPage(path.join(dir, 'index.page.html'), dir);
222
+ if(html.indexOf('FIRST') > html.indexOf('LAST')) return fail(`wrong order: ${html}`);
223
+ pass();
224
+ });
225
+ },
226
+ 'renderPage page content priority beats global at same location': async ({pass, fail}) => {
227
+ await withTempDir(async dir => {
228
+ await setupFiles(dir, {
229
+ 'default.template.html': '<location name="scripts" />',
230
+ 'site.global.html': '<content location="scripts" priority="0">global</content>',
231
+ 'index.page.html': '<page template="default"><content location="scripts" priority="5">page</content></page>'
232
+ });
233
+ const html = await renderPage(path.join(dir, 'index.page.html'), dir);
234
+ if(html.indexOf('page') > html.indexOf('global')) return fail(`page should come before global: ${html}`);
235
+ pass();
236
+ });
237
+ },
238
+ 'renderPage fills locations inside page content from globals': async ({pass, fail}) => {
239
+ await withTempDir(async dir => {
240
+ await setupFiles(dir, {
241
+ 'default.template.html': '<location name="main" />',
242
+ 'site.global.html': '<content location="badge"><span class="badge">NEW</span></content>',
243
+ 'index.page.html': '<page template="default"><content location="main"><h1>Title</h1><location name="badge" /></content></page>'
244
+ });
245
+ const html = await renderPage(path.join(dir, 'index.page.html'), dir);
246
+ if(!html.includes('<span class="badge">NEW</span>')) return fail(`badge missing: ${html}`);
247
+ pass();
248
+ });
249
+ },
250
+ 'renderPage loads globals from subdirectories': async ({pass, fail}) => {
251
+ await withTempDir(async dir => {
252
+ await setupFiles(dir, {
253
+ 'default.template.html': '<location name="head" /><location name="main" />',
254
+ 'sub/site.global.html': '<content location="head">subglobal</content>',
255
+ 'index.page.html': '<page template="default"><content location="main">x</content></page>'
256
+ });
257
+ const html = await renderPage(path.join(dir, 'index.page.html'), dir);
258
+ if(!html.includes('subglobal')) return fail(`subdir global missing: ${html}`);
259
+ pass();
260
+ });
261
+ },
262
+ 'renderDir applies global content to all pages': async ({pass, fail}) => {
263
+ await withTempDir(async dir => {
264
+ await setupFiles(dir, {
265
+ 'default.template.html': '<location name="head" /><location name="main" />',
266
+ 'site.global.html': '<content location="head"><meta name="global"></content>',
267
+ 'index.page.html': '<page template="default"><content location="main">home</content></page>',
268
+ 'about.page.html': '<page template="default"><content location="main">about</content></page>'
269
+ });
270
+ const outDir = path.join(dir, 'out');
271
+ await renderDir(dir, outDir);
272
+ const home = await readFile(path.join(outDir, 'index.html'), 'utf8');
273
+ const about = await readFile(path.join(outDir, 'about.html'), 'utf8');
274
+ if(!home.includes('<meta name="global">')) return fail(`home missing global: ${home}`);
275
+ if(!about.includes('<meta name="global">')) return fail(`about missing global: ${about}`);
276
+ pass();
277
+ });
187
278
  }
188
279
  };