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()
61 .find(&needle.to_ascii_lowercase())
62}
63
64pub 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
77pub 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
93pub 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
103pub 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
120pub 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 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}