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()