Sampled contrast: url() is a token, not a suffix of an identifier

The sheet url rewrite and its comment scan both matched `url(` inside a
longer identifier, so `myurl(a /* url(x.png) */)` read as an unquoted
url and the comment inside it as url content. Both now require a
non-identifier byte before `url(`, the way the tokenizer does; the
regex re-emits that byte unchanged. A test pins a custom function
wrapping a comment beside a real url on the same rule.

AI assistance: Claude Code (Claude Fable 5.1), on maintainer instruction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Abdul Wahab
2026-09-10 11:16:24 +05:00
co-authored by Claude Fable 5.1
parent 4c5f3974b3
commit 634b579b63
2 changed files with 21 additions and 3 deletions
+13 -3
View File
@@ -145,8 +145,10 @@ pub fn collect_static_css_text(
// whitespace, quotes, parens, braces, or semicolons (CSS syntax), so a
// stray `url(` can never pair with a `)` in a later rule.
static CSS_URL_RE: Lazy<Regex> = Lazy::new(|| {
// The leading group keeps `url(` from matching inside a longer
// identifier such as `myurl(`; the closure emits it unchanged.
Regex::new(
r#"(?i)url\(\s*(?:"((?:[^"\\\n\r]|\\.)*)"|'((?:[^'\\\n\r]|\\.)*)'|([^)"'(\s{};]*))\s*\)"#,
r#"(?i)(^|[^A-Za-z0-9_-])url\(\s*(?:"((?:[^"\\\n\r]|\\.)*)"|'((?:[^'\\\n\r]|\\.)*)'|([^)"'(\s{};]*))\s*\)"#,
)
.expect("CSS_URL_RE")
});
@@ -177,6 +179,12 @@ pub fn rewrite_sheet_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String
/// `url()` is content, not a comment opener, and an unclosed comment runs to
/// the end of the sheet. Every span boundary sits on an ASCII byte, so the
/// spans are valid `str` indices.
/// A byte that can continue a CSS identifier, so `myurl(` is a custom
/// function and not the `url(` token.
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b >= 0x80
}
fn comment_spans(css: &str) -> Vec<(usize, usize)> {
#[derive(Clone, Copy, PartialEq)]
enum State {
@@ -207,6 +215,7 @@ fn comment_spans(css: &str) -> Vec<(usize, usize)> {
&& css[i..]
.get(..4)
.is_some_and(|s| s.eq_ignore_ascii_case("url("))
&& !(i > 0 && is_ident_byte(bytes[i - 1]))
{
i += 4;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
@@ -249,7 +258,8 @@ fn rewrite_code_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String {
CSS_URL_RE
.replace_all(css, |caps: &regex::Captures| {
let whole = caps.get(0).map(|m| m.as_str()).unwrap_or("");
let (target, quote) = match (caps.get(1), caps.get(2), caps.get(3)) {
let before = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let (target, quote) = match (caps.get(2), caps.get(3), caps.get(4)) {
(Some(m), _, _) => (m.as_str(), "\""),
(_, Some(m), _) => (m.as_str(), "'"),
(_, _, Some(m)) => (js::trim(m.as_str()), ""),
@@ -282,7 +292,7 @@ fn rewrite_code_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String {
.chars()
.any(|c| c.is_whitespace() || matches!(c, '(' | ')' | '"' | '\''));
let quote = if needs_quotes { "\"" } else { quote };
format!("url({quote}{relative}{suffix}{quote})")
format!("{before}url({quote}{relative}{suffix}{quote})")
})
.into_owned()
}
+8
View File
@@ -406,4 +406,12 @@ fn linked_sheet_urls_are_rewritten_page_relative() {
out.contains("/* real url( */ .n { background: url(css/n.png) }"),
"{out}"
);
// `myurl(` is a custom function, not the url token: its comment stays a
// comment and its argument is not rewritten.
let custom = ".q { mask: myurl(a /* url(x.png) */); background: url(q.png) }";
let out = rewrite_sheet_urls(custom, "/site/css", "/site");
assert_eq!(
out,
".q { mask: myurl(a /* url(x.png) */); background: url(css/q.png) }"
);
}