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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 04:41:00 +02:00

4.6 KiB

writeonce-view — server-rendered HTML as plain Text

Renamed 2026-08-25: this library was wo-html, imported as use html. Stories, specs and plans dated before that still say the old name — they are dated records and were left as written.

A view library, not a framework and not a template engine. Everything in it is a pure function or a class with a render(); nothing here opens a socket, reads a file, or touches the database.

[deps]
view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }

The four layers

layer what it is
esc() HTML-escape & < > ". A {{ }} hole in a raw text literal compiles to a call to this, so display data is escaped by construction; calling it by hand is the fallback, not the norm
el(), link(), card(), form_post(), … element builders — el("h1", "text-3xl font-bold", t)
Component / Layout / render_all() the view unit and its composition
tw_css() / page() a hand-written Tailwind-style utility sheet, inlined into one self-contained document — no CDN, no build step, no JS

Components

A component is a class with fields and fn render() -> Text. Nothing declares that it implements Component — satisfaction is structural, exactly like the framework's Handler. Its fields ARE its inputs; the language has no closures, so a field is the capture.

class ProductCard {
  sku:  Text
  name: Text
  fn render() -> Text {
    return `
      <div class="card">
        <h3><a href="/p/{{ self.sku }}">{{ self.name }}</a></h3>
      </div>`;
  }
}

Composition is nesting — a parent holds children and calls their render:

pub class ProductListPage {
  cards: multi Component
  fn render() -> Text {
    return `<div class="grid">${render_all(self.cards)}</div>`;
  }
}

multi Component holds a heterogeneous list directly; no wrapper record is needed (the framework's Mw/Aw wrappers are not a language requirement). render_all(cs) renders children in order.

Layout { title, head, nav, content, footer } is content projection — Angular's <ng-content> 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. 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 component — docs/examples/shop/layout/app.wo is that case, and it is a component like any other.

The MVC seam

where it lives
Model @table rows, queried in the HANDLER. This library contains no from … select anywhere and must not grow one
View components: fields in, Text out. No hidden state, no globals — a page is byte-deterministic from its fields
Controller the framework's Handler: it queries, fills the component's fields, and answers ok_html(c.render())

ok_html is the framework's (framework/http, beside ok_text and ok_json): a status line plus a content-type is transport, not rendering, so writeonce-view never learns what a Resp is.

The seam is what makes a view testable without a server and a query testable without markup. Breaking it looks like one convenience — a component that queries "just this once" — and costs both.

Deliberately absent

Client-side anything (change detection, event bindings, two-way binding, SPA routing, hydration): the no-JS posture stands, and interactivity is form round trips. A runtime template engine: reflection-free means an untyped map<Text, Text>, so templates compile to code at build time or they do not exist. Dependency injection: components are data-in, Text-out. Structural directives (w:if / w:for): the language's own if and for compose literals, and no sample has yet proven the need for a second control-flow dialect. Scoped CSS: the utility sheet stays one static string until the pain is measured.

Consumers

  • docs/examples/site — the tutorial site: Layout + a ChapterNav component reused on the homepage and every chapter page. Gated by just site.
  • docs/examples/shop — the program template: its own AppShell component, page components holding child components.