Skip to content

Commit 8cdfe4b

Browse files
feat(example): Creates the best demo for acme commerce store (#110)
1 parent 4a65642 commit 8cdfe4b

110 files changed

Lines changed: 8095 additions & 65 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ members = [
44
"xtask",
55
"examples/integration/rust",
66
"examples/integration/ssr-performance-showdown",
7+
"examples/app/commerce/server",
78
]
89
resolver = "2"
910

crates/webui-cli/src/commands/serve.rs

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -361,8 +361,7 @@ fn run(args: &ServeArgs) -> Result<()> {
361361
.app_data(server_context.clone())
362362
.route("/", web::get().to(handle_index))
363363
.route("/index.html", web::get().to(handle_index))
364-
.route("/hmr", web::get().to(handle_hmr))
365-
.route("/.webui/routes.json", web::get().to(handle_routes_json));
364+
.route("/hmr", web::get().to(handle_hmr));
366365

367366
if has_api_proxy {
368367
app = app.route("/api/{tail:.*}", web::route().to(handle_api_proxy));
@@ -810,46 +809,6 @@ fn collect_needed_template_names(
810809
)
811810
}
812811

813-
/// Handle GET /.webui/routes.json — return the route registry as JSON.
814-
async fn handle_routes_json(context: web::Data<ServerContext>) -> HttpResponse {
815-
let routes_array = match context.state.lock() {
816-
Ok(s) => match &s.protocol {
817-
Some(p) => {
818-
let entries: Vec<Value> = p
819-
.routes
820-
.iter()
821-
.map(|(key, record): (&String, &webui_protocol::RouteRecord)| {
822-
let name_val = if record.name.is_empty() {
823-
key.as_str()
824-
} else {
825-
record.name.as_str()
826-
};
827-
let mut m = serde_json::Map::new();
828-
m.insert("name".into(), Value::String(name_val.to_string()));
829-
m.insert("path".into(), Value::String(record.path.clone()));
830-
m.insert(
831-
"fragmentId".into(),
832-
Value::String(record.fragment_id.clone()),
833-
);
834-
m.insert("exact".into(), Value::Bool(record.exact));
835-
Value::Object(m)
836-
})
837-
.collect();
838-
Value::Array(entries)
839-
}
840-
None => Value::Array(Vec::new()),
841-
},
842-
Err(_) => Value::Array(Vec::new()),
843-
};
844-
845-
let mut envelope = serde_json::Map::new();
846-
envelope.insert("routes".into(), routes_array);
847-
848-
HttpResponse::Ok()
849-
.content_type("application/json")
850-
.json(Value::Object(envelope))
851-
}
852-
853812
/// Forward requests under `/api/*` to the user's API server.
854813
async fn handle_api_proxy(
855814
req: HttpRequest,

crates/webui-handler/src/lib.rs

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -152,24 +152,17 @@ fn emit_state_attributes(state: &Value, writer: &mut dyn ResponseWriter) -> Resu
152152
// Emit scalar values as individual attributes
153153
for (key, value) in map {
154154
let val_str = match value {
155-
Value::String(s) => s.clone(),
156-
Value::Number(n) => n.to_string(),
157-
Value::Bool(b) => b.to_string(),
155+
Value::String(s) => std::borrow::Cow::Borrowed(s.as_str()),
156+
Value::Number(n) => std::borrow::Cow::Owned(n.to_string()),
157+
Value::Bool(true) => std::borrow::Cow::Borrowed("true"),
158+
Value::Bool(false) => std::borrow::Cow::Borrowed("false"),
158159
_ => continue,
159160
};
160161
let attr_name = camel_to_kebab(key);
161162
writer.write(" ")?;
162163
writer.write(&attr_name)?;
163164
writer.write("=\"")?;
164-
for ch in val_str.chars() {
165-
match ch {
166-
'&' => writer.write("&amp;")?,
167-
'"' => writer.write("&quot;")?,
168-
'<' => writer.write("&lt;")?,
169-
'>' => writer.write("&gt;")?,
170-
_ => writer.write(&String::from(ch))?,
171-
}
172-
}
165+
write_escaped_state_attr(writer, val_str.as_ref())?;
173166
writer.write("\"")?;
174167
}
175168

@@ -178,16 +171,36 @@ fn emit_state_attributes(state: &Value, writer: &mut dyn ResponseWriter) -> Resu
178171
if has_complex {
179172
let json_str = state.to_string();
180173
writer.write(" data-state=\"")?;
181-
for ch in json_str.chars() {
182-
match ch {
183-
'&' => writer.write("&amp;")?,
184-
'"' => writer.write("&quot;")?,
185-
'<' => writer.write("&lt;")?,
186-
'>' => writer.write("&gt;")?,
187-
_ => writer.write(&String::from(ch))?,
174+
write_escaped_state_attr(writer, &json_str)?;
175+
writer.write("\"")?;
176+
}
177+
178+
Ok(())
179+
}
180+
181+
fn write_escaped_state_attr(writer: &mut dyn ResponseWriter, value: &str) -> Result<()> {
182+
let mut last = 0;
183+
184+
for (index, ch) in value.char_indices() {
185+
let escaped = match ch {
186+
'&' => Some("&amp;"),
187+
'"' => Some("&quot;"),
188+
'<' => Some("&lt;"),
189+
'>' => Some("&gt;"),
190+
_ => None,
191+
};
192+
193+
if let Some(entity) = escaped {
194+
if last < index {
195+
writer.write(&value[last..index])?;
188196
}
197+
writer.write(entity)?;
198+
last = index + ch.len_utf8();
189199
}
190-
writer.write("\"")?;
200+
}
201+
202+
if last < value.len() {
203+
writer.write(&value[last..])?;
191204
}
192205

193206
Ok(())
@@ -5194,4 +5207,41 @@ mod tests {
51945207
"component attr should be on webui-route: {html}"
51955208
);
51965209
}
5210+
5211+
#[test]
5212+
fn test_route_state_attributes_escape_scalars_and_data_state() {
5213+
let protocol = make_route_protocol();
5214+
let state = test_json!({
5215+
"title": "Fish & Chips <\"special\">",
5216+
"cartOpen": true,
5217+
"items": [{"name": "A&B"}]
5218+
});
5219+
let mut writer = TestWriter::new();
5220+
5221+
handle(
5222+
&protocol,
5223+
&state,
5224+
&RenderOptions::new("index.html", "/"),
5225+
&mut writer,
5226+
)
5227+
.unwrap();
5228+
5229+
let html = writer.get_content();
5230+
assert!(
5231+
html.contains(r#"title="Fish &amp; Chips &lt;&quot;special&quot;&gt;""#),
5232+
"escaped title should be emitted: {html}"
5233+
);
5234+
assert!(
5235+
html.contains(r#"cart-open="true""#),
5236+
"bool attrs should render: {html}"
5237+
);
5238+
assert!(
5239+
html.contains(r#"data-state=""#),
5240+
"complex state should be emitted as data-state: {html}"
5241+
);
5242+
assert!(
5243+
html.contains(r#"&quot;name&quot;:&quot;A&amp;B&quot;"#),
5244+
"complex state values should be escaped inside data-state: {html}"
5245+
);
5246+
}
51975247
}

examples/app/commerce/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
static/images/products

examples/app/commerce/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# WebUI Store — WebUI Marketplace Demo
2+
3+
A blazing-fast, server-rendered marketplace dashboard built with **WebUI** and **actix-web**.
4+
5+
This demo proves WebUI's SSR performance: 1,000+ products rendered server-side in sub-millisecond time, with seamless FAST-HTML client hydration for interactivity.
6+
7+
## Architecture
8+
9+
- **Server**: actix-web (Rust) with 3 SSR page routes + cart API
10+
- **Templates**: WebUI binary protocol — compiled once, rendered per-request
11+
- **Client**: FAST-HTML hydration for cart, search, gallery, variant selection
12+
- **Design**: Atomic Design (atoms → molecules → organisms → pages)
13+
- **Theme**: WebUI dark theme inspired by copilot.microsoft.com
14+
15+
## Quick Start
16+
17+
```bash
18+
# Install client dependencies
19+
pnpm install
20+
21+
# Run the server.
22+
pnpm start
23+
24+
# Open http://localhost:3100
25+
```
26+
27+
## Pages
28+
29+
| Page | URL | Description |
30+
|------|-----|-------------|
31+
| Homepage | `/` | 3-item hero grid + product carousel |
32+
| Search | `/search?q=&sort=` | Category sidebar + product grid + sort |
33+
| Category | `/search/{category}` | Filtered by category |
34+
| Product | `/product/{handle}` | Gallery + variants + add-to-cart |
35+
36+
## Performance
37+
38+
Templates are compiled to binary protocol at server startup. Per-request rendering only injects state data — no template parsing, no JavaScript execution on the server.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"storeName": "WebUI Store",
3+
"cartItemCount": "0",
4+
"navCategories": [
5+
{ "handle": "shirts", "title": "Shirts" },
6+
{ "handle": "stickers", "title": "Stickers" },
7+
{ "handle": "bags", "title": "Bags" }
8+
],
9+
"categories": [
10+
{ "handle": "shirts", "title": "Shirts" },
11+
{ "handle": "stickers", "title": "Stickers" },
12+
{ "handle": "bags", "title": "Bags" }
13+
],
14+
"featuredProducts": [],
15+
"carouselProducts": [],
16+
"cartEmpty": true,
17+
"cartSubtotal": "$0.00",
18+
"cartTaxes": "$0.00",
19+
"cartItems": []
20+
}

examples/app/commerce/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "commerce",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"private": true,
6+
"scripts": {
7+
"start:client": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --sourcemap --watch",
8+
"start:server": "cargo run -p marketplace-api -- --port 3100",
9+
"start": "cargo xtask dev commerce"
10+
},
11+
"imports": {
12+
"#atoms/*": "./src/atoms/*",
13+
"#molecules/*": "./src/molecules/*",
14+
"#organisms/*": "./src/organisms/*",
15+
"#pages/*": "./src/pages/*"
16+
},
17+
"devDependencies": {
18+
"@microsoft/fast-element": "catalog:",
19+
"@microsoft/fast-html": "catalog:",
20+
"@microsoft/webui-router": "workspace:*",
21+
"esbuild": "catalog:",
22+
"typescript": "catalog:",
23+
"tslib": "catalog:"
24+
}
25+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "marketplace-api"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[[bin]]
8+
name = "marketplace-api"
9+
path = "src/main.rs"
10+
11+
[dependencies]
12+
actix-web = { workspace = true }
13+
serde = { workspace = true }
14+
serde_json = { workspace = true }
15+
anyhow = { workspace = true }
16+
clap = { workspace = true }
17+
mime_guess = { workspace = true }
18+
thiserror = { workspace = true }
19+
webui = { path = "../../../../crates/webui" }
20+
webui-handler = { path = "../../../../crates/webui-handler" }
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
use anyhow::Result;
2+
use std::path::Path;
3+
4+
use crate::catalog::Catalog;
5+
use crate::frontend::FrontendRuntime;
6+
7+
pub(crate) struct AppState {
8+
catalog: Catalog,
9+
frontend: FrontendRuntime,
10+
}
11+
12+
impl AppState {
13+
pub(crate) fn load(app_root: &Path) -> Result<Self> {
14+
let frontend = FrontendRuntime::load(app_root)?;
15+
let catalog = Catalog::generate();
16+
Ok(Self { catalog, frontend })
17+
}
18+
19+
#[must_use]
20+
pub(crate) fn catalog(&self) -> &Catalog {
21+
&self.catalog
22+
}
23+
24+
#[must_use]
25+
pub(crate) fn frontend(&self) -> &FrontendRuntime {
26+
&self.frontend
27+
}
28+
29+
#[must_use]
30+
pub(crate) fn product_count(&self) -> usize {
31+
self.catalog.product_count()
32+
}
33+
}
34+
35+
#[cfg(test)]
36+
pub(crate) fn test_state() -> actix_web::web::Data<AppState> {
37+
let app_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
38+
.parent()
39+
.expect("server crate should live under the app directory");
40+
let state = match AppState::load(app_root) {
41+
Ok(state) => state,
42+
Err(error) => panic!("{error}"),
43+
};
44+
actix_web::web::Data::new(state)
45+
}

0 commit comments

Comments
 (0)