Skip to content

Commit 7ae0fd4

Browse files
feat(routing): add routing documentation and route directive reference (#90)
### Why Routing is a core feature that touches every layer of WebUI — protocol, parser, handler, FFI, and a new client-side router package. Before reviewing any code, reviewers need to understand what the routing system does, how servers and clients interact, and what contract they follow. Shipping docs first gives reviewers (and future contributors) the full mental model before the implementation lands. ### What Two new documentation pages and updates across existing docs: - **`<route>` directive reference** — documents the HTML syntax developers write: `path`, `name`, `component`, `exact` attributes, parameter segments (`:id`, `:id?`, `*splat`), specificity-based matching, and how the server renders matched vs non-matched routes during SSR. - **Routing guide** — end-to-end walkthrough of the routing system: installing `@microsoft/webui-router`, starting the `Router` class after hydration, opt-in lazy loading with dynamic imports, the `webui:route:navigated` event, and critically the **server contract for JSON partials** — when the client navigates, it sends `Accept: application/json` with an inventory bitmask, and the server returns `{ state, templates, inventory, path }` instead of full HTML. Includes implementation examples for Node/Express and C#/.NET. - **Handler docs updated** — all platform handler pages (Rust, Node, FFI) now show the new `RenderOptions` parameter with `entry_id` and `request_path`, so developers aren't surprised by the signature change in later PRs. - **Installation page** — added `@microsoft/webui-router` as an optional dependency with install commands and a link to the routing guide. - **Router npm README** — package-level documentation for npmjs.com: quick start, API reference, path syntax table, lazy loading setup, server contract. ### How Docs-only PR — no Rust or TypeScript logic changes. The handler doc examples reference `RenderOptions` which doesn't exist on `main` yet (it lands in PR3). This is intentional: the docs describe the target API so reviewers can evaluate the design before the implementation.
1 parent 5d0bc6b commit 7ae0fd4

15 files changed

Lines changed: 676 additions & 36 deletions

File tree

DESIGN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ pub trait ParserPlugin {
507507
- Skips FAST-specific runtime attributes (`@click`, `f-ref`, `f-slotted`, `f-children`)
508508
- Emits `Plugin` fragments with u32 LE attribute binding counts
509509
- Tracks components and injects `<f-template>` wrappers at body end
510-
- Converts BTR syntax to FAST syntax: `<if>``<f-when>`, `<for>``<f-repeat>`, `{{expr}}``{expr}` in `:attr` values
510+
- Converts syntax to FAST syntax: `<if>``<f-when>`, `<for>``<f-repeat>`, `{{expr}}``{expr}` in `:attr` values
511511

512512
**Usage:**
513513
```rust

crates/webui-parser/src/plugin/fast.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! FAST parser plugin for the WebUI parser.
22
//!
33
//! Tracks component definitions during HTML parsing and generates `<f-template>`
4-
//! wrappers at body end. Converts BTR template syntax (`<if>`, `<for>`, `{{}}`)
4+
//! wrappers at body end. Converts WebUI Framework template syntax (`<if>`, `<for>`, `{{}}`)
55
//! into FAST-compatible syntax (`<f-when>`, `<f-repeat>`, `{}`).
66
77
use super::ParserPlugin;
@@ -20,7 +20,7 @@ struct TrackedComponent {
2020
/// Implements the `ParserPlugin` trait for the FAST framework:
2121
/// - Filters FAST-specific runtime binding attributes (`@click`, `f-ref`, etc.)
2222
/// - Tracks components encountered during parsing
23-
/// - Generates `<f-template>` wrappers with converted BTR→FAST syntax at body end
23+
/// - Generates `<f-template>` wrappers with converted FAST syntax at body end
2424
/// - Emits binding attribute counts as `Plugin` protocol fragment data
2525
pub struct FastParserPlugin {
2626
/// Components tracked during parsing, in discovery order.
@@ -118,7 +118,7 @@ impl ParserPlugin for FastParserPlugin {
118118
}
119119
}
120120

121-
/// Convert BTR template syntax to FAST syntax in HTML content.
121+
/// Convert WebUI Framework template syntax to FAST syntax in HTML content.
122122
///
123123
/// Performs the following transformations without regex:
124124
/// - `<if condition="EXPR">` → `<f-when value="{EXPR}">`
@@ -655,7 +655,7 @@ mod tests {
655655
assert_eq!(output.matches("<f-template name=\"my-card\">").count(), 1);
656656
}
657657

658-
// --- BTR→FAST syntax conversion ---
658+
// --- FAST syntax conversion ---
659659

660660
#[test]
661661
fn convert_if_to_f_when() {

docs/.vitepress/config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,12 @@ export default {
6161
{ text: 'Overview', link: '/guide/concepts/directives/' },
6262
{ text: '<if> Conditional', link: '/guide/concepts/directives/if' },
6363
{ text: '<for> Loop', link: '/guide/concepts/directives/for' },
64+
{ text: '<route> Routing', link: '/guide/concepts/directives/route' },
6465
{ text: '{{}} Signals', link: '/guide/concepts/directives/signals' },
6566
{ text: 'Attribute Directives', link: '/guide/concepts/directives/attributes' },
6667
]
6768
},
69+
{ text: 'Routing', link: '/guide/concepts/routing' },
6870
{ text: 'Hydration & Interactivity', link: '/guide/concepts/hydration', items: [
6971
{ text: 'Class Definition', link: '/guide/concepts/hydration#class-definition' },
7072
{ text: 'Templating', link: '/guide/concepts/hydration#templating' },

docs/guide/concepts/directives/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ WebUI Framework provides the following core directives:
88

99
- [**`<if>` Conditional Rendering**](./if) - Conditionally render content based on expressions
1010
- [**`<for>` Loop Iteration**](./for) - Iterate over collections to generate repeated content
11+
- [**`<route>` Routing**](./route) - Define client-side routes that map URL paths to components
1112
- [**<code v-pre>{{}}</code> Signal Binding**](./signals) - Insert dynamic values with automatic HTML escaping
1213
- [**<code v-pre>{{{}}}</code> Raw Signal Binding**](./signals#raw-signals) - Insert unescaped HTML content
1314
- [**Attribute Directives**](./attributes) - Bind dynamic data to HTML attributes (`{{}}`, `?`, `:`, and mixed)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# `<route>` Directive
2+
3+
The `<route>` directive defines a client-side route that maps a URL path to a component. At build time, `<route>` elements are compiled into `<webui-route>` custom elements. At runtime, the server renders the matched route's component and the client router handles subsequent navigations.
4+
5+
## Basic Usage
6+
7+
```html
8+
<main>
9+
<route path="/" name="home" component="home-page" exact />
10+
<route path="/users" name="users" component="user-list" exact />
11+
<route path="/users/:id" name="user-detail" component="user-detail" exact />
12+
</main>
13+
```
14+
15+
The server matches the request path against the defined routes and renders the matching component with its state. Non-matching routes are rendered hidden.
16+
17+
## Attributes
18+
19+
| Attribute | Required | Description |
20+
|-----------|----------|-------------|
21+
| `path` | Yes | URL path template to match (e.g., `/users/:id`) |
22+
| `component` | Yes | Tag name of the component to render (e.g., `user-detail`) |
23+
| `name` | No | Unique name for the route (used in navigation events) |
24+
| `exact` | No | Only match when the path matches exactly (no prefix matching) |
25+
26+
## Path Parameters
27+
28+
Route paths support dynamic segments that capture values from the URL:
29+
30+
### Required Parameters
31+
32+
Use `:name` to capture a path segment:
33+
34+
```html
35+
<route path="/users/:id" component="user-detail" exact />
36+
```
37+
38+
Matches `/users/42``{ id: "42" }`
39+
40+
### Optional Parameters
41+
42+
Use `:name?` for optional segments:
43+
44+
```html
45+
<route path="/search/:query?" component="search-page" exact />
46+
```
47+
48+
Matches both `/search` and `/search/hello``{ query: "hello" }`
49+
50+
### Splat (Catch-all)
51+
52+
Use `*name` to capture the rest of the path:
53+
54+
```html
55+
<route path="/files/*path" component="file-browser" />
56+
```
57+
58+
Matches `/files/docs/readme.md``{ path: "docs/readme.md" }`
59+
60+
## Route Specificity
61+
62+
When multiple routes can match a path, WebUI picks the most specific one — the route with the most literal (non-parameter) segments wins.
63+
64+
```html
65+
<route path="/users/add" component="user-form" exact />
66+
<route path="/users/:id" component="user-detail" exact />
67+
```
68+
69+
A request to `/users/add` matches the first route (2 literal segments) over the second (1 literal + 1 param).
70+
71+
## Exact vs Prefix Matching
72+
73+
By default, routes use prefix matching — `/users` matches `/users`, `/users/42`, and `/users/42/edit`. Add the `exact` attribute to require a full match:
74+
75+
```html
76+
<!-- Prefix: matches /app, /app/settings, /app/anything -->
77+
<route path="/app" component="app-shell" />
78+
79+
<!-- Exact: only matches /app/settings -->
80+
<route path="/app/settings" component="settings-page" exact />
81+
```
82+
83+
## Inside Components
84+
85+
Routes are typically placed inside a shell component's template:
86+
87+
```html
88+
<!-- app-shell.html -->
89+
<template>
90+
<header><nav-bar></nav-bar></header>
91+
<main>
92+
<route path="/" name="dashboard" component="dashboard-page" exact />
93+
<route path="/contacts" name="contacts" component="contacts-page" exact />
94+
<route path="/contacts/:id" name="detail" component="contact-detail" exact />
95+
</main>
96+
</template>
97+
```
98+
99+
The shell component is rendered on every page. Only the matched route's component changes.
100+
101+
## Server-Side Rendering
102+
103+
When the server receives a request, it matches the URL against the route definitions and:
104+
105+
1. **Matched route** — rendered visible with its component's full HTML content (including declarative shadow root)
106+
2. **Non-matched routes** — rendered hidden (`style="display:none"`) with no content
107+
108+
This means the browser displays the correct page instantly, before any JavaScript loads.
109+
110+
## Notes and Limitations
111+
112+
- Route elements are converted to `<webui-route>` custom elements at build time
113+
- Routes can be placed in the light DOM or inside a component's shadow DOM
114+
- The server performs route matching during SSR — no client JavaScript is needed for the initial render
115+
- For client-side navigation between routes, install the [`@microsoft/webui-router`](/guide/concepts/routing) package
116+
- Self-closing syntax (`<route ... />`) and open/close syntax (`<route ...>...</route>`) are both supported

docs/guide/concepts/handlers/ffi.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ void *handler = webui_handler_create();
5151
uint8_t *data = load_file("dist/protocol.bin", &len);
5252

5353
// Render
54-
char *html = webui_handler_render(handler, data, len, state_json);
54+
char *html = webui_handler_render(handler, data, len, state_json,
55+
"index.html", request_path);
5556
if (html) {
5657
// use html...
5758
webui_free(html);
@@ -68,7 +69,7 @@ webui_handler_destroy(handler);
6869
| `webui_render` | `char *(const char *html, const char *data_json)` | Parse + render in one call |
6970
| `webui_handler_create` | `void *()` | Create a reusable handler (no plugin) |
7071
| `webui_handler_create_with_plugin` | `void *(const char *plugin_id)` | Create a handler with a named plugin (e.g., `"fast"`) |
71-
| `webui_handler_render` | `char *(void *handler, const uint8_t *data, uintptr_t len, const char *json)` | Render a pre-compiled protocol |
72+
| `webui_handler_render` | `char *(void *handler, const uint8_t *data, uintptr_t len, const char *json, const char *entry_id, const char *request_path)` | Render a pre-compiled protocol with route matching |
7273
| `webui_handler_destroy` | `void(void *handler)` | Destroy a handler instance |
7374
| `webui_free` | `void(char *ptr)` | Free a string returned by any render function |
7475
| `webui_last_error` | `const char *()` | Get per-thread error message |
@@ -114,7 +115,8 @@ if (handler == NULL) {
114115
}
115116

116117
// Render — output includes hydration markers
117-
char *html = webui_handler_render(handler, protocol_data, protocol_len, state_json);
118+
char *html = webui_handler_render(handler, protocol_data, protocol_len,
119+
state_json, "index.html", "/");
118120

119121
webui_free(html);
120122
webui_handler_destroy(handler);

docs/guide/concepts/handlers/index.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ This consistent approach ensures that the same template produces identical resul
2727
While the specific implementation details vary between languages, all handlers provide a similar API:
2828

2929
```
30-
handle(protocol, state, writer)
30+
handle(protocol, state, options, writer)
3131
```
3232

3333
Where:
3434
- `protocol` is the WebUI protocol object
3535
- `state` is the data object with values to be rendered
36+
- `options` specifies the entry fragment and request path for [route matching](/guide/concepts/routing)
3637
- `writer` is a callback or interface for writing the rendered output
3738

3839
## Plugin System
@@ -41,7 +42,7 @@ Handlers support an optional **plugin system** for injecting framework-specific
4142

4243
```
4344
handler = Handler::with_plugin(plugin)
44-
handler.handle(protocol, state, writer)
45+
handler.handle(protocol, state, options, writer)
4546
```
4647

4748
When no plugin is configured, the handler renders plain HTML. When a plugin is loaded (e.g., `FastHydrationPlugin`), it injects markers that enable client-side hydration.

docs/guide/concepts/handlers/node.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,33 @@ npm install @microsoft/webui
1414
```js [Node.js]
1515
import { createServer } from 'node:http';
1616
import { readFileSync } from 'node:fs';
17-
import { renderStream } from '@microsoft/webui';
17+
import { render } from '@microsoft/webui';
1818

1919
const protocol = readFileSync('./dist/protocol.bin');
2020

2121
const server = createServer((req, res) => {
22-
if (req.url === '/') {
23-
res.writeHead(200, { 'Content-Type': 'text/html' });
24-
renderStream(protocol, { title: "Home" }, (chunk) => res.write(chunk));
25-
res.end();
26-
}
22+
res.writeHead(200, { 'Content-Type': 'text/html' });
23+
render(protocol, JSON.stringify({ title: "Home" }), 'index.html', req.url,
24+
(chunk) => res.write(chunk));
25+
res.end();
2726
});
2827

2928
server.listen(3000);
3029
```
3130

3231
```ts [Bun]
33-
import { renderStream } from '@microsoft/webui';
32+
import { render } from '@microsoft/webui';
3433

3534
const protocol = Bun.file('./dist/protocol.bin');
3635
const protocolData = Buffer.from(await protocol.arrayBuffer());
3736

3837
Bun.serve({
3938
port: 3000,
4039
fetch(req) {
40+
const url = new URL(req.url);
4141
const chunks: string[] = [];
42-
renderStream(protocolData, { title: "Home" }, (chunk) => chunks.push(chunk));
42+
render(protocolData, JSON.stringify({ title: "Home" }), 'index.html', url.pathname,
43+
(chunk: string) => chunks.push(chunk));
4344
return new Response(chunks.join(''), {
4445
headers: { 'Content-Type': 'text/html' },
4546
});
@@ -48,14 +49,16 @@ Bun.serve({
4849
```
4950

5051
```ts [Deno]
51-
import { renderStream } from '@microsoft/webui';
52+
import { render } from '@microsoft/webui';
5253

5354
const protocol = Deno.readFileSync('./dist/protocol.bin');
5455
const protocolData = Buffer.from(protocol);
5556

56-
Deno.serve({ port: 3000 }, (_req) => {
57+
Deno.serve({ port: 3000 }, (req) => {
58+
const url = new URL(req.url);
5759
const chunks: string[] = [];
58-
renderStream(protocolData, { title: "Home" }, (chunk) => chunks.push(chunk));
60+
render(protocolData, JSON.stringify({ title: "Home" }), 'index.html', url.pathname,
61+
(chunk: string) => chunks.push(chunk));
5962
return new Response(chunks.join(''), {
6063
headers: { 'Content-Type': 'text/html' },
6164
});
@@ -68,8 +71,7 @@ Deno.serve({ port: 3000 }, (_req) => {
6871
| Function | Description |
6972
|----------|-------------|
7073
| `build(options)` | Build templates into a protocol. Returns `{ protocol, cssFiles, stats }` |
71-
| `render(protocol, state)` | Render protocol to HTML string |
72-
| `renderStream(protocol, state, onChunk)` | Stream rendered HTML fragments via callback |
74+
| `render(protocol, state, entry, requestPath, onChunk, plugin?)` | Render protocol with route matching, streaming HTML fragments via callback |
7375
| `inspect(protocol)` | Convert protocol to JSON for debugging |
7476

7577
### BuildOptions

docs/guide/concepts/handlers/rust.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ serde_json = "1"
1414

1515
::: code-group
1616
```rust [Actix Web]
17-
use actix_web::{web, App, HttpServer, HttpResponse};
18-
use webui::{WebUIHandler, ResponseWriter, WebUIProtocol};
17+
use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse};
18+
use webui::{WebUIHandler, RenderOptions, ResponseWriter, WebUIProtocol};
1919
use serde_json::json;
2020
use std::fs;
2121

@@ -38,11 +38,12 @@ async fn main() -> std::io::Result<()> {
3838
HttpServer::new(move || {
3939
App::new()
4040
.app_data(protocol.clone())
41-
.route("/", web::get().to(|proto: web::Data<WebUIProtocol>| async move {
41+
.route("/{path:.*}", web::get().to(|proto: web::Data<WebUIProtocol>, req: HttpRequest| async move {
4242
let state = json!({ "title": "Home" });
4343
let mut writer = StringWriter(String::new());
4444
let mut handler = WebUIHandler::new();
45-
handler.handle(&proto, &state, &mut writer).unwrap();
45+
let options = RenderOptions::new("index.html", req.path());
46+
handler.handle(&proto, &state, &options, &mut writer).unwrap();
4647
HttpResponse::Ok().content_type("text/html").body(writer.0)
4748
}))
4849
})
@@ -53,8 +54,8 @@ async fn main() -> std::io::Result<()> {
5354
```
5455

5556
```rust [Axum]
56-
use axum::{routing::get, Router, extract::State};
57-
use webui::{WebUIHandler, ResponseWriter, WebUIProtocol};
57+
use axum::{routing::get, Router, extract::{State, Request}};
58+
use webui::{WebUIHandler, RenderOptions, ResponseWriter, WebUIProtocol};
5859
use serde_json::json;
5960
use std::{fs, sync::Arc};
6061

@@ -74,11 +75,12 @@ async fn main() {
7475
let protocol = Arc::new(WebUIProtocol::from_protobuf(&protocol_bytes).unwrap());
7576

7677
let app = Router::new()
77-
.route("/", get(|State(proto): State<Arc<WebUIProtocol>>| async move {
78+
.route("/{*path}", get(|State(proto): State<Arc<WebUIProtocol>>, req: Request| async move {
7879
let state = json!({ "title": "Home" });
7980
let mut writer = StringWriter(String::new());
8081
let mut handler = WebUIHandler::new();
81-
handler.handle(&proto, &state, &mut writer).unwrap();
82+
let options = RenderOptions::new("index.html", req.uri().path());
83+
handler.handle(&proto, &state, &options, &mut writer).unwrap();
8284
axum::response::Html(writer.0)
8385
}))
8486
.with_state(protocol);
@@ -92,7 +94,7 @@ async fn main() {
9294
use hyper::{server::conn::http1, service::service_fn, body::Bytes, Request, Response};
9395
use hyper_util::rt::TokioIo;
9496
use http_body_util::Full;
95-
use webui::{WebUIHandler, ResponseWriter, WebUIProtocol};
97+
use webui::{WebUIHandler, RenderOptions, ResponseWriter, WebUIProtocol};
9698
use serde_json::json;
9799
use std::{fs, sync::Arc};
98100

@@ -117,13 +119,14 @@ async fn main() {
117119
let proto = protocol.clone();
118120
tokio::spawn(async move {
119121
http1::Builder::new()
120-
.serve_connection(TokioIo::new(stream), service_fn(move |_: Request<_>| {
122+
.serve_connection(TokioIo::new(stream), service_fn(move |req: Request<_>| {
121123
let proto = proto.clone();
122124
async move {
123125
let state = json!({ "title": "Home" });
124126
let mut writer = StringWriter(String::new());
125127
let mut handler = WebUIHandler::new();
126-
handler.handle(&proto, &state, &mut writer).unwrap();
128+
let options = RenderOptions::new("index.html", req.uri().path());
129+
handler.handle(&proto, &state, &options, &mut writer).unwrap();
127130
Ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from(writer.0))))
128131
}
129132
}))

docs/guide/concepts/plugins/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ During `webui build --plugin=fast`, the parser plugin:
6868
- **Skips framework attributes**: `@click`, `f-ref`, `f-slotted`, `f-children` are removed from the protocol (they're handled client-side)
6969
- **Counts dynamic bindings**: Emits binding counts per element as `Plugin` fragments for the handler
7070
- **Tracks components**: Records all custom elements discovered during parsing
71-
- **Injects `<f-template>` wrappers**: At `</body>`, injects template wrappers for each component with BTR→FAST syntax conversion
71+
- **Injects `<f-template>` wrappers**: At `</body>`, injects template wrappers for each component with FAST syntax conversion
7272

7373
#### Syntax Conversion
7474

0 commit comments

Comments
 (0)