From ef37b8ffa21057f641dfa193a0ccc0e2ed12f948 Mon Sep 17 00:00:00 2001
From: "shoney.arickathil"
Date: Tue, 25 Aug 2026 04:41:00 +0200
Subject: [PATCH] feat(serve+view): file serving, downloads, supported systems;
rename
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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)
---
README.md | 4 +-
compiler/src/CODE-LOGIC.md | 2 +-
docs/00-code-review.md | 2 +-
docs/examples/shop/README.md | 12 ++--
docs/examples/shop/layout/app.wo | 8 +--
docs/examples/shop/main.wo | 10 +--
docs/examples/shop/orders/controller.wo | 2 +-
docs/examples/shop/orders/view.wo | 2 +-
docs/examples/shop/product_list/controller.wo | 2 +-
docs/examples/shop/product_list/view.wo | 2 +-
docs/examples/shop/product_page/controller.wo | 2 +-
docs/examples/shop/product_page/view.wo | 2 +-
docs/examples/shop/static_files/controller.wo | 33 ---------
docs/examples/shop/wo.toml | 4 +-
docs/examples/site/CODE-LOGIC.md | 15 +++-
docs/examples/site/README.md | 15 ++--
docs/examples/site/admin/controller.wo | 2 +-
docs/examples/site/chapter/controller.wo | 2 +-
docs/examples/site/chapter/view.wo | 2 +-
docs/examples/site/content.wo | 16 ++---
docs/examples/site/favicon/controller.wo | 2 +-
docs/examples/site/health/controller.wo | 2 +-
docs/examples/site/home/controller.wo | 2 +-
docs/examples/site/home/view.wo | 4 +-
docs/examples/site/install/controller.wo | 2 +-
docs/examples/site/install/view.wo | 57 ++++++++++++---
docs/examples/site/layout/app.wo | 8 +--
docs/examples/site/layout/footer.wo | 2 +-
docs/examples/site/layout/header.wo | 2 +-
docs/examples/site/main.wo | 21 ++++--
docs/examples/site/packages/controller.wo | 19 ++---
docs/examples/site/packages/view.wo | 2 +-
docs/examples/site/wo.toml | 8 +--
docs/examples/web-app/README.md | 2 +-
docs/examples/web-app/main.wo | 8 +--
docs/examples/web-app/wo.toml | 6 +-
.../README.md | 26 ++++++-
.../app.wo | 0
.../http/auth.wo | 0
docs/examples/writeonce-serve/http/files.wo | 72 +++++++++++++++++++
.../http/form.wo | 0
.../http/multipart.wo | 0
.../http/nego.wo | 0
.../http/secure.wo | 0
.../http/types.wo | 2 +-
.../http/ws.wo | 0
.../http/wsframe.wo | 0
.../internal/parse.wo | 0
.../internal/serve.wo | 0
.../router/router.wo | 0
.../wo.toml | 4 +-
.../{wo-html => writeonce-view}/README.md | 9 ++-
.../{wo-html => writeonce-view}/html.wo | 2 +-
.../{wo-html => writeonce-view}/wo.toml | 4 +-
docs/plan/discarded.md | 2 +-
docs/plan/oop-vm/08-builtin-surface.md | 2 +-
docs/stories/00-status.md | 2 +-
.../hold/18-memory-db-features.md | 2 +-
justfile | 4 +-
scripts/site-accept.sh | 39 ++++++++--
scripts/web-app-accept.sh | 10 +--
tests/corpus/run/raw-text-literal/fixture.wo | 2 +-
62 files changed, 310 insertions(+), 158 deletions(-)
delete mode 100644 docs/examples/shop/static_files/controller.wo
rename docs/examples/{writeonce-framework => writeonce-serve}/README.md (90%)
rename docs/examples/{writeonce-framework => writeonce-serve}/app.wo (100%)
rename docs/examples/{writeonce-framework => writeonce-serve}/http/auth.wo (100%)
create mode 100644 docs/examples/writeonce-serve/http/files.wo
rename docs/examples/{writeonce-framework => writeonce-serve}/http/form.wo (100%)
rename docs/examples/{writeonce-framework => writeonce-serve}/http/multipart.wo (100%)
rename docs/examples/{writeonce-framework => writeonce-serve}/http/nego.wo (100%)
rename docs/examples/{writeonce-framework => writeonce-serve}/http/secure.wo (100%)
rename docs/examples/{writeonce-framework => writeonce-serve}/http/types.wo (98%)
rename docs/examples/{writeonce-framework => writeonce-serve}/http/ws.wo (100%)
rename docs/examples/{writeonce-framework => writeonce-serve}/http/wsframe.wo (100%)
rename docs/examples/{writeonce-framework => writeonce-serve}/internal/parse.wo (100%)
rename docs/examples/{writeonce-framework => writeonce-serve}/internal/serve.wo (100%)
rename docs/examples/{writeonce-framework => writeonce-serve}/router/router.wo (100%)
rename docs/examples/{writeonce-framework => writeonce-serve}/wo.toml (80%)
rename docs/examples/{wo-html => writeonce-view}/README.md (91%)
rename docs/examples/{wo-html => writeonce-view}/html.wo (99%)
rename docs/examples/{wo-html => writeonce-view}/wo.toml (74%)
diff --git a/README.md b/README.md
index 0878464..65e5573 100644
--- a/README.md
+++ b/README.md
@@ -269,7 +269,7 @@ dependencies, declared in the manifest:
```toml
[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//`, pins
@@ -303,7 +303,7 @@ acceptance tests:
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,
router with `:param` captures, interface-based handlers) and a storefront
consuming it **as a `[deps]` dependency**, with `@table` persistence. Run:
diff --git a/compiler/src/CODE-LOGIC.md b/compiler/src/CODE-LOGIC.md
index 238780e..b76bb12 100644
--- a/compiler/src/CODE-LOGIC.md
+++ b/compiler/src/CODE-LOGIC.md
@@ -332,7 +332,7 @@ Things worth knowing before editing them:
- **`{{ e }}` desugars to `esc(${e})`, resolved by ordinary name
lookup.** `desugar_interp` in `parser.ml` builds a `Call` on an
`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,
and a locally defined `esc` shadows deliberately (a custom escaper is
a feature). `${ }` inside the same literal stays raw — that is the
diff --git a/docs/00-code-review.md b/docs/00-code-review.md
index 8123503..5e672f2 100644
--- a/docs/00-code-review.md
+++ b/docs/00-code-review.md
@@ -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 |
| TSan covers one demo | only `scripts/fibers-accept.sh` builds and runs `wovm_tsan` |
| 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
diff --git a/docs/examples/shop/README.md b/docs/examples/shop/README.md
index 0592262..e3bd455 100644
--- a/docs/examples/shop/README.md
+++ b/docs/examples/shop/README.md
@@ -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.
Parent components hold children directly (`cards: multi Component`) and
render them with `render_all`, so `ProductListPage` knows nothing about
`ProductCard` beyond `render()`. The document itself is a component too:
`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
-not wo-html's `Layout`.
+not writeonce-view's `Layout`.
Two holes, and the difference is the whole escaping story:
- `{{ 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.
- `${ expr }` is **raw** — for markup you built yourself, like the
`${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/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 |
-| `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 |
| 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 `` (in-SFC) |
| `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
statically (there is no scss preprocessor).
- **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.
- **The MVC seam is enforced by where the query sits.** Controllers hold
every `from … select`; a view receives VALUES — for the list page, a
`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
needs a session story that does not exist yet.
- **No client-side JS.** Every interaction is a form round trip.
diff --git a/docs/examples/shop/layout/app.wo b/docs/examples/shop/layout/app.wo
index f0d43d4..309a3c7 100644
--- a/docs/examples/shop/layout/app.wo
+++ b/docs/examples/shop/layout/app.wo
@@ -2,11 +2,11 @@
-- wrapping every page with the shared header and footer. Styles are NOT
-- inlined — pages link /assets/style.css (served by static_files), so
-- markup and styling stay separate files.
-use framework/http
-use html
+use serve/http
+use view
--- The app shell as a COMPONENT (wo-html's `Component`: fields in, Text
--- out), the same shape as wo-html's own `Layout` — two slots instead of
+-- The app shell as a COMPONENT (writeonce-view's `Component`: fields in, Text
+-- 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
-- template deliberately LINKS a stylesheet instead of inlining one.
-- That is the whole reason it is not just `Layout`: markup and styling
diff --git a/docs/examples/shop/main.wo b/docs/examples/shop/main.wo
index a47ff78..1bacd3d 100644
--- a/docs/examples/shop/main.wo
+++ b/docs/examples/shop/main.wo
@@ -4,12 +4,12 @@
--
-- WO_DATA=./data ./target/shop 8080 (run from the shop directory:
-- /assets/* serves from ./assets)
-use framework
-use framework/router
+use serve
+use serve/http
+use serve/router
use product_list
use product_page
use orders
-use static_files
fn seed_if_empty() {
let n = 0;
@@ -44,6 +44,8 @@ fn main(args: multi Text) -> Int {
app.get("/p/:sku", ShowProduct {});
app.post("/orders/:sku", CreateOrder {});
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);
}
diff --git a/docs/examples/shop/orders/controller.wo b/docs/examples/shop/orders/controller.wo
index 9031f17..54906f9 100644
--- a/docs/examples/shop/orders/controller.wo
+++ b/docs/examples/shop/orders/controller.wo
@@ -1,7 +1,7 @@
-- orders/controller.wo — the buying flow: stock-checked order creation
-- (decrement + insert are each WAL-committed before they acknowledge)
-- and the orders list (ref navigation: o.product.name).
-use framework/http
+use serve/http
use layout
use time
diff --git a/docs/examples/shop/orders/view.wo b/docs/examples/shop/orders/view.wo
index e1509f0..c717266 100644
--- a/docs/examples/shop/orders/view.wo
+++ b/docs/examples/shop/orders/view.wo
@@ -1,7 +1,7 @@
-- orders/view.wo — the confirmation page and the orders table.
-- Raw text literals for the bodies; see product_list/view.wo for the
-- convention.
-use html
+use view
pub class OrderOk {
name: Text
diff --git a/docs/examples/shop/product_list/controller.wo b/docs/examples/shop/product_list/controller.wo
index b708cdb..05006ca 100644
--- a/docs/examples/shop/product_list/controller.wo
+++ b/docs/examples/shop/product_list/controller.wo
@@ -3,7 +3,7 @@
-- directory = one module, view and controller together. The @tables it
-- queries live in the root module, which is fine: a CLASS is reachable
-- across module lines (only free `fn`s are module-scoped).
-use framework/http
+use serve/http
use layout
pub class ListProducts {
diff --git a/docs/examples/shop/product_list/view.wo b/docs/examples/shop/product_list/view.wo
index fa7bdc8..067cac0 100644
--- a/docs/examples/shop/product_list/view.wo
+++ b/docs/examples/shop/product_list/view.wo
@@ -4,7 +4,7 @@
-- source indentation removed at compile time. `${}` interpolates raw,
-- `{{ }}` HTML-escapes — so display data goes through `{{ }}` and never
-- through a hand-written esc() call.
-use html
+use view
pub class ProductCard {
sku: Text
diff --git a/docs/examples/shop/product_page/controller.wo b/docs/examples/shop/product_page/controller.wo
index 0023859..4933572 100644
--- a/docs/examples/shop/product_page/controller.wo
+++ b/docs/examples/shop/product_page/controller.wo
@@ -1,5 +1,5 @@
-- product_page/controller.wo — the CONTROLLER for /p/:sku.
-use framework/http
+use serve/http
use layout
pub class ShowProduct {
diff --git a/docs/examples/shop/product_page/view.wo b/docs/examples/shop/product_page/view.wo
index 24127b6..f8449e3 100644
--- a/docs/examples/shop/product_page/view.wo
+++ b/docs/examples/shop/product_page/view.wo
@@ -1,7 +1,7 @@
-- 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
-- convention.
-use html
+use view
pub class ProductPage {
sku: Text
diff --git a/docs/examples/shop/static_files/controller.wo b/docs/examples/shop/static_files/controller.wo
deleted file mode 100644
index 5533d36..0000000
--- a/docs/examples/shop/static_files/controller.wo
+++ /dev/null
@@ -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 = {};
- h["content-type"] = ct;
- return Resp { status: 200, headers: h, body: "${body}" };
- }
-}
diff --git a/docs/examples/shop/wo.toml b/docs/examples/shop/wo.toml
index ee459bc..56b0879 100644
--- a/docs/examples/shop/wo.toml
+++ b/docs/examples/shop/wo.toml
@@ -8,5 +8,5 @@ wo = ">= 0.1"
# Two library dependencies, the site sample's proven shape. 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" }
+serve = { git = "https://github.com/shoneyj/writeonce-serve", rev = "v0.1.0" }
+view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }
diff --git a/docs/examples/site/CODE-LOGIC.md b/docs/examples/site/CODE-LOGIC.md
index 00cf6fc..e52df55 100644
--- a/docs/examples/site/CODE-LOGIC.md
+++ b/docs/examples/site/CODE-LOGIC.md
@@ -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()` |
| `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/logo.wo` | VIEW (chrome) | the mark as inline SVG, plus the `` links |
| `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
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
+ 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.
- **The seam is enforced by where the query sits.** `Chapters.links()`
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 `{` entities instead. This is the same
limitation the chapter code samples hit with `${`, and the reason both
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
a proxy — with the env var for reaching a dev instance across the LAN.
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
toolchain; growing the sheet is appending a line in `tw_css()`.
diff --git a/docs/examples/site/README.md b/docs/examples/site/README.md
index 7541d28..b1695a1 100644
--- a/docs/examples/site/README.md
+++ b/docs/examples/site/README.md
@@ -2,13 +2,13 @@
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
+rows in a `@table`, the markup is built by the `writeonce-view` 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" }
+serve = { git = "https://github.com/shoneyj/writeonce-serve", rev = "v0.1.0" }
+view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }
```
## Run it
@@ -29,6 +29,9 @@ SITE_HOST=0.0.0.0 SITE_TOKEN=change-me WO_DATA=./data ./target/site 8080
`GET /packages/` — the package catalogue with copy-paste
`[deps]` lines and usage.
- `GET /favicon.svg` — the mark, inline SVG, no asset pipeline.
+- `GET /dl/` — release tarballs, served by the framework's
+ `StaticFiles` from `$WO_DIST` (default `./dist`, where `just dist`
+ writes them).
- `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
@@ -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` |
| `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
an already-built child component in a slot, so neither knows what a
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.**
`chapter_links()` sits in `chapter.controller.wo` and hands the views a
`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
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
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
+assignment, and `writeonce-view`'s escaping/builders/Tailwind-style utility
sheet — self-contained pages, no CDN, no JS, no build step.
diff --git a/docs/examples/site/admin/controller.wo b/docs/examples/site/admin/controller.wo
index 3052464..7a7153b 100644
--- a/docs/examples/site/admin/controller.wo
+++ b/docs/examples/site/admin/controller.wo
@@ -2,7 +2,7 @@
-- 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. No rendering: the answer is a redirect.
-use framework/http
+use serve/http
pub class AdminEdit {
token: Text
diff --git a/docs/examples/site/chapter/controller.wo b/docs/examples/site/chapter/controller.wo
index c79d80d..bba391d 100644
--- a/docs/examples/site/chapter/controller.wo
+++ b/docs/examples/site/chapter/controller.wo
@@ -1,7 +1,7 @@
-- chapter/controller.wo — the CONTROLLER for `/ch/:slug`. It queries the
-- model and fills the view components that sit beside it in this module;
-- a view receives VALUES, never a cursor.
-use framework/http
+use serve/http
use layout
pub class ShowChapter {
diff --git a/docs/examples/site/chapter/view.wo b/docs/examples/site/chapter/view.wo
index b359b3e..bb9519c 100644
--- a/docs/examples/site/chapter/view.wo
+++ b/docs/examples/site/chapter/view.wo
@@ -6,7 +6,7 @@
-- links), so it renders identically on the homepage and on a chapter
-- page — the only difference is which ord is `current`. Reuse is the
-- same component with different fields, never copied markup.
-use html
+use view
-- `ChapterLink` is the MODEL's projection type (types.wo); a class is
-- reachable across module lines, so the view just names it.
diff --git a/docs/examples/site/content.wo b/docs/examples/site/content.wo
index 086ab40..885af22 100644
--- a/docs/examples/site/content.wo
+++ b/docs/examples/site/content.wo
@@ -1,12 +1,12 @@
-- 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
-- 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
-- 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
+use view
fn ch_hello() -> Text {
let b = el("p", "leading-relaxed mb-4",
@@ -61,15 +61,15 @@ fn ch_actors() -> Text {
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.");
+ "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 writeonce-view " .. "library that rendered the page you are reading.");
b = b .. code_block(`
[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" }`);
+ serve = { git = "https://github.com/shoneyj/writeonce-serve", rev = "v0.1.0" }
+ view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }`);
b = b .. code_block(`
- use framework
- use framework/http
- use html
+ use serve
+ use serve/http
+ use view
-- html's builders + tailwind-style utilities, zero JS, no build step:
let body = el("h1", "text-3xl font-bold", "Hello");
diff --git a/docs/examples/site/favicon/controller.wo b/docs/examples/site/favicon/controller.wo
index ff5608b..f6654a6 100644
--- a/docs/examples/site/favicon/controller.wo
+++ b/docs/examples/site/favicon/controller.wo
@@ -1,6 +1,6 @@
-- favicon/controller.wo — GET /favicon.svg. The one route that answers
-- something other than HTML or text, so it builds its own Resp.
-use framework/http
+use serve/http
use layout
pub class Favicon {
diff --git a/docs/examples/site/health/controller.wo b/docs/examples/site/health/controller.wo
index d3c34ce..80ebdd7 100644
--- a/docs/examples/site/health/controller.wo
+++ b/docs/examples/site/health/controller.wo
@@ -1,6 +1,6 @@
-- health.controller.wo — GET /health: the liveness probe the accept
-- script and any proxy poll. Text, not HTML, on purpose.
-use framework/http
+use serve/http
pub class Health {
fn handle(req: Req) -> Resp {
diff --git a/docs/examples/site/home/controller.wo b/docs/examples/site/home/controller.wo
index 8f2fd1f..05f6234 100644
--- a/docs/examples/site/home/controller.wo
+++ b/docs/examples/site/home/controller.wo
@@ -5,7 +5,7 @@
-- It reaches the chapter nav through `use chapter` and the query through
-- the model's `Chapters.links()` static — a class crosses module lines,
-- a free fn does not.
-use framework/http
+use serve/http
use layout
use chapter
diff --git a/docs/examples/site/home/view.wo b/docs/examples/site/home/view.wo
index 3631dad..fe15a54 100644
--- a/docs/examples/site/home/view.wo
+++ b/docs/examples/site/home/view.wo
@@ -1,10 +1,10 @@
-- home/view.wo — the VIEW for `/`. A component: fields in, Text out.
-- Everything on this page is static copy EXCEPT the chapter list, so
-- 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
-- nav decides which entry is current.
-use html
+use view
pub class HomePage {
chapter_nav: Component
diff --git a/docs/examples/site/install/controller.wo b/docs/examples/site/install/controller.wo
index a4b0cea..8ef9cc7 100644
--- a/docs/examples/site/install/controller.wo
+++ b/docs/examples/site/install/controller.wo
@@ -1,6 +1,6 @@
-- install/controller.wo — GET /install. Nothing to query: the page is
-- static copy, so the controller only wraps it in the shell.
-use framework/http
+use serve/http
use layout
pub class ShowInstall {
diff --git a/docs/examples/site/install/view.wo b/docs/examples/site/install/view.wo
index aeee016..a0a3391 100644
--- a/docs/examples/site/install/view.wo
+++ b/docs/examples/site/install/view.wo
@@ -1,7 +1,7 @@
-- install/view.wo — the VIEW for /install. Static copy: no fields, so
-- the component has none. It is still a component, and still renders
-- through the same interface as every other page.
-use html
+use view
pub class InstallPage {
fn render() -> Text {
@@ -13,43 +13,58 @@ pub class InstallPage {
language runtime to install on the machines you deploy to, and no
build toolchain beyond these two files.
`;
- let s1 = section("1. Get the toolchain",
- `
Download the release tarball and extract
- it into /usr/local. Run this as root, or through
+ let sys = supported();
+
+ let s1 = section("1. Download",
+ `
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:
Extract into /usr/local,
+ replacing any previous install. Run this as root, or through
sudo:
`,
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",
`
Add one line to your
$HOME/.profile (or /etc/profile for every user
on the box), then restart your shell:
`,
code_block("export PATH=$PATH:/usr/local/writeonce/bin"));
- let s3 = section("3. Check it",
+ let s4 = section("4. Check it",
`
Both should print the same version.
woc finds wovm beside itself, so a tarball
install needs no further configuration.
`,
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",
`
A project is a directory with a
wo.toml manifest and one or more .wo files.
Nothing else — no lockfile to create by hand, no scaffolding step.
`,
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",
`
woc <dir> emits ONE
standalone binary at target/<name>. Copy that file to a
server and run it — the VM is inside it, and so is the database.
`,
code_block("woc .\n./target/hello # hello, writeonce"));
- let s6 = section("6. Add a dependency",
+ let s7 = section("7. Add a dependency",
`
Dependencies are git repositories pinned
by revision. The [deps] KEY is the module name your code
uses. woc writes a wo.lock with the
exact revision, and a locked build works offline. See
Packages for what is available.
`,
- 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",
el("h2", "text-lg font-bold mb-2", "Where things go") ..
@@ -62,10 +77,30 @@ pub class InstallPage {
[runtime] wo = ">= 0.1"; woc refuses to build
a project that needs a newer toolchain than itself.`);
- 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 `
+
+
Supported systems
+
+
Linux on x86-64 — the only target built today. There is no ARM, macOS or Windows build.
+
glibc 2.38 or newer. The binaries link the system C library dynamically and import symbols up to GLIBC_2.38. 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).
+
Not musl. Alpine needs a build against musl, which does not exist yet.
+
Nothing else. No JVM, no Node, no Python, no package manager. The two binaries and libc are the whole dependency list.
+
+
Check yours with
+ ldd --version. If it is older than 2.38, build from source
+ until a wider-compatibility release exists.
+
`;
+}
+
-- One numbered step: heading, prose, and the commands to run.
fn section(title: Text, prose: Text, code: Text) -> Text {
return el("div", "mb-8",
diff --git a/docs/examples/site/layout/app.wo b/docs/examples/site/layout/app.wo
index 0b39ee5..28fcdd6 100644
--- a/docs/examples/site/layout/app.wo
+++ b/docs/examples/site/layout/app.wo
@@ -1,13 +1,13 @@
-- layout/app.wo — the app shell: one component wrapping every page with
-- the shared header and footer. Unlike the shop template, this site
--- INLINES its stylesheet (wo-html's `page()` does that), so the shell
--- fills wo-html's own `Layout` rather than writing its own document.
+-- INLINES its stylesheet (writeonce-view's `page()` does that), so the shell
+-- fills writeonce-view's own `Layout` rather than writing its own document.
--
-- 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,
-- instead of as class strings scattered through the controllers.
-use framework/http
-use html
+use serve/http
+use view
pub class AppShell {
title: Text
diff --git a/docs/examples/site/layout/footer.wo b/docs/examples/site/layout/footer.wo
index e2ab3b2..552c765 100644
--- a/docs/examples/site/layout/footer.wo
+++ b/docs/examples/site/layout/footer.wo
@@ -1,5 +1,5 @@
-- layout/footer.wo — the site footer, shared by every page.
-use html
+use view
pub fn footer() -> Text {
return el("div", "footer", "writeonce.de — served by the language it teaches. " .. "One binary: compiler, runtime, database, this page.");
diff --git a/docs/examples/site/layout/header.wo b/docs/examples/site/layout/header.wo
index 323523f..abbdcfe 100644
--- a/docs/examples/site/layout/header.wo
+++ b/docs/examples/site/layout/header.wo
@@ -1,5 +1,5 @@
-- layout/header.wo — the site navigation bar, shared by every page.
-use html
+use view
pub fn header() -> Text {
let links = link("/install", "text-gray-700", "Install");
diff --git a/docs/examples/site/main.wo b/docs/examples/site/main.wo
index 8286675..c3edbe8 100644
--- a/docs/examples/site/main.wo
+++ b/docs/examples/site/main.wo
@@ -1,11 +1,12 @@
-- 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
+-- Full stack in one binary: writeonce-serve ([deps]) for HTTP/routing/
+-- auth, writeonce-view ([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
-- 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
-- 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
-- its controller.
use env
-use framework
-use framework/router
+use serve
+use serve/http
+use serve/router
use home
use chapter
use install
@@ -49,6 +51,14 @@ fn main(args: multi Text) -> Int {
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();
let app = App { middleware: [], routes: [] };
@@ -59,6 +69,9 @@ fn main(args: multi Text) -> Int {
app.get("/packages/:name", ShowPackage {});
app.get("/health", Health {});
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.post("/admin/ch/:slug", AdminEdit { token: "${token}" });
print_err("site: listening on ${host}:${port}");
diff --git a/docs/examples/site/packages/controller.wo b/docs/examples/site/packages/controller.wo
index c456134..2634ec4 100644
--- a/docs/examples/site/packages/controller.wo
+++ b/docs/examples/site/packages/controller.wo
@@ -3,8 +3,8 @@
-- 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
-- values exactly as they would be if this were a table one day.
-use framework/http
-use html
+use serve/http
+use view
use layout
typedef PackageInfo = {
@@ -54,25 +54,26 @@ fn catalogue() -> multi PackageInfo {
let out: multi PackageInfo = [];
push(out, PackageInfo {
- name: "framework",
- 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.",
- git: "https://github.com/shoneyj/writeonce-framework",
+ name: "serve",
+ 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-serve",
rev: "v0.1.0",
what: `
App — the route table and the middleware chains; app.get/post/delete_, use_mw, use_after, mount, serve.
+
StaticFiles — serve a directory over a wildcard route. Traversal is refused, not normalised; max_bytes is a hard ceiling. This site's /dl downloads run through it.
Handler, Middleware, After — structural interfaces. A handler is a CLASS; its fields are the closure this language does not have.
`;\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
\n
{{ self.name }}
\n
`;\n }\n}\n\n-- in a handler:\nlet c = Card { name: user_supplied };\nreturn ok_html(page(\"Hello\", c.render()));")
});
return out;
diff --git a/docs/examples/site/packages/view.wo b/docs/examples/site/packages/view.wo
index 5e9af1f..242495c 100644
--- a/docs/examples/site/packages/view.wo
+++ b/docs/examples/site/packages/view.wo
@@ -3,7 +3,7 @@
-- Three components: a card for the index, the index itself (holding its
-- cards as CHILDREN through the structural interface), and the detail
-- page. None of them knows where the catalogue came from.
-use html
+use view
pub class PackageCard {
name: Text
diff --git a/docs/examples/site/wo.toml b/docs/examples/site/wo.toml
index d2e15a5..5f8648a 100644
--- a/docs/examples/site/wo.toml
+++ b/docs/examples/site/wo.toml
@@ -1,13 +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"
+description = "writeonce.de — the language tutorial served by the language: framework + writeonce-view [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
+# docs/examples/writeonce-serve and docs/examples/writeonce-view, 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" }
+serve = { git = "https://github.com/shoneyj/writeonce-serve", rev = "v0.1.0" }
+view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }
diff --git a/docs/examples/web-app/README.md b/docs/examples/web-app/README.md
index 950b91e..05e9f29 100644
--- a/docs/examples/web-app/README.md
+++ b/docs/examples/web-app/README.md
@@ -1,7 +1,7 @@
# web-app — the storefront sample
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
acceptance workload: `just web-app` runs the whole chain — fetch → lock →
build → serve → curl matrix → restart persistence → SIGTERM.
diff --git a/docs/examples/web-app/main.wo b/docs/examples/web-app/main.wo
index 469ad21..57bf79a 100644
--- a/docs/examples/web-app/main.wo
+++ b/docs/examples/web-app/main.wo
@@ -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
-- a Middleware; the data layer is the language's own database — no ORM, no
-- separate process, one binary.
@@ -6,9 +6,9 @@ use env
use json
use net
use time
-use framework
-use framework/http
-use framework/router
+use serve
+use serve/http
+use serve/router
-- decode target for POST /products, encode shape for every product answer
typedef ProductView = { name: Text, price: Float, stock: Int }
diff --git a/docs/examples/web-app/wo.toml b/docs/examples/web-app/wo.toml
index 56c7e81..5fbad02 100644
--- a/docs/examples/web-app/wo.toml
+++ b/docs/examples/web-app/wo.toml
@@ -1,6 +1,6 @@
name = "web-app"
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]
wo = ">= 0.1"
@@ -11,9 +11,9 @@ wo = ">= 0.1"
# The framework is a real dependency, never a relative path — extraction of
# the framework to its own repository changes only this URL. The acceptance
# 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.
# 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).
[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" }
diff --git a/docs/examples/writeonce-framework/README.md b/docs/examples/writeonce-serve/README.md
similarity index 90%
rename from docs/examples/writeonce-framework/README.md
rename to docs/examples/writeonce-serve/README.md
index c799f9b..cfd42b2 100644
--- a/docs/examples/writeonce-framework/README.md
+++ b/docs/examples/writeonce-serve/README.md
@@ -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
(iteration 15). Spec: `docs/superpowers/specs/2026-08-18-web-framework-design.md` §B.
```toml
[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
@@ -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
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
- `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]`
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
through `[deps]`. Its acceptance (`just web-app`) exercises the whole chain:
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.
diff --git a/docs/examples/writeonce-framework/app.wo b/docs/examples/writeonce-serve/app.wo
similarity index 100%
rename from docs/examples/writeonce-framework/app.wo
rename to docs/examples/writeonce-serve/app.wo
diff --git a/docs/examples/writeonce-framework/http/auth.wo b/docs/examples/writeonce-serve/http/auth.wo
similarity index 100%
rename from docs/examples/writeonce-framework/http/auth.wo
rename to docs/examples/writeonce-serve/http/auth.wo
diff --git a/docs/examples/writeonce-serve/http/files.wo b/docs/examples/writeonce-serve/http/files.wo
new file mode 100644
index 0000000..f382708
--- /dev/null
+++ b/docs/examples/writeonce-serve/http/files.wo
@@ -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 = {};
+ 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;
+}
diff --git a/docs/examples/writeonce-framework/http/form.wo b/docs/examples/writeonce-serve/http/form.wo
similarity index 100%
rename from docs/examples/writeonce-framework/http/form.wo
rename to docs/examples/writeonce-serve/http/form.wo
diff --git a/docs/examples/writeonce-framework/http/multipart.wo b/docs/examples/writeonce-serve/http/multipart.wo
similarity index 100%
rename from docs/examples/writeonce-framework/http/multipart.wo
rename to docs/examples/writeonce-serve/http/multipart.wo
diff --git a/docs/examples/writeonce-framework/http/nego.wo b/docs/examples/writeonce-serve/http/nego.wo
similarity index 100%
rename from docs/examples/writeonce-framework/http/nego.wo
rename to docs/examples/writeonce-serve/http/nego.wo
diff --git a/docs/examples/writeonce-framework/http/secure.wo b/docs/examples/writeonce-serve/http/secure.wo
similarity index 100%
rename from docs/examples/writeonce-framework/http/secure.wo
rename to docs/examples/writeonce-serve/http/secure.wo
diff --git a/docs/examples/writeonce-framework/http/types.wo b/docs/examples/writeonce-serve/http/types.wo
similarity index 98%
rename from docs/examples/writeonce-framework/http/types.wo
rename to docs/examples/writeonce-serve/http/types.wo
index 9725cc5..df2eb1a 100644
--- a/docs/examples/writeonce-framework/http/types.wo
+++ b/docs/examples/writeonce-serve/http/types.wo
@@ -40,7 +40,7 @@ pub fn ok_text(body: Text) -> Resp {
-- iteration 37: HTML is transport here, exactly like text and JSON —
-- the status line and the content-type, nothing about rendering. It
-- 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.
pub fn ok_html(body: Text) -> Resp {
let h: map = {};
diff --git a/docs/examples/writeonce-framework/http/ws.wo b/docs/examples/writeonce-serve/http/ws.wo
similarity index 100%
rename from docs/examples/writeonce-framework/http/ws.wo
rename to docs/examples/writeonce-serve/http/ws.wo
diff --git a/docs/examples/writeonce-framework/http/wsframe.wo b/docs/examples/writeonce-serve/http/wsframe.wo
similarity index 100%
rename from docs/examples/writeonce-framework/http/wsframe.wo
rename to docs/examples/writeonce-serve/http/wsframe.wo
diff --git a/docs/examples/writeonce-framework/internal/parse.wo b/docs/examples/writeonce-serve/internal/parse.wo
similarity index 100%
rename from docs/examples/writeonce-framework/internal/parse.wo
rename to docs/examples/writeonce-serve/internal/parse.wo
diff --git a/docs/examples/writeonce-framework/internal/serve.wo b/docs/examples/writeonce-serve/internal/serve.wo
similarity index 100%
rename from docs/examples/writeonce-framework/internal/serve.wo
rename to docs/examples/writeonce-serve/internal/serve.wo
diff --git a/docs/examples/writeonce-framework/router/router.wo b/docs/examples/writeonce-serve/router/router.wo
similarity index 100%
rename from docs/examples/writeonce-framework/router/router.wo
rename to docs/examples/writeonce-serve/router/router.wo
diff --git a/docs/examples/writeonce-framework/wo.toml b/docs/examples/writeonce-serve/wo.toml
similarity index 80%
rename from docs/examples/writeonce-framework/wo.toml
rename to docs/examples/writeonce-serve/wo.toml
index 0e12d31..3428f49 100644
--- a/docs/examples/writeonce-framework/wo.toml
+++ b/docs/examples/writeonce-serve/wo.toml
@@ -1,4 +1,4 @@
-name = "writeonce-framework"
+name = "writeonce-serve"
kind = "library"
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)"
@@ -9,4 +9,4 @@ wo = ">= 0.1"
# A LIBRARY project (declared above since iteration 17): no `fn main` here —
# the consuming app owns the entry. `woc ` typechecks the whole project.
# 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`.
diff --git a/docs/examples/wo-html/README.md b/docs/examples/writeonce-view/README.md
similarity index 91%
rename from docs/examples/wo-html/README.md
rename to docs/examples/writeonce-view/README.md
index 68f39ba..d15b8fa 100644
--- a/docs/examples/wo-html/README.md
+++ b/docs/examples/writeonce-view/README.md
@@ -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
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]
-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
@@ -78,7 +81,7 @@ component like any other.
`ok_html` is the **framework's** (`framework/http`, beside `ok_text` and
`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
testable without markup. Breaking it looks like one convenience — a
diff --git a/docs/examples/wo-html/html.wo b/docs/examples/writeonce-view/html.wo
similarity index 99%
rename from docs/examples/wo-html/html.wo
rename to docs/examples/writeonce-view/html.wo
index 76b2f8c..23d45d7 100644
--- a/docs/examples/wo-html/html.wo
+++ b/docs/examples/writeonce-view/html.wo
@@ -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
-- literal compiles to a call to this, so display data is
-- escaped by construction; esc() by hand is the fallback
diff --git a/docs/examples/wo-html/wo.toml b/docs/examples/writeonce-view/wo.toml
similarity index 74%
rename from docs/examples/wo-html/wo.toml
rename to docs/examples/writeonce-view/wo.toml
index a3cbd16..c2863a0 100644
--- a/docs/examples/wo-html/wo.toml
+++ b/docs/examples/writeonce-view/wo.toml
@@ -1,4 +1,4 @@
-name = "wo-html"
+name = "writeonce-view"
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"
@@ -7,4 +7,4 @@ description = "HTML building for writeonce apps: escaping, element builders, and
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`).
+# (the key names the module — `view = { git = ... }` gives `use view`).
diff --git a/docs/plan/discarded.md b/docs/plan/discarded.md
index 90765a7..b8795d9 100644
--- a/docs/plan/discarded.md
+++ b/docs/plan/discarded.md
@@ -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. |
| **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. |
-| **`##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. |
| **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. |
diff --git a/docs/plan/oop-vm/08-builtin-surface.md b/docs/plan/oop-vm/08-builtin-surface.md
index 7200772..b49a149 100644
--- a/docs/plan/oop-vm/08-builtin-surface.md
+++ b/docs/plan/oop-vm/08-builtin-surface.md
@@ -183,7 +183,7 @@ Lowered by the emitter, not added to the format:
| `a == b` on `Text` | `EQS` (content equality); `EQ` otherwise |
| `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 }}...` `` | `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 or b` | evaluate `a`; `JZ` + `JMP` past evaluating `b` when `a` is already true; else evaluate `b` (haxe-parity Task 2) |
diff --git a/docs/stories/00-status.md b/docs/stories/00-status.md
index 1ddea2d..1360ce9 100644
--- a/docs/stories/00-status.md
+++ b/docs/stories/00-status.md
@@ -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/
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
seams, crypto hashes need C builtins since the language has no bitwise
operators, streaming/cancellation park behind 8/11). The memory-rich
diff --git a/docs/stories/language-runtime-database/hold/18-memory-db-features.md b/docs/stories/language-runtime-database/hold/18-memory-db-features.md
index d1b51ba..a3bdbbd 100644
--- a/docs/stories/language-runtime-database/hold/18-memory-db-features.md
+++ b/docs/stories/language-runtime-database/hold/18-memory-db-features.md
@@ -7,7 +7,7 @@ status: hold
> **Scope label (2026-08-20): this iteration is FRAMEWORK V2.** Framework
> 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
> alongside v2 as slices, per the ledger.
diff --git a/justfile b/justfile
index 9efc7c4..9d66433 100644
--- a/justfile
+++ b/justfile
@@ -41,7 +41,7 @@ deps-accept:
./scripts/deps-accept.sh
# 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
# -> build -> serve -> the storefront matrix (auth/CRUD/@unique/FK/404/400/
# pipelining) -> SIGTERM -> WAL restart persistence.
@@ -49,7 +49,7 @@ 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
+# (serve + view), server-rendered pages, page matrix -> 401/edit
# -> SIGTERM -> WAL restart persistence. `just site` runs it.
site:
./scripts/site-accept.sh
diff --git a/scripts/site-accept.sh b/scripts/site-accept.sh
index 6243242..e005d75 100755
--- a/scripts/site-accept.sh
+++ b/scripts/site-accept.sh
@@ -1,6 +1,6 @@
#!/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 + view) 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
@@ -27,8 +27,8 @@ cleanup() {
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"
+cp -r "$ROOT/docs/examples/writeonce-serve" "$W/fw"
+cp -r "$ROOT/docs/examples/writeonce-view" "$W/lib"
for d in "$W/fw" "$W/lib"; do
git -C "$d" init -q
git -C "$d" add -A
@@ -36,9 +36,15 @@ for d in "$W/fw" "$W/lib"; do
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"
+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"
+# 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 ----
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"
@@ -69,6 +75,20 @@ except urllib.error.HTTPError as e:
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
local name="$1" got="$2" want="$3" sub="$4"
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 "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 "packages index lists both" "$(hit /packages)" 200 "/packages/framework"
-expect "package detail shows its dep" "$(hit /packages/html)" 200 "wo-html"
+expect "packages index lists both" "$(hit /packages)" 200 "/packages/serve"
+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 "favicon is served as svg" "$(hit /favicon.svg)" 200 "