rspack_plugin_lightning_css_minimizer/
lib.rs

1use std::{
2  collections::HashSet,
3  hash::Hash,
4  sync::{Arc, LazyLock, RwLock},
5};
6
7pub use lightningcss::targets::Browsers;
8use lightningcss::{
9  printer::PrinterOptions,
10  stylesheet::{MinifyOptions, ParserFlags, ParserOptions, StyleSheet},
11  targets::{Features, Targets},
12};
13use rayon::prelude::*;
14use regex::Regex;
15use rspack_core::{
16  ChunkUkey, Compilation, CompilationChunkHash, CompilationProcessAssets, Plugin,
17  diagnostics::MinifyError,
18  rspack_sources::{
19    MapOptions, RawStringSource, SourceExt, SourceMap, SourceMapSource, SourceMapSourceOptions,
20  },
21};
22use rspack_error::{Diagnostic, Result, ToStringResultToRspackResultExt};
23use rspack_hash::RspackHash;
24use rspack_hook::{plugin, plugin_hook};
25use rspack_util::asset_condition::AssetConditions;
26
27static CSS_ASSET_REGEXP: LazyLock<Regex> =
28  LazyLock::new(|| Regex::new(r"\.css(\?.*)?$").expect("Invalid RegExp"));
29
30#[derive(Debug, Hash)]
31pub struct PluginOptions {
32  pub test: Option<AssetConditions>,
33  pub include: Option<AssetConditions>,
34  pub exclude: Option<AssetConditions>,
35  pub remove_unused_local_idents: bool,
36  pub minimizer_options: MinimizerOptions,
37}
38
39#[derive(Debug, Hash)]
40pub struct Draft {
41  pub custom_media: bool,
42}
43
44#[derive(Debug, Hash)]
45pub struct NonStandard {
46  pub deep_selector_combinator: bool,
47}
48
49#[derive(Debug, Hash)]
50pub struct PseudoClasses {
51  pub hover: Option<String>,
52  pub active: Option<String>,
53  pub focus: Option<String>,
54  pub focus_visible: Option<String>,
55  pub focus_within: Option<String>,
56}
57
58#[derive(Debug)]
59pub struct MinimizerOptions {
60  pub error_recovery: bool,
61  pub targets: Option<Browsers>,
62  pub include: Option<u32>,
63  pub exclude: Option<u32>,
64  pub draft: Option<Draft>,
65  pub non_standard: Option<NonStandard>,
66  pub pseudo_classes: Option<PseudoClasses>,
67  pub unused_symbols: Vec<String>,
68}
69
70impl Hash for MinimizerOptions {
71  fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
72    self.error_recovery.hash(state);
73    self.include.hash(state);
74    self.exclude.hash(state);
75    self.draft.hash(state);
76    self.non_standard.hash(state);
77    self.unused_symbols.hash(state);
78    if let Some(pseudo_classes) = &self.pseudo_classes {
79      pseudo_classes.hover.hash(state);
80      pseudo_classes.active.hash(state);
81      pseudo_classes.focus.hash(state);
82      pseudo_classes.focus_visible.hash(state);
83      pseudo_classes.focus_within.hash(state);
84    }
85    if let Some(targets) = &self.targets {
86      targets.android.hash(state);
87      targets.chrome.hash(state);
88      targets.edge.hash(state);
89      targets.firefox.hash(state);
90      targets.ie.hash(state);
91      targets.ios_saf.hash(state);
92      targets.opera.hash(state);
93      targets.safari.hash(state);
94      targets.samsung.hash(state);
95    }
96  }
97}
98
99#[plugin]
100#[derive(Debug)]
101pub struct LightningCssMinimizerRspackPlugin {
102  options: PluginOptions,
103}
104
105pub fn match_object(obj: &PluginOptions, str: &str) -> bool {
106  if let Some(condition) = &obj.test
107    && !condition.try_match(str)
108  {
109    return false;
110  }
111  if let Some(condition) = &obj.include
112    && !condition.try_match(str)
113  {
114    return false;
115  }
116  if let Some(condition) = &obj.exclude
117    && condition.try_match(str)
118  {
119    return false;
120  }
121  true
122}
123
124impl LightningCssMinimizerRspackPlugin {
125  pub fn new(options: PluginOptions) -> Self {
126    Self::new_inner(options)
127  }
128}
129
130#[plugin_hook(CompilationChunkHash for LightningCssMinimizerRspackPlugin)]
131async fn chunk_hash(
132  &self,
133  _compilation: &Compilation,
134  _chunk_ukey: &ChunkUkey,
135  hasher: &mut RspackHash,
136) -> Result<()> {
137  self.options.hash(hasher);
138  Ok(())
139}
140
141#[plugin_hook(CompilationProcessAssets for LightningCssMinimizerRspackPlugin, stage = Compilation::PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE)]
142async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> {
143  let options = &self.options;
144  let minimizer_options = &self.options.minimizer_options;
145  let all_warnings: RwLock<Vec<Diagnostic>> = Default::default();
146  compilation
147    .assets_mut()
148    .par_iter_mut()
149    .filter(|(filename, original)| {
150      if !CSS_ASSET_REGEXP.is_match(filename) {
151        return false;
152      }
153
154      let is_matched = match_object(options, filename);
155
156      if !is_matched || original.get_info().minimized.unwrap_or(false) {
157        return false;
158      }
159
160      true
161    })
162    .try_for_each(|(filename, original)| -> Result<()> {
163      if original.get_info().minimized.unwrap_or(false) {
164        return Ok(());
165      }
166
167      if let Some(original_source) = original.get_source() {
168        let input = original_source.source().into_owned();
169        let input_source_map = original_source.map(&MapOptions::default());
170
171        let mut parser_flags = ParserFlags::empty();
172        parser_flags.set(
173          ParserFlags::CUSTOM_MEDIA,
174          matches!(&minimizer_options.draft, Some(draft) if draft.custom_media),
175        );
176        parser_flags.set(
177          ParserFlags::DEEP_SELECTOR_COMBINATOR,
178          matches!(&minimizer_options.non_standard, Some(non_standard) if non_standard.deep_selector_combinator),
179        );
180
181        let mut source_map = input_source_map
182          .as_ref()
183          .map(|input_source_map| -> Result<_> {
184            let mut sm =
185              parcel_sourcemap::SourceMap::new(input_source_map.source_root().unwrap_or("/"));
186            sm.add_source(filename);
187            sm.set_source_content(0, &input).to_rspack_result()?;
188            Ok(sm)
189          })
190          .transpose()?;
191        let result = {
192          let warnings: Arc<RwLock<Vec<_>>> = Default::default();
193          let mut stylesheet = StyleSheet::parse(
194            &input,
195            ParserOptions {
196              filename: filename.to_string(),
197              css_modules: None,
198              source_index: 0,
199              error_recovery: minimizer_options.error_recovery,
200              warnings: Some(warnings.clone()),
201              flags: parser_flags,
202            },
203          )
204          .to_rspack_result()?;
205
206          let targets = Targets {
207            browsers: minimizer_options.targets,
208            include: minimizer_options
209              .include
210              .as_ref()
211              .map(|include| Features::from_bits_truncate(*include))
212              .unwrap_or(Features::empty()),
213            exclude: minimizer_options
214              .exclude
215              .as_ref()
216              .map(|exclude| Features::from_bits_truncate(*exclude))
217              .unwrap_or(Features::empty()),
218          };
219          let mut unused_symbols = HashSet::from_iter(minimizer_options.unused_symbols.clone());
220          if self.options.remove_unused_local_idents
221            && let Some(css_unused_idents) = original.info.css_unused_idents.take()
222          {
223            unused_symbols.extend(css_unused_idents);
224          }
225          stylesheet
226            .minify(MinifyOptions {
227              targets,
228              unused_symbols,
229            })
230            .to_rspack_result()?;
231          // FIXME: Disable the warnings for now, cause it cause too much positive-negative warnings,
232          // enable when we have a better way to handle it. let warnings = warnings.read().expect("should lock");
233          // all_warnings.write().expect("should lock").extend(
234          //   warnings.iter().map(|e| {
235          //     if let Some(loc) = &e.loc {
236          //       let rope = ropey::Rope::from_str(&input);
237          //       let start = rope.line_to_byte(loc.line as usize) + loc.column as usize - 1;
238          //       let end = start;
239          //       Diagnostic::from(Box::new(TraceableError::from_file(
240          //         input.clone(),
241          //         start,
242          //         end,
243          //         "LightningCSS minimize warning".to_string(),
244          //         e.to_string(),
245          //       )
246          //       .with_severity(RspackSeverity::Warn)) as Box<dyn miette::Diagnostic + Send + Sync>)
247          //     } else {
248          //       Diagnostic::warn("LightningCSS minimize warning".to_string(), e.to_string())
249          //     }
250          //   }),
251          // );
252          stylesheet
253            .to_css(PrinterOptions {
254              minify: true,
255              source_map: source_map.as_mut(),
256              project_root: None,
257              targets,
258              analyze_dependencies: None,
259              pseudo_classes: minimizer_options.pseudo_classes
260              .as_ref()
261              .map(|pseudo_classes| lightningcss::stylesheet::PseudoClasses {
262                hover: pseudo_classes.hover.as_deref(),
263                active: pseudo_classes.active.as_deref(),
264                focus: pseudo_classes.focus.as_deref(),
265                focus_visible: pseudo_classes.focus_visible.as_deref(),
266                focus_within: pseudo_classes.focus_within.as_deref(),
267              }),
268            })
269            .to_rspack_result()?
270        };
271
272        let minimized_source = if let Some(mut source_map) = source_map {
273          SourceMapSource::new(SourceMapSourceOptions {
274            value: result.code,
275            name: filename,
276            source_map: SourceMap::from_json(
277              &source_map
278                .to_json(None)
279                .to_rspack_result()?,
280            )
281            .expect("should be able to generate source-map"),
282            original_source: Some(input),
283            inner_source_map: input_source_map,
284            remove_original_source: true,
285          })
286          .boxed()
287        } else {
288          RawStringSource::from(result.code).boxed()
289        };
290
291        original.set_source(Some(minimized_source));
292      }
293      original.get_info_mut().minimized.replace(true);
294      Ok(())
295    }).map_err(MinifyError)?;
296
297  compilation.extend_diagnostics(all_warnings.into_inner().expect("should lock"));
298
299  Ok(())
300}
301
302impl Plugin for LightningCssMinimizerRspackPlugin {
303  fn name(&self) -> &'static str {
304    "rspack.LightningCssMinimizerRspackPlugin"
305  }
306
307  fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> {
308    ctx.compilation_hooks.chunk_hash.tap(chunk_hash::new(self));
309    ctx
310      .compilation_hooks
311      .process_assets
312      .tap(process_assets::new(self));
313    Ok(())
314  }
315}