diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 0f11ed7..4c313ce 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -435,7 +435,7 @@ what a valid business email is. ## axum_integration ### req: axum/001 -001 slhx-axum supports the common shell/partial pattern. Full-page requests are wrapped in a user-provided Shell; slhx/partial requests may return only the rendered component or an EffectBatch. +001 slhx-axum supports the common shell/partial pattern. Full-page requests are wrapped in a user-provided Shell; slhx/partial requests may return only the rendered component or an EffectBatch. The shell/partial helper has a `SafeHtml` path so already-rendered hemplate fragments can cross the page boundary without downgrading to unchecked strings. ### req: axum/002 002 Existing Axum routes remain normal Axum routes. slhx does not own routing. slhx-axum only mounts handler dispatch, runtime assets, and optional push endpoints. diff --git a/examples/kanban/src/main.rs b/examples/kanban/src/main.rs index 502fff8..a9683fd 100644 --- a/examples/kanban/src/main.rs +++ b/examples/kanban/src/main.rs @@ -111,7 +111,7 @@ async fn main() { async fn home(State(state): State>, request: PageRequest) -> impl IntoResponse { let board = state.board.lock().unwrap().clone(); request - .page(page_html(&board), shell) + .page_html(page_html(&board), shell) .title("slhx Kanban") .fingerprint(ui::BUILD_FINGERPRINT) } @@ -223,18 +223,18 @@ fn parse_column(value: Option<&str>) -> usize { .unwrap_or(0) } -fn page_html(board: &BoardState) -> String { - // Explicit full-page composition boundary for already-rendered hemplate fragments. +fn page_html(board: &BoardState) -> SafeHtml { // req: html_safety/002 req: view/001 board_ui::render(&Board { options: render_options(), board: ui::render(&board_view(board)), }) - .into_string() } -fn shell(body: String) -> String { - format!( +fn shell(body: SafeHtml) -> SafeHtml { + // Explicit full-page composition boundary for already-rendered hemplate fragments. + // req: html_safety/001 req: html_safety/002 req: axum_integration/001 + SafeHtml::trusted(format!( r#" @@ -257,7 +257,7 @@ fn shell(body: String) -> String { {body} "# - ) + )) } fn render_options() -> SafeHtml { @@ -311,10 +311,10 @@ mod tests { #[test] fn kanban_page_is_composed_by_a_hemplate_view() { let html = page_html(&BoardState::default()); - assert!(!html.contains("__OPTIONS__")); - assert!(!html.contains("__BOARD__")); + assert!(!html.as_str().contains("__OPTIONS__")); + assert!(!html.as_str().contains("__BOARD__")); - let document = Html::parse_fragment(&html); + let document = Html::parse_fragment(html.as_str()); assert_eq!(document.select(&selector("section[data-slhx-root=\"kanban\"]")).count(), 1); assert_eq!(document.select(&selector("select[name=\"column\"] > option")).count(), 3); assert_eq!(document.select(&selector("[data-sid]")).count(), 3); diff --git a/examples/techdemo/src/main.rs b/examples/techdemo/src/main.rs index 91ea092..ad653ad 100644 --- a/examples/techdemo/src/main.rs +++ b/examples/techdemo/src/main.rs @@ -223,7 +223,7 @@ async fn main() { async fn home(State(state): State>, request: PageRequest) -> impl IntoResponse { let demo = state.demo.lock().unwrap().clone(); request - .page(page_html(&demo), shell) + .page_html(page_html(&demo), shell) .title("slhx Techdemo") .fingerprint(ui::BUILD_FINGERPRINT) } @@ -238,7 +238,7 @@ async fn architecture(request: PageRequest) -> impl IntoResponse { island_snapshot: "2|3|21|architecture route ยท same opaque island bridge".to_owned(), }); request - .page(body, shell) + .page_html(body, shell) .title("slhx Architecture") .fingerprint(ui::BUILD_FINGERPRINT) } @@ -446,8 +446,7 @@ fn island_snapshot(demo: &DemoState) -> String { ) } -fn page_html(demo: &DemoState) -> String { - // Explicit full-page composition boundary for already-rendered hemplate fragments. +fn page_html(demo: &DemoState) -> SafeHtml { // req: html_safety/002 req: view/001 render_control_center(ControlCenter { hero: render_hero(demo), @@ -458,10 +457,9 @@ fn page_html(demo: &DemoState) -> String { }) } -fn shell(body: String) -> String { - // Explicit full-page shell composition boundary for already-rendered hemplate fragments. - // req: html_safety/002 req: view/001 req: component/003 - ui::app_shell::render(&AppShell { body: SafeHtml::trusted(body) }).into_string() +fn shell(body: SafeHtml) -> SafeHtml { + // req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003 + ui::app_shell::render(&AppShell { body }) } fn hero_view(demo: &DemoState) -> HeroMetrics { @@ -580,12 +578,11 @@ fn architecture_activity() -> SafeHtml { ui::render(&ArchitectureActivity) } -fn render_control_center(page: ControlCenter) -> String { +fn render_control_center(page: ControlCenter) -> SafeHtml { // req: html_safety/002 req: view/001 req: component/003 - ui::render(&page).into_string() + ui::render(&page) } - #[cfg(test)] mod tests { use super::*; @@ -599,7 +596,7 @@ mod tests { #[test] fn shell_is_composed_by_a_hemplate_view() { let html = shell(page_html(&DemoState::default())); - let document = Html::parse_document(&html); + let document = Html::parse_document(html.as_str()); assert_eq!( document .select(&selector("title")) @@ -624,7 +621,7 @@ mod tests { assert!(!html.contains("__INSPECTOR__")); assert!(!html.contains("__ACTIVITY__")); - let document = Html::parse_fragment(&html); + let document = Html::parse_fragment(html.as_str()); assert_eq!(document.select(&selector("[data-slhx-root=\"techdemo\"]")).count(), 1); assert!(document.select(&selector("[data-sid]")).count() >= 7); assert!(document.select(&selector("[data-hid]")).count() >= 7); diff --git a/examples/v0/src/main.rs b/examples/v0/src/main.rs index 00dc685..040ba0d 100644 --- a/examples/v0/src/main.rs +++ b/examples/v0/src/main.rs @@ -80,7 +80,7 @@ async fn main() { // req: examples/001 async fn home(request: PageRequest) -> impl IntoResponse { request - .page(all_examples(), shell) + .page_html(all_examples(), shell) .title("slhx v0 examples") .fingerprint(ui::BUILD_FINGERPRINT) } @@ -89,7 +89,7 @@ async fn home(request: PageRequest) -> impl IntoResponse { async fn docs(request: PageRequest) -> impl IntoResponse { let partial = render_page_swap("Docs", "This page was swapped without a full reload."); request - .page(partial, shell) + .page_html(partial, shell) .title("Docs") .fingerprint(ui::BUILD_FINGERPRINT) } @@ -179,25 +179,25 @@ fn registry(state: Arc) -> HandlerRegistry { }) } -fn all_examples() -> String { - [ - counter::lower(include_str!("../templates/counter.heml")), - todos::lower(include_str!("../templates/todos.heml")), - wizard::lower(include_str!("../templates/wizard.heml")), - auth::lower(include_str!("../templates/auth.heml")), - render_page_swap("Welcome", "Welcome"), - notifications::lower(include_str!("../templates/notifications.heml")), - ] - .join("\n") +fn all_examples() -> SafeHtml { + // Static `.heml` fragments are lowered by generated code before they join rendered views. + // req: html_safety/001 req: html_safety/002 req: component/003 + SafeHtml::trusted( + [ + counter::lower(include_str!("../templates/counter.heml")), + todos::lower(include_str!("../templates/todos.heml")), + wizard::lower(include_str!("../templates/wizard.heml")), + auth::lower(include_str!("../templates/auth.heml")), + render_page_swap("Welcome", "Welcome").into_string(), + notifications::lower(include_str!("../templates/notifications.heml")), + ] + .join("\n"), + ) } -fn shell(body: String) -> String { - // Explicit full-page shell composition boundary for already-rendered hemplate fragments. - // req: html_safety/002 req: view/001 req: component/003 - ui::render(&AppShell { - body: SafeHtml::trusted(body), - }) - .into_string() +fn shell(body: SafeHtml) -> SafeHtml { + // req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003 + ui::render(&AppShell { body }) } fn todos_view(todos: &[Todo]) -> TodoItems { @@ -213,13 +213,12 @@ fn todos_view(todos: &[Todo]) -> TodoItems { } } -fn render_page_swap(title: &'static str, message: &'static str) -> String { +fn render_page_swap(title: &'static str, message: &'static str) -> SafeHtml { // req: html_safety/002 req: view/001 req: component/003 page_swap::render(&PageSwap { content: render_docs_content(message), title, }) - .into_string() } fn render_docs_content(message: &'static str) -> SafeHtml { @@ -240,7 +239,7 @@ mod tests { #[test] fn shell_is_rendered_by_a_hemplate_view() { let html = shell(render_page_swap("Welcome", "Welcome")); - let document = Html::parse_document(&html); + let document = Html::parse_document(html.as_str()); assert_eq!( document .select(&selector("title")) @@ -281,7 +280,7 @@ mod tests { #[test] fn docs_page_partial_is_rendered_by_a_hemplate_view() { let html = render_page_swap("Docs", "This page was swapped without a full reload."); - let document = Html::parse_fragment(&html); + let document = Html::parse_fragment(html.as_str()); assert_eq!(document.select(&selector("main[data-slhx-root=\"docs\"]")).count(), 1); assert_eq!(document.select(&selector("article[data-sid]")).count(), 1); assert_eq!( diff --git a/slhx-axum/src/lib.rs b/slhx-axum/src/lib.rs index cc7e9c5..7e0ac19 100644 --- a/slhx-axum/src/lib.rs +++ b/slhx-axum/src/lib.rs @@ -5,7 +5,7 @@ use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Respon use axum::response::sse::{Event, Sse}; use axum::response::IntoResponse; use futures_util::{Stream, StreamExt}; -use slhx_core::{BuildFingerprint, EffectBatch, Handle, IntoEffect}; +use slhx_core::{BuildFingerprint, EffectBatch, Handle, IntoEffect, SafeHtml}; use std::collections::BTreeMap; use std::convert::Infallible; @@ -61,6 +61,17 @@ impl PageRequest { PageMode::Partial => PageResponse::partial(partial_html), } } + + pub fn page_html( + self, + partial_html: SafeHtml, + shell: impl FnOnce(SafeHtml) -> SafeHtml, + ) -> PageResponse { + match self.mode { + PageMode::Full => PageResponse::full(shell(partial_html).into_string()), + PageMode::Partial => PageResponse::partial(partial_html.into_string()), + } + } } #[async_trait] diff --git a/slhx-axum/tests/response.rs b/slhx-axum/tests/response.rs index 77c872b..c5000cd 100644 --- a/slhx-axum/tests/response.rs +++ b/slhx-axum/tests/response.rs @@ -6,7 +6,7 @@ use slhx_axum::{ InteractionFormRejection, PageMode, PageRequest, PageResponse, SLHX_CONTENT_TYPE, SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE, SLHX_TITLE_HEADER, }; -use slhx_core::{push, BuildFingerprint, Handle, Slot}; +use slhx_core::{push, BuildFingerprint, Handle, SafeHtml, Slot}; fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") @@ -47,6 +47,46 @@ fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() { assert_eq!(partial_fragment.select(&selector("body[data-shell=\"docs\"]")).count(), 0); } +#[test] +fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() { + // req: axum_integration/001 req: html_safety/001 req: html_safety/002 req: test/005 + let full = PageRequest { + mode: PageMode::Full, + } + .page_html( + SafeHtml::trusted("
Docs
"), + |content| { + SafeHtml::trusted(format!( + "{content}" + )) + }, + ); + assert_eq!(full.mode, PageMode::Full); + let full_document = Html::parse_document(&full.html); + assert_eq!( + full_document + .select(&selector("body[data-shell=\"docs\"] main[data-page=\"docs\"]")) + .count(), + 1 + ); + + let partial = PageRequest { + mode: PageMode::Partial, + } + .page_html( + SafeHtml::trusted("
Docs
"), + |content| { + SafeHtml::trusted(format!( + "{content}" + )) + }, + ); + assert_eq!(partial.mode, PageMode::Partial); + let partial_fragment = Html::parse_fragment(&partial.html); + assert_eq!(partial_fragment.select(&selector("main[data-page=\"docs\"]")).count(), 1); + assert_eq!(partial_fragment.select(&selector("body[data-shell=\"docs\"]")).count(), 0); +} + #[test] fn partial_page_response_sets_partial_and_title_headers() { let response = PageResponse::partial("
Docs
")