writeonce/docs/examples/blog
2026-05-04 23:16:10 +02:00
..
tests writeonce language prototype for simple blog and ecommerce 2026-04-21 02:53:15 +02:00
types writeonce language prototype for simple blog and ecommerce 2026-04-21 02:53:15 +02:00
ui adding a app-web-UI prototype 2026-05-04 22:56:28 +02:00
api.rest single thread event loop runtime 2026-05-04 23:16:10 +02:00
app.wo adding a app-web-UI prototype 2026-05-04 22:56:28 +02:00
README.md adding a app-web-UI prototype 2026-05-04 22:56:28 +02:00
wo.toml writeonce language prototype for simple blog and ecommerce 2026-04-21 02:53:15 +02:00

blog — a sample writeonce app

A complete blogging website in ~200 lines of .wo that creates a database, exposes REST + live-subscription endpoints, renders HTML pages, enforces row-level policies, and emits typed client SDKs.

This project is a docs artifact — it illustrates the shape of a real wo init'd project. The wo toolchain referenced here is the one specified in ../../runtime/wo-language.md; the engine is at prototype stage in ../../../prototypes/wo-db/.

What it does

Thing How
Persists articles, authors, tags, comments type declarations compiled to relational rows + embedded documents + graph edges
Serves 24 REST endpoints (CRUD + subscribe × 4 types) service rest blocks on each type
Serves 4 web pages (list, detail, tag, admin) ##ui screens + route table in app.wo
Pushes live updates on every commit live: true on screens + LIVE queries under the hood
Enforces "drafts hidden from anonymous readers" policy read anyone when published == true
Bumps published_at automatically on update trigger inside the transaction
Generates a typed Go client wo gen sdk --lang go

Project layout

blog/
├── wo.toml                   # project manifest (like go.mod)
├── app.wo                    # routes, theme, startup hooks
├── types/
│   ├── author.wo             # Author type + per-type service/policy
│   ├── article.wo            # Article — all three paradigms in one type
│   ├── tag.wo                # Tag taxonomy
│   └── comment.wo            # Reader comments
├── styles/
│   ├── main.css              # global stylesheet (linked from app.wo styles:)
│   └── code-theme.css        # syntax highlighting tokens
├── ui/
│   ├── article_list.wo       # home page list view (live)
│   ├── article_detail.wo     # per-article page with comments + related
│   └── components/           # reusable components (.wo + .htmlx + .css per component)
│       ├── article-card.wo   # selector + typed inputs + styles:
│       ├── article-card.htmlx
│       ├── article-card.css
│       ├── comments.wo       # selector + source + actions + role + styles:
│       ├── comments.htmlx
│       └── comments.css
└── tests/
    └── article_test.wo       # `wo test` picks this up

No main.wo is needed — a pure type+service app auto-generates its entry point. Add main.wo if you need CLI args, background workers, or custom startup logic beyond the on startup hook in app.wo.

UI: components vs. screens

