feat(axum): add safe html page composition
Add a SafeHtml PageRequest path for shell/partial composition and migrate examples so rendered hemplate fragments stay typed through the transport boundary instead of round-tripping through unchecked strings. req: axum_integration/001 req: html_safety/001 req: html_safety/002
This commit is contained in:
+1
-1
@@ -435,7 +435,7 @@ what a valid business email is.
|
|||||||
## axum_integration
|
## axum_integration
|
||||||
|
|
||||||
### req: axum/001
|
### 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
|
### 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.
|
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.
|
||||||
|
|||||||
+10
-10
@@ -111,7 +111,7 @@ async fn main() {
|
|||||||
async fn home(State(state): State<Arc<AppState>>, request: PageRequest) -> impl IntoResponse {
|
async fn home(State(state): State<Arc<AppState>>, request: PageRequest) -> impl IntoResponse {
|
||||||
let board = state.board.lock().unwrap().clone();
|
let board = state.board.lock().unwrap().clone();
|
||||||
request
|
request
|
||||||
.page(page_html(&board), shell)
|
.page_html(page_html(&board), shell)
|
||||||
.title("slhx Kanban")
|
.title("slhx Kanban")
|
||||||
.fingerprint(ui::BUILD_FINGERPRINT)
|
.fingerprint(ui::BUILD_FINGERPRINT)
|
||||||
}
|
}
|
||||||
@@ -223,18 +223,18 @@ fn parse_column(value: Option<&str>) -> usize {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn page_html(board: &BoardState) -> String {
|
fn page_html(board: &BoardState) -> SafeHtml {
|
||||||
// Explicit full-page composition boundary for already-rendered hemplate fragments.
|
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
board_ui::render(&Board {
|
board_ui::render(&Board {
|
||||||
options: render_options(),
|
options: render_options(),
|
||||||
board: ui::render(&board_view(board)),
|
board: ui::render(&board_view(board)),
|
||||||
})
|
})
|
||||||
.into_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shell(body: String) -> String {
|
fn shell(body: SafeHtml) -> SafeHtml {
|
||||||
format!(
|
// 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#"<!doctype html>
|
r#"<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -257,7 +257,7 @@ fn shell(body: String) -> String {
|
|||||||
{body}
|
{body}
|
||||||
</body>
|
</body>
|
||||||
</html>"#
|
</html>"#
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_options() -> SafeHtml {
|
fn render_options() -> SafeHtml {
|
||||||
@@ -311,10 +311,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn kanban_page_is_composed_by_a_hemplate_view() {
|
fn kanban_page_is_composed_by_a_hemplate_view() {
|
||||||
let html = page_html(&BoardState::default());
|
let html = page_html(&BoardState::default());
|
||||||
assert!(!html.contains("__OPTIONS__"));
|
assert!(!html.as_str().contains("__OPTIONS__"));
|
||||||
assert!(!html.contains("__BOARD__"));
|
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("section[data-slhx-root=\"kanban\"]")).count(), 1);
|
||||||
assert_eq!(document.select(&selector("select[name=\"column\"] > option")).count(), 3);
|
assert_eq!(document.select(&selector("select[name=\"column\"] > option")).count(), 3);
|
||||||
assert_eq!(document.select(&selector("[data-sid]")).count(), 3);
|
assert_eq!(document.select(&selector("[data-sid]")).count(), 3);
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ async fn main() {
|
|||||||
async fn home(State(state): State<Arc<Shared>>, request: PageRequest) -> impl IntoResponse {
|
async fn home(State(state): State<Arc<Shared>>, request: PageRequest) -> impl IntoResponse {
|
||||||
let demo = state.demo.lock().unwrap().clone();
|
let demo = state.demo.lock().unwrap().clone();
|
||||||
request
|
request
|
||||||
.page(page_html(&demo), shell)
|
.page_html(page_html(&demo), shell)
|
||||||
.title("slhx Techdemo")
|
.title("slhx Techdemo")
|
||||||
.fingerprint(ui::BUILD_FINGERPRINT)
|
.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(),
|
island_snapshot: "2|3|21|architecture route · same opaque island bridge".to_owned(),
|
||||||
});
|
});
|
||||||
request
|
request
|
||||||
.page(body, shell)
|
.page_html(body, shell)
|
||||||
.title("slhx Architecture")
|
.title("slhx Architecture")
|
||||||
.fingerprint(ui::BUILD_FINGERPRINT)
|
.fingerprint(ui::BUILD_FINGERPRINT)
|
||||||
}
|
}
|
||||||
@@ -446,8 +446,7 @@ fn island_snapshot(demo: &DemoState) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn page_html(demo: &DemoState) -> String {
|
fn page_html(demo: &DemoState) -> SafeHtml {
|
||||||
// Explicit full-page composition boundary for already-rendered hemplate fragments.
|
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
render_control_center(ControlCenter {
|
render_control_center(ControlCenter {
|
||||||
hero: render_hero(demo),
|
hero: render_hero(demo),
|
||||||
@@ -458,10 +457,9 @@ fn page_html(demo: &DemoState) -> String {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shell(body: String) -> String {
|
fn shell(body: SafeHtml) -> SafeHtml {
|
||||||
// Explicit full-page shell composition boundary for already-rendered hemplate fragments.
|
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
|
||||||
// req: html_safety/002 req: view/001 req: component/003
|
ui::app_shell::render(&AppShell { body })
|
||||||
ui::app_shell::render(&AppShell { body: SafeHtml::trusted(body) }).into_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hero_view(demo: &DemoState) -> HeroMetrics {
|
fn hero_view(demo: &DemoState) -> HeroMetrics {
|
||||||
@@ -580,12 +578,11 @@ fn architecture_activity() -> SafeHtml {
|
|||||||
ui::render(&ArchitectureActivity)
|
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
|
// req: html_safety/002 req: view/001 req: component/003
|
||||||
ui::render(&page).into_string()
|
ui::render(&page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -599,7 +596,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn shell_is_composed_by_a_hemplate_view() {
|
fn shell_is_composed_by_a_hemplate_view() {
|
||||||
let html = shell(page_html(&DemoState::default()));
|
let html = shell(page_html(&DemoState::default()));
|
||||||
let document = Html::parse_document(&html);
|
let document = Html::parse_document(html.as_str());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document
|
document
|
||||||
.select(&selector("title"))
|
.select(&selector("title"))
|
||||||
@@ -624,7 +621,7 @@ mod tests {
|
|||||||
assert!(!html.contains("__INSPECTOR__"));
|
assert!(!html.contains("__INSPECTOR__"));
|
||||||
assert!(!html.contains("__ACTIVITY__"));
|
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_eq!(document.select(&selector("[data-slhx-root=\"techdemo\"]")).count(), 1);
|
||||||
assert!(document.select(&selector("[data-sid]")).count() >= 7);
|
assert!(document.select(&selector("[data-sid]")).count() >= 7);
|
||||||
assert!(document.select(&selector("[data-hid]")).count() >= 7);
|
assert!(document.select(&selector("[data-hid]")).count() >= 7);
|
||||||
|
|||||||
+15
-16
@@ -80,7 +80,7 @@ async fn main() {
|
|||||||
// req: examples/001
|
// req: examples/001
|
||||||
async fn home(request: PageRequest) -> impl IntoResponse {
|
async fn home(request: PageRequest) -> impl IntoResponse {
|
||||||
request
|
request
|
||||||
.page(all_examples(), shell)
|
.page_html(all_examples(), shell)
|
||||||
.title("slhx v0 examples")
|
.title("slhx v0 examples")
|
||||||
.fingerprint(ui::BUILD_FINGERPRINT)
|
.fingerprint(ui::BUILD_FINGERPRINT)
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ async fn home(request: PageRequest) -> impl IntoResponse {
|
|||||||
async fn docs(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.");
|
let partial = render_page_swap("Docs", "This page was swapped without a full reload.");
|
||||||
request
|
request
|
||||||
.page(partial, shell)
|
.page_html(partial, shell)
|
||||||
.title("Docs")
|
.title("Docs")
|
||||||
.fingerprint(ui::BUILD_FINGERPRINT)
|
.fingerprint(ui::BUILD_FINGERPRINT)
|
||||||
}
|
}
|
||||||
@@ -179,25 +179,25 @@ fn registry(state: Arc<ExampleState>) -> HandlerRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn all_examples() -> String {
|
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")),
|
counter::lower(include_str!("../templates/counter.heml")),
|
||||||
todos::lower(include_str!("../templates/todos.heml")),
|
todos::lower(include_str!("../templates/todos.heml")),
|
||||||
wizard::lower(include_str!("../templates/wizard.heml")),
|
wizard::lower(include_str!("../templates/wizard.heml")),
|
||||||
auth::lower(include_str!("../templates/auth.heml")),
|
auth::lower(include_str!("../templates/auth.heml")),
|
||||||
render_page_swap("Welcome", "Welcome"),
|
render_page_swap("Welcome", "Welcome").into_string(),
|
||||||
notifications::lower(include_str!("../templates/notifications.heml")),
|
notifications::lower(include_str!("../templates/notifications.heml")),
|
||||||
]
|
]
|
||||||
.join("\n")
|
.join("\n"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shell(body: String) -> String {
|
fn shell(body: SafeHtml) -> SafeHtml {
|
||||||
// Explicit full-page shell composition boundary for already-rendered hemplate fragments.
|
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
|
||||||
// req: html_safety/002 req: view/001 req: component/003
|
ui::render(&AppShell { body })
|
||||||
ui::render(&AppShell {
|
|
||||||
body: SafeHtml::trusted(body),
|
|
||||||
})
|
|
||||||
.into_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn todos_view(todos: &[Todo]) -> TodoItems {
|
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
|
// req: html_safety/002 req: view/001 req: component/003
|
||||||
page_swap::render(&PageSwap {
|
page_swap::render(&PageSwap {
|
||||||
content: render_docs_content(message),
|
content: render_docs_content(message),
|
||||||
title,
|
title,
|
||||||
})
|
})
|
||||||
.into_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_docs_content(message: &'static str) -> SafeHtml {
|
fn render_docs_content(message: &'static str) -> SafeHtml {
|
||||||
@@ -240,7 +239,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn shell_is_rendered_by_a_hemplate_view() {
|
fn shell_is_rendered_by_a_hemplate_view() {
|
||||||
let html = shell(render_page_swap("Welcome", "Welcome"));
|
let html = shell(render_page_swap("Welcome", "Welcome"));
|
||||||
let document = Html::parse_document(&html);
|
let document = Html::parse_document(html.as_str());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document
|
document
|
||||||
.select(&selector("title"))
|
.select(&selector("title"))
|
||||||
@@ -281,7 +280,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn docs_page_partial_is_rendered_by_a_hemplate_view() {
|
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 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("main[data-slhx-root=\"docs\"]")).count(), 1);
|
||||||
assert_eq!(document.select(&selector("article[data-sid]")).count(), 1);
|
assert_eq!(document.select(&selector("article[data-sid]")).count(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+12
-1
@@ -5,7 +5,7 @@ use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Respon
|
|||||||
use axum::response::sse::{Event, Sse};
|
use axum::response::sse::{Event, Sse};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use futures_util::{Stream, StreamExt};
|
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::collections::BTreeMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
|
||||||
@@ -61,6 +61,17 @@ impl PageRequest {
|
|||||||
PageMode::Partial => PageResponse::partial(partial_html),
|
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]
|
#[async_trait]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use slhx_axum::{
|
|||||||
InteractionFormRejection, PageMode, PageRequest, PageResponse, SLHX_CONTENT_TYPE,
|
InteractionFormRejection, PageMode, PageRequest, PageResponse, SLHX_CONTENT_TYPE,
|
||||||
SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE, SLHX_TITLE_HEADER,
|
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 {
|
fn selector(value: &str) -> Selector {
|
||||||
Selector::parse(value).expect("test selector parses")
|
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);
|
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("<main data-page=\"docs\">Docs</main>"),
|
||||||
|
|content| {
|
||||||
|
SafeHtml::trusted(format!(
|
||||||
|
"<html><body data-shell=\"docs\">{content}</body></html>"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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("<main data-page=\"docs\">Docs</main>"),
|
||||||
|
|content| {
|
||||||
|
SafeHtml::trusted(format!(
|
||||||
|
"<html><body data-shell=\"docs\">{content}</body></html>"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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]
|
#[test]
|
||||||
fn partial_page_response_sets_partial_and_title_headers() {
|
fn partial_page_response_sets_partial_and_title_headers() {
|
||||||
let response = PageResponse::partial("<main>Docs</main>")
|
let response = PageResponse::partial("<main>Docs</main>")
|
||||||
|
|||||||
Reference in New Issue
Block a user