use html5ever::{ Attribute, ParseOpts, QualName, parse_document, tendril::{Tendril, TendrilSink, fmt::UTF8},};use markup5ever_rcdom::{Node, NodeData, RcDom};use std::{cell::RefCell, io, rc::Rc, str}; #[derive(Default)]pub(crate) struct MinifierOptions { pub omit_doctype: bool, pub preserve_comments: bool, pub collapse_whitespace: bool,} pub(crate) struct Minifier<'a, W: io::Write> { w: &'a mut W, options: MinifierOptions, preceding_whitespace: bool,} impl<'a, W> Minifier<'a, W>where W: io::Write,{ /// Creates a new `Minifier` instance. #[inline] pub fn new(w: &'a mut W, options: MinifierOptions) -> Self { Self { w, options, preceding_whitespace: false, } } /// Minifies the given reader input. /// /// # Errors /// /// Will return `Err` if unable to write to the output writer. #[inline] pub fn minify<R: io::Read>(&mut self, mut r: &mut R) -> io::Result<()> { let dom = parse_document(RcDom::default(), ParseOpts::default()) .from_utf8() .read_from(&mut r)?; if !self.options.omit_doctype { self.w.write_all(b"<!doctype html>")?; } self.minify_node(&None, &dom.document) } fn minify_node<'b>(&mut self, ctx: &'b Option<Context>, node: &'b Node) -> io::Result<()> { match &node.data { NodeData::Text { contents } => { // Check if whitespace collapsing disabled let contents = contents.borrow(); let contents = contents.as_ref(); if !self.options.collapse_whitespace { return self.w.write_all(contents.as_bytes()); } // Check if parent is whitespace preserving element or contains code (<script>, <style>) let (skip_collapse_whitespace, contains_code) = ctx.as_ref().map_or((false, false), |ctx| { if let NodeData::Element { name, .. } = &ctx.parent.data { let name = name.local.as_ref(); (preserve_whitespace(name), contains_code(name)) } else { (false, false) } }); if skip_collapse_whitespace { return self.w.write_all(contents.as_bytes()); } if contains_code { return self .w .write_all(contents.trim_matches(is_ascii_whitespace).as_bytes()); } // Early exit if empty to forego expensive trim logic if contents.is_empty() { return io::Result::Ok(()); } let (trim_left, trim_right) = ctx .as_ref() .map_or((true, true), |ctx| ctx.trim(self.preceding_whitespace)); let contents = match (trim_left, trim_right) { (true, true) => contents.trim_matches(is_ascii_whitespace), (true, false) => contents.trim_start_matches(is_ascii_whitespace), (false, true) => contents.trim_end_matches(is_ascii_whitespace), _ => contents, }; // Second empty check after trimming whitespace if !contents.is_empty() { // replace \n, \r to ' ' let contents = contents .bytes() .map(|c| if matches!(c, b'\n' | b'\r') { b' ' } else { c }) .collect::<Vec<u8>>(); self.write_collapse_whitespace(&contents, reserved_entity, None)?; self.preceding_whitespace = !trim_right && contents .iter() .last() .map_or(false, u8::is_ascii_whitespace); } Ok(()) } NodeData::Comment { contents } if self.options.preserve_comments => { self.w.write_all(b"<!--")?; self.w.write_all(contents.as_bytes())?; self.w.write_all(b"-->") } NodeData::Document => self.minify_children(ctx, node), NodeData::Element { name, attrs, .. } => { let attrs = attrs.borrow(); let tag = name.local.as_ref(); if is_self_closing(tag) { return self.write_start_tag(name, &attrs); } let (omit_start_tag, omit_end_tag) = self.omit_tags(ctx, node, tag, attrs.is_empty()); if !omit_start_tag { self.write_start_tag(name, &attrs)?; } self.minify_children(ctx, node)?; if !omit_end_tag { self.write_end_tag(name)?; } Ok(()) } _ => Ok(()), } } fn next_is_comment<'b, I>(&self, v: I) -> bool where I: IntoIterator<Item = &'b Rc<Node>>, { v.into_iter() .find_map(|node| match &node.data { NodeData::Text { contents } => { if self.options.collapse_whitespace && is_whitespace(contents) { // Blocks of whitespace are skipped None } else { Some(false) } } NodeData::Comment { .. } => Some(self.options.preserve_comments), _ => Some(false), }) .unwrap_or(false) } fn is_whitespace(&self, s: &RefCell<Tendril<UTF8>>) -> Option<bool> { if self.options.collapse_whitespace && is_whitespace(s) { None } else { Some( !s.borrow() .as_bytes() .iter() .next() .map_or(false, u8::is_ascii_whitespace), ) } } /// Determines if start and end tags can be omitted. /// Whitespace rules are ignored if `collapse_whitespace` is enabled. #[allow(clippy::too_many_lines)] fn omit_tags( &self, ctx: &Option<Context>, node: &Node, name: &str, empty_attributes: bool, ) -> (bool, bool) { ctx.as_ref().map_or((false, false), |ctx| match name { "html" => { // The end tag may be omitted if the <html> element is not immediately followed by a comment. let omit_end = ctx.right.map_or(true, |right| !self.next_is_comment(right)); // The start tag may be omitted if the first thing inside the <html> element is not a comment. let omit_start = empty_attributes && omit_end && !self.next_is_comment(&*node.children.borrow()); (omit_start, omit_end) } "head" => { // The end tag may be omitted if the first thing following the <head> element is not a space character or a comment. let omit_end = ctx.right.map_or(true, |right| { right .iter() .find_map(|node| match &node.data { NodeData::Text { contents } => self.is_whitespace(contents), NodeData::Comment { .. } => { if self.options.preserve_comments { Some(false) } else { None } } _ => Some(true), }) .unwrap_or(true) }); // The start tag may be omitted if the first thing inside the <head> element is an element. let omit_start = empty_attributes && omit_end && node .children .borrow() .iter() .find_map(|node| match &node.data { NodeData::Text { contents } => self.is_whitespace(contents), NodeData::Element { .. } => Some(true), NodeData::Comment { .. } => { if self.options.preserve_comments { Some(false) } else { None } } _ => Some(false), }) .unwrap_or(true); (omit_start, omit_end) } "body" => { // The start tag may be omitted if the first thing inside it is not a space character, comment, <script> element or <style> element. let omit_start = empty_attributes && node .children .borrow() .iter() .find_map(|node| match &node.data { NodeData::Text { contents } => self.is_whitespace(contents), NodeData::Element { name, .. } => { Some(!matches!(name.local.as_ref(), "script" | "style")) } NodeData::Comment { .. } => { if self.options.preserve_comments { Some(false) } else { None } } _ => Some(true), }) .unwrap_or(true); // The end tag may be omitted if the <body> element has contents or has a start tag, and is not immediately followed by a comment. let omit_end = ctx.right.map_or(true, |right| !self.next_is_comment(right)); (omit_start && omit_end, omit_end) } "p" => { let omit_end = ctx.next_element().map_or(true, |node| { if let NodeData::Element { name, .. } = &node.data { matches!( name.local.as_ref().to_ascii_lowercase().as_str(), "address" | "article" | "aside" | "blockquote" | "div" | "dl" | "fieldset" | "footer" | "form" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "header" | "hr" | "menu" | "nav" | "ol" | "p" | "pre" | "section" | "table" | "ul" ) } else { false } }); (false, omit_end) } // TODO: comprehensive handling of optional end element rules _ => (false, optional_end_tag(name)), }) } #[allow(clippy::needless_pass_by_value)] fn minify_children(&mut self, ctx: &Option<Context>, node: &Node) -> io::Result<()> { let children = node.children.borrow(); let l = children.len(); children.iter().enumerate().try_for_each(|(i, child)| { if self.preceding_whitespace && is_block_element(child) { self.preceding_whitespace = false; } self.minify_node( &Some(Context { parent: node, parent_context: ctx.as_ref(), left: if i > 0 { Some(&children[..i]) } else { None }, right: if i + 1 < l { Some(&children[i + 1..]) } else { None }, }), child, ) }) } fn write_qualified_name(&mut self, name: &QualName) -> io::Result<()> { if let Some(prefix) = &name.prefix { self.w .write_all(prefix.as_ref().to_ascii_lowercase().as_bytes())?; self.w.write_all(b":")?; } self.w .write_all(name.local.as_ref().to_ascii_lowercase().as_bytes()) } fn write_start_tag(&mut self, name: &QualName, attrs: &[Attribute]) -> io::Result<()> { self.w.write_all(b"<")?; self.write_qualified_name(name)?; attrs .iter() .try_for_each(|attr| self.write_attribute(attr))?; self.w.write_all(b">") } fn write_end_tag(&mut self, name: &QualName) -> io::Result<()> { self.w.write_all(b"</")?; self.write_qualified_name(name)?; self.w.write_all(b">") } fn write_attribute(&mut self, attr: &Attribute) -> io::Result<()> { self.w.write_all(b" ")?; self.write_qualified_name(&attr.name)?; let value = attr.value.as_ref(); let value = if self.options.collapse_whitespace { value.trim_matches(is_ascii_whitespace) } else { value }; if value.is_empty() { return io::Result::Ok(()); } self.w.write_all(b"=")?; let b = value.as_bytes(); let (unquoted, double, _) = b.iter() .fold((true, false, false), |(unquoted, double, single), &c| { let (double, single) = (double || c == b'"', single || c == b'\''); let unquoted = unquoted && !double && !single && c != b'=' && !c.is_ascii_whitespace(); (unquoted, double, single) }); if unquoted { self.w.write_all(b) } else if double { self.write_attribute_value(b, b"'", reserved_entity_with_apos) } else { self.write_attribute_value(b, b"\"", reserved_entity) } } fn write_attribute_value<T: AsRef<[u8]>>( &mut self, v: T, quote: &[u8], f: EntityFn, ) -> io::Result<()> { self.w.write_all(quote)?; let b = v.as_ref(); if self.options.collapse_whitespace { self.write_collapse_whitespace(b, f, Some(false)) } else { self.w.write_all(b) }?; self.w.write_all(quote) } /// Efficiently writes blocks of content, e.g. a string with no collapsed /// whitespace would result in a single write. fn write_collapse_whitespace( &mut self, b: &[u8], f: EntityFn, preceding_whitespace: Option<bool>, ) -> io::Result<()> { b.iter() .enumerate() .try_fold( (0, preceding_whitespace.unwrap_or(self.preceding_whitespace)), |(pos, preceding_whitespace), (i, &c)| { let is_whitespace = c.is_ascii_whitespace(); Ok(if is_whitespace && preceding_whitespace { if i != pos { self.write(&b[pos..i], f)?; } // ASCII whitespace = 1 byte (i + 1, true) } else { (pos, is_whitespace) }) }, ) .and_then(|(pos, _)| { if pos < b.len() { self.write(&b[pos..], f)?; } Ok(()) }) } fn write(&mut self, b: &[u8], f: EntityFn) -> io::Result<()> { b.iter() .enumerate() .try_fold(0, |pos, (i, &c)| { Ok(if let Some(entity) = f(c) { self.w.write_all(&b[pos..i])?; self.w.write_all(entity)?; // Reserved characters are 1 byte i + 1 } else { pos }) }) .and_then(|pos| { if pos < b.len() { self.w.write_all(&b[pos..])?; } Ok(()) }) }} struct Context<'a> { parent: &'a Node, parent_context: Option<&'a Context<'a>>, left: Option<&'a [Rc<Node>]>, right: Option<&'a [Rc<Node>]>,} impl<'a> Context<'a> { /// Determine whether to trim whitespace.Lines 1–500 of 829. Load more
use html5ever::{ Attribute, ParseOpts, QualName, parse_document, tendril::{Tendril, TendrilSink, fmt::UTF8}, }; use markup5ever_rcdom::{Node, NodeData, RcDom}; use std::{cell::RefCell, io, rc::Rc, str}; #[derive(Default)] pub(crate) struct MinifierOptions { pub omit_doctype: bool, pub preserve_comments: bool, pub collapse_whitespace: bool, } pub(crate) struct Minifier<'a, W: io::Write> { w: &'a mut W, options: MinifierOptions, preceding_whitespace: bool, } impl<'a, W> Minifier<'a, W> where W: io::Write, { /// Creates a new `Minifier` instance. #[inline] pub fn new(w: &'a mut W, options: MinifierOptions) -> Self { Self { w, options, preceding_whitespace: false, } } /// Minifies the given reader input. /// /// # Errors /// /// Will return `Err` if unable to write to the output writer. #[inline] pub fn minify<R: io::Read>(&mut self, mut r: &mut R) -> io::Result<()> { let dom = parse_document(RcDom::default(), ParseOpts::default()) .from_utf8() .read_from(&mut r)?; if !self.options.omit_doctype { self.w.write_all(b"<!doctype html>")?; } self.minify_node(&None, &dom.document) } fn minify_node<'b>(&mut self, ctx: &'b Option<Context>, node: &'b Node) -> io::Result<()> { match &node.data { NodeData::Text { contents } => { // Check if whitespace collapsing disabled let contents = contents.borrow(); let contents = contents.as_ref(); if !self.options.collapse_whitespace { return self.w.write_all(contents.as_bytes()); } // Check if parent is whitespace preserving element or contains code (<script>, <style>) let (skip_collapse_whitespace, contains_code) = ctx.as_ref().map_or((false, false), |ctx| { if let NodeData::Element { name, .. } = &ctx.parent.data { let name = name.local.as_ref(); (preserve_whitespace(name), contains_code(name)) } else { (false, false) } }); if skip_collapse_whitespace { return self.w.write_all(contents.as_bytes()); } if contains_code { return self .w .write_all(contents.trim_matches(is_ascii_whitespace).as_bytes()); } // Early exit if empty to forego expensive trim logic if contents.is_empty() { return io::Result::Ok(()); } let (trim_left, trim_right) = ctx .as_ref() .map_or((true, true), |ctx| ctx.trim(self.preceding_whitespace)); let contents = match (trim_left, trim_right) { (true, true) => contents.trim_matches(is_ascii_whitespace), (true, false) => contents.trim_start_matches(is_ascii_whitespace), (false, true) => contents.trim_end_matches(is_ascii_whitespace), _ => contents, }; // Second empty check after trimming whitespace if !contents.is_empty() { // replace \n, \r to ' ' let contents = contents .bytes() .map(|c| if matches!(c, b'\n' | b'\r') { b' ' } else { c }) .collect::<Vec<u8>>(); self.write_collapse_whitespace(&contents, reserved_entity, None)?; self.preceding_whitespace = !trim_right && contents .iter() .last() .map_or(false, u8::is_ascii_whitespace); } Ok(()) } NodeData::Comment { contents } if self.options.preserve_comments => { self.w.write_all(b"<!--")?; self.w.write_all(contents.as_bytes())?; self.w.write_all(b"-->") } NodeData::Document => self.minify_children(ctx, node), NodeData::Element { name, attrs, .. } => { let attrs = attrs.borrow(); let tag = name.local.as_ref(); if is_self_closing(tag) { return self.write_start_tag(name, &attrs); } let (omit_start_tag, omit_end_tag) = self.omit_tags(ctx, node, tag, attrs.is_empty()); if !omit_start_tag { self.write_start_tag(name, &attrs)?; } self.minify_children(ctx, node)?; if !omit_end_tag { self.write_end_tag(name)?; } Ok(()) } _ => Ok(()), } } fn next_is_comment<'b, I>(&self, v: I) -> bool where I: IntoIterator<Item = &'b Rc<Node>>, { v.into_iter() .find_map(|node| match &node.data { NodeData::Text { contents } => { if self.options.collapse_whitespace && is_whitespace(contents) { // Blocks of whitespace are skipped None } else { Some(false) } } NodeData::Comment { .. } => Some(self.options.preserve_comments), _ => Some(false), }) .unwrap_or(false) } fn is_whitespace(&self, s: &RefCell<Tendril<UTF8>>) -> Option<bool> { if self.options.collapse_whitespace && is_whitespace(s) { None } else { Some( !s.borrow() .as_bytes() .iter() .next() .map_or(false, u8::is_ascii_whitespace), ) } } /// Determines if start and end tags can be omitted. /// Whitespace rules are ignored if `collapse_whitespace` is enabled. #[allow(clippy::too_many_lines)] fn omit_tags( &self, ctx: &Option<Context>, node: &Node, name: &str, empty_attributes: bool, ) -> (bool, bool) { ctx.as_ref().map_or((false, false), |ctx| match name { "html" => { // The end tag may be omitted if the <html> element is not immediately followed by a comment. let omit_end = ctx.right.map_or(true, |right| !self.next_is_comment(right)); // The start tag may be omitted if the first thing inside the <html> element is not a comment. let omit_start = empty_attributes && omit_end && !self.next_is_comment(&*node.children.borrow()); (omit_start, omit_end) } "head" => { // The end tag may be omitted if the first thing following the <head> element is not a space character or a comment. let omit_end = ctx.right.map_or(true, |right| { right .iter() .find_map(|node| match &node.data { NodeData::Text { contents } => self.is_whitespace(contents), NodeData::Comment { .. } => { if self.options.preserve_comments { Some(false) } else { None } } _ => Some(true), }) .unwrap_or(true) }); // The start tag may be omitted if the first thing inside the <head> element is an element. let omit_start = empty_attributes && omit_end && node .children .borrow() .iter() .find_map(|node| match &node.data { NodeData::Text { contents } => self.is_whitespace(contents), NodeData::Element { .. } => Some(true), NodeData::Comment { .. } => { if self.options.preserve_comments { Some(false) } else { None } } _ => Some(false), }) .unwrap_or(true); (omit_start, omit_end) } "body" => { // The start tag may be omitted if the first thing inside it is not a space character, comment, <script> element or <style> element. let omit_start = empty_attributes && node .children .borrow() .iter() .find_map(|node| match &node.data { NodeData::Text { contents } => self.is_whitespace(contents), NodeData::Element { name, .. } => { Some(!matches!(name.local.as_ref(), "script" | "style")) } NodeData::Comment { .. } => { if self.options.preserve_comments { Some(false) } else { None } } _ => Some(true), }) .unwrap_or(true); // The end tag may be omitted if the <body> element has contents or has a start tag, and is not immediately followed by a comment. let omit_end = ctx.right.map_or(true, |right| !self.next_is_comment(right)); (omit_start && omit_end, omit_end) } "p" => { let omit_end = ctx.next_element().map_or(true, |node| { if let NodeData::Element { name, .. } = &node.data { matches!( name.local.as_ref().to_ascii_lowercase().as_str(), "address" | "article" | "aside" | "blockquote" | "div" | "dl" | "fieldset" | "footer" | "form" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "header" | "hr" | "menu" | "nav" | "ol" | "p" | "pre" | "section" | "table" | "ul" ) } else { false } }); (false, omit_end) } // TODO: comprehensive handling of optional end element rules _ => (false, optional_end_tag(name)), }) } #[allow(clippy::needless_pass_by_value)] fn minify_children(&mut self, ctx: &Option<Context>, node: &Node) -> io::Result<()> { let children = node.children.borrow(); let l = children.len(); children.iter().enumerate().try_for_each(|(i, child)| { if self.preceding_whitespace && is_block_element(child) { self.preceding_whitespace = false; } self.minify_node( &Some(Context { parent: node, parent_context: ctx.as_ref(), left: if i > 0 { Some(&children[..i]) } else { None }, right: if i + 1 < l { Some(&children[i + 1..]) } else { None }, }), child, ) }) } fn write_qualified_name(&mut self, name: &QualName) -> io::Result<()> { if let Some(prefix) = &name.prefix { self.w .write_all(prefix.as_ref().to_ascii_lowercase().as_bytes())?; self.w.write_all(b":")?; } self.w .write_all(name.local.as_ref().to_ascii_lowercase().as_bytes()) } fn write_start_tag(&mut self, name: &QualName, attrs: &[Attribute]) -> io::Result<()> { self.w.write_all(b"<")?; self.write_qualified_name(name)?; attrs .iter() .try_for_each(|attr| self.write_attribute(attr))?; self.w.write_all(b">") } fn write_end_tag(&mut self, name: &QualName) -> io::Result<()> { self.w.write_all(b"</")?; self.write_qualified_name(name)?; self.w.write_all(b">") } fn write_attribute(&mut self, attr: &Attribute) -> io::Result<()> { self.w.write_all(b" ")?; self.write_qualified_name(&attr.name)?; let value = attr.value.as_ref(); let value = if self.options.collapse_whitespace { value.trim_matches(is_ascii_whitespace) } else { value }; if value.is_empty() { return io::Result::Ok(()); } self.w.write_all(b"=")?; let b = value.as_bytes(); let (unquoted, double, _) = b.iter() .fold((true, false, false), |(unquoted, double, single), &c| { let (double, single) = (double || c == b'"', single || c == b'\''); let unquoted = unquoted && !double && !single && c != b'=' && !c.is_ascii_whitespace(); (unquoted, double, single) }); if unquoted { self.w.write_all(b) } else if double { self.write_attribute_value(b, b"'", reserved_entity_with_apos) } else { self.write_attribute_value(b, b"\"", reserved_entity) } } fn write_attribute_value<T: AsRef<[u8]>>( &mut self, v: T, quote: &[u8], f: EntityFn, ) -> io::Result<()> { self.w.write_all(quote)?; let b = v.as_ref(); if self.options.collapse_whitespace { self.write_collapse_whitespace(b, f, Some(false)) } else { self.w.write_all(b) }?; self.w.write_all(quote) } /// Efficiently writes blocks of content, e.g. a string with no collapsed /// whitespace would result in a single write. fn write_collapse_whitespace( &mut self, b: &[u8], f: EntityFn, preceding_whitespace: Option<bool>, ) -> io::Result<()> { b.iter() .enumerate() .try_fold( (0, preceding_whitespace.unwrap_or(self.preceding_whitespace)), |(pos, preceding_whitespace), (i, &c)| { let is_whitespace = c.is_ascii_whitespace(); Ok(if is_whitespace && preceding_whitespace { if i != pos { self.write(&b[pos..i], f)?; } // ASCII whitespace = 1 byte (i + 1, true) } else { (pos, is_whitespace) }) }, ) .and_then(|(pos, _)| { if pos < b.len() { self.write(&b[pos..], f)?; } Ok(()) }) } fn write(&mut self, b: &[u8], f: EntityFn) -> io::Result<()> { b.iter() .enumerate() .try_fold(0, |pos, (i, &c)| { Ok(if let Some(entity) = f(c) { self.w.write_all(&b[pos..i])?; self.w.write_all(entity)?; // Reserved characters are 1 byte i + 1 } else { pos }) }) .and_then(|pos| { if pos < b.len() { self.w.write_all(&b[pos..])?; } Ok(()) }) } } struct Context<'a> { parent: &'a Node, parent_context: Option<&'a Context<'a>>, left: Option<&'a [Rc<Node>]>, right: Option<&'a [Rc<Node>]>, } impl<'a> Context<'a> { /// Determine whether to trim whitespace.