Files
pkgs.lux/packages/web/web.lux
Brandon Lucas cbb66fbb73 Add frontmatter, markdown, path, xml, rss, and web packages
Sync local packages into the registry repo and update index.json
and README.md to include all 9 packages.
2026-02-24 21:04:20 -05:00

655 lines
27 KiB
Plaintext

// 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<Route>, List<Middleware>)
// 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<String>,
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<Middleware>): 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<Route>): Response =
match List.head(routes) {
None => htmlResponse(404, "<h1>Not Found</h1>"),
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 + "<a href=\"" + href + "\" class=\"hover:text-[#f97316] transition-colors\">" + label + "</a>"
}
)
"<!doctype html><html lang=\"en\"><head>" +
"<meta charset=\"utf-8\">" +
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
"<title>" + title + "</title>" +
"<meta name=\"description\" content=\"" + description + "\">" +
"<link href=\"" + config.cssPath + "\" rel=\"stylesheet\" type=\"text/css\">" +
"</head><body class=\"bg-white text-[#1f2937] min-h-screen flex flex-col\">" +
"<nav class=\"w-full bg-[#1e3a5f] text-white sticky top-0 z-50\">" +
"<div class=\"max-w-6xl mx-auto px-6 py-4 flex items-center justify-between\">" +
"<a href=\"/\" class=\"text-2xl font-bold tracking-tight\">" + config.siteTitle + "</a>" +
"<div class=\"hidden md:flex items-center gap-6 text-sm font-medium\">" + navLinksHtml + "</div>" +
"</div></nav>" +
"<main class=\"flex-1\">" + bodyContent + "</main>" +
config.footerHtml +
"</body></html>"
}
pub fn hero(title: String, subtitle: String, ctaUrl: String, ctaText: String): String =
"<section class=\"w-full bg-gradient-to-br from-[#1e3a5f] to-[#0f1f36] text-white py-20\">" +
"<div class=\"max-w-4xl mx-auto px-6 text-center flex flex-col gap-6\">" +
"<h1 class=\"text-4xl md:text-6xl font-bold leading-tight\">" + title + "</h1>" +
"<p class=\"text-xl md:text-2xl text-gray-300\">" + subtitle + "</p>" +
"<a href=\"" + ctaUrl + "\" class=\"inline-block bg-[#f97316] hover:bg-[#ea580c] text-white font-bold py-3 px-8 rounded-lg transition-colors text-lg mx-auto\">" + ctaText + "</a>" +
"</div></section>"
pub fn miniHero(title: String, subtitle: String): String =
"<section class=\"w-full bg-gradient-to-br from-[#1e3a5f] to-[#0f1f36] text-white py-16\">" +
"<div class=\"max-w-4xl mx-auto px-6 text-center flex flex-col gap-4\">" +
"<h1 class=\"text-3xl md:text-5xl font-bold\">" + title + "</h1>" +
"<p class=\"text-lg md:text-xl text-gray-300\">" + subtitle + "</p>" +
"</div></section>"
pub fn cardGrid(cards: List<String>): String = {
let cardsHtml = String.join(cards, "")
"<div class=\"grid grid-cols-1 md:grid-cols-3 gap-6\">" + cardsHtml + "</div>"
}
pub fn card(icon: String, title: String, description: String): String =
"<div class=\"bg-white border border-gray-200 rounded-xl p-6 flex flex-col gap-4 shadow-sm hover:shadow-md transition-shadow\">" +
"<div class=\"text-4xl\">" + icon + "</div>" +
"<h3 class=\"text-xl font-bold text-[#1e3a5f]\">" + title + "</h3>" +
"<p class=\"text-gray-600\">" + description + "</p>" +
"</div>"
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 + "<div class=\"text-center\"><div class=\"text-3xl font-bold text-[#f97316]\">" + number + "</div><div class=\"text-sm text-gray-300 mt-1\">" + label + "</div></div>"
}
)
"<section class=\"w-full py-12 bg-[#1e3a5f] text-white\">" +
"<div class=\"max-w-4xl mx-auto px-6\">" +
"<div class=\"grid grid-cols-3 gap-8 text-center\">" + statsHtml + "</div>" +
"</div></section>"
}
pub fn emailSignup(heading: String, buttonText: String, action: String): String =
"<section class=\"w-full bg-[#1e3a5f] text-white py-16\">" +
"<div class=\"max-w-2xl mx-auto px-6 text-center flex flex-col gap-6\">" +
"<h2 class=\"text-3xl font-bold\">" + heading + "</h2>" +
"<form action=\"" + action + "\" method=\"POST\" class=\"flex flex-col sm:flex-row gap-3 max-w-md mx-auto w-full\">" +
"<input type=\"email\" name=\"email\" placeholder=\"your@email.com\" required=\"\" class=\"flex-1 px-4 py-3 rounded-lg text-gray-900 placeholder-gray-400\">" +
"<button type=\"submit\" class=\"bg-[#f97316] hover:bg-[#ea580c] text-white font-bold py-3 px-6 rounded-lg transition-colors\">" + buttonText + "</button>" +
"</form></div></section>"
pub fn ctaSection(heading: String, ctaUrl: String, ctaText: String): String =
"<section class=\"w-full bg-[#f97316] text-white py-16\">" +
"<div class=\"max-w-4xl mx-auto px-6 text-center flex flex-col gap-6\">" +
"<h2 class=\"text-3xl font-bold\">" + heading + "</h2>" +
"<a href=\"" + ctaUrl + "\" class=\"inline-block bg-white text-[#f97316] font-bold py-3 px-8 rounded-lg hover:bg-gray-100 transition-colors text-lg mx-auto\">" + ctaText + "</a>" +
"</div></section>"
// ============================================================
// web.blog — Blog Engine
// ============================================================
pub fn loadPosts(contentDir: String, section: String): List<Post> 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<Post> with {File} =
List.take(loadPosts(contentDir, section), count)
pub fn loadPost(contentDir: String, section: String, slug: String): Option<Post> 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<String>): List<Post> 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<Post>): List<Post> =
List.fold(posts, [], fn(sorted: List<Post>, item: Post): List<Post> =>
insertPost(sorted, item)
)
fn insertPost(sorted: List<Post>, item: Post): List<Post> = {
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<Post>): String = {
let postsHtml = List.fold(posts, "", fn(acc: String, post: Post): String =>
acc + "<div class=\"border-b border-gray-200 pb-6\">" +
"<a href=\"/blog/" + post.slug + "\" class=\"block hover:bg-gray-50 rounded-lg p-4 -mx-4 transition-colors\">" +
"<div class=\"flex flex-col gap-2\">" +
"<span class=\"text-sm text-gray-400\">" + post.date + "</span>" +
"<h2 class=\"text-xl font-bold text-[#1e3a5f]\">" + post.title + "</h2>" +
"<p class=\"text-gray-600\">" + post.excerpt + "</p>" +
"</div></a></div>"
)
"<div class=\"max-w-3xl mx-auto px-6 py-16 flex flex-col gap-6\">" + postsHtml + "</div>"
}
pub fn blogPostHtml(post: Post): String =
"<article class=\"w-full py-16\">" +
"<div class=\"max-w-3xl mx-auto px-6\">" +
"<div class=\"mb-8\"><span class=\"text-sm text-gray-400\">" + post.date + "</span>" +
"<h1 class=\"text-3xl font-bold text-[#1e3a5f] mt-2\">" + post.title + "</h1></div>" +
"<div class=\"prose prose-lg max-w-none\">" + post.content + "</div>" +
"</div></article>"
pub fn rssFeed(siteTitle: String, siteUrl: String, description: String, posts: List<Post>): String = {
let items = List.fold(posts, "", fn(acc: String, post: Post): String =>
acc + "<item>" +
"<title>" + escapeXml(post.title) + "</title>" +
"<link>" + siteUrl + "/blog/" + post.slug + "</link>" +
"<description>" + escapeXml(post.excerpt) + "</description>" +
"<pubDate>" + post.date + "</pubDate>" +
"</item>"
)
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<rss version=\"2.0\"><channel>" +
"<title>" + escapeXml(siteTitle) + "</title>" +
"<link>" + siteUrl + "</link>" +
"<description>" + escapeXml(description) + "</description>" +
items +
"</channel></rss>"
}
fn escapeXml(s: String): String = {
let s1 = String.replace(s, "&", "&amp;")
let s2 = String.replace(s1, "<", "&lt;")
let s3 = String.replace(s2, ">", "&gt;")
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<String> =
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<String, String> =
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<String, String> =
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, "<", "&lt;")
let s5 = String.replace(s4, ">", "&gt;")
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<Unit, String> 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<String> 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>): String =>
match List.head(row) {
Some(email) => email,
None => ""
}
)
}
// ============================================================
// web.seo — SEO Helpers
// ============================================================
pub fn metaTags(config: SeoConfig): String =
"<meta name=\"description\" content=\"" + config.description + "\">" +
"<meta property=\"og:title\" content=\"" + config.title + "\">" +
"<meta property=\"og:description\" content=\"" + config.description + "\">" +
"<meta property=\"og:type\" content=\"website\">" +
"<meta property=\"og:url\" content=\"" + config.url + "\">" +
"<meta property=\"og:image\" content=\"" + config.ogImage + "\">" +
"<meta property=\"og:site_name\" content=\"" + config.siteName + "\">" +
"<meta name=\"twitter:card\" content=\"summary_large_image\">" +
"<meta name=\"twitter:title\" content=\"" + config.title + "\">" +
"<meta name=\"twitter:description\" content=\"" + config.description + "\">" +
"<link rel=\"canonical\" href=\"" + config.url + "\">"
pub fn structuredData(name: String, description: String, url: String): String =
"<script type=\"application/ld+json\">" +
"{\"@context\":\"https://schema.org\"," +
"\"@type\":\"Organization\"," +
"\"name\":\"" + name + "\"," +
"\"description\":\"" + description + "\"," +
"\"url\":\"" + url + "\"}" +
"</script>"
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 + "<url><loc>" + escapeXml(url) + "</loc><lastmod>" + lastmod + "</lastmod></url>"
}
)
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">" +
entries +
"</urlset>"
}
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<String>, patternParts: List<String>): 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<String> = {
let pathParts = String.split(reqPath, "/")
let patternParts = String.split(pattern, "/")
extractParam(pathParts, patternParts, paramName)
}
fn extractParam(pathParts: List<String>, patternParts: List<String>, paramName: String): Option<String> = {
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))
}
}