feat(serve+view): file serving, downloads, supported systems; rename

- rename the two libraries: writeonce-framework -> writeonce-serve
  (`use serve`), wo-html -> writeonce-view (`use view`). Names say the
  ROLE now; every sample, script, gate and live doc follows
- stories/specs/plans keep the old names: they are dated records, and
  both library READMEs carry a "renamed 2026-08-25" note
- serve/http/files.wo: StaticFiles { dir, max_bytes } — traversal
  refused not normalised, extension content types, attachment
  disposition for archives. Lifted out of the shop, which had said in
  a comment that it belonged in the framework
- shop drops its private copy and mounts the framework's
- site: /dl/*path over $WO_DIST (default ./dist), 16 MiB ceiling
- /install gains supported systems — Linux x86-64, glibc >= 2.38,
  not musl — read off `file` and the binaries' GLIBC_ symbol
  versions, not off a wish list; plus GitHub release as primary,
  /dl as mirror, and the sha256 verify step
- site-accept: 17 -> 21 checks (supported systems, gzip download with
  a binary-safe probe, checksum, /dl traversal 404)

Verified on 192.168.0.165: the real 960,820-byte tarball downloads
as application/gzip and its sha256 matches the published digest.

Gates: oop-accept MET, site 21/0, web-app 46/0, fibers 10/0,
db-actor 8/0; shop rebuilt and its /assets served by the framework.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-25 04:41:00 +02:00
parent 3afdafa5c4
commit ef37b8ffa2
62 changed files with 310 additions and 158 deletions

View file

@ -269,7 +269,7 @@ dependencies, declared in the manifest:
```toml ```toml
[deps] [deps]
niceframework = { git = "https://github.com/shoneyj/niceframework", rev = "v0.1.0" } niceserve = { git = "https://github.com/shoneyj/niceframework", rev = "v0.1.0" }
``` ```
`woc` fetches each dep (via the `git` binary) into `.wo-deps/<name>/`, pins `woc` fetches each dep (via the `git` binary) into `.wo-deps/<name>/`, pins
@ -303,7 +303,7 @@ acceptance tests:
just employee # compile + run every mode against a durable database just employee # compile + run every mode against a durable database
``` ```
- **`docs/examples/writeonce-framework/` + `docs/examples/web-app/`** — a web - **`docs/examples/writeonce-serve/` + `docs/examples/web-app/`** — a web
framework written in writeonce (HTTP/1.1 behind a TLS-terminating proxy, framework written in writeonce (HTTP/1.1 behind a TLS-terminating proxy,
router with `:param` captures, interface-based handlers) and a storefront router with `:param` captures, interface-based handlers) and a storefront
consuming it **as a `[deps]` dependency**, with `@table` persistence. Run: consuming it **as a `[deps]` dependency**, with `@table` persistence. Run:

View file

@ -332,7 +332,7 @@ Things worth knowing before editing them:
- **`{{ e }}` desugars to `esc(${e})`, resolved by ordinary name - **`{{ e }}` desugars to `esc(${e})`, resolved by ordinary name
lookup.** `desugar_interp` in `parser.ml` builds a `Call` on an lookup.** `desugar_interp` in `parser.ml` builds a `Call` on an
`Ident "esc"` — precisely what a developer wrote by hand before. The `Ident "esc"` — precisely what a developer wrote by hand before. The
compiler learns nothing about HTML, `esc` stays wo-html's ordinary compiler learns nothing about HTML, `esc` stays writeonce-view's ordinary
`pub fn`, a typo'd field inside the hole is a normal name/type error, `pub fn`, a typo'd field inside the hole is a normal name/type error,
and a locally defined `esc` shadows deliberately (a custom escaper is and a locally defined `esc` shadows deliberately (a custom escaper is
a feature). `${ }` inside the same literal stays raw — that is the a feature). `${ }` inside the same literal stays raw — that is the

View file

@ -79,7 +79,7 @@ the number or produce the benchmark.
| 22's battery never run | 22 is ⬜ "needs a spec first"; no `bench/baseline.json`, no `just db-bench`; `runtime/bench/` is the retired C prototype's harness | | 22's battery never run | 22 is ⬜ "needs a spec first"; no `bench/baseline.json`, no `just db-bench`; `runtime/bench/` is the retired C prototype's harness |
| TSan covers one demo | only `scripts/fibers-accept.sh` builds and runs `wovm_tsan` | | TSan covers one demo | only `scripts/fibers-accept.sh` builds and runs `wovm_tsan` |
| no fuzzing, no CI | no `.github/`, no fuzz target | | no fuzzing, no CI | no `.github/`, no fuzz target |
| one framework, five samples, one consumer | exact: `writeonce-framework`; employee, employee-list, fibers, gc-cycle, log-watcher; `web-app` | | one framework, five samples, one consumer | exact: `writeonce-serve`; employee, employee-list, fibers, gc-cycle, log-watcher; `web-app` |
### Consequence ### Consequence

View file

@ -38,19 +38,19 @@ fn render() -> Text {
} }
``` ```
Every `render()` makes its class a **component** — wo-html's structural Every `render()` makes its class a **component** — writeonce-view's structural
`Component` interface, satisfied by having the method, never declared. `Component` interface, satisfied by having the method, never declared.
Parent components hold children directly (`cards: multi Component`) and Parent components hold children directly (`cards: multi Component`) and
render them with `render_all`, so `ProductListPage` knows nothing about render them with `render_all`, so `ProductListPage` knows nothing about
`ProductCard` beyond `render()`. The document itself is a component too: `ProductCard` beyond `render()`. The document itself is a component too:
`AppShell { title, content }` in `layout/app.wo`, which links a real `AppShell { title, content }` in `layout/app.wo`, which links a real
stylesheet rather than inlining one — that is why it is its own shell and stylesheet rather than inlining one — that is why it is its own shell and
not wo-html's `Layout`. not writeonce-view's `Layout`.
Two holes, and the difference is the whole escaping story: Two holes, and the difference is the whole escaping story:
- `{{ expr }}` **HTML-escapes** — it compiles to a call to the `esc` in - `{{ expr }}` **HTML-escapes** — it compiles to a call to the `esc` in
scope (wo-html's, unless the app declares its own). Display data goes scope (writeonce-view's, unless the app declares its own). Display data goes
here; a typo'd field is a compile error, not a broken page. here; a typo'd field is a compile error, not a broken page.
- `${ expr }` is **raw** — for markup you built yourself, like the - `${ expr }` is **raw** — for markup you built yourself, like the
`${stock}` fragment above or `${content}` in the app shell. `${stock}` fragment above or `${content}` in the app shell.
@ -70,7 +70,7 @@ up, or sandbox.
| `product_list/view.wo` | VIEW — classes with `fn render() -> Text`, fields = exactly what is displayed | `product-list/view.html` | | `product_list/view.wo` | VIEW — classes with `fn render() -> Text`, fields = exactly what is displayed | `product-list/view.html` |
| `product_list/controller.wo` | CONTROLLER — query the model, fill the view, answer a `Resp`; beside its view in the same module | component `.ts` + service | | `product_list/controller.wo` | CONTROLLER — query the model, fill the view, answer a `Resp`; beside its view in the same module | component `.ts` + service |
| `product_page/`, `orders/` | one module per feature: `view.wo` + `controller.wo` | feature folders | | `product_page/`, `orders/` | one module per feature: `view.wo` + `controller.wo` | feature folders |
| `static_files/controller.wo` | `/assets/*` from disk, traversal-safe, typed | `angular.json` assets | | `/assets/*` | served by the framework's `StaticFiles` — the template no longer carries its own copy | `angular.json` assets |
| `assets/style.css` | ONE real stylesheet, sectioned per feature | the `.scss` files | | `assets/style.css` | ONE real stylesheet, sectioned per feature | the `.scss` files |
| the `render()` bodies | ONE raw text literal each: real newlines, real double-quoted attributes, source indentation removed at compile time, `${}` raw holes and `{{ }}` auto-escaping ones | Vue `<template>` (in-SFC) | | the `render()` bodies | ONE raw text literal each: real newlines, real double-quoted attributes, source indentation removed at compile time, `${}` raw holes and `{{ }}` auto-escaping ones | Vue `<template>` (in-SFC) |
| `layout/app.wo` → `AppShell` | the document, as a two-slot component | `app.component.html` | | `layout/app.wo` → `AppShell` | the document, as a two-slot component | `app.component.html` |
@ -94,12 +94,12 @@ it looks like.
this directory was its consumer. Styles stay a real CSS file, served this directory was its consumer. Styles stay a real CSS file, served
statically (there is no scss preprocessor). statically (there is no scss preprocessor).
- **No closures, no DI.** A view is a class with fields + `render()` - **No closures, no DI.** A view is a class with fields + `render()`
(wo-html's `Component`); a controller is a class satisfying `Handler`. (writeonce-view's `Component`); a controller is a class satisfying `Handler`.
Capture = a field. Capture = a field.
- **The MVC seam is enforced by where the query sits.** Controllers hold - **The MVC seam is enforced by where the query sits.** Controllers hold
every `from … select`; a view receives VALUES — for the list page, a every `from … select`; a view receives VALUES — for the list page, a
`multi Component` of already-filled cards. No view in this template `multi Component` of already-filled cards. No view in this template
touches the database, and wo-html contains no query at all. touches the database, and writeonce-view contains no query at all.
- **No sessions/cart yet.** Buying is per-product (qty → order). A cart - **No sessions/cart yet.** Buying is per-product (qty → order). A cart
needs a session story that does not exist yet. needs a session story that does not exist yet.
- **No client-side JS.** Every interaction is a form round trip. - **No client-side JS.** Every interaction is a form round trip.

View file

@ -2,11 +2,11 @@
-- wrapping every page with the shared header and footer. Styles are NOT -- wrapping every page with the shared header and footer. Styles are NOT
-- inlined — pages link /assets/style.css (served by static_files), so -- inlined — pages link /assets/style.css (served by static_files), so
-- markup and styling stay separate files. -- markup and styling stay separate files.
use framework/http use serve/http
use html use view
-- The app shell as a COMPONENT (wo-html's `Component`: fields in, Text -- The app shell as a COMPONENT (writeonce-view's `Component`: fields in, Text
-- out), the same shape as wo-html's own `Layout` — two slots instead of -- out), the same shape as writeonce-view's own `Layout` — two slots instead of
-- four, and its own document rather than `page()`'s, because this -- four, and its own document rather than `page()`'s, because this
-- template deliberately LINKS a stylesheet instead of inlining one. -- template deliberately LINKS a stylesheet instead of inlining one.
-- That is the whole reason it is not just `Layout`: markup and styling -- That is the whole reason it is not just `Layout`: markup and styling

View file

@ -4,12 +4,12 @@
-- --
-- WO_DATA=./data ./target/shop 8080 (run from the shop directory: -- WO_DATA=./data ./target/shop 8080 (run from the shop directory:
-- /assets/* serves from ./assets) -- /assets/* serves from ./assets)
use framework use serve
use framework/router use serve/http
use serve/router
use product_list use product_list
use product_page use product_page
use orders use orders
use static_files
fn seed_if_empty() { fn seed_if_empty() {
let n = 0; let n = 0;
@ -44,6 +44,8 @@ fn main(args: multi Text) -> Int {
app.get("/p/:sku", ShowProduct {}); app.get("/p/:sku", ShowProduct {});
app.post("/orders/:sku", CreateOrder {}); app.post("/orders/:sku", CreateOrder {});
app.get("/orders", ListOrders {}); app.get("/orders", ListOrders {});
app.get("/assets/*path", StaticFiles { dir: "assets" }); -- the framework's file server (serve/http): 2 MiB ceiling is well
-- above this template's stylesheet.
app.get("/assets/*path", StaticFiles { dir: "assets", max_bytes: 2097152 });
return app.serve("127.0.0.1", port); return app.serve("127.0.0.1", port);
} }

View file

@ -1,7 +1,7 @@
-- orders/controller.wo — the buying flow: stock-checked order creation -- orders/controller.wo — the buying flow: stock-checked order creation
-- (decrement + insert are each WAL-committed before they acknowledge) -- (decrement + insert are each WAL-committed before they acknowledge)
-- and the orders list (ref navigation: o.product.name). -- and the orders list (ref navigation: o.product.name).
use framework/http use serve/http
use layout use layout
use time use time

View file

@ -1,7 +1,7 @@
-- orders/view.wo — the confirmation page and the orders table. -- orders/view.wo — the confirmation page and the orders table.
-- Raw text literals for the bodies; see product_list/view.wo for the -- Raw text literals for the bodies; see product_list/view.wo for the
-- convention. -- convention.
use html use view
pub class OrderOk { pub class OrderOk {
name: Text name: Text

View file

@ -3,7 +3,7 @@
-- directory = one module, view and controller together. The @tables it -- directory = one module, view and controller together. The @tables it
-- queries live in the root module, which is fine: a CLASS is reachable -- queries live in the root module, which is fine: a CLASS is reachable
-- across module lines (only free `fn`s are module-scoped). -- across module lines (only free `fn`s are module-scoped).
use framework/http use serve/http
use layout use layout
pub class ListProducts { pub class ListProducts {

View file

@ -4,7 +4,7 @@
-- source indentation removed at compile time. `${}` interpolates raw, -- source indentation removed at compile time. `${}` interpolates raw,
-- `{{ }}` HTML-escapes — so display data goes through `{{ }}` and never -- `{{ }}` HTML-escapes — so display data goes through `{{ }}` and never
-- through a hand-written esc() call. -- through a hand-written esc() call.
use html use view
pub class ProductCard { pub class ProductCard {
sku: Text sku: Text

View file

@ -1,5 +1,5 @@
-- product_page/controller.wo — the CONTROLLER for /p/:sku. -- product_page/controller.wo — the CONTROLLER for /p/:sku.
use framework/http use serve/http
use layout use layout
pub class ShowProduct { pub class ShowProduct {

View file

@ -1,7 +1,7 @@
-- product_page/view.wo — the product detail view with the order form. -- product_page/view.wo — the product detail view with the order form.
-- Raw text literals for the bodies; see product_list/view.wo for the -- Raw text literals for the bodies; see product_list/view.wo for the
-- convention. -- convention.
use html use view
pub class ProductPage { pub class ProductPage {
sku: Text sku: Text

View file

@ -1,33 +0,0 @@
-- static_files/controller.wo — serves /assets/* from disk. Traversal-
-- safe (any ".." answers 404, never touches the filesystem), extension-
-- mapped content types, 2 MiB cap per file. Text is binary-safe, so
-- images travel as-is. (Story 38 lifts this into the framework; until
-- then the template carries its own copy — it is ~40 lines.)
use framework/http
use fs
pub class StaticFiles {
dir: Text
fn handle(req: Req) -> Resp {
let rel = req.params["path"];
if rel == nil {
return not_found();
}
if index_of("${rel}", "..") != -1 {
return not_found();
}
let body = try fs.read_all("${self.dir}/${rel}", 2097152) catch (e) nil;
if body == nil {
return not_found();
}
let ct = "application/octet-stream";
if ends_with("${rel}", ".css") { ct = "text/css; charset=utf-8"; }
if ends_with("${rel}", ".js") { ct = "text/javascript"; }
if ends_with("${rel}", ".svg") { ct = "image/svg+xml"; }
if ends_with("${rel}", ".png") { ct = "image/png"; }
if ends_with("${rel}", ".webp") { ct = "image/webp"; }
let h: map<Text, Text> = {};
h["content-type"] = ct;
return Resp { status: 200, headers: h, body: "${body}" };
}
}

View file

@ -8,5 +8,5 @@ wo = ">= 0.1"
# Two library dependencies, the site sample's proven shape. The [deps] # Two library dependencies, the site sample's proven shape. The [deps]
# KEY is the module name `use` imports. # KEY is the module name `use` imports.
[deps] [deps]
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" } serve = { git = "https://github.com/shoneyj/writeonce-serve", rev = "v0.1.0" }
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" } view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }

View file

@ -10,7 +10,7 @@ samples now read the same way.
| --- | --- | --- | | --- | --- | --- |
| `types.wo` | MODEL | the `Chapter` `@table`, the `ChapterLink` projection, `Chapters.links()`, and `seed_if_empty()` | | `types.wo` | MODEL | the `Chapter` `@table`, the `ChapterLink` projection, `Chapters.links()`, and `seed_if_empty()` |
| `content.wo` | MODEL (content) | the nine chapter bodies as fragment-returning functions, plus `seed_chapters()` | | `content.wo` | MODEL (content) | the nine chapter bodies as fragment-returning functions, plus `seed_chapters()` |
| `layout/app.wo` | VIEW (chrome) | `AppShell` — the component that fills wo-html's `Layout` — the two named widths, and `html_error` | | `layout/app.wo` | VIEW (chrome) | `AppShell` — the component that fills writeonce-view's `Layout` — the two named widths, and `html_error` |
| `layout/header.wo`, `layout/footer.wo` | VIEW (chrome) | the shared nav bar (brand = mark + wordmark) and footer | | `layout/header.wo`, `layout/footer.wo` | VIEW (chrome) | the shared nav bar (brand = mark + wordmark) and footer |
| `layout/logo.wo` | VIEW (chrome) | the mark as inline SVG, plus the `<head>` links | | `layout/logo.wo` | VIEW (chrome) | the mark as inline SVG, plus the `<head>` links |
| `install/view.wo`, `install/controller.wo` | VIEW + CONTROLLER | `/install` — the toolchain guide. Static copy, so `InstallPage` has no fields | | `install/view.wo`, `install/controller.wo` | VIEW + CONTROLLER | `/install` — the toolchain guide. Static copy, so `InstallPage` has no fields |
@ -43,7 +43,7 @@ class — recorded gap #1 — but nothing needs it to.)
- **Chapters are rows, not constants.** `seed_if_empty()` inserts them - **Chapters are rows, not constants.** `seed_if_empty()` inserts them
only when the table answers empty, so a WAL restart keeps admin edits 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 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 claim. The seed bodies are BUILT with writeonce-view's builders at boot; after
that the table is the truth and the builders are never consulted again. that the table is the truth and the builders are never consulted again.
- **The seam is enforced by where the query sits.** `Chapters.links()` - **The seam is enforced by where the query sits.** `Chapters.links()`
lives with the MODEL and hands the view a `multi ChapterLink` — lives with the MODEL and hands the view a `multi ChapterLink` —
@ -87,10 +87,19 @@ class — recorded gap #1 — but nothing needs it to.)
hole; it is written with `&#123;` entities instead. This is the same hole; it is written with `&#123;` entities instead. This is the same
limitation the chapter code samples hit with `${`, and the reason both limitation the chapter code samples hit with `${`, and the reason both
doors exist. doors exist.
- **Downloads are the framework's, not the site's.** `/dl/*path` is
`StaticFiles` mounted in `main.wo` with a 16 MiB ceiling — no
controller, because there is no decision to make. `WO_DIST` says where
the tarballs are (default `./dist`). The install page offers the GitHub
release as primary and this as a mirror, with the `.sha256` beside it.
- **The supported-systems list is read off the binaries**, not off a
wish list: `file` gives the triple, and the highest `GLIBC_` symbol
version they import gives the libc floor (2.38 today). Overstating
support costs a reader an afternoon.
- **`SITE_HOST` picks the interface.** Loopback by default — right behind - **`SITE_HOST` picks the interface.** Loopback by default — right behind
a proxy — with the env var for reaching a dev instance across the LAN. a proxy — with the env var for reaching a dev instance across the LAN.
The bound address is printed at startup. The bound address is printed at startup.
- **wo-html's sheet is static.** Tailwind's class NAMES, one hand-written - **writeonce-view's sheet is static.** Tailwind's class NAMES, one hand-written
CSS string inlined per page by `page()` — self-contained responses, no CSS string inlined per page by `page()` — self-contained responses, no
toolchain; growing the sheet is appending a line in `tw_css()`. toolchain; growing the sheet is appending a line in `tw_css()`.

View file

@ -2,13 +2,13 @@
The language tutorial, served BY the language. One binary carries the HTTP 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 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 rows in a `@table`, the markup is built by the `writeonce-view` dependency, and
the whole thing is chapter 9's own example. the whole thing is chapter 9's own example.
``` ```
[deps] [deps]
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" } serve = { git = "https://github.com/shoneyj/writeonce-serve", rev = "v0.1.0" }
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" } view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }
``` ```
## Run it ## Run it
@ -29,6 +29,9 @@ SITE_HOST=0.0.0.0 SITE_TOKEN=change-me WO_DATA=./data ./target/site 8080
`GET /packages/<name>` — the package catalogue with copy-paste `GET /packages/<name>` — the package catalogue with copy-paste
`[deps]` lines and usage. `[deps]` lines and usage.
- `GET /favicon.svg` — the mark, inline SVG, no asset pipeline. - `GET /favicon.svg` — the mark, inline SVG, no asset pipeline.
- `GET /dl/<file>` — release tarballs, served by the framework's
`StaticFiles` from `$WO_DIST` (default `./dist`, where `just dist`
writes them).
- `POST /admin/ch/<slug>` — edit a chapter (`title`/`body`, form-encoded, - `POST /admin/ch/<slug>` — edit a chapter (`title`/`body`, form-encoded,
`authorization: Bearer $SITE_TOKEN`). Edits are WAL-durable under `authorization: Bearer $SITE_TOKEN`). Edits are WAL-durable under
`WO_DATA` and replay on restart — that is chapter 6, demonstrated by `WO_DATA` and replay on restart — that is chapter 6, demonstrated by
@ -54,7 +57,7 @@ MVC, laid out exactly like the program template
| `layout/logo.wo` | the mark as inline SVG: one source for the nav brand and `/favicon.svg` | | `layout/logo.wo` | the mark as inline SVG: one source for the nav brand and `/favicon.svg` |
| `main.wo` | bootstrap: seed, routes, serve — nothing else | | `main.wo` | bootstrap: seed, routes, serve — nothing else |
Every `render()` makes its class a component (wo-html's structural Every `render()` makes its class a component (writeonce-view's structural
`Component`). `HomePage` and `ChapterPage` each take the chapter nav as `Component`). `HomePage` and `ChapterPage` each take the chapter nav as
an already-built child component in a slot, so neither knows what a an already-built child component in a slot, so neither knows what a
chapter is; `ChapterNav` is therefore literally the same component on the chapter is; `ChapterNav` is therefore literally the same component on the
@ -64,7 +67,7 @@ current.
The seam that keeps it honest: **every query lives in a controller.** The seam that keeps it honest: **every query lives in a controller.**
`chapter_links()` sits in `chapter.controller.wo` and hands the views a `chapter_links()` sits in `chapter.controller.wo` and hands the views a
`multi ChapterLink` projection — no component in this sample touches the `multi ChapterLink` projection — no component in this sample touches the
database, and wo-html contains no query at all. database, and writeonce-view contains no query at all.
One feature = one directory = one module, holding that feature's view One feature = one directory = one module, holding that feature's view
and its controller. The `@table` lives in `types.wo` at the root and is and its controller. The `@table` lives in `types.wo` at the root and is
@ -96,5 +99,5 @@ Chapters 1–9 teach the language (values, containers, classes, optionals,
tables, actors, deps, serving); the app itself exercises the framework's tables, actors, deps, serving); the app itself exercises the framework's
routing/:params, the Logging middleware, bearer auth (mechanism from routing/:params, the Logging middleware, bearer auth (mechanism from
`http/auth.wo`, policy here), `form_values`, `@table` + query + update by `http/auth.wo`, policy here), `form_values`, `@table` + query + update by
assignment, and `wo-html`'s escaping/builders/Tailwind-style utility assignment, and `writeonce-view`'s escaping/builders/Tailwind-style utility
sheet — self-contained pages, no CDN, no JS, no build step. sheet — self-contained pages, no CDN, no JS, no build step.

View file

@ -2,7 +2,7 @@
-- form-encoded, bearer-gated. Mechanism (bearer_token, constant-time -- form-encoded, bearer-gated. Mechanism (bearer_token, constant-time
-- ct_eq) is the framework's; POLICY — which routes, which token — is -- ct_eq) is the framework's; POLICY — which routes, which token — is
-- this app's, right here. No rendering: the answer is a redirect. -- this app's, right here. No rendering: the answer is a redirect.
use framework/http use serve/http
pub class AdminEdit { pub class AdminEdit {
token: Text token: Text

View file

@ -1,7 +1,7 @@
-- chapter/controller.wo — the CONTROLLER for `/ch/:slug`. It queries the -- chapter/controller.wo — the CONTROLLER for `/ch/:slug`. It queries the
-- model and fills the view components that sit beside it in this module; -- model and fills the view components that sit beside it in this module;
-- a view receives VALUES, never a cursor. -- a view receives VALUES, never a cursor.
use framework/http use serve/http
use layout use layout
pub class ShowChapter { pub class ShowChapter {

View file

@ -6,7 +6,7 @@
-- links), so it renders identically on the homepage and on a chapter -- links), so it renders identically on the homepage and on a chapter
-- page — the only difference is which ord is `current`. Reuse is the -- page — the only difference is which ord is `current`. Reuse is the
-- same component with different fields, never copied markup. -- same component with different fields, never copied markup.
use html use view
-- `ChapterLink` is the MODEL's projection type (types.wo); a class is -- `ChapterLink` is the MODEL's projection type (types.wo); a class is
-- reachable across module lines, so the view just names it. -- reachable across module lines, so the view just names it.

View file

@ -1,12 +1,12 @@
-- content.wo — the tutorial chapters, seeded into the Chapter table on -- content.wo — the tutorial chapters, seeded into the Chapter table on
-- first boot (types.wo's seed_if_empty). Model CONTENT, so it sits in -- first boot (types.wo's seed_if_empty). Model CONTENT, so it sits in
-- the root module beside types.wo: it inserts rows. Bodies are HTML fragments -- the root module beside types.wo: it inserts rows. Bodies are HTML fragments
-- BUILT with the wo-html dep — prose in el(), code samples through -- BUILT with the writeonce-view dep — prose in el(), code samples through
-- code_block() which escapes them. Editing a chapter later (the admin -- code_block() which escapes them. Editing a chapter later (the admin
-- route) overwrites body/title in place; the WAL keeps the edit across -- route) overwrites body/title in place; the WAL keeps the edit across
-- restarts, which is exactly chapter 6's lesson demonstrated by the -- restarts, which is exactly chapter 6's lesson demonstrated by the
-- site that teaches it. -- site that teaches it.
use html use view
fn ch_hello() -> Text { fn ch_hello() -> Text {
let b = el("p", "leading-relaxed mb-4", let b = el("p", "leading-relaxed mb-4",
@ -61,15 +61,15 @@ fn ch_actors() -> Text {
fn ch_deps() -> Text { fn ch_deps() -> Text {
let b = el("p", "leading-relaxed mb-4", 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."); "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 writeonce-view " .. "library that rendered the page you are reading.");
b = b .. code_block(` b = b .. code_block(`
[deps] [deps]
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" } serve = { git = "https://github.com/shoneyj/writeonce-serve", rev = "v0.1.0" }
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" }`); view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }`);
b = b .. code_block(` b = b .. code_block(`
use framework use serve
use framework/http use serve/http
use html use view
-- html's builders + tailwind-style utilities, zero JS, no build step: -- html's builders + tailwind-style utilities, zero JS, no build step:
let body = el("h1", "text-3xl font-bold", "Hello"); let body = el("h1", "text-3xl font-bold", "Hello");

View file

@ -1,6 +1,6 @@
-- favicon/controller.wo — GET /favicon.svg. The one route that answers -- favicon/controller.wo — GET /favicon.svg. The one route that answers
-- something other than HTML or text, so it builds its own Resp. -- something other than HTML or text, so it builds its own Resp.
use framework/http use serve/http
use layout use layout
pub class Favicon { pub class Favicon {

View file

@ -1,6 +1,6 @@
-- health.controller.wo — GET /health: the liveness probe the accept -- health.controller.wo — GET /health: the liveness probe the accept
-- script and any proxy poll. Text, not HTML, on purpose. -- script and any proxy poll. Text, not HTML, on purpose.
use framework/http use serve/http
pub class Health { pub class Health {
fn handle(req: Req) -> Resp { fn handle(req: Req) -> Resp {

View file

@ -5,7 +5,7 @@
-- It reaches the chapter nav through `use chapter` and the query through -- It reaches the chapter nav through `use chapter` and the query through
-- the model's `Chapters.links()` static — a class crosses module lines, -- the model's `Chapters.links()` static — a class crosses module lines,
-- a free fn does not. -- a free fn does not.
use framework/http use serve/http
use layout use layout
use chapter use chapter

View file

@ -1,10 +1,10 @@
-- home/view.wo — the VIEW for `/`. A component: fields in, Text out. -- home/view.wo — the VIEW for `/`. A component: fields in, Text out.
-- Everything on this page is static copy EXCEPT the chapter list, so -- Everything on this page is static copy EXCEPT the chapter list, so
-- the one field is that list's already-built component — content -- the one field is that list's already-built component — content
-- projection, the same slot pattern wo-html's `Layout` uses. HomePage -- projection, the same slot pattern writeonce-view's `Layout` uses. HomePage
-- therefore knows nothing about chapters, the Chapter table, or how the -- therefore knows nothing about chapters, the Chapter table, or how the
-- nav decides which entry is current. -- nav decides which entry is current.
use html use view
pub class HomePage { pub class HomePage {
chapter_nav: Component chapter_nav: Component

View file

@ -1,6 +1,6 @@
-- install/controller.wo — GET /install. Nothing to query: the page is -- install/controller.wo — GET /install. Nothing to query: the page is
-- static copy, so the controller only wraps it in the shell. -- static copy, so the controller only wraps it in the shell.
use framework/http use serve/http
use layout use layout
pub class ShowInstall { pub class ShowInstall {

View file

@ -1,7 +1,7 @@
-- install/view.wo — the VIEW for /install. Static copy: no fields, so -- install/view.wo — the VIEW for /install. Static copy: no fields, so
-- the component has none. It is still a component, and still renders -- the component has none. It is still a component, and still renders
-- through the same interface as every other page. -- through the same interface as every other page.
use html use view
pub class InstallPage { pub class InstallPage {
fn render() -> Text { fn render() -> Text {
@ -13,43 +13,58 @@ pub class InstallPage {
language runtime to install on the machines you deploy to, and no language runtime to install on the machines you deploy to, and no
build toolchain beyond these two files.</p>`; build toolchain beyond these two files.</p>`;
let s1 = section("1. Get the toolchain", let sys = supported();
`<p class="leading-relaxed mb-4">Download the release tarball and extract
it into <code>/usr/local</code>. Run this as root, or through let s1 = section("1. Download",
`<p class="leading-relaxed mb-4">One tarball, two binaries. Take it from
the GitHub release, or from this site as a mirror — they are the same
bytes, and the checksum below proves it:</p>
<p class="mb-4">
<a class="btn no-underline" href="https://github.com/shoneyj/writeonce/releases/download/v0.1.0/writeonce-0.1.0-linux-amd64.tar.gz">Download 0.1.0 (linux-amd64)</a>
<a class="btn-outline no-underline" href="/dl/writeonce-0.1.0-linux-amd64.tar.gz">Mirror</a>
</p>
<p class="leading-relaxed mb-4">Verify it before you extract it. The
expected digest is served beside the archive at
<a href="/dl/writeonce-0.1.0-linux-amd64.tar.gz.sha256">.sha256</a>:</p>`,
code_block("curl -O https://writeonce.de/dl/writeonce-0.1.0-linux-amd64.tar.gz\ncurl -O https://writeonce.de/dl/writeonce-0.1.0-linux-amd64.tar.gz.sha256\nsha256sum -c writeonce-0.1.0-linux-amd64.tar.gz.sha256"));
let s2 = section("2. Extract it",
`<p class="leading-relaxed mb-4">Extract into <code>/usr/local</code>,
replacing any previous install. Run this as root, or through
<code>sudo</code>:</p>`, <code>sudo</code>:</p>`,
code_block("rm -rf /usr/local/writeonce\ntar -C /usr/local -xzf writeonce-0.1.0-linux-amd64.tar.gz")); code_block("rm -rf /usr/local/writeonce\ntar -C /usr/local -xzf writeonce-0.1.0-linux-amd64.tar.gz"));
let s2 = section("2. Put it on your PATH", let s3 = section("3. Put it on your PATH",
`<p class="leading-relaxed mb-4">Add one line to your `<p class="leading-relaxed mb-4">Add one line to your
<code>$HOME/.profile</code> (or <code>/etc/profile</code> for every user <code>$HOME/.profile</code> (or <code>/etc/profile</code> for every user
on the box), then restart your shell:</p>`, on the box), then restart your shell:</p>`,
code_block("export PATH=$PATH:/usr/local/writeonce/bin")); code_block("export PATH=$PATH:/usr/local/writeonce/bin"));
let s3 = section("3. Check it", let s4 = section("4. Check it",
`<p class="leading-relaxed mb-4">Both should print the same version. `<p class="leading-relaxed mb-4">Both should print the same version.
<code>woc</code> finds <code>wovm</code> beside itself, so a tarball <code>woc</code> finds <code>wovm</code> beside itself, so a tarball
install needs no further configuration.</p>`, install needs no further configuration.</p>`,
code_block("woc version # writeonce 0.1.0 linux/amd64\nwovm --version # wovm 0.1.0")); code_block("woc version # writeonce 0.1.0 linux/amd64\nwovm --version # wovm 0.1.0"));
let s4 = section("4. Your first project", let s5 = section("5. Your first project",
`<p class="leading-relaxed mb-4">A project is a directory with a `<p class="leading-relaxed mb-4">A project is a directory with a
<code>wo.toml</code> manifest and one or more <code>.wo</code> files. <code>wo.toml</code> manifest and one or more <code>.wo</code> files.
Nothing else — no lockfile to create by hand, no scaffolding step.</p>`, Nothing else — no lockfile to create by hand, no scaffolding step.</p>`,
code_block("mkdir hello && cd hello\n\ncat > wo.toml <<'EOF'\nname = \"hello\"\nversion = \"0.1.0\"\n\n[runtime]\nwo = \">= 0.1\"\nEOF\n\ncat > main.wo <<'EOF'\nfn main() -> Int {\n print(\"hello, writeonce\");\n return 0;\n}\nEOF")); code_block("mkdir hello && cd hello\n\ncat > wo.toml <<'EOF'\nname = \"hello\"\nversion = \"0.1.0\"\n\n[runtime]\nwo = \">= 0.1\"\nEOF\n\ncat > main.wo <<'EOF'\nfn main() -> Int {\n print(\"hello, writeonce\");\n return 0;\n}\nEOF"));
let s5 = section("5. Build and run", let s6 = section("6. Build and run",
`<p class="leading-relaxed mb-4"><code>woc &lt;dir&gt;</code> emits ONE `<p class="leading-relaxed mb-4"><code>woc &lt;dir&gt;</code> emits ONE
standalone binary at <code>target/&lt;name&gt;</code>. Copy that file to a standalone binary at <code>target/&lt;name&gt;</code>. Copy that file to a
server and run it — the VM is inside it, and so is the database.</p>`, server and run it — the VM is inside it, and so is the database.</p>`,
code_block("woc .\n./target/hello # hello, writeonce")); code_block("woc .\n./target/hello # hello, writeonce"));
let s6 = section("6. Add a dependency", let s7 = section("7. Add a dependency",
`<p class="leading-relaxed mb-4">Dependencies are git repositories pinned `<p class="leading-relaxed mb-4">Dependencies are git repositories pinned
by revision. The <code>[deps]</code> KEY is the module name your code by revision. The <code>[deps]</code> KEY is the module name your code
<code>use</code>s. <code>woc</code> writes a <code>wo.lock</code> with the <code>use</code>s. <code>woc</code> writes a <code>wo.lock</code> with the
exact revision, and a locked build works offline. See exact revision, and a locked build works offline. See
<a href="/packages">Packages</a> for what is available.</p>`, <a href="/packages">Packages</a> for what is available.</p>`,
code_block("[deps]\nhtml = { git = \"https://github.com/shoneyj/wo-html\", rev = \"v0.1.0\" }")); code_block("[deps]\nview = { git = \"https://github.com/shoneyj/writeonce-view\", rev = \"v0.1.0\" }"));
let note = el("div", "bg-white rounded-lg border shadow-sm p-6 mt-8", let note = el("div", "bg-white rounded-lg border shadow-sm p-6 mt-8",
el("h2", "text-lg font-bold mb-2", "Where things go") .. el("h2", "text-lg font-bold mb-2", "Where things go") ..
@ -62,10 +77,30 @@ pub class InstallPage {
<code>[runtime] wo = "&gt;= 0.1"</code>; <code>woc</code> refuses to build <code>[runtime] wo = "&gt;= 0.1"</code>; <code>woc</code> refuses to build
a project that needs a newer toolchain than itself.</p>`); a project that needs a newer toolchain than itself.</p>`);
return head .. s1 .. s2 .. s3 .. s4 .. s5 .. s6 .. note; return head .. sys .. s1 .. s2 .. s3 .. s4 .. s5 .. s6 .. s7 .. note;
} }
} }
-- What the release actually runs on. Every claim here is read off the
-- shipped binaries (`file`, and the highest GLIBC_ symbol version they
-- import), not off a wish list — an install page that overstates its
-- support costs someone an afternoon.
fn supported() -> Text {
return `
<div class="bg-white rounded-lg border shadow-sm p-6 mb-8">
<h2 class="text-2xl font-bold mb-2">Supported systems</h2>
<ul class="list-disc pl-6 leading-relaxed">
<li class="mb-2"><b>Linux on x86-64</b> — the only target built today. There is no ARM, macOS or Windows build.</li>
<li class="mb-2"><b>glibc 2.38 or newer.</b> The binaries link the system C library dynamically and import symbols up to <code>GLIBC_2.38</code>. That covers Ubuntu 24.04+, Debian 13+, and Fedora 39+ — and rules out Ubuntu 22.04 (2.35), Debian 12 (2.36) and RHEL 9 (2.34).</li>
<li class="mb-2"><b>Not musl.</b> Alpine needs a build against musl, which does not exist yet.</li>
<li class="mb-2"><b>Nothing else.</b> No JVM, no Node, no Python, no package manager. The two binaries and libc are the whole dependency list.</li>
</ul>
<p class="leading-relaxed mt-4 text-gray-700">Check yours with
<code>ldd --version</code>. If it is older than 2.38, build from source
until a wider-compatibility release exists.</p>
</div>`;
}
-- One numbered step: heading, prose, and the commands to run. -- One numbered step: heading, prose, and the commands to run.
fn section(title: Text, prose: Text, code: Text) -> Text { fn section(title: Text, prose: Text, code: Text) -> Text {
return el("div", "mb-8", return el("div", "mb-8",

View file

@ -1,13 +1,13 @@
-- layout/app.wo — the app shell: one component wrapping every page with -- layout/app.wo — the app shell: one component wrapping every page with
-- the shared header and footer. Unlike the shop template, this site -- the shared header and footer. Unlike the shop template, this site
-- INLINES its stylesheet (wo-html's `page()` does that), so the shell -- INLINES its stylesheet (writeonce-view's `page()` does that), so the shell
-- fills wo-html's own `Layout` rather than writing its own document. -- fills writeonce-view's own `Layout` rather than writing its own document.
-- --
-- The only thing that varies between pages is the container width, so -- The only thing that varies between pages is the container width, so
-- that is the one extra slot — and the two widths are named once, here, -- that is the one extra slot — and the two widths are named once, here,
-- instead of as class strings scattered through the controllers. -- instead of as class strings scattered through the controllers.
use framework/http use serve/http
use html use view
pub class AppShell { pub class AppShell {
title: Text title: Text

View file

@ -1,5 +1,5 @@
-- layout/footer.wo — the site footer, shared by every page. -- layout/footer.wo — the site footer, shared by every page.
use html use view
pub fn footer() -> Text { pub fn footer() -> Text {
return el("div", "footer", "writeonce.de — served by the language it teaches. " .. "One binary: compiler, runtime, database, this page."); return el("div", "footer", "writeonce.de — served by the language it teaches. " .. "One binary: compiler, runtime, database, this page.");

View file

@ -1,5 +1,5 @@
-- layout/header.wo — the site navigation bar, shared by every page. -- layout/header.wo — the site navigation bar, shared by every page.
use html use view
pub fn header() -> Text { pub fn header() -> Text {
let links = link("/install", "text-gray-700", "Install"); let links = link("/install", "text-gray-700", "Install");

View file

@ -1,11 +1,12 @@
-- site — writeonce.de: the language tutorial, served BY the language. -- site — writeonce.de: the language tutorial, served BY the language.
-- Full stack in one binary: writeonce-framework ([deps]) for HTTP/routing/ -- Full stack in one binary: writeonce-serve ([deps]) for HTTP/routing/
-- auth, wo-html ([deps]) for server-rendered pages with Tailwind-style -- auth, writeonce-view ([deps]) for server-rendered pages with Tailwind-style
-- utilities, @table + WAL for the chapters themselves. The site is its own -- utilities, @table + WAL for the chapters themselves. The site is its own
-- final chapter: /ch/serving shows this file's shape. -- final chapter: /ch/serving shows this file's shape.
-- --
-- SITE_TOKEN=... WO_DATA=./data ./site 8080 -- SITE_TOKEN=... WO_DATA=./data ./site 8080
-- SITE_HOST=0.0.0.0 ... ./site 8080 (reachable from the network) -- SITE_HOST=0.0.0.0 ... ./site 8080 (reachable from the network)
-- WO_DIST=/srv/dist ... (where /dl serves tarballs from)
-- --
-- Behind nginx/caddy for writeonce.de: the proxy terminates TLS and -- 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). -- forwards to 127.0.0.1:8080 (the framework speaks HTTP/1.1 keep-alive).
@ -14,8 +15,9 @@
-- model is types.wo; every feature is a directory holding its view and -- model is types.wo; every feature is a directory holding its view and
-- its controller. -- its controller.
use env use env
use framework use serve
use framework/router use serve/http
use serve/router
use home use home
use chapter use chapter
use install use install
@ -49,6 +51,14 @@ fn main(args: multi Text) -> Int {
host = "${h}"; host = "${h}";
} }
-- Where the release tarballs live. `just dist` writes them to ./dist;
-- a deployment points WO_DIST at wherever it keeps them.
let dist = "dist";
let d = env.get("WO_DIST");
if d != nil {
dist = "${d}";
}
seed_if_empty(); seed_if_empty();
let app = App { middleware: [], routes: [] }; let app = App { middleware: [], routes: [] };
@ -59,6 +69,9 @@ fn main(args: multi Text) -> Int {
app.get("/packages/:name", ShowPackage {}); app.get("/packages/:name", ShowPackage {});
app.get("/health", Health {}); app.get("/health", Health {});
app.get("/favicon.svg", Favicon {}); app.get("/favicon.svg", Favicon {});
-- 16 MiB ceiling: the toolchain tarball is under 1 MiB today, and
-- `max_bytes` is a hard truncation point, not a hint.
app.get("/dl/*path", StaticFiles { dir: dist, max_bytes: 16777216 });
app.get("/ch/:slug", ShowChapter {}); app.get("/ch/:slug", ShowChapter {});
app.post("/admin/ch/:slug", AdminEdit { token: "${token}" }); app.post("/admin/ch/:slug", AdminEdit { token: "${token}" });
print_err("site: listening on ${host}:${port}"); print_err("site: listening on ${host}:${port}");

View file

@ -3,8 +3,8 @@
-- The catalogue is static data, not rows, so it lives here on the DATA -- The catalogue is static data, not rows, so it lives here on the DATA
-- side of the feature rather than in the view: the components are handed -- side of the feature rather than in the view: the components are handed
-- values exactly as they would be if this were a table one day. -- values exactly as they would be if this were a table one day.
use framework/http use serve/http
use html use view
use layout use layout
typedef PackageInfo = { typedef PackageInfo = {
@ -54,25 +54,26 @@ fn catalogue() -> multi PackageInfo {
let out: multi PackageInfo = []; let out: multi PackageInfo = [];
push(out, PackageInfo { push(out, PackageInfo {
name: "framework", name: "serve",
summary: "A web framework written in writeonce: an HTTP/1.1 keep-alive server core, a router with :param captures, and Handler/Middleware structural interfaces.", summary: "The web framework: an HTTP/1.1 keep-alive server core, a router with :param captures, and Handler/Middleware structural interfaces.",
git: "https://github.com/shoneyj/writeonce-framework", git: "https://github.com/shoneyj/writeonce-serve",
rev: "v0.1.0", rev: "v0.1.0",
what: ` what: `
<ul class="list-disc pl-6 leading-relaxed"> <ul class="list-disc pl-6 leading-relaxed">
<li class="mb-2"><code>App</code> — the route table and the middleware chains; <code>app.get</code>/<code>post</code>/<code>delete_</code>, <code>use_mw</code>, <code>use_after</code>, <code>mount</code>, <code>serve</code>.</li> <li class="mb-2"><code>App</code> — the route table and the middleware chains; <code>app.get</code>/<code>post</code>/<code>delete_</code>, <code>use_mw</code>, <code>use_after</code>, <code>mount</code>, <code>serve</code>.</li>
<li class="mb-2"><code>StaticFiles</code> — serve a directory over a wildcard route. Traversal is refused, not normalised; <code>max_bytes</code> is a hard ceiling. This site's <code>/dl</code> downloads run through it.</li>
<li class="mb-2"><code>Handler</code>, <code>Middleware</code>, <code>After</code> — structural interfaces. A handler is a CLASS; its fields are the closure this language does not have.</li> <li class="mb-2"><code>Handler</code>, <code>Middleware</code>, <code>After</code> — structural interfaces. A handler is a CLASS; its fields are the closure this language does not have.</li>
<li class="mb-2"><code>Req</code>/<code>Resp</code> plus builders: <code>ok_text</code>, <code>ok_html</code>, <code>ok_json</code>, <code>created_json</code>, <code>not_found</code>, <code>bad_request</code>, <code>unauthorized</code>, <code>conflict</code>, <code>redirect</code>.</li> <li class="mb-2"><code>Req</code>/<code>Resp</code> plus builders: <code>ok_text</code>, <code>ok_html</code>, <code>ok_json</code>, <code>created_json</code>, <code>not_found</code>, <code>bad_request</code>, <code>unauthorized</code>, <code>conflict</code>, <code>redirect</code>.</li>
<li class="mb-2">Auth MECHANISM only — <code>bearer_token</code>, <code>basic_credentials</code>, constant-time <code>ct_eq</code>. Which routes are gated stays your policy.</li> <li class="mb-2">Auth MECHANISM only — <code>bearer_token</code>, <code>basic_credentials</code>, constant-time <code>ct_eq</code>. Which routes are gated stays your policy.</li>
<li class="mb-2">Bodies: <code>form_values</code>, <code>multipart_parts</code>, content negotiation, ETags, security headers, CORS, WebSocket frames.</li> <li class="mb-2">Bodies: <code>form_values</code>, <code>multipart_parts</code>, content negotiation, ETags, security headers, CORS, WebSocket frames.</li>
</ul>`, </ul>`,
usage: code_block("use framework\nuse framework/http\nuse framework/router\n\nclass Hello {\n fn handle(req: Req) -> Resp {\n return ok_text(\"hello\");\n }\n}\n\nfn main(args: multi Text) -> Int {\n let app = App { middleware: [], routes: [] };\n app.use_mw(Mw { m: Logging {} });\n app.get(\"/\", Hello {});\n return app.serve(\"127.0.0.1\", 8080);\n}") usage: code_block("use serve\nuse serve/http\nuse serve/router\n\nclass Hello {\n fn handle(req: Req) -> Resp {\n return ok_text(\"hello\");\n }\n}\n\nfn main(args: multi Text) -> Int {\n let app = App { middleware: [], routes: [] };\n app.use_mw(Mw { m: Logging {} });\n app.get(\"/\", Hello {});\n return app.serve(\"127.0.0.1\", 8080);\n}")
}); });
push(out, PackageInfo { push(out, PackageInfo {
name: "html", name: "view",
summary: "Server-rendered HTML as plain Text: escaping, element builders, a component layer, and a Tailwind-style utility stylesheet inlined into every page.", summary: "Server-rendered HTML as plain Text: escaping, element builders, a component layer, and a Tailwind-style utility stylesheet inlined into every page.",
git: "https://github.com/shoneyj/wo-html", git: "https://github.com/shoneyj/writeonce-view",
rev: "v0.1.0", rev: "v0.1.0",
what: ` what: `
<ul class="list-disc pl-6 leading-relaxed"> <ul class="list-disc pl-6 leading-relaxed">
@ -82,7 +83,7 @@ fn catalogue() -> multi PackageInfo {
<li class="mb-2">Builders: <code>el</code>, <code>link</code>, <code>card</code>, <code>nav_bar</code>, <code>code_block</code>, <code>form_post</code>, <code>text_input</code>, <code>text_area</code>, <code>submit_btn</code>, <code>btn_link</code>.</li> <li class="mb-2">Builders: <code>el</code>, <code>link</code>, <code>card</code>, <code>nav_bar</code>, <code>code_block</code>, <code>form_post</code>, <code>text_input</code>, <code>text_area</code>, <code>submit_btn</code>, <code>btn_link</code>.</li>
<li class="mb-2"><code>tw_css()</code> + <code>page()</code> — one hand-written utility sheet, inlined, so a page is one self-contained response. No CDN, no build step, no JS.</li> <li class="mb-2"><code>tw_css()</code> + <code>page()</code> — one hand-written utility sheet, inlined, so a page is one self-contained response. No CDN, no build step, no JS.</li>
</ul>`, </ul>`,
usage: code_block("use html\n\nclass Card {\n name: Text\n fn render() -> Text {\n return `\n <div class=\"card\">\n <h3>{{ self.name }}</h3>\n </div>`;\n }\n}\n\n-- in a handler:\nlet c = Card { name: user_supplied };\nreturn ok_html(page(\"Hello\", c.render()));") usage: code_block("use view\n\nclass Card {\n name: Text\n fn render() -> Text {\n return `\n <div class=\"card\">\n <h3>{{ self.name }}</h3>\n </div>`;\n }\n}\n\n-- in a handler:\nlet c = Card { name: user_supplied };\nreturn ok_html(page(\"Hello\", c.render()));")
}); });
return out; return out;

View file

@ -3,7 +3,7 @@
-- Three components: a card for the index, the index itself (holding its -- Three components: a card for the index, the index itself (holding its
-- cards as CHILDREN through the structural interface), and the detail -- cards as CHILDREN through the structural interface), and the detail
-- page. None of them knows where the catalogue came from. -- page. None of them knows where the catalogue came from.
use html use view
pub class PackageCard { pub class PackageCard {
name: Text name: Text

View file

@ -1,13 +1,13 @@
name = "site" name = "site"
version = "0.1.0" version = "0.1.0"
description = "writeonce.de — the language tutorial served by the language: framework + wo-html [deps], @table chapters, server-rendered pages" description = "writeonce.de — the language tutorial served by the language: framework + writeonce-view [deps], @table chapters, server-rendered pages"
[runtime] [runtime]
wo = ">= 0.1" wo = ">= 0.1"
# Two real dependencies (the gate substitutes file:// remotes built from # Two real dependencies (the gate substitutes file:// remotes built from
# docs/examples/writeonce-framework and docs/examples/wo-html, so CI never # docs/examples/writeonce-serve and docs/examples/writeonce-view, so CI never
# touches the network). The [deps] KEY is the module name `use` imports. # touches the network). The [deps] KEY is the module name `use` imports.
[deps] [deps]
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" } serve = { git = "https://github.com/shoneyj/writeonce-serve", rev = "v0.1.0" }
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" } view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }

View file

@ -1,7 +1,7 @@
# web-app — the storefront sample # web-app — the storefront sample
A small store: `Product`/`Order` as `@table` classes, JSON routes, one auth A small store: `Product`/`Order` as `@table` classes, JSON routes, one auth
middleware — built on [`writeonce-framework`](../writeonce-framework/), which middleware — built on [`writeonce-serve`](../writeonce-serve/), which
it imports **through `[deps]`** (iteration 15). This app is iteration 16's it imports **through `[deps]`** (iteration 15). This app is iteration 16's
acceptance workload: `just web-app` runs the whole chain — fetch → lock → acceptance workload: `just web-app` runs the whole chain — fetch → lock →
build → serve → curl matrix → restart persistence → SIGTERM. build → serve → curl matrix → restart persistence → SIGTERM.

View file

@ -1,4 +1,4 @@
-- web-app — the storefront: writeonce-framework (via [deps]) + @table -- web-app — the storefront: writeonce-serve (via [deps]) + @table
-- persistence. Every handler is a class satisfying Handler; the auth gate is -- persistence. Every handler is a class satisfying Handler; the auth gate is
-- a Middleware; the data layer is the language's own database — no ORM, no -- a Middleware; the data layer is the language's own database — no ORM, no
-- separate process, one binary. -- separate process, one binary.
@ -6,9 +6,9 @@ use env
use json use json
use net use net
use time use time
use framework use serve
use framework/http use serve/http
use framework/router use serve/router
-- decode target for POST /products, encode shape for every product answer -- decode target for POST /products, encode shape for every product answer
typedef ProductView = { name: Text, price: Float, stock: Int } typedef ProductView = { name: Text, price: Float, stock: Int }

View file

@ -1,6 +1,6 @@
name = "web-app" name = "web-app"
version = "0.1.0" version = "0.1.0"
description = "Storefront sample: consumes writeonce-framework through [deps]; @table persistence; iteration 16's acceptance workload" description = "Storefront sample: consumes writeonce-serve through [deps]; @table persistence; iteration 16's acceptance workload"
[runtime] [runtime]
wo = ">= 0.1" wo = ">= 0.1"
@ -11,9 +11,9 @@ wo = ">= 0.1"
# The framework is a real dependency, never a relative path — extraction of # The framework is a real dependency, never a relative path — extraction of
# the framework to its own repository changes only this URL. The acceptance # the framework to its own repository changes only this URL. The acceptance
# gate (scripts/web-app-accept.sh) substitutes a run-time file:// remote # gate (scripts/web-app-accept.sh) substitutes a run-time file:// remote
# built from docs/examples/writeonce-framework, so CI never needs the # built from docs/examples/writeonce-serve, so CI never needs the
# network and this repo never carries .wo-deps/wo.lock artifacts. # network and this repo never carries .wo-deps/wo.lock artifacts.
# The [deps] KEY is the module name `use` imports (hyphens are not identifier # The [deps] KEY is the module name `use` imports (hyphens are not identifier
# characters, so the key is `framework` while the repository keeps its name). # characters, so the key is `framework` while the repository keeps its name).
[deps] [deps]
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" } serve = { git = "https://github.com/shoneyj/writeonce-serve", rev = "v0.1.0" }

View file

@ -1,11 +1,14 @@
# writeonce-framework # writeonce-serve
> Renamed 2026-08-25: this library was `writeonce-framework`, imported as
> `use framework`. Stories, specs and plans dated before that still say the
> old name — they are dated records and were left as written.
A web framework **written in writeonce**, consumed as a `[deps]` dependency A web framework **written in writeonce**, consumed as a `[deps]` dependency
(iteration 15). Spec: `docs/superpowers/specs/2026-08-18-web-framework-design.md` §B. (iteration 15). Spec: `docs/superpowers/specs/2026-08-18-web-framework-design.md` §B.
```toml ```toml
[deps] [deps]
writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" } writeonce-serve = { git = "https://github.com/shoneyj/writeonce-serve", rev = "v0.1.0" }
``` ```
## What it is ## What it is
@ -167,7 +170,7 @@ naming the kind, unless a demo `main` is added (lib+bin is allowed).
- **`internal/` — not importable by a consumer.** The connection-level request - **`internal/` — not importable by a consumer.** The connection-level request
parser and carry-state record (`parse.wo`) and the serve loop, status text, parser and carry-state record (`parse.wo`) and the serve loop, status text,
and response serializer (`serve.wo`) live here. A consuming app that writes and response serializer (`serve.wo`) live here. A consuming app that writes
`use writeonce-framework/internal` gets **WO-E108** at that `use`. The rule `use writeonce-serve/internal` gets **WO-E108** at that `use`. The rule
is Go's: a path segment named `internal` is refused across the `[deps]` is Go's: a path segment named `internal` is refused across the `[deps]`
boundary only — the framework's own modules import it freely. boundary only — the framework's own modules import it freely.
@ -180,3 +183,20 @@ elimination); a consumer simply cannot name them.
`docs/examples/web-app` — a small storefront importing this framework `docs/examples/web-app` — a small storefront importing this framework
through `[deps]`. Its acceptance (`just web-app`) exercises the whole chain: through `[deps]`. Its acceptance (`just web-app`) exercises the whole chain:
fetch → lock → build → serve → durable restart. fetch → lock → build → serve → durable restart.
## Serving files
`StaticFiles { dir, max_bytes }` mounts a directory on a wildcard route:
```
app.get("/assets/*path", StaticFiles { dir: "assets", max_bytes: 2097152 })
app.get("/dl/*path", StaticFiles { dir: "dist", max_bytes: 16777216 })
```
Two rules, both refusals rather than repairs: a path containing `..` is a
404 and never reaches the filesystem, and `max_bytes` is a hard ceiling —
`fs.read_all` truncates above it, so set it above the largest file you
mean to serve. Content types come from the extension; archives
(`.tar.gz`, `.tgz`, `.zip`) also get `content-disposition: attachment`.
Text is binary-safe in this language, so archives and images travel
unchanged. Lifted out of the shop template 2026-08-25.

View file

@ -0,0 +1,72 @@
-- http/files.wo — serving a file from disk, the framework's answer to
-- "let people download something". Lifted out of the shop template
-- 2026-08-25, which had carried its own copy and said in a comment that
-- this belonged here.
--
-- Mount it on a wildcard route and it answers that subtree:
--
-- app.get("/assets/*path", StaticFiles { dir: "assets", max_bytes: 2097152 })
-- app.get("/dl/*path", StaticFiles { dir: "dist", max_bytes: 8388608 })
--
-- Safety is two rules, both refusals rather than repairs: a path holding
-- `..` is a 404 and never reaches the filesystem, and a file bigger than
-- `max_bytes` is truncated by `fs.read_all` — so `max_bytes` is a real
-- ceiling you must set above the largest file you intend to serve, not a
-- hint. Text is binary-safe in this language, so archives and images
-- travel unchanged.
use fs
pub class StaticFiles {
dir: Text
max_bytes: Int
fn handle(req: Req) -> Resp {
let rel = req.params["path"];
if rel == nil {
return not_found();
}
-- Traversal: refuse, never normalise. A rewritten path is a second
-- chance to get it wrong.
if index_of("${rel}", "..") != -1 {
return not_found();
}
let body = try fs.read_all("${self.dir}/${rel}", self.max_bytes) catch (e) nil;
if body == nil {
return not_found();
}
let h: map<Text, Text> = {};
h["content-type"] = content_type("${rel}");
if is_download("${rel}") {
h["content-disposition"] = "attachment";
}
return Resp { status: 200, headers: h, body: "${body}" };
}
}
-- Extension to content type. Unknown extensions are octet-stream: a
-- wrong guess is worse than no guess.
pub fn content_type(name: Text) -> Text {
if ends_with(name, ".html") { return "text/html; charset=utf-8"; }
if ends_with(name, ".css") { return "text/css; charset=utf-8"; }
if ends_with(name, ".js") { return "text/javascript"; }
if ends_with(name, ".json") { return "application/json"; }
if ends_with(name, ".svg") { return "image/svg+xml"; }
if ends_with(name, ".png") { return "image/png"; }
if ends_with(name, ".webp") { return "image/webp"; }
if ends_with(name, ".ico") { return "image/x-icon"; }
if ends_with(name, ".woff2") { return "font/woff2"; }
if ends_with(name, ".txt") { return "text/plain; charset=utf-8"; }
if ends_with(name, ".sha256") { return "text/plain; charset=utf-8"; }
if ends_with(name, ".tar.gz") { return "application/gzip"; }
if ends_with(name, ".tgz") { return "application/gzip"; }
if ends_with(name, ".zip") { return "application/zip"; }
return "application/octet-stream";
}
-- Archives are offered as a save, not rendered into a tab.
fn is_download(name: Text) -> Bool {
if ends_with(name, ".tar.gz") { return true; }
if ends_with(name, ".tgz") { return true; }
if ends_with(name, ".zip") { return true; }
return false;
}

View file

@ -40,7 +40,7 @@ pub fn ok_text(body: Text) -> Resp {
-- iteration 37: HTML is transport here, exactly like text and JSON — -- iteration 37: HTML is transport here, exactly like text and JSON —
-- the status line and the content-type, nothing about rendering. It -- the status line and the content-type, nothing about rendering. It
-- lives beside its two siblings because both HTML apps had hand-rolled -- lives beside its two siblings because both HTML apps had hand-rolled
-- the identical four lines; wo-html stays a pure Text library and never -- the identical four lines; writeonce-view stays a pure Text library and never
-- learns what a Resp is. -- learns what a Resp is.
pub fn ok_html(body: Text) -> Resp { pub fn ok_html(body: Text) -> Resp {
let h: map<Text, Text> = {}; let h: map<Text, Text> = {};

View file

@ -1,4 +1,4 @@
name = "writeonce-framework" name = "writeonce-serve"
kind = "library" kind = "library"
version = "0.1.0" version = "0.1.0"
description = "A web framework written in writeonce: HTTP/1.1 keep-alive server core, router with :param captures, Handler/Middleware structural interfaces (iteration 16)" description = "A web framework written in writeonce: HTTP/1.1 keep-alive server core, router with :param captures, Handler/Middleware structural interfaces (iteration 16)"
@ -9,4 +9,4 @@ wo = ">= 0.1"
# A LIBRARY project (declared above since iteration 17): no `fn main` here — # A LIBRARY project (declared above since iteration 17): no `fn main` here —
# the consuming app owns the entry. `woc <dir>` typechecks the whole project. # the consuming app owns the entry. `woc <dir>` typechecks the whole project.
# Apps import this repo through `wo.toml [deps]` (iteration 15) and # Apps import this repo through `wo.toml [deps]` (iteration 15) and
# `use writeonce-framework` / `use writeonce-framework/http` / `.../router`. # `use serve` / `use serve/http` / `use serve/router`.

View file

@ -1,4 +1,7 @@
# wo-html — server-rendered HTML as plain Text # writeonce-view — server-rendered HTML as plain Text
> Renamed 2026-08-25: this library was `wo-html`, imported as `use html`.
> Stories, specs and plans dated before that still say the old name — they
> are dated records and were left as written.
A view library, not a framework and not a template engine. Everything in A view library, not a framework and not a template engine. Everything in
it is a pure function or a class with a `render()`; nothing here opens a it is a pure function or a class with a `render()`; nothing here opens a
@ -6,7 +9,7 @@ socket, reads a file, or touches the database.
``` ```
[deps] [deps]
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" } view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }
``` ```
## The four layers ## The four layers
@ -78,7 +81,7 @@ component like any other.
`ok_html` is the **framework's** (`framework/http`, beside `ok_text` and `ok_html` is the **framework's** (`framework/http`, beside `ok_text` and
`ok_json`): a status line plus a content-type is transport, not `ok_json`): a status line plus a content-type is transport, not
rendering, so wo-html never learns what a `Resp` is. rendering, so writeonce-view never learns what a `Resp` is.
The seam is what makes a view testable without a server and a query The seam is what makes a view testable without a server and a query
testable without markup. Breaking it looks like one convenience — a testable without markup. Breaking it looks like one convenience — a

View file

@ -1,4 +1,4 @@
-- wo-html — server-rendered HTML as plain Text. Four layers, all pure: -- writeonce-view — server-rendered HTML as plain Text. Four layers, all pure:
-- esc() HTML-escape untrusted text. `{{ }}` in a raw text -- esc() HTML-escape untrusted text. `{{ }}` in a raw text
-- literal compiles to a call to this, so display data is -- literal compiles to a call to this, so display data is
-- escaped by construction; esc() by hand is the fallback -- escaped by construction; esc() by hand is the fallback

View file

@ -1,4 +1,4 @@
name = "wo-html" name = "writeonce-view"
kind = "library" kind = "library"
version = "0.1.0" 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" description = "HTML building for writeonce apps: escaping, element builders, and a Tailwind-style utility stylesheet — server-rendered pages as plain Text"
@ -7,4 +7,4 @@ description = "HTML building for writeonce apps: escaping, element builders, and
wo = ">= 0.1" wo = ">= 0.1"
# A LIBRARY project: no `fn main`. Apps import it through wo.toml [deps] # A LIBRARY project: no `fn main`. Apps import it through wo.toml [deps]
# (the key names the module — `html = { git = ... }` gives `use html`). # (the key names the module — `view = { git = ... }` gives `use view`).

View file

@ -52,7 +52,7 @@ Status board: [`00-status.md`](../stories/00-status.md) · Doctrine: [`../00-pri
| **Per-example `principle.md` files** | 2026-08-08: one canonical repo-level [`docs/00-principles.md`](../00-principles.md) instead; examples link to it. | | **Per-example `principle.md` files** | 2026-08-08: one canonical repo-level [`docs/00-principles.md`](../00-principles.md) instead; examples link to it. |
| **Minimal 3-file log-watcher sample** | Breaks the file-for-file `.hx` → `.wo` mapping and leaves the "could not express" column unproven — which is the sample's entire acceptance criterion. | | **Minimal 3-file log-watcher sample** | Breaks the file-for-file `.hx` → `.wo` mapping and leaves the "could not express" column unproven — which is the sample's entire acceptance criterion. |
| **Raw code in plan documents** | Plans carry concept, reason, and required behavior in words; the executor writes the code. | | **Raw code in plan documents** | Plans carry concept, reason, and required behavior in words; the executor writes the code. |
| **`##ui` / `.htmlx` LiveView frontend track** | 2026-08-17: removed the 9-doc `exploration/ui/` design set, the `14-mvc-ui-implementation` plan, and the `ui-htmlx-live` plan. All were built on the non-advancing Rust runtime (`.dev/reference/crates/wo-htmlx`, `cargo run`, WebSocket live-patches) and contradict the current woc/wovm direction. The 13d pricing-UI row went with them. Revisit only if a UI story is re-opened on the woc/wovm stack. | | **`##ui` / `.htmlx` LiveView frontend track** | 2026-08-17: removed the 9-doc `exploration/ui/` design set, the `14-mvc-ui-implementation` plan, and the `ui-htmlx-live` plan. All were built on the non-advancing Rust runtime (`.dev/reference/crates/writeonce-viewx`, `cargo run`, WebSocket live-patches) and contradict the current woc/wovm direction. The 13d pricing-UI row went with them. Revisit only if a UI story is re-opened on the woc/wovm stack. |
| **Old-runtime "front door" + v1 design docs** | 2026-08-17: removed `writeonce-pl.md`, `runtime/wo-language.md`, `future-scope/ai-agents-content-management.md`, the numbered v1 set `02-recovery`/`03-data`/`04-ui`/`05-datalayer`/`06-markdown-render`/`07-ssl`, and `runtime/database/05-go-sdk.md`. They pitched the old Rust `wo` runtime (REST + LiveView + SQL/Cypher) as the current language and contradicted the shipped woc/wovm toolchain. | | **Old-runtime "front door" + v1 design docs** | 2026-08-17: removed `writeonce-pl.md`, `runtime/wo-language.md`, `future-scope/ai-agents-content-management.md`, the numbered v1 set `02-recovery`/`03-data`/`04-ui`/`05-datalayer`/`06-markdown-render`/`07-ssl`, and `runtime/database/05-go-sdk.md`. They pitched the old Rust `wo` runtime (REST + LiveView + SQL/Cypher) as the current language and contradicted the shipped woc/wovm toolchain. |
| **The 2026-08-01 shard-actor plan (epoll-based)** | 2026-08-21: [`superpowers/plans/2026-08-01-shard-actor-vm-runtime.md`](../superpowers/plans/2026-08-01-shard-actor-vm-runtime.md) marked discarded, file kept as reference. Superseded by the arc plan of record ([`2026-08-20-shard-fiber-arc.md`](../superpowers/plans/2026-08-20-shard-fiber-arc.md), stages 1+2 landed): io_uring is a MUST and the epoll-based approach is discarded — the old plan's "epoll now / io_uring later" premise is inverted, and its substrate (`runtime/wo-rt.c`) left with the Rust track 2026-08-18. | | **The 2026-08-01 shard-actor plan (epoll-based)** | 2026-08-21: [`superpowers/plans/2026-08-01-shard-actor-vm-runtime.md`](../superpowers/plans/2026-08-01-shard-actor-vm-runtime.md) marked discarded, file kept as reference. Superseded by the arc plan of record ([`2026-08-20-shard-fiber-arc.md`](../superpowers/plans/2026-08-20-shard-fiber-arc.md), stages 1+2 landed): io_uring is a MUST and the epoll-based approach is discarded — the old plan's "epoll now / io_uring later" premise is inverted, and its substrate (`runtime/wo-rt.c`) left with the Rust track 2026-08-18. |
| **The entire Rust `wo` runtime track** | 2026-08-18: removed `crates/` (the Stage-2 Rust runtime), `Cargo.toml`/`Cargo.lock`, `prototypes/` (wo-rt-c stale duplicate + wo-db C++ ref), the `rt-c-*` justfile recipes, the Rust engineering plans (`docs/plan/05..16`, `docs/plan/done/`), and `docs/runtime/` (the old runtime overview + 7-phase DB design series + async/fibers/gc/surreal essays). It was the prior, abandoned architecture — fully independent of the woc/wovm stack. Master now reflects only the current single-language project; the removed track lives in git history if ever needed as reference. Kept: the syscall/postgres/assembly/c-runtime **exploration studies** (they fed the current C runtime) and the discarded/learnings registers. | | **The entire Rust `wo` runtime track** | 2026-08-18: removed `crates/` (the Stage-2 Rust runtime), `Cargo.toml`/`Cargo.lock`, `prototypes/` (wo-rt-c stale duplicate + wo-db C++ ref), the `rt-c-*` justfile recipes, the Rust engineering plans (`docs/plan/05..16`, `docs/plan/done/`), and `docs/runtime/` (the old runtime overview + 7-phase DB design series + async/fibers/gc/surreal essays). It was the prior, abandoned architecture — fully independent of the woc/wovm stack. Master now reflects only the current single-language project; the removed track lives in git history if ever needed as reference. Kept: the syscall/postgres/assembly/c-runtime **exploration studies** (they fed the current C runtime) and the discarded/learnings registers. |

View file

@ -183,7 +183,7 @@ Lowered by the emitter, not added to the format:
| `a == b` on `Text` | `EQS` (content equality); `EQ` otherwise | | `a == b` on `Text` | `EQS` (content equality); `EQ` otherwise |
| `a .. b` | `CONCAT` — `+` is arithmetic only, never string addition | | `a .. b` | `CONCAT` — `+` is arithmetic only, never string addition |
| `` `...${e}...` `` | the same `StrLit`/`Interp`/`CONCAT` chain a `"..."` string produces — the raw literal is a LEXER form (verbatim content, common margin removed at lex time), not a new node or opcode | | `` `...${e}...` `` | the same `StrLit`/`Interp`/`CONCAT` chain a `"..."` string produces — the raw literal is a LEXER form (verbatim content, common margin removed at lex time), not a new node or opcode |
| `` `...{{ e }}...` `` | `esc(${e})` — the parser wraps the interpolation in a call to whatever `esc` is in scope (wo-html's `pub fn esc`, or a local one that deliberately shadows it). No builtin, no opcode, no HTML knowledge in the compiler or the VM | | `` `...{{ e }}...` `` | `esc(${e})` — the parser wraps the interpolation in a call to whatever `esc` is in scope (writeonce-view's `pub fn esc`, or a local one that deliberately shadows it). No builtin, no opcode, no HTML knowledge in the compiler or the VM |
| `a and b` | evaluate `a`; `JZ` past evaluating `b` (result stays `a`'s value); else evaluate `b` into the same register (haxe-parity Task 2) | | `a and b` | evaluate `a`; `JZ` past evaluating `b` (result stays `a`'s value); else evaluate `b` into the same register (haxe-parity Task 2) |
| `a or b` | evaluate `a`; `JZ` + `JMP` past evaluating `b` when `a` is already true; else evaluate `b` (haxe-parity Task 2) | | `a or b` | evaluate `a`; `JZ` + `JMP` past evaluating `b` when `a` is already true; else evaluate `b` (haxe-parity Task 2) |

View file

@ -168,7 +168,7 @@ sees through Interp; same corpus pin). Body-parsing hooks: all three ✅.
Scope split (2026-08-20): the surface above plus the remaining transport/ Scope split (2026-08-20): the surface above plus the remaining transport/
routing/security gaps is **framework v1**, tracked item-by-item in the routing/security gaps is **framework v1**, tracked item-by-item in the
[framework README's status ledger](../examples/writeonce-framework/README.md) [framework README's status ledger](../examples/writeonce-serve/README.md)
(✅/🔶/⬜/⏸/🔧 per feature — timeouts and Unix sockets need `net` runtime (✅/🔶/⬜/⏸/🔧 per feature — timeouts and Unix sockets need `net` runtime
seams, crypto hashes need C builtins since the language has no bitwise seams, crypto hashes need C builtins since the language has no bitwise
operators, streaming/cancellation park behind 8/11). The memory-rich operators, streaming/cancellation park behind 8/11). The memory-rich

View file

@ -7,7 +7,7 @@ status: hold
> **Scope label (2026-08-20): this iteration is FRAMEWORK V2.** Framework > **Scope label (2026-08-20): this iteration is FRAMEWORK V2.** Framework
> v1 is the transport/routing/body/security surface tracked in the > v1 is the transport/routing/body/security surface tracked in the
> [framework README's status ledger](../../../examples/writeonce-framework/README.md); > [framework README's status ledger](../../../examples/writeonce-serve/README.md);
> v2 is what the embedded store adds on top. v1 gaps land before or > v2 is what the embedded store adds on top. v1 gaps land before or
> alongside v2 as slices, per the ledger. > alongside v2 as slices, per the ledger.

View file

@ -41,7 +41,7 @@ deps-accept:
./scripts/deps-accept.sh ./scripts/deps-accept.sh
# web-app: iteration 16's gate — the whole chain at run time, network-free: # web-app: iteration 16's gate — the whole chain at run time, network-free:
# a temp git remote from docs/examples/writeonce-framework, file:// URL # a temp git remote from docs/examples/writeonce-serve, file:// URL
# substituted into a temp copy of docs/examples/web-app, then fetch -> lock # substituted into a temp copy of docs/examples/web-app, then fetch -> lock
# -> build -> serve -> the storefront matrix (auth/CRUD/@unique/FK/404/400/ # -> build -> serve -> the storefront matrix (auth/CRUD/@unique/FK/404/400/
# pipelining) -> SIGTERM -> WAL restart persistence. # pipelining) -> SIGTERM -> WAL restart persistence.
@ -49,7 +49,7 @@ web-app:
./scripts/web-app-accept.sh ./scripts/web-app-accept.sh
# site: the writeonce.de tutorial (docs/examples/site) — TWO deps # site: the writeonce.de tutorial (docs/examples/site) — TWO deps
# (framework + wo-html), server-rendered pages, page matrix -> 401/edit # (serve + view), server-rendered pages, page matrix -> 401/edit
# -> SIGTERM -> WAL restart persistence. `just site` runs it. # -> SIGTERM -> WAL restart persistence. `just site` runs it.
site: site:
./scripts/site-accept.sh ./scripts/site-accept.sh

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# scripts/site-accept.sh — the writeonce.de tutorial site's gate: TWO deps # scripts/site-accept.sh — the writeonce.de tutorial site's gate: TWO deps
# (framework + wo-html) resolved from run-time file:// remotes, build, # (serve + view) resolved from run-time file:// remotes, build,
# serve, the page matrix (render/escape/404/401/authed edit), SIGTERM, # serve, the page matrix (render/escape/404/401/authed edit), SIGTERM,
# and WAL restart persistence of an admin edit. # and WAL restart persistence of an admin edit.
set -uo pipefail set -uo pipefail
@ -27,8 +27,8 @@ cleanup() {
trap cleanup EXIT trap cleanup EXIT
# ---- both deps as git remotes; the app pointed at them ---- # ---- both deps as git remotes; the app pointed at them ----
cp -r "$ROOT/docs/examples/writeonce-framework" "$W/fw" cp -r "$ROOT/docs/examples/writeonce-serve" "$W/fw"
cp -r "$ROOT/docs/examples/wo-html" "$W/lib" cp -r "$ROOT/docs/examples/writeonce-view" "$W/lib"
for d in "$W/fw" "$W/lib"; do for d in "$W/fw" "$W/lib"; do
git -C "$d" init -q git -C "$d" init -q
git -C "$d" add -A git -C "$d" add -A
@ -36,9 +36,15 @@ for d in "$W/fw" "$W/lib"; do
git -C "$d" tag v0.1.0 git -C "$d" tag v0.1.0
done done
cp -r "$ROOT/docs/examples/site" "$W/app" 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" sed -i "s|https://github.com/shoneyj/writeonce-serve|file://$W/fw|; s|https://github.com/shoneyj/writeonce-view|file://$W/lib|" "$W/app/wo.toml"
printf '[build]\nruntime = "%s"\n' "$WOVM" >> "$W/app/wo.toml" printf '[build]\nruntime = "%s"\n' "$WOVM" >> "$W/app/wo.toml"
# a stand-in release tarball so /dl can be exercised without running
# `just dist` first — the bytes do not matter, the serving path does
mkdir -p "$W/app/dist"
printf 'not-a-real-tarball' | gzip > "$W/app/dist/writeonce-0.1.0-linux-amd64.tar.gz"
( cd "$W/app/dist" && sha256sum writeonce-0.1.0-linux-amd64.tar.gz > writeonce-0.1.0-linux-amd64.tar.gz.sha256 )
# ---- 1. two-dep fetch + lock + build ---- # ---- 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 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" ok "deps chain: two remotes fetched + wo.lock + build"
@ -69,6 +75,20 @@ except urllib.error.HTTPError as e:
PYEOF PYEOF
} }
# binary-safe: status + content-type + byte count, no decoding. The
# release tarball is gzip, which `hit` cannot represent.
hit_bin() {
python3 - "$PORT" "$1" <<'PYEOF'
import sys, urllib.request, urllib.error
port, path = sys.argv[1], sys.argv[2]
try:
r = urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=5)
print(f"{r.status}|{r.headers.get('content-type','')}|{len(r.read())}")
except urllib.error.HTTPError as e:
print(f"{e.code}|{e.headers.get('content-type','')}|{len(e.read())}")
PYEOF
}
expect() { # name got want_status want_substr expect() { # name got want_status want_substr
local name="$1" got="$2" want="$3" sub="$4" local name="$1" got="$2" want="$3" sub="$4"
local st="${got%%|*}" body="${got#*|}" local st="${got%%|*}" body="${got#*|}"
@ -94,10 +114,17 @@ expect "chapter renders a code sample" "$(hit /ch/hello)" 200 "fn main"
expect "escaped interpolation visible" "$(hit /ch/values)" 200 '${port}' expect "escaped interpolation visible" "$(hit /ch/values)" 200 '${port}'
expect "unknown chapter is a 404 page" "$(hit /ch/nope)" 404 "No such chapter" expect "unknown chapter is a 404 page" "$(hit /ch/nope)" 404 "No such chapter"
expect "install guide renders" "$(hit /install)" 200 "tar -C /usr/local" expect "install guide renders" "$(hit /install)" 200 "tar -C /usr/local"
expect "packages index lists both" "$(hit /packages)" 200 "/packages/framework" expect "packages index lists both" "$(hit /packages)" 200 "/packages/serve"
expect "package detail shows its dep" "$(hit /packages/html)" 200 "wo-html" expect "package detail shows its dep" "$(hit /packages/view)" 200 "writeonce-view"
expect "unknown package is a 404 page" "$(hit /packages/nope)" 404 "No such package" expect "unknown package is a 404 page" "$(hit /packages/nope)" 404 "No such package"
expect "favicon is served as svg" "$(hit /favicon.svg)" 200 "<svg" expect "favicon is served as svg" "$(hit /favicon.svg)" 200 "<svg"
expect "install lists supported systems" "$(hit /install)" 200 "glibc 2.38 or newer"
got="$(hit_bin /dl/writeonce-0.1.0-linux-amd64.tar.gz)"
if [[ "$got" == 200\|application/gzip\|* && "${got##*|}" -gt 0 ]]; then
ok "the release tarball downloads as gzip"
else bad "tarball download" "$got"; fi
expect "its checksum downloads" "$(hit /dl/writeonce-0.1.0-linux-amd64.tar.gz.sha256)" 200 "writeonce-0.1.0-linux-amd64.tar.gz"
expect "traversal out of /dl is a 404" "$(hit /dl/../wo.toml)" 404 ""
expect "the logo is inline in the nav" "$(hit /)" 200 "aria-label=\"writeonce\"" expect "the logo is inline in the nav" "$(hit /)" 200 "aria-label=\"writeonce\""
expect "admin without token is 401" "$(hit /admin/ch/hello POST "title=X")" 401 "unauthorized" 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 "admin edit answers a redirect" "$(hit /admin/ch/hello POST "title=Hello v2" s3cr3t)" 302 ""

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# scripts/web-app-accept.sh — iteration 16's acceptance gate. Proves the whole # scripts/web-app-accept.sh — iteration 16's acceptance gate. Proves the whole
# chain at run time, network-free: a temp git remote is built from # chain at run time, network-free: a temp git remote is built from
# docs/examples/writeonce-framework, its file:// URL is substituted into a # docs/examples/writeonce-serve, its file:// URL is substituted into a
# temp copy of docs/examples/web-app, then: fetch -> lock -> build -> serve -> # temp copy of docs/examples/web-app, then: fetch -> lock -> build -> serve ->
# the storefront matrix -> SIGTERM -> restart persistence, plus iteration 17's # the storefront matrix -> SIGTERM -> restart persistence, plus iteration 17's
# library-kind and internal/-boundary checks. The repo itself # library-kind and internal/-boundary checks. The repo itself
@ -33,13 +33,13 @@ cleanup() {
trap cleanup EXIT trap cleanup EXIT
# ---- the framework as a git remote; the app pointed at it ---- # ---- the framework as a git remote; the app pointed at it ----
cp -r "$ROOT/docs/examples/writeonce-framework" "$W/fw" cp -r "$ROOT/docs/examples/writeonce-serve" "$W/fw"
git -C "$W/fw" init -q git -C "$W/fw" init -q
git -C "$W/fw" add -A git -C "$W/fw" add -A
git -C "$W/fw" -c user.email=t@t -c user.name=t commit -qm v01 git -C "$W/fw" -c user.email=t@t -c user.name=t commit -qm v01
git -C "$W/fw" tag v0.1.0 git -C "$W/fw" tag v0.1.0
cp -r "$ROOT/docs/examples/web-app" "$W/app" cp -r "$ROOT/docs/examples/web-app" "$W/app"
sed -i "s|https://github.com/shoneyj/writeonce-framework|file://$W/fw|" "$W/app/wo.toml" sed -i "s|https://github.com/shoneyj/writeonce-serve|file://$W/fw|" "$W/app/wo.toml"
printf '[build]\nruntime = "%s"\n' "$WOVM" >> "$W/app/wo.toml" printf '[build]\nruntime = "%s"\n' "$WOVM" >> "$W/app/wo.toml"
# ---- 1. fetch + lock + build ---- # ---- 1. fetch + lock + build ----
@ -63,10 +63,10 @@ fi
# A consumer reaching past the privacy line is WO-E108 at its own `use`. # A consumer reaching past the privacy line is WO-E108 at its own `use`.
cp -r "$W/app" "$W/app-internal" cp -r "$W/app" "$W/app-internal"
rm -rf "$W/app-internal/target" "$W/app-internal/wo.lock" "$W/app-internal/.wo-deps" rm -rf "$W/app-internal/target" "$W/app-internal/wo.lock" "$W/app-internal/.wo-deps"
sed -i '1i use framework/internal' "$W/app-internal/main.wo" sed -i '1i use serve/internal' "$W/app-internal/main.wo"
out="$("$WOC" "$W/app-internal" 2>&1)"; rc=$? out="$("$WOC" "$W/app-internal" 2>&1)"; rc=$?
if [[ "$rc" == "1" ]] && printf '%s' "$out" | grep -q 'WO-E108'; then if [[ "$rc" == "1" ]] && printf '%s' "$out" | grep -q 'WO-E108'; then
ok "dep boundary: importing framework/internal is WO-E108" ok "dep boundary: importing serve/internal is WO-E108"
else else
bad "internal-boundary" "exit=$rc out=$(printf '%s' "$out" | head -1)" bad "internal-boundary" "exit=$rc out=$(printf '%s' "$out" | head -1)"
fi fi

View file

@ -4,7 +4,7 @@
-- carry the markup's own nesting and not the method's. -- carry the markup's own nesting and not the method's.
-- `${...}` interpolates raw; `{{ ... }}` HTML-escapes through the `esc` -- `${...}` interpolates raw; `{{ ... }}` HTML-escapes through the `esc`
-- in scope (here a local one, which is exactly how a program overrides -- in scope (here a local one, which is exactly how a program overrides
-- wo-html's). -- writeonce-view's).
fn esc(t: Text) -> Text { fn esc(t: Text) -> Text {
let out = ""; let out = "";
let i = 0; let i = 0;