mlux 1.14.0

A rich Markdown viewer for modern terminals
Documentation
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};

/// Default sidebar width in typst points for headless (non-terminal) rendering.
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("");

    // git describe --tags --always output patterns:
    //   ""                     → no git (tarball, crates.io): use Cargo version
    //   starts with 'v'       → tag present: use as-is (e.g. "v0.4.1" or "v0.4.1-3-ge0e4555")
    //   otherwise             → no tags (shallow clone etc): "{base}-dev+{hash}"
    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>,

    /// Input Markdown file (for view mode; use `-` for stdin)
    #[arg(global = true)]
    input: Option<PathBuf>,

    /// Theme name (loaded from themes/{name}.typ)
    #[arg(long, global = true)]
    theme: Option<String>,

    /// Disable automatic file watching (viewer reloads on file change by default)
    #[arg(long, global = true)]
    no_watch: bool,

    /// Disable Landlock filesystem sandbox (Linux only; fork is always used)
    #[arg(long, global = true)]
    no_sandbox: bool,

    /// Allow fetching remote images (http/https URLs) in Markdown
    #[arg(long, global = true)]
    allow_remote_images: bool,

    /// Log output file path (enables logging when specified)
    #[arg(long, global = true)]
    log: Option<PathBuf>,

    /// Enable debug-level logging (default is info level)
    #[arg(long, global = true)]
    debug: bool,
}

#[derive(Subcommand)]
enum Command {
    /// Render Markdown to PNG
    Render {
        /// Input Markdown file (use `-` for stdin)
        input: PathBuf,

        /// Output PNG file
        #[arg(short, long, default_value = "output.png")]
        output: PathBuf,

        /// Page width in pt
        #[arg(long)]
        width: Option<f64>,

        /// Output resolution in PPI
        #[arg(long)]
        ppi: Option<f32>,

        /// Tile height in pt
        #[arg(long)]
        tile_height: Option<f64>,

        /// Dump frame tree to stderr
        #[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);

    // Load config file and merge CLI overrides
    let mut cfg = match config::load_config() {
        Ok(c) => c,
        Err(e) => {
            eprintln!("Error: {e:#}");
            std::process::exit(1);
        }
    };

    // Extract render-subcommand CLI overrides (width/ppi/tile_height)
    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),
    };

    // Build CliOverrides for viewer config reload support
    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();

    // Theme detection: only when theme is "auto" and stdout is a TTY
    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
    };

    // Build InputSource and read markdown
    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);
        }
    };

    // Build AppContext: shared initialization for both modes.
    // Theme resolution is deferred to the build pipeline (prescan → CJK → theme).
    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(&params, 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(&params, 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(())
}