we (web engine): Experimental web browser project to understand the limits of Claude
at main 895 lines 29 kB view raw
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 7use we_css::parser::{ImportRule, Parser, Rule, Stylesheet}; 8use we_dom::{Document, NodeData, NodeId}; 9use we_url::Url; 10 11use crate::loader::{LoadError, Resource, ResourceLoader}; 12 13/// Maximum depth for `@import` resolution to prevent cycles. 14const MAX_IMPORT_DEPTH: usize = 5; 15 16/// Errors that can occur during CSS loading. 17#[derive(Debug)] 18pub enum CssLoadError { 19 /// A resource failed to load. 20 Load(LoadError), 21 /// The fetched resource was not CSS. 22 NotCss { url: String }, 23} 24 25impl 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 34impl 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). 49pub 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. 84fn 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. 97enum 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. 107fn 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. 155fn 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`. 169fn 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. 187fn 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. 226fn 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)] 269mod 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}