Sync local packages into the registry repo and update index.json and README.md to include all 9 packages.
655 lines
27 KiB
Plaintext
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, "&", "&")
|
|
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<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, "<", "<")
|
|
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<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))
|
|
}
|
|
}
|