Skip to main content

spark/
morph.rs

1//! Island slicing — extract `spark:island="name"` subtrees from rendered HTML so
2//! the server can return only the changed region in `effects.islands`.
3//!
4//! The slicing is byte-accurate but naive: we scan for the opening attribute and
5//! pair it with the matching close tag at the same nesting depth.
6
7/// Find the inner HTML of the named `spark:island="..."` region. Returns
8/// `Some(html_string)` if found, including the whole tag including its outer
9/// attributes (so the JS runtime can morph the wrapper too).
10pub fn slice_island(html: &str, island_name: &str) -> Option<String> {
11    let needle = format!(r#"spark:island="{island_name}""#);
12    let attr_pos = html.find(&needle)?;
13    // Walk backwards to the opening `<` of this tag.
14    let tag_open = html[..attr_pos].rfind('<')?;
15    // Find tag name (e.g. "div") to enable balanced matching.
16    let tag_name_start = tag_open + 1;
17    let after_name = html[tag_name_start..]
18        .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
19        .unwrap_or(0);
20    let tag_name = &html[tag_name_start..tag_name_start + after_name];
21    // Close of the opening tag.
22    let open_close = tag_open + html[tag_open..].find('>')?;
23    let _ = open_close;
24    // Find the matching close `</tag>` at the same depth.
25    let close = find_balanced_close(html, tag_open, tag_name)?;
26    Some(html[tag_open..close].to_string())
27}
28
29fn find_balanced_close(html: &str, start: usize, tag: &str) -> Option<usize> {
30    let mut depth: i32 = 0;
31    let open_marker = format!("<{tag}");
32    let close_marker = format!("</{tag}>");
33    let mut i = start;
34    while i < html.len() {
35        if html[i..].starts_with(&close_marker) {
36            depth -= 1;
37            if depth == 0 {
38                return Some(i + close_marker.len());
39            }
40            i += close_marker.len();
41            continue;
42        }
43        if html[i..].starts_with(&open_marker) {
44            // Find the end of this opening tag (skip self-closing).
45            if let Some(end_rel) = html[i..].find('>') {
46                let self_close = html.as_bytes().get(i + end_rel - 1) == Some(&b'/');
47                if !self_close {
48                    depth += 1;
49                }
50                i += end_rel + 1;
51                continue;
52            }
53            return None;
54        }
55        // Advance by one character.
56        let next = html[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
57        i += next;
58    }
59    None
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn slices_named_island() {
68        let html = r#"<div spark:id="x"><h1>head</h1><div spark:island="messages"><ul><li>hi</li></ul></div><p>footer</p></div>"#;
69        let sliced = slice_island(html, "messages").unwrap();
70        assert!(sliced.contains("<ul><li>hi</li></ul>"));
71        assert!(sliced.starts_with("<div spark:island=\"messages\""));
72        assert!(sliced.ends_with("</div>"));
73    }
74
75    #[test]
76    fn returns_none_when_missing() {
77        let html = "<div spark:id=\"x\"></div>";
78        assert!(slice_island(html, "messages").is_none());
79    }
80
81    #[test]
82    fn handles_nested_same_tag() {
83        let html = r#"<div spark:island="a"><div>inner</div><div>also</div></div>"#;
84        let sliced = slice_island(html, "a").unwrap();
85        assert_eq!(
86            sliced,
87            r#"<div spark:island="a"><div>inner</div><div>also</div></div>"#
88        );
89    }
90}