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::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}