we (web engine): Experimental web browser project to understand the limits of Claude
at encoding-sniffing 896 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::Script { text, url } => (text, url), 212 Resource::Image { .. } => { 213 return Err(CssLoadError::NotCss { 214 url: href.to_string(), 215 }) 216 } 217 }; 218 219 let sheet = Parser::parse(&css_text); 220 Ok(resolve_imports(sheet, loader, &resolved_url, depth)) 221} 222 223/// Resolve `@import` rules in a stylesheet by fetching and inlining imported sheets. 224/// 225/// Replaces `Rule::Import` entries with the imported stylesheet's rules. 226/// Respects `MAX_IMPORT_DEPTH` to prevent infinite loops. 227fn resolve_imports( 228 sheet: Stylesheet, 229 loader: &mut ResourceLoader, 230 base_url: &Url, 231 depth: usize, 232) -> Stylesheet { 233 if depth >= MAX_IMPORT_DEPTH { 234 // Strip import rules at max depth to prevent cycles 235 return Stylesheet { 236 rules: sheet 237 .rules 238 .into_iter() 239 .filter(|r| !matches!(r, Rule::Import(_))) 240 .collect(), 241 }; 242 } 243 244 let mut resolved_rules: Vec<Rule> = Vec::new(); 245 246 for rule in sheet.rules { 247 match rule { 248 Rule::Import(ImportRule { ref url }) => { 249 match fetch_stylesheet(loader, url, base_url, depth + 1) { 250 Ok(imported) => resolved_rules.extend(imported.rules), 251 Err(_) => { 252 // Graceful degradation: skip failed imports. 253 } 254 } 255 } 256 other => resolved_rules.push(other), 257 } 258 } 259 260 Stylesheet { 261 rules: resolved_rules, 262 } 263} 264 265// --------------------------------------------------------------------------- 266// Tests 267// --------------------------------------------------------------------------- 268 269#[cfg(test)] 270mod tests { 271 use super::*; 272 use we_css::parser::StyleRule; 273 274 // ----------------------------------------------------------------------- 275 // Helper: build a DOM manually for testing 276 // ----------------------------------------------------------------------- 277 278 fn make_doc_with_style(css: &str) -> Document { 279 let mut doc = Document::new(); 280 let root = doc.root(); 281 282 let html = doc.create_element("html"); 283 doc.append_child(root, html); 284 285 let head = doc.create_element("head"); 286 doc.append_child(html, head); 287 288 let style = doc.create_element("style"); 289 doc.append_child(head, style); 290 291 let text = doc.create_text(css); 292 doc.append_child(style, text); 293 294 let body = doc.create_element("body"); 295 doc.append_child(html, body); 296 297 doc 298 } 299 300 fn make_doc_with_link(href: &str) -> Document { 301 let mut doc = Document::new(); 302 let root = doc.root(); 303 304 let html = doc.create_element("html"); 305 doc.append_child(root, html); 306 307 let head = doc.create_element("head"); 308 doc.append_child(html, head); 309 310 let link = doc.create_element("link"); 311 doc.set_attribute(link, "rel", "stylesheet"); 312 doc.set_attribute(link, "href", href); 313 doc.append_child(head, link); 314 315 let body = doc.create_element("body"); 316 doc.append_child(html, body); 317 318 doc 319 } 320 321 // ----------------------------------------------------------------------- 322 // collect_style_nodes 323 // ----------------------------------------------------------------------- 324 325 #[test] 326 fn collects_style_elements() { 327 let doc = make_doc_with_style("body { color: red; }"); 328 let mut nodes = Vec::new(); 329 collect_style_nodes(&doc, doc.root(), &mut nodes); 330 assert_eq!(nodes.len(), 1); 331 assert_eq!(doc.tag_name(nodes[0]), Some("style")); 332 } 333 334 #[test] 335 fn collects_link_elements() { 336 let doc = make_doc_with_link("style.css"); 337 let mut nodes = Vec::new(); 338 collect_style_nodes(&doc, doc.root(), &mut nodes); 339 assert_eq!(nodes.len(), 1); 340 assert_eq!(doc.tag_name(nodes[0]), Some("link")); 341 } 342 343 #[test] 344 fn collects_both_style_and_link() { 345 let mut doc = Document::new(); 346 let root = doc.root(); 347 348 let html = doc.create_element("html"); 349 doc.append_child(root, html); 350 351 let head = doc.create_element("head"); 352 doc.append_child(html, head); 353 354 let link = doc.create_element("link"); 355 doc.set_attribute(link, "rel", "stylesheet"); 356 doc.set_attribute(link, "href", "a.css"); 357 doc.append_child(head, link); 358 359 let style = doc.create_element("style"); 360 doc.append_child(head, style); 361 let text = doc.create_text("p { margin: 0; }"); 362 doc.append_child(style, text); 363 364 let mut nodes = Vec::new(); 365 collect_style_nodes(&doc, doc.root(), &mut nodes); 366 assert_eq!(nodes.len(), 2); 367 // Document order: link first, then style 368 assert_eq!(doc.tag_name(nodes[0]), Some("link")); 369 assert_eq!(doc.tag_name(nodes[1]), Some("style")); 370 } 371 372 #[test] 373 fn ignores_non_style_elements() { 374 let mut doc = Document::new(); 375 let root = doc.root(); 376 377 let html = doc.create_element("html"); 378 doc.append_child(root, html); 379 380 let body = doc.create_element("body"); 381 doc.append_child(html, body); 382 383 let p = doc.create_element("p"); 384 doc.append_child(body, p); 385 386 let mut nodes = Vec::new(); 387 collect_style_nodes(&doc, doc.root(), &mut nodes); 388 assert!(nodes.is_empty()); 389 } 390 391 // ----------------------------------------------------------------------- 392 // classify_style_node 393 // ----------------------------------------------------------------------- 394 395 #[test] 396 fn classify_style_element() { 397 let doc = make_doc_with_style("body { color: red; }"); 398 let mut nodes = Vec::new(); 399 collect_style_nodes(&doc, doc.root(), &mut nodes); 400 match classify_style_node(&doc, nodes[0]) { 401 StyleSource::InlineStyle(text) => { 402 assert_eq!(text, "body { color: red; }"); 403 } 404 _ => panic!("expected InlineStyle"), 405 } 406 } 407 408 #[test] 409 fn classify_link_stylesheet() { 410 let doc = make_doc_with_link("style.css"); 411 let mut nodes = Vec::new(); 412 collect_style_nodes(&doc, doc.root(), &mut nodes); 413 match classify_style_node(&doc, nodes[0]) { 414 StyleSource::ExternalLink { href, media } => { 415 assert_eq!(href, "style.css"); 416 assert!(media.is_none()); 417 } 418 _ => panic!("expected ExternalLink"), 419 } 420 } 421 422 #[test] 423 fn classify_link_with_media() { 424 let mut doc = Document::new(); 425 let root = doc.root(); 426 427 let link = doc.create_element("link"); 428 doc.set_attribute(link, "rel", "stylesheet"); 429 doc.set_attribute(link, "href", "screen.css"); 430 doc.set_attribute(link, "media", "screen"); 431 doc.append_child(root, link); 432 433 match classify_style_node(&doc, link) { 434 StyleSource::ExternalLink { href, media } => { 435 assert_eq!(href, "screen.css"); 436 assert_eq!(media.as_deref(), Some("screen")); 437 } 438 _ => panic!("expected ExternalLink"), 439 } 440 } 441 442 #[test] 443 fn classify_link_without_rel_stylesheet() { 444 let mut doc = Document::new(); 445 let root = doc.root(); 446 447 let link = doc.create_element("link"); 448 doc.set_attribute(link, "rel", "icon"); 449 doc.set_attribute(link, "href", "favicon.ico"); 450 doc.append_child(root, link); 451 452 assert!(matches!( 453 classify_style_node(&doc, link), 454 StyleSource::NotStylesheet 455 )); 456 } 457 458 #[test] 459 fn classify_link_without_href() { 460 let mut doc = Document::new(); 461 let root = doc.root(); 462 463 let link = doc.create_element("link"); 464 doc.set_attribute(link, "rel", "stylesheet"); 465 doc.append_child(root, link); 466 467 assert!(matches!( 468 classify_style_node(&doc, link), 469 StyleSource::NotStylesheet 470 )); 471 } 472 473 #[test] 474 fn classify_style_wrong_type() { 475 let mut doc = Document::new(); 476 let root = doc.root(); 477 478 let style = doc.create_element("style"); 479 doc.set_attribute(style, "type", "text/javascript"); 480 doc.append_child(root, style); 481 let text = doc.create_text("not css"); 482 doc.append_child(style, text); 483 484 assert!(matches!( 485 classify_style_node(&doc, style), 486 StyleSource::NotStylesheet 487 )); 488 } 489 490 #[test] 491 fn classify_style_with_type_text_css() { 492 let mut doc = Document::new(); 493 let root = doc.root(); 494 495 let style = doc.create_element("style"); 496 doc.set_attribute(style, "type", "text/css"); 497 doc.append_child(root, style); 498 let text = doc.create_text("p { color: blue; }"); 499 doc.append_child(style, text); 500 501 match classify_style_node(&doc, style) { 502 StyleSource::InlineStyle(css) => assert_eq!(css, "p { color: blue; }"), 503 _ => panic!("expected InlineStyle"), 504 } 505 } 506 507 #[test] 508 fn classify_link_wrong_type() { 509 let mut doc = Document::new(); 510 let root = doc.root(); 511 512 let link = doc.create_element("link"); 513 doc.set_attribute(link, "rel", "stylesheet"); 514 doc.set_attribute(link, "href", "style.css"); 515 doc.set_attribute(link, "type", "text/plain"); 516 doc.append_child(root, link); 517 518 assert!(matches!( 519 classify_style_node(&doc, link), 520 StyleSource::NotStylesheet 521 )); 522 } 523 524 // ----------------------------------------------------------------------- 525 // collect_text_content 526 // ----------------------------------------------------------------------- 527 528 #[test] 529 fn collect_text_single_child() { 530 let mut doc = Document::new(); 531 let root = doc.root(); 532 533 let style = doc.create_element("style"); 534 doc.append_child(root, style); 535 let text = doc.create_text("body {}"); 536 doc.append_child(style, text); 537 538 assert_eq!(collect_text_content(&doc, style), "body {}"); 539 } 540 541 #[test] 542 fn collect_text_multiple_children() { 543 let mut doc = Document::new(); 544 let root = doc.root(); 545 546 let style = doc.create_element("style"); 547 doc.append_child(root, style); 548 let t1 = doc.create_text("body { "); 549 let t2 = doc.create_text("color: red; }"); 550 doc.append_child(style, t1); 551 doc.append_child(style, t2); 552 553 assert_eq!(collect_text_content(&doc, style), "body { color: red; }"); 554 } 555 556 #[test] 557 fn collect_text_empty_element() { 558 let mut doc = Document::new(); 559 let root = doc.root(); 560 561 let style = doc.create_element("style"); 562 doc.append_child(root, style); 563 564 assert_eq!(collect_text_content(&doc, style), ""); 565 } 566 567 // ----------------------------------------------------------------------- 568 // media_matches 569 // ----------------------------------------------------------------------- 570 571 #[test] 572 fn media_none_matches() { 573 assert!(media_matches(&None)); 574 } 575 576 #[test] 577 fn media_empty_matches() { 578 assert!(media_matches(&Some(String::new()))); 579 } 580 581 #[test] 582 fn media_all_matches() { 583 assert!(media_matches(&Some("all".to_string()))); 584 } 585 586 #[test] 587 fn media_screen_matches() { 588 assert!(media_matches(&Some("screen".to_string()))); 589 } 590 591 #[test] 592 fn media_print_does_not_match() { 593 assert!(!media_matches(&Some("print".to_string()))); 594 } 595 596 #[test] 597 fn media_comma_list_with_screen() { 598 assert!(media_matches(&Some("print, screen".to_string()))); 599 } 600 601 #[test] 602 fn media_comma_list_without_screen() { 603 assert!(!media_matches(&Some("print, handheld".to_string()))); 604 } 605 606 #[test] 607 fn media_case_insensitive() { 608 assert!(media_matches(&Some("SCREEN".to_string()))); 609 assert!(media_matches(&Some("All".to_string()))); 610 } 611 612 // ----------------------------------------------------------------------- 613 // collect_stylesheets with inline <style> 614 // ----------------------------------------------------------------------- 615 616 #[test] 617 fn collect_inline_style_rules() { 618 let doc = make_doc_with_style("p { color: red; } div { margin: 0; }"); 619 let mut loader = ResourceLoader::new(); 620 let base = Url::parse("http://example.com/").unwrap(); 621 622 let sheet = collect_stylesheets(&doc, &mut loader, &base); 623 assert_eq!(sheet.rules.len(), 2); 624 // Both should be style rules 625 assert!(matches!(sheet.rules[0], Rule::Style(_))); 626 assert!(matches!(sheet.rules[1], Rule::Style(_))); 627 } 628 629 #[test] 630 fn collect_empty_style_element() { 631 let doc = make_doc_with_style(""); 632 let mut loader = ResourceLoader::new(); 633 let base = Url::parse("http://example.com/").unwrap(); 634 635 let sheet = collect_stylesheets(&doc, &mut loader, &base); 636 assert!(sheet.rules.is_empty()); 637 } 638 639 #[test] 640 fn collect_multiple_style_elements() { 641 let mut doc = Document::new(); 642 let root = doc.root(); 643 644 let html = doc.create_element("html"); 645 doc.append_child(root, html); 646 647 let head = doc.create_element("head"); 648 doc.append_child(html, head); 649 650 // First <style> 651 let style1 = doc.create_element("style"); 652 doc.append_child(head, style1); 653 let t1 = doc.create_text("p { color: red; }"); 654 doc.append_child(style1, t1); 655 656 // Second <style> 657 let style2 = doc.create_element("style"); 658 doc.append_child(head, style2); 659 let t2 = doc.create_text("div { color: blue; }"); 660 doc.append_child(style2, t2); 661 662 let mut loader = ResourceLoader::new(); 663 let base = Url::parse("http://example.com/").unwrap(); 664 665 let sheet = collect_stylesheets(&doc, &mut loader, &base); 666 assert_eq!(sheet.rules.len(), 2); 667 } 668 669 #[test] 670 fn collect_link_graceful_failure() { 671 // External link will fail to load (no real server), but should not crash 672 let doc = make_doc_with_link("http://nonexistent.test/style.css"); 673 let mut loader = ResourceLoader::new(); 674 let base = Url::parse("http://example.com/").unwrap(); 675 676 let sheet = collect_stylesheets(&doc, &mut loader, &base); 677 // Should return empty stylesheet (graceful degradation) 678 assert!(sheet.rules.is_empty()); 679 } 680 681 #[test] 682 fn link_with_print_media_skipped() { 683 let mut doc = Document::new(); 684 let root = doc.root(); 685 686 let html = doc.create_element("html"); 687 doc.append_child(root, html); 688 689 let head = doc.create_element("head"); 690 doc.append_child(html, head); 691 692 let link = doc.create_element("link"); 693 doc.set_attribute(link, "rel", "stylesheet"); 694 doc.set_attribute(link, "href", "print.css"); 695 doc.set_attribute(link, "media", "print"); 696 doc.append_child(head, link); 697 698 let mut loader = ResourceLoader::new(); 699 let base = Url::parse("http://example.com/").unwrap(); 700 701 let sheet = collect_stylesheets(&doc, &mut loader, &base); 702 // Print media link should be skipped entirely 703 assert!(sheet.rules.is_empty()); 704 } 705 706 // ----------------------------------------------------------------------- 707 // resolve_imports 708 // ----------------------------------------------------------------------- 709 710 #[test] 711 fn resolve_imports_strips_at_max_depth() { 712 let sheet = Stylesheet { 713 rules: vec![ 714 Rule::Import(ImportRule { 715 url: "deep.css".to_string(), 716 }), 717 Rule::Style(StyleRule { 718 selectors: we_css::parser::SelectorList { 719 selectors: Vec::new(), 720 }, 721 declarations: Vec::new(), 722 }), 723 ], 724 }; 725 726 let mut loader = ResourceLoader::new(); 727 let base = Url::parse("http://example.com/").unwrap(); 728 729 let resolved = resolve_imports(sheet, &mut loader, &base, MAX_IMPORT_DEPTH); 730 // Import should be stripped, style rule kept 731 assert_eq!(resolved.rules.len(), 1); 732 assert!(matches!(resolved.rules[0], Rule::Style(_))); 733 } 734 735 #[test] 736 fn resolve_imports_failed_import_skipped() { 737 let sheet = Stylesheet { 738 rules: vec![ 739 Rule::Import(ImportRule { 740 url: "http://nonexistent.test/import.css".to_string(), 741 }), 742 Rule::Style(StyleRule { 743 selectors: we_css::parser::SelectorList { 744 selectors: Vec::new(), 745 }, 746 declarations: Vec::new(), 747 }), 748 ], 749 }; 750 751 let mut loader = ResourceLoader::new(); 752 let base = Url::parse("http://example.com/").unwrap(); 753 754 let resolved = resolve_imports(sheet, &mut loader, &base, 0); 755 // Failed import should be skipped, style rule kept 756 assert_eq!(resolved.rules.len(), 1); 757 assert!(matches!(resolved.rules[0], Rule::Style(_))); 758 } 759 760 // ----------------------------------------------------------------------- 761 // CssLoadError display 762 // ----------------------------------------------------------------------- 763 764 #[test] 765 fn css_load_error_display_not_css() { 766 let e = CssLoadError::NotCss { 767 url: "test.png".to_string(), 768 }; 769 assert_eq!(e.to_string(), "resource at test.png is not CSS"); 770 } 771 772 #[test] 773 fn css_load_error_display_load() { 774 let e = CssLoadError::Load(LoadError::InvalidUrl("bad".to_string())); 775 assert!(e.to_string().contains("CSS load error")); 776 } 777 778 // ----------------------------------------------------------------------- 779 // Integration: style + link document order 780 // ----------------------------------------------------------------------- 781 782 #[test] 783 fn style_and_link_document_order() { 784 // When both <style> and <link> are present, inline styles should be 785 // collected in document order (link will fail but style should work) 786 let mut doc = Document::new(); 787 let root = doc.root(); 788 789 let html = doc.create_element("html"); 790 doc.append_child(root, html); 791 792 let head = doc.create_element("head"); 793 doc.append_child(html, head); 794 795 // Link first (will fail) 796 let link = doc.create_element("link"); 797 doc.set_attribute(link, "rel", "stylesheet"); 798 doc.set_attribute(link, "href", "http://nonexistent.test/first.css"); 799 doc.append_child(head, link); 800 801 // Style second 802 let style = doc.create_element("style"); 803 doc.append_child(head, style); 804 let text = doc.create_text("p { color: green; }"); 805 doc.append_child(style, text); 806 807 let mut loader = ResourceLoader::new(); 808 let base = Url::parse("http://example.com/").unwrap(); 809 810 let sheet = collect_stylesheets(&doc, &mut loader, &base); 811 // Only the inline style should succeed 812 assert_eq!(sheet.rules.len(), 1); 813 } 814 815 // ----------------------------------------------------------------------- 816 // Edge cases 817 // ----------------------------------------------------------------------- 818 819 #[test] 820 fn link_with_empty_href_skipped() { 821 let mut doc = Document::new(); 822 let root = doc.root(); 823 824 let link = doc.create_element("link"); 825 doc.set_attribute(link, "rel", "stylesheet"); 826 doc.set_attribute(link, "href", ""); 827 doc.append_child(root, link); 828 829 assert!(matches!( 830 classify_style_node(&doc, link), 831 StyleSource::NotStylesheet 832 )); 833 } 834 835 #[test] 836 fn style_in_body_is_collected() { 837 // <style> in <body> should also be collected (browsers allow this) 838 let mut doc = Document::new(); 839 let root = doc.root(); 840 841 let html = doc.create_element("html"); 842 doc.append_child(root, html); 843 844 let body = doc.create_element("body"); 845 doc.append_child(html, body); 846 847 let style = doc.create_element("style"); 848 doc.append_child(body, style); 849 let text = doc.create_text("h1 { font-size: 2em; }"); 850 doc.append_child(style, text); 851 852 let mut loader = ResourceLoader::new(); 853 let base = Url::parse("http://example.com/").unwrap(); 854 855 let sheet = collect_stylesheets(&doc, &mut loader, &base); 856 assert_eq!(sheet.rules.len(), 1); 857 } 858 859 #[test] 860 fn link_rel_case_insensitive() { 861 // rel="Stylesheet" should work too (case-insensitive matching) 862 let mut doc = Document::new(); 863 let root = doc.root(); 864 865 let link = doc.create_element("link"); 866 doc.set_attribute(link, "rel", "Stylesheet"); 867 doc.set_attribute(link, "href", "style.css"); 868 doc.append_child(root, link); 869 870 match classify_style_node(&doc, link) { 871 StyleSource::ExternalLink { href, .. } => assert_eq!(href, "style.css"), 872 _ => panic!("expected ExternalLink"), 873 } 874 } 875 876 #[test] 877 fn link_rel_with_extra_values() { 878 // rel="alternate stylesheet" should NOT match as a regular stylesheet 879 // (alternate stylesheets are disabled by default) 880 // Actually, per spec, "stylesheet" in the token list means it IS a stylesheet 881 // But "alternate stylesheet" is a disabled stylesheet. For simplicity, 882 // we treat any rel containing "stylesheet" as a stylesheet. 883 let mut doc = Document::new(); 884 let root = doc.root(); 885 886 let link = doc.create_element("link"); 887 doc.set_attribute(link, "rel", "stylesheet"); 888 doc.set_attribute(link, "href", "style.css"); 889 doc.append_child(root, link); 890 891 assert!(matches!( 892 classify_style_node(&doc, link), 893 StyleSource::ExternalLink { .. } 894 )); 895 } 896}