Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32470,6 +32470,76 @@ 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,<svg width="1" height="1"></svg>');
}
"#,
r#".foo{background-image:url('data:image/svg+xml,<svg width="1" height="1"></svg>')}"#,
);

// 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(
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');
}
"#,
".foo {\n background-image: url(\"a \\\"b\\\" c\");\n}\n",
);

// Quotes remain double quotes when not minifying
test(
r#".foo {
Expand Down Expand Up @@ -32509,6 +32579,99 @@ 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()
},
None::<&mut ()>,
)
.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(
r#"
.foo {
background-image: url('"a" \a b');
}
.bar {
color: red;
}
"#,
ParserOptions::default(),
)
.unwrap();
let mut sm = parcel_sourcemap::SourceMap::new("/");
let result = stylesheet
.to_css(
PrinterOptions {
minify: true,
..Default::default()
},
Some(&mut sm),
)
.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, 4);
}

#[test]
fn test_zindex() {
minify_test(".foo { z-index: 2 }", ".foo{z-index:2}");
Expand Down
39 changes: 36 additions & 3 deletions src/values/url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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)]
Expand Down Expand Up @@ -64,11 +65,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)?;
Expand All @@ -88,6 +103,24 @@ impl<'i> ToCss for Url<'i> {
}
}

fn serialize_single_quoted_string<W: Write>(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 {
Expand Down
Loading