diff --git a/docs/examples/site/CODE-LOGIC.md b/docs/examples/site/CODE-LOGIC.md
index bb70c53..00cf6fc 100644
--- a/docs/examples/site/CODE-LOGIC.md
+++ b/docs/examples/site/CODE-LOGIC.md
@@ -11,7 +11,11 @@ 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/header.wo`, `layout/footer.wo` | VIEW (chrome) | the shared nav bar 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 `
` links |
+| `install/view.wo`, `install/controller.wo` | VIEW + CONTROLLER | `/install` — the toolchain guide. Static copy, so `InstallPage` has no fields |
+| `packages/view.wo`, `packages/controller.wo` | VIEW + CONTROLLER | `/packages` and `/packages/:name` — the catalogue, its cards, and per-package usage |
+| `favicon/controller.wo` | CONTROLLER | `/favicon.svg` — builds its own `Resp` (image/svg+xml) |
| `home/view.wo` | VIEW | `HomePage` and the homepage's code showcase |
| `home/controller.wo` | CONTROLLER | `Home` — the `/` handler |
| `chapter/view.wo` | VIEW | `ChapterNav`, `ChapterPage` |
@@ -73,6 +77,19 @@ class — recorded gap #1 — but nothing needs it to.)
removed at compile time, `${ }` raw and `{{ }}` auto-escaping. The old
"`..` does not straddle newlines, so build accumulator-style" note is
obsolete and was removed.
+- **The logo is inline SVG, authored once.** `logo_svg(px)` goes in the
+ nav brand and `favicon_svg()` is served at `/favicon.svg` — a dark tile
+ with a two-stroke "W", white then accent blue. No asset pipeline, no
+ binary in the repo, and it stays legible at 16px. The `` link
+ reaches the document through `Layout`'s `head` slot.
+- **A raw literal cannot contain a literal `{{`.** The packages page has
+ prose ABOUT `{{ }}` holes, and writing it directly would have made it a
+ hole; it is written with `{` entities instead. This is the same
+ limitation the chapter code samples hit with `${`, and the reason both
+ doors exist.
+- **`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
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 57b91b4..7541d28 100644
--- a/docs/examples/site/README.md
+++ b/docs/examples/site/README.md
@@ -17,7 +17,18 @@ html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.
woc . && SITE_TOKEN=change-me WO_DATA=./data ./target/site 8080
```
-- `GET /` — the chapter index; `GET /ch/` — one chapter.
+It binds loopback by default. To reach it from another machine while
+developing, name the interface:
+
+```
+SITE_HOST=0.0.0.0 SITE_TOKEN=change-me WO_DATA=./data ./target/site 8080
+```
+
+- `GET /` — the homepage; `GET /ch/` — one chapter.
+- `GET /install` — the installation guide; `GET /packages` and
+ `GET /packages/` — the package catalogue with copy-paste
+ `[deps]` lines and usage.
+- `GET /favicon.svg` — the mark, inline SVG, no asset pipeline.
- `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
@@ -38,7 +49,9 @@ MVC, laid out exactly like the program template
| `types.wo` | MODEL — the `Chapter` `@table`, and seed-if-empty |
| `content.wo` | the nine chapter bodies + `seed_chapters()` |
| `layout/` | the chrome: `AppShell` (+ the two named widths), header, footer, `html_error` |
-| `home/`, `chapter/`, `admin/`, `health/` | one module per feature: its `view.wo` (components: fields in, Text out) and its `controller.wo` (query the model, fill the components, answer a `Resp`) |
+| `home/`, `chapter/`, `install/`, `packages/` | one module per feature: its `view.wo` (components: fields in, Text out) and its `controller.wo` (query the model, fill the components, answer a `Resp`) |
+| `admin/`, `health/`, `favicon/` | controller-only features — a redirect, a text probe, an 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 |
Every `render()` makes its class a component (wo-html's structural
diff --git a/docs/examples/site/favicon/controller.wo b/docs/examples/site/favicon/controller.wo
new file mode 100644
index 0000000..ff5608b
--- /dev/null
+++ b/docs/examples/site/favicon/controller.wo
@@ -0,0 +1,13 @@
+-- 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 layout
+
+pub class Favicon {
+ fn handle(req: Req) -> Resp {
+ let h: map = {};
+ h["content-type"] = "image/svg+xml";
+ h["cache-control"] = "public, max-age=86400";
+ return Resp { status: 200, headers: h, body: favicon_svg() };
+ }
+}
diff --git a/docs/examples/site/install/controller.wo b/docs/examples/site/install/controller.wo
new file mode 100644
index 0000000..a4b0cea
--- /dev/null
+++ b/docs/examples/site/install/controller.wo
@@ -0,0 +1,12 @@
+-- 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 layout
+
+pub class ShowInstall {
+ fn handle(req: Req) -> Resp {
+ let page = InstallPage {};
+ let shell = reading_shell("writeonce — install", page.render());
+ return ok_html(shell.render());
+ }
+}
diff --git a/docs/examples/site/install/view.wo b/docs/examples/site/install/view.wo
new file mode 100644
index 0000000..aeee016
--- /dev/null
+++ b/docs/examples/site/install/view.wo
@@ -0,0 +1,73 @@
+-- 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
+
+pub class InstallPage {
+ fn render() -> Text {
+ let head = `
+
Install writeonce
+
Two native binaries — woc,
+ the compiler, and wovm, the runtime VM. Both depend only on
+ the system C library. There is no package manager to install, no
+ 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
+ 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",
+ `
Add one line to your
+ $HOME/.profile (or /etc/profile for every user
+ on the box), then restart your shell:
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",
+ `
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",
+ `
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",
+ `
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.
The tarball installs to
+ /usr/local/writeonce: bin/woc,
+ bin/wovm, and a VERSION file. To point
+ woc at a different VM, set $WO_RUNTIME or add a
+ [build] runtime = "..." key to wo.toml. A
+ manifest may also require a minimum toolchain with
+ [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;
+ }
+}
+
+-- One numbered step: heading, prose, and the commands to run.
+fn section(title: Text, prose: Text, code: Text) -> Text {
+ return el("div", "mb-8",
+ el("h2", "text-2xl font-bold mb-2", title) .. prose .. code);
+}
diff --git a/docs/examples/site/layout/app.wo b/docs/examples/site/layout/app.wo
index d367269..0b39ee5 100644
--- a/docs/examples/site/layout/app.wo
+++ b/docs/examples/site/layout/app.wo
@@ -17,6 +17,7 @@ pub class AppShell {
fn render() -> Text {
let l = Layout {
title: self.title,
+ head: head_links(),
nav: header(),
content: el("div", self.container, self.content),
footer: footer()
diff --git a/docs/examples/site/layout/header.wo b/docs/examples/site/layout/header.wo
index 4adee3c..323523f 100644
--- a/docs/examples/site/layout/header.wo
+++ b/docs/examples/site/layout/header.wo
@@ -2,8 +2,12 @@
use html
pub fn header() -> Text {
- let links = link("/ch/hello", "text-gray-700", "Tutorial");
- links = links .. link("/health", "text-gray-700", "Health");
+ let links = link("/install", "text-gray-700", "Install");
+ links = links .. link("/ch/hello", "text-gray-700", "Tutorial");
+ links = links .. link("/packages", "text-gray-700", "Packages");
links = links .. link("https://github.com/shoneyj", "text-gray-700", "GitHub");
- return nav_bar("/", "writeonce.de", links);
+ -- The brand is the mark plus the wordmark, one inline-flex row so the
+ -- tile and the text share a baseline at any font size.
+ let brand = el("span", "flex items-center gap-2", logo_svg(28) .. "writeonce.de");
+ return nav_bar("/", brand, links);
}
diff --git a/docs/examples/site/layout/logo.wo b/docs/examples/site/layout/logo.wo
new file mode 100644
index 0000000..1c2f2aa
--- /dev/null
+++ b/docs/examples/site/layout/logo.wo
@@ -0,0 +1,23 @@
+-- layout/logo.wo — the mark, authored as inline SVG so it needs no asset
+-- pipeline and no second request: a dark tile with a two-stroke "W", the
+-- first half white and the second the site's accent blue. One glyph, two
+-- colours, still legible at 16px.
+--
+-- One source, two consumers: the nav brand embeds it, and
+-- `favicon/controller.wo` serves the same bytes at /favicon.svg.
+
+pub fn logo_svg(px: Int) -> Text {
+ return ``;
+}
+
+-- The favicon is the same mark without intrinsic width/height, so the
+-- browser scales it to whatever the tab needs.
+pub fn favicon_svg() -> Text {
+ return ``;
+}
+
+-- What goes in . Inline SVG, no request, no cache question.
+pub fn head_links() -> Text {
+ return `
+ `;
+}
diff --git a/docs/examples/site/main.wo b/docs/examples/site/main.wo
index 65b5c89..8286675 100644
--- a/docs/examples/site/main.wo
+++ b/docs/examples/site/main.wo
@@ -5,6 +5,7 @@
-- 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)
--
-- 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).
@@ -17,8 +18,11 @@ use framework
use framework/router
use home
use chapter
+use install
+use packages
use admin
use health
+use favicon
fn main(args: multi Text) -> Int {
if len(args) < 1 {
@@ -36,13 +40,27 @@ fn main(args: multi Text) -> Int {
return 2;
}
+ -- Bind to loopback unless SITE_HOST says otherwise. Behind a proxy
+ -- loopback is right; SITE_HOST=0.0.0.0 (or a LAN address) is how you
+ -- reach it from another machine while developing.
+ let host = "127.0.0.1";
+ let h = env.get("SITE_HOST");
+ if h != nil {
+ host = "${h}";
+ }
+
seed_if_empty();
let app = App { middleware: [], routes: [] };
app.use_mw(Mw { m: Logging {} });
app.get("/", Home {});
+ app.get("/install", ShowInstall {});
+ app.get("/packages", ShowPackages {});
+ app.get("/packages/:name", ShowPackage {});
app.get("/health", Health {});
+ app.get("/favicon.svg", Favicon {});
app.get("/ch/:slug", ShowChapter {});
app.post("/admin/ch/:slug", AdminEdit { token: "${token}" });
- return app.serve("127.0.0.1", port);
+ print_err("site: listening on ${host}:${port}");
+ return app.serve(host, port);
}
diff --git a/docs/examples/site/packages/controller.wo b/docs/examples/site/packages/controller.wo
new file mode 100644
index 0000000..c456134
--- /dev/null
+++ b/docs/examples/site/packages/controller.wo
@@ -0,0 +1,89 @@
+-- packages/controller.wo — GET /packages and /packages/:name.
+--
+-- 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 layout
+
+typedef PackageInfo = {
+ name: Text,
+ summary: Text,
+ git: Text,
+ rev: Text,
+ what: Text,
+ usage: Text
+}
+
+pub class ShowPackages {
+ fn handle(req: Req) -> Resp {
+ let cards: multi Component = [];
+ for p in catalogue() {
+ push(cards, PackageCard { name: p.name, summary: p.summary });
+ }
+ let page = PackagesPage { cards: cards };
+ let shell = reading_shell("writeonce — packages", page.render());
+ return ok_html(shell.render());
+ }
+}
+
+pub class ShowPackage {
+ fn handle(req: Req) -> Resp {
+ let name = req.params["name"];
+ if name == nil {
+ return html_error(404, "No such package", "The address names no package.");
+ }
+ for p in catalogue() {
+ if p.name == name {
+ let page = PackagePage {
+ name: p.name, summary: p.summary, git: p.git, rev: p.rev,
+ what: p.what, usage: p.usage
+ };
+ let shell = reading_shell("writeonce — ${p.name}", page.render());
+ return ok_html(shell.render());
+ }
+ }
+ return html_error(404, "No such package", "Nothing is published under that name.");
+ }
+}
+
+-- The two libraries this site runs on. Both are `kind = "library"`
+-- projects: no `fn main`, imported through `[deps]`.
+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",
+ rev: "v0.1.0",
+ what: `
+
+
App — the route table and the middleware chains; app.get/post/delete_, use_mw, use_after, mount, serve.
+
Handler, Middleware, After — structural interfaces. A handler is a CLASS; its fields are the closure this language does not have.
`,
+ 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}")
+ });
+
+ push(out, PackageInfo {
+ name: "html",
+ 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",
+ rev: "v0.1.0",
+ what: `
+
+
esc() — escapes & < > ". A {{ }} hole in a raw text literal compiles to a call to it, so display data is escaped by construction. (Written with entities here: inside a raw literal, a real {{ would BE a hole.)
+
Component — a class with fields and fn render() -> Text, satisfied structurally. multi Component holds children directly; render_all renders them in order.
+
Layout — content projection: title, head, nav, content and footer as pre-rendered slots.
`;\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
new file mode 100644
index 0000000..5e9af1f
--- /dev/null
+++ b/docs/examples/site/packages/view.wo
@@ -0,0 +1,61 @@
+-- packages/view.wo — the VIEW for /packages and /packages/:name.
+--
+-- 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
+
+pub class PackageCard {
+ name: Text
+ summary: Text
+
+ fn render() -> Text {
+ return `
+
`;
+ }
+}
+
+pub class PackagesPage {
+ cards: multi Component
+
+ fn render() -> Text {
+ return `
+
Packages
+
A package is a git repository with a
+ wo.toml that says kind = "library". There is no
+ registry and no publish step: you depend on a URL and a revision, and
+ wo.lock pins exactly what you built against. These are the
+ libraries this site itself is built on.
+
${render_all(self.cards)}
`;
+ }
+}
+
+pub class PackagePage {
+ name: Text
+ summary: Text
+ git: Text
+ rev: Text
+ what: Text
+ usage: Text
+
+ fn render() -> Text {
+ let dep = code_block("[deps]\n" .. self.name .. " = { git = \"" .. self.git .. "\", rev = \"" .. self.rev .. "\" }");
+ let head = `
+
{{ self.name }}
+
{{ self.summary }}
+
Install
+
Add it to your wo.toml. The
+ KEY is the module name — that is what use imports.
`;
+ let what = `
+
What you get
+ ${self.what}`;
+ let usage = `
+
Usage
+ ${self.usage}`;
+ let back = el("p", "mt-8", link("/packages", "", "← all packages"));
+ return head .. dep .. what .. usage .. back;
+ }
+}
diff --git a/docs/examples/wo-html/README.md b/docs/examples/wo-html/README.md
index cd3030b..68f39ba 100644
--- a/docs/examples/wo-html/README.md
+++ b/docs/examples/wo-html/README.md
@@ -53,12 +53,15 @@ pub class ProductListPage {
record is needed (the framework's `Mw`/`Aw` wrappers are not a language
requirement). `render_all(cs)` renders children in order.
-`Layout { title, nav, content, footer }` is content projection —
+`Layout { title, head, nav, content, footer }` is content projection —
Angular's `` with the slots as ordinary pre-rendered `Text`.
The caller passes `child.render()`, a raw literal, or a builder's output;
the layout never learns which, which is precisely why it never needs the
child's type. A page wanting a fixed-width column wraps its content
-before handing it over — deliberately no container knob here.
+before handing it over — deliberately no container knob here. `head` is
+the one slot that is not body markup: a favicon link or a meta tag has
+nowhere else to go, and `""` is the ordinary value (`page()` passes it
+for you).
`Layout` renders through `page()`, so it inlines the utility sheet. An
app that links a real stylesheet instead writes its own two-slot shell
diff --git a/docs/examples/wo-html/html.wo b/docs/examples/wo-html/html.wo
index 20a372b..76b2f8c 100644
--- a/docs/examples/wo-html/html.wo
+++ b/docs/examples/wo-html/html.wo
@@ -166,6 +166,14 @@ pub fn tw_css() -> Text {
-- One full document: the sheet inlined, viewport set, body handed in.
-- Self-contained by construction — view-source shows everything.
pub fn page(title: Text, body: Text) -> Text {
+ return page_head(title, "", body);
+}
+
+-- The same document with extra markup: a favicon link, a meta
+-- description, whatever the app needs up there. Kept RAW (`${}`) — head
+-- content is markup the app built, not data — and defaulted to "" by
+-- `page()` so no consumer has to care.
+pub fn page_head(title: Text, head: Text, body: Text) -> Text {
-- The whole document as one literal. Every newline here lands
-- inside , where whitespace is insignificant; the body hole
-- and its closing tags share one line so nothing is inserted into
@@ -174,6 +182,7 @@ pub fn page(title: Text, body: Text) -> Text {
{{ title }}
+ ${head}
${body}`;
}
@@ -213,16 +222,18 @@ pub fn render_all(cs: multi Component) -> Text {
-- builder's output — the layout never knows which, which is exactly why
-- it never needs to know the child's type.
--
--- Deliberately four slots and no container/width field: a page that
--- wants its content in a fixed-width column wraps it before passing it
--- in. One less knob here beats one more.
+-- Deliberately no container/width field: a page that wants its content
+-- in a fixed-width column wraps it before passing it in. `head` is the
+-- one non-body slot — a favicon link or a meta tag has nowhere else to
+-- go, and "" is the ordinary value.
pub class Layout {
title: Text
+ head: Text
nav: Text
content: Text
footer: Text
fn render() -> Text {
- return page(self.title, self.nav .. self.content .. self.footer);
+ return page_head(self.title, self.head, self.nav .. self.content .. self.footer);
}
}
diff --git a/scripts/site-accept.sh b/scripts/site-accept.sh
index 42c666b..6243242 100755
--- a/scripts/site-accept.sh
+++ b/scripts/site-accept.sh
@@ -93,6 +93,12 @@ expect "tailwind sheet inlined" "$(hit /)" 200 ".btn{"
expect "chapter renders a code sample" "$(hit /ch/hello)" 200 "fn main"
expect "escaped interpolation visible" "$(hit /ch/values)" 200 '${port}'
expect "unknown chapter is a 404 page" "$(hit /ch/nope)" 404 "No such chapter"
+expect "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 "unknown package is a 404 page" "$(hit /packages/nope)" 404 "No such package"
+expect "favicon is served as svg" "$(hit /favicon.svg)" 200 "