1#![doc = include_str!("../examples/basic_usage.rs")]
22#![doc = include_str!("../examples/advanced_config.rs")]
30use html::traits::TendrilSink;
33use html::{NodeData, NodeRef};
34use itertools::Itertools;
35use lightningcss::printer::PrinterOptions;
36use lightningcss::properties::PropertyId;
37use lightningcss::rules::{font_face::FontFaceProperty, keyframes::KeyframesName, CssRule};
38use lightningcss::selector::SelectorList;
39use lightningcss::stylesheet::StyleSheet;
40use lightningcss::traits::ToCss;
41use lightningcss::values::ident::CustomIdent;
42use log::{debug, error, warn};
43use path_clean::PathClean;
44use regex::Regex;
45use serde::{Deserialize, Serialize};
46use std::collections::HashSet;
47use std::fs;
48use std::{default, path};
49use utils::{is_valid_media_query, regex, NodeRefExt, StyleRuleExt};
50
51#[cfg(feature = "use-napi")]
52use napi_derive::napi;
53
54use crate::html::{style_calculation, Selectors};
55
56#[doc(hidden)]
57pub mod html;
58mod utils;
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize, clap::ValueEnum)]
61#[cfg_attr(feature = "typegen", derive(ts_rs::TS))]
62pub enum PreloadStrategy {
63 #[default]
65 BodyPreload,
66 Body,
68 Media,
70 Swap,
72 SwapHigh,
74 None,
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize, clap::ValueEnum)]
83#[cfg_attr(feature = "typegen", derive(ts_rs::TS))]
84pub enum KeyframesStrategy {
85 #[default]
87 Critical,
88 All,
90 None,
92}
93
94#[derive(Debug, Clone)]
95pub enum Matcher {
96 String(String),
97 Regex(Regex),
98}
99impl Matcher {
100 pub fn matches(&self, value: &str) -> bool {
101 match self {
102 Matcher::Regex(regex) => regex.is_match(value),
103 Matcher::String(exp) => exp == value,
104 }
105 }
106}
107
108#[deprecated(note = "Use `Matcher` instead.")]
109pub use Matcher as SelectorMatcher;
110
111impl Serialize for Matcher {
112 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
113 where
114 S: serde::Serializer,
115 {
116 serializer.serialize_str(match self {
117 Self::Regex(r) => r.as_str(),
118 Self::String(s) => s,
119 })
120 }
121}
122impl<'de> Deserialize<'de> for Matcher {
123 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124 where
125 D: serde::Deserializer<'de>,
126 {
127 let s = String::deserialize(deserializer)?;
128
129 if s.starts_with('/') && s.ends_with('/') {
130 Regex::new(&s[1..s.len() - 1])
131 .map(Self::Regex)
132 .map_err(|e| {
133 serde::de::Error::custom(format!("Failed to parse regular expression. {e}"))
134 })
135 } else {
136 Ok(Self::String(s))
137 }
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, clap::Args)]
142#[serde(default, rename_all = "camelCase")]
143#[cfg_attr(feature = "typegen", derive(ts_rs::TS))]
144#[cfg_attr(feature = "typegen", ts(export))]
145pub struct CrittersOptions {
146 #[clap(short, long)]
148 pub path: String,
149 #[clap(long, default_value_t)]
151 pub public_path: String,
152 #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
154 pub external: bool,
155 #[clap(long, default_value_t)]
157 pub inline_threshold: u32,
158 #[clap(long, default_value_t)]
160 pub minimum_external_size: u32,
161 #[clap(long)]
163 pub prune_source: bool,
164 #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
166 pub merge_stylesheets: bool,
167 #[clap(long)]
169 pub additional_stylesheets: Vec<String>,
170 #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
174 pub reduce_inline_styles: bool,
175 #[clap(long, default_value = "body-preload")]
177 pub preload: PreloadStrategy,
178 #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
180 pub noscript_fallback: bool,
181 #[clap(long)]
183 pub inline_fonts: bool,
184 #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
186 pub preload_fonts: bool,
187 #[clap(long, default_value = "critical")]
189 pub keyframes: KeyframesStrategy,
190 #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
192 pub compress: bool,
193 #[clap(skip)]
195 #[cfg_attr(feature = "typegen", ts(as = "Vec<String>"))]
196 pub allow_rules: Vec<Matcher>,
197 #[clap(skip)]
201 #[cfg_attr(feature = "typegen", ts(as = "Vec<String>"))]
202 pub exclude_external: Vec<Matcher>,
203}
204
205#[derive(Debug)]
207#[cfg(any(feature = "cli", feature = "use-napi"))]
208#[cfg_attr(feature = "use-napi", napi)]
209pub struct CrittersDirectoryStats {
210 pub time_sec: f64,
212 pub pages: u32,
214 }
216
217impl default::Default for CrittersOptions {
218 fn default() -> Self {
219 Self {
220 path: Default::default(),
221 public_path: Default::default(),
222 external: true,
223 inline_threshold: 0,
224 minimum_external_size: 0,
225 prune_source: false,
226 merge_stylesheets: true,
227 additional_stylesheets: Default::default(),
228 reduce_inline_styles: true,
229 preload: Default::default(),
230 noscript_fallback: true,
231 inline_fonts: false,
232 preload_fonts: true,
233 keyframes: Default::default(),
234 compress: true,
235 allow_rules: Default::default(),
236 exclude_external: Default::default(),
237 }
238 }
239}
240
241#[derive(Clone)]
242#[cfg_attr(feature = "use-napi", napi)]
243pub struct Critters {
244 options: CrittersOptions,
245}
246
247#[cfg(feature = "use-napi")]
248#[napi]
249impl Critters {
250 #[napi(constructor)]
251 pub fn new(options: Option<serde_json::Value>) -> anyhow::Result<Self> {
252 use anyhow::anyhow;
253 env_logger::try_init().ok();
255 let options: CrittersOptions = match options {
256 Some(options) => serde_json::from_value(options)
257 .map_err(|e| anyhow!("Failed to parse options: {}", e))?,
258 None => Default::default(),
259 };
260 Ok(Critters { options })
261 }
262
263 #[napi]
265 pub fn process(&self, html: String) -> anyhow::Result<String> {
266 self.process_impl(&html)
267 }
268
269 #[napi]
271 pub fn process_dir(&self) -> anyhow::Result<CrittersDirectoryStats> {
272 self.process_dir_impl(None)
273 }
274}
275
276impl Critters {
277 #[cfg(not(feature = "use-napi"))]
278 pub fn new(options: CrittersOptions) -> Self {
279 Critters { options }
280 }
281
282 #[cfg(not(feature = "use-napi"))]
284 pub fn process(&self, html: &str) -> anyhow::Result<String> {
285 self.process_impl(html)
286 }
287
288 fn process_impl(&self, html: &str) -> anyhow::Result<String> {
290 let parser = html::parse_html();
292 let dom = parser.one(html);
293
294 let mut styles = Vec::new();
295
296 if self.options.reduce_inline_styles {
298 styles.append(&mut self.get_inline_stylesheets(&dom));
299 }
300
301 if self.options.external {
303 styles.append(&mut self.get_external_stylesheets(&dom));
304 }
305
306 if !self.options.additional_stylesheets.is_empty() {
308 styles.append(&mut self.get_additional_stylesheets(&dom)?);
309 }
310
311 debug!("Inlining {} stylesheets.", styles.len());
313 for style in styles.iter() {
314 let res = self.process_style_el(style, dom.clone());
315 if let Err(err) = res {
317 error!(
318 "Error encountered when processing stylesheet, skipping. {}",
319 err
320 );
321 }
322 }
323
324 if self.options.merge_stylesheets {
326 self.merge_stylesheets(styles)
327 }
328
329 let mut result = Vec::new();
331 dom.serialize(&mut result)?;
332 Ok(String::from_utf8(result)?)
333 }
334
335 #[cfg(feature = "cli")]
337 pub fn process_dir(
338 &self,
339 multi_progress: Option<&indicatif::MultiProgress>,
340 ) -> anyhow::Result<CrittersDirectoryStats> {
341 self.process_dir_impl(multi_progress)
342 }
343
344 #[cfg(feature = "directory")]
346 fn process_dir_impl(
347 &self,
348 multi_progress: Option<&indicatif::MultiProgress>,
349 ) -> anyhow::Result<CrittersDirectoryStats> {
350 use indicatif::{ParallelProgressIterator, ProgressBar};
351 use log::info;
352 use rayon::prelude::*;
353 use std::time::Instant;
354 use utils::ProgressBarExt;
355
356 let files = utils::locate_html_files(&self.options.path)?;
357
358 let start = Instant::now();
359 let progress_bar = ProgressBar::new(files.len() as u64)
360 .with_crate_style()
361 .with_prefix("Inlining Critical CSS");
362 let progress_bar = if let Some(multi) = multi_progress {
363 multi.add(progress_bar)
364 } else {
365 progress_bar
366 };
367
368 files
369 .par_iter()
370 .progress_with(progress_bar.clone())
371 .for_each(|path| {
372 let start = Instant::now();
373
374 let html =
375 fs::read_to_string(path.clone()).expect("Failed to load HTML file from disk.");
376 let processed = match self.process_impl(&html) {
377 Ok(s) => s,
378 Err(e) => {
379 error!("Failed to process file {} with error {e}", path.display());
380 return;
381 }
382 };
383 fs::write(path.clone(), processed).expect("Failed to write HTML file to disk.");
384
385 let duration = start.elapsed();
386
387 info!(
388 "Processed {} in {} ms",
389 path.strip_prefix(&self.options.path).unwrap().display(),
390 duration.as_millis()
391 );
392 });
393
394 progress_bar.finish_and_clear();
395 if let Some(multi) = multi_progress {
396 multi.remove(&progress_bar);
397 }
398 Ok(CrittersDirectoryStats {
399 pages: files.len() as u32,
400 time_sec: start.elapsed().as_secs_f64(),
401 })
402 }
403
404 fn get_inline_stylesheets(&self, dom: &NodeRef) -> Vec<NodeRef> {
406 dom.select("style")
407 .unwrap()
408 .map(|n| n.as_node().clone())
409 .collect()
410 }
411
412 fn get_external_stylesheets(&self, dom: &NodeRef) -> Vec<NodeRef> {
414 let external_sheets: Vec<_> = dom.select("link[rel=\"stylesheet\"]").unwrap().collect();
415
416 external_sheets
417 .iter()
418 .filter_map(|link| {
419 self.inline_external_stylesheet(link.as_node(), dom)
420 .unwrap_or_else(|e| {
421 error!("Failed to inline external stylesheet. {e}");
422 None
423 })
424 })
425 .collect()
426 }
427
428 fn get_additional_stylesheets(&self, dom: &NodeRef) -> anyhow::Result<Vec<NodeRef>> {
430 self.options
431 .additional_stylesheets
432 .iter()
433 .sorted()
434 .dedup()
435 .filter_map(|href| self.get_css_asset(href))
436 .map(|css| self.inject_style(&css, dom))
437 .collect()
438 }
439
440 fn process_style(&self, sheet: &str, dom: NodeRef) -> anyhow::Result<String> {
442 let critters_container = dom
443 .select_first("[data-critters-container]")
444 .unwrap_or_else(|_| dom.select_first("body").unwrap());
445 let mut failed_selectors = Vec::new();
446 let mut rules_to_remove = HashSet::new();
447 let mut critical_keyframe_names: HashSet<String> = HashSet::new();
448 let mut critical_fonts = String::new();
449
450 let mut ast = StyleSheet::parse(sheet, Default::default())
451 .map_err(|_| anyhow::Error::msg("Failed to parse stylesheet."))?;
452
453 let all_selectors = ast
455 .rules
456 .0
457 .iter()
458 .filter_map(|rule| match rule {
459 CssRule::Style(style_rule) => Some(style_rule.selectors.clone()),
460 _ => None,
461 })
462 .filter_map(|selectors| {
463 match Selectors::compile(
464 &selectors
465 .to_css_string(Default::default())
466 .expect("Failed to write selector to string"),
467 ) {
468 Ok(selectors) => Some(selectors),
469 Err(err) => {
470 failed_selectors.push(format!("{} -> {:?}", selectors, err));
471 None
472 }
473 }
474 })
475 .flat_map(|selectors| selectors.0)
476 .collect::<HashSet<_>>();
477
478 let used_selectors = style_calculation::calculate_styles_for_tree(
479 &critters_container,
480 all_selectors.clone(),
481 )
482 .iter()
483 .map(|sel| sel.to_string())
484 .collect::<HashSet<_>>();
485
486 for rule in &mut ast.rules.0 {
489 if let CssRule::Style(style_rule) = rule {
490 let global_pseudo_regex = regex!(r"^::?(before|after)$");
491
492 let filtered_selectors = style_rule
494 .selectors
495 .0
496 .iter()
497 .filter(|sel| {
498 let selector = sel.to_css_string(Default::default()).unwrap();
499 if selector == ":root"
501 || selector == "html"
502 || selector == "body"
503 || global_pseudo_regex.is_match(&selector)
504 {
505 return true;
506 }
507
508 if self
510 .options
511 .allow_rules
512 .iter()
513 .any(|m| m.matches(&selector))
514 {
515 return true;
516 }
517
518 used_selectors.contains(&selector)
522 })
523 .cloned()
524 .collect::<Vec<_>>();
525
526 if filtered_selectors.is_empty() {
527 rules_to_remove.insert(style_rule.id());
528 continue;
529 } else {
530 style_rule.selectors = SelectorList::new(filtered_selectors.into());
531 }
532
533 for decl in &style_rule.declarations.declarations {
535 if matches!(
536 decl.property_id(),
537 PropertyId::Animation(_) | PropertyId::AnimationName(_)
538 ) {
539 let value = decl.value_to_css_string(Default::default()).unwrap();
540 for v in value.split_whitespace() {
541 if !v.trim().is_empty() {
542 critical_keyframe_names.insert(v.trim().to_string());
543 }
544 }
545 }
546
547 if matches!(decl.property_id(), PropertyId::FontFamily) {
548 critical_fonts.push_str(
549 format!(
550 " {}",
551 &decl.value_to_css_string(Default::default()).unwrap()
552 )
553 .as_str(),
554 );
555 }
556 }
557 }
558 }
559
560 let mut preloaded_fonts = HashSet::new();
561 let original_rules = ast.rules.0.len();
562 ast.rules.0.retain(|rule| match rule {
563 CssRule::Style(s) => !rules_to_remove.contains(&s.id()),
564 CssRule::Keyframes(k) => {
565 let kf_name = match &k.name {
567 KeyframesName::Ident(CustomIdent(id)) | KeyframesName::Custom(id) => id,
568 };
569 critical_keyframe_names.contains(&kf_name.to_string())
570 }
571 CssRule::FontFace(f) => {
572 let href_regex = regex!(r#"url\s*\(\s*(['"]?)(.+?)\1\s*\)"#, fancy_regex::Regex);
573 let mut href = None;
574 let mut family = None;
575
576 for p in &f.properties {
577 match p {
578 FontFaceProperty::Source(s) => {
579 let src = s.to_css_string(Default::default()).unwrap();
580 href = href_regex
581 .captures(&src)
582 .unwrap()
583 .and_then(|m| m.get(2).map(|c| c.as_str().to_string()));
584 }
585 FontFaceProperty::FontFamily(f) => {
586 family = Some(f.to_css_string(Default::default()).unwrap())
587 }
588 _ => (),
589 }
590 }
591
592 if href.is_some()
594 && self.options.preload_fonts
595 && !preloaded_fonts.contains(href.as_ref().unwrap())
596 {
597 let href = href.clone().unwrap();
598 if let Err(e) = self.inject_font_preload(&href, &dom) {
599 error!("Failed to inject font preload directive. {e}");
600 }
601 preloaded_fonts.insert(href);
602 }
603
604 self.options.inline_fonts
605 && family.is_some()
606 && href.as_ref().is_some()
607 && critical_fonts.contains(&family.unwrap())
608 }
609 _ => true,
610 });
611
612 debug!(
613 "Removed {}/{} rules.",
614 original_rules - ast.rules.0.len(),
615 original_rules
616 );
617
618 let css = ast.to_css(PrinterOptions {
620 minify: self.options.compress,
621 ..Default::default()
622 })?;
623
624 Ok(css.code)
625 }
626
627 fn process_style_el(&self, style: &NodeRef, dom: NodeRef) -> anyhow::Result<()> {
629 let style_child = match style.children().nth(0) {
630 Some(c) => c,
631 None => return Ok(()),
633 };
634 let style_data = style_child.data();
635
636 let sheet = match style_data {
637 NodeData::Text(t) => t.borrow().to_string(),
638 _ => return Err(anyhow::Error::msg("Invalid style tag")),
639 };
640
641 if sheet.is_empty() {
643 return Ok(());
644 }
645
646 let css = self.process_style(&sheet, dom)?;
647
648 style.children().for_each(|c| c.detach());
650 style.append(NodeRef::new_text(css));
651
652 Ok(())
653 }
654
655 fn get_css_asset(&self, href: &str) -> Option<String> {
657 let output_path = &self.options.path;
658 let output_path_absolute = path::absolute(&self.options.path).unwrap();
659 let public_path = &self.options.public_path;
660
661 let mut normalized_path = href.strip_prefix("/").unwrap_or(href);
664 let path_prefix = regex!(r"(^\/|\/$)").replace_all(public_path, "") + "/";
665
666 if normalized_path.starts_with(&*path_prefix) {
667 normalized_path = normalized_path
668 .strip_prefix(&*path_prefix)
669 .unwrap_or(normalized_path);
670 normalized_path = normalized_path.strip_prefix("/").unwrap_or(normalized_path);
671 }
672
673 if regex!(r"^https?:\/\/").is_match(normalized_path) || href.starts_with("//") {
675 return None;
676 }
677
678 let filename = match path::absolute(path::Path::new(output_path).join(normalized_path)) {
679 Ok(path) => path.clean(),
680 Err(e) => {
681 warn!(
682 "Failed to resolve path with output path {} and href {}. {e}",
683 output_path, normalized_path
684 );
685 return None;
686 }
687 };
688
689 if !filename.starts_with(&output_path_absolute) {
691 warn!(
692 "Matched stylesheet with path \"{}\", which is not within the configured output path \"{}\".",
693 filename.display(),
694 output_path_absolute.display()
695 );
696 return None;
697 }
698
699 match fs::read_to_string(filename.clone()) {
700 Ok(sheet) => Some(sheet),
701 Err(e) => {
702 error!(
703 "Loading stylesheet at path \"{}\" failed. {e}",
704 filename.display()
705 );
706 None
707 }
708 }
709 }
710
711 fn inline_external_stylesheet(
713 &self,
714 link: &NodeRef,
715 dom: &NodeRef,
716 ) -> anyhow::Result<Option<NodeRef>> {
717 let link_el = link.as_element().unwrap();
718 let link_attrs = link_el.attributes.borrow();
719 let href = match link_attrs.get("href") {
720 Some(v) if v.ends_with(".css") => v.to_owned(),
721 _ => return Ok(None),
722 };
723 drop(link_attrs);
724
725 let sheet = match self.get_css_asset(&href) {
726 Some(v) => v,
727 None => return Ok(None),
728 };
729
730 let style = NodeRef::new_html_element("style", vec![]);
731 style.append(NodeRef::new_text(sheet));
732 link.insert_before(style.clone());
733
734 if self
737 .options
738 .exclude_external
739 .iter()
740 .any(|m| m.matches(&href))
741 {
742 link.detach();
743 return Ok(Some(style));
744 }
745
746 let body = dom
747 .select_first("body")
748 .map_err(|_| anyhow::Error::msg("Failed to locate document body"))?;
749
750 let update_link_to_preload = || {
751 let mut link_attrs = link_el.attributes.borrow_mut();
752 link_attrs.insert("rel", "preload".to_string());
753 link_attrs.insert("as", "style".to_string());
754 };
755
756 let noscript_link = NodeRef::new(link.data().clone());
757 let inject_noscript_fallback = || {
758 let noscript = NodeRef::new_html_element("noscript", Vec::new());
759 let noscript_link_el = noscript_link.as_element().unwrap();
760 let mut noscript_link_attrs = noscript_link_el.attributes.borrow_mut();
761 noscript_link_attrs.remove("id");
762 drop(noscript_link_attrs);
763 noscript.append(noscript_link);
764 link.insert_before(noscript);
765 };
766
767 match self.options.preload {
768 PreloadStrategy::BodyPreload => {
769 let body_link = NodeRef::new(link.data().clone());
771
772 let mut body_link_attrs = body_link.as_element().unwrap().attributes.borrow_mut();
774 body_link_attrs.remove("id");
775 drop(body_link_attrs);
776
777 body.as_node().append(body_link);
778
779 update_link_to_preload();
780 }
781 PreloadStrategy::Body => body.as_node().append(link.clone()),
782 PreloadStrategy::Media => {
783 let mut link_attrs = link_el.attributes.borrow_mut();
784 let media = link_attrs.get("media").and_then(|m| {
785 is_valid_media_query(m).then(|| m.to_string())
787 });
788 link_attrs.insert("media", "print".to_string());
789 link_attrs.insert(
790 "onload",
791 format!("this.media='{}'", media.unwrap_or("all".to_string())),
792 );
793 drop(link_attrs);
794
795 inject_noscript_fallback();
796 }
797 PreloadStrategy::Swap => {
798 let mut link_attrs = link_el.attributes.borrow_mut();
799 link_attrs.insert("onload", "this.rel='stylesheet'".to_string());
800 drop(link_attrs);
801
802 update_link_to_preload();
803 inject_noscript_fallback();
804 }
805 PreloadStrategy::SwapHigh => {
806 let mut link_attrs = link_el.attributes.borrow_mut();
807 link_attrs.insert("rel", "alternate stylesheet preload".to_string());
808 link_attrs.insert("as", "style".to_string());
809 link_attrs.insert("title", "styles".to_string());
810 link_attrs.insert("onload", "this.title='';this.rel='stylesheet'".to_string());
811 drop(link_attrs);
812
813 inject_noscript_fallback();
814 }
815 PreloadStrategy::None => (),
817 };
818
819 Ok(Some(style))
820 }
821
822 fn inject_style(&self, sheet: &str, dom: &NodeRef) -> anyhow::Result<NodeRef> {
824 let head = dom
825 .select_first("head")
826 .map_err(|_| anyhow::Error::msg("Failed to locate <head> element in DOM."))?;
827 let style_node = NodeRef::new_html_element("style", vec![]);
828
829 style_node.append(NodeRef::new_text(sheet));
830 head.as_node().append(style_node.clone());
831
832 Ok(style_node)
833 }
834
835 fn inject_font_preload(&self, font: &str, dom: &NodeRef) -> anyhow::Result<()> {
837 let head = dom
838 .select_first("head")
839 .map_err(|_| anyhow::Error::msg("Failed to locate <head> element in DOM."))?;
840
841 head.as_node().append(NodeRef::new_html_element(
842 "link",
843 vec![
844 ("rel", "preload"),
845 ("as", "font"),
846 ("crossorigin", "anonymous"),
847 ("href", font.trim()),
848 ],
849 ));
850
851 Ok(())
852 }
853
854 fn merge_stylesheets(&self, styles: Vec<NodeRef>) {
855 let mut styles_iter = styles.into_iter().rev();
856 let first = match styles_iter.next() {
857 Some(f) => match f.first_child() {
858 Some(c) => c,
859 None => return,
860 },
861 None => return,
862 };
863
864 let mut sheet = first.text_contents();
865 for style in styles_iter {
866 sheet += &style.text_contents();
867 style.detach();
868 }
869
870 first.into_text_ref().unwrap().replace(sheet);
871 }
872}
873
874#[cfg(all(test, not(feature = "use-napi")))]
875mod tests {
876 use std::fs::File;
877 use std::io::Write;
878 use tempdir::TempDir;
879 use test_log::test;
880
881 use super::*;
882
883 const BASIC_CSS: &str = r#"
884 .critical { color: red; }
885 .non-critical { color: blue; }
886 "#;
887
888 const BASIC_HTML: &str = r#"
889 <html>
890 <head>
891 <style>
892 .critical { color: red; }
893 .non-critical { color: blue; }
894 </style>
895 </head>
896 <body>
897 <div class="critical">Hello World</div>
898 </body>
899 </html>
900 "#;
901
902 fn construct_html(head: &str, body: &str) -> String {
903 format!(
904 r#"
905 <html>
906 <head>
907 {head}
908 </head>
909 <body>
910 {body}
911 </body>
912 </html>
913 "#
914 )
915 }
916
917 fn create_test_folder(files: &[(&str, &str)]) -> String {
921 let tmp_dir = TempDir::new("dist").expect("Failed to create temporary directory");
922
923 for (path, contents) in files {
924 let file_path = tmp_dir.path().join(path);
925 let mut tmp_file = File::create(file_path).unwrap();
926 writeln!(tmp_file, "{}", contents).unwrap();
927 }
928
929 tmp_dir.into_path().to_string_lossy().to_string()
930 }
931
932 #[test]
933 fn basic() {
934 let critters = Critters::new(Default::default());
935
936 let processed = critters.process(BASIC_HTML).unwrap();
937
938 let parser = html::parse_html();
939 let dom = parser.one(processed);
940 let stylesheet = dom.select_first("style").unwrap().text_contents();
941
942 assert!(stylesheet.contains(".critical"));
943 assert!(!stylesheet.contains(".non-critical"));
944 }
945
946 #[test]
947 fn complex() {
948 let critters = Critters::new(Default::default());
949
950 let html = construct_html(
951 r#"<style>
952 .red { color: red; }
953 .green { color: green; }
954 .blue { color: blue; }
955 .purple { color: purple; }
956 .link-underline > a { text-decoration: underline; }
957 </style>"#,
958 r#"<div>
959 <h1 class="red">This is a heading</h1>
960 <p class="purple link-underline">
961 This is some body text
962 <a>This should be underlined</a>
963 </p>
964 </div>"#,
965 );
966
967 let processed = critters.process(&html).unwrap();
968
969 let parser = html::parse_html();
970 let dom = parser.one(processed);
971 let stylesheet = dom.select_first("style").unwrap().text_contents();
972
973 assert!(stylesheet.contains(".red"));
974 assert!(stylesheet.contains(".purple"));
975 assert!(stylesheet.contains(".link-underline>a"));
976 assert!(!stylesheet.contains(".green"));
977 assert!(!stylesheet.contains(".blue"));
978 }
979
980 #[test]
981 fn font_preload() {
982 let html = construct_html(
983 r#"<style>
984 @font-face {
985 font-family: "Trickster";
986 src:
987 local("Trickster"),
988 url("trickster-COLRv1.otf") format("opentype") tech(color-COLRv1),
989 url("trickster-outline.otf") format("opentype"),
990 url("trickster-outline.woff") format("woff");
991 }
992 </style>"#,
993 "",
994 );
995 let critters = Critters::new(Default::default());
996
997 let processed = critters.process(&html).unwrap();
998
999 let parser = html::parse_html();
1000 let dom = parser.one(processed);
1001 let preload = dom
1002 .select_first("head > link[rel=preload]")
1003 .expect("Failed to locate preload link.");
1004 let preload_attrs = preload.attributes.borrow();
1005
1006 assert_eq!(preload_attrs.get("rel"), Some("preload"));
1007 assert_eq!(preload_attrs.get("as"), Some("font"));
1008 assert_eq!(preload_attrs.get("crossorigin"), Some("anonymous"));
1009 assert_eq!(preload_attrs.get("href"), Some("trickster-COLRv1.otf"));
1010 }
1011
1012 #[test]
1013 fn external_stylesheet() {
1014 let tmp_dir = create_test_folder(&[("external.css", BASIC_CSS)]);
1015
1016 let html = construct_html(
1017 r#"<link rel="stylesheet" href="external.css" />"#,
1018 r#"<div class="critical">Hello world</div>"#,
1019 );
1020
1021 let critters = Critters::new(CrittersOptions {
1022 path: tmp_dir,
1023 external: true,
1024 preload: PreloadStrategy::BodyPreload,
1025 ..Default::default()
1026 });
1027
1028 let processed = critters
1029 .process(&html)
1030 .expect("Failed to inline critical css");
1031
1032 let parser = html::parse_html();
1033 let dom = parser.one(processed);
1034
1035 let preload_link = dom
1036 .select_first("head > link[rel=preload]")
1037 .expect("Failed to locate preload link.");
1038 assert_eq!(
1039 preload_link.attributes.borrow().get("href"),
1040 Some("external.css")
1041 );
1042 assert_eq!(preload_link.attributes.borrow().get("as"), Some("style"));
1043
1044 let stylesheet = dom
1045 .select_first("style")
1046 .expect("Failed to locate inline stylesheet")
1047 .text_contents();
1048 assert!(stylesheet.contains(".critical"));
1049 assert!(!stylesheet.contains(".non-critical"));
1050
1051 let stylesheet_link = dom
1052 .select_first("body > link[rel=stylesheet]:last-child")
1053 .expect("Failed to locate external stylesheet link.");
1054 assert_eq!(
1055 stylesheet_link.attributes.borrow().get("rel"),
1056 Some("stylesheet")
1057 );
1058 assert_eq!(
1059 stylesheet_link.attributes.borrow().get("href"),
1060 Some("external.css")
1061 );
1062 }
1063
1064 #[test]
1065 fn external_stylesheet_exclude() {
1066 let tmp_dir = create_test_folder(&[("external.css", BASIC_CSS)]);
1067
1068 let html = construct_html(
1069 r#"<link rel="stylesheet" href="external.css" />"#,
1070 r#"<div class="critical">Hello world</div>"#,
1071 );
1072
1073 let critters = Critters::new(CrittersOptions {
1074 path: tmp_dir,
1075 external: true,
1076 preload: PreloadStrategy::BodyPreload,
1077 exclude_external: vec![Matcher::Regex(Regex::new("external\\.css$").unwrap())],
1078 ..Default::default()
1079 });
1080
1081 let processed = critters
1082 .process(&html)
1083 .expect("Failed to inline critical css");
1084
1085 let parser = html::parse_html();
1086 let dom = parser.one(processed);
1087
1088 dom.select_first("link[rel=preload]")
1089 .expect_err("Unexpected preload link.");
1090
1091 let stylesheet = dom
1092 .select_first("style")
1093 .expect("Failed to locate inline stylesheet")
1094 .text_contents();
1095 assert!(stylesheet.contains(".critical"));
1096 assert!(!stylesheet.contains(".non-critical"));
1097
1098 dom.select_first("link[rel=stylesheet]")
1099 .expect_err("Unexpected external stylesheet link.");
1100 }
1101
1102 #[test]
1103 fn additional_stylesheets() {
1104 let tmp_dir = create_test_folder(&[(
1105 "add.css",
1106 ".critical { background-color: blue; } .non-critical { background-color: red; }",
1107 )]);
1108
1109 let critters = Critters::new(CrittersOptions {
1110 path: tmp_dir,
1111 merge_stylesheets: false,
1112 additional_stylesheets: vec!["add.css".to_string()],
1113 ..Default::default()
1114 });
1115
1116 let processed = critters.process(BASIC_HTML).unwrap();
1117
1118 let parser = html::parse_html();
1119 let dom = parser.one(processed);
1120 let stylesheets: Vec<_> = dom
1121 .select("style")
1122 .unwrap()
1123 .map(|s| s.text_contents())
1124 .collect();
1125
1126 assert_eq!(stylesheets.len(), 2);
1127 assert!(stylesheets[0].contains(".critical{color:red}"));
1128 assert!(!stylesheets[0].contains(".non-critical"));
1129 assert!(stylesheets[1].contains(".critical{background-color"));
1130 assert!(!stylesheets[1].contains(".non-critical"));
1131 }
1132
1133 #[test]
1134 fn merge_stylesheets() {
1135 let tmp_dir = create_test_folder(&[(
1136 "add.css",
1137 ".critical { background-color: blue; } .non-critical { background-color: red; }",
1138 )]);
1139
1140 let critters = Critters::new(CrittersOptions {
1141 path: tmp_dir,
1142 merge_stylesheets: true,
1143 additional_stylesheets: vec!["add.css".to_string()],
1144 ..Default::default()
1145 });
1146
1147 let processed = critters.process(BASIC_HTML).unwrap();
1148
1149 let parser = html::parse_html();
1150 let dom = parser.one(processed);
1151 let stylesheets: Vec<_> = dom
1152 .select("style")
1153 .unwrap()
1154 .map(|s| s.text_contents())
1155 .collect();
1156
1157 assert_eq!(stylesheets.len(), 1);
1158 let stylesheet = &stylesheets[0];
1159 assert!(stylesheet.contains(".critical{color:red}"));
1160 assert!(!stylesheet.contains(".non-critical"));
1161 assert!(stylesheet.contains(".critical{background-color"));
1162 assert!(!stylesheet.contains(".non-critical"));
1163 }
1164
1165 fn setup_preload_test(strategy: PreloadStrategy, link_attrs: Vec<(&str, &str)>) -> NodeRef {
1166 let tmp_dir = create_test_folder(&[("external.css", BASIC_CSS)]);
1167
1168 let html = construct_html(
1169 &format!(
1170 r#"<link rel="stylesheet" href="external.css" {} />"#,
1171 link_attrs
1172 .iter()
1173 .map(|(k, v)| format!(r#"{k}="{v}""#))
1174 .join(" ")
1175 ),
1176 r#"<div class="critical">Hello world</div>"#,
1177 );
1178
1179 let critters = Critters::new(CrittersOptions {
1180 path: tmp_dir,
1181 external: true,
1182 preload: strategy,
1183 ..Default::default()
1184 });
1185
1186 let processed = critters
1187 .process(&html)
1188 .expect("Failed to inline critical css");
1189
1190 let parser = html::parse_html();
1191
1192 parser.one(processed)
1193 }
1194
1195 fn get_noscript_link(noscript_el: &NodeRef) -> NodeRef {
1196 use markup5ever::{local_name, namespace_url, ns, QualName};
1197
1198 let noscript_text = noscript_el
1199 .children()
1200 .exactly_one()
1201 .expect("Could not get noscript text content.");
1202 let noscript_text_val = noscript_text.as_text().unwrap().borrow().clone();
1203
1204 let ctx_name = QualName::new(None, ns!(html), local_name!("link"));
1205 let parser = html::parse_fragment(ctx_name, vec![]);
1206 let noscript_doc = parser.one(noscript_text_val);
1207 let noscript_child = noscript_doc
1208 .first_child()
1209 .expect("Could not get noscript link element.")
1210 .first_child()
1211 .expect("Could not get noscript link element.");
1212 let noscript_child_el = noscript_child.as_element().unwrap();
1213
1214 assert_eq!(
1215 noscript_child_el.name.local,
1216 markup5ever::LocalName::from("link")
1217 );
1218
1219 noscript_child
1220 }
1221
1222 #[test]
1223 fn preload_swap() {
1224 let dom = setup_preload_test(PreloadStrategy::Swap, vec![]);
1225
1226 let preload_link = dom
1227 .select_first("head > link[rel=preload]")
1228 .expect("Failed to locate preload link.");
1229 assert_eq!(
1230 preload_link.attributes.borrow().get("href"),
1231 Some("external.css")
1232 );
1233 assert_eq!(preload_link.attributes.borrow().get("as"), Some("style"));
1234
1235 let noscript_el = dom
1236 .select_first("noscript")
1237 .expect("Failed to locate noscript link");
1238 let noscript_link = get_noscript_link(noscript_el.as_node());
1239 let noscript_link_el = noscript_link.as_element().unwrap();
1240
1241 assert_eq!(
1242 noscript_link_el.attributes.borrow().get("rel"),
1243 Some("stylesheet")
1244 );
1245 assert_eq!(
1246 noscript_link_el.attributes.borrow().get("href"),
1247 Some("external.css")
1248 );
1249 }
1250
1251 #[test]
1252 fn preload_swap_high() {
1253 let dom = setup_preload_test(PreloadStrategy::SwapHigh, vec![]);
1254
1255 let preload_link = dom
1256 .select_first("head > link[rel~=preload]")
1257 .expect("Failed to locate preload link.");
1258 assert_eq!(
1259 preload_link.attributes.borrow().get("href"),
1260 Some("external.css")
1261 );
1262 assert_eq!(
1263 preload_link.attributes.borrow().get("rel"),
1264 Some("alternate stylesheet preload")
1265 );
1266 assert_eq!(preload_link.attributes.borrow().get("as"), Some("style"));
1267 assert_eq!(
1268 preload_link.attributes.borrow().get("title"),
1269 Some("styles")
1270 );
1271 assert_eq!(
1272 preload_link.attributes.borrow().get("onload"),
1273 Some("this.title='';this.rel='stylesheet'")
1274 );
1275
1276 let noscript_el = dom
1277 .select_first("noscript")
1278 .expect("Failed to locate noscript link");
1279 let noscript_link = get_noscript_link(noscript_el.as_node());
1280 let noscript_link_el = noscript_link.as_element().unwrap();
1281
1282 assert_eq!(
1283 noscript_link_el.attributes.borrow().get("rel"),
1284 Some("stylesheet")
1285 );
1286 assert_eq!(
1287 noscript_link_el.attributes.borrow().get("href"),
1288 Some("external.css")
1289 );
1290 }
1291
1292 #[test]
1293 fn preload_media() {
1294 let dom = setup_preload_test(PreloadStrategy::Media, vec![("media", "test")]);
1295
1296 let preload_link = dom
1297 .select_first("head > link[media=print]")
1298 .expect("Failed to locate preload link.");
1299 assert_eq!(
1300 preload_link.attributes.borrow().get("onload"),
1301 Some("this.media='test'")
1302 );
1303
1304 let noscript_el = dom
1305 .select_first("noscript")
1306 .expect("Failed to locate noscript link");
1307 let noscript_link = get_noscript_link(noscript_el.as_node());
1308 let noscript_link_el = noscript_link.as_element().unwrap();
1309
1310 assert_eq!(
1311 noscript_link_el.attributes.borrow().get("rel"),
1312 Some("stylesheet")
1313 );
1314 assert_eq!(
1315 noscript_link_el.attributes.borrow().get("href"),
1316 Some("external.css")
1317 );
1318 }
1319
1320 #[test]
1321 fn allow_rules_string() {
1322 let critters = Critters::new(CrittersOptions {
1323 allow_rules: vec![Matcher::String(".non-critical".to_string())],
1324 ..Default::default()
1325 });
1326
1327 let processed = critters.process(BASIC_HTML).unwrap();
1328
1329 let parser = html::parse_html();
1330 let dom = parser.one(processed);
1331 let stylesheet = dom.select_first("style").unwrap().text_contents();
1332
1333 assert!(stylesheet.contains(".critical"));
1334 assert!(stylesheet.contains(".non-critical"));
1335 }
1336
1337 #[test]
1338 fn allow_rules_regex() {
1339 let critters = Critters::new(CrittersOptions {
1340 allow_rules: vec![Matcher::Regex(Regex::new("^.non").unwrap())],
1341 ..Default::default()
1342 });
1343
1344 let processed = critters.process(BASIC_HTML).unwrap();
1345
1346 let parser = html::parse_html();
1347 let dom = parser.one(processed);
1348 let stylesheet = dom.select_first("style").unwrap().text_contents();
1349
1350 assert!(stylesheet.contains(".critical"));
1351 assert!(stylesheet.contains(".non-critical"));
1352 }
1353}