| //! 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<Mutex<&'s mut SourceMap>>, |
| fs: &'a P, |
| source_indexes: DashMap<PathBuf, u32>, |
| stylesheets: Mutex<Vec<BundleStyleSheet<'a, 'o, T::AtRule>>>, |
| options: ParserOptions<'o, 'a>, |
| at_rule_parser: Mutex<AtRuleParserValue<'s, T>>, |
| } |
| |
| enum AtRuleParserValue<'a, T> { |
| Owned(T), |
| Borrowed(&'a mut T), |
| } |
| |
| struct BundleStyleSheet<'i, 'o, T> { |
| stylesheet: Option<StyleSheet<'i, 'o, T>>, |
| dependencies: Vec<u32>, |
| css_modules_deps: Vec<u32>, |
| parent_source_index: u32, |
| parent_dep_index: u32, |
| layer: Option<Option<LayerName<'i>>>, |
| supports: Option<SupportsCondition<'i>>, |
| 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<PathBuf, Self::Error>; |
| } |
| |
| /// Provides an implementation of [SourceProvider](SourceProvider) |
| /// that reads files from the file system. |
| pub struct FileProvider { |
| inputs: Mutex<Vec<*mut String>>, |
| } |
| |
| 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<PathBuf, Self::Error> { |
| // 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<Error<ParserError<'i>>> for Error<BundleErrorKind<'i, T>> { |
| fn from(err: Error<ParserError<'i>>) -> 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<StyleSheet<'a, 'o, T::AtRule>, Error<BundleErrorKind<'a, P::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<CssRule<'a, T::AtRule>> = 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<u32, Error<BundleErrorKind<'a, P::Error>>> { |
| // 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::<T::AtRule>::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<Vec<u32>, _> = 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<Vec<u32>, _> = 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<Specifier>, |
| ) -> Option<Result<u32, Error<BundleErrorKind<'a, P::Error>>>> { |
| 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<BundleStyleSheet<'i, '_, T>>, |
| source_index: u32, |
| visited: &mut HashSet<u32>, |
| ) { |
| 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<CssRule<'a, T::AtRule>>) { |
| process(self.stylesheets.get_mut().unwrap(), 0, dest); |
| |
| fn process<'a, T>( |
| stylesheets: &mut Vec<BundleStyleSheet<'a, '_, T>>, |
| source_index: u32, |
| dest: &mut Vec<CssRule<'a, T>>, |
| ) { |
| 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<SupportsCondition<'a>>, |
| b: &Option<SupportsCondition<'a>>, |
| ) -> Option<SupportsCondition<'a>> { |
| 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<Item = &'b mut DashedIdentReference<'a>> { |
| 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<PathBuf, String>, |
| } |
| |
| 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<PathBuf, Self::Error> { |
| Ok(originating_file.with_file_name(specifier)) |
| } |
| } |
| |
| /// Stand-in for a user-authored `SourceProvider` with application-specific logic. |
| struct CustomProvider { |
| map: HashMap<PathBuf, String>, |
| } |
| |
| 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<PathBuf, Self::Error> { |
| 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<P: SourceProvider>(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<P: SourceProvider>( |
| 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<P: SourceProvider>( |
| 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<P: SourceProvider>(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<P: SourceProvider>( |
| fs: P, |
| entry: &str, |
| maybe_cb: Option<Box<dyn FnOnce(BundleErrorKind<P::Error>) -> ()>>, |
| ) { |
| 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<String, String> { |
| 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; |
| } |
| "#} |
| ); |
| } |
| } |