Skip to main content

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