1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum HtmlAnchor {
18 BeforeCloseHead,
20 AfterOpenBody,
22 BeforeCloseBody,
24}
25
26#[derive(Debug, Clone)]
28pub struct HtmlInject<'a> {
29 pub fragment: &'a str,
30 pub anchor: HtmlAnchor,
31 pub marker: Option<&'a str>,
33 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
58pub fn find_ci(hay: &str, needle: &str) -> Option<usize> {
60 hay.to_ascii_lowercase().find(&needle.to_ascii_lowercase())
61}
62
63pub fn inject_before(html: &str, needle: &str, fragment: &str) -> Option<String> {
65 if fragment.is_empty() {
66 return None;
67 }
68 let idx = find_ci(html, needle)?;
69 let mut out = String::with_capacity(html.len() + fragment.len());
70 out.push_str(&html[..idx]);
71 out.push_str(fragment);
72 out.push_str(&html[idx..]);
73 Some(out)
74}
75
76pub fn inject_after_open_tag(html: &str, open_tag: &str, fragment: &str) -> Option<String> {
79 if fragment.is_empty() {
80 return None;
81 }
82 let start = find_ci(html, open_tag)?;
83 let gt = html[start..].find('>')? + start;
84 let idx = gt + 1;
85 let mut out = String::with_capacity(html.len() + fragment.len());
86 out.push_str(&html[..idx]);
87 out.push_str(fragment);
88 out.push_str(&html[idx..]);
89 Some(out)
90}
91
92pub fn replace_once(html: &str, from: &str, to: &str) -> Option<String> {
94 let idx = html.find(from)?;
95 let mut out = String::with_capacity(html.len() - from.len() + to.len());
96 out.push_str(&html[..idx]);
97 out.push_str(to);
98 out.push_str(&html[idx + from.len()..]);
99 Some(out)
100}
101
102pub fn replace_between(
104 html: &str,
105 start_marker: &str,
106 end_marker: &str,
107 replacement: &str,
108) -> Option<String> {
109 let start = html.find(start_marker)? + start_marker.len();
110 let end_rel = html[start..].find(end_marker)?;
111 let end = start + end_rel;
112 let mut out = String::with_capacity(html.len() - (end - start) + replacement.len());
113 out.push_str(&html[..start]);
114 out.push_str(replacement);
115 out.push_str(&html[end..]);
116 Some(out)
117}
118
119pub fn inject(html: &str, opts: &HtmlInject<'_>) -> Option<String> {
121 let frag = opts.fragment;
122 if frag.trim().is_empty() {
123 return None;
124 }
125 if let Some(m) = opts.marker {
126 if html.contains(m) {
127 return None;
128 }
129 }
130 for n in opts.skip_if_contains {
131 if html.contains(n) {
132 return None;
133 }
134 }
135
136 let mut block = String::new();
137 if let Some(m) = opts.marker {
138 block.push_str(m);
139 block.push('\n');
140 }
141 block.push_str(frag);
142
143 match opts.anchor {
144 HtmlAnchor::BeforeCloseHead => inject_before_close_head(html, &block),
145 HtmlAnchor::AfterOpenBody => inject_after_open_tag(html, "<body", &block)
146 .or_else(|| Some(format!("{html}\n{block}\n"))),
147 HtmlAnchor::BeforeCloseBody => {
148 inject_before(html, "</body>", &block).or_else(|| Some(format!("{html}\n{block}\n")))
149 }
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 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 Some(format!(
172 "<!doctype html>\n<html>\n<head>\n{block}</head>\n<body>\n{html}\n</body>\n</html>\n"
173 ))
174}
175
176pub 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
184pub 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}