From 4c5f3974b37e394377e09137bac4e2b893c598f7 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 11:03:50 +0500 Subject: [PATCH] Sampled contrast: comment scan honors CSS strings and url tokens The sheet url rewrite skipped comments by looking for `/*` in the raw text, which is not how the CSS tokenizer sees it: a `/*` inside a quoted string or an unquoted url() is content, so `content: "a/*b"` turned the rest of the sheet into a comment and its relative urls stayed sheet-relative, where the sampler could not find them. The scan is a small state machine now (code, string, unquoted url) that mirrors the tokenizer: a string ends at its quote or at a raw newline, a url at its closing paren, and only a `/*` in code opens a comment. Tests pin a string with a marker, a url with a marker, and a real comment after a string. AI assistance: Claude Code (Claude Fable 5.1), on maintainer instruction. Co-Authored-By: Claude Fable 5.1 --- crates/html/src/cascade/build.rs | 90 ++++++++++++++++++++++++++---- crates/html/tests/cascade_units.rs | 17 ++++++ 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/crates/html/src/cascade/build.rs b/crates/html/src/cascade/build.rs index 70218d257..e9ba85e3c 100644 --- a/crates/html/src/cascade/build.rs +++ b/crates/html/src/cascade/build.rs @@ -162,21 +162,89 @@ static URL_SCHEME_RE: Lazy = /// untouched, so nothing inside one can reach the rules after it. pub fn rewrite_sheet_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String { let mut out = String::with_capacity(css.len()); - let mut rest = css; - while let Some(start) = rest.find("/*") { - out.push_str(&rewrite_code_urls(&rest[..start], sheet_dir, page_dir)); - // An unclosed comment runs to the end of the sheet, as in CSS. - let end = rest[start + 2..] - .find("*/") - .map(|i| start + 2 + i + 2) - .unwrap_or(rest.len()); - out.push_str(&rest[start..end]); - rest = &rest[end..]; + let mut cursor = 0usize; + for (start, end) in comment_spans(css) { + out.push_str(&rewrite_code_urls(&css[cursor..start], sheet_dir, page_dir)); + out.push_str(&css[start..end]); + cursor = end; } - out.push_str(&rewrite_code_urls(rest, sheet_dir, page_dir)); + out.push_str(&rewrite_code_urls(&css[cursor..], sheet_dir, page_dir)); out } +/// The byte spans of every `/* */` comment in `css`, found the way the CSS +/// tokenizer finds them: a `/*` inside a quoted string or an unquoted +/// `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. +fn comment_spans(css: &str) -> Vec<(usize, usize)> { + #[derive(Clone, Copy, PartialEq)] + enum State { + Code, + Str(u8), + Url, + } + let bytes = css.as_bytes(); + let mut spans = Vec::new(); + let mut state = State::Code; + let mut i = 0usize; + while i < bytes.len() { + let b = bytes[i]; + match state { + State::Code => { + if b == b'/' && bytes.get(i + 1) == Some(&b'*') { + let end = css[i + 2..] + .find("*/") + .map(|at| i + 2 + at + 2) + .unwrap_or(bytes.len()); + spans.push((i, end)); + i = end; + continue; + } + if b == b'"' || b == b'\'' { + state = State::Str(b); + } else if b.eq_ignore_ascii_case(&b'u') + && css[i..] + .get(..4) + .is_some_and(|s| s.eq_ignore_ascii_case("url(")) + { + i += 4; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + // A quoted argument is a string like any other; an + // unquoted one runs to the closing paren. + if i < bytes.len() && bytes[i] != b'"' && bytes[i] != b'\'' { + state = State::Url; + } + continue; + } + } + State::Str(quote) => { + if b == b'\\' { + i += 2; + continue; + } + // A raw newline ends a string in CSS (a bad-string token). + if b == quote || b == b'\n' || b == b'\r' { + state = State::Code; + } + } + State::Url => { + if b == b'\\' { + i += 2; + continue; + } + if b == b')' { + state = State::Code; + } + } + } + i += 1; + } + spans +} + fn rewrite_code_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String { CSS_URL_RE .replace_all(css, |caps: ®ex::Captures| { diff --git a/crates/html/tests/cascade_units.rs b/crates/html/tests/cascade_units.rs index 4afd266ed..513d40e96 100644 --- a/crates/html/tests/cascade_units.rs +++ b/crates/html/tests/cascade_units.rs @@ -389,4 +389,21 @@ fn linked_sheet_urls_are_rewritten_page_relative() { assert!(out.contains(".i { background: url(\"oops }"), "{out}"); assert!(out.contains(".j { background: url(css/b.png) }"), "{out}"); assert!(out.ends_with("/* url(unterminated.png"), "{out}"); + // A `/*` inside a string or an unquoted url is content, not a comment, + // so the rules after it are still rewritten. + let strings = concat!( + ".k { content: \"a/*b\"; background: url(k.png) }\n", + ".l { background: url(l/*.png) }\n", + ".m { content: 'c/*d'; } /* real url( */ .n { background: url(n.png) }\n", + ); + let out = rewrite_sheet_urls(strings, "/site/css", "/site"); + assert!( + out.contains(".k { content: \"a/*b\"; background: url(css/k.png) }"), + "{out}" + ); + assert!(out.contains(".l { background: url(css/l/*.png) }"), "{out}"); + assert!( + out.contains("/* real url( */ .n { background: url(css/n.png) }"), + "{out}" + ); }