use atty::Stream; use clap::{ArgGroup, Parser}; use lightningcss::bundler::{Bundler, FileProvider}; use lightningcss::stylesheet::{MinifyOptions, ParserFlags, ParserOptions, PrinterOptions, StyleSheet}; use lightningcss::targets::Browsers; use parcel_sourcemap::SourceMap; use serde::Serialize; use std::borrow::Cow; use std::sync::{Arc, RwLock}; use std::{ffi, fs, io, path::Path}; #[cfg(target_os = "macos")] #[global_allocator] static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; #[derive(Parser, Debug)] #[clap(author, version, about, long_about = None)] #[clap(group( ArgGroup::new("targets-resolution") .args(&["targets", "browserslist"]), ))] struct CliArgs { /// Target CSS file (default: stdin) #[clap(value_parser)] input_file: Vec, /// Destination file for the output #[clap(short, long, group = "output_file", value_parser)] output_file: Option, /// Destination directory to output into. #[clap(short = 'd', long, group = "output_file", value_parser)] output_dir: Option, /// Minify the output #[clap(short, long, value_parser)] minify: bool, /// Enable parsing CSS nesting // Now on by default, but left for backward compatibility. #[clap(long, value_parser, hide = true)] nesting: bool, /// Enable parsing custom media queries #[clap(long, value_parser)] custom_media: bool, /// Enable CSS modules in output. /// If no filename is provided, .json will be used. /// If no --output-file is specified, code and exports will be printed to stdout as JSON. #[clap(long, group = "css_modules", value_parser)] css_modules: Option>, #[clap(long, requires = "css_modules", value_parser)] css_modules_pattern: Option, #[clap(long, requires = "css_modules", value_parser)] css_modules_dashed_idents: bool, /// Enable sourcemap, at .map #[clap(long, requires = "output_file", value_parser)] sourcemap: bool, #[clap(long, value_parser)] bundle: bool, #[clap(short, long, value_parser)] targets: Vec, #[clap(long, value_parser)] browserslist: bool, #[clap(long, value_parser)] error_recovery: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SourceMapJson<'a> { version: u8, mappings: String, sources: &'a Vec, sources_content: &'a Vec, names: &'a Vec, } pub fn main() -> Result<(), std::io::Error> { let cli_args = CliArgs::parse(); let project_root = std::env::current_dir()?; // If we're given an input file, read from it and adjust its name. // // If we're not given an input file and stdin was redirected, read // from it and create a fake name. Return an error if stdin was not // redirected (otherwise the program will hang waiting for input). // let inputs = if !cli_args.input_file.is_empty() { if cli_args.input_file.len() > 1 && cli_args.output_file.is_some() { eprintln!("Cannot use the --output-file option with multiple inputs. Use --output-dir instead."); std::process::exit(1); } if cli_args.input_file.len() > 1 && cli_args.output_file.is_none() && cli_args.output_dir.is_none() { eprintln!("Cannot output to stdout with multiple inputs. Use --output-dir instead."); std::process::exit(1); } cli_args .input_file .into_iter() .map(|ref f| -> Result<_, std::io::Error> { let absolute_path = fs::canonicalize(f)?; let filename = pathdiff::diff_paths(absolute_path, &project_root).unwrap(); let filename = filename.to_string_lossy().into_owned(); let contents = fs::read_to_string(f)?; Ok((filename, contents)) }) .collect::>()? } else { // Don't silently wait for input if stdin was not redirected. if atty::is(Stream::Stdin) { return Err(io::Error::new( io::ErrorKind::Other, "Not reading from stdin as it was not redirected", )); } let filename = format!("stdin-{}", std::process::id()); let contents = io::read_to_string(io::stdin())?; vec![(filename, contents)] }; let css_modules = if let Some(_) = cli_args.css_modules { let pattern = if let Some(pattern) = cli_args.css_modules_pattern.as_ref() { match lightningcss::css_modules::Pattern::parse(pattern) { Ok(p) => p, Err(e) => { eprintln!("{}", e); std::process::exit(1); } } } else { Default::default() }; Some(lightningcss::css_modules::Config { pattern, dashed_idents: cli_args.css_modules_dashed_idents, ..Default::default() }) } else { cli_args.css_modules.as_ref().map(|_| Default::default()) }; let fs = FileProvider::new(); for (filename, source) in inputs { let warnings = if cli_args.error_recovery { Some(Arc::new(RwLock::new(Vec::new()))) } else { None }; let mut source_map = if cli_args.sourcemap { Some(SourceMap::new(&project_root.to_string_lossy())) } else { None }; let output_file = if let Some(output_file) = &cli_args.output_file { Some(Cow::Borrowed(Path::new(output_file))) } else if let Some(dir) = &cli_args.output_dir { Some(Cow::Owned( Path::new(dir).join(Path::new(&filename).file_name().unwrap()), )) } else { None }; let res = { let mut flags = ParserFlags::empty(); flags.set(ParserFlags::CUSTOM_MEDIA, cli_args.custom_media); let mut options = ParserOptions { flags, css_modules: css_modules.clone(), error_recovery: cli_args.error_recovery, warnings: warnings.clone(), ..ParserOptions::default() }; let mut stylesheet = if cli_args.bundle { let mut bundler = Bundler::new(&fs, source_map.as_mut(), options); bundler.bundle(Path::new(&filename)).unwrap() } else { if let Some(sm) = &mut source_map { sm.add_source(&filename); let _ = sm.set_source_content(0, &source); } options.filename = filename; StyleSheet::parse(&source, options).unwrap() }; let targets = if !cli_args.targets.is_empty() { Browsers::from_browserslist(&cli_args.targets).unwrap() } else if cli_args.browserslist { Browsers::load_browserslist().unwrap() } else { None } .into(); stylesheet .minify(MinifyOptions { targets, ..MinifyOptions::default() }) .unwrap(); stylesheet .to_css(PrinterOptions { minify: cli_args.minify, source_map: source_map.as_mut(), project_root: Some(&project_root.to_string_lossy()), targets, ..PrinterOptions::default() }) .unwrap() }; let map = if let Some(ref mut source_map) = source_map { let mut vlq_output: Vec = Vec::new(); source_map .write_vlq(&mut vlq_output) .map_err(|_| io::Error::new(io::ErrorKind::Other, "Error writing sourcemap vlq"))?; let sm = SourceMapJson { version: 3, mappings: unsafe { String::from_utf8_unchecked(vlq_output) }, sources: source_map.get_sources(), sources_content: source_map.get_sources_content(), names: source_map.get_names(), }; serde_json::to_vec(&sm).ok() } else { None }; if let Some(warnings) = warnings { let warnings = Arc::try_unwrap(warnings).unwrap().into_inner().unwrap(); for warning in warnings { eprintln!("{}", warning); } } if let Some(output_file) = &output_file { let mut code = res.code; if cli_args.sourcemap { if let Some(map_buf) = map { let map_filename = output_file.to_string_lossy() + ".map"; code += &format!("\n/*# sourceMappingURL={} */\n", map_filename); fs::write(map_filename.as_ref(), map_buf)?; } } if let Some(p) = output_file.parent() { fs::create_dir_all(p)? }; fs::write(output_file, code.as_bytes())?; if let Some(css_modules) = &cli_args.css_modules { let css_modules_filename = if let Some(name) = css_modules { Cow::Borrowed(name) } else { Cow::Owned(infer_css_modules_filename(output_file.as_ref())?) }; if let Some(exports) = res.exports { let css_modules_json = serde_json::to_string(&exports)?; fs::write(css_modules_filename.as_ref(), css_modules_json)?; } } } else { if let Some(exports) = res.exports { println!( "{}", serde_json::json!({ "code": res.code, "exports": exports }) ); } else { println!("{}", res.code); } } } Ok(()) } fn infer_css_modules_filename(path: &Path) -> Result { if path.extension() == Some(ffi::OsStr::new("json")) { Err(io::Error::new( io::ErrorKind::Other, "Cannot infer a css modules json filename, since the output file extension is '.json'", )) } else { // unwrap: the filename option is a String from clap, so is valid utf-8 Ok(path.with_extension("json").to_str().unwrap().into()) } }