obol
Owner: IIIlllIIIllI URL: git@git.0x00nyx.xyz:seb/obolserver.git
first commit
Commit 2613222bd99cc2f78f8212acafbf66625d4655d4 by IIIlllIIIllI <seb.michalk@gmail.com> on 2025-12-21 13:06:43 +0100
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ec15429
--- /dev/null
+++ b/README.md
@@ -0,0 +1,126 @@
+Obol - small static site server in Nim
+=====================================
+
+Obol serves a directory of JSON content and HTML templates with a single
+self-contained binary. No dependencies outside Nim stdlib.
+
+Features
+--------
+- declarative routing from config.json
+- JSON content files, no schema enforcement
+- minimal template engine (includes, blocks, if/range)
+- static files from /static
+- param routes with {param} data paths
+- hot reload in DEV_MODE
+
+Installation
+------------
+Build with Nim:
+ nim c -d:release obol.nim
+
+Usage
+-----
+1. Prepare a site directory (see layout below or `example-site/`).
+2. Run the server:
+ SITE_DIR=/path/to/site ./obol
+ or build the server binary and run it from there.
+3. Optional env:
+ - PORT (default 8080)
+ - SITE_DIR (default .)
+ - DEV_MODE=1 (reload config/global on mtime change)
+
+Layout
+------
+Each site directory follows this structure:
+- config.json
+- content/global.json
+- content/pages/*.json
+- templates/*.html
+- static/
+
+config.json
+-----------
+Example:
+ {
+ "site": {...},
+ "navigation": [...],
+ "pages": [
+ {
+ "path": "/docs",
+ "template": "docs.html",
+ "data": "docs.json"
+ }
+ ],
+ "notFoundTemplate": "404.html"
+ }
+
+Rules:
+- paths must be unique
+- template names must exist in templates/
+- data is optional; missing file -> empty object
+
+Render Data
+-----------
+Every template gets a single JSON object:
+ {
+ "config": config.json tree,
+ "global": content/global.json tree,
+ "page": current page data,
+ "pageConfig": the page entry from config.json,
+ "request": {"path", "method", "params"}
+ }
+
+Template Approach
+-----------------
+Templates are plain HTML with a tiny, data-only template language. There is
+no custom context and no helpers: everything comes from the render data above.
+Includes and blocks are resolved at runtime and cached in production.
+
+Template Syntax
+---------------
+- Variable: {{name}} (supports dotted paths, e.g. {{page.title}})
+- Conditionals:
+ {{if page.published}}
+ ...
+ {{else}}
+ ...
+ {{end}}
+- Ranges (arrays/objects):
+ {{range page.items}}
+ {{name}}
+ {{end}}
+- Include another template file:
+ {{template "header"}} -> templates/header.html
+- Define a named block:
+ {{define "layout"}}
+ ...
+ {{end}}
+
+Notes:
+- include depth is capped at 10; circular chains error
+- include data is always the merged render object (no custom context)
+
+Routing
+-------
+- routes are defined only in config.json
+- /static/* is served automatically when static/ exists
+- /health is provided unless you define it
+- :param segments are supported; /blog/:slug exposes request.params.slug
+- exact routes win over parameterized, then fewer params, then declaration order
+
+Data Paths
+----------
+- data accepts {param} placeholders: posts/{slug}.json
+- missing params or files -> 404 (with log message)
+- resolved paths are clamped to content/ (no traversal)
+
+Error Handling
+--------------
+- missing route -> render notFoundTemplate or plain 404
+- JSON parse failure -> 500, logged to stdout
+- template failure -> 500, logged to stdout
+- framework never crashes on user content
+
+Philosophy
+----------
+What Obol does: map URL paths to JSON files and render them into HTML.
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..219c4c6
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,23 @@
+#!/bin/sh
+# obol - build script
+# see LICENSE for copyright and license details.
+
+die() {
+ printf "error: %s\n" "$1" >&2
+ exit 1
+}
+
+check_cmd() {
+ command -v "$1" >/dev/null 2>&1 || die "$1 not found"
+}
+
+check_cmd nim
+
+printf "building obol...\n"
+
+nim c \
+ -d:release \
+ --opt:size \
+ obol.nim || die "build failed"
+
+printf "done.\n"
diff --git a/example-site/config.json b/example-site/config.json
new file mode 100644
index 0000000..56776ec
--- /dev/null
+++ b/example-site/config.json
@@ -0,0 +1,13 @@
+{
+ "site": {
+ "title": "Example Site",
+ "baseUrl": "http://localhost:8080"
+ },
+ "pages": [
+ {
+ "path": "/",
+ "template": "home.html",
+ "data": "home.json"
+ }
+ ]
+}
diff --git a/example-site/content/global.json b/example-site/content/global.json
new file mode 100644
index 0000000..3f2dae8
--- /dev/null
+++ b/example-site/content/global.json
@@ -0,0 +1,3 @@
+{
+ "tagline": "Static content, minimal server"
+}
diff --git a/example-site/content/pages/home.json b/example-site/content/pages/home.json
new file mode 100644
index 0000000..1761564
--- /dev/null
+++ b/example-site/content/pages/home.json
@@ -0,0 +1,9 @@
+{
+ "title": "Welcome",
+ "published": true,
+ "items": [
+ {"name": "Routing from config.json"},
+ {"name": "JSON content files"},
+ {"name": "Tiny templates"}
+ ]
+}
diff --git a/example-site/obol b/example-site/obol
new file mode 100755
index 0000000..62aed60
Binary files /dev/null and b/example-site/obol differ
diff --git a/example-site/static/robots.txt b/example-site/static/robots.txt
new file mode 100644
index 0000000..eb05362
--- /dev/null
+++ b/example-site/static/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow:
diff --git a/example-site/templates/header.html b/example-site/templates/header.html
new file mode 100644
index 0000000..e556166
--- /dev/null
+++ b/example-site/templates/header.html
@@ -0,0 +1,4 @@
+<header>
+ <h1>{{config.site.title}}</h1>
+ <p>{{global.tagline}}</p>
+</header>
diff --git a/example-site/templates/home.html b/example-site/templates/home.html
new file mode 100644
index 0000000..bcceb11
--- /dev/null
+++ b/example-site/templates/home.html
@@ -0,0 +1,14 @@
+{{define "content"}}
+<h2>{{page.title}}</h2>
+{{if page.published}}
+ <ul>
+ {{range page.items}}
+ <li>{{name}}</li>
+ {{end}}
+ </ul>
+{{else}}
+ <p>Not published.</p>
+{{end}}
+{{end}}
+
+{{template "layout"}}
diff --git a/example-site/templates/layout.html b/example-site/templates/layout.html
new file mode 100644
index 0000000..1009261
--- /dev/null
+++ b/example-site/templates/layout.html
@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{{config.site.title}}</title>
+ </head>
+ <body>
+ {{template "header"}}
+ <main>
+ {{template "content"}}
+ </main>
+ </body>
+</html>
diff --git a/obol.nim b/obol.nim
new file mode 100644
index 0000000..f2f75a1
--- /dev/null
+++ b/obol.nim
@@ -0,0 +1,960 @@
+# (c) 2025 Sebastian Michalk <sebastian.michalk@pm.me>
+
+import std/asynchttpserver
+import std/asyncdispatch
+import std/httpcore
+import std/json
+import std/os
+import std/options
+import std/sets
+import std/strutils
+import std/tables
+import std/times
+import std/uri
+
+const
+ defaultPort = 8080
+ defaultMimeType = "application/octet-stream"
+
+# ----------------
+# Request/Response
+# ----------------
+
+type
+ Headers = Table[string, string]
+
+ Request = object
+ httpMethod: string
+ path: string
+ headers: Headers
+ body: string
+ params: Headers
+
+ Response = object
+ status: int
+ headers: Headers
+ body: string
+
+proc newHeaders(): Headers =
+ initTable[string, string]()
+
+proc newResponse(status: int = 200, body: string = ""): Response =
+ result.status = status
+ result.headers = newHeaders()
+ result.body = body
+
+proc notFoundResponse(): Response =
+ newResponse(404, "not found")
+
+proc serverErrorResponse(): Response =
+ newResponse(500, "internal server error")
+
+# ------
+# Router
+# ------
+
+type
+ SegmentKind = enum
+ segLiteral
+ segParam
+
+ RouteSegment = object
+ kind: SegmentKind
+ name: string
+ value: string
+
+ Handler = proc (req: Request): Response {.closure, gcsafe.}
+
+ Route = object
+ httpMethod: string
+ segments: seq[RouteSegment]
+ paramCount: int
+ handler: Handler
+
+ Router = object
+ exact: Table[string, Handler]
+ parametric: seq[Route]
+
+ DataSegmentKind = enum
+ dskLiteral
+ dskParam
+
+ DataSegment = object
+ kind: DataSegmentKind
+ value: string
+
+proc normalizeUrlPath(raw: string): string =
+ var cleaned = raw.strip()
+ if cleaned.len == 0:
+ return "/"
+ let queryPos = cleaned.find('?')
+ if queryPos >= 0:
+ cleaned = cleaned[0 ..< queryPos]
+ if cleaned.len == 0:
+ return "/"
+ if not cleaned.startsWith('/'):
+ cleaned = "/" & cleaned
+ var parts: seq[string]
+ for part in cleaned.split('/'):
+ if part.len > 0:
+ parts.add(part)
+ if parts.len == 0:
+ return "/"
+ "/" & parts.join("/")
+
+proc splitPath(path: string): seq[string] =
+ let normalized = normalizeUrlPath(path)
+ if normalized == "/":
+ return @[]
+ for part in normalized[1 .. ^1].split('/'):
+ if part.len > 0:
+ result.add(part)
+
+proc isValidParamName(name: string): bool =
+ if name.len == 0:
+ return false
+ for ch in name:
+ if not (ch.isAlphaNumeric or ch == '_'):
+ return false
+ true
+
+proc parsePattern(pattern: string): Route =
+ let segments = splitPath(pattern)
+ var route = Route()
+ route.segments = @[]
+ for raw in segments:
+ if raw.len == 0:
+ continue
+ if raw[0] == ':':
+ let name = raw[1 .. ^1]
+ if not isValidParamName(name):
+ raise newException(ValueError, "invalid parameter name in pattern '" & pattern & "'")
+ route.segments.add(RouteSegment(kind: segParam, name: name))
+ inc route.paramCount
+ else:
+ if raw.contains(':'):
+ raise newException(ValueError, "unexpected ':' in literal segment of pattern '" & pattern & "'")
+ route.segments.add(RouteSegment(kind: segLiteral, value: raw))
+ route
+
+proc parseDataPath(pattern: string, allowed: HashSet[string]): seq[DataSegment] =
+ if pattern.len == 0:
+ return @[]
+ var idx = 0
+ var literal = newStringOfCap(pattern.len)
+ while idx < pattern.len:
+ let ch = pattern[idx]
+ if ch == '{':
+ inc idx
+ var name = ""
+ while idx < pattern.len and pattern[idx] != '}':
+ name.add(pattern[idx])
+ inc idx
+ if idx >= pattern.len or pattern[idx] != '}':
+ raise newException(ValueError, "unterminated parameter in data path '" & pattern & "'")
+ if literal.len > 0:
+ result.add(DataSegment(kind: dskLiteral, value: literal))
+ literal.setLen(0)
+ if not isValidParamName(name):
+ raise newException(ValueError, "invalid parameter name '" & name & "' in data path '" & pattern & "'")
+ if name notin allowed:
+ raise newException(ValueError, "data path references unknown parameter '" & name & "'")
+ result.add(DataSegment(kind: dskParam, value: name))
+ elif ch == '}':
+ raise newException(ValueError, "unexpected '}' in data path '" & pattern & "'")
+ else:
+ literal.add(ch)
+ inc idx
+ if literal.len > 0:
+ result.add(DataSegment(kind: dskLiteral, value: literal))
+
+proc decodeComponent(value: string): string =
+ try:
+ result = value.decodeUrl()
+ except ValueError:
+ result = value
+
+proc routeMatches(route: Route, httpMethod: string, pathSegments: seq[string], params: var Headers): bool =
+ if route.httpMethod != httpMethod:
+ return false
+ if pathSegments.len != route.segments.len:
+ return false
+ for idx, segment in route.segments:
+ let value = pathSegments[idx]
+ case segment.kind
+ of segLiteral:
+ if value != segment.value:
+ return false
+ of segParam:
+ params[segment.name] = decodeComponent(value)
+ true
+
+proc initRouter(): Router =
+ Router(
+ exact: initTable[string, Handler](),
+ parametric: @[]
+ )
+
+proc routeKey(httpMethod, path: string): string =
+ httpMethod & "\x1f" & path
+
+proc addRoute(router: var Router, httpMethod, path: string, handler: Handler) =
+ let normalized = normalizeUrlPath(path)
+ var pattern = parsePattern(normalized)
+ pattern.httpMethod = httpMethod.toUpperAscii()
+ pattern.handler = handler
+ if pattern.paramCount == 0:
+ let key = routeKey(pattern.httpMethod, normalized)
+ if key in router.exact:
+ raise newException(ValueError, "duplicate route for " & httpMethod & " " & normalized)
+ router.exact[key] = handler
+ else:
+ var inserted = false
+ for idx, existing in router.parametric:
+ if pattern.paramCount < existing.paramCount:
+ router.parametric.insert(pattern, idx)
+ inserted = true
+ break
+ if not inserted:
+ router.parametric.add(pattern)
+
+proc match(router: Router, httpMethod, path: string): Option[tuple[handler: Handler, params: Headers]] =
+ let normalized = normalizeUrlPath(path)
+ let methodUpper = httpMethod.toUpperAscii()
+ let key = routeKey(methodUpper, normalized)
+ if key in router.exact:
+ return some((router.exact[key], newHeaders()))
+
+ let segments = splitPath(normalized)
+ for route in router.parametric:
+ var params = newHeaders()
+ if routeMatches(route, methodUpper, segments, params):
+ return some((route.handler, params))
+ none(tuple[handler: Handler, params: Headers])
+
+# ----------------
+# Template Engine
+# ----------------
+
+type
+ NodeKind = enum
+ nkText
+ nkVar
+ nkIf
+ nkRange
+ nkInclude
+ nkDefine
+
+ TemplateNode = ref object
+ kind: NodeKind
+ text: string
+ name: string
+ children: seq[TemplateNode]
+ elseBranch: seq[TemplateNode]
+
+ Template = object
+ nodes: seq[TemplateNode]
+ definitions: TemplateDefs
+
+ TemplateEntry = object
+ compiled: Template
+ mtime: Time
+
+ TemplateCache = object
+ dir: string
+ entries: Table[string, TemplateEntry]
+
+ TemplateDefs = TableRef[string, seq[TemplateNode]]
+
+proc newTemplateDefs(): TemplateDefs =
+ newTable[string, seq[TemplateNode]]()
+
+proc newTextNode(content: string): TemplateNode =
+ TemplateNode(kind: nkText, text: content)
+
+proc newVarNode(name: string): TemplateNode =
+ TemplateNode(kind: nkVar, name: name)
+
+proc newBlockNode(kind: NodeKind, name: string): TemplateNode =
+ TemplateNode(kind: kind, name: name, children: @[], elseBranch: @[])
+
+proc addChild(stack: seq[TemplateNode], inElse: seq[bool], child: TemplateNode) =
+ if stack.len == 0:
+ return
+ let current = stack[^1]
+ if current.kind == nkIf and inElse[^1]:
+ current.elseBranch.add(child)
+ else:
+ current.children.add(child)
+
+proc pushText(stack: seq[TemplateNode], inElse: seq[bool], content: string) =
+ if content.len == 0:
+ return
+ addChild(stack, inElse, newTextNode(content))
+
+proc parseQuotedName(raw: string, directive: string): string =
+ if raw.len < 2 or raw[0] != '"' or raw[^1] != '"':
+ raise newException(ValueError, directive & " expects quoted identifier")
+ let name = raw[1 ..< raw.len - 1]
+ if name.len == 0:
+ raise newException(ValueError, directive & " received empty name")
+ name
+
+proc parseTemplateString(source: string): Template =
+ var tpl = Template(nodes: @[], definitions: newTemplateDefs())
+ var root = newBlockNode(nkIf, "__root__")
+ var stack = @[root]
+ var inElse = @[false]
+ var cursor = 0
+ while cursor < source.len:
+ let openIdx = source.find("{{", cursor)
+ if openIdx < 0:
+ pushText(stack, inElse, source[cursor .. ^1])
+ break
+ pushText(stack, inElse, source[cursor ..< openIdx])
+ let closeIdx = source.find("}}", openIdx + 2)
+ if closeIdx < 0:
+ raise newException(ValueError, "unclosed template tag")
+ let rawTag = source[(openIdx + 2) ..< closeIdx].strip()
+ cursor = closeIdx + 2
+ if rawTag.len == 0:
+ continue
+ if rawTag.startsWith("if "):
+ let name = rawTag[3 .. ^1].strip()
+ let node = newBlockNode(nkIf, name)
+ addChild(stack, inElse, node)
+ stack.add(node)
+ inElse.add(false)
+ elif rawTag == "end":
+ if stack.len <= 1:
+ raise newException(ValueError, "unexpected {{end}}")
+ let finished = stack.pop()
+ discard inElse.pop()
+ if finished.kind == nkDefine:
+ tpl.definitions[finished.name] = finished.children
+ elif rawTag == "else":
+ if stack.len <= 1 or stack[^1].kind != nkIf:
+ raise newException(ValueError, "{{else}} without matching {{if}}")
+ if inElse[^1]:
+ raise newException(ValueError, "duplicate {{else}}")
+ inElse[^1] = true
+ elif rawTag.startsWith("range "):
+ let name = rawTag[6 .. ^1].strip()
+ let node = newBlockNode(nkRange, name)
+ addChild(stack, inElse, node)
+ stack.add(node)
+ inElse.add(false)
+ elif rawTag.startsWith("template "):
+ let arg = rawTag[8 .. ^1].strip()
+ if arg.len == 0:
+ raise newException(ValueError, "{{template}} requires a name")
+ let name = parseQuotedName(arg, "template")
+ addChild(stack, inElse, TemplateNode(kind: nkInclude, name: name))
+ elif rawTag.startsWith("define "):
+ if stack.len != 1:
+ raise newException(ValueError, "{{define}} must appear at top level")
+ let arg = rawTag[6 .. ^1].strip()
+ if arg.len == 0:
+ raise newException(ValueError, "{{define}} requires a name")
+ let name = parseQuotedName(arg, "define")
+ let node = TemplateNode(kind: nkDefine, name: name, children: @[], elseBranch: @[])
+ stack.add(node)
+ inElse.add(false)
+ else:
+ addChild(stack, inElse, newVarNode(rawTag))
+ if stack.len != 1:
+ raise newException(ValueError, "unclosed block in template")
+ tpl.nodes = root.children
+ result = tpl
+
+proc initTemplateCache(dir: string): TemplateCache =
+ TemplateCache(dir: dir, entries: initTable[string, TemplateEntry]())
+
+proc templatePath(cache: TemplateCache, name: string): string =
+ if cache.dir.len == 0:
+ return name
+ if cache.dir.isAbsolute():
+ return cache.dir / name
+ getCurrentDir() / cache.dir / name
+
+proc loadTemplate(cache: var TemplateCache, name: string): Template {.gcsafe.} =
+ let path = templatePath(cache, name)
+ if not fileExists(path):
+ raise newException(IOError, "template not found: " & path)
+ let info = getFileInfo(path)
+ if name in cache.entries:
+ let entry = cache.entries[name]
+ if entry.mtime == info.lastWriteTime:
+ return entry.compiled
+ let content = readFile(path)
+ let tpl = parseTemplateString(content)
+ cache.entries[name] = TemplateEntry(compiled: tpl, mtime: info.lastWriteTime)
+ tpl
+
+proc lookupField(node: JsonNode, parts: seq[string]): JsonNode {.gcsafe.} =
+ var current = node
+ for part in parts:
+ if part == ".":
+ continue
+ if current.kind == JObject and part in current:
+ current = current[part]
+ else:
+ return newJNull()
+ current
+
+proc resolveValue(identifier: string, ctx, parent: JsonNode): JsonNode {.gcsafe.} =
+ if identifier == ".":
+ return ctx
+ let parts = identifier.split('.')
+ var value = lookupField(ctx, parts)
+ if value.kind != JNull:
+ return value
+ lookupField(parent, parts)
+
+proc isTruthy(node: JsonNode): bool {.gcsafe.} =
+ case node.kind
+ of JNull:
+ false
+ of JBool:
+ node.getBool()
+ of JInt:
+ node.getInt() != 0
+ of JFloat:
+ node.getFloat() != 0.0
+ of JString:
+ node.getStr().len > 0
+ of JArray:
+ node.len > 0
+ of JObject:
+ node.len > 0
+
+const
+ maxIncludeDepth = 10
+
+proc combineDefinitions(base, overrides: TemplateDefs): TemplateDefs {.gcsafe.} =
+ let combined = newTemplateDefs()
+ if base != nil:
+ for key, nodes in base.pairs:
+ combined[key] = nodes
+ if overrides != nil:
+ for key, nodes in overrides.pairs:
+ combined[key] = nodes
+ combined
+
+proc renderNodes(cache: var TemplateCache, nodes: seq[TemplateNode], ctx, parent: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.}
+
+proc renderInclude(cache: var TemplateCache, name: string, ctx: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.} =
+ if depth > maxIncludeDepth:
+ raise newException(ValueError, "template include depth exceeded")
+ if defs != nil and defs.hasKey(name):
+ renderNodes(cache, defs[name], ctx, ctx, acc, defs, stack, depth + 1)
+ return
+ let fileName = if name.endsWith(".html"): name else: name & ".html"
+ for existing in stack:
+ if existing == fileName:
+ raise newException(ValueError, "circular template include: " & stack.join(" -> ") & " -> " & fileName)
+ stack.add(fileName)
+ let tpl = loadTemplate(cache, fileName)
+ let combined = combineDefinitions(tpl.definitions, defs)
+ renderNodes(cache, tpl.nodes, ctx, newJNull(), acc, combined, stack, depth + 1)
+ discard stack.pop()
+
+proc renderNode(cache: var TemplateCache, node: TemplateNode, ctx, parent: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.} =
+ case node.kind
+ of nkText:
+ acc.add(node.text)
+ of nkVar:
+ let value = resolveValue(node.name, ctx, parent)
+ if value.kind == JString:
+ acc.add(value.getStr())
+ elif value.kind in {JInt, JFloat, JBool}:
+ acc.add($value)
+ of nkIf:
+ let value = resolveValue(node.name, ctx, parent)
+ if isTruthy(value):
+ renderNodes(cache, node.children, ctx, parent, acc, defs, stack, depth)
+ elif node.elseBranch.len > 0:
+ renderNodes(cache, node.elseBranch, ctx, parent, acc, defs, stack, depth)
+ of nkRange:
+ let value = resolveValue(node.name, ctx, parent)
+ if value.kind == JArray:
+ for element in value.items:
+ renderNodes(cache, node.children, element, ctx, acc, defs, stack, depth)
+ elif value.kind == JObject:
+ renderNodes(cache, node.children, value, ctx, acc, defs, stack, depth)
+ of nkInclude:
+ renderInclude(cache, node.name, ctx, acc, defs, stack, depth)
+ of nkDefine:
+ discard
+
+proc renderNodes(cache: var TemplateCache, nodes: seq[TemplateNode], ctx, parent: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.} =
+ for node in nodes:
+ renderNode(cache, node, ctx, parent, acc, defs, stack, depth)
+
+proc renderTemplate(cache: var TemplateCache, tpl: Template, data: JsonNode, overrides: TemplateDefs, stack: var seq[string], depth: int, acc: var string) {.gcsafe.} =
+ let defs = combineDefinitions(tpl.definitions, overrides)
+ renderNodes(cache, tpl.nodes, data, newJNull(), acc, defs, stack, depth)
+
+proc render(cache: var TemplateCache, name: string, data: JsonNode): string {.gcsafe.} =
+ let tpl = loadTemplate(cache, name)
+ var acc = newStringOfCap(256)
+ var stack: seq[string] = @[]
+ stack.add(name)
+ renderTemplate(cache, tpl, data, tpl.definitions, stack, 0, acc)
+ discard stack.pop()
+ result = acc
+
+# ------------------
+# Static File Server
+# ------------------
+
+proc guessMimeType(path: string): string =
+ let ext = splitFile(path).ext.toLowerAscii()
+ case ext
+ of ".html", ".htm": result = "text/html; charset=utf-8"
+ of ".css": result = "text/css"
+ of ".js": result = "application/javascript"
+ of ".json": result = "application/json"
+ of ".png": result = "image/png"
+ of ".jpg", ".jpeg": result = "image/jpeg"
+ of ".gif": result = "image/gif"
+ of ".svg": result = "image/svg+xml"
+ of ".txt": result = "text/plain; charset=utf-8"
+ else: result = defaultMimeType
+
+proc serveFile(path: string): Response =
+ if not fileExists(path):
+ return notFoundResponse()
+ var resp = newResponse(200, readFile(path))
+ resp.headers["content-type"] = guessMimeType(path)
+ resp
+
+proc cleanStaticPath(baseDir, rel: string): string =
+ let baseAbs = absolutePath(baseDir)
+ var segments: seq[string]
+ for raw in rel.split({'/', '\\'}):
+ let part = raw.strip()
+ if part.len == 0 or part == ".":
+ continue
+ if part == "..":
+ return ""
+ segments.add(part)
+ var target = baseAbs
+ for segment in segments:
+ target = joinPath(target, segment)
+ if not target.startsWith(baseAbs):
+ return ""
+ target
+
+proc serveDir(baseDir, urlPrefix: string): Handler =
+ let prefix = normalizeUrlPath(urlPrefix)
+ result = proc (req: Request): Response {.gcsafe.} =
+ var relative = ""
+ if prefix == "/":
+ relative = req.path
+ else:
+ if not req.path.startsWith(prefix):
+ return notFoundResponse()
+ if req.path.len > prefix.len:
+ relative = req.path[prefix.len .. ^1]
+ else:
+ relative = ""
+ if relative.startsWith('/'):
+ if relative.len == 1:
+ relative = ""
+ else:
+ relative = relative[1 .. ^1]
+ let target = cleanStaticPath(baseDir, relative)
+ if target.len == 0 or dirExists(target) or not fileExists(target):
+ return notFoundResponse()
+ serveFile(target)
+
+# ---------------------
+# Site configuration
+# ---------------------
+
+type
+ PageSpec = object
+ path: string
+ templateFile: string
+ dataFile: string
+ raw: JsonNode
+ paramNames: seq[string]
+ dataSegments: seq[DataSegment]
+
+ Site = ref object
+ dir: string
+ configPath: string
+ contentDir: string
+ pagesDir: string
+ templatesDir: string
+ staticDir: string
+ configNode: JsonNode
+ globalData: JsonNode
+ notFoundTemplate: string
+ pages: seq[PageSpec]
+ devMode: bool
+ configMtime: Time
+ globalMtime: Time
+ cache: TemplateCache
+ staticHandler: Handler
+
+proc sitePath(site: Site, parts: varargs[string]): string =
+ var path = site.dir
+ for part in parts:
+ path = path / part
+ path
+
+proc readJsonFile(path: string): JsonNode =
+ if not fileExists(path):
+ raise newException(IOError, "missing JSON file: " & path)
+ let text = readFile(path)
+ try:
+ result = parseJson(text)
+ except JsonParsingError as err:
+ raise newException(ValueError, "invalid JSON in " & path & ": " & err.msg)
+
+proc requireString(node: JsonNode, key: string): string =
+ if node.kind != JObject or not node.hasKey(key):
+ raise newException(ValueError, "missing required key '" & key & "'")
+ let value = node[key]
+ if value.kind != JString:
+ raise newException(ValueError, "key '" & key & "' must be a string")
+ value.getStr()
+
+proc getString(node: JsonNode, key, fallback: string): string =
+ if node.kind != JObject or not node.hasKey(key):
+ return fallback
+ let value = node[key]
+ if value.kind != JString:
+ return fallback
+ value.getStr()
+
+proc initSite(dir: string, devMode: bool): Site =
+ result = Site(
+ dir: absolutePath(dir),
+ configPath: absolutePath(dir / "config.json"),
+ contentDir: absolutePath(dir / "content"),
+ pagesDir: absolutePath(dir / "content" / "pages"),
+ templatesDir: absolutePath(dir / "templates"),
+ staticDir: absolutePath(dir / "static"),
+ notFoundTemplate: "404.html",
+ devMode: devMode,
+ configNode: newJObject(),
+ globalData: newJObject(),
+ pages: @[],
+ cache: initTemplateCache(absolutePath(dir / "templates")),
+ staticHandler: nil
+ )
+
+proc ensureSiteLayout(site: Site) =
+ if not dirExists(site.dir):
+ raise newException(IOError, "site directory not found: " & site.dir)
+ if not fileExists(site.configPath):
+ raise newException(IOError, "missing config.json in site directory")
+ if not dirExists(site.templatesDir):
+ raise newException(IOError, "templates directory missing in site")
+ if not dirExists(site.contentDir):
+ raise newException(IOError, "content directory missing in site")
+ if not dirExists(site.pagesDir):
+ raise newException(IOError, "content/pages directory missing in site")
+
+proc loadSiteConfig(site: Site) =
+ let info = getFileInfo(site.configPath)
+ site.configMtime = info.lastWriteTime
+ let node = readJsonFile(site.configPath)
+ if node.kind != JObject:
+ raise newException(ValueError, "config.json must contain a JSON object")
+ site.configNode = node
+ site.notFoundTemplate = getString(node, "notFoundTemplate", "404.html")
+
+ site.pages.setLen(0)
+ if node.hasKey("pages"):
+ let pagesNode = node["pages"]
+ if pagesNode.kind != JArray:
+ raise newException(ValueError, "config.pages must be an array")
+ for pageNode in pagesNode.items:
+ if pageNode.kind != JObject:
+ raise newException(ValueError, "page entry must be an object")
+ let rawPath = requireString(pageNode, "path")
+ let templateFile = requireString(pageNode, "template")
+ let dataFile = getString(pageNode, "data", "")
+ let normalizedPath = normalizeUrlPath(rawPath)
+ var pattern = parsePattern(normalizedPath)
+ var paramNames: seq[string] = @[]
+ var allowed = initHashSet[string]()
+ for segment in pattern.segments:
+ if segment.kind == segParam:
+ paramNames.add(segment.name)
+ allowed.incl(segment.name)
+ var dataSegments: seq[DataSegment] = @[]
+ if dataFile.len > 0:
+ dataSegments = parseDataPath(dataFile, allowed)
+ site.pages.add(PageSpec(
+ path: normalizedPath,
+ templateFile: templateFile,
+ dataFile: dataFile,
+ raw: pageNode,
+ paramNames: paramNames,
+ dataSegments: dataSegments
+ ))
+ else:
+ raise newException(ValueError, "config.json must define a 'pages' array")
+
+proc loadGlobalData(site: Site) =
+ let globalPath = sitePath(site, "content", "global.json")
+ if fileExists(globalPath):
+ let info = getFileInfo(globalPath)
+ site.globalMtime = info.lastWriteTime
+ try:
+ site.globalData = readJsonFile(globalPath)
+ except CatchableError:
+ site.globalData = newJObject()
+ else:
+ site.globalData = newJObject()
+ site.globalMtime = Time()
+
+proc refreshSite(site: Site) =
+ if not site.devMode:
+ return
+ try:
+ let cfgInfo = getFileInfo(site.configPath)
+ if cfgInfo.lastWriteTime > site.configMtime:
+ loadSiteConfig(site)
+ except OSError:
+ discard
+ let globalPath = sitePath(site, "content", "global.json")
+ if fileExists(globalPath):
+ let info = getFileInfo(globalPath)
+ if info.lastWriteTime > site.globalMtime:
+ loadGlobalData(site)
+
+proc resolveContentPath(site: Site, relative: string, baseDir: string): string =
+ if relative.len == 0:
+ return ""
+ if relative.startsWith('/') or relative.startsWith('\\'):
+ return ""
+ let baseAbs = absolutePath(baseDir)
+ let target = absolutePath(baseDir / relative)
+ if not target.startsWith(baseAbs):
+ return ""
+ target
+
+proc loadPageData(site: Site, spec: PageSpec, params: Headers): tuple[data: JsonNode, found: bool] =
+ if spec.dataSegments.len == 0:
+ return (newJObject(), true)
+
+ var builder = newStringOfCap(64)
+ for segment in spec.dataSegments:
+ case segment.kind
+ of dskLiteral:
+ builder.add(segment.value)
+ of dskParam:
+ if segment.value notin params:
+ echo "[info] missing route parameter '" & segment.value & "' for data path in " & spec.path
+ return (newJObject(), false)
+ builder.add(params[segment.value])
+
+ let relativePath = builder
+ let baseDir =
+ if spec.dataFile.find({'/', '\\'}) >= 0: site.contentDir else: site.pagesDir
+ let fullPath = resolveContentPath(site, relativePath, baseDir)
+ if fullPath.len == 0:
+ echo "[warn] unsafe data path resolved for " & spec.path & ": " & relativePath
+ return (newJObject(), false)
+ if not fileExists(fullPath):
+ echo "[info] data file not found for " & spec.path & ": " & relativePath
+ return (newJObject(), false)
+
+ result.found = true
+ result.data = readJsonFile(fullPath)
+
+proc cloneOrEmpty(node: JsonNode): JsonNode =
+ if node.isNil:
+ return newJObject()
+ node.copy()
+
+proc mergeData(site: Site, pageData: JsonNode, req: Request, pageConfig: JsonNode): JsonNode =
+ result = newJObject()
+ result["config"] = cloneOrEmpty(site.configNode)
+ result["global"] = cloneOrEmpty(site.globalData)
+ result["page"] = if pageData.isNil: newJObject() else: pageData.copy()
+ if not pageConfig.isNil:
+ result["pageConfig"] = pageConfig.copy()
+ var requestNode = newJObject()
+ requestNode["path"] = %req.path
+ requestNode["method"] = %req.httpMethod
+ var paramsNode = newJObject()
+ for key, value in req.params.pairs:
+ paramsNode[key] = %value
+ requestNode["params"] = paramsNode
+ result["request"] = requestNode
+
+proc envBool(name: string): bool =
+ let raw = getEnv(name, "").toLowerAscii()
+ raw in ["1", "true", "yes", "on"]
+
+proc makePageHandler(site: Site, spec: PageSpec): Handler =
+ result = proc (req: Request): Response {.gcsafe.} =
+ if site.devMode:
+ refreshSite(site)
+ try:
+ let (pageData, found) = site.loadPageData(spec, req.params)
+ if not found:
+ return notFoundResponse()
+ if site.devMode:
+ var paramPairs: seq[string] = @[]
+ for key, val in req.params.pairs:
+ paramPairs.add(key & "=" & val)
+ echo "[debug] page data for ", spec.path, " (", paramPairs.join(","), ") => ", $pageData
+ let merged = mergeData(site, pageData, req, spec.raw)
+ if site.devMode:
+ echo "[debug] merged payload => ", $merged
+ let body = render(site.cache, spec.templateFile, merged)
+ var resp = newResponse(200, body)
+ resp.headers["content-type"] = "text/html; charset=utf-8"
+ return resp
+ except CatchableError as err:
+ echo "[error] page render failed for " & spec.path & ": " & err.msg
+ return serverErrorResponse()
+
+proc renderNotFound(site: Site, req: Request): Response =
+ if site.devMode:
+ refreshSite(site)
+ try:
+ let data = mergeData(site, newJObject(), req, newJObject())
+ let tplName = site.notFoundTemplate
+ if tplName.len > 0:
+ let body = render(site.cache, tplName, data)
+ var resp = newResponse(404, body)
+ resp.headers["content-type"] = "text/html; charset=utf-8"
+ return resp
+ except CatchableError as err:
+ echo "[warn] 404 template failed: " & err.msg
+ newResponse(404, "page not found")
+
+# ----------------------
+# Async server plumbing
+# ----------------------
+
+proc headersToTable(src: HttpHeaders): Headers =
+ var table = newHeaders()
+ for key, value in src.pairs:
+ table[key.toLowerAscii()] = value
+ table
+
+proc tableToHttpHeaders(src: Headers): HttpHeaders =
+ var pairs: seq[(string, string)]
+ for key, value in src.pairs:
+ pairs.add((key, value))
+ newHttpHeaders(pairs)
+
+proc toFrameworkRequest(req: asynchttpserver.Request, params: Headers): Request =
+ result.httpMethod = $req.reqMethod
+ result.path = normalizeUrlPath(req.url.path)
+ result.headers = headersToTable(req.headers)
+ result.body = req.body
+ result.params = params
+
+proc respondAsync(req: asynchttpserver.Request, resp: Response): Future[void] =
+ var headers = resp.headers
+ if "content-type" notin headers:
+ headers["content-type"] = "text/plain; charset=utf-8"
+ let status = if resp.status in 100..599: HttpCode(resp.status) else: Http200
+ return req.respond(status, resp.body, tableToHttpHeaders(headers))
+
+proc runServer(port: int, site: Site, router: Router) {.async.} =
+ var server = newAsyncHttpServer()
+ server.listen(Port(port))
+ let actualPort = server.getPort
+ echo "listening on http://127.0.0.1:" & $actualPort.uint16
+
+ proc onRequest(req: asynchttpserver.Request) {.async, gcsafe.} =
+ try:
+ if site.devMode:
+ refreshSite(site)
+ let normalizedPath = normalizeUrlPath(req.url.path)
+ let methodStr = $req.reqMethod
+ if not site.staticHandler.isNil and (methodStr == "GET" or methodStr == "HEAD") and normalizedPath.startsWith("/static"):
+ var staticReq = Request(
+ httpMethod: methodStr,
+ path: normalizedPath,
+ headers: headersToTable(req.headers),
+ body: "",
+ params: newHeaders()
+ )
+ let resp = site.staticHandler(staticReq)
+ await req.respondAsync(resp)
+ return
+ let matchResult = match(router, methodStr, normalizedPath)
+ if matchResult.isNone:
+ let frameworkReq = toFrameworkRequest(req, newHeaders())
+ await req.respondAsync(renderNotFound(site, frameworkReq))
+ return
+ let (handler, routeParams) = matchResult.get()
+ var frameworkReq = toFrameworkRequest(req, routeParams)
+ echo $req.reqMethod & " " & req.url.path
+ let response = handler(frameworkReq)
+ await req.respondAsync(response)
+ except CatchableError:
+ await req.respondAsync(serverErrorResponse())
+
+ while true:
+ if server.shouldAcceptRequest():
+ await server.acceptRequest(onRequest)
+ else:
+ await sleepAsync(50)
+
+# ----
+# Main
+# ----
+
+proc main() {.async.} =
+ let portStr = getEnv("PORT", "")
+ var port = defaultPort
+ if portStr.len > 0:
+ try:
+ port = parseInt(portStr)
+ except ValueError:
+ echo "[warn] invalid PORT value '" & portStr & "', falling back to " & $defaultPort
+ port = defaultPort
+
+ let siteDir = getEnv("SITE_DIR", ".")
+ let devMode = envBool("DEV_MODE")
+ var site = initSite(siteDir, devMode)
+
+ try:
+ ensureSiteLayout(site)
+ loadSiteConfig(site)
+ loadGlobalData(site)
+ except CatchableError as err:
+ echo "[fatal] site initialization failed: " & err.msg
+ return
+
+ if site.devMode:
+ echo "[info] development mode enabled"
+
+ var router = initRouter()
+
+ var hasHealthRoute = false
+ for spec in site.pages:
+ if spec.path == "/health":
+ hasHealthRoute = true
+ router.addRoute("GET", spec.path, makePageHandler(site, spec))
+
+ if dirExists(site.staticDir):
+ site.staticHandler = serveDir(site.staticDir, "/static")
+ else:
+ echo "[info] static directory missing, /static disabled"
+
+ if not hasHealthRoute:
+ router.addRoute("GET", "/health", proc (req: Request): Response {.gcsafe.} =
+ var resp = newResponse(200, "ok\n")
+ resp.headers["content-type"] = "text/plain; charset=utf-8"
+ resp
+ )
+
+ await runServer(port, site, router)
+
+when isMainModule:
+ waitFor main()