Merge branch 'site-sample' into framework-v1b

This commit is contained in:
shoney.arickathil 2026-08-23 06:33:15 +02:00
commit e8563bed16
9 changed files with 611 additions and 0 deletions

View 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).

View 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.

View 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 &amp;&amp; ./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>&amp; | ^ &lt;&lt; &gt;&gt;</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&lt;K, V&gt;</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=&lt;dir&gt;</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=...&amp;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
View 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);
}

View 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" }

View 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 .. "&amp;"; }
else {
if b == 60 { out = out .. "&lt;"; }
else {
if b == 62 { out = out .. "&gt;"; }
else {
if b == 34 { out = out .. "&quot;"; }
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;
}

View 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`).

View file

@ -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
View 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 ]]