kempo-server 3.0.12 → 3.1.1

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.
@@ -37,13 +37,35 @@
37
37
  class="d-if ph"
38
38
  style="align-items: center"
39
39
  >
40
- <img src="./media/icon32.png" alt="Kempo Server Icon" class="pr" />
40
+ <img
41
+ src="./media/icon32.png"
42
+ alt="Kempo Server Icon"
43
+ class="pr"
44
+ />
41
45
  Kempo Server
42
46
  </a>
43
47
  <div class="flex"></div>
44
- <a href="https://github.com/dustinpoissant/kempo-ui?tab=License-1-ov-file#creative-commons-attribution-noncommercial-sharealike-20" target="_blank"><k-icon name="license"></k-icont></a>
45
- <a href="https://github.com/dustinpoissant/kempo-ui" target="_blank"><k-icon name="github-mark"></k-icont></a>
46
- <k-theme-switcher></k-theme-switcher>
48
+ <a
49
+ href="https://github.com/dustinpoissant/kempo-server?tab=License-1-ov-file#creative-commons-attribution-noncommercial-sharealike-20"
50
+ target="_blank"
51
+ ><k-icon name="license"></k-icon></a>
52
+ <a
53
+ href="https://www.npmjs.com/package/kempo-server"
54
+ target="_blank"
55
+ ><k-icon name="npm"></k-icon></a>
56
+ <a
57
+ href="https://github.com/dustinpoissant/kempo-server"
58
+ target="_blank"
59
+ ><k-icon name="github-mark"></k-icon></a>
60
+ <k-theme-switcher
61
+ class="mr"
62
+ style="
63
+ --padding: 0.5rem;
64
+ --c_active: var(--tc_on_primary);
65
+ --tc_active: var(--c_primary);
66
+ --c_inactive__hover: rgba(255, 255, 255, 0.1);
67
+ "
68
+ ></k-theme-switcher>
47
69
  </k-nav>
48
70
  <div style="width: 100%; height: 4rem;"></div>
49
71
  <k-aside
@@ -51,9 +73,15 @@
51
73
  state="offscreen"
52
74
  >
53
75
  <menu>
54
- <a href="./" class="ta-center bb mb r0">
76
+ <a
77
+ href="./"
78
+ class="ta-center bb mb r0"
79
+ >
55
80
  <h1 class="tc-primary">Kempo Server</h1>
56
- <img src="./media/icon128.png" alt="Kempo UI Icon" />
81
+ <img
82
+ src="./media/icon128.png"
83
+ alt="Kempo UI Icon"
84
+ />
57
85
  </a>
58
86
 
59
87
  <h3 class="mt mb0">Advanced Features</h3>
@@ -72,18 +100,38 @@
72
100
  <br /><br />
73
101
 
74
102
  </menu>
103
+ <k-aside-spacer></k-aside-spacer>
104
+ <k-theme-switcher
105
+ labels
106
+ style="--padding:var(--spacer_h);margin:var(--spacer_h) auto"
107
+ ></k-theme-switcher>
75
108
  </k-aside>
76
- <script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Aside.js" type="module"></script>
77
- <script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Main.js" type="module"></script>
78
- <script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Nav.js" type="module"></script>
79
- <script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Icon.js" type="module"></script>
80
- <script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/ThemeSwitcher.js" type="module"></script>
109
+ <script
110
+ src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Aside.js"
111
+ type="module"
112
+ ></script>
113
+ <script
114
+ src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Main.js"
115
+ type="module"
116
+ ></script>
117
+ <script
118
+ src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Nav.js"
119
+ type="module"
120
+ ></script>
121
+ <script
122
+ src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Icon.js"
123
+ type="module"
124
+ ></script>
125
+ <script
126
+ src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/ThemeSwitcher.js"
127
+ type="module"
128
+ ></script>
81
129
  <script>
82
130
  document.getElementById('toggleNavSideMenu').addEventListener('click', async () => {
83
131
  await window.customElements.whenDefined('k-aside');
84
132
  document.getElementById('navSideMenu').toggle();
85
133
  });
