Skip to main content

sova_core/
html.rs

1//! HTML string helpers for plugins that mutate `text/html` responses.
2//!
3//! # Layering
4//!
5//! 1. **String ops** — [`inject`], [`inject_before`], [`replace_once`],
6//!    [`replace_between`] (markers / anchors, no HTTP).
7//! 2. **Response** — [`crate::Response::map_buffered_html`] skips streams / non-HTML.
8//! 3. **Middleware** — [`crate::middleware::before`] / [`after`] / [`around`] /
9//!    [`map_html`] for request/response hooks around the whole chain.
10//!
11//! Prefer markers (`<!-- plugin -->`) so stacked injects stay idempotent.
12//! Template-slot style edits: put `<!--slot:name-->…<!--/slot:name-->` in the
13//! document and use [`replace_between`].
14
15/// Where to place a fragment inside an HTML document.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum HtmlAnchor {
18    /// Before `</head>` (creates `<head>` after `<html>` if missing).
19    BeforeCloseHead,
20    /// Right after `<body…>`.
21    AfterOpenBody,
22    /// Before `</body>` (appends if missing).
23    BeforeCloseBody,
24}
25
26/// Options for [`inject`].
27#[derive(Debug, Clone)]
28pub struct HtmlInject<'a> {
29    pub fragment: &'a str,
30    pub anchor: HtmlAnchor,
31    /// If this substring is already present, return `None` (idempotent).
32    pub marker: Option<&'a str>,
33    /// Extra skip needles (e.g. `id="sova-devtools"`).
34    pub skip_if_contains: &'a [&'a str],
35}
36
37impl<'a> HtmlInject<'a> {
38    pub fn new(anchor: HtmlAnchor, fragment: &'a str) -> Self {
39        Self {
40            fragment,
41            anchor,
42            marker: None,
43            skip_if_contains: &[],
44        }
45    }
46
47    pub fn marker(mut self, marker: &'a str) -> Self {
48        self.marker = Some(marker);
49        self
50    }
51
52    pub fn skip_if_contains(mut self, needles: &'a [&'a str]) -> Self {
53        self.skip_if_contains = needles;
54        self
55    }
56}
57
58/// Case-insensitive substring search; returns byte index in `hay` (ASCII tags).
59pub fn find_ci(hay: &str, needle: &str) -> Option<usize> {
60    hay.to_ascii_lowercase()
61        .find(&needle.to_ascii_lowercase())
62}
63
64/// Insert `fragment` immediately before the first case-insensitive `needle`.
65pub fn inject_before(html: &str, needle: &str, fragment: &str) -> Option<String> {
66    if fragment.is_empty() {
67        return None;
68    }
69    let idx = find_ci(html, needle)?;
70    let mut out = String::with_capacity(html.len() + fragment.len());
71    out.push_str(&html[..idx]);
72    out.push_str(fragment);
73    out.push_str(&html[idx..]);
74    Some(out)
75}
76
77/// Insert `fragment` immediately after the first case-insensitive open tag `needle`
78/// (e.g. `"<body"` → after the closing `>` of that tag).
79pub fn inject_after_open_tag(html: &str, open_tag: &str, fragment: &str) -> Option<String> {
80    if fragment.is_empty() {
81        return None;
82    }
83    let start = find_ci(html, open_tag)?;
84    let gt = html[start..].find('>')? + start;
85    let idx = gt + 1;
86    let mut out = String::with_capacity(html.len() + fragment.len());
87    out.push_str(&html[..idx]);
88    out.push_str(fragment);
89    out.push_str(&html[idx..]);
90    Some(out)
91}
92
93/// Replace the first occurrence of `from` with `to` (case-sensitive).
94pub fn replace_once(html: &str, from: &str, to: &str) -> Option<String> {
95    let idx = html.find(from)?;
96    let mut out = String::with_capacity(html.len() - from.len() + to.len());
97    out.push_str(&html[..idx]);
98    out.push_str(to);
99    out.push_str(&html[idx + from.len()..]);
100    Some(out)
101}
102
103/// Replace content between `start_marker` and `end_marker` (exclusive markers kept).
104pub fn replace_between(
105    html: &str,
106    start_marker: &str,
107    end_marker: &str,
108    replacement: &str,
109) -> Option<String> {
110    let start = html.find(start_marker)? + start_marker.len();
111    let end_rel = html[start..].find(end_marker)?;
112    let end = start + end_rel;
113    let mut out = String::with_capacity(html.len() - (end - start) + replacement.len());
114    out.push_str(&html[..start]);
115    out.push_str(replacement);
116    out.push_str(&html[end..]);
117    Some(out)
118}
119
120/// Apply [`HtmlInject`]. Returns `None` when skipped or unchanged.
121pub fn inject(html: &str, opts: &HtmlInject<'_>) -> Option<String> {
122    let frag = opts.fragment;
123    if frag.trim().is_empty() {
124        return None;
125    }
126    if let Some(m) = opts.marker {
127        if html.contains(m) {
128            return None;
129        }
130    }
131    for n in opts.skip_if_contains {
132        if html.contains(n) {
133            return None;
134        }
135    }
136
137    let mut block = String::new();
138    if let Some(m) = opts.marker {
139        block.push_str(m);
140        block.push('\n');
141    }
142    block.push_str(frag);
143
144    match opts.anchor {
145        HtmlAnchor::BeforeCloseHead => inject_before_close_head(html, &block),
146        HtmlAnchor::AfterOpenBody => inject_after_open_tag(html, "<body", &block)
147            .or_else(|| Some(format!("{html}\n{block}\n"))),
148        HtmlAnchor::BeforeCloseBody => inject_before(html, "</body>", &block)
149            .or_else(|| Some(format!("{html}\n{block}\n"))),
150    }
151}
152
153fn inject_before_close_head(html: &str, block: &str) -> Option<String> {
154    if let Some(out) = inject_before(html, "</head>", block) {
155        return Some(out);
156    }
157    // After <html…>
158    if let Some(start) = find_ci(html, "<html") {
159        if let Some(gt) = html[start..].find('>') {
160            let idx = start + gt + 1;
161            let mut out = String::with_capacity(html.len() + block.len() + 16);
162            out.push_str(&html[..idx]);
163            out.push_str("\n<head>\n");
164            out.push_str(block);
165            out.push_str("</head>\n");
166            out.push_str(&html[idx..]);
167            return Some(out);
168        }
169    }
170    // Bare fragment
171    Some(format!(
172        "<!doctype html>\n<html>\n<head>\n{block}</head>\n<body>\n{html}\n</body>\n</html>\n"
173    ))
174}
175
176/// Convenience: head inject with marker.
177pub fn inject_head(html: &str, fragment: &str, marker: &str) -> Option<String> {
178    inject(
179        html,
180        &HtmlInject::new(HtmlAnchor::BeforeCloseHead, fragment).marker(marker),
181    )
182}
183
184/// Convenience: before `</body>` with marker.
185pub fn inject_body_end(html: &str, fragment: &str, marker: &str) -> Option<String> {
186    inject(
187        html,
188        &HtmlInject::new(HtmlAnchor::BeforeCloseBody, fragment).marker(marker),
189    )
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn head_and_body() {
198        let html = "<html><head></head><body><p>x</p></body></html>";
199        let h = inject_head(html, "<title>T</title>", "<!-- m -->").unwrap();
200        assert!(h.contains("<title>T</title>"));
201        assert!(h.contains("<!-- m -->"));
202        let b = inject_body_end(&h, "<div id=\"x\"></div>", "<!-- b -->").unwrap();
203        assert!(b.find("id=\"x\"").unwrap() < b.find("</body>").unwrap());
204    }
205
206    #[test]
207    fn replace_between_works() {
208        let html = "a<!--s-->OLD<!--e-->z";
209        let out = replace_between(html, "<!--s-->", "<!--e-->", "NEW").unwrap();
210        assert_eq!(out, "a<!--s-->NEW<!--e-->z");
211    }
212
213    #[test]
214    fn idempotent_marker() {
215        let html = "<html><head><!-- m --></head><body></body></html>";
216        assert!(inject_head(html, "<title>T</title>", "<!-- m -->").is_none());
217    }
218}