refactor(v0): render todos with hemplate

Move the v0 todo-list payload from Rust raw HTML formatting into a hemplate partial/view and cover both populated and empty rendered structure with scraper.

req: html_safety/002

req: view/001

req: test/005
This commit is contained in:
slhx agent
2026-05-25 21:42:08 +02:00
parent 3b61451858
commit c655d877ca
4 changed files with 70 additions and 17 deletions
Generated
+2
View File
@@ -1447,6 +1447,8 @@ version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"hemplate",
"scraper",
"slhx",
"slhx-axum",
"slhx-build",
+2
View File
@@ -10,11 +10,13 @@ path = "src/lib.rs"
[dependencies]
axum = "0.7"
futures-util = "0.3"
hemplate = { path = "../../../hemplate/hemplate" }
slhx = { path = "../../slhx" }
slhx-axum = { path = "../../slhx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
scraper = "0.23"
slhx-test = { path = "../../slhx-test" }
[build-dependencies]
+60 -17
View File
@@ -3,6 +3,7 @@ use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{push, IntoEffect, SafeHtml};
use slhx_axum::{runtime_js, sse, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
use slhx_v0_examples::ui;
@@ -25,6 +26,17 @@ struct Todo {
title: String,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct TodoItems {
items: Vec<TodoItem>,
}
struct TodoItem {
id: u64,
title: String,
}
#[tokio::main]
async fn main() {
let state = Arc::new(ExampleState::default());
@@ -116,7 +128,7 @@ fn registry(state: Arc<ExampleState>) -> HandlerRegistry {
todos.push(Todo { id, title: title.into() });
}
(
ui::todos::slots::todo_list.html(SafeHtml::trusted(render_todos(&todos))),
ui::todos::slots::todo_list.html(render_todos(&todos)),
ui::todos::forms::new_todo.clear("title"),
)
}
@@ -189,22 +201,53 @@ fn shell(body: String) -> String {
)
}
fn render_todos(todos: &[Todo]) -> String {
if todos.is_empty() {
return "<li>No todos yet</li>".into();
}
todos
.iter()
.map(|todo| format!(r#"<li data-key="{}">{}</li>"#, todo.id, escape_html(&todo.title)))
.collect::<Vec<_>>()
.join("")
fn render_todos(todos: &[Todo]) -> SafeHtml {
// req: html_safety/002 req: view/001
render_html(&TodoItems {
items: todos
.iter()
.map(|todo| TodoItem {
id: todo.id,
title: todo.title.clone(),
})
.collect(),
})
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
fn render_html(template: &impl Hemplate) -> SafeHtml {
SafeHtml::trusted(template.render().expect("v0 hemplate partial renders"))
}
#[cfg(test)]
mod tests {
use super::*;
use scraper::{Html, Selector};
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn todos_payload_is_rendered_by_a_hemplate_view() {
let todos = vec![Todo { id: 7, title: "<b>Ship v0</b>".to_owned() }];
let html = render_todos(&todos);
let document = Html::parse_fragment(html.as_str());
let rows = document.select(&selector("li[data-key]")).collect::<Vec<_>>();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].value().attr("data-key"), Some("7"));
assert!(rows[0].text().collect::<String>().contains("<b>Ship v0</b>"));
assert!(document.select(&selector("b")).next().is_none());
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn empty_todos_payload_is_rendered_by_a_hemplate_view() {
let html = render_todos(&[]);
let document = Html::parse_fragment(html.as_str());
let rows = document.select(&selector("li")).collect::<Vec<_>>();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].text().collect::<String>(), "No todos yet");
}
}
@@ -0,0 +1,6 @@
<template h-if="self.items.is_empty()">
<li>No todos yet</li>
</template>
<template h-else>
<li h-for="todo in &self.items" +data-key="todo.id">{+ todo.title +}</li>
</template>