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
package/src/router.js
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
securityMiddleware,
|
|
17
17
|
loggingMiddleware
|
|
18
18
|
} from './builtinMiddleware.js';
|
|
19
|
+
import { onRescan } from './rescan.js';
|
|
20
|
+
import { renderDir } from './templating/index.js';
|
|
19
21
|
|
|
20
22
|
export default async (flags, log) => {
|
|
21
23
|
log('Initializing router', 3);
|
|
@@ -24,8 +26,7 @@ export default async (flags, log) => {
|
|
|
24
26
|
|
|
25
27
|
let config = defaultConfig;
|
|
26
28
|
try {
|
|
27
|
-
|
|
28
|
-
const configFileName = flags.config || '.config.json';
|
|
29
|
+
const configFileName = flags.config || '.config.js';
|
|
29
30
|
const configPath = path.isAbsolute(configFileName)
|
|
30
31
|
? configFileName
|
|
31
32
|
: path.join(rootPath, configFileName);
|
|
@@ -49,12 +50,19 @@ export default async (flags, log) => {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
log(`Loading config from: ${configPath}`, 3);
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
let userConfig;
|
|
54
|
+
if(configPath.endsWith('.js')){
|
|
55
|
+
const configUrl = pathToFileURL(configPath).href + `?t=${Date.now()}`;
|
|
56
|
+
const configModule = await import(configUrl);
|
|
57
|
+
userConfig = configModule.default;
|
|
58
|
+
} else {
|
|
59
|
+
const configContent = await readFile(configPath, 'utf8');
|
|
60
|
+
userConfig = JSON.parse(configContent);
|
|
61
|
+
}
|
|
62
|
+
if(!userConfig) throw new Error('Config file is empty or has no default export');
|
|
54
63
|
config = {
|
|
55
64
|
...defaultConfig,
|
|
56
65
|
...userConfig,
|
|
57
|
-
// Deep merge nested objects
|
|
58
66
|
allowedMimes: {
|
|
59
67
|
...defaultConfig.allowedMimes,
|
|
60
68
|
...(userConfig.allowedMimes || {})
|
|
@@ -70,16 +78,59 @@ export default async (flags, log) => {
|
|
|
70
78
|
cache: {
|
|
71
79
|
...defaultConfig.cache,
|
|
72
80
|
...(userConfig.cache || {})
|
|
81
|
+
},
|
|
82
|
+
templating: {
|
|
83
|
+
...defaultConfig.templating,
|
|
84
|
+
...(userConfig.templating || {})
|
|
73
85
|
}
|
|
74
86
|
};
|
|
75
87
|
log('User config loaded and merged with defaults', 3);
|
|
76
88
|
} catch (e){
|
|
77
|
-
// Only fall back to default config for file reading/parsing errors
|
|
78
|
-
// Let validation errors propagate up
|
|
79
89
|
if (e.message.includes('Config file must be within the server root directory')) {
|
|
80
90
|
throw e;
|
|
81
91
|
}
|
|
82
|
-
|
|
92
|
+
// If .config.js failed, try .config.json fallback
|
|
93
|
+
const configFileName = flags.config || '.config.js';
|
|
94
|
+
if(configFileName.endsWith('.js')){
|
|
95
|
+
try {
|
|
96
|
+
const jsonFallback = configFileName.replace(/\.js$/, '.json');
|
|
97
|
+
const jsonPath = path.isAbsolute(jsonFallback)
|
|
98
|
+
? jsonFallback
|
|
99
|
+
: path.join(rootPath, jsonFallback);
|
|
100
|
+
log(`Trying JSON fallback: ${jsonPath}`, 3);
|
|
101
|
+
const configContent = await readFile(jsonPath, 'utf8');
|
|
102
|
+
const userConfig = JSON.parse(configContent);
|
|
103
|
+
config = {
|
|
104
|
+
...defaultConfig,
|
|
105
|
+
...userConfig,
|
|
106
|
+
allowedMimes: {
|
|
107
|
+
...defaultConfig.allowedMimes,
|
|
108
|
+
...(userConfig.allowedMimes || {})
|
|
109
|
+
},
|
|
110
|
+
middleware: {
|
|
111
|
+
...defaultConfig.middleware,
|
|
112
|
+
...(userConfig.middleware || {})
|
|
113
|
+
},
|
|
114
|
+
customRoutes: {
|
|
115
|
+
...defaultConfig.customRoutes,
|
|
116
|
+
...(userConfig.customRoutes || {})
|
|
117
|
+
},
|
|
118
|
+
cache: {
|
|
119
|
+
...defaultConfig.cache,
|
|
120
|
+
...(userConfig.cache || {})
|
|
121
|
+
},
|
|
122
|
+
templating: {
|
|
123
|
+
...defaultConfig.templating,
|
|
124
|
+
...(userConfig.templating || {})
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
log('User config loaded from JSON fallback', 3);
|
|
128
|
+
} catch(e2){
|
|
129
|
+
log('Using default config (no config file found)', 3);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
log('Using default config (no config file found)', 3);
|
|
133
|
+
}
|
|
83
134
|
}
|
|
84
135
|
|
|
85
136
|
/*
|
|
@@ -87,14 +138,32 @@ export default async (flags, log) => {
|
|
|
87
138
|
*/
|
|
88
139
|
const dis = new Set(config.disallowedRegex);
|
|
89
140
|
dis.add("^/\\..*");
|
|
90
|
-
dis.add("\\.config$");
|
|
141
|
+
dis.add("\\.config\\.js$");
|
|
142
|
+
dis.add("\\.config\\.json$");
|
|
91
143
|
dis.add("\\.git/");
|
|
92
144
|
config.disallowedRegex = [...dis];
|
|
93
145
|
log(`Config loaded with ${config.disallowedRegex.length} disallowed patterns`, 3);
|
|
94
146
|
|
|
147
|
+
if(config.templating.preRender){
|
|
148
|
+
const {globals, state, maxFragmentDepth} = config.templating;
|
|
149
|
+
const count = await renderDir(rootPath, rootPath, globals, state, maxFragmentDepth);
|
|
150
|
+
log(`Pre-rendered ${count} page(s)`, 2);
|
|
151
|
+
}
|
|
152
|
+
|
|
95
153
|
let files = await getFiles(rootPath, config, log);
|
|
96
154
|
log(`Initial scan found ${files.length} files`, 2);
|
|
97
|
-
|
|
155
|
+
|
|
156
|
+
onRescan(async done => {
|
|
157
|
+
try {
|
|
158
|
+
files = await getFiles(rootPath, config, log);
|
|
159
|
+
log(`Rescan found ${files.length} files`, 2);
|
|
160
|
+
done(null, files.length);
|
|
161
|
+
} catch(error) {
|
|
162
|
+
log(`Rescan failed: ${error.message}`, 1);
|
|
163
|
+
done(error);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
98
167
|
// Initialize middleware runner
|
|
99
168
|
const middlewareRunner = new MiddlewareRunner();
|
|
100
169
|
|
|
@@ -182,9 +251,11 @@ export default async (flags, log) => {
|
|
|
182
251
|
|
|
183
252
|
// Helper function to match wildcard patterns
|
|
184
253
|
const matchWildcardRoute = (requestPath, pattern) => {
|
|
254
|
+
// Normalize pattern to ensure leading slash
|
|
255
|
+
const normalizedPattern = pattern.startsWith('/') ? pattern : '/' + pattern;
|
|
185
256
|
// Convert wildcard pattern to regex
|
|
186
257
|
// IMPORTANT: Replace ** BEFORE * to avoid replacing both * in **
|
|
187
|
-
const regexPattern =
|
|
258
|
+
const regexPattern = normalizedPattern
|
|
188
259
|
.replace(/\*\*/g, '(.+)') // Replace ** with capture group for multiple segments
|
|
189
260
|
.replace(/\*/g, '([^/]+)'); // Replace * with capture group for single segment
|
|
190
261
|
|
package/src/serveFile.js
CHANGED
|
@@ -4,12 +4,39 @@ import { pathToFileURL } from 'url';
|
|
|
4
4
|
import findFile from './findFile.js';
|
|
5
5
|
import createRequestWrapper, { readRawBody, parseBody } from './requestWrapper.js';
|
|
6
6
|
import createResponseWrapper from './responseWrapper.js';
|
|
7
|
+
import { renderPage } from './templating/index.js';
|
|
8
|
+
|
|
9
|
+
const trySSR = async (rootPath, requestPath, config, res, log) => {
|
|
10
|
+
const htmlPath = requestPath.endsWith('/') ? requestPath + 'index' : requestPath;
|
|
11
|
+
const pagePath = path.join(rootPath, htmlPath.replace(/\.html$/, '') + '.page.html');
|
|
12
|
+
try {
|
|
13
|
+
await stat(pagePath);
|
|
14
|
+
const {globals, state, maxFragmentDepth} = config.templating;
|
|
15
|
+
const html = await renderPage(pagePath, rootPath, globals, state, maxFragmentDepth);
|
|
16
|
+
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
|
|
17
|
+
res.end(html);
|
|
18
|
+
log(`SSR rendered: ${pagePath}`, 2);
|
|
19
|
+
return true;
|
|
20
|
+
} catch(e){
|
|
21
|
+
log(`SSR error for ${requestPath}: ${e.message}`, 3);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
7
25
|
|
|
8
26
|
export default async (files, rootPath, requestPath, method, config, req, res, log, moduleCache = null) => {
|
|
9
27
|
log(`Attempting to serve: ${requestPath}`, 3);
|
|
28
|
+
|
|
29
|
+
if(config.templating?.ssr && config.templating?.ssrPriority){
|
|
30
|
+
if(await trySSR(rootPath, requestPath, config, res, log)) return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
10
33
|
const [file, params] = await findFile(files, rootPath, requestPath, method, log);
|
|
11
34
|
|
|
12
35
|
if (!file) {
|
|
36
|
+
if(config.templating?.ssr){
|
|
37
|
+
if(await trySSR(rootPath, requestPath, config, res, log)) return true;
|
|
38
|
+
log(`SSR fallback not available for: ${requestPath}`, 3);
|
|
39
|
+
}
|
|
13
40
|
log(`No file found for: ${requestPath}`, 3);
|
|
14
41
|
return false; // Could not find file
|
|
15
42
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, readdir } from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
extractAttrs,
|
|
5
|
+
extractContentBlocks,
|
|
6
|
+
replaceLocations,
|
|
7
|
+
resolveVars,
|
|
8
|
+
resolveIfs,
|
|
9
|
+
resolveForeach,
|
|
10
|
+
resolveFragmentTags
|
|
11
|
+
} from './parse.js';
|
|
12
|
+
import { readFileSync, statSync } from 'fs';
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
Synchronous File Lookup — walk up from startDir to rootDir
|
|
16
|
+
*/
|
|
17
|
+
const findFileUpSync = (filename, startDir, rootDir) => {
|
|
18
|
+
let dir = startDir;
|
|
19
|
+
const root = path.resolve(rootDir);
|
|
20
|
+
while(true){
|
|
21
|
+
const candidate = path.join(dir, filename);
|
|
22
|
+
try {
|
|
23
|
+
statSync(candidate);
|
|
24
|
+
return candidate;
|
|
25
|
+
} catch(e){ /* not found */ }
|
|
26
|
+
if(path.resolve(dir) === root) return null;
|
|
27
|
+
const parent = path.dirname(dir);
|
|
28
|
+
if(parent === dir) return null;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const loadVersion = rootDir => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(path.join(rootDir, 'package.json'), 'utf8')).version || '';
|
|
36
|
+
} catch(e){
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
Render a Single Page
|
|
43
|
+
*/
|
|
44
|
+
const renderPage = async (pageFilePath, rootDir, globals = {}, state = {}, maxDepth = 10) => {
|
|
45
|
+
const pageContent = await readFile(pageFilePath, 'utf8');
|
|
46
|
+
const pageTagMatch = pageContent.match(/^[\s\S]*?<page\s([^>]*)>/);
|
|
47
|
+
if(!pageTagMatch) throw new Error(`Invalid page file: missing <page> root element in ${pageFilePath}`);
|
|
48
|
+
const pageAttrs = extractAttrs(pageTagMatch[1]);
|
|
49
|
+
const templateName = pageAttrs.template || 'default';
|
|
50
|
+
delete pageAttrs.template;
|
|
51
|
+
|
|
52
|
+
const contentBlocks = extractContentBlocks(pageContent);
|
|
53
|
+
const pageDir = path.dirname(pageFilePath);
|
|
54
|
+
const templateFile = findFileUpSync(`${templateName}.template.html`, pageDir, rootDir);
|
|
55
|
+
if(!templateFile) throw new Error(`Template not found: ${templateName}.template.html (searched from ${pageDir} to ${rootDir})`);
|
|
56
|
+
|
|
57
|
+
let templateHtml = readFileSync(templateFile, 'utf8');
|
|
58
|
+
|
|
59
|
+
const findFragmentFile = name => {
|
|
60
|
+
const filePath = findFileUpSync(name + '.fragment.html', pageDir, rootDir);
|
|
61
|
+
if(!filePath) return null;
|
|
62
|
+
return readFileSync(filePath, 'utf8');
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
templateHtml = resolveFragmentTags(templateHtml, findFragmentFile, 0, maxDepth);
|
|
66
|
+
templateHtml = replaceLocations(templateHtml, contentBlocks);
|
|
67
|
+
|
|
68
|
+
const rel = path.relative(rootDir, path.dirname(pageFilePath));
|
|
69
|
+
const depth = rel ? rel.split(path.sep).length : 0;
|
|
70
|
+
const now = new Date();
|
|
71
|
+
|
|
72
|
+
const vars = {
|
|
73
|
+
pathToRoot: depth > 0 ? '../'.repeat(depth) : './',
|
|
74
|
+
year: String(now.getFullYear()),
|
|
75
|
+
date: now.toISOString().slice(0, 10),
|
|
76
|
+
datetime: now.toISOString(),
|
|
77
|
+
timestamp: String(Date.now()),
|
|
78
|
+
version: loadVersion(rootDir),
|
|
79
|
+
env: process.env.NODE_ENV || '',
|
|
80
|
+
...globals,
|
|
81
|
+
...state,
|
|
82
|
+
...pageAttrs
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Call function values in globals/state to resolve them
|
|
86
|
+
for(const [key, val] of Object.entries(vars)){
|
|
87
|
+
if(typeof val === 'function') vars[key] = val();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
templateHtml = resolveIfs(templateHtml, vars);
|
|
91
|
+
templateHtml = resolveForeach(templateHtml, vars);
|
|
92
|
+
templateHtml = resolveVars(templateHtml, vars);
|
|
93
|
+
|
|
94
|
+
return templateHtml;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
Recursively Walk Directory for *.page.html
|
|
99
|
+
*/
|
|
100
|
+
const walkPages = async dir => {
|
|
101
|
+
const entries = await readdir(dir, {withFileTypes: true});
|
|
102
|
+
const results = [];
|
|
103
|
+
for(const entry of entries){
|
|
104
|
+
const full = path.join(dir, entry.name);
|
|
105
|
+
if(entry.isDirectory()){
|
|
106
|
+
results.push(...await walkPages(full));
|
|
107
|
+
} else if(entry.name.endsWith('.page.html')){
|
|
108
|
+
results.push(full);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return results;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
Render All Pages in a Directory
|
|
116
|
+
*/
|
|
117
|
+
const renderDir = async (inputDir, outputDir, globals = {}, state = {}, maxDepth = 10) => {
|
|
118
|
+
const pages = await walkPages(inputDir);
|
|
119
|
+
let count = 0;
|
|
120
|
+
for(const page of pages){
|
|
121
|
+
const rel = path.relative(inputDir, page);
|
|
122
|
+
const outRel = rel.replace(/\.page\.html$/, '.html');
|
|
123
|
+
const outPath = path.join(outputDir, outRel);
|
|
124
|
+
await mkdir(path.dirname(outPath), {recursive: true});
|
|
125
|
+
const html = await renderPage(page, inputDir, globals, state, maxDepth);
|
|
126
|
+
await writeFile(outPath, html, 'utf8');
|
|
127
|
+
count++;
|
|
128
|
+
}
|
|
129
|
+
return count;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export { renderPage, renderDir };
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Attribute Extraction
|
|
3
|
+
*/
|
|
4
|
+
const extractAttrs = tagString => {
|
|
5
|
+
const attrs = {};
|
|
6
|
+
const re = /(\w[\w-]*)=(?:"([^"]*)"|'([^']*)')/g;
|
|
7
|
+
let match;
|
|
8
|
+
while((match = re.exec(tagString)) !== null){
|
|
9
|
+
attrs[match[1]] = match[2] ?? match[3];
|
|
10
|
+
}
|
|
11
|
+
return attrs;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
Content Block Extraction
|
|
16
|
+
*/
|
|
17
|
+
const extractContentBlocks = xml => {
|
|
18
|
+
const blocks = {};
|
|
19
|
+
const re = /<content(?:\s+location="([^"]*)")?\s*>([\s\S]*?)<\/content>/g;
|
|
20
|
+
let match;
|
|
21
|
+
while((match = re.exec(xml)) !== null){
|
|
22
|
+
const name = match[1] || 'default';
|
|
23
|
+
blocks[name] = (blocks[name] || '') + match[2];
|
|
24
|
+
}
|
|
25
|
+
return blocks;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
Location Replacement
|
|
30
|
+
*/
|
|
31
|
+
const replaceLocations = (html, contentMap) =>
|
|
32
|
+
html
|
|
33
|
+
.replace(/<location(?:\s+name="([^"]*)")?>([\s\S]*?)<\/location>/g, (_, name, fallback) =>
|
|
34
|
+
contentMap[name || 'default'] ?? fallback
|
|
35
|
+
)
|
|
36
|
+
.replace(/<location(?:\s+name="([^"]*)")?\s*\/>/g, (_, name) =>
|
|
37
|
+
contentMap[name || 'default'] ?? ''
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
Fragment Wrapper Stripping
|
|
42
|
+
*/
|
|
43
|
+
const stripFragmentWrapper = xml => {
|
|
44
|
+
const match = xml.match(/^\s*<fragment\b[^>]*>([\s\S]*)<\/fragment>\s*$/);
|
|
45
|
+
return match ? match[1] : xml;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
Dot-Path Resolution
|
|
50
|
+
*/
|
|
51
|
+
const resolvePath = (obj, dotPath) =>
|
|
52
|
+
dotPath.split('.').reduce((cur, key) => cur?.[key], obj);
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
Variable Resolution
|
|
56
|
+
*/
|
|
57
|
+
const resolveVars = (html, vars) =>
|
|
58
|
+
html.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
|
|
59
|
+
const trimmed = key.trim();
|
|
60
|
+
const val = resolvePath(vars, trimmed);
|
|
61
|
+
if(typeof val === 'function') return val();
|
|
62
|
+
return val ?? '';
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
If-Block Resolution
|
|
67
|
+
*/
|
|
68
|
+
const resolveIfs = (html, vars) => {
|
|
69
|
+
const re = /<if\s+condition="([^"]+)">([\s\S]*?)<\/if>/g;
|
|
70
|
+
let result = html;
|
|
71
|
+
let prev;
|
|
72
|
+
do {
|
|
73
|
+
prev = result;
|
|
74
|
+
result = result.replace(re, (_, condition, inner) =>
|
|
75
|
+
evalCondition(condition, vars) ? inner : ''
|
|
76
|
+
);
|
|
77
|
+
} while(result !== prev);
|
|
78
|
+
return result;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/*
|
|
82
|
+
Foreach-Block Resolution
|
|
83
|
+
*/
|
|
84
|
+
const resolveForeach = (html, vars) => {
|
|
85
|
+
const re = /<foreach\s+in="([^"]+)"\s+as="([^"]+)">([\s\S]*?)<\/foreach>/g;
|
|
86
|
+
let result = html;
|
|
87
|
+
let prev;
|
|
88
|
+
do {
|
|
89
|
+
prev = result;
|
|
90
|
+
result = result.replace(re, (_, inAttr, asAttr, inner) => {
|
|
91
|
+
const arr = resolvePath(vars, inAttr.trim());
|
|
92
|
+
if(!Array.isArray(arr)) return '';
|
|
93
|
+
return arr.map(item => {
|
|
94
|
+
const scopedVars = {...vars, [asAttr]: item};
|
|
95
|
+
return resolveVars(inner, scopedVars);
|
|
96
|
+
}).join('');
|
|
97
|
+
});
|
|
98
|
+
} while(result !== prev);
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
Fragment Tag Resolution
|
|
104
|
+
*/
|
|
105
|
+
const resolveFragmentTags = (html, findFragmentFile, depth, maxDepth) => {
|
|
106
|
+
if(depth > maxDepth) throw new Error(`Fragment depth exceeded maximum of ${maxDepth}`);
|
|
107
|
+
const re = /<fragment\s+name="([^"]+)"(?:\s*\/>|>([\s\S]*?)<\/fragment>)/g;
|
|
108
|
+
return html.replace(re, (_, name, fallback) => {
|
|
109
|
+
const content = findFragmentFile(name);
|
|
110
|
+
if(content === null) return fallback ?? '';
|
|
111
|
+
const stripped = stripFragmentWrapper(content);
|
|
112
|
+
return resolveFragmentTags(stripped, findFragmentFile, depth + 1, maxDepth);
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/*
|
|
117
|
+
Condition Evaluator — Mini Expression Parser
|
|
118
|
+
*/
|
|
119
|
+
const TOKEN_TYPES = {
|
|
120
|
+
NUMBER: 'NUMBER',
|
|
121
|
+
STRING: 'STRING',
|
|
122
|
+
BOOLEAN: 'BOOLEAN',
|
|
123
|
+
IDENTIFIER: 'IDENTIFIER',
|
|
124
|
+
OPERATOR: 'OPERATOR',
|
|
125
|
+
NOT: 'NOT',
|
|
126
|
+
LPAREN: 'LPAREN',
|
|
127
|
+
RPAREN: 'RPAREN'
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const tokenize = expr => {
|
|
131
|
+
const tokens = [];
|
|
132
|
+
let i = 0;
|
|
133
|
+
while(i < expr.length){
|
|
134
|
+
if(/\s/.test(expr[i])){
|
|
135
|
+
i++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if(expr[i] === '('){
|
|
139
|
+
tokens.push({type: TOKEN_TYPES.LPAREN});
|
|
140
|
+
i++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if(expr[i] === ')'){
|
|
144
|
+
tokens.push({type: TOKEN_TYPES.RPAREN});
|
|
145
|
+
i++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if(expr[i] === '!' && expr[i + 1] !== '='){
|
|
149
|
+
tokens.push({type: TOKEN_TYPES.NOT});
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const opMatch = expr.slice(i).match(/^(===|!==|>=|<=|&&|\|\||>|<)/);
|
|
154
|
+
if(opMatch){
|
|
155
|
+
tokens.push({type: TOKEN_TYPES.OPERATOR, value: opMatch[1]});
|
|
156
|
+
i += opMatch[1].length;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if(expr[i] === '"' || expr[i] === "'"){
|
|
160
|
+
const quote = expr[i];
|
|
161
|
+
let str = '';
|
|
162
|
+
i++;
|
|
163
|
+
while(i < expr.length && expr[i] !== quote){
|
|
164
|
+
str += expr[i];
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
if(i >= expr.length) throw new Error(`Unterminated string in condition: ${expr}`);
|
|
168
|
+
i++;
|
|
169
|
+
tokens.push({type: TOKEN_TYPES.STRING, value: str});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const numMatch = expr.slice(i).match(/^(\d+(\.\d+)?)/);
|
|
173
|
+
if(numMatch){
|
|
174
|
+
tokens.push({type: TOKEN_TYPES.NUMBER, value: Number(numMatch[1])});
|
|
175
|
+
i += numMatch[1].length;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const idMatch = expr.slice(i).match(/^([a-zA-Z_$][\w$.]*)/);
|
|
179
|
+
if(idMatch){
|
|
180
|
+
const id = idMatch[1];
|
|
181
|
+
if(id === 'true' || id === 'false'){
|
|
182
|
+
tokens.push({type: TOKEN_TYPES.BOOLEAN, value: id === 'true'});
|
|
183
|
+
} else {
|
|
184
|
+
tokens.push({type: TOKEN_TYPES.IDENTIFIER, value: id});
|
|
185
|
+
}
|
|
186
|
+
i += id.length;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`Unexpected character '${expr[i]}' in condition: ${expr}`);
|
|
190
|
+
}
|
|
191
|
+
return tokens;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const parse = (tokens, vars) => {
|
|
195
|
+
let pos = 0;
|
|
196
|
+
|
|
197
|
+
const peek = () => tokens[pos];
|
|
198
|
+
const advance = () => tokens[pos++];
|
|
199
|
+
|
|
200
|
+
const parsePrimary = () => {
|
|
201
|
+
const tok = peek();
|
|
202
|
+
if(!tok) throw new Error('Unexpected end of expression');
|
|
203
|
+
if(tok.type === TOKEN_TYPES.NOT){
|
|
204
|
+
advance();
|
|
205
|
+
return !parsePrimary();
|
|
206
|
+
}
|
|
207
|
+
if(tok.type === TOKEN_TYPES.LPAREN){
|
|
208
|
+
advance();
|
|
209
|
+
const val = parseOr();
|
|
210
|
+
if(!peek() || peek().type !== TOKEN_TYPES.RPAREN){
|
|
211
|
+
throw new Error('Missing closing parenthesis');
|
|
212
|
+
}
|
|
213
|
+
advance();
|
|
214
|
+
return val;
|
|
215
|
+
}
|
|
216
|
+
if(tok.type === TOKEN_TYPES.NUMBER || tok.type === TOKEN_TYPES.STRING || tok.type === TOKEN_TYPES.BOOLEAN){
|
|
217
|
+
advance();
|
|
218
|
+
return tok.value;
|
|
219
|
+
}
|
|
220
|
+
if(tok.type === TOKEN_TYPES.IDENTIFIER){
|
|
221
|
+
advance();
|
|
222
|
+
return resolvePath(vars, tok.value);
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`Unexpected token: ${JSON.stringify(tok)}`);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const parseComparison = () => {
|
|
228
|
+
let left = parsePrimary();
|
|
229
|
+
while(peek() && peek().type === TOKEN_TYPES.OPERATOR && ['===', '!==', '>', '<', '>=', '<='].includes(peek().value)){
|
|
230
|
+
const op = advance().value;
|
|
231
|
+
const right = parsePrimary();
|
|
232
|
+
switch(op){
|
|
233
|
+
case '===': left = left === right; break;
|
|
234
|
+
case '!==': left = left !== right; break;
|
|
235
|
+
case '>': left = left > right; break;
|
|
236
|
+
case '<': left = left < right; break;
|
|
237
|
+
case '>=': left = left >= right; break;
|
|
238
|
+
case '<=': left = left <= right; break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return left;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const parseAnd = () => {
|
|
245
|
+
let left = parseComparison();
|
|
246
|
+
while(peek() && peek().type === TOKEN_TYPES.OPERATOR && peek().value === '&&'){
|
|
247
|
+
advance();
|
|
248
|
+
const right = parseComparison();
|
|
249
|
+
left = left && right;
|
|
250
|
+
}
|
|
251
|
+
return left;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const parseOr = () => {
|
|
255
|
+
let left = parseAnd();
|
|
256
|
+
while(peek() && peek().type === TOKEN_TYPES.OPERATOR && peek().value === '||'){
|
|
257
|
+
advance();
|
|
258
|
+
const right = parseAnd();
|
|
259
|
+
left = left || right;
|
|
260
|
+
}
|
|
261
|
+
return left;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const result = parseOr();
|
|
265
|
+
if(pos < tokens.length) throw new Error(`Unexpected token after expression: ${JSON.stringify(tokens[pos])}`);
|
|
266
|
+
return result;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const evalCondition = (expression, vars) => {
|
|
270
|
+
const tokens = tokenize(expression);
|
|
271
|
+
return !!parse(tokens, vars);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export {
|
|
275
|
+
extractAttrs,
|
|
276
|
+
extractContentBlocks,
|
|
277
|
+
replaceLocations,
|
|
278
|
+
stripFragmentWrapper,
|
|
279
|
+
resolveVars,
|
|
280
|
+
resolveIfs,
|
|
281
|
+
resolveForeach,
|
|
282
|
+
resolveFragmentTags,
|
|
283
|
+
evalCondition,
|
|
284
|
+
resolvePath
|
|
285
|
+
};
|
|
@@ -9,8 +9,8 @@ const createTempConfig = async (config) => {
|
|
|
9
9
|
const tempDir = path.join(process.cwd(), 'tests', 'temp-config-test');
|
|
10
10
|
await mkdir(tempDir, { recursive: true });
|
|
11
11
|
|
|
12
|
-
const configPath = path.join(tempDir, 'test.config.
|
|
13
|
-
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
12
|
+
const configPath = path.join(tempDir, 'test.config.js');
|
|
13
|
+
await writeFile(configPath, `export default ${JSON.stringify(config, null, 2)}`);
|
|
14
14
|
|
|
15
15
|
return { tempDir, configPath };
|
|
16
16
|
};
|