From c655d877ca65412e890110abbcfab11f12e6b2b2 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 25 May 2026 21:42:08 +0200 Subject: [PATCH] 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 --- Cargo.lock | 2 + examples/v0/Cargo.toml | 2 + examples/v0/src/main.rs | 77 +++++++++++++++---- .../v0/templates/partials/todo_items.heml | 6 ++ 4 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 examples/v0/templates/partials/todo_items.heml diff --git a/Cargo.lock b/Cargo.lock index d9a9697..ae4f4b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1447,6 +1447,8 @@ version = "0.1.0" dependencies = [ "axum", "futures-util", + "hemplate", + "scraper", "slhx", "slhx-axum", "slhx-build", diff --git a/examples/v0/Cargo.toml b/examples/v0/Cargo.toml index 53716c6..9d9369e 100644 --- a/examples/v0/Cargo.toml +++ b/examples/v0/Cargo.toml @@ -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] diff --git a/examples/v0/src/main.rs b/examples/v0/src/main.rs index 5f3f410..47e17c5 100644 --- a/examples/v0/src/main.rs +++ b/examples/v0/src/main.rs @@ -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, +} + +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) -> 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 "
  • No todos yet
  • ".into(); - } - todos - .iter() - .map(|todo| format!(r#"
  • {}
  • "#, todo.id, escape_html(&todo.title))) - .collect::>() - .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('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") +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: "Ship v0".to_owned() }]; + + let html = render_todos(&todos); + let document = Html::parse_fragment(html.as_str()); + let rows = document.select(&selector("li[data-key]")).collect::>(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].value().attr("data-key"), Some("7")); + assert!(rows[0].text().collect::().contains("Ship v0")); + 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::>(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].text().collect::(), "No todos yet"); + } } diff --git a/examples/v0/templates/partials/todo_items.heml b/examples/v0/templates/partials/todo_items.heml new file mode 100644 index 0000000..f37e71d --- /dev/null +++ b/examples/v0/templates/partials/todo_items.heml @@ -0,0 +1,6 @@ + +