feat: writeonce.de tutorial site + wo-html library (two-dep full stack)
- 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>
This commit is contained in:
parent
64a4700190
commit
ad4b380cf1
9 changed files with 611 additions and 0 deletions
36
docs/examples/site/CODE-LOGIC.md
Normal file
36
docs/examples/site/CODE-LOGIC.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# site — how it is put together
|
||||
|
||||
Written 2026-08-23, with the sample's landing. Three files, one binary.
|
||||
|
||||
| file | what it owns |
|
||||
| --- | --- |
|
||||
| `main.wo` | the `Chapter` table, seed-if-empty, the HTML shell (header/nav), four handlers (Home, ShowChapter, AdminEdit, Health), `main` |
|
||||
| `content.wo` | the nine chapter bodies as functions returning HTML fragments, and `seed_chapters()` — same directory, so it shares `main.wo`'s declarations without `use` |
|
||||
| `wo.toml` | the two [deps]: `framework` (serving) and `html` (markup) |
|
||||
|
||||
Decisions that are not obvious from the code:
|
||||
|
||||
- **Chapters are rows, not constants.** `seed_if_empty()` inserts them only
|
||||
when the table answers empty, so a WAL restart keeps admin edits instead
|
||||
of reseeding over them — the sample's own proof of chapter 6's claim.
|
||||
The seed bodies are BUILT with wo-html's builders at boot; after that
|
||||
the table is the truth and the builders are never consulted again.
|
||||
- **Auth is handler-side by doctrine.** The framework ships mechanism
|
||||
(`bearer_token`, constant-time `ct_eq`); which routes are gated and by
|
||||
which token is policy, so `AdminEdit` checks its own field. No global
|
||||
middleware — the public pages stay public.
|
||||
- **`\$` in chapter code samples.** Chapter sources show interpolation
|
||||
(`${port}`) inside string literals of a language that interpolates —
|
||||
the lexer's `\$` escape keeps them literal; `code_block()` then
|
||||
HTML-escapes the result.
|
||||
- **One-line concat chains.** `..` does not straddle newlines (Go-style
|
||||
implicit statement ends), so long fragments build accumulator-style
|
||||
(`b = b .. "...";` per line) — the same shape serve.wo uses for
|
||||
response heads.
|
||||
- **wo-html's sheet is static.** Tailwind's class NAMES, one hand-written
|
||||
CSS string inlined per page by `page()` — self-contained responses, no
|
||||
toolchain; growing the sheet is appending a line in `tw_css()`.
|
||||
|
||||
Gate: `just site` — see scripts/site-accept.sh (11 checks; the restart
|
||||
leg polls `/health` instead of sleeping, so it does not share
|
||||
web-app-accept's 0.5s boot race).
|
||||
54
docs/examples/site/README.md
Normal file
54
docs/examples/site/README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# site — writeonce.de
|
||||
|
||||
The language tutorial, served BY the language. One binary carries the HTTP
|
||||
server, the router, the pages and the database; the chapters you read are
|
||||
rows in a `@table`, the markup is built by the `wo-html` dependency, and
|
||||
the whole thing is chapter 9's own example.
|
||||
|
||||
```
|
||||
[deps]
|
||||
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" }
|
||||
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" }
|
||||
```
|
||||
|
||||
## Run it
|
||||
|
||||
```
|
||||
woc . && SITE_TOKEN=change-me WO_DATA=./data ./target/site 8080
|
||||
```
|
||||
|
||||
- `GET /` — the chapter index; `GET /ch/<slug>` — one chapter.
|
||||
- `POST /admin/ch/<slug>` — edit a chapter (`title`/`body`, form-encoded,
|
||||
`authorization: Bearer $SITE_TOKEN`). Edits are WAL-durable under
|
||||
`WO_DATA` and replay on restart — that is chapter 6, demonstrated by
|
||||
the site that teaches it.
|
||||
- Without `WO_DATA` the chapters live in RAM and reseed on every boot.
|
||||
|
||||
The acceptance gate is `just site` (scripts/site-accept.sh): two file://
|
||||
dep remotes, build, the page matrix, 401, an authed edit, SIGTERM, and
|
||||
the edit surviving a restart.
|
||||
|
||||
## writeonce.de deployment
|
||||
|
||||
The framework speaks HTTP/1.1 keep-alive and no TLS by design — terminate
|
||||
TLS at the proxy and forward:
|
||||
|
||||
```
|
||||
server {
|
||||
server_name writeonce.de;
|
||||
listen 443 ssl http2; # certs via certbot/acme
|
||||
location / { proxy_pass http://127.0.0.1:8080; }
|
||||
}
|
||||
```
|
||||
|
||||
Run the binary under systemd (`Restart=on-failure`, `Environment=SITE_TOKEN=...`,
|
||||
`Environment=WO_DATA=/var/lib/writeonce-site`); SIGTERM drains cleanly.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
Chapters 1–9 teach the language (values, containers, classes, optionals,
|
||||
tables, actors, deps, serving); the app itself exercises the framework's
|
||||
routing/:params, the Logging middleware, bearer auth (mechanism from
|
||||
`http/auth.wo`, policy here), `form_values`, `@table` + query + update by
|
||||
assignment, and `wo-html`'s escaping/builders/Tailwind-style utility
|
||||
sheet — self-contained pages, no CDN, no JS, no build step.
|
||||
89
docs/examples/site/content.wo
Normal file
89
docs/examples/site/content.wo
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
-- site/content.wo — the tutorial chapters, seeded into the Chapter table
|
||||
-- on first boot (main.wo's seed_if_empty). Bodies are HTML fragments
|
||||
-- BUILT with the wo-html dep — prose in el(), code samples through
|
||||
-- code_block() which escapes them. Editing a chapter later (the admin
|
||||
-- route) overwrites body/title in place; the WAL keeps the edit across
|
||||
-- restarts, which is exactly chapter 6's lesson demonstrated by the
|
||||
-- site that teaches it.
|
||||
use html
|
||||
|
||||
fn ch_hello() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"A writeonce program is one directory of <code>.wo</code> files and one entry: a free " .. "function named <code>main</code>. It returns the process exit code. There is no " .. "runtime to install separately and no build pipeline — <code>woc build</code> produces " .. "ONE self-contained binary with the VM and your bytecode inside.");
|
||||
b = b .. code_block("fn main() -> Int {\n print(\"hello, writeonce\");\n return 0;\n}");
|
||||
b = b .. el("p", "leading-relaxed mt-4",
|
||||
"Run it: <code>woc build . -o hello && ./hello</code>. " .. "Statements end with <code>;</code>, blocks use braces, comments start with <code>--</code>.");
|
||||
return b;
|
||||
}
|
||||
|
||||
fn ch_values() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"<code>let</code> binds a value; the type is inferred. Scalars: <code>Int</code> (64-bit), " .. "<code>Float</code>, <code>Bool</code>, <code>Text</code> (bytes, binary-safe). Text " .. "interpolates with <code>\${...}</code> and concatenates with <code>..</code>. " .. "Integer literals speak hex and binary, and the full bitwise set is here: " .. "<code>& | ^ << >></code> — grouped Go-style, so a mask compare needs no parentheses.");
|
||||
b = b .. code_block("let port = 8080;\nlet pi = 3.14159;\nlet name = \"writeonce\";\nlet msg = \"listening on \${port}\";\n\nlet flags = 0b1010_0001;\nlet high = flags & 0xF0; -- bitwise AND, then == compares\nlet shifted = 1 << 12; -- 4096\nif flags & 0x80 != 0 {\n print(\"top bit set\"); -- groups (flags & 0x80) != 0\n}");
|
||||
return b;
|
||||
}
|
||||
|
||||
fn ch_containers() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"Two containers: <code>multi T</code> (a growable list) and <code>map<K, V></code>. " .. "A map read <code>m[k]</code> answers nil when the key is absent — the everyday idiom " .. "for optional lookups like HTTP headers. <code>for .. in</code> walks both.");
|
||||
b = b .. code_block("let langs: multi Text = [\"c\", \"ocaml\", \"writeonce\"];\npush(langs, \"more\");\nprint(\"count \${len(langs)}\");\n\nlet ages: map<Text, Int> = {};\nages[\"ada\"] = 36;\nlet a = ages[\"grace\"]; -- ?Int: nil, no trap\nif a == nil { print(\"unknown\"); }\n\nfor l in langs {\n print(l);\n}\nfor k, v in ages {\n print(\"\${k} is \${v}\");\n}");
|
||||
return b;
|
||||
}
|
||||
|
||||
fn ch_classes() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"Classes hold fields and methods. There are NO function values and NO closures — a " .. "deliberate doctrine: behavior travels as a class satisfying an interface, and " .. "satisfaction is structural (same method name and shape, Go-style, no " .. "<code>implements</code>). This is how the web framework takes handlers.");
|
||||
b = b .. code_block("interface Handler {\n fn handle(req: Req) -> Resp\n}\n\nclass Hello {\n greeting: Text\n fn handle(req: Req) -> Resp {\n return ok_text(\"\${self.greeting}, \${req.path}\");\n }\n}\n\n-- any class with a matching handle() satisfies Handler\napp.get(\"/hello\", Hello { greeting: \"hi\" });");
|
||||
return b;
|
||||
}
|
||||
|
||||
fn ch_optionals() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"<code>?T</code> is a value or nil, and the compiler forces the check before use. " .. "Failures are TRAPS: named, catchable, never silent. <code>try ... catch (e)</code> is " .. "an expression; <code>e</code> carries code, line, method and message. Anything " .. "uncaught ends the program with the same structured report.");
|
||||
b = b .. code_block("let n = parse_int(\"42x\"); -- ?Int\nif n == nil {\n print(\"not a number\");\n}\n\nlet r = try fs.read_all(\"/etc/missing\", 4096) catch (e) e.msg;\nprint(r); -- the file's bytes, or \"No such file or directory\"\n\nlet d = 0;\nlet q = try 10 / d catch (e) 0 - 1; -- DIV0 is a trap, caught here");
|
||||
return b;
|
||||
}
|
||||
|
||||
fn ch_tables() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"The database is IN the language. <code>@table</code> makes a class a table; " .. "<code>insert</code> writes a row; queries are first-class expressions; an UPDATE is a " .. "plain field assignment on a query result. With <code>WO_DATA=<dir></code> every " .. "commit is WAL-durable before it is acknowledged and replays on restart — this very " .. "site stores these chapters that way, and the admin form's edits survive a kill.");
|
||||
b = b .. code_block("@table(name: \"notes\", index: [tag])\nclass Note {\n tag: Text @unique\n val: Int\n}\n\ninsert Note { tag: \"first\", val: 1 };\n\nfor n in from x in Note where x.val > 0 order by x.tag select x {\n print(\"\${n.tag} = \${n.val}\");\n}\n\nlet hits = from x in Note where x.tag == \"first\" take 1 select x;\nif len(hits) == 1 {\n hits[0].val = 2; -- an update: assign through the row\n}");
|
||||
return b;
|
||||
}
|
||||
|
||||
fn ch_actors() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"Concurrency is actors on fibers: <code>spawn</code> makes an actor from a class with a " .. "<code>receive</code> method, <code>send</code> delivers one message at a time, and " .. "ownership MOVES with the message — no locks, no shared mutable state, no data races " .. "by construction. Blocking calls park the fiber; the shard serves others meanwhile. " .. "One VM per core by default; mailboxes are bounded (a full one is a catchable trap).");
|
||||
b = b .. code_block("class Counter {\n total: Int\n fn receive(msg: Tick) {\n self.total = self.total + msg.n;\n print(\"total \${self.total}\");\n }\n}\n\nclass Tick {\n n: Int\n}\n\nfn main() -> Int {\n let c: actor Tick = spawn Counter { total: 0 };\n send(c, Tick { n: 1 });\n send(c, Tick { n: 2 });\n time.sleep(50); -- parks this fiber; the actor runs\n return 0;\n}");
|
||||
return b;
|
||||
}
|
||||
|
||||
fn ch_deps() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"Dependencies are git repositories pinned in <code>wo.toml</code>; <code>wo.lock</code> " .. "records the exact revision, and locked builds work offline. The [deps] KEY names the " .. "module you <code>use</code>. This site has two: the web framework, and the wo-html " .. "library that rendered the page you are reading.");
|
||||
b = b .. code_block("[deps]\nframework = { git = \"https://github.com/shoneyj/writeonce-framework\", rev = \"v0.1.0\" }\nhtml = { git = \"https://github.com/shoneyj/wo-html\", rev = \"v0.1.0\" }");
|
||||
b = b .. code_block("use framework\nuse framework/http\nuse html\n\n-- html's builders + tailwind-style utilities, zero JS, no build step:\nlet body = el(\"h1\", \"text-3xl font-bold\", \"Hello\");\nreturn ok_html(page(\"Hello\", body));");
|
||||
return b;
|
||||
}
|
||||
|
||||
fn ch_serving() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"The whole stack of this site: routes with <code>:param</code> captures, a middleware " .. "chain, handler classes, <code>@table</code> persistence, and server-rendered HTML — " .. "one binary behind a proxy. This is the site's own main, abbreviated:");
|
||||
b = b .. code_block("fn main(args: multi Text) -> Int {\n seed_if_empty();\n let app = App { middleware: [], routes: [] };\n app.use_mw(Mw { m: Logging { pad: 0 } });\n app.get(\"/\", Home { pad: 0 });\n app.get(\"/ch/:slug\", ShowChapter { pad: 0 });\n app.post(\"/admin/ch/:slug\", AdminEdit { token: token });\n return app.serve(\"127.0.0.1\", port);\n}");
|
||||
b = b .. el("p", "leading-relaxed mt-4",
|
||||
"The admin route checks its bearer token in the handler — mechanism lives in the " .. "framework (<code>bearer_token</code>, constant-time <code>ct_eq</code>), POLICY stays " .. "in the app. Try editing this chapter: " .. "<code>curl -X POST -H \"authorization: Bearer ...\" -d \"title=...&body=...\" /admin/ch/serving</code>.");
|
||||
return b;
|
||||
}
|
||||
|
||||
-- One seed row per chapter: (ord, slug, title, body-builder above).
|
||||
pub fn seed_chapters() {
|
||||
insert Chapter { slug: "hello", ord: 1, title: "Hello, writeonce", body: ch_hello() };
|
||||
insert Chapter { slug: "values", ord: 2, title: "Values, Text and bitwise", body: ch_values() };
|
||||
insert Chapter { slug: "containers", ord: 3, title: "multi and map", body: ch_containers() };
|
||||
insert Chapter { slug: "classes", ord: 4, title: "Classes and interfaces", body: ch_classes() };
|
||||
insert Chapter { slug: "optionals", ord: 5, title: "Optionals and traps", body: ch_optionals() };
|
||||
insert Chapter { slug: "tables", ord: 6, title: "@table: the built-in database", body: ch_tables() };
|
||||
insert Chapter { slug: "actors", ord: 7, title: "Actors and fibers", body: ch_actors() };
|
||||
insert Chapter { slug: "deps", ord: 8, title: "Dependencies", body: ch_deps() };
|
||||
insert Chapter { slug: "serving", ord: 9, title: "Serving the web (this site)", body: ch_serving() };
|
||||
}
|
||||
166
docs/examples/site/main.wo
Normal file
166
docs/examples/site/main.wo
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
-- 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);
|
||||
}
|
||||
13
docs/examples/site/wo.toml
Normal file
13
docs/examples/site/wo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
name = "site"
|
||||
version = "0.1.0"
|
||||
description = "writeonce.de — the language tutorial served by the language: framework + wo-html [deps], @table chapters, server-rendered pages"
|
||||
|
||||
[runtime]
|
||||
wo = ">= 0.1"
|
||||
|
||||
# Two real dependencies (the gate substitutes file:// remotes built from
|
||||
# docs/examples/writeonce-framework and docs/examples/wo-html, so CI never
|
||||
# touches the network). The [deps] KEY is the module name `use` imports.
|
||||
[deps]
|
||||
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" }
|
||||
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" }
|
||||
123
docs/examples/wo-html/html.wo
Normal file
123
docs/examples/wo-html/html.wo
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
-- wo-html — server-rendered HTML as plain Text. Three layers, all pure:
|
||||
-- esc() HTML-escape untrusted text (the ONLY defense: use it on
|
||||
-- everything that did not come from your own code)
|
||||
-- el()/... element builders — `el("h1", "text-3xl font-bold", t)`
|
||||
-- tw_css() a Tailwind-style utility stylesheet: the same class
|
||||
-- names Tailwind popularized, hand-written as one static
|
||||
-- sheet, inlined by page() so a page is one self-contained
|
||||
-- response — no CDN, no build step, no JS
|
||||
--
|
||||
-- The language has no varargs and no closures (doctrine), so builders
|
||||
-- take exactly (tag, classes, inner) and pages compose by `..` and by
|
||||
-- functions returning Text. That constraint is the demo: an HTML layer
|
||||
-- in writeonce is ordinary code, not a template dialect.
|
||||
|
||||
-- HTML-escape: & < > " (the four that matter in text and attributes).
|
||||
pub fn esc(t: Text) -> Text {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
let n = len(t);
|
||||
while i < n {
|
||||
let b = byte_at(t, i);
|
||||
if b == 38 { out = out .. "&"; }
|
||||
else {
|
||||
if b == 60 { out = out .. "<"; }
|
||||
else {
|
||||
if b == 62 { out = out .. ">"; }
|
||||
else {
|
||||
if b == 34 { out = out .. """; }
|
||||
else { out = out .. substr(t, i, 1); }
|
||||
}
|
||||
}
|
||||
}
|
||||
i = i + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
-- One element. Empty class list = no attribute. The inner text is the
|
||||
-- CALLER's business: pass esc(user_text) for data, raw markup for
|
||||
-- fragments you built yourself.
|
||||
pub fn el(tag: Text, cls: Text, inner: Text) -> Text {
|
||||
if cls == "" { return "<${tag}>${inner}</${tag}>"; }
|
||||
return "<${tag} class=\"${cls}\">${inner}</${tag}>";
|
||||
}
|
||||
|
||||
-- An anchor: href is attribute context, so it is escaped here.
|
||||
pub fn link(href: Text, cls: Text, label: Text) -> Text {
|
||||
if cls == "" { return "<a href=\"${esc(href)}\">${label}</a>"; }
|
||||
return "<a href=\"${esc(href)}\" class=\"${cls}\">${label}</a>";
|
||||
}
|
||||
|
||||
-- A code block: content is ALWAYS escaped — code samples are exactly the
|
||||
-- text that breaks HTML otherwise.
|
||||
pub fn code_block(src: Text) -> Text {
|
||||
return el("pre", "code-block", el("code", "", esc(src)));
|
||||
}
|
||||
|
||||
pub fn text_input(name: Text, value: Text) -> Text {
|
||||
return "<input type=\"text\" name=\"${esc(name)}\" value=\"${esc(value)}\" class=\"field\">";
|
||||
}
|
||||
|
||||
pub fn text_area(name: Text, value: Text, rows: Int) -> Text {
|
||||
return "<textarea name=\"${esc(name)}\" rows=\"${rows}\" class=\"field\">${esc(value)}</textarea>";
|
||||
}
|
||||
|
||||
pub fn submit_btn(label: Text) -> Text {
|
||||
return "<button type=\"submit\" class=\"btn\">${esc(label)}</button>";
|
||||
}
|
||||
|
||||
pub fn form_post(action: Text, inner: Text) -> Text {
|
||||
return "<form method=\"POST\" action=\"${esc(action)}\" class=\"flex flex-col gap-2\">${inner}</form>";
|
||||
}
|
||||
|
||||
-- The utility sheet. Tailwind's names, one hand-written static sheet —
|
||||
-- only the utilities this ecosystem's pages actually use; growing it is
|
||||
-- adding a line, not adopting a toolchain. `.code-block`, `.field` and
|
||||
-- `.btn` are the three composites the builders above rely on.
|
||||
pub fn tw_css() -> Text {
|
||||
let c = "*{box-sizing:border-box;margin:0;padding:0}";
|
||||
c = c .. "body{font-family:system-ui,sans-serif;background:#f9fafb;color:#111827;line-height:1.6}";
|
||||
c = c .. ".mx-auto{margin-left:auto;margin-right:auto}";
|
||||
c = c .. ".max-w-3xl{max-width:48rem}";
|
||||
c = c .. ".p-4{padding:1rem}.p-6{padding:1.5rem}.px-4{padding-left:1rem;padding-right:1rem}";
|
||||
c = c .. ".py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}";
|
||||
c = c .. ".mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}";
|
||||
c = c .. ".mt-4{margin-top:1rem}.mt-8{margin-top:2rem}";
|
||||
c = c .. ".flex{display:flex}.flex-col{flex-direction:column}";
|
||||
c = c .. ".items-center{align-items:center}.justify-between{justify-content:space-between}";
|
||||
c = c .. ".gap-2{gap:.5rem}.gap-4{gap:1rem}";
|
||||
c = c .. ".text-sm{font-size:.875rem}.text-lg{font-size:1.125rem}";
|
||||
c = c .. ".text-xl{font-size:1.25rem}.text-2xl{font-size:1.5rem}.text-3xl{font-size:1.875rem}";
|
||||
c = c .. ".font-bold{font-weight:700}.font-mono{font-family:ui-monospace,monospace}";
|
||||
c = c .. ".text-gray-500{color:#6b7280}.text-gray-700{color:#374151}.text-gray-900{color:#111827}";
|
||||
c = c .. ".text-blue-600{color:#2563eb}.text-white{color:#fff}";
|
||||
c = c .. ".bg-white{background:#fff}.bg-gray-50{background:#f9fafb}";
|
||||
c = c .. ".bg-gray-900{background:#111827}.bg-blue-600{background:#2563eb}";
|
||||
c = c .. ".rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}";
|
||||
c = c .. ".border{border:1px solid #e5e7eb}.shadow-sm{box-shadow:0 1px 2px rgba(0,0,0,.05)}";
|
||||
c = c .. ".block{display:block}.w-full{width:100%}.leading-relaxed{line-height:1.75}";
|
||||
c = c .. ".underline{text-decoration:underline}.no-underline{text-decoration:none}";
|
||||
c = c .. ".list-disc{list-style:disc}.pl-6{padding-left:1.5rem}";
|
||||
c = c .. "a{color:#2563eb;text-decoration:none}a:hover{text-decoration:underline}";
|
||||
c = c .. ".code-block{background:#111827;color:#e5e7eb;padding:1rem;border-radius:.5rem;";
|
||||
c = c .. "overflow-x:auto;font-size:.875rem;line-height:1.6;margin:1rem 0}";
|
||||
c = c .. ".code-block code{font-family:ui-monospace,monospace;white-space:pre}";
|
||||
c = c .. ".field{display:block;width:100%;border:1px solid #e5e7eb;border-radius:.25rem;";
|
||||
c = c .. "padding:.5rem;font-family:ui-monospace,monospace;font-size:.875rem}";
|
||||
c = c .. ".btn{background:#2563eb;color:#fff;border:0;border-radius:.25rem;";
|
||||
c = c .. "padding:.5rem 1rem;font-weight:700;cursor:pointer}";
|
||||
return c;
|
||||
}
|
||||
|
||||
-- One full document: the sheet inlined, viewport set, body handed in.
|
||||
-- Self-contained by construction — view-source shows everything.
|
||||
pub fn page(title: Text, body: Text) -> Text {
|
||||
let d = "<!doctype html><html><head><meta charset=\"utf-8\">";
|
||||
d = d .. "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">";
|
||||
d = d .. "<title>${esc(title)}</title>";
|
||||
d = d .. "<style>${tw_css()}</style></head><body>";
|
||||
d = d .. body;
|
||||
d = d .. "</body></html>";
|
||||
return d;
|
||||
}
|
||||
10
docs/examples/wo-html/wo.toml
Normal file
10
docs/examples/wo-html/wo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
name = "wo-html"
|
||||
kind = "library"
|
||||
version = "0.1.0"
|
||||
description = "HTML building for writeonce apps: escaping, element builders, and a Tailwind-style utility stylesheet — server-rendered pages as plain Text"
|
||||
|
||||
[runtime]
|
||||
wo = ">= 0.1"
|
||||
|
||||
# A LIBRARY project: no `fn main`. Apps import it through wo.toml [deps]
|
||||
# (the key names the module — `html = { git = ... }` gives `use html`).
|
||||
6
justfile
6
justfile
|
|
@ -48,6 +48,12 @@ deps-accept:
|
|||
web-app:
|
||||
./scripts/web-app-accept.sh
|
||||
|
||||
# site: the writeonce.de tutorial (docs/examples/site) — TWO deps
|
||||
# (framework + wo-html), server-rendered pages, page matrix -> 401/edit
|
||||
# -> SIGTERM -> WAL restart persistence. `just site` runs it.
|
||||
site:
|
||||
./scripts/site-accept.sh
|
||||
|
||||
# fibers: the hybrid-scheduler demo (docs/examples/fibers) — part 1 byte-
|
||||
# exact budget interleave, part 2 parked-sleeper-blocks-nobody, on the
|
||||
# uring AND epoll backends plus an ASan run.
|
||||
|
|
|
|||
114
scripts/site-accept.sh
Executable file
114
scripts/site-accept.sh
Executable file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env bash
|
||||
# scripts/site-accept.sh — the writeonce.de tutorial site's gate: TWO deps
|
||||
# (framework + wo-html) resolved from run-time file:// remotes, build,
|
||||
# serve, the page matrix (render/escape/404/401/authed edit), SIGTERM,
|
||||
# and WAL restart persistence of an admin edit.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WOC="$ROOT/compiler/_build/default/bin/woc"
|
||||
WOVM="$ROOT/runtime/wovm"
|
||||
|
||||
pass=0; fail=0
|
||||
ok() { echo "ok $1"; pass=$((pass + 1)); }
|
||||
bad() { echo "FAIL $1 -- $2"; fail=$((fail + 1)); }
|
||||
|
||||
if [[ ! -x "$WOC" || ! -x "$WOVM" ]]; then
|
||||
echo "site-accept: build woc and wovm first (just woc-build; just wovm-build)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
W="$(mktemp -d "${TMPDIR:-/tmp}/site-accept.XXXXXX")"
|
||||
SRV=""
|
||||
cleanup() {
|
||||
[[ -n "$SRV" ]] && kill -9 "$SRV" 2>/dev/null
|
||||
rm -rf "$W"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---- both deps as git remotes; the app pointed at them ----
|
||||
cp -r "$ROOT/docs/examples/writeonce-framework" "$W/fw"
|
||||
cp -r "$ROOT/docs/examples/wo-html" "$W/lib"
|
||||
for d in "$W/fw" "$W/lib"; do
|
||||
git -C "$d" init -q
|
||||
git -C "$d" add -A
|
||||
git -C "$d" -c user.email=t@t -c user.name=t commit -qm v01
|
||||
git -C "$d" tag v0.1.0
|
||||
done
|
||||
cp -r "$ROOT/docs/examples/site" "$W/app"
|
||||
sed -i "s|https://github.com/shoneyj/writeonce-framework|file://$W/fw|; s|https://github.com/shoneyj/wo-html|file://$W/lib|" "$W/app/wo.toml"
|
||||
printf '[build]\nruntime = "%s"\n' "$WOVM" >> "$W/app/wo.toml"
|
||||
|
||||
# ---- 1. two-dep fetch + lock + build ----
|
||||
if "$WOC" "$W/app" >"$W/build.out" 2>&1 && [[ -x "$W/app/target/site" && -f "$W/app/wo.lock" ]]; then
|
||||
ok "deps chain: two remotes fetched + wo.lock + build"
|
||||
else
|
||||
bad "build" "$(grep -m1 "error" "$W/build.out" || head -1 "$W/build.out")"
|
||||
printf 'site-accept: %d checks, %d failures\n' "$((pass + fail))" "$fail"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORT=$((8500 + RANDOM % 400))
|
||||
DATA="$W/data"; mkdir -p "$DATA"
|
||||
|
||||
hit() { # path [method] [data] [token] -> "STATUS|BODY" (redirects not followed)
|
||||
python3 - "$PORT" "$1" "${2:-GET}" "${3:-}" "${4:-}" <<'PYEOF'
|
||||
import sys, urllib.request, urllib.error
|
||||
port, path, method, data, token = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *a, **k): return None
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{port}{path}",
|
||||
data=data.encode() if data else None, method=method)
|
||||
if data: req.add_header("content-type", "application/x-www-form-urlencoded")
|
||||
if token: req.add_header("authorization", "Bearer " + token)
|
||||
try:
|
||||
r = urllib.request.build_opener(NoRedirect).open(req, timeout=5)
|
||||
print(f"{r.status}|{r.read().decode()}")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"{e.code}|{e.read().decode()}")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
expect() { # name got want_status want_substr
|
||||
local name="$1" got="$2" want="$3" sub="$4"
|
||||
local st="${got%%|*}" body="${got#*|}"
|
||||
if [[ "$st" == "$want" && "$body" == *"$sub"* ]]; then ok "$name"
|
||||
else bad "$name" "status=$st body=$(printf '%.90s' "$body")"; fi
|
||||
}
|
||||
|
||||
serve() {
|
||||
SITE_TOKEN=s3cr3t WO_DATA="$DATA" "$W/app/target/site" "$PORT" >>"$W/srv.out" 2>&1 &
|
||||
SRV=$!
|
||||
for _ in $(seq 1 40); do
|
||||
[[ "$(hit /health 2>/dev/null)" == 200* ]] && return 0
|
||||
sleep 0.25
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- 2..8 the page matrix ----
|
||||
serve || bad "serve" "server never answered /health"
|
||||
expect "home renders the tutorial" "$(hit /)" 200 "Learn writeonce"
|
||||
expect "tailwind sheet inlined" "$(hit /)" 200 ".btn{"
|
||||
expect "chapter renders a code sample" "$(hit /ch/hello)" 200 "fn main"
|
||||
expect "escaped interpolation visible" "$(hit /ch/values)" 200 '${port}'
|
||||
expect "unknown chapter is a 404 page" "$(hit /ch/nope)" 404 "No such chapter"
|
||||
expect "admin without token is 401" "$(hit /admin/ch/hello POST "title=X")" 401 "unauthorized"
|
||||
expect "admin edit answers a redirect" "$(hit /admin/ch/hello POST "title=Hello v2" s3cr3t)" 302 ""
|
||||
expect "the edit is live" "$(hit /ch/hello)" 200 "Hello v2"
|
||||
|
||||
# ---- 9. SIGTERM stops it ----
|
||||
kill -TERM "$SRV"
|
||||
stopped=1
|
||||
for _ in $(seq 1 30); do kill -0 "$SRV" 2>/dev/null || { stopped=0; break; }; sleep 0.1; done
|
||||
[[ $stopped -eq 0 ]] && ok "SIGTERM stops the server" || bad "stop" "still running"
|
||||
SRV=""
|
||||
|
||||
# ---- 10. restart persistence: the edit replayed from the WAL ----
|
||||
serve || bad "re-serve" "server never answered /health after restart"
|
||||
expect "edit survives a restart (WAL)" "$(hit /ch/hello)" 200 "Hello v2"
|
||||
kill -TERM "$SRV" 2>/dev/null; SRV=""
|
||||
|
||||
echo
|
||||
printf 'site-accept: %d checks, %d failures\n' "$((pass + fail))" "$fail"
|
||||
[[ $fail -eq 0 ]]
|
||||
Loading…
Reference in a new issue