From 89df51761325a10a85bffb5f5f14972380256999 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sun, 23 Aug 2026 23:57:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(shop):=20the=20program=20template=20?= =?UTF-8?q?=E2=80=94=20MVC=20on=20disk;=20story=2037=20redirected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/examples/shop: types.wo model, per-feature view modules (render() classes), root controller files, layout shell/header/ footer, static assets controller + real style.css, README with Angular file map + run + DX referendum notes - buy flow proven by hand: stock check/decrement, 409s, traversal 404, css typed, WAL-durable; builds on the UNPATCHED toolchain - two language gaps recorded, not fixed (per directive): pub+@table cannot combine (controllers forced into root module); @view projection classes absent - story 37 REDIRECTED per Vue-SFC review: view.html compiled by woc ({{}} auto-escaped, w:if/w:for, typed against view class, no runtime engine); render()-as-concatenation failed the referendum; forks revised; shop named the acceptance consumer Co-Authored-By: Claude Fable 5 --- docs/examples/shop/README.md | 69 +++++++++++++++++++ docs/examples/shop/assets/style.css | 47 +++++++++++++ docs/examples/shop/layout/app.wo | 34 +++++++++ docs/examples/shop/layout/footer.wo | 6 ++ docs/examples/shop/layout/header.wo | 8 +++ docs/examples/shop/main.wo | 49 +++++++++++++ docs/examples/shop/orders.controller.wo | 55 +++++++++++++++ docs/examples/shop/orders/view.wo | 44 ++++++++++++ docs/examples/shop/product_list.controller.wo | 20 ++++++ docs/examples/shop/product_list/view.wo | 33 +++++++++ docs/examples/shop/product_page.controller.wo | 21 ++++++ docs/examples/shop/product_page/view.wo | 25 +++++++ docs/examples/shop/static_files/controller.wo | 33 +++++++++ docs/examples/shop/types.wo | 29 ++++++++ docs/examples/shop/wo.toml | 12 ++++ .../refine/37-wo-html-components.md | 56 +++++++++++---- 16 files changed, 527 insertions(+), 14 deletions(-) create mode 100644 docs/examples/shop/README.md create mode 100644 docs/examples/shop/assets/style.css create mode 100644 docs/examples/shop/layout/app.wo create mode 100644 docs/examples/shop/layout/footer.wo create mode 100644 docs/examples/shop/layout/header.wo create mode 100644 docs/examples/shop/main.wo create mode 100644 docs/examples/shop/orders.controller.wo create mode 100644 docs/examples/shop/orders/view.wo create mode 100644 docs/examples/shop/product_list.controller.wo create mode 100644 docs/examples/shop/product_list/view.wo create mode 100644 docs/examples/shop/product_page.controller.wo create mode 100644 docs/examples/shop/product_page/view.wo create mode 100644 docs/examples/shop/static_files/controller.wo create mode 100644 docs/examples/shop/types.wo create mode 100644 docs/examples/shop/wo.toml diff --git a/docs/examples/shop/README.md b/docs/examples/shop/README.md new file mode 100644 index 0000000..0d99509 --- /dev/null +++ b/docs/examples/shop/README.md @@ -0,0 +1,69 @@ +# shop — the writeonce program template + +A small store you can buy from, structured the way a real writeonce web +app should be. **Copy this directory to start a new app**; every file +has one concern, and the module system (one directory = one module, +`pub` = the export line) enforces the separation the layout promises. + +## Run it + +``` +cd docs/examples/shop +woc . && WO_DATA=./data ./target/shop 8080 # durable store +./target/shop 8080 # RAM-only (dev) +``` + +Browse http://127.0.0.1:8080/ — products → product page → buy (stock +checked and decremented) → confirmation → /orders. With `WO_DATA`, kill +it and restart: the orders are still there (WAL replay). + +## The file map (Angular equivalents) + +| this template | concern | Angular analog | +| --- | --- | --- | +| `types.wo` | MODEL — `@table` classes ARE the WAL database | `models/*.ts` (+ the entire database) | +| `layout/app.wo` | app shell: document, header+footer composition, `ok_html`/`html_error` transport helpers | `app.component.html` | +| `layout/header.wo` / `footer.wo` | shared chrome fragments | `header.html` / `footer.html` | +| `product_list/view.wo` | VIEW — classes with `fn render() -> Text`, fields = exactly what is displayed | `product-list/view.html` | +| `product_list.controller.wo` | CONTROLLER — query the model, fill the view, answer a `Resp` (one file per feature, root module) | component `.ts` + service | +| `product_page/`, `orders/` | one view module per feature + its root controller file | feature folders | +| `static_files/controller.wo` | `/assets/*` from disk, traversal-safe, typed | `angular.json` assets | +| `assets/style.css` | ONE real stylesheet, sectioned per feature | the `.scss` files | +| `main.wo` | bootstrap: seed, routes, serve — nothing else | `app-routing.module.ts` + `main.ts` | + +Separation is compiler-enforced where the language allows it today: +each feature's VIEW directory is a module — the root controllers see +only its `pub` classes and `layout`'s exports. Controllers themselves +sit in the root module beside `types.wo`, because of gap #1 below. + +## What is deliberately different (doctrine) + +- **No `.html`/`.scss` template files.** Views are `.wo` code — the + compiler type-checks them, `esc()` is the one escaping rule, and no + template engine runs at request time. Styles stay a real CSS file, + served statically (there is no scss preprocessor). +- **No closures, no DI.** A view is a class with fields + `render()`; + a controller is a class satisfying `Handler`. Capture = a field. +- **No sessions/cart yet.** Buying is per-product (qty → order). A cart + needs a session story that does not exist yet. +- **No client-side JS.** Every interaction is a form round trip. +- **`pub` + `@table` cannot combine yet (recorded gap #1).** An + annotated class cannot be exported, so a shared `types/` MODULE is + impossible today — which is why the controllers live in the root + module with `types.wo` instead of inside their feature folders. A + one-clause grammar fix closes this; until then the template shows the + honest layout. +- **`@view` projection classes (recorded gap #2):** today controllers + copy row fields into view classes by hand. The wished-for form — + `class ProductCard @view { ... }` filled by + `from p in Product select p.name, p.price` — needs projection + queries; recorded, not worked around. + +## Judging the DX — what to look at + +1. `types.wo` — the entire persistence layer is 20 lines. +2. `orders.controller.wo` — the whole buying flow (validate, stock + check, decrement, durable insert, render) with no framework magic. +3. `product_list/view.wo` — is markup-as-code readable enough without + templates? This file is the referendum. +4. `main.wo` — the app at a glance: five routes, one middleware, serve. diff --git a/docs/examples/shop/assets/style.css b/docs/examples/shop/assets/style.css new file mode 100644 index 0000000..cdafc59 --- /dev/null +++ b/docs/examples/shop/assets/style.css @@ -0,0 +1,47 @@ +/* shop/assets/style.css — one real stylesheet, sectioned per feature. + Served by static_files/controller.wo; app_shell links it. This file is + the .scss stand-in: the language ships no preprocessor, so styles are + plain CSS kept OUT of the markup code. */ + +/* ---- layout (app.wo, header.wo, footer.wo) ---- */ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: system-ui, sans-serif; background: #f9fafb; color: #111827; line-height: 1.6; } +.site-header { display: flex; align-items: center; justify-content: space-between; + padding: .75rem 1.5rem; background: #fff; border-bottom: 1px solid #e5e7eb; + position: sticky; top: 0; } +.brand { font-size: 1.25rem; font-weight: 700; color: #111827; text-decoration: none; } +.site-nav a { margin-left: 1rem; color: #2563eb; text-decoration: none; } +.site-nav a:hover { text-decoration: underline; } +.site-main { max-width: 64rem; margin: 0 auto; padding: 2rem 1rem; } +.site-footer { border-top: 1px solid #e5e7eb; color: #6b7280; font-size: .875rem; + padding: 2rem 1rem; text-align: center; margin-top: 4rem; } +h1 { margin-bottom: 1rem; } + +/* ---- product_list ---- */ +.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; } +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } } +.card { background: #fff; border: 1px solid #e5e7eb; border-radius: .5rem; + padding: 1.5rem; box-shadow: 0 1px 2px rgba(0,0,0,.05); } +.card h3 { margin-bottom: .5rem; } +.card h3 a { color: #111827; text-decoration: none; } +.card h3 a:hover { color: #2563eb; } +.price { font-weight: 700; } +.stock { color: #6b7280; font-size: .875rem; } +.stock.out { color: #b91c1c; font-weight: 700; } + +/* ---- product_page ---- */ +.card.detail { max-width: 28rem; } +.card.detail form { margin-top: 1rem; display: flex; flex-direction: column; gap: .5rem; } +.card.detail label { font-size: .875rem; color: #374151; } +.field { display: block; width: 100%; border: 1px solid #e5e7eb; border-radius: .25rem; + padding: .5rem; font-size: .875rem; } +.btn { background: #2563eb; color: #fff; border: 0; border-radius: .25rem; + padding: .5rem 1rem; font-weight: 700; cursor: pointer; } +.btn:hover { background: #1d4ed8; } + +/* ---- orders ---- */ +table.orders { width: 100%; border-collapse: collapse; background: #fff; + border: 1px solid #e5e7eb; border-radius: .5rem; } +table.orders th, table.orders td { text-align: left; padding: .5rem .75rem; + border-bottom: 1px solid #e5e7eb; } +table.orders th { background: #f9fafb; font-size: .875rem; color: #374151; } diff --git a/docs/examples/shop/layout/app.wo b/docs/examples/shop/layout/app.wo new file mode 100644 index 0000000..7e2905b --- /dev/null +++ b/docs/examples/shop/layout/app.wo @@ -0,0 +1,34 @@ +-- layout/app.wo — the app shell (app.html's analog): one full document +-- wrapping every page with the shared header and footer. Styles are NOT +-- inlined here — the shop links /assets/style.css, a real stylesheet +-- served by the static_files controller, so view markup and styling +-- stay separate files exactly as the template promises. +use framework/http +use html + +pub fn app_shell(title: Text, content: Text) -> Text { + let d = ""; + d = d .. ""; + d = d .. "${esc(title)}"; + d = d .. ""; + d = d .. ""; + d = d .. header(); + d = d .. el("main", "site-main", content); + d = d .. footer(); + d = d .. ""; + return d; +} + +-- The one transport helper every controller shares: a 200 HTML Resp. +pub fn ok_html(body: Text) -> Resp { + let h: map = {}; + h["content-type"] = "text/html; charset=utf-8"; + return Resp { status: 200, headers: h, body: body }; +} + +pub fn html_error(status: Int, title: Text, msg: Text) -> Resp { + let content = el("h1", "", esc(title)) .. el("p", "", esc(msg)) .. el("p", "", link("/", "", "Back to products")); + let h: map = {}; + h["content-type"] = "text/html; charset=utf-8"; + return Resp { status: status, headers: h, body: app_shell("shop — ${title}", content) }; +} diff --git a/docs/examples/shop/layout/footer.wo b/docs/examples/shop/layout/footer.wo new file mode 100644 index 0000000..e840075 --- /dev/null +++ b/docs/examples/shop/layout/footer.wo @@ -0,0 +1,6 @@ +-- layout/footer.wo — the footer fragment (footer.html's analog). +use html + +pub fn footer() -> Text { + return el("footer", "site-footer", "writeonce shop — one binary: server, database, these pages."); +} diff --git a/docs/examples/shop/layout/header.wo b/docs/examples/shop/layout/header.wo new file mode 100644 index 0000000..3426d4a --- /dev/null +++ b/docs/examples/shop/layout/header.wo @@ -0,0 +1,8 @@ +-- layout/header.wo — the header fragment (header.html's analog). +use html + +pub fn header() -> Text { + let brand = link("/", "brand", "writeonce shop"); + let nav = link("/", "", "Products") .. link("/orders", "", "Orders"); + return el("header", "site-header", brand .. el("nav", "site-nav", nav)); +} diff --git a/docs/examples/shop/main.wo b/docs/examples/shop/main.wo new file mode 100644 index 0000000..af005d6 --- /dev/null +++ b/docs/examples/shop/main.wo @@ -0,0 +1,49 @@ +-- shop/main.wo — the bootstrap (app-routing.module's analog): seed the +-- store on first boot, wire routes to the feature controllers, serve. +-- No rendering and no queries here beyond the seed. +-- +-- WO_DATA=./data ./target/shop 8080 (run from the shop directory: +-- /assets/* serves from ./assets) +use framework +use framework/router +use product_list +use product_page +use orders +use static_files + +fn seed_if_empty() { + let n = 0; + for p in from x in Product take 1 select x { + n = n + 1; + } + if n > 0 { + return; + } + insert Product { sku: "keyb-75", name: "75% mechanical keyboard", price: 89.0, stock: 12 }; + insert Product { sku: "mug-wal", name: "WAL-backed coffee mug", price: 14.5, stock: 40 }; + insert Product { sku: "tee-own", name: "Ownership-checked t-shirt", price: 24.9, stock: 25 }; + insert Product { sku: "desk-pad", name: "Deskmat (one binary edition)", price: 19.0, stock: 0 }; +} + +fn main(args: multi Text) -> Int { + if len(args) < 1 { + print_err("usage: shop (WO_DATA= makes the store durable)"); + return 2; + } + let port = parse_int(args[0]); + if port == nil { + print_err("shop: must be a number"); + return 2; + } + + seed_if_empty(); + + let app = App { middleware: [], routes: [] }; + app.use_mw(Mw { m: Logging { pad: 0 } }); + app.get("/", ListProducts { pad: 0 }); + app.get("/p/:sku", ShowProduct { pad: 0 }); + app.post("/orders/:sku", CreateOrder { pad: 0 }); + app.get("/orders", ListOrders { pad: 0 }); + app.get("/assets/*path", StaticFiles { dir: "assets" }); + return app.serve("127.0.0.1", port); +} diff --git a/docs/examples/shop/orders.controller.wo b/docs/examples/shop/orders.controller.wo new file mode 100644 index 0000000..71cd0d5 --- /dev/null +++ b/docs/examples/shop/orders.controller.wo @@ -0,0 +1,55 @@ +-- orders.controller.wo — the buying flow: stock-checked order creation +-- (decrement + insert are each WAL-committed before they acknowledge) +-- and the orders list (ref navigation: o.product.name). +use framework/http +use layout +use orders +use time + +pub class CreateOrder { + pad: Int + fn handle(req: Req) -> Resp { + let sku = req.params["sku"]; + if sku == nil { + return html_error(404, "No such product", "The order names no product."); + } + let f = form_values(req); + if f == nil { + return html_error(400, "Bad order", "The form did not arrive form-encoded."); + } + let qraw = f["qty"]; + if qraw == nil { + return html_error(400, "Bad order", "How many? The qty field is missing."); + } + let qty = parse_int(trim("${qraw}")); + if qty == nil or qty < 1 { + return html_error(400, "Bad order", "qty must be a positive number."); + } + let hits = from p in Product where p.sku == sku take 1 select p; + if len(hits) == 0 { + return html_error(404, "No such product", "Nothing is listed under that sku."); + } + let p = hits[0]; + if p.stock < qty { + return html_error(409, "Not enough stock", "Only ${p.stock} left of ${p.name}."); + } + let total = p.price * float(qty); + p.stock = p.stock - qty; + insert Order { product: p, qty: qty, total: total, placed: time.now(), status: "placed" }; + let page = OrderOk { name: p.name, qty: qty, total: total }; + return ok_html(app_shell("shop — order placed", page.render())); + } +} + +pub class ListOrders { + pad: Int + fn handle(req: Req) -> Resp { + let rows = ""; + for o in from x in Order select x { + let row = OrderRow { name: o.product.name, qty: o.qty, total: o.total, status: o.status }; + rows = rows .. row.render(); + } + let page = OrdersPage { rows: rows }; + return ok_html(app_shell("shop — orders", page.render())); + } +} diff --git a/docs/examples/shop/orders/view.wo b/docs/examples/shop/orders/view.wo new file mode 100644 index 0000000..c7f60ba --- /dev/null +++ b/docs/examples/shop/orders/view.wo @@ -0,0 +1,44 @@ +-- orders/view.wo — the confirmation page and the orders table. +use html + +pub class OrderOk { + name: Text + qty: Int + total: Float + + fn render() -> Text { + let head = el("h1", "", "Order placed"); + let what = el("p", "", "${self.qty} × ${esc(self.name)} — total € ${self.total}"); + let links = el("p", "", link("/orders", "", "See all orders") .. " · " .. link("/", "", "Keep shopping")); + return el("div", "card", head .. what .. links); + } +} + +pub class OrderRow { + name: Text + qty: Int + total: Float + status: Text + + fn render() -> Text { + let cells = el("td", "", esc(self.name)); + cells = cells .. el("td", "", "${self.qty}"); + cells = cells .. el("td", "", "€ ${self.total}"); + cells = cells .. el("td", "", esc(self.status)); + return el("tr", "", cells); + } +} + +pub class OrdersPage { + rows: Text + + fn render() -> Text { + let head = el("h1", "", "Orders"); + if self.rows == "" { + return head .. el("p", "", "No orders yet — " .. link("/", "", "go buy something") .. "."); + } + let thead = el("tr", "", el("th", "", "Product") .. el("th", "", "Qty") .. el("th", "", "Total") .. el("th", "", "Status")); + let table = el("table", "orders", thead .. self.rows); + return head .. table; + } +} diff --git a/docs/examples/shop/product_list.controller.wo b/docs/examples/shop/product_list.controller.wo new file mode 100644 index 0000000..6035d08 --- /dev/null +++ b/docs/examples/shop/product_list.controller.wo @@ -0,0 +1,20 @@ +-- product_list.controller.wo — the CONTROLLER for /: query the model, +-- fill the view classes, answer a Resp. One controller file per feature; +-- they live in the ROOT module because the @tables do (see types.wo's +-- note on the pub+@table gap) — the views stay behind their module line. +use framework/http +use layout +use product_list + +pub class ListProducts { + pad: Int + fn handle(req: Req) -> Resp { + let cards = ""; + for p in from x in Product order by x.name select x { + let card = ProductCard { sku: p.sku, name: p.name, price: p.price, stock: p.stock }; + cards = cards .. card.render(); + } + let page = ProductListPage { cards: cards }; + return ok_html(app_shell("shop — products", page.render())); + } +} diff --git a/docs/examples/shop/product_list/view.wo b/docs/examples/shop/product_list/view.wo new file mode 100644 index 0000000..981406d --- /dev/null +++ b/docs/examples/shop/product_list/view.wo @@ -0,0 +1,33 @@ +-- product_list/view.wo — the VIEW (view.html's analog). Classes with +-- `fn render() -> Text`: fields are exactly the values displayed, the +-- markup reads top to bottom. No queries here — the controller fills +-- the fields. (A future `@view` class would let a projection query fill +-- them directly; today the controller copies the fields in.) +use html + +pub class ProductCard { + sku: Text + name: Text + price: Float + stock: Int + + fn render() -> Text { + let h = el("h3", "", link("/p/${esc(self.sku)}", "", esc(self.name))); + let price = el("p", "price", "€ ${self.price}"); + let stock = el("p", "stock", "${self.stock} in stock"); + if self.stock == 0 { + stock = el("p", "stock out", "sold out"); + } + return el("div", "card", h .. price .. stock); + } +} + +pub class ProductListPage { + cards: Text + + fn render() -> Text { + let head = el("h1", "", "Products"); + let grid = el("div", "grid", self.cards); + return head .. grid; + } +} diff --git a/docs/examples/shop/product_page.controller.wo b/docs/examples/shop/product_page.controller.wo new file mode 100644 index 0000000..8023481 --- /dev/null +++ b/docs/examples/shop/product_page.controller.wo @@ -0,0 +1,21 @@ +-- product_page.controller.wo — the CONTROLLER for /p/:sku. +use framework/http +use layout +use product_page + +pub class ShowProduct { + pad: Int + fn handle(req: Req) -> Resp { + let sku = req.params["sku"]; + if sku == nil { + return html_error(404, "No such product", "The address is missing a product."); + } + let hits = from p in Product where p.sku == sku take 1 select p; + if len(hits) == 0 { + return html_error(404, "No such product", "Nothing is listed under that sku."); + } + let p = hits[0]; + let page = ProductPage { sku: p.sku, name: p.name, price: p.price, stock: p.stock }; + return ok_html(app_shell("shop — ${p.name}", page.render())); + } +} diff --git a/docs/examples/shop/product_page/view.wo b/docs/examples/shop/product_page/view.wo new file mode 100644 index 0000000..2acbc04 --- /dev/null +++ b/docs/examples/shop/product_page/view.wo @@ -0,0 +1,25 @@ +-- product_page/view.wo — the product detail view with the order form. +use html + +pub class ProductPage { + sku: Text + name: Text + price: Float + stock: Int + + fn render() -> Text { + let head = el("h1", "", esc(self.name)); + let price = el("p", "price", "€ ${self.price}"); + let stock = el("p", "stock", "${self.stock} in stock"); + let form = ""; + if self.stock > 0 { + let qty = text_input("qty", "1"); + let buy = submit_btn("Buy"); + form = form_post("/orders/${esc(self.sku)}", el("label", "", "Quantity") .. qty .. buy); + } else { + form = el("p", "stock out", "sold out"); + } + let back = el("p", "", link("/", "", "← all products")); + return el("div", "card detail", head .. price .. stock .. form) .. back; + } +} diff --git a/docs/examples/shop/static_files/controller.wo b/docs/examples/shop/static_files/controller.wo new file mode 100644 index 0000000..5533d36 --- /dev/null +++ b/docs/examples/shop/static_files/controller.wo @@ -0,0 +1,33 @@ +-- static_files/controller.wo — serves /assets/* from disk. Traversal- +-- safe (any ".." answers 404, never touches the filesystem), extension- +-- mapped content types, 2 MiB cap per file. Text is binary-safe, so +-- images travel as-is. (Story 38 lifts this into the framework; until +-- then the template carries its own copy — it is ~40 lines.) +use framework/http +use fs + +pub class StaticFiles { + dir: Text + fn handle(req: Req) -> Resp { + let rel = req.params["path"]; + if rel == nil { + return not_found(); + } + if index_of("${rel}", "..") != -1 { + return not_found(); + } + let body = try fs.read_all("${self.dir}/${rel}", 2097152) catch (e) nil; + if body == nil { + return not_found(); + } + let ct = "application/octet-stream"; + if ends_with("${rel}", ".css") { ct = "text/css; charset=utf-8"; } + if ends_with("${rel}", ".js") { ct = "text/javascript"; } + if ends_with("${rel}", ".svg") { ct = "image/svg+xml"; } + if ends_with("${rel}", ".png") { ct = "image/png"; } + if ends_with("${rel}", ".webp") { ct = "image/webp"; } + let h: map = {}; + h["content-type"] = ct; + return Resp { status: 200, headers: h, body: "${body}" }; + } +} diff --git a/docs/examples/shop/types.wo b/docs/examples/shop/types.wo new file mode 100644 index 0000000..2dd41a3 --- /dev/null +++ b/docs/examples/shop/types.wo @@ -0,0 +1,29 @@ +-- 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 (handy while developing). 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. When the gap closes, this file becomes a +-- `types/` module and the controllers move into their feature folders. + +@table(name: "products", index: [sku]) +class Product { + sku: Text @unique + name: Text + price: Float + stock: Int + + orders: backlink Order.product +} + +@table(name: "orders", index: [product]) +class Order { + product: ref Product + qty: Int + total: Float + placed: Int -- epoch ms (time.now at purchase) + status: Text -- "placed" in v1; a fulfilment flow would grow this +} diff --git a/docs/examples/shop/wo.toml b/docs/examples/shop/wo.toml new file mode 100644 index 0000000..ee459bc --- /dev/null +++ b/docs/examples/shop/wo.toml @@ -0,0 +1,12 @@ +name = "shop" +version = "0.1.0" +description = "The writeonce program template: an MVC-separated shop — @table model, render() view classes, controller handlers, static assets" + +[runtime] +wo = ">= 0.1" + +# Two library dependencies, the site sample's proven shape. The [deps] +# KEY is the module name `use` imports. +[deps] +framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" } +html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" } diff --git a/docs/stories/language-runtime-database/refine/37-wo-html-components.md b/docs/stories/language-runtime-database/refine/37-wo-html-components.md index bd9ca68..db22229 100644 --- a/docs/stories/language-runtime-database/refine/37-wo-html-components.md +++ b/docs/stories/language-runtime-database/refine/37-wo-html-components.md @@ -15,6 +15,29 @@ status: refine > own dependency (the site sample's two-dep lesson). Unscheduled — > independent of the concurrency chain; needs its spec brainstormed > first. +> +> **REDIRECTED 2026-08-23** (developer review of the shop template +> against a Vue SFC): render()-as-string-concatenation failed the DX +> referendum — markup must be markup-FIRST. New direction: +> **`view.html` template files COMPILED BY `woc` into render code** — +> `{{ self.name }}` interpolation (auto-escaped; a raw opt-out spelling +> for prebuilt fragments), `w:if`/`w:for` structural attributes, typed +> against the view class's fields at compile time (a typo'd field is a +> compile error), NO template engine at runtime — the doctrine's real +> meaning becomes "no template interpreted at request time", not "no +> template files". A runtime mustache-lite was considered and REJECTED: +> reflection-free means values degrade to `map` — typing +> lost. Client-side reactivity from the Vue sample (`ref`, `@click`, +> `v-model`) stays out under the no-JS posture; qty steppers are form +> fields, actions are POSTs. This makes 37 a COMPILER iteration too +> (template-to-code lowering), gated behind the standing +> "no compiler/VM/database changes yet" directive — schedule +> accordingly. The shop template +> ([`docs/examples/shop`](../../../examples/shop/README.md)) is the +> consumer: its `view.wo` classes become `view.html` + view classes, +> and its README's recorded gaps ride along (gap #1: `pub` + `@table` +> cannot combine — blocks a shared model module; gap #2: `@view` +> projection classes). ## Why this iteration exists @@ -78,8 +101,9 @@ detection, event bindings, SPA router) deliberately does not. - Client-side anything: change detection, event/two-way bindings, SPA routing, hydration — the no-JS posture stands; interactivity is form round trips until a directive says otherwise. -- A template LANGUAGE (files parsed at build or runtime) — templates are - `.wo` code by doctrine (no closures also means no template lambdas). +- A RUNTIME template engine (files parsed per request, mustache-style) — + rejected 2026-08-23: reflection-free means untyped `map` + values. Templates compile to code at build time or they don't exist. - Dependency injection / services — components are data-in, Text-out. - Moving wo-html into the framework — settled 2026-08-23: separate libraries, composed via `[deps]`. @@ -88,21 +112,25 @@ detection, event bindings, SPA router) deliberately does not. ## Info -Forks the spec must settle: +Forks the spec must settle (REVISED 2026-08-23 for the compiled-template +direction): -1. **Interface shape** — `render() -> Text` alone, or `render(ctx) -> - Text` with a context record (e.g. the request's principal for - view-level decisions)? Leaning: bare `render()` — context smells like - DI; whatever the view needs arrives as a field. -2. **The framework seam** — does `ok_html(body)` move into the framework +1. **Template pairing** — Vue-SFC style (one `view.html` whose + frontmatter/`