// Lux Web Framework // // A full-stack web framework for building websites with Lux. // Provides routing, layouts, blog engine, forms, SEO, static file serving, // and middleware — all built on Lux's effect system. // // Usage: // import web // // fn main(): Unit with {Console, HttpServer, File} = { // let app = web.app("My Site", "A Lux website", "./static", "./content") // let app = web.addRoute(app, web.get("/", homePage)) // web.serve(app, 8080) // } import markdown import frontmatter import path // ============================================================ // Core Types // ============================================================ pub type App = | App(String, String, String, String, List, List) // title desc staticDir contentDir routes middleware pub type Route = | Route(String, String, fn(Request): Response) // method pattern handler pub type Request = { method: String, path: String, query: List<(String, String)>, headers: List<(String, String)>, body: String } pub type Response = { status: Int, headers: List<(String, String)>, body: String } pub type Middleware = fn(fn(Request): Response): fn(Request): Response pub type Post = { title: String, date: String, slug: String, tags: List, excerpt: String, content: String } pub type SeoConfig = { title: String, description: String, url: String, ogImage: String, siteName: String } pub type LayoutConfig = { siteTitle: String, navLinks: List<(String, String)>, footerHtml: String, cssPath: String } // ============================================================ // web.app — Application Builder // ============================================================ pub fn app(title: String, description: String, staticDir: String, contentDir: String): App = App(title, description, staticDir, contentDir, [], []) pub fn addRoute(application: App, route: Route): App = match application { App(t, d, s, c, routes, mw) => App(t, d, s, c, List.concat(routes, [route]), mw) } pub fn addMiddleware(application: App, middleware: Middleware): App = match application { App(t, d, s, c, routes, mw) => App(t, d, s, c, routes, List.concat(mw, [middleware])) } pub fn serve(application: App, port: Int): Unit with {Console, HttpServer, File} = { match application { App(title, _, _, _, _, _) => { Console.print("=== " + title + " ===") HttpServer.listen(port) Console.print("Listening on http://localhost:" + toString(port)) appLoop(application) } } } fn appLoop(application: App): Unit with {Console, HttpServer, File} = { let rawReq = HttpServer.accept() let req = parseRequest(rawReq.method, rawReq.path, rawReq.headers, rawReq.body) Console.print(req.method + " " + req.path) let handler = buildHandler(application) let resp = handler(req) HttpServer.respondWithHeaders(resp.status, resp.body, resp.headers) appLoop(application) } fn buildHandler(application: App): fn(Request): Response with {File} = match application { App(_, _, staticDir, _, routes, middleware) => { let baseHandler = fn(req: Request): Response with {File} => { // Check static files first if String.startsWith(req.path, "/static/") then serveStatic(staticDir, req.path) else matchAndHandle(req, routes) } applyMiddleware(baseHandler, middleware) } } fn applyMiddleware(handler: fn(Request): Response, middleware: List): fn(Request): Response = List.fold(middleware, handler, fn(h: fn(Request): Response, mw: Middleware): fn(Request): Response => mw(h)) fn matchAndHandle(req: Request, routes: List): Response = match List.head(routes) { None => htmlResponse(404, "

Not Found

"), Some(route) => match route { Route(method, pattern, handler) => if method == req.method && pathMatches(req.path, pattern) then handler(req) else matchAndHandle(req, Option.getOrElse(List.tail(routes), [])) } } // ============================================================ // Route Builders // ============================================================ pub fn get(pattern: String, handler: fn(Request): Response): Route = Route("GET", pattern, handler) pub fn post(pattern: String, handler: fn(Request): Response): Route = Route("POST", pattern, handler) pub fn put(pattern: String, handler: fn(Request): Response): Route = Route("PUT", pattern, handler) pub fn delete(pattern: String, handler: fn(Request): Response): Route = Route("DELETE", pattern, handler) // ============================================================ // web.page — Page Builder / Layout System // ============================================================ pub fn layout(config: LayoutConfig, title: String, description: String, bodyContent: String): String = { let navLinksHtml = List.fold(config.navLinks, "", fn(acc: String, link: (String, String)): String => match link { (href, label) => acc + "" + label + "" } ) "" + "" + "" + "" + title + "" + "" + "" + "" + "" + "
" + bodyContent + "
" + config.footerHtml + "" } pub fn hero(title: String, subtitle: String, ctaUrl: String, ctaText: String): String = "
" + "
" + "

" + title + "

" + "

" + subtitle + "

" + "" + ctaText + "" + "
" pub fn miniHero(title: String, subtitle: String): String = "
" + "
" + "

