1#[allow(dead_code)]
23pub use ssg_a11y::find_tag_end;
36
37pub fn hreflang_attr(tag: &str, name: &str) -> Option<String> {
61 let bytes = tag.as_bytes();
62 let mut i = usize::from(bytes.first() == Some(&b'<'));
63 while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>'
65 {
66 i += 1;
67 }
68 while i < bytes.len() {
69 while i < bytes.len()
71 && (bytes[i].is_ascii_whitespace() || bytes[i] == b'/')
72 {
73 i += 1;
74 }
75 if i >= bytes.len() || bytes[i] == b'>' {
76 return None;
77 }
78 let name_start = i;
80 while i < bytes.len()
81 && !bytes[i].is_ascii_whitespace()
82 && bytes[i] != b'='
83 && bytes[i] != b'>'
84 && bytes[i] != b'/'
85 {
86 i += 1;
87 }
88 let attr_name = &tag[name_start..i];
89 let mut j = i;
91 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
92 j += 1;
93 }
94 if j >= bytes.len() || bytes[j] != b'=' {
95 if attr_name.eq_ignore_ascii_case(name) {
97 return Some(String::new());
98 }
99 continue;
100 }
101 let mut k = j + 1;
103 while k < bytes.len() && bytes[k].is_ascii_whitespace() {
104 k += 1;
105 }
106 let (value_start, value_end, resume) =
107 if k < bytes.len() && (bytes[k] == b'"' || bytes[k] == b'\'') {
108 let quote = bytes[k] as char;
109 let vstart = k + 1;
110 let close = vstart + tag[vstart..].find(quote)?;
112 (vstart, close, close + 1)
113 } else {
114 let mut vend = k;
115 while vend < bytes.len()
116 && !bytes[vend].is_ascii_whitespace()
117 && bytes[vend] != b'>'
118 {
119 vend += 1;
120 }
121 (k, vend, vend)
122 };
123 if attr_name.eq_ignore_ascii_case(name) {
124 return Some(tag[value_start..value_end].to_string());
125 }
126 i = resume;
127 }
128 None
129}
130
131#[must_use]
149pub fn strip_script_and_style(html: &str) -> String {
150 let mut out = html.as_bytes().to_vec();
151 let lower = html.to_ascii_lowercase();
152 for (open, close) in &[("<script", "</script"), ("<style", "</style")] {
153 let mut cursor = 0;
154 while let Some(rel) = lower[cursor..].find(open) {
155 let abs = cursor + rel;
156 let content_start = find_tag_end(&lower, abs);
157 let content_end = lower[content_start..]
158 .find(close)
159 .map_or(lower.len(), |e| content_start + e);
160 for b in &mut out[content_start..content_end] {
161 if !b.is_ascii_whitespace() {
162 *b = b' ';
163 }
164 }
165 cursor = content_end.max(abs + open.len());
166 }
167 }
168 String::from_utf8_lossy(&out).into_owned()
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn find_tag_end_handles_quoted_gt() {
180 let h = "<img src=\"data:>foo\" alt=\"x\">rest";
181 let end = find_tag_end(h, 0);
182 assert_eq!(&h[..end], "<img src=\"data:>foo\" alt=\"x\">");
183 }
184
185 #[test]
186 fn hreflang_attr_handles_quotes() {
187 let tag = r#"<link rel='alternate' hreflang="en" href=/en/>"#;
188 assert_eq!(hreflang_attr(tag, "hreflang"), Some("en".to_string()));
189 assert_eq!(hreflang_attr(tag, "href"), Some("/en/".to_string()));
190 }
191
192 #[test]
193 fn hreflang_attr_missing_returns_none() {
194 assert_eq!(hreflang_attr("<a>", "href"), None);
195 }
196
197 #[test]
198 fn find_tag_end_returns_len_when_unterminated() {
199 let h = "<img src=\"x\" alt=\"y\"";
200 assert_eq!(find_tag_end(h, 0), h.len());
201 }
202
203 #[test]
204 fn find_tag_end_skips_gt_inside_single_quotes() {
205 let h = "<a href='data:>foo'>rest";
206 let end = find_tag_end(h, 0);
207 assert_eq!(&h[..end], "<a href='data:>foo'>");
208 }
209
210 #[test]
211 fn find_tag_end_simple_unquoted() {
212 let h = "<br>x";
213 assert_eq!(find_tag_end(h, 0), 4);
214 }
215
216 #[test]
217 fn hreflang_attr_handles_single_quotes() {
218 let tag = r#"<a href='single-quoted'>"#;
219 assert_eq!(
220 hreflang_attr(tag, "href"),
221 Some("single-quoted".to_string())
222 );
223 }
224
225 #[test]
226 fn hreflang_attr_unquoted_value_terminated_by_space() {
227 let tag = "<a href=/path other=1>";
228 assert_eq!(hreflang_attr(tag, "href"), Some("/path".to_string()));
229 }
230
231 #[test]
232 fn hreflang_attr_unquoted_value_terminated_by_gt() {
233 let tag = "<a href=/end>";
234 assert_eq!(hreflang_attr(tag, "href"), Some("/end".to_string()));
235 }
236
237 #[test]
238 fn hreflang_attr_case_insensitive_match() {
239 let tag = r#"<A HREF="upper">"#;
240 assert_eq!(hreflang_attr(tag, "href"), Some("upper".to_string()));
241 }
242
243 #[test]
244 fn hreflang_attr_unterminated_double_quote_returns_none() {
245 let tag = r#"<a href="never-closed"#;
246 assert_eq!(hreflang_attr(tag, "href"), None);
247 }
248
249 #[test]
250 fn hreflang_attr_unterminated_single_quote_returns_none() {
251 let tag = r#"<a href='never-closed"#;
252 assert_eq!(hreflang_attr(tag, "href"), None);
253 }
254
255 #[test]
256 fn hreflang_attr_valueless_attribute_returns_empty_value() {
257 let tag = "<img alt height=33 role=presentation width=100>";
259 assert_eq!(hreflang_attr(tag, "alt"), Some(String::new()));
260 assert_eq!(hreflang_attr(tag, "height"), Some("33".to_string()));
261 assert_eq!(hreflang_attr(tag, "width"), Some("100".to_string()));
262 }
263
264 #[test]
265 fn hreflang_attr_rejects_substring_attribute_names() {
266 let tag = r#"<a data-href="/wrong" hreflang="en-GB">"#;
269 assert_eq!(hreflang_attr(tag, "href"), None);
270 assert_eq!(hreflang_attr(tag, "lang"), None);
271 assert_eq!(hreflang_attr(tag, "hreflang"), Some("en-GB".to_string()));
272 }
273
274 #[test]
275 fn hreflang_attr_unquoted_mixed_case_value_preserved() {
276 let tag = "<meta http-equiv=Content-Security-Policy content=x>";
278 assert_eq!(
279 hreflang_attr(tag, "HTTP-EQUIV"),
280 Some("Content-Security-Policy".to_string())
281 );
282 }
283
284 #[test]
285 fn hreflang_attr_tolerates_whitespace_around_equals() {
286 let tag = r#"<a href = "/spaced">"#;
287 assert_eq!(hreflang_attr(tag, "href"), Some("/spaced".to_string()));
288 }
289
290 #[test]
291 fn hreflang_attr_ignores_tag_name_prefix() {
292 assert_eq!(hreflang_attr("<content x=1>", "content"), None);
294 }
295
296 #[test]
297 fn hreflang_attr_unterminated_tag_after_last_attr_returns_none() {
298 assert_eq!(hreflang_attr("<a foo=bar", "href"), None);
301 assert_eq!(hreflang_attr(r#"<a foo="bar""#, "href"), None);
302 }
303
304 #[test]
305 fn strip_script_and_style_blanks_embedded_markup() {
306 let html = "<script>var h='<a href=\"/ghost\">x</a>';</script>\
307 <style>a[href=\"/styled\"]{}</style>\
308 <a href=\"/real\">r</a>";
309 let s = strip_script_and_style(html);
310 assert_eq!(s.len(), html.len(), "offsets must be preserved");
311 assert!(!s.contains("/ghost"));
312 assert!(!s.contains("/styled"));
313 assert!(s.contains("/real"));
314 }
315
316 #[test]
317 fn strip_script_and_style_preserves_newlines() {
318 let html = "<script>\nline1\nline2\n</script>\n<p>after</p>";
319 let s = strip_script_and_style(html);
320 assert_eq!(
321 s.matches('\n').count(),
322 html.matches('\n').count(),
323 "newlines inside stripped regions must survive"
324 );
325 assert!(s.contains("<p>after</p>"));
326 }
327
328 #[test]
329 fn strip_script_and_style_unclosed_script_blanks_to_eof() {
330 let html = "<p>keep</p><script>var x='<a href=\"/js\">';";
331 let s = strip_script_and_style(html);
332 assert!(s.contains("keep"));
333 assert!(!s.contains("/js"));
334 }
335
336 #[test]
337 fn strip_script_and_style_handles_multibyte_content() {
338 let html = "<script>var s='héllo — “quoted”';</script><p>café</p>";
339 let s = strip_script_and_style(html);
340 assert!(s.contains("café"));
341 assert!(!s.contains("héllo"));
342 }
343}