kempo-server 2.2.0 → 3.0.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.
Files changed (62) hide show
  1. package/CONFIG.md +295 -187
  2. package/README.md +5 -4
  3. package/SPA.md +14 -14
  4. package/dist/defaultConfig.js +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/render.js +2 -0
  7. package/dist/router.js +1 -1
  8. package/dist/serveFile.js +1 -1
  9. package/dist/templating/index.js +1 -0
  10. package/dist/templating/parse.js +1 -0
  11. package/docs/caching.html +103 -17
  12. package/docs/cli-utils.html +102 -16
  13. package/docs/configuration.html +104 -17
  14. package/docs/examples.html +104 -17
  15. package/docs/fs-utils.html +102 -16
  16. package/docs/getting-started.html +104 -17
  17. package/docs/index.html +176 -81
  18. package/docs/middleware.html +104 -17
  19. package/docs/request-response.html +104 -17
  20. package/docs/routing.html +104 -17
  21. package/docs/templating.html +292 -0
  22. package/docs-src/.config.js +11 -0
  23. package/docs-src/caching.page.html +220 -0
  24. package/docs-src/cli-utils.page.html +71 -0
  25. package/docs-src/configuration.page.html +310 -0
  26. package/docs-src/default.template.html +35 -0
  27. package/docs-src/examples.page.html +192 -0
  28. package/docs-src/fs-utils.page.html +102 -0
  29. package/docs-src/getting-started.page.html +63 -0
  30. package/docs-src/index.page.html +79 -0
  31. package/docs-src/middleware.page.html +133 -0
  32. package/docs-src/nav.fragment.html +73 -0
  33. package/docs-src/request-response.page.html +96 -0
  34. package/docs-src/routing.page.html +73 -0
  35. package/docs-src/templating.page.html +188 -0
  36. package/llms.txt +97 -31
  37. package/package.json +5 -2
  38. package/scripts/build.js +22 -1
  39. package/scripts/render.js +58 -0
  40. package/src/defaultConfig.js +14 -2
  41. package/src/index.js +1 -1
  42. package/src/router.js +69 -10
  43. package/src/serveFile.js +27 -0
  44. package/src/templating/index.js +132 -0
  45. package/src/templating/parse.js +285 -0
  46. package/tests/cacheConfig.node-test.js +2 -2
  47. package/tests/config-flag.node-test.js +61 -25
  48. package/tests/customRoute-outside-root.node-test.js +1 -1
  49. package/tests/router-wildcard.node-test.js +47 -2
  50. package/tests/templating-parse.node-test.js +243 -0
  51. package/tests/templating-render.node-test.js +188 -0
  52. package/tests/utils/test-scenario.js +4 -4
  53. package/docs/.config.json.example +0 -29
  54. package/docs/api/_admin/cache/DELETE.js +0 -28
  55. package/docs/api/_admin/cache/GET.js +0 -53
  56. package/docs/api/user/[id]/GET.js +0 -15
  57. package/docs/api/user/[id]/[info]/DELETE.js +0 -12
  58. package/docs/api/user/[id]/[info]/GET.js +0 -17
  59. package/docs/api/user/[id]/[info]/POST.js +0 -18
  60. package/docs/api/user/[id]/[info]/PUT.js +0 -19
  61. package/docs/init.js +0 -2
  62. package/docs/nav.inc.html +0 -70
package/src/router.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  loggingMiddleware
18
18
  } from './builtinMiddleware.js';
19
19
  import { onRescan } from './rescan.js';
20
+ import { renderDir } from './templating/index.js';
20
21
 
21
22
  export default async (flags, log) => {
22
23
  log('Initializing router', 3);
@@ -25,8 +26,7 @@ export default async (flags, log) => {
25
26
 
26
27
  let config = defaultConfig;
27
28
  try {
28
- // Use the provided config path or fallback to .config.json in rootPath
29
- const configFileName = flags.config || '.config.json';
29
+ const configFileName = flags.config || '.config.js';
30
30
  const configPath = path.isAbsolute(configFileName)
31
31
  ? configFileName
32
32
  : path.join(rootPath, configFileName);
@@ -50,12 +50,19 @@ export default async (flags, log) => {
50
50
  }
51
51
 
52
52
  log(`Loading config from: ${configPath}`, 3);
53
- const configContent = await readFile(configPath, 'utf8');
54
- const userConfig = JSON.parse(configContent);
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');
55
63
  config = {
56
64
  ...defaultConfig,
57
65
  ...userConfig,
58
- // Deep merge nested objects
59
66
  allowedMimes: {
60
67
  ...defaultConfig.allowedMimes,
61
68
  ...(userConfig.allowedMimes || {})
@@ -71,16 +78,59 @@ export default async (flags, log) => {
71
78
  cache: {
72
79
  ...defaultConfig.cache,
73
80
  ...(userConfig.cache || {})
81
+ },
82
+ templating: {
83
+ ...defaultConfig.templating,
84
+ ...(userConfig.templating || {})
74
85
  }
75
86
  };
76
87
  log('User config loaded and merged with defaults', 3);
77
88
  } catch (e){
78
- // Only fall back to default config for file reading/parsing errors
79
- // Let validation errors propagate up
80
89
  if (e.message.includes('Config file must be within the server root directory')) {
81
90
  throw e;
82
91
  }
83
- log('Using default config (no config file found)', 3);
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
+ }
84
134
  }
85
135
 
86
136
  /*
@@ -88,11 +138,18 @@ export default async (flags, log) => {
88
138
  */
89
139
  const dis = new Set(config.disallowedRegex);
90
140
  dis.add("^/\\..*");
91
- dis.add("\\.config$");
141
+ dis.add("\\.config\\.js$");
142
+ dis.add("\\.config\\.json$");
92
143
  dis.add("\\.git/");
93
144
  config.disallowedRegex = [...dis];
94
145
  log(`Config loaded with ${config.disallowedRegex.length} disallowed patterns`, 3);
95
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
+
96
153
  let files = await getFiles(rootPath, config, log);
97
154
  log(`Initial scan found ${files.length} files`, 2);
98
155
 
@@ -194,9 +251,11 @@ export default async (flags, log) => {
194
251
 
195
252
  // Helper function to match wildcard patterns
196
253
  const matchWildcardRoute = (requestPath, pattern) => {
254
+ // Normalize pattern to ensure leading slash
255
+ const normalizedPattern = pattern.startsWith('/') ? pattern : '/' + pattern;
197
256
  // Convert wildcard pattern to regex
198
257
  // IMPORTANT: Replace ** BEFORE * to avoid replacing both * in **
199
- const regexPattern = pattern
258
+ const regexPattern = normalizedPattern
200
259
  .replace(/\*\*/g, '(.+)') // Replace ** with capture group for multiple segments
201
260
  .replace(/\*/g, '([^/]+)'); // Replace * with capture group for single segment
202
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.json');
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
  };