use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use log::info;
use mlux::config;
use mlux::input::{self, InputSource};
use mlux::pipeline::{BuildParams, FontCache};
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>,
}
#[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();
if let Some(log_path) = &cli.log {
let file = std::fs::File::create(log_path).expect("failed to open log file");
env_logger::Builder::from_default_env()
.target(env_logger::Target::Pipe(Box::new(file)))
.init();
} else if cli.command.is_some() {
env_logger::init();
}
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 result = match cli.command {
Some(Command::Render {
input,
output,
dump,
..
}) => cmd_render(
input,
&config,
output,
dump,
cli.no_sandbox,
cli.allow_remote_images,
),
None => {
let input_source = if input::is_stdin_input(cli.input.as_deref()) {
InputSource::Stdin(input::StdinReader::new())
} else {
match cli.input {
Some(p) => InputSource::File(p),
None => {
eprintln!("Error: input file required (or pipe via stdin)");
std::process::exit(1);
}
}
};
mlux::viewer::run(
input_source,
config,
&cli_overrides,
!cli.no_watch,
cli.no_sandbox,
)
}
};
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 cmd_render(
input: PathBuf,
config: &config::Config,
output: PathBuf,
dump: bool,
no_sandbox: bool,
allow_remote_images: bool,
) -> Result<()> {
let pipeline_start = Instant::now();
let width = config.width;
let ppi = config.ppi;
let tile_height = config.viewer.tile_height;
let is_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 theme_resolved = mlux::theme::resolve_theme_name(&config.theme, is_light);
let theme = theme_resolved;
let is_stdin = input.as_os_str() == "-";
let markdown = if is_stdin {
input::read_stdin_to_string().context("failed to read stdin")?
} else {
fs::read_to_string(&input).with_context(|| format!("failed to read {}", input.display()))?
};
let theme_text =
mlux::theme::get(theme).ok_or_else(|| anyhow::anyhow!("unknown theme '{theme}'"))?;
if markdown.trim().is_empty() {
anyhow::bail!("input file is empty or contains only whitespace");
}
let base_dir = if is_stdin { None } else { input.parent() };
let read_base = if is_stdin {
None
} else {
Some(
input
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.canonicalize()
.context("failed to canonicalize input directory")?,
)
};
let data_files = mlux::theme::data_files(theme);
let font_cache = FontCache::new();
let params = BuildParams {
theme_name: theme,
theme_text,
data_files,
markdown: &markdown,
base_dir,
width_pt: width,
sidebar_width_pt: DEFAULT_SIDEBAR_WIDTH_PT,
tile_height_pt: tile_height,
ppi,
fonts: &font_cache,
allow_remote_images,
};
if dump {
let mut child = mlux::fork_render::fork_dump(¶ms, read_base.as_deref(), no_sandbox)?;
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();
cmd_render_fork(
¶ms,
read_base.as_deref(),
output_parent,
&stem,
&ext,
is_stdin,
&input,
pipeline_start,
no_sandbox,
)
}
#[allow(clippy::too_many_arguments)]
fn cmd_render_fork(
params: &BuildParams<'_>,
read_base: Option<&Path>,
output_parent: &Path,
stem: &str,
ext: &str,
is_stdin: bool,
input: &Path,
pipeline_start: Instant,
no_sandbox: bool,
) -> Result<()> {
use mlux::fork_render::spawn_renderer;
let (meta, mut renderer, mut _child) = spawn_renderer(params, read_base, no_sandbox)?;
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 = if is_stdin {
"<stdin>".to_string()
} else {
input.display().to_string()
};
eprintln!("rendered {} -> {} tile(s):", input_name, meta.tile_count);
for (filename, size) in &files {
eprintln!(" {} ({} bytes)", filename, size);
}
Ok(())
}