feat(shop): the program template — MVC on disk; story 37 redirected
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
67cd039533
commit
89df517613
16 changed files with 527 additions and 14 deletions
69
docs/examples/shop/README.md
Normal file
69
docs/examples/shop/README.md
Normal file
|
|
@ -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.
|
||||
47
docs/examples/shop/assets/style.css
Normal file
47
docs/examples/shop/assets/style.css
Normal file
|
|
@ -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; }
|
||||
34
docs/examples/shop/layout/app.wo
Normal file
34
docs/examples/shop/layout/app.wo
Normal file
|
|
@ -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 = "<!doctype html><html><head><meta charset=\"utf-8\">";
|
||||
d = d .. "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">";
|
||||
d = d .. "<title>${esc(title)}</title>";
|
||||
d = d .. "<link rel=\"stylesheet\" href=\"/assets/style.css\">";
|
||||
d = d .. "</head><body>";
|
||||
d = d .. header();
|
||||
d = d .. el("main", "site-main", content);
|
||||
d = d .. footer();
|
||||
d = d .. "</body></html>";
|
||||
return d;
|
||||
}
|
||||
|
||||
-- The one transport helper every controller shares: a 200 HTML Resp.
|
||||
pub fn ok_html(body: Text) -> Resp {
|
||||
let h: map<Text, Text> = {};
|
||||
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<Text, Text> = {};
|
||||
h["content-type"] = "text/html; charset=utf-8";
|
||||
return Resp { status: status, headers: h, body: app_shell("shop — ${title}", content) };
|
||||
}
|
||||
6
docs/examples/shop/layout/footer.wo
Normal file
6
docs/examples/shop/layout/footer.wo
Normal file
|
|
@ -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.");
|
||||
}
|
||||
8
docs/examples/shop/layout/header.wo
Normal file
8
docs/examples/shop/layout/header.wo
Normal file
|
|
@ -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));
|
||||
}
|
||||
49
docs/examples/shop/main.wo
Normal file
49
docs/examples/shop/main.wo
Normal file
|
|
@ -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 <port> (WO_DATA=<dir> makes the store durable)");
|
||||
return 2;
|
||||
}
|
||||
let port = parse_int(args[0]);
|
||||
if port == nil {
|
||||
print_err("shop: <port> 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);
|
||||
}
|
||||
55
docs/examples/shop/orders.controller.wo
Normal file
55
docs/examples/shop/orders.controller.wo
Normal file
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
44
docs/examples/shop/orders/view.wo
Normal file
44
docs/examples/shop/orders/view.wo
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
20
docs/examples/shop/product_list.controller.wo
Normal file
20
docs/examples/shop/product_list.controller.wo
Normal file
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
33
docs/examples/shop/product_list/view.wo
Normal file
33
docs/examples/shop/product_list/view.wo
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
21
docs/examples/shop/product_page.controller.wo
Normal file
21
docs/examples/shop/product_page.controller.wo
Normal file
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
25
docs/examples/shop/product_page/view.wo
Normal file
25
docs/examples/shop/product_page/view.wo
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
33
docs/examples/shop/static_files/controller.wo
Normal file
33
docs/examples/shop/static_files/controller.wo
Normal file
|
|
@ -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<Text, Text> = {};
|
||||
h["content-type"] = ct;
|
||||
return Resp { status: 200, headers: h, body: "${body}" };
|
||||
}
|
||||
}
|
||||
29
docs/examples/shop/types.wo
Normal file
29
docs/examples/shop/types.wo
Normal file
|
|
@ -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
|
||||
}
|
||||
12
docs/examples/shop/wo.toml
Normal file
12
docs/examples/shop/wo.toml
Normal file
|
|
@ -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" }
|
||||
|
|
@ -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<Text, Text>` — 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<Text, Text>`
|
||||
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/`<script>` block declares the fields) vs paired files
|
||||
(`view.html` + a `.wo` view class it compiles against). Leaning:
|
||||
paired — the class stays ordinary `.wo`, the template is pure markup.
|
||||
2. **Directive surface** — the minimal set: `{{ expr }}` (auto-escaped),
|
||||
a raw spelling for prebuilt fragments (slots), `w:if`, `w:for`,
|
||||
`w:class`-style conditional classes. Anything beyond is YAGNI until a
|
||||
sample demands it.
|
||||
3. **Escaping default** — `{{ }}` escapes ALWAYS (safer than today's
|
||||
caller-explicit `esc()`); the raw spelling is the only door, and it
|
||||
is greppable.
|
||||
4. **The framework seam** — does `ok_html(body)` move into the framework
|
||||
beside `ok_text`/`ok_json` (transport, not rendering — flagged
|
||||
2026-08-23), and does wo-html gain `respond(c: Component)` sugar?
|
||||
3. **Slot typing** — slots as pre-rendered `Text` fields (simple, order
|
||||
forced by the parent) vs slots as `Component` fields (uniform, but
|
||||
ownership of nested components needs care under move semantics).
|
||||
4. **Migration depth** — site only, or web-app's HTML-less JSON pages
|
||||
deliberately untouched as the counter-example (a framework consumer
|
||||
that never links the view layer)?
|
||||
5. **Migration depth** — shop first (the template is its acceptance),
|
||||
site second; web-app stays HTML-less as the counter-example.
|
||||
|
||||
Study sources: Angular's component/`@Input`/`ng-content` docs (format
|
||||
only; no Angular code enters the repo — candidate `.dev/reference`
|
||||
|
|
|
|||
Loading…
Reference in a new issue