From 634b579b63d8c3d098c47005fa4138c0e0e9f117 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 11:16:24 +0500 Subject: [PATCH] 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 --- crates/html/src/cascade/build.rs | 16 +++++++++++++--- crates/html/tests/cascade_units.rs | 8 ++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/html/src/cascade/build.rs b/crates/html/src/cascade/build.rs index e9ba85e3c..235776e35 100644 --- a/crates/html/src/cascade/build.rs +++ b/crates/html/src/cascade/build.rs @@ -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 = 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: ®ex::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() } diff --git a/crates/html/tests/cascade_units.rs b/crates/html/tests/cascade_units.rs index 513d40e96..3131834ec 100644 --- a/crates/html/tests/cascade_units.rs +++ b/crates/html/tests/cascade_units.rs @@ -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) }" + ); }