- docs/examples/wo-html: library dep — esc(), element builders,
Tailwind-style utility sheet as one static string, page() shell;
self-contained responses, no CDN/JS/build step
- docs/examples/site: the language tutorial served by the language —
9 seeded chapters in a @table (hello/values+bitwise/containers/
classes/optionals+traps/tables/actors/deps/serving), server-rendered
via wo-html, seed-if-empty so WAL restarts keep admin edits
- routes: / index, /ch/:slug (styled 404), /health, POST /admin/ch/
:slug (bearer handler-side — mechanism framework's, policy app's;
form-encoded title/body update by assignment, 302 back)
- chapter code samples use the lexer's \$ escape to show ${...}
literally; .. never straddles newlines (accumulator style)
- gate: scripts/site-accept.sh + just site — TWO file:// dep remotes,
11 checks incl. authed-edit-survives-restart; /health polling, no
boot-race sleep
- README: run + nginx sketch for writeonce.de; CODE-LOGIC beside code
- full battery 13/13 (site gate included)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
166 lines
5.8 KiB
Text
166 lines
5.8 KiB
Text
-- site — writeonce.de: the language tutorial, served BY the language.
|
|
-- Full stack in one binary: writeonce-framework ([deps]) for HTTP/routing/
|
|
-- auth, wo-html ([deps]) for server-rendered pages with Tailwind-style
|
|
-- utilities, @table + WAL for the chapters themselves. The site is its own
|
|
-- final chapter: /ch/serving shows this file's shape.
|
|
--
|
|
-- SITE_TOKEN=... WO_DATA=./data ./site 8080
|
|
--
|
|
-- Behind nginx/caddy for writeonce.de: the proxy terminates TLS and
|
|
-- forwards to 127.0.0.1:8080 (the framework speaks HTTP/1.1 keep-alive).
|
|
use env
|
|
use framework
|
|
use framework/http
|
|
use framework/router
|
|
use html
|
|
|
|
-- Every chapter is a row: slug is the URL, ord orders the nav, body is a
|
|
-- server-rendered HTML fragment. Edits (the admin route) persist through
|
|
-- the WAL under WO_DATA and replay on restart.
|
|
@table(name: "chapters", index: [slug])
|
|
class Chapter {
|
|
slug: Text @unique
|
|
ord: Int
|
|
title: Text
|
|
body: Text
|
|
}
|
|
|
|
fn seed_if_empty() {
|
|
let n = 0;
|
|
for c in from x in Chapter take 1 select x {
|
|
n = n + 1;
|
|
}
|
|
if n == 0 {
|
|
seed_chapters();
|
|
}
|
|
}
|
|
|
|
-- ---- rendering ---------------------------------------------------------
|
|
|
|
fn ok_html(body: Text) -> Resp {
|
|
let h: map<Text, Text> = {};
|
|
h["content-type"] = "text/html; charset=utf-8";
|
|
return Resp { status: 200, headers: h, body: body };
|
|
}
|
|
|
|
fn site_header() -> Text {
|
|
let brand = link("/", "text-xl font-bold text-gray-900 no-underline", "writeonce.de");
|
|
let tag = el("span", "text-sm text-gray-500", "one language, one runtime, one database, one binary");
|
|
return el("div", "flex items-center justify-between mb-8", brand .. tag);
|
|
}
|
|
|
|
fn shell(title: Text, inner: Text) -> Text {
|
|
let body = el("div", "mx-auto max-w-3xl px-4 py-8", site_header() .. inner);
|
|
return page(title, body);
|
|
}
|
|
|
|
fn chapter_nav(current: Int) -> Text {
|
|
let items = "";
|
|
for c in from x in Chapter order by x.ord select x {
|
|
let label = "${c.ord}. ${esc(c.title)}";
|
|
if c.ord == current {
|
|
items = items .. el("li", "mb-2 font-bold text-gray-900", label);
|
|
} else {
|
|
items = items .. el("li", "mb-2", link("/ch/${c.slug}", "", label));
|
|
}
|
|
}
|
|
return el("ul", "list-disc pl-6", items);
|
|
}
|
|
|
|
-- ---- handlers ----------------------------------------------------------
|
|
|
|
class Home {
|
|
pad: Int
|
|
fn handle(req: Req) -> Resp {
|
|
let hero = el("h1", "text-3xl font-bold mb-4", "Learn writeonce");
|
|
let intro = el("p", "leading-relaxed mb-4", "A language where the runtime, the database and the web server are one thing. " .. "This site is written in it — every page you read here is a " .. "<code>Resp</code> built by <code>.wo</code> code, stored in the language's own " .. "tables, served by its own framework. Work through the chapters in order:");
|
|
let body = el("div", "bg-white rounded-lg border shadow-sm p-6", hero .. intro .. chapter_nav(0));
|
|
return ok_html(shell("writeonce — learn the language", body));
|
|
}
|
|
}
|
|
|
|
class ShowChapter {
|
|
pad: Int
|
|
fn handle(req: Req) -> Resp {
|
|
let slug = req.params["slug"];
|
|
if slug == nil { return not_found(); }
|
|
let hits = from c in Chapter where c.slug == slug take 1 select c;
|
|
if len(hits) == 0 {
|
|
let msg = el("h1", "text-2xl font-bold mb-4", "No such chapter");
|
|
let back = el("p", "", link("/", "", "Back to the chapters"));
|
|
let h: map<Text, Text> = {};
|
|
h["content-type"] = "text/html; charset=utf-8";
|
|
let nf = shell("writeonce — not found", msg .. back);
|
|
return Resp { status: 404, headers: h, body: nf };
|
|
}
|
|
let c = hits[0];
|
|
let head = el("h1", "text-3xl font-bold mb-4", "${c.ord}. ${esc(c.title)}");
|
|
let art = el("div", "bg-white rounded-lg border shadow-sm p-6", head .. c.body);
|
|
let nav = el("div", "mt-8", el("h2", "text-lg font-bold mb-2", "Chapters") .. chapter_nav(c.ord));
|
|
return ok_html(shell("writeonce — ${c.title}", art .. nav));
|
|
}
|
|
}
|
|
|
|
-- POST /admin/ch/:slug — title/body update, form-encoded, bearer-gated.
|
|
-- Mechanism (bearer_token, constant-time ct_eq) is the framework's;
|
|
-- POLICY — which routes, which token — is this app's, right here.
|
|
class AdminEdit {
|
|
token: Text
|
|
fn handle(req: Req) -> Resp {
|
|
let got = bearer_token(req);
|
|
if got == nil { return unauthorized(); }
|
|
if ct_eq("${got}", self.token) == false { return unauthorized(); }
|
|
let slug = req.params["slug"];
|
|
if slug == nil { return not_found(); }
|
|
let hits = from c in Chapter where c.slug == slug take 1 select c;
|
|
if len(hits) == 0 { return not_found(); }
|
|
let f = form_values(req);
|
|
if f == nil { return bad_request("body must be form-encoded (title, body)"); }
|
|
let title = f["title"];
|
|
let body = f["body"];
|
|
if title == nil and body == nil { return bad_request("nothing to update"); }
|
|
if title != nil {
|
|
let t = trim("${title}");
|
|
if t == "" { return bad_request("title must not be empty"); }
|
|
hits[0].title = t;
|
|
}
|
|
if body != nil {
|
|
hits[0].body = "${body}";
|
|
}
|
|
return redirect("/ch/${slug}");
|
|
}
|
|
}
|
|
|
|
class Health {
|
|
pad: Int
|
|
fn handle(req: Req) -> Resp {
|
|
return ok_text("ok");
|
|
}
|
|
}
|
|
|
|
fn main(args: multi Text) -> Int {
|
|
if len(args) < 1 {
|
|
print_err("usage: site <port> (SITE_TOKEN gates /admin; WO_DATA makes chapters durable)");
|
|
return 2;
|
|
}
|
|
let port = parse_int(args[0]);
|
|
if port == nil {
|
|
print_err("site: <port> must be a number");
|
|
return 2;
|
|
}
|
|
let token = env.get("SITE_TOKEN");
|
|
if token == nil {
|
|
print_err("site: SITE_TOKEN is required (the admin route's bearer token)");
|
|
return 2;
|
|
}
|
|
|
|
seed_if_empty();
|
|
|
|
let app = App { middleware: [], routes: [] };
|
|
app.use_mw(Mw { m: Logging { pad: 0 } });
|
|
app.get("/", Home { pad: 0 });
|
|
app.get("/health", Health { pad: 0 });
|
|
app.get("/ch/:slug", ShowChapter { pad: 0 });
|
|
app.post("/admin/ch/:slug", AdminEdit { token: "${token}" });
|
|
return app.serve("127.0.0.1", port);
|
|
}
|