" + title + "

" + "

" + subtitle + "

" + "
" pub fn cardGrid(cards: List): String = { let cardsHtml = String.join(cards, "") "
" + cardsHtml + "
" } pub fn card(icon: String, title: String, description: String): String = "
" + "
" + icon + "
" + "

" + title + "

" + "

" + description + "

" + "
" pub fn statsBar(stats: List<(String, String)>): String = { let statsHtml = List.fold(stats, "", fn(acc: String, stat: (String, String)): String => match stat { (number, label) => acc + "
" + number + "
" + label + "
" } ) "
" + "
" + "
" + statsHtml + "
" + "
" } pub fn emailSignup(heading: String, buttonText: String, action: String): String = "
" + "
" + "

" + heading + "

" + "
" + "" + "" + "
" pub fn ctaSection(heading: String, ctaUrl: String, ctaText: String): String = "
" + "
" + "

" + heading + "

" + "" + ctaText + "" + "
" // ============================================================ // web.blog — Blog Engine // ============================================================ pub fn loadPosts(contentDir: String, section: String): List with {File} = { let dir = contentDir + "/" + section if File.exists(dir) then { let entries = File.readDir(dir) let mdFiles = List.filter(entries, fn(e: String): Bool => String.endsWith(e, ".md")) let posts = loadPostFiles(dir, mdFiles) sortPosts(posts) } else [] } pub fn latestPosts(contentDir: String, section: String, count: Int): List with {File} = List.take(loadPosts(contentDir, section), count) pub fn loadPost(contentDir: String, section: String, slug: String): Option with {File} = { let filePath = contentDir + "/" + section + "/" + slug + ".md" if File.exists(filePath) then { let raw = File.read(filePath) let doc = frontmatter.parse(raw) let title = frontmatter.title(doc) let date = frontmatter.date(doc) let excerpt = frontmatter.getOrDefault(doc, "description", "") let tagsStr = frontmatter.getOrDefault(doc, "tags", "") let tags = if tagsStr == "" then [] else String.split(tagsStr, " ") let htmlContent = markdown.toHtml(frontmatter.body(doc)) Some({ title: title, date: date, slug: slug, tags: tags, excerpt: excerpt, content: htmlContent }) } else None } fn loadPostFiles(dir: String, files: List): List with {File} = match List.head(files) { None => [], Some(filename) => { let raw = File.read(dir + "/" + filename) let doc = frontmatter.parse(raw) let title = frontmatter.title(doc) let date = frontmatter.date(doc) let excerpt = frontmatter.getOrDefault(doc, "description", "") let tagsStr = frontmatter.getOrDefault(doc, "tags", "") let tags = if tagsStr == "" then [] else String.split(tagsStr, " ") let slug = path.stripExtension(filename) let htmlContent = markdown.toHtml(frontmatter.body(doc)) let post = { title: title, date: date, slug: slug, tags: tags, excerpt: excerpt, content: htmlContent } match List.tail(files) { Some(rest) => List.concat([post], loadPostFiles(dir, rest)), None => [post] } } } fn sortPosts(posts: List): List = List.fold(posts, [], fn(sorted: List, item: Post): List => insertPost(sorted, item) ) fn insertPost(sorted: List, item: Post): List = { match List.head(sorted) { None => [item], Some(first) => if item.date >= first.date then List.concat([item], sorted) else match List.tail(sorted) { Some(rest) => List.concat([first], insertPost(rest, item)), None => [first, item] } } } pub fn blogIndexHtml(posts: List): String = { let postsHtml = List.fold(posts, "", fn(acc: String, post: Post): String => acc + "" ) "
" + postsHtml + "
" } pub fn blogPostHtml(post: Post): String = "
" + "
" + "
" + post.date + "" + "

" + post.title + "