The ui/ tree separates concerns the way Angular separates @Component / template / parent:

  • Screens (ui/article_list.wo, ui/article_detail.wo) declare a ##ui block — they own the route, the page-level data source, and the section layout. They embed components by selector via use: <name> + with: { ... } and pass typed inputs.
  • Components (ui/components/*.wo) declare a ##component block with template: 'foo.htmlx', typed inputs:, and — when the component owns its own query — its source:, sort:, live:, and actions:. No HTML.
  • Templates (ui/components/*.htmlx) are pure presentation. They read from the component's inputs and from the rows produced by its source. No data-source declarations, no role checks.

Screens never inline a component's HTML or its query; templates never declare data sources. Each .wo paired with one .htmlx is the unit of UI reuse.

Styling

CSS is declared at two scopes; in both cases the compiler emits the <link> tags into the SSR layout and serves the files under /static/:

  • App-level (global) — ##app styles: [...] in app.wo lists global stylesheets. Resolved relative to ./styles/. Linked once, in declaration order, on every page.
  • Component-scoped — ##component styles: [...] lists CSS files alongside the component. The compiler rewrites bare selectors in those files to [data-component="<selector>"] <rule>, using the data-component attribute the templates already emit. Rules cannot leak outside the component subtree, so two components can both declare .title without colliding.

A component's CSS is only fetched on pages that embed the component. Global styles always load. Neither layer requires a build step — wo run serves the files as-is.

Run it

$ cd docs/examples/blog
$ wo run
[wo] parsing: 7 files, 4 types, 2 ui screens
[wo] compiling schema: 4 sql tables, 1 doc collection, 3 graph edge types
[wo] starting runtime (engine: in-memory, data_dir: ./data)
[wo] on startup: seed_admin() — inserted admin@example.com
[wo] HTTP listening on :8080

  GET    /api/articles              list
  GET    /api/articles/:id          get
  POST   /api/articles              create
  PATCH  /api/articles/:id          update
  DELETE /api/articles/:id          delete
  WS     /api/articles/live         subscribe
  GET    /api/authors               list
  GET    /api/authors/me            me
  WS     /api/authors/live          subscribe
  GET    /api/tags                  list
  GET    /api/comments              list
  POST   /api/comments              create
  WS     /api/comments/live         subscribe
  ... (and the rest)

  GET    /                          ui.article-list
  GET    /article/:slug             ui.article-detail
  GET    /tag/:slug                 ui.article-list (filtered)
  GET    /admin                     ui.article-list (role: Admin)

Exercise the REST API

# Create an author (requires admin session — see auth docs; stub'd here for brevity)
$ curl -X POST localhost:8080/api/authors \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $ADMIN_TOKEN" \
    -d '{"email":"alice@example.com","handle":"alice","display":"Alice","role":"Author"}'
{"id":2,"email":"alice@example.com","handle":"alice",...}

# Create an article as that author
$ curl -X POST localhost:8080/api/articles \
    -H "Authorization: Bearer $ALICE_TOKEN" \
    -d '{
      "slug": "hello",
      "title": "Hello, writeonce",
      "author": 2,
      "meta": {"excerpt":"First post","body_md":"# Hi\n\nHello."},
      "published": true
    }'
{"id":1,"slug":"hello","title":"Hello, writeonce","published_at":"2026-04-17T12:00:00Z",...}

# List published articles (public — no token)
$ curl localhost:8080/api/articles
[{"id":1,"slug":"hello","title":"Hello, writeonce",...}]

# Filter by tag (via the query layer)
$ curl 'localhost:8080/api/articles?tags.slug=rust'
[...]

Subscribe to live updates

$ websocat ws://localhost:8080/api/articles/live?published=eq.true
{"kind":"snapshot","rows":[{"id":1,"slug":"hello",...}]}

# Now in another terminal, update article 1. The open socket receives:
{"kind":"update","id":1,"old":{"title":"Hello, writeonce"},"new":{"title":"Hello!"}}

No polling. The subscription predicate was registered at connect time; the engine's commit path emits the delta directly.

Generate a Go client

$ wo gen sdk --lang go --out ./client
[wo] reading types from ./types/
[wo] writing ./client/sdk.go (4 types, 16 endpoints, 4 subscriptions)

Use it:

import "github.com/you/blog/client"

c, _ := client.Connect(ctx, "wo://localhost:8080", client.WithToken(token))

// Typed query
articles, _ := c.Articles.List(ctx, client.Where{Published: ptr(true)})

// Typed subscription — deltas arrive on a channel
sub, _ := c.Articles.Subscribe(ctx, client.Where{Published: ptr(true)})
for d := range sub.C {
    switch d.Kind {
    case client.Insert:
        fmt.Printf("new article: %s\n", d.Row.Title)
    case client.Update:
        fmt.Printf("updated: %s\n", d.Row.Slug)
    }
}

Run the tests

$ wo test
=== tests/article_test.wo ===
  create and fetch by slug                               OK (3ms)
  policy blocks public read of unpublished drafts        OK (4ms)
  graph traversal: related articles                      OK (7ms)
  live subscription receives delta on commit             OK (12ms)

PASS  4/4 tests, 0 failures (26ms)

Each test block runs against an isolated engine snapshot that's rolled back at the end — no setup/teardown code needed.

Build a production binary

$ wo build --target linux-amd64 --out bin/blog
[wo] static binary: bin/blog (14 MB, database + HTTP + subscription engine embedded)
$ ./bin/blog
[wo] HTTP listening on :8080

One binary, no dependencies. Copy it to a server, run it, done. The database file lives in ./data/ relative to the binary; the WAL ensures crash safety (Phase 3).