diff --git a/docs/examples/site/CODE-LOGIC.md b/docs/examples/site/CODE-LOGIC.md new file mode 100644 index 0000000..fa17165 --- /dev/null +++ b/docs/examples/site/CODE-LOGIC.md @@ -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). diff --git a/docs/examples/site/README.md b/docs/examples/site/README.md new file mode 100644 index 0000000..82f0e1d --- /dev/null +++ b/docs/examples/site/README.md @@ -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/` — one chapter. +- `POST /admin/ch/` — 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. diff --git a/docs/examples/site/content.wo b/docs/examples/site/content.wo new file mode 100644 index 0000000..7026ec8 --- /dev/null +++ b/docs/examples/site/content.wo @@ -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 .wo files and one entry: a free " .. "function named main. It returns the process exit code. There is no " .. "runtime to install separately and no build pipeline — woc build 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: woc build . -o hello && ./hello. " .. "Statements end with ;, blocks use braces, comments start with --."); + return b; +} + +fn ch_values() -> Text { + let b = el("p", "leading-relaxed mb-4", + "let binds a value; the type is inferred. Scalars: Int (64-bit), " .. "Float, Bool, Text (bytes, binary-safe). Text " .. "interpolates with \${...} and concatenates with ... " .. "Integer literals speak hex and binary, and the full bitwise set is here: " .. "& | ^ << >> — 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: multi T (a growable list) and map<K, V>. " .. "A map read m[k] answers nil when the key is absent — the everyday idiom " .. "for optional lookups like HTTP headers. for .. in walks both."); + b = b .. code_block("let langs: multi Text = [\"c\", \"ocaml\", \"writeonce\"];\npush(langs, \"more\");\nprint(\"count \${len(langs)}\");\n\nlet ages: map = {};\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 " .. "implements). 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", + "?T is a value or nil, and the compiler forces the check before use. " .. "Failures are TRAPS: named, catchable, never silent. try ... catch (e) is " .. "an expression; e 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. @table makes a class a table; " .. "insert writes a row; queries are first-class expressions; an UPDATE is a " .. "plain field assignment on a query result. With WO_DATA=<dir> 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: spawn makes an actor from a class with a " .. "receive method, send 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 wo.toml; wo.lock " .. "records the exact revision, and locked builds work offline. The [deps] KEY names the " .. "module you use. 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 :param captures, a middleware " .. "chain, handler classes, @table 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 (bearer_token, constant-time ct_eq), POLICY stays " .. "in the app. Try editing this chapter: " .. "curl -X POST -H \"authorization: Bearer ...\" -d \"title=...&body=...\" /admin/ch/serving."); + 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() }; +} diff --git a/docs/examples/site/main.wo b/docs/examples/site/main.wo new file mode 100644 index 0000000..52b7b24 --- /dev/null +++ b/docs/examples/site/main.wo @@ -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 = {}; + 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 " .. "Resp built by .wo 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 = {}; + 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 (SITE_TOKEN gates /admin; WO_DATA makes chapters durable)"); + return 2; + } + let port = parse_int(args[0]); + if port == nil { + print_err("site: 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); +} diff --git a/docs/examples/site/wo.toml b/docs/examples/site/wo.toml new file mode 100644 index 0000000..d2e15a5 --- /dev/null +++ b/docs/examples/site/wo.toml @@ -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" } diff --git a/docs/examples/wo-html/html.wo b/docs/examples/wo-html/html.wo new file mode 100644 index 0000000..e8a5ce8 --- /dev/null +++ b/docs/examples/wo-html/html.wo @@ -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}"; } + return "<${tag} class=\"${cls}\">${inner}"; +} + +-- An anchor: href is attribute context, so it is escaped here. +pub fn link(href: Text, cls: Text, label: Text) -> Text { + if cls == "" { return "${label}"; } + return "${label}"; +} + +-- 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 ""; +} + +pub fn text_area(name: Text, value: Text, rows: Int) -> Text { + return ""; +} + +pub fn submit_btn(label: Text) -> Text { + return ""; +} + +pub fn form_post(action: Text, inner: Text) -> Text { + return "
${inner}
"; +} + +-- 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 = ""; + d = d .. ""; + d = d .. "${esc(title)}"; + d = d .. ""; + d = d .. body; + d = d .. ""; + return d; +} diff --git a/docs/examples/wo-html/wo.toml b/docs/examples/wo-html/wo.toml new file mode 100644 index 0000000..a3cbd16 --- /dev/null +++ b/docs/examples/wo-html/wo.toml @@ -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`). diff --git a/justfile b/justfile index f962974..9efc7c4 100644 --- a/justfile +++ b/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. diff --git a/scripts/site-accept.sh b/scripts/site-accept.sh new file mode 100755 index 0000000..42c666b --- /dev/null +++ b/scripts/site-accept.sh @@ -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 ]]