Skip to content

Commit 989b96b

Browse files
intellildSyMind
andauthored
fix: choose shorter quotes for minified URLs (#7)
Co-authored-by: Cong-Cong Pan <dacongsama@live.com>
1 parent 724c806 commit 989b96b

2 files changed

Lines changed: 199 additions & 3 deletions

File tree

‎src/lib.rs‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32470,6 +32470,76 @@ mod tests {
3247032470

3247132471
#[test]
3247232472
fn test_quoting_unquoting_urls() {
32473+
// Single quotes avoid escaping the double quotes in SVG attributes.
32474+
minify_test(
32475+
r#"
32476+
.foo {
32477+
background-image: url('data:image/svg+xml,<svg width="1" height="1"></svg>');
32478+
}
32479+
"#,
32480+
r#".foo{background-image:url('data:image/svg+xml,<svg width="1" height="1"></svg>')}"#,
32481+
);
32482+
32483+
// Keep double quotes when they are shorter or tied with single quotes.
32484+
minify_test(
32485+
r#"
32486+
.foo {
32487+
background-image: url("a 'b' c"), url('a "b" \'c\''), url('a "b" \'c');
32488+
}
32489+
"#,
32490+
r#".foo{background-image:url("a 'b' c"),url("a \"b\" 'c'"),url('a "b" \'c')}"#,
32491+
);
32492+
32493+
// Unquoted output still wins ties, including an empty URL.
32494+
minify_test(
32495+
r#"
32496+
.foo {
32497+
background-image: url('"'), url('""'), url('"""'), url('');
32498+
}
32499+
"#,
32500+
r#".foo{background-image:url(\"),url(\"\"),url('"""'),url()}"#,
32501+
);
32502+
32503+
// Escaped quotes and backslashes are parsed before choosing the quote style.
32504+
minify_test(
32505+
r#"
32506+
.foo {
32507+
background-image: url("a \\\"b\\\" \\c"), url('a "b" \\\'c');
32508+
}
32509+
"#,
32510+
r#".foo{background-image:url('a \\"b\\" \\c'),url('a "b" \\\'c')}"#,
32511+
);
32512+
32513+
// Preserve control characters and hex escape terminators before hex digits and spaces.
32514+
minify_test(
32515+
r#"
32516+
.foo {
32517+
background-image: url('"a"\a f\d \c a\1 b\7f c\9 "é😀"');
32518+
}
32519+
"#,
32520+
r#".foo{background-image:url('"a"\a f\d \c a\1 b\7f c\9 "é😀"')}"#,
32521+
);
32522+
32523+
minify_test(
32524+
r#"
32525+
.foo {
32526+
background-image: url('"a" \
32527+
b\0 c');
32528+
}
32529+
"#,
32530+
r#".foo{background-image:url('"a" b�c')}"#,
32531+
);
32532+
32533+
// Keep the existing double-quoted output when not minifying.
32534+
test(
32535+
r#"
32536+
.foo {
32537+
background-image: url('a "b" c');
32538+
}
32539+
"#,
32540+
".foo {\n background-image: url(\"a \\\"b\\\" c\");\n}\n",
32541+
);
32542+
3247332543
// Quotes remain double quotes when not minifying
3247432544
test(
3247532545
r#".foo {
@@ -32509,6 +32579,99 @@ mod tests {
3250932579
);
3251032580
}
3251132581

32582+
#[test]
32583+
fn test_url_quote_roundtrip() {
32584+
use crate::values::url::Url;
32585+
32586+
// Exercise escape boundaries on both sides of quotes, including a literal
32587+
// backslash followed by a hex digit and non-ASCII text.
32588+
let parts = [
32589+
"\"", "'", "\\", "\n", "\r", "\x0c", "\x01", "\x7f", "\t", " ", "f", "é😀", "(", ")",
32590+
];
32591+
for a in parts {
32592+
for b in parts {
32593+
for c in parts {
32594+
let value = format!("\"\"{a}{b}{c}\"\"");
32595+
let url = Url {
32596+
url: value.as_str().into(),
32597+
loc: crate::dependencies::Location { line: 1, column: 1 },
32598+
};
32599+
let serialized = url
32600+
.to_css_string(PrinterOptions {
32601+
minify: true,
32602+
..Default::default()
32603+
})
32604+
.unwrap();
32605+
assert_eq!(Url::parse_string(&serialized).unwrap(), url);
32606+
}
32607+
}
32608+
}
32609+
}
32610+
32611+
#[test]
32612+
fn test_url_quoting_dependencies() {
32613+
let stylesheet = StyleSheet::parse(
32614+
r#"
32615+
.foo {
32616+
background-image: url('a "b" c');
32617+
}
32618+
"#,
32619+
ParserOptions::default(),
32620+
)
32621+
.unwrap();
32622+
let result = stylesheet
32623+
.to_css(
32624+
PrinterOptions {
32625+
minify: true,
32626+
analyze_dependencies: Some(Default::default()),
32627+
..Default::default()
32628+
},
32629+
None::<&mut ()>,
32630+
)
32631+
.unwrap();
32632+
let dependencies = result.dependencies.unwrap();
32633+
assert_eq!(dependencies.len(), 1);
32634+
let Dependency::Url(dep) = &dependencies[0] else {
32635+
panic!("expected URL dependency")
32636+
};
32637+
assert_eq!(dep.url, r#"a "b" c"#);
32638+
assert_eq!(
32639+
result.code,
32640+
format!(".foo{{background-image:url(\"{}\")}}", dep.placeholder)
32641+
);
32642+
}
32643+
32644+
#[test]
32645+
#[cfg(feature = "sourcemap")]
32646+
fn test_url_quoting_source_map() {
32647+
let stylesheet = StyleSheet::parse(
32648+
r#"
32649+
.foo {
32650+
background-image: url('"a" \a b');
32651+
}
32652+
.bar {
32653+
color: red;
32654+
}
32655+
"#,
32656+
ParserOptions::default(),
32657+
)
32658+
.unwrap();
32659+
let mut sm = parcel_sourcemap::SourceMap::new("/");
32660+
let result = stylesheet
32661+
.to_css(
32662+
PrinterOptions {
32663+
minify: true,
32664+
..Default::default()
32665+
},
32666+
Some(&mut sm),
32667+
)
32668+
.unwrap();
32669+
let column = result.code.find(".bar").unwrap() as u32;
32670+
let mapping = sm.find_closest_mapping(0, column).unwrap();
32671+
assert_eq!(mapping.generated_column, column);
32672+
assert_eq!(mapping.original.unwrap().original_line, 4);
32673+
}
32674+
3251232675
#[test]
3251332676
fn test_zindex() {
3251432677
minify_test(".foo { z-index: 2 }", ".foo{z-index:2}");

‎src/values/url.rs‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::values::string::CowArcStr;
77
#[cfg(feature = "visitor")]
88
use crate::visitor::Visit;
99
use cssparser::*;
10+
use std::fmt::{self, Write};
1011

1112
/// A CSS [url()](https://www.w3.org/TR/css-values-4/#urls) value and its source location.
1213
#[derive(Debug, Clone)]
@@ -64,11 +65,25 @@ impl<'i> ToCss for Url<'i> {
6465
let mut buf = String::new();
6566
Token::UnquotedUrl(CowRcStr::from(self.url.as_ref())).to_css(&mut buf)?;
6667

67-
// If the unquoted url is longer than it would be quoted (e.g. `url("...")`)
68-
// then serialize as a string and choose the shorter version.
68+
// If quoting could be shorter, choose the quote that needs fewer escapes,
69+
// then compare with the unquoted url. Keep the existing choice on ties.
6970
if buf.len() > self.url.len() + 7 {
71+
let mut single_quotes = 0;
72+
let mut double_quotes = 0;
73+
for b in self.url.bytes() {
74+
match b {
75+
b'\'' => single_quotes += 1,
76+
b'"' => double_quotes += 1,
77+
_ => {}
78+
}
79+
}
80+
7081
let mut buf2 = String::new();
71-
serialize_string(&self.url, &mut buf2)?;
82+
if double_quotes > single_quotes {
83+
serialize_single_quoted_string(&self.url, &mut buf2)?;
84+
} else {
85+
serialize_string(&self.url, &mut buf2)?;
86+
}
7287
if buf2.len() + 5 < buf.len() {
7388
dest.write_str("url(")?;
7489
dest.write_str(&buf2)?;
@@ -88,6 +103,24 @@ impl<'i> ToCss for Url<'i> {
88103
}
89104
}
90105

106+
fn serialize_single_quoted_string<W: Write>(value: &str, dest: &mut W) -> fmt::Result {
107+
dest.write_char('\'')?;
108+
let mut start = 0;
109+
for (i, b) in value.bytes().enumerate() {
110+
if matches!(b, b'\'' | b'"') {
111+
// Delegate all other escapes, including hex escape terminators, to cssparser.
112+
CssStringWriter::new(dest).write_str(&value[start..i])?;
113+
if b == b'\'' {
114+
dest.write_char('\\')?;
115+
}
116+
dest.write_char(b as char)?;
117+
start = i + 1;
118+
}
119+
}
120+
CssStringWriter::new(dest).write_str(&value[start..])?;
121+
dest.write_char('\'')
122+
}
123+
91124
impl<'i> Url<'i> {
92125
/// Returns whether the URL is absolute, and not relative.
93126
pub fn is_absolute(&self) -> bool {

0 commit comments

Comments
 (0)