1use super::SlimOptions;
2use crate::error::{Error, Result};
3use ego_tree::NodeRef;
4use scraper::{ElementRef, Html, node::Node};
5
6use super::support::{
7 BLOCK_LEVEL_TAGS, REMOVABLE_EMPTY_TAGS, TAGS_TO_REMOVE, VOID_ELEMENTS, filter_and_write_attributes,
8 is_string_effectively_empty, remove_empty_lines, should_keep_meta,
9};
10
11pub fn decode_html_entities(content: &str) -> String {
14 html_escape::decode_html_entities(content).to_string()
15}
16
17pub fn slim(html_content: &str, options: impl Into<SlimOptions>) -> Result<String> {
43 let options = options.into();
44 let html = Html::parse_document(html_content);
45 let mut output = String::new();
46
47 process_node_stack_based(html.tree.root(), false, &options, 0, &mut output)?;
48
49 let content = remove_empty_lines(output)?;
51
52 Ok(content)
53}
54
55fn process_node_stack_based(
57 root_node: NodeRef<Node>,
58 is_in_head_context: bool,
59 options: &SlimOptions,
60 depth: usize,
61 output: &mut String,
62) -> Result<()> {
63 let indent_spaces = options.indent.unwrap_or(0) as usize;
64 let use_tabs = options.indent_with_tabs;
65
66 #[derive(Clone)]
67 enum FrameState {
68 Enter,
69 Exit,
70 }
71
72 struct Frame<'a> {
73 node: NodeRef<'a, Node>,
74 is_in_head_context: bool,
75 depth: usize,
76 state: FrameState,
77 children_output: String,
78 output_target_index: Option<usize>,
82 }
83
84 let mut stack: Vec<Frame> = Vec::new();
85 stack.push(Frame {
86 node: root_node,
87 is_in_head_context,
88 depth,
89 state: FrameState::Enter,
90 children_output: String::new(),
91 output_target_index: None,
92 });
93
94 while let Some(frame) = stack.pop() {
95 match frame.state {
96 FrameState::Enter => {
97 match frame.node.value() {
98 Node::Document => {
99 let children: Vec<_> = frame.node.children().collect();
101 for child in children.into_iter().rev() {
102 stack.push(Frame {
103 node: child,
104 is_in_head_context: false,
105 depth: frame.depth,
106 state: FrameState::Enter,
107 children_output: String::new(),
108 output_target_index: frame.output_target_index,
109 });
110 }
111 }
112 Node::Doctype(doctype) => {
113 let mut s = String::new();
115 s.push_str("<!DOCTYPE ");
116 s.push_str(&doctype.name);
117 let has_public = !doctype.public_id.is_empty();
118 let has_system = !doctype.system_id.is_empty();
119
120 if has_public {
121 s.push_str(" PUBLIC \"");
122 s.push_str(&doctype.public_id);
123 s.push('"');
124 }
125
126 if has_system {
127 if !has_public {
128 s.push_str(" SYSTEM");
129 }
130 s.push(' ');
131 s.push('"');
132 s.push_str(&doctype.system_id);
133 s.push('"');
134 }
135 s.push('>');
136
137 if indent_spaces > 0 {
138 s.push('\n');
139 }
140
141 match frame.output_target_index {
143 Some(idx) => {
144 stack
145 .get_mut(idx)
146 .expect("target frame should exist")
147 .children_output
148 .push_str(&s);
149 }
150 None => {
151 output.push_str(&s);
152 }
153 }
154 }
155 Node::Comment(_) => { }
156 Node::Text(text) => {
157 let text_content = text.trim();
158 if !text_content.is_empty() {
159 let s = text.to_string();
160 match frame.output_target_index {
161 Some(idx) => {
162 stack
163 .get_mut(idx)
164 .expect("target frame should exist")
165 .children_output
166 .push_str(&s);
167 }
168 None => {
169 output.push_str(&s);
170 }
171 }
172 }
173 }
174 Node::Element(element) => {
175 let tag_name = element.name();
176
177 if tag_name == "html" {
179 let child_context_is_in_head = frame.is_in_head_context;
180 let mut children: Vec<_> = frame.node.children().collect();
181 children.reverse();
182 for child in children {
183 stack.push(Frame {
184 node: child,
185 is_in_head_context: child_context_is_in_head,
186 depth: frame.depth,
187 state: FrameState::Enter,
188 children_output: String::new(),
189 output_target_index: frame.output_target_index,
190 });
191 }
192 continue;
193 }
194
195 let el_ref = ElementRef::wrap(frame.node)
196 .ok_or_else(|| Error::custom("Failed to wrap node as ElementRef"))?;
197
198 let current_node_is_head = tag_name == "head";
199 let child_context_is_in_head = frame.is_in_head_context || current_node_is_head;
200
201 let should_skip = match tag_name {
204 _ if !child_context_is_in_head && TAGS_TO_REMOVE.contains(&tag_name) => true,
205 "script" | "style" | "link" | "base" | "svg" => true,
206 _ if frame.is_in_head_context => {
207 !(tag_name == "title" || (tag_name == "meta" && should_keep_meta(el_ref)))
208 }
209 _ => false,
210 };
211
212 if should_skip {
213 continue;
214 }
215
216 let exit_idx = stack.len();
218 stack.push(Frame {
219 node: frame.node,
220 is_in_head_context: frame.is_in_head_context,
221 depth: frame.depth,
222 state: FrameState::Exit,
223 children_output: String::new(),
224 output_target_index: frame.output_target_index,
225 });
226
227 let is_formatting = indent_spaces > 0 || use_tabs;
229 let is_block = if is_formatting {
230 BLOCK_LEVEL_TAGS.contains(&tag_name) || tag_name == "title"
231 } else {
232 false
233 };
234 let child_depth = if is_block { frame.depth + 1 } else { frame.depth };
235
236 let mut children: Vec<_> = frame.node.children().collect();
237 children.reverse();
238 for child in children {
239 stack.push(Frame {
240 node: child,
241 is_in_head_context: child_context_is_in_head,
242 depth: child_depth,
243 state: FrameState::Enter,
244 children_output: String::new(),
245 output_target_index: Some(exit_idx),
246 });
247 }
248 }
249 Node::Fragment => {
250 let children: Vec<_> = frame.node.children().collect();
251 for child in children.into_iter().rev() {
252 stack.push(Frame {
253 node: child,
254 is_in_head_context: false,
255 depth: frame.depth,
256 state: FrameState::Enter,
257 children_output: String::new(),
258 output_target_index: frame.output_target_index,
259 });
260 }
261 }
262 Node::ProcessingInstruction(_) => { }
263 }
264 }
265 FrameState::Exit => {
266 let el_ref =
267 ElementRef::wrap(frame.node).ok_or_else(|| Error::custom("Failed to wrap node as ElementRef"))?;
268 let tag_name = el_ref.value().name();
269
270 let is_formatting = indent_spaces > 0 || use_tabs;
271 let is_block = if is_formatting {
272 BLOCK_LEVEL_TAGS.contains(&tag_name) || tag_name == "title"
273 } else {
274 false
275 };
276 let is_void = is_formatting && VOID_ELEMENTS.contains(&tag_name);
277
278 let is_empty_after_processing = is_string_effectively_empty(&frame.children_output);
279 let is_in_head_for_removal = frame.is_in_head_context || tag_name == "head";
280 let is_removable_tag_when_empty = !is_in_head_for_removal && REMOVABLE_EMPTY_TAGS.contains(&tag_name);
281 let is_empty_head_tag = tag_name == "head" && is_empty_after_processing;
282 let should_remove = (is_removable_tag_when_empty && is_empty_after_processing) || is_empty_head_tag;
283
284 if should_remove {
285 continue;
286 }
287
288 let mut out = String::new();
289
290 if is_block {
292 out.push('\n');
293 let indent_str = if use_tabs {
294 "\t".repeat(frame.depth)
295 } else {
296 " ".repeat(frame.depth * indent_spaces)
297 };
298 out.push_str(&indent_str);
299 }
300
301 out.push('<');
303 out.push_str(tag_name);
304 let is_in_head_for_attrs = frame.is_in_head_context || tag_name == "head";
306 filter_and_write_attributes(el_ref, is_in_head_for_attrs, &mut out)?;
307 out.push('>');
308
309 out.push_str(&frame.children_output);
311
312 if is_block && !is_void && frame.children_output.contains('\n') {
314 out.push('\n');
315 let indent_str = if use_tabs {
316 "\t".repeat(frame.depth)
317 } else {
318 " ".repeat(frame.depth * indent_spaces)
319 };
320 out.push_str(&indent_str);
321 }
322
323 if !is_void {
325 out.push_str("</");
326 out.push_str(tag_name);
327 out.push('>');
328 }
329
330 match frame.output_target_index {
332 Some(idx) => {
333 stack
334 .get_mut(idx)
335 .expect("target frame should exist")
336 .children_output
337 .push_str(&out);
338 }
339 None => {
340 output.push_str(&out);
341 }
342 }
343 }
344 }
345 }
346
347 Ok(())
348}
349
350#[cfg(test)]
353mod tests {
354 use super::*;
355 type TestResult<T> = core::result::Result<T, Box<dyn std::error::Error>>;
357
358 #[test]
362 fn test_slimmer2_slim_basic() -> TestResult<()> {
363 let fx_html = r#"
365<!DOCTYPE html>
366<html lang="en">
367<head>
368 <meta charset="UTF-8">
369 <meta name="viewport" content="width=device-width, initial-scale=1.0">
370 <meta property="og:title" content="Test Title">
371 <meta property="og:url" content="http://example.com">
372 <meta property="og:image" content="http://example.com/img.png">
373 <meta property="og:description" content="Test Description">
374 <meta name="keywords" content="test, html"> <!-- Should be removed -->
375 <title>Simple HTML Page</title>
376 <style> body{ color: red } </style>
377 <link rel="stylesheet" href="style.css">
378 <script> console.log("hi"); </script>
379 <base href="/"> <!-- Should be removed -->
380</head>
381<body class="main-body" aria-label="Page body">
382 <svg><path d="M0 0 L 10 10"></path></svg> <!-- Should be removed -->
383 <div>
384 <span></span> <!-- Should be removed (effectively empty after processing) -->
385 <p> <!-- Effectively empty after processing --> </p>
386 <b> </b> <!-- Effectively empty after processing -->
387 <i><!-- comment --></i> <!-- Effectively empty after processing -->
388 </div> <!-- Should be removed (effectively empty after children removed) -->
389 <section>Content Inside</section> <!-- Should be kept -->
390 <article> </article> <!-- Should be removed (empty after processing) -->
391 <h1 funky-attribute="removeme">Hello, World!</h1> <!-- funky-attribute removed -->
392 <p>This is a simple HTML page.</p>
393 <a href="https://example.org" class="link-style" extra="gone">Link</a> <!-- href and class kept -->
394 <!-- Some Comment -->
395</body>
396</html>
397 "#;
398
399 let expected_body_content = r#"<body aria-label="Page body" class="main-body"><section>Content Inside</section><h1>Hello, World!</h1><p>This is a simple HTML page.</p><a class="link-style" href="https://example.org">Link</a></body>"#;
402 let html = slim(fx_html, SlimOptions::default())?;
406 assert!(html.contains("<head>"));
414 assert!(html.contains("</head>"));
415 assert!(html.contains(r#"<meta content="Test Title" property="og:title">"#));
416 assert!(html.contains(r#"<meta content="http://example.com" property="og:url">"#));
417 assert!(html.contains(r#"<meta content="http://example.com/img.png" property="og:image">"#));
418 assert!(html.contains(r#"<meta content="Test Description" property="og:description">"#));
419 assert!(html.contains(r#"<title>Simple HTML Page</title>"#));
420
421 assert!(
422 !html.contains("<meta charset") && !html.contains("<meta name"),
423 "Should remove disallowed meta tags"
424 );
425 assert!(
426 !html.contains("<style") && !html.contains("<link") && !html.contains("<script") && !html.contains("<base"),
427 "Should remove style, link, script, base"
428 );
429
430 assert!(
433 html.contains("<body")
434 && html.contains(r#"class="main-body""#)
435 && html.contains(r#"aria-label="Page body""#)
436 && html.contains(">")
437 );
438 assert!(html.contains(r#"</body>"#));
439 assert!(html.contains(expected_body_content)); assert!(!html.contains("<svg>"), "Should remove svg");
443 assert!(!html.contains("<span>"), "Should remove empty span");
444 assert!(!html.contains("<p> </p>"), "Should remove empty p tag");
445 assert!(!html.contains("<b>"), "Should remove empty b");
446 assert!(!html.contains("<i>"), "Should remove empty i");
447 assert!(!html.contains("<div>"), "Should remove outer empty div");
448 assert!(!html.contains("<article>"), "Should remove empty article");
449 assert!(!html.contains("funky-attribute"), "Should remove funky-attribute");
450 assert!(!html.contains("extra=\"gone\""), "Should remove extra anchor attribute");
451 assert!(!html.contains("<!--"), "Should remove comments");
452
453 Ok(())
454 }
455
456 #[test]
457 fn test_slimmer2_slim_empty_head_removed() -> TestResult<()> {
458 let fx_html = r#"
460 <!DOCTYPE html>
461 <html>
462 <head>
463 <meta charset="utf-8">
464 <link rel="icon" href="favicon.ico">
465 </head>
466 <body>
467 <p>Content</p>
468 </body>
469 </html>
470 "#;
471
472 let html = slim(fx_html, SlimOptions::default())?;
474 assert!(
479 !html.contains("<head>"),
480 "Empty <head> tag should be removed after processing. Got: {}",
481 html
482 );
483 assert!(html.contains("<body><p>Content</p></body>"), "Body should remain");
484
485 Ok(())
486 }
487
488 #[test]
489 fn test_slimmer2_slim_keeps_head_if_title_present() -> TestResult<()> {
490 let fx_html = r#"
492 <!DOCTYPE html>
493 <html>
494 <head>
495 <title>Only Title</title>
496 <script></script>
497 </head>
498 <body>
499 <p>Content</p>
500 </body>
501 </html>
502 "#;
503
504 let html = slim(fx_html, SlimOptions::default())?;
506 assert!(
511 html.contains("<head><title>Only Title</title></head>"),
512 "<head> with only title should remain"
513 );
514 assert!(!html.contains("<script>"), "Script should be removed");
515 assert!(html.contains("<body><p>Content</p></body>"), "Body should remain");
516
517 Ok(())
518 }
519
520 #[test]
521 fn test_slimmer2_slim_nested_empty_removal() -> TestResult<()> {
522 let fx_html = r#"
524 <!DOCTYPE html>
525 <html>
526 <body>
527 <div> <!-- Will become empty after children removed -->
528 <p> </p> <!-- empty p -->
529 <div> <!-- Inner div, will become empty -->
530 <span><!-- comment --></span> <!-- empty span -->
531 </div>
532 </div>
533 <section>
534 <h1>Title</h1> <!-- Keep H1 -->
535 <div> </div> <!-- Remove empty div -->
536 </section>
537 </body>
538 </html>
539 "#;
540 let expected_body = r#"<body><section><h1>Title</h1></section></body>"#;
543
544 let html = slim(fx_html, SlimOptions::default())?;
546 assert!(
550 html.contains(expected_body),
551 "Should remove nested empty elements correctly after processing. Expected: '{}', Got: '{}'",
552 expected_body,
553 html
554 );
555 assert!(!html.contains("<p>"), "Empty <p> should be removed");
556 assert!(!html.contains("<span>"), "Empty <span> should be removed");
557 assert!(
558 !html.contains("<div>"),
559 "All empty <div> tags should be removed (inner and outer)"
560 );
561 assert!(html.contains("<section>"), "Section should remain");
562 assert!(html.contains("<h1>"), "H1 should remain");
563
564 Ok(())
565 }
566
567 #[test]
568 fn test_slimmer2_slim_keep_empty_but_not_removable() -> TestResult<()> {
569 let fx_html = r#"
571 <!DOCTYPE html>
572 <html>
573 <body>
574 <main></main> <!-- Should keep 'main' even if empty -->
575 <table><tr><td></td></tr></table> <!-- Should keep table structure even if cells empty -->
576 </body>
577 </html>
578 "#;
579 let expected_body_fragment1 = "<main></main>";
580
581 let html = slim(fx_html, SlimOptions::default())?;
583
584 assert!(html.contains(expected_body_fragment1), "Should keep empty <main>");
586 assert!(
588 html.contains("<table>") && html.contains("<tr>") && html.contains("<td>") && html.contains("</table>"),
589 "Should keep empty table structure. Got: {}",
590 html
591 );
592
593 Ok(())
594 }
595}
596
597