obol
first commit
2613222bd99cc2f78f8212acafbf66625d4655d4
IIIlllIIIllI <seb.michalk@gmail.com>
2025-12-21 12:06:43 +0000
README.md | 126 +++++ build.sh | 23 + example-site/config.json | 13 + example-site/content/global.json | 3 + example-site/content/pages/home.json | 9 + example-site/obol | Bin 0 -> 665200 bytes example-site/static/robots.txt | 2 + example-site/templates/header.html | 4 + example-site/templates/home.html | 14 + example-site/templates/layout.html | 14 + obol.nim | 960 +++++++++++++++++++++++++++++++++++ 11 files changed, 1168 insertions(+) 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()