kempo-server 2.1.0 → 2.2.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/README.md +26 -0
- package/UTILS.md +39 -0
- package/dist/rescan.js +1 -0
- package/dist/router.js +1 -1
- package/docs/caching.html +8 -5
- package/docs/cli-utils.html +8 -6
- package/docs/configuration.html +7 -4
- package/docs/examples.html +12 -4
- package/docs/fs-utils.html +8 -6
- package/docs/getting-started.html +7 -4
- package/docs/index.html +3 -3
- package/docs/init.js +2 -0
- package/docs/middleware.html +7 -4
- package/docs/nav.inc.html +42 -13
- package/docs/request-response.html +7 -4
- package/docs/routing.html +9 -6
- package/docs/theme.css +1 -3
- package/{llm.txt → llms.txt} +4 -0
- package/package.json +2 -1
- package/scripts/build.js +0 -13
- package/src/rescan.js +14 -0
- package/src/router.js +88 -23
- package/tests/rescan.node-test.js +69 -0
- package/tests/router-custom-route-dirs.node-test.js +121 -0
- package/docs/kempo.min.css +0 -1
- package/docs/nav.inc.js +0 -16
package/README.md
CHANGED
|
@@ -238,6 +238,7 @@ kempo-server --root public --config dev.config.json
|
|
|
238
238
|
- **Configurable** - Customize behavior with a simple JSON config file
|
|
239
239
|
- **Security** - Built-in protection against serving sensitive files plus security headers middleware
|
|
240
240
|
- **Performance** - Smart file system caching, rescan optimization, and optional compression
|
|
241
|
+
- **Programmatic Rescan** - Trigger a file rescan from anywhere in the Node process without restarting
|
|
241
242
|
|
|
242
243
|
## Examples
|
|
243
244
|
|
|
@@ -313,6 +314,30 @@ export default async function(request, response) {
|
|
|
313
314
|
}
|
|
314
315
|
```
|
|
315
316
|
|
|
317
|
+
## Programmatic File Rescan
|
|
318
|
+
|
|
319
|
+
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:
|
|
320
|
+
|
|
321
|
+
```javascript
|
|
322
|
+
import rescan from 'kempo-server/rescan';
|
|
323
|
+
|
|
324
|
+
// Returns a promise that resolves with the new file count
|
|
325
|
+
const fileCount = await rescan();
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
This works from anywhere in the same Node process — route handlers, middleware, background tasks, file watchers, or any other code running alongside the server.
|
|
329
|
+
|
|
330
|
+
```javascript
|
|
331
|
+
// Example: CMS generates a page and makes it immediately available
|
|
332
|
+
import { writeFile } from 'fs/promises';
|
|
333
|
+
import rescan from 'kempo-server/rescan';
|
|
334
|
+
|
|
335
|
+
const html = buildPage(theme, template, content);
|
|
336
|
+
await writeFile('./public/new-page.html', html);
|
|
337
|
+
await rescan();
|
|
338
|
+
// New page is now live
|
|
339
|
+
```
|
|
340
|
+
|
|
316
341
|
## Command Line Options
|
|
317
342
|
|
|
318
343
|
Kempo Server supports several command line options to customize its behavior:
|
|
@@ -367,6 +392,7 @@ See **[SPA.md](./SPA.md)** for a full walkthrough.
|
|
|
367
392
|
- **[Caching](./docs/caching.html)** - Cache configuration and management
|
|
368
393
|
- **[CLI Utilities](./docs/cli-utils.html)** - Command-line argument parsing
|
|
369
394
|
- **[File System Utilities](./docs/fs-utils.html)** - File and directory operations
|
|
395
|
+
- **[UTILS.md](./UTILS.md)** - Utility modules including rescan, CLI, and file system helpers
|
|
370
396
|
- **[Examples](./docs/examples.html)** - Interactive examples and demos
|
|
371
397
|
- **[CONFIG.md](./CONFIG.md)** - Comprehensive server configuration guide
|
|
372
398
|
- **[UTILS.md](./UTILS.md)** - Utility modules for Node.js projects
|
package/UTILS.md
CHANGED
|
@@ -111,6 +111,44 @@ async function backupProject() {
|
|
|
111
111
|
}
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
+
## Rescan
|
|
115
|
+
|
|
116
|
+
The rescan utility lets you programmatically trigger a file rescan on the running server without restarting. This is useful when files are created or removed at runtime, such as a CMS generating static HTML pages.
|
|
117
|
+
|
|
118
|
+
### Usage
|
|
119
|
+
|
|
120
|
+
```javascript
|
|
121
|
+
import rescan from 'kempo-server/rescan';
|
|
122
|
+
|
|
123
|
+
const fileCount = await rescan();
|
|
124
|
+
console.log(`Server now serving ${fileCount} files`);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The function returns a promise that resolves with the total number of files found in the new scan. It works from anywhere in the same Node process as the server — route handlers, middleware, scheduled tasks, file watchers, or any other code.
|
|
128
|
+
|
|
129
|
+
### Example: CMS Page Generation
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
import { writeFile } from 'fs/promises';
|
|
133
|
+
import rescan from 'kempo-server/rescan';
|
|
134
|
+
|
|
135
|
+
async function publishPage(theme, template, content, slug) {
|
|
136
|
+
const html = buildPage(theme, template, content);
|
|
137
|
+
await writeFile(`./public/${slug}.html`, html);
|
|
138
|
+
await rescan();
|
|
139
|
+
// Page is immediately available at /{slug}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Example: File Watcher
|
|
144
|
+
|
|
145
|
+
```javascript
|
|
146
|
+
import { watch } from 'fs';
|
|
147
|
+
import rescan from 'kempo-server/rescan';
|
|
148
|
+
|
|
149
|
+
watch('./content', { recursive: true }, () => rescan());
|
|
150
|
+
```
|
|
151
|
+
|
|
114
152
|
## Installation and Build
|
|
115
153
|
|
|
116
154
|
These utilities are automatically built when you install kempo-server. They're available through the package's exports:
|
|
@@ -118,6 +156,7 @@ These utilities are automatically built when you install kempo-server. They're a
|
|
|
118
156
|
```json
|
|
119
157
|
{
|
|
120
158
|
"exports": {
|
|
159
|
+
"./rescan": "./dist/rescan.js",
|
|
121
160
|
"./utils/cli": "./dist/utils/cli.js",
|
|
122
161
|
"./utils/fs-utils": "./dist/utils/fs-utils.js"
|
|
123
162
|
}
|
package/dist/rescan.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{EventEmitter}from"events";const emitter=new EventEmitter;export const onRescan=callback=>{emitter.on("rescan",callback)};export default()=>new Promise((resolve,reject)=>{emitter.emit("rescan",(error,fileCount)=>{error?reject(error):resolve(fileCount)})});
|
package/dist/router.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import path from"path";import{readFile,stat}from"fs/promises";import{pathToFileURL}from"url";import defaultConfig from"./defaultConfig.js";import getFiles from"./getFiles.js";import findFile from"./findFile.js";import serveFile from"./serveFile.js";import MiddlewareRunner from"./middlewareRunner.js";import ModuleCache from"./moduleCache.js";import createRequestWrapper,{readRawBody,parseBody}from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.js";import{corsMiddleware,compressionMiddleware,rateLimitMiddleware,securityMiddleware,loggingMiddleware}from"./builtinMiddleware.js";export default async(flags,log)=>{log("Initializing router",3);const rootPath=path.isAbsolute(flags.root)?flags.root:path.join(process.cwd(),flags.root);log(`Root path: ${rootPath}`,3);let config=defaultConfig;try{const configFileName=flags.config||".config.json",configPath=path.isAbsolute(configFileName)?configFileName:path.join(rootPath,configFileName);if(log(`Config file name: ${configFileName}`,3),log(`Config path: ${configPath}`,3),path.isAbsolute(configFileName))log("Config file name is absolute, skipping validation",4);else{const relativeConfigPath=path.relative(rootPath,configPath);if(log(`Relative config path: ${relativeConfigPath}`,4),log(`Starts with '..': ${relativeConfigPath.startsWith("..")}`,4),relativeConfigPath.startsWith("..")||path.isAbsolute(relativeConfigPath))throw log("Validation failed - throwing error",4),new Error(`Config file must be within the server root directory. Config path: ${configPath}, Root path: ${rootPath}`);log("Validation passed",4)}log(`Loading config from: ${configPath}`,3);const configContent=await readFile(configPath,"utf8"),userConfig=JSON.parse(configContent);config={...defaultConfig,...userConfig,allowedMimes:{...defaultConfig.allowedMimes,...userConfig.allowedMimes||{}},middleware:{...defaultConfig.middleware,...userConfig.middleware||{}},customRoutes:{...defaultConfig.customRoutes,...userConfig.customRoutes||{}},cache:{...defaultConfig.cache,...userConfig.cache||{}}},log("User config loaded and merged with defaults",3)}catch(e){if(e.message.includes("Config file must be within the server root directory"))throw e;log("Using default config (no config file found)",3)}const dis=new Set(config.disallowedRegex);dis.add("^/\\..*"),dis.add("\\.config$"),dis.add("\\.git/"),config.disallowedRegex=[...dis],log(`Config loaded with ${config.disallowedRegex.length} disallowed patterns`,3);let files=await getFiles(rootPath,config,log);log(`Initial scan found ${files.length} files`,2);const middlewareRunner=new MiddlewareRunner;if(config.middleware?.cors?.enabled&&(middlewareRunner.use(corsMiddleware(config.middleware.cors)),log("CORS middleware enabled",3)),config.middleware?.compression?.enabled&&(middlewareRunner.use(compressionMiddleware(config.middleware.compression)),log("Compression middleware enabled",3)),config.middleware?.rateLimit?.enabled&&(middlewareRunner.use(rateLimitMiddleware(config.middleware.rateLimit)),log("Rate limit middleware enabled",3)),config.middleware?.security?.enabled&&(middlewareRunner.use(securityMiddleware(config.middleware.security)),log("Security middleware enabled",3)),config.middleware?.logging?.enabled&&(middlewareRunner.use(loggingMiddleware(config.middleware.logging,log)),log("Logging middleware enabled",3)),config.middleware?.custom&&config.middleware.custom.length>0){log(`Loading ${config.middleware.custom.length} custom middleware files`,3);for(const middlewarePath of config.middleware.custom)try{const resolvedPath=path.isAbsolute(middlewarePath)?middlewarePath:path.resolve(rootPath,middlewarePath),middlewareUrl=pathToFileURL(resolvedPath).href+`?t=${Date.now()}`,customMiddleware=(await import(middlewareUrl)).default;"function"==typeof customMiddleware?(middlewareRunner.use(customMiddleware(config.middleware)),log(`Custom middleware loaded: ${middlewarePath}`,3)):log(`Custom middleware error: ${middlewarePath} does not export a default function`,1)}catch(error){log(`Custom middleware error for ${middlewarePath}: ${error.message}`,1)}}let moduleCache=null;config.cache?.enabled&&(moduleCache=new ModuleCache(config.cache),log(`Module cache initialized: ${config.cache.maxSize} max modules, ${config.cache.maxMemoryMB}MB limit, ${config.cache.ttlMs}ms TTL`,2));const customRoutes=new Map,wildcardRoutes=new Map;if(config.customRoutes&&Object.keys(config.customRoutes).length>0){log(`Processing ${Object.keys(config.customRoutes).length} custom routes`,3);for(const[urlPath,filePath]of Object.entries(config.customRoutes))if(urlPath.includes("*")){const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);wildcardRoutes.set(urlPath,resolvedPath),log(`Wildcard route mapped: ${urlPath} -> ${resolvedPath}`,3)}else{const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);customRoutes.set(urlPath,resolvedPath),log(`Custom route mapped: ${urlPath} -> ${resolvedPath}`,3)}}const matchWildcardRoute=(requestPath,pattern)=>{const regexPattern=pattern.replace(/\*\*/g,"(.+)").replace(/\*/g,"([^/]+)");return new RegExp(`^${regexPattern}$`).exec(requestPath)},serveStaticCustomFile=async(filePath,res)=>{const fileExtension=path.extname(filePath).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(filePath,encoding);log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;res.writeHead(200,{"Content-Type":contentType}),res.end(fileContent)},executeRouteModule=async(filePath,req,res)=>{let module;if(moduleCache&&config.cache?.enabled){const fileStats=await stat(filePath);if(module=moduleCache.get(filePath,fileStats),!module){const fileUrl=pathToFileURL(filePath).href+`?t=${Date.now()}`;module=await import(fileUrl);const estimatedSizeKB=fileStats.size/1024;moduleCache.set(filePath,module,fileStats,estimatedSizeKB)}}else{const fileUrl=pathToFileURL(filePath).href+`?t=${Date.now()}`;module=await import(fileUrl)}if("function"!=typeof module.default)return log(`Route file does not export a function: ${filePath}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Route file does not export a function");const enhancedReq=createRequestWrapper(req,{}),enhancedRes=createResponseWrapper(res),rawBody=await readRawBody(req);enhancedReq._rawBody=rawBody,enhancedReq.body=parseBody(rawBody,req.headers["content-type"]),moduleCache&&(enhancedReq._kempoCache=moduleCache),await module.default(enhancedReq,enhancedRes)},serveCustomRoutePath=async(resolvedFilePath,req,res)=>{let fileStat;try{fileStat=await stat(resolvedFilePath)}catch(e){if("ENOENT"===e.code)return null;throw e}if(fileStat.isDirectory()){const methodUpper=req.method.toUpperCase(),candidates=[`${methodUpper}.js`,`${methodUpper}.html`,"index.js","index.html","index.htm"];for(const candidate of candidates){const candidatePath=path.join(resolvedFilePath,candidate);try{await stat(candidatePath)}catch{continue}return config.routeFiles.includes(candidate)?(log(`Executing route file: ${candidatePath}`,2),await executeRouteModule(candidatePath,req,res),!0):(log(`Serving index file: ${candidatePath}`,2),await serveStaticCustomFile(candidatePath,res),!0)}return null}const fileName=path.basename(resolvedFilePath);return config.routeFiles.includes(fileName)?(log(`Executing route file: ${resolvedFilePath}`,2),await executeRouteModule(resolvedFilePath,req,res),!0):(await serveStaticCustomFile(resolvedFilePath,res),!0)},rescanAttempts=new Map,dynamicNoRescanPaths=new Set,shouldSkipRescan=requestPath=>config.noRescanPaths.some(pattern=>new RegExp(pattern).test(requestPath))?(log(`Skipping rescan for configured pattern: ${requestPath}`,3),!0):!!dynamicNoRescanPaths.has(requestPath)&&(log(`Skipping rescan for dynamically blacklisted path: ${requestPath}`,3),!0),handler=async(req,res)=>{if(parseInt(req.headers["content-length"]||"0",10)>config.maxBodySize)return res.writeHead(413,{"Content-Type":"text/plain"}),void res.end("Payload Too Large");const rawBody=await new Promise((resolve,reject)=>{if(["GET","HEAD"].includes(req.method)&&!req.headers["content-length"])return resolve("");let body="",size=0;req.on("data",chunk=>{if(size+=chunk.length,size>config.maxBodySize)return req.destroy(),void reject(new Error("Payload Too Large"));body+=chunk.toString()}),req.on("end",()=>resolve(body)),req.on("error",reject)}).catch(err=>{if("Payload Too Large"===err.message)return res.writeHead(413,{"Content-Type":"text/plain"}),res.end("Payload Too Large"),null;throw err});if(null===rawBody)return;req._bufferedBody=rawBody;const enhancedRequest=createRequestWrapper(req,{}),enhancedResponse=createResponseWrapper(res);enhancedRequest._rawBody=rawBody,enhancedRequest.body=parseBody(rawBody,req.headers["content-type"]),await middlewareRunner.run(enhancedRequest,enhancedResponse,async()=>{const requestPath=enhancedRequest.url.split("?")[0];log(`${enhancedRequest.method} ${requestPath}`,4),log(`customRoutes keys: ${Array.from(customRoutes.keys()).join(", ")}`,4);const normalizePath=p=>{try{let np=decodeURIComponent(p);return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}catch(e){log(`Warning: Failed to decode URI component "${p}": ${e.message}`,1);let np=p;return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}},normalizedRequestPath=normalizePath(requestPath);log(`Normalized requestPath: ${normalizedRequestPath}`,4);let matchedKey=null;for(const key of customRoutes.keys())if(normalizePath(key)===normalizedRequestPath){matchedKey=key;break}if(matchedKey){const customFilePath=customRoutes.get(matchedKey);log(`Serving custom route: ${normalizedRequestPath} -> ${customFilePath}`,3);try{if(await serveCustomRoutePath(customFilePath,req,res))return;return log(`Custom route path not found: ${customFilePath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),void res.end("Custom route file not found")}catch(error){return log(`Error serving custom route ${normalizedRequestPath}: ${error.message}`,1),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Internal Server Error")}}const wildcardMatch=(requestPath=>{for(const[pattern,filePath]of wildcardRoutes){const matches=matchWildcardRoute(requestPath,pattern);if(matches)return{filePath:filePath,matches:matches}}return null})(requestPath);if(wildcardMatch){const resolvedFilePath=((filePath,matches)=>{let resolvedPath=filePath,matchIndex=1;for(;resolvedPath.includes("**")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("**",matches[matchIndex]),matchIndex++;for(;resolvedPath.includes("*")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("*",matches[matchIndex]),matchIndex++;return path.isAbsolute(resolvedPath)?resolvedPath:path.resolve(rootPath,resolvedPath)})(wildcardMatch.filePath,wildcardMatch.matches);log(`Serving wildcard route: ${requestPath} -> ${resolvedFilePath}`,3);try{if(await serveCustomRoutePath(resolvedFilePath,req,res))return;log(`Wildcard route path not found: ${requestPath}`,2)}catch(error){return log(`Error serving wildcard route ${requestPath}: ${error.message}`,1),enhancedResponse.writeHead(500,{"Content-Type":"text/plain"}),void enhancedResponse.end("Internal Server Error")}}const served=await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache);if(!served&&config.maxRescanAttempts>0&&!shouldSkipRescan(requestPath)){log("File not found, rescanning directory...",1),files=await getFiles(rootPath,config,log),log(`Rescan found ${files.length} files`,2);await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache)?rescanAttempts.delete(requestPath):((requestPath=>{const newAttempts=(rescanAttempts.get(requestPath)||0)+1;rescanAttempts.set(requestPath,newAttempts),newAttempts>config.maxRescanAttempts&&(dynamicNoRescanPaths.add(requestPath),log(`Path ${requestPath} added to dynamic blacklist after ${newAttempts} failed attempts`,1)),log(`Rescan attempt ${newAttempts}/${config.maxRescanAttempts} for: ${requestPath}`,3)})(requestPath),log(`404 - File not found after rescan: ${requestPath}`,1),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found"))}else served||(shouldSkipRescan(requestPath)?log(`404 - Skipped rescan for: ${requestPath}`,2):log(`404 - File not found: ${requestPath}`,1),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found"))})};return handler.moduleCache=moduleCache,handler.getStats=()=>moduleCache?.getStats()||null,handler.logCacheStats=()=>moduleCache?.logStats(log),handler.clearCache=()=>moduleCache?.clear(),handler};
|
|
1
|
+
import path from"path";import{readFile,stat,readdir}from"fs/promises";import{pathToFileURL}from"url";import defaultConfig from"./defaultConfig.js";import getFiles from"./getFiles.js";import findFile from"./findFile.js";import serveFile from"./serveFile.js";import MiddlewareRunner from"./middlewareRunner.js";import ModuleCache from"./moduleCache.js";import createRequestWrapper,{readRawBody,parseBody}from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.js";import{corsMiddleware,compressionMiddleware,rateLimitMiddleware,securityMiddleware,loggingMiddleware}from"./builtinMiddleware.js";import{onRescan}from"./rescan.js";export default async(flags,log)=>{log("Initializing router",3);const rootPath=path.isAbsolute(flags.root)?flags.root:path.join(process.cwd(),flags.root);log(`Root path: ${rootPath}`,3);let config=defaultConfig;try{const configFileName=flags.config||".config.json",configPath=path.isAbsolute(configFileName)?configFileName:path.join(rootPath,configFileName);if(log(`Config file name: ${configFileName}`,3),log(`Config path: ${configPath}`,3),path.isAbsolute(configFileName))log("Config file name is absolute, skipping validation",4);else{const relativeConfigPath=path.relative(rootPath,configPath);if(log(`Relative config path: ${relativeConfigPath}`,4),log(`Starts with '..': ${relativeConfigPath.startsWith("..")}`,4),relativeConfigPath.startsWith("..")||path.isAbsolute(relativeConfigPath))throw log("Validation failed - throwing error",4),new Error(`Config file must be within the server root directory. Config path: ${configPath}, Root path: ${rootPath}`);log("Validation passed",4)}log(`Loading config from: ${configPath}`,3);const configContent=await readFile(configPath,"utf8"),userConfig=JSON.parse(configContent);config={...defaultConfig,...userConfig,allowedMimes:{...defaultConfig.allowedMimes,...userConfig.allowedMimes||{}},middleware:{...defaultConfig.middleware,...userConfig.middleware||{}},customRoutes:{...defaultConfig.customRoutes,...userConfig.customRoutes||{}},cache:{...defaultConfig.cache,...userConfig.cache||{}}},log("User config loaded and merged with defaults",3)}catch(e){if(e.message.includes("Config file must be within the server root directory"))throw e;log("Using default config (no config file found)",3)}const dis=new Set(config.disallowedRegex);dis.add("^/\\..*"),dis.add("\\.config$"),dis.add("\\.git/"),config.disallowedRegex=[...dis],log(`Config loaded with ${config.disallowedRegex.length} disallowed patterns`,3);let files=await getFiles(rootPath,config,log);log(`Initial scan found ${files.length} files`,2),onRescan(async done=>{try{files=await getFiles(rootPath,config,log),log(`Rescan found ${files.length} files`,2),done(null,files.length)}catch(error){log(`Rescan failed: ${error.message}`,1),done(error)}});const middlewareRunner=new MiddlewareRunner;if(config.middleware?.cors?.enabled&&(middlewareRunner.use(corsMiddleware(config.middleware.cors)),log("CORS middleware enabled",3)),config.middleware?.compression?.enabled&&(middlewareRunner.use(compressionMiddleware(config.middleware.compression)),log("Compression middleware enabled",3)),config.middleware?.rateLimit?.enabled&&(middlewareRunner.use(rateLimitMiddleware(config.middleware.rateLimit)),log("Rate limit middleware enabled",3)),config.middleware?.security?.enabled&&(middlewareRunner.use(securityMiddleware(config.middleware.security)),log("Security middleware enabled",3)),config.middleware?.logging?.enabled&&(middlewareRunner.use(loggingMiddleware(config.middleware.logging,log)),log("Logging middleware enabled",3)),config.middleware?.custom&&config.middleware.custom.length>0){log(`Loading ${config.middleware.custom.length} custom middleware files`,3);for(const middlewarePath of config.middleware.custom)try{const resolvedPath=path.isAbsolute(middlewarePath)?middlewarePath:path.resolve(rootPath,middlewarePath),middlewareUrl=pathToFileURL(resolvedPath).href+`?t=${Date.now()}`,customMiddleware=(await import(middlewareUrl)).default;"function"==typeof customMiddleware?(middlewareRunner.use(customMiddleware(config.middleware)),log(`Custom middleware loaded: ${middlewarePath}`,3)):log(`Custom middleware error: ${middlewarePath} does not export a default function`,1)}catch(error){log(`Custom middleware error for ${middlewarePath}: ${error.message}`,1)}}let moduleCache=null;config.cache?.enabled&&(moduleCache=new ModuleCache(config.cache),log(`Module cache initialized: ${config.cache.maxSize} max modules, ${config.cache.maxMemoryMB}MB limit, ${config.cache.ttlMs}ms TTL`,2));const customRoutes=new Map,wildcardRoutes=new Map;if(config.customRoutes&&Object.keys(config.customRoutes).length>0){log(`Processing ${Object.keys(config.customRoutes).length} custom routes`,3);for(const[urlPath,filePath]of Object.entries(config.customRoutes))if(urlPath.includes("*")){const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);wildcardRoutes.set(urlPath,resolvedPath),log(`Wildcard route mapped: ${urlPath} -> ${resolvedPath}`,3)}else{const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);customRoutes.set(urlPath,resolvedPath),log(`Custom route mapped: ${urlPath} -> ${resolvedPath}`,3)}}const matchWildcardRoute=(requestPath,pattern)=>{const regexPattern=pattern.replace(/\*\*/g,"(.+)").replace(/\*/g,"([^/]+)");return new RegExp(`^${regexPattern}$`).exec(requestPath)},serveStaticCustomFile=async(filePath,res)=>{const fileExtension=path.extname(filePath).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(filePath,encoding);log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;res.writeHead(200,{"Content-Type":contentType}),res.end(fileContent)},executeRouteModule=async(filePath,req,res,params={})=>{let module;if(moduleCache&&config.cache?.enabled){const fileStats=await stat(filePath);if(module=moduleCache.get(filePath,fileStats),!module){const fileUrl=pathToFileURL(filePath).href+`?t=${Date.now()}`;module=await import(fileUrl);const estimatedSizeKB=fileStats.size/1024;moduleCache.set(filePath,module,fileStats,estimatedSizeKB)}}else{const fileUrl=pathToFileURL(filePath).href+`?t=${Date.now()}`;module=await import(fileUrl)}if("function"!=typeof module.default)return log(`Route file does not export a function: ${filePath}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Route file does not export a function");const enhancedReq=createRequestWrapper(req,params),enhancedRes=createResponseWrapper(res),rawBody=await readRawBody(req);enhancedReq._rawBody=rawBody,enhancedReq.body=parseBody(rawBody,req.headers["content-type"]),moduleCache&&(enhancedReq._kempoCache=moduleCache),await module.default(enhancedReq,enhancedRes)},walkDynamic=async(base,segments)=>{if(0===segments.length)return{filePath:base,params:{}};const[head,...rest]=segments;let entries;try{entries=await readdir(base,{withFileTypes:!0})}catch{return null}for(const entry of entries)if(entry.name===head)if(entry.isDirectory()){const result=await walkDynamic(path.join(base,head),rest);if(result)return result}else if(entry.isFile()&&0===rest.length)return{filePath:path.join(base,head),params:{}};for(const entry of entries){if(!entry.isDirectory()||!entry.name.startsWith("[")||!entry.name.endsWith("]"))continue;const paramName=entry.name.slice(1,-1),result=await walkDynamic(path.join(base,entry.name),rest);if(result)return{filePath:result.filePath,params:{[paramName]:head,...result.params}}}return null},serveResolvedPath=async(filePath,fileStat,params,req,res)=>{if(fileStat.isDirectory()){const methodUpper=req.method.toUpperCase(),candidates=[`${methodUpper}.js`,`${methodUpper}.html`,"index.js","index.html","index.htm"];for(const candidate of candidates){const candidatePath=path.join(filePath,candidate);try{await stat(candidatePath)}catch{continue}return config.routeFiles.includes(candidate)?(log(`Executing route file: ${candidatePath}`,2),await executeRouteModule(candidatePath,req,res,params),!0):(log(`Serving index file: ${candidatePath}`,2),await serveStaticCustomFile(candidatePath,res),!0)}return null}const fileName=path.basename(filePath);return config.routeFiles.includes(fileName)?(log(`Executing route file: ${filePath}`,2),await executeRouteModule(filePath,req,res,params),!0):(await serveStaticCustomFile(filePath,res),!0)},serveCustomRoutePath=async(resolvedFilePath,req,res)=>{let fileStat;try{return fileStat=await stat(resolvedFilePath),await serveResolvedPath(resolvedFilePath,fileStat,{},req,res)}catch(e){if("ENOENT"!==e.code)throw e}let current=resolvedFilePath;const remaining=[];for(;current!==path.dirname(current);){remaining.unshift(path.basename(current)),current=path.dirname(current);try{if(!(await stat(current)).isDirectory())break;const result=await walkDynamic(current,remaining);if(!result)return null;const resolvedStat=await stat(result.filePath);return await serveResolvedPath(result.filePath,resolvedStat,result.params,req,res)}catch(e2){if("ENOENT"!==e2.code)throw e2}}return null},rescanAttempts=new Map,dynamicNoRescanPaths=new Set,shouldSkipRescan=requestPath=>config.noRescanPaths.some(pattern=>new RegExp(pattern).test(requestPath))?(log(`Skipping rescan for configured pattern: ${requestPath}`,3),!0):!!dynamicNoRescanPaths.has(requestPath)&&(log(`Skipping rescan for dynamically blacklisted path: ${requestPath}`,3),!0),handler=async(req,res)=>{if(parseInt(req.headers["content-length"]||"0",10)>config.maxBodySize)return res.writeHead(413,{"Content-Type":"text/plain"}),void res.end("Payload Too Large");const rawBody=await new Promise((resolve,reject)=>{if(["GET","HEAD"].includes(req.method)&&!req.headers["content-length"])return resolve("");let body="",size=0;req.on("data",chunk=>{if(size+=chunk.length,size>config.maxBodySize)return req.destroy(),void reject(new Error("Payload Too Large"));body+=chunk.toString()}),req.on("end",()=>resolve(body)),req.on("error",reject)}).catch(err=>{if("Payload Too Large"===err.message)return res.writeHead(413,{"Content-Type":"text/plain"}),res.end("Payload Too Large"),null;throw err});if(null===rawBody)return;req._bufferedBody=rawBody;const enhancedRequest=createRequestWrapper(req,{}),enhancedResponse=createResponseWrapper(res);enhancedRequest._rawBody=rawBody,enhancedRequest.body=parseBody(rawBody,req.headers["content-type"]),await middlewareRunner.run(enhancedRequest,enhancedResponse,async()=>{const requestPath=enhancedRequest.url.split("?")[0];log(`${enhancedRequest.method} ${requestPath}`,4),log(`customRoutes keys: ${Array.from(customRoutes.keys()).join(", ")}`,4);const normalizePath=p=>{try{let np=decodeURIComponent(p);return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}catch(e){log(`Warning: Failed to decode URI component "${p}": ${e.message}`,1);let np=p;return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}},normalizedRequestPath=normalizePath(requestPath);log(`Normalized requestPath: ${normalizedRequestPath}`,4);let matchedKey=null;for(const key of customRoutes.keys())if(normalizePath(key)===normalizedRequestPath){matchedKey=key;break}if(matchedKey){const customFilePath=customRoutes.get(matchedKey);log(`Serving custom route: ${normalizedRequestPath} -> ${customFilePath}`,3);try{if(await serveCustomRoutePath(customFilePath,req,res))return;return log(`Custom route path not found: ${customFilePath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),void res.end("Custom route file not found")}catch(error){return log(`Error serving custom route ${normalizedRequestPath}: ${error.message}`,1),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Internal Server Error")}}const wildcardMatch=(requestPath=>{for(const[pattern,filePath]of wildcardRoutes){const matches=matchWildcardRoute(requestPath,pattern);if(matches)return{filePath:filePath,matches:matches}}return null})(requestPath);if(wildcardMatch){const resolvedFilePath=((filePath,matches)=>{let resolvedPath=filePath,matchIndex=1;for(;resolvedPath.includes("**")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("**",matches[matchIndex]),matchIndex++;for(;resolvedPath.includes("*")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("*",matches[matchIndex]),matchIndex++;return path.isAbsolute(resolvedPath)?resolvedPath:path.resolve(rootPath,resolvedPath)})(wildcardMatch.filePath,wildcardMatch.matches);log(`Serving wildcard route: ${requestPath} -> ${resolvedFilePath}`,3);try{if(await serveCustomRoutePath(resolvedFilePath,req,res))return;log(`Wildcard route path not found: ${requestPath}`,2)}catch(error){return log(`Error serving wildcard route ${requestPath}: ${error.message}`,1),enhancedResponse.writeHead(500,{"Content-Type":"text/plain"}),void enhancedResponse.end("Internal Server Error")}}const served=await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache);if(!served&&config.maxRescanAttempts>0&&!shouldSkipRescan(requestPath)){log("File not found, rescanning directory...",1),files=await getFiles(rootPath,config,log),log(`Rescan found ${files.length} files`,2);await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache)?rescanAttempts.delete(requestPath):((requestPath=>{const newAttempts=(rescanAttempts.get(requestPath)||0)+1;rescanAttempts.set(requestPath,newAttempts),newAttempts>config.maxRescanAttempts&&(dynamicNoRescanPaths.add(requestPath),log(`Path ${requestPath} added to dynamic blacklist after ${newAttempts} failed attempts`,1)),log(`Rescan attempt ${newAttempts}/${config.maxRescanAttempts} for: ${requestPath}`,3)})(requestPath),log(`404 - File not found after rescan: ${requestPath}`,1),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found"))}else served||(shouldSkipRescan(requestPath)?log(`404 - Skipped rescan for: ${requestPath}`,2):log(`404 - File not found: ${requestPath}`,1),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found"))})};return handler.moduleCache=moduleCache,handler.getStats=()=>moduleCache?.getStats()||null,handler.logCacheStats=()=>moduleCache?.logStats(log),handler.clearCache=()=>moduleCache?.clear(),handler};
|
package/docs/caching.html
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
8
8
|
<link rel="icon" type="image/png" sizes="48x48" href="./media/icon48.png">
|
|
9
9
|
<link rel="manifest" href="./manifest.json" />
|
|
10
|
-
<link rel="stylesheet" href="
|
|
10
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@2.1.3/dist/kempo.min.css" />
|
|
11
|
+
<link rel="stylesheet" href="./theme.css" />
|
|
12
|
+
<script src="./init.js"></script>
|
|
11
13
|
</head>
|
|
12
14
|
<body>
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
+
<k-import src="./nav.inc.html"></k-import>
|
|
16
|
+
<k-main>
|
|
15
17
|
<h1>Module Caching</h1>
|
|
16
18
|
<p>Kempo Server includes an intelligent module caching system that dramatically improves performance by caching JavaScript route modules in memory.</p>
|
|
17
19
|
|
|
@@ -229,7 +231,8 @@
|
|
|
229
231
|
<li>Extend <code>ttlMs</code> for stable route files</li>
|
|
230
232
|
<li>Monitor hit rates with admin endpoints</li>
|
|
231
233
|
</ul>
|
|
232
|
-
|
|
233
|
-
|
|
234
|
+
</k-main>
|
|
235
|
+
<div style="height:25vh"></div>
|
|
236
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/kempo-ui@0.0.42/dist/components/Import.js"></script>
|
|
234
237
|
</body>
|
|
235
238
|
</html>
|
package/docs/cli-utils.html
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
8
8
|
<link rel="icon" type="image/png" sizes="48x48" href="./media/icon48.png">
|
|
9
9
|
<link rel="manifest" href="./manifest.json" />
|
|
10
|
-
<link rel="stylesheet" href="
|
|
10
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@2.1.3/dist/kempo.min.css" />
|
|
11
|
+
<link rel="stylesheet" href="./theme.css" />
|
|
12
|
+
<script src="./init.js"></script>
|
|
11
13
|
</head>
|
|
12
14
|
<body>
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
+
<k-import src="./nav.inc.html"></k-import>
|
|
16
|
+
<k-main>
|
|
15
17
|
<h1>CLI Utilities</h1>
|
|
16
18
|
<p>The CLI utilities provide simple command-line argument parsing functionality for Node.js applications.</p>
|
|
17
19
|
|
|
@@ -80,8 +82,8 @@
|
|
|
80
82
|
<li><code>promptUser(query)</code> - Prompt user for input</li>
|
|
81
83
|
<li><code>promptYN(query, defaultValue)</code> - Prompt for yes/no with default</li>
|
|
82
84
|
</ul>
|
|
83
|
-
</main>
|
|
85
|
+
</k-main>
|
|
84
86
|
<div style="height:25vh"></div>
|
|
87
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/kempo-ui@0.0.42/dist/components/Import.js"></script>
|
|
85
88
|
</body>
|
|
86
|
-
</html
|
|
87
|
-
<parameter name="filePath">c:\Users\dusti\dev\kempo-server\docs\cli-utils.html
|
|
89
|
+
</html>
|
package/docs/configuration.html
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
7
7
|
<link rel="icon" type="image/png" sizes="48x48" href="./media/icon48.png">
|
|
8
8
|
<link rel="manifest" href="./manifest.json" />
|
|
9
|
-
<link rel="stylesheet" href="
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@2.1.3/dist/kempo.min.css" />
|
|
10
|
+
<link rel="stylesheet" href="./theme.css" />
|
|
11
|
+
<script src="./init.js"></script>
|
|
10
12
|
</head>
|
|
11
13
|
<body>
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
+
<k-import src="./nav.inc.html"></k-import>
|
|
15
|
+
<k-main>
|
|
14
16
|
<h1>Configuration</h1>
|
|
15
17
|
<p>Customize Kempo Server's behavior with a simple JSON configuration file.</p>
|
|
16
18
|
|
|
@@ -318,7 +320,8 @@
|
|
|
318
320
|
<li>Enable file watching in development, disable in production for stability</li>
|
|
319
321
|
<li>Use shorter TTL in development for faster iteration</li>
|
|
320
322
|
</ul>
|
|
321
|
-
</main>
|
|
323
|
+
</k-main>
|
|
322
324
|
<div style="height:25vh"></div>
|
|
325
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/kempo-ui@0.0.42/dist/components/Import.js"></script>
|
|
323
326
|
</body>
|
|
324
327
|
</html>
|
package/docs/examples.html
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
7
7
|
<link rel="icon" type="image/png" sizes="48x48" href="./media/icon48.png">
|
|
8
8
|
<link rel="manifest" href="./manifest.json" />
|
|
9
|
-
<link rel="stylesheet" href="
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@2.1.3/dist/kempo.min.css" />
|
|
10
|
+
<link rel="stylesheet" href="./theme.css" />
|
|
11
|
+
<script src="./init.js"></script>
|
|
10
12
|
</head>
|
|
11
13
|
<body>
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
+
<k-import src="./nav.inc.html"></k-import>
|
|
15
|
+
<k-main>
|
|
14
16
|
<h1>Examples & Demos</h1>
|
|
15
17
|
<p>Explore practical examples and try interactive demos of Kempo Server features.</p>
|
|
16
18
|
|
|
@@ -129,6 +131,11 @@
|
|
|
129
131
|
<h3>Production Configuration</h3>
|
|
130
132
|
<pre><code class="hljs json">{<br /> <span class="hljs-attr">"allowedMimes"</span>: {<br /> <span class="hljs-attr">"html"</span>: <span class="hljs-string">"text/html"</span>,<br /> <span class="hljs-attr">"css"</span>: <span class="hljs-string">"text/css"</span>,<br /> <span class="hljs-attr">"js"</span>: <span class="hljs-string">"application/javascript"</span>,<br /> <span class="hljs-attr">"json"</span>: <span class="hljs-string">"application/json"</span>,<br /> <span class="hljs-attr">"png"</span>: <span class="hljs-string">"image/png"</span>,<br /> <span class="hljs-attr">"jpg"</span>: <span class="hljs-string">"image/jpeg"</span>,<br /> <span class="hljs-attr">"svg"</span>: <span class="hljs-string">"image/svg+xml"</span>,<br /> <span class="hljs-attr">"woff"</span>: <span class="hljs-string">"font/woff"</span>,<br /> <span class="hljs-attr">"woff2"</span>: <span class="hljs-string">"font/woff2"</span><br /> },<br /> <span class="hljs-attr">"disallowedRegex"</span>: [<br /> <span class="hljs-string">"^/\\..*"</span>,<br /> <span class="hljs-string">"\\.env$"</span>,<br /> <span class="hljs-string">"\\.config$"</span>,<br /> <span class="hljs-string">"password"</span>,<br /> <span class="hljs-string">"secret"</span>,<br /> <span class="hljs-string">"node_modules"</span>,<br /> <span class="hljs-string">"\\.git"</span>,<br /> <span class="hljs-string">"\\.map$"</span><br /> ],<br /> <span class="hljs-attr">"middleware"</span>: {<br /> <span class="hljs-attr">"cors"</span>: {<br /> <span class="hljs-attr">"enabled"</span>: <span class="hljs-literal">true</span>,<br /> <span class="hljs-attr">"origin"</span>: <span class="hljs-string">"https://yourdomain.com"</span>,<br /> <span class="hljs-attr">"credentials"</span>: <span class="hljs-literal">true</span><br /> },<br /> <span class="hljs-attr">"compression"</span>: {<br /> <span class="hljs-attr">"enabled"</span>: <span class="hljs-literal">true</span>,<br /> <span class="hljs-attr">"threshold"</span>: <span class="hljs-number">1024</span><br /> },<br /> <span class="hljs-attr">"security"</span>: {<br /> <span class="hljs-attr">"enabled"</span>: <span class="hljs-literal">true</span>,<br /> <span class="hljs-attr">"headers"</span>: {<br /> <span class="hljs-attr">"X-Content-Type-Options"</span>: <span class="hljs-string">"nosniff"</span>,<br /> <span class="hljs-attr">"X-Frame-Options"</span>: <span class="hljs-string">"DENY"</span>,<br /> <span class="hljs-attr">"X-XSS-Protection"</span>: <span class="hljs-string">"1; mode=block"</span>,<br /> <span class="hljs-attr">"Strict-Transport-Security"</span>: <span class="hljs-string">"max-age=31536000; includeSubDomains"</span><br /> }<br /> },<br /> <span class="hljs-attr">"rateLimit"</span>: {<br /> <span class="hljs-attr">"enabled"</span>: <span class="hljs-literal">true</span>,<br /> <span class="hljs-attr">"maxRequests"</span>: <span class="hljs-number">100</span>,<br /> <span class="hljs-attr">"windowMs"</span>: <span class="hljs-number">60000</span><br /> },<br /> <span class="hljs-attr">"custom"</span>: [<br /> <span class="hljs-string">"./middleware/auth.js"</span>,<br /> <span class="hljs-string">"./middleware/logging.js"</span>,<br /> <span class="hljs-string">"./middleware/analytics.js"</span><br /> ]<br /> }<br />}</code></pre>
|
|
131
133
|
|
|
134
|
+
<h3>Programmatic File Rescan</h3>
|
|
135
|
+
<p>When your code creates or removes files at runtime (e.g., a CMS generating static pages), you can trigger a file rescan without restarting the server. Import the <code>rescan</code> function from anywhere in the same Node process:</p>
|
|
136
|
+
<pre><code class="hljs javascript"><span class="hljs-comment">// api/pages/POST.js</span><br /><span class="hljs-keyword">import</span> { writeFile } <span class="hljs-keyword">from</span> <span class="hljs-string">'fs/promises'</span>;<br /><span class="hljs-keyword">import</span> rescan <span class="hljs-keyword">from</span> <span class="hljs-string">'kempo-server/rescan'</span>;<br /><br /><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> (req, res) => {<br /> <span class="hljs-keyword">const</span> { slug, html } = req.body;<br /> <span class="hljs-keyword">await</span> writeFile(<span class="hljs-string">`./pages/${slug}.html`</span>, html);<br /> <span class="hljs-keyword">const</span> fileCount = <span class="hljs-keyword">await</span> rescan();<br /> res.json({ published: <span class="hljs-literal">true</span>, fileCount });<br />};</code></pre>
|
|
137
|
+
<p>The <code>rescan()</code> function returns a promise that resolves with the number of files found. It works from route handlers, middleware, file watchers, scheduled tasks, or any other code running in the same Node process.</p>
|
|
138
|
+
|
|
132
139
|
<script>
|
|
133
140
|
async function fetchUser() {
|
|
134
141
|
const userId = document.getElementById('userId').value || 'john';
|
|
@@ -195,7 +202,8 @@
|
|
|
195
202
|
}
|
|
196
203
|
}
|
|
197
204
|
</script>
|
|
198
|
-
</main>
|
|
205
|
+
</k-main>
|
|
199
206
|
<div style="height:25vh"></div>
|
|
207
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/kempo-ui@0.0.42/dist/components/Import.js"></script>
|
|
200
208
|
</body>
|
|
201
209
|
</html>
|
package/docs/fs-utils.html
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
8
8
|
<link rel="icon" type="image/png" sizes="48x48" href="./media/icon48.png">
|
|
9
9
|
<link rel="manifest" href="./manifest.json" />
|
|
10
|
-
<link rel="stylesheet" href="
|
|
10
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@2.1.3/dist/kempo.min.css" />
|
|
11
|
+
<link rel="stylesheet" href="./theme.css" />
|
|
12
|
+
<script src="./init.js"></script>
|
|
11
13
|
</head>
|
|
12
14
|
<body>
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
+
<k-import src="./nav.inc.html"></k-import>
|
|
16
|
+
<k-main>
|
|
15
17
|
<h1>File System Utilities</h1>
|
|
16
18
|
<p>The file system utilities provide common file and directory operations with Promise-based APIs for Node.js applications.</p>
|
|
17
19
|
|
|
@@ -111,8 +113,8 @@ async function backupProject() {
|
|
|
111
113
|
<li>Functions handle errors appropriately (e.g., ensureDir ignores EEXIST)</li>
|
|
112
114
|
<li>copyDir preserves directory structure recursively</li>
|
|
113
115
|
</ul>
|
|
114
|
-
</main>
|
|
116
|
+
</k-main>
|
|
115
117
|
<div style="height:25vh"></div>
|
|
118
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/kempo-ui@0.0.42/dist/components/Import.js"></script>
|
|
116
119
|
</body>
|
|
117
|
-
</html
|
|
118
|
-
<parameter name="filePath">c:\Users\dusti\dev\kempo-server\docs\fs-utils.html
|
|
120
|
+
</html>
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
7
7
|
<link rel="icon" type="image/png" sizes="48x48" href="./media/icon48.png">
|
|
8
8
|
<link rel="manifest" href="./manifest.json" />
|
|
9
|
-
<link rel="stylesheet" href="
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@2.1.3/dist/kempo.min.css" />
|
|
10
|
+
<link rel="stylesheet" href="./theme.css" />
|
|
11
|
+
<script src="./init.js"></script>
|
|
10
12
|
</head>
|
|
11
13
|
<body>
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
+
<k-import src="./nav.inc.html"></k-import>
|
|
15
|
+
<k-main>
|
|
14
16
|
<h1>Getting Started</h1>
|
|
15
17
|
<p>Get up and running with Kempo Server in just a few steps.</p>
|
|
16
18
|
|
|
@@ -71,7 +73,8 @@
|
|
|
71
73
|
<li><a href="middleware.html">Middleware</a> - Add authentication, logging, CORS, and more</li>
|
|
72
74
|
<li><a href="examples.html">Examples & Demos</a> - See real-world examples in action</li>
|
|
73
75
|
</ul>
|
|
74
|
-
</main>
|
|
76
|
+
</k-main>
|
|
75
77
|
<div style="height:25vh"></div>
|
|
78
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/kempo-ui@0.0.42/dist/components/Import.js"></script>
|
|
76
79
|
</body>
|
|
77
80
|
</html>
|
package/docs/index.html
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
7
7
|
<link rel="icon" type="image/png" sizes="48x48" href="./media/icon48.png">
|
|
8
8
|
<link rel="manifest" href="./manifest.json" />
|
|
9
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@1.3
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@2.1.3/dist/kempo.min.css" />
|
|
10
10
|
<link rel="stylesheet" href="./theme.css" />
|
|
11
|
-
<script
|
|
11
|
+
<script src="./init.js"></script>
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<k-import src="./nav.inc.html"></k-import>
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
<h4 class="mt0">Getting Started</h4>
|
|
29
29
|
<ul>
|
|
30
30
|
<li><a href="getting-started.html">Getting Started</a></li>
|
|
31
|
-
<li><a href="routing.html">
|
|
31
|
+
<li><a href="routing.html">Routing</a></li>
|
|
32
32
|
<li><a href="request-response.html">Request & Response</a></li>
|
|
33
33
|
</ul>
|
|
34
34
|
</div>
|
package/docs/init.js
CHANGED
package/docs/middleware.html
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
7
7
|
<link rel="icon" type="image/png" sizes="48x48" href="./media/icon48.png">
|
|
8
8
|
<link rel="manifest" href="./manifest.json" />
|
|
9
|
-
<link rel="stylesheet" href="
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@2.1.3/dist/kempo.min.css" />
|
|
10
|
+
<link rel="stylesheet" href="./theme.css" />
|
|
11
|
+
<script src="./init.js"></script>
|
|
10
12
|
</head>
|
|
11
13
|
<body>
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
+
<k-import src="./nav.inc.html"></k-import>
|
|
15
|
+
<k-main>
|
|
14
16
|
<h1>Middleware</h1>
|
|
15
17
|
<p>Kempo Server includes a powerful middleware system that allows you to add functionality like authentication, logging, CORS, compression, and more.</p>
|
|
16
18
|
|
|
@@ -141,7 +143,8 @@
|
|
|
141
143
|
<li>Cache expensive operations when possible</li>
|
|
142
144
|
<li>Consider the performance impact of middleware order</li>
|
|
143
145
|
</ul>
|
|
144
|
-
</main>
|
|
146
|
+
</k-main>
|
|
145
147
|
<div style="height:25vh"></div>
|
|
148
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/kempo-ui@0.0.42/dist/components/Import.js"></script>
|
|
146
149
|
</body>
|
|
147
150
|
</html>
|
package/docs/nav.inc.html
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
<nav
|
|
2
|
-
|
|
1
|
+
<k-nav
|
|
2
|
+
fixed
|
|
3
|
+
class="bg-primary"
|
|
3
4
|
>
|
|
4
5
|
<button
|
|
5
6
|
id="toggleNavSideMenu"
|
|
@@ -19,23 +20,51 @@
|
|
|
19
20
|
<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>
|
|
20
21
|
<a href="https://github.com/dustinpoissant/kempo-ui" target="_blank"><k-icon name="github-mark"></k-icont></a>
|
|
21
22
|
<k-theme-switcher></k-theme-switcher>
|
|
22
|
-
</nav>
|
|
23
|
+
</k-nav>
|
|
23
24
|
<div style="width: 100%; height: 4rem;"></div>
|
|
24
|
-
<k-
|
|
25
|
+
<k-aside
|
|
25
26
|
id="navSideMenu"
|
|
27
|
+
state="offscreen"
|
|
26
28
|
>
|
|
27
29
|
<menu>
|
|
28
30
|
<a href="./" class="ta-center bb mb r0">
|
|
29
31
|
<h1 class="tc-primary">Kempo Server</h1>
|
|
30
32
|
<img src="./media/icon128.png" alt="Kempo UI Icon" />
|
|
31
33
|
</a>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
<h3>Getting Started</h3>
|
|
35
|
+
<a href="./" class="d-b pq pl">Quick Start</a>
|
|
36
|
+
<a href="./routing.html" class="d-b pq pl">Routing</a>
|
|
37
|
+
<a href="./request-response.html" class="d-b pq pl">Request & Response</a>
|
|
38
|
+
<br /><br />
|
|
39
|
+
<h3>Advanced Features</h3>
|
|
40
|
+
<a href="configuration.html" class="d-b pq pl">Configuration</a>
|
|
41
|
+
<a href="middleware.html" class="d-b pq pl">Middleware</a>
|
|
42
|
+
<a href="caching.html" class="d-b pq pl">Module Caching</a>
|
|
43
|
+
<a href="cli-utils.html" class="d-b pq pl">CLI Utilities</a>
|
|
44
|
+
<a href="fs-utils.html" class="d-b pq pl">File System Utilities</a>
|
|
45
|
+
<a href="examples.html" class="d-b pq pl">Examples & Demos</a>
|
|
36
46
|
</menu>
|
|
37
|
-
</k-
|
|
38
|
-
<script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.
|
|
39
|
-
<script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.
|
|
40
|
-
<script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.
|
|
41
|
-
<script src="
|
|
47
|
+
</k-aside>
|
|
48
|
+
<script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3.5/src/components/Aside.js" type="module"></script>
|
|
49
|
+
<script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3.5/src/components/Main.js" type="module"></script>
|
|
50
|
+
<script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3.5/src/components/Nav.js" type="module"></script>
|
|
51
|
+
<script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3.5/src/components/Icon.js" type="module"></script>
|
|
52
|
+
<script src="https://cdn.jsdelivr.net/npm/kempo-ui@0.3.5/src/components/ThemeSwitcher.js" type="module"></script>
|
|
53
|
+
<script>
|
|
54
|
+
document.getElementById('toggleNavSideMenu').addEventListener('click', async () => {
|
|
55
|
+
await window.customElements.whenDefined('k-aside');
|
|
56
|
+
document.getElementById('navSideMenu').toggle();
|
|
57
|
+
});
|
|
58
|
+
document.addEventListener('click', function(e) {
|
|
59
|
+
if (e.target.matches('a[href^="#"]')) {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
const targetId = e.target.getAttribute('href').replace('#', '');
|
|
62
|
+
const target = document.getElementById(targetId);
|
|
63
|
+
if (target) {
|
|
64
|
+
target.scrollIntoView({ behavior: 'smooth' });
|
|
65
|
+
const url = window.location.pathname + window.location.search + '#' + targetId;
|
|
66
|
+
history.replaceState(null, '', url);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
</script>
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
7
7
|
<link rel="icon" type="image/png" sizes="48x48" href="./media/icon48.png">
|
|
8
8
|
<link rel="manifest" href="./manifest.json" />
|
|
9
|
-
<link rel="stylesheet" href="
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@2.1.3/dist/kempo.min.css" />
|
|
10
|
+
<link rel="stylesheet" href="./theme.css" />
|
|
11
|
+
<script src="./init.js"></script>
|
|
10
12
|
</head>
|
|
11
13
|
<body>
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
+
<k-import src="./nav.inc.html"></k-import>
|
|
15
|
+
<k-main>
|
|
14
16
|
<h1>Request & Response Objects</h1>
|
|
15
17
|
<p>Learn how to work with HTTP requests and responses in Kempo Server.</p>
|
|
16
18
|
|
|
@@ -104,7 +106,8 @@
|
|
|
104
106
|
|
|
105
107
|
<h3>Request Validation</h3>
|
|
106
108
|
<pre><code class="hljs javascript"><span class="hljs-comment">// api/user/POST.js</span><br /><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">request, response</span>) </span>{<br /> <span class="hljs-keyword">try</span> {<br /> <span class="hljs-keyword">const</span> userData = <span class="hljs-keyword">await</span> request.json();<br /> <br /> <span class="hljs-comment">// Validate required fields</span><br /> <span class="hljs-keyword">const</span> requiredFields = [<span class="hljs-string">'name'</span>, <span class="hljs-string">'email'</span>, <span class="hljs-string">'password'</span>];<br /> <span class="hljs-keyword">const</span> missingFields = requiredFields.filter(<span class="hljs-function"><span class="hljs-params">field</span> =></span> !userData[field]);<br /> <br /> <span class="hljs-keyword">if</span> (missingFields.length > <span class="hljs-number">0</span>) {<br /> <span class="hljs-keyword">return</span> response.status(<span class="hljs-number">400</span>).json({<br /> error: <span class="hljs-string">'Missing required fields'</span>,<br /> missingFields<br /> });<br /> }<br /> <br /> <span class="hljs-comment">// Validate email format</span><br /> <span class="hljs-keyword">const</span> emailRegex = <span class="hljs-regexp">/^[^\s@]+@[^\s@]+\.[^\s@]+$/</span>;<br /> <span class="hljs-keyword">if</span> (!emailRegex.test(userData.email)) {<br /> <span class="hljs-keyword">return</span> response.status(<span class="hljs-number">400</span>).json({<br /> error: <span class="hljs-string">'Invalid email format'</span><br /> });<br /> }<br /> <br /> <span class="hljs-comment">// Create user</span><br /> <span class="hljs-keyword">const</span> newUser = <span class="hljs-keyword">await</span> createUser(userData);<br /> <br /> response.status(<span class="hljs-number">201</span>).json({<br /> message: <span class="hljs-string">'User created successfully'</span>,<br /> user: {<br /> id: newUser.id,<br /> name: newUser.name,<br /> email: newUser.email<br /> }<br /> });<br /> } <span class="hljs-keyword">catch</span> (error) {<br /> response.status(<span class="hljs-number">400</span>).json({ error: <span class="hljs-string">'Invalid request data'</span> });<br /> }<br />}</code></pre>
|
|
107
|
-
</main>
|
|
109
|
+
</k-main>
|
|
108
110
|
<div style="height:25vh"></div><div style="height:25vh"></div>
|
|
111
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/kempo-ui@0.0.42/dist/components/Import.js"></script>
|
|
109
112
|
</body>
|
|
110
113
|
</html>
|
package/docs/routing.html
CHANGED
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
<head>
|
|
3
3
|
<meta charset='utf-8'>
|
|
4
4
|
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
|
|
5
|
-
<title>
|
|
5
|
+
<title>Routing - Kempo Server</title>
|
|
6
6
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
7
7
|
<link rel="icon" type="image/png" sizes="48x48" href="./media/icon48.png">
|
|
8
8
|
<link rel="manifest" href="./manifest.json" />
|
|
9
|
-
<link rel="stylesheet" href="
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@2.1.3/dist/kempo.min.css" />
|
|
10
|
+
<link rel="stylesheet" href="./theme.css" />
|
|
11
|
+
<script src="./init.js"></script>
|
|
10
12
|
</head>
|
|
11
13
|
<body>
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
<h1>
|
|
14
|
+
<k-import src="./nav.inc.html"></k-import>
|
|
15
|
+
<k-main>
|
|
16
|
+
<h1>Routing</h1>
|
|
15
17
|
<p>Learn how Kempo Server's file-based routing system works.</p>
|
|
16
18
|
|
|
17
19
|
<h2>How Routes Work</h2>
|
|
@@ -81,7 +83,8 @@
|
|
|
81
83
|
<li><code>index.html</code> / <code>index.htm</code> — served as static</li>
|
|
82
84
|
</ol>
|
|
83
85
|
<p>This applies to both exact and wildcard custom routes. For example, with a wildcard route <code>"/api/**": "../api/**"</code>, a request to <code>/api/auth/session</code> resolves to the <code>../api/auth/session/</code> directory and executes the appropriate route file (e.g. <code>GET.js</code> for GET requests).</p>
|
|
84
|
-
</main>
|
|
86
|
+
</k-main>
|
|
85
87
|
<div style="height:25vh"></div>
|
|
88
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/kempo-ui@0.0.42/dist/components/Import.js"></script>
|
|
86
89
|
</body>
|
|
87
90
|
</html>
|
package/docs/theme.css
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
:root {
|
|
2
|
-
--c_primary:
|
|
3
|
-
--c_primary__hover: rgb(119, 17, 221);
|
|
2
|
+
--c_primary: hsl(262, 52%, 47%);
|
|
4
3
|
--c_secondary: rgb(51, 102, 255);
|
|
5
|
-
--c_secondary__hover: rgb(17, 68, 221);
|
|
6
4
|
--tc_primary: light-dark(#93f, rgb(187, 102, 255));
|
|
7
5
|
--tc_secondary: light-dark(#36f, rgb(138, 180, 248));
|
|
8
6
|
--c_highlight: light-dark(rgba(153, 51, 255, 0.25), rgba(153, 51, 255, 0.25));
|
package/{llm.txt → llms.txt}
RENAMED
|
@@ -132,6 +132,10 @@ Use `customRoutes` to redirect all page paths to the SPA entry:
|
|
|
132
132
|
## Utility Modules
|
|
133
133
|
|
|
134
134
|
```js
|
|
135
|
+
import rescan from 'kempo-server/rescan';
|
|
136
|
+
// rescan() → Promise<number> — triggers a file rescan on the running server, returns new file count
|
|
137
|
+
// Works from anywhere in the same Node process: route handlers, middleware, file watchers, scheduled tasks
|
|
138
|
+
|
|
135
139
|
import { getArgs } from 'kempo-server/utils/cli';
|
|
136
140
|
// getArgs(mapping?) → parsed argv object
|
|
137
141
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kempo-server",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"description": "A lightweight, zero-dependency, file based routing server.",
|
|
6
6
|
"exports": {
|
|
7
|
+
"./rescan": "./dist/rescan.js",
|
|
7
8
|
"./utils/cli": "./dist/utils/cli.js",
|
|
8
9
|
"./utils/fs-utils": "./dist/utils/fs-utils.js"
|
|
9
10
|
},
|
package/scripts/build.js
CHANGED
|
@@ -79,19 +79,6 @@ const build = async () => {
|
|
|
79
79
|
await processJsFile(join(utilsDir, file), join(distDir, 'utils', file));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
console.log('Copying kempo.min.css to docs...');
|
|
83
|
-
|
|
84
|
-
// Copy kempo.min.css to docs directory
|
|
85
|
-
const cssSource = join(rootDir, 'node_modules', 'kempo-css', 'dist', 'kempo.min.css');
|
|
86
|
-
const cssTarget = join(docsDir, 'kempo.min.css');
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
await copyFile(cssSource, cssTarget);
|
|
90
|
-
console.log('✓ Copied kempo.min.css to docs');
|
|
91
|
-
} catch (error) {
|
|
92
|
-
console.warn('⚠ Could not copy kempo.min.css:', error.message);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
82
|
console.log('Build completed successfully!');
|
|
96
83
|
|
|
97
84
|
} catch (error) {
|
package/src/rescan.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
|
|
3
|
+
const emitter = new EventEmitter();
|
|
4
|
+
|
|
5
|
+
export const onRescan = callback => {
|
|
6
|
+
emitter.on('rescan', callback);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default () => new Promise((resolve, reject) => {
|
|
10
|
+
emitter.emit('rescan', (error, fileCount) => {
|
|
11
|
+
if(error) reject(error);
|
|
12
|
+
else resolve(fileCount);
|
|
13
|
+
});
|
|
14
|
+
});
|
package/src/router.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
-
import { readFile, stat } from 'fs/promises';
|
|
2
|
+
import { readFile, stat, readdir } from 'fs/promises';
|
|
3
3
|
import { pathToFileURL } from 'url';
|
|
4
4
|
import defaultConfig from './defaultConfig.js';
|
|
5
5
|
import getFiles from './getFiles.js';
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
securityMiddleware,
|
|
17
17
|
loggingMiddleware
|
|
18
18
|
} from './builtinMiddleware.js';
|
|
19
|
+
import { onRescan } from './rescan.js';
|
|
19
20
|
|
|
20
21
|
export default async (flags, log) => {
|
|
21
22
|
log('Initializing router', 3);
|
|
@@ -94,7 +95,18 @@ export default async (flags, log) => {
|
|
|
94
95
|
|
|
95
96
|
let files = await getFiles(rootPath, config, log);
|
|
96
97
|
log(`Initial scan found ${files.length} files`, 2);
|
|
97
|
-
|
|
98
|
+
|
|
99
|
+
onRescan(async done => {
|
|
100
|
+
try {
|
|
101
|
+
files = await getFiles(rootPath, config, log);
|
|
102
|
+
log(`Rescan found ${files.length} files`, 2);
|
|
103
|
+
done(null, files.length);
|
|
104
|
+
} catch(error) {
|
|
105
|
+
log(`Rescan failed: ${error.message}`, 1);
|
|
106
|
+
done(error);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
98
110
|
// Initialize middleware runner
|
|
99
111
|
const middlewareRunner = new MiddlewareRunner();
|
|
100
112
|
|
|
@@ -248,7 +260,7 @@ export default async (flags, log) => {
|
|
|
248
260
|
res.end(fileContent);
|
|
249
261
|
};
|
|
250
262
|
|
|
251
|
-
const executeRouteModule = async (filePath, req, res) => {
|
|
263
|
+
const executeRouteModule = async (filePath, req, res, params = {}) => {
|
|
252
264
|
let module;
|
|
253
265
|
if(moduleCache && config.cache?.enabled) {
|
|
254
266
|
const fileStats = await stat(filePath);
|
|
@@ -269,7 +281,7 @@ export default async (flags, log) => {
|
|
|
269
281
|
res.end('Route file does not export a function');
|
|
270
282
|
return;
|
|
271
283
|
}
|
|
272
|
-
const enhancedReq = createRequestWrapper(req,
|
|
284
|
+
const enhancedReq = createRequestWrapper(req, params);
|
|
273
285
|
const enhancedRes = createResponseWrapper(res);
|
|
274
286
|
const rawBody = await readRawBody(req);
|
|
275
287
|
enhancedReq._rawBody = rawBody;
|
|
@@ -278,17 +290,42 @@ export default async (flags, log) => {
|
|
|
278
290
|
await module.default(enhancedReq, enhancedRes);
|
|
279
291
|
};
|
|
280
292
|
|
|
281
|
-
//
|
|
282
|
-
// Returns
|
|
283
|
-
const
|
|
284
|
-
|
|
293
|
+
// Traverse a directory tree supporting [param] directory names.
|
|
294
|
+
// Returns { filePath, params } or null.
|
|
295
|
+
const walkDynamic = async (base, segments) => {
|
|
296
|
+
if(segments.length === 0) return { filePath: base, params: {} };
|
|
297
|
+
|
|
298
|
+
const [head, ...rest] = segments;
|
|
299
|
+
let entries;
|
|
285
300
|
try {
|
|
286
|
-
|
|
287
|
-
} catch
|
|
288
|
-
|
|
289
|
-
|
|
301
|
+
entries = await readdir(base, { withFileTypes: true });
|
|
302
|
+
} catch { return null; }
|
|
303
|
+
|
|
304
|
+
// Exact match first
|
|
305
|
+
for(const entry of entries) {
|
|
306
|
+
if(entry.name !== head) continue;
|
|
307
|
+
if(entry.isDirectory()) {
|
|
308
|
+
const result = await walkDynamic(path.join(base, head), rest);
|
|
309
|
+
if(result) return result;
|
|
310
|
+
} else if(entry.isFile() && rest.length === 0) {
|
|
311
|
+
return { filePath: path.join(base, head), params: {} };
|
|
312
|
+
}
|
|
290
313
|
}
|
|
291
314
|
|
|
315
|
+
// [param] directory match
|
|
316
|
+
for(const entry of entries) {
|
|
317
|
+
if(!entry.isDirectory() || !entry.name.startsWith('[') || !entry.name.endsWith(']')) continue;
|
|
318
|
+
const paramName = entry.name.slice(1, -1);
|
|
319
|
+
const result = await walkDynamic(path.join(base, entry.name), rest);
|
|
320
|
+
if(result) return { filePath: result.filePath, params: { [paramName]: head, ...result.params } };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return null;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// Serve a resolved file or directory (fileStat already known).
|
|
327
|
+
// Returns true if handled, null if directory has no matching route/index file.
|
|
328
|
+
const serveResolvedPath = async (filePath, fileStat, params, req, res) => {
|
|
292
329
|
if(fileStat.isDirectory()) {
|
|
293
330
|
const methodUpper = req.method.toUpperCase();
|
|
294
331
|
const candidates = [
|
|
@@ -299,15 +336,11 @@ export default async (flags, log) => {
|
|
|
299
336
|
'index.htm'
|
|
300
337
|
];
|
|
301
338
|
for(const candidate of candidates) {
|
|
302
|
-
const candidatePath = path.join(
|
|
303
|
-
try {
|
|
304
|
-
await stat(candidatePath);
|
|
305
|
-
} catch {
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
339
|
+
const candidatePath = path.join(filePath, candidate);
|
|
340
|
+
try { await stat(candidatePath); } catch { continue; }
|
|
308
341
|
if(config.routeFiles.includes(candidate)) {
|
|
309
342
|
log(`Executing route file: ${candidatePath}`, 2);
|
|
310
|
-
await executeRouteModule(candidatePath, req, res);
|
|
343
|
+
await executeRouteModule(candidatePath, req, res, params);
|
|
311
344
|
return true;
|
|
312
345
|
}
|
|
313
346
|
log(`Serving index file: ${candidatePath}`, 2);
|
|
@@ -317,16 +350,48 @@ export default async (flags, log) => {
|
|
|
317
350
|
return null;
|
|
318
351
|
}
|
|
319
352
|
|
|
320
|
-
const fileName = path.basename(
|
|
353
|
+
const fileName = path.basename(filePath);
|
|
321
354
|
if(config.routeFiles.includes(fileName)) {
|
|
322
|
-
log(`Executing route file: ${
|
|
323
|
-
await executeRouteModule(
|
|
355
|
+
log(`Executing route file: ${filePath}`, 2);
|
|
356
|
+
await executeRouteModule(filePath, req, res, params);
|
|
324
357
|
return true;
|
|
325
358
|
}
|
|
326
|
-
await serveStaticCustomFile(
|
|
359
|
+
await serveStaticCustomFile(filePath, res);
|
|
327
360
|
return true;
|
|
328
361
|
};
|
|
329
362
|
|
|
363
|
+
// Resolves a custom route path supporting files, directories, and [param] segments.
|
|
364
|
+
// Returns true if handled, null if path not found.
|
|
365
|
+
const serveCustomRoutePath = async (resolvedFilePath, req, res) => {
|
|
366
|
+
let fileStat;
|
|
367
|
+
try {
|
|
368
|
+
fileStat = await stat(resolvedFilePath);
|
|
369
|
+
return await serveResolvedPath(resolvedFilePath, fileStat, {}, req, res);
|
|
370
|
+
} catch(e) {
|
|
371
|
+
if(e.code !== 'ENOENT') throw e;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Path doesn't exist literally — walk backwards to find the nearest existing
|
|
375
|
+
// ancestor directory, then traverse forward with [param] support.
|
|
376
|
+
let current = resolvedFilePath;
|
|
377
|
+
const remaining = [];
|
|
378
|
+
while(current !== path.dirname(current)) {
|
|
379
|
+
remaining.unshift(path.basename(current));
|
|
380
|
+
current = path.dirname(current);
|
|
381
|
+
try {
|
|
382
|
+
const s = await stat(current);
|
|
383
|
+
if(!s.isDirectory()) break;
|
|
384
|
+
const result = await walkDynamic(current, remaining);
|
|
385
|
+
if(!result) return null;
|
|
386
|
+
const resolvedStat = await stat(result.filePath);
|
|
387
|
+
return await serveResolvedPath(result.filePath, resolvedStat, result.params, req, res);
|
|
388
|
+
} catch(e2) {
|
|
389
|
+
if(e2.code !== 'ENOENT') throw e2;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
};
|
|
394
|
+
|
|
330
395
|
// Track 404 attempts to avoid unnecessary rescans
|
|
331
396
|
const rescanAttempts = new Map(); // path -> attempt count
|
|
332
397
|
const dynamicNoRescanPaths = new Set(); // paths that have exceeded max attempts
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import {withTestDir} from './utils/test-dir.js';
|
|
3
|
+
import {write} from './utils/file-writer.js';
|
|
4
|
+
import {randomPort} from './utils/port.js';
|
|
5
|
+
import {httpGet} from './utils/http.js';
|
|
6
|
+
import router from '../src/router.js';
|
|
7
|
+
import rescan from '../src/rescan.js';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
'rescan() triggers file rescan and returns file count': async ({pass, fail}) => {
|
|
11
|
+
await withTestDir(async dir => {
|
|
12
|
+
const prev = process.cwd();
|
|
13
|
+
process.chdir(dir);
|
|
14
|
+
const flags = {root: '.', logging: 0};
|
|
15
|
+
const logFn = () => {};
|
|
16
|
+
|
|
17
|
+
await write(dir, '.config.json', JSON.stringify({
|
|
18
|
+
maxRescanAttempts: 0
|
|
19
|
+
}));
|
|
20
|
+
await write(dir, 'index.html', '<h1>Home</h1>');
|
|
21
|
+
|
|
22
|
+
const handler = await router(flags, logFn);
|
|
23
|
+
const server = http.createServer(handler);
|
|
24
|
+
const port = randomPort();
|
|
25
|
+
await new Promise(r => server.listen(port, r));
|
|
26
|
+
await new Promise(r => setTimeout(r, 50));
|
|
27
|
+
|
|
28
|
+
const miss = await httpGet(`http://localhost:${port}/added.html`);
|
|
29
|
+
if(miss.res.statusCode !== 404) {
|
|
30
|
+
server.close();
|
|
31
|
+
process.chdir(prev);
|
|
32
|
+
return fail('should 404 before file exists');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await write(dir, 'added.html', '<h1>Added</h1>');
|
|
36
|
+
|
|
37
|
+
const stillMiss = await httpGet(`http://localhost:${port}/added.html`);
|
|
38
|
+
if(stillMiss.res.statusCode !== 404) {
|
|
39
|
+
server.close();
|
|
40
|
+
process.chdir(prev);
|
|
41
|
+
return fail('should still 404 with maxRescanAttempts=0');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const count = await rescan();
|
|
45
|
+
if(typeof count !== 'number' || count < 2) {
|
|
46
|
+
server.close();
|
|
47
|
+
process.chdir(prev);
|
|
48
|
+
return fail(`rescan should return file count, got: ${count}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hit = await httpGet(`http://localhost:${port}/added.html`);
|
|
52
|
+
if(hit.res.statusCode !== 200) {
|
|
53
|
+
server.close();
|
|
54
|
+
process.chdir(prev);
|
|
55
|
+
return fail('should serve file after rescan()');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if(!hit.body.toString().includes('Added')) {
|
|
59
|
+
server.close();
|
|
60
|
+
process.chdir(prev);
|
|
61
|
+
return fail('should serve correct content after rescan');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
server.close();
|
|
65
|
+
process.chdir(prev);
|
|
66
|
+
});
|
|
67
|
+
pass('rescan() works from imported function');
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -315,5 +315,126 @@ export default {
|
|
|
315
315
|
} catch(e) {
|
|
316
316
|
fail(e.message);
|
|
317
317
|
}
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
'wildcard route supports [param] directory segments': async ({pass, fail, log}) => {
|
|
321
|
+
try {
|
|
322
|
+
await withTempDir(async (dir) => {
|
|
323
|
+
await write(dir, 'api/users/[id]/GET.js',
|
|
324
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end(JSON.stringify({id: req.params.id})); };`
|
|
325
|
+
);
|
|
326
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
327
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
328
|
+
customRoutes: { '/api/**': '../api/**' }
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
const prev = process.cwd();
|
|
332
|
+
process.chdir(dir);
|
|
333
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
334
|
+
const server = http.createServer(handler);
|
|
335
|
+
const port = randomPort();
|
|
336
|
+
await new Promise(r => server.listen(port, r));
|
|
337
|
+
await new Promise(r => setTimeout(r, 50));
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const r1 = await httpGet(`http://localhost:${port}/api/users/abc123`);
|
|
341
|
+
log('status: ' + r1.res.statusCode);
|
|
342
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200, got ' + r1.res.statusCode);
|
|
343
|
+
const body = JSON.parse(r1.body.toString());
|
|
344
|
+
if(body.id !== 'abc123') throw new Error('expected id=abc123, got: ' + r1.body.toString());
|
|
345
|
+
} finally {
|
|
346
|
+
server.close();
|
|
347
|
+
process.chdir(prev);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
pass('wildcard route supports [param] directory segments');
|
|
351
|
+
} catch(e) {
|
|
352
|
+
fail(e.message);
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
'wildcard route supports multiple [param] segments': async ({pass, fail, log}) => {
|
|
357
|
+
try {
|
|
358
|
+
await withTempDir(async (dir) => {
|
|
359
|
+
await write(dir, 'api/[org]/[repo]/GET.js',
|
|
360
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end(JSON.stringify({org: req.params.org, repo: req.params.repo})); };`
|
|
361
|
+
);
|
|
362
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
363
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
364
|
+
customRoutes: { '/api/**': '../api/**' }
|
|
365
|
+
}));
|
|
366
|
+
|
|
367
|
+
const prev = process.cwd();
|
|
368
|
+
process.chdir(dir);
|
|
369
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
370
|
+
const server = http.createServer(handler);
|
|
371
|
+
const port = randomPort();
|
|
372
|
+
await new Promise(r => server.listen(port, r));
|
|
373
|
+
await new Promise(r => setTimeout(r, 50));
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const r1 = await httpGet(`http://localhost:${port}/api/acme/myrepo`);
|
|
377
|
+
log('status: ' + r1.res.statusCode);
|
|
378
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200, got ' + r1.res.statusCode);
|
|
379
|
+
const body = JSON.parse(r1.body.toString());
|
|
380
|
+
if(body.org !== 'acme') throw new Error('wrong org: ' + body.org);
|
|
381
|
+
if(body.repo !== 'myrepo') throw new Error('wrong repo: ' + body.repo);
|
|
382
|
+
} finally {
|
|
383
|
+
server.close();
|
|
384
|
+
process.chdir(prev);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
pass('wildcard route supports multiple [param] segments');
|
|
388
|
+
} catch(e) {
|
|
389
|
+
fail(e.message);
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
'wildcard route [param] exact match takes priority over [param]': async ({pass, fail, log}) => {
|
|
394
|
+
try {
|
|
395
|
+
await withTempDir(async (dir) => {
|
|
396
|
+
// Both a literal and [param] directory exist
|
|
397
|
+
await write(dir, 'api/users/me/GET.js',
|
|
398
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end('{"who":"me"}'); };`
|
|
399
|
+
);
|
|
400
|
+
await write(dir, 'api/users/[id]/GET.js',
|
|
401
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end(JSON.stringify({id: req.params.id})); };`
|
|
402
|
+
);
|
|
403
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
404
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
405
|
+
customRoutes: { '/api/**': '../api/**' }
|
|
406
|
+
}));
|
|
407
|
+
|
|
408
|
+
const prev = process.cwd();
|
|
409
|
+
process.chdir(dir);
|
|
410
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
411
|
+
const server = http.createServer(handler);
|
|
412
|
+
const port = randomPort();
|
|
413
|
+
await new Promise(r => server.listen(port, r));
|
|
414
|
+
await new Promise(r => setTimeout(r, 50));
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
// Literal 'me' should win over [id]
|
|
418
|
+
const r1 = await httpGet(`http://localhost:${port}/api/users/me`);
|
|
419
|
+
log('me status: ' + r1.res.statusCode);
|
|
420
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200');
|
|
421
|
+
const b1 = JSON.parse(r1.body.toString());
|
|
422
|
+
if(b1.who !== 'me') throw new Error('literal match should win: ' + r1.body.toString());
|
|
423
|
+
|
|
424
|
+
// Dynamic [id] still works for other values
|
|
425
|
+
const r2 = await httpGet(`http://localhost:${port}/api/users/456`);
|
|
426
|
+
log('dynamic status: ' + r2.res.statusCode);
|
|
427
|
+
if(r2.res.statusCode !== 200) throw new Error('expected 200 for dynamic');
|
|
428
|
+
const b2 = JSON.parse(r2.body.toString());
|
|
429
|
+
if(b2.id !== '456') throw new Error('dynamic param wrong: ' + r2.body.toString());
|
|
430
|
+
} finally {
|
|
431
|
+
server.close();
|
|
432
|
+
process.chdir(prev);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
pass('wildcard route [param] exact match takes priority over [param]');
|
|
436
|
+
} catch(e) {
|
|
437
|
+
fail(e.message);
|
|
438
|
+
}
|
|
318
439
|
}
|
|
319
440
|
};
|
package/docs/kempo.min.css
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
:root{color-scheme:light;--ff_body:"Helvetica Neue",Helvetica,Arial,sans-serif;--ff_heading:"Helvetica Neue",Helvetica,Arial,sans-serif;--ff_mono:Consolas,monaco,monospace;--fs_base:16px;--fs_small:calc(0.6 * var(--fs_base));--fs_large:calc(1.5 * var(--fs_base));--fs_h6:var(--fs_base);--fs_h5:calc(1.25 * var(--fs_base));--fs_h4:calc(1.5 * var(--fs_base));--fs_h3:calc(1.75 * var(--fs_base));--fs_h2:calc(2 * var(--fs_base));--fs_h1:calc(2.5 * var(--fs_base));--fw_base:400;--fw_bold:700;--spacer:1rem;--spacer_h:calc(0.5 * var(--spacer));--spacer_q:calc(0.25 * var(--spacer));--line-height:1.35em;--container_width:90rem;--animation_ms:256ms;--radius:0.25rem;--link_decoration:underline;--input_padding:var(--spacer_h) var(--spacer);--input_border_width:1px;--btn_padding:var(--spacer_h) var(--spacer);--c_bg:light-dark(rgb(249, 249, 249), rgb(51, 51, 51));--c_bg__inv:light-dark(rgb(51, 51, 51), rgb(249, 249, 249));--c_bg__alt:light-dark(rgb(238, 238, 238), rgb(34, 34, 34));--c_overscroll:light-dark(rgb(255, 255, 255), rgb(0, 0, 0));--c_border:light-dark(rgb(204, 204, 204), rgb(119, 119, 119));--c_border__inv:light-dark(rgb(119, 119, 119), rgb(204, 204, 204));--c_primary:rgb(51, 102, 255);--c_primary__hover:rgb(17, 68, 221);--c_secondary:rgb(153, 51, 255);--c_secondary__hover:rgb(119, 17, 221);--c_success:rgb(0, 136, 0);--c_success__hover:rgb(0, 102, 0);--c_warning:rgb(255, 102, 0);--c_warning__hover:rgb(221, 68, 0);--c_danger:rgb(255, 0, 51);--c_danger__hover:rgb(221, 0, 17);--c_input_accent:rgb(51, 102, 255);--c_input_border:var(--c_border);--c_highlight:light-dark(rgba(41, 100, 210, 0.25), rgba(0, 89, 255, 0.25));--tc:light-dark(rgba(0, 0, 0, 0.93), rgba(255, 255, 255, 0.93));--tc_dark:light-dark(rgba(0, 0, 0, 0.93), rgba(0, 0, 0, 0.93));--tc_light:light-dark(rgba(255, 255, 255, 0.93), rgba(255, 255, 255, 0.93));--tc_inv:light-dark(rgba(255, 255, 255, 0.93), rgba(0, 0, 0, 0.93));--tc_muted:light-dark(rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.5));--tc_on_primary:light-dark(rgba(255, 255, 255, 0.93), rgba(255, 255, 255, 0.93));--tc_on_secondary:light-dark(rgba(255, 255, 255, 0.93), rgba(255, 255, 255, 0.93));--tc_on_success:light-dark(rgba(255, 255, 255, 0.93), rgba(255, 255, 255, 0.93));--tc_on_warning:light-dark(rgba(255, 255, 255, 0.93), rgba(255, 255, 255, 0.93));--tc_on_danger:light-dark(rgba(255, 255, 255, 0.93), rgba(255, 255, 255, 0.93));--c_overlay:rgba(0, 0, 0, 0.5);--tc_primary:light-dark(#36f, rgb(138, 180, 248));--tc_secondary:light-dark(#93f, rgb(187, 102, 255));--tc_success:light-dark(#080, rgb(102, 187, 102));--tc_warning:light-dark(#f60, rgb(255, 153, 51));--tc_danger:light-dark(#f03, rgb(255, 85, 119));--btn_box_shadow:0 0 0 transparent;--btn_box_shadow__hover:0 0 0 transparent;--btn_border:transparent;--btn_bg:light-dark(rgb(221, 221, 221), rgb(170, 170, 170));--btn_bg__hover:light-dark(rgb(204, 204, 204), rgb(187, 187, 187));--btn_tc:light-dark(rgba(0, 0, 0, 0.93), rgba(0, 0, 0, 0.93));--btn_transparent__hover:light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));--tc_link:var(--tc_primary);--tc_link__hover:var(--tc_secondary);--tc_link__inv:var(--tc_primary__inv);--tc_link__inv__hover:var(--tc_secondary__inv);--focus_shadow:0 0 2px 2px var(--c_primary);--focus_shadow_on_primary:0 0 2px 2px var(--tc_on_primary);--input_bg:light-dark(white, var(--c_bg__alt));--input_tc:light-dark(rgba(0, 0, 0, 0.93), var(--tc));--drop_shadow__light:0 0.25rem 0.5rem rgba(0, 0, 0, 0.333);--drop_shadow__dark:0 0.25rem 0.5rem rgba(0, 0, 0, 0.5);--drop_shadow:var(--drop_shadow__light);--date_picker_icon_filter:light-dark(invert(0), invert(1));--elevation_-2_bg:light-dark(rgb(215, 215, 215), rgb(25, 25, 25));--elevation_-1_bg:light-dark(rgb(232, 232, 232), rgb(38, 38, 38));--elevation_0_bg:var(--c_bg);--elevation_1_bg:light-dark(rgb(255, 255, 255), rgb(64, 64, 64));--elevation_2_bg:light-dark(rgb(255, 255, 255), rgb(77, 77, 77));--elevation_3_bg:light-dark(rgb(255, 255, 255), rgb(90, 90, 90));--elevation_-2_shadow__light:inset 0 2px 6px rgba(0, 0, 0, 0.18),inset 0 1px 3px rgba(0, 0, 0, 0.12);--elevation_-1_shadow__light:inset 0 1px 3px rgba(0, 0, 0, 0.1),inset 0 1px 2px rgba(0, 0, 0, 0.06);--elevation_0_shadow:none;--elevation_1_shadow__light:0 1px 3px rgba(0, 0, 0, 0.1),0 1px 2px rgba(0, 0, 0, 0.16);--elevation_2_shadow__light:0 3px 6px rgba(0, 0, 0, 0.12),0 2px 4px rgba(0, 0, 0, 0.1);--elevation_3_shadow__light:0 8px 16px rgba(0, 0, 0, 0.14),0 3px 6px rgba(0, 0, 0, 0.1);--elevation_-2_shadow__dark:inset 0 2px 8px rgba(0, 0, 0, 0.5),inset 0 1px 4px rgba(0, 0, 0, 0.4);--elevation_-1_shadow__dark:inset 0 1px 4px rgba(0, 0, 0, 0.35),inset 0 1px 2px rgba(0, 0, 0, 0.25);--elevation_1_shadow__dark:0 2px 6px rgba(0, 0, 0, 0.5),0 1px 3px rgba(0, 0, 0, 0.4);--elevation_2_shadow__dark:0 4px 12px rgba(0, 0, 0, 0.55),0 2px 4px rgba(0, 0, 0, 0.45);--elevation_3_shadow__dark:0 8px 20px rgba(0, 0, 0, 0.6),0 4px 8px rgba(0, 0, 0, 0.5);--elevation_-2_shadow:var(--elevation_-2_shadow__light);--elevation_-1_shadow:var(--elevation_-1_shadow__light);--elevation_1_shadow:var(--elevation_1_shadow__light);--elevation_2_shadow:var(--elevation_2_shadow__light);--elevation_3_shadow:var(--elevation_3_shadow__light)}[theme=light]{color-scheme:light}[theme=dark]{color-scheme:dark;--drop_shadow:var(--drop_shadow__dark);--elevation_-2_shadow:var(--elevation_-2_shadow__dark);--elevation_-1_shadow:var(--elevation_-1_shadow__dark);--elevation_1_shadow:var(--elevation_1_shadow__dark);--elevation_2_shadow:var(--elevation_2_shadow__dark);--elevation_3_shadow:var(--elevation_3_shadow__dark)}[theme=auto]{color-scheme:light dark}@media (prefers-color-scheme:dark){[theme=auto]{--drop_shadow:var(--drop_shadow__dark);--elevation_-2_shadow:var(--elevation_-2_shadow__dark);--elevation_-1_shadow:var(--elevation_-1_shadow__dark);--elevation_1_shadow:var(--elevation_1_shadow__dark);--elevation_2_shadow:var(--elevation_2_shadow__dark);--elevation_3_shadow:var(--elevation_3_shadow__dark)}}:root{interpolate-size:allow-keywords}*,::after,::before{font-family:inherit;box-sizing:border-box;line-height:var(--line-height)}blockquote,body,code,dd,dl,dt,h1,h2,h3,h4,h5,h6,li,ol,p,pre,ul{margin:0;padding:.1px}html{font-family:var(--ff_body);font-size:var(--fs_base);font-weight:var(--fw_base);color:var(--tc);scrollbar-gutter:stable}::selection{background:var(--c_highlight)}body{min-height:100vh;background-color:var(--c_bg);color:var(--tc);overflow-y:scroll;font-family:var(--ff_body);position:relative}body.no-scroll{overflow:hidden!important}.container,main{max-width:var(--container_width);margin-left:auto;margin-right:auto;padding-top:var(--spacer);padding-left:var(--spacer);padding-right:var(--spacer)}nav>.link,nav>a{display:inline-block;padding:var(--spacer)!important;text-decoration:none}menu{margin:0;padding:0}menu a{display:block;padding:var(--spacer_q);text-decoration:none;color:inherit}summary{cursor:pointer;margin-bottom:var(--sapcer);outline:0;box-shadow:0 0 0 transparent;transition:box-shadow var(--animation_ms);border-radius:var(--radius)}summary:focus{box-shadow:var(--focus_shadow)}.d-b{display:block!important}.d-ib{display:inline-block!important}.d-g{display:grid!important}.d-i{display:inline!important}.d-n{display:none!important}.d-f{display:flex!important;flex-wrap:wrap}.d-if{display:inline-flex!important;flex-wrap:wrap}@media (min-width:1024px){.d-d-b{display:block!important}.d-d-ib{display:inline-block!important}.d-d-g{display:grid!important}.d-d-i{display:inline!important}.d-d-n{display:none!important}.d-d-f{display:flex!important;flex-wrap:wrap}.d-d-if{display:inline-flex!important;flex-wrap:wrap}}@media (min-width:769px) and (max-width:1023px){.t-d-b{display:block!important}.t-d-ib{display:inline-block!important}.t-d-g{display:grid!important}.t-d-i{display:inline!important}.t-d-n{display:none!important}.t-d-f{display:flex!important;flex-wrap:wrap}.t-d-if{display:inline-flex!important;flex-wrap:wrap}}@media (max-width:768px){.m-d-b{display:block!important}.m-d-ib{display:inline-block!important}.m-d-g{display:grid!important}.m-d-i{display:inline!important}.m-d-n{display:none!important}.m-d-f{display:flex!important;flex-wrap:wrap}.m-d-if{display:inline-flex!important;flex-wrap:wrap}}.flex,.flex-1{flex:1 1 auto}.flex-0{flex:0 0}.flex-2{flex:2 2 auto}.flex-3{flex:3 3 auto}.flex-4{flex:4 4 auto}.flex-5{flex:5 5 auto}.flex-6{flex:6 6 auto}.flex-7{flex:7 7 auto}.flex-8{flex:8 8 auto}.flex-9{flex:9 9 auto}.flex-10{flex:10 10 auto}@media (min-width:1024px){.d-d-b{display:block!important}.d-d-ib{display:inline-block!important}.d-d-g{display:grid!important}.d-d-i{display:inline!important}.d-d-n{display:none!important}.d-d-if{display:inline-flex!important;flex-wrap:wrap}.d-d-f{display:flex!important;flex-wrap:wrap}.d-flex,.d-flex-1{flex:1 1 auto}.d-flex-0{flex:0 0}.d-flex-2{flex:2 2 auto}.d-flex-3{flex:3 3 auto}.d-flex-4{flex:4 4 auto}.d-flex-5{flex:5 5 auto}.d-flex-6{flex:6 6 auto}.d-flex-7{flex:7 7 auto}.d-flex-8{flex:8 8 auto}.d-flex-9{flex:9 9 auto}.d-flex-10{flex:10 10 auto}}@media (min-width:769px) and (max-width:1023px){.t-d-b{display:block!important}.t-d-ib{display:inline-block!important}.t-d-g{display:grid!important}.t-d-i{display:inline!important}.t-d-n{display:none!important}.t-d-if{display:inline-flex!important;flex-wrap:wrap}.t-d-f{display:flex!important;flex-wrap:wrap}.t-flex,.t-flex-1{flex:1 1 auto}.t-flex-0{flex:0 0}.t-flex-2{flex:2 2 auto}.t-flex-3{flex:3 3 auto}.t-flex-4{flex:4 4 auto}.t-flex-5{flex:5 5 auto}.t-flex-6{flex:6 6 auto}.t-flex-7{flex:7 7 auto}.t-flex-8{flex:8 8 auto}.t-flex-9{flex:9 9 auto}.t-flex-10{flex:10 10 auto}}@media (max-width:768px){.m-d-b{display:block!important}.m-d-ib{display:inline-block!important}.m-d-g{display:grid!important}.m-d-i{display:inline!important}.m-d-n{display:none!important}.m-d-if{display:inline-flex!important;flex-wrap:wrap}.m-d-f{display:flex!important;flex-wrap:wrap}.m-flex,.m-flex-1{flex:1 1 auto}.m-flex-0{flex:0 0}.m-flex-2{flex:2 2 auto}.m-flex-3{flex:3 3 auto}.m-flex-4{flex:4 4 auto}.m-flex-5{flex:5 5 auto}.m-flex-6{flex:6 6 auto}.m-flex-7{flex:7 7 auto}.m-flex-8{flex:8 8 auto}.m-flex-9{flex:9 9 auto}.m-flex-10{flex:10 10 auto}}.fixed{position:fixed;top:0;width:100%;z-index:99;box-shadow:none;transition:box-shadow var(--animation_ms)}.fixed.scrolled{box-shadow:var(--elevation_2_shadow)}.small,small{font-size:var(--fs_small)!important}.large{font-size:var(--fs_large)!important}.h1,.h2,.h3,.h4,.h5,.h6,b,h1,h2,h3,h4,h5,h6,strong{font-weight:var(--fw_bold)}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:var(--ff_heading)}.h1,h1{font-size:var(--fs_h1)}.h2,h2{font-size:var(--fs_h2)}.h3,h3{font-size:var(--fs_h3)}.h4,h4{font-size:var(--fs_h4)}.h5,h5{font-size:var(--fs_h5)}.h6,h6{font-size:var(--fs_h6)}blockquote,dd,dl,h1,h2,h3,h4,h5,h6,hr,ol,p,pre,ul{margin-bottom:var(--spacer)}dl,ol,ul{padding-left:calc(1.5 * var(--spacer))}blockquote{border-left:2px solid var(--c_border);padding:var(--spacer)}mark{background-color:var(--c_highlight);color:inherit}.ff-mono,code,pre{font-family:var(--ff_mono)}code{background-color:var(--c_bg__alt);border-radius:var(--radius);word-break:break-word;padding:calc(.125 * var(--spacer)) var(--spacer_q)}pre code{display:block;padding:var(--spacer);word-break:normal;overflow:auto;white-space:pre-wrap}output{display:block;border:1px solid var(--c_border);border-radius:var(--radius);padding:var(--spacer) var(--spacer) 0 var(--spacer)}.ta-left{text-align:left}.ta-center{text-align:center}.ta-right{text-align:right}.link,a{color:var(--tc_link);box-shadow:0 0 0 transparent;transition:color var(--animation_ms),box-shadow var(--animation_ms);outline:0;border-radius:var(--radius);text-decoration:var(--link_decoration)}.link:hover,a:hover{color:var(--tc_link__hover)}.link:focus-visible,a:focus{box-shadow:var(--focus_shadow)}.no-link,.no-link:hover{text-decoration:none;color:inherit}hr{border:none;border-top:1px solid var(--c_border)}small{font-size:.75em}dl{padding:0;margin:0}dd,dt{padding-left:var(--spacer);border-left:2px solid var(--c_border)}dt{padding-top:var(--spacer_h)}dd{padding-bottom:var(--spacer_h)}dd+dd{margin-top:calc(-1 * var(--spacer));padding-top:0}li ul{margin-bottom:0}.td-n{text-decoration:none}.p,.pt,.py{padding-top:var(--spacer)!important}.p,.pr,.px{padding-right:var(--spacer)!important}.p,.pb,.py{padding-bottom:var(--spacer)!important}.p,.pl,.px{padding-left:var(--spacer)!important}.ph,.pth,.pyh{padding-top:var(--spacer_h)!important}.ph,.prh,.pxh{padding-right:var(--spacer_h)!important}.pbh,.ph,.pyh{padding-bottom:var(--spacer_h)!important}.ph,.plh,.pxh{padding-left:var(--spacer_h)!important}.pq,.ptq,.pyq{padding-top:var(--spacer_q)!important}.pq,.prq,.pxq{padding-right:var(--spacer_q)!important}.pbq,.pq,.pyq{padding-bottom:var(--spacer_q)!important}.plq,.pq,.pxq{padding-left:var(--spacer_q)!important}.p0,.pt0,.py0{padding-top:.1px!important}.p0,.pr0,.px0{padding-right:.1px!important}.p0,.pb0,.py0{padding-bottom:.1px!important}.p0,.pl0,.px0{padding-left:.1px!important}.m,.mt,.my{margin-top:var(--spacer)!important}.m,.mr,.mx{margin-right:var(--spacer)!important}.m,.mb,.my{margin-bottom:var(--spacer)!important}.m,.ml,.mx{margin-left:var(--spacer)!important}.mh,.mth,.myh{margin-top:var(--spacer_h)!important}.mh,.mrh,.mxh{margin-right:var(--spacer_h)!important}.mbh,.mh,.myh{margin-bottom:var(--spacer_h)!important}.mh,.mlh,.mxh{margin-left:var(--spacer_h)!important}.mq,.mtq,.myq{margin-top:var(--spacer_q)!important}.mq,.mrq,.mxq{margin-right:var(--spacer_q)!important}.mbq,.mq,.myq{margin-bottom:var(--spacer_q)!important}.mlq,.mq,.mxq{margin-left:var(--spacer_q)!important}.m0,.mt0,.my0{margin-top:0!important}.m0,.mr0,.mx0{margin-right:0!important}.m0,.mb0,.my0{margin-bottom:0!important}.m0,.ml0,.mx0{margin-left:0!important}.-m,.-mt,.-my{margin-top:calc(-1 * var(--spacer))!important}.-m,.-mr,.-mx{margin-right:calc(-1 * var(--spacer))!important}.-m,.-mb,.-my{margin-bottom:calc(-1 * var(--spacer))!important}.-m,.-ml,.-mx{margin-left:calc(-1 * var(--spacer))!important}.b,.bt,.by{border-top:1px solid var(--c_border)!important}.b,.br,.bx{border-right:1px solid var(--c_border)!important}.b,.bb,.by{border-bottom:1px solid var(--c_border)!important}.b,.bl,.bx{border-left:1px solid var(--c_border)!important}.b0,.bt0,.by0{border-top:none!important}.b0,.br0,.bx0{border-right:none!important}.b0,.bb0,.by0{border-bottom:none!important}.b0,.bl0,.bx0{border-left:none!important}.r,.rl,.rt,.rtl{border-top-left-radius:var(--radius)!important}.r,.rr,.rt,.rtr{border-top-right-radius:var(--radius)!important}.r,.rb,.rbr,.rr{border-bottom-right-radius:var(--radius)!important}.r,.rb,.rbl,.rl{border-bottom-left-radius:var(--radius)!important}.r0,.rl0,.rt0,.rtl0{border-top-left-radius:0!important}.r0,.rr0,.rt0,.rtr0{border-top-right-radius:0!important}.r0,.rb0,.rbr0,.rr0{border-bottom-right-radius:0!important}.r0,.rb0,.rbl0,.rl0{border-bottom-left-radius:0!important}.round{border-radius:9999rem!important}.row{display:flex;flex-wrap:wrap}.col{flex:1 1}.span-1{min-width:8.333%;flex-basis:8.333%}.span-2{min-width:16.666%;flex-basis:16.666%}.span-3{min-width:25%;flex-basis:25%}.span-4{min-width:33.333%;flex-basis:33.333%}.span-5{min-width:41.666%;flex-basis:41.666%}.span-6{min-width:50%;flex-basis:50%}.span-7{min-width:58.333%;flex-basis:58.333%}.span-8{min-width:66.666%;flex-basis:66.666%}.span-9{min-width:75%;flex-basis:75%}.span-10{min-width:83.333%;flex-basis:83.333%}.span-11{min-width:91.333%;flex-basis:91.333%}.span-12{min-width:100%;flex-basis:100%}@media (min-width:1024px){.d-span-1{min-width:8.333%;flex-basis:8.333%}.d-span-2{min-width:16.666%;flex-basis:16.666%}.d-span-3{min-width:25%;flex-basis:25%}.d-span-4{min-width:33.333%;flex-basis:33.333%}.d-span-5{min-width:41.666%;flex-basis:41.666%}.d-span-6{min-width:50%;flex-basis:50%}.d-span-7{min-width:58.333%;flex-basis:58.333%}.d-span-8{min-width:66.666%;flex-basis:66.666%}.d-span-9{min-width:75%;flex-basis:75%}.d-span-10{min-width:83.333%;flex-basis:83.333%}.d-span-11{min-width:91.333%;flex-basis:91.333%}.d-span-12{min-width:100%;flex-basis:100%}}@media (min-width:769px) and (max-width:1023px){.t-span-1{min-width:8.333%;flex-basis:8.333%}.t-span-2{min-width:16.666%;flex-basis:16.666%}.t-span-3{min-width:25%;flex-basis:25%}.t-span-4{min-width:33.333%;flex-basis:33.333%}.t-span-5{min-width:41.666%;flex-basis:41.666%}.t-span-6{min-width:50%;flex-basis:50%}.t-span-7{min-width:58.333%;flex-basis:58.333%}.t-span-8{min-width:66.666%;flex-basis:66.666%}.t-span-9{min-width:75%;flex-basis:75%}.t-span-10{min-width:83.333%;flex-basis:83.333%}.t-span-11{min-width:91.333%;flex-basis:91.333%}.t-span-12{min-width:100%;flex-basis:100%}}@media (max-width:768px){.m-span-1{min-width:8.333%;flex-basis:8.333%}.m-span-2{min-width:16.666%;flex-basis:16.666%}.m-span-3{min-width:25%;flex-basis:25%}.m-span-4{min-width:33.333%;flex-basis:33.333%}.m-span-5{min-width:41.666%;flex-basis:41.666%}.m-span-6{min-width:50%;flex-basis:50%}.m-span-7{min-width:58.333%;flex-basis:58.333%}.m-span-8{min-width:66.666%;flex-basis:66.666%}.m-span-9{min-width:75%;flex-basis:75%}.m-span-10{min-width:83.333%;flex-basis:83.333%}.m-span-11{min-width:91.333%;flex-basis:91.333%}.m-span-12{min-width:100%;flex-basis:100%}}.cols-2{grid-template-columns:repeat(2,1fr)}.cols-3{grid-template-columns:repeat(3,1fr)}.cols-4{grid-template-columns:repeat(4,1fr)}.cols-5{grid-template-columns:repeat(5,1fr)}.cols-6{grid-template-columns:repeat(6,1fr)}.cols-7{grid-template-columns:repeat(7,1fr)}.cols-8{grid-template-columns:repeat(8,1fr)}.cols-9{grid-template-columns:repeat(9,1fr)}.cols-10{grid-template-columns:repeat(10,1fr)}@media (min-width:1024px){.d-cols-2{grid-template-columns:repeat(2,1fr)}.d-cols-3{grid-template-columns:repeat(3,1fr)}.d-cols-4{grid-template-columns:repeat(4,1fr)}.d-cols-5{grid-template-columns:repeat(5,1fr)}.d-cols-6{grid-template-columns:repeat(6,1fr)}.d-cols-7{grid-template-columns:repeat(7,1fr)}.d-cols-8{grid-template-columns:repeat(8,1fr)}.d-cols-9{grid-template-columns:repeat(9,1fr)}.d-cols-10{grid-template-columns:repeat(10,1fr)}}@media (min-width:769px) and (max-width:1023px){.t-cols-2{grid-template-columns:repeat(2,1fr)}.t-cols-3{grid-template-columns:repeat(3,1fr)}.t-cols-4{grid-template-columns:repeat(4,1fr)}.t-cols-5{grid-template-columns:repeat(5,1fr)}.t-cols-6{grid-template-columns:repeat(6,1fr)}.t-cols-7{grid-template-columns:repeat(7,1fr)}.t-cols-8{grid-template-columns:repeat(8,1fr)}.t-cols-9{grid-template-columns:repeat(9,1fr)}.t-cols-10{grid-template-columns:repeat(10,1fr)}}@media (max-width:768px){.m-cols-2{grid-template-columns:repeat(2,1fr)}.m-cols-3{grid-template-columns:repeat(3,1fr)}.m-cols-4{grid-template-columns:repeat(4,1fr)}.m-cols-5{grid-template-columns:repeat(5,1fr)}.m-cols-6{grid-template-columns:repeat(6,1fr)}.m-cols-7{grid-template-columns:repeat(7,1fr)}.m-cols-8{grid-template-columns:repeat(8,1fr)}.m-cols-9{grid-template-columns:repeat(9,1fr)}.m-cols-10{grid-template-columns:repeat(10,1fr)}}.btn,button:not(.no-btn):not(.no-style),input[type=button],input[type=reset],input[type=submit]{display:inline-block;padding:var(--btn_padding);background-color:var(--btn_bg);border:1px solid var(--btn_border);cursor:pointer;outline:0;border-radius:var(--radius);color:var(--btn_tc);transition:background-color var(--animation_ms),box-shadow var(--animation_ms);text-decoration:none;box-shadow:var(--btn_box_shadow);font-size:inherit;vertical-align:middle}.btn:hover,button:not(.no-btn):not(.no-style):hover,input[type=button]:hover,input[type=reset]:hover,input[type=submit]:hover{background-color:var(--btn_bg__hover);color:var(--btn_tc);box-shadow:var(--btn_box_shadow__hover)}.btn:focus,button:not(.no-btn):not(.no-style):focus,input[type=button]:focus,input[type=reset]:focus,input[type=submit]:focus{box-shadow:var(--btn_box_shadow__hover),var(--focus_shadow);z-index:1}.btn[disabled],button:not(.no-btn):not(.no-style):disabled,input[type=button]:disabled,input[type=reset]:disabled,input[type=submit]:disabled{opacity:.6}.btn.primary,button:not(.no-btn).primary,input[type=button].primary,input[type=reset].primary,input[type=submit].primary{background-color:var(--c_primary)!important;--btn_tc:var(--tc_on_primary)}.btn.primary:hover,button:not(.no-btn).primary:hover,input[type=button].primary:hover,input[type=reset].primary:hover,input[type=submit].primary:hover{background-color:var(--c_primary__hover)!important}.btn.secondary,button:not(.no-btn).secondary,input[type=button].secondary,input[type=reset].secondary,input[type=submit].secondary{background-color:var(--c_secondary)!important;--btn_tc:var(--tc_on_secondary)}.btn.secondary:hover,button:not(.no-btn).secondary:hover,input[type=button].secondary:hover,input[type=reset].secondary:hover,input[type=submit].secondary:hover{background-color:var(--c_secondary__hover)!important}.btn.success,button:not(.no-btn).success,input[type=button].success,input[type=reset].success,input[type=submit].success{background-color:var(--c_success)!important;--btn_tc:var(--tc_on_success)}.btn.success:hover,button:not(.no-btn).success:hover,input[type=button].success:hover,input[type=reset].success:hover,input[type=submit].success:hover{background-color:var(--c_success__hover)!important}.btn.warning,button:not(.no-btn).warning,input[type=button].warning,input[type=reset].warning,input[type=submit].warning{background-color:var(--c_warning)!important;--btn_tc:var(--tc_on_warning)}.btn.warning:hover,button:not(.no-btn).warning:hover,input[type=button].warning:hover,input[type=reset].warning:hover,input[type=submit].warning:hover{background-color:var(--c_warning__hover)!important}.btn.danger,button:not(.no-btn).danger,input[type=button].danger,input[type=reset].danger,input[type=submit].danger{background-color:var(--c_danger)!important;--btn_tc:var(--tc_on_danger)}.btn.danger:hover,button:not(.no-btn).danger:hover,input[type=button].danger:hover,input[type=reset].danger:hover,input[type=submit].danger:hover{background-color:var(--c_danger__hover)!important}.btn.link,button:not(.no-btn):not(.no-style).link,input[type=button].link,input[type=reset].link,input[type=submit].link{background-color:transparent;color:inherit;box-shadow:0 0 0 transparent;border:none;padding:.1px;font-size:inherit}.btn-grp{display:inline-flex}.btn-grp .btn:not(:first-child),.btn-grp button:not(.no-btn):not(:first-child),.btn-grp input[type=button]:not(:first-child),.btn-grp input[type=reset]:not(:first-child),.btn-grp input[type=submit]:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0;border-left:1px solid rgba(0,0,0,.25)}.btn-grp .btn:not(:last-child),.btn-grp button:not(.no-btn):not(:last-child),.btn-grp input[type=button]:not(:last-child),.btn-grp input[type=reset]:not(:last-child),.btn-grp input[type=submit]:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.no-btn{display:inline;border:none;background-color:transparent;padding:0;font-size:inherit;font-family:inherit;cursor:pointer;outline:0;box-shadow:0 0 0 transparent;transition:box-shadow var(--animation_ms);border-radius:0;text-align:left;color:inherit}.no-btn:focus{box-shadow:var(--focus_shadow)}.full{display:block;width:100%}input:not([type=button]):not([type=submit]):not([type=reset]):not([type=radio]):not([type=checkbox]),select,textarea{display:block;width:100%;background-color:var(--input_bg);color:var(--input_tc);border:var(--input_border_width) solid var(--c_input_border);padding:var(--input_padding);border-radius:var(--radius);outline:0;transition:box-shadow var(--animation_ms)}input:not([type=button]):not([type=submit]):not([type=reset]):not([type=radio]):not([type=checkbox]):focus,input[type=checkbox]:focus,input[type=radio]:focus,select:focus,textarea:focus{box-shadow:var(--focus_shadow)}input:not([type=button]):not([type=submit]):not([type=reset]):not([type=radio]):not([type=checkbox]):disabled,input[type=checkbox]:disabled,input[type=radio]:disabled,select:disabled,textarea:disabled{opacity:.6}select[multiple],textarea{resize:vertical;max-height:75vh;height:6rem;min-height:4rem}select[multiple]{height:8rem}select{cursor:pointer}label{display:block;cursor:pointer;padding-bottom:var(--spacer_h)}label.checkbox,label.radio{display:inline-block;vertical-align:middle;width:calc(100% - 2em - (2 * var(--spacer_h)) - 6px)}input[type=checkbox],input[type=radio]{display:inline-block;width:1em;height:1em;cursor:pointer;vertical-align:middle;accent-color:var(--c_input_accent);margin:var(--spacer_q) var(--spacer_h);transition:background-color var(--animation_ms),color var(--animation_ms),box-shadow var(--animation_ms)}input[type=checkbox]{width:1.75em;height:1.75em;appearance:none;-webkit-appearance:none;background-color:transparent;border:2px solid var(--c_border);border-radius:var(--radius);vertical-align:-.5em;position:relative}input[type=checkbox]::before{content:"";position:absolute;inset:0;border-radius:calc(var(--radius) - 2px);background-color:transparent;mask-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="200 -760 560 560"><path d="m424-312 282-282-56-56-226 226-114-114-56 56 170 170Z"/></svg>');-webkit-mask-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="200 -760 560 560"><path d="m424-312 282-282-56-56-226 226-114-114-56 56 170 170Z"/></svg>');mask-size:contain;-webkit-mask-size:contain;mask-position:center;-webkit-mask-position:center;mask-repeat:no-repeat;-webkit-mask-repeat:no-repeat;transition:background-color var(--animation_ms)}input[type=checkbox]:focus{box-shadow:var(--focus_shadow);outline:0}input[type=checkbox]:checked{background-color:var(--c_primary);border-color:var(--c_primary)}input[type=checkbox]:checked::before{background-color:var(--tc_on_primary)}input[type=radio]{width:1.75em;height:1.75em;appearance:none;-webkit-appearance:none;background-color:transparent;border:2px solid var(--c_border);border-radius:50%;vertical-align:-.5em}input[type=radio]:focus{box-shadow:var(--focus_shadow);outline:0}input[type=radio]:checked{border-color:var(--c_primary);background:radial-gradient(circle,var(--c_primary) 40%,transparent 45%)}select option{padding:var(--spacer_h) var(--spacer);background-color:var(--input_bg);color:var(--input_tc)}select[multiple]{padding:.1px}select[multiple] option{padding:var(--spacer_h) var(--spacer)}input[type=color]{padding:0!important;height:2.35em}input[type=color]::-webkit-color-swatch-wrapper{padding:0}input[type=color]::-webkit-color-swatch{border-radius:var(--radius,.25rem);cursor:pointer}input[type=date]::-webkit-calendar-picker-indicator,input[type=month]::-webkit-calendar-picker-indicator,input[type=search]::-webkit-search-cancel-button,input[type=time]::-webkit-calendar-picker-indicator,input[type=week]::-webkit-calendar-picker-indicator{filter:var(--date_picker_icon_filter)}.table-wrapper{overflow-x:auto}table{width:100%;border-spacing:0}th{font-weight:var(--fw_bold);text-align:left;background-color:var(--c_bg__alt);border-top:1px solid var(--c_border)}td,th{padding:var(--spacer_h) var(--spacer);border-bottom:1px solid var(--c_border);border-left:1px solid var(--c_border)}td:last-child,th:last-child{border-right:1px solid var(--c_border)}th:first-child{border-top-left-radius:var(--radius)}th:last-child{border-top-right-radius:var(--radius)}tr:last-child td:first-child{border-bottom-left-radius:var(--radius)}tr:last-child td:last-child{border-bottom-right-radius:var(--radius)}.bg-default{background-color:var(--c_bg)!important;color:var(--tc)!important}.bg-alt{background-color:var(--c_bg__alt)!important;color:var(--tc)!important}.bg-inv{--c_primary:var(--c_primary__inv);--c_primary__hover:var(--c_primary__inv__hover);--c_secondary:var(--c_secondary__inv);--c_secondary__hover:var(--c_secondary__inv__hover);--c_success:var(--c_success__inv);--c_success__hover:var(--c_success__inv__hover);--c_warning:var(--c_warning__inv);--c_warning__hover:var(--c_warning__inv__hover);--c_danger:var(--c_danger__inv);--c_danger__hover:var(--c_danger__inv__hover);--tc_link:var(--tc_link__inv);--tc_link__hover:var(--tc_link__inv__hover);background-color:var(--c_bg__inv)!important;color:var(--tc_inv)!important}.bg-primary{--tc_link:var(--tc_on_primary);--tc_link__hover:var(--tc_on_primary);--c_border:var(--tc_on_primary);background-color:var(--c_primary)!important;color:var(--tc_on_primary)!important;--focus_shadow:var(--focus_shadow_on_primary)}.bg-secondary{--tc_link:var(--tc_on_secondary);--tc_link__hover:var(--tc_on_secondary);background-color:var(--c_secondary)!important;color:var(--tc_on_secondary)!important}.bg-success{--tc_link:var(--tc_on_success);--tc_link__hover:var(--tc_on_success);background-color:var(--c_success)!important;color:var(--tc_on_success)!important}.bg-warning{--tc_link:var(--tc_on_warning);--tc_link__hover:var(--tc_on_warning);background-color:var(--c_warning)!important;color:var(--tc_on_warning)!important}.bg-danger{--tc_link:var(--tc_on_danger);--tc_link__hover:var(--tc_on_danger);background-color:var(--c_danger)!important;color:var(--tc_on_danger)!important}.tc-default{color:var(--tc)!important}.tc-inv{color:var(--tc__inv)!important}.tc-primary{color:var(--tc_primary)!important}.bg-inv .tc-primary,.is-inv .tc-primary{color:var(--tc_primary__inv)!important}.tc-secondary{color:var(--tc_secondary)!important}.bg-inv .tc-secondary,.is-inv .tc-secondary{color:var(--tc_secondary__inv)!important}.tc-success{color:var(--tc_success)!important}.bg-inv .tc-success,.is-inv .tc-success{color:var(--tc_success__inv)!important}.tc-warning{color:var(--tc_warning)!important}.bg-inv .tc-warning,.is-inv .tc-warning{color:var(--tc_warning__inv)!important}.tc-danger{color:var(--tc_danger)!important}.bg-inv .tc-danger,.is-inv .tc-danger{color:var(--tc_danger__inv)!important}.tc-muted{color:var(--tc_muted)!important}.card{border:1px solid var(--c_border);border-radius:var(--radius);padding-top:var(--spacer);padding-left:var(--spacer);padding-right:var(--spacer);margin-bottom:var(--spacer)}.drop-shadow{box-shadow:var(--drop_shadow)}.elevation--2{background-color:var(--elevation_-2_bg);box-shadow:var(--elevation_-2_shadow)}.elevation--1{background-color:var(--elevation_-1_bg);box-shadow:var(--elevation_-1_shadow)}.elevation-0{background-color:var(--elevation_0_bg);box-shadow:var(--elevation_0_shadow)}.elevation-1{background-color:var(--elevation_1_bg);box-shadow:var(--elevation_1_shadow)}.elevation-2{background-color:var(--elevation_2_bg);box-shadow:var(--elevation_2_shadow)}.elevation-3{background-color:var(--elevation_3_bg);box-shadow:var(--elevation_3_shadow)}.icon{display:inline-block;width:1.35em;vertical-align:top;margin-left:auto;margin-right:auto}iframe{border:none;width:100%}
|
package/docs/nav.inc.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
document.getElementById('toggleNavSideMenu').addEventListener('click', async () => {
|
|
2
|
-
await window.customElements.whenDefined('k-side-menu');
|
|
3
|
-
document.getElementById('navSideMenu').toggle();
|
|
4
|
-
});
|
|
5
|
-
document.addEventListener('click', function(e) {
|
|
6
|
-
if (e.target.matches('a[href^="#"]')) {
|
|
7
|
-
e.preventDefault();
|
|
8
|
-
const targetId = e.target.getAttribute('href').replace('#', '');
|
|
9
|
-
const target = document.getElementById(targetId);
|
|
10
|
-
if (target) {
|
|
11
|
-
target.scrollIntoView({ behavior: 'smooth' });
|
|
12
|
-
const url = window.location.pathname + window.location.search + '#' + targetId;
|
|
13
|
-
history.replaceState(null, '', url);
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
});
|