86
- document.addEventListener('click', function(e) {
134
+ document.addEventListener('click', function (e) {
87
135
  if (e.target.matches('a[href^="#"]')) {
88
136
  e.preventDefault();
89
137
  const targetId = e.target.getAttribute('href').replace('#', '');
@@ -336,7 +384,7 @@
336
384
  </div>
337
385
  <p>A common use case is sending templated emails. Create a dedicated email directory with a shared template, per-email pages, reusable fragments, and optional global content:</p>
338
386
  <pre><code class="hljs javascript"><span class="hljs-keyword">import</span> { renderPageToString } <span class="hljs-keyword">from</span> <span class="hljs-string">'kempo-server/templating'</span>;<br /><span class="hljs-keyword">import</span> path <span class="hljs-keyword">from</span> <span class="hljs-string">'path'</span>;<br /><br /><span class="hljs-keyword">const</span> emailsDir = path.resolve(<span class="hljs-string">'./emails'</span>);<br /><span class="hljs-keyword">const</span> html = <span class="hljs-keyword">await</span> renderPageToString(<br /> path.join(emailsDir, <span class="hljs-string">'welcome.page.html'</span>),<br /> { userName: <span class="hljs-string">'Alice'</span>, orderId: <span class="hljs-string">'1234'</span> }<br />);</code></pre>
339
- <p>Built-in vars (<code>2026</code>, <code>2026-04-17</code>, <code>2026-04-17T16:44:17.587Z</code>, <code>1776444257587</code>) are always available. Note that <code>&lt;page&gt;</code> tag attributes take highest priority and override <code>vars</code> with the same key.</p>
387
+ <p>Built-in vars (<code>2026</code>, <code>2026-04-20</code>, <code>2026-04-20T13:49:28.936Z</code>, <code>1776692968936</code>) are always available. Note that <code>&lt;page&gt;</code> tag attributes take highest priority and override <code>vars</code> with the same key.</p>
340
388
 
341
389
  <h3 id="render-external-page">renderExternalPage</h3>
342
390
  <p>Identical pipeline to <code>renderPage</code>, but decouples the page file's physical location from where templates and fragments are resolved. Use this when a page file lives outside <code>rootDir</code> — for example, in a plugin or extension package — but should be rendered using the host project's templates, fragments, and globals.</p>
package/docs/theme.css CHANGED
@@ -1,7 +1,7 @@
1
1
  :root {
2
2
  --c_primary: hsl(262, 52%, 47%);
3
3
  --c_secondary: rgb(51, 102, 255);
4
- --tc_primary: light-dark(#93f, rgb(187, 102, 255));
4
+ --tc_primary: light-dark(hsl(262, 52%, 47%), rgb(187, 102, 255));
5
5
  --tc_secondary: light-dark(#36f, rgb(138, 180, 248));
6
6
  --c_highlight: light-dark(rgba(153, 51, 255, 0.25), rgba(153, 51, 255, 0.25));
7
7
  }
@@ -76,7 +76,7 @@
76
76
 
77
77
  <h3>Middleware Function Parameters</h3>
78
78
  <ul>
79
- <li><code>config</code> - Configuration object (can be used for middleware settings)</li>
79
+ <li><code>config</code> - The middleware config object, extended with <code>rootPath</code> (absolute path to the server root directory)</li>
80
80
  <li><code>req</code> - Request object (can be modified)</li>
81
81
  <li><code>res</code> - Response object (can be modified)</li>
82
82
  <li><code>next</code> - Function to call the next middleware or route handler</li>
@@ -14,13 +14,35 @@
14
14
  class="d-if ph"
15
15
  style="align-items: center"
16
16
  >
17
- <img src="./media/icon32.png" alt="Kempo Server Icon" class="pr" />
17
+ <img
18
+ src="./media/icon32.png"
19
+ alt="Kempo Server Icon"
20
+ class="pr"
21
+ />
18
22
  Kempo Server
19
23
  </a>
20
24
  <div class="flex"></div>
21
- <a href="https://github.com/dustinpoissant/kempo-ui?tab=License-1-ov-file#creative-commons-attribution-noncommercial-sharealike-20" target="_blank"><k-icon name="license"></k-icont></a>
22
- <a href="https://github.com/dustinpoissant/kempo-ui" target="_blank"><k-icon name="github-mark"></k-icont></a>
23
- <k-theme-switcher></k-theme-switcher>
25
+ <a
26
+ href="https://github.com/dustinpoissant/kempo-server?tab=License-1-ov-file#creative-commons-attribution-noncommercial-sharealike-20"
27
+ target="_blank"
28
+ ><k-icon name="license"></k-icon></a>
29
+ <a
30
+ href="https://www.npmjs.com/package/kempo-server"
31
+ target="_blank"
32
+ ><k-icon name="npm"></k-icon></a>
33
+ <a
34
+ href="https://github.com/dustinpoissant/kempo-server"
35
+ target="_blank"
36
+ ><k-icon name="github-mark"></k-icon></a>
37
+ <k-theme-switcher
38
+ class="mr"
39
+ style="
40
+ --padding: 0.5rem;
41
+ --c_active: var(--tc_on_primary);
42
+ --tc_active: var(--c_primary);
43
+ --c_inactive__hover: rgba(255, 255, 255, 0.1);
44
+ "
45
+ ></k-theme-switcher>
24
46
  </k-nav>
25
47
  <div style="width: 100%; height: 4rem;"></div>
26
48
  <k-aside
@@ -28,24 +50,50 @@
28
50
  state="offscreen"
29
51
  >
30
52
  <menu>
31
- <a href="./" class="ta-center bb mb r0">
53
+ <a
54
+ href="./"
55
+ class="ta-center bb mb r0"
56
+ >
32
57
  <h1 class="tc-primary">Kempo Server</h1>
33
- <img src="./media/icon128.png" alt="Kempo UI Icon" />
58
+ <img
59
+ src="./media/icon128.png"
60
+ alt="Kempo UI Icon"
61
+ />
34
62
  </a>
35
63
  <location name="links" />
36
64
  </menu>
65
+ <k-aside-spacer></k-aside-spacer>
66
+ <k-theme-switcher
67
+ labels
68
+ style="--padding:var(--spacer_h);margin:var(--spacer_h) auto"
69
+ ></k-theme-switcher>
37
70
  </k-aside>
38
- <script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Aside.js" type="module"></script>
39
- <script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Main.js" type="module"></script>
40
- <script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Nav.js" type="module"></script>
41
- <script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Icon.js" type="module"></script>
42
- <script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/ThemeSwitcher.js" type="module"></script>
71
+ <script
72
+ src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Aside.js"
73
+ type="module"
74
+ ></script>
75
+ <script
76
+ src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Main.js"
77
+ type="module"
78
+ ></script>
79
+ <script
80
+ src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Nav.js"
81
+ type="module"
82
+ ></script>
83
+ <script
84
+ src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/Icon.js"
85
+ type="module"
86
+ ></script>
87
+ <script
88
+ src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3/src/components/ThemeSwitcher.js"
89
+ type="module"
90
+ ></script>
43
91
  <script>
44
92
  document.getElementById('toggleNavSideMenu').addEventListener('click', async () => {
45
93
  await window.customElements.whenDefined('k-aside');
46
94
  document.getElementById('navSideMenu').toggle();
47
95
  });
48
- document.addEventListener('click', function(e) {
96
+ document.addEventListener('click', function (e) {
49
97
  if (e.target.matches('a[href^="#"]')) {
50
98
  e.preventDefault();
51
99
  const targetId = e.target.getAttribute('href').replace('#', '');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kempo-server",
3
3
  "type": "module",
4
- "version": "3.0.12",
4
+ "version": "3.1.1",
5
5
  "description": "A lightweight, zero-dependency, file based routing server.",
6
6
  "exports": {
7
7
  "./rescan": "./dist/rescan.js",
@@ -32,5 +32,8 @@
32
32
  "repository": {
33
33
  "type": "git",
34
34
  "url": "https://github.com/dustinpoissant/kempo-server"
35
+ },
36
+ "dependencies": {
37
+ "kempo-ui": "^0.3.17"
35
38
  }
36
39
  }
package/src/router.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  loggingMiddleware
18
18
  } from './builtinMiddleware.js';
19
19
  import { onRescan } from './rescan.js';
20
- import { renderDir, renderPage } from './templating/index.js';
20
+ import { renderDir, renderExternalPage } from './templating/index.js';
21
21
 
22
22
  export default async (flags, log) => {
23
23
  log('Initializing router', 3);
@@ -218,7 +218,7 @@ export default async (flags, log) => {
218
218
  const customMiddleware = middlewareModule.default;
219
219
 
220
220
  if (typeof customMiddleware === 'function') {
221
- middlewareRunner.use(customMiddleware(config.middleware));
221
+ middlewareRunner.use(customMiddleware({ ...config.middleware, rootPath }));
222
222
  log(`Custom middleware loaded: ${middlewarePath}`, 3);
223
223
  } else {
224
224
  log(`Custom middleware error: ${middlewarePath} does not export a default function`, 1);
@@ -269,8 +269,9 @@ export default async (flags, log) => {
269
269
  // Convert wildcard pattern to regex
270
270
  // IMPORTANT: Replace ** BEFORE * to avoid replacing both * in **
271
271
  const regexPattern = normalizedPattern
272
- .replace(/\*\*/g, '(.+)') // Replace ** with capture group for multiple segments
273
- .replace(/\*/g, '([^/]+)'); // Replace * with capture group for single segment
272
+ .replace(/\*\*/g, '\x00GLOBSTAR\x00') // placeholder to avoid double-replace
273
+ .replace(/\*/g, '([^/]+)') // single * one path segment
274
+ .replace(/\x00GLOBSTAR\x00/g, '(.*)'); // ** — zero or more segments including slashes
274
275
 
275
276
  const regex = new RegExp(`^${regexPattern}$`);
276
277
  return regex.exec(requestPath);
@@ -395,6 +396,18 @@ export default async (flags, log) => {
395
396
  return null;
396
397
  };
397
398
 
399
+ // Build a virtual resolveDir that reflects the URL depth, so pathToRoot is computed correctly.
400
+ // For pages inside rootPath use their actual dir; for external pages fake a subdir at the right depth.
401
+ const getResolveDir = (pageFilePath, reqUrl) => {
402
+ if(pageFilePath.startsWith(rootPath)) return path.dirname(pageFilePath);
403
+ const urlParts = reqUrl.split('?')[0].replace(/\/+$/, '').split('/').filter(Boolean);
404
+ const depth = urlParts.length;
405
+ // Build a virtual path depth levels deep inside rootPath
406
+ let fakeDir = rootPath;
407
+ for(let i = 0; i < depth; i++) fakeDir = path.join(fakeDir, urlParts[i] || '__scope__');
408
+ return fakeDir;
409
+ };
410
+
398
411
  // Serve a resolved file or directory (fileStat already known).
399
412
  // Returns true if handled, null if directory has no matching route/index file.
400
413
  const serveResolvedPath = async (filePath, fileStat, params, req, res) => {
@@ -418,7 +431,8 @@ export default async (flags, log) => {
418
431
  if(candidate.endsWith('.page.html')) {
419
432
  log(`Rendering page template: ${candidatePath}`, 2);
420
433
  const {globals, state, maxFragmentDepth} = config.templating;
421
- const html = await renderPage(candidatePath, rootPath, globals, state, maxFragmentDepth);
434
+ const resolveDir = getResolveDir(candidatePath, req.url);
435
+ const html = await renderExternalPage(candidatePath, rootPath, resolveDir, globals, state, maxFragmentDepth);
422
436
  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
423
437
  res.end(html);
424
438
  return true;
@@ -439,7 +453,8 @@ export default async (flags, log) => {
439
453
  if(fileName.endsWith('.page.html')) {
440
454
  log(`Rendering page template: ${filePath}`, 2);
441
455
  const {globals, state, maxFragmentDepth} = config.templating;
442
- const html = await renderPage(filePath, rootPath, globals, state, maxFragmentDepth);
456
+ const resolveDir = getResolveDir(filePath, req.url);
457
+ const html = await renderExternalPage(filePath, rootPath, resolveDir, globals, state, maxFragmentDepth);
443
458
  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
444
459
  res.end(html);
445
460
  return true;
@@ -496,7 +511,8 @@ export default async (flags, log) => {
496
511
  for(const pageFile of [base + '.page.html', path.join(base, 'index.page.html')]) {
497
512
  try {
498
513
  await stat(pageFile);
499
- const html = await renderPage(pageFile, customRootDir, globals, state, maxFragmentDepth);
514
+ const resolveDir = getResolveDir(pageFile, req.url);
515
+ const html = await renderExternalPage(pageFile, rootPath, resolveDir, globals, state, maxFragmentDepth);
500
516
  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
501
517
  res.end(html);
502
518
  log(`SSR rendered custom route: ${pageFile}`, 2);
@@ -513,22 +529,23 @@ export default async (flags, log) => {
513
529
  // Track 404 attempts to avoid unnecessary rescans
514
530
  const rescanAttempts = new Map(); // path -> attempt count
515
531
 
516
- // Walk up the directory tree from requestPath looking for CATCH.js, CATCH.html, CATCH.page.html.
532
+ // Walk up the directory tree looking for CATCH.js, CATCH.html, CATCH.page.html.
533
+ // startDir: absolute path to begin the walk. boundDir: walk stops here (inclusive).
517
534
  // Returns true if a catch fallback handler was found and served, false otherwise.
518
- const serveCatchFallback = async (requestPath, req, res) => {
535
+ const serveCatchFallbackFrom = async (startDir, boundDir, req, res) => {
519
536
  const candidates = ['CATCH.js', 'CATCH.html', 'CATCH.page.html'];
520
- let dir = path.join(rootPath, requestPath.startsWith('/') ? requestPath.slice(1) : requestPath);
521
- // Start from the requested directory (or its parent if it's a file path)
522
- if(path.extname(dir)) dir = path.dirname(dir);
537
+ let dir = path.extname(startDir) ? path.dirname(startDir) : startDir;
538
+ const bound = path.resolve(boundDir);
523
539
 
524
- while(dir.startsWith(rootPath)) {
540
+ while(path.resolve(dir).startsWith(bound)) {
525
541
  for(const candidate of candidates) {
526
542
  const candidatePath = path.join(dir, candidate);
527
543
  try { await stat(candidatePath); } catch { continue; }
528
544
  log(`Serving catch fallback: ${candidatePath}`, 2);
529
545
  if(candidate === 'CATCH.page.html') {
530
546
  const {globals, state, maxFragmentDepth} = config.templating;
531
- const html = await renderPage(candidatePath, rootPath, globals, state, maxFragmentDepth);
547
+ const resolveDir = getResolveDir(candidatePath, req.url);
548
+ const html = await renderExternalPage(candidatePath, rootPath, resolveDir, globals, state, maxFragmentDepth);
532
549
  res.writeHead(404, {'Content-Type': 'text/html; charset=utf-8'});
533
550
  res.end(html);
534
551
  return true;
@@ -549,6 +566,12 @@ export default async (flags, log) => {
549
566
  }
550
567
  return false;
551
568
  };
569
+
570
+ const serveCatchFallback = (requestPath, req, res) => {
571
+ const startDir = path.join(rootPath, requestPath.startsWith('/') ? requestPath.slice(1) : requestPath);
572
+ return serveCatchFallbackFrom(startDir, rootPath, req, res);
573
+ };
574
+
552
575
  const dynamicNoRescanPaths = new Set(); // paths that have exceeded max attempts
553
576
 
554
577
  // Helper function to check if a path should skip rescanning
@@ -692,6 +715,7 @@ export default async (flags, log) => {
692
715
  const result = await serveCustomRoutePath(resolvedFilePath, req, res, customRootDir);
693
716
  if(result) return;
694
717
  log(`Wildcard route path not found: ${requestPath}`, 2);
718
+ if(await serveCatchFallbackFrom(resolvedFilePath, customRootDir, req, res)) return;
695
719
  } catch(error) {
696
720
  log(`Error serving wildcard route ${requestPath}: ${error.message}`, 1);
697
721
  enhancedResponse.writeHead(500, { 'Content-Type': 'text/plain' });
@@ -23,7 +23,7 @@ export default {
23
23
  const template = '<html><body><location name="main" /></body></html>';
24
24
  const page = '<page template="default"><content location="main"><h1>Admin Page</h1></content></page>';
25
25
 
26
- await write(dir, 'admin/default.template.html', template);
26
+ await write(dir, 'public/default.template.html', template);
27
27
  await write(dir, 'admin/dashboard.page.html', page);
28
28
  await write(dir, 'public/index.html', '<h1>root</h1>');
29
29
 
@@ -57,7 +57,7 @@ export default {
57
57
  const template = '<html><body><location name="main" /></body></html>';
58
58
  const page = '<page template="default"><content location="main"><h1>Section Index</h1></content></page>';
59
59
 
60
- await write(dir, 'admin/default.template.html', template);
60
+ await write(dir, 'public/default.template.html', template);
61
61
  await write(dir, 'admin/users/index.page.html', page);
62
62
  await write(dir, 'public/index.html', '<h1>root</h1>');
63
63
 
@@ -115,15 +115,15 @@ export default {
115
115
  }
116
116
  },
117
117
 
118
- 'wildcard custom route SSR uses custom root for template/fragment lookup': async ({pass, fail}) => {
118
+ 'wildcard custom route SSR uses public root for template/fragment lookup': async ({pass, fail}) => {
119
119
  try {
120
120
  await withTempDir(async (dir) => {
121
121
  const template = '<html><fragment name="nav" /><location name="main" /></html>';
122
122
  const fragment = '<nav>Custom Nav</nav>';
123
123
  const page = '<page template="default"><content location="main"><p>Content</p></content></page>';
124
124
 
125
- await write(dir, 'admin/default.template.html', template);
126
- await write(dir, 'admin/nav.fragment.html', fragment);
125
+ await write(dir, 'public/default.template.html', template);
126
+ await write(dir, 'public/nav.fragment.html', fragment);
127
127
  await write(dir, 'admin/about.page.html', page);
128
128
  await write(dir, 'public/index.html', '<h1>root</h1>');
129
129