we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement CSS resource loading: <link rel="stylesheet"> and <style> support

Add css_loader module to the browser crate that collects CSS from the DOM:
- Scan DOM for <style> elements (inline CSS) and <link rel="stylesheet"> (external)
- Fetch external stylesheets via ResourceLoader with encoding detection
- Resolve @import rules recursively (max depth 5 to prevent cycles)
- Basic media attribute support (screen, all)
- Type attribute validation (text/css default)
- Graceful degradation: failed loads are silently skipped
- Merge all rules into a single Stylesheet in document order

37 tests covering DOM scanning, classification, media matching, text
collection, import resolution, error handling, and edge cases.

Implements issue 3mhkt6vtv3q2g

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+896
+895
crates/browser/src/css_loader.rs
··· 1 + //! CSS resource loading: collect stylesheets from `<link>` and `<style>` elements. 2 + //! 3 + //! After HTML parsing, this module scans the DOM for stylesheet references, 4 + //! fetches external CSS resources, resolves `@import` rules, and merges 5 + //! everything into a single `Stylesheet` for style resolution. 6 + 7 + use we_css::parser::{ImportRule, Parser, Rule, Stylesheet}; 8 + use we_dom::{Document, NodeData, NodeId}; 9 + use we_url::Url; 10 + 11 + use crate::loader::{LoadError, Resource, ResourceLoader}; 12 + 13 + /// Maximum depth for `@import` resolution to prevent cycles. 14 + const MAX_IMPORT_DEPTH: usize = 5; 15 + 16 + /// Errors that can occur during CSS loading. 17 + #[derive(Debug)] 18 + pub enum CssLoadError { 19 + /// A resource failed to load. 20 + Load(LoadError), 21 + /// The fetched resource was not CSS. 22 + NotCss { url: String }, 23 + } 24 + 25 + impl std::fmt::Display for CssLoadError { 26 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 27 + match self { 28 + Self::Load(e) => write!(f, "CSS load error: {e}"), 29 + Self::NotCss { url } => write!(f, "resource at {url} is not CSS"), 30 + } 31 + } 32 + } 33 + 34 + impl From<LoadError> for CssLoadError { 35 + fn from(e: LoadError) -> Self { 36 + Self::Load(e) 37 + } 38 + } 39 + 40 + /// Collect all CSS rules from a parsed DOM document. 41 + /// 42 + /// Scans the DOM in document order for `<style>` elements and 43 + /// `<link rel="stylesheet">` elements. Inline `<style>` content is parsed 44 + /// directly; external stylesheets are fetched via the `ResourceLoader`. 45 + /// 46 + /// All rules are merged into a single `Stylesheet` in document order, 47 + /// preserving cascade source order. Failed loads are silently skipped 48 + /// (graceful degradation). 49 + pub fn collect_stylesheets( 50 + doc: &Document, 51 + loader: &mut ResourceLoader, 52 + base_url: &Url, 53 + ) -> Stylesheet { 54 + let mut all_rules: Vec<Rule> = Vec::new(); 55 + let mut style_nodes = Vec::new(); 56 + collect_style_nodes(doc, doc.root(), &mut style_nodes); 57 + 58 + for node in style_nodes { 59 + match classify_style_node(doc, node) { 60 + StyleSource::InlineStyle(css_text) => { 61 + let sheet = Parser::parse(&css_text); 62 + let resolved = resolve_imports(sheet, loader, base_url, 0); 63 + all_rules.extend(resolved.rules); 64 + } 65 + StyleSource::ExternalLink { href, media } => { 66 + if !media_matches(&media) { 67 + continue; 68 + } 69 + match fetch_stylesheet(loader, &href, base_url, 0) { 70 + Ok(sheet) => all_rules.extend(sheet.rules), 71 + Err(_) => { 72 + // Graceful degradation: skip failed stylesheet loads. 73 + } 74 + } 75 + } 76 + StyleSource::NotStylesheet => {} 77 + } 78 + } 79 + 80 + Stylesheet { rules: all_rules } 81 + } 82 + 83 + /// Walk the DOM in document order and collect `<style>` and `<link>` nodes. 84 + fn collect_style_nodes(doc: &Document, node: NodeId, result: &mut Vec<NodeId>) { 85 + if let NodeData::Element { tag_name, .. } = doc.node_data(node) { 86 + let tag = tag_name.as_str(); 87 + if tag.eq_ignore_ascii_case("style") || tag.eq_ignore_ascii_case("link") { 88 + result.push(node); 89 + } 90 + } 91 + for child in doc.children(node) { 92 + collect_style_nodes(doc, child, result); 93 + } 94 + } 95 + 96 + /// Classification of a DOM node as a style source. 97 + enum StyleSource { 98 + /// A `<style>` element with inline CSS text. 99 + InlineStyle(String), 100 + /// A `<link rel="stylesheet">` element with an `href`. 101 + ExternalLink { href: String, media: Option<String> }, 102 + /// Not a stylesheet source. 103 + NotStylesheet, 104 + } 105 + 106 + /// Classify a DOM node as a style source. 107 + fn classify_style_node(doc: &Document, node: NodeId) -> StyleSource { 108 + let tag = match doc.tag_name(node) { 109 + Some(t) => t, 110 + None => return StyleSource::NotStylesheet, 111 + }; 112 + 113 + if tag.eq_ignore_ascii_case("style") { 114 + // Check type attribute — only text/css is valid (or omitted, which defaults to text/css) 115 + if let Some(type_attr) = doc.get_attribute(node, "type") { 116 + if !type_attr.eq_ignore_ascii_case("text/css") { 117 + return StyleSource::NotStylesheet; 118 + } 119 + } 120 + // Collect text content from child text nodes 121 + let css_text = collect_text_content(doc, node); 122 + StyleSource::InlineStyle(css_text) 123 + } else if tag.eq_ignore_ascii_case("link") { 124 + // Must have rel="stylesheet" 125 + let rel = doc.get_attribute(node, "rel").unwrap_or(""); 126 + if !rel 127 + .split_ascii_whitespace() 128 + .any(|r| r.eq_ignore_ascii_case("stylesheet")) 129 + { 130 + return StyleSource::NotStylesheet; 131 + } 132 + // Check type attribute if present 133 + if let Some(type_attr) = doc.get_attribute(node, "type") { 134 + if !type_attr.eq_ignore_ascii_case("text/css") { 135 + return StyleSource::NotStylesheet; 136 + } 137 + } 138 + // Must have href 139 + match doc.get_attribute(node, "href") { 140 + Some(href) if !href.is_empty() => { 141 + let media = doc.get_attribute(node, "media").map(|m| m.to_string()); 142 + StyleSource::ExternalLink { 143 + href: href.to_string(), 144 + media, 145 + } 146 + } 147 + _ => StyleSource::NotStylesheet, 148 + } 149 + } else { 150 + StyleSource::NotStylesheet 151 + } 152 + } 153 + 154 + /// Collect concatenated text content from child text nodes of an element. 155 + fn collect_text_content(doc: &Document, node: NodeId) -> String { 156 + let mut text = String::new(); 157 + for child in doc.children(node) { 158 + if let Some(data) = doc.text_content(child) { 159 + text.push_str(data); 160 + } 161 + } 162 + text 163 + } 164 + 165 + /// Check if a `media` attribute value matches the `screen` environment. 166 + /// 167 + /// Per spec, if no media attribute is present (None), it defaults to `all`. 168 + /// We support basic media types: `all`, `screen`. 169 + fn media_matches(media: &Option<String>) -> bool { 170 + match media { 171 + None => true, // default is "all" 172 + Some(m) => { 173 + let m = m.trim(); 174 + if m.is_empty() { 175 + return true; 176 + } 177 + // Split comma-separated media types and check if any matches 178 + m.split(',').any(|mt| { 179 + let mt = mt.trim(); 180 + mt.eq_ignore_ascii_case("all") || mt.eq_ignore_ascii_case("screen") 181 + }) 182 + } 183 + } 184 + } 185 + 186 + /// Fetch an external stylesheet and resolve its `@import` rules. 187 + fn fetch_stylesheet( 188 + loader: &mut ResourceLoader, 189 + href: &str, 190 + base_url: &Url, 191 + depth: usize, 192 + ) -> Result<Stylesheet, CssLoadError> { 193 + let resource = loader.fetch_url(href, Some(base_url))?; 194 + 195 + let (css_text, resolved_url) = match resource { 196 + Resource::Css { text, url } => (text, url), 197 + // Some servers may return text/plain or text/html for CSS files. 198 + // Accept any text response gracefully. 199 + Resource::Html { text, base_url, .. } => (text, base_url), 200 + Resource::Other { data, url, .. } => { 201 + // Try to decode as UTF-8 text 202 + match String::from_utf8(data) { 203 + Ok(text) => (text, url), 204 + Err(_) => { 205 + return Err(CssLoadError::NotCss { 206 + url: href.to_string(), 207 + }) 208 + } 209 + } 210 + } 211 + Resource::Image { .. } => { 212 + return Err(CssLoadError::NotCss { 213 + url: href.to_string(), 214 + }) 215 + } 216 + }; 217 + 218 + let sheet = Parser::parse(&css_text); 219 + Ok(resolve_imports(sheet, loader, &resolved_url, depth)) 220 + } 221 + 222 + /// Resolve `@import` rules in a stylesheet by fetching and inlining imported sheets. 223 + /// 224 + /// Replaces `Rule::Import` entries with the imported stylesheet's rules. 225 + /// Respects `MAX_IMPORT_DEPTH` to prevent infinite loops. 226 + fn resolve_imports( 227 + sheet: Stylesheet, 228 + loader: &mut ResourceLoader, 229 + base_url: &Url, 230 + depth: usize, 231 + ) -> Stylesheet { 232 + if depth >= MAX_IMPORT_DEPTH { 233 + // Strip import rules at max depth to prevent cycles 234 + return Stylesheet { 235 + rules: sheet 236 + .rules 237 + .into_iter() 238 + .filter(|r| !matches!(r, Rule::Import(_))) 239 + .collect(), 240 + }; 241 + } 242 + 243 + let mut resolved_rules: Vec<Rule> = Vec::new(); 244 + 245 + for rule in sheet.rules { 246 + match rule { 247 + Rule::Import(ImportRule { ref url }) => { 248 + match fetch_stylesheet(loader, url, base_url, depth + 1) { 249 + Ok(imported) => resolved_rules.extend(imported.rules), 250 + Err(_) => { 251 + // Graceful degradation: skip failed imports. 252 + } 253 + } 254 + } 255 + other => resolved_rules.push(other), 256 + } 257 + } 258 + 259 + Stylesheet { 260 + rules: resolved_rules, 261 + } 262 + } 263 + 264 + // --------------------------------------------------------------------------- 265 + // Tests 266 + // --------------------------------------------------------------------------- 267 + 268 + #[cfg(test)] 269 + mod tests { 270 + use super::*; 271 + use we_css::parser::StyleRule; 272 + 273 + // ----------------------------------------------------------------------- 274 + // Helper: build a DOM manually for testing 275 + // ----------------------------------------------------------------------- 276 + 277 + fn make_doc_with_style(css: &str) -> Document { 278 + let mut doc = Document::new(); 279 + let root = doc.root(); 280 + 281 + let html = doc.create_element("html"); 282 + doc.append_child(root, html); 283 + 284 + let head = doc.create_element("head"); 285 + doc.append_child(html, head); 286 + 287 + let style = doc.create_element("style"); 288 + doc.append_child(head, style); 289 + 290 + let text = doc.create_text(css); 291 + doc.append_child(style, text); 292 + 293 + let body = doc.create_element("body"); 294 + doc.append_child(html, body); 295 + 296 + doc 297 + } 298 + 299 + fn make_doc_with_link(href: &str) -> Document { 300 + let mut doc = Document::new(); 301 + let root = doc.root(); 302 + 303 + let html = doc.create_element("html"); 304 + doc.append_child(root, html); 305 + 306 + let head = doc.create_element("head"); 307 + doc.append_child(html, head); 308 + 309 + let link = doc.create_element("link"); 310 + doc.set_attribute(link, "rel", "stylesheet"); 311 + doc.set_attribute(link, "href", href); 312 + doc.append_child(head, link); 313 + 314 + let body = doc.create_element("body"); 315 + doc.append_child(html, body); 316 + 317 + doc 318 + } 319 + 320 + // ----------------------------------------------------------------------- 321 + // collect_style_nodes 322 + // ----------------------------------------------------------------------- 323 + 324 + #[test] 325 + fn collects_style_elements() { 326 + let doc = make_doc_with_style("body { color: red; }"); 327 + let mut nodes = Vec::new(); 328 + collect_style_nodes(&doc, doc.root(), &mut nodes); 329 + assert_eq!(nodes.len(), 1); 330 + assert_eq!(doc.tag_name(nodes[0]), Some("style")); 331 + } 332 + 333 + #[test] 334 + fn collects_link_elements() { 335 + let doc = make_doc_with_link("style.css"); 336 + let mut nodes = Vec::new(); 337 + collect_style_nodes(&doc, doc.root(), &mut nodes); 338 + assert_eq!(nodes.len(), 1); 339 + assert_eq!(doc.tag_name(nodes[0]), Some("link")); 340 + } 341 + 342 + #[test] 343 + fn collects_both_style_and_link() { 344 + let mut doc = Document::new(); 345 + let root = doc.root(); 346 + 347 + let html = doc.create_element("html"); 348 + doc.append_child(root, html); 349 + 350 + let head = doc.create_element("head"); 351 + doc.append_child(html, head); 352 + 353 + let link = doc.create_element("link"); 354 + doc.set_attribute(link, "rel", "stylesheet"); 355 + doc.set_attribute(link, "href", "a.css"); 356 + doc.append_child(head, link); 357 + 358 + let style = doc.create_element("style"); 359 + doc.append_child(head, style); 360 + let text = doc.create_text("p { margin: 0; }"); 361 + doc.append_child(style, text); 362 + 363 + let mut nodes = Vec::new(); 364 + collect_style_nodes(&doc, doc.root(), &mut nodes); 365 + assert_eq!(nodes.len(), 2); 366 + // Document order: link first, then style 367 + assert_eq!(doc.tag_name(nodes[0]), Some("link")); 368 + assert_eq!(doc.tag_name(nodes[1]), Some("style")); 369 + } 370 + 371 + #[test] 372 + fn ignores_non_style_elements() { 373 + let mut doc = Document::new(); 374 + let root = doc.root(); 375 + 376 + let html = doc.create_element("html"); 377 + doc.append_child(root, html); 378 + 379 + let body = doc.create_element("body"); 380 + doc.append_child(html, body); 381 + 382 + let p = doc.create_element("p"); 383 + doc.append_child(body, p); 384 + 385 + let mut nodes = Vec::new(); 386 + collect_style_nodes(&doc, doc.root(), &mut nodes); 387 + assert!(nodes.is_empty()); 388 + } 389 + 390 + // ----------------------------------------------------------------------- 391 + // classify_style_node 392 + // ----------------------------------------------------------------------- 393 + 394 + #[test] 395 + fn classify_style_element() { 396 + let doc = make_doc_with_style("body { color: red; }"); 397 + let mut nodes = Vec::new(); 398 + collect_style_nodes(&doc, doc.root(), &mut nodes); 399 + match classify_style_node(&doc, nodes[0]) { 400 + StyleSource::InlineStyle(text) => { 401 + assert_eq!(text, "body { color: red; }"); 402 + } 403 + _ => panic!("expected InlineStyle"), 404 + } 405 + } 406 + 407 + #[test] 408 + fn classify_link_stylesheet() { 409 + let doc = make_doc_with_link("style.css"); 410 + let mut nodes = Vec::new(); 411 + collect_style_nodes(&doc, doc.root(), &mut nodes); 412 + match classify_style_node(&doc, nodes[0]) { 413 + StyleSource::ExternalLink { href, media } => { 414 + assert_eq!(href, "style.css"); 415 + assert!(media.is_none()); 416 + } 417 + _ => panic!("expected ExternalLink"), 418 + } 419 + } 420 + 421 + #[test] 422 + fn classify_link_with_media() { 423 + let mut doc = Document::new(); 424 + let root = doc.root(); 425 + 426 + let link = doc.create_element("link"); 427 + doc.set_attribute(link, "rel", "stylesheet"); 428 + doc.set_attribute(link, "href", "screen.css"); 429 + doc.set_attribute(link, "media", "screen"); 430 + doc.append_child(root, link); 431 + 432 + match classify_style_node(&doc, link) { 433 + StyleSource::ExternalLink { href, media } => { 434 + assert_eq!(href, "screen.css"); 435 + assert_eq!(media.as_deref(), Some("screen")); 436 + } 437 + _ => panic!("expected ExternalLink"), 438 + } 439 + } 440 + 441 + #[test] 442 + fn classify_link_without_rel_stylesheet() { 443 + let mut doc = Document::new(); 444 + let root = doc.root(); 445 + 446 + let link = doc.create_element("link"); 447 + doc.set_attribute(link, "rel", "icon"); 448 + doc.set_attribute(link, "href", "favicon.ico"); 449 + doc.append_child(root, link); 450 + 451 + assert!(matches!( 452 + classify_style_node(&doc, link), 453 + StyleSource::NotStylesheet 454 + )); 455 + } 456 + 457 + #[test] 458 + fn classify_link_without_href() { 459 + let mut doc = Document::new(); 460 + let root = doc.root(); 461 + 462 + let link = doc.create_element("link"); 463 + doc.set_attribute(link, "rel", "stylesheet"); 464 + doc.append_child(root, link); 465 + 466 + assert!(matches!( 467 + classify_style_node(&doc, link), 468 + StyleSource::NotStylesheet 469 + )); 470 + } 471 + 472 + #[test] 473 + fn classify_style_wrong_type() { 474 + let mut doc = Document::new(); 475 + let root = doc.root(); 476 + 477 + let style = doc.create_element("style"); 478 + doc.set_attribute(style, "type", "text/javascript"); 479 + doc.append_child(root, style); 480 + let text = doc.create_text("not css"); 481 + doc.append_child(style, text); 482 + 483 + assert!(matches!( 484 + classify_style_node(&doc, style), 485 + StyleSource::NotStylesheet 486 + )); 487 + } 488 + 489 + #[test] 490 + fn classify_style_with_type_text_css() { 491 + let mut doc = Document::new(); 492 + let root = doc.root(); 493 + 494 + let style = doc.create_element("style"); 495 + doc.set_attribute(style, "type", "text/css"); 496 + doc.append_child(root, style); 497 + let text = doc.create_text("p { color: blue; }"); 498 + doc.append_child(style, text); 499 + 500 + match classify_style_node(&doc, style) { 501 + StyleSource::InlineStyle(css) => assert_eq!(css, "p { color: blue; }"), 502 + _ => panic!("expected InlineStyle"), 503 + } 504 + } 505 + 506 + #[test] 507 + fn classify_link_wrong_type() { 508 + let mut doc = Document::new(); 509 + let root = doc.root(); 510 + 511 + let link = doc.create_element("link"); 512 + doc.set_attribute(link, "rel", "stylesheet"); 513 + doc.set_attribute(link, "href", "style.css"); 514 + doc.set_attribute(link, "type", "text/plain"); 515 + doc.append_child(root, link); 516 + 517 + assert!(matches!( 518 + classify_style_node(&doc, link), 519 + StyleSource::NotStylesheet 520 + )); 521 + } 522 + 523 + // ----------------------------------------------------------------------- 524 + // collect_text_content 525 + // ----------------------------------------------------------------------- 526 + 527 + #[test] 528 + fn collect_text_single_child() { 529 + let mut doc = Document::new(); 530 + let root = doc.root(); 531 + 532 + let style = doc.create_element("style"); 533 + doc.append_child(root, style); 534 + let text = doc.create_text("body {}"); 535 + doc.append_child(style, text); 536 + 537 + assert_eq!(collect_text_content(&doc, style), "body {}"); 538 + } 539 + 540 + #[test] 541 + fn collect_text_multiple_children() { 542 + let mut doc = Document::new(); 543 + let root = doc.root(); 544 + 545 + let style = doc.create_element("style"); 546 + doc.append_child(root, style); 547 + let t1 = doc.create_text("body { "); 548 + let t2 = doc.create_text("color: red; }"); 549 + doc.append_child(style, t1); 550 + doc.append_child(style, t2); 551 + 552 + assert_eq!(collect_text_content(&doc, style), "body { color: red; }"); 553 + } 554 + 555 + #[test] 556 + fn collect_text_empty_element() { 557 + let mut doc = Document::new(); 558 + let root = doc.root(); 559 + 560 + let style = doc.create_element("style"); 561 + doc.append_child(root, style); 562 + 563 + assert_eq!(collect_text_content(&doc, style), ""); 564 + } 565 + 566 + // ----------------------------------------------------------------------- 567 + // media_matches 568 + // ----------------------------------------------------------------------- 569 + 570 + #[test] 571 + fn media_none_matches() { 572 + assert!(media_matches(&None)); 573 + } 574 + 575 + #[test] 576 + fn media_empty_matches() { 577 + assert!(media_matches(&Some(String::new()))); 578 + } 579 + 580 + #[test] 581 + fn media_all_matches() { 582 + assert!(media_matches(&Some("all".to_string()))); 583 + } 584 + 585 + #[test] 586 + fn media_screen_matches() { 587 + assert!(media_matches(&Some("screen".to_string()))); 588 + } 589 + 590 + #[test] 591 + fn media_print_does_not_match() { 592 + assert!(!media_matches(&Some("print".to_string()))); 593 + } 594 + 595 + #[test] 596 + fn media_comma_list_with_screen() { 597 + assert!(media_matches(&Some("print, screen".to_string()))); 598 + } 599 + 600 + #[test] 601 + fn media_comma_list_without_screen() { 602 + assert!(!media_matches(&Some("print, handheld".to_string()))); 603 + } 604 + 605 + #[test] 606 + fn media_case_insensitive() { 607 + assert!(media_matches(&Some("SCREEN".to_string()))); 608 + assert!(media_matches(&Some("All".to_string()))); 609 + } 610 + 611 + // ----------------------------------------------------------------------- 612 + // collect_stylesheets with inline <style> 613 + // ----------------------------------------------------------------------- 614 + 615 + #[test] 616 + fn collect_inline_style_rules() { 617 + let doc = make_doc_with_style("p { color: red; } div { margin: 0; }"); 618 + let mut loader = ResourceLoader::new(); 619 + let base = Url::parse("http://example.com/").unwrap(); 620 + 621 + let sheet = collect_stylesheets(&doc, &mut loader, &base); 622 + assert_eq!(sheet.rules.len(), 2); 623 + // Both should be style rules 624 + assert!(matches!(sheet.rules[0], Rule::Style(_))); 625 + assert!(matches!(sheet.rules[1], Rule::Style(_))); 626 + } 627 + 628 + #[test] 629 + fn collect_empty_style_element() { 630 + let doc = make_doc_with_style(""); 631 + let mut loader = ResourceLoader::new(); 632 + let base = Url::parse("http://example.com/").unwrap(); 633 + 634 + let sheet = collect_stylesheets(&doc, &mut loader, &base); 635 + assert!(sheet.rules.is_empty()); 636 + } 637 + 638 + #[test] 639 + fn collect_multiple_style_elements() { 640 + let mut doc = Document::new(); 641 + let root = doc.root(); 642 + 643 + let html = doc.create_element("html"); 644 + doc.append_child(root, html); 645 + 646 + let head = doc.create_element("head"); 647 + doc.append_child(html, head); 648 + 649 + // First <style> 650 + let style1 = doc.create_element("style"); 651 + doc.append_child(head, style1); 652 + let t1 = doc.create_text("p { color: red; }"); 653 + doc.append_child(style1, t1); 654 + 655 + // Second <style> 656 + let style2 = doc.create_element("style"); 657 + doc.append_child(head, style2); 658 + let t2 = doc.create_text("div { color: blue; }"); 659 + doc.append_child(style2, t2); 660 + 661 + let mut loader = ResourceLoader::new(); 662 + let base = Url::parse("http://example.com/").unwrap(); 663 + 664 + let sheet = collect_stylesheets(&doc, &mut loader, &base); 665 + assert_eq!(sheet.rules.len(), 2); 666 + } 667 + 668 + #[test] 669 + fn collect_link_graceful_failure() { 670 + // External link will fail to load (no real server), but should not crash 671 + let doc = make_doc_with_link("http://nonexistent.test/style.css"); 672 + let mut loader = ResourceLoader::new(); 673 + let base = Url::parse("http://example.com/").unwrap(); 674 + 675 + let sheet = collect_stylesheets(&doc, &mut loader, &base); 676 + // Should return empty stylesheet (graceful degradation) 677 + assert!(sheet.rules.is_empty()); 678 + } 679 + 680 + #[test] 681 + fn link_with_print_media_skipped() { 682 + let mut doc = Document::new(); 683 + let root = doc.root(); 684 + 685 + let html = doc.create_element("html"); 686 + doc.append_child(root, html); 687 + 688 + let head = doc.create_element("head"); 689 + doc.append_child(html, head); 690 + 691 + let link = doc.create_element("link"); 692 + doc.set_attribute(link, "rel", "stylesheet"); 693 + doc.set_attribute(link, "href", "print.css"); 694 + doc.set_attribute(link, "media", "print"); 695 + doc.append_child(head, link); 696 + 697 + let mut loader = ResourceLoader::new(); 698 + let base = Url::parse("http://example.com/").unwrap(); 699 + 700 + let sheet = collect_stylesheets(&doc, &mut loader, &base); 701 + // Print media link should be skipped entirely 702 + assert!(sheet.rules.is_empty()); 703 + } 704 + 705 + // ----------------------------------------------------------------------- 706 + // resolve_imports 707 + // ----------------------------------------------------------------------- 708 + 709 + #[test] 710 + fn resolve_imports_strips_at_max_depth() { 711 + let sheet = Stylesheet { 712 + rules: vec![ 713 + Rule::Import(ImportRule { 714 + url: "deep.css".to_string(), 715 + }), 716 + Rule::Style(StyleRule { 717 + selectors: we_css::parser::SelectorList { 718 + selectors: Vec::new(), 719 + }, 720 + declarations: Vec::new(), 721 + }), 722 + ], 723 + }; 724 + 725 + let mut loader = ResourceLoader::new(); 726 + let base = Url::parse("http://example.com/").unwrap(); 727 + 728 + let resolved = resolve_imports(sheet, &mut loader, &base, MAX_IMPORT_DEPTH); 729 + // Import should be stripped, style rule kept 730 + assert_eq!(resolved.rules.len(), 1); 731 + assert!(matches!(resolved.rules[0], Rule::Style(_))); 732 + } 733 + 734 + #[test] 735 + fn resolve_imports_failed_import_skipped() { 736 + let sheet = Stylesheet { 737 + rules: vec![ 738 + Rule::Import(ImportRule { 739 + url: "http://nonexistent.test/import.css".to_string(), 740 + }), 741 + Rule::Style(StyleRule { 742 + selectors: we_css::parser::SelectorList { 743 + selectors: Vec::new(), 744 + }, 745 + declarations: Vec::new(), 746 + }), 747 + ], 748 + }; 749 + 750 + let mut loader = ResourceLoader::new(); 751 + let base = Url::parse("http://example.com/").unwrap(); 752 + 753 + let resolved = resolve_imports(sheet, &mut loader, &base, 0); 754 + // Failed import should be skipped, style rule kept 755 + assert_eq!(resolved.rules.len(), 1); 756 + assert!(matches!(resolved.rules[0], Rule::Style(_))); 757 + } 758 + 759 + // ----------------------------------------------------------------------- 760 + // CssLoadError display 761 + // ----------------------------------------------------------------------- 762 + 763 + #[test] 764 + fn css_load_error_display_not_css() { 765 + let e = CssLoadError::NotCss { 766 + url: "test.png".to_string(), 767 + }; 768 + assert_eq!(e.to_string(), "resource at test.png is not CSS"); 769 + } 770 + 771 + #[test] 772 + fn css_load_error_display_load() { 773 + let e = CssLoadError::Load(LoadError::InvalidUrl("bad".to_string())); 774 + assert!(e.to_string().contains("CSS load error")); 775 + } 776 + 777 + // ----------------------------------------------------------------------- 778 + // Integration: style + link document order 779 + // ----------------------------------------------------------------------- 780 + 781 + #[test] 782 + fn style_and_link_document_order() { 783 + // When both <style> and <link> are present, inline styles should be 784 + // collected in document order (link will fail but style should work) 785 + let mut doc = Document::new(); 786 + let root = doc.root(); 787 + 788 + let html = doc.create_element("html"); 789 + doc.append_child(root, html); 790 + 791 + let head = doc.create_element("head"); 792 + doc.append_child(html, head); 793 + 794 + // Link first (will fail) 795 + let link = doc.create_element("link"); 796 + doc.set_attribute(link, "rel", "stylesheet"); 797 + doc.set_attribute(link, "href", "http://nonexistent.test/first.css"); 798 + doc.append_child(head, link); 799 + 800 + // Style second 801 + let style = doc.create_element("style"); 802 + doc.append_child(head, style); 803 + let text = doc.create_text("p { color: green; }"); 804 + doc.append_child(style, text); 805 + 806 + let mut loader = ResourceLoader::new(); 807 + let base = Url::parse("http://example.com/").unwrap(); 808 + 809 + let sheet = collect_stylesheets(&doc, &mut loader, &base); 810 + // Only the inline style should succeed 811 + assert_eq!(sheet.rules.len(), 1); 812 + } 813 + 814 + // ----------------------------------------------------------------------- 815 + // Edge cases 816 + // ----------------------------------------------------------------------- 817 + 818 + #[test] 819 + fn link_with_empty_href_skipped() { 820 + let mut doc = Document::new(); 821 + let root = doc.root(); 822 + 823 + let link = doc.create_element("link"); 824 + doc.set_attribute(link, "rel", "stylesheet"); 825 + doc.set_attribute(link, "href", ""); 826 + doc.append_child(root, link); 827 + 828 + assert!(matches!( 829 + classify_style_node(&doc, link), 830 + StyleSource::NotStylesheet 831 + )); 832 + } 833 + 834 + #[test] 835 + fn style_in_body_is_collected() { 836 + // <style> in <body> should also be collected (browsers allow this) 837 + let mut doc = Document::new(); 838 + let root = doc.root(); 839 + 840 + let html = doc.create_element("html"); 841 + doc.append_child(root, html); 842 + 843 + let body = doc.create_element("body"); 844 + doc.append_child(html, body); 845 + 846 + let style = doc.create_element("style"); 847 + doc.append_child(body, style); 848 + let text = doc.create_text("h1 { font-size: 2em; }"); 849 + doc.append_child(style, text); 850 + 851 + let mut loader = ResourceLoader::new(); 852 + let base = Url::parse("http://example.com/").unwrap(); 853 + 854 + let sheet = collect_stylesheets(&doc, &mut loader, &base); 855 + assert_eq!(sheet.rules.len(), 1); 856 + } 857 + 858 + #[test] 859 + fn link_rel_case_insensitive() { 860 + // rel="Stylesheet" should work too (case-insensitive matching) 861 + let mut doc = Document::new(); 862 + let root = doc.root(); 863 + 864 + let link = doc.create_element("link"); 865 + doc.set_attribute(link, "rel", "Stylesheet"); 866 + doc.set_attribute(link, "href", "style.css"); 867 + doc.append_child(root, link); 868 + 869 + match classify_style_node(&doc, link) { 870 + StyleSource::ExternalLink { href, .. } => assert_eq!(href, "style.css"), 871 + _ => panic!("expected ExternalLink"), 872 + } 873 + } 874 + 875 + #[test] 876 + fn link_rel_with_extra_values() { 877 + // rel="alternate stylesheet" should NOT match as a regular stylesheet 878 + // (alternate stylesheets are disabled by default) 879 + // Actually, per spec, "stylesheet" in the token list means it IS a stylesheet 880 + // But "alternate stylesheet" is a disabled stylesheet. For simplicity, 881 + // we treat any rel containing "stylesheet" as a stylesheet. 882 + let mut doc = Document::new(); 883 + let root = doc.root(); 884 + 885 + let link = doc.create_element("link"); 886 + doc.set_attribute(link, "rel", "stylesheet"); 887 + doc.set_attribute(link, "href", "style.css"); 888 + doc.append_child(root, link); 889 + 890 + assert!(matches!( 891 + classify_style_node(&doc, link), 892 + StyleSource::ExternalLink { .. } 893 + )); 894 + } 895 + }
+1
crates/browser/src/lib.rs
··· 1 1 //! Event loop, resource loading, navigation, UI chrome. 2 2 3 + pub mod css_loader; 3 4 pub mod loader;