refactor(support): add signal response helpers for Datastar
- render_signals_json() returns application/json signal patches - render_signals_fragment() returns text/html with data-signals attributes - kebab_to_camel() converts signal names for JSON responses - escape_html_attr() for safe attribute embedding
This commit is contained in:
parent
e39d2bff2e
commit
48afde8448
1 changed files with 69 additions and 0 deletions
|
|
@ -244,6 +244,75 @@ pub fn set_datastar_patch_headers(headers: &mut HeaderMap, selector: &'static st
|
||||||
let _ = headers.insert("datastar-mode", HeaderValue::from_static("replace"));
|
let _ = headers.insert("datastar-mode", HeaderValue::from_static("replace"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a `<div>` fragment with `data-signals` attributes for Datastar signal merging.
|
||||||
|
///
|
||||||
|
/// The selector must be a `#id` selector. Signal values are JSON-encoded and HTML-escaped
|
||||||
|
/// so they are safe to embed in HTML attributes and evaluate as JavaScript expressions.
|
||||||
|
pub fn render_signals_fragment(
|
||||||
|
selector: &'static str,
|
||||||
|
signals: &[(&str, serde_json::Value)],
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
let id = selector.strip_prefix('#').unwrap_or(selector);
|
||||||
|
|
||||||
|
let mut html = format!(r#"<div id="{id}""#);
|
||||||
|
for (name, value) in signals {
|
||||||
|
let js_expr = value.to_string();
|
||||||
|
let escaped = escape_html_attr(&js_expr);
|
||||||
|
// write! to String is infallible
|
||||||
|
let _ = write!(&mut html, r#" data-signals:{name}="{escaped}""#);
|
||||||
|
}
|
||||||
|
html.push_str("></div>");
|
||||||
|
|
||||||
|
let mut response = Html(html).into_response();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(CONTENT_TYPE, HeaderValue::from_static("text/html"));
|
||||||
|
set_datastar_patch_headers(response.headers_mut(), selector);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a JSON response that Datastar interprets as a signal patch.
|
||||||
|
///
|
||||||
|
/// Signal names may use kebab-case (`_roaster-name`); they are automatically
|
||||||
|
/// converted to camelCase (`_roasterName`) to match Datastar's internal store.
|
||||||
|
pub fn render_signals_json(signals: &[(&str, serde_json::Value)]) -> Result<Response, AppError> {
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
for (name, value) in signals {
|
||||||
|
map.insert(kebab_to_camel(name), value.clone());
|
||||||
|
}
|
||||||
|
let body = serde_json::Value::Object(map).to_string();
|
||||||
|
let mut response = body.into_response();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kebab_to_camel(s: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(s.len());
|
||||||
|
let mut cap_next = false;
|
||||||
|
for c in s.chars() {
|
||||||
|
if c == '-' {
|
||||||
|
cap_next = true;
|
||||||
|
} else if cap_next {
|
||||||
|
result.push(c.to_ascii_uppercase());
|
||||||
|
cap_next = false;
|
||||||
|
} else {
|
||||||
|
result.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html_attr(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue