diff --git a/docs/examples/blog/README.md b/docs/examples/blog/README.md index b558212..9a6814e 100644 --- a/docs/examples/blog/README.md +++ b/docs/examples/blog/README.md @@ -27,15 +27,44 @@ blog/ │ ├── article.wo # Article — all three paradigms in one type │ ├── tag.wo # Tag taxonomy │ └── comment.wo # Reader comments +├── styles/ +│ ├── main.css # global stylesheet (linked from app.wo styles:) +│ └── code-theme.css # syntax highlighting tokens ├── ui/ │ ├── article_list.wo # home page list view (live) -│ └── article_detail.wo # per-article page with comments + related +│ ├── article_detail.wo # per-article page with comments + related +│ └── components/ # reusable components (.wo + .htmlx + .css per component) +│ ├── article-card.wo # selector + typed inputs + styles: +│ ├── article-card.htmlx +│ ├── article-card.css +│ ├── comments.wo # selector + source + actions + role + styles: +│ ├── comments.htmlx +│ └── comments.css └── tests/ └── article_test.wo # `wo test` picks this up ``` No `main.wo` is needed — a pure type+service app auto-generates its entry point. Add `main.wo` if you need CLI args, background workers, or custom startup logic beyond the `on startup` hook in `app.wo`. +### UI: components vs. screens + +The `ui/` tree separates concerns the way Angular separates `@Component` / template / parent: + +- **Screens** (`ui/article_list.wo`, `ui/article_detail.wo`) declare a `##ui` block — they own the route, the page-level data source, and the section layout. They embed components by selector via `use: ` + `with: { ... }` and pass typed inputs. +- **Components** (`ui/components/*.wo`) declare a `##component` block with `template: 'foo.htmlx'`, typed `inputs:`, and — when the component owns its own query — its `source:`, `sort:`, `live:`, and `actions:`. No HTML. +- **Templates** (`ui/components/*.htmlx`) are pure presentation. They read from the component's `inputs` and from the rows produced by its `source`. No data-source declarations, no role checks. + +Screens never inline a component's HTML or its query; templates never declare data sources. Each `.wo` paired with one `.htmlx` is the unit of UI reuse. + +### Styling + +CSS is declared at two scopes; in both cases the compiler emits the `` tags into the SSR layout and serves the files under `/static/`: + +- **App-level (global)** — `##app styles: [...]` in `app.wo` lists global stylesheets. Resolved relative to `./styles/`. Linked once, in declaration order, on every page. +- **Component-scoped** — `##component styles: [...]` lists CSS files alongside the component. The compiler rewrites bare selectors in those files to `[data-component=""] `, using the `data-component` attribute the templates already emit. Rules cannot leak outside the component subtree, so two components can both declare `.title` without colliding. + +A component's CSS is only fetched on pages that embed the component. Global styles always load. Neither layer requires a build step — `wo run` serves the files as-is. + ## Run it ```bash diff --git a/docs/examples/blog/app.wo b/docs/examples/blog/app.wo index f288d95..cde95e2 100644 --- a/docs/examples/blog/app.wo +++ b/docs/examples/blog/app.wo @@ -8,6 +8,13 @@ version: 1 theme: "light" i18n: [en] +-- Global stylesheets. Resolved relative to ./styles/, served under +-- /static/styles/, and emitted as tags in the SSR layout in this +-- order. Component-scoped CSS lives next to the component (see ui/components). +styles: + - styles/main.css + - styles/code-theme.css + -- URL → UI screen binding. `:slug` is a dynamic path segment that binds to a -- parameter visible inside the screen as `$slug`. routes: diff --git a/docs/examples/blog/ui/article_detail.wo b/docs/examples/blog/ui/article_detail.wo index 5fbd081..62731f4 100644 --- a/docs/examples/blog/ui/article_detail.wo +++ b/docs/examples/blog/ui/article_detail.wo @@ -24,13 +24,10 @@ columns: [title, meta.excerpt, published_at] live: true - -- Reader comments. Two-way live: new comments appear without refresh. + -- Reader comments. The article-comments component owns its own data + -- source, sort, live binding, and create action — this screen only + -- declares the embed and binds its typed inputs. - comments: - title: "Comments" - renderer: list - source: Comment{ article.slug == $key } - columns: [author.display, body, created_at] - sort: { default: created_at asc } - live: true - actions: - create: /api/comments role: Author | Admin + use: article-comments + with: + article-id: $article.id diff --git a/docs/examples/blog/ui/components/article-card.css b/docs/examples/blog/ui/components/article-card.css new file mode 100644 index 0000000..a0ccd8b --- /dev/null +++ b/docs/examples/blog/ui/components/article-card.css @@ -0,0 +1,15 @@ +/* Scoped to the article-card component via [data-component="article-card"]. */ + +.article-card { padding: 1.25rem 0; border-bottom: 1px solid #eee; } +.article-card-title { margin: 0 0 0.25rem; font-size: 1.2rem; } +.article-card-title a { text-decoration: none; color: #222; } +.article-card-title a:hover { color: #0066cc; } + +.article-card-meta { color: #999; font-size: 0.85rem; margin-bottom: 0.25rem; } +.article-card-author { color: #555; } + +.article-card-excerpt { color: #555; margin-bottom: 0.5rem; } + +.article-card-tags { list-style: none; padding: 0; display: flex; gap: 0.5rem; font-size: 0.8rem; } +.article-card-tags a { color: #888; text-decoration: none; } +.article-card-tags a:hover { color: #0066cc; } diff --git a/docs/examples/blog/ui/components/article-card.htmlx b/docs/examples/blog/ui/components/article-card.htmlx new file mode 100644 index 0000000..8f0f6af --- /dev/null +++ b/docs/examples/blog/ui/components/article-card.htmlx @@ -0,0 +1,22 @@ +
+

