diff --git a/compiler/src/CODE-LOGIC.md b/compiler/src/CODE-LOGIC.md
index 4f8549e..238780e 100644
--- a/compiler/src/CODE-LOGIC.md
+++ b/compiler/src/CODE-LOGIC.md
@@ -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.
diff --git a/compiler/src/dump.ml b/compiler/src/dump.ml
index 95722d8..11ed6e2 100644
--- a/compiler/src/dump.ml
+++ b/compiler/src/dump.ml
@@ -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"
diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml
index 271f71f..01cc8d3 100644
--- a/compiler/src/emit.ml
+++ b/compiler/src/emit.ml
@@ -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) -> (
diff --git a/compiler/src/lexer.ml b/compiler/src/lexer.ml
index b9782ba..d5c4f72 100644
--- a/compiler/src/lexer.ml
+++ b/compiler/src/lexer.ml
@@ -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
diff --git a/compiler/src/parser.ml b/compiler/src/parser.ml
index c1ecff0..8734d82 100644
--- a/compiler/src/parser.ml
+++ b/compiler/src/parser.ml
@@ -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
diff --git a/compiler/src/token.ml b/compiler/src/token.ml
index 5f71a0c..bf9515b 100644
--- a/compiler/src/token.ml
+++ b/compiler/src/token.ml
@@ -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 *)
diff --git a/compiler/test/golden/ast/raw-literal.expected b/compiler/test/golden/ast/raw-literal.expected
new file mode 100644
index 0000000..babebc8
--- /dev/null
+++ b/compiler/test/golden/ast/raw-literal.expected
@@ -0,0 +1,2 @@
+1:1 METHOD page(name: Text) -> Text
+ 2:3 RETURN "
" .. INTERP(name) .. esc(INTERP(name)) .. "
"
diff --git a/compiler/test/golden/ast/raw-literal.wo b/compiler/test/golden/ast/raw-literal.wo
new file mode 100644
index 0000000..2d095f8
--- /dev/null
+++ b/compiler/test/golden/ast/raw-literal.wo
@@ -0,0 +1,3 @@
+fn page(name: Text) -> Text {
+ return `
))
+13:41 NEWLINE
+14:3 KW_RETURN
+14:10 IDENT(block)
+14:15 NEWLINE
+15:1 RBRACE
+15:2 NEWLINE
+16:1 EOF
diff --git a/compiler/test/golden/tokens/raw-literal.wo b/compiler/test/golden/tokens/raw-literal.wo
new file mode 100644
index 0000000..5680827
--- /dev/null
+++ b/compiler/test/golden/tokens/raw-literal.wo
@@ -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 = `
hi
`
+ let verbatim = `a"b\n`
+ let block = `
+
+ many
+ line
+
+ `
+ let holes = `
${name}{{ name }}
`
+ return block
+}
diff --git a/compiler/test/runner.ml b/compiler/test/runner.ml
index 61c8875..10e969d 100644
--- a/compiler/test/runner.ml
+++ b/compiler/test/runner.ml
@@ -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 = `
hi
`" 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 "
hi
"; 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
\n many\n
\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 "
\n many\n
\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" "`
${a}{{ b }}
`" 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 "
";
+ 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 "
";
+ ];
+ 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,
diff --git a/docs/examples/db-actor/main.wo b/docs/examples/db-actor/main.wo
index e879608..b4b47c1 100644
--- a/docs/examples/db-actor/main.wo
+++ b/docs/examples/db-actor/main.wo
@@ -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
diff --git a/docs/examples/fibers/main.wo b/docs/examples/fibers/main.wo
index f7c4025..debb25d 100644
--- a/docs/examples/fibers/main.wo
+++ b/docs/examples/fibers/main.wo
@@ -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 {
diff --git a/docs/examples/shop/README.md b/docs/examples/shop/README.md
index 216ae42..0592262 100644
--- a/docs/examples/shop/README.md
+++ b/docs/examples/shop/README.md
@@ -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 `
+
`;
+}
+```
+
+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 `` (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 `` (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.
diff --git a/docs/examples/shop/assets/style.css b/docs/examples/shop/assets/style.css
index cdafc59..715045e 100644
--- a/docs/examples/shop/assets/style.css
+++ b/docs/examples/shop/assets/style.css
@@ -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. */
diff --git a/docs/examples/shop/layout/app.wo b/docs/examples/shop/layout/app.wo
index 0f33d52..f0d43d4 100644
--- a/docs/examples/shop/layout/app.wo
+++ b/docs/examples/shop/layout/app.wo
@@ -5,32 +5,46 @@
use framework/http
use html
-pub fn app_shell(title: Text, content: Text) -> Text {
- let h = "";
- h = h .. "";
- h = h .. "";
- h = h .. "${esc(title)}";
- h = h .. "";
- h = h .. "";
- h = h .. header();
- h = h .. "${content}";
- h = h .. footer();
- h = h .. "";
- 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 `
+
+
+
+
+
+ {{ self.title }}
+
+
+
+ ${header()}
+ ${self.content}
+ ${footer()}
+
+ `;
+ }
}
--- The one transport helper every controller shares: a 200 HTML Resp.
-pub fn ok_html(body: Text) -> Resp {
- let h: map = {};
- h["content-type"] = "text/html; charset=utf-8";
- return Resp { status: 200, headers: h, body: body };
-}
+-- `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 = "
`;
}
}
diff --git a/docs/examples/shop/product_list.controller.wo b/docs/examples/shop/product_list.controller.wo
deleted file mode 100644
index 6035d08..0000000
--- a/docs/examples/shop/product_list.controller.wo
+++ /dev/null
@@ -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()));
- }
-}
diff --git a/docs/examples/shop/product_list/controller.wo b/docs/examples/shop/product_list/controller.wo
new file mode 100644
index 0000000..b708cdb
--- /dev/null
+++ b/docs/examples/shop/product_list/controller.wo
@@ -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());
+ }
+}
diff --git a/docs/examples/shop/product_list/view.wo b/docs/examples/shop/product_list/view.wo
index 80dbac3..fa7bdc8 100644
--- a/docs/examples/shop/product_list/view.wo
+++ b/docs/examples/shop/product_list/view.wo
@@ -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 = "
`;
}
}
+-- 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 = "
Products
";
- h = h .. "
${self.cards}
";
- return h;
+ return `
+
Products
+
${render_all(self.cards)}
`;
}
}
diff --git a/docs/examples/shop/product_page.controller.wo b/docs/examples/shop/product_page/controller.wo
similarity index 74%
rename from docs/examples/shop/product_page.controller.wo
rename to docs/examples/shop/product_page/controller.wo
index 8023481..0023859 100644
--- a/docs/examples/shop/product_page.controller.wo
+++ b/docs/examples/shop/product_page/controller.wo
@@ -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());
}
}
diff --git a/docs/examples/shop/product_page/view.wo b/docs/examples/shop/product_page/view.wo
index e3e3fb1..24127b6 100644
--- a/docs/examples/shop/product_page/view.wo
+++ b/docs/examples/shop/product_page/view.wo
@@ -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 = "
";
- h = h .. "
${esc(self.name)}
";
- h = h .. "
€ ${self.price}
";
- h = h .. "
${self.stock} in stock
";
- if self.stock > 0 {
- h = h .. "";
- } else {
- h = h .. "
sold out
";
+ let action = `
+ `;
+ if self.stock == 0 {
+ action = `
`;
}
}
diff --git a/docs/examples/site/CODE-LOGIC.md b/docs/examples/site/CODE-LOGIC.md
index fa17165..bb70c53 100644
--- a/docs/examples/site/CODE-LOGIC.md
+++ b/docs/examples/site/CODE-LOGIC.md
@@ -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 ``, 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).
diff --git a/docs/examples/site/README.md b/docs/examples/site/README.md
index 82f0e1d..57b91b4 100644
--- a/docs/examples/site/README.md
+++ b/docs/examples/site/README.md
@@ -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
diff --git a/docs/examples/site/admin/controller.wo b/docs/examples/site/admin/controller.wo
new file mode 100644
index 0000000..3052464
--- /dev/null
+++ b/docs/examples/site/admin/controller.wo
@@ -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}");
+ }
+}
diff --git a/docs/examples/site/chapter/controller.wo b/docs/examples/site/chapter/controller.wo
new file mode 100644
index 0000000..c79d80d
--- /dev/null
+++ b/docs/examples/site/chapter/controller.wo
@@ -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());
+ }
+}
diff --git a/docs/examples/site/chapter/view.wo b/docs/examples/site/chapter/view.wo
new file mode 100644
index 0000000..b359b3e
--- /dev/null
+++ b/docs/examples/site/chapter/view.wo
@@ -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;
+ }
+}
diff --git a/docs/examples/site/content.wo b/docs/examples/site/content.wo
index 0eb8340..086ab40 100644
--- a/docs/examples/site/content.wo
+++ b/docs/examples/site/content.wo
@@ -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 .wo files and one entry: a free " .. "function named main. It returns the process exit code. There is no " .. "runtime to install separately and no build pipeline — woc build 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 wo.toml; wo.lock " .. "records the exact revision, and locked builds work offline. The [deps] KEY names the " .. "module you use. 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 :param captures, a middleware " .. "chain, handler classes, @table 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 (bearer_token, constant-time ct_eq), POLICY stays " .. "in the app. Try editing this chapter: " .. "curl -X POST -H \"authorization: Bearer ...\" -d \"title=...&body=...\" /admin/ch/serving.");
return b;
diff --git a/docs/examples/site/health/controller.wo b/docs/examples/site/health/controller.wo
new file mode 100644
index 0000000..d3c34ce
--- /dev/null
+++ b/docs/examples/site/health/controller.wo
@@ -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");
+ }
+}
diff --git a/docs/examples/site/home/controller.wo b/docs/examples/site/home/controller.wo
new file mode 100644
index 0000000..8f2fd1f
--- /dev/null
+++ b/docs/examples/site/home/controller.wo
@@ -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());
+ }
+}
diff --git a/docs/examples/site/home/view.wo b/docs/examples/site/home/view.wo
new file mode 100644
index 0000000..3631dad
--- /dev/null
+++ b/docs/examples/site/home/view.wo
@@ -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. 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);
+}
diff --git a/docs/examples/site/layout/app.wo b/docs/examples/site/layout/app.wo
new file mode 100644
index 0000000..d367269
--- /dev/null
+++ b/docs/examples/site/layout/app.wo
@@ -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 = {};
+ h["content-type"] = "text/html; charset=utf-8";
+ return Resp { status: status, headers: h, body: shell.render() };
+}
diff --git a/docs/examples/site/layout/footer.wo b/docs/examples/site/layout/footer.wo
new file mode 100644
index 0000000..e2ab3b2
--- /dev/null
+++ b/docs/examples/site/layout/footer.wo
@@ -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.");
+}
diff --git a/docs/examples/site/layout/header.wo b/docs/examples/site/layout/header.wo
new file mode 100644
index 0000000..4adee3c
--- /dev/null
+++ b/docs/examples/site/layout/header.wo
@@ -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);
+}
diff --git a/docs/examples/site/main.wo b/docs/examples/site/main.wo
index 7df67e9..65b5c89 100644
--- a/docs/examples/site/main.wo
+++ b/docs/examples/site/main.wo
@@ -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 = {};
- 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. 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 = {};
- 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);
}
diff --git a/docs/examples/site/types.wo b/docs/examples/site/types.wo
new file mode 100644
index 0000000..b1cb9fb
--- /dev/null
+++ b/docs/examples/site/types.wo
@@ -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();
+ }
+}
diff --git a/docs/examples/web-app/main.wo b/docs/examples/web-app/main.wo
index 76a9d81..469ad21 100644
--- a/docs/examples/web-app/main.wo
+++ b/docs/examples/web-app/main.wo
@@ -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 = {};
@@ -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;
}
diff --git a/docs/examples/wo-html/README.md b/docs/examples/wo-html/README.md
new file mode 100644
index 0000000..cd3030b
--- /dev/null
+++ b/docs/examples/wo-html/README.md
@@ -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 `
+
`;
+ }
+}
+```
+
+Composition is nesting — a parent holds children and calls their render:
+
+```
+pub class ProductListPage {
+ cards: multi Component
+ fn render() -> Text {
+ return `
${render_all(self.cards)}
`;
+ }
+}
+```
+
+`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 `` 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`, 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.
diff --git a/docs/examples/wo-html/html.wo b/docs/examples/wo-html/html.wo
index 70adfdf..20a372b 100644
--- a/docs/examples/wo-html/html.wo
+++ b/docs/examples/wo-html/html.wo
@@ -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 "${label}"; }
- return "${label}";
+ if cls == "" { return `${label}`; }
+ return `${label}`;
}
-- 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 "";
+ return ``;
}
pub fn text_area(name: Text, value: Text, rows: Int) -> Text {
- return "";
+ return ``;
}
pub fn submit_btn(label: Text) -> Text {
- return "";
+ return ``;
}
pub fn form_post(action: Text, inner: Text) -> Text {
- return "";
+ return ``;
}
-- 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 = "";
- d = d .. "";
- d = d .. "${esc(title)}";
- d = d .. "";
- d = d .. body;
- d = d .. "";
- return d;
+ -- The whole document as one literal. Every newline here lands
+ -- inside , where whitespace is insignificant; the body hole
+ -- and its closing tags share one line so nothing is inserted into
+ -- the rendered content.
+ return `
+
+
+ {{ title }}
+
+ ${body}`;
+}
+
+-- ---- 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 `` 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);
+ }
}
diff --git a/docs/examples/writeonce-framework/app.wo b/docs/examples/writeonce-framework/app.wo
index 78ec0b4..12a9cd4 100644
--- a/docs/examples/writeonce-framework/app.wo
+++ b/docs/examples/writeonce-framework/app.wo
@@ -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);
--
diff --git a/docs/examples/writeonce-framework/http/secure.wo b/docs/examples/writeonce-framework/http/secure.wo
index 84aa26e..db8079e 100644
--- a/docs/examples/writeonce-framework/http/secure.wo
+++ b/docs/examples/writeonce-framework/http/secure.wo
@@ -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";
diff --git a/docs/examples/writeonce-framework/http/types.wo b/docs/examples/writeonce-framework/http/types.wo
index 1287576..9725cc5 100644
--- a/docs/examples/writeonce-framework/http/types.wo
+++ b/docs/examples/writeonce-framework/http/types.wo
@@ -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 = {};
+ 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 = {};
h["content-type"] = "application/json";
diff --git a/docs/examples/writeonce-framework/router/router.wo b/docs/examples/writeonce-framework/router/router.wo
index 5a626cc..069df7b 100644
--- a/docs/examples/writeonce-framework/router/router.wo
+++ b/docs/examples/writeonce-framework/router/router.wo
@@ -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;
diff --git a/docs/guides/language-surface.md b/docs/guides/language-surface.md
new file mode 100644
index 0000000..c77b872
--- /dev/null
+++ b/docs/guides/language-surface.md
@@ -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 = ` — 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` |
+| 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 `= ` 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 `, `for k, v in