use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use log::info;
use mlux::app_context::{AppContext, AppContextBuilder};
use mlux::config;
use mlux::input_source::{self, InputSource};
const DEFAULT_SIDEBAR_WIDTH_PT: f64 = 40.0;
fn long_version() -> &'static str {
let base = env!("CARGO_PKG_VERSION");
let hash = option_env!("MLUX_BUILD_GIT_HASH").unwrap_or("");
let profile = option_env!("MLUX_BUILD_PROFILE").unwrap_or("unknown");
let describe = option_env!("MLUX_BUILD_GIT_DESCRIBE").unwrap_or("");
let version = if describe.is_empty() {
base.to_string()
} else if describe.starts_with('v') {
describe.to_string()
} else {
format!("{base}-dev+{describe}")
};
if hash.is_empty() {
format!("{version} ({profile})").leak()
} else {
format!("{version} (rev {hash}, {profile})").leak()
}
}
#[derive(Parser)]
#[command(name = "mlux", version = long_version(), about = "Markdown viewer and renderer powered by Typst")]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(global = true)]
input: Option<PathBuf>,
#[arg(long, global = true)]
theme: Option<String>,
#[arg(long, global = true)]
no_watch: bool,
#[arg(long, global = true)]
no_sandbox: bool,
#[arg(long, global = true)]
allow_remote_images: bool,
#[arg(long, global = true)]
log: Option<PathBuf>,
#[arg(long, global = true)]
debug: bool,
}
#[derive(Subcommand)]
enum Command {
Render {
input: PathBuf,
#[arg(short, long, default_value = "output.png")]
output: PathBuf,
#[arg(long)]
width: Option<f64>,
#[arg(long)]
ppi: Option<f32>,
#[arg(long)]
tile_height: Option<f64>,
#[arg(long)]
dump: bool,
},
}
fn main() {
let cli = Cli::parse();
let log_file: Option<Box<dyn std::io::Write + Send>> = if let Some(log_path) = &cli.log {
let file = std::fs::File::create(log_path).expect("failed to open log file");
Some(Box::new(file))
} else {
None
};
let log_buffer = mlux::log::init(cli.debug, log_file);
let mut cfg = match config::load_config() {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {e:#}");
std::process::exit(1);
}
};
let (render_width, render_ppi, render_tile_height) = match &cli.command {
Some(Command::Render {
width,
ppi,
tile_height,
..
}) => (*width, *ppi, *tile_height),
None => (None, None, None),
};
let cli_overrides = config::CliOverrides {
theme: cli.theme.clone(),
width: render_width,
ppi: render_ppi,
tile_height: render_tile_height,
allow_remote_images: cli.allow_remote_images,
};
cfg.merge_cli(cli.theme, render_width, render_ppi, render_tile_height);
let config = cfg.resolve();
let detected_light = if config.theme == "auto" {
use std::io::IsTerminal;
if std::io::stdout().is_terminal() {
let _raw = crossterm::terminal::enable_raw_mode();
let result = mlux::viewer::detect_terminal_theme(std::time::Duration::from_millis(100))
== mlux::viewer::TerminalTheme::Light;
let _ = crossterm::terminal::disable_raw_mode();
result
} else {
false
}
} else {
false
};
let render_input_path = cli
.command
.as_ref()
.map(|Command::Render { input, .. }| input.clone());
let mut input_source = build_input_source(cli.input.or(render_input_path));
let markdown = match input_source.read_all() {
Ok(md) => md,
Err(e) => {
eprintln!("Error: {e:#}");
std::process::exit(1);
}
};
let app = match AppContextBuilder::new(config, cli_overrides)
.load_fonts()
.set_detected_light(detected_light)
.build()
{
Ok(app) => app,
Err(e) => {
eprintln!("Error: {e:#}");
std::process::exit(1);
}
};
let base_dir = match &input_source {
InputSource::File(path) => path.parent().map(|d| d.to_path_buf()),
InputSource::Stdin(_) => None,
};
let result = match cli.command {
Some(Command::Render { output, dump, .. }) => cmd_render(
app,
&input_source,
markdown,
base_dir,
output,
dump,
cli.no_sandbox,
&log_buffer,
),
None => mlux::viewer::run(
app,
input_source,
markdown,
!cli.no_watch,
cli.no_sandbox,
log_buffer,
),
};
if let Err(e) = result {
let msg = format!("{e:#}");
if msg.contains("[BUG]") {
eprintln!("\x1b[1;31m{msg}\x1b[0m");
} else {
eprintln!("Error: {msg}");
}
std::process::exit(1);
}
}
fn build_input_source(input: Option<PathBuf>) -> InputSource {
if input_source::is_stdin_input(input.as_deref()) {
InputSource::Stdin(input_source::StdinReader::new())
} else {
match input {
Some(p) => match p.canonicalize() {
Ok(canonical) => InputSource::File(canonical),
Err(e) => {
eprintln!("Error: failed to resolve {}: {e}", p.display());
std::process::exit(1);
}
},
None => {
eprintln!("Error: input file required (or pipe via stdin)");
std::process::exit(1);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn cmd_render(
app: AppContext,
input: &InputSource,
markdown: String,
base_dir: Option<PathBuf>,
output: PathBuf,
dump: bool,
no_sandbox: bool,
log_buffer: &mlux::log::LogBuffer,
) -> Result<()> {
let pipeline_start = Instant::now();
if markdown.trim().is_empty() {
anyhow::bail!("input file is empty or contains only whitespace");
}
let params = app.build_params(
markdown.clone(),
base_dir,
app.config.width,
DEFAULT_SIDEBAR_WIDTH_PT,
app.config.viewer.tile_height,
);
if dump {
let mut child = mlux::renderer::build_dump(¶ms, no_sandbox, log_buffer)?;
let code = child.wait()?;
if code != 0 {
anyhow::bail!("dump failed (child exited with code {code})");
}
return Ok(());
}
let output_parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
fs::create_dir_all(output_parent).ok();
let stem = output
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let ext = output
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (meta, mut renderer, mut _child) =
mlux::renderer::build_renderer_blocking(¶ms, no_sandbox, log_buffer)?;
let mut files = Vec::new();
for i in 0..meta.tile_count {
let pngs = renderer.render_tile_pair(i)?;
let filename = format!("{}-{:03}.{}", stem, i, ext);
let path = output_parent.join(&filename);
fs::write(&path, &pngs.content)
.with_context(|| format!("failed to write {}", path.display()))?;
files.push((filename, pngs.content.len()));
}
renderer.shutdown();
info!(
"cmd_render: total pipeline completed in {:.1}ms",
pipeline_start.elapsed().as_secs_f64() * 1000.0
);
let input_name = input.display_name();
eprintln!("rendered {} -> {} tile(s):", input_name, meta.tile_count);
for (filename, size) in &files {
eprintln!(" {} ({} bytes)", filename, size);
}
Ok(())
}