//! CSS bundling. //! //! A [Bundler](Bundler) can be used to combine a CSS file and all of its dependencies //! into a single merged style sheet. It works together with a [SourceProvider](SourceProvider) //! (e.g. [FileProvider](FileProvider)) to read files from the file system or another source, //! and returns a [StyleSheet](super::stylesheet::StyleSheet) containing the rules from all //! of the dependencies of the entry file, recursively. //! //! Rules are bundled following `@import` order, and wrapped in the necessary `@media`, `@supports`, //! and `@layer` rules as appropriate to preserve the authored behavior. //! //! # Example //! //! ```no_run //! use std::path::Path; //! use lightningcss::{ //! bundler::{Bundler, FileProvider}, //! stylesheet::ParserOptions //! }; //! //! let fs = FileProvider::new(); //! let mut bundler = Bundler::new(&fs, None, ParserOptions::default()); //! let stylesheet = bundler.bundle(Path::new("style.css")).unwrap(); //! ``` use crate::{ error::ErrorLocation, parser::DefaultAtRuleParser, properties::{ css_modules::Specifier, custom::{ CustomProperty, EnvironmentVariableName, TokenList, TokenOrValue, UnparsedProperty, UnresolvedColor, }, Property, }, rules::{ layer::{LayerBlockRule, LayerName}, Location, }, traits::{AtRuleParser, ToCss}, values::ident::DashedIdentReference, }; use crate::{ error::{Error, ParserError}, media_query::MediaList, rules::{ import::ImportRule, media::MediaRule, supports::{SupportsCondition, SupportsRule}, CssRule, CssRuleList, }, stylesheet::{ParserOptions, StyleSheet}, }; use dashmap::DashMap; use parcel_sourcemap::SourceMap; use rayon::prelude::*; use std::{ collections::HashSet, fs, path::{Path, PathBuf}, sync::Mutex, }; /// A Bundler combines a CSS file and all imported dependencies together into /// a single merged style sheet. pub struct Bundler<'a, 'o, 's, P, T: AtRuleParser<'a>> { source_map: Option>, fs: &'a P, source_indexes: DashMap, stylesheets: Mutex>>, options: ParserOptions<'o, 'a>, at_rule_parser: Mutex>, } enum AtRuleParserValue<'a, T> { Owned(T), Borrowed(&'a mut T), } struct BundleStyleSheet<'i, 'o, T> { stylesheet: Option>, dependencies: Vec, css_modules_deps: Vec, parent_source_index: u32, parent_dep_index: u32, layer: Option>>, supports: Option>, media: MediaList<'i>, loc: Location, } /// A trait to provide the contents of files to a Bundler. /// /// See [FileProvider](FileProvider) for an implementation that uses the /// file system. pub trait SourceProvider: Send + Sync { /// A custom error. type Error: std::error::Error + Send + Sync; /// Reads the contents of the given file path to a string. fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error>; /// Resolves the given import specifier to a file path given the file /// which the import originated from. fn resolve(&self, specifier: &str, originating_file: &Path) -> Result; } /// Provides an implementation of [SourceProvider](SourceProvider) /// that reads files from the file system. pub struct FileProvider { inputs: Mutex>, } impl FileProvider { /// Creates a new FileProvider. pub fn new() -> FileProvider { FileProvider { inputs: Mutex::new(Vec::new()), } } } unsafe impl Sync for FileProvider {} unsafe impl Send for FileProvider {} impl SourceProvider for FileProvider { type Error = std::io::Error; fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> { let source = fs::read_to_string(file)?; let ptr = Box::into_raw(Box::new(source)); self.inputs.lock().unwrap().push(ptr); // SAFETY: this is safe because the pointer is not dropped // until the FileProvider is, and we never remove from the // list of pointers stored in the vector. Ok(unsafe { &*ptr }) } fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { // Assume the specifier is a relative file path and join it with current path. Ok(originating_file.with_file_name(specifier)) } } impl Drop for FileProvider { fn drop(&mut self) { for ptr in self.inputs.lock().unwrap().iter() { std::mem::drop(unsafe { Box::from_raw(*ptr) }) } } } /// An error that could occur during bundling. #[derive(Debug)] #[cfg_attr(any(feature = "serde", feature = "nodejs"), derive(serde::Serialize))] pub enum BundleErrorKind<'i, T: std::error::Error> { /// A parser error occurred. ParserError(ParserError<'i>), /// An unsupported `@import` condition was encountered. UnsupportedImportCondition, /// An unsupported cascade layer combination was encountered. UnsupportedLayerCombination, /// Unsupported media query boolean logic was encountered. UnsupportedMediaBooleanLogic, /// A custom resolver error. ResolverError(#[cfg_attr(any(feature = "serde", feature = "nodejs"), serde(skip))] T), } impl<'i, T: std::error::Error> From>> for Error> { fn from(err: Error>) -> Self { Error { kind: BundleErrorKind::ParserError(err.kind), loc: err.loc, } } } impl<'i, T: std::error::Error> std::fmt::Display for BundleErrorKind<'i, T> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { use BundleErrorKind::*; match self { ParserError(err) => err.fmt(f), UnsupportedImportCondition => write!(f, "Unsupported import condition"), UnsupportedLayerCombination => write!(f, "Unsupported layer combination in @import"), UnsupportedMediaBooleanLogic => write!(f, "Unsupported boolean logic in @import media query"), ResolverError(err) => std::fmt::Display::fmt(&err, f), } } } impl<'i, T: std::error::Error> BundleErrorKind<'i, T> { #[deprecated(note = "use `BundleErrorKind::to_string()` or `std::fmt::Display` instead")] #[allow(missing_docs)] pub fn reason(&self) -> String { self.to_string() } } impl<'a, 'o, 's, P: SourceProvider> Bundler<'a, 'o, 's, P, DefaultAtRuleParser> { /// Creates a new Bundler using the given source provider. /// If a source map is given, the content of each source file included in the bundle will /// be added accordingly. pub fn new( fs: &'a P, source_map: Option<&'s mut SourceMap>, options: ParserOptions<'o, 'a>, ) -> Bundler<'a, 'o, 's, P, DefaultAtRuleParser> { Bundler { source_map: source_map.map(Mutex::new), fs, source_indexes: DashMap::new(), stylesheets: Mutex::new(Vec::new()), options, at_rule_parser: Mutex::new(AtRuleParserValue::Owned(DefaultAtRuleParser)), } } } impl<'a, 'o, 's, P: SourceProvider, T: AtRuleParser<'a> + Clone + Sync + Send> Bundler<'a, 'o, 's, P, T> where T::AtRule: Sync + Send + ToCss + Clone, { /// Creates a new Bundler using the given source provider. /// If a source map is given, the content of each source file included in the bundle will /// be added accordingly. pub fn new_with_at_rule_parser( fs: &'a P, source_map: Option<&'s mut SourceMap>, options: ParserOptions<'o, 'a>, at_rule_parser: &'s mut T, ) -> Self { Bundler { source_map: source_map.map(Mutex::new), fs, source_indexes: DashMap::new(), stylesheets: Mutex::new(Vec::new()), options, at_rule_parser: Mutex::new(AtRuleParserValue::Borrowed(at_rule_parser)), } } /// Bundles the given entry file and all dependencies into a single style sheet. pub fn bundle<'e>( &mut self, entry: &'e Path, ) -> Result, Error>> { // Phase 1: load and parse all files. This is done in parallel. self.load_file( &entry, ImportRule { url: "".into(), layer: None, supports: None, media: MediaList::new(), loc: Location { source_index: 0, line: 0, column: 0, }, }, )?; // Phase 2: determine the order that the files should be concatenated. self.order(); // Phase 3: concatenate. let mut rules: Vec> = Vec::new(); self.inline(&mut rules); let sources = self .stylesheets .get_mut() .unwrap() .iter() .flat_map(|s| s.stylesheet.as_ref().unwrap().sources.iter().cloned()) .collect(); let mut stylesheet = StyleSheet::new(sources, CssRuleList(rules), self.options.clone()); stylesheet.source_map_urls = self .stylesheets .get_mut() .unwrap() .iter() .flat_map(|s| s.stylesheet.as_ref().unwrap().source_map_urls.iter().cloned()) .collect(); stylesheet.license_comments = self .stylesheets .get_mut() .unwrap() .iter() .flat_map(|s| s.stylesheet.as_ref().unwrap().license_comments.iter().cloned()) .collect(); if let Some(config) = &self.options.css_modules { if config.pattern.has_content_hash() { stylesheet.content_hashes = Some( self .stylesheets .get_mut() .unwrap() .iter() .flat_map(|s| { let s = s.stylesheet.as_ref().unwrap(); s.content_hashes.as_ref().unwrap().iter().cloned() }) .collect(), ); } } Ok(stylesheet) } fn find_filename(&self, source_index: u32) -> String { // This function is only used for error handling, so it's ok if this is a bit slow. let entry = self.source_indexes.iter().find(|x| *x.value() == source_index).unwrap(); entry.key().to_str().unwrap().into() } fn load_file(&self, file: &Path, rule: ImportRule<'a>) -> Result>> { // Check if we already loaded this file. let mut stylesheets = self.stylesheets.lock().unwrap(); let source_index = match self.source_indexes.get(file) { Some(source_index) => { // If we already loaded this file, combine the media queries and supports conditions // from this import rule with the existing ones using a logical or operator. let entry = &mut stylesheets[*source_index as usize]; // We cannot combine a media query and a supports query from different @import rules. // e.g. @import "a.css" print; @import "a.css" supports(color: red); // This would require duplicating the actual rules in the file. if (!rule.media.media_queries.is_empty() && !entry.supports.is_none()) || (!entry.media.media_queries.is_empty() && !rule.supports.is_none()) { return Err(Error { kind: BundleErrorKind::UnsupportedImportCondition, loc: Some(ErrorLocation::new(rule.loc, self.find_filename(rule.loc.source_index))), }); } if rule.media.media_queries.is_empty() { entry.media.media_queries.clear(); } else if !entry.media.media_queries.is_empty() { entry.media.or(&rule.media); } if let Some(supports) = rule.supports { if let Some(existing_supports) = &mut entry.supports { existing_supports.or(&supports) } } else { entry.supports = None; } if let Some(layer) = &rule.layer { if let Some(existing_layer) = &entry.layer { // We can't OR layer names without duplicating all of the nested rules, so error for now. if layer != existing_layer || (layer.is_none() && existing_layer.is_none()) { return Err(Error { kind: BundleErrorKind::UnsupportedLayerCombination, loc: Some(ErrorLocation::new(rule.loc, self.find_filename(rule.loc.source_index))), }); } } else { entry.layer = rule.layer; } } return Ok(*source_index); } None => { let source_index = stylesheets.len() as u32; self.source_indexes.insert(file.to_owned(), source_index); stylesheets.push(BundleStyleSheet { stylesheet: None, layer: rule.layer.clone(), media: rule.media.clone(), supports: rule.supports.clone(), loc: rule.loc.clone(), dependencies: Vec::new(), css_modules_deps: Vec::new(), parent_source_index: 0, parent_dep_index: 0, }); source_index } }; drop(stylesheets); // ensure we aren't holding the lock anymore let code = self.fs.read(file).map_err(|e| Error { kind: BundleErrorKind::ResolverError(e), loc: if rule.loc.column == 0 { None } else { Some(ErrorLocation::new(rule.loc, self.find_filename(rule.loc.source_index))) }, })?; let mut opts = self.options.clone(); let filename = file.to_str().unwrap(); opts.filename = filename.to_owned(); opts.source_index = source_index; let mut stylesheet = { let mut at_rule_parser = self.at_rule_parser.lock().unwrap(); let at_rule_parser = match &mut *at_rule_parser { AtRuleParserValue::Owned(owned) => owned, AtRuleParserValue::Borrowed(borrowed) => *borrowed, }; StyleSheet::::parse_with(code, opts, at_rule_parser)? }; if let Some(source_map) = &self.source_map { // Only add source if we don't have an input source map. // If we do, this will be handled by the printer when remapping locations. let sm = stylesheet.source_map_url(0); if sm.is_none() || !sm.unwrap().starts_with("data") { let mut source_map = source_map.lock().unwrap(); let source_index = source_map.add_source(filename); let _ = source_map.set_source_content(source_index as usize, code); } } // Collect and load dependencies for this stylesheet in parallel. let dependencies: Result, _> = stylesheet .rules .0 .par_iter_mut() .filter_map(|r| { // Prepend parent layer name to @layer statements. if let CssRule::LayerStatement(layer) = r { if let Some(Some(parent_layer)) = &rule.layer { for name in &mut layer.names { name.0.insert_many(0, parent_layer.0.iter().cloned()) } } } if let CssRule::Import(import) = r { let specifier = &import.url; // Combine media queries and supports conditions from parent // stylesheet with @import rule using a logical and operator. let mut media = rule.media.clone(); let result = media.and(&import.media).map_err(|_| Error { kind: BundleErrorKind::UnsupportedMediaBooleanLogic, loc: Some(ErrorLocation::new( import.loc, self.find_filename(import.loc.source_index), )), }); if let Err(e) = result { return Some(Err(e)); } let layer = if (rule.layer == Some(None) && import.layer.is_some()) || (import.layer == Some(None) && rule.layer.is_some()) { // Cannot combine anonymous layers return Some(Err(Error { kind: BundleErrorKind::UnsupportedLayerCombination, loc: Some(ErrorLocation::new( import.loc, self.find_filename(import.loc.source_index), )), })); } else if let Some(Some(a)) = &rule.layer { if let Some(Some(b)) = &import.layer { let mut name = a.clone(); name.0.extend(b.0.iter().cloned()); Some(Some(name)) } else { Some(Some(a.clone())) } } else { import.layer.clone() }; let result = match self.fs.resolve(&specifier, file) { Ok(path) => self.load_file( &path, ImportRule { layer, media, supports: combine_supports(rule.supports.clone(), &import.supports), url: "".into(), loc: import.loc, }, ), Err(err) => Err(Error { kind: BundleErrorKind::ResolverError(err), loc: Some(ErrorLocation::new( import.loc, self.find_filename(import.loc.source_index), )), }), }; Some(result) } else { None } }) .collect(); // Collect CSS modules dependencies from the `composes` property. let css_modules_deps: Result, _> = if self.options.css_modules.is_some() { stylesheet .rules .0 .par_iter_mut() .filter_map(|r| { if let CssRule::Style(style) = r { Some( style .declarations .declarations .par_iter_mut() .chain(style.declarations.important_declarations.par_iter_mut()) .filter_map(|d| match d { Property::Composes(composes) => self .add_css_module_dep(file, &rule, style.loc, composes.loc, &mut composes.from) .map(|result| rayon::iter::Either::Left(rayon::iter::once(result))), // Handle variable references if the dashed_idents option is present. Property::Custom(CustomProperty { value, .. }) | Property::Unparsed(UnparsedProperty { value, .. }) if matches!(&self.options.css_modules, Some(css_modules) if css_modules.dashed_idents) => { Some(rayon::iter::Either::Right(visit_vars(value).filter_map(|name| { self.add_css_module_dep( file, &rule, style.loc, // TODO: store loc in variable reference? crate::dependencies::Location { line: style.loc.line, column: style.loc.column, }, &mut name.from, ) }))) } _ => None, }) .flatten(), ) } else { None } }) .flatten() .collect() } else { Ok(vec![]) }; let entry = &mut self.stylesheets.lock().unwrap()[source_index as usize]; entry.stylesheet = Some(stylesheet); entry.dependencies = dependencies?; entry.css_modules_deps = css_modules_deps?; Ok(source_index) } fn add_css_module_dep( &self, file: &Path, rule: &ImportRule<'a>, style_loc: Location, loc: crate::dependencies::Location, specifier: &mut Option, ) -> Option>>> { if let Some(Specifier::File(f)) = specifier { let result = match self.fs.resolve(&f, file) { Ok(path) => { let res = self.load_file( &path, ImportRule { layer: rule.layer.clone(), media: rule.media.clone(), supports: rule.supports.clone(), url: "".into(), loc: Location { source_index: style_loc.source_index, line: loc.line, column: loc.column, }, }, ); if let Ok(source_index) = res { *specifier = Some(Specifier::SourceIndex(source_index)); } res } Err(err) => Err(Error { kind: BundleErrorKind::ResolverError(err), loc: Some(ErrorLocation::new( style_loc, self.find_filename(style_loc.source_index), )), }), }; Some(result) } else { None } } fn order(&mut self) { process(self.stylesheets.get_mut().unwrap(), 0, &mut HashSet::new()); fn process<'i, T>( stylesheets: &mut Vec>, source_index: u32, visited: &mut HashSet, ) { if visited.contains(&source_index) { return; } visited.insert(source_index); let mut dep_index = 0; for i in 0..stylesheets[source_index as usize].css_modules_deps.len() { let dep_source_index = stylesheets[source_index as usize].css_modules_deps[i]; let resolved = &mut stylesheets[dep_source_index as usize]; // CSS modules preserve the first instance of composed stylesheets. if !visited.contains(&dep_source_index) { resolved.parent_dep_index = dep_index; resolved.parent_source_index = source_index; process(stylesheets, dep_source_index, visited); } dep_index += 1; } for i in 0..stylesheets[source_index as usize].dependencies.len() { let dep_source_index = stylesheets[source_index as usize].dependencies[i]; let resolved = &mut stylesheets[dep_source_index as usize]; // In browsers, every instance of an @import is evaluated, so we preserve the last. resolved.parent_dep_index = dep_index; resolved.parent_source_index = source_index; process(stylesheets, dep_source_index, visited); dep_index += 1; } } } fn inline(&mut self, dest: &mut Vec>) { process(self.stylesheets.get_mut().unwrap(), 0, dest); fn process<'a, T>( stylesheets: &mut Vec>, source_index: u32, dest: &mut Vec>, ) { let stylesheet = &mut stylesheets[source_index as usize]; let mut rules = std::mem::take(&mut stylesheet.stylesheet.as_mut().unwrap().rules.0); // Hoist css modules deps let mut dep_index = 0; for i in 0..stylesheet.css_modules_deps.len() { let dep_source_index = stylesheets[source_index as usize].css_modules_deps[i]; let resolved = &stylesheets[dep_source_index as usize]; // Include the dependency if this is the first instance as computed earlier. if resolved.parent_source_index == source_index && resolved.parent_dep_index == dep_index as u32 { process(stylesheets, dep_source_index, dest); } dep_index += 1; } let mut import_index = 0; for rule in &mut rules { match rule { CssRule::Import(_) => { let dep_source_index = stylesheets[source_index as usize].dependencies[import_index]; let resolved = &stylesheets[dep_source_index as usize]; // Include the dependency if this is the last instance as computed earlier. if resolved.parent_source_index == source_index && resolved.parent_dep_index == dep_index { process(stylesheets, dep_source_index, dest); } *rule = CssRule::Ignored; dep_index += 1; import_index += 1; } CssRule::LayerStatement(_) => { // @layer rules are the only rules that may appear before an @import. // We must preserve this order to ensure correctness. let layer = std::mem::replace(rule, CssRule::Ignored); dest.push(layer); } CssRule::Ignored => {} _ => break, } } // Wrap rules in the appropriate @layer, @media, and @supports rules. let stylesheet = &mut stylesheets[source_index as usize]; if stylesheet.layer.is_some() { rules = vec![CssRule::LayerBlock(LayerBlockRule { name: stylesheet.layer.take().unwrap(), rules: CssRuleList(rules), loc: stylesheet.loc, })] } if !stylesheet.media.media_queries.is_empty() { rules = vec![CssRule::Media(MediaRule { query: std::mem::replace(&mut stylesheet.media, MediaList::new()), rules: CssRuleList(rules), loc: stylesheet.loc, })] } if stylesheet.supports.is_some() { rules = vec![CssRule::Supports(SupportsRule { condition: stylesheet.supports.take().unwrap(), rules: CssRuleList(rules), loc: stylesheet.loc, })] } dest.extend(rules); } } } fn combine_supports<'a>( a: Option>, b: &Option>, ) -> Option> { if let Some(mut a) = a { if let Some(b) = b { a.and(b) } Some(a) } else { b.clone() } } fn visit_vars<'a, 'b>( token_list: &'b mut TokenList<'a>, ) -> impl ParallelIterator> { let mut stack = vec![token_list.0.iter_mut()]; std::iter::from_fn(move || { while !stack.is_empty() { let iter = stack.last_mut().unwrap(); match iter.next() { Some(TokenOrValue::Var(var)) => { if let Some(fallback) = &mut var.fallback { stack.push(fallback.0.iter_mut()); } return Some(&mut var.name); } Some(TokenOrValue::Env(env)) => { if let Some(fallback) = &mut env.fallback { stack.push(fallback.0.iter_mut()); } if let EnvironmentVariableName::Custom(name) = &mut env.name { return Some(name); } } Some(TokenOrValue::UnresolvedColor(color)) => match color { UnresolvedColor::RGB { alpha, .. } | UnresolvedColor::HSL { alpha, .. } => { stack.push(alpha.0.iter_mut()); } UnresolvedColor::LightDark { light, dark } => { stack.push(light.0.iter_mut()); stack.push(dark.0.iter_mut()); } }, None => { stack.pop(); } _ => {} } } None }) .par_bridge() } #[cfg(test)] mod tests { use super::*; use crate::{ css_modules::{self, CssModuleExports, CssModuleReference}, parser::ParserFlags, stylesheet::{MinifyOptions, PrinterOptions}, targets::{Browsers, Targets}, }; use indoc::indoc; use std::collections::HashMap; #[derive(Clone)] struct TestProvider { map: HashMap, } impl SourceProvider for TestProvider { type Error = std::io::Error; fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> { Ok(self.map.get(file).unwrap()) } fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { Ok(originating_file.with_file_name(specifier)) } } /// Stand-in for a user-authored `SourceProvider` with application-specific logic. struct CustomProvider { map: HashMap, } impl SourceProvider for CustomProvider { type Error = std::io::Error; /// Read files from in-memory map. fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> { Ok(self.map.get(file).unwrap()) } /// Resolve by stripping a `foo:` prefix off any import. Specifiers without /// this prefix fail with an error. fn resolve(&self, specifier: &str, _originating_file: &Path) -> Result { if specifier.starts_with("foo:") { Ok(Path::new(&specifier["foo:".len()..]).to_path_buf()) } else { let err = std::io::Error::new( std::io::ErrorKind::NotFound, format!( "Failed to resolve `{}`, specifier does not start with `foo:`.", &specifier ), ); Err(err) } } } macro_rules! fs( { $($key:literal: $value:expr),* } => { { #[allow(unused_mut)] let mut m = HashMap::new(); $( m.insert(PathBuf::from($key), $value.to_owned()); )* m } }; ); fn bundle(fs: P, entry: &str) -> String { let mut bundler = Bundler::new(&fs, None, ParserOptions::default()); let stylesheet = bundler.bundle(Path::new(entry)).unwrap(); stylesheet.to_css(PrinterOptions::default()).unwrap().code } fn bundle_css_module( fs: P, entry: &str, project_root: Option<&str>, ) -> (String, CssModuleExports) { bundle_css_module_with_pattern(fs, entry, project_root, "[hash]_[local]") } fn bundle_css_module_with_pattern( fs: P, entry: &str, project_root: Option<&str>, pattern: &'static str, ) -> (String, CssModuleExports) { let mut bundler = Bundler::new( &fs, None, ParserOptions { css_modules: Some(css_modules::Config { dashed_idents: true, pattern: css_modules::Pattern::parse(pattern).unwrap(), ..Default::default() }), ..ParserOptions::default() }, ); let mut stylesheet = bundler.bundle(Path::new(entry)).unwrap(); stylesheet.minify(MinifyOptions::default()).unwrap(); let res = stylesheet .to_css(PrinterOptions { project_root, ..PrinterOptions::default() }) .unwrap(); (res.code, res.exports.unwrap()) } fn bundle_custom_media(fs: P, entry: &str) -> String { let mut bundler = Bundler::new( &fs, None, ParserOptions { flags: ParserFlags::CUSTOM_MEDIA, ..ParserOptions::default() }, ); let mut stylesheet = bundler.bundle(Path::new(entry)).unwrap(); let targets = Targets { browsers: Some(Browsers { safari: Some(13 << 16), ..Browsers::default() }), ..Default::default() }; stylesheet .minify(MinifyOptions { targets, ..MinifyOptions::default() }) .unwrap(); stylesheet .to_css(PrinterOptions { targets, ..PrinterOptions::default() }) .unwrap() .code } fn error_test( fs: P, entry: &str, maybe_cb: Option) -> ()>>, ) { let mut bundler = Bundler::new(&fs, None, ParserOptions::default()); let res = bundler.bundle(Path::new(entry)); match res { Ok(_) => unreachable!(), Err(e) => { if let Some(cb) = maybe_cb { cb(e.kind); } } } } fn flatten_exports(exports: CssModuleExports) -> HashMap { let mut res = HashMap::new(); for (name, export) in &exports { let mut classes = export.name.clone(); for composes in &export.composes { classes.push(' '); classes.push_str(match composes { CssModuleReference::Local { name } => name, CssModuleReference::Global { name } => name, _ => unreachable!(), }) } res.insert(name.clone(), classes); } res } #[test] fn test_bundle() { let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css"; .a { color: red } "#, "/b.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" .b { color: green; } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" print; .a { color: red } "#, "/b.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @media print { .b { color: green; } } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" supports(color: green); .a { color: red } "#, "/b.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @supports (color: green) { .b { color: green; } } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" supports(color: green) print; .a { color: red } "#, "/b.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @supports (color: green) { @media print { .b { color: green; } } } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" print; @import "b.css" screen; .a { color: red } "#, "/b.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @media print, screen { .b { color: green; } } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" supports(color: red); @import "b.css" supports(foo: bar); .a { color: red } "#, "/b.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @supports (color: red) or (foo: bar) { .b { color: green; } } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" print; .a { color: red } "#, "/b.css": r#" @import "c.css" (color); .b { color: yellow } "#, "/c.css": r#" .c { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @media print and (color) { .c { color: green; } } @media print { .b { color: #ff0; } } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css"; .a { color: red } "#, "/b.css": r#" @import "c.css"; "#, "/c.css": r#" @import "a.css"; .c { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" .c { color: green; } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b/c.css"; .a { color: red } "#, "/b/c.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" .b { color: green; } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "./b/c.css"; .a { color: red } "#, "/b/c.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" .b { color: green; } .a { color: red; } "#} ); let res = bundle_custom_media( TestProvider { map: fs! { "/a.css": r#" @import "media.css"; @import "b.css"; .a { color: red } "#, "/media.css": r#" @custom-media --foo print; "#, "/b.css": r#" @media (--foo) { .a { color: green } } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @media print { .a { color: green; } } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" layer(foo); .a { color: red } "#, "/b.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @layer foo { .b { color: green; } } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" layer; .a { color: red } "#, "/b.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @layer { .b { color: green; } } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" layer(foo); .a { color: red } "#, "/b.css": r#" @import "c.css" layer(bar); .b { color: green } "#, "/c.css": r#" .c { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @layer foo.bar { .c { color: green; } } @layer foo { .b { color: green; } } .a { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" layer(foo); @import "b.css" layer(foo); "#, "/b.css": r#" .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @layer foo { .b { color: green; } } "#} ); let res = bundle( TestProvider { map: fs! { "/a.css": r#" @layer bar, foo; @import "b.css" layer(foo); @layer bar { div { background: red; } } "#, "/b.css": r#" @layer qux, baz; @import "c.css" layer(baz); @layer qux { div { background: green; } } "#, "/c.css": r#" div { background: yellow; } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @layer bar, foo; @layer foo.qux, foo.baz; @layer foo.baz { div { background: #ff0; } } @layer foo { @layer qux { div { background: green; } } } @layer bar { div { background: red; } } "#} ); // Layer order depends on @import conditions. let res = bundle( TestProvider { map: fs! { "/a.css": r#" @import "b.css" layer(bar) (min-width: 1000px); @layer baz { #box { background: purple } } @layer bar { #box { background: yellow } } "#, "/b.css": r#" #box { background: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" @media (width >= 1000px) { @layer bar { #box { background: green; } } } @layer baz { #box { background: purple; } } @layer bar { #box { background: #ff0; } } "#} ); error_test( TestProvider { map: fs! { "/a.css": r#" @import "b.css" layer(foo); @import "b.css" layer(bar); "#, "/b.css": r#" .b { color: red } "# }, }, "/a.css", Some(Box::new(|err| { assert!(matches!(err, BundleErrorKind::UnsupportedLayerCombination)); })), ); error_test( TestProvider { map: fs! { "/a.css": r#" @import "b.css" layer; @import "b.css" layer; "#, "/b.css": r#" .b { color: red } "# }, }, "/a.css", Some(Box::new(|err| { assert!(matches!(err, BundleErrorKind::UnsupportedLayerCombination)); })), ); error_test( TestProvider { map: fs! { "/a.css": r#" @import "b.css" layer; .a { color: red } "#, "/b.css": r#" @import "c.css" layer; .b { color: green } "#, "/c.css": r#" .c { color: green } "# }, }, "/a.css", Some(Box::new(|err| { assert!(matches!(err, BundleErrorKind::UnsupportedLayerCombination)); })), ); error_test( TestProvider { map: fs! { "/a.css": r#" @import "b.css" layer; .a { color: red } "#, "/b.css": r#" @import "c.css" layer(foo); .b { color: green } "#, "/c.css": r#" .c { color: green } "# }, }, "/a.css", Some(Box::new(|err| { assert!(matches!(err, BundleErrorKind::UnsupportedLayerCombination)); })), ); let res = bundle( TestProvider { map: fs! { "/index.css": r#" @import "a.css"; @import "b.css"; "#, "/a.css": r#" @import "./c.css"; body { background: red; } "#, "/b.css": r#" @import "./c.css"; body { color: red; } "#, "/c.css": r#" body { background: white; color: black; } "# }, }, "/index.css", ); assert_eq!( res, indoc! { r#" body { background: red; } body { background: #fff; color: #000; } body { color: red; } "#} ); let res = bundle( TestProvider { map: fs! { "/index.css": r#" @import "a.css"; @import "b.css"; @import "a.css"; "#, "/a.css": r#" body { background: green; } "#, "/b.css": r#" body { background: red; } "# }, }, "/index.css", ); assert_eq!( res, indoc! { r#" body { background: red; } body { background: green; } "#} ); let res = bundle( CustomProvider { map: fs! { "/a.css": r#" @import "foo:/b.css"; .a { color: red; } "#, "/b.css": ".b { color: green; }" }, }, "/a.css", ); assert_eq!( res, indoc! { r#" .b { color: green; } .a { color: red; } "# } ); error_test( CustomProvider { map: fs! { "/a.css": r#" /* Forgot to prefix with `foo:`. */ @import "/b.css"; .a { color: red; } "#, "/b.css": ".b { color: green; }" }, }, "/a.css", Some(Box::new(|err| { let kind = match err { BundleErrorKind::ResolverError(ref error) => error.kind(), _ => unreachable!(), }; assert!(matches!(kind, std::io::ErrorKind::NotFound)); assert!(err .to_string() .contains("Failed to resolve `/b.css`, specifier does not start with `foo:`.")); })), ); // let res = bundle(fs! { // "/a.css": r#" // @import "b.css" supports(color: red) (color); // @import "b.css" supports(foo: bar) (orientation: horizontal); // .a { color: red } // "#, // "/b.css": r#" // .b { color: green } // "# // }, "/a.css"); // let res = bundle(fs! { // "/a.css": r#" // @import "b.css" not print; // .a { color: red } // "#, // "/b.css": r#" // @import "c.css" not screen; // .b { color: green } // "#, // "/c.css": r#" // .c { color: yellow } // "# // }, "/a.css"); } #[test] fn test_css_module() { macro_rules! map { { $($key:expr => $val:expr),* } => { HashMap::from([ $(($key.to_owned(), $val.to_owned()),)* ]) }; } let (code, exports) = bundle_css_module( TestProvider { map: fs! { "/a.css": r#" @import "b.css"; .a { color: red } "#, "/b.css": r#" .a { color: green } "# }, }, "/a.css", None, ); assert_eq!( code, indoc! { r#" ._9z6RGq_a { color: green; } ._6lixEq_a { color: red; } "#} ); assert_eq!( flatten_exports(exports), map! { "a" => "_6lixEq_a" } ); let (code, exports) = bundle_css_module( TestProvider { map: fs! { "/a.css": r#" .a { composes: x from './b.css'; color: red; } .b { color: yellow } "#, "/b.css": r#" .x { composes: y; background: green } .y { font: Helvetica } "# }, }, "/a.css", None, ); assert_eq!( code, indoc! { r#" ._8Cs9ZG_x { background: green; } ._8Cs9ZG_y { font: Helvetica; } ._6lixEq_a { color: red; } ._6lixEq_b { color: #ff0; } "#} ); assert_eq!( flatten_exports(exports), map! { "a" => "_6lixEq_a _8Cs9ZG_x _8Cs9ZG_y", "b" => "_6lixEq_b" } ); let (code, exports) = bundle_css_module( TestProvider { map: fs! { "/a.css": r#" .a { composes: x from './b.css'; background: red; } "#, "/b.css": r#" .a { background: red } "# }, }, "/a.css", None, ); assert_eq!( code, indoc! { r#" ._8Cs9ZG_a { background: red; } ._6lixEq_a { background: red; } "#} ); assert_eq!( flatten_exports(exports), map! { "a" => "_6lixEq_a" } ); let (code, exports) = bundle_css_module( TestProvider { map: fs! { "/a.css": r#" .a { background: var(--bg from "./b.css", var(--fallback from "./b.css")); color: rgb(255 255 255 / var(--opacity from "./b.css")); width: env(--env, var(--env-fallback from "./env.css")); } "#, "/b.css": r#" .b { --bg: red; --fallback: yellow; --opacity: 0.5; } "#, "/env.css": r#" .env { --env-fallback: 20px; } "# }, }, "/a.css", None, ); assert_eq!( code, indoc! { r#" ._8Cs9ZG_b { --_8Cs9ZG_bg: red; --_8Cs9ZG_fallback: yellow; --_8Cs9ZG_opacity: .5; } .GbJUva_env { --GbJUva_env-fallback: 20px; } ._6lixEq_a { background: var(--_8Cs9ZG_bg, var(--_8Cs9ZG_fallback)); color: rgb(255 255 255 / var(--_8Cs9ZG_opacity)); width: env(--_6lixEq_env, var(--GbJUva_env-fallback)); } "#} ); assert_eq!( flatten_exports(exports), map! { "a" => "_6lixEq_a", "--env" => "--_6lixEq_env" } ); // Hashes are stable between project roots. let expected = indoc! { r#" .dyGcAa_b { background: #ff0; } .CK9avG_a { background: #fff; } "#}; let (code, _) = bundle_css_module( TestProvider { map: fs! { "/foo/bar/a.css": r#" @import "b.css"; .a { background: white; } "#, "/foo/bar/b.css": r#" .b { background: yellow; } "# }, }, "/foo/bar/a.css", Some("/foo/bar"), ); assert_eq!(code, expected); let (code, _) = bundle_css_module( TestProvider { map: fs! { "/x/y/z/a.css": r#" @import "b.css"; .a { background: white; } "#, "/x/y/z/b.css": r#" .b { background: yellow; } "# }, }, "/x/y/z/a.css", Some("/x/y/z"), ); assert_eq!(code, expected); let (code, _) = bundle_css_module_with_pattern( TestProvider { map: fs! { "/a.css": r#" @import "b.css"; .a { color: red } "#, "/b.css": r#" .a { color: green } "# }, }, "/a.css", None, "[content-hash]-[local]", ); assert_eq!( code, indoc! { r#" .do5n2W-a { color: green; } .pP97eq-a { color: red; } "#} ); } #[test] fn test_source_map() { let source = r#".imported { content: "yay, file support!"; } .selector { margin: 1em; background-color: #f60; } .selector .nested { margin: 0.5em; } /*# sourceMappingURL=data:application/json;base64,ewoJInZlcnNpb24iOiAzLAoJInNvdXJjZVJvb3QiOiAicm9vdCIsCgkiZmlsZSI6ICJzdGRvdXQiLAoJInNvdXJjZXMiOiBbCgkJInN0ZGluIiwKCQkic2Fzcy9fdmFyaWFibGVzLnNjc3MiLAoJCSJzYXNzL19kZW1vLnNjc3MiCgldLAoJInNvdXJjZXNDb250ZW50IjogWwoJCSJAaW1wb3J0IFwiX3ZhcmlhYmxlc1wiO1xuQGltcG9ydCBcIl9kZW1vXCI7XG5cbi5zZWxlY3RvciB7XG4gIG1hcmdpbjogJHNpemU7XG4gIGJhY2tncm91bmQtY29sb3I6ICRicmFuZENvbG9yO1xuXG4gIC5uZXN0ZWQge1xuICAgIG1hcmdpbjogJHNpemUgLyAyO1xuICB9XG59IiwKCQkiJGJyYW5kQ29sb3I6ICNmNjA7XG4kc2l6ZTogMWVtOyIsCgkJIi5pbXBvcnRlZCB7XG4gIGNvbnRlbnQ6IFwieWF5LCBmaWxlIHN1cHBvcnQhXCI7XG59IgoJXSwKCSJtYXBwaW5ncyI6ICJBRUFBLFNBQVMsQ0FBQztFQUNSLE9BQU8sRUFBRSxvQkFBcUI7Q0FDL0I7O0FGQ0QsU0FBUyxDQUFDO0VBQ1IsTUFBTSxFQ0hELEdBQUc7RURJUixnQkFBZ0IsRUNMTCxJQUFJO0NEVWhCOztBQVBELFNBQVMsQ0FJUCxPQUFPLENBQUM7RUFDTixNQUFNLEVDUEgsS0FBRztDRFFQIiwKCSJuYW1lcyI6IFtdCn0= */"#; let fs = TestProvider { map: fs! { "/a.css": r#" @import "/b.css"; .a { color: red; } "#, "/b.css": source }, }; let mut sm = parcel_sourcemap::SourceMap::new("/"); let mut bundler = Bundler::new(&fs, Some(&mut sm), ParserOptions::default()); let mut stylesheet = bundler.bundle(Path::new("/a.css")).unwrap(); stylesheet.minify(MinifyOptions::default()).unwrap(); stylesheet .to_css(PrinterOptions { source_map: Some(&mut sm), minify: true, ..PrinterOptions::default() }) .unwrap(); let map = sm.to_json(None).unwrap(); assert_eq!( map, r#"{"version":3,"sourceRoot":null,"mappings":"ACAA,uCCGA,2CAAA,8BFDQ","sources":["a.css","sass/_demo.scss","stdin"],"sourcesContent":["\n @import \"/b.css\";\n .a { color: red; }\n ",".imported {\n content: \"yay, file support!\";\n}","@import \"_variables\";\n@import \"_demo\";\n\n.selector {\n margin: $size;\n background-color: $brandColor;\n\n .nested {\n margin: $size / 2;\n }\n}"],"names":[]}"# ); } #[test] fn test_license_comments() { let res = bundle( TestProvider { map: fs! { "/a.css": r#" /*! Copyright 2023 Someone awesome */ @import "b.css"; .a { color: red } "#, "/b.css": r#" /*! Copyright 2023 Someone else */ .b { color: green } "# }, }, "/a.css", ); assert_eq!( res, indoc! { r#" /*! Copyright 2023 Someone awesome */ /*! Copyright 2023 Someone else */ .b { color: green; } .a { color: red; } "#} ); } }