" + "
" + post.content + "
" + "
" pub fn rssFeed(siteTitle: String, siteUrl: String, description: String, posts: List): String = { let items = List.fold(posts, "", fn(acc: String, post: Post): String => acc + "" + "" + escapeXml(post.title) + "" + "" + siteUrl + "/blog/" + post.slug + "" + "" + escapeXml(post.excerpt) + "" + "" + post.date + "" + "" ) "" + "" + "" + escapeXml(siteTitle) + "" + "" + siteUrl + "" + "" + escapeXml(description) + "" + items + "" } fn escapeXml(s: String): String = { let s1 = String.replace(s, "&", "&") let s2 = String.replace(s1, "<", "<") let s3 = String.replace(s2, ">", ">") s3 } // ============================================================ // web.form — Form Processing // ============================================================ pub fn parseBody(body: String): List<(String, String)> = { let pairs = String.split(body, "&") List.filterMap(pairs, fn(pair: String): Option<(String, String)> => { match String.indexOf(pair, "=") { None => None, Some(idx) => { let key = String.substring(pair, 0, idx) let value = String.substring(pair, idx + 1, String.length(pair)) Some((urlDecode(key), urlDecode(value))) } } }) } pub fn getField(fields: List<(String, String)>, name: String): Option = match List.head(fields) { None => None, Some(pair) => match pair { (k, v) => if k == name then Some(v) else getField(Option.getOrElse(List.tail(fields), []), name) } } pub fn requireField(fields: List<(String, String)>, name: String): Result = match getField(fields, name) { Some(v) => if String.length(v) > 0 then Ok(v) else Err(name + " is required"), None => Err(name + " is required") } pub fn validateEmail(email: String): Result = if String.contains(email, "@") && String.contains(email, ".") then Ok(email) else Err("Invalid email address") pub fn sanitize(input: String): String = { let s1 = String.replace(input, "'", "''") let s2 = String.replace(s1, ";", "") let s3 = String.replace(s2, "--", "") let s4 = String.replace(s3, "<", "<") let s5 = String.replace(s4, ">", ">") s5 } fn urlDecode(s: String): String = String.replace(s, "+", " ") // ============================================================ // web.db — Database Helpers // ============================================================ pub fn initDb(dbPath: String): Unit with {Sql} = { let db = Sql.open(dbPath) db } pub fn saveSubscriber(dbPath: String, email: String): Result with {Sql} = { let sanitized = sanitize(email) let db = Sql.open(dbPath) Sql.execute(db, "CREATE TABLE IF NOT EXISTS subscribers (email TEXT UNIQUE, created_at TEXT DEFAULT CURRENT_TIMESTAMP)") Sql.execute(db, "INSERT OR IGNORE INTO subscribers (email) VALUES ('" + sanitized + "')") Sql.close(db) Ok(()) } pub fn getSubscribers(dbPath: String): List with {Sql} = { let db = Sql.open(dbPath) Sql.execute(db, "CREATE TABLE IF NOT EXISTS subscribers (email TEXT UNIQUE, created_at TEXT DEFAULT CURRENT_TIMESTAMP)") let rows = Sql.query(db, "SELECT email FROM subscribers ORDER BY created_at DESC") Sql.close(db) List.map(rows, fn(row: List): String => match List.head(row) { Some(email) => email, None => "" } ) } // ============================================================ // web.seo — SEO Helpers // ============================================================ pub fn metaTags(config: SeoConfig): String = "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" pub fn structuredData(name: String, description: String, url: String): String = "" pub fn sitemap(pages: List<(String, String)>): String = { let entries = List.fold(pages, "", fn(acc: String, page: (String, String)): String => match page { (url, lastmod) => acc + "" + escapeXml(url) + "" + lastmod + "" } ) "" + "" + entries + "" } pub fn robots(sitemapUrl: String): String = "User-agent: *\nAllow: /\nSitemap: " + sitemapUrl // ============================================================ // web.static — Static Asset Serving // ============================================================ pub fn serveStatic(basePath: String, requestPath: String): Response with {File} = { let relPath = if String.startsWith(requestPath, "/static/") then String.substring(requestPath, 8, String.length(requestPath)) else requestPath let filePath = basePath + "/" + relPath if File.exists(filePath) then { let content = File.read(filePath) let mime = mimeType(filePath) { status: 200, headers: [("Content-Type", mime)], body: content } } else { status: 404, headers: [("Content-Type", "text/plain")], body: "Not Found" } } pub fn mimeType(filePath: String): String = { let ext = fileExtension(filePath) if ext == "css" then "text/css; charset=utf-8" else if ext == "js" then "application/javascript; charset=utf-8" else if ext == "json" then "application/json; charset=utf-8" else if ext == "svg" then "image/svg+xml" else if ext == "png" then "image/png" else if ext == "jpg" then "image/jpeg" else if ext == "jpeg" then "image/jpeg" else if ext == "gif" then "image/gif" else if ext == "ico" then "image/x-icon" else if ext == "woff" then "font/woff" else if ext == "woff2" then "font/woff2" else if ext == "ttf" then "font/ttf" else if ext == "html" then "text/html; charset=utf-8" else if ext == "xml" then "application/xml" else if ext == "txt" then "text/plain; charset=utf-8" else "application/octet-stream" } pub fn cacheHeaders(maxAge: Int): List<(String, String)> = [("Cache-Control", "public, max-age=" + toString(maxAge))] fn fileExtension(filePath: String): String = match String.lastIndexOf(filePath, ".") { None => "", Some(idx) => String.toLower(String.substring(filePath, idx + 1, String.length(filePath))) } // ============================================================ // web.middleware — Composable Middleware // ============================================================ pub fn logging(): Middleware with {Console} = fn(handler: fn(Request): Response): fn(Request): Response => fn(req: Request): Response => { Console.print("[HTTP] " + req.method + " " + req.path) let resp = handler(req) Console.print("[HTTP] " + toString(resp.status)) resp } pub fn cors(origin: String): Middleware = fn(handler: fn(Request): Response): fn(Request): Response => fn(req: Request): Response => { if req.method == "OPTIONS" then { status: 204, headers: [ ("Access-Control-Allow-Origin", origin), ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"), ("Access-Control-Allow-Headers", "Content-Type, Authorization") ], body: "" } else { let resp = handler(req) { status: resp.status, headers: List.concat(resp.headers, [("Access-Control-Allow-Origin", origin)]), body: resp.body } } } pub fn securityHeaders(): Middleware = fn(handler: fn(Request): Response): fn(Request): Response => fn(req: Request): Response => { let resp = handler(req) { status: resp.status, headers: List.concat(resp.headers, [ ("X-Frame-Options", "DENY"), ("X-Content-Type-Options", "nosniff"), ("Referrer-Policy", "strict-origin-when-cross-origin") ]), body: resp.body } } // ============================================================ // Response Helpers // ============================================================ pub fn htmlResponse(status: Int, body: String): Response = { status: status, headers: [("Content-Type", "text/html; charset=utf-8")], body: body } pub fn jsonResponse(status: Int, body: String): Response = { status: status, headers: [("Content-Type", "application/json; charset=utf-8")], body: body } pub fn textResponse(status: Int, body: String): Response = { status: status, headers: [("Content-Type", "text/plain; charset=utf-8")], body: body } pub fn redirect(location: String): Response = { status: 302, headers: [("Location", location)], body: "" } pub fn redirectPermanent(location: String): Response = { status: 301, headers: [("Location", location)], body: "" } // ============================================================ // Path Matching (from http.lux stdlib) // ============================================================ fn pathMatches(reqPath: String, pattern: String): Bool = { let pathParts = String.split(reqPath, "/") let patternParts = String.split(pattern, "/") if List.length(pathParts) != List.length(patternParts) then false else matchParts(pathParts, patternParts) } fn matchParts(pathParts: List, patternParts: List): Bool = { if List.length(pathParts) == 0 then true else { match List.head(pathParts) { None => true, Some(pp) => match List.head(patternParts) { None => true, Some(pat) => { let isMatch = if String.startsWith(pat, ":") then true else pp == pat if isMatch then { let restPath = Option.getOrElse(List.tail(pathParts), []) let restPattern = Option.getOrElse(List.tail(patternParts), []) matchParts(restPath, restPattern) } else false } } } } } pub fn getPathParam(reqPath: String, pattern: String, paramName: String): Option = { let pathParts = String.split(reqPath, "/") let patternParts = String.split(pattern, "/") extractParam(pathParts, patternParts, paramName) } fn extractParam(pathParts: List, patternParts: List, paramName: String): Option = { if List.length(pathParts) == 0 || List.length(patternParts) == 0 then None else { match List.head(pathParts) { None => None, Some(pp) => match List.head(patternParts) { None => None, Some(pat) => { if String.startsWith(pat, ":") then { let name = String.substring(pat, 1, String.length(pat)) if name == paramName then Some(pp) else extractParam(Option.getOrElse(List.tail(pathParts), []), Option.getOrElse(List.tail(patternParts), []), paramName) } else extractParam(Option.getOrElse(List.tail(pathParts), []), Option.getOrElse(List.tail(patternParts), []), paramName) } } } } } // ============================================================ // Request Parsing // ============================================================ fn parseRequest(method: String, fullPath: String, headers: List<(String, String)>, body: String): Request = { let (cleanPath, query) = parseQueryString(fullPath) { method: method, path: cleanPath, query: query, headers: headers, body: body } } fn parseQueryString(fullPath: String): (String, List<(String, String)>) = match String.indexOf(fullPath, "?") { None => (fullPath, []), Some(idx) => { let p = String.substring(fullPath, 0, idx) let qs = String.substring(fullPath, idx + 1, String.length(fullPath)) (p, parseBody(qs)) } }