refactor(site-submodule): docs/examples/site becomes a submodule
- extracted to github.com/shoneyJ/writeonce-site with `git subtree split`, so the site keeps its own 9 commits of history rather than landing there as a flattened snapshot - .gitmodules gains the third entry, alongside reference/writeonce-app and reference/writeonce-api; path is unchanged, so every doc and script that names docs/examples/site still resolves - site-accept.sh fails early and says `git submodule update --init` when the directory is empty. Without it a clone lacking submodules copies an empty app and fails later as a build error naming nothing - releasing.md: the steps that edit install/view.wo now say that edit is a commit in the site repo plus a pointer bump here — editing and committing only in this repo would record nothing - gate re-run against the submodule: site-accept 23 checks, 0 failures Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 4b5634801d5379890bad24c8129cfb23ab6b98df)
This commit is contained in:
parent
e93284befb
commit
7d9d526bb6
25 changed files with 23 additions and 1068 deletions
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -4,3 +4,6 @@
|
||||||
[submodule "reference/writeonce-api"]
|
[submodule "reference/writeonce-api"]
|
||||||
path = reference/writeonce-api
|
path = reference/writeonce-api
|
||||||
url = https://github.com/shoneyJ/writeonce-api
|
url = https://github.com/shoneyJ/writeonce-api
|
||||||
|
[submodule "docs/examples/site"]
|
||||||
|
path = docs/examples/site
|
||||||
|
url = git@github.com:shoneyJ/writeonce-site.git
|
||||||
|
|
|
||||||
1
docs/examples/site
Submodule
1
docs/examples/site
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 81ef9b9ca00c7941dd37d3d3e3e4de6af849a5b4
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
# site — how it is put together
|
|
||||||
|
|
||||||
Written 2026-08-23 with the sample's landing; restructured 2026-08-25
|
|
||||||
onto the program template's MVC layout (`docs/examples/shop`), so the two
|
|
||||||
samples now read the same way.
|
|
||||||
|
|
||||||
## The layout
|
|
||||||
|
|
||||||
| file | layer | what it owns |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `types.wo` | MODEL | the `Chapter` `@table`, the `ChapterLink` projection, `Chapters.links()`, and `seed_if_empty()` |
|
|
||||||
| `content.wo` | MODEL (content) | the ten chapter bodies as fragment-returning functions, plus `seed_chapters()` |
|
|
||||||
| `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 `<head>` 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` |
|
|
||||||
| `chapter/controller.wo` | CONTROLLER | `ShowChapter` — the `/ch/:slug` handler |
|
|
||||||
| `admin/controller.wo` | CONTROLLER | `AdminEdit` — bearer-gated edit, answers a redirect (no view: it redirects) |
|
|
||||||
| `health/controller.wo` | CONTROLLER | `Health` — the liveness probe (no view: it answers text) |
|
|
||||||
| `main.wo` | BOOTSTRAP | seed, routes, serve. Nothing else |
|
|
||||||
| `wo.toml` | — | the two `[deps]`: `framework` (serving) and `html` (markup) |
|
|
||||||
|
|
||||||
One feature = one directory = one module, holding that feature's view
|
|
||||||
and its controller together. A module sees its own declarations plus
|
|
||||||
what it `use`s, so `home/` reaching the chapter nav has to say `use
|
|
||||||
chapter`.
|
|
||||||
|
|
||||||
The model stays at the root and is reachable from everywhere: a CLASS
|
|
||||||
crosses module lines without being exported, and only a free `fn` is
|
|
||||||
module-scoped (`WO-E210`). That single rule explains the whole layout —
|
|
||||||
`Chapter` and `ChapterLink` are classes, so the feature modules just
|
|
||||||
name them; the shared query would have been a free fn, so it is a
|
|
||||||
`static fn` on `Chapters` instead. (`pub` cannot prefix an `@table`
|
|
||||||
class — recorded gap #1 — but nothing needs it to.)
|
|
||||||
|
|
||||||
## Decisions that are not obvious from the code
|
|
||||||
|
|
||||||
- **Chapters are rows, not constants.** `seed_if_empty()` inserts them
|
|
||||||
only when the table answers empty, so a WAL restart keeps admin edits
|
|
||||||
instead of reseeding over them — the sample's own proof of chapter 6's
|
|
||||||
claim. The seed bodies are BUILT with 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` —
|
|
||||||
a projection, not a cursor. No component in this sample touches the
|
|
||||||
database, which is what lets `ChapterNav` be the same component on the
|
|
||||||
homepage and on every chapter page, differing only by `current`.
|
|
||||||
- **`HomePage` and `ChapterPage` hold a `Component`, not chapter data.**
|
|
||||||
The nav arrives as an already-built child component in a slot, so
|
|
||||||
neither page knows what a chapter is. That is content projection —
|
|
||||||
Angular's `<ng-content>`, with the slot as an ordinary field.
|
|
||||||
- **Two widths, named once.** `AppShell` carries a `container` field and
|
|
||||||
`layout/app.wo` exports `reading_shell` / `wide_shell`. The Tailwind
|
|
||||||
class strings appear in exactly one place instead of being repeated at
|
|
||||||
every call site.
|
|
||||||
- **Auth is handler-side by doctrine.** The framework ships mechanism
|
|
||||||
(`bearer_token`, constant-time `ct_eq`); which routes are gated and by
|
|
||||||
which token is policy, so `AdminEdit` checks its own field. No global
|
|
||||||
middleware — the public pages stay public.
|
|
||||||
- **`ok_html` is the framework's**, beside `ok_text`/`ok_json`: a status
|
|
||||||
line plus a content-type is transport, not rendering.
|
|
||||||
- **`\$` in chapter code samples.** Chapter sources show interpolation
|
|
||||||
(`${port}`) inside string literals of a language that interpolates —
|
|
||||||
the lexer's `\$` escape keeps them literal; `code_block()` then
|
|
||||||
HTML-escapes the result. This is also why those two samples stay
|
|
||||||
escaped `"..."` strings rather than becoming raw literals: a raw
|
|
||||||
literal has no escape character, so it cannot spell a literal `${`.
|
|
||||||
- **Concat spans lines two ways now.** A line ENDING in `..` continues on
|
|
||||||
the next (the one newline suppression in the language) — it never works
|
|
||||||
at the START of a line. For markup, prefer the backtick raw literal:
|
|
||||||
real newlines, real double-quoted attributes, source indentation
|
|
||||||
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 `<head>` 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.
|
|
||||||
- **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.
|
|
||||||
- **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()`.
|
|
||||||
|
|
||||||
Gate: `just site` — see `scripts/site-accept.sh` (11 checks; the restart
|
|
||||||
leg polls `/health` instead of sleeping, so it does not share
|
|
||||||
web-app-accept's 0.5s boot race).
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
# site — writeonce.de
|
|
||||||
|
|
||||||
The language tutorial, served BY the language. One binary carries the HTTP
|
|
||||||
server, the router, the pages and the database; the chapters you read are
|
|
||||||
rows in a `@table`, the markup is built by the `writeonce-view` dependency, and
|
|
||||||
the whole thing is chapter 9's own example.
|
|
||||||
|
|
||||||
```
|
|
||||||
[deps]
|
|
||||||
porch = { git = "https://github.com/shoneyj/porch", rev = "v0.1.0" }
|
|
||||||
view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run it
|
|
||||||
|
|
||||||
```
|
|
||||||
woc . && SITE_TOKEN=change-me WO_DATA=./data ./target/site 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
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/<slug>` — one chapter.
|
|
||||||
- `GET /install` — the installation guide; `GET /packages` and
|
|
||||||
`GET /packages/<name>` — the package catalogue with copy-paste
|
|
||||||
`[deps]` lines and usage.
|
|
||||||
- `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,
|
|
||||||
`authorization: Bearer $SITE_TOKEN`). Edits are WAL-durable under
|
|
||||||
`WO_DATA` and replay on restart — that is chapter 6, demonstrated by
|
|
||||||
the site that teaches it.
|
|
||||||
- Without `WO_DATA` the chapters live in RAM and reseed on every boot.
|
|
||||||
|
|
||||||
The acceptance gate is `just site` (scripts/site-accept.sh): two file://
|
|
||||||
dep remotes, build, the page matrix, 401, an authed edit, SIGTERM, and
|
|
||||||
the edit surviving a restart.
|
|
||||||
|
|
||||||
## The file map
|
|
||||||
|
|
||||||
MVC, laid out exactly like the program template
|
|
||||||
([`docs/examples/shop`](../shop/README.md)) so the two read the same way:
|
|
||||||
|
|
||||||
| this app | layer |
|
|
||||||
| --- | --- |
|
|
||||||
| `types.wo` | MODEL — the `Chapter` `@table`, and seed-if-empty |
|
|
||||||
| `content.wo` | the ten chapter bodies + `seed_chapters()` |
|
|
||||||
| `layout/` | the chrome: `AppShell` (+ the two named widths), header, footer, `html_error` |
|
|
||||||
| `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 (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
|
|
||||||
homepage and on every chapter page, differing only by which `ord` is
|
|
||||||
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 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
|
|
||||||
reachable from every feature module without being exported — a CLASS
|
|
||||||
crosses module lines, only a free `fn` is module-scoped (`WO-E210`).
|
|
||||||
That is why the query both pages need is `Chapters.links()`, a `static
|
|
||||||
fn` on a root class, rather than a free function one of them would have
|
|
||||||
to import from the other.
|
|
||||||
|
|
||||||
## writeonce.de deployment
|
|
||||||
|
|
||||||
The framework speaks HTTP/1.1 keep-alive and no TLS by design — terminate
|
|
||||||
TLS at the proxy and forward:
|
|
||||||
|
|
||||||
```
|
|
||||||
server {
|
|
||||||
server_name writeonce.de;
|
|
||||||
listen 443 ssl http2; # certs via certbot/acme
|
|
||||||
location / { proxy_pass http://127.0.0.1:8080; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the binary under systemd (`Restart=on-failure`, `Environment=SITE_TOKEN=...`,
|
|
||||||
`Environment=WO_DATA=/var/lib/writeonce-site`); SIGTERM drains cleanly.
|
|
||||||
|
|
||||||
### What to copy to the host
|
|
||||||
|
|
||||||
The binary is self-contained — VM, bytecode and database engine are
|
|
||||||
inside it — but two directories are read at RUNTIME and must travel
|
|
||||||
with it:
|
|
||||||
|
|
||||||
```
|
|
||||||
/srv/writeonce-site/
|
|
||||||
site the binary (docs/examples/site/target/site)
|
|
||||||
dist/ what /dl serves — the RELEASE assets:
|
|
||||||
writeonce-0.1.0-linux-amd64.tar.gz
|
|
||||||
writeonce-0.1.0-linux-amd64.tar.gz.sha256
|
|
||||||
data/ WO_DATA — the WAL; create it, keep it
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
SITE_TOKEN=<bearer for /admin> required, the process refuses to start without it
|
|
||||||
WO_DATA=/srv/writeonce-site/data durable chapters; omit for RAM-only
|
|
||||||
WO_DIST=/srv/writeonce-site/dist where /dl reads from (default ./dist)
|
|
||||||
SITE_HOST leave UNSET behind a proxy — loopback is the
|
|
||||||
right default; set it only to expose directly
|
|
||||||
```
|
|
||||||
|
|
||||||
Behind a proxy `SITE_HOST` stays unset, so the process binds
|
|
||||||
`127.0.0.1` and is unreachable except through nginx. It prints the
|
|
||||||
bound address at startup, which is the quickest way to confirm that.
|
|
||||||
|
|
||||||
**Keep `dist/` the published release, not a local build.** `just dist`
|
|
||||||
produces a different digest on every run, so a locally built tarball
|
|
||||||
would not match the `.sha256` the release publishes and would make the
|
|
||||||
mirror disagree with the GitHub download. Fetch the assets from the
|
|
||||||
release instead:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh release download v0.1.0 -D dist -R shoneyJ/writeonce
|
|
||||||
```
|
|
||||||
|
|
||||||
## What it demonstrates
|
|
||||||
|
|
||||||
Chapters 1–9 teach the language (values, containers, classes, optionals,
|
|
||||||
tables, actors, deps, serving); the app itself exercises the framework's
|
|
||||||
routing/:params, the Logging middleware, bearer auth (mechanism from
|
|
||||||
`http/auth.wo`, policy here), `form_values`, `@table` + query + update by
|
|
||||||
assignment, and `writeonce-view`'s escaping/builders/Tailwind-style utility
|
|
||||||
sheet — self-contained pages, no CDN, no JS, no build step.
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
-- admin.controller.wo — POST /admin/ch/:slug: title/body update,
|
|
||||||
-- form-encoded, bearer-gated. Mechanism (bearer_token, constant-time
|
|
||||||
-- ct_eq) is the framework's; POLICY — which routes, which token — is
|
|
||||||
-- this app's, right here. No rendering: the answer is a redirect.
|
|
||||||
use porch/http
|
|
||||||
|
|
||||||
pub class AdminEdit {
|
|
||||||
token: Text
|
|
||||||
|
|
||||||
fn handle(req: Req) -> Resp {
|
|
||||||
let got = bearer_token(req);
|
|
||||||
if got == nil { return unauthorized(); }
|
|
||||||
if ct_eq("${got}", self.token) == false { return unauthorized(); }
|
|
||||||
let slug = req.params["slug"];
|
|
||||||
if slug == nil { return not_found(); }
|
|
||||||
let hits = from c in Chapter where c.slug == slug take 1 select c;
|
|
||||||
if len(hits) == 0 { return not_found(); }
|
|
||||||
let f = form_values(req);
|
|
||||||
if f == nil { return bad_request("body must be form-encoded (title, body)"); }
|
|
||||||
let title = f["title"];
|
|
||||||
let body = f["body"];
|
|
||||||
if title == nil and body == nil { return bad_request("nothing to update"); }
|
|
||||||
if title != nil {
|
|
||||||
let t = trim("${title}");
|
|
||||||
if t == "" { return bad_request("title must not be empty"); }
|
|
||||||
hits[0].title = t;
|
|
||||||
}
|
|
||||||
if body != nil {
|
|
||||||
hits[0].body = "${body}";
|
|
||||||
}
|
|
||||||
return redirect("/ch/${slug}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- 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 porch/http
|
|
||||||
use layout
|
|
||||||
|
|
||||||
pub class ShowChapter {
|
|
||||||
fn handle(req: Req) -> Resp {
|
|
||||||
let slug = req.params["slug"];
|
|
||||||
if slug == nil {
|
|
||||||
return html_error(404, "No such chapter", "The address names no chapter.");
|
|
||||||
}
|
|
||||||
let hits = from c in Chapter where c.slug == slug take 1 select c;
|
|
||||||
if len(hits) == 0 {
|
|
||||||
return html_error(404, "No such chapter", "Nothing is filed under that slug.");
|
|
||||||
}
|
|
||||||
let c = hits[0];
|
|
||||||
let nav = ChapterNav { items: Chapters.links(), current: c.ord };
|
|
||||||
let page = ChapterPage { ord: c.ord, title: c.title, body: c.body, chapter_nav: nav };
|
|
||||||
let shell = reading_shell("writeonce — ${c.title}", page.render());
|
|
||||||
return ok_html(shell.render());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
-- chapter/view.wo — the VIEW for `/ch/:slug`, plus the chapter nav that
|
|
||||||
-- the homepage reuses.
|
|
||||||
--
|
|
||||||
-- The MVC seam in one place: these components RENDER, the controller
|
|
||||||
-- QUERIES, and the two never meet. ChapterNav holds VALUES (a list of
|
|
||||||
-- 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 view
|
|
||||||
|
|
||||||
-- `ChapterLink` is the MODEL's projection type (types.wo); a class is
|
|
||||||
-- reachable across module lines, so the view just names it.
|
|
||||||
|
|
||||||
pub class ChapterNav {
|
|
||||||
items: multi ChapterLink
|
|
||||||
current: Int
|
|
||||||
|
|
||||||
fn render() -> Text {
|
|
||||||
let out = "";
|
|
||||||
for c in self.items {
|
|
||||||
let label = `${c.ord}. {{ c.title }}`;
|
|
||||||
if c.ord == self.current {
|
|
||||||
out = out .. el("li", "mb-2 font-bold text-gray-900", label);
|
|
||||||
} else {
|
|
||||||
out = out .. el("li", "mb-2", link("/ch/${c.slug}", "", label));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return el("ul", "list-disc pl-6", out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
-- One chapter. `body` is site-authored HTML held in the row, so it goes
|
|
||||||
-- through the RAW hole; the title is data and goes through `{{ }}`.
|
|
||||||
pub class ChapterPage {
|
|
||||||
ord: Int
|
|
||||||
title: Text
|
|
||||||
body: Text
|
|
||||||
chapter_nav: Component
|
|
||||||
|
|
||||||
fn render() -> Text {
|
|
||||||
let head = el("h1", "text-3xl font-bold mb-4", `${self.ord}. {{ self.title }}`);
|
|
||||||
let art = el("div", "bg-white rounded-lg border shadow-sm p-6", head .. self.body);
|
|
||||||
let nav = el("div", "mt-8", el("h2", "text-lg font-bold mb-2", "Chapters") .. self.chapter_nav.render());
|
|
||||||
return art .. nav;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
-- 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 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 view
|
|
||||||
|
|
||||||
fn ch_hello() -> Text {
|
|
||||||
let b = el("p", "leading-relaxed mb-4",
|
|
||||||
"A writeonce program is one directory of <code>.wo</code> files and one entry: a free " .. "function named <code>main</code>. It returns the process exit code. There is no " .. "runtime to install separately and no build pipeline — <code>woc build</code> produces " .. "ONE self-contained binary with the VM and your bytecode inside.");
|
|
||||||
b = b .. code_block("fn main() -> Int {\n print(\"hello, writeonce\");\n return 0;\n}");
|
|
||||||
b = b .. el("p", "leading-relaxed mt-4",
|
|
||||||
"Run it: <code>woc build . -o hello && ./hello</code>. " .. "Statements end with <code>;</code>, blocks use braces, comments start with <code>--</code>.");
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_values() -> Text {
|
|
||||||
let b = el("p", "leading-relaxed mb-4",
|
|
||||||
"<code>let</code> binds a value; the type is inferred. Scalars: <code>Int</code> (64-bit), " .. "<code>Float</code>, <code>Bool</code>, <code>Text</code> (bytes, binary-safe). Text " .. "interpolates with <code>\${...}</code> and concatenates with <code>..</code>. " .. "Integer literals speak hex and binary, and the full bitwise set is here: " .. "<code>& | ^ << >></code> — grouped Go-style, so a mask compare needs no parentheses.");
|
|
||||||
b = b .. code_block("let port = 8080;\nlet pi = 3.14159;\nlet name = \"writeonce\";\nlet msg = \"listening on \${port}\";\n\nlet flags = 0b1010_0001;\nlet high = flags & 0xF0; -- bitwise AND, then == compares\nlet shifted = 1 << 12; -- 4096\nif flags & 0x80 != 0 {\n print(\"top bit set\"); -- groups (flags & 0x80) != 0\n}");
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_containers() -> Text {
|
|
||||||
let b = el("p", "leading-relaxed mb-4",
|
|
||||||
"Two containers: <code>multi T</code> (a growable list) and <code>map<K, V></code>. " .. "A map read <code>m[k]</code> answers nil when the key is absent — the everyday idiom " .. "for optional lookups like HTTP headers. <code>for .. in</code> walks both.");
|
|
||||||
b = b .. code_block("let langs: multi Text = [\"c\", \"ocaml\", \"writeonce\"];\npush(langs, \"more\");\nprint(\"count \${len(langs)}\");\n\nlet ages: map<Text, Int> = {};\nages[\"ada\"] = 36;\nlet a = ages[\"grace\"]; -- ?Int: nil, no trap\nif a == nil { print(\"unknown\"); }\n\nfor l in langs {\n print(l);\n}\nfor k, v in ages {\n print(\"\${k} is \${v}\");\n}");
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_classes() -> Text {
|
|
||||||
let b = el("p", "leading-relaxed mb-4",
|
|
||||||
"Classes hold fields and methods. There are NO function values and NO closures — a " .. "deliberate doctrine: behavior travels as a class satisfying an interface, and " .. "satisfaction is structural (same method name and shape, Go-style, no " .. "<code>implements</code>). This is how <code>porch</code>, the web framework, takes handlers.");
|
|
||||||
b = b .. code_block("interface Handler {\n fn handle(req: Req) -> Resp\n}\n\nclass Hello {\n greeting: Text\n fn handle(req: Req) -> Resp {\n return ok_text(\"\${self.greeting}, \${req.path}\");\n }\n}\n\n-- any class with a matching handle() satisfies Handler\napp.get(\"/hello\", Hello { greeting: \"hi\" });");
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_optionals() -> Text {
|
|
||||||
let b = el("p", "leading-relaxed mb-4",
|
|
||||||
"<code>?T</code> is a value or nil, and the compiler forces the check before use. " .. "Failures are TRAPS: named, catchable, never silent. <code>try ... catch (e)</code> is " .. "an expression; <code>e</code> carries code, line, method and message. Anything " .. "uncaught ends the program with the same structured report.");
|
|
||||||
b = b .. code_block("let n = parse_int(\"42x\"); -- ?Int\nif n == nil {\n print(\"not a number\");\n}\n\nlet r = try fs.read_all(\"/etc/missing\", 4096) catch (e) e.msg;\nprint(r); -- the file's bytes, or \"No such file or directory\"\n\nlet d = 0;\nlet q = try 10 / d catch (e) 0 - 1; -- DIV0 is a trap, caught here");
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_tables() -> Text {
|
|
||||||
let b = el("p", "leading-relaxed mb-4",
|
|
||||||
"The database is IN the language. <code>@table</code> makes a class a table; " .. "<code>insert</code> writes a row; queries are first-class expressions; an UPDATE is a " .. "plain field assignment on a query result. With <code>WO_DATA=<dir></code> every " .. "commit is WAL-durable before it is acknowledged and replays on restart — this very " .. "site stores these chapters that way, and the admin form's edits survive a kill.");
|
|
||||||
b = b .. code_block("@table(name: \"notes\", index: [tag])\nclass Note {\n tag: Text @unique\n val: Int\n}\n\ninsert Note { tag: \"first\", val: 1 };\n\nfor n in from x in Note where x.val > 0 order by x.tag select x {\n print(\"\${n.tag} = \${n.val}\");\n}\n\nlet hits = from x in Note where x.tag == \"first\" take 1 select x;\nif len(hits) == 1 {\n hits[0].val = 2; -- an update: assign through the row\n}");
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_storage() -> Text {
|
|
||||||
let b = el("p", "leading-relaxed mb-4",
|
|
||||||
"Two optional <code>@table</code> keys decide where a table's rows LIVE. Both default " .. "to today's behaviour, so every table above keeps working untouched. " .. "<code>durable: false</code> keeps a table entirely in RAM — a full table in-process, " .. "same indexes, same <code>@unique</code>, same queries — that is simply empty after a " .. "restart. That is what a session store or a rate-limit counter wants: losing it on " .. "reboot is correct, and paying to make it durable is waste.");
|
|
||||||
b = b .. code_block("@table(name: \"sessions\", durable: false)\nclass Session {\n sid: Text @unique\n hits: Int\n}\n\n@table(name: \"catalog\", resident: keys, index: [sku])\nclass Product {\n sku: Text @unique\n price: Int\n blurb: Text\n}");
|
|
||||||
b = b .. el("p", "leading-relaxed mt-4 mb-4",
|
|
||||||
"<code>resident: keys</code> is the other direction: only the KEY stays in memory. The " .. "row body lives in the write-ahead log and is read back on demand, so a table can be " .. "far larger than RAM without a page-fault cliff. Measured on a product catalogue, the " .. "resident set is <strong>2.55× smaller</strong> than the same table fully resident. " .. "Such a table has nowhere to keep its rows without a log, so declaring it and starting " .. "without <code>WO_DATA</code> is REFUSED at startup rather than silently ignored.");
|
|
||||||
b = b .. el("p", "leading-relaxed mb-4",
|
|
||||||
"Reads and writes are unchanged — the storage mode is not a different API:");
|
|
||||||
b = b .. code_block("let hits = from x in Product where x.sku == \"a-1\" take 1 select x;\nif len(hits) == 1 {\n hits[0].price = 1299; -- an ordinary assignment\n}");
|
|
||||||
b = b .. el("p", "leading-relaxed mt-4",
|
|
||||||
"Underneath, that assignment appends a <em>delta</em> — which field changed, plus a " .. "back-pointer to the row's previous record — instead of rewriting the whole row. A read " .. "walks that chain backward, newest wins. Left alone the chain would grow forever on a " .. "hot row, so it is BOUNDED: past 16 links an update writes a full row image and the " .. "chain restarts at zero. A row updated a million times still costs a bounded number of " .. "record reads, on read and on replay alike.");
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_actors() -> Text {
|
|
||||||
let b = el("p", "leading-relaxed mb-4",
|
|
||||||
"Concurrency is actors on fibers: <code>spawn</code> makes an actor from a class with a " .. "<code>receive</code> method, <code>send</code> delivers one message at a time, and " .. "ownership MOVES with the message — no locks, no shared mutable state, no data races " .. "by construction. Blocking calls park the fiber; the shard serves others meanwhile. " .. "One VM per core by default; mailboxes are bounded (a full one is a catchable trap).");
|
|
||||||
b = b .. code_block("class Counter {\n total: Int\n fn receive(msg: Tick) {\n self.total = self.total + msg.n;\n print(\"total \${self.total}\");\n }\n}\n\nclass Tick {\n n: Int\n}\n\nfn main() -> Int {\n let c: actor Tick = spawn Counter { total: 0 };\n send(c, Tick { n: 1 });\n send(c, Tick { n: 2 });\n time.sleep(50); -- parks this fiber; the actor runs\n return 0;\n}");
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_deps() -> Text {
|
|
||||||
let b = el("p", "leading-relaxed mb-4",
|
|
||||||
"Dependencies are git repositories pinned in <code>wo.toml</code>; <code>wo.lock</code> " .. "records the exact revision, and locked builds work offline. The [deps] KEY names the " .. "module you <code>use</code>. This site has two: <code>porch</code>, the writeonce web framework, and the writeonce-view " .. "library that rendered the page you are reading.");
|
|
||||||
b = b .. code_block(`
|
|
||||||
[deps]
|
|
||||||
porch = { git = "https://github.com/shoneyj/porch", rev = "v0.1.0" }
|
|
||||||
view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }`);
|
|
||||||
b = b .. code_block(`
|
|
||||||
use porch
|
|
||||||
use porch/http
|
|
||||||
use view
|
|
||||||
|
|
||||||
-- html's builders + tailwind-style utilities, zero JS, no build step:
|
|
||||||
let body = el("h1", "text-3xl font-bold", "Hello");
|
|
||||||
return ok_html(page("Hello", body));`);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_serving() -> Text {
|
|
||||||
let b = el("p", "leading-relaxed mb-4",
|
|
||||||
"The whole stack of this site: routes with <code>:param</code> captures, a middleware " .. "chain, handler classes, <code>@table</code> persistence, and server-rendered HTML — " .. "one binary behind a proxy. This is the site's own main, abbreviated:");
|
|
||||||
b = b .. code_block("fn main(args: multi Text) -> Int {\n seed_if_empty();\n let app = App { middleware: [], routes: [] };\n app.use_mw(Mw { m: Logging {} });\n app.get(\"/\", Home {});\n app.get(\"/ch/:slug\", ShowChapter {});\n app.post(\"/admin/ch/:slug\", AdminEdit { token: token });\n return app.serve(\"127.0.0.1\", port);\n}");
|
|
||||||
b = b .. el("p", "leading-relaxed mt-4",
|
|
||||||
"The admin route checks its bearer token in the handler — mechanism lives in the " .. "framework (<code>bearer_token</code>, constant-time <code>ct_eq</code>), POLICY stays " .. "in the app. Try editing this chapter: " .. "<code>curl -X POST -H \"authorization: Bearer ...\" -d \"title=...&body=...\" /admin/ch/serving</code>.");
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
-- One seed row per chapter: (ord, slug, title, body-builder above).
|
|
||||||
pub fn seed_chapters() {
|
|
||||||
insert Chapter { slug: "hello", ord: 1, title: "Hello, writeonce", body: ch_hello() };
|
|
||||||
insert Chapter { slug: "values", ord: 2, title: "Values, Text and bitwise", body: ch_values() };
|
|
||||||
insert Chapter { slug: "containers", ord: 3, title: "multi and map", body: ch_containers() };
|
|
||||||
insert Chapter { slug: "classes", ord: 4, title: "Classes and interfaces", body: ch_classes() };
|
|
||||||
insert Chapter { slug: "optionals", ord: 5, title: "Optionals and traps", body: ch_optionals() };
|
|
||||||
insert Chapter { slug: "tables", ord: 6, title: "@table: the built-in database", body: ch_tables() };
|
|
||||||
insert Chapter { slug: "storage", ord: 7, title: "Storage modes: durable and resident", body: ch_storage() };
|
|
||||||
insert Chapter { slug: "actors", ord: 8, title: "Actors and fibers", body: ch_actors() };
|
|
||||||
insert Chapter { slug: "deps", ord: 9, title: "Dependencies", body: ch_deps() };
|
|
||||||
insert Chapter { slug: "serving", ord: 10, title: "Serving the web (this site)", body: ch_serving() };
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
-- favicon/controller.wo — GET /favicon.svg. The one route that answers
|
|
||||||
-- something other than HTML or text, so it builds its own Resp.
|
|
||||||
use porch/http
|
|
||||||
use layout
|
|
||||||
|
|
||||||
pub class Favicon {
|
|
||||||
fn handle(req: Req) -> Resp {
|
|
||||||
let h: map<Text, Text> = {};
|
|
||||||
h["content-type"] = "image/svg+xml";
|
|
||||||
h["cache-control"] = "public, max-age=86400";
|
|
||||||
return Resp { status: 200, headers: h, body: favicon_svg() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- health.controller.wo — GET /health: the liveness probe the accept
|
|
||||||
-- script and any proxy poll. Text, not HTML, on purpose.
|
|
||||||
use porch/http
|
|
||||||
|
|
||||||
pub class Health {
|
|
||||||
fn handle(req: Req) -> Resp {
|
|
||||||
return ok_text("ok");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
-- home/controller.wo — the CONTROLLER for `/`: query the model, fill the
|
|
||||||
-- view components that sit beside it, answer a Resp. One feature = one
|
|
||||||
-- directory = one module, view and controller together.
|
|
||||||
--
|
|
||||||
-- 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 porch/http
|
|
||||||
use layout
|
|
||||||
use chapter
|
|
||||||
|
|
||||||
pub class Home {
|
|
||||||
fn handle(req: Req) -> Resp {
|
|
||||||
let nav = ChapterNav { items: Chapters.links(), current: 0 };
|
|
||||||
let page = HomePage { chapter_nav: nav };
|
|
||||||
let shell = wide_shell("writeonce — learn the language", page.render());
|
|
||||||
return ok_html(shell.render());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
-- 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 writeonce-view's `Layout` uses. HomePage
|
|
||||||
-- therefore knows nothing about chapters, the Chapter table, or how the
|
|
||||||
-- nav decides which entry is current.
|
|
||||||
use view
|
|
||||||
|
|
||||||
pub class HomePage {
|
|
||||||
chapter_nav: Component
|
|
||||||
|
|
||||||
fn render() -> Text {
|
|
||||||
-- hero: tagline + the two CTAs (the go.dev shape, no JS anywhere)
|
|
||||||
let h1 = el("h1", "text-4xl font-bold mb-4", "One language. One runtime.<br>One database. One binary.");
|
|
||||||
let sub = el("p", "text-lg text-gray-700 leading-relaxed mb-6", "writeonce is a language whose compiler, runtime, web server and " .. "database ship as a single never-stopping Linux binary. Ownership-" .. "checked memory, inferred GC where ownership cannot reach, actors " .. "on every core — and the page you are reading is served by it.");
|
|
||||||
let ctas = el("div", "flex items-center justify-center gap-4", btn_link("/ch/hello", "Get started", true) .. btn_link("https://github.com/shoneyJ/writeonce", "View source", false));
|
|
||||||
let hero = el("div", "text-center py-16", h1 .. sub .. ctas);
|
|
||||||
|
|
||||||
-- code showcase: a real flavor of the language
|
|
||||||
let show_head = el("h2", "text-2xl font-bold mb-2 text-center", "An actor per chat room, rows in the built-in database");
|
|
||||||
let show_cap = el("p", "text-sm text-gray-500 text-center mb-4", "No broker, no ORM, no async keyword — ownership moves the message, the WAL makes the row durable.");
|
|
||||||
let showcase = el("div", "mx-auto max-w-3xl mb-8", show_head .. show_cap .. home_snippet());
|
|
||||||
|
|
||||||
-- why-cards (2x2 grid, collapses on small screens)
|
|
||||||
let cards = card("One binary", "woc build emits a self-contained executable: VM, your bytecode, the database engine. Deploys are a file copy; the runtime swaps code in place.");
|
|
||||||
cards = cards .. card("Memory safety, no tax", "Rust-shaped ownership checked at compile time; where ownership cannot express the shape, the compiler infers GC — per shard, no global pause.");
|
|
||||||
cards = cards .. card("The database is built in", "Every class is a table. Inserts are WAL-logged before they acknowledge; restart replays. No server to operate, no connection string — and a table can declare itself RAM-only, or keep only its keys in memory and outgrow RAM.");
|
|
||||||
cards = cards .. card("Actors on every core", "spawn returns an address, send moves ownership. Fibers park on io_uring instead of blocking threads — no async/await, ever.");
|
|
||||||
let grid = el("div", "grid grid-cols-2 gap-6 mb-8", cards);
|
|
||||||
|
|
||||||
-- chapters (the gate's anchor string lives here)
|
|
||||||
let learn = el("h2", "text-2xl font-bold mb-4", "Learn writeonce");
|
|
||||||
let learn_p = el("p", "leading-relaxed mb-4", "The tutorial is written in the language and stored in its tables — work through the chapters in order:");
|
|
||||||
let chapters = el("div", "bg-white rounded-lg border shadow-sm p-6 mb-8", learn .. learn_p .. self.chapter_nav.render());
|
|
||||||
|
|
||||||
return hero .. showcase .. grid .. chapters;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
-- The homepage's code showcase: a real flavor of the language — an
|
|
||||||
-- actor per chat room, rows in the built-in database, one binary.
|
|
||||||
-- Page copy, so it lives with the page, not with the seed data.
|
|
||||||
fn home_snippet() -> Text {
|
|
||||||
let s = "@table\nclass Message {\n room: Text\n body: Text\n}\n\n";
|
|
||||||
s = s .. "class Room {\n name: Text\n fn receive(msg: Post) {\n";
|
|
||||||
s = s .. " insert Message { room: self.name, body: msg.body };\n";
|
|
||||||
s = s .. " print(\"[\${self.name}] \${msg.body}\");\n }\n}\n\n";
|
|
||||||
s = s .. "fn main() -> Int {\n";
|
|
||||||
s = s .. " let general: actor Post = spawn Room { name: \"general\" };\n";
|
|
||||||
s = s .. " send(general, Post { body: \"hello, writeonce\" });\n";
|
|
||||||
s = s .. " time.sleep(50);\n return 0;\n}";
|
|
||||||
return code_block(s);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
-- install/controller.wo — GET /install. Nothing to query: the page is
|
|
||||||
-- static copy, so the controller only wraps it in the shell.
|
|
||||||
use porch/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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
-- 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 view
|
|
||||||
|
|
||||||
pub class InstallPage {
|
|
||||||
fn render() -> Text {
|
|
||||||
let head = `
|
|
||||||
<h1 class="text-3xl font-bold mb-4">Install writeonce</h1>
|
|
||||||
<p class="leading-relaxed mb-6">Two native binaries — <code>woc</code>,
|
|
||||||
the compiler, and <code>wovm</code>, 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.</p>`;
|
|
||||||
|
|
||||||
let sys = supported();
|
|
||||||
|
|
||||||
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_block("rm -rf /usr/local/writeonce\ntar -C /usr/local -xzf writeonce-0.1.0-linux-amd64.tar.gz"));
|
|
||||||
|
|
||||||
let s3 = section("3. Put it on your PATH",
|
|
||||||
`<p class="leading-relaxed mb-4">Add one line to your
|
|
||||||
<code>$HOME/.profile</code> (or <code>/etc/profile</code> for every user
|
|
||||||
on the box), then restart your shell:</p>`,
|
|
||||||
code_block("export PATH=$PATH:/usr/local/writeonce/bin"));
|
|
||||||
|
|
||||||
let s4 = section("4. Check it",
|
|
||||||
`<p class="leading-relaxed mb-4">Both should print the same version.
|
|
||||||
<code>woc</code> finds <code>wovm</code> beside itself, so a tarball
|
|
||||||
install needs no further configuration.</p>`,
|
|
||||||
code_block("woc version # writeonce 0.1.0 linux/amd64\nwovm --version # wovm 0.1.0"));
|
|
||||||
|
|
||||||
let s5 = section("5. Your first project",
|
|
||||||
`<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.
|
|
||||||
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"));
|
|
||||||
|
|
||||||
let s6 = section("6. Build and run",
|
|
||||||
`<p class="leading-relaxed mb-4"><code>woc <dir></code> emits ONE
|
|
||||||
standalone binary at <code>target/<name></code>. Copy that file to a
|
|
||||||
server and run it — the VM is inside it, and so is the database.</p>`,
|
|
||||||
code_block("woc .\n./target/hello # hello, writeonce"));
|
|
||||||
|
|
||||||
let s7 = section("7. Add a dependency",
|
|
||||||
`<p class="leading-relaxed mb-4">Dependencies are git repositories pinned
|
|
||||||
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
|
|
||||||
exact revision, and a locked build works offline. See
|
|
||||||
<a href="/packages">Packages</a> for what is available.</p>`,
|
|
||||||
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") ..
|
|
||||||
`<p class="leading-relaxed">The tarball installs to
|
|
||||||
<code>/usr/local/writeonce</code>: <code>bin/woc</code>,
|
|
||||||
<code>bin/wovm</code>, and a <code>VERSION</code> file. To point
|
|
||||||
<code>woc</code> at a different VM, set <code>$WO_RUNTIME</code> or add a
|
|
||||||
<code>[build] runtime = "..."</code> key to <code>wo.toml</code>. A
|
|
||||||
manifest may also require a minimum toolchain with
|
|
||||||
<code>[runtime] wo = ">= 0.1"</code>; <code>woc</code> refuses to build
|
|
||||||
a project that needs a newer toolchain than itself.</p>`);
|
|
||||||
|
|
||||||
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.35 or newer.</b> The binaries link the system C library dynamically: <code>woc</code> imports symbols up to <code>GLIBC_2.35</code> and <code>wovm</code> up to <code>GLIBC_2.34</code>, read off the published v0.1.0 release. That covers Ubuntu 22.04+, Debian 12+ and Fedora 36+. On RHEL 9 (2.34) the runtime works but the compiler does not — build elsewhere and copy the binary over, which is the point of a self-contained executable.</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.35, build from source
|
|
||||||
until a wider-compatibility release exists. The floor is set by the
|
|
||||||
machine that BUILT the release, not by the language — the CI runner is
|
|
||||||
pinned to Ubuntu 22.04 to keep it this low.</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
-- 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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
-- 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 (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 porch/http
|
|
||||||
use view
|
|
||||||
|
|
||||||
pub class AppShell {
|
|
||||||
title: Text
|
|
||||||
container: Text
|
|
||||||
content: Text
|
|
||||||
|
|
||||||
fn render() -> Text {
|
|
||||||
let l = Layout {
|
|
||||||
title: self.title,
|
|
||||||
head: head_links(),
|
|
||||||
nav: header(),
|
|
||||||
content: el("div", self.container, self.content),
|
|
||||||
footer: footer()
|
|
||||||
};
|
|
||||||
return l.render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Chapter pages keep a reading width.
|
|
||||||
pub fn reading_shell(title: Text, content: Text) -> AppShell {
|
|
||||||
return AppShell { title: title, container: "mx-auto max-w-3xl px-4 py-8", content: content };
|
|
||||||
}
|
|
||||||
|
|
||||||
-- The homepage uses the wider container (the go.dev shape).
|
|
||||||
pub fn wide_shell(title: Text, content: Text) -> AppShell {
|
|
||||||
return AppShell { title: title, container: "mx-auto max-w-5xl px-4", content: content };
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Every non-200 HTML answer goes through here, so an error page is a
|
|
||||||
-- real page: same chrome, same shell, just a different status.
|
|
||||||
pub fn html_error(status: Int, title: Text, msg: Text) -> Resp {
|
|
||||||
let content = el("h1", "text-2xl font-bold mb-4", title) ..
|
|
||||||
el("p", "", msg) ..
|
|
||||||
el("p", "", link("/", "", "Back to the chapters"));
|
|
||||||
let shell = reading_shell("writeonce — ${title}", content);
|
|
||||||
let h: map<Text, Text> = {};
|
|
||||||
h["content-type"] = "text/html; charset=utf-8";
|
|
||||||
return Resp { status: status, headers: h, body: shell.render() };
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- layout/footer.wo — the site footer, shared by every page.
|
|
||||||
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.");
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
-- layout/header.wo — the site navigation bar, shared by every page.
|
|
||||||
use view
|
|
||||||
|
|
||||||
pub fn header() -> Text {
|
|
||||||
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/writeonce", "text-gray-700", "GitHub");
|
|
||||||
-- 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) .. "<span>writeonce.de</span>");
|
|
||||||
return nav_bar("/", brand, links);
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- 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 `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 64 64" role="img" aria-label="writeonce"><rect width="64" height="64" rx="14" fill="#111827"/><path d="M14 20 L22 44 L32 28" fill="none" stroke="#ffffff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><path d="M32 28 L42 44 L50 20" fill="none" stroke="#2563eb" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
-- 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 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#111827"/><path d="M14 20 L22 44 L32 28" fill="none" stroke="#ffffff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><path d="M32 28 L42 44 L50 20" fill="none" stroke="#2563eb" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
-- What goes in <head>. Inline SVG, no request, no cache question.
|
|
||||||
pub fn head_links() -> Text {
|
|
||||||
return `<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
||||||
<meta name="description" content="writeonce — one language, one runtime, one database, one binary.">`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
-- site — writeonce.de: the language tutorial, served BY the language.
|
|
||||||
-- Full stack in one binary: porch ([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).
|
|
||||||
--
|
|
||||||
-- This file is the BOOTSTRAP and nothing else: seed, routes, serve. The
|
|
||||||
-- model is types.wo; every feature is a directory holding its view and
|
|
||||||
-- its controller.
|
|
||||||
use env
|
|
||||||
use porch
|
|
||||||
use porch/http
|
|
||||||
use porch/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 {
|
|
||||||
print_err("usage: site <port> (SITE_TOKEN gates /admin; WO_DATA makes chapters durable)");
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
let port = parse_int(args[0]);
|
|
||||||
if port == nil {
|
|
||||||
print_err("site: <port> must be a number");
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
let token = env.get("SITE_TOKEN");
|
|
||||||
if token == nil {
|
|
||||||
print_err("site: SITE_TOKEN is required (the admin route's bearer token)");
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
-- 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}";
|
|
||||||
}
|
|
||||||
|
|
||||||
-- 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: [] };
|
|
||||||
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 {});
|
|
||||||
-- 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}");
|
|
||||||
return app.serve(host, port);
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
-- 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 porch/http
|
|
||||||
use view
|
|
||||||
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: "porch",
|
|
||||||
summary: "The writeonce web framework: an HTTP/1.1 keep-alive server core, a router with :param captures, and Handler/Middleware structural interfaces. Imported as <code>use porch</code>.",
|
|
||||||
git: "https://github.com/shoneyj/porch",
|
|
||||||
rev: "v0.1.0",
|
|
||||||
what: `
|
|
||||||
<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>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>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">Bodies: <code>form_values</code>, <code>multipart_parts</code>, content negotiation, ETags, security headers, CORS, WebSocket frames.</li>
|
|
||||||
</ul>`,
|
|
||||||
usage: code_block("use porch\nuse porch/http\nuse porch/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: "view",
|
|
||||||
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/writeonce-view",
|
|
||||||
rev: "v0.1.0",
|
|
||||||
what: `
|
|
||||||
<ul class="list-disc pl-6 leading-relaxed">
|
|
||||||
<li class="mb-2"><code>esc()</code> — escapes <code>& < > "</code>. A <code>{{ }}</code> 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 <code>{{</code> would BE a hole.)</li>
|
|
||||||
<li class="mb-2"><code>Component</code> — a class with fields and <code>fn render() -> Text</code>, satisfied structurally. <code>multi Component</code> holds children directly; <code>render_all</code> renders them in order.</li>
|
|
||||||
<li class="mb-2"><code>Layout</code> — content projection: title, head, nav, content and footer as pre-rendered slots.</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>
|
|
||||||
</ul>`,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
-- 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 view
|
|
||||||
|
|
||||||
pub class PackageCard {
|
|
||||||
name: Text
|
|
||||||
summary: Text
|
|
||||||
|
|
||||||
fn render() -> Text {
|
|
||||||
return `
|
|
||||||
<div class="bg-white rounded-lg border shadow-sm p-6">
|
|
||||||
<h3 class="text-lg font-bold mb-2"><a href="/packages/{{ self.name }}">{{ self.name }}</a></h3>
|
|
||||||
<p class="leading-relaxed text-gray-700">{{ self.summary }}</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub class PackagesPage {
|
|
||||||
cards: multi Component
|
|
||||||
|
|
||||||
fn render() -> Text {
|
|
||||||
return `
|
|
||||||
<h1 class="text-3xl font-bold mb-4">Packages</h1>
|
|
||||||
<p class="leading-relaxed mb-6">A package is a git repository with a
|
|
||||||
<code>wo.toml</code> that says <code>kind = "library"</code>. There is no
|
|
||||||
registry and no publish step: you depend on a URL and a revision, and
|
|
||||||
<code>wo.lock</code> pins exactly what you built against. These are the
|
|
||||||
libraries this site itself is built on.</p>
|
|
||||||
<div class="grid grid-cols-2 gap-6 mb-8">${render_all(self.cards)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = `
|
|
||||||
<h1 class="text-3xl font-bold mb-2">{{ self.name }}</h1>
|
|
||||||
<p class="leading-relaxed text-gray-700 mb-6">{{ self.summary }}</p>
|
|
||||||
<h2 class="text-2xl font-bold mb-2">Install</h2>
|
|
||||||
<p class="leading-relaxed mb-4">Add it to your <code>wo.toml</code>. The
|
|
||||||
KEY is the module name — that is what <code>use</code> imports.</p>`;
|
|
||||||
let what = `
|
|
||||||
<h2 class="text-2xl font-bold mb-2 mt-8">What you get</h2>
|
|
||||||
${self.what}`;
|
|
||||||
let usage = `
|
|
||||||
<h2 class="text-2xl font-bold mb-2 mt-8">Usage</h2>
|
|
||||||
${self.usage}`;
|
|
||||||
let back = el("p", "mt-8", link("/packages", "", "← all packages"));
|
|
||||||
return head .. dep .. what .. usage .. back;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
-- types.wo — the MODEL. Every @table class IS a WAL-backed table: rows
|
|
||||||
-- persist under WO_DATA and replay on restart; without WO_DATA the
|
|
||||||
-- store is RAM-only. Nothing else lives here — no rendering, no request
|
|
||||||
-- handling.
|
|
||||||
--
|
|
||||||
-- Root module by NECESSITY, not choice: `pub` and `@table` cannot
|
|
||||||
-- combine yet (recorded language gap), so tables cannot be exported to
|
|
||||||
-- other modules — everything that queries them (the controllers) lives
|
|
||||||
-- in the root module too.
|
|
||||||
|
|
||||||
-- Every chapter is a row: slug is the URL, ord orders the nav, body is a
|
|
||||||
-- server-rendered HTML fragment. Edits (the admin route) persist through
|
|
||||||
-- the WAL under WO_DATA and replay on restart.
|
|
||||||
@table(name: "chapters", index: [slug])
|
|
||||||
class Chapter {
|
|
||||||
slug: Text @unique
|
|
||||||
ord: Int
|
|
||||||
title: Text
|
|
||||||
body: Text
|
|
||||||
}
|
|
||||||
|
|
||||||
-- One nav entry: a PROJECTION of a Chapter row, not the row itself. It
|
|
||||||
-- lives with the model because that is what it is — the views merely
|
|
||||||
-- consume it, and never hold a database handle.
|
|
||||||
typedef ChapterLink = { ord: Int, title: Text, slug: Text }
|
|
||||||
|
|
||||||
-- The chapter queries, on a class so every feature module can reach
|
|
||||||
-- them: a free `fn` is scoped to the module that declares it (WO-E210),
|
|
||||||
-- but a CLASS — and its statics — is reachable across module lines.
|
|
||||||
-- That is what lets `home/` and `chapter/` share one query without one
|
|
||||||
-- importing the other.
|
|
||||||
class Chapters {
|
|
||||||
-- Every chapter as a nav entry, ordered. Two pages call this.
|
|
||||||
static fn links() -> multi ChapterLink {
|
|
||||||
let items: multi ChapterLink = [];
|
|
||||||
for c in from x in Chapter order by x.ord select x {
|
|
||||||
push(items, ChapterLink { ord: c.ord, title: c.title, slug: c.slug });
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
-- First boot only: an empty table gets the tutorial (content.wo).
|
|
||||||
fn seed_if_empty() {
|
|
||||||
let n = 0;
|
|
||||||
for c in from x in Chapter take 1 select x {
|
|
||||||
n = n + 1;
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
seed_chapters();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
name = "site"
|
|
||||||
version = "0.1.0"
|
|
||||||
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/porch and docs/examples/writeonce-view, so CI never
|
|
||||||
# touches the network). The [deps] KEY is the module name `use` imports.
|
|
||||||
[deps]
|
|
||||||
porch = { git = "https://github.com/shoneyj/porch", rev = "v0.1.0" }
|
|
||||||
view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }
|
|
||||||
|
|
@ -72,6 +72,12 @@ ignored by this workflow.
|
||||||
Publishing binaries whose floor differs from the page is the one
|
Publishing binaries whose floor differs from the page is the one
|
||||||
failure a user cannot debug.
|
failure a user cannot debug.
|
||||||
|
|
||||||
|
docs/examples/site is a SUBMODULE (github.com/shoneyJ/writeonce-site),
|
||||||
|
so this edit is a commit in THAT repo, pushed there, and then a
|
||||||
|
second commit here moving the submodule pointer. Editing the files
|
||||||
|
and committing only in this repo records nothing — the change lives
|
||||||
|
in a directory this repo tracks by revision, not by content.
|
||||||
|
|
||||||
10 Ship for real:
|
10 Ship for real:
|
||||||
git tag -a v0.1.0 -m "writeonce 0.1.0"
|
git tag -a v0.1.0 -m "writeonce 0.1.0"
|
||||||
git push origin v0.1.0
|
git push origin v0.1.0
|
||||||
|
|
@ -404,7 +410,9 @@ archive.
|
||||||
updating every place the site names the current release:
|
updating every place the site names the current release:
|
||||||
`docs/examples/site/install/view.wo` (the download links, the tar
|
`docs/examples/site/install/view.wo` (the download links, the tar
|
||||||
command, and the `.sha256` link). The version appears there as literal
|
command, and the `.sha256` link). The version appears there as literal
|
||||||
text, so grep for the old number before you publish.
|
text, so grep for the old number before you publish. That file is in
|
||||||
|
the `writeonce-site` submodule — commit and push it there, then bump
|
||||||
|
the pointer here.
|
||||||
- **One target today.** `mkdist.sh` builds `linux-amd64` only; a cross
|
- **One target today.** `mkdist.sh` builds `linux-amd64` only; a cross
|
||||||
matrix is future work. Do not add architectures to the release notes
|
matrix is future work. Do not add architectures to the release notes
|
||||||
that no build produces.
|
that no build produces.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,16 @@ if [[ ! -x "$WOC" || ! -x "$WOVM" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# docs/examples/site is a SUBMODULE (github.com/shoneyJ/writeonce-site). A clone
|
||||||
|
# without --recurse-submodules leaves it an empty directory, and the copy below
|
||||||
|
# would then "succeed" into an empty app and fail much later as a build error
|
||||||
|
# that says nothing about the real cause. Say the real cause here.
|
||||||
|
if [[ ! -f "$ROOT/docs/examples/site/main.wo" ]]; then
|
||||||
|
echo "site-accept: docs/examples/site is empty — it is a submodule." >&2
|
||||||
|
echo " run: git submodule update --init docs/examples/site" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
W="$(mktemp -d "${TMPDIR:-/tmp}/site-accept.XXXXXX")"
|
W="$(mktemp -d "${TMPDIR:-/tmp}/site-accept.XXXXXX")"
|
||||||
SRV=""
|
SRV=""
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue