From bcbf680ad6bc5568eb7c5c653c864c6985d58a02 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Tue, 8 Sep 2026 16:34:42 +0800 Subject: [PATCH 1/2] fix: choose shorter quotes for minified URLs --- src/lib.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++++ src/values/url.rs | 39 ++++++++++++++-- 2 files changed, 151 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 6416b7bae..b2dbd5b6b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30150,6 +30150,47 @@ mod tests { #[test] fn test_quoting_unquoting_urls() { + // Single quotes avoid escaping the double quotes in SVG attributes. + minify_test( + r#".foo { background-image: url('data:image/svg+xml,') }"#, + r#".foo{background-image:url('data:image/svg+xml,')}"#, + ); + + // Keep double quotes when they are shorter or tied with single quotes. + minify_test( + r#".foo { background-image: url("a 'b' c"), url('a "b" \'c\''), url('a "b" \'c') }"#, + r#".foo{background-image:url("a 'b' c"),url("a \"b\" 'c'"),url('a "b" \'c')}"#, + ); + + // Unquoted output still wins ties, including an empty URL. + minify_test( + r#".foo { background-image: url('"'), url('""'), url('"""'), url('') }"#, + r#".foo{background-image:url(\"),url(\"\"),url('"""'),url()}"#, + ); + + // Escaped quotes and backslashes are parsed before choosing the quote style. + minify_test( + r#".foo { background-image: url("a \\\"b\\\" \\c"), url('a "b" \\\'c') }"#, + r#".foo{background-image:url('a \\"b\\" \\c'),url('a "b" \\\'c')}"#, + ); + + // Preserve control characters and hex escape terminators before hex digits and spaces. + minify_test( + r#".foo { background-image: url('"a"\a f\d \c a\1 b\7f c\9 "é😀"') }"#, + r#".foo{background-image:url('"a"\a f\d \c a\1 b\7f c\9 "é😀"')}"#, + ); + + minify_test( + ".foo { background-image: url('\"a\" \\\nb\\0 c') }", + r#".foo{background-image:url('"a" b�c')}"#, + ); + + // Keep the existing double-quoted output when not minifying. + test( + r#".foo { background-image: url('a "b" c') }"#, + ".foo {\n background-image: url(\"a \\\"b\\\" c\");\n}\n", + ); + // Quotes remain double quotes when not minifying test( r#".foo { @@ -30189,6 +30230,80 @@ mod tests { ); } + #[test] + fn test_url_quote_roundtrip() { + use crate::values::url::Url; + + // Exercise escape boundaries on both sides of quotes, including a literal + // backslash followed by a hex digit and non-ASCII text. + let parts = [ + "\"", "'", "\\", "\n", "\r", "\x0c", "\x01", "\x7f", "\t", " ", "f", "é😀", "(", ")", + ]; + for a in parts { + for b in parts { + for c in parts { + let value = format!("\"\"{a}{b}{c}\"\""); + let url = Url { + url: value.as_str().into(), + loc: crate::dependencies::Location { line: 1, column: 1 }, + }; + let serialized = url + .to_css_string(PrinterOptions { + minify: true, + ..Default::default() + }) + .unwrap(); + assert_eq!(Url::parse_string(&serialized).unwrap(), url); + } + } + } + } + + #[test] + fn test_url_quoting_dependencies() { + let stylesheet = + StyleSheet::parse(r#".foo { background-image: url('a "b" c') }"#, ParserOptions::default()).unwrap(); + let result = stylesheet + .to_css(PrinterOptions { + minify: true, + analyze_dependencies: Some(Default::default()), + ..Default::default() + }) + .unwrap(); + let dependencies = result.dependencies.unwrap(); + assert_eq!(dependencies.len(), 1); + let Dependency::Url(dep) = &dependencies[0] else { + panic!("expected URL dependency") + }; + assert_eq!(dep.url, r#"a "b" c"#); + assert_eq!( + result.code, + format!(".foo{{background-image:url(\"{}\")}}", dep.placeholder) + ); + } + + #[test] + #[cfg(feature = "sourcemap")] + fn test_url_quoting_source_map() { + let stylesheet = StyleSheet::parse( + ".foo { background-image: url('\"a\" \\a b') }\n.bar { color: red }", + ParserOptions::default(), + ) + .unwrap(); + let mut sm = parcel_sourcemap::SourceMap::new("/"); + let result = stylesheet + .to_css(PrinterOptions { + minify: true, + source_map: Some(&mut sm), + ..Default::default() + }) + .unwrap(); + let column = result.code.find(".bar").unwrap() as u32; + let mapping = sm.find_closest_mapping(0, column).unwrap(); + assert_eq!(mapping.generated_column, column); + assert_eq!(mapping.original.unwrap().original_line, 1); + } + #[test] fn test_zindex() { minify_test(".foo { z-index: 2 }", ".foo{z-index:2}"); diff --git a/src/values/url.rs b/src/values/url.rs index cebdeed9d..b26f77524 100644 --- a/src/values/url.rs +++ b/src/values/url.rs @@ -8,6 +8,7 @@ use crate::values::string::CowArcStr; #[cfg(feature = "visitor")] use crate::visitor::Visit; use cssparser::*; +use std::fmt::{self, Write}; /// A CSS [url()](https://www.w3.org/TR/css-values-4/#urls) value and its source location. #[derive(Debug, Clone)] @@ -68,11 +69,25 @@ impl<'i> ToCss for Url<'i> { let mut buf = String::new(); Token::UnquotedUrl(CowRcStr::from(self.url.as_ref())).to_css(&mut buf)?; - // If the unquoted url is longer than it would be quoted (e.g. `url("...")`) - // then serialize as a string and choose the shorter version. + // If quoting could be shorter, choose the quote that needs fewer escapes, + // then compare with the unquoted url. Keep the existing choice on ties. if buf.len() > self.url.len() + 7 { + let mut single_quotes = 0; + let mut double_quotes = 0; + for b in self.url.bytes() { + match b { + b'\'' => single_quotes += 1, + b'"' => double_quotes += 1, + _ => {} + } + } + let mut buf2 = String::new(); - serialize_string(&self.url, &mut buf2)?; + if double_quotes > single_quotes { + serialize_single_quoted_string(&self.url, &mut buf2)?; + } else { + serialize_string(&self.url, &mut buf2)?; + } if buf2.len() + 5 < buf.len() { dest.write_str("url(")?; dest.write_str(&buf2)?; @@ -91,6 +106,24 @@ impl<'i> ToCss for Url<'i> { } } +fn serialize_single_quoted_string(value: &str, dest: &mut W) -> fmt::Result { + dest.write_char('\'')?; + let mut start = 0; + for (i, b) in value.bytes().enumerate() { + if matches!(b, b'\'' | b'"') { + // Delegate all other escapes, including hex escape terminators, to cssparser. + CssStringWriter::new(dest).write_str(&value[start..i])?; + if b == b'\'' { + dest.write_char('\\')?; + } + dest.write_char(b as char)?; + start = i + 1; + } + } + CssStringWriter::new(dest).write_str(&value[start..])?; + dest.write_char('\'') +} + impl<'i> Url<'i> { /// Returns whether the URL is absolute, and not relative. pub fn is_absolute(&self) -> bool { From 6fbfc66db9291f1044a098501d441a3d77e0a71d Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Tue, 8 Sep 2026 16:42:41 +0800 Subject: [PATCH 2/2] test: format URL regression fixtures --- src/lib.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b2dbd5b6b..bee94a2dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30152,42 +30152,71 @@ mod tests { fn test_quoting_unquoting_urls() { // Single quotes avoid escaping the double quotes in SVG attributes. minify_test( - r#".foo { background-image: url('data:image/svg+xml,') }"#, + r#" + .foo { + background-image: url('data:image/svg+xml,'); + } + "#, r#".foo{background-image:url('data:image/svg+xml,')}"#, ); // Keep double quotes when they are shorter or tied with single quotes. minify_test( - r#".foo { background-image: url("a 'b' c"), url('a "b" \'c\''), url('a "b" \'c') }"#, + r#" + .foo { + background-image: url("a 'b' c"), url('a "b" \'c\''), url('a "b" \'c'); + } + "#, r#".foo{background-image:url("a 'b' c"),url("a \"b\" 'c'"),url('a "b" \'c')}"#, ); // Unquoted output still wins ties, including an empty URL. minify_test( - r#".foo { background-image: url('"'), url('""'), url('"""'), url('') }"#, + r#" + .foo { + background-image: url('"'), url('""'), url('"""'), url(''); + } + "#, r#".foo{background-image:url(\"),url(\"\"),url('"""'),url()}"#, ); // Escaped quotes and backslashes are parsed before choosing the quote style. minify_test( - r#".foo { background-image: url("a \\\"b\\\" \\c"), url('a "b" \\\'c') }"#, + r#" + .foo { + background-image: url("a \\\"b\\\" \\c"), url('a "b" \\\'c'); + } + "#, r#".foo{background-image:url('a \\"b\\" \\c'),url('a "b" \\\'c')}"#, ); // Preserve control characters and hex escape terminators before hex digits and spaces. minify_test( - r#".foo { background-image: url('"a"\a f\d \c a\1 b\7f c\9 "é😀"') }"#, + r#" + .foo { + background-image: url('"a"\a f\d \c a\1 b\7f c\9 "é😀"'); + } + "#, r#".foo{background-image:url('"a"\a f\d \c a\1 b\7f c\9 "é😀"')}"#, ); minify_test( - ".foo { background-image: url('\"a\" \\\nb\\0 c') }", + r#" + .foo { + background-image: url('"a" \ +b\0 c'); + } + "#, r#".foo{background-image:url('"a" b�c')}"#, ); // Keep the existing double-quoted output when not minifying. test( - r#".foo { background-image: url('a "b" c') }"#, + r#" + .foo { + background-image: url('a "b" c'); + } + "#, ".foo {\n background-image: url(\"a \\\"b\\\" c\");\n}\n", ); @@ -30261,8 +30290,15 @@ mod tests { #[test] fn test_url_quoting_dependencies() { - let stylesheet = - StyleSheet::parse(r#".foo { background-image: url('a "b" c') }"#, ParserOptions::default()).unwrap(); + let stylesheet = StyleSheet::parse( + r#" + .foo { + background-image: url('a "b" c'); + } + "#, + ParserOptions::default(), + ) + .unwrap(); let result = stylesheet .to_css(PrinterOptions { minify: true, @@ -30286,7 +30322,14 @@ mod tests { #[cfg(feature = "sourcemap")] fn test_url_quoting_source_map() { let stylesheet = StyleSheet::parse( - ".foo { background-image: url('\"a\" \\a b') }\n.bar { color: red }", + r#" + .foo { + background-image: url('"a" \a b'); + } + .bar { + color: red; + } + "#, ParserOptions::default(), ) .unwrap(); @@ -30301,7 +30344,7 @@ mod tests { let column = result.code.find(".bar").unwrap() as u32; let mapping = sm.find_closest_mapping(0, column).unwrap(); assert_eq!(mapping.generated_column, column); - assert_eq!(mapping.original.unwrap().original_line, 1); + assert_eq!(mapping.original.unwrap().original_line, 4); } #[test]