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.
This commit is contained in:
204
packages/web/README.md
Normal file
204
packages/web/README.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Lux Web Framework
|
||||
|
||||
A full-stack web framework for building websites with Lux. Provides routing, layouts, a blog engine, form processing, SEO helpers, static file serving, and middleware — all built on Lux's effect system.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```lux
|
||||
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))
|
||||
let app = web.addRoute(app, web.get("/about", aboutPage))
|
||||
web.serve(app, 8080)
|
||||
}
|
||||
|
||||
fn homePage(req: web.Request): web.Response =
|
||||
web.htmlResponse(200,
|
||||
web.layout(myLayout, "Home | My Site", "Welcome",
|
||||
web.hero("Welcome", "Build something great", "/about", "Learn More")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Effects-based** | Every handler declares its side effects (`File`, `Sql`, `HttpServer`) |
|
||||
| **Type-safe** | Elm-style Html builder catches broken markup at compile time |
|
||||
| **Zero JS** | Server-rendered HTML. No client bundle. Pages work without JavaScript |
|
||||
| **Blog engine** | Markdown + frontmatter blog with zero config |
|
||||
| **SQLite built-in** | `Sql` effect is a language primitive. No ORM setup |
|
||||
| **Single binary** | Compile to C via `lux compile` for single binary deployment |
|
||||
|
||||
## API Reference
|
||||
|
||||
### `web.app` — Application Builder
|
||||
|
||||
```lux
|
||||
web.app(title, description, staticDir, contentDir): App
|
||||
web.addRoute(app, route): App
|
||||
web.addMiddleware(app, middleware): App
|
||||
web.serve(app, port): Unit with {Console, HttpServer, File}
|
||||
```
|
||||
|
||||
### Route Builders
|
||||
|
||||
```lux
|
||||
web.get(pattern, handler): Route
|
||||
web.post(pattern, handler): Route
|
||||
web.put(pattern, handler): Route
|
||||
web.delete(pattern, handler): Route
|
||||
```
|
||||
|
||||
### `web.page` — Layout & Components
|
||||
|
||||
```lux
|
||||
web.layout(config, title, description, body): String
|
||||
web.hero(title, subtitle, ctaUrl, ctaText): String
|
||||
web.miniHero(title, subtitle): String
|
||||
web.card(icon, title, description): String
|
||||
web.cardGrid(cards): String
|
||||
web.statsBar(stats): String
|
||||
web.emailSignup(heading, buttonText, action): String
|
||||
web.ctaSection(heading, ctaUrl, ctaText): String
|
||||
```
|
||||
|
||||
### `web.blog` — Blog Engine
|
||||
|
||||
```lux
|
||||
web.loadPosts(contentDir, section): List<Post> with {File}
|
||||
web.latestPosts(contentDir, section, count): List<Post> with {File}
|
||||
web.loadPost(contentDir, section, slug): Option<Post> with {File}
|
||||
web.blogIndexHtml(posts): String
|
||||
web.blogPostHtml(post): String
|
||||
web.rssFeed(title, url, description, posts): String
|
||||
```
|
||||
|
||||
### `web.form` — Form Processing
|
||||
|
||||
```lux
|
||||
web.parseBody(body): List<(String, String)>
|
||||
web.getField(fields, name): Option<String>
|
||||
web.requireField(fields, name): Result<String, String>
|
||||
web.validateEmail(email): Result<String, String>
|
||||
web.sanitize(input): String
|
||||
```
|
||||
|
||||
### `web.db` — Database Helpers
|
||||
|
||||
```lux
|
||||
web.saveSubscriber(dbPath, email): Result<Unit, String> with {Sql}
|
||||
web.getSubscribers(dbPath): List<String> with {Sql}
|
||||
```
|
||||
|
||||
### `web.seo` — SEO Helpers
|
||||
|
||||
```lux
|
||||
web.metaTags(config): String
|
||||
web.structuredData(name, description, url): String
|
||||
web.sitemap(pages): String
|
||||
web.robots(sitemapUrl): String
|
||||
```
|
||||
|
||||
### `web.static` — Static File Serving
|
||||
|
||||
```lux
|
||||
web.serveStatic(basePath, requestPath): Response with {File}
|
||||
web.mimeType(path): String
|
||||
web.cacheHeaders(maxAge): List<(String, String)>
|
||||
```
|
||||
|
||||
### `web.middleware` — Composable Middleware
|
||||
|
||||
```lux
|
||||
web.logging(): Middleware with {Console}
|
||||
web.cors(origin): Middleware
|
||||
web.securityHeaders(): Middleware
|
||||
```
|
||||
|
||||
### Response Helpers
|
||||
|
||||
```lux
|
||||
web.htmlResponse(status, body): Response
|
||||
web.jsonResponse(status, body): Response
|
||||
web.textResponse(status, body): Response
|
||||
web.redirect(location): Response
|
||||
web.redirectPermanent(location): Response
|
||||
```
|
||||
|
||||
## Guide: Build a Marketing Site
|
||||
|
||||
1. Create project structure:
|
||||
```
|
||||
my-site/
|
||||
├── lux.toml
|
||||
├── main.lux
|
||||
├── content/blog/
|
||||
├── static/styles.css
|
||||
```
|
||||
|
||||
2. Add dependency in `lux.toml`:
|
||||
```toml
|
||||
[dependencies]
|
||||
web = { version = "0.1.0", path = "../lux/packages/web" }
|
||||
```
|
||||
|
||||
3. Define your routes and pages in `main.lux`
|
||||
4. Add blog posts as markdown files in `content/blog/`
|
||||
5. Run: `lux main.lux`
|
||||
|
||||
## Guide: Add a Blog
|
||||
|
||||
```lux
|
||||
// Load and display blog posts
|
||||
let app = web.addRoute(app, web.get("/blog", fn(req: web.Request): web.Response with {File} => {
|
||||
let posts = web.loadPosts("./content", "blog")
|
||||
web.htmlResponse(200, web.layout(myLayout, "Blog", "Our blog",
|
||||
web.miniHero("Blog", "Latest posts") + web.blogIndexHtml(posts)
|
||||
))
|
||||
}))
|
||||
|
||||
// Individual post pages
|
||||
let app = web.addRoute(app, web.get("/blog/:slug", fn(req: web.Request): web.Response with {File} => {
|
||||
let slug = web.getPathParam(req.path, "/blog/:slug", "slug")
|
||||
match slug {
|
||||
Some(s) => match web.loadPost("./content", "blog", s) {
|
||||
Some(post) => web.htmlResponse(200, web.layout(myLayout, post.title, post.excerpt, web.blogPostHtml(post))),
|
||||
None => web.htmlResponse(404, "Not found")
|
||||
},
|
||||
None => web.htmlResponse(404, "Not found")
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
## Guide: Handle Form Submissions
|
||||
|
||||
```lux
|
||||
let app = web.addRoute(app, web.post("/api/subscribe", fn(req: web.Request): web.Response with {Sql} => {
|
||||
let fields = web.parseBody(req.body)
|
||||
match web.getField(fields, "email") {
|
||||
Some(email) => match web.validateEmail(email) {
|
||||
Ok(validEmail) => {
|
||||
web.saveSubscriber("data/subscribers.db", validEmail)
|
||||
web.redirect("/thanks")
|
||||
},
|
||||
Err(msg) => web.htmlResponse(400, msg)
|
||||
},
|
||||
None => web.htmlResponse(400, "Email required")
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The framework is built on Lux's algebraic effect system:
|
||||
|
||||
- **`HttpServer` effect** — Accepts connections and sends responses
|
||||
- **`File` effect** — Reads static files and markdown content
|
||||
- **`Sql` effect** — SQLite database for form submissions
|
||||
- **`Console` effect** — Request logging
|
||||
|
||||
Effects are declared in function signatures, making it clear what each handler does. For testing, swap effect handlers to mock the database, filesystem, or HTTP server.
|
||||
9
packages/web/lux.toml
Normal file
9
packages/web/lux.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[project]
|
||||
name = "web"
|
||||
version = "0.1.0"
|
||||
description = "Full-stack web framework for Lux — routing, layouts, blog engine, forms, SEO, and static file serving"
|
||||
|
||||
[dependencies]
|
||||
markdown = { version = "0.1.0", path = "../markdown" }
|
||||
frontmatter = { version = "0.1.0", path = "../frontmatter" }
|
||||
path = { version = "0.1.0", path = "../path" }
|
||||
654
packages/web/web.lux
Normal file
654
packages/web/web.lux
Normal file
@@ -0,0 +1,654 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user