use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use mni::{Minifier, MinifyOptions, Target};
use std::fs;
use std::io::{self, Read, Write};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "mni")]
#[command(about = "A world-class minifier for JavaScript, CSS, and JSON", long_about = None)]
#[command(version)]
struct Cli {
#[arg(value_name = "FILE")]
input: Option<PathBuf>,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(short, long, value_enum, default_value = "es2020")]
target: TargetArg,
#[arg(short, long, default_value = "true")]
mangle: bool,
#[arg(short, long, default_value = "true")]
compress: bool,
#[arg(long)]
source_map: bool,
#[arg(long)]
keep_fnames: bool,
#[arg(long)]
keep_classnames: bool,
#[arg(long)]
drop_console: bool,
#[arg(long, default_value = "true")]
drop_debugger: bool,
#[arg(long, default_value = "1")]
passes: usize,
#[arg(short, long, value_enum)]
preset: Option<Preset>,
#[arg(long)]
stats: bool,
}
#[derive(Clone, ValueEnum)]
enum TargetArg {
ES5,
ES2015,
ES2016,
ES2017,
ES2018,
ES2019,
ES2020,
ES2021,
ES2022,
ES2023,
ES2024,
ESNext,
}
impl From<TargetArg> for Target {
fn from(arg: TargetArg) -> Self {
match arg {
TargetArg::ES5 => Target::ES5,
TargetArg::ES2015 => Target::ES2015,
TargetArg::ES2016 => Target::ES2016,
TargetArg::ES2017 => Target::ES2017,
TargetArg::ES2018 => Target::ES2018,
TargetArg::ES2019 => Target::ES2019,
TargetArg::ES2020 => Target::ES2020,
TargetArg::ES2021 => Target::ES2021,
TargetArg::ES2022 => Target::ES2022,
TargetArg::ES2023 => Target::ES2023,
TargetArg::ES2024 => Target::ES2024,
TargetArg::ESNext => Target::ESNext,
}
}
}
#[derive(Clone, ValueEnum)]
enum Preset {
Dev,
Prod,
Aggressive,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let (source, filename) = read_input(&cli.input)?;
let mut options = match cli.preset {
Some(Preset::Dev) => MinifyOptions::development(),
Some(Preset::Prod) => MinifyOptions::production(),
Some(Preset::Aggressive) => MinifyOptions::aggressive(),
None => MinifyOptions::default(),
};
options.target = cli.target.into();
options.mangle = cli.mangle;
options.compress = cli.compress;
options.source_map = cli.source_map;
options.keep_fnames = cli.keep_fnames;
options.keep_classnames = cli.keep_classnames;
options.compress_options.drop_console = cli.drop_console;
options.compress_options.drop_debugger = cli.drop_debugger;
options.compress_options.passes = cli.passes;
let minifier = Minifier::new(options);
let result = minifier
.minify_auto(&source, filename.as_deref())
.context("Minification failed")?;
write_output(&cli.output, &result.code)?;
if cli.source_map
&& let Some(map) = &result.map
{
let map_path = match &cli.output {
Some(path) if path.to_str() != Some("-") => {
let mut map_path = path.clone();
map_path.set_extension(format!(
"{}.map",
path.extension().and_then(|s| s.to_str()).unwrap_or("js")
));
map_path
}
_ => PathBuf::from("output.map"),
};
fs::write(&map_path, map)
.with_context(|| format!("Failed to write source map to {}", map_path.display()))?;
}
if cli.stats {
eprintln!("\nMinification Statistics:");
eprintln!(" Original: {} bytes", result.stats.original_size);
eprintln!(" Minified: {} bytes", result.stats.minified_size);
eprintln!(
" Reduction: {:.1}%",
result.stats.compression_ratio * 100.0
);
eprintln!(" Time: {} ms", result.stats.time_ms);
}
Ok(())
}
fn read_input(path: &Option<PathBuf>) -> Result<(String, Option<String>)> {
match path {
Some(path) if path.to_str() == Some("-") || path.to_str() == Some("") => {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.context("Failed to read from stdin")?;
Ok((buffer, None))
}
Some(path) => {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read file: {}", path.display()))?;
Ok((content, Some(path.to_string_lossy().to_string())))
}
None => {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.context("Failed to read from stdin")?;
Ok((buffer, None))
}
}
}
fn write_output(path: &Option<PathBuf>, content: &str) -> Result<()> {
match path {
Some(path) if path.to_str() == Some("-") => {
io::stdout()
.write_all(content.as_bytes())
.context("Failed to write to stdout")
}
Some(path) => {
fs::write(path, content)
.with_context(|| format!("Failed to write to file: {}", path.display()))
}
None => {
io::stdout()
.write_all(content.as_bytes())
.context("Failed to write to stdout")
}
}
}