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 = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
"hemplate",
"scraper",
"slhx", "slhx",
"slhx-axum", "slhx-axum",
"slhx-build", "slhx-build",
+2
View File
@@ -10,11 +10,13 @@ path = "src/lib.rs"
[dependencies] [dependencies]
axum = "0.7" axum = "0.7"
futures-util = "0.3" futures-util = "0.3"
hemplate = { path = "../../../hemplate/hemplate" }
slhx = { path = "../../slhx" } slhx = { path = "../../slhx" }
slhx-axum = { path = "../../slhx-axum" } slhx-axum = { path = "../../slhx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies] [dev-dependencies]
scraper = "0.23"
slhx-test = { path = "../../slhx-test" } slhx-test = { path = "../../slhx-test" }
[build-dependencies] [build-dependencies]
+59 -16
View File
@@ -3,6 +3,7 @@ use axum::response::IntoResponse;
use axum::routing::get; use axum::routing::get;
use axum::Router; use axum::Router;
use futures_util::{stream, StreamExt}; use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{push, IntoEffect, SafeHtml}; use slhx::{push, IntoEffect, SafeHtml};
use slhx_axum::{runtime_js, sse, EffectResponse, HandlerRegistry, InteractionForm, PageRequest}; use slhx_axum::{runtime_js, sse, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
use slhx_v0_examples::ui; use slhx_v0_examples::ui;
@@ -25,6 +26,17 @@ struct Todo {
title: String, title: String,
} }
#[derive(Hemplate)]
#[hemplate = "partials"]
struct TodoItems {
items: Vec<TodoItem>,
}
struct TodoItem {
id: u64,
title: String,
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let state = Arc::new(ExampleState::default()); let state = Arc::new(ExampleState::default());
@@ -116,7 +128,7 @@ fn registry(state: Arc<ExampleState>) -> HandlerRegistry {
todos.push(Todo { id, title: title.into() }); 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"), ui::todos::forms::new_todo.clear("title"),
) )
} }
@@ -189,22 +201,53 @@ fn shell(body: String) -> String {
) )
} }
fn render_todos(todos: &[Todo]) -> String { fn render_todos(todos: &[Todo]) -> SafeHtml {
if todos.is_empty() { // req: html_safety/002 req: view/001
return "<li>No todos yet</li>".into(); render_html(&TodoItems {
} items: todos
todos
.iter() .iter()
.map(|todo| format!(r#"<li data-key="{}">{}</li>"#, todo.id, escape_html(&todo.title))) .map(|todo| TodoItem {
.collect::<Vec<_>>() id: todo.id,
.join("") title: todo.title.clone(),
})
.collect(),
})
} }
fn escape_html(value: &str) -> String { fn render_html(template: &impl Hemplate) -> SafeHtml {
value SafeHtml::trusted(template.render().expect("v0 hemplate partial renders"))
.replace('&', "&amp;") }
.replace('<', "&lt;")
.replace('>', "&gt;") #[cfg(test)]
.replace('"', "&quot;") mod tests {
.replace('\'', "&#39;") 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>