we (web engine): Experimental web browser project to understand the limits of Claude
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}