diff --git a/src/application/routes/support.rs b/src/application/routes/support.rs
index be4d9fe..6805465 100644
--- a/src/application/routes/support.rs
+++ b/src/application/routes/support.rs
@@ -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"));
}
+/// Render a `
` 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
{
+ use std::fmt::Write;
+
+ let id = selector.strip_prefix('#').unwrap_or(selector);
+
+ let mut html = format!(r#"");
+
+ 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 {
+ 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)]
mod tests {
use super::*;