+ {{article.title}} +

+ +

+ {{article.author.display}} + +

+ + {{#if article.meta.excerpt}} +

{{article.meta.excerpt}}

+ {{/if}} + + {{#if article.tags}} +
    + {{#each article.tags as tag}} +
  • {{tag.name}}
  • + {{/each}} +
+ {{/if}} +
diff --git a/docs/examples/blog/ui/components/article-card.wo b/docs/examples/blog/ui/components/article-card.wo new file mode 100644 index 0000000..d657e2f --- /dev/null +++ b/docs/examples/blog/ui/components/article-card.wo @@ -0,0 +1,11 @@ +-- The article-card component renders one article preview. It has no source of +-- its own — the parent (a list screen) iterates a query and hands each row in +-- via the typed `article` input. Pure presentation lives in article-card.htmlx. + +##component +#article-card + template: 'article-card.htmlx' + styles: ['article-card.css'] + + inputs: + article: Article diff --git a/docs/examples/blog/ui/components/comments.css b/docs/examples/blog/ui/components/comments.css new file mode 100644 index 0000000..750cd74 --- /dev/null +++ b/docs/examples/blog/ui/components/comments.css @@ -0,0 +1,19 @@ +/* Scoped to the article-comments component via the data-component attribute + * emitted by comments.htmlx. The compiler rewrites bare selectors below to + * [data-component="article-comments"] , so the rules cannot leak + * outside the component subtree. */ + +.comments-header h3 { font-size: 1rem; text-transform: uppercase; letter-spacing: 1px; color: #888; } + +.comment-list { list-style: none; padding: 0; margin: 1rem 0; } +.comment { padding: 0.75rem 0; border-bottom: 1px solid #eee; } +.comment:last-child { border-bottom: none; } + +.comment-meta { display: flex; gap: 0.75rem; font-size: 0.85rem; color: #999; margin-bottom: 0.25rem; } +.comment-author { color: #555; font-weight: 600; } +.comment-body { margin: 0; } + +.comment-form { margin-top: 1rem; display: flex; flex-direction: column; gap: 0.5rem; } +.comment-form textarea { padding: 0.5rem; font-family: inherit; border: 1px solid #ddd; border-radius: 4px; resize: vertical; min-height: 4rem; } +.comment-form button { align-self: flex-start; padding: 0.4rem 1rem; background: #222; color: #fff; border: none; border-radius: 4px; cursor: pointer; } +.comment-form button:hover { background: #0066cc; } diff --git a/docs/examples/blog/ui/components/comments.htmlx b/docs/examples/blog/ui/components/comments.htmlx new file mode 100644 index 0000000..81a34b0 --- /dev/null +++ b/docs/examples/blog/ui/components/comments.htmlx @@ -0,0 +1,25 @@ +
+
+

Comments

+
+ +
    + {{#each comments as comment}} +
  • +
    + {{comment.author.display}} + +
    +

    {{comment.body}}

    +
  • + {{/each}} +
+ + {{#when actions.create}} +
+ + + +
+ {{/when}} +
diff --git a/docs/examples/blog/ui/components/comments.wo b/docs/examples/blog/ui/components/comments.wo new file mode 100644 index 0000000..a4acae3 --- /dev/null +++ b/docs/examples/blog/ui/components/comments.wo @@ -0,0 +1,23 @@ +-- The article-comments component renders the comment thread for one article +-- and exposes an inline create action. Wiring lives here; presentation lives +-- in comments.htmlx; the parent screen (ui/article_detail.wo) embeds the +-- component by selector and binds its inputs. + +##component +#article-comments + template: 'comments.htmlx' + styles: ['comments.css'] + + inputs: + article-id: int + + source: + Comment{ article.id == $article-id } + + sort: + default: created_at asc + + live: true + + actions: + create: /api/comments role: Author | Admin diff --git a/docs/plan/ui/01-htmlx-format-spec.md b/docs/plan/ui/01-htmlx-format-spec.md new file mode 100644 index 0000000..aee9e1f --- /dev/null +++ b/docs/plan/ui/01-htmlx-format-spec.md @@ -0,0 +1,111 @@ +# 01 — `.htmlx` format spec + +**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "`.htmlx` with live subscriptions — target format" (L127–166), "Design decisions" (L28–37), [`reference/crates/wo-htmlx/`](../../../reference/crates/wo-htmlx/) (the v1 template engine that 90% of this phase ports), [`templates/article.htmlx`](../../../templates/article.htmlx) and [`templates/home.htmlx`](../../../templates/home.htmlx) (v1 concrete usage), [`docs/examples/ecommerce/shared/components/order-row.htmlx`](../../examples/ecommerce/shared/components/order-row.htmlx) (the live-binding workload this format must serve). + +## Goal + +Lock the exact `.htmlx` grammar — every v1 Mustache construct unchanged plus two new constructs: a `…` subscription subtree and a `wo:bind="field"` field-level live attribute — and define the JSON manifest emitted in a `` and consumed only by phase 03's client runtime. Schema below; `version: 1` is a constant for this phase. + +## Scope + +### New files inside `crates/ui/src/htmlx/` + +| File | Responsibility | Port source | +| --- | --- | --- | +| `mod.rs` | Re-exports `Template`, `Manifest`, `LiveSubscription`, `BindSite`, `ParseError`, `RenderError` | [`reference/crates/wo-htmlx/src/lib.rs`](../../../reference/crates/wo-htmlx/src/lib.rs) (11 LOC) | +| `ast.rs` | Adds `Node::Live { attrs, body }` and `wo_bind: Option` on element nodes | [`reference/crates/wo-htmlx/src/ast.rs`](../../../reference/crates/wo-htmlx/src/ast.rs) (18 LOC) — extend by ~50 LOC | +| `parser.rs` | Adds `` body in `
` for the runtime | [`reference/crates/wo-htmlx/src/render.rs`](../../../reference/crates/wo-htmlx/src/render.rs) (140 LOC) — extend by ~70 LOC | +| `manifest.rs` | Walks the AST, collects subscriptions + bind sites, serialises JSON | new (~150 LOC) | + +Total: ~835 LOC (585 ported + ~250 new). + +### `Cargo.toml` change + +```toml +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +``` + +(Both already in `crates/rt`; `crates/ui` adopts them rather than hand-rolling JSON for the manifest at this phase.) + +### Manifest schema + +```json +{ + "version": 1, + "subscriptions": [ + { + "id": "orders-live-0", + "source": "Order{ status != Cancelled }", + "key": "id", + "sort": "placed_at desc", + "filter": null, + "root_selector": "[data-wo-subscription=\"orders-live-0\"]" + } + ], + "bind_sites": [ + { "subscription_id": "orders-live-0", "key": "id", "field": "status" }, + { "subscription_id": "orders-live-0", "key": "id", "field": "total" } + ] +} +``` + +## API shape (target) + +```rust +use ui::htmlx::{Template, Manifest, HelperRegistry}; + +let tmpl = Template::parse(src)?; // Result +let html = tmpl.render(&ctx, ®istry)?; // Result +let mani = tmpl.manifest(); // Manifest + +// SSR pattern: page = head + html + "" +``` + +## Exit criteria + +1. `cargo build -p ui` green; no new top-level dependencies beyond `serde`/`serde_json`. +2. **Golden parse + render** for every `.htmlx` file under [`docs/examples/blog/ui/components/`](../../examples/blog/ui/components/) and [`docs/examples/ecommerce/shared/components/`](../../examples/ecommerce/shared/components/) — output is HTML and parses back into an isomorphic AST. +3. **Manifest emission** for `…` produces a `LiveSubscription` with the source string preserved verbatim and the body wrapped under `data-wo-subscription="orders-live-0"`. +4. **Bind-site collection** for `{{status}}` inside `` records `(subscription_id, key="id", field="status")` once and only once. +5. **v1 regression**: `cargo run --bin wo -- run docs/examples/blog` continues to start without parser errors. The blog sample has no `` or `wo:bind` today; nothing should regress. +6. All 14 existing `crates/rt` tests pass. + +## Non-scope + +- **No SSR.** Phase 02 emits templates; phase 03 ships the runtime; serving them is a downstream concern that the per-app binary in phase 05 wires together. +- **No nested ``.** Parse error in this phase. Author can compose live subtrees by partial inclusion (`{{> child}}`). +- **No author-extensible helpers.** The closed enum is the contract for this phase. +- **No streaming render.** Templates are rendered to a single `String`. +- **No manifest version negotiation.** Wire format is `version: 1` always. + +## Verification + +```bash +cargo build -p ui +cargo test -p ui --test golden_v1 # blog/ecommerce templates byte-identical +cargo test -p ui --test golden_extensions # + wo:bind cases +cargo test -p ui --test manifest # manifest emission + +# 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 02 (`02-ui-compiler.md`) consumes the `Template` + `Manifest` types defined here as its emission target — every `##ui` block compiles down to an `.htmlx` file that parses cleanly under this phase's parser. Phase 03 (`03-client-runtime.md`) consumes the manifest JSON schema as its wire input. diff --git a/docs/plan/ui/02-ui-compiler.md b/docs/plan/ui/02-ui-compiler.md new file mode 100644 index 0000000..bf33e9c --- /dev/null +++ b/docs/plan/ui/02-ui-compiler.md @@ -0,0 +1,95 @@ +# 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 `.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//ui//.htmlx` exists alongside `.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` ⇒ `` wrapper.** The compiler emits `` around the auto-generated ``. `key` defaults to `id` when not declared. +3. **`renderer: ` ⇒ helper invocation by rule table.** A static rule table maps each renderer name to its emission form: `markdown` → `{{markdown }}`, `money` → `{{> money amount=}}`, `relative-date` → `{{relative }}`, `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/`. Role gating is a `data-role=""` attribute the runtime hides on; phase 07 wires the server-side check. +5. **Generated templates land in `target/wo//ui/.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 + +# 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//ui/`, which phase 05 then bakes into the per-app binary. diff --git a/docs/plan/ui/03-client-runtime.md b/docs/plan/ui/03-client-runtime.md new file mode 100644 index 0000000..0faf3ab --- /dev/null +++ b/docs/plan/ui/03-client-runtime.md @@ -0,0 +1,109 @@ +# 03 — Client runtime + +**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "`.htmlx` with live subscriptions — target format" (L127–166) and decisions 1–2, [`./01-htmlx-format-spec.md`](./01-htmlx-format-spec.md) (the manifest schema this runtime consumes), [`reference/crates/wo-sub/src/lib.rs`](../../../reference/crates/wo-sub/src/lib.rs) (the v1 frame model the wire format mirrors), [`docs/examples/ecommerce/shared/components/order-row.htmlx`](../../examples/ecommerce/shared/components/order-row.htmlx) (the live workload the runtime must update without reload). + +## Goal + +Ship a ~500-line vanilla-JS client at `crates/ui/assets/wo-runtime.js` that, on page load, reads the `