feat(lang+wo-html): raw text literals, component layer, MVC samples

- lexer: backtick raw text literal — content verbatim, no escape
  processing, common source margin removed at lex time; `${ }` raw and
  `{{ }}` auto-escaping holes
- `{{ e }}` desugars to `esc(${e})` in parser.ml — a Call on the `esc`
  in scope, so types/owner/emit/.wob/VM are untouched
- WO-E004 unterminated raw literal; WO-E005 newline inside "..." —
  closes a hole where a missing quote silently ate the rest of the file
- wo-html: `Component` interface, `render_all`, `Layout`, README
- framework: `ok_html` joins ok_text/ok_json in http/types.wo
- site + shop restructured to one-feature-one-module MVC (view +
  controller per directory, model at the root, bootstrap-only main)
- removed the filler `pad: Int` convention — verified unnecessary for
  plain classes, interface dispatch, containers and actors
- corrected recorded claims: gap #1 blocks neither the build nor the
  layout; a class crosses module lines, only a free fn is scoped
- docs/guides/language-surface.md — the full grammar inventory
- story 37 landed and moved to done/

Gates: oop-accept MET, oop-e2e 116/0, woc-test 556/0, site 11/0,
web-app 46/0, fibers 10/0, db-actor 8/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-25 03:58:41 +02:00
parent e2511fc461
commit f465ad5751
67 changed files with 2022 additions and 572 deletions

View file

@ -295,3 +295,62 @@ a keyword, and visibility is name resolution at compile time.
The `0x`/`0b` prefix commits only when a real base digit follows, so
`0xg` stays `Int 0` + `Ident` — a parse error at its own position, no
new lexer diagnostic. `_` separators are consumed only BETWEEN digits.
## The raw text literal (iteration 37)
Multi-line markup used to be impossible to write: a statement ends at a
newline, so a page was one `h = h .. "<...>"` statement per line, every
attribute single-quoted to dodge `\"`, and every piece of data wrapped
in a hand-written `esc()` call. Backtick literals replace all three.
Things worth knowing before editing them:
- **It is a LEXER form, not a node.** A backtick literal emits exactly
the `Token.Str` (no holes) or `Token.InterpStr` (holes) a `"..."`
string emits, so `types.ml`, `owner.ml`, `emit.ml`, the `.wob` format
and the VM are all untouched — nothing downstream can tell the two
spellings apart. That is the whole reason the feature is small. A
design that introduced a `Markup`/`Element` AST variant instead would
have had to teach five files about it.
- **No escape processing at all inside.** Quotes and backslashes are
content, which is the point. The cost is that the form cannot express
a literal backtick, a literal `${`, or a literal `{{` — those are
written by concatenating an ordinary `"..."` string with `..`. One
greppable door beats inventing an escape character for the one form
whose selling point is not having any. (`docs/examples/site/content.wo`
keeps two `code_block` samples as escaped `"..."` strings for exactly
this reason: they contain `\${`.)
- **The margin is stripped at LEX time**, so the constant pool holds the
dedented text and there is no runtime cost. Java's text-block rule:
one newline right after the opening backtick is dropped, the smallest
leading whitespace run across non-blank lines is removed from every
line, and a whitespace-only closing line loses its whitespace but
keeps its newline. A literal with no newline is left alone — eating
the leading spaces of `` ` hi` `` would be a surprise, not a service.
The measuring pass runs over a SHADOW string where each hole is one
non-whitespace sentinel byte, so ` {{ x }}` counts as indent 4 and
as a non-blank line.
- **`{{ e }}` desugars to `esc(${e})`, resolved by ordinary name
lookup.** `desugar_interp` in `parser.ml` builds a `Call` on an
`Ident "esc"` — precisely what a developer wrote by hand before. The
compiler learns nothing about HTML, `esc` stays wo-html's ordinary
`pub fn`, a typo'd field inside the hole is a normal name/type error,
and a locally defined `esc` shadows deliberately (a custom escaper is
a feature). `${ }` inside the same literal stays raw — that is the
greppable door for markup you built yourself. The one place the
desugar leaks: with no `esc` in scope the program fails on a name it
never typed, so `emit.ml`'s WO-E403 message carries a hint for that
one name.
- **`{{` is special ONLY inside a backtick literal.** Inside `"..."` it
is still two braces, so CSS and JS text in existing samples lexes
byte-identically.
- **WO-E005 closed a real hole.** The string scanner's catch-all used to
append a raw newline like any other byte, so a forgotten closing quote
silently swallowed the rest of the file with no diagnostic. Now the
scan stops at the newline WITHOUT consuming it — the `Newline` token
still terminates the statement, so recovery costs one line instead of
the file. The rt-parity silence for a plain unterminated string with
no newline is untouched, and `runner.ml` still pins it.
- **The `..` line continuation stays.** A line ending in `..` still
swallows its newline. Raw literals took over the multi-line-markup job
that motivated it, but it remains the general way to spread a long
concatenation over several lines and has its own corpus fixture.

View file

@ -34,6 +34,7 @@ let kind_label (k : Token.kind) : string =
let part_str = function
| Token.SText s -> Printf.sprintf "TEXT(%s)" s
| Token.SExpr s -> Printf.sprintf "EXPR(%s)" s
| Token.SEsc s -> Printf.sprintf "ESC(%s)" s
in
Printf.sprintf "INTERP_STR(%s)" (String.concat "," (List.map part_str segs))
| Token.KwType -> "KW_TYPE"

View file

