- Board renamed docs/plan/00-kanban.md -> docs/00-status.md and rebuilt: ▶ NEXT PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets — stories, in progress, done, pending, discarded, learnings. It covered only the Rust runtime before, so the whole OOP track was invisible. All 16 inbound refs repointed; `Kanban:` banners renamed to `Status:`. - New discarded.md (settled rejections with reasons: inheritance, `abstract`, Money/SKU/Float, Dynamic/cast/macro/extern, AOT-to-C, Menhir, shared engine state) and learnings.md (plumbed≠enforced, vacuous goldens, exit-0-wrong- output, malloc-path ASan trick, deferred checks that never reach the VM). - RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk, never committed (gitignored path), cited by five docs incl. principle 12. Root cause was broader: all seven forward-roadmap plans in docs/superpowers/plans/ were untracked and ignored, on one disk only. Dropped the docs ignore rules with a do-not-re-add note; added __pycache__/*.pyc. - Repaired broken links across docs/, 270 -> 36: fixes a regression from the earlier reference/ -> .dev/reference/ move (relative paths at ../../ and deeper were skipped), plus depth and reorg drift. The 36 residual point at content that does not exist and need decisions, not paths. - New spec docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md, applied: `and`/`or` verdict row; Part 3 gains `env` (six modules), swaps time.mono for iso/local, adds 22 bare core builtins; throw/time.mono/is cut (0 uses in the sample). Plan 8: Task 2 gains and/or, Task 5 drops throw, abstract+`is` task deleted, 8/9 renumber to 7/8. Plan 9 gains core builtins. Plan 10 gains the 307 -> 0 diagnostic gate. WO-E205 re-filed unreachable-by- design. types.ml header drops its false satisfaction-set claim. 00-code- review.md reduced to a stub — its rival Phase 1-4 roadmap retired.
95 lines
6.9 KiB
Markdown
95 lines
6.9 KiB
Markdown
# 02 — `##ui` → `.htmlx` compiler
|
||
|
||
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Sub-phase sequence" (L172–180), "Design decisions" 1–6, [`./01-htmlx-format-spec.md`](./01-htmlx-format-spec.md) (the emission target), [`../../runtime/database/06-lowcode-fullstack.md`](../../../runtime/database/06-lowcode-fullstack.md) (the `##ui` block spec), [`docs/examples/blog/ui/article_list.wo`](../../../examples/blog/ui/article_list.wo), [`docs/examples/blog/ui/article_detail.wo`](../../../examples/blog/ui/article_detail.wo), [`docs/examples/ecommerce/apps/admin/ui/orders/orders.wo`](../../examples/ecommerce/apps/admin/ui/orders/orders.wo) (the test corpus), [`crates/rt/src/parser.rs:80–116`](../../../../crates/rt/src/parser.rs) (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)
|
||
|
||
1. **File-presence dispatch.** If `apps/<X>/ui/<screen>/<screen>.htmlx` exists alongside `<screen>.wo`, the hand-written file wins. The compiler still emits a manifest; it errors if the manifest's `bind_sites` reference fields the hand-written template doesn't expose. Anchored in [`./00-overview.md`](./00-overview.md) L24, L30, L46.
|
||
2. **`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>`. `key` defaults to `id` when not declared.
|
||
3. **`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` / `list` similarly. The set is closed and matches phase 01's helper registry.
|
||
4. **`actions: row-* / bulk-*` ⇒ `data-action` + `data-role` attributes.** Click → POST `/api/fn/<fn>`. Role gating is a `data-role="<set>"` attribute the runtime hides on; phase 07 wires the server-side check.
|
||
5. **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.
|
||
6. **`crates/rt/src/parser.rs:80–88` no longer skips `##ui`.** The `Kind::HashHash` arm parses into a `Screen` IR (this phase's new type). All other `##` blocks (`##app`, `##component`) keep their current `skip_top_level_chunk` behaviour for now — `##app` is owned by phase 05 and `##component` parses inline as a sibling of `##ui` but 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)
|
||
|
||
```rust
|
||
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
|
||
|
||
1. `cargo build -p ui` and `cargo build -p rt` green.
|
||
2. **Compile every sample `##ui`** in the test corpus: blog `article_list.wo`, blog `article_detail.wo`, ecommerce `apps/storefront/ui/home/home.wo`, `apps/storefront/ui/orders/orders.wo`, `apps/admin/ui/orders/orders.wo`. Output template parses cleanly under phase 01.
|
||
3. **Hand-written fallback honoured.** With a hand-written `apps/admin/ui/orders/orders.htmlx` present, the compiler returns its source unchanged but still emits the manifest.
|
||
4. **Manifest cross-check fires.** Renaming `body` to `text` in a hand-written template that the `##ui` block expects under `wo:bind="body"` produces a `CompileError::HandWrittenMissingField` diagnostic.
|
||
5. **Parser change is non-breaking.** `crates/rt`'s 14 unit tests still pass; `cargo run --bin wo -- run docs/examples/blog` boots and serves REST as before.
|
||
6. `cd 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 `##component` codegen in this phase.** Components remain their existing partial-include shape (`{{> name args}}`) — only `##ui` screens drive new emission.
|
||
|
||
## Verification
|
||
|
||
```bash
|
||
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 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.
|