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 draft: 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.draft.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.draft, Some(draft) if draft.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.to_string(),
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(|include| Features::from_bits_truncate(*include))
203              .unwrap_or(Features::empty()),
204            exclude: minimizer_options
205              .exclude
206              .as_ref()
207              .map(|exclude| Features::from_bits_truncate(*exclude))
208              .unwrap_or(Features::empty()),
209          };
210          let mut unused_symbols = HashSet::from_iter(minimizer_options.unused_symbols.clone());
211          if self.options.remove_unused_local_idents
212            && let Some(css_unused_idents) = original.info.css_unused_idents.take()
213          {
214            unused_symbols.extend(css_unused_idents);
215          }
216          stylesheet
217            .minify(MinifyOptions {
218              targets,
219              unused_symbols,
220            })
221            .to_rspack_result()?;
222          // FIXME: Disable the warnings for now, cause it cause too much positive-negative warnings,
223          // enable when we have a better way to handle it. let warnings = warnings.read().expect("should lock");
224          // all_warnings.write().expect("should lock").extend(
225          //   warnings.iter().map(|e| {
226          //     if let Some(loc) = &e.loc {
227          //       let rope = ropey::Rope::from_str(&input);
228          //       let start = rope.line_to_byte(loc.line as usize) + loc.column as usize - 1;
229          //       let end = start;
230          //       Diagnostic::from(Box::new(Error::from_file(
231          //         input.clone(),
232          //         start,
233          //         end,
234          //         "LightningCSS minimize warning".to_string(),
235          //         e.to_string(),
236          //       )
237          //       .with_severity(Severity::Warning)))
238          //     } else {
239          //       Diagnostic::warn("LightningCSS minimize warning".to_string(), e.to_string())
240          //     }
241          //   }),
242          // );
243          stylesheet
244            .to_css(PrinterOptions {
245              minify: true,
246              source_map: source_map.as_mut(),
247              project_root: None,
248              targets,
249              analyze_dependencies: None,
250              pseudo_classes: minimizer_options.pseudo_classes
251              .as_ref()
252              .map(|pseudo_classes| lightningcss::stylesheet::PseudoClasses {
253                hover: pseudo_classes.hover.as_deref(),
254                active: pseudo_classes.active.as_deref(),
255                focus: pseudo_classes.focus.as_deref(),
256                focus_visible: pseudo_classes.focus_visible.as_deref(),
257                focus_within: pseudo_classes.focus_within.as_deref(),
258              }),
259            })
260            .to_rspack_result()?
261        };
262
263        let minimized_source = if let Some(mut source_map) = source_map {
264          SourceMapSource::new(SourceMapSourceOptions {
265            value: result.code,
266            name: filename,
267            source_map: SourceMap::from_json(
268              &source_map
269                .to_json(None)
270                .to_rspack_result()?,
271            )
272            .expect("should be able to generate source-map"),
273            original_source: Some(Arc::from(input)),
274            inner_source_map: input_source_map,
275            remove_original_source: true,
276          })
277          .boxed()
278        } else {
279          RawStringSource::from(result.code).boxed()
280        };
281
282        original.set_source(Some(minimized_source));
283      }
284      original.get_info_mut().minimized.replace(true);
285      Ok(())
286    }).map_err(MinifyError)?;
287
288  compilation.extend_diagnostics(all_warnings.into_inner().expect("should lock"));
289
290  Ok(())
291}
292
293impl Plugin for LightningCssMinimizerRspackPlugin {
294  fn name(&self) -> &'static str {
295    "rspack.LightningCssMinimizerRspackPlugin"
296  }
297
298  fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> {
299    ctx.compilation_hooks.chunk_hash.tap(chunk_hash::new(self));
300    ctx
301      .compilation_hooks
302      .process_assets
303      .tap(process_assets::new(self));
304    Ok(())
305  }
306}