1use std::{
2 borrow::Cow,
3 hash::Hash,
4 sync::{Arc, LazyLock},
5};
6
7use cow_utils::CowUtils;
8use regex::Regex;
9use rspack_cacheable::cacheable;
10use rspack_collections::{DatabaseItem, IdentifierMap, IdentifierSet, UkeySet};
11use rspack_core::{
12 AssetInfo, Chunk, ChunkGraph, ChunkGroupUkey, ChunkKind, ChunkUkey, Compilation,
13 CompilationContentHash, CompilationParams, CompilationRenderManifest,
14 CompilationRuntimeRequirementInTree, CompilerCompilation, DependencyType, Filename, Module,
15 ModuleGraph, ModuleIdentifier, ModuleType, NormalModuleFactoryParser, ParserAndGenerator,
16 ParserOptions, PathData, Plugin, RenderManifestEntry, RuntimeGlobals, SourceType, get_undo_path,
17 rspack_sources::{
18 BoxSource, CachedSource, ConcatSource, RawStringSource, SourceExt, SourceMap, SourceMapSource,
19 WithoutOriginalOptions,
20 },
21};
22use rspack_error::{Diagnostic, Result};
23use rspack_hash::RspackHash;
24use rspack_hook::{plugin, plugin_hook};
25use rspack_plugin_javascript::{
26 BoxJavascriptParserPlugin, parser_and_generator::JavaScriptParserAndGenerator,
27};
28use rspack_plugin_runtime::GetChunkFilenameRuntimeModule;
29use rustc_hash::FxHashMap;
30use ustr::Ustr;
31
32use crate::{
33 css_module::{CssModule, CssModuleFactory},
34 parser_plugin::PluginCssExtractParserPlugin,
35 runtime::CssLoadingRuntimeModule,
36};
37pub static PLUGIN_NAME: &str = "css-extract-rspack-plugin";
38
39pub static MODULE_TYPE_STR: LazyLock<Ustr> = LazyLock::new(|| Ustr::from("css/mini-extract"));
40pub static MODULE_TYPE: LazyLock<ModuleType> =
41 LazyLock::new(|| ModuleType::Custom(*MODULE_TYPE_STR));
42pub static SOURCE_TYPE: LazyLock<[SourceType; 1]> =
43 LazyLock::new(|| [SourceType::Custom(*MODULE_TYPE_STR)]);
44
45pub static BASE_URI: &str = "webpack://";
46pub static ABSOLUTE_PUBLIC_PATH: &str = "webpack:///mini-css-extract-plugin/";
47pub static AUTO_PUBLIC_PATH: &str = "__mini_css_extract_plugin_public_path_auto__";
48pub static SINGLE_DOT_PATH_SEGMENT: &str = "__mini_css_extract_plugin_single_dot_path_segment__";
49
50static STARTS_WITH_AT_IMPORT: &str = "@import url";
51
52struct CssOrderConflicts {
53 chunk: ChunkUkey,
54 fallback_module: ModuleIdentifier,
55
56 reasons: Vec<(ModuleIdentifier, Option<String>, Option<String>)>,
58}
59
60#[plugin]
61#[derive(Debug)]
62pub struct PluginCssExtract {
63 pub(crate) options: Arc<CssExtractOptions>,
64}
65
66impl Eq for PluginCssExtractInner {}
67
68impl PartialEq for PluginCssExtractInner {
69 fn eq(&self, other: &Self) -> bool {
70 Arc::ptr_eq(&self.options, &other.options)
71 }
72}
73
74#[derive(Debug)]
75pub struct CssExtractOptions {
76 pub filename: Filename,
77 pub chunk_filename: Filename,
78 pub ignore_order: bool,
79 pub insert: InsertType,
80 pub attributes: FxHashMap<String, String>,
81 pub link_type: Option<String>,
82 pub runtime: bool,
83 pub pathinfo: bool,
84 pub enforce_relative: bool,
85}
86
87#[cacheable]
111#[derive(Debug, PartialEq, Eq, Clone)]
112pub enum InsertType {
113 Fn(String),
114 Selector(String),
115 Default,
116}
117
118impl PluginCssExtract {
119 pub fn new(options: CssExtractOptions) -> Self {
120 Self::new_inner(Arc::new(options))
121 }
122
123 fn sort_modules<'comp>(
125 &self,
126 chunk: &Chunk,
127 modules: &[&dyn Module],
128 compilation: &'comp Compilation,
129 module_graph: &'comp ModuleGraph<'comp>,
130 ) -> (Vec<&'comp dyn Module>, Option<Vec<CssOrderConflicts>>) {
131 let mut module_deps_reasons: IdentifierMap<IdentifierMap<UkeySet<ChunkGroupUkey>>> = modules
132 .iter()
133 .map(|m| (m.identifier(), Default::default()))
134 .collect();
135
136 let mut module_dependencies: IdentifierMap<IdentifierSet> = modules
137 .iter()
138 .map(|module| (module.identifier(), IdentifierSet::default()))
139 .collect();
140
141 let mut groups = chunk.groups().iter().cloned().collect::<Vec<_>>();
142 groups.sort_by(|a, b| {
143 let a = compilation.chunk_group_by_ukey.expect_get(a);
144 let b = compilation.chunk_group_by_ukey.expect_get(b);
145 match a.index.cmp(&b.index) {
146 std::cmp::Ordering::Equal => a.ukey.cmp(&b.ukey),
147 order_res => order_res,
148 }
149 });
150
151 let mut modules_by_chunk_group = groups
152 .iter()
153 .map(|chunk_group| {
154 let chunk_group = compilation.chunk_group_by_ukey.expect_get(chunk_group);
155 let mut sorted_module = modules
156 .iter()
157 .map(|module| {
158 let identifier = module.identifier();
159 (identifier, chunk_group.module_post_order_index(&identifier))
160 })
161 .filter_map(|(id, idx)| idx.map(|idx| (id, idx)))
162 .collect::<Vec<_>>();
163
164 sorted_module.sort_by(|(_, idx1), (_, idx2)| idx2.cmp(idx1));
165
166 for (i, (module, _)) in sorted_module.iter().enumerate() {
167 let set = module_dependencies
168 .get_mut(module)
169 .expect("should have module before");
170
171 let reasons = module_deps_reasons
172 .get_mut(module)
173 .expect("should have module dep reason");
174
175 let mut j = i + 1;
176 while j < sorted_module.len() {
177 let (module, _) = sorted_module[j];
178 set.insert(module);
179
180 let reason = reasons.entry(module).or_default();
181 reason.insert(chunk_group.ukey);
182
183 j += 1;
184 }
185 }
186
187 sorted_module
188 })
189 .collect::<Vec<Vec<(ModuleIdentifier, usize)>>>();
190
191 let mut used_modules: IdentifierSet = Default::default();
192 let mut result: Vec<&dyn Module> = Default::default();
193 let mut conflicts: Option<Vec<CssOrderConflicts>> = None;
194
195 while used_modules.len() < modules.len() {
196 let mut success = false;
197 let mut best_match: Option<Vec<ModuleIdentifier>> = None;
198 let mut best_match_deps: Option<Vec<ModuleIdentifier>> = None;
199
200 for list in &mut modules_by_chunk_group {
201 while !list.is_empty()
203 && used_modules.contains(&list.last().expect("should have list item").0)
204 {
205 list.pop();
206 }
207
208 if !list.is_empty() {
210 let module = list.last().expect("should have item").0;
211 let deps = module_dependencies.get(&module).expect("should have deps");
212 let failed_deps = deps
213 .iter()
214 .filter(|dep| !used_modules.contains(dep))
215 .cloned()
216 .collect::<Vec<_>>();
217
218 let failed_count = failed_deps.len();
219
220 if best_match_deps.is_none()
221 || best_match_deps
222 .as_ref()
223 .expect("should have best match dep")
224 .len()
225 > failed_deps.len()
226 {
227 best_match = Some(list.iter().map(|(id, _)| *id).collect());
228 best_match_deps = Some(failed_deps);
229 }
230
231 if failed_count == 0 {
232 list.pop();
233 used_modules.insert(module);
234 result.push(
235 module_graph
236 .module_by_identifier(&module)
237 .expect("should have module")
238 .as_ref(),
239 );
240 success = true;
241 break;
242 }
243 }
244 }
245
246 if !success {
247 let mut best_match = best_match.expect("should have best match");
251 let best_match_deps = best_match_deps.expect("should have best match");
252 let fallback_module = best_match.pop().expect("should have best match");
253 if !self.options.ignore_order {
254 let reasons = module_deps_reasons
255 .get(&fallback_module)
256 .expect("should have dep reason");
257
258 let new_conflict = CssOrderConflicts {
259 chunk: chunk.ukey(),
260 fallback_module,
261 reasons: best_match_deps
262 .into_iter()
263 .map(|m| {
264 let good_reasons_map = module_deps_reasons.get(&m);
265 let good_reasons =
266 good_reasons_map.and_then(|reasons| reasons.get(&fallback_module));
267
268 let failed_chunk_groups = reasons.get(&m).map(|reasons| {
269 reasons
270 .iter()
271 .filter_map(|cg| {
272 let chunk_group = compilation.chunk_group_by_ukey.expect_get(cg);
273
274 chunk_group.name()
275 })
276 .collect::<Vec<_>>()
277 .join(",")
278 });
279
280 let good_chunk_groups = good_reasons.map(|reasons| {
281 reasons
282 .iter()
283 .filter_map(|cg| compilation.chunk_group_by_ukey.expect_get(cg).name())
284 .collect::<Vec<_>>()
285 .join(", ")
286 });
287
288 (m, failed_chunk_groups, good_chunk_groups)
289 })
290 .collect(),
291 };
292 if let Some(conflicts) = &mut conflicts {
293 conflicts.push(new_conflict);
294 } else {
295 conflicts = Some(vec![new_conflict]);
296 }
297 }
298
299 used_modules.insert(fallback_module);
300 result.push(
301 module_graph
302 .module_by_identifier(&fallback_module)
303 .expect("should have fallback module")
304 .as_ref(),
305 );
306 }
307 }
308
309 (result, conflicts)
310 }
311
312 async fn render_content_asset(
313 &self,
314 chunk: &Chunk,
315 rendered_modules: &[&dyn Module],
316 filename: &str,
317 compilation: &'_ Compilation,
318 ) -> (BoxSource, Vec<Diagnostic>) {
319 let module_graph = compilation.get_module_graph();
320 let (used_modules, conflicts) =
323 self.sort_modules(chunk, rendered_modules, compilation, &module_graph);
324
325 let mut diagnostics = Vec::new();
326 if let Some(conflicts) = conflicts {
327 diagnostics.extend(conflicts.into_iter().map(|conflict| {
328 let chunk = compilation.chunk_by_ukey.expect_get(&conflict.chunk);
329 let fallback_module = module_graph
330 .module_by_identifier(&conflict.fallback_module)
331 .expect("should have module");
332
333 let mut diagnostic = Diagnostic::warn(
334 "".into(),
335 format!(
336 r#"chunk {} [{PLUGIN_NAME}]
337Conflicting order. Following module has been added:
338 * {}
339despite it was not able to fulfill desired ordering with these modules:
340{}"#,
341 chunk
342 .name()
343 .or_else(|| chunk
344 .id(&compilation.chunk_ids_artifact)
345 .map(|id| id.as_str()))
346 .unwrap_or_default(),
347 fallback_module.readable_identifier(&compilation.options.context),
348 conflict
349 .reasons
350 .iter()
351 .map(|(m, failed_reasons, good_reasons)| {
352 let m = module_graph
353 .module_by_identifier(m)
354 .expect("should have module");
355
356 format!(
357 " * {}\n - couldn't fulfill desired order of chunk group(s) {}{}",
358 m.readable_identifier(&compilation.options.context),
359 failed_reasons
360 .as_ref()
361 .map(|s| s.as_str())
362 .unwrap_or_default(),
363 good_reasons
364 .as_ref()
365 .map(|s| format!(
366 "\n - while fulfilling desired order of chunk group(s) {}",
367 s.as_str()
368 ))
369 .unwrap_or_default(),
370 )
371 })
372 .collect::<Vec<_>>()
373 .join("\n")
374 ),
375 );
376 diagnostic.file = Some(filename.to_owned().into());
377 diagnostic.chunk = Some(chunk.ukey().as_u32());
378 diagnostic
379 }));
380 }
381
382 let used_modules = used_modules
383 .into_iter()
384 .filter_map(|module| module.downcast_ref::<CssModule>());
385
386 let mut source = ConcatSource::default();
387 let mut external_source = ConcatSource::default();
388
389 for module in used_modules {
390 let content = Cow::Borrowed(module.content.as_str());
391 let readable_identifier = module.readable_identifier(&compilation.options.context);
392 let starts_with_at_import = content.starts_with(STARTS_WITH_AT_IMPORT);
393
394 let header = self.options.pathinfo.then(|| {
395 let req_str = readable_identifier.cow_replace("*/", "*_/");
396 let req_str_star = "*".repeat(req_str.len());
397 RawStringSource::from(format!(
398 "/*!****{req_str_star}****!*\\\n !*** {req_str} ***!\n \\****{req_str_star}****/\n"
399 ))
400 });
401
402 if starts_with_at_import {
403 if let Some(header) = header {
404 external_source.add(header);
405 }
406 if let Some(media) = &module.media {
407 static MEDIA_RE: LazyLock<Regex> =
408 LazyLock::new(|| Regex::new(r#";|\s*$"#).expect("should compile"));
409 let new_content = MEDIA_RE.replace_all(content.as_ref(), media);
410 external_source.add(RawStringSource::from(new_content.to_string() + "\n"));
411 } else {
412 external_source.add(RawStringSource::from(content.to_string() + "\n"));
413 }
414 } else {
415 let mut need_supports = false;
416 let mut need_media = false;
417
418 if let Some(header) = header {
419 source.add(header);
420 }
421
422 if let Some(supports) = &module.supports
423 && !supports.is_empty()
424 {
425 need_supports = true;
426 source.add(RawStringSource::from(format!(
427 "@supports ({supports}) {{\n"
428 )));
429 }
430
431 if let Some(media) = &module.media
432 && !media.is_empty()
433 {
434 need_media = true;
435 source.add(RawStringSource::from(format!("@media {media} {{\n")));
436 }
437
438 if let Some(layer) = &module.css_layer {
439 source.add(RawStringSource::from(format!("@layer {layer} {{\n")));
440 }
441
442 let undo_path = get_undo_path(
444 filename,
445 compilation.options.output.path.to_string(),
446 self.options.enforce_relative,
447 );
448
449 let content = content.cow_replace(ABSOLUTE_PUBLIC_PATH, "");
450 let content = content.cow_replace(SINGLE_DOT_PATH_SEGMENT, ".");
451 let content = content.cow_replace(AUTO_PUBLIC_PATH, &undo_path);
452 let content = content.cow_replace(
453 BASE_URI,
454 chunk
455 .get_entry_options(&compilation.chunk_group_by_ukey)
456 .and_then(|entry_options| entry_options.base_uri.as_ref())
457 .unwrap_or(&undo_path),
458 );
459
460 if let Some(source_map) = &module.source_map {
461 source.add(SourceMapSource::new(WithoutOriginalOptions {
462 value: content.to_string(),
463 name: readable_identifier,
464 source_map: SourceMap::from_json(source_map).expect("invalid sourcemap"),
465 }))
466 } else {
467 source.add(RawStringSource::from(content.to_string()));
468 }
469
470 source.add(RawStringSource::from_static("\n"));
471
472 if need_media {
473 source.add(RawStringSource::from_static("}\n"));
474 }
475
476 if need_supports {
477 source.add(RawStringSource::from_static("}\n"));
478 }
479
480 if module.css_layer.is_some() {
481 source.add(RawStringSource::from_static("}\n"));
482 }
483 }
484 }
485
486 external_source.add(source);
487 (external_source.boxed(), diagnostics)
488 }
489}
490
491#[plugin_hook(CompilerCompilation for PluginCssExtract)]
492async fn compilation(
493 &self,
494 compilation: &mut Compilation,
495 _params: &mut CompilationParams,
496) -> Result<()> {
497 compilation.set_dependency_factory(DependencyType::ExtractCSS, Arc::new(CssModuleFactory));
498 Ok(())
499}
500
501#[plugin_hook(CompilationRuntimeRequirementInTree for PluginCssExtract)]
502async fn runtime_requirement_in_tree(
503 &self,
504 compilation: &mut Compilation,
505 chunk_ukey: &ChunkUkey,
506 _all_runtime_requirements: &RuntimeGlobals,
507 runtime_requirements: &RuntimeGlobals,
508 runtime_requirements_mut: &mut RuntimeGlobals,
509) -> Result<Option<()>> {
510 if !self.options.runtime {
519 return Ok(None);
520 }
521
522 let has_hot_update = runtime_requirements.contains(RuntimeGlobals::HMR_DOWNLOAD_UPDATE_HANDLERS);
523
524 if has_hot_update || runtime_requirements.contains(RuntimeGlobals::ENSURE_CHUNK_HANDLERS) {
525 if self.options.chunk_filename.has_hash_placeholder() {
526 runtime_requirements_mut.insert(RuntimeGlobals::GET_FULL_HASH);
527 }
528
529 runtime_requirements_mut.insert(RuntimeGlobals::PUBLIC_PATH);
530
531 let filename = self.options.filename.clone();
532 let chunk_filename = self.options.chunk_filename.clone();
533
534 compilation.add_runtime_module(
535 chunk_ukey,
536 Box::new(GetChunkFilenameRuntimeModule::new(
537 "css",
538 "mini-css",
539 SOURCE_TYPE[0],
540 "__webpack_require__.miniCssF".into(),
541 move |runtime_requirements| {
542 runtime_requirements.contains(RuntimeGlobals::HMR_DOWNLOAD_UPDATE_HANDLERS)
543 },
544 move |chunk, compilation| {
545 chunk
546 .content_hash(&compilation.chunk_hashes_artifact)?
547 .contains_key(&SOURCE_TYPE[0])
548 .then(|| {
549 if chunk.can_be_initial(&compilation.chunk_group_by_ukey) {
550 filename.clone()
551 } else {
552 chunk_filename.clone()
553 }
554 })
555 },
556 )),
557 )?;
558
559 compilation.add_runtime_module(
560 chunk_ukey,
561 Box::new(CssLoadingRuntimeModule::new(
562 *chunk_ukey,
563 self.options.attributes.clone(),
564 self.options.link_type.clone(),
565 self.options.insert.clone(),
566 )),
567 )?;
568 }
569
570 Ok(None)
571}
572
573#[plugin_hook(CompilationContentHash for PluginCssExtract)]
574async fn content_hash(
575 &self,
576 compilation: &Compilation,
577 chunk_ukey: &ChunkUkey,
578 hashes: &mut FxHashMap<SourceType, RspackHash>,
579) -> Result<()> {
580 let module_graph = compilation.get_module_graph();
581
582 let rendered_modules = compilation.chunk_graph.get_chunk_modules_by_source_type(
583 chunk_ukey,
584 SOURCE_TYPE[0],
585 &module_graph,
586 );
587
588 if rendered_modules.is_empty() {
589 return Ok(());
590 }
591 let chunk = compilation.chunk_by_ukey.expect_get(chunk_ukey);
592
593 let (used_modules, diagnostics) =
594 self.sort_modules(chunk, &rendered_modules, compilation, &module_graph);
595
596 let hasher = hashes
597 .entry(SOURCE_TYPE[0])
598 .or_insert_with(|| RspackHash::from(&compilation.options.output));
599
600 used_modules
601 .iter()
602 .map(|m| ChunkGraph::get_module_hash(compilation, m.identifier(), chunk.runtime()))
603 .for_each(|current| current.hash(hasher));
604
605 " ".hash(hasher);
606 if let Some(diagnostics) = diagnostics {
607 diagnostics.iter().for_each(|curr| {
608 curr.fallback_module.hash(hasher);
609 });
610 }
611
612 Ok(())
613}
614
615#[plugin_hook(CompilationRenderManifest for PluginCssExtract)]
616async fn render_manifest(
617 &self,
618 compilation: &Compilation,
619 chunk_ukey: &ChunkUkey,
620 manifest: &mut Vec<RenderManifestEntry>,
621 diagnostics: &mut Vec<Diagnostic>,
622) -> Result<()> {
623 let module_graph = compilation.get_module_graph();
624 let chunk = compilation.chunk_by_ukey.expect_get(chunk_ukey);
625
626 if matches!(chunk.kind(), ChunkKind::HotUpdate) {
627 return Ok(());
628 }
629
630 let rendered_modules = compilation.chunk_graph.get_chunk_modules_by_source_type(
631 chunk_ukey,
632 SOURCE_TYPE[0],
633 &module_graph,
634 );
635
636 if rendered_modules.is_empty() {
637 return Ok(());
638 }
639
640 let filename_template = if chunk.can_be_initial(&compilation.chunk_group_by_ukey) {
641 &self.options.filename
642 } else {
643 &self.options.chunk_filename
644 };
645
646 let mut asset_info = AssetInfo::default();
647 let filename = compilation
648 .get_path_with_info(
649 filename_template,
650 PathData::default()
651 .chunk_id_optional(
652 chunk
653 .id(&compilation.chunk_ids_artifact)
654 .map(|id| id.as_str()),
655 )
656 .chunk_hash_optional(chunk.rendered_hash(
657 &compilation.chunk_hashes_artifact,
658 compilation.options.output.hash_digest_length,
659 ))
660 .chunk_name_optional(chunk.name_for_filename_template(&compilation.chunk_ids_artifact))
661 .content_hash_optional(chunk.rendered_content_hash_by_source_type(
662 &compilation.chunk_hashes_artifact,
663 &SOURCE_TYPE[0],
664 compilation.options.output.hash_digest_length,
665 )),
666 &mut asset_info,
667 )
668 .await?;
669
670 let (source, more_diagnostics) = compilation
671 .chunk_render_cache_artifact
672 .use_cache(compilation, chunk, &SOURCE_TYPE[0], || async {
673 let (source, diagnostics) = self
674 .render_content_asset(chunk, &rendered_modules, &filename, compilation)
675 .await;
676 Ok((CachedSource::new(source).boxed(), diagnostics))
677 })
678 .await?;
679
680 diagnostics.extend(more_diagnostics);
681 manifest.push(RenderManifestEntry {
682 source,
683 filename,
684 has_filename: false,
685 info: asset_info,
686 auxiliary: false,
687 });
688
689 Ok(())
690}
691
692#[plugin_hook(NormalModuleFactoryParser for PluginCssExtract)]
693async fn nmf_parser(
694 &self,
695 module_type: &ModuleType,
696 parser: &mut dyn ParserAndGenerator,
697 _parser_options: Option<&ParserOptions>,
698) -> Result<()> {
699 if module_type.is_js_like()
700 && let Some(parser) = parser.downcast_mut::<JavaScriptParserAndGenerator>()
701 {
702 parser.add_parser_plugin(
703 Box::<PluginCssExtractParserPlugin>::default() as BoxJavascriptParserPlugin
704 );
705 }
706 Ok(())
707}
708
709impl Plugin for PluginCssExtract {
710 fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> {
711 ctx.compiler_hooks.compilation.tap(compilation::new(self));
712 ctx
713 .compilation_hooks
714 .runtime_requirement_in_tree
715 .tap(runtime_requirement_in_tree::new(self));
716 ctx
717 .compilation_hooks
718 .content_hash
719 .tap(content_hash::new(self));
720 ctx
721 .compilation_hooks
722 .render_manifest
723 .tap(render_manifest::new(self));
724
725 ctx
726 .normal_module_factory_hooks
727 .parser
728 .tap(nmf_parser::new(self));
729
730 Ok(())
731 }
732}