@ -3258,8 +3258,20 @@ and emit_call (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : As
| None ->
if is_builtin_name name then emit_builtin p f v ~dst ?expected e name args
else begin
(* iteration 37: `{{ e }}` in a raw text literal desugars to
a call to `esc`, so a program using that hole without an
`esc` in scope lands here with a name it never typed.
The caret is already on the literal; this says why. *)
let hint =
if name = "esc" then
" (a `{{ ... }}` hole calls it -- add `use html`, or \
declare your own `fn esc(t: Text) -> Text`)"
else ""
in
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:(Printf.sprintf "call to `%s`, which is not a declared fn or a builtin" name);
~message:
(Printf.sprintf "call to `%s`, which is not a declared fn or a builtin%s"
name hint);
put f (ins_abx op_loadk dst (const_int p 0))
end)))
| Field (base, mname) -> (

View file

@ -55,6 +55,32 @@ let unknown_char_code = Diag.lexing_prefix ^ "01" (* WO-E001 *)
let unterminated_escape_code = Diag.lexing_prefix ^ "02" (* WO-E002 *)
let directive_code = Diag.lexing_prefix ^ "03" (* WO-E003: #if/#else/#end misuse *)
(* iteration 37, the raw text literal (backtick-delimited, verbatim
content, no backslash escapes). Two codes, because the two shapes
are genuinely different situations:
WO-E004 — a raw literal that runs off the end of the file. Unlike a
plain "..." string (silent, rt parity, see above), this one IS
reported: multi-line is the raw literal's normal case, so a missing
closing backtick would otherwise swallow every remaining line of the
file with nothing to show for it. Reported at the OPENING backtick,
which is the only position that helps -- EOF tells the reader
nothing about which literal never closed.
WO-E005 — a raw newline inside a "..." or '...' string. This used to
be accepted silently: the string scanner's catch-all appended the
newline like any other byte, so a forgotten closing quote ate the
rest of the file with no diagnostic at all. Nothing in the repo ever
relied on it (zero of the .wo sources span a line inside quotes) and
the backtick literal is now the spelling for multi-line text, so the
accident becomes an error. The scan stops at the newline WITHOUT
consuming it, so the Newline token is still emitted and the
statement terminates -- one diagnostic, and the next line parses
normally instead of being swallowed. The rt-parity silence for a
plain unterminated string with no newline is untouched. *)
let unterminated_raw_code = Diag.lexing_prefix ^ "04" (* WO-E004 *)
let newline_in_string_code = Diag.lexing_prefix ^ "05" (* WO-E005 *)
(* haxe-parity Task 8: build flags. `woc -D name` fills this before any
tokenize call; undefined flags are false. A module-level ref because the
compiler is a single-shot process — tests that care set it explicitly
@ -269,6 +295,126 @@ let preprocess (collector : Diag.Collector.t) ~(file : string)
go toks;
List.rev !out
(* ---- the raw literal's margin rule (iteration 37) -------------------
A render() body is written at its method's indentation, but that
indentation is an artifact of the SOURCE, not of the markup -- nobody
wants six leading spaces on every line of the served HTML. So the
common margin is removed here, at lex time: the constant pool holds
the dedented text, no downstream stage ever sees the source
indentation, and the whole rule costs nothing at run time.
The rule (Java's text blocks, which solved exactly this):
- one newline immediately after the opening backtick is dropped,
so the first markup line can start on its own line;
- the smallest leading run of spaces/tabs across all non-blank
lines is removed from every line (characters counted, tabs NOT
expanded -- mixing them is the author's problem, and expanding
would need a tab width the language does not have);
- a whitespace-only final line (the usual case: the closing
backtick sits on its own line) loses its whitespace but keeps
its newline.
A literal with no newline in it is left completely alone -- there is
no margin to speak of, and silently eating the leading spaces of
` hi` would be a surprise, not a service.
Holes do not disturb any of this. A line's indentation is by
definition the run of whitespace at its start, and the only thing
that can split a line across segments is a hole, which ends that run
-- so an indentation run always lives whole inside one SText. The
measuring pass replaces each hole with a single non-whitespace
sentinel byte so that a line that is ` {{ x }}` correctly counts
as indent 4 and as NON-blank. *)
let is_indent_char c = c = ' ' || c = '\t'
let segments_shadow (segs : Token.str_part list) : string =
let b = Buffer.create 64 in
List.iter
(function
| Token.SText s -> Buffer.add_string b s
| Token.SExpr _ | Token.SEsc _ -> Buffer.add_char b '\001')
segs;
Buffer.contents b
let min_indent (shadow : string) : int =
let m = ref max_int in
List.iter
(fun line ->
let n = String.length line in
let i = ref 0 in
while !i < n && is_indent_char line.[!i] do
incr i
done;
(* a blank (or whitespace-only) line never sets the margin *)
if !i < n && !i < !m then m := !i)
(String.split_on_char '\n' shadow);
if !m = max_int then 0 else !m
let strip_margin (k : int) (segs : Token.str_part list) : Token.str_part list =
if k = 0 then segs
else begin
let at_line_start = ref true in
let one seg =
match seg with
| Token.SExpr _ | Token.SEsc _ ->
at_line_start := false;
seg
| Token.SText s ->
let n = String.length s in
let b = Buffer.create n in
let i = ref 0 in
while !i < n do
if !at_line_start then begin
let dropped = ref 0 in
while !dropped < k && !i < n && is_indent_char s.[!i] do
incr dropped;
incr i
done;
at_line_start := false
end
else begin
let c = s.[!i] in
Buffer.add_char b c;
if c = '\n' then at_line_start := true;
incr i
end
done;
Token.SText (Buffer.contents b)
in
(* fold_left, not List.map: `one` carries state across segments and
List.map's application order is unspecified. *)
List.rev (List.fold_left (fun acc seg -> one seg :: acc) [] segs)
end
let drop_trailing_margin (segs : Token.str_part list) : Token.str_part list =
match List.rev segs with
| Token.SText s :: rest_rev ->
let n = String.length s in
let i = ref n in
while !i > 0 && is_indent_char s.[!i - 1] do
decr i
done;
(* only a run that directly follows a newline is a closing line *)
if !i < n && !i > 0 && s.[!i - 1] = '\n' then
List.rev (Token.SText (String.sub s 0 !i) :: rest_rev)
else segs
| _ -> segs
let dedent (segs : Token.str_part list) : Token.str_part list =
let shadow = segments_shadow segs in
if not (String.contains shadow '\n') then segs
else begin
let segs =
match segs with
| Token.SText s :: rest when String.length s > 0 && s.[0] = '\n' ->
Token.SText (String.sub s 1 (String.length s - 1)) :: rest
| _ -> segs
in
let k = min_indent (segments_shadow segs) in
drop_trailing_margin (strip_margin k segs)
end
let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
Token.t list =
let lx = make src in
@ -279,6 +425,14 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
| { Token.kind = Token.Newline; _ } :: _ -> true
| _ -> false
in
(* A line ending in `..` continues on the next line — the ONE newline
suppression in the language, so multi-line markup/text builds read
as one expression (the shop template's ask; story 37 rides it). *)
let last_is_dotdot () =
match !out with
| { Token.kind = Token.DotDot; _ } :: _ -> true
| _ -> false
in
let report_unknown line col c =
Diag.Collector.add collector
(Diag.error ~code:unknown_char_code ~file ~line ~col
@ -289,6 +443,18 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
(Diag.error ~code:unterminated_escape_code ~file ~line ~col
~message:"unterminated string escape" ())
in
let report_unterminated_raw line col =
Diag.Collector.add collector
(Diag.error ~code:unterminated_raw_code ~file ~line ~col
~message:"unterminated raw text literal" ())
in
let report_newline_in_string line col =
Diag.Collector.add collector
(Diag.error ~code:newline_in_string_code ~file ~line ~col
~message:
"newline in string literal (use a `...` raw text literal for \
multi-line text)" ())
in
let running = ref true in
while !running do
match peek lx with
@ -307,7 +473,8 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
end
else if c = '\n' then begin
ignore (advance lx);
if not (last_is_newline ()) then emit Token.Newline line col
if not (last_is_newline ()) && not (last_is_dotdot ()) then
emit Token.Newline line col
end
else if c = ' ' || c = '\t' || c = '\r' then ignore (advance lx)
else if c = '"' || c = '\'' then begin
@ -374,6 +541,13 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
| None ->
report_unterminated_escape esc_line esc_col;
scanning := false)
| Some '\n' ->
(* WO-E005. Deliberately NOT consumed: the outer loop turns
it into the Newline token that terminates the statement,
so recovery is one bad line rather than the rest of the
file. *)
report_newline_in_string lx.line lx.col;
scanning := false
| Some other ->
ignore (advance lx);
Buffer.add_char buf other
@ -384,6 +558,64 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
| [ Token.SText s ] -> emit (Token.Str s) line col
| segs -> emit (Token.InterpStr segs) line col)
end
else if c = '`' then begin
(* iteration 37: the raw text literal. Everything up to the
closing backtick is content -- newlines included, and with NO
escape processing at all, which is the whole point: markup
carries quotes and backslashes verbatim. A literal backtick
(or a literal `{{`) is written by concatenating an ordinary
"..." string with `..`; that door is one greppable operator,
which beats inventing an escape character for the one form
whose selling point is not having any.
Two hole forms, and ONLY here -- inside "..." a `{{` is still
two literal braces, so existing CSS/JS text is untouched:
${ expr } raw, exactly like a "..." string's hole
{{ expr }} HTML-escaped (the parser wraps it in esc()) *)
ignore (advance lx);
let buf = Buffer.create 64 in
let parts = ref [] in
let flush_text () =
parts := Token.SText (Buffer.contents buf) :: !parts;
Buffer.clear buf
in
let scanning = ref true in
while !scanning do
match peek lx with
| None ->
report_unterminated_raw line col;
scanning := false
| Some '`' ->
ignore (advance lx);
scanning := false
| Some '$' when peek_at lx 1 = Some '{' ->
flush_text ();
ignore (advance lx);
ignore (advance lx);
parts := Token.SExpr (read_interp_expr lx) :: !parts
| Some '{' when peek_at lx 1 = Some '{' ->
flush_text ();
ignore (advance lx);
ignore (advance lx);
(* read_interp_expr stops at the first `}` at depth 0 and
consumes it -- the second one closes this hole. Reusing it
means brace depth and nested string literals are already
handled, so `{{ Point{x:1}.x }}` scans correctly. *)
let raw = read_interp_expr lx in
(match peek lx with
| Some '}' -> ignore (advance lx)
| _ -> report_unterminated_raw line col);
parts := Token.SEsc raw :: !parts
| Some other ->
ignore (advance lx);
Buffer.add_char buf other
done;
flush_text ();
(match dedent (List.rev !parts) with
| [] -> emit (Token.Str "") line col
| [ Token.SText s ] -> emit (Token.Str s) line col
| segs -> emit (Token.InterpStr segs) line col)
end
else if is_digit c then begin
(* iteration 19: one scanner for both numeric worlds. The integer run
is scanned into a buffer as well as accumulated, because a fraction

View file

@ -1332,6 +1332,19 @@ and parse_primary (st : state) : Ast.expr =
and desugar_interp (st : state) (pos : Ast.pos) (segs : Token.str_part list) : Ast.expr =
let mk_str s = { Ast.id = fresh_id st; pos; kind = Ast.StrLit s } in
let mk_interp inner = { Ast.id = fresh_id st; pos; kind = Ast.Interp inner } in
(* iteration 37: `{{ e }}` in a raw text literal IS `esc(${e})` -- the
desugar builds exactly the call a developer writes by hand today
(docs/examples/shop/**/view.wo used `${esc(...)}` throughout), so
every later stage sees only Call/Interp/StrLit/Concat nodes it
already handles. No new AST variant, no new builtin, no VM change,
and a typo'd field inside the hole is an ordinary name/type error.
`esc` resolves by ordinary lookup (wo-html's `pub fn esc`, in scope
after `use html`); a locally defined `esc` shadows it deliberately
-- a custom escaper is a feature, not a collision. *)
let mk_esc inner =
let callee = { Ast.id = fresh_id st; pos; kind = Ast.Ident "esc" } in
{ Ast.id = fresh_id st; pos; kind = Ast.Call (callee, [ mk_interp inner ]) }
in
let parse_segment_expr (raw : string) : Ast.expr =
let sub_collector = Diag.Collector.create () in
let sub_toks = Lexer.tokenize sub_collector ~file:st.file raw in
@ -1362,7 +1375,8 @@ and desugar_interp (st : state) (pos : Ast.pos) (segs : Token.str_part list) : A
(function
| Token.SText "" -> None
| Token.SText s -> Some (mk_str s)
| Token.SExpr raw -> Some (mk_interp (parse_segment_expr raw)))
| Token.SExpr raw -> Some (mk_interp (parse_segment_expr raw))
| Token.SEsc raw -> Some (mk_esc (parse_segment_expr raw)))
segs
in
match parts with

View file

@ -23,6 +23,13 @@
type str_part =
| SText of string (* literal text, escapes already applied *)
| SExpr of string (* raw, unlexed source of one `${...}`'s body *)
(* iteration 37: the escaping half of the raw text literal. Same raw,
unlexed payload as SExpr -- what differs is only what the parser
wraps it in: `${...}` desugars to a bare Interp, `{{...}}` to an
`esc(Interp ...)` call. Produced ONLY by a backtick raw literal;
inside a "..." string `{{` stays two literal braces, so CSS and JS
text in existing samples lexes byte-identically. *)
| SEsc of string (* raw, unlexed source of one `{{...}}`'s body *)
type kind =
(* literals *)

View file

@ -0,0 +1,2 @@
1:1 METHOD page(name: Text) -> Text
2:3 RETURN "<p>" .. INTERP(name) .. esc(INTERP(name)) .. "</p>"

View file

@ -0,0 +1,3 @@
fn page(name: Text) -> Text {
return `<p>${name}{{ name }}</p>`
}

View file

@ -0,0 +1,42 @@
1:71 NEWLINE
4:1 KW_FN
4:4 IDENT(page)
4:8 LPAREN
4:9 IDENT(name)
4:13 COLON
4:15 IDENT(Text)
4:19 RPAREN
4:21 ARROW
4:24 IDENT(Text)
4:29 LBRACE
4:30 NEWLINE
5:3 KW_LET
5:7 IDENT(one)
5:11 EQ
5:13 STR(<div>hi</div>)
5:28 NEWLINE
6:3 KW_LET
6:7 IDENT(verbatim)
6:16 EQ
6:18 STR(a"b\n)
6:25 NEWLINE
7:3 KW_LET
7:7 IDENT(block)
7:13 EQ
7:15 STR(<div class="card">
many
line
</div>
)
12:4 NEWLINE
13:3 KW_LET
13:7 IDENT(holes)
13:13 EQ
13:15 INTERP_STR(TEXT(<p>),EXPR(name),TEXT(),ESC( name ),TEXT(</p>))
13:41 NEWLINE
14:3 KW_RETURN
14:10 IDENT(block)
14:15 NEWLINE
15:1 RBRACE
15:2 NEWLINE
16:1 EOF

View file

@ -0,0 +1,15 @@
-- iteration 37: the backtick raw text literal. One token per literal,
-- content verbatim (no escape processing), common margin removed at
-- lex time, two hole forms -- ${} raw and {{}} escaped.
fn page(name: Text) -> Text {
let one = `<div>hi</div>`
let verbatim = `a"b\n`
let block = `
<div class="card">
many
line
</div>
`
let holes = `<p>${name}{{ name }}</p>`
return block
}

View file

@ -236,6 +236,128 @@ let () =
check "dash-continuation: a - b (spaced) lexes as Ident, Dash, Ident"
(kinds = [ Token.Ident "a"; Token.Dash; Token.Ident "b"; Token.Eof ])
(* ---- raw text literal, iteration 37 (not golden-diffed) ------------
golden/tokens/raw-literal.wo pins the token STREAM; these pin the
pieces a dump cannot show: that a backtick literal with no holes is
byte-identical to the Str a "..." string would have produced, that
the common margin is removed at LEX time (so no runtime cost and no
downstream stage ever sees the source indentation), and that the two
new diagnostics fire at the right position. *)
let () =
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"raw.wo" "let t = `<div>hi</div>`" in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "raw literal: no holes lexes as a plain Str"
(kinds
= [ Token.KwLet; Token.Ident "t"; Token.Eq; Token.Str "<div>hi</div>"; Token.Eof ]);
check_eq "raw literal: reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector))
string_of_int
let () =
(* Nothing between the backticks is escape-processed: a quote is a
quote and a backslash-n is two characters, which is the whole
point of the form: markup without quote-escape noise. *)
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"raw.wo" "`a\"b\\n`" in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "raw literal: content is verbatim, no escape processing"
(kinds = [ Token.Str "a\"b\\n"; Token.Eof ])
let () =
(* The margin case, written the way a render() body actually is:
opening newline dropped, the 4-space common margin removed from
every line, the whitespace-only closing line reduced to nothing
while its newline survives (Java text-block behavior). *)
let src = "let t = `\n <div>\n many\n </div>\n `" in
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"raw.wo" src in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "raw literal: common margin stripped, leading newline dropped"
(kinds
= [
Token.KwLet;
Token.Ident "t";
Token.Eq;
Token.Str "<div>\n many\n</div>\n";
Token.Eof;
])
let () =
(* Both hole forms in one literal. The payloads are raw and unlexed,
exactly as SExpr has always carried `${...}` -- the parser is what
tells them apart (SEsc gains the esc() wrapper). *)
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"raw.wo" "`<p>${a}{{ b }}</p>`" in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "raw literal: ${} stays raw, {{}} becomes SEsc"
(kinds
= [
Token.InterpStr
[
Token.SText "<p>";
Token.SExpr "a";
(* the empty run between two adjacent holes, exactly as a
"..." string has always produced it -- the parser drops
empty SText segments in desugar_interp *)
Token.SText "";
Token.SEsc " b ";
Token.SText "</p>";
];
Token.Eof;
])
let () =
(* Unlike a plain "..." string, an unterminated raw literal is an
error: multi-line is its normal case, so silently swallowing the
rest of the file would be a footgun, not rt parity. *)
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"raw.wo" "let t = `abc" in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "unterminated raw literal: closes with what was collected"
(kinds = [ Token.KwLet; Token.Ident "t"; Token.Eq; Token.Str "abc"; Token.Eof ]);
let diags = Diag.Collector.diagnostics collector in
check_eq "unterminated raw literal: exactly one diagnostic reported" ~expected:1
~actual:(List.length diags) string_of_int;
match diags with
| [ d ] ->
check "unterminated raw literal: WO-E004 at the backtick (line 1, col 9)"
(d.code = "WO-E004" && d.site.line = 1 && d.site.col = 9)
| _ -> check "unterminated raw literal: diagnostic shape" false
let () =
(* A raw newline inside "..." used to be accepted silently (the
scanner's catch-all appended it like any other byte), which meant a
forgotten closing quote ate the rest of the file with no
diagnostic. Now that the backtick literal is the blessed spelling
for multi-line text, that newline is an error and the scan stops
WITHOUT consuming it, so the Newline token still terminates the
statement and the next line parses normally. *)
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"nl.wo" "let t = \"ab\ncd" in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "newline in string: scan stops at the newline, which still tokenizes"
(kinds
= [
Token.KwLet;
Token.Ident "t";
Token.Eq;
Token.Str "ab";
Token.Newline;
Token.Ident "cd";
Token.Eof;
]);
let diags = Diag.Collector.diagnostics collector in
check_eq "newline in string: exactly one diagnostic reported" ~expected:1
~actual:(List.length diags) string_of_int;
match diags with
| [ d ] ->
check "newline in string: WO-E005 at the newline (line 1, col 12)"
(d.code = "WO-E005" && d.site.line = 1 && d.site.col = 12)
| _ -> check "newline in string: diagnostic shape" false
(* ---- direct parser/AST assertions (Task 4, not golden-diffed) ------------
golden/ast/*.wo fixtures already pin the AST *shape* via --dump-ast,

View file

@ -21,7 +21,6 @@ class Job {
-- round-robin, so with two writers at default shards one lands off the
-- primary — the RPC path under test.
class Writer {
pad: Int
fn receive(msg: Job) {
insert Note { tag: "w", val: msg.n };
let total = 0;
@ -33,8 +32,8 @@ class Writer {
}
fn main() -> Int {
let a: actor Job = spawn Writer { pad: 0 };
let b: actor Job = spawn Writer { pad: 1 };
let a: actor Job = spawn Writer {};
let b: actor Job = spawn Writer {};
send(a, Job { n: 1 });
send(b, Job { n: 2 });
-- no request/response surface yet (iteration 31): poll until both rows

View file

@ -31,7 +31,6 @@ class Counter {
-- Part 2: an actor whose receive PARKS mid-message. The park releases
-- the shard: main keeps ticking while this fiber sleeps.
class Sleeper {
pad: Int
fn receive(msg: Tick) {
print("sleeper: down for ${msg.n}ms");
time.sleep(msg.n);
@ -61,7 +60,7 @@ fn main() -> Int {
print("part1 done");
-- ---- part 2: a parked fiber blocks nobody ----
let s: actor Tick = spawn Sleeper { pad: 0 };
let s: actor Tick = spawn Sleeper {};
send(s, Tick { n: 150 });
let t = 0;
while t < 8 {

View file

@ -17,6 +17,49 @@ 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 template builds and runs as written — the two `[deps]` resolve, the
seed lands, and every route answers.
## The view form
A `render()` body is one backtick raw text literal. Markup is markup:
real newlines, real double-quoted attributes, and the method's source
indentation removed at compile time, so the served bytes carry the
markup's own nesting and not the code's.
```
fn render() -> Text {
return `
<div class="card">
<h3><a href="/p/{{ self.sku }}">{{ self.name }}</a></h3>
<p class="price">€ ${self.price}</p>
${stock}
</div>`;
}
```
Every `render()` makes its class a **component** — wo-html's structural
`Component` interface, satisfied by having the method, never declared.
Parent components hold children directly (`cards: multi Component`) and
render them with `render_all`, so `ProductListPage` knows nothing about
`ProductCard` beyond `render()`. The document itself is a component too:
`AppShell { title, content }` in `layout/app.wo`, which links a real
stylesheet rather than inlining one — that is why it is its own shell and
not wo-html's `Layout`.
Two holes, and the difference is the whole escaping story:
- `{{ expr }}` **HTML-escapes** — it compiles to a call to the `esc` in
scope (wo-html's, unless the app declares its own). Display data goes
here; a typo'd field is a compile error, not a broken page.
- `${ expr }` is **raw** — for markup you built yourself, like the
`${stock}` fragment above or `${content}` in the app shell.
Nothing is parsed at request time. The literal is a compile-time form:
it produces exactly the string constant and concatenation chain the old
hand-written version did, so there is no template engine to ship, warm
up, or sandbox.
## The file map (Angular equivalents)
| this template | concern | Angular analog |
@ -25,42 +68,53 @@ it and restart: the orders are still there (WAL replay).
| `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 |
| `product_list/controller.wo` | CONTROLLER — query the model, fill the view, answer a `Resp`; beside its view in the same module | component `.ts` + service |
| `product_page/`, `orders/` | one module per feature: `view.wo` + `controller.wo` | 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 |
| the `render()` bodies | markup-first TODAY: one HTML line per `h = h ..` statement, `${}` holes; story 37 collapses each body to ONE raw literal with `{{ }}` auto-escaped typed holes | Vue `<template>` (in-SFC) |
| the `render()` bodies | ONE raw text literal each: real newlines, real double-quoted attributes, source indentation removed at compile time, `${}` raw holes and `{{ }}` auto-escaping ones | Vue `<template>` (in-SFC) |
| `layout/app.wo` → `AppShell` | the document, as a two-slot component | `app.component.html` |
| `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.
Separation is compiler-enforced: one feature = one directory = one
module, holding that feature's view AND its controller. A module sees
only its own declarations plus what it `use`s, so a controller reaching
into another feature has to say so. The `@table` classes sit in
`types.wo` at the root and are reachable from every feature module
without export — see gap #1 below for why that is not the contradiction
it looks like.
## What is deliberately different (doctrine)
- **Templates compile or they don't exist (story 37).** The views are
markup-first within today's language: one HTML line per `h = h ..`
statement (single-quoted attributes — no escape noise), `${}` holes,
data through `esc()`. Story 37's raw template literal removes exactly
the two taxes you can see: the `h = h ..` prefix on every line (the
language has NO multi-line expression or literal — recorded gap #3,
proven while writing this file) and the explicit `esc()` calls
(`{{ }}` auto-escapes and type-checks; `{!! !!}` is the raw slot).
Same markup, less ceremony. NO template engine runs at request time —
ever. 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.
- **Templates compile or they don't exist (story 37).** See *The view
form* above: the markup is a compile-time literal, never a file
parsed per request. Gap #3 ("the language has NO multi-line
expression or literal", recorded while writing this template) is
CLOSED — the raw literal landed with story 37's compiler slice, and
this directory was its consumer. 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()`
(wo-html's `Component`); a controller is a class satisfying `Handler`.
Capture = a field.
- **The MVC seam is enforced by where the query sits.** Controllers hold
every `from … select`; a view receives VALUES — for the list page, a
`multi Component` of already-filled cards. No view in this template
touches the database, and wo-html contains no query at all.
- **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.
- **`pub` + `@table` cannot combine (recorded gap #1) — and it does not
matter.** `pub` in front of an annotated class is a parse error
(`WO-E101`), but no `@table` needs it: a CLASS is reachable across
module lines without being exported. Verified 2026-08-25 by building
and running all three shapes — a feature module querying a root
`@table`, a feature module using a root plain class, and an `@table`
declared inside a `types/` module and queried from the root. What IS
module-scoped is a free `fn` (`WO-E210`), so a query shared by two
features belongs on a class as a `static fn`. An earlier revision of
this file claimed the gap forced controllers into the root module and
made a `types/` module impossible; both were wrong, and the layout
below is what the language actually allows.
- **`@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
@ -72,7 +126,7 @@ sit in the root module beside `types.wo`, because of gap #1 below.
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. `orders/view.wo` — the referendum: is HTML-per-line with `${}`
holes readable enough today, and is 37's delta (delete every
`h = h ..` prefix and `esc()` call) worth a compiler slice?
3. `orders/view.wo` — the referendum, now answered: three render()
bodies, each one literal, no concatenation and no `esc()` calls, and
`OrdersPage` holding its rows as child components.
4. `main.wo` — the app at a glance: five routes, one middleware, serve.

View file

@ -1,5 +1,5 @@
/* shop/assets/style.css — one real stylesheet, sectioned per feature.
Served by static_files/controller.wo; app_shell links it. This file is
Served by static_files/controller.wo; AppShell 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. */

View file

@ -5,32 +5,46 @@
use framework/http
use html
pub fn app_shell(title: Text, content: Text) -> Text {
let h = "<!doctype html><html><head>";
h = h .. "<meta charset='utf-8'>";
h = h .. "<meta name='viewport' content='width=device-width,initial-scale=1'>";
h = h .. "<title>${esc(title)}</title>";
h = h .. "<link rel='stylesheet' href='/assets/style.css'>";
h = h .. "</head><body>";
h = h .. header();
h = h .. "<main class='site-main'>${content}</main>";
h = h .. footer();
h = h .. "</body></html>";
return h;
-- The app shell as a COMPONENT (wo-html's `Component`: fields in, Text
-- out), the same shape as wo-html's own `Layout` — two slots instead of
-- four, and its own document rather than `page()`'s, because this
-- template deliberately LINKS a stylesheet instead of inlining one.
-- That is the whole reason it is not just `Layout`: markup and styling
-- stay separate files here.
pub class AppShell {
title: Text
content: Text
fn render() -> Text {
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ self.title }}</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
${header()}
<main class="site-main">${self.content}</main>
${footer()}
</body>
</html>`;
}
}
-- 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 };
}
-- `ok_html` is the FRAMEWORK's (http/types.wo, beside ok_text/ok_json):
-- a status line and a content-type is transport, not rendering, and both
-- HTML samples had hand-rolled the identical four lines.
pub fn html_error(status: Int, title: Text, msg: Text) -> Resp {
let c = "<h1>${esc(title)}</h1>";
c = c .. "<p>${esc(msg)}</p>";
c = c .. "<p><a href='/'>Back to products</a></p>";
let content = `
<h1>{{ title }}</h1>
<p>{{ msg }}</p>
<p><a href="/">Back to products</a></p>`;
let shell = AppShell { title: "shop — ${title}", content: content };
let h: map<Text, Text> = {};
h["content-type"] = "text/html; charset=utf-8";
return Resp { status: status, headers: h, body: app_shell("shop — ${title}", c) };
return Resp { status: status, headers: h, body: shell.render() };
}

View file

@ -1,4 +1,4 @@
-- layout/footer.wo — the footer fragment (footer.html's analog).
pub fn footer() -> Text {
return "<footer class='site-footer'>writeonce shop — one binary: server, database, these pages.</footer>";
return `<footer class="site-footer">writeonce shop — one binary: server, database, these pages.</footer>`;
}

View file

@ -1,8 +1,8 @@
-- layout/header.wo — the header fragment (header.html's analog).
pub fn header() -> Text {
let h = "<header class='site-header'>";
h = h .. "<a href='/' class='brand'>writeonce shop</a>";
h = h .. "<nav class='site-nav'><a href='/'>Products</a> <a href='/orders'>Orders</a></nav>";
h = h .. "</header>";
return h;
return `
<header class="site-header">
<a href="/" class="brand">writeonce shop</a>
<nav class="site-nav"><a href="/">Products</a> <a href="/orders">Orders</a></nav>
</header>`;
}

View file

@ -39,11 +39,11 @@ fn main(args: multi Text) -> Int {
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.use_mw(Mw { m: Logging {} });
app.get("/", ListProducts {});
app.get("/p/:sku", ShowProduct {});
app.post("/orders/:sku", CreateOrder {});
app.get("/orders", ListOrders {});
app.get("/assets/*path", StaticFiles { dir: "assets" });
return app.serve("127.0.0.1", port);
}

View file

@ -1,13 +1,11 @@
-- orders.controller.wo — the buying flow: stock-checked order creation
-- 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 {
@ -37,19 +35,19 @@ pub class CreateOrder {
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()));
let shell = AppShell { title: "shop — order placed", content: page.render() };
return ok_html(shell.render());
}
}
pub class ListOrders {
pad: Int
fn handle(req: Req) -> Resp {
let rows = "";
let rows: multi Component = [];
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();
push(rows, OrderRow { name: o.product.name, qty: o.qty, total: o.total, status: o.status });
}
let page = OrdersPage { rows: rows };
return ok_html(app_shell("shop — orders", page.render()));
let shell = AppShell { title: "shop — orders", content: page.render() };
return ok_html(shell.render());
}
}

View file

@ -1,6 +1,6 @@
-- orders/view.wo — the confirmation page and the orders table.
-- Markup-first, one HTML line per statement; see product_list/view.wo
-- for the convention and the story-37 endgame.
-- Raw text literals for the bodies; see product_list/view.wo for the
-- convention.
use html
pub class OrderOk {
@ -9,12 +9,12 @@ pub class OrderOk {
total: Float
fn render() -> Text {
let h = "<div class='card'>";
h = h .. "<h1>Order placed</h1>";
h = h .. "<p>${self.qty} × ${esc(self.name)} — total € ${self.total}</p>";
h = h .. "<p><a href='/orders'>See all orders</a> · <a href='/'>Keep shopping</a></p>";
h = h .. "</div>";
return h;
return `
<div class="card">
<h1>Order placed</h1>
<p>${self.qty} × {{ self.name }} — total € ${self.total}</p>
<p><a href="/orders">See all orders</a> · <a href="/">Keep shopping</a></p>
</div>`;
}
}
@ -25,29 +25,30 @@ pub class OrderRow {
status: Text
fn render() -> Text {
let h = "<tr>";
h = h .. "<td>${esc(self.name)}</td>";
h = h .. "<td>${self.qty}</td>";
h = h .. "<td>€ ${self.total}</td>";
h = h .. "<td>${esc(self.status)}</td>";
h = h .. "</tr>";
return h;
return `
<tr>
<td>{{ self.name }}</td>
<td>${self.qty}</td>
<td>€ ${self.total}</td>
<td>{{ self.status }}</td>
</tr>`;
}
}
pub class OrdersPage {
rows: Text
rows: multi Component
fn render() -> Text {
let h = "<h1>Orders</h1>";
if self.rows == "" {
h = h .. "<p>No orders yet — <a href='/'>go buy something</a>.</p>";
return h;
if len(self.rows) == 0 {
return `
<h1>Orders</h1>
<p>No orders yet — <a href="/">go buy something</a>.</p>`;
}
h = h .. "<table class='orders'>";
h = h .. "<tr><th>Product</th><th>Qty</th><th>Total</th><th>Status</th></tr>";
h = h .. "${self.rows}";
h = h .. "</table>";
return h;
return `
<h1>Orders</h1>
<table class="orders">
<tr><th>Product</th><th>Qty</th><th>Total</th><th>Status</th></tr>
${render_all(self.rows)}
</table>`;
}
}

View file

@ -1,20 +0,0 @@
-- 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()));
}
}

View file

@ -0,0 +1,21 @@
-- product_list/controller.wo — the CONTROLLER for /: query the model,
-- fill the view components beside it, answer a Resp. One feature = one
-- directory = one module, view and controller together. The @tables it
-- queries live in the root module, which is fine: a CLASS is reachable
-- across module lines (only free `fn`s are module-scoped).
use framework/http
use layout
pub class ListProducts {
fn handle(req: Req) -> Resp {
-- The MVC seam: the query is HERE, and what crosses into the view is
-- a list of child components, never a cursor.
let cards: multi Component = [];
for p in from x in Product order by x.name select x {
push(cards, ProductCard { sku: p.sku, name: p.name, price: p.price, stock: p.stock });
}
let page = ProductListPage { cards: cards };
let shell = AppShell { title: "shop — products", content: page.render() };
return ok_html(shell.render());
}
}

View file

@ -1,12 +1,9 @@
-- product_list/view.wo — the VIEW. Classes with `fn render() -> Text`;
-- fields are exactly the values displayed, bodies are MARKUP-FIRST: one
-- HTML line per statement (single-quoted attributes — no escape noise),
-- ${} holes, data through esc().
--
-- The `h = h ..` prefix on every line is the language's cost today:
-- statements are single-line and there is no multi-line literal. Story
-- 37's raw template literal deletes exactly that prefix and the esc()
-- calls ({{ }} auto-escapes and type-checks) — the markup stays as-is.
-- fields are exactly the values displayed, and a body is ONE raw text
-- literal: markup written as markup, attributes in real double quotes,
-- source indentation removed at compile time. `${}` interpolates raw,
-- `{{ }}` HTML-escapes — so display data goes through `{{ }}` and never
-- through a hand-written esc() call.
use html
pub class ProductCard {
@ -16,25 +13,29 @@ pub class ProductCard {
stock: Int
fn render() -> Text {
let h = "<div class='card'>";
h = h .. "<h3><a href='/p/${esc(self.sku)}'>${esc(self.name)}</a></h3>";
h = h .. "<p class='price'>€ ${self.price}</p>";
if self.stock > 0 {
h = h .. "<p class='stock'>${self.stock} in stock</p>";
} else {
h = h .. "<p class='stock out'>sold out</p>";
let stock = `<p class="stock">${self.stock} in stock</p>`;
if self.stock == 0 {
stock = `<p class="stock out">sold out</p>`;
}
h = h .. "</div>";
return h;
return `
<div class="card">
<h3><a href="/p/{{ self.sku }}">{{ self.name }}</a></h3>
<p class="price">€ ${self.price}</p>
${stock}
</div>`;
}
}
-- A parent component: its `cards` are CHILDREN, held through the
-- structural interface, and its render calls theirs. Nothing here knows
-- that a card is a ProductCard — swapping in a different card component
-- is a controller change, not a view rewrite.
pub class ProductListPage {
cards: Text
cards: multi Component
fn render() -> Text {
let h = "<h1>Products</h1>";
h = h .. "<div class='grid'>${self.cards}</div>";
return h;
return `
<h1>Products</h1>
<div class="grid">${render_all(self.cards)}</div>`;
}
}

View file

@ -1,10 +1,8 @@
-- product_page.controller.wo — the CONTROLLER for /p/:sku.
-- 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 {
@ -16,6 +14,7 @@ pub class ShowProduct {
}
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()));
let shell = AppShell { title: "shop — ${p.name}", content: page.render() };
return ok_html(shell.render());
}
}

View file

@ -1,6 +1,6 @@
-- product_page/view.wo — the product detail view with the order form.
-- Markup-first, one HTML line per statement; see product_list/view.wo
-- for the convention and the story-37 endgame.
-- Raw text literals for the bodies; see product_list/view.wo for the
-- convention.
use html
pub class ProductPage {
@ -10,21 +10,22 @@ pub class ProductPage {
stock: Int
fn render() -> Text {
let h = "<div class='card detail'>";
h = h .. "<h1>${esc(self.name)}</h1>";
h = h .. "<p class='price'>€ ${self.price}</p>";
h = h .. "<p class='stock'>${self.stock} in stock</p>";
if self.stock > 0 {
h = h .. "<form method='POST' action='/orders/${esc(self.sku)}'>";
h = h .. "<label>Quantity</label>";
h = h .. "<input type='text' name='qty' value='1' class='field'>";
h = h .. "<button type='submit' class='btn'>Buy</button>";
h = h .. "</form>";
} else {
h = h .. "<p class='stock out'>sold out</p>";
let action = `
<form method="POST" action="/orders/{{ self.sku }}">
<label>Quantity</label>
<input type="text" name="qty" value="1" class="field">
<button type="submit" class="btn">Buy</button>
</form>`;
if self.stock == 0 {
action = `<p class="stock out">sold out</p>`;
}
h = h .. "</div>";
h = h .. "<p><a href='/'>← all products</a></p>";
return h;
return `
<div class="card detail">
<h1>{{ self.name }}</h1>
<p class="price">€ ${self.price}</p>
<p class="stock">${self.stock} in stock</p>
${action}
</div>
<p><a href="/">← all products</a></p>`;
}
}

View file

@ -1,36 +1,82 @@
# site — how it is put together
Written 2026-08-23, with the sample's landing. Three files, one binary.
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.
| file | what it owns |
| --- | --- |
| `main.wo` | the `Chapter` table, seed-if-empty, the HTML shell (header/nav), four handlers (Home, ShowChapter, AdminEdit, Health), `main` |
| `content.wo` | the nine chapter bodies as functions returning HTML fragments, and `seed_chapters()` — same directory, so it shares `main.wo`'s declarations without `use` |
| `wo.toml` | the two [deps]: `framework` (serving) and `html` (markup) |
## The layout
Decisions that are not obvious from the code:
| 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 nine chapter bodies as fragment-returning functions, plus `seed_chapters()` |
| `layout/app.wo` | VIEW (chrome) | `AppShell` — the component that fills wo-html's `Layout` — the two named widths, and `html_error` |
| `layout/header.wo`, `layout/footer.wo` | VIEW (chrome) | the shared nav bar and footer |
| `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) |
- **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 wo-html's builders at boot; after that
the table is the truth and the builders are never consulted again.
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 wo-html'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.
- **One-line concat chains.** `..` does not straddle newlines (Go-style
implicit statement ends), so long fragments build accumulator-style
(`b = b .. "...";` per line) — the same shape serve.wo uses for
response heads.
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.
- **wo-html'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
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).

View file

@ -28,6 +28,39 @@ 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 nine chapter bodies + `seed_chapters()` |
| `layout/` | the chrome: `AppShell` (+ the two named widths), header, footer, `html_error` |
| `home/`, `chapter/`, `admin/`, `health/` | 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`) |
| `main.wo` | bootstrap: seed, routes, serve — nothing else |
Every `render()` makes its class a component (wo-html'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 wo-html 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

View file

@ -0,0 +1,33 @@
-- 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 framework/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}");
}
}

View file

@ -0,0 +1,23 @@
-- 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 framework/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());
}
}

View file

@ -0,0 +1,46 @@
-- 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 html
-- `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;
}
}

View file

@ -1,5 +1,6 @@
-- site/content.wo — the tutorial chapters, seeded into the Chapter table
-- on first boot (main.wo's seed_if_empty). Bodies are HTML fragments
-- 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 wo-html 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
@ -7,20 +8,6 @@
-- site that teaches it.
use html
-- The homepage's code showcase: a real flavor of the language — an
-- actor per chat room, rows in the built-in database, one binary.
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);
}
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.");
@ -75,15 +62,25 @@ fn ch_actors() -> Text {
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: the web framework, and the wo-html " .. "library that rendered the page you are reading.");
b = b .. code_block("[deps]\nframework = { git = \"https://github.com/shoneyj/writeonce-framework\", rev = \"v0.1.0\" }\nhtml = { git = \"https://github.com/shoneyj/wo-html\", rev = \"v0.1.0\" }");
b = b .. code_block("use framework\nuse framework/http\nuse html\n\n-- html's builders + tailwind-style utilities, zero JS, no build step:\nlet body = el(\"h1\", \"text-3xl font-bold\", \"Hello\");\nreturn ok_html(page(\"Hello\", body));");
b = b .. code_block(`
[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" }`);
b = b .. code_block(`
use framework
use framework/http
use html
-- 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 { pad: 0 } });\n app.get(\"/\", Home { pad: 0 });\n app.get(\"/ch/:slug\", ShowChapter { pad: 0 });\n app.post(\"/admin/ch/:slug\", AdminEdit { token: token });\n return app.serve(\"127.0.0.1\", port);\n}");
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=...&amp;body=...\" /admin/ch/serving</code>.");
return b;

View file

@ -0,0 +1,9 @@
-- health.controller.wo — GET /health: the liveness probe the accept
-- script and any proxy poll. Text, not HTML, on purpose.
use framework/http
pub class Health {
fn handle(req: Req) -> Resp {
return ok_text("ok");
}
}

View file

@ -0,0 +1,19 @@
-- 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 framework/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());
}
}

View file

@ -0,0 +1,53 @@
-- 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 wo-html's `Layout` uses. HomePage
-- therefore knows nothing about chapters, the Chapter table, or how the
-- nav decides which entry is current.
use html
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", "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.");
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);
}

View file

@ -0,0 +1,48 @@
-- 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 (wo-html's `page()` does that), so the shell
-- fills wo-html'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 framework/http
use html
pub class AppShell {
title: Text
container: Text
content: Text
fn render() -> Text {
let l = Layout {
title: self.title,
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() };
}

View file

@ -0,0 +1,6 @@
-- layout/footer.wo — the site footer, shared by every page.
use html
pub fn footer() -> Text {
return el("div", "footer", "writeonce.de — served by the language it teaches. " .. "One binary: compiler, runtime, database, this page.");
}

View file

@ -0,0 +1,9 @@
-- layout/header.wo — the site navigation bar, shared by every page.
use html
pub fn header() -> Text {
let links = link("/ch/hello", "text-gray-700", "Tutorial");
links = links .. link("/health", "text-gray-700", "Health");
links = links .. link("https://github.com/shoneyj", "text-gray-700", "GitHub");
return nav_bar("/", "writeonce.de", links);
}

View file

@ -8,167 +8,17 @@
--
-- 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 framework
use framework/http
use framework/router
use html
-- 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
}
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();
}
}
-- ---- rendering ---------------------------------------------------------
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 };
}
fn site_nav() -> Text {
let links = link("/ch/hello", "text-gray-700", "Tutorial");
links = links .. link("/health", "text-gray-700", "Health");
links = links .. link("https://github.com/shoneyj", "text-gray-700", "GitHub");
return nav_bar("/", "writeonce.de", links);
}
fn site_footer() -> Text {
return el("div", "footer", "writeonce.de — served by the language it teaches. " .. "One binary: compiler, runtime, database, this page.");
}
fn shell(title: Text, inner: Text) -> Text {
let body = site_nav() .. el("div", "mx-auto max-w-3xl px-4 py-8", inner) .. site_footer();
return page(title, body);
}
-- The homepage uses the wider container (go.dev shape); chapter pages
-- keep the reading width above.
fn shell_wide(title: Text, inner: Text) -> Text {
let body = site_nav() .. el("div", "mx-auto max-w-5xl px-4", inner) .. site_footer();
return page(title, body);
}
fn chapter_nav(current: Int) -> Text {
let items = "";
for c in from x in Chapter order by x.ord select x {
let label = "${c.ord}. ${esc(c.title)}";
if c.ord == current {
items = items .. el("li", "mb-2 font-bold text-gray-900", label);
} else {
items = items .. el("li", "mb-2", link("/ch/${c.slug}", "", label));
}
}
return el("ul", "list-disc pl-6", items);
}
-- ---- handlers ----------------------------------------------------------
class Home {
pad: Int
fn handle(req: Req) -> Resp {
-- 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", "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.");
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 .. chapter_nav(0));
return ok_html(shell_wide("writeonce — learn the language", hero .. showcase .. grid .. chapters));
}
}
class ShowChapter {
pad: Int
fn handle(req: Req) -> Resp {
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 {
let msg = el("h1", "text-2xl font-bold mb-4", "No such chapter");
let back = el("p", "", link("/", "", "Back to the chapters"));
let h: map<Text, Text> = {};
h["content-type"] = "text/html; charset=utf-8";
let nf = shell("writeonce — not found", msg .. back);
return Resp { status: 404, headers: h, body: nf };
}
let c = hits[0];
let head = el("h1", "text-3xl font-bold mb-4", "${c.ord}. ${esc(c.title)}");
let art = el("div", "bg-white rounded-lg border shadow-sm p-6", head .. c.body);
let nav = el("div", "mt-8", el("h2", "text-lg font-bold mb-2", "Chapters") .. chapter_nav(c.ord));
return ok_html(shell("writeonce — ${c.title}", art .. nav));
}
}
-- 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.
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}");
}
}
class Health {
pad: Int
fn handle(req: Req) -> Resp {
return ok_text("ok");
}
}
use home
use chapter
use admin
use health
fn main(args: multi Text) -> Int {
if len(args) < 1 {
@ -189,10 +39,10 @@ fn main(args: multi Text) -> Int {
seed_if_empty();
let app = App { middleware: [], routes: [] };
app.use_mw(Mw { m: Logging { pad: 0 } });
app.get("/", Home { pad: 0 });
app.get("/health", Health { pad: 0 });
app.get("/ch/:slug", ShowChapter { pad: 0 });
app.use_mw(Mw { m: Logging {} });
app.get("/", Home {});
app.get("/health", Health {});
app.get("/ch/:slug", ShowChapter {});
app.post("/admin/ch/:slug", AdminEdit { token: "${token}" });
return app.serve("127.0.0.1", port);
}

View file

@ -0,0 +1,52 @@
-- 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();
}
}

View file

@ -19,7 +19,6 @@ fn view_json(name: Text, price: Float, stock: Int) -> Text {
}
class ListProducts {
pad: Int
fn handle(req: Req) -> Resp {
let body = "[";
let first = true;
@ -33,7 +32,6 @@ class ListProducts {
}
class ShowProduct {
pad: Int
fn handle(req: Req) -> Resp {
let name = req.params["name"];
if name == nil { return bad_request("no name"); }
@ -54,7 +52,6 @@ fn create_product(name: Text, price: Float, stock: Int) -> Resp {
}
class CreateProduct {
pad: Int
fn handle(req: Req) -> Resp {
if media_type(req) == "multipart/form-data" {
let ps = multipart_parts(req);
@ -95,7 +92,6 @@ class CreateProduct {
}
class CreateOrder {
pad: Int
fn handle(req: Req) -> Resp {
let v = json.decode(req.body) as NewOrder;
if v == nil { return bad_request("body must be {product, qty}"); }
@ -108,7 +104,6 @@ class CreateOrder {
}
class DeleteProduct {
pad: Int
fn handle(req: Req) -> Resp {
let name = req.params["name"];
if name == nil { return bad_request("no name"); }
@ -146,7 +141,6 @@ class ConnWorker {
-- a deliberately slow route: the concurrency proof's workload
class Slow {
pad: Int
fn handle(req: Req) -> Resp {
time.sleep(400);
return ok_text("slow done");
@ -157,7 +151,6 @@ class Slow {
-- wildcard capture: GET /files/*path echoes the rest
class EchoPath {
pad: Int
fn handle(req: Req) -> Resp {
let p = req.params["path"];
if p == nil { return ok_text("path="); }
@ -167,7 +160,6 @@ class EchoPath {
-- group middleware writes the request-scoped ctx bag; the handler reads it
class StampCtx {
pad: Int
fn before(mut req: Req) -> ?Resp {
req.ctx["via"] = "api-group";
return nil;
@ -175,7 +167,6 @@ class StampCtx {
}
class ApiPing {
pad: Int
fn handle(req: Req) -> Resp {
let via = req.ctx["via"];
if via == nil { return ok_text("pong via="); }
@ -185,7 +176,6 @@ class ApiPing {
-- ETag + conditional: same body = same tag; If-None-Match collapses to 304
class EtagProbe {
pad: Int
fn handle(req: Req) -> Resp {
return with_etag(req, ok_json("{\"v\":1}"));
}
@ -193,7 +183,6 @@ class EtagProbe {
-- response-side negotiation: JSON or nothing
class NegoProbe {
pad: Int
fn handle(req: Req) -> Resp {
if accepts(req, "application/json") == false {
let h: map<Text, Text> = {};
@ -256,20 +245,20 @@ fn build_app(token: Text) -> App {
app.use_mw(Mw { m: BearerAuth { token: "${token}", principal: "api" } });
-- the response half: security headers + the CORS origin stamp on every
-- response that leaves dispatch (404/405/401 included)
app.use_after(Aw { a: SecurityHeaders { pad: 0 } });
app.use_after(Aw { a: SecurityHeaders {} });
app.use_after(Aw { a: Cors { allow_origin: "*" } });
app.get("/products", ListProducts { pad: 0 });
app.get("/products/:name", ShowProduct { pad: 0 });
app.post("/products", CreateProduct { pad: 0 });
app.post("/orders", CreateOrder { pad: 0 });
app.delete_("/products/:name", DeleteProduct { pad: 0 });
app.get("/files/*path", EchoPath { pad: 0 });
app.get("/etag-probe", EtagProbe { pad: 0 });
app.get("/nego", NegoProbe { pad: 0 });
app.get("/slow", Slow { pad: 0 });
app.get("/products", ListProducts {});
app.get("/products/:name", ShowProduct {});
app.post("/products", CreateProduct {});
app.post("/orders", CreateOrder {});
app.delete_("/products/:name", DeleteProduct {});
app.get("/files/*path", EchoPath {});
app.get("/etag-probe", EtagProbe {});
app.get("/nego", NegoProbe {});
app.get("/slow", Slow {});
let g = Group { prefix: "/api" };
g.use_mw(Mw { m: StampCtx { pad: 0 } });
g.get("/ping", ApiPing { pad: 0 });
g.use_mw(Mw { m: StampCtx {} });
g.get("/ping", ApiPing {});
app.mount(g);
return app;
}

View file

@ -0,0 +1,102 @@
# wo-html — server-rendered HTML as plain Text
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]
html = { git = "https://github.com/shoneyj/wo-html", 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, 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.
`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 wo-html 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`](../site) — the tutorial site: `Layout` +
a `ChapterNav` component reused on the homepage and every chapter page.
Gated by `just site`.
- [`docs/examples/shop`](../shop/README.md) — the program template: its
own `AppShell` component, page components holding child components.

View file

@ -1,7 +1,10 @@
-- wo-html — server-rendered HTML as plain Text. Three layers, all pure:
-- esc() HTML-escape untrusted text (the ONLY defense: use it on
-- everything that did not come from your own code)
-- wo-html — server-rendered HTML as plain Text. Four layers, all pure:
-- esc() HTML-escape untrusted text. `{{ }}` in a raw text
-- literal compiles to a call to this, so display data is
-- escaped by construction; esc() by hand is the fallback
-- el()/... element builders — `el("h1", "text-3xl font-bold", t)`
-- Component the view unit: a class with fields + `fn render() ->
-- Text`, satisfied structurally (iteration 37)
-- tw_css() a Tailwind-style utility stylesheet: the same class
-- names Tailwind popularized, hand-written as one static
-- sheet, inlined by page() so a page is one self-contained
@ -11,6 +14,20 @@
-- take exactly (tag, classes, inner) and pages compose by `..` and by
-- functions returning Text. That constraint is the demo: an HTML layer
-- in writeonce is ordinary code, not a template dialect.
--
-- ---- the MVC seam this library sits on --------------------------------
--
-- MODEL `@table` rows. Queried in the HANDLER, never here — this
-- library has no `from ... select` anywhere in it and must
-- not grow one. A component receives VALUES, not a cursor.
-- VIEW components: fields in, Text out, no hidden state and no
-- globals, so 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())`.
--
-- 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.
-- HTML-escape: & < > " (the four that matter in text and attributes).
pub fn esc(t: Text) -> Text {
@ -39,14 +56,14 @@ pub fn esc(t: Text) -> Text {
-- CALLER's business: pass esc(user_text) for data, raw markup for
-- fragments you built yourself.
pub fn el(tag: Text, cls: Text, inner: Text) -> Text {
if cls == "" { return "<${tag}>${inner}</${tag}>"; }
return "<${tag} class=\"${cls}\">${inner}</${tag}>";
if cls == "" { return `<${tag}>${inner}</${tag}>`; }
return `<${tag} class="${cls}">${inner}</${tag}>`;
}
-- An anchor: href is attribute context, so it is escaped here.
pub fn link(href: Text, cls: Text, label: Text) -> Text {
if cls == "" { return "<a href=\"${esc(href)}\">${label}</a>"; }
return "<a href=\"${esc(href)}\" class=\"${cls}\">${label}</a>";
if cls == "" { return `<a href="{{ href }}">${label}</a>`; }
return `<a href="{{ href }}" class="${cls}">${label}</a>`;
}
-- A code block: content is ALWAYS escaped — code samples are exactly the
@ -56,19 +73,19 @@ pub fn code_block(src: Text) -> Text {
}
pub fn text_input(name: Text, value: Text) -> Text {
return "<input type=\"text\" name=\"${esc(name)}\" value=\"${esc(value)}\" class=\"field\">";
return `<input type="text" name="{{ name }}" value="{{ value }}" class="field">`;
}
pub fn text_area(name: Text, value: Text, rows: Int) -> Text {
return "<textarea name=\"${esc(name)}\" rows=\"${rows}\" class=\"field\">${esc(value)}</textarea>";
return `<textarea name="{{ name }}" rows="${rows}" class="field">{{ value }}</textarea>`;
}
pub fn submit_btn(label: Text) -> Text {
return "<button type=\"submit\" class=\"btn\">${esc(label)}</button>";
return `<button type="submit" class="btn">{{ label }}</button>`;
}
pub fn form_post(action: Text, inner: Text) -> Text {
return "<form method=\"POST\" action=\"${esc(action)}\" class=\"flex flex-col gap-2\">${inner}</form>";
return `<form method="POST" action="{{ action }}" class="flex flex-col gap-2">${inner}</form>`;
}
-- A sticky top navigation bar: brand on the left, a prebuilt row of
@ -149,11 +166,63 @@ pub fn tw_css() -> Text {
-- One full document: the sheet inlined, viewport set, body handed in.
-- Self-contained by construction — view-source shows everything.
pub fn page(title: Text, body: 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 .. "<style>${tw_css()}</style></head><body>";
d = d .. body;
d = d .. "</body></html>";
return d;
-- The whole document as one literal. Every newline here lands
-- inside <head>, where whitespace is insignificant; the body hole
-- and its closing tags share one line so nothing is inserted into
-- the rendered content.
return `
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ title }}</title>
<style>${tw_css()}</style>
</head><body>${body}</body></html>`;
}
-- ---- components (iteration 37) ----------------------------------------
-- The view twin of the framework's `Handler`: a class with fields and a
-- render, satisfied STRUCTURALLY — nothing declares that it implements
-- this, a class either has `fn render() -> Text` or it does not (a class
-- missing it is WO-E205 at the use site). Composition is nesting: a
-- parent's render calls its children's.
--
-- A component's fields ARE its inputs — the closure substitute this
-- language's no-function-values doctrine forces, and the same shape the
-- framework already proved for handlers. `multi Component` works
-- directly, so a heterogeneous list of children needs no wrapper record.
pub interface Component {
fn render() -> Text
}
-- Render children in order. The one place in this library where the
-- interface is genuinely load-bearing: this function knows nothing about
-- any of them beyond `render()`, and `multi Component` holds a
-- heterogeneous list directly — no wrapper record, which the framework's
-- Mw/Aw shape might lead you to expect.
pub fn render_all(cs: multi Component) -> Text {
let out = "";
for c in cs {
out = out .. c.render();
}
return out;
}
-- Content projection, writeonce-shaped: Angular's `<ng-content>` is a
-- slot the parent fills, and here a slot is just a pre-rendered `Text`
-- field. The caller passes `child.render()`, a raw literal, or a
-- builder's output — the layout never knows which, which is exactly why
-- it never needs to know the child's type.
--
-- Deliberately four slots and no container/width field: a page that
-- wants its content in a fixed-width column wraps it before passing it
-- in. One less knob here beats one more.
pub class Layout {
title: Text
nav: Text
content: Text
footer: Text
fn render() -> Text {
return page(self.title, self.nav .. self.content .. self.footer);
}
}

View file

@ -3,7 +3,7 @@
--
-- let app = App { middleware: [], gmw: [], afters: [], routes: [] };
-- app.use_mw(Mw { m: Auth { token: t } });
-- app.use_after(Aw { a: SecurityHeaders { pad: 0 } });
-- app.use_after(Aw { a: SecurityHeaders {} });
-- app.add(Route { method: "GET", pattern: "/products/:id", h: Show {} });
-- return app.serve("127.0.0.1", port);
--

View file

@ -8,7 +8,6 @@
-- framework's standing decision), so Strict-Transport-Security belongs
-- in the proxy config next to the certificates.
pub class SecurityHeaders {
pad: Int
fn after(req: Req, mut r: Resp) {
r.headers["x-content-type-options"] = "nosniff";
r.headers["x-frame-options"] = "DENY";

View file

@ -37,6 +37,17 @@ pub fn ok_text(body: Text) -> Resp {
return Resp { status: 200, headers: h, body: body };
}
-- iteration 37: HTML is transport here, exactly like text and JSON —
-- the status line and the content-type, nothing about rendering. It
-- lives beside its two siblings because both HTML apps had hand-rolled
-- the identical four lines; wo-html stays a pure Text library and never
-- learns what a Resp is.
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 ok_json(body: Text) -> Resp {
let h: map<Text, Text> = {};
h["content-type"] = "application/json";

View file

@ -47,10 +47,10 @@ pub class Mw {
}
-- Request-line logging, the one middleware every framework ships: method +
-- path to stderr, never short-circuits. `pad` is the record-class ctor
-- convention (every stateless handler carries one Int field).
-- path to stderr, never short-circuits. A stateless handler declares NO
-- fields and constructs as `Logging {}` — an earlier convention gave every
-- one of them a filler `pad: Int`, which the language never required.
pub class Logging {
pad: Int
fn before(mut req: Req) -> ?Resp {
print_err("${req.method} ${req.path}");
return nil;

View file

@ -0,0 +1,244 @@
# The writeonce language surface — everything a `.wo` file may contain
Derived from the front end as it stands on 2026-08-24, by reading
`compiler/src/lexer.ml`, `parser.ml`, `ast.ml` and `types.ml` — not a
spec. Where this disagrees with the compiler, the compiler is right.
The normative companions are
[`08-builtin-surface.md`](../plan/oop-vm/08-builtin-surface.md) (what the
runtime offers) and [`01-error-catalog.md`](../plan/oop-vm/01-error-catalog.md)
(every diagnostic). The reasoning under the front end is
[`compiler/src/CODE-LOGIC.md`](../../compiler/src/CODE-LOGIC.md).
Read this as the answer to "what can I write?" — the last section is the
matching answer to "what will the compiler refuse?", which is just as
much part of the surface.
Every form listed below was compiled and run against `woc`/`wovm` while
writing this page, not read off the parser and hoped for.
---
## 1. Lexical
| thing | form | notes |
| --- | --- | --- |
| comment | `-- to end of line` | the only comment form; no block comment |
| identifier | `[A-Za-z_][A-Za-z0-9_-]*` | **internal dashes are legal** — `a-b` is ONE identifier, so binary minus after an identifier needs spaces (`a - b`) |
| integer | `42`, `0xFF`, `0b1011`, `1_000_000` | `_` only BETWEEN digits; hex/binary accumulate in 63-bit OCaml int, so a full-width `0xFFFF…FFFF` is out of reach (`-1` spells all-ones) |
| float | `1.5`, `2e9`, `1.0e-3` | a bare digit run stays `Int`; only a fraction or exponent makes a `Float` |
| text | `"..."` or `'...'` | escapes `\n \t \r \0 \\ \" \'`; anything else after `\` is that literal character. A raw newline inside is **WO-E005** |
| interpolation | `"${expr}"` | desugars at parse time to a `..` chain; `\$` is a literal `$`, and a lone `$` not followed by `{` is literal |
| raw text literal | `` `...` `` | iteration 37 — content verbatim (NO escape processing), newlines are content, common source margin removed at compile time. Holes: `${e}` raw, `{{ e }}` HTML-escaped |
| booleans / nil | `true`, `false`, `nil` | |
| newline | significant | terminates a statement (or `;`). A line ending in `..` continues on the next — the one newline suppression |
| build flags | `#if name` / `#else` / `#end` | token-level filter, flag NAMES only (no expressions); nesting allowed; set with `woc -D name` |
**Keywords** (35): `type class interface fn let mut take return if else
while for in true false use spawn using pub break continue do const and
or not inline switch case default typedef try catch nil as INSERT
SELECT`.
Deliberately NOT keywords, so they lex as ordinary identifiers: `self`,
`me`, `subscribe`, `receive`, lowercase `insert`/`select`, and every
query clause word (`from`, `where`, `group`, `by`, `into`, `order`,
`desc`, `take`, `select`) plus every type constructor word (`ref`,
`multi`, `map`, `backlink`, `actor`).
## 2. File and module level
A directory is a module; `pub` is the export line. A file may contain,
in any order:
| declaration | form |
| --- | --- |
| import | `use fs` (reserved stdlib namespace) or `use shared/util` (project-relative path) |
| import + extension methods | `using shared/textutil` — the module's `pub` free fns whose first parameter matches a receiver become callable as methods on it (compile-time rewrite) |
| class | `[pub] class Name { fields, methods, consts }` |
| plain type | `[pub] type Name { ... }` — identical field grammar to `class`; methods parse for real in both |
| structural record | `typedef Name = { field: T, ... }` — fields only, and two records of the same SHAPE are the same type |
| tagged union | `type Name = A \| B \| C(x: Int, ...)` — bare variants lower to integer tags, payload variants to records. Construction is call-style and POSITIONAL: `C(1, 2)`, never `C { x: 1 }` |
| interface | `[pub] interface Name { fn sig(...) -> T }` — signatures only, no fields, no bodies. Satisfaction is STRUCTURAL |
| free function | `[pub] fn name(params) -> T { ... }` |
| constant | `const NAME = <literal>` — substituted by the parser before typecheck; a local of the same name shadows it |
The program entry is the free `fn main`, zero-argument or `fn
main(args: multi Text) -> Int`.
### Annotations
| annotation | where | effect |
| --- | --- | --- |
| `@table(name: "…", index: [a], index: [b, c])` | on a `class`/`type` | the class IS a WAL-backed table |
| `@unique` | on a field | uniqueness constraint |
| `@gc` | on a class | **rejected** — GC-ness is inferred, never declared |
Unknown annotation names parse and are ignored; argument lists on field
annotations are consumed and discarded.
## 3. Types
| kind | spelling |
| --- | --- |
| scalars | `Int`, `Float`, `Bool`, `Text`, `Bytes`, `Timestamp`, `Id` |
| nullable | `?T` — legal on any of the above and on heap shapes |
| list | `multi T` |
| map | `map<K, V>` |
| row reference | `ref C` |
| reverse relation | `backlink C.field` — the computed inverse of a `ref` |
| actor address | `actor M` — M is the message type, inferred from the class's `receive` |
| declared types | any `class` / `type` / `typedef` / union name |
| stdlib types | `json.Value`, `net.Conn` |
| predeclared records | `Stat`, `TimeParts`, `Proc`, `Error` — no source declares them; field ORDER is the contract with the C runtime |
### Fields and parameters
- Field: `name: T`, optionally `= <default>` and/or `@ann`.
- `pub(read) name: T` — readable outside the declaring class, writable
only inside it.
- Parameter conventions: **borrow is the default**; `mut x: T` for a
mutable borrow, `take x: T` to move ownership in. These apply to
NAMED parameters only.
- **`self` is implicit** — never written in the parameter list, and
always writable inside its own class's methods (`self.total += 1`
needs no annotation). `self` is an ordinary identifier, not a keyword.
- Methods may be `static fn`; class-level constants may be `static
const` or bare `const`.
## 4. Statements
| statement | form |
| --- | --- |
| binding | `let x = e`, `let x: T = e` |
| assignment | `x = e`, `obj.f = e`, `m[k] = e` |
| compound assignment | `x += e`, `-=`, `*=`, `/=`, `%=` — parse-time sugar for the written-out form (there are no bitwise compound assigns) |
| conditional | `if c { } else if c { } else { }` |
| while | `while c { }` |
| do-while | `do { } while c` — body always runs once |
| for | `for x in <multi \| query>`, `for k, v in <map>` |
| loop control | `break`, `continue` — owned values alive in the body are dropped at the jump site |
| return | `return` / `return e` |
| database write | `insert C { f: v, ... }`, `delete e` |
| expression | any expression in statement position |
## 5. Expressions
| form | spelling |
| --- | --- |
| literals | int, float, text, raw text, bool, `nil` |
| container literals | `[]`, `[a, b, c]` (a `multi`), `{}` (an empty `map`) — a fresh container needs a destination of declared type |
| constructor | `C { field: v, ... }` |
| access | `x.f`, `c[i]` (a `multi` — out of range traps), `m[k]` (a `map` — a missing key is **nil**) |
| call | `f(a, b)`, `x.m(a)`, `mod.member(a)` |
| unary | `-e`, `not e` |
| binary | see the ladder below |
| checked conversion | `e as T` — its ONE meaning is decoding JSON text, yielding `?T`. There is no reinterpret cast |
| actor spawn | `spawn C { fields }` → an `actor M` address |
| trapping guard | `try <expr> catch (e) <expr-or-block>` — an EXPRESSION; `e` binds the `Error` record `{ code, line, method, msg }` |
| multi-way choice | `switch e { case A: ...; default: ...; }` — an expression. Arms match VALUES; `case a, b:` fires for either. A union payload binds positionally: `case Rect(w, h): w * h`. `default` is required UNLESS the subject is a union whose variants are all covered |
| query | `from … select …`, see below |
| interpolation | inside `"…"` and `` `…` `` |
### Operator precedence, loosest to tightest
1. `or`
2. `and`
3. comparison — `== != < <= > >=`
4. concatenation — `..`
5. additive — `+ -` **and `|` `^`**
6. multiplicative — `* / %` **and `&` `<<` `>>`**
7. unary — `-`, `not`
8. `as` conversion — binds to a postfix expression, so tighter than unary
9. postfix — call, index, field
Bitwise operators do not get their own tiers: `|`/`^` ride the additive
rung and `&`/`<<`/`>>` the multiplicative one (Go's arrangement). They
are Int-only on BOTH sides — there is no Float twin — and a literal
shift count outside `0..63` is a compile error.
`+` is arithmetic ONLY, never string addition. `and`/`or` are
short-circuit, `Bool`-typed operands only — there is no truthiness, and
no `&&`/`||`/`!`/`~` anywhere in the language.
## 6. Queries (language-integrated, never SQL text)
```
from <var> in <source>
where <expr> -- zero or more
group <expr> by <key> into <gvar>
order by <expr> [desc]
take <expr>
select <expr>
```
The source is either a table class (`from p in Product`) or a
navigation (`from s in dept.staff` — a `backlink` or a `multi`). Present
today: from / where / order / take / select plus group-by aggregation.
Joins are not in the slice. A query is an expression and is also what
`for x in <query>` iterates.
## 7. Concurrency
- `spawn C { fields }` constructs the actor's state (fields MOVE in) and
starts it; the value is an `actor M` address.
- `send(addr, msg)` — fire and forget; the message MOVES to the runtime.
- `call(addr, msg) -> R` — a send that parks the calling fiber until the
receive returns. Every `receive` program-wide must agree on `R`, and
`R` must be a copyable scalar. A dead callee traps, never hangs.
- A class becomes an actor by declaring `fn receive(msg: M)` — `receive`
is an ordinary identifier, not a keyword.
- Blocking stdlib calls park the fiber. There is no `async`, no `await`,
and no user-visible thread.
## 8. Builtins and the stdlib
**Free builtins** (a user-declared `fn` of the same name always wins):
`print`, `print_err`, `print_int`, `now`, `words`, `len`, `count`,
`byte_at`, `char_of`, `substr`, `trim`, `to_lower`, `starts_with`,
`ends_with`, `index_of`, `last_index_of`, `split`, `split_ws`, `join`,
`parse_int`, `int_to_text`, `multi_new`, `map_new`, `push`, `get`,
`set`, `has`, `remove`, `latest`, `pop`, `shift`, `slice`, `sort`,
`reverse`, `key_at`, `val_at`, `send`, `call`, `sha1`, `sha256`,
`hmac_sha256`, and the Float/Bytes bridges `float`, `trunc`,
`parse_float`, `float_to_text`, `float_cmp`, `bytes_len`, `bytes_at`,
`bytes_slice`, `bytes_eq`, `bytes_concat`, `bytes_of_text`,
`text_of_bytes`, `base64_encode`, `base64_decode`.
**Reserved module namespaces**, each resolving to builtins:
| module | covers |
| --- | --- |
| `fs` | `exists`, `list`, `stat`, `read_all`, `read_at`, `append` |
| `time` | `now`, `sleep`, `local`, `iso`, `ticks` |
| `env` | `get`, `stopping` (the SIGTERM/SIGINT latch) |
| `net` | `listen`, `listen_unix`, `accept`, `accept_dl`, `read`, `read_dl`, `write`, `write_dl`, `peer`, `close` |
| `proc` | `run` |
| `json` | `encode`, `decode` (paired with `as T`) |
A failing syscall traps `IO` with errno's message; **absence is never a
trap** — a missing path or an unset variable is nil.
## 9. What the language deliberately does NOT have
This list is doctrine, not a backlog. Each was considered and rejected.
- **Closures and function values.** Capture is a field on a class. This
is why there is no dependency injection and no callback API anywhere.
- **`&&`, `||`, `!`, `~`.** Word operators only (`and`, `or`, `not`);
complement is `-1 ^ x`.
- **Inheritance.** Interfaces are structural; there is no `extends`.
- **`inline fn`.** The keyword exists solely to produce a clear
rejection — optimization is the compiler's job.
- **A reinterpret cast.** `as` decodes JSON and nothing else.
- **Varargs, and generics beyond the built-in containers.**
- **Truthiness.** A condition must be `Bool`.
- **A block comment, and a `{{`/`${`/backtick escape inside a raw
literal.** Those three are written by concatenating an ordinary
`"..."` string with `..` — one greppable door.
- **`async`/`await`.** Fibers park; the shard runs someone else.
- **A runtime template engine.** Markup is a compile-time literal or it
does not exist.
- **Non-empty map literals** (`{ k: v }` in expression position) —
indistinguishable from a constructor literal without lookahead nothing
else needs.
- **A full-width 64-bit integer literal**, and **joins in queries** —
both real limits rather than doctrine.

View file

@ -26,6 +26,8 @@ half of the story ("moved here" / "borrowed here" / etc.).
| --- | --- | --- |
| WO-E001 | an input byte the lexer doesn't recognize as the start of any token. Reported once per bad byte, which is then skipped — one bad byte never stops the whole file. | `unknown character '$'` |
| WO-E002 | a string literal's backslash escape is the last byte of the file, with no character left to escape (a plain unterminated string with no dangling backslash is *not* an error — rt parity). | `unterminated string escape` |
| WO-E004 | a raw text literal (backtick-delimited) that runs off the end of the file. Reported at the OPENING backtick — unlike a plain `"..."` string this is never silent, because multi-line is this form's normal case and a missing close would swallow every remaining line. | `unterminated raw text literal` |
| WO-E005 | a raw newline inside a `"..."` or `'...'` string. The scan stops at the newline without consuming it, so the `Newline` token still terminates the statement and only one line is lost. Multi-line text is spelled with a raw literal instead. | ``newline in string literal (use a `...` raw text literal for multi-line text)`` |
## WO-E1xx — parsing (Tasks 4–5, `compiler/src/parser.ml`; WO-E103 haxe-parity Task 2)

View file

@ -182,6 +182,8 @@ Lowered by the emitter, not added to the format:
| `a > b`, `a >= b` | `LT` / `LE` with the operands swapped |
| `a == b` on `Text` | `EQS` (content equality); `EQ` otherwise |
| `a .. b` | `CONCAT` — `+` is arithmetic only, never string addition |
| `` `...${e}...` `` | the same `StrLit`/`Interp`/`CONCAT` chain a `"..."` string produces — the raw literal is a LEXER form (verbatim content, common margin removed at lex time), not a new node or opcode |
| `` `...{{ e }}...` `` | `esc(${e})` — the parser wraps the interpolation in a call to whatever `esc` is in scope (wo-html's `pub fn esc`, or a local one that deliberately shadows it). No builtin, no opcode, no HTML knowledge in the compiler or the VM |
| `a and b` | evaluate `a`; `JZ` past evaluating `b` (result stays `a`'s value); else evaluate `b` into the same register (haxe-parity Task 2) |
| `a or b` | evaluate `a`; `JZ` + `JMP` past evaluating `b` when `a` is already true; else evaluate `b` (haxe-parity Task 2) |

View file

@ -34,6 +34,50 @@ machine-readable truth behind this board; live Obsidian Dataview views:
## ▶ NEXT PLAN
### Landed 2026-08-25 — iteration 37, wo-html components (off-chain)
**Implemented last time (2026-08-25):** iteration 37 CLOSED, both
halves. The grammar half (2026-08-24) added the backtick raw text
literal — content verbatim, common margin removed at lex time, `${ }`
raw and `{{ }}` compiling to a call on the `esc` in scope — plus
WO-E004/WO-E005. The library half (2026-08-25) added `Component`,
`render_all` and `Layout` to wo-html, moved `ok_html` into
`framework/http` beside `ok_text`/`ok_json`, and migrated BOTH HTML
samples onto the component layer.
**Key findings (measured, not asserted):** `multi Component` holds a
heterogeneous list DIRECTLY — no wrapper record — so the framework's
`Mw`/`Aw` shape is a local choice, not a language requirement; that is
what made page components able to own their children. The whole
escaping desugar needed zero compiler knowledge of HTML: `{{ e }}` is a
`Call` on an ordinary in-scope `esc`, so typecheck, ownership, codegen,
the `.wob` format and the VM were all untouched. `{{ }}` was proven
byte-identical to the hand-written `esc()` calls it replaced, hostile
input (`< > & "`) included, across all eight migrated builders.
**Learned:** an interface that nothing consumes as a TYPE is
decoration — `Component` only started earning its place once
`render_all` and the page components held `multi Component`. And the
shop template DOES build and run — an earlier note in this repo had that
wrong, and wrong again about why: gap #1 (`pub` + `@table`) constrains
neither the build NOR the layout. A class crosses module lines without
export; only a free `fn` is module-scoped (`WO-E210`).
**Dependencies unblocked:** shop README gap #3 ("no multi-line
expression or literal") is closed. Separate `.html` templates, if ever
wanted, now have exactly one honest shape — a COMPILE-TIME include
feeding the raw-literal machinery; a per-request file read is the
already-rejected engine.
**Next steps:** the concurrency chain below is untouched by this and
remains the live queue.
**`.dev/reference` used:** none this slice (Angular's component format
was studied from its public docs during the 2026-08-23 story write-up;
no reference project was consulted for the implementation).
---
**The concurrency + fiber chain — ✅ stage 3 → ✅ 22 → 31 → 24 → 23 → 32**
(directive 2026-08-21). Next slice: **iteration 31, actor lifecycle** —
its spec brainstorm is the next act (four forks in
@ -244,7 +288,7 @@ that sequences its tasks. Read one, approve, then the next starts.
| 32 | [WAL checkpoint](language-runtime-database/refine/32-wal-checkpoint.md) | ⬜ last in chain, after 23 — disk reclamation + bounded replay (story written 2026-08-21) |
| 33 | [Single-file store](language-runtime-database/refine/33-single-file-db.md) | ⬜ off-chain, small — `WO_DATA=<path>.db` file form; driver-only (story written 2026-08-22) |
| 34 | [Crypto builtins](language-runtime-database/refine/34-crypto-builtins.md) | ⬜ off-chain but GATES 24 (WS handshake needs SHA-1) — digests + HMAC as vector-verified C builtins (story written 2026-08-22) |
| 37 | [wo-html components](language-runtime-database/refine/37-wo-html-components.md) | ⬜ off-chain — MVC-shaped view layer in wo-html (Component interface, layouts/slots, site migrates as acceptance); Angular format studied, client-side half rejected (story written 2026-08-23) |
| 37 | [wo-html components](language-runtime-database/done/37-wo-html-components.md) | ✅ off-chain — LANDED 2026-08-25. Raw text literal (backtick, margin stripped at lex time, `{{ }}` auto-escapes) + the component layer: `Component`/`render_all`/`Layout` in wo-html, `ok_html` moved into the framework, site and shop both migrated |
| 35 | [net runtime seams](language-runtime-database/done/35-net-runtime-seams.md) | ⬜ off-chain — fd deadlines on the park plane, Unix sockets, peer address; owns the ledger's three 🔧 rows (story written 2026-08-22) |
| 20 | [Cross-program tables](language-runtime-database/hold/20-cross-program-tables.md) | ⏸ hold (2026-08-21); channel done (branch ipc-attach keeps its manifest) |
| 21 | [Keypair attach auth](language-runtime-database/hold/21-keypair-attach-auth.md) | ⏸ hold (2026-08-21); crypto+handshake done (branch keypair-auth keeps its manifest) |
@ -316,6 +360,7 @@ Gates at the end of that session: corpus 71/0, `woc` runtest 565/0, every
| Status | Item | Doc | What actually landed |
| ------ | ------------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ✅ | iteration 37 — wo-html components | [37](language-runtime-database/done/37-wo-html-components.md) | Two halves. **Grammar (2026-08-24):** the backtick raw text literal — content verbatim, common margin removed at lex time, `${ }` raw and `{{ }}` auto-escaping to a call on the `esc` in scope; WO-E004/WO-E005 added; lexer + one parser desugar only, nothing downstream. **Library (2026-08-25):** `Component`/`render_all`/`Layout` in wo-html, `ok_html` moved into `framework/http` beside `ok_text`/`ok_json`, site migrated onto `Layout` + a reused `ChapterNav`, shop onto `AppShell` + `multi Component` children with queries in the controllers; the site was then restructured onto the program template's MVC layout (model / layout / view modules / one controller per feature / bootstrap-only main). `multi Component` needs no wrapper record — the framework's Mw/Aw shape is not a language requirement. Gates: `just site` 11/0, `just web-app` 46/0, `woc-test` 556/0, `oop-e2e` 116/0 |
| ✅ | Principles | [`../00-principles.md`](../00-principles.md) | 13 principles, each with a why and a link to the doc that enforces it |
| ✅ | `wovm` VM core | [plan 1](../superpowers/plans/2026-08-01-wob-format-and-vm-core.md) | `.wob` v1 loader with full static validation, register interpreter (computed-goto + ISO-C fallback), arena with size-class free lists, borrow word, RC + budgeted Bacon–Rajan cycle collector, drop-map trap unwinding, containers, builtins, ICALL, CLI. 13 suites × 2 dispatch flavors + CLI smoke, ASan/UBSan clean |
| ✅ | `.wob` format contract | [`oop-vm/00-wob-format.md`](../plan/oop-vm/00-wob-format.md) | Normative; twinned with `runtime/src/wob.h` |

View file

@ -109,7 +109,7 @@ still pending IS the runtime-concurrency chain; order:
| 20 | 32 | [WAL checkpoint](refine/32-wal-checkpoint.md) | **NEW 2026-08-21** (stage-3 guarantee refinement found the hole) — the WAL is append-only forever: snapshot + truncate reclaims disk and bounds replay time; every durability guarantee byte-identical; crash mid-checkpoint recovers from the previous snapshot + full tail. After 23 (composes with group-commit); RAM slot-reuse already contracted in `04-db-binding.md`. |
| 21 | 33 | [Single-file store](refine/33-single-file-db.md) | **NEW 2026-08-22** — `WO_DATA=<path>.db`: a file path IS the wal (the store already lives in exactly one file; this makes the surface say so). Driver-only, independent of the chain; composes with 32's rename-swap. |
| 22 | 34 | [Crypto builtins](refine/34-crypto-builtins.md) | **NEW 2026-08-22** — SHA-1/SHA-256/HMAC-SHA256 as C builtins over Bytes (no bitwise ops in the language, hand-rolled per doctrine, vector-verified). GATES 24's WS handshake; digest floor for held 21 and the ETag row. |
| 23 | 37 | [wo-html components](refine/37-wo-html-components.md) | **NEW 2026-08-23** — an MVC-shaped view layer in the wo-html LIBRARY (framework stays micro): structural `Component` interface (`render() -> Text`), layout components with slots, the site sample migrated as acceptance. Angular's component FORMAT studied and translated to server-rendered no-JS `.wo`; DI/bindings rejected. Unscheduled. |
| 23 | 37 | [wo-html components](done/37-wo-html-components.md) | **NEW 2026-08-23** — an MVC-shaped view layer in the wo-html LIBRARY (framework stays micro): structural `Component` interface (`render() -> Text`), layout components with slots, the site sample migrated as acceptance. Angular's component FORMAT studied and translated to server-rendered no-JS `.wo`; DI/bindings rejected. **LANDED 2026-08-25.** Raw text literal 2026-08-24 (backtick, verbatim content, margin stripped at lex time, `${ }` raw / `{{ }}` auto-escaping; WO-E004/WO-E005) — lexer plus one parser desugar, nothing downstream. Component layer 2026-08-25: `Component`/`render_all`/`Layout` in wo-html, `ok_html` moved into the framework, site and shop both migrated. |
| 23 | 35 | [net runtime seams](done/35-net-runtime-seams.md) | **NEW 2026-08-22** — the ledger's three 🔧 rows owned: fd deadlines composing with the park plane, Unix-socket listeners, peer address (trusted-proxy check). Framework knobs stay framework slices; pairs naturally with 24 (dead-client eviction). |
| 24 | 25 | [HTTP service layer](../../superpowers/plans/2026-08-01-http-service-layer.md) | `service` blocks lower onto the framework (after 9b + 20 by their own precedence notes). **HELD 2026-08-21** — story file removed; the plan doc remains. *(was 10)* |
| 25 | 18 | [framework v2: memory-rich features](hold/18-memory-db-features.md) | spec+plan approved: TTL cache, @table flags, durable job queue, `transaction { }` over the WAL's staged batch. **Demoted from seq 14**: more surface on a framework with one consumer, and the cache still stores `Text` because there are no generics |

View file

@ -0,0 +1,265 @@
---
iteration: "37"
status: done
---
# Iteration 37 — wo-html components: an MVC-shaped view layer (Angular's format, studied)
> Format: fiberloom `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](../00-story.md).
>
> **Inserted 2026-08-23** (developer ask: "enhance wo-html like MVC;
> understand Angular format"). Grows the wo-html LIBRARY, never the
> framework — the 2026-08-20 micro-framework directive stands: routing/
> middleware/`Req`/`Resp` stay MVC-free, and the view layer lives in its
> 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. **RE-POINTED 2026-08-24**
> (developer counter-proposal): the leaning is now **in-class raw
> template literals**, not separate `.html` files — `render()` RETURNS
> a raw multi-line string literal whose `{{ expr }}` holes are
> auto-escaped, compile-time-checked interpolations in class scope (a
> typo'd field is a compile error). Compiler surface shrinks to one
> lexer addition (the raw literal — a general language win: multi-line
> text without `\"` noise) plus one desugar; no file pairing, no
> `w:component` sections. Structural control stays the language's own
> `if`/`for` composing literals; `w:if`/`w:for` attributes and separate
> `.html` files are DEMOTED to a later option that can layer on without
> breaking this form. Escaping: `{{ }}` always escapes; the raw/slot
> spelling is the one greppable door. A runtime mustache-lite stays
> REJECTED (reflection-free means untyped `map<Text, Text>`); client-
> side reactivity (`ref`, `@click`, `v-model`) stays out under the
> no-JS posture — forms and POSTs instead. Still a COMPILER iteration,
> parked behind the standing "no compiler/VM/database changes yet"
> directive. The shop template
> ([`docs/examples/shop`](../../../examples/shop/README.md)) is the
> consumer: each `render()` body becomes one raw template literal, and
> its README's recorded gaps ride along (gap #1: `pub` + `@table`
> cannot combine — blocks a shared model module; gap #2: `@view`
> projection classes).
>
> **CODE LANDED 2026-08-24** — the compiler slice only. Forks #1 and #3
> are settled and shipped: the raw literal is BACKTICK-delimited with
> verbatim content, `{{ }}` always HTML-escapes and `${ }` stays raw,
> and the common source margin is removed at lex time. `{!! !!}` as the
> raw slot is DISCARDED — `${ }` already was the raw spelling and a
> second one would have been a synonym. The escaping desugar turned out
> to need no compiler knowledge of HTML at all: `{{ e }}` becomes a
> `Call` on whatever `esc` is in scope, so typecheck, ownership,
> codegen, the `.wob` format and the VM are all untouched, and the
> standing "no VM/database changes" directive was never in play. Two new
> lexing diagnostics ride along (WO-E004 unterminated raw literal,
> WO-E005 newline inside a quoted string — the latter closing a silent
> file-swallowing hole that existed since Task 3). The COMPONENT half of
> this story — the `Component` interface, `Layout` with slots, the site
> migrating onto them — is untouched and still pending.
>
> **LANDED 2026-08-25** — the component half, and with it the iteration.
> wo-html gained `Component` (structural, the view twin of the
> framework's `Handler`), `render_all` and `Layout`; fork #4 is settled
> by MOVING `ok_html` into the framework beside `ok_text`/`ok_json`
> (transport, not rendering — both HTML samples had hand-rolled the same
> four lines), and fork #5 by migrating BOTH samples. Fork #2 stands as
> written: no directive surface ships. Layout slots are pre-rendered
> `Text`, not `multi Component` — the compositional variant was proven
> possible and deliberately not taken for the layout, though the PAGE
> components do use it.
## Why this iteration exists
wo-html today is element builders + one utility sheet: pages are
functions concatenating Text. That works (the site proves it) but has
no unit of reuse bigger than a function — no way to say "this fragment
owns its data, its markup, and its place in a layout" and hand it
around. Angular's component FORMAT — a class declaring its inputs, a
template rendering them, composition by nesting, structural directives
for repetition and choice — is the studied precedent: the FORMAT
translates to server-rendered `.wo`; the client-side half (change
detection, event bindings, SPA router) deliberately does not.
## What Angular's format maps to (the study, summarized)
| Angular | wo-html translation | doctrine fit |
| --- | --- | --- |
| `@Component` class with `@Input()`s | a class whose FIELDS are the inputs, satisfying a structural `Component` interface (`fn render() -> Text`) | behavior-as-class; no closures needed |
| template (`{{ expr }}`) | the render method's interpolation — `.wo` already has `${...}` in Text | no template dialect: templates ARE code |
| `*ngFor` / `*ngIf` | explicit `for`/`if` in render() building Text — the language's own control flow | no structural-directive mini-language |
| content projection (`<ng-content>`) | a layout component taking pre-rendered `Text` slots as fields | slots are ordinary values |
| services/DI | no translation — a component reads its fields; queries stay in handlers (M and V stay separate) | rejected: DI needs function values |
| event bindings `(click)` / two-way `[(ngModel)]` | no translation — server-rendered, no JS doctrine; forms stay `form_post` round trips | rejected surface |
## Goals
- **A `Component` interface in wo-html**: structural (`fn render() ->
Text`), so any class with fields + render satisfies it — the view
twin of the framework's `Handler`. Composition is nesting: a parent's
render calls children's render.
- **The MVC seam stated**: Model = `@table` rows queried in the HANDLER,
moved into component fields; View = components rendering Text;
Controller = the framework handler wiring them. The library documents
the seam; it never queries.
- **Layout components with slots**: the site's nav/shell/footer become
the proof — a `Layout { title, nav, content, footer }` component
replacing today's `shell()` functions, chapter pages and homepage
composing it.
- **The site sample migrates** as acceptance: same rendered bytes (or
deliberately better), gate stays green — the library grew a floor, not
a rewrite.
## Acceptance Criteria (draft — the spec refines)
- **Given** a class with fields and `fn render() -> Text`, **when** a
handler moves data in and calls render, **then** the page it serves is
byte-deterministic from the fields — no hidden state, no globals.
- **Given** nested components (layout → section → card), **when** the
outer render runs, **then** children render through the same
structural interface, and escaping stays the caller-explicit `esc()`
rule at every level.
- **Given** the migrated site sample, **when** `just site` runs,
**then** 11/0 — the gate is the proof the component layer reproduces
the existing pages.
- **Given** a component reused across two pages (the chapters card on
home and chapter pages), **when** either page changes its data,
**then** the other's markup is untouched — reuse is real, not copied.
## Out Of Scope
- 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 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]`.
- CSS componentization (scoped styles) — the utility sheet stays one
static string; measure pain first.
## Info
Forks the spec must settle (REVISED 2026-08-23 for the compiled-template
direction):
1. ~~**The raw-literal spelling**~~ — SETTLED 2026-08-24 and landed:
backticks, content verbatim (no escape processing), common margin
removed at lex time, `${ }` raw and `{{ }}` escaping. A literal
backtick, `${` or `{{` is written by concatenating a `"..."` string
with `..` — one greppable door instead of an escape character in the
one form whose point is not having any.
2. ~~**Directive surface**~~ — SETTLED 2026-08-25 as written: v1 ships
NONE. Structural control is `if`/`for` composing literals, and two
migrated samples produced no case that wanted more. `w:if`/`w:for`
and separate `.html` files remain the later option — and a
COMPILE-TIME include is the only shape either could honestly take,
since a file read per request is the rejected engine.
3. ~~**Escaping default**~~ — SETTLED 2026-08-24 and landed: `{{ }}`
escapes ALWAYS, through whatever `esc` is in scope (wo-html's, or a
local one that shadows it deliberately). `${ }` is the raw door.
Proven byte-identical against the hand-written `esc()` calls it
replaces, hostile input included.
4. ~~**The framework seam**~~ — SETTLED 2026-08-25: `ok_html` MOVED
into `framework/http/types.wo`; no `respond(c)` sugar, because
`ok_html(c.render())` already is it.
5. ~~**Migration depth**~~ — SETTLED 2026-08-25: BOTH, site gated and
shop hand-driven; 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`
addition if deeper study is wanted), Go's `html/template` as the
server-side contrast, and the site sample as the living consumer.
## Landed 2026-08-24 — the raw text literal
The compiler slice, and nothing else. `compiler/src/lexer.ml` gained one
branch: a backtick opens a literal whose content is verbatim to the
closing backtick, newlines included, with two hole forms — `${ }` raw
and `{{ }}` escaped — and the common source margin removed before the
token is emitted (Java's text-block rule). `token.ml` gained one
`str_part` variant to carry the escaped hole; `parser.ml`'s
`desugar_interp` wraps it in a call to `esc`; `dump.ml` labels it. That
is the entire compiler surface. Nothing in typecheck, ownership,
codegen, the `.wob` format or the VM changed, because the literal emits
the same `Str`/`InterpStr` token a `"..."` string always did.
Proven: `woc-test` 554/0 with new token and AST goldens; `oop-e2e`
115/0 with a run fixture for the literal and a compile-fail fixture for
WO-E004; `just site` 11/0 and `just web-app` 46/0 after wo-html's
builders and the site's two chapter snippets migrated onto the form; a
parity program comparing every migrated wo-html builder's old and new
output on hostile input (`<`, `>`, `&`, `"`) — byte-identical in all
eight. `page()` is the one deliberate byte change: four newlines now
sit inside `<head>`, where whitespace is insignificant, and none inside
`<body>`. Every `.wo` in the repo was re-lexed: no file gained a
diagnostic.
The shop template migrated too — five view files, every `render()` body
now one literal with real double-quoted attributes and no `esc()` calls
— verified by building it against local `file://` dep remotes and driving
every route, since no `just` recipe gates it.
## Landed 2026-08-25 — the component half
`wo-html` grew three things and no more: `pub interface Component { fn
render() -> Text }`, `pub fn render_all(cs: multi Component) -> Text`,
and `pub class Layout { title, nav, content, footer }` whose slots are
pre-rendered Text. The MVC seam is stated in the library's own header
and README, and the library still contains no query.
A finding that shaped the design: **`multi Component` holds a
heterogeneous list directly** — no wrapper record. The framework's
`Mw`/`Aw` wrappers had suggested otherwise; they are not a language
requirement. That is what let a page component hold its children as
`multi Component` and call `render_all` on them, which is where the
interface actually earns its place — an interface nothing consumes as a
TYPE would have been decoration.
Fork #4 settled by moving: `ok_html` now lives in `framework/http/types.wo`
beside `ok_text`/`ok_json`, and both duplicate copies are deleted. No
`respond(c: Component)` sugar — `ok_html(c.render())` is already the
whole thing, and a second spelling would earn nothing.
The site migrated: `shell`/`shell_wide` fill `Layout` (byte-identical —
`Layout.render()` is `page(title, nav .. content .. footer)`, exactly
what the old functions built), and `chapter_nav` became a `ChapterNav`
component whose query moved out into `chapter_links()`, called by the
handlers. The homepage and every chapter page now render the SAME
component with a different `current` — acceptance criterion 4, met by
construction rather than by inspection.
The shop migrated further than planned, because its views already had
`render()`: `app_shell()` became an `AppShell` component, and
`ProductListPage`/`OrdersPage` now hold `multi Component` children
instead of a concatenated Text blob, with the queries in the
controllers. `AppShell` is deliberately NOT `Layout` — the template
links a real stylesheet where `Layout` inlines `tw_css()`, and that
difference is the point of it having its own shell.
**Restructured 2026-08-25 (follow-on):** the site sample was then laid
out like the program template — `types.wo` for the model, a `layout/`
module for the chrome, `home/` and `chapter/` view modules, one
`*.controller.wo` per feature in the root module, and a `main.wo` that
is bootstrap and nothing else. `main.wo` went from 231 lines holding
everything to 44 lines holding routes. Both samples now read the same
way, which was the point: the template teaches a shape, and the site
should not contradict it.
Proven: `just site` 11/0 (the gate, and the escape check still passes),
`just web-app` 46/0 (the framework gained a function), `woc-test` and
`oop-e2e` untouched and green. The shop was built against local `file://`
dep remotes and driven through every route — product grid (4 cards
rendered as child components), product page with its form, buy,
`/orders` (rows as child components), the empty-orders branch, 404, 409,
and `/assets/*`.
## Proposed Solution
Brainstorm → spec → plan (the superpowers path): settle the four forks,
grow wo-html by the `Component` interface + a `Layout` proof, migrate
the site sample as acceptance, keep the framework untouched. Ships
independently of the concurrency chain; slots wherever the developer
schedules it.

View file

@ -1,147 +0,0 @@
---
iteration: "37"
status: refine
---
# Iteration 37 — wo-html components: an MVC-shaped view layer (Angular's format, studied)
> Format: fiberloom `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](../00-story.md).
>
> **Inserted 2026-08-23** (developer ask: "enhance wo-html like MVC;
> understand Angular format"). Grows the wo-html LIBRARY, never the
> framework — the 2026-08-20 micro-framework directive stands: routing/
> middleware/`Req`/`Resp` stay MVC-free, and the view layer lives in its
> 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. **RE-POINTED 2026-08-24**
> (developer counter-proposal): the leaning is now **in-class raw
> template literals**, not separate `.html` files — `render()` RETURNS
> a raw multi-line string literal whose `{{ expr }}` holes are
> auto-escaped, compile-time-checked interpolations in class scope (a
> typo'd field is a compile error). Compiler surface shrinks to one
> lexer addition (the raw literal — a general language win: multi-line
> text without `\"` noise) plus one desugar; no file pairing, no
> `w:component` sections. Structural control stays the language's own
> `if`/`for` composing literals; `w:if`/`w:for` attributes and separate
> `.html` files are DEMOTED to a later option that can layer on without
> breaking this form. Escaping: `{{ }}` always escapes; the raw/slot
> spelling is the one greppable door. A runtime mustache-lite stays
> REJECTED (reflection-free means untyped `map<Text, Text>`); client-
> side reactivity (`ref`, `@click`, `v-model`) stays out under the
> no-JS posture — forms and POSTs instead. Still a COMPILER iteration,
> parked behind the standing "no compiler/VM/database changes yet"
> directive. The shop template
> ([`docs/examples/shop`](../../../examples/shop/README.md)) is the
> consumer: each `render()` body becomes one raw template literal, 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
wo-html today is element builders + one utility sheet: pages are
functions concatenating Text. That works (the site proves it) but has
no unit of reuse bigger than a function — no way to say "this fragment
owns its data, its markup, and its place in a layout" and hand it
around. Angular's component FORMAT — a class declaring its inputs, a
template rendering them, composition by nesting, structural directives
for repetition and choice — is the studied precedent: the FORMAT
translates to server-rendered `.wo`; the client-side half (change
detection, event bindings, SPA router) deliberately does not.
## What Angular's format maps to (the study, summarized)
| Angular | wo-html translation | doctrine fit |
| --- | --- | --- |
| `@Component` class with `@Input()`s | a class whose FIELDS are the inputs, satisfying a structural `Component` interface (`fn render() -> Text`) | behavior-as-class; no closures needed |
| template (`{{ expr }}`) | the render method's interpolation — `.wo` already has `${...}` in Text | no template dialect: templates ARE code |
| `*ngFor` / `*ngIf` | explicit `for`/`if` in render() building Text — the language's own control flow | no structural-directive mini-language |
| content projection (`<ng-content>`) | a layout component taking pre-rendered `Text` slots as fields | slots are ordinary values |
| services/DI | no translation — a component reads its fields; queries stay in handlers (M and V stay separate) | rejected: DI needs function values |
| event bindings `(click)` / two-way `[(ngModel)]` | no translation — server-rendered, no JS doctrine; forms stay `form_post` round trips | rejected surface |
## Goals
- **A `Component` interface in wo-html**: structural (`fn render() ->
Text`), so any class with fields + render satisfies it — the view
twin of the framework's `Handler`. Composition is nesting: a parent's
render calls children's render.
- **The MVC seam stated**: Model = `@table` rows queried in the HANDLER,
moved into component fields; View = components rendering Text;
Controller = the framework handler wiring them. The library documents
the seam; it never queries.
- **Layout components with slots**: the site's nav/shell/footer become
the proof — a `Layout { title, nav, content, footer }` component
replacing today's `shell()` functions, chapter pages and homepage
composing it.
- **The site sample migrates** as acceptance: same rendered bytes (or
deliberately better), gate stays green — the library grew a floor, not
a rewrite.
## Acceptance Criteria (draft — the spec refines)
- **Given** a class with fields and `fn render() -> Text`, **when** a
handler moves data in and calls render, **then** the page it serves is
byte-deterministic from the fields — no hidden state, no globals.
- **Given** nested components (layout → section → card), **when** the
outer render runs, **then** children render through the same
structural interface, and escaping stays the caller-explicit `esc()`
rule at every level.
- **Given** the migrated site sample, **when** `just site` runs,
**then** 11/0 — the gate is the proof the component layer reproduces
the existing pages.
- **Given** a component reused across two pages (the chapters card on
home and chapter pages), **when** either page changes its data,
**then** the other's markup is untouched — reuse is real, not copied.
## Out Of Scope
- 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 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]`.
- CSS componentization (scoped styles) — the utility sheet stays one
static string; measure pain first.
## Info
Forks the spec must settle (REVISED 2026-08-23 for the compiled-template
direction):
1. **The raw-literal spelling** — backticks, triple quotes, or another
delimiter; and the raw-slot spelling inside it (`{!! !!}` vs `${}`
staying unescaped). One rule: `{{ }}` always escapes.
2. **Directive surface** — v1 ships NONE (structural control is `if`/
`for` composing literals); `w:if`/`w:for` and separate `.html` files
layer on later only if a sample proves the need.
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?
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`
addition if deeper study is wanted), Go's `html/template` as the
server-side contrast, and the site sample as the living consumer.
## Proposed Solution
Brainstorm → spec → plan (the superpowers path): settle the four forks,
grow wo-html by the `Component` interface + a `Layout` proof, migrate
the site sample as acceptance, keep the framework untouched. Ships
independently of the concurrency chain; slots wherever the developer
schedules it.

View file

@ -7,21 +7,19 @@ class Q {
}
class IntAnswer {
pad: Int
fn receive(msg: Q) -> Int {
return msg.n;
}
}
class BoolAnswer {
pad: Int
fn receive(msg: Q) -> Bool {
return msg.n > 0;
}
}
fn main() -> Int {
let a: actor Q = spawn IntAnswer { pad: 0 };
let a: actor Q = spawn IntAnswer {};
let r = call(a, Q { n: 1 });
return r;
}

View file

@ -0,0 +1 @@
WO-E403

View file

@ -0,0 +1,8 @@
-- A `{{ ... }}` hole desugars to a call to `esc`, so a program that
-- uses one without an `esc` in scope fails at the literal's position
-- with a name it never typed -- the diagnostic says why.
fn main() -> Int {
let n = "x";
print(`<p>{{ n }}</p>`);
return 0;
}

View file

@ -0,0 +1 @@
WO-E004

View file

@ -0,0 +1,10 @@
-- WO-E004: a raw text literal with no closing backtick. Unlike a plain
-- unterminated "..." string (silent, rt parity), this is reported --
-- multi-line is this form's normal case, so the rest of the file would
-- otherwise vanish into the literal with nothing said.
fn main() -> Int {
let page = `<div>
never closed
print(page);
return 0;
}

View file

@ -5,14 +5,13 @@ class Note {
}
class Sink {
pad: Int
fn receive(msg: Note) {
print(msg.text);
}
}
fn main() -> Int {
let a = spawn Sink { pad: 0 };
let a = spawn Sink {};
let n = Note { text: "gone" };
send(a, n);
print(n.text);

View file

@ -12,13 +12,12 @@ class Box {
}
class Keeper {
pad: Int
fn receive(msg: Box) {
if msg.head == nil { print("empty"); } else { print("full"); }
}
}
fn main() -> Int {
let a = spawn Keeper { pad: 0 };
let a = spawn Keeper {};
return 0;
}

View file

@ -8,7 +8,6 @@ class Q {
}
class Bomb {
pad: Int
fn receive(msg: Q) -> Int {
return msg.n / (msg.n - msg.n); -- DIV0: the actor dies mid-call
}
@ -20,7 +19,7 @@ fn call_one(b: actor Q, n: Int) -> Text {
}
fn main() -> Int {
let b: actor Q = spawn Bomb { pad: 0 };
let b: actor Q = spawn Bomb {};
let first = try call_one(b, 7) catch (e) e.msg;
print("mid-call: ${first}");
let second = try call_one(b, 8) catch (e) e.msg;

View file

@ -0,0 +1,2 @@
one two three
n=1 done

View file

@ -0,0 +1,13 @@
-- A line ending in `..` continues the expression on the next line —
-- the one newline suppression in the language (multi-line HTML/text).
fn main() -> Int {
let t = "one" ..
" two" ..
" three";
print(t);
let n = 1;
let u = "n=${n}" ..
" done";
print(u);
return 0;
}

View file

@ -11,7 +11,6 @@ class Tick {
}
class Sleeper {
pad: Int
fn receive(msg: Tick) {
time.sleep(5000);
}
@ -23,7 +22,7 @@ fn send_one(s: actor Tick, n: Int) -> Text {
}
fn main() -> Int {
let s: actor Tick = spawn Sleeper { pad: 0 };
let s: actor Tick = spawn Sleeper {};
let sent = 0;
let caught = "";
let i = 0;

View file

@ -0,0 +1,5 @@
<div class="card">
<h3>&lt;script&gt;</h3>
<p><script></p>
</div>
no newline, no margin rule

View file

@ -0,0 +1,35 @@
-- iteration 37: the backtick raw text literal. Markup is written as
-- markup -- one literal, real newlines, quotes verbatim -- and the
-- source indentation is removed at compile time, so the served bytes
-- carry the markup's own nesting and not the method's.
-- `${...}` interpolates raw; `{{ ... }}` HTML-escapes through the `esc`
-- in scope (here a local one, which is exactly how a program overrides
-- wo-html's).
fn esc(t: Text) -> Text {
let out = "";
let i = 0;
let n = len(t);
while i < n {
let b = byte_at(t, i);
if b == 60 { out = out .. "&lt;"; }
else {
if b == 62 { out = out .. "&gt;"; }
else { out = out .. substr(t, i, 1); }
}
i = i + 1;
}
return out;
}
fn main() -> Int {
let name = "<script>";
let card = `
<div class="card">
<h3>{{ name }}</h3>
<p>${name}</p>
</div>`;
print(card);
let plain = `no newline, no margin rule`;
print(plain);
return 0;
}