kempo-server 3.0.9 → 3.0.11

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `kempo-server` are documented in this file.
4
4
 
5
+ ## [3.0.11] - 2026-04-16
6
+
7
+ ### Added
8
+
9
+ - **`renderPageToString(pagePath, vars, rootDir)`** exported from `kempo-server/templating`. Runs the full templating pipeline (template resolution, fragment injection, global content, `<if>`, `<foreach>`, `{{vars}}`) against a `.page.html` file and returns the final HTML string. Intended for programmatic use such as rendering emails.
10
+
11
+ ---
12
+
5
13
  ## [3.0.0] - 2026-04-09
6
14
 
7
15
  ### Breaking Changes
package/README.md CHANGED
@@ -315,6 +315,73 @@ export default async function(request, response) {
315
315
  }
316
316
  ```
317
317
 
318
+ ## Programmatic HTML Rendering
319
+
320
+ Use `renderPageToString` to run the full templating pipeline outside of an HTTP request — ideal for rendering emails, generating HTML to pass to a PDF library, or any other programmatic use case.
321
+
322
+ ```javascript
323
+ import { renderPageToString } from 'kempo-server/templating';
324
+
325
+ const html = await renderPageToString(pagePath, vars, rootDir);
326
+ ```
327
+
328
+ **Parameters:**
329
+
330
+ | Parameter | Type | Description |
331
+ |-----------|------|-------------|
332
+ | `pagePath` | `string` | Absolute path to a `.page.html` file |
333
+ | `vars` | `object` | Data available as `{{varName}}` in templates, fragments, and `<if>` conditions |
334
+ | `rootDir` | `string` | *(optional)* Root for template/fragment/global search. Defaults to the directory of `pagePath` |
335
+
336
+ The same pipeline that runs for web requests is used: template resolution, fragment injection, global content push, `<if>` conditionals, `<foreach>` loops, and `{{var}}` interpolation.
337
+
338
+ **Email example:**
339
+
340
+ ```
341
+ emails/
342
+ ├─ email.template.html ← shared header/footer for all emails
343
+ ├─ signature.fragment.html ← reusable fragment
344
+ ├─ promo-banner.global.html ← pushed into every email automatically
345
+ ├─ welcome.page.html
346
+ ├─ password-reset.page.html
347
+ └─ order-confirmation.page.html
348
+ ```
349
+
350
+ ```javascript
351
+ // email.template.html
352
+ <html>
353
+ <body>
354
+ <location name="body" />
355
+ <fragment name="signature" />
356
+ <location name="promo" />
357
+ </body>
358
+ </html>
359
+ ```
360
+
361
+ ```javascript
362
+ // welcome.page.html
363
+ <page template="email">
364
+ <content location="body">
365
+ <h1>Welcome, {{userName}}!</h1>
366
+ </content>
367
+ </page>
368
+ ```
369
+
370
+ ```javascript
371
+ import { renderPageToString } from 'kempo-server/templating';
372
+ import path from 'path';
373
+
374
+ const emailsDir = path.resolve('./emails');
375
+ const html = await renderPageToString(
376
+ path.join(emailsDir, 'welcome.page.html'),
377
+ { userName: 'Alice' },
378
+ emailsDir
379
+ );
380
+ // html is the fully rendered email string — ready to send
381
+ ```
382
+
383
+ **Note:** `vars` are merged as `state` in the pipeline, meaning `<page>` tag attributes take highest priority, followed by `vars`, then `globals`. Built-in vars (`{{year}}`, `{{date}}`, `{{datetime}}`, `{{timestamp}}`) are always available.
384
+
318
385
  ## Programmatic File Rescan
319
386
 
320
387
  When files are added or removed at runtime (e.g., by a CMS generating static pages), you can trigger a file rescan without restarting the server:
@@ -1 +1 @@
1
- import{readFile,writeFile,mkdir,readdir}from"fs/promises";import path from"path";import{extractAttrs,extractContentBlocks,mergeContentBlocks,replaceLocations,resolveVars,resolveIfs,resolveForeach,resolveFragmentTags}from"./parse.js";import{readFileSync,statSync}from"fs";const findFileUpSync=(filename,startDir,rootDir)=>{let dir=startDir;const root=path.resolve(rootDir);for(;;){const candidate=path.join(dir,filename);try{return statSync(candidate),candidate}catch(e){}if(path.resolve(dir)===root)return null;const parent=path.dirname(dir);if(parent===dir)return null;dir=parent}},loadVersion=rootDir=>{try{return JSON.parse(readFileSync(path.join(rootDir,"package.json"),"utf8")).version||""}catch(e){return""}},walkGlobals=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkGlobals(full)):entry.name.endsWith(".global.html")&&results.push(full)}return results},loadGlobalContent=async rootDir=>{const files=await walkGlobals(rootDir),maps=await Promise.all(files.map(async f=>extractContentBlocks(await readFile(f,"utf8"))));return mergeContentBlocks(...maps)},renderPage=async(pageFilePath,rootDir,globals={},state={},maxDepth=10,preloadedGlobalContent=null)=>{const pageContent=await readFile(pageFilePath,"utf8"),pageTagMatch=pageContent.match(/^[\s\S]*?<page((?:[^>"']|"[^"]*"|'[^']*')*)>/);if(!pageTagMatch)throw new Error(`Invalid page file: missing <page> root element in ${pageFilePath}`);const pageAttrs=extractAttrs(pageTagMatch[1]),templateName=pageAttrs.template||"default";delete pageAttrs.template;const pageDir=path.dirname(pageFilePath);let templateFile=findFileUpSync(`${templateName}.template.html`,pageDir,rootDir);if(templateFile||"default"===templateName||(templateFile=findFileUpSync("default.template.html",pageDir,rootDir)),!templateFile)throw new Error(`Template not found: ${templateName}.template.html or default.template.html (searched from ${pageDir} to ${rootDir})`);const globalContent=preloadedGlobalContent??await loadGlobalContent(rootDir),rawPageBlocks=extractContentBlocks(pageContent),pageBlocks={};for(const[name,entries]of Object.entries(rawPageBlocks))pageBlocks[name]=entries.map(e=>({...e,html:replaceLocations(e.html,globalContent)}));const contentBlocks=mergeContentBlocks(pageBlocks,globalContent);let templateHtml=readFileSync(templateFile,"utf8");templateHtml=resolveFragmentTags(templateHtml,name=>{const filePath=findFileUpSync(name+".fragment.html",pageDir,rootDir);return filePath?readFileSync(filePath,"utf8"):null},0,maxDepth),templateHtml=replaceLocations(templateHtml,contentBlocks);const rel=path.relative(rootDir,path.dirname(pageFilePath)),depth=rel?rel.split(path.sep).length:0,now=new Date,vars={pathToRoot:depth>0?"../".repeat(depth):"./",year:String(now.getFullYear()),date:now.toISOString().slice(0,10),datetime:now.toISOString(),timestamp:String(Date.now()),version:loadVersion(rootDir),env:process.env.NODE_ENV||"",...globals,...state,...pageAttrs};for(const[key,val]of Object.entries(vars))"function"==typeof val&&(vars[key]=val());return templateHtml=resolveIfs(templateHtml,vars),templateHtml=resolveForeach(templateHtml,vars),templateHtml=resolveVars(templateHtml,vars),templateHtml},walkPages=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkPages(full)):entry.name.endsWith(".page.html")&&results.push(full)}return results},renderDir=async(inputDir,outputDir,globals={},state={},maxDepth=10)=>{const[pages,globalContent]=await Promise.all([walkPages(inputDir),loadGlobalContent(inputDir)]);let count=0;for(const page of pages){const outRel=path.relative(inputDir,page).replace(/\.page\.html$/,".html"),outPath=path.join(outputDir,outRel);await mkdir(path.dirname(outPath),{recursive:!0});const html=await renderPage(page,inputDir,globals,state,maxDepth,globalContent);await writeFile(outPath,html,"utf8"),count++}return count};export{renderPage,renderDir};
1
+ import{readFile,writeFile,mkdir,readdir}from"fs/promises";import path from"path";import{extractAttrs,extractContentBlocks,mergeContentBlocks,replaceLocations,resolveVars,resolveIfs,resolveForeach,resolveFragmentTags}from"./parse.js";import{readFileSync,statSync}from"fs";const findFileUpSync=(filename,startDir,rootDir)=>{let dir=startDir;const root=path.resolve(rootDir);for(;;){const candidate=path.join(dir,filename);try{return statSync(candidate),candidate}catch(e){}if(path.resolve(dir)===root)return null;const parent=path.dirname(dir);if(parent===dir)return null;dir=parent}},loadVersion=rootDir=>{try{return JSON.parse(readFileSync(path.join(rootDir,"package.json"),"utf8")).version||""}catch(e){return""}},walkGlobals=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkGlobals(full)):entry.name.endsWith(".global.html")&&results.push(full)}return results},loadGlobalContent=async rootDir=>{const files=await walkGlobals(rootDir),maps=await Promise.all(files.map(async f=>extractContentBlocks(await readFile(f,"utf8"))));return mergeContentBlocks(...maps)},renderPage=async(pageFilePath,rootDir,globals={},state={},maxDepth=10,preloadedGlobalContent=null)=>{const pageContent=await readFile(pageFilePath,"utf8"),pageTagMatch=pageContent.match(/^[\s\S]*?<page((?:[^>"']|"[^"]*"|'[^']*')*)>/);if(!pageTagMatch)throw new Error(`Invalid page file: missing <page> root element in ${pageFilePath}`);const pageAttrs=extractAttrs(pageTagMatch[1]),templateName=pageAttrs.template||"default";delete pageAttrs.template;const pageDir=path.dirname(pageFilePath);let templateFile=findFileUpSync(`${templateName}.template.html`,pageDir,rootDir);if(templateFile||"default"===templateName||(templateFile=findFileUpSync("default.template.html",pageDir,rootDir)),!templateFile)throw new Error(`Template not found: ${templateName}.template.html or default.template.html (searched from ${pageDir} to ${rootDir})`);const globalContent=preloadedGlobalContent??await loadGlobalContent(rootDir),rawPageBlocks=extractContentBlocks(pageContent),pageBlocks={};for(const[name,entries]of Object.entries(rawPageBlocks))pageBlocks[name]=entries.map(e=>({...e,html:replaceLocations(e.html,globalContent)}));const contentBlocks=mergeContentBlocks(pageBlocks,globalContent);let templateHtml=readFileSync(templateFile,"utf8");templateHtml=resolveFragmentTags(templateHtml,name=>{const filePath=findFileUpSync(name+".fragment.html",pageDir,rootDir);return filePath?readFileSync(filePath,"utf8"):null},0,maxDepth),templateHtml=replaceLocations(templateHtml,contentBlocks);const rel=path.relative(rootDir,path.dirname(pageFilePath)),depth=rel?rel.split(path.sep).length:0,now=new Date,vars={pathToRoot:depth>0?"../".repeat(depth):"./",year:String(now.getFullYear()),date:now.toISOString().slice(0,10),datetime:now.toISOString(),timestamp:String(Date.now()),version:loadVersion(rootDir),env:process.env.NODE_ENV||"",...globals,...state,...pageAttrs};for(const[key,val]of Object.entries(vars))"function"==typeof val&&(vars[key]=val());return templateHtml=resolveIfs(templateHtml,vars),templateHtml=resolveForeach(templateHtml,vars),templateHtml=resolveVars(templateHtml,vars),templateHtml},walkPages=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkPages(full)):entry.name.endsWith(".page.html")&&results.push(full)}return results},renderDir=async(inputDir,outputDir,globals={},state={},maxDepth=10)=>{const[pages,globalContent]=await Promise.all([walkPages(inputDir),loadGlobalContent(inputDir)]);let count=0;for(const page of pages){const outRel=path.relative(inputDir,page).replace(/\.page\.html$/,".html"),outPath=path.join(outputDir,outRel);await mkdir(path.dirname(outPath),{recursive:!0});const html=await renderPage(page,inputDir,globals,state,maxDepth,globalContent);await writeFile(outPath,html,"utf8"),count++}return count},renderPageToString=(pagePath,vars={},rootDir=path.dirname(pagePath))=>renderPage(pagePath,rootDir,{},vars);export{renderPage,renderDir,renderPageToString};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kempo-server",
3
3
  "type": "module",
4
- "version": "3.0.9",
4
+ "version": "3.0.11",
5
5
  "description": "A lightweight, zero-dependency, file based routing server.",
6
6
  "exports": {
7
7
  "./rescan": "./dist/rescan.js",
@@ -168,4 +168,7 @@ const renderDir = async (inputDir, outputDir, globals = {}, state = {}, maxDepth
168
168
  return count;
169
169
  };
170
170
 
171
- export { renderPage, renderDir };
171
+ const renderPageToString = (pagePath, vars = {}, rootDir = path.dirname(pagePath)) =>
172
+ renderPage(pagePath, rootDir, {}, vars);
173
+
174
+ export { renderPage, renderDir, renderPageToString };
@@ -0,0 +1,178 @@
1
+ import { renderPageToString } from '../src/templating/index.js';
2
+ import { writeFile, mkdir } from 'fs/promises';
3
+ import path from 'path';
4
+ import { withTempDir } from './utils/temp-dir.js';
5
+
6
+ const setupFiles = async (dir, files) => {
7
+ for(const [rel, content] of Object.entries(files)){
8
+ const full = path.join(dir, rel);
9
+ await mkdir(path.dirname(full), {recursive: true});
10
+ await writeFile(full, content, 'utf8');
11
+ }
12
+ };
13
+
14
+ export default {
15
+ 'renderPageToString returns html string': async ({pass, fail}) => {
16
+ await withTempDir(async dir => {
17
+ await setupFiles(dir, {
18
+ 'default.template.html': '<html><body><location name="main" /></body></html>',
19
+ 'index.page.html': '<page><content location="main"><h1>Hello</h1></content></page>'
20
+ });
21
+ const html = await renderPageToString(path.join(dir, 'index.page.html'));
22
+ if(typeof html !== 'string') return fail(`expected string, got ${typeof html}`);
23
+ if(!html.includes('<h1>Hello</h1>')) return fail(`missing content: ${html}`);
24
+ if(!html.includes('<html>')) return fail(`missing template: ${html}`);
25
+ pass();
26
+ });
27
+ },
28
+
29
+ 'renderPageToString interpolates vars': async ({pass, fail}) => {
30
+ await withTempDir(async dir => {
31
+ await setupFiles(dir, {
32
+ 'default.template.html': '<location name="main" />',
33
+ 'welcome.page.html': '<page><content location="main">Hello {{userName}}</content></page>'
34
+ });
35
+ const html = await renderPageToString(path.join(dir, 'welcome.page.html'), {userName: 'Alice'});
36
+ if(!html.includes('Hello Alice')) return fail(`var not interpolated: ${html}`);
37
+ pass();
38
+ });
39
+ },
40
+
41
+ 'renderPageToString uses named template': async ({pass, fail}) => {
42
+ await withTempDir(async dir => {
43
+ await setupFiles(dir, {
44
+ 'email.template.html': '<email><location name="body" /></email>',
45
+ 'welcome.page.html': '<page template="email"><content location="body">Welcome!</content></page>'
46
+ });
47
+ const html = await renderPageToString(path.join(dir, 'welcome.page.html'));
48
+ if(!html.includes('<email>')) return fail(`email template not used: ${html}`);
49
+ if(!html.includes('Welcome!')) return fail(`content missing: ${html}`);
50
+ pass();
51
+ });
52
+ },
53
+
54
+ 'renderPageToString injects fragments': async ({pass, fail}) => {
55
+ await withTempDir(async dir => {
56
+ await setupFiles(dir, {
57
+ 'email.template.html': '<fragment name="signature" /><location name="body" />',
58
+ 'signature.fragment.html': '<p>Best regards, Acme Corp</p>',
59
+ 'welcome.page.html': '<page template="email"><content location="body">Hi there</content></page>'
60
+ });
61
+ const html = await renderPageToString(path.join(dir, 'welcome.page.html'));
62
+ if(!html.includes('Best regards, Acme Corp')) return fail(`fragment missing: ${html}`);
63
+ if(!html.includes('Hi there')) return fail(`body missing: ${html}`);
64
+ pass();
65
+ });
66
+ },
67
+
68
+ 'renderPageToString uses global content': async ({pass, fail}) => {
69
+ await withTempDir(async dir => {
70
+ await setupFiles(dir, {
71
+ 'email.template.html': '<location name="promo" /><location name="body" />',
72
+ 'promo.global.html': '<content location="promo"><b>Summer Sale!</b></content>',
73
+ 'welcome.page.html': '<page template="email"><content location="body">Welcome</content></page>'
74
+ });
75
+ const html = await renderPageToString(path.join(dir, 'welcome.page.html'));
76
+ if(!html.includes('<b>Summer Sale!</b>')) return fail(`global promo missing: ${html}`);
77
+ if(!html.includes('Welcome')) return fail(`body missing: ${html}`);
78
+ pass();
79
+ });
80
+ },
81
+
82
+ 'renderPageToString processes if conditionals': async ({pass, fail}) => {
83
+ await withTempDir(async dir => {
84
+ await setupFiles(dir, {
85
+ 'default.template.html': '<location name="main" />',
86
+ 'reset.page.html': '<page><content location="main"><if condition="resetLink">Click {{resetLink}}</if></content></page>'
87
+ });
88
+ const withLink = await renderPageToString(path.join(dir, 'reset.page.html'), {resetLink: 'https://example.com/reset'});
89
+ if(!withLink.includes('Click https://example.com/reset')) return fail(`link not rendered: ${withLink}`);
90
+ const withoutLink = await renderPageToString(path.join(dir, 'reset.page.html'), {});
91
+ if(withoutLink.includes('Click')) return fail(`should be hidden: ${withoutLink}`);
92
+ pass();
93
+ });
94
+ },
95
+
96
+ 'renderPageToString processes foreach loops': async ({pass, fail}) => {
97
+ await withTempDir(async dir => {
98
+ await setupFiles(dir, {
99
+ 'default.template.html': '<location name="main" />',
100
+ 'order.page.html': '<page><content location="main"><foreach in="items" as="item"><li>{{item}}</li></foreach></content></page>'
101
+ });
102
+ const html = await renderPageToString(path.join(dir, 'order.page.html'), {items: ['Widget', 'Gadget']});
103
+ if(!html.includes('<li>Widget</li>')) return fail(`Widget missing: ${html}`);
104
+ if(!html.includes('<li>Gadget</li>')) return fail(`Gadget missing: ${html}`);
105
+ pass();
106
+ });
107
+ },
108
+
109
+ 'renderPageToString accepts explicit rootDir': async ({pass, fail}) => {
110
+ await withTempDir(async dir => {
111
+ await setupFiles(dir, {
112
+ 'email.template.html': '<location name="body" />',
113
+ 'emails/welcome.page.html': '<page template="email"><content location="body">Hi</content></page>'
114
+ });
115
+ const pagePath = path.join(dir, 'emails', 'welcome.page.html');
116
+ const html = await renderPageToString(pagePath, {}, dir);
117
+ if(!html.includes('Hi')) return fail(`content missing with explicit rootDir: ${html}`);
118
+ pass();
119
+ });
120
+ },
121
+
122
+ 'renderPageToString page attributes override vars': async ({pass, fail}) => {
123
+ await withTempDir(async dir => {
124
+ await setupFiles(dir, {
125
+ 'default.template.html': '<title>{{title}}</title><location name="main" />',
126
+ 'welcome.page.html': '<page title="Page Title"><content location="main">x</content></page>'
127
+ });
128
+ // page attributes take highest priority — they override vars with same key
129
+ const html = await renderPageToString(path.join(dir, 'welcome.page.html'), {title: 'Var Title'});
130
+ if(!html.includes('<title>Page Title</title>')) return fail(`page attr should win: ${html}`);
131
+ pass();
132
+ });
133
+ },
134
+
135
+ 'renderPageToString throws on missing template': async ({pass, fail}) => {
136
+ await withTempDir(async dir => {
137
+ await setupFiles(dir, {
138
+ 'welcome.page.html': '<page template="email"><content location="body">hi</content></page>'
139
+ });
140
+ try {
141
+ await renderPageToString(path.join(dir, 'welcome.page.html'));
142
+ fail('should have thrown');
143
+ } catch(e){
144
+ if(!e.message.includes('Template not found')) return fail(`wrong error: ${e.message}`);
145
+ pass();
146
+ }
147
+ });
148
+ },
149
+
150
+ 'renderPageToString includes built-in year var': async ({pass, fail}) => {
151
+ await withTempDir(async dir => {
152
+ await setupFiles(dir, {
153
+ 'default.template.html': '{{year}}<location name="main" />',
154
+ 'index.page.html': '<page><content location="main">x</content></page>'
155
+ });
156
+ const html = await renderPageToString(path.join(dir, 'index.page.html'));
157
+ if(!html.includes(String(new Date().getFullYear()))) return fail(`year missing: ${html}`);
158
+ pass();
159
+ });
160
+ },
161
+
162
+ 'renderPageToString shared template across multiple pages': async ({pass, fail}) => {
163
+ await withTempDir(async dir => {
164
+ await setupFiles(dir, {
165
+ 'email.template.html': '<html><body><location name="body" /></body></html>',
166
+ 'welcome.page.html': '<page template="email"><content location="body">Welcome email</content></page>',
167
+ 'reset.page.html': '<page template="email"><content location="body">Reset email</content></page>'
168
+ });
169
+ const [welcome, reset] = await Promise.all([
170
+ renderPageToString(path.join(dir, 'welcome.page.html')),
171
+ renderPageToString(path.join(dir, 'reset.page.html'))
172
+ ]);
173
+ if(!welcome.includes('<html>') || !welcome.includes('Welcome email')) return fail(`welcome wrong: ${welcome}`);
174
+ if(!reset.includes('<html>') || !reset.includes('Reset email')) return fail(`reset wrong: ${reset}`);
175
+ pass();
176
+ });
177
+ }
178
+ };