feat(lang+wo-html): raw text literals, component layer, MVC samples
- lexer: backtick raw text literal — content verbatim, no escape
processing, common source margin removed at lex time; `${ }` raw and
`{{ }}` auto-escaping holes
- `{{ e }}` desugars to `esc(${e})` in parser.ml — a Call on the `esc`
in scope, so types/owner/emit/.wob/VM are untouched
- WO-E004 unterminated raw literal; WO-E005 newline inside "..." —
closes a hole where a missing quote silently ate the rest of the file
- wo-html: `Component` interface, `render_all`, `Layout`, README
- framework: `ok_html` joins ok_text/ok_json in http/types.wo
- site + shop restructured to one-feature-one-module MVC (view +
controller per directory, model at the root, bootstrap-only main)
- removed the filler `pad: Int` convention — verified unnecessary for
plain classes, interface dispatch, containers and actors
- corrected recorded claims: gap #1 blocks neither the build nor the
layout; a class crosses module lines, only a free fn is scoped
- docs/guides/language-surface.md — the full grammar inventory
- story 37 landed and moved to done/
Gates: oop-accept MET, oop-e2e 116/0, woc-test 556/0, site 11/0,
web-app 46/0, fibers 10/0, db-actor 8/0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a4743edcc6
commit
23550e7021
67 changed files with 2022 additions and 572 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) -> (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 *)
|
||||
|
|
|
|||
2
compiler/test/golden/ast/raw-literal.expected
Normal file
2
compiler/test/golden/ast/raw-literal.expected
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
1:1 METHOD page(name: Text) -> Text
|
||||
2:3 RETURN "<p>" .. INTERP(name) .. esc(INTERP(name)) .. "</p>"
|
||||
3
compiler/test/golden/ast/raw-literal.wo
Normal file
3
compiler/test/golden/ast/raw-literal.wo
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn page(name: Text) -> Text {
|
||||
return `<p>${name}{{ name }}</p>`
|
||||
}
|
||||
42
compiler/test/golden/tokens/raw-literal.expected
Normal file
42
compiler/test/golden/tokens/raw-literal.expected
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
1:71 NEWLINE
|
||||
4:1 KW_FN
|
||||
4:4 IDENT(page)
|
||||
4:8 LPAREN
|
||||
4:9 IDENT(name)
|
||||
4:13 COLON
|
||||
4:15 IDENT(Text)
|
||||
4:19 RPAREN
|
||||
4:21 ARROW
|
||||
4:24 IDENT(Text)
|
||||
4:29 LBRACE
|
||||
4:30 NEWLINE
|
||||
5:3 KW_LET
|
||||
5:7 IDENT(one)
|
||||
5:11 EQ
|
||||
5:13 STR(<div>hi</div>)
|
||||
5:28 NEWLINE
|
||||
6:3 KW_LET
|
||||
6:7 IDENT(verbatim)
|
||||
6:16 EQ
|
||||
6:18 STR(a"b\n)
|
||||
6:25 NEWLINE
|
||||
7:3 KW_LET
|
||||
7:7 IDENT(block)
|
||||
7:13 EQ
|
||||
7:15 STR(<div class="card">
|
||||
many
|
||||
line
|
||||
</div>
|
||||
)
|
||||
12:4 NEWLINE
|
||||
13:3 KW_LET
|
||||
13:7 IDENT(holes)
|
||||
13:13 EQ
|
||||
13:15 INTERP_STR(TEXT(<p>),EXPR(name),TEXT(),ESC( name ),TEXT(</p>))
|
||||
13:41 NEWLINE
|
||||
14:3 KW_RETURN
|
||||
14:10 IDENT(block)
|
||||
14:15 NEWLINE
|
||||
15:1 RBRACE
|
||||
15:2 NEWLINE
|
||||
16:1 EOF
|
||||
15
compiler/test/golden/tokens/raw-literal.wo
Normal file
15
compiler/test/golden/tokens/raw-literal.wo
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- iteration 37: the backtick raw text literal. One token per literal,
|
||||
-- content verbatim (no escape processing), common margin removed at
|
||||
-- lex time, two hole forms -- ${} raw and {{}} escaped.
|
||||
fn page(name: Text) -> Text {
|
||||
let one = `<div>hi</div>`
|
||||
let verbatim = `a"b\n`
|
||||
let block = `
|
||||
<div class="card">
|
||||
many
|
||||
line
|
||||
</div>
|
||||
`
|
||||
let holes = `<p>${name}{{ name }}</p>`
|
||||
return block
|
||||
}
|
||||
|
|
@ -236,6 +236,128 @@ let () =
|
|||
check "dash-continuation: a - b (spaced) lexes as Ident, Dash, Ident"
|
||||
(kinds = [ Token.Ident "a"; Token.Dash; Token.Ident "b"; Token.Eof ])
|
||||
|
||||
(* ---- raw text literal, iteration 37 (not golden-diffed) ------------
|
||||
|
||||
golden/tokens/raw-literal.wo pins the token STREAM; these pin the
|
||||
pieces a dump cannot show: that a backtick literal with no holes is
|
||||
byte-identical to the Str a "..." string would have produced, that
|
||||
the common margin is removed at LEX time (so no runtime cost and no
|
||||
downstream stage ever sees the source indentation), and that the two
|
||||
new diagnostics fire at the right position. *)
|
||||
|
||||
let () =
|
||||
let collector = Diag.Collector.create () in
|
||||
let toks = Lexer.tokenize collector ~file:"raw.wo" "let t = `<div>hi</div>`" in
|
||||
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
|
||||
check "raw literal: no holes lexes as a plain Str"
|
||||
(kinds
|
||||
= [ Token.KwLet; Token.Ident "t"; Token.Eq; Token.Str "<div>hi</div>"; Token.Eof ]);
|
||||
check_eq "raw literal: reports nothing" ~expected:0
|
||||
~actual:(List.length (Diag.Collector.diagnostics collector))
|
||||
string_of_int
|
||||
|
||||
let () =
|
||||
(* Nothing between the backticks is escape-processed: a quote is a
|
||||
quote and a backslash-n is two characters, which is the whole
|
||||
point of the form: markup without quote-escape noise. *)
|
||||
let collector = Diag.Collector.create () in
|
||||
let toks = Lexer.tokenize collector ~file:"raw.wo" "`a\"b\\n`" in
|
||||
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
|
||||
check "raw literal: content is verbatim, no escape processing"
|
||||
(kinds = [ Token.Str "a\"b\\n"; Token.Eof ])
|
||||
|
||||
let () =
|
||||
(* The margin case, written the way a render() body actually is:
|
||||
opening newline dropped, the 4-space common margin removed from
|
||||
every line, the whitespace-only closing line reduced to nothing
|
||||
while its newline survives (Java text-block behavior). *)
|
||||
let src = "let t = `\n <div>\n many\n </div>\n `" in
|
||||
let collector = Diag.Collector.create () in
|
||||
let toks = Lexer.tokenize collector ~file:"raw.wo" src in
|
||||
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
|
||||
check "raw literal: common margin stripped, leading newline dropped"
|
||||
(kinds
|
||||
= [
|
||||
Token.KwLet;
|
||||
Token.Ident "t";
|
||||
Token.Eq;
|
||||
Token.Str "<div>\n many\n</div>\n";
|
||||
Token.Eof;
|
||||
])
|
||||
|
||||
let () =
|
||||
(* Both hole forms in one literal. The payloads are raw and unlexed,
|
||||
exactly as SExpr has always carried `${...}` -- the parser is what
|
||||
tells them apart (SEsc gains the esc() wrapper). *)
|
||||
let collector = Diag.Collector.create () in
|
||||
let toks = Lexer.tokenize collector ~file:"raw.wo" "`<p>${a}{{ b }}</p>`" in
|
||||
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
|
||||
check "raw literal: ${} stays raw, {{}} becomes SEsc"
|
||||
(kinds
|
||||
= [
|
||||
Token.InterpStr
|
||||
[
|
||||
Token.SText "<p>";
|
||||
Token.SExpr "a";
|
||||
(* the empty run between two adjacent holes, exactly as a
|
||||
"..." string has always produced it -- the parser drops
|
||||
empty SText segments in desugar_interp *)
|
||||
Token.SText "";
|
||||
Token.SEsc " b ";
|
||||
Token.SText "</p>";
|
||||
];
|
||||
Token.Eof;
|
||||
])
|
||||
|
||||
let () =
|
||||
(* Unlike a plain "..." string, an unterminated raw literal is an
|
||||
error: multi-line is its normal case, so silently swallowing the
|
||||
rest of the file would be a footgun, not rt parity. *)
|
||||
let collector = Diag.Collector.create () in
|
||||
let toks = Lexer.tokenize collector ~file:"raw.wo" "let t = `abc" in
|
||||
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
|
||||
check "unterminated raw literal: closes with what was collected"
|
||||
(kinds = [ Token.KwLet; Token.Ident "t"; Token.Eq; Token.Str "abc"; Token.Eof ]);
|
||||
let diags = Diag.Collector.diagnostics collector in
|
||||
check_eq "unterminated raw literal: exactly one diagnostic reported" ~expected:1
|
||||
~actual:(List.length diags) string_of_int;
|
||||
match diags with
|
||||
| [ d ] ->
|
||||
check "unterminated raw literal: WO-E004 at the backtick (line 1, col 9)"
|
||||
(d.code = "WO-E004" && d.site.line = 1 && d.site.col = 9)
|
||||
| _ -> check "unterminated raw literal: diagnostic shape" false
|
||||
|
||||
let () =
|
||||
(* A raw newline inside "..." used to be accepted silently (the
|
||||
scanner's catch-all appended it like any other byte), which meant a
|
||||
forgotten closing quote ate the rest of the file with no
|
||||
diagnostic. Now that the backtick literal is the blessed spelling
|
||||
for multi-line text, that newline is an error and the scan stops
|
||||
WITHOUT consuming it, so the Newline token still terminates the
|
||||
statement and the next line parses normally. *)
|
||||
let collector = Diag.Collector.create () in
|
||||
let toks = Lexer.tokenize collector ~file:"nl.wo" "let t = \"ab\ncd" in
|
||||
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
|
||||
check "newline in string: scan stops at the newline, which still tokenizes"
|
||||
(kinds
|
||||
= [
|
||||
Token.KwLet;
|
||||
Token.Ident "t";
|
||||
Token.Eq;
|
||||
Token.Str "ab";
|
||||
Token.Newline;
|
||||
Token.Ident "cd";
|
||||
Token.Eof;
|
||||
]);
|
||||
let diags = Diag.Collector.diagnostics collector in
|
||||
check_eq "newline in string: exactly one diagnostic reported" ~expected:1
|
||||
~actual:(List.length diags) string_of_int;
|
||||
match diags with
|
||||
| [ d ] ->
|
||||
check "newline in string: WO-E005 at the newline (line 1, col 12)"
|
||||
(d.code = "WO-E005" && d.site.line = 1 && d.site.col = 12)
|
||||
| _ -> check "newline in string: diagnostic shape" false
|
||||
|
||||
(* ---- direct parser/AST assertions (Task 4, not golden-diffed) ------------
|
||||
|
||||
golden/ast/*.wo fixtures already pin the AST *shape* via --dump-ast,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,49 @@ Browse http://127.0.0.1:8080/ — products → product page → buy (stock
|
|||
checked and decremented) → confirmation → /orders. With `WO_DATA`, kill
|
||||
it and restart: the orders are still there (WAL replay).
|
||||
|
||||
The template builds and runs as written — the two `[deps]` resolve, the
|
||||
seed lands, and every route answers.
|
||||
|
||||
## The view form
|
||||
|
||||
A `render()` body is one backtick raw text literal. Markup is markup:
|
||||
real newlines, real double-quoted attributes, and the method's source
|
||||
indentation removed at compile time, so the served bytes carry the
|
||||
markup's own nesting and not the code's.
|
||||
|
||||
```
|
||||
fn render() -> Text {
|
||||
return `
|
||||
<div class="card">
|
||||
<h3><a href="/p/{{ self.sku }}">{{ self.name }}</a></h3>
|
||||
<p class="price">€ ${self.price}</p>
|
||||
${stock}
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
Every `render()` makes its class a **component** — wo-html's structural
|
||||
`Component` interface, satisfied by having the method, never declared.
|
||||
Parent components hold children directly (`cards: multi Component`) and
|
||||
render them with `render_all`, so `ProductListPage` knows nothing about
|
||||
`ProductCard` beyond `render()`. The document itself is a component too:
|
||||
`AppShell { title, content }` in `layout/app.wo`, which links a real
|
||||
stylesheet rather than inlining one — that is why it is its own shell and
|
||||
not wo-html's `Layout`.
|
||||
|
||||
Two holes, and the difference is the whole escaping story:
|
||||
|
||||
- `{{ expr }}` **HTML-escapes** — it compiles to a call to the `esc` in
|
||||
scope (wo-html's, unless the app declares its own). Display data goes
|
||||
here; a typo'd field is a compile error, not a broken page.
|
||||
- `${ expr }` is **raw** — for markup you built yourself, like the
|
||||
`${stock}` fragment above or `${content}` in the app shell.
|
||||
|
||||
Nothing is parsed at request time. The literal is a compile-time form:
|
||||
it produces exactly the string constant and concatenation chain the old
|
||||
hand-written version did, so there is no template engine to ship, warm
|
||||
up, or sandbox.
|
||||
|
||||
## The file map (Angular equivalents)
|
||||
|
||||
| this template | concern | Angular analog |
|
||||
|
|
@ -25,42 +68,53 @@ it and restart: the orders are still there (WAL replay).
|
|||
| `layout/app.wo` | app shell: document, header+footer composition, `ok_html`/`html_error` transport helpers | `app.component.html` |
|
||||
| `layout/header.wo` / `footer.wo` | shared chrome fragments | `header.html` / `footer.html` |
|
||||
| `product_list/view.wo` | VIEW — classes with `fn render() -> Text`, fields = exactly what is displayed | `product-list/view.html` |
|
||||
| `product_list.controller.wo` | CONTROLLER — query the model, fill the view, answer a `Resp` (one file per feature, root module) | component `.ts` + service |
|
||||
| `product_page/`, `orders/` | one view module per feature + its root controller file | feature folders |
|
||||
| `product_list/controller.wo` | CONTROLLER — query the model, fill the view, answer a `Resp`; beside its view in the same module | component `.ts` + service |
|
||||
| `product_page/`, `orders/` | one module per feature: `view.wo` + `controller.wo` | feature folders |
|
||||
| `static_files/controller.wo` | `/assets/*` from disk, traversal-safe, typed | `angular.json` assets |
|
||||
| `assets/style.css` | ONE real stylesheet, sectioned per feature | the `.scss` files |
|
||||
| the `render()` bodies | markup-first TODAY: one HTML line per `h = h ..` statement, `${}` holes; story 37 collapses each body to ONE raw literal with `{{ }}` auto-escaped typed holes | Vue `<template>` (in-SFC) |
|
||||
| the `render()` bodies | ONE raw text literal each: real newlines, real double-quoted attributes, source indentation removed at compile time, `${}` raw holes and `{{ }}` auto-escaping ones | Vue `<template>` (in-SFC) |
|
||||
| `layout/app.wo` → `AppShell` | the document, as a two-slot component | `app.component.html` |
|
||||
| `main.wo` | bootstrap: seed, routes, serve — nothing else | `app-routing.module.ts` + `main.ts` |
|
||||
|
||||
Separation is compiler-enforced where the language allows it today:
|
||||
each feature's VIEW directory is a module — the root controllers see
|
||||
only its `pub` classes and `layout`'s exports. Controllers themselves
|
||||
sit in the root module beside `types.wo`, because of gap #1 below.
|
||||
Separation is compiler-enforced: one feature = one directory = one
|
||||
module, holding that feature's view AND its controller. A module sees
|
||||
only its own declarations plus what it `use`s, so a controller reaching
|
||||
into another feature has to say so. The `@table` classes sit in
|
||||
`types.wo` at the root and are reachable from every feature module
|
||||
without export — see gap #1 below for why that is not the contradiction
|
||||
it looks like.
|
||||
|
||||
## What is deliberately different (doctrine)
|
||||
|
||||
- **Templates compile or they don't exist (story 37).** The views are
|
||||
markup-first within today's language: one HTML line per `h = h ..`
|
||||
statement (single-quoted attributes — no escape noise), `${}` holes,
|
||||
data through `esc()`. Story 37's raw template literal removes exactly
|
||||
the two taxes you can see: the `h = h ..` prefix on every line (the
|
||||
language has NO multi-line expression or literal — recorded gap #3,
|
||||
proven while writing this file) and the explicit `esc()` calls
|
||||
(`{{ }}` auto-escapes and type-checks; `{!! !!}` is the raw slot).
|
||||
Same markup, less ceremony. NO template engine runs at request time —
|
||||
ever. Styles stay a real CSS file, served statically (there is no
|
||||
scss preprocessor).
|
||||
- **No closures, no DI.** A view is a class with fields + `render()`;
|
||||
a controller is a class satisfying `Handler`. Capture = a field.
|
||||
- **Templates compile or they don't exist (story 37).** See *The view
|
||||
form* above: the markup is a compile-time literal, never a file
|
||||
parsed per request. Gap #3 ("the language has NO multi-line
|
||||
expression or literal", recorded while writing this template) is
|
||||
CLOSED — the raw literal landed with story 37's compiler slice, and
|
||||
this directory was its consumer. Styles stay a real CSS file, served
|
||||
statically (there is no scss preprocessor).
|
||||
- **No closures, no DI.** A view is a class with fields + `render()`
|
||||
(wo-html's `Component`); a controller is a class satisfying `Handler`.
|
||||
Capture = a field.
|
||||
- **The MVC seam is enforced by where the query sits.** Controllers hold
|
||||
every `from … select`; a view receives VALUES — for the list page, a
|
||||
`multi Component` of already-filled cards. No view in this template
|
||||
touches the database, and wo-html contains no query at all.
|
||||
- **No sessions/cart yet.** Buying is per-product (qty → order). A cart
|
||||
needs a session story that does not exist yet.
|
||||
- **No client-side JS.** Every interaction is a form round trip.
|
||||
- **`pub` + `@table` cannot combine yet (recorded gap #1).** An
|
||||
annotated class cannot be exported, so a shared `types/` MODULE is
|
||||
impossible today — which is why the controllers live in the root
|
||||
module with `types.wo` instead of inside their feature folders. A
|
||||
one-clause grammar fix closes this; until then the template shows the
|
||||
honest layout.
|
||||
- **`pub` + `@table` cannot combine (recorded gap #1) — and it does not
|
||||
matter.** `pub` in front of an annotated class is a parse error
|
||||
(`WO-E101`), but no `@table` needs it: a CLASS is reachable across
|
||||
module lines without being exported. Verified 2026-08-25 by building
|
||||
and running all three shapes — a feature module querying a root
|
||||
`@table`, a feature module using a root plain class, and an `@table`
|
||||
declared inside a `types/` module and queried from the root. What IS
|
||||
module-scoped is a free `fn` (`WO-E210`), so a query shared by two
|
||||
features belongs on a class as a `static fn`. An earlier revision of
|
||||
this file claimed the gap forced controllers into the root module and
|
||||
made a `types/` module impossible; both were wrong, and the layout
|
||||
below is what the language actually allows.
|
||||
- **`@view` projection classes (recorded gap #2):** today controllers
|
||||
copy row fields into view classes by hand. The wished-for form —
|
||||
`class ProductCard @view { ... }` filled by
|
||||
|
|
@ -72,7 +126,7 @@ sit in the root module beside `types.wo`, because of gap #1 below.
|
|||
1. `types.wo` — the entire persistence layer is 20 lines.
|
||||
2. `orders.controller.wo` — the whole buying flow (validate, stock
|
||||
check, decrement, durable insert, render) with no framework magic.
|
||||
3. `orders/view.wo` — the referendum: is HTML-per-line with `${}`
|
||||
holes readable enough today, and is 37's delta (delete every
|
||||
`h = h ..` prefix and `esc()` call) worth a compiler slice?
|
||||
3. `orders/view.wo` — the referendum, now answered: three render()
|
||||
bodies, each one literal, no concatenation and no `esc()` calls, and
|
||||
`OrdersPage` holding its rows as child components.
|
||||
4. `main.wo` — the app at a glance: five routes, one middleware, serve.
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
||||
|
|
|
|||
|
|
@ -5,32 +5,46 @@
|
|||
use framework/http
|
||||
use html
|
||||
|
||||
pub fn app_shell(title: Text, content: Text) -> Text {
|
||||
let h = "<!doctype html><html><head>";
|
||||
h = h .. "<meta charset='utf-8'>";
|
||||
h = h .. "<meta name='viewport' content='width=device-width,initial-scale=1'>";
|
||||
h = h .. "<title>${esc(title)}</title>";
|
||||
h = h .. "<link rel='stylesheet' href='/assets/style.css'>";
|
||||
h = h .. "</head><body>";
|
||||
h = h .. header();
|
||||
h = h .. "<main class='site-main'>${content}</main>";
|
||||
h = h .. footer();
|
||||
h = h .. "</body></html>";
|
||||
return h;
|
||||
-- The app shell as a COMPONENT (wo-html's `Component`: fields in, Text
|
||||
-- out), the same shape as wo-html's own `Layout` — two slots instead of
|
||||
-- four, and its own document rather than `page()`'s, because this
|
||||
-- template deliberately LINKS a stylesheet instead of inlining one.
|
||||
-- That is the whole reason it is not just `Layout`: markup and styling
|
||||
-- stay separate files here.
|
||||
pub class AppShell {
|
||||
title: Text
|
||||
content: Text
|
||||
|
||||
fn render() -> Text {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{ self.title }}</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
${header()}
|
||||
<main class="site-main">${self.content}</main>
|
||||
${footer()}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
-- The one transport helper every controller shares: a 200 HTML Resp.
|
||||
pub fn ok_html(body: Text) -> Resp {
|
||||
let h: map<Text, Text> = {};
|
||||
h["content-type"] = "text/html; charset=utf-8";
|
||||
return Resp { status: 200, headers: h, body: body };
|
||||
}
|
||||
-- `ok_html` is the FRAMEWORK's (http/types.wo, beside ok_text/ok_json):
|
||||
-- a status line and a content-type is transport, not rendering, and both
|
||||
-- HTML samples had hand-rolled the identical four lines.
|
||||
|
||||
pub fn html_error(status: Int, title: Text, msg: Text) -> Resp {
|
||||
let c = "<h1>${esc(title)}</h1>";
|
||||
c = c .. "<p>${esc(msg)}</p>";
|
||||
c = c .. "<p><a href='/'>Back to products</a></p>";
|
||||
let content = `
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ msg }}</p>
|
||||
<p><a href="/">Back to products</a></p>`;
|
||||
let shell = AppShell { title: "shop — ${title}", content: content };
|
||||
let h: map<Text, Text> = {};
|
||||
h["content-type"] = "text/html; charset=utf-8";
|
||||
return Resp { status: status, headers: h, body: app_shell("shop — ${title}", c) };
|
||||
return Resp { status: status, headers: h, body: shell.render() };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
-- layout/footer.wo — the footer fragment (footer.html's analog).
|
||||
pub fn footer() -> Text {
|
||||
return "<footer class='site-footer'>writeonce shop — one binary: server, database, these pages.</footer>";
|
||||
return `<footer class="site-footer">writeonce shop — one binary: server, database, these pages.</footer>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
-- layout/header.wo — the header fragment (header.html's analog).
|
||||
pub fn header() -> Text {
|
||||
let h = "<header class='site-header'>";
|
||||
h = h .. "<a href='/' class='brand'>writeonce shop</a>";
|
||||
h = h .. "<nav class='site-nav'><a href='/'>Products</a> <a href='/orders'>Orders</a></nav>";
|
||||
h = h .. "</header>";
|
||||
return h;
|
||||
return `
|
||||
<header class="site-header">
|
||||
<a href="/" class="brand">writeonce shop</a>
|
||||
<nav class="site-nav"><a href="/">Products</a> <a href="/orders">Orders</a></nav>
|
||||
</header>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ fn main(args: multi Text) -> Int {
|
|||
seed_if_empty();
|
||||
|
||||
let app = App { middleware: [], routes: [] };
|
||||
app.use_mw(Mw { m: Logging { pad: 0 } });
|
||||
app.get("/", ListProducts { pad: 0 });
|
||||
app.get("/p/:sku", ShowProduct { pad: 0 });
|
||||
app.post("/orders/:sku", CreateOrder { pad: 0 });
|
||||
app.get("/orders", ListOrders { pad: 0 });
|
||||
app.use_mw(Mw { m: Logging {} });
|
||||
app.get("/", ListProducts {});
|
||||
app.get("/p/:sku", ShowProduct {});
|
||||
app.post("/orders/:sku", CreateOrder {});
|
||||
app.get("/orders", ListOrders {});
|
||||
app.get("/assets/*path", StaticFiles { dir: "assets" });
|
||||
return app.serve("127.0.0.1", port);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
-- orders.controller.wo — the buying flow: stock-checked order creation
|
||||
-- orders/controller.wo — the buying flow: stock-checked order creation
|
||||
-- (decrement + insert are each WAL-committed before they acknowledge)
|
||||
-- and the orders list (ref navigation: o.product.name).
|
||||
use framework/http
|
||||
use layout
|
||||
use orders
|
||||
use time
|
||||
|
||||
pub class CreateOrder {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
let sku = req.params["sku"];
|
||||
if sku == nil {
|
||||
|
|
@ -37,19 +35,19 @@ pub class CreateOrder {
|
|||
p.stock = p.stock - qty;
|
||||
insert Order { product: p, qty: qty, total: total, placed: time.now(), status: "placed" };
|
||||
let page = OrderOk { name: p.name, qty: qty, total: total };
|
||||
return ok_html(app_shell("shop — order placed", page.render()));
|
||||
let shell = AppShell { title: "shop — order placed", content: page.render() };
|
||||
return ok_html(shell.render());
|
||||
}
|
||||
}
|
||||
|
||||
pub class ListOrders {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
let rows = "";
|
||||
let rows: multi Component = [];
|
||||
for o in from x in Order select x {
|
||||
let row = OrderRow { name: o.product.name, qty: o.qty, total: o.total, status: o.status };
|
||||
rows = rows .. row.render();
|
||||
push(rows, OrderRow { name: o.product.name, qty: o.qty, total: o.total, status: o.status });
|
||||
}
|
||||
let page = OrdersPage { rows: rows };
|
||||
return ok_html(app_shell("shop — orders", page.render()));
|
||||
let shell = AppShell { title: "shop — orders", content: page.render() };
|
||||
return ok_html(shell.render());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
-- orders/view.wo — the confirmation page and the orders table.
|
||||
-- Markup-first, one HTML line per statement; see product_list/view.wo
|
||||
-- for the convention and the story-37 endgame.
|
||||
-- Raw text literals for the bodies; see product_list/view.wo for the
|
||||
-- convention.
|
||||
use html
|
||||
|
||||
pub class OrderOk {
|
||||
|
|
@ -9,12 +9,12 @@ pub class OrderOk {
|
|||
total: Float
|
||||
|
||||
fn render() -> Text {
|
||||
let h = "<div class='card'>";
|
||||
h = h .. "<h1>Order placed</h1>";
|
||||
h = h .. "<p>${self.qty} × ${esc(self.name)} — total € ${self.total}</p>";
|
||||
h = h .. "<p><a href='/orders'>See all orders</a> · <a href='/'>Keep shopping</a></p>";
|
||||
h = h .. "</div>";
|
||||
return h;
|
||||
return `
|
||||
<div class="card">
|
||||
<h1>Order placed</h1>
|
||||
<p>${self.qty} × {{ self.name }} — total € ${self.total}</p>
|
||||
<p><a href="/orders">See all orders</a> · <a href="/">Keep shopping</a></p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -25,29 +25,30 @@ pub class OrderRow {
|
|||
status: Text
|
||||
|
||||
fn render() -> Text {
|
||||
let h = "<tr>";
|
||||
h = h .. "<td>${esc(self.name)}</td>";
|
||||
h = h .. "<td>${self.qty}</td>";
|
||||
h = h .. "<td>€ ${self.total}</td>";
|
||||
h = h .. "<td>${esc(self.status)}</td>";
|
||||
h = h .. "</tr>";
|
||||
return h;
|
||||
return `
|
||||
<tr>
|
||||
<td>{{ self.name }}</td>
|
||||
<td>${self.qty}</td>
|
||||
<td>€ ${self.total}</td>
|
||||
<td>{{ self.status }}</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
pub class OrdersPage {
|
||||
rows: Text
|
||||
rows: multi Component
|
||||
|
||||
fn render() -> Text {
|
||||
let h = "<h1>Orders</h1>";
|
||||
if self.rows == "" {
|
||||
h = h .. "<p>No orders yet — <a href='/'>go buy something</a>.</p>";
|
||||
return h;
|
||||
if len(self.rows) == 0 {
|
||||
return `
|
||||
<h1>Orders</h1>
|
||||
<p>No orders yet — <a href="/">go buy something</a>.</p>`;
|
||||
}
|
||||
h = h .. "<table class='orders'>";
|
||||
h = h .. "<tr><th>Product</th><th>Qty</th><th>Total</th><th>Status</th></tr>";
|
||||
h = h .. "${self.rows}";
|
||||
h = h .. "</table>";
|
||||
return h;
|
||||
return `
|
||||
<h1>Orders</h1>
|
||||
<table class="orders">
|
||||
<tr><th>Product</th><th>Qty</th><th>Total</th><th>Status</th></tr>
|
||||
${render_all(self.rows)}
|
||||
</table>`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
21
docs/examples/shop/product_list/controller.wo
Normal file
21
docs/examples/shop/product_list/controller.wo
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- product_list/controller.wo — the CONTROLLER for /: query the model,
|
||||
-- fill the view components beside it, answer a Resp. One feature = one
|
||||
-- directory = one module, view and controller together. The @tables it
|
||||
-- queries live in the root module, which is fine: a CLASS is reachable
|
||||
-- across module lines (only free `fn`s are module-scoped).
|
||||
use framework/http
|
||||
use layout
|
||||
|
||||
pub class ListProducts {
|
||||
fn handle(req: Req) -> Resp {
|
||||
-- The MVC seam: the query is HERE, and what crosses into the view is
|
||||
-- a list of child components, never a cursor.
|
||||
let cards: multi Component = [];
|
||||
for p in from x in Product order by x.name select x {
|
||||
push(cards, ProductCard { sku: p.sku, name: p.name, price: p.price, stock: p.stock });
|
||||
}
|
||||
let page = ProductListPage { cards: cards };
|
||||
let shell = AppShell { title: "shop — products", content: page.render() };
|
||||
return ok_html(shell.render());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,9 @@
|
|||
-- product_list/view.wo — the VIEW. Classes with `fn render() -> Text`;
|
||||
-- fields are exactly the values displayed, bodies are MARKUP-FIRST: one
|
||||
-- HTML line per statement (single-quoted attributes — no escape noise),
|
||||
-- ${} holes, data through esc().
|
||||
--
|
||||
-- The `h = h ..` prefix on every line is the language's cost today:
|
||||
-- statements are single-line and there is no multi-line literal. Story
|
||||
-- 37's raw template literal deletes exactly that prefix and the esc()
|
||||
-- calls ({{ }} auto-escapes and type-checks) — the markup stays as-is.
|
||||
-- fields are exactly the values displayed, and a body is ONE raw text
|
||||
-- literal: markup written as markup, attributes in real double quotes,
|
||||
-- source indentation removed at compile time. `${}` interpolates raw,
|
||||
-- `{{ }}` HTML-escapes — so display data goes through `{{ }}` and never
|
||||
-- through a hand-written esc() call.
|
||||
use html
|
||||
|
||||
pub class ProductCard {
|
||||
|
|
@ -16,25 +13,29 @@ pub class ProductCard {
|
|||
stock: Int
|
||||
|
||||
fn render() -> Text {
|
||||
let h = "<div class='card'>";
|
||||
h = h .. "<h3><a href='/p/${esc(self.sku)}'>${esc(self.name)}</a></h3>";
|
||||
h = h .. "<p class='price'>€ ${self.price}</p>";
|
||||
if self.stock > 0 {
|
||||
h = h .. "<p class='stock'>${self.stock} in stock</p>";
|
||||
} else {
|
||||
h = h .. "<p class='stock out'>sold out</p>";
|
||||
let stock = `<p class="stock">${self.stock} in stock</p>`;
|
||||
if self.stock == 0 {
|
||||
stock = `<p class="stock out">sold out</p>`;
|
||||
}
|
||||
h = h .. "</div>";
|
||||
return h;
|
||||
return `
|
||||
<div class="card">
|
||||
<h3><a href="/p/{{ self.sku }}">{{ self.name }}</a></h3>
|
||||
<p class="price">€ ${self.price}</p>
|
||||
${stock}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
-- A parent component: its `cards` are CHILDREN, held through the
|
||||
-- structural interface, and its render calls theirs. Nothing here knows
|
||||
-- that a card is a ProductCard — swapping in a different card component
|
||||
-- is a controller change, not a view rewrite.
|
||||
pub class ProductListPage {
|
||||
cards: Text
|
||||
cards: multi Component
|
||||
|
||||
fn render() -> Text {
|
||||
let h = "<h1>Products</h1>";
|
||||
h = h .. "<div class='grid'>${self.cards}</div>";
|
||||
return h;
|
||||
return `
|
||||
<h1>Products</h1>
|
||||
<div class="grid">${render_all(self.cards)}</div>`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
-- product_page/view.wo — the product detail view with the order form.
|
||||
-- Markup-first, one HTML line per statement; see product_list/view.wo
|
||||
-- for the convention and the story-37 endgame.
|
||||
-- Raw text literals for the bodies; see product_list/view.wo for the
|
||||
-- convention.
|
||||
use html
|
||||
|
||||
pub class ProductPage {
|
||||
|
|
@ -10,21 +10,22 @@ pub class ProductPage {
|
|||
stock: Int
|
||||
|
||||
fn render() -> Text {
|
||||
let h = "<div class='card detail'>";
|
||||
h = h .. "<h1>${esc(self.name)}</h1>";
|
||||
h = h .. "<p class='price'>€ ${self.price}</p>";
|
||||
h = h .. "<p class='stock'>${self.stock} in stock</p>";
|
||||
if self.stock > 0 {
|
||||
h = h .. "<form method='POST' action='/orders/${esc(self.sku)}'>";
|
||||
h = h .. "<label>Quantity</label>";
|
||||
h = h .. "<input type='text' name='qty' value='1' class='field'>";
|
||||
h = h .. "<button type='submit' class='btn'>Buy</button>";
|
||||
h = h .. "</form>";
|
||||
} else {
|
||||
h = h .. "<p class='stock out'>sold out</p>";
|
||||
let action = `
|
||||
<form method="POST" action="/orders/{{ self.sku }}">
|
||||
<label>Quantity</label>
|
||||
<input type="text" name="qty" value="1" class="field">
|
||||
<button type="submit" class="btn">Buy</button>
|
||||
</form>`;
|
||||
if self.stock == 0 {
|
||||
action = `<p class="stock out">sold out</p>`;
|
||||
}
|
||||
h = h .. "</div>";
|
||||
h = h .. "<p><a href='/'>← all products</a></p>";
|
||||
return h;
|
||||
return `
|
||||
<div class="card detail">
|
||||
<h1>{{ self.name }}</h1>
|
||||
<p class="price">€ ${self.price}</p>
|
||||
<p class="stock">${self.stock} in stock</p>
|
||||
${action}
|
||||
</div>
|
||||
<p><a href="/">← all products</a></p>`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,82 @@
|
|||
# site — how it is put together
|
||||
|
||||
Written 2026-08-23, with the sample's landing. Three files, one binary.
|
||||
Written 2026-08-23 with the sample's landing; restructured 2026-08-25
|
||||
onto the program template's MVC layout (`docs/examples/shop`), so the two
|
||||
samples now read the same way.
|
||||
|
||||
| file | what it owns |
|
||||
| --- | --- |
|
||||
| `main.wo` | the `Chapter` table, seed-if-empty, the HTML shell (header/nav), four handlers (Home, ShowChapter, AdminEdit, Health), `main` |
|
||||
| `content.wo` | the nine chapter bodies as functions returning HTML fragments, and `seed_chapters()` — same directory, so it shares `main.wo`'s declarations without `use` |
|
||||
| `wo.toml` | the two [deps]: `framework` (serving) and `html` (markup) |
|
||||
## The layout
|
||||
|
||||
Decisions that are not obvious from the code:
|
||||
| file | layer | what it owns |
|
||||
| --- | --- | --- |
|
||||
| `types.wo` | MODEL | the `Chapter` `@table`, the `ChapterLink` projection, `Chapters.links()`, and `seed_if_empty()` |
|
||||
| `content.wo` | MODEL (content) | the nine chapter bodies as fragment-returning functions, plus `seed_chapters()` |
|
||||
| `layout/app.wo` | VIEW (chrome) | `AppShell` — the component that fills wo-html's `Layout` — the two named widths, and `html_error` |
|
||||
| `layout/header.wo`, `layout/footer.wo` | VIEW (chrome) | the shared nav bar and footer |
|
||||
| `home/view.wo` | VIEW | `HomePage` and the homepage's code showcase |
|
||||
| `home/controller.wo` | CONTROLLER | `Home` — the `/` handler |
|
||||
| `chapter/view.wo` | VIEW | `ChapterNav`, `ChapterPage` |
|
||||
| `chapter/controller.wo` | CONTROLLER | `ShowChapter` — the `/ch/:slug` handler |
|
||||
| `admin/controller.wo` | CONTROLLER | `AdminEdit` — bearer-gated edit, answers a redirect (no view: it redirects) |
|
||||
| `health/controller.wo` | CONTROLLER | `Health` — the liveness probe (no view: it answers text) |
|
||||
| `main.wo` | BOOTSTRAP | seed, routes, serve. Nothing else |
|
||||
| `wo.toml` | — | the two `[deps]`: `framework` (serving) and `html` (markup) |
|
||||
|
||||
- **Chapters are rows, not constants.** `seed_if_empty()` inserts them only
|
||||
when the table answers empty, so a WAL restart keeps admin edits instead
|
||||
of reseeding over them — the sample's own proof of chapter 6's claim.
|
||||
The seed bodies are BUILT with wo-html's builders at boot; after that
|
||||
the table is the truth and the builders are never consulted again.
|
||||
One feature = one directory = one module, holding that feature's view
|
||||
and its controller together. A module sees its own declarations plus
|
||||
what it `use`s, so `home/` reaching the chapter nav has to say `use
|
||||
chapter`.
|
||||
|
||||
The model stays at the root and is reachable from everywhere: a CLASS
|
||||
crosses module lines without being exported, and only a free `fn` is
|
||||
module-scoped (`WO-E210`). That single rule explains the whole layout —
|
||||
`Chapter` and `ChapterLink` are classes, so the feature modules just
|
||||
name them; the shared query would have been a free fn, so it is a
|
||||
`static fn` on `Chapters` instead. (`pub` cannot prefix an `@table`
|
||||
class — recorded gap #1 — but nothing needs it to.)
|
||||
|
||||
## Decisions that are not obvious from the code
|
||||
|
||||
- **Chapters are rows, not constants.** `seed_if_empty()` inserts them
|
||||
only when the table answers empty, so a WAL restart keeps admin edits
|
||||
instead of reseeding over them — the sample's own proof of chapter 6's
|
||||
claim. The seed bodies are BUILT with wo-html's builders at boot; after
|
||||
that the table is the truth and the builders are never consulted again.
|
||||
- **The seam is enforced by where the query sits.** `Chapters.links()`
|
||||
lives with the MODEL and hands the view a `multi ChapterLink` —
|
||||
a projection, not a cursor. No component in this sample touches the
|
||||
database, which is what lets `ChapterNav` be the same component on the
|
||||
homepage and on every chapter page, differing only by `current`.
|
||||
- **`HomePage` and `ChapterPage` hold a `Component`, not chapter data.**
|
||||
The nav arrives as an already-built child component in a slot, so
|
||||
neither page knows what a chapter is. That is content projection —
|
||||
Angular's `<ng-content>`, with the slot as an ordinary field.
|
||||
- **Two widths, named once.** `AppShell` carries a `container` field and
|
||||
`layout/app.wo` exports `reading_shell` / `wide_shell`. The Tailwind
|
||||
class strings appear in exactly one place instead of being repeated at
|
||||
every call site.
|
||||
- **Auth is handler-side by doctrine.** The framework ships mechanism
|
||||
(`bearer_token`, constant-time `ct_eq`); which routes are gated and by
|
||||
which token is policy, so `AdminEdit` checks its own field. No global
|
||||
middleware — the public pages stay public.
|
||||
- **`ok_html` is the framework's**, beside `ok_text`/`ok_json`: a status
|
||||
line plus a content-type is transport, not rendering.
|
||||
- **`\$` in chapter code samples.** Chapter sources show interpolation
|
||||
(`${port}`) inside string literals of a language that interpolates —
|
||||
the lexer's `\$` escape keeps them literal; `code_block()` then
|
||||
HTML-escapes the result.
|
||||
- **One-line concat chains.** `..` does not straddle newlines (Go-style
|
||||
implicit statement ends), so long fragments build accumulator-style
|
||||
(`b = b .. "...";` per line) — the same shape serve.wo uses for
|
||||
response heads.
|
||||
HTML-escapes the result. This is also why those two samples stay
|
||||
escaped `"..."` strings rather than becoming raw literals: a raw
|
||||
literal has no escape character, so it cannot spell a literal `${`.
|
||||
- **Concat spans lines two ways now.** A line ENDING in `..` continues on
|
||||
the next (the one newline suppression in the language) — it never works
|
||||
at the START of a line. For markup, prefer the backtick raw literal:
|
||||
real newlines, real double-quoted attributes, source indentation
|
||||
removed at compile time, `${ }` raw and `{{ }}` auto-escaping. The old
|
||||
"`..` does not straddle newlines, so build accumulator-style" note is
|
||||
obsolete and was removed.
|
||||
- **wo-html's sheet is static.** Tailwind's class NAMES, one hand-written
|
||||
CSS string inlined per page by `page()` — self-contained responses, no
|
||||
toolchain; growing the sheet is appending a line in `tw_css()`.
|
||||
|
||||
Gate: `just site` — see scripts/site-accept.sh (11 checks; the restart
|
||||
Gate: `just site` — see `scripts/site-accept.sh` (11 checks; the restart
|
||||
leg polls `/health` instead of sleeping, so it does not share
|
||||
web-app-accept's 0.5s boot race).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
docs/examples/site/admin/controller.wo
Normal file
33
docs/examples/site/admin/controller.wo
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
-- admin.controller.wo — POST /admin/ch/:slug: title/body update,
|
||||
-- form-encoded, bearer-gated. Mechanism (bearer_token, constant-time
|
||||
-- ct_eq) is the framework's; POLICY — which routes, which token — is
|
||||
-- this app's, right here. No rendering: the answer is a redirect.
|
||||
use framework/http
|
||||
|
||||
pub class AdminEdit {
|
||||
token: Text
|
||||
|
||||
fn handle(req: Req) -> Resp {
|
||||
let got = bearer_token(req);
|
||||
if got == nil { return unauthorized(); }
|
||||
if ct_eq("${got}", self.token) == false { return unauthorized(); }
|
||||
let slug = req.params["slug"];
|
||||
if slug == nil { return not_found(); }
|
||||
let hits = from c in Chapter where c.slug == slug take 1 select c;
|
||||
if len(hits) == 0 { return not_found(); }
|
||||
let f = form_values(req);
|
||||
if f == nil { return bad_request("body must be form-encoded (title, body)"); }
|
||||
let title = f["title"];
|
||||
let body = f["body"];
|
||||
if title == nil and body == nil { return bad_request("nothing to update"); }
|
||||
if title != nil {
|
||||
let t = trim("${title}");
|
||||
if t == "" { return bad_request("title must not be empty"); }
|
||||
hits[0].title = t;
|
||||
}
|
||||
if body != nil {
|
||||
hits[0].body = "${body}";
|
||||
}
|
||||
return redirect("/ch/${slug}");
|
||||
}
|
||||
}
|
||||
23
docs/examples/site/chapter/controller.wo
Normal file
23
docs/examples/site/chapter/controller.wo
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- chapter/controller.wo — the CONTROLLER for `/ch/:slug`. It queries the
|
||||
-- model and fills the view components that sit beside it in this module;
|
||||
-- a view receives VALUES, never a cursor.
|
||||
use framework/http
|
||||
use layout
|
||||
|
||||
pub class ShowChapter {
|
||||
fn handle(req: Req) -> Resp {
|
||||
let slug = req.params["slug"];
|
||||
if slug == nil {
|
||||
return html_error(404, "No such chapter", "The address names no chapter.");
|
||||
}
|
||||
let hits = from c in Chapter where c.slug == slug take 1 select c;
|
||||
if len(hits) == 0 {
|
||||
return html_error(404, "No such chapter", "Nothing is filed under that slug.");
|
||||
}
|
||||
let c = hits[0];
|
||||
let nav = ChapterNav { items: Chapters.links(), current: c.ord };
|
||||
let page = ChapterPage { ord: c.ord, title: c.title, body: c.body, chapter_nav: nav };
|
||||
let shell = reading_shell("writeonce — ${c.title}", page.render());
|
||||
return ok_html(shell.render());
|
||||
}
|
||||
}
|
||||
46
docs/examples/site/chapter/view.wo
Normal file
46
docs/examples/site/chapter/view.wo
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
-- chapter/view.wo — the VIEW for `/ch/:slug`, plus the chapter nav that
|
||||
-- the homepage reuses.
|
||||
--
|
||||
-- The MVC seam in one place: these components RENDER, the controller
|
||||
-- QUERIES, and the two never meet. ChapterNav holds VALUES (a list of
|
||||
-- links), so it renders identically on the homepage and on a chapter
|
||||
-- page — the only difference is which ord is `current`. Reuse is the
|
||||
-- same component with different fields, never copied markup.
|
||||
use html
|
||||
|
||||
-- `ChapterLink` is the MODEL's projection type (types.wo); a class is
|
||||
-- reachable across module lines, so the view just names it.
|
||||
|
||||
pub class ChapterNav {
|
||||
items: multi ChapterLink
|
||||
current: Int
|
||||
|
||||
fn render() -> Text {
|
||||
let out = "";
|
||||
for c in self.items {
|
||||
let label = `${c.ord}. {{ c.title }}`;
|
||||
if c.ord == self.current {
|
||||
out = out .. el("li", "mb-2 font-bold text-gray-900", label);
|
||||
} else {
|
||||
out = out .. el("li", "mb-2", link("/ch/${c.slug}", "", label));
|
||||
}
|
||||
}
|
||||
return el("ul", "list-disc pl-6", out);
|
||||
}
|
||||
}
|
||||
|
||||
-- One chapter. `body` is site-authored HTML held in the row, so it goes
|
||||
-- through the RAW hole; the title is data and goes through `{{ }}`.
|
||||
pub class ChapterPage {
|
||||
ord: Int
|
||||
title: Text
|
||||
body: Text
|
||||
chapter_nav: Component
|
||||
|
||||
fn render() -> Text {
|
||||
let head = el("h1", "text-3xl font-bold mb-4", `${self.ord}. {{ self.title }}`);
|
||||
let art = el("div", "bg-white rounded-lg border shadow-sm p-6", head .. self.body);
|
||||
let nav = el("div", "mt-8", el("h2", "text-lg font-bold mb-2", "Chapters") .. self.chapter_nav.render());
|
||||
return art .. nav;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
-- site/content.wo — the tutorial chapters, seeded into the Chapter table
|
||||
-- on first boot (main.wo's seed_if_empty). Bodies are HTML fragments
|
||||
-- content.wo — the tutorial chapters, seeded into the Chapter table on
|
||||
-- first boot (types.wo's seed_if_empty). Model CONTENT, so it sits in
|
||||
-- the root module beside types.wo: it inserts rows. Bodies are HTML fragments
|
||||
-- BUILT with the wo-html dep — prose in el(), code samples through
|
||||
-- code_block() which escapes them. Editing a chapter later (the admin
|
||||
-- route) overwrites body/title in place; the WAL keeps the edit across
|
||||
|
|
@ -7,20 +8,6 @@
|
|||
-- site that teaches it.
|
||||
use html
|
||||
|
||||
-- The homepage's code showcase: a real flavor of the language — an
|
||||
-- actor per chat room, rows in the built-in database, one binary.
|
||||
fn home_snippet() -> Text {
|
||||
let s = "@table\nclass Message {\n room: Text\n body: Text\n}\n\n";
|
||||
s = s .. "class Room {\n name: Text\n fn receive(msg: Post) {\n";
|
||||
s = s .. " insert Message { room: self.name, body: msg.body };\n";
|
||||
s = s .. " print(\"[\${self.name}] \${msg.body}\");\n }\n}\n\n";
|
||||
s = s .. "fn main() -> Int {\n";
|
||||
s = s .. " let general: actor Post = spawn Room { name: \"general\" };\n";
|
||||
s = s .. " send(general, Post { body: \"hello, writeonce\" });\n";
|
||||
s = s .. " time.sleep(50);\n return 0;\n}";
|
||||
return code_block(s);
|
||||
}
|
||||
|
||||
fn ch_hello() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"A writeonce program is one directory of <code>.wo</code> files and one entry: a free " .. "function named <code>main</code>. It returns the process exit code. There is no " .. "runtime to install separately and no build pipeline — <code>woc build</code> produces " .. "ONE self-contained binary with the VM and your bytecode inside.");
|
||||
|
|
@ -75,15 +62,25 @@ fn ch_actors() -> Text {
|
|||
fn ch_deps() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"Dependencies are git repositories pinned in <code>wo.toml</code>; <code>wo.lock</code> " .. "records the exact revision, and locked builds work offline. The [deps] KEY names the " .. "module you <code>use</code>. This site has two: the web framework, and the wo-html " .. "library that rendered the page you are reading.");
|
||||
b = b .. code_block("[deps]\nframework = { git = \"https://github.com/shoneyj/writeonce-framework\", rev = \"v0.1.0\" }\nhtml = { git = \"https://github.com/shoneyj/wo-html\", rev = \"v0.1.0\" }");
|
||||
b = b .. code_block("use framework\nuse framework/http\nuse html\n\n-- html's builders + tailwind-style utilities, zero JS, no build step:\nlet body = el(\"h1\", \"text-3xl font-bold\", \"Hello\");\nreturn ok_html(page(\"Hello\", body));");
|
||||
b = b .. code_block(`
|
||||
[deps]
|
||||
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" }
|
||||
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" }`);
|
||||
b = b .. code_block(`
|
||||
use framework
|
||||
use framework/http
|
||||
use html
|
||||
|
||||
-- html's builders + tailwind-style utilities, zero JS, no build step:
|
||||
let body = el("h1", "text-3xl font-bold", "Hello");
|
||||
return ok_html(page("Hello", body));`);
|
||||
return b;
|
||||
}
|
||||
|
||||
fn ch_serving() -> Text {
|
||||
let b = el("p", "leading-relaxed mb-4",
|
||||
"The whole stack of this site: routes with <code>:param</code> captures, a middleware " .. "chain, handler classes, <code>@table</code> persistence, and server-rendered HTML — " .. "one binary behind a proxy. This is the site's own main, abbreviated:");
|
||||
b = b .. code_block("fn main(args: multi Text) -> Int {\n seed_if_empty();\n let app = App { middleware: [], routes: [] };\n app.use_mw(Mw { m: Logging { pad: 0 } });\n app.get(\"/\", Home { pad: 0 });\n app.get(\"/ch/:slug\", ShowChapter { pad: 0 });\n app.post(\"/admin/ch/:slug\", AdminEdit { token: token });\n return app.serve(\"127.0.0.1\", port);\n}");
|
||||
b = b .. code_block("fn main(args: multi Text) -> Int {\n seed_if_empty();\n let app = App { middleware: [], routes: [] };\n app.use_mw(Mw { m: Logging {} });\n app.get(\"/\", Home {});\n app.get(\"/ch/:slug\", ShowChapter {});\n app.post(\"/admin/ch/:slug\", AdminEdit { token: token });\n return app.serve(\"127.0.0.1\", port);\n}");
|
||||
b = b .. el("p", "leading-relaxed mt-4",
|
||||
"The admin route checks its bearer token in the handler — mechanism lives in the " .. "framework (<code>bearer_token</code>, constant-time <code>ct_eq</code>), POLICY stays " .. "in the app. Try editing this chapter: " .. "<code>curl -X POST -H \"authorization: Bearer ...\" -d \"title=...&body=...\" /admin/ch/serving</code>.");
|
||||
return b;
|
||||
|
|
|
|||
9
docs/examples/site/health/controller.wo
Normal file
9
docs/examples/site/health/controller.wo
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- health.controller.wo — GET /health: the liveness probe the accept
|
||||
-- script and any proxy poll. Text, not HTML, on purpose.
|
||||
use framework/http
|
||||
|
||||
pub class Health {
|
||||
fn handle(req: Req) -> Resp {
|
||||
return ok_text("ok");
|
||||
}
|
||||
}
|
||||
19
docs/examples/site/home/controller.wo
Normal file
19
docs/examples/site/home/controller.wo
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- home/controller.wo — the CONTROLLER for `/`: query the model, fill the
|
||||
-- view components that sit beside it, answer a Resp. One feature = one
|
||||
-- directory = one module, view and controller together.
|
||||
--
|
||||
-- It reaches the chapter nav through `use chapter` and the query through
|
||||
-- the model's `Chapters.links()` static — a class crosses module lines,
|
||||
-- a free fn does not.
|
||||
use framework/http
|
||||
use layout
|
||||
use chapter
|
||||
|
||||
pub class Home {
|
||||
fn handle(req: Req) -> Resp {
|
||||
let nav = ChapterNav { items: Chapters.links(), current: 0 };
|
||||
let page = HomePage { chapter_nav: nav };
|
||||
let shell = wide_shell("writeonce — learn the language", page.render());
|
||||
return ok_html(shell.render());
|
||||
}
|
||||
}
|
||||
53
docs/examples/site/home/view.wo
Normal file
53
docs/examples/site/home/view.wo
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
-- home/view.wo — the VIEW for `/`. A component: fields in, Text out.
|
||||
-- Everything on this page is static copy EXCEPT the chapter list, so
|
||||
-- the one field is that list's already-built component — content
|
||||
-- projection, the same slot pattern wo-html's `Layout` uses. HomePage
|
||||
-- therefore knows nothing about chapters, the Chapter table, or how the
|
||||
-- nav decides which entry is current.
|
||||
use html
|
||||
|
||||
pub class HomePage {
|
||||
chapter_nav: Component
|
||||
|
||||
fn render() -> Text {
|
||||
-- hero: tagline + the two CTAs (the go.dev shape, no JS anywhere)
|
||||
let h1 = el("h1", "text-4xl font-bold mb-4", "One language. One runtime.<br>One database. One binary.");
|
||||
let sub = el("p", "text-lg text-gray-700 leading-relaxed mb-6", "writeonce is a language whose compiler, runtime, web server and " .. "database ship as a single never-stopping Linux binary. Ownership-" .. "checked memory, inferred GC where ownership cannot reach, actors " .. "on every core — and the page you are reading is served by it.");
|
||||
let ctas = el("div", "flex items-center justify-center gap-4", btn_link("/ch/hello", "Get started", true) .. btn_link("https://github.com/shoneyj", "View source", false));
|
||||
let hero = el("div", "text-center py-16", h1 .. sub .. ctas);
|
||||
|
||||
-- code showcase: a real flavor of the language
|
||||
let show_head = el("h2", "text-2xl font-bold mb-2 text-center", "An actor per chat room, rows in the built-in database");
|
||||
let show_cap = el("p", "text-sm text-gray-500 text-center mb-4", "No broker, no ORM, no async keyword — ownership moves the message, the WAL makes the row durable.");
|
||||
let showcase = el("div", "mx-auto max-w-3xl mb-8", show_head .. show_cap .. home_snippet());
|
||||
|
||||
-- why-cards (2x2 grid, collapses on small screens)
|
||||
let cards = card("One binary", "woc build emits a self-contained executable: VM, your bytecode, the database engine. Deploys are a file copy; the runtime swaps code in place.");
|
||||
cards = cards .. card("Memory safety, no tax", "Rust-shaped ownership checked at compile time; where ownership cannot express the shape, the compiler infers GC — per shard, no global pause.");
|
||||
cards = cards .. card("The database is built in", "Every class is a table. Inserts are WAL-logged before they acknowledge; restart replays. No server to operate, no connection string.");
|
||||
cards = cards .. card("Actors on every core", "spawn returns an address, send moves ownership. Fibers park on io_uring instead of blocking threads — no async/await, ever.");
|
||||
let grid = el("div", "grid grid-cols-2 gap-6 mb-8", cards);
|
||||
|
||||
-- chapters (the gate's anchor string lives here)
|
||||
let learn = el("h2", "text-2xl font-bold mb-4", "Learn writeonce");
|
||||
let learn_p = el("p", "leading-relaxed mb-4", "The tutorial is written in the language and stored in its tables — work through the chapters in order:");
|
||||
let chapters = el("div", "bg-white rounded-lg border shadow-sm p-6 mb-8", learn .. learn_p .. self.chapter_nav.render());
|
||||
|
||||
return hero .. showcase .. grid .. chapters;
|
||||
}
|
||||
}
|
||||
|
||||
-- The homepage's code showcase: a real flavor of the language — an
|
||||
-- actor per chat room, rows in the built-in database, one binary.
|
||||
-- Page copy, so it lives with the page, not with the seed data.
|
||||
fn home_snippet() -> Text {
|
||||
let s = "@table\nclass Message {\n room: Text\n body: Text\n}\n\n";
|
||||
s = s .. "class Room {\n name: Text\n fn receive(msg: Post) {\n";
|
||||
s = s .. " insert Message { room: self.name, body: msg.body };\n";
|
||||
s = s .. " print(\"[\${self.name}] \${msg.body}\");\n }\n}\n\n";
|
||||
s = s .. "fn main() -> Int {\n";
|
||||
s = s .. " let general: actor Post = spawn Room { name: \"general\" };\n";
|
||||
s = s .. " send(general, Post { body: \"hello, writeonce\" });\n";
|
||||
s = s .. " time.sleep(50);\n return 0;\n}";
|
||||
return code_block(s);
|
||||
}
|
||||
48
docs/examples/site/layout/app.wo
Normal file
48
docs/examples/site/layout/app.wo
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
-- layout/app.wo — the app shell: one component wrapping every page with
|
||||
-- the shared header and footer. Unlike the shop template, this site
|
||||
-- INLINES its stylesheet (wo-html's `page()` does that), so the shell
|
||||
-- fills wo-html's own `Layout` rather than writing its own document.
|
||||
--
|
||||
-- The only thing that varies between pages is the container width, so
|
||||
-- that is the one extra slot — and the two widths are named once, here,
|
||||
-- instead of as class strings scattered through the controllers.
|
||||
use framework/http
|
||||
use html
|
||||
|
||||
pub class AppShell {
|
||||
title: Text
|
||||
container: Text
|
||||
content: Text
|
||||
|
||||
fn render() -> Text {
|
||||
let l = Layout {
|
||||
title: self.title,
|
||||
nav: header(),
|
||||
content: el("div", self.container, self.content),
|
||||
footer: footer()
|
||||
};
|
||||
return l.render();
|
||||
}
|
||||
}
|
||||
|
||||
-- Chapter pages keep a reading width.
|
||||
pub fn reading_shell(title: Text, content: Text) -> AppShell {
|
||||
return AppShell { title: title, container: "mx-auto max-w-3xl px-4 py-8", content: content };
|
||||
}
|
||||
|
||||
-- The homepage uses the wider container (the go.dev shape).
|
||||
pub fn wide_shell(title: Text, content: Text) -> AppShell {
|
||||
return AppShell { title: title, container: "mx-auto max-w-5xl px-4", content: content };
|
||||
}
|
||||
|
||||
-- Every non-200 HTML answer goes through here, so an error page is a
|
||||
-- real page: same chrome, same shell, just a different status.
|
||||
pub fn html_error(status: Int, title: Text, msg: Text) -> Resp {
|
||||
let content = el("h1", "text-2xl font-bold mb-4", title) ..
|
||||
el("p", "", msg) ..
|
||||
el("p", "", link("/", "", "Back to the chapters"));
|
||||
let shell = reading_shell("writeonce — ${title}", content);
|
||||
let h: map<Text, Text> = {};
|
||||
h["content-type"] = "text/html; charset=utf-8";
|
||||
return Resp { status: status, headers: h, body: shell.render() };
|
||||
}
|
||||
6
docs/examples/site/layout/footer.wo
Normal file
6
docs/examples/site/layout/footer.wo
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- layout/footer.wo — the site footer, shared by every page.
|
||||
use html
|
||||
|
||||
pub fn footer() -> Text {
|
||||
return el("div", "footer", "writeonce.de — served by the language it teaches. " .. "One binary: compiler, runtime, database, this page.");
|
||||
}
|
||||
9
docs/examples/site/layout/header.wo
Normal file
9
docs/examples/site/layout/header.wo
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- layout/header.wo — the site navigation bar, shared by every page.
|
||||
use html
|
||||
|
||||
pub fn header() -> Text {
|
||||
let links = link("/ch/hello", "text-gray-700", "Tutorial");
|
||||
links = links .. link("/health", "text-gray-700", "Health");
|
||||
links = links .. link("https://github.com/shoneyj", "text-gray-700", "GitHub");
|
||||
return nav_bar("/", "writeonce.de", links);
|
||||
}
|
||||
|
|
@ -8,167 +8,17 @@
|
|||
--
|
||||
-- Behind nginx/caddy for writeonce.de: the proxy terminates TLS and
|
||||
-- forwards to 127.0.0.1:8080 (the framework speaks HTTP/1.1 keep-alive).
|
||||
--
|
||||
-- This file is the BOOTSTRAP and nothing else: seed, routes, serve. The
|
||||
-- model is types.wo; every feature is a directory holding its view and
|
||||
-- its controller.
|
||||
use env
|
||||
use framework
|
||||
use framework/http
|
||||
use framework/router
|
||||
use html
|
||||
|
||||
-- Every chapter is a row: slug is the URL, ord orders the nav, body is a
|
||||
-- server-rendered HTML fragment. Edits (the admin route) persist through
|
||||
-- the WAL under WO_DATA and replay on restart.
|
||||
@table(name: "chapters", index: [slug])
|
||||
class Chapter {
|
||||
slug: Text @unique
|
||||
ord: Int
|
||||
title: Text
|
||||
body: Text
|
||||
}
|
||||
|
||||
fn seed_if_empty() {
|
||||
let n = 0;
|
||||
for c in from x in Chapter take 1 select x {
|
||||
n = n + 1;
|
||||
}
|
||||
if n == 0 {
|
||||
seed_chapters();
|
||||
}
|
||||
}
|
||||
|
||||
-- ---- rendering ---------------------------------------------------------
|
||||
|
||||
fn ok_html(body: Text) -> Resp {
|
||||
let h: map<Text, Text> = {};
|
||||
h["content-type"] = "text/html; charset=utf-8";
|
||||
return Resp { status: 200, headers: h, body: body };
|
||||
}
|
||||
|
||||
fn site_nav() -> Text {
|
||||
let links = link("/ch/hello", "text-gray-700", "Tutorial");
|
||||
links = links .. link("/health", "text-gray-700", "Health");
|
||||
links = links .. link("https://github.com/shoneyj", "text-gray-700", "GitHub");
|
||||
return nav_bar("/", "writeonce.de", links);
|
||||
}
|
||||
|
||||
fn site_footer() -> Text {
|
||||
return el("div", "footer", "writeonce.de — served by the language it teaches. " .. "One binary: compiler, runtime, database, this page.");
|
||||
}
|
||||
|
||||
fn shell(title: Text, inner: Text) -> Text {
|
||||
let body = site_nav() .. el("div", "mx-auto max-w-3xl px-4 py-8", inner) .. site_footer();
|
||||
return page(title, body);
|
||||
}
|
||||
|
||||
-- The homepage uses the wider container (go.dev shape); chapter pages
|
||||
-- keep the reading width above.
|
||||
fn shell_wide(title: Text, inner: Text) -> Text {
|
||||
let body = site_nav() .. el("div", "mx-auto max-w-5xl px-4", inner) .. site_footer();
|
||||
return page(title, body);
|
||||
}
|
||||
|
||||
fn chapter_nav(current: Int) -> Text {
|
||||
let items = "";
|
||||
for c in from x in Chapter order by x.ord select x {
|
||||
let label = "${c.ord}. ${esc(c.title)}";
|
||||
if c.ord == current {
|
||||
items = items .. el("li", "mb-2 font-bold text-gray-900", label);
|
||||
} else {
|
||||
items = items .. el("li", "mb-2", link("/ch/${c.slug}", "", label));
|
||||
}
|
||||
}
|
||||
return el("ul", "list-disc pl-6", items);
|
||||
}
|
||||
|
||||
-- ---- handlers ----------------------------------------------------------
|
||||
|
||||
class Home {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
-- hero: tagline + the two CTAs (the go.dev shape, no JS anywhere)
|
||||
let h1 = el("h1", "text-4xl font-bold mb-4", "One language. One runtime.<br>One database. One binary.");
|
||||
let sub = el("p", "text-lg text-gray-700 leading-relaxed mb-6", "writeonce is a language whose compiler, runtime, web server and " .. "database ship as a single never-stopping Linux binary. Ownership-" .. "checked memory, inferred GC where ownership cannot reach, actors " .. "on every core — and the page you are reading is served by it.");
|
||||
let ctas = el("div", "flex items-center justify-center gap-4", btn_link("/ch/hello", "Get started", true) .. btn_link("https://github.com/shoneyj", "View source", false));
|
||||
let hero = el("div", "text-center py-16", h1 .. sub .. ctas);
|
||||
|
||||
-- code showcase: a real flavor of the language
|
||||
let show_head = el("h2", "text-2xl font-bold mb-2 text-center", "An actor per chat room, rows in the built-in database");
|
||||
let show_cap = el("p", "text-sm text-gray-500 text-center mb-4", "No broker, no ORM, no async keyword — ownership moves the message, the WAL makes the row durable.");
|
||||
let showcase = el("div", "mx-auto max-w-3xl mb-8", show_head .. show_cap .. home_snippet());
|
||||
|
||||
-- why-cards (2x2 grid, collapses on small screens)
|
||||
let cards = card("One binary", "woc build emits a self-contained executable: VM, your bytecode, the database engine. Deploys are a file copy; the runtime swaps code in place.");
|
||||
cards = cards .. card("Memory safety, no tax", "Rust-shaped ownership checked at compile time; where ownership cannot express the shape, the compiler infers GC — per shard, no global pause.");
|
||||
cards = cards .. card("The database is built in", "Every class is a table. Inserts are WAL-logged before they acknowledge; restart replays. No server to operate, no connection string.");
|
||||
cards = cards .. card("Actors on every core", "spawn returns an address, send moves ownership. Fibers park on io_uring instead of blocking threads — no async/await, ever.");
|
||||
let grid = el("div", "grid grid-cols-2 gap-6 mb-8", cards);
|
||||
|
||||
-- chapters (the gate's anchor string lives here)
|
||||
let learn = el("h2", "text-2xl font-bold mb-4", "Learn writeonce");
|
||||
let learn_p = el("p", "leading-relaxed mb-4", "The tutorial is written in the language and stored in its tables — work through the chapters in order:");
|
||||
let chapters = el("div", "bg-white rounded-lg border shadow-sm p-6 mb-8", learn .. learn_p .. chapter_nav(0));
|
||||
|
||||
return ok_html(shell_wide("writeonce — learn the language", hero .. showcase .. grid .. chapters));
|
||||
}
|
||||
}
|
||||
|
||||
class ShowChapter {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
let slug = req.params["slug"];
|
||||
if slug == nil { return not_found(); }
|
||||
let hits = from c in Chapter where c.slug == slug take 1 select c;
|
||||
if len(hits) == 0 {
|
||||
let msg = el("h1", "text-2xl font-bold mb-4", "No such chapter");
|
||||
let back = el("p", "", link("/", "", "Back to the chapters"));
|
||||
let h: map<Text, Text> = {};
|
||||
h["content-type"] = "text/html; charset=utf-8";
|
||||
let nf = shell("writeonce — not found", msg .. back);
|
||||
return Resp { status: 404, headers: h, body: nf };
|
||||
}
|
||||
let c = hits[0];
|
||||
let head = el("h1", "text-3xl font-bold mb-4", "${c.ord}. ${esc(c.title)}");
|
||||
let art = el("div", "bg-white rounded-lg border shadow-sm p-6", head .. c.body);
|
||||
let nav = el("div", "mt-8", el("h2", "text-lg font-bold mb-2", "Chapters") .. chapter_nav(c.ord));
|
||||
return ok_html(shell("writeonce — ${c.title}", art .. nav));
|
||||
}
|
||||
}
|
||||
|
||||
-- POST /admin/ch/:slug — title/body update, form-encoded, bearer-gated.
|
||||
-- Mechanism (bearer_token, constant-time ct_eq) is the framework's;
|
||||
-- POLICY — which routes, which token — is this app's, right here.
|
||||
class AdminEdit {
|
||||
token: Text
|
||||
fn handle(req: Req) -> Resp {
|
||||
let got = bearer_token(req);
|
||||
if got == nil { return unauthorized(); }
|
||||
if ct_eq("${got}", self.token) == false { return unauthorized(); }
|
||||
let slug = req.params["slug"];
|
||||
if slug == nil { return not_found(); }
|
||||
let hits = from c in Chapter where c.slug == slug take 1 select c;
|
||||
if len(hits) == 0 { return not_found(); }
|
||||
let f = form_values(req);
|
||||
if f == nil { return bad_request("body must be form-encoded (title, body)"); }
|
||||
let title = f["title"];
|
||||
let body = f["body"];
|
||||
if title == nil and body == nil { return bad_request("nothing to update"); }
|
||||
if title != nil {
|
||||
let t = trim("${title}");
|
||||
if t == "" { return bad_request("title must not be empty"); }
|
||||
hits[0].title = t;
|
||||
}
|
||||
if body != nil {
|
||||
hits[0].body = "${body}";
|
||||
}
|
||||
return redirect("/ch/${slug}");
|
||||
}
|
||||
}
|
||||
|
||||
class Health {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
return ok_text("ok");
|
||||
}
|
||||
}
|
||||
use home
|
||||
use chapter
|
||||
use admin
|
||||
use health
|
||||
|
||||
fn main(args: multi Text) -> Int {
|
||||
if len(args) < 1 {
|
||||
|
|
@ -189,10 +39,10 @@ fn main(args: multi Text) -> Int {
|
|||
seed_if_empty();
|
||||
|
||||
let app = App { middleware: [], routes: [] };
|
||||
app.use_mw(Mw { m: Logging { pad: 0 } });
|
||||
app.get("/", Home { pad: 0 });
|
||||
app.get("/health", Health { pad: 0 });
|
||||
app.get("/ch/:slug", ShowChapter { pad: 0 });
|
||||
app.use_mw(Mw { m: Logging {} });
|
||||
app.get("/", Home {});
|
||||
app.get("/health", Health {});
|
||||
app.get("/ch/:slug", ShowChapter {});
|
||||
app.post("/admin/ch/:slug", AdminEdit { token: "${token}" });
|
||||
return app.serve("127.0.0.1", port);
|
||||
}
|
||||
|
|
|
|||
52
docs/examples/site/types.wo
Normal file
52
docs/examples/site/types.wo
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
-- types.wo — the MODEL. Every @table class IS a WAL-backed table: rows
|
||||
-- persist under WO_DATA and replay on restart; without WO_DATA the
|
||||
-- store is RAM-only. Nothing else lives here — no rendering, no request
|
||||
-- handling.
|
||||
--
|
||||
-- Root module by NECESSITY, not choice: `pub` and `@table` cannot
|
||||
-- combine yet (recorded language gap), so tables cannot be exported to
|
||||
-- other modules — everything that queries them (the controllers) lives
|
||||
-- in the root module too.
|
||||
|
||||
-- Every chapter is a row: slug is the URL, ord orders the nav, body is a
|
||||
-- server-rendered HTML fragment. Edits (the admin route) persist through
|
||||
-- the WAL under WO_DATA and replay on restart.
|
||||
@table(name: "chapters", index: [slug])
|
||||
class Chapter {
|
||||
slug: Text @unique
|
||||
ord: Int
|
||||
title: Text
|
||||
body: Text
|
||||
}
|
||||
|
||||
-- One nav entry: a PROJECTION of a Chapter row, not the row itself. It
|
||||
-- lives with the model because that is what it is — the views merely
|
||||
-- consume it, and never hold a database handle.
|
||||
typedef ChapterLink = { ord: Int, title: Text, slug: Text }
|
||||
|
||||
-- The chapter queries, on a class so every feature module can reach
|
||||
-- them: a free `fn` is scoped to the module that declares it (WO-E210),
|
||||
-- but a CLASS — and its statics — is reachable across module lines.
|
||||
-- That is what lets `home/` and `chapter/` share one query without one
|
||||
-- importing the other.
|
||||
class Chapters {
|
||||
-- Every chapter as a nav entry, ordered. Two pages call this.
|
||||
static fn links() -> multi ChapterLink {
|
||||
let items: multi ChapterLink = [];
|
||||
for c in from x in Chapter order by x.ord select x {
|
||||
push(items, ChapterLink { ord: c.ord, title: c.title, slug: c.slug });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
-- First boot only: an empty table gets the tutorial (content.wo).
|
||||
fn seed_if_empty() {
|
||||
let n = 0;
|
||||
for c in from x in Chapter take 1 select x {
|
||||
n = n + 1;
|
||||
}
|
||||
if n == 0 {
|
||||
seed_chapters();
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ fn view_json(name: Text, price: Float, stock: Int) -> Text {
|
|||
}
|
||||
|
||||
class ListProducts {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
let body = "[";
|
||||
let first = true;
|
||||
|
|
@ -33,7 +32,6 @@ class ListProducts {
|
|||
}
|
||||
|
||||
class ShowProduct {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
let name = req.params["name"];
|
||||
if name == nil { return bad_request("no name"); }
|
||||
|
|
@ -54,7 +52,6 @@ fn create_product(name: Text, price: Float, stock: Int) -> Resp {
|
|||
}
|
||||
|
||||
class CreateProduct {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
if media_type(req) == "multipart/form-data" {
|
||||
let ps = multipart_parts(req);
|
||||
|
|
@ -95,7 +92,6 @@ class CreateProduct {
|
|||
}
|
||||
|
||||
class CreateOrder {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
let v = json.decode(req.body) as NewOrder;
|
||||
if v == nil { return bad_request("body must be {product, qty}"); }
|
||||
|
|
@ -108,7 +104,6 @@ class CreateOrder {
|
|||
}
|
||||
|
||||
class DeleteProduct {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
let name = req.params["name"];
|
||||
if name == nil { return bad_request("no name"); }
|
||||
|
|
@ -146,7 +141,6 @@ class ConnWorker {
|
|||
|
||||
-- a deliberately slow route: the concurrency proof's workload
|
||||
class Slow {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
time.sleep(400);
|
||||
return ok_text("slow done");
|
||||
|
|
@ -157,7 +151,6 @@ class Slow {
|
|||
|
||||
-- wildcard capture: GET /files/*path echoes the rest
|
||||
class EchoPath {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
let p = req.params["path"];
|
||||
if p == nil { return ok_text("path="); }
|
||||
|
|
@ -167,7 +160,6 @@ class EchoPath {
|
|||
|
||||
-- group middleware writes the request-scoped ctx bag; the handler reads it
|
||||
class StampCtx {
|
||||
pad: Int
|
||||
fn before(mut req: Req) -> ?Resp {
|
||||
req.ctx["via"] = "api-group";
|
||||
return nil;
|
||||
|
|
@ -175,7 +167,6 @@ class StampCtx {
|
|||
}
|
||||
|
||||
class ApiPing {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
let via = req.ctx["via"];
|
||||
if via == nil { return ok_text("pong via="); }
|
||||
|
|
@ -185,7 +176,6 @@ class ApiPing {
|
|||
|
||||
-- ETag + conditional: same body = same tag; If-None-Match collapses to 304
|
||||
class EtagProbe {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
return with_etag(req, ok_json("{\"v\":1}"));
|
||||
}
|
||||
|
|
@ -193,7 +183,6 @@ class EtagProbe {
|
|||
|
||||
-- response-side negotiation: JSON or nothing
|
||||
class NegoProbe {
|
||||
pad: Int
|
||||
fn handle(req: Req) -> Resp {
|
||||
if accepts(req, "application/json") == false {
|
||||
let h: map<Text, Text> = {};
|
||||
|
|
@ -256,20 +245,20 @@ fn build_app(token: Text) -> App {
|
|||
app.use_mw(Mw { m: BearerAuth { token: "${token}", principal: "api" } });
|
||||
-- the response half: security headers + the CORS origin stamp on every
|
||||
-- response that leaves dispatch (404/405/401 included)
|
||||
app.use_after(Aw { a: SecurityHeaders { pad: 0 } });
|
||||
app.use_after(Aw { a: SecurityHeaders {} });
|
||||
app.use_after(Aw { a: Cors { allow_origin: "*" } });
|
||||
app.get("/products", ListProducts { pad: 0 });
|
||||
app.get("/products/:name", ShowProduct { pad: 0 });
|
||||
app.post("/products", CreateProduct { pad: 0 });
|
||||
app.post("/orders", CreateOrder { pad: 0 });
|
||||
app.delete_("/products/:name", DeleteProduct { pad: 0 });
|
||||
app.get("/files/*path", EchoPath { pad: 0 });
|
||||
app.get("/etag-probe", EtagProbe { pad: 0 });
|
||||
app.get("/nego", NegoProbe { pad: 0 });
|
||||
app.get("/slow", Slow { pad: 0 });
|
||||
app.get("/products", ListProducts {});
|
||||
app.get("/products/:name", ShowProduct {});
|
||||
app.post("/products", CreateProduct {});
|
||||
app.post("/orders", CreateOrder {});
|
||||
app.delete_("/products/:name", DeleteProduct {});
|
||||
app.get("/files/*path", EchoPath {});
|
||||
app.get("/etag-probe", EtagProbe {});
|
||||
app.get("/nego", NegoProbe {});
|
||||
app.get("/slow", Slow {});
|
||||
let g = Group { prefix: "/api" };
|
||||
g.use_mw(Mw { m: StampCtx { pad: 0 } });
|
||||
g.get("/ping", ApiPing { pad: 0 });
|
||||
g.use_mw(Mw { m: StampCtx {} });
|
||||
g.get("/ping", ApiPing {});
|
||||
app.mount(g);
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
102
docs/examples/wo-html/README.md
Normal file
102
docs/examples/wo-html/README.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# wo-html — server-rendered HTML as plain Text
|
||||
|
||||
A view library, not a framework and not a template engine. Everything in
|
||||
it is a pure function or a class with a `render()`; nothing here opens a
|
||||
socket, reads a file, or touches the database.
|
||||
|
||||
```
|
||||
[deps]
|
||||
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" }
|
||||
```
|
||||
|
||||
## The four layers
|
||||
|
||||
| layer | what it is |
|
||||
| --- | --- |
|
||||
| `esc()` | HTML-escape `& < > "`. A `{{ }}` hole in a raw text literal compiles to a call to this, so display data is escaped by construction; calling it by hand is the fallback, not the norm |
|
||||
| `el()`, `link()`, `card()`, `form_post()`, … | element builders — `el("h1", "text-3xl font-bold", t)` |
|
||||
| `Component` / `Layout` / `render_all()` | the view unit and its composition |
|
||||
| `tw_css()` / `page()` | a hand-written Tailwind-style utility sheet, inlined into one self-contained document — no CDN, no build step, no JS |
|
||||
|
||||
## Components
|
||||
|
||||
A component is a class with fields and `fn render() -> Text`. Nothing
|
||||
declares that it implements `Component` — satisfaction is **structural**,
|
||||
exactly like the framework's `Handler`. Its fields ARE its inputs; the
|
||||
language has no closures, so a field is the capture.
|
||||
|
||||
```
|
||||
class ProductCard {
|
||||
sku: Text
|
||||
name: Text
|
||||
fn render() -> Text {
|
||||
return `
|
||||
<div class="card">
|
||||
<h3><a href="/p/{{ self.sku }}">{{ self.name }}</a></h3>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Composition is nesting — a parent holds children and calls their render:
|
||||
|
||||
```
|
||||
pub class ProductListPage {
|
||||
cards: multi Component
|
||||
fn render() -> Text {
|
||||
return `<div class="grid">${render_all(self.cards)}</div>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`multi Component` holds a heterogeneous list **directly**; no wrapper
|
||||
record is needed (the framework's `Mw`/`Aw` wrappers are not a language
|
||||
requirement). `render_all(cs)` renders children in order.
|
||||
|
||||
`Layout { title, nav, content, footer }` is content projection —
|
||||
Angular's `<ng-content>` with the slots as ordinary pre-rendered `Text`.
|
||||
The caller passes `child.render()`, a raw literal, or a builder's output;
|
||||
the layout never learns which, which is precisely why it never needs the
|
||||
child's type. A page wanting a fixed-width column wraps its content
|
||||
before handing it over — deliberately no container knob here.
|
||||
|
||||
`Layout` renders through `page()`, so it inlines the utility sheet. An
|
||||
app that links a real stylesheet instead writes its own two-slot shell
|
||||
component — `docs/examples/shop/layout/app.wo` is that case, and it is a
|
||||
component like any other.
|
||||
|
||||
## The MVC seam
|
||||
|
||||
| | where it lives |
|
||||
| --- | --- |
|
||||
| **Model** | `@table` rows, queried in the HANDLER. This library contains no `from … select` anywhere and must not grow one |
|
||||
| **View** | components: fields in, Text out. No hidden state, no globals — a page is byte-deterministic from its fields |
|
||||
| **Controller** | the framework's `Handler`: it queries, fills the component's fields, and answers `ok_html(c.render())` |
|
||||
|
||||
`ok_html` is the **framework's** (`framework/http`, beside `ok_text` and
|
||||
`ok_json`): a status line plus a content-type is transport, not
|
||||
rendering, so wo-html never learns what a `Resp` is.
|
||||
|
||||
The seam is what makes a view testable without a server and a query
|
||||
testable without markup. Breaking it looks like one convenience — a
|
||||
component that queries "just this once" — and costs both.
|
||||
|
||||
## Deliberately absent
|
||||
|
||||
Client-side anything (change detection, event bindings, two-way binding,
|
||||
SPA routing, hydration): the no-JS posture stands, and interactivity is
|
||||
form round trips. A runtime template engine: reflection-free means an
|
||||
untyped `map<Text, Text>`, so templates compile to code at build time or
|
||||
they do not exist. Dependency injection: components are data-in,
|
||||
Text-out. Structural directives (`w:if` / `w:for`): the language's own
|
||||
`if` and `for` compose literals, and no sample has yet proven the need
|
||||
for a second control-flow dialect. Scoped CSS: the utility sheet stays
|
||||
one static string until the pain is measured.
|
||||
|
||||
## Consumers
|
||||
|
||||
- [`docs/examples/site`](../site) — the tutorial site: `Layout` +
|
||||
a `ChapterNav` component reused on the homepage and every chapter page.
|
||||
Gated by `just site`.
|
||||
- [`docs/examples/shop`](../shop/README.md) — the program template: its
|
||||
own `AppShell` component, page components holding child components.
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
-- wo-html — server-rendered HTML as plain Text. Three layers, all pure:
|
||||
-- esc() HTML-escape untrusted text (the ONLY defense: use it on
|
||||
-- everything that did not come from your own code)
|
||||
-- wo-html — server-rendered HTML as plain Text. Four layers, all pure:
|
||||
-- esc() HTML-escape untrusted text. `{{ }}` in a raw text
|
||||
-- literal compiles to a call to this, so display data is
|
||||
-- escaped by construction; esc() by hand is the fallback
|
||||
-- el()/... element builders — `el("h1", "text-3xl font-bold", t)`
|
||||
-- Component the view unit: a class with fields + `fn render() ->
|
||||
-- Text`, satisfied structurally (iteration 37)
|
||||
-- tw_css() a Tailwind-style utility stylesheet: the same class
|
||||
-- names Tailwind popularized, hand-written as one static
|
||||
-- sheet, inlined by page() so a page is one self-contained
|
||||
|
|
@ -11,6 +14,20 @@
|
|||
-- take exactly (tag, classes, inner) and pages compose by `..` and by
|
||||
-- functions returning Text. That constraint is the demo: an HTML layer
|
||||
-- in writeonce is ordinary code, not a template dialect.
|
||||
--
|
||||
-- ---- the MVC seam this library sits on --------------------------------
|
||||
--
|
||||
-- MODEL `@table` rows. Queried in the HANDLER, never here — this
|
||||
-- library has no `from ... select` anywhere in it and must
|
||||
-- not grow one. A component receives VALUES, not a cursor.
|
||||
-- VIEW components: fields in, Text out, no hidden state and no
|
||||
-- globals, so a page is byte-deterministic from its fields.
|
||||
-- CONTROLLER the framework's `Handler`: it queries, fills the component's
|
||||
-- fields, and answers `ok_html(c.render())`.
|
||||
--
|
||||
-- The seam is what makes a view testable without a server and a query
|
||||
-- testable without markup. Breaking it looks like one convenience —
|
||||
-- a component that queries "just this once" — and costs both.
|
||||
|
||||
-- HTML-escape: & < > " (the four that matter in text and attributes).
|
||||
pub fn esc(t: Text) -> Text {
|
||||
|
|
@ -39,14 +56,14 @@ pub fn esc(t: Text) -> Text {
|
|||
-- CALLER's business: pass esc(user_text) for data, raw markup for
|
||||
-- fragments you built yourself.
|
||||
pub fn el(tag: Text, cls: Text, inner: Text) -> Text {
|
||||
if cls == "" { return "<${tag}>${inner}</${tag}>"; }
|
||||
return "<${tag} class=\"${cls}\">${inner}</${tag}>";
|
||||
if cls == "" { return `<${tag}>${inner}</${tag}>`; }
|
||||
return `<${tag} class="${cls}">${inner}</${tag}>`;
|
||||
}
|
||||
|
||||
-- An anchor: href is attribute context, so it is escaped here.
|
||||
pub fn link(href: Text, cls: Text, label: Text) -> Text {
|
||||
if cls == "" { return "<a href=\"${esc(href)}\">${label}</a>"; }
|
||||
return "<a href=\"${esc(href)}\" class=\"${cls}\">${label}</a>";
|
||||
if cls == "" { return `<a href="{{ href }}">${label}</a>`; }
|
||||
return `<a href="{{ href }}" class="${cls}">${label}</a>`;
|
||||
}
|
||||
|
||||
-- A code block: content is ALWAYS escaped — code samples are exactly the
|
||||
|
|
@ -56,19 +73,19 @@ pub fn code_block(src: Text) -> Text {
|
|||
}
|
||||
|
||||
pub fn text_input(name: Text, value: Text) -> Text {
|
||||
return "<input type=\"text\" name=\"${esc(name)}\" value=\"${esc(value)}\" class=\"field\">";
|
||||
return `<input type="text" name="{{ name }}" value="{{ value }}" class="field">`;
|
||||
}
|
||||
|
||||
pub fn text_area(name: Text, value: Text, rows: Int) -> Text {
|
||||
return "<textarea name=\"${esc(name)}\" rows=\"${rows}\" class=\"field\">${esc(value)}</textarea>";
|
||||
return `<textarea name="{{ name }}" rows="${rows}" class="field">{{ value }}</textarea>`;
|
||||
}
|
||||
|
||||
pub fn submit_btn(label: Text) -> Text {
|
||||
return "<button type=\"submit\" class=\"btn\">${esc(label)}</button>";
|
||||
return `<button type="submit" class="btn">{{ label }}</button>`;
|
||||
}
|
||||
|
||||
pub fn form_post(action: Text, inner: Text) -> Text {
|
||||
return "<form method=\"POST\" action=\"${esc(action)}\" class=\"flex flex-col gap-2\">${inner}</form>";
|
||||
return `<form method="POST" action="{{ action }}" class="flex flex-col gap-2">${inner}</form>`;
|
||||
}
|
||||
|
||||
-- A sticky top navigation bar: brand on the left, a prebuilt row of
|
||||
|
|
@ -149,11 +166,63 @@ pub fn tw_css() -> Text {
|
|||
-- One full document: the sheet inlined, viewport set, body handed in.
|
||||
-- Self-contained by construction — view-source shows everything.
|
||||
pub fn page(title: Text, body: Text) -> Text {
|
||||
let d = "<!doctype html><html><head><meta charset=\"utf-8\">";
|
||||
d = d .. "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">";
|
||||
d = d .. "<title>${esc(title)}</title>";
|
||||
d = d .. "<style>${tw_css()}</style></head><body>";
|
||||
d = d .. body;
|
||||
d = d .. "</body></html>";
|
||||
return d;
|
||||
-- The whole document as one literal. Every newline here lands
|
||||
-- inside <head>, where whitespace is insignificant; the body hole
|
||||
-- and its closing tags share one line so nothing is inserted into
|
||||
-- the rendered content.
|
||||
return `
|
||||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{ title }}</title>
|
||||
<style>${tw_css()}</style>
|
||||
</head><body>${body}</body></html>`;
|
||||
}
|
||||
|
||||
-- ---- components (iteration 37) ----------------------------------------
|
||||
|
||||
-- The view twin of the framework's `Handler`: a class with fields and a
|
||||
-- render, satisfied STRUCTURALLY — nothing declares that it implements
|
||||
-- this, a class either has `fn render() -> Text` or it does not (a class
|
||||
-- missing it is WO-E205 at the use site). Composition is nesting: a
|
||||
-- parent's render calls its children's.
|
||||
--
|
||||
-- A component's fields ARE its inputs — the closure substitute this
|
||||
-- language's no-function-values doctrine forces, and the same shape the
|
||||
-- framework already proved for handlers. `multi Component` works
|
||||
-- directly, so a heterogeneous list of children needs no wrapper record.
|
||||
pub interface Component {
|
||||
fn render() -> Text
|
||||
}
|
||||
|
||||
-- Render children in order. The one place in this library where the
|
||||
-- interface is genuinely load-bearing: this function knows nothing about
|
||||
-- any of them beyond `render()`, and `multi Component` holds a
|
||||
-- heterogeneous list directly — no wrapper record, which the framework's
|
||||
-- Mw/Aw shape might lead you to expect.
|
||||
pub fn render_all(cs: multi Component) -> Text {
|
||||
let out = "";
|
||||
for c in cs {
|
||||
out = out .. c.render();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
-- Content projection, writeonce-shaped: Angular's `<ng-content>` is a
|
||||
-- slot the parent fills, and here a slot is just a pre-rendered `Text`
|
||||
-- field. The caller passes `child.render()`, a raw literal, or a
|
||||
-- builder's output — the layout never knows which, which is exactly why
|
||||
-- it never needs to know the child's type.
|
||||
--
|
||||
-- Deliberately four slots and no container/width field: a page that
|
||||
-- wants its content in a fixed-width column wraps it before passing it
|
||||
-- in. One less knob here beats one more.
|
||||
pub class Layout {
|
||||
title: Text
|
||||
nav: Text
|
||||
content: Text
|
||||
footer: Text
|
||||
|
||||
fn render() -> Text {
|
||||
return page(self.title, self.nav .. self.content .. self.footer);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
--
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -37,6 +37,17 @@ pub fn ok_text(body: Text) -> Resp {
|
|||
return Resp { status: 200, headers: h, body: body };
|
||||
}
|
||||
|
||||
-- iteration 37: HTML is transport here, exactly like text and JSON —
|
||||
-- the status line and the content-type, nothing about rendering. It
|
||||
-- lives beside its two siblings because both HTML apps had hand-rolled
|
||||
-- the identical four lines; wo-html stays a pure Text library and never
|
||||
-- learns what a Resp is.
|
||||
pub fn ok_html(body: Text) -> Resp {
|
||||
let h: map<Text, Text> = {};
|
||||
h["content-type"] = "text/html; charset=utf-8";
|
||||
return Resp { status: 200, headers: h, body: body };
|
||||
}
|
||||
|
||||
pub fn ok_json(body: Text) -> Resp {
|
||||
let h: map<Text, Text> = {};
|
||||
h["content-type"] = "application/json";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
244
docs/guides/language-surface.md
Normal file
244
docs/guides/language-surface.md
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# The writeonce language surface — everything a `.wo` file may contain
|
||||
|
||||
Derived from the front end as it stands on 2026-08-24, by reading
|
||||
`compiler/src/lexer.ml`, `parser.ml`, `ast.ml` and `types.ml` — not a
|
||||
spec. Where this disagrees with the compiler, the compiler is right.
|
||||
The normative companions are
|
||||
[`08-builtin-surface.md`](../plan/oop-vm/08-builtin-surface.md) (what the
|
||||
runtime offers) and [`01-error-catalog.md`](../plan/oop-vm/01-error-catalog.md)
|
||||
(every diagnostic). The reasoning under the front end is
|
||||
[`compiler/src/CODE-LOGIC.md`](../../compiler/src/CODE-LOGIC.md).
|
||||
|
||||
Read this as the answer to "what can I write?" — the last section is the
|
||||
matching answer to "what will the compiler refuse?", which is just as
|
||||
much part of the surface.
|
||||
|
||||
Every form listed below was compiled and run against `woc`/`wovm` while
|
||||
writing this page, not read off the parser and hoped for.
|
||||
|
||||
---
|
||||
|
||||
## 1. Lexical
|
||||
|
||||
| thing | form | notes |
|
||||
| --- | --- | --- |
|
||||
| comment | `-- to end of line` | the only comment form; no block comment |
|
||||
| identifier | `[A-Za-z_][A-Za-z0-9_-]*` | **internal dashes are legal** — `a-b` is ONE identifier, so binary minus after an identifier needs spaces (`a - b`) |
|
||||
| integer | `42`, `0xFF`, `0b1011`, `1_000_000` | `_` only BETWEEN digits; hex/binary accumulate in 63-bit OCaml int, so a full-width `0xFFFF…FFFF` is out of reach (`-1` spells all-ones) |
|
||||
| float | `1.5`, `2e9`, `1.0e-3` | a bare digit run stays `Int`; only a fraction or exponent makes a `Float` |
|
||||
| text | `"..."` or `'...'` | escapes `\n \t \r \0 \\ \" \'`; anything else after `\` is that literal character. A raw newline inside is **WO-E005** |
|
||||
| interpolation | `"${expr}"` | desugars at parse time to a `..` chain; `\$` is a literal `$`, and a lone `$` not followed by `{` is literal |
|
||||
| raw text literal | `` `...` `` | iteration 37 — content verbatim (NO escape processing), newlines are content, common source margin removed at compile time. Holes: `${e}` raw, `{{ e }}` HTML-escaped |
|
||||
| booleans / nil | `true`, `false`, `nil` | |
|
||||
| newline | significant | terminates a statement (or `;`). A line ending in `..` continues on the next — the one newline suppression |
|
||||
| build flags | `#if name` / `#else` / `#end` | token-level filter, flag NAMES only (no expressions); nesting allowed; set with `woc -D name` |
|
||||
|
||||
**Keywords** (35): `type class interface fn let mut take return if else
|
||||
while for in true false use spawn using pub break continue do const and
|
||||
or not inline switch case default typedef try catch nil as INSERT
|
||||
SELECT`.
|
||||
|
||||
Deliberately NOT keywords, so they lex as ordinary identifiers: `self`,
|
||||
`me`, `subscribe`, `receive`, lowercase `insert`/`select`, and every
|
||||
query clause word (`from`, `where`, `group`, `by`, `into`, `order`,
|
||||
`desc`, `take`, `select`) plus every type constructor word (`ref`,
|
||||
`multi`, `map`, `backlink`, `actor`).
|
||||
|
||||
## 2. File and module level
|
||||
|
||||
A directory is a module; `pub` is the export line. A file may contain,
|
||||
in any order:
|
||||
|
||||
| declaration | form |
|
||||
| --- | --- |
|
||||
| import | `use fs` (reserved stdlib namespace) or `use shared/util` (project-relative path) |
|
||||
| import + extension methods | `using shared/textutil` — the module's `pub` free fns whose first parameter matches a receiver become callable as methods on it (compile-time rewrite) |
|
||||
| class | `[pub] class Name { fields, methods, consts }` |
|
||||
| plain type | `[pub] type Name { ... }` — identical field grammar to `class`; methods parse for real in both |
|
||||
| structural record | `typedef Name = { field: T, ... }` — fields only, and two records of the same SHAPE are the same type |
|
||||
| tagged union | `type Name = A \| B \| C(x: Int, ...)` — bare variants lower to integer tags, payload variants to records. Construction is call-style and POSITIONAL: `C(1, 2)`, never `C { x: 1 }` |
|
||||
| interface | `[pub] interface Name { fn sig(...) -> T }` — signatures only, no fields, no bodies. Satisfaction is STRUCTURAL |
|
||||
| free function | `[pub] fn name(params) -> T { ... }` |
|
||||
| constant | `const NAME = <literal>` — substituted by the parser before typecheck; a local of the same name shadows it |
|
||||
|
||||
The program entry is the free `fn main`, zero-argument or `fn
|
||||
main(args: multi Text) -> Int`.
|
||||
|
||||
### Annotations
|
||||
|
||||
| annotation | where | effect |
|
||||
| --- | --- | --- |
|
||||
| `@table(name: "…", index: [a], index: [b, c])` | on a `class`/`type` | the class IS a WAL-backed table |
|
||||
| `@unique` | on a field | uniqueness constraint |
|
||||
| `@gc` | on a class | **rejected** — GC-ness is inferred, never declared |
|
||||
|
||||
Unknown annotation names parse and are ignored; argument lists on field
|
||||
annotations are consumed and discarded.
|
||||
|
||||
## 3. Types
|
||||
|
||||
| kind | spelling |
|
||||
| --- | --- |
|
||||
| scalars | `Int`, `Float`, `Bool`, `Text`, `Bytes`, `Timestamp`, `Id` |
|
||||
| nullable | `?T` — legal on any of the above and on heap shapes |
|
||||
| list | `multi T` |
|
||||
| map | `map<K, V>` |
|
||||
| row reference | `ref C` |
|
||||
| reverse relation | `backlink C.field` — the computed inverse of a `ref` |
|
||||
| actor address | `actor M` — M is the message type, inferred from the class's `receive` |
|
||||
| declared types | any `class` / `type` / `typedef` / union name |
|
||||
| stdlib types | `json.Value`, `net.Conn` |
|
||||
| predeclared records | `Stat`, `TimeParts`, `Proc`, `Error` — no source declares them; field ORDER is the contract with the C runtime |
|
||||
|
||||
### Fields and parameters
|
||||
|
||||
- Field: `name: T`, optionally `= <default>` and/or `@ann`.
|
||||
- `pub(read) name: T` — readable outside the declaring class, writable
|
||||
only inside it.
|
||||
- Parameter conventions: **borrow is the default**; `mut x: T` for a
|
||||
mutable borrow, `take x: T` to move ownership in. These apply to
|
||||
NAMED parameters only.
|
||||
- **`self` is implicit** — never written in the parameter list, and
|
||||
always writable inside its own class's methods (`self.total += 1`
|
||||
needs no annotation). `self` is an ordinary identifier, not a keyword.
|
||||
- Methods may be `static fn`; class-level constants may be `static
|
||||
const` or bare `const`.
|
||||
|
||||
## 4. Statements
|
||||
|
||||
| statement | form |
|
||||
| --- | --- |
|
||||
| binding | `let x = e`, `let x: T = e` |
|
||||
| assignment | `x = e`, `obj.f = e`, `m[k] = e` |
|
||||
| compound assignment | `x += e`, `-=`, `*=`, `/=`, `%=` — parse-time sugar for the written-out form (there are no bitwise compound assigns) |
|
||||
| conditional | `if c { } else if c { } else { }` |
|
||||
| while | `while c { }` |
|
||||
| do-while | `do { } while c` — body always runs once |
|
||||
| for | `for x in <multi \| query>`, `for k, v in <map>` |
|
||||
| loop control | `break`, `continue` — owned values alive in the body are dropped at the jump site |
|
||||
| return | `return` / `return e` |
|
||||
| database write | `insert C { f: v, ... }`, `delete e` |
|
||||
| expression | any expression in statement position |
|
||||
|
||||
## 5. Expressions
|
||||
|
||||
| form | spelling |
|
||||
| --- | --- |
|
||||
| literals | int, float, text, raw text, bool, `nil` |
|
||||
| container literals | `[]`, `[a, b, c]` (a `multi`), `{}` (an empty `map`) — a fresh container needs a destination of declared type |
|
||||
| constructor | `C { field: v, ... }` |
|
||||
| access | `x.f`, `c[i]` (a `multi` — out of range traps), `m[k]` (a `map` — a missing key is **nil**) |
|
||||
| call | `f(a, b)`, `x.m(a)`, `mod.member(a)` |
|
||||
| unary | `-e`, `not e` |
|
||||
| binary | see the ladder below |
|
||||
| checked conversion | `e as T` — its ONE meaning is decoding JSON text, yielding `?T`. There is no reinterpret cast |
|
||||
| actor spawn | `spawn C { fields }` → an `actor M` address |
|
||||
| trapping guard | `try <expr> catch (e) <expr-or-block>` — an EXPRESSION; `e` binds the `Error` record `{ code, line, method, msg }` |
|
||||
| multi-way choice | `switch e { case A: ...; default: ...; }` — an expression. Arms match VALUES; `case a, b:` fires for either. A union payload binds positionally: `case Rect(w, h): w * h`. `default` is required UNLESS the subject is a union whose variants are all covered |
|
||||
| query | `from … select …`, see below |
|
||||
| interpolation | inside `"…"` and `` `…` `` |
|
||||
|
||||
### Operator precedence, loosest to tightest
|
||||
|
||||
1. `or`
|
||||
2. `and`
|
||||
3. comparison — `== != < <= > >=`
|
||||
4. concatenation — `..`
|
||||
5. additive — `+ -` **and `|` `^`**
|
||||
6. multiplicative — `* / %` **and `&` `<<` `>>`**
|
||||
7. unary — `-`, `not`
|
||||
8. `as` conversion — binds to a postfix expression, so tighter than unary
|
||||
9. postfix — call, index, field
|
||||
|
||||
Bitwise operators do not get their own tiers: `|`/`^` ride the additive
|
||||
rung and `&`/`<<`/`>>` the multiplicative one (Go's arrangement). They
|
||||
are Int-only on BOTH sides — there is no Float twin — and a literal
|
||||
shift count outside `0..63` is a compile error.
|
||||
|
||||
`+` is arithmetic ONLY, never string addition. `and`/`or` are
|
||||
short-circuit, `Bool`-typed operands only — there is no truthiness, and
|
||||
no `&&`/`||`/`!`/`~` anywhere in the language.
|
||||
|
||||
## 6. Queries (language-integrated, never SQL text)
|
||||
|
||||
```
|
||||
from <var> in <source>
|
||||
where <expr> -- zero or more
|
||||
group <expr> by <key> into <gvar>
|
||||
order by <expr> [desc]
|
||||
take <expr>
|
||||
select <expr>
|
||||
```
|
||||
|
||||
The source is either a table class (`from p in Product`) or a
|
||||
navigation (`from s in dept.staff` — a `backlink` or a `multi`). Present
|
||||
today: from / where / order / take / select plus group-by aggregation.
|
||||
Joins are not in the slice. A query is an expression and is also what
|
||||
`for x in <query>` iterates.
|
||||
|
||||
## 7. Concurrency
|
||||
|
||||
- `spawn C { fields }` constructs the actor's state (fields MOVE in) and
|
||||
starts it; the value is an `actor M` address.
|
||||
- `send(addr, msg)` — fire and forget; the message MOVES to the runtime.
|
||||
- `call(addr, msg) -> R` — a send that parks the calling fiber until the
|
||||
receive returns. Every `receive` program-wide must agree on `R`, and
|
||||
`R` must be a copyable scalar. A dead callee traps, never hangs.
|
||||
- A class becomes an actor by declaring `fn receive(msg: M)` — `receive`
|
||||
is an ordinary identifier, not a keyword.
|
||||
- Blocking stdlib calls park the fiber. There is no `async`, no `await`,
|
||||
and no user-visible thread.
|
||||
|
||||
## 8. Builtins and the stdlib
|
||||
|
||||
**Free builtins** (a user-declared `fn` of the same name always wins):
|
||||
`print`, `print_err`, `print_int`, `now`, `words`, `len`, `count`,
|
||||
`byte_at`, `char_of`, `substr`, `trim`, `to_lower`, `starts_with`,
|
||||
`ends_with`, `index_of`, `last_index_of`, `split`, `split_ws`, `join`,
|
||||
`parse_int`, `int_to_text`, `multi_new`, `map_new`, `push`, `get`,
|
||||
`set`, `has`, `remove`, `latest`, `pop`, `shift`, `slice`, `sort`,
|
||||
`reverse`, `key_at`, `val_at`, `send`, `call`, `sha1`, `sha256`,
|
||||
`hmac_sha256`, and the Float/Bytes bridges `float`, `trunc`,
|
||||
`parse_float`, `float_to_text`, `float_cmp`, `bytes_len`, `bytes_at`,
|
||||
`bytes_slice`, `bytes_eq`, `bytes_concat`, `bytes_of_text`,
|
||||
`text_of_bytes`, `base64_encode`, `base64_decode`.
|
||||
|
||||
**Reserved module namespaces**, each resolving to builtins:
|
||||
|
||||
| module | covers |
|
||||
| --- | --- |
|
||||
| `fs` | `exists`, `list`, `stat`, `read_all`, `read_at`, `append` |
|
||||
| `time` | `now`, `sleep`, `local`, `iso`, `ticks` |
|
||||
| `env` | `get`, `stopping` (the SIGTERM/SIGINT latch) |
|
||||
| `net` | `listen`, `listen_unix`, `accept`, `accept_dl`, `read`, `read_dl`, `write`, `write_dl`, `peer`, `close` |
|
||||
| `proc` | `run` |
|
||||
| `json` | `encode`, `decode` (paired with `as T`) |
|
||||
|
||||
A failing syscall traps `IO` with errno's message; **absence is never a
|
||||
trap** — a missing path or an unset variable is nil.
|
||||
|
||||
## 9. What the language deliberately does NOT have
|
||||
|
||||
This list is doctrine, not a backlog. Each was considered and rejected.
|
||||
|
||||
- **Closures and function values.** Capture is a field on a class. This
|
||||
is why there is no dependency injection and no callback API anywhere.
|
||||
- **`&&`, `||`, `!`, `~`.** Word operators only (`and`, `or`, `not`);
|
||||
complement is `-1 ^ x`.
|
||||
- **Inheritance.** Interfaces are structural; there is no `extends`.
|
||||
- **`inline fn`.** The keyword exists solely to produce a clear
|
||||
rejection — optimization is the compiler's job.
|
||||
- **A reinterpret cast.** `as` decodes JSON and nothing else.
|
||||
- **Varargs, and generics beyond the built-in containers.**
|
||||
- **Truthiness.** A condition must be `Bool`.
|
||||
- **A block comment, and a `{{`/`${`/backtick escape inside a raw
|
||||
literal.** Those three are written by concatenating an ordinary
|
||||
`"..."` string with `..` — one greppable door.
|
||||
- **`async`/`await`.** Fibers park; the shard runs someone else.
|
||||
- **A runtime template engine.** Markup is a compile-time literal or it
|
||||
does not exist.
|
||||
- **Non-empty map literals** (`{ k: v }` in expression position) —
|
||||
indistinguishable from a constructor literal without lookahead nothing
|
||||
else needs.
|
||||
- **A full-width 64-bit integer literal**, and **joins in queries** —
|
||||
both real limits rather than doctrine.
|
||||
|
|
@ -26,6 +26,8 @@ half of the story ("moved here" / "borrowed here" / etc.).
|
|||
| --- | --- | --- |
|
||||
| WO-E001 | an input byte the lexer doesn't recognize as the start of any token. Reported once per bad byte, which is then skipped — one bad byte never stops the whole file. | `unknown character '$'` |
|
||||
| WO-E002 | a string literal's backslash escape is the last byte of the file, with no character left to escape (a plain unterminated string with no dangling backslash is *not* an error — rt parity). | `unterminated string escape` |
|
||||
| WO-E004 | a raw text literal (backtick-delimited) that runs off the end of the file. Reported at the OPENING backtick — unlike a plain `"..."` string this is never silent, because multi-line is this form's normal case and a missing close would swallow every remaining line. | `unterminated raw text literal` |
|
||||
| WO-E005 | a raw newline inside a `"..."` or `'...'` string. The scan stops at the newline without consuming it, so the `Newline` token still terminates the statement and only one line is lost. Multi-line text is spelled with a raw literal instead. | ``newline in string literal (use a `...` raw text literal for multi-line text)`` |
|
||||
|
||||
## WO-E1xx — parsing (Tasks 4–5, `compiler/src/parser.ml`; WO-E103 haxe-parity Task 2)
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,8 @@ Lowered by the emitter, not added to the format:
|
|||
| `a > b`, `a >= b` | `LT` / `LE` with the operands swapped |
|
||||
| `a == b` on `Text` | `EQS` (content equality); `EQ` otherwise |
|
||||
| `a .. b` | `CONCAT` — `+` is arithmetic only, never string addition |
|
||||
| `` `...${e}...` `` | the same `StrLit`/`Interp`/`CONCAT` chain a `"..."` string produces — the raw literal is a LEXER form (verbatim content, common margin removed at lex time), not a new node or opcode |
|
||||
| `` `...{{ e }}...` `` | `esc(${e})` — the parser wraps the interpolation in a call to whatever `esc` is in scope (wo-html's `pub fn esc`, or a local one that deliberately shadows it). No builtin, no opcode, no HTML knowledge in the compiler or the VM |
|
||||
| `a and b` | evaluate `a`; `JZ` past evaluating `b` (result stays `a`'s value); else evaluate `b` into the same register (haxe-parity Task 2) |
|
||||
| `a or b` | evaluate `a`; `JZ` + `JMP` past evaluating `b` when `a` is already true; else evaluate `b` (haxe-parity Task 2) |
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,50 @@ machine-readable truth behind this board; live Obsidian Dataview views:
|
|||
|
||||
## ▶ NEXT PLAN
|
||||
|
||||
### Landed 2026-08-25 — iteration 37, wo-html components (off-chain)
|
||||
|
||||
**Implemented last time (2026-08-25):** iteration 37 CLOSED, both
|
||||
halves. The grammar half (2026-08-24) added the backtick raw text
|
||||
literal — content verbatim, common margin removed at lex time, `${ }`
|
||||
raw and `{{ }}` compiling to a call on the `esc` in scope — plus
|
||||
WO-E004/WO-E005. The library half (2026-08-25) added `Component`,
|
||||
`render_all` and `Layout` to wo-html, moved `ok_html` into
|
||||
`framework/http` beside `ok_text`/`ok_json`, and migrated BOTH HTML
|
||||
samples onto the component layer.
|
||||
|
||||
**Key findings (measured, not asserted):** `multi Component` holds a
|
||||
heterogeneous list DIRECTLY — no wrapper record — so the framework's
|
||||
`Mw`/`Aw` shape is a local choice, not a language requirement; that is
|
||||
what made page components able to own their children. The whole
|
||||
escaping desugar needed zero compiler knowledge of HTML: `{{ e }}` is a
|
||||
`Call` on an ordinary in-scope `esc`, so typecheck, ownership, codegen,
|
||||
the `.wob` format and the VM were all untouched. `{{ }}` was proven
|
||||
byte-identical to the hand-written `esc()` calls it replaced, hostile
|
||||
input (`< > & "`) included, across all eight migrated builders.
|
||||
|
||||
**Learned:** an interface that nothing consumes as a TYPE is
|
||||
decoration — `Component` only started earning its place once
|
||||
`render_all` and the page components held `multi Component`. And the
|
||||
shop template DOES build and run — an earlier note in this repo had that
|
||||
wrong, and wrong again about why: gap #1 (`pub` + `@table`) constrains
|
||||
neither the build NOR the layout. A class crosses module lines without
|
||||
export; only a free `fn` is module-scoped (`WO-E210`).
|
||||
|
||||
**Dependencies unblocked:** shop README gap #3 ("no multi-line
|
||||
expression or literal") is closed. Separate `.html` templates, if ever
|
||||
wanted, now have exactly one honest shape — a COMPILE-TIME include
|
||||
feeding the raw-literal machinery; a per-request file read is the
|
||||
already-rejected engine.
|
||||
|
||||
**Next steps:** the concurrency chain below is untouched by this and
|
||||
remains the live queue.
|
||||
|
||||
**`.dev/reference` used:** none this slice (Angular's component format
|
||||
was studied from its public docs during the 2026-08-23 story write-up;
|
||||
no reference project was consulted for the implementation).
|
||||
|
||||
---
|
||||
|
||||
**The concurrency + fiber chain — ✅ stage 3 → ✅ 22 → 31 → 24 → 23 → 32**
|
||||
(directive 2026-08-21). Next slice: **iteration 31, actor lifecycle** —
|
||||
its spec brainstorm is the next act (four forks in
|
||||
|
|
@ -244,7 +288,7 @@ that sequences its tasks. Read one, approve, then the next starts.
|
|||
| 32 | [WAL checkpoint](language-runtime-database/refine/32-wal-checkpoint.md) | ⬜ last in chain, after 23 — disk reclamation + bounded replay (story written 2026-08-21) |
|
||||
| 33 | [Single-file store](language-runtime-database/refine/33-single-file-db.md) | ⬜ off-chain, small — `WO_DATA=<path>.db` file form; driver-only (story written 2026-08-22) |
|
||||
| 34 | [Crypto builtins](language-runtime-database/refine/34-crypto-builtins.md) | ⬜ off-chain but GATES 24 (WS handshake needs SHA-1) — digests + HMAC as vector-verified C builtins (story written 2026-08-22) |
|
||||
| 37 | [wo-html components](language-runtime-database/refine/37-wo-html-components.md) | ⬜ off-chain — MVC-shaped view layer in wo-html (Component interface, layouts/slots, site migrates as acceptance); Angular format studied, client-side half rejected (story written 2026-08-23) |
|
||||
| 37 | [wo-html components](language-runtime-database/done/37-wo-html-components.md) | ✅ off-chain — LANDED 2026-08-25. Raw text literal (backtick, margin stripped at lex time, `{{ }}` auto-escapes) + the component layer: `Component`/`render_all`/`Layout` in wo-html, `ok_html` moved into the framework, site and shop both migrated |
|
||||
| 35 | [net runtime seams](language-runtime-database/done/35-net-runtime-seams.md) | ⬜ off-chain — fd deadlines on the park plane, Unix sockets, peer address; owns the ledger's three 🔧 rows (story written 2026-08-22) |
|
||||
| 20 | [Cross-program tables](language-runtime-database/hold/20-cross-program-tables.md) | ⏸ hold (2026-08-21); channel done (branch ipc-attach keeps its manifest) |
|
||||
| 21 | [Keypair attach auth](language-runtime-database/hold/21-keypair-attach-auth.md) | ⏸ hold (2026-08-21); crypto+handshake done (branch keypair-auth keeps its manifest) |
|
||||
|
|
@ -316,6 +360,7 @@ Gates at the end of that session: corpus 71/0, `woc` runtest 565/0, every
|
|||
|
||||
| Status | Item | Doc | What actually landed |
|
||||
| ------ | ------------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ✅ | iteration 37 — wo-html components | [37](language-runtime-database/done/37-wo-html-components.md) | Two halves. **Grammar (2026-08-24):** the backtick raw text literal — content verbatim, common margin removed at lex time, `${ }` raw and `{{ }}` auto-escaping to a call on the `esc` in scope; WO-E004/WO-E005 added; lexer + one parser desugar only, nothing downstream. **Library (2026-08-25):** `Component`/`render_all`/`Layout` in wo-html, `ok_html` moved into `framework/http` beside `ok_text`/`ok_json`, site migrated onto `Layout` + a reused `ChapterNav`, shop onto `AppShell` + `multi Component` children with queries in the controllers; the site was then restructured onto the program template's MVC layout (model / layout / view modules / one controller per feature / bootstrap-only main). `multi Component` needs no wrapper record — the framework's Mw/Aw shape is not a language requirement. Gates: `just site` 11/0, `just web-app` 46/0, `woc-test` 556/0, `oop-e2e` 116/0 |
|
||||
| ✅ | Principles | [`../00-principles.md`](../00-principles.md) | 13 principles, each with a why and a link to the doc that enforces it |
|
||||
| ✅ | `wovm` VM core | [plan 1](../superpowers/plans/2026-08-01-wob-format-and-vm-core.md) | `.wob` v1 loader with full static validation, register interpreter (computed-goto + ISO-C fallback), arena with size-class free lists, borrow word, RC + budgeted Bacon–Rajan cycle collector, drop-map trap unwinding, containers, builtins, ICALL, CLI. 13 suites × 2 dispatch flavors + CLI smoke, ASan/UBSan clean |
|
||||
| ✅ | `.wob` format contract | [`oop-vm/00-wob-format.md`](../plan/oop-vm/00-wob-format.md) | Normative; twinned with `runtime/src/wob.h` |
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ still pending IS the runtime-concurrency chain; order:
|
|||
| 20 | 32 | [WAL checkpoint](refine/32-wal-checkpoint.md) | **NEW 2026-08-21** (stage-3 guarantee refinement found the hole) — the WAL is append-only forever: snapshot + truncate reclaims disk and bounds replay time; every durability guarantee byte-identical; crash mid-checkpoint recovers from the previous snapshot + full tail. After 23 (composes with group-commit); RAM slot-reuse already contracted in `04-db-binding.md`. |
|
||||
| 21 | 33 | [Single-file store](refine/33-single-file-db.md) | **NEW 2026-08-22** — `WO_DATA=<path>.db`: a file path IS the wal (the store already lives in exactly one file; this makes the surface say so). Driver-only, independent of the chain; composes with 32's rename-swap. |
|
||||
| 22 | 34 | [Crypto builtins](refine/34-crypto-builtins.md) | **NEW 2026-08-22** — SHA-1/SHA-256/HMAC-SHA256 as C builtins over Bytes (no bitwise ops in the language, hand-rolled per doctrine, vector-verified). GATES 24's WS handshake; digest floor for held 21 and the ETag row. |
|
||||
| 23 | 37 | [wo-html components](refine/37-wo-html-components.md) | **NEW 2026-08-23** — an MVC-shaped view layer in the wo-html LIBRARY (framework stays micro): structural `Component` interface (`render() -> Text`), layout components with slots, the site sample migrated as acceptance. Angular's component FORMAT studied and translated to server-rendered no-JS `.wo`; DI/bindings rejected. Unscheduled. |
|
||||
| 23 | 37 | [wo-html components](done/37-wo-html-components.md) | **NEW 2026-08-23** — an MVC-shaped view layer in the wo-html LIBRARY (framework stays micro): structural `Component` interface (`render() -> Text`), layout components with slots, the site sample migrated as acceptance. Angular's component FORMAT studied and translated to server-rendered no-JS `.wo`; DI/bindings rejected. **LANDED 2026-08-25.** Raw text literal 2026-08-24 (backtick, verbatim content, margin stripped at lex time, `${ }` raw / `{{ }}` auto-escaping; WO-E004/WO-E005) — lexer plus one parser desugar, nothing downstream. Component layer 2026-08-25: `Component`/`render_all`/`Layout` in wo-html, `ok_html` moved into the framework, site and shop both migrated. |
|
||||
| 23 | 35 | [net runtime seams](done/35-net-runtime-seams.md) | **NEW 2026-08-22** — the ledger's three 🔧 rows owned: fd deadlines composing with the park plane, Unix-socket listeners, peer address (trusted-proxy check). Framework knobs stay framework slices; pairs naturally with 24 (dead-client eviction). |
|
||||
| 24 | 25 | [HTTP service layer](../../superpowers/plans/2026-08-01-http-service-layer.md) | `service` blocks lower onto the framework (after 9b + 20 by their own precedence notes). **HELD 2026-08-21** — story file removed; the plan doc remains. *(was 10)* |
|
||||
| 25 | 18 | [framework v2: memory-rich features](hold/18-memory-db-features.md) | spec+plan approved: TTL cache, @table flags, durable job queue, `transaction { }` over the WAL's staged batch. **Demoted from seq 14**: more surface on a framework with one consumer, and the cache still stores `Text` because there are no generics |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
---
|
||||
iteration: "37"
|
||||
status: done
|
||||
---
|
||||
|
||||
# Iteration 37 — wo-html components: an MVC-shaped view layer (Angular's format, studied)
|
||||
|
||||
> Format: `product/story-iteration-template`. Part of
|
||||
> [Story — one language, one runtime, one database, one binary](../00-story.md).
|
||||
>
|
||||
> **Inserted 2026-08-23** (developer ask: "enhance wo-html like MVC;
|
||||
> understand Angular format"). Grows the wo-html LIBRARY, never the
|
||||
> framework — the 2026-08-20 micro-framework directive stands: routing/
|
||||
> middleware/`Req`/`Resp` stay MVC-free, and the view layer lives in its
|
||||
> own dependency (the site sample's two-dep lesson). Unscheduled —
|
||||
> independent of the concurrency chain; needs its spec brainstormed
|
||||
> first.
|
||||
>
|
||||
> **REDIRECTED 2026-08-23** (developer review of the shop template
|
||||
> against a Vue SFC): render()-as-string-concatenation failed the DX
|
||||
> referendum — markup must be markup-FIRST. **RE-POINTED 2026-08-24**
|
||||
> (developer counter-proposal): the leaning is now **in-class raw
|
||||
> template literals**, not separate `.html` files — `render()` RETURNS
|
||||
> a raw multi-line string literal whose `{{ expr }}` holes are
|
||||
> auto-escaped, compile-time-checked interpolations in class scope (a
|
||||
> typo'd field is a compile error). Compiler surface shrinks to one
|
||||
> lexer addition (the raw literal — a general language win: multi-line
|
||||
> text without `\"` noise) plus one desugar; no file pairing, no
|
||||
> `w:component` sections. Structural control stays the language's own
|
||||
> `if`/`for` composing literals; `w:if`/`w:for` attributes and separate
|
||||
> `.html` files are DEMOTED to a later option that can layer on without
|
||||
> breaking this form. Escaping: `{{ }}` always escapes; the raw/slot
|
||||
> spelling is the one greppable door. A runtime mustache-lite stays
|
||||
> REJECTED (reflection-free means untyped `map<Text, Text>`); client-
|
||||
> side reactivity (`ref`, `@click`, `v-model`) stays out under the
|
||||
> no-JS posture — forms and POSTs instead. Still a COMPILER iteration,
|
||||
> parked behind the standing "no compiler/VM/database changes yet"
|
||||
> directive. The shop template
|
||||
> ([`docs/examples/shop`](../../../examples/shop/README.md)) is the
|
||||
> consumer: each `render()` body becomes one raw template literal, and
|
||||
> its README's recorded gaps ride along (gap #1: `pub` + `@table`
|
||||
> cannot combine — blocks a shared model module; gap #2: `@view`
|
||||
> projection classes).
|
||||
>
|
||||
> **CODE LANDED 2026-08-24** — the compiler slice only. Forks #1 and #3
|
||||
> are settled and shipped: the raw literal is BACKTICK-delimited with
|
||||
> verbatim content, `{{ }}` always HTML-escapes and `${ }` stays raw,
|
||||
> and the common source margin is removed at lex time. `{!! !!}` as the
|
||||
> raw slot is DISCARDED — `${ }` already was the raw spelling and a
|
||||
> second one would have been a synonym. The escaping desugar turned out
|
||||
> to need no compiler knowledge of HTML at all: `{{ e }}` becomes a
|
||||
> `Call` on whatever `esc` is in scope, so typecheck, ownership,
|
||||
> codegen, the `.wob` format and the VM are all untouched, and the
|
||||
> standing "no VM/database changes" directive was never in play. Two new
|
||||
> lexing diagnostics ride along (WO-E004 unterminated raw literal,
|
||||
> WO-E005 newline inside a quoted string — the latter closing a silent
|
||||
> file-swallowing hole that existed since Task 3). The COMPONENT half of
|
||||
> this story — the `Component` interface, `Layout` with slots, the site
|
||||
> migrating onto them — is untouched and still pending.
|
||||
>
|
||||
> **LANDED 2026-08-25** — the component half, and with it the iteration.
|
||||
> wo-html gained `Component` (structural, the view twin of the
|
||||
> framework's `Handler`), `render_all` and `Layout`; fork #4 is settled
|
||||
> by MOVING `ok_html` into the framework beside `ok_text`/`ok_json`
|
||||
> (transport, not rendering — both HTML samples had hand-rolled the same
|
||||
> four lines), and fork #5 by migrating BOTH samples. Fork #2 stands as
|
||||
> written: no directive surface ships. Layout slots are pre-rendered
|
||||
> `Text`, not `multi Component` — the compositional variant was proven
|
||||
> possible and deliberately not taken for the layout, though the PAGE
|
||||
> components do use it.
|
||||
|
||||
## Why this iteration exists
|
||||
|
||||
wo-html today is element builders + one utility sheet: pages are
|
||||
functions concatenating Text. That works (the site proves it) but has
|
||||
no unit of reuse bigger than a function — no way to say "this fragment
|
||||
owns its data, its markup, and its place in a layout" and hand it
|
||||
around. Angular's component FORMAT — a class declaring its inputs, a
|
||||
template rendering them, composition by nesting, structural directives
|
||||
for repetition and choice — is the studied precedent: the FORMAT
|
||||
translates to server-rendered `.wo`; the client-side half (change
|
||||
detection, event bindings, SPA router) deliberately does not.
|
||||
|
||||
## What Angular's format maps to (the study, summarized)
|
||||
|
||||
| Angular | wo-html translation | doctrine fit |
|
||||
| --- | --- | --- |
|
||||
| `@Component` class with `@Input()`s | a class whose FIELDS are the inputs, satisfying a structural `Component` interface (`fn render() -> Text`) | behavior-as-class; no closures needed |
|
||||
| template (`{{ expr }}`) | the render method's interpolation — `.wo` already has `${...}` in Text | no template dialect: templates ARE code |
|
||||
| `*ngFor` / `*ngIf` | explicit `for`/`if` in render() building Text — the language's own control flow | no structural-directive mini-language |
|
||||
| content projection (`<ng-content>`) | a layout component taking pre-rendered `Text` slots as fields | slots are ordinary values |
|
||||
| services/DI | no translation — a component reads its fields; queries stay in handlers (M and V stay separate) | rejected: DI needs function values |
|
||||
| event bindings `(click)` / two-way `[(ngModel)]` | no translation — server-rendered, no JS doctrine; forms stay `form_post` round trips | rejected surface |
|
||||
|
||||
## Goals
|
||||
|
||||
- **A `Component` interface in wo-html**: structural (`fn render() ->
|
||||
Text`), so any class with fields + render satisfies it — the view
|
||||
twin of the framework's `Handler`. Composition is nesting: a parent's
|
||||
render calls children's render.
|
||||
- **The MVC seam stated**: Model = `@table` rows queried in the HANDLER,
|
||||
moved into component fields; View = components rendering Text;
|
||||
Controller = the framework handler wiring them. The library documents
|
||||
the seam; it never queries.
|
||||
- **Layout components with slots**: the site's nav/shell/footer become
|
||||
the proof — a `Layout { title, nav, content, footer }` component
|
||||
replacing today's `shell()` functions, chapter pages and homepage
|
||||
composing it.
|
||||
- **The site sample migrates** as acceptance: same rendered bytes (or
|
||||
deliberately better), gate stays green — the library grew a floor, not
|
||||
a rewrite.
|
||||
|
||||
## Acceptance Criteria (draft — the spec refines)
|
||||
|
||||
- **Given** a class with fields and `fn render() -> Text`, **when** a
|
||||
handler moves data in and calls render, **then** the page it serves is
|
||||
byte-deterministic from the fields — no hidden state, no globals.
|
||||
- **Given** nested components (layout → section → card), **when** the
|
||||
outer render runs, **then** children render through the same
|
||||
structural interface, and escaping stays the caller-explicit `esc()`
|
||||
rule at every level.
|
||||
- **Given** the migrated site sample, **when** `just site` runs,
|
||||
**then** 11/0 — the gate is the proof the component layer reproduces
|
||||
the existing pages.
|
||||
- **Given** a component reused across two pages (the chapters card on
|
||||
home and chapter pages), **when** either page changes its data,
|
||||
**then** the other's markup is untouched — reuse is real, not copied.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Client-side anything: change detection, event/two-way bindings, SPA
|
||||
routing, hydration — the no-JS posture stands; interactivity is form
|
||||
round trips until a directive says otherwise.
|
||||
- A RUNTIME template engine (files parsed per request, mustache-style) —
|
||||
rejected 2026-08-23: reflection-free means untyped `map<Text, Text>`
|
||||
values. Templates compile to code at build time or they don't exist.
|
||||
- Dependency injection / services — components are data-in, Text-out.
|
||||
- Moving wo-html into the framework — settled 2026-08-23: separate
|
||||
libraries, composed via `[deps]`.
|
||||
- CSS componentization (scoped styles) — the utility sheet stays one
|
||||
static string; measure pain first.
|
||||
|
||||
## Info
|
||||
|
||||
Forks the spec must settle (REVISED 2026-08-23 for the compiled-template
|
||||
direction):
|
||||
|
||||
1. ~~**The raw-literal spelling**~~ — SETTLED 2026-08-24 and landed:
|
||||
backticks, content verbatim (no escape processing), common margin
|
||||
removed at lex time, `${ }` raw and `{{ }}` escaping. A literal
|
||||
backtick, `${` or `{{` is written by concatenating a `"..."` string
|
||||
with `..` — one greppable door instead of an escape character in the
|
||||
one form whose point is not having any.
|
||||
2. ~~**Directive surface**~~ — SETTLED 2026-08-25 as written: v1 ships
|
||||
NONE. Structural control is `if`/`for` composing literals, and two
|
||||
migrated samples produced no case that wanted more. `w:if`/`w:for`
|
||||
and separate `.html` files remain the later option — and a
|
||||
COMPILE-TIME include is the only shape either could honestly take,
|
||||
since a file read per request is the rejected engine.
|
||||
3. ~~**Escaping default**~~ — SETTLED 2026-08-24 and landed: `{{ }}`
|
||||
escapes ALWAYS, through whatever `esc` is in scope (wo-html's, or a
|
||||
local one that shadows it deliberately). `${ }` is the raw door.
|
||||
Proven byte-identical against the hand-written `esc()` calls it
|
||||
replaces, hostile input included.
|
||||
4. ~~**The framework seam**~~ — SETTLED 2026-08-25: `ok_html` MOVED
|
||||
into `framework/http/types.wo`; no `respond(c)` sugar, because
|
||||
`ok_html(c.render())` already is it.
|
||||
5. ~~**Migration depth**~~ — SETTLED 2026-08-25: BOTH, site gated and
|
||||
shop hand-driven; web-app stays HTML-less as the counter-example.
|
||||
|
||||
Study sources: Angular's component/`@Input`/`ng-content` docs (format
|
||||
only; no Angular code enters the repo — candidate `.dev/reference`
|
||||
addition if deeper study is wanted), Go's `html/template` as the
|
||||
server-side contrast, and the site sample as the living consumer.
|
||||
|
||||
## Landed 2026-08-24 — the raw text literal
|
||||
|
||||
The compiler slice, and nothing else. `compiler/src/lexer.ml` gained one
|
||||
branch: a backtick opens a literal whose content is verbatim to the
|
||||
closing backtick, newlines included, with two hole forms — `${ }` raw
|
||||
and `{{ }}` escaped — and the common source margin removed before the
|
||||
token is emitted (Java's text-block rule). `token.ml` gained one
|
||||
`str_part` variant to carry the escaped hole; `parser.ml`'s
|
||||
`desugar_interp` wraps it in a call to `esc`; `dump.ml` labels it. That
|
||||
is the entire compiler surface. Nothing in typecheck, ownership,
|
||||
codegen, the `.wob` format or the VM changed, because the literal emits
|
||||
the same `Str`/`InterpStr` token a `"..."` string always did.
|
||||
|
||||
Proven: `woc-test` 554/0 with new token and AST goldens; `oop-e2e`
|
||||
115/0 with a run fixture for the literal and a compile-fail fixture for
|
||||
WO-E004; `just site` 11/0 and `just web-app` 46/0 after wo-html's
|
||||
builders and the site's two chapter snippets migrated onto the form; a
|
||||
parity program comparing every migrated wo-html builder's old and new
|
||||
output on hostile input (`<`, `>`, `&`, `"`) — byte-identical in all
|
||||
eight. `page()` is the one deliberate byte change: four newlines now
|
||||
sit inside `<head>`, where whitespace is insignificant, and none inside
|
||||
`<body>`. Every `.wo` in the repo was re-lexed: no file gained a
|
||||
diagnostic.
|
||||
|
||||
The shop template migrated too — five view files, every `render()` body
|
||||
now one literal with real double-quoted attributes and no `esc()` calls
|
||||
— verified by building it against local `file://` dep remotes and driving
|
||||
every route, since no `just` recipe gates it.
|
||||
|
||||
## Landed 2026-08-25 — the component half
|
||||
|
||||
`wo-html` grew three things and no more: `pub interface Component { fn
|
||||
render() -> Text }`, `pub fn render_all(cs: multi Component) -> Text`,
|
||||
and `pub class Layout { title, nav, content, footer }` whose slots are
|
||||
pre-rendered Text. The MVC seam is stated in the library's own header
|
||||
and README, and the library still contains no query.
|
||||
|
||||
A finding that shaped the design: **`multi Component` holds a
|
||||
heterogeneous list directly** — no wrapper record. The framework's
|
||||
`Mw`/`Aw` wrappers had suggested otherwise; they are not a language
|
||||
requirement. That is what let a page component hold its children as
|
||||
`multi Component` and call `render_all` on them, which is where the
|
||||
interface actually earns its place — an interface nothing consumes as a
|
||||
TYPE would have been decoration.
|
||||
|
||||
Fork #4 settled by moving: `ok_html` now lives in `framework/http/types.wo`
|
||||
beside `ok_text`/`ok_json`, and both duplicate copies are deleted. No
|
||||
`respond(c: Component)` sugar — `ok_html(c.render())` is already the
|
||||
whole thing, and a second spelling would earn nothing.
|
||||
|
||||
The site migrated: `shell`/`shell_wide` fill `Layout` (byte-identical —
|
||||
`Layout.render()` is `page(title, nav .. content .. footer)`, exactly
|
||||
what the old functions built), and `chapter_nav` became a `ChapterNav`
|
||||
component whose query moved out into `chapter_links()`, called by the
|
||||
handlers. The homepage and every chapter page now render the SAME
|
||||
component with a different `current` — acceptance criterion 4, met by
|
||||
construction rather than by inspection.
|
||||
|
||||
The shop migrated further than planned, because its views already had
|
||||
`render()`: `app_shell()` became an `AppShell` component, and
|
||||
`ProductListPage`/`OrdersPage` now hold `multi Component` children
|
||||
instead of a concatenated Text blob, with the queries in the
|
||||
controllers. `AppShell` is deliberately NOT `Layout` — the template
|
||||
links a real stylesheet where `Layout` inlines `tw_css()`, and that
|
||||
difference is the point of it having its own shell.
|
||||
|
||||
**Restructured 2026-08-25 (follow-on):** the site sample was then laid
|
||||
out like the program template — `types.wo` for the model, a `layout/`
|
||||
module for the chrome, `home/` and `chapter/` view modules, one
|
||||
`*.controller.wo` per feature in the root module, and a `main.wo` that
|
||||
is bootstrap and nothing else. `main.wo` went from 231 lines holding
|
||||
everything to 44 lines holding routes. Both samples now read the same
|
||||
way, which was the point: the template teaches a shape, and the site
|
||||
should not contradict it.
|
||||
|
||||
Proven: `just site` 11/0 (the gate, and the escape check still passes),
|
||||
`just web-app` 46/0 (the framework gained a function), `woc-test` and
|
||||
`oop-e2e` untouched and green. The shop was built against local `file://`
|
||||
dep remotes and driven through every route — product grid (4 cards
|
||||
rendered as child components), product page with its form, buy,
|
||||
`/orders` (rows as child components), the empty-orders branch, 404, 409,
|
||||
and `/assets/*`.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Brainstorm → spec → plan (the superpowers path): settle the four forks,
|
||||
grow wo-html by the `Component` interface + a `Layout` proof, migrate
|
||||
the site sample as acceptance, keep the framework untouched. Ships
|
||||
independently of the concurrency chain; slots wherever the developer
|
||||
schedules it.
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
---
|
||||
iteration: "37"
|
||||
status: refine
|
||||
---
|
||||
|
||||
# Iteration 37 — wo-html components: an MVC-shaped view layer (Angular's format, studied)
|
||||
|
||||
> Format: `product/story-iteration-template`. Part of
|
||||
> [Story — one language, one runtime, one database, one binary](../00-story.md).
|
||||
>
|
||||
> **Inserted 2026-08-23** (developer ask: "enhance wo-html like MVC;
|
||||
> understand Angular format"). Grows the wo-html LIBRARY, never the
|
||||
> framework — the 2026-08-20 micro-framework directive stands: routing/
|
||||
> middleware/`Req`/`Resp` stay MVC-free, and the view layer lives in its
|
||||
> own dependency (the site sample's two-dep lesson). Unscheduled —
|
||||
> independent of the concurrency chain; needs its spec brainstormed
|
||||
> first.
|
||||
>
|
||||
> **REDIRECTED 2026-08-23** (developer review of the shop template
|
||||
> against a Vue SFC): render()-as-string-concatenation failed the DX
|
||||
> referendum — markup must be markup-FIRST. **RE-POINTED 2026-08-24**
|
||||
> (developer counter-proposal): the leaning is now **in-class raw
|
||||
> template literals**, not separate `.html` files — `render()` RETURNS
|
||||
> a raw multi-line string literal whose `{{ expr }}` holes are
|
||||
> auto-escaped, compile-time-checked interpolations in class scope (a
|
||||
> typo'd field is a compile error). Compiler surface shrinks to one
|
||||
> lexer addition (the raw literal — a general language win: multi-line
|
||||
> text without `\"` noise) plus one desugar; no file pairing, no
|
||||
> `w:component` sections. Structural control stays the language's own
|
||||
> `if`/`for` composing literals; `w:if`/`w:for` attributes and separate
|
||||
> `.html` files are DEMOTED to a later option that can layer on without
|
||||
> breaking this form. Escaping: `{{ }}` always escapes; the raw/slot
|
||||
> spelling is the one greppable door. A runtime mustache-lite stays
|
||||
> REJECTED (reflection-free means untyped `map<Text, Text>`); client-
|
||||
> side reactivity (`ref`, `@click`, `v-model`) stays out under the
|
||||
> no-JS posture — forms and POSTs instead. Still a COMPILER iteration,
|
||||
> parked behind the standing "no compiler/VM/database changes yet"
|
||||
> directive. The shop template
|
||||
> ([`docs/examples/shop`](../../../examples/shop/README.md)) is the
|
||||
> consumer: each `render()` body becomes one raw template literal, and
|
||||
> its README's recorded gaps ride along (gap #1: `pub` + `@table`
|
||||
> cannot combine — blocks a shared model module; gap #2: `@view`
|
||||
> projection classes).
|
||||
|
||||
## Why this iteration exists
|
||||
|
||||
wo-html today is element builders + one utility sheet: pages are
|
||||
functions concatenating Text. That works (the site proves it) but has
|
||||
no unit of reuse bigger than a function — no way to say "this fragment
|
||||
owns its data, its markup, and its place in a layout" and hand it
|
||||
around. Angular's component FORMAT — a class declaring its inputs, a
|
||||
template rendering them, composition by nesting, structural directives
|
||||
for repetition and choice — is the studied precedent: the FORMAT
|
||||
translates to server-rendered `.wo`; the client-side half (change
|
||||
detection, event bindings, SPA router) deliberately does not.
|
||||
|
||||
## What Angular's format maps to (the study, summarized)
|
||||
|
||||
| Angular | wo-html translation | doctrine fit |
|
||||
| --- | --- | --- |
|
||||
| `@Component` class with `@Input()`s | a class whose FIELDS are the inputs, satisfying a structural `Component` interface (`fn render() -> Text`) | behavior-as-class; no closures needed |
|
||||
| template (`{{ expr }}`) | the render method's interpolation — `.wo` already has `${...}` in Text | no template dialect: templates ARE code |
|
||||
| `*ngFor` / `*ngIf` | explicit `for`/`if` in render() building Text — the language's own control flow | no structural-directive mini-language |
|
||||
| content projection (`<ng-content>`) | a layout component taking pre-rendered `Text` slots as fields | slots are ordinary values |
|
||||
| services/DI | no translation — a component reads its fields; queries stay in handlers (M and V stay separate) | rejected: DI needs function values |
|
||||
| event bindings `(click)` / two-way `[(ngModel)]` | no translation — server-rendered, no JS doctrine; forms stay `form_post` round trips | rejected surface |
|
||||
|
||||
## Goals
|
||||
|
||||
- **A `Component` interface in wo-html**: structural (`fn render() ->
|
||||
Text`), so any class with fields + render satisfies it — the view
|
||||
twin of the framework's `Handler`. Composition is nesting: a parent's
|
||||
render calls children's render.
|
||||
- **The MVC seam stated**: Model = `@table` rows queried in the HANDLER,
|
||||
moved into component fields; View = components rendering Text;
|
||||
Controller = the framework handler wiring them. The library documents
|
||||
the seam; it never queries.
|
||||
- **Layout components with slots**: the site's nav/shell/footer become
|
||||
the proof — a `Layout { title, nav, content, footer }` component
|
||||
replacing today's `shell()` functions, chapter pages and homepage
|
||||
composing it.
|
||||
- **The site sample migrates** as acceptance: same rendered bytes (or
|
||||
deliberately better), gate stays green — the library grew a floor, not
|
||||
a rewrite.
|
||||
|
||||
## Acceptance Criteria (draft — the spec refines)
|
||||
|
||||
- **Given** a class with fields and `fn render() -> Text`, **when** a
|
||||
handler moves data in and calls render, **then** the page it serves is
|
||||
byte-deterministic from the fields — no hidden state, no globals.
|
||||
- **Given** nested components (layout → section → card), **when** the
|
||||
outer render runs, **then** children render through the same
|
||||
structural interface, and escaping stays the caller-explicit `esc()`
|
||||
rule at every level.
|
||||
- **Given** the migrated site sample, **when** `just site` runs,
|
||||
**then** 11/0 — the gate is the proof the component layer reproduces
|
||||
the existing pages.
|
||||
- **Given** a component reused across two pages (the chapters card on
|
||||
home and chapter pages), **when** either page changes its data,
|
||||
**then** the other's markup is untouched — reuse is real, not copied.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Client-side anything: change detection, event/two-way bindings, SPA
|
||||
routing, hydration — the no-JS posture stands; interactivity is form
|
||||
round trips until a directive says otherwise.
|
||||
- A RUNTIME template engine (files parsed per request, mustache-style) —
|
||||
rejected 2026-08-23: reflection-free means untyped `map<Text, Text>`
|
||||
values. Templates compile to code at build time or they don't exist.
|
||||
- Dependency injection / services — components are data-in, Text-out.
|
||||
- Moving wo-html into the framework — settled 2026-08-23: separate
|
||||
libraries, composed via `[deps]`.
|
||||
- CSS componentization (scoped styles) — the utility sheet stays one
|
||||
static string; measure pain first.
|
||||
|
||||
## Info
|
||||
|
||||
Forks the spec must settle (REVISED 2026-08-23 for the compiled-template
|
||||
direction):
|
||||
|
||||
1. **The raw-literal spelling** — backticks, triple quotes, or another
|
||||
delimiter; and the raw-slot spelling inside it (`{!! !!}` vs `${}`
|
||||
staying unescaped). One rule: `{{ }}` always escapes.
|
||||
2. **Directive surface** — v1 ships NONE (structural control is `if`/
|
||||
`for` composing literals); `w:if`/`w:for` and separate `.html` files
|
||||
layer on later only if a sample proves the need.
|
||||
3. **Escaping default** — `{{ }}` escapes ALWAYS (safer than today's
|
||||
caller-explicit `esc()`); the raw spelling is the only door, and it
|
||||
is greppable.
|
||||
4. **The framework seam** — does `ok_html(body)` move into the framework
|
||||
beside `ok_text`/`ok_json` (transport, not rendering — flagged
|
||||
2026-08-23), and does wo-html gain `respond(c: Component)` sugar?
|
||||
5. **Migration depth** — shop first (the template is its acceptance),
|
||||
site second; web-app stays HTML-less as the counter-example.
|
||||
|
||||
Study sources: Angular's component/`@Input`/`ng-content` docs (format
|
||||
only; no Angular code enters the repo — candidate `.dev/reference`
|
||||
addition if deeper study is wanted), Go's `html/template` as the
|
||||
server-side contrast, and the site sample as the living consumer.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Brainstorm → spec → plan (the superpowers path): settle the four forks,
|
||||
grow wo-html by the `Component` interface + a `Layout` proof, migrate
|
||||
the site sample as acceptance, keep the framework untouched. Ships
|
||||
independently of the concurrency chain; slots wherever the developer
|
||||
schedules it.
|
||||
|
|
@ -7,21 +7,19 @@ class Q {
|
|||
}
|
||||
|
||||
class IntAnswer {
|
||||
pad: Int
|
||||
fn receive(msg: Q) -> Int {
|
||||
return msg.n;
|
||||
}
|
||||
}
|
||||
|
||||
class BoolAnswer {
|
||||
pad: Int
|
||||
fn receive(msg: Q) -> Bool {
|
||||
return msg.n > 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let a: actor Q = spawn IntAnswer { pad: 0 };
|
||||
let a: actor Q = spawn IntAnswer {};
|
||||
let r = call(a, Q { n: 1 });
|
||||
return r;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
WO-E403
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- A `{{ ... }}` hole desugars to a call to `esc`, so a program that
|
||||
-- uses one without an `esc` in scope fails at the literal's position
|
||||
-- with a name it never typed -- the diagnostic says why.
|
||||
fn main() -> Int {
|
||||
let n = "x";
|
||||
print(`<p>{{ n }}</p>`);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E004
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- WO-E004: a raw text literal with no closing backtick. Unlike a plain
|
||||
-- unterminated "..." string (silent, rt parity), this is reported --
|
||||
-- multi-line is this form's normal case, so the rest of the file would
|
||||
-- otherwise vanish into the literal with nothing said.
|
||||
fn main() -> Int {
|
||||
let page = `<div>
|
||||
never closed
|
||||
print(page);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -5,14 +5,13 @@ class Note {
|
|||
}
|
||||
|
||||
class Sink {
|
||||
pad: Int
|
||||
fn receive(msg: Note) {
|
||||
print(msg.text);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let a = spawn Sink { pad: 0 };
|
||||
let a = spawn Sink {};
|
||||
let n = Note { text: "gone" };
|
||||
send(a, n);
|
||||
print(n.text);
|
||||
|
|
|
|||
|
|
@ -12,13 +12,12 @@ class Box {
|
|||
}
|
||||
|
||||
class Keeper {
|
||||
pad: Int
|
||||
fn receive(msg: Box) {
|
||||
if msg.head == nil { print("empty"); } else { print("full"); }
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let a = spawn Keeper { pad: 0 };
|
||||
let a = spawn Keeper {};
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ class Q {
|
|||
}
|
||||
|
||||
class Bomb {
|
||||
pad: Int
|
||||
fn receive(msg: Q) -> Int {
|
||||
return msg.n / (msg.n - msg.n); -- DIV0: the actor dies mid-call
|
||||
}
|
||||
|
|
@ -20,7 +19,7 @@ fn call_one(b: actor Q, n: Int) -> Text {
|
|||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let b: actor Q = spawn Bomb { pad: 0 };
|
||||
let b: actor Q = spawn Bomb {};
|
||||
let first = try call_one(b, 7) catch (e) e.msg;
|
||||
print("mid-call: ${first}");
|
||||
let second = try call_one(b, 8) catch (e) e.msg;
|
||||
|
|
|
|||
2
tests/corpus/run/dotdot-continuation/fixture.out
Normal file
2
tests/corpus/run/dotdot-continuation/fixture.out
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
one two three
|
||||
n=1 done
|
||||
13
tests/corpus/run/dotdot-continuation/fixture.wo
Normal file
13
tests/corpus/run/dotdot-continuation/fixture.wo
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- A line ending in `..` continues the expression on the next line —
|
||||
-- the one newline suppression in the language (multi-line HTML/text).
|
||||
fn main() -> Int {
|
||||
let t = "one" ..
|
||||
" two" ..
|
||||
" three";
|
||||
print(t);
|
||||
let n = 1;
|
||||
let u = "n=${n}" ..
|
||||
" done";
|
||||
print(u);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ class Tick {
|
|||
}
|
||||
|
||||
class Sleeper {
|
||||
pad: Int
|
||||
fn receive(msg: Tick) {
|
||||
time.sleep(5000);
|
||||
}
|
||||
|
|
@ -23,7 +22,7 @@ fn send_one(s: actor Tick, n: Int) -> Text {
|
|||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let s: actor Tick = spawn Sleeper { pad: 0 };
|
||||
let s: actor Tick = spawn Sleeper {};
|
||||
let sent = 0;
|
||||
let caught = "";
|
||||
let i = 0;
|
||||
|
|
|
|||
5
tests/corpus/run/raw-text-literal/fixture.out
Normal file
5
tests/corpus/run/raw-text-literal/fixture.out
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<div class="card">
|
||||
<h3><script></h3>
|
||||
<p><script></p>
|
||||
</div>
|
||||
no newline, no margin rule
|
||||
35
tests/corpus/run/raw-text-literal/fixture.wo
Normal file
35
tests/corpus/run/raw-text-literal/fixture.wo
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
-- iteration 37: the backtick raw text literal. Markup is written as
|
||||
-- markup -- one literal, real newlines, quotes verbatim -- and the
|
||||
-- source indentation is removed at compile time, so the served bytes
|
||||
-- carry the markup's own nesting and not the method's.
|
||||
-- `${...}` interpolates raw; `{{ ... }}` HTML-escapes through the `esc`
|
||||
-- in scope (here a local one, which is exactly how a program overrides
|
||||
-- wo-html's).
|
||||
fn esc(t: Text) -> Text {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
let n = len(t);
|
||||
while i < n {
|
||||
let b = byte_at(t, i);
|
||||
if b == 60 { out = out .. "<"; }
|
||||
else {
|
||||
if b == 62 { out = out .. ">"; }
|
||||
else { out = out .. substr(t, i, 1); }
|
||||
}
|
||||
i = i + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let name = "<script>";
|
||||
let card = `
|
||||
<div class="card">
|
||||
<h3>{{ name }}</h3>
|
||||
<p>${name}</p>
|
||||
</div>`;
|
||||
print(card);
|
||||
let plain = `no newline, no margin rule`;
|
||||
print(plain);
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in a new issue