rspack_plugin_lightning_css_minimizer/
lib.rs1use 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 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}