render: preserve stylesheet asset bases

This commit is contained in:
SGavrl
2026-07-26 22:33:44 +00:00
parent fa6503c560
commit f7fec080ad

View File

@@ -224,14 +224,14 @@ fn fetch_css_with_imports(
.await
.ok()?
.ok()?;
let css = String::from_utf8(resp.body).ok()?;
let css = obscura_net::decode_non_html(&resp.body, resp.content_type());
// Stop recursing at the cap but keep this level's CSS as-is.
if depth >= 4 {
return Some(css);
return Some(rebase_css_urls(&css, &parsed));
}
let (imports, stripped) = split_css_imports(&css);
if imports.is_empty() {
return Some(css);
return Some(rebase_css_urls(&css, &parsed));
}
let mut out = String::new();
for imp in imports {
@@ -242,11 +242,121 @@ fn fetch_css_with_imports(
}
}
}
out.push_str(&stripped);
out.push_str(&rebase_css_urls(&stripped, &parsed));
Some(out)
})
}
/// Preserve the URL base of a fetched stylesheet after it is materialized as
/// inline CSS. Relative `url(...)` values resolve against the stylesheet's
/// URL in browsers, not the document URL; failing to rebase them drops common
/// background, mask, cursor, and font assets from nested theme directories.
fn rebase_css_urls(css: &str, base: &url::Url) -> String {
let mut out = String::with_capacity(css.len());
let mut index = 0usize;
while index < css.len() {
let rest = &css[index..];
if rest.starts_with("/*") {
if let Some(end) = rest[2..].find("*/") {
let length = end + 4;
out.push_str(&rest[..length]);
index += length;
} else {
out.push_str(rest);
break;
}
continue;
}
let Some(first) = rest.chars().next() else { break };
if first == '"' || first == '\'' {
let quote = first;
let mut escaped = false;
let mut length = quote.len_utf8();
for ch in rest[quote.len_utf8()..].chars() {
length += ch.len_utf8();
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == quote {
break;
}
}
out.push_str(&rest[..length]);
index += length;
continue;
}
let is_url = rest
.get(..4)
.map_or(false, |prefix| prefix.eq_ignore_ascii_case("url("));
if !is_url {
out.push(first);
index += first.len_utf8();
continue;
}
let mut quote = None;
let mut escaped = false;
let mut end = None;
for (offset, ch) in rest[4..].char_indices() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
match quote {
Some(open) if ch == open => quote = None,
Some(_) => {}
None if ch == '"' || ch == '\'' => quote = Some(ch),
None if ch == ')' => {
end = Some(4 + offset);
break;
}
None => {}
}
}
let Some(end) = end else {
out.push_str(rest);
break;
};
let raw = rest[4..end].trim();
let value = if raw.len() >= 2
&& ((raw.starts_with('"') && raw.ends_with('"'))
|| (raw.starts_with('\'') && raw.ends_with('\'')))
{
&raw[1..raw.len() - 1]
} else {
raw
};
let resolved = if value.is_empty()
|| value.starts_with('#')
|| value.contains("var(")
|| url::Url::parse(value).is_ok()
{
None
} else {
base.join(value).ok().map(|url| url.to_string())
};
if let Some(resolved) = resolved {
out.push_str("url(\"");
for ch in resolved.chars() {
if ch == '\\' || ch == '"' {
out.push('\\');
}
out.push(ch);
}
out.push_str("\")");
} else {
out.push_str(&rest[..=end]);
}
index += end + 1;
}
out
}
/// Pull leading `@import` rules out of a stylesheet. Returns each import target
/// URL (skipping print-only and `prefers-color-scheme: dark` conditional
/// imports, which must not apply in our light desktop context) plus the CSS
@@ -2173,7 +2283,7 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
parse_import_url, should_restore_hydration_snapshot, split_css_imports,
parse_import_url, rebase_css_urls, should_restore_hydration_snapshot, split_css_imports,
truncate_on_char_boundary, url_matches_cdp_pattern, HydrationStats,
};
use obscura_dom::parse_html;
@@ -2225,6 +2335,31 @@ mod tests {
assert_eq!(stripped, css);
}
#[test]
fn stylesheet_asset_urls_keep_the_importing_sheets_base() {
let base = url::Url::parse("https://example.com/css/theme/app.css").unwrap();
let css = r#"
.hero { background:url("../img/hero.png") }
.icon { mask-image:URL('./icons/mark.svg') }
.data { background:url("data:image/svg+xml,<svg></svg>") }
.fragment { mask:url(#shape) }
.copy::before { content:"url(../not-an-asset.png)" }
/* url(../not-an-asset-either.png) */
"#;
let rebased = rebase_css_urls(css, &base);
assert!(rebased.contains(
r#"url("https://example.com/css/img/hero.png")"#
));
assert!(rebased.contains(
r#"url("https://example.com/css/theme/icons/mark.svg")"#
));
assert!(rebased.contains(r#"url("data:image/svg+xml,<svg></svg>")"#));
assert!(rebased.contains("url(#shape)"));
assert!(rebased.contains(r#"content:"url(../not-an-asset.png)""#));
assert!(rebased.contains("/* url(../not-an-asset-either.png) */"));
}
#[test]
fn stylesheet_rel_token_selector_includes_preloaded_stylesheets() {
let dom = parse_html(