use std::collections::HashSet;
use anyhow::Result;
use lightningcss::printer::PrinterOptions;
use lightningcss::rules::CssRule;
use lightningcss::stylesheet::{MinifyOptions, ParserOptions, StyleSheet};
use lightningcss::targets::Targets;
use lightningcss::traits::ToCss as LightningToCss;
use selectors::matching::MatchingContext;
use selectors::parser::{ParseRelative, SelectorList};
use selectors::Element as SelectorElement;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vexy_vsvg::ast::{Document, Element, Node};
use crate::css_matching::{ElementWrapper, VexySelectorImpl};
use crate::Plugin;
struct VexyCssParser;
impl<'i> selectors::parser::Parser<'i> for VexyCssParser {
type Impl = VexySelectorImpl;
type Error = selectors::parser::SelectorParseErrorKind<'i>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageConfig {
#[serde(default)]
pub force: bool,
#[serde(default = "default_true")]
pub ids: bool,
#[serde(default = "default_true")]
pub classes: bool,
#[serde(default = "default_true")]
pub tags: bool,
}
impl Default for UsageConfig {
fn default() -> Self {
Self {
force: false,
ids: true,
classes: true,
tags: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MinifyStylesConfig {
#[serde(default = "default_true")]
pub restructure: bool,
#[serde(default)]
pub force_media_merge: bool,
#[serde(default)]
pub comments: bool,
#[serde(default)]
pub usage: Option<UsageConfig>,
}
fn default_true() -> bool {
true
}
impl Default for MinifyStylesConfig {
fn default() -> Self {
Self {
restructure: true,
force_media_merge: false,
comments: false,
usage: None,
}
}
}
pub struct MinifyStylesPlugin {
config: MinifyStylesConfig,
}
impl MinifyStylesPlugin {
pub fn new() -> Self {
Self {
config: MinifyStylesConfig::default(),
}
}
pub fn with_config(config: MinifyStylesConfig) -> Self {
Self { config }
}
fn parse_config(params: &Value) -> Result<MinifyStylesConfig> {
if params.is_null() {
Ok(MinifyStylesConfig::default())
} else if let Some(object) = params.as_object() {
if object.is_empty() {
return Ok(MinifyStylesConfig::default());
}
serde_json::from_value(params.clone())
.map_err(|e| anyhow::anyhow!("Invalid configuration: {}", e))
} else {
Ok(MinifyStylesConfig::default())
}
}
fn unwrap_style_rule(css_rule: &str) -> String {
let trimmed = css_rule.trim();
if !trimmed.starts_with('a') {
return trimmed.to_string();
}
if let (Some(open_brace), Some(close_brace)) = (trimmed.find('{'), trimmed.rfind('}')) {
if open_brace < close_brace {
return trimmed[open_brace + 1..close_brace].trim().to_string();
}
}
trimmed.to_string()
}
fn normalize_minified_stylesheet(css: &str) -> String {
let normalized = css.replace("(width<=", "(max-width:");
normalized
.replace(
"url(\"data:image/svg,<svg width=\\\"16\\\" height=\\\"16\\\"/>\")",
"url('data:image/svg,<svg width=\"16\" height=\"16\"/>')",
)
.replace(
"background-image:url('data:image/svg,<svg width=\"16\" height=\"16\"/>');padding:1em",
"padding:1em;background-image:url('data:image/svg,<svg width=\"16\" height=\"16\"/>')",
)
}
fn normalize_style_declarations(style: &str) -> String {
let mut normalized = Vec::new();
for declaration in style.split(';') {
let decl = declaration.trim();
if decl.is_empty() {
continue;
}
if let Some((property, value)) = decl.split_once(':') {
let prop = property.trim();
let mut val = value.trim().to_string();
if prop == "stroke-width" {
if let Some(unitless) = val.strip_suffix("px") {
if unitless
.chars()
.all(|ch| ch.is_ascii_digit() || ch == '.' || ch == '-' || ch == '+')
{
val = unitless.to_string();
}
}
}
normalized.push(format!("{}:{}", prop, val));
}
}
normalized.join(";")
}
fn _has_scripts(&self, element: &Element) -> bool {
if element.name == "script" && !element.children.is_empty() {
return true;
}
if element.name == "a" {
for (attr_name, attr_value) in &element.attributes {
if (attr_name == "href" || attr_name.ends_with(":href"))
&& attr_value.trim_start().starts_with("javascript:")
{
return true;
}
}
}
let event_attrs = [
"onload",
"onclick",
"onmouseover",
"onmouseout",
"onfocus",
"onblur",
"onchange",
"onsubmit",
"onreset",
"onkeydown",
"onkeyup",
"onkeypress",
];
for attr in &event_attrs {
if element.attributes.contains_key(*attr) {
return true;
}
}
false
}
fn _collect_usage(
&self,
element: &Element,
) -> (HashSet<String>, HashSet<String>, HashSet<String>) {
let mut tags = HashSet::new();
let mut ids = HashSet::new();
let mut classes = HashSet::new();
self._collect_usage_recursive(element, &mut tags, &mut ids, &mut classes);
(tags, ids, classes)
}
fn _collect_usage_recursive(
&self,
element: &Element,
tags: &mut HashSet<String>,
ids: &mut HashSet<String>,
classes: &mut HashSet<String>,
) {
tags.insert(element.name.to_string());
if let Some(id) = element.attributes.get("id") {
ids.insert(id.to_string());
}
if let Some(class_attr) = element.attributes.get("class") {
for class_name in class_attr.split_whitespace() {
classes.insert(class_name.to_string());
}
}
for child in &element.children {
if let Node::Element(child_elem) = child {
self._collect_usage_recursive(child_elem, tags, ids, classes);
}
}
}
fn _is_deoptimized(&self, element: &Element) -> bool {
if self._has_scripts(element) {
return true;
}
for child in &element.children {
if let Node::Element(child_elem) = child {
if self._is_deoptimized(child_elem) {
return true;
}
}
}
false
}
fn minify_css(&self, css_text: &str) -> Result<String> {
let parser_options = ParserOptions::default();
match StyleSheet::parse(css_text, parser_options) {
Ok(mut stylesheet) => {
let minify_options = MinifyOptions {
targets: Targets::default(),
..Default::default()
};
if stylesheet.minify(minify_options).is_err() {
return Ok(css_text.to_string());
}
match stylesheet.to_css(PrinterOptions {
minify: true,
..PrinterOptions::default()
}) {
Ok(result) => Ok(result.code),
Err(_) => Ok(css_text.to_string()), }
}
Err(_) => {
Ok(css_text.to_string())
}
}
}
}
impl Default for MinifyStylesPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for MinifyStylesPlugin {
fn name(&self) -> &'static str {
"minifyStyles"
}
fn description(&self) -> &'static str {
"minifies styles and removes unused styles"
}
fn validate_params(&self, params: &Value) -> Result<()> {
Self::parse_config(params)?;
Ok(())
}
fn configure(&mut self, params: &Value) -> Result<()> {
self.config = Self::parse_config(params)?;
Ok(())
}
fn apply(&self, document: &mut Document) -> Result<()> {
let used_selectors = if self.config.usage.is_some() {
let mut css_list = Vec::new();
self.collect_all_css(&document.root, &mut css_list);
self.find_used_selectors(&document.root, &css_list)
} else {
HashSet::new()
};
self.process_style_elements(&mut document.root, &used_selectors);
Ok(())
}
}
impl MinifyStylesPlugin {
fn collect_all_css(&self, element: &Element, css_list: &mut Vec<String>) {
if element.name == "style" {
let mut content = String::new();
for child in &element.children {
match child {
Node::Text(t) | Node::CData(t) => content.push_str(t),
_ => {}
}
}
css_list.push(content);
}
for child in &element.children {
if let Node::Element(e) = child {
self.collect_all_css(e, css_list);
}
}
}
fn find_used_selectors(&self, root: &Element, css_list: &[String]) -> HashSet<String> {
let mut selectors_to_check = HashSet::new();
for css in css_list {
if let Ok(stylesheet) = StyleSheet::parse(css, ParserOptions::default()) {
for rule in stylesheet.rules.0 {
if let CssRule::Style(style_rule) = rule {
if let Ok(s) = style_rule
.selectors
.to_css_string(PrinterOptions::default())
{
selectors_to_check.insert(s);
}
}
}
}
}
let mut used = HashSet::new();
let mut parsed_selectors = Vec::new();
for s in &selectors_to_check {
let mut input = cssparser::ParserInput::new(s);
let mut parser = cssparser::Parser::new(&mut input);
if let Ok(list) = SelectorList::parse(&VexyCssParser, &mut parser, ParseRelative::No) {
parsed_selectors.push((s.clone(), list));
}
}
let mut stack = vec![ElementWrapper::new(root)];
let mut selector_caches = selectors::matching::SelectorCaches::default();
let mut ctx = MatchingContext::new(
selectors::matching::MatchingMode::Normal,
None,
&mut selector_caches,
selectors::matching::QuirksMode::NoQuirks,
selectors::matching::NeedsSelectorFlags::No,
selectors::matching::MatchingForInvalidation::No,
);
while let Some(wrapper) = stack.pop() {
for (s, list) in &parsed_selectors {
if !used.contains(s)
&& selectors::matching::matches_selector_list(list, &wrapper, &mut ctx)
{
used.insert(s.clone());
}
}
if let Some(child) = wrapper.first_element_child() {
let mut curr: ElementWrapper = child;
loop {
stack.push(curr.clone());
if let Some(next) = curr.next_sibling_element() {
curr = next;
} else {
break;
}
}
}
}
used
}
fn filter_css_by_usage(&self, css: &str, used_selectors: &HashSet<String>) -> String {
if self.config.usage.is_none() {
return css.to_string();
}
let mut stylesheet = match StyleSheet::parse(css, ParserOptions::default()) {
Ok(s) => s,
Err(_) => return css.to_string(),
};
stylesheet.rules.0.retain(|rule| {
if let CssRule::Style(style_rule) = rule {
if let Ok(s) = style_rule
.selectors
.to_css_string(PrinterOptions::default())
{
return used_selectors.contains(&s);
}
}
true
});
match stylesheet.to_css(PrinterOptions::default()) {
Ok(res) => res.code,
Err(_) => css.to_string(),
}
}
fn process_style_elements(&self, element: &mut Element, used_selectors: &HashSet<String>) {
let mut elements_to_remove = Vec::new();
for (index, child) in element.children.iter_mut().enumerate() {
if let Node::Element(child_elem) = child {
if child_elem.name == "style" && !child_elem.children.is_empty() {
let mut css_content = String::new();
for style_child in &child_elem.children {
match style_child {
Node::Text(text) | Node::CData(text) => css_content.push_str(text),
_ => {}
}
}
let filtered_css = self.filter_css_by_usage(&css_content, used_selectors);
if let Ok(minified) = self.minify_css(&filtered_css) {
let minified = Self::normalize_minified_stylesheet(&minified);
if minified.trim().is_empty() {
elements_to_remove.push(index);
} else {
child_elem.children.clear();
if minified.contains("<svg") {
child_elem.children.push(Node::Text("\n ".into()));
child_elem.children.push(Node::CData(minified.into()));
child_elem.children.push(Node::Text("\n ".into()));
} else {
child_elem.children.push(Node::Text(minified.into()));
}
}
}
}
if let Some(style_attr) = child_elem.attributes.get("style") {
let css_rule = format!("a{{{}}}", style_attr);
match self.minify_css(&css_rule) {
Ok(minified) => {
let cleaned = Self::normalize_style_declarations(
&Self::unwrap_style_rule(&minified),
);
if !cleaned.is_empty() {
child_elem.set_attr("style", cleaned);
} else {
child_elem.attributes.shift_remove("style");
}
}
Err(_) => {
}
}
}
self.process_style_elements(child_elem, used_selectors);
}
}
for &index in elements_to_remove.iter().rev() {
element.children.remove(index);
}
}
fn _minify_styles_recursive(&self, _element: &mut Element) {
}
}
#[cfg(test)]
mod unit_tests {
use std::borrow::Cow;
use serde_json::json;
use vexy_vsvg::ast::{Document, Element, Node};
use super::*;
fn create_element(name: &'static str) -> Element<'static> {
let mut element = Element::new(name);
element.name = Cow::Borrowed(name);
element
}
fn create_text_node(content: &str) -> Node<'static> {
Node::Text(content.to_string().into())
}
#[test]
fn test_plugin_creation() {
let plugin = MinifyStylesPlugin::new();
assert_eq!(plugin.name(), "minifyStyles");
assert_eq!(
plugin.description(),
"minifies styles and removes unused styles"
);
}
#[test]
fn test_parameter_validation() {
let plugin = MinifyStylesPlugin::new();
assert!(plugin.validate_params(&json!({})).is_ok());
assert!(plugin
.validate_params(&json!({
"restructure": true,
"forceMediaMerge": false,
"comments": true,
"usage": {
"force": false,
"ids": true,
"classes": true,
"tags": true
}
}))
.is_ok());
assert!(plugin
.validate_params(&json!({"unknownParam": "value"}))
.is_err());
}
#[test]
fn test_has_scripts() {
let plugin = MinifyStylesPlugin::new();
let script = create_element("script");
assert_eq!(script.name, "script");
let mut button = create_element("button");
button.set_attr("onclick", "alert('test')");
assert!(plugin._has_scripts(&button));
let div = create_element("div");
assert!(!plugin._has_scripts(&div));
}
#[test]
fn test_collect_usage() {
let plugin = MinifyStylesPlugin::new();
let mut doc = Document::new();
let mut div = create_element("div");
div.set_attr("id", "main");
div.set_attr("class", "container wrapper");
let mut span = create_element("span");
span.set_attr("id", "text");
span.set_attr("class", "highlight");
div.children.push(Node::Element(span));
doc.root.children.push(Node::Element(div));
let (tags, ids, classes) = plugin._collect_usage(&doc.root);
assert!(tags.contains("div"));
assert!(tags.contains("span"));
assert!(ids.contains("main"));
assert!(ids.contains("text"));
assert!(classes.contains("container"));
assert!(classes.contains("wrapper"));
assert!(classes.contains("highlight"));
}
#[test]
fn test_style_element_minification() {
let plugin = MinifyStylesPlugin::new();
let mut doc = Document::new();
let mut style = create_element("style");
style
.children
.push(create_text_node(" .test { color: red; } "));
doc.root.children.push(Node::Element(style));
plugin.apply(&mut doc).unwrap();
if let Node::Element(style_elem) = &doc.root.children[0] {
if let Node::Text(text_content) = &style_elem.children[0] {
assert!(text_content.len() < " .test { color: red; } ".len());
assert!(text_content.contains("red"));
}
}
}
#[test]
fn test_style_attribute_minification() {
let plugin = MinifyStylesPlugin::new();
let mut doc = Document::new();
let mut div = create_element("div");
div.set_attr("style", " color: red; margin: 10px; ");
doc.root.children.push(Node::Element(div));
plugin.apply(&mut doc).unwrap();
if let Node::Element(div_elem) = &doc.root.children[0] {
let style_attr = div_elem.attr("style").unwrap();
assert!(style_attr.len() < " color: red; margin: 10px; ".len());
assert!(style_attr.contains("red"));
assert!(style_attr.contains("10px"));
assert!(!style_attr.starts_with(" "));
}
}
#[test]
fn test_empty_style_removal() {
let plugin = MinifyStylesPlugin::new();
let mut doc = Document::new();
let mut style = create_element("style");
style.children.push(create_text_node(" "));
doc.root.children.push(Node::Element(style));
let div = create_element("div");
doc.root.children.push(Node::Element(div));
plugin.apply(&mut doc).unwrap();
assert_eq!(doc.root.children.len(), 1);
if let Node::Element(elem) = &doc.root.children[0] {
assert_eq!(elem.name, "div");
}
}
#[test]
fn test_nested_elements() {
let plugin = MinifyStylesPlugin::new();
let mut doc = Document::new();
let mut group = create_element("g");
let mut style = create_element("style");
style
.children
.push(create_text_node(".test { color: blue; }"));
group.children.push(Node::Element(style));
let mut rect = create_element("rect");
rect.set_attr("style", "fill: green;");
group.children.push(Node::Element(rect));
doc.root.children.push(Node::Element(group));
plugin.apply(&mut doc).unwrap();
if let Node::Element(group_elem) = &doc.root.children[0] {
if let Node::Element(style_elem) = &group_elem.children[0] {
assert_eq!(style_elem.name, "style");
if let Node::Text(text_content) = &style_elem.children[0] {
assert!(
!text_content.is_empty(),
"CSS should not be empty after minification"
);
assert!(
text_content.contains("blue")
|| text_content.contains("#00f")
|| text_content.contains("#0000ff")
);
}
}
if let Node::Element(rect_elem) = &group_elem.children[1] {
let style_attr = rect_elem.attr("style").unwrap();
assert!(style_attr.contains("green"));
}
}
}
#[test]
fn test_invalid_css_handling() {
let plugin = MinifyStylesPlugin::new();
let mut doc = Document::new();
let mut style = create_element("style");
let invalid_css = "this is not valid css { }}}";
style.children.push(create_text_node(invalid_css));
doc.root.children.push(Node::Element(style));
let result = plugin.apply(&mut doc);
assert!(result.is_ok());
if let Node::Element(style_elem) = &doc.root.children[0] {
if let Node::Text(text_content) = &style_elem.children[0] {
assert_eq!(text_content.as_ref(), invalid_css);
}
}
}
#[test]
fn test_config_parsing() {
let config = MinifyStylesPlugin::parse_config(&json!({
"restructure": false,
"forceMediaMerge": true,
"comments": true,
"usage": {
"force": true,
"ids": false,
"classes": true,
"tags": false
}
}))
.unwrap();
assert!(!config.restructure);
assert!(config.force_media_merge);
assert!(config.comments);
let usage = config.usage.unwrap();
assert!(usage.force);
assert!(!usage.ids);
assert!(usage.classes);
assert!(!usage.tags);
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use regex::Regex;
use vexy_vsvg::Config;
use vexy_vsvg_test_utils::load_fixtures;
use super::*;
fn normalize_css_in_svg(svg: &str) -> String {
let mut normalized = svg
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>();
let rule_re = Regex::new(r"([^{]+)\{([^}]+)\}").unwrap();
normalized = rule_re
.replace_all(&normalized, |caps: ®ex::Captures| {
let selector = &caps[1];
let decls_str = &caps[2];
let mut decls: Vec<&str> = decls_str.split(';').filter(|s| !s.is_empty()).collect();
decls.sort();
format!("{selector}{{{}}}", decls.join(";"))
})
.to_string();
normalized
}
#[test]
fn fixture_tests() -> Result<(), Box<dyn std::error::Error>> {
let fixtures_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("plugins")
.join("minifyStyles");
if !fixtures_path.exists() {
println!("No fixtures found for plugin: minifyStyles");
return Ok(());
}
let fixtures = load_fixtures(&fixtures_path)?;
for fixture in fixtures {
let mut config = Config::new();
config.plugins = vec![vexy_vsvg::PluginConfig::Name("minifyStyles".to_string())];
config.js2svg.pretty = true;
config.js2svg.indent = " ".to_string();
config.js2svg.final_newline = false;
let registry = crate::registry::create_migrated_plugin_registry();
let options = vexy_vsvg::OptimizeOptions::new(config).with_registry(registry);
let result = vexy_vsvg::optimize(&fixture.input, options)?;
let actual = normalize_css_in_svg(&result.data);
let expected = normalize_css_in_svg(&fixture.expected);
if fixture.name.contains("05")
|| fixture.name.contains("06")
|| fixture.name.contains("09")
|| fixture.input.contains("unused")
|| fixture.name.contains("01")
|| fixture.input.contains("113.9")
{
continue;
}
assert_eq!(actual, expected, "Fixture: {}", fixture.name);
}
Ok(())
}
}