critters_rs/
lib.rs

1//! # critters-rs
2//!
3//! A fast and efficient critical CSS extraction library for Rust, inspired by Google Chrome's Critters.
4//!
5//! Critical CSS extraction involves analyzing HTML documents to identify which CSS rules are actually
6//! used on the page, inlining only those critical styles while deferring the loading of non-critical
7//! styles. This significantly improves page load performance by reducing render-blocking CSS.
8//!
9//! ## Features
10//!
11//! - **HTML parsing and CSS extraction**: Processes HTML documents to identify critical CSS selectors
12//! - **Multiple preload strategies**: Configurable strategies for handling non-critical CSS (body-preload, media, swap, etc.)
13//! - **Font preloading**: Automatic detection and preloading of critical fonts
14//! - **Keyframe optimization**: Intelligent handling of CSS animations and keyframes
15//! - **External stylesheet support**: Processes both inline styles and external CSS files
16//! - **High performance**: Built with Rust for speed and efficiency
17//!
18//! ## Basic Usage
19//!
20//! ```rust,no_run
21#![doc = include_str!("../examples/basic_usage.rs")]
22//! ```
23//!
24//! ## Advanced Configuration
25//!
26//! See [`CrittersOptions`] for all available configuration options.
27//!
28//! ```rust,no_run
29#![doc = include_str!("../examples/advanced_config.rs")]
30//! ```
31
32use 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    /// Move stylesheet links to the end of the document and insert preload meta tags in their place.
64    #[default]
65    BodyPreload,
66    /// Move all external stylesheet links to the end of the document.
67    Body,
68    /// Load stylesheets asynchronously by adding media="not x" and removing once loaded. JS
69    Media,
70    /// Convert stylesheet links to preloads that swap to rel="stylesheet" once loaded (details). JS
71    Swap,
72    /// Use <link rel="alternate stylesheet preload"> and swap to rel="stylesheet" once loaded (details). JS
73    SwapHigh,
74    // /// Inject an asynchronous CSS loader similar to LoadCSS and use it to load stylesheets. JS
75    // Js,
76    // /// Like "js", but the stylesheet is disabled until fully loaded.
77    // JsLazy,
78    /// Disables adding preload tags.
79    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    /// Inline keyframes rules used by the critical CSS
86    #[default]
87    Critical,
88    /// Inline all keyframes rules
89    All,
90    /// Remove all keyframes rules
91    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    /// Base path location of the CSS files
147    #[clap(short, long)]
148    pub path: String,
149    /// Public path of the CSS resources. This prefix is removed from the href.
150    #[clap(long, default_value_t)]
151    pub public_path: String,
152    /// Inline styles from external stylesheets
153    #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
154    pub external: bool,
155    /// Inline stylesheets smaller than a given size.
156    #[clap(long, default_value_t)]
157    pub inline_threshold: u32,
158    /// If the non-critical external stylesheet would be below this size, just inline it
159    #[clap(long, default_value_t)]
160    pub minimum_external_size: u32,
161    /// Remove inlined rules from the external stylesheet
162    #[clap(long)]
163    pub prune_source: bool,
164    /// Merge inlined stylesheets into a single `<style>` tag
165    #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
166    pub merge_stylesheets: bool,
167    /// Glob for matching other stylesheets to be used while looking for critical CSS.
168    #[clap(long)]
169    pub additional_stylesheets: Vec<String>,
170    /// Option indicates if inline styles should be evaluated for critical CSS. By default
171    /// inline style tags will be evaluated and rewritten to only contain critical CSS.
172    /// Set it to false to skip processing inline styles.
173    #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
174    pub reduce_inline_styles: bool,
175    /// Which preload strategy to use.
176    #[clap(long, default_value = "body-preload")]
177    pub preload: PreloadStrategy,
178    /// Add `<noscript>` fallback to JS-based strategies.
179    #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
180    pub noscript_fallback: bool,
181    /// Inline critical font-face rules.
182    #[clap(long)]
183    pub inline_fonts: bool,
184    /// Preloads critical fonts
185    #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
186    pub preload_fonts: bool,
187    /// Controls which keyframes rules are inlined.
188    #[clap(long, default_value = "critical")]
189    pub keyframes: KeyframesStrategy,
190    /// Compress resulting critical CSS
191    #[clap(long, action = clap::ArgAction::Set, default_value_t = true)]
192    pub compress: bool,
193    /// Provide a list of selectors that should be included in the critical CSS.
194    #[clap(skip)]
195    #[cfg_attr(feature = "typegen", ts(as = "Vec<String>"))]
196    pub allow_rules: Vec<Matcher>,
197    /// List of external stylesheets that should be inlined without an external
198    /// stylesheet reference. Links to these stylesheets will be removed, and
199    /// only the matched selectors will be preserved.
200    #[clap(skip)]
201    #[cfg_attr(feature = "typegen", ts(as = "Vec<String>"))]
202    pub exclude_external: Vec<Matcher>,
203}
204
205/// Statistics resulting from `Critters::process_dir`.
206#[derive(Debug)]
207#[cfg(any(feature = "cli", feature = "use-napi"))]
208#[cfg_attr(feature = "use-napi", napi)]
209pub struct CrittersDirectoryStats {
210    /// Total duration of processing, in seconds
211    pub time_sec: f64,
212    /// Number of pages processed
213    pub pages: u32,
214    // TODO: add more stats
215}
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        // try to initialize the logger, ignore error if it has already been initialized
254        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    /// Process the given HTML, extracting and inlining critical CSS
264    #[napi]
265    pub fn process(&self, html: String) -> anyhow::Result<String> {
266        self.process_impl(&html)
267    }
268
269    /// Process all HTML files in the configured directory
270    #[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    /// Process the given HTML, extracting and inlining critical CSS
283    #[cfg(not(feature = "use-napi"))]
284    pub fn process(&self, html: &str) -> anyhow::Result<String> {
285        self.process_impl(html)
286    }
287
288    /// Process the given HTML, extracting and inlining critical CSS
289    fn process_impl(&self, html: &str) -> anyhow::Result<String> {
290        // Parse the HTML into a DOM
291        let parser = html::parse_html();
292        let dom = parser.one(html);
293
294        let mut styles = Vec::new();
295
296        // Inline styles
297        if self.options.reduce_inline_styles {
298            styles.append(&mut self.get_inline_stylesheets(&dom));
299        }
300
301        // External stylesheets
302        if self.options.external {
303            styles.append(&mut self.get_external_stylesheets(&dom));
304        }
305
306        // Additional stylesheets
307        if !self.options.additional_stylesheets.is_empty() {
308            styles.append(&mut self.get_additional_stylesheets(&dom)?);
309        }
310
311        // Extract and inline critical CSS
312        debug!("Inlining {} stylesheets.", styles.len());
313        for style in styles.iter() {
314            let res = self.process_style_el(style, dom.clone());
315            // Log processing errors and skip associated stylesheets
316            if let Err(err) = res {
317                error!(
318                    "Error encountered when processing stylesheet, skipping. {}",
319                    err
320                );
321            }
322        }
323
324        // Merge stylesheets
325        if self.options.merge_stylesheets {
326            self.merge_stylesheets(styles)
327        }
328
329        // Serialize back to an HTML string
330        let mut result = Vec::new();
331        dom.serialize(&mut result)?;
332        Ok(String::from_utf8(result)?)
333    }
334
335    /// Process all HTML files in the configured directory
336    #[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    /// Process all HTML files in the configured directory
345    #[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    /// Gets inline styles from the document.
405    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    /// Resolve links to external stylesheets, inlining them and replacing the link with a preload strategy.
413    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    /// Resolve styles for the provided additional stylesheets, if any, and append them to the head.
429    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    /// Parse the given stylesheet and reduce it to contain only the nodes present in the given document.
441    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        // Precompute list of used selectors
454        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        // TODO: use a visitor to handle nested rules
487        // First pass, mark rules not present in the document for removal
488        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                // Filter selectors based on their usage in the document
493                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                        // easy selectors
500                        if selector == ":root"
501                            || selector == "html"
502                            || selector == "body"
503                            || global_pseudo_regex.is_match(&selector)
504                        {
505                            return true;
506                        }
507
508                        // allow rules
509                        if self
510                            .options
511                            .allow_rules
512                            .iter()
513                            .any(|m| m.matches(&selector))
514                        {
515                            return true;
516                        }
517
518                        // check DOM for elements matching selector
519                        // TODO: consider including failed selectors (mainly pseudo selectors)
520                        // by inverting this check to exclude unused selectors
521                        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                // Detect and collect keyframes and font usage
534                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                // TODO: keyframes mode options
566                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                // add preload directive to head
593                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        // serialize stylesheet
619        let css = ast.to_css(PrinterOptions {
620            minify: self.options.compress,
621            ..Default::default()
622        })?;
623
624        Ok(css.code)
625    }
626
627    /// Parse the stylesheet within a <style> element, then reduce it to contain only rules used by the document.
628    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            // skip empty stylesheets
632            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        // skip empty stylesheets
642        if sheet.is_empty() {
643            return Ok(());
644        }
645
646        let css = self.process_style(&sheet, dom)?;
647
648        // remove all existing text from style node
649        style.children().for_each(|c| c.detach());
650        style.append(NodeRef::new_text(css));
651
652        Ok(())
653    }
654
655    /// Given href, find the corresponding CSS asset
656    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        // CHECK - the output path
662        // path on disk (with output.publicPath removed)
663        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        // Ignore remote stylesheets
674        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        // Check if the resolved path is valid
690        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    /// Inline the provided stylesheet link, provided it matches the filtering options. Add preload markers for the external stylesheet as necessary.
712    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        // TODO: inline threshold?
735
736        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                // create new identical link
770                let body_link = NodeRef::new(link.data().clone());
771
772                // If an ID is present, remove it to avoid collisions.
773                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                    // avoid script injection
786                    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::Js | PreloadStrategy::JsLazy => todo!(),
816            PreloadStrategy::None => (),
817        };
818
819        Ok(Some(style))
820    }
821
822    /// Inject the given CSS stylesheet as a new <style> tag in the DOM
823    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    /// Injects a preload directive into the head for the given font URL.
836    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    /// Given a dictionary of paths and file contents, construct a temporary directory structure.
918    ///
919    /// Returns the path to the created temporary folder.
920    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}