6.9 KiB
02 — ##ui → .htmlx compiler
Context sources: ./00-overview.md §§ "Sub-phase sequence" (L172–180), "Design decisions" 1–6, ./01-htmlx-format-spec.md (the emission target), ../../runtime/database/06-lowcode-fullstack.md (the ##ui block spec), docs/examples/blog/ui/article_list.wo, docs/examples/blog/ui/article_detail.wo, docs/examples/ecommerce/apps/admin/ui/orders/orders.wo (the test corpus), crates/rt/src/parser.rs:80–116 (the parse-and-discard call site to replace).
Goal
Walk a parsed ##ui block — every key listed in 00-overview's locked grammar (title, source, live, role, filter, quick-filters, columns, sort, actions, pagination, refresh, highlight-new, key, sections, inputs, use, with, template, styles) — and emit a complete .htmlx template that parses under phase 01's grammar. When a hand-written <screen>.htmlx sits beside the .wo, the compiler honours it and only validates that the manifest still aligns.
Design decisions (locked)
- File-presence dispatch. If
apps/<X>/ui/<screen>/<screen>.htmlxexists alongside<screen>.wo, the hand-written file wins. The compiler still emits a manifest; it errors if the manifest'sbind_sitesreference fields the hand-written template doesn't expose. Anchored in./00-overview.mdL24, L30, L46. live: true⇒<wo:live>wrapper. The compiler emits<wo:live source="<source>" key="<key|id>" sort="<sort.default|nothing>" filter="<filter|nothing>">around the auto-generated<table>.keydefaults toidwhen not declared.renderer: <name>⇒ helper invocation by rule table. A static rule table maps each renderer name to its emission form:markdown→{{markdown <field>}},money→{{> money amount=<field>}},relative-date→{{relative <field>}},code/tag-chips/pill/image/stock-badge/listsimilarly. The set is closed and matches phase 01's helper registry.actions: row-* / bulk-*⇒data-action+data-roleattributes. Click → POST/api/fn/<fn>. Role gating is adata-role="<set>"attribute the runtime hides on; phase 07 wires the server-side check.- Generated templates land in
target/wo/<app>/ui/<screen>.htmlx. Same path the per-app binary in phase 05 reads from at startup. Build artefact, not committed. crates/rt/src/parser.rs:80–88no longer skips##ui. TheKind::HashHasharm parses into aScreenIR (this phase's new type). All other##blocks (##app,##component) keep their currentskip_top_level_chunkbehaviour for now —##appis owned by phase 05 and##componentparses inline as a sibling of##uibut emits no template (it's a partial that other screens reference).
Scope
New files inside crates/ui/src/compiler/
| File | Responsibility | Port source |
|---|---|---|
mod.rs |
Re-exports compile_screen, Screen, Column, Action, CompileError |
new (~30 LOC) |
screen.rs |
Screen IR — every key listed in the goal section above |
new (~150 LOC) |
codegen.rs |
Walk Screen → emit .htmlx source string |
new (~280 LOC) |
renderers.rs |
Closed renderer: → helper-emission rule table |
new (~120 LOC) |
fallback.rs |
File-presence dispatch + manifest cross-check | new (~80 LOC) |
Modified file
| File | Change | Notes |
|---|---|---|
crates/rt/src/parser.rs |
Replace the Kind::HashHash(_) skip arm at L80–88 with a real parse into Screen when the tag is ui |
+60 LOC delta |
Total: ~720 LOC (all new — the v1 codebase has no ##ui precedent to port).
Cargo.toml change
crates/ui already depends on serde/serde_json from phase 01. No new deps.
API shape (target)
use ui::compiler::{compile_screen, compile_app, Screen};
let screen: Screen = ql::parse_ui_block(src)?;
let template: String = compile_screen(&screen, &ctx)?; // an .htmlx string
let mani = ui::htmlx::Template::parse(&template)?.manifest();
// Whole-app pipeline used by phase 05's `wo build`:
let outputs: Vec<(PathBuf, String)> = compile_app(&app_dir)?;
for (path, src) in outputs { fs::write(path, src)?; }
Exit criteria
cargo build -p uiandcargo build -p rtgreen.- Compile every sample
##uiin the test corpus: blogarticle_list.wo, blogarticle_detail.wo, ecommerceapps/storefront/ui/home/home.wo,apps/storefront/ui/orders/orders.wo,apps/admin/ui/orders/orders.wo. Output template parses cleanly under phase 01. - Hand-written fallback honoured. With a hand-written
apps/admin/ui/orders/orders.htmlxpresent, the compiler returns its source unchanged but still emits the manifest. - Manifest cross-check fires. Renaming
bodytotextin a hand-written template that the##uiblock expects underwo:bind="body"produces aCompileError::HandWrittenMissingFielddiagnostic. - Parser change is non-breaking.
crates/rt's 14 unit tests still pass;cargo run --bin wo -- run docs/examples/blogboots and serves REST as before. cd .dev/reference/crates && cargo build && cargo test.
Non-scope
- No SSR. This phase emits files only — the runtime in phase 05 reads them at startup.
- No author-defined renderers. The closed table from phase 01's helper registry is the contract.
- No build-output caching. The compiler runs on every
wo build. Caching is a future concern. - No partial recovery. First parse or codegen error aborts compilation for the screen; whole-app compilation reports per-screen status.
- No
##componentcodegen in this phase. Components remain their existing partial-include shape ({{> name args}}) — only##uiscreens drive new emission.
Verification
cargo build -p ui -p rt
cargo test -p ui --test compile_blog
cargo test -p ui --test compile_ecommerce
cargo test -p ui --test fallback_handwritten
# end-to-end: emit templates for the storefront and inspect them
cargo run --bin wo -- build docs/examples/ecommerce/apps/storefront --emit-templates-only
ls target/wo/storefront/ui/ # home.htmlx, orders.htmlx
head -1 target/wo/storefront/ui/orders.htmlx # starts with <wo:live source="Order{ … }">
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd .dev/reference/crates && cargo build && cargo test
After this phase
Phase 03 (03-client-runtime.md) is now unblocked: every screen has a manifest the client runtime can read. Phase 04 (04-workspace-layout.md) places the compiled outputs under target/wo/<app>/ui/, which phase 05 then bakes into the per-app binary.