merman-cli 0.8.0-alpha.3

CLI to parse/layout/render Mermaid diagrams to SVG/PNG/JPG/PDF/ASCII/Unicode (headless).
use super::executor::RenderRequest;
use crate::error::CliError;
use crate::io::OutputTarget;
use crate::io::write_file;
use crate::markdown::{self, MarkdownImage};
use crate::render::plan::RenderPlan;
use rayon::prelude::*;
use std::path::Path;

impl RenderPlan {
    pub(super) fn is_mmdc_markdown_input(&self) -> bool {
        matches!(self.mode, super::plan::RenderMode::MmdcCompat)
            && self
                .input
                .as_deref()
                .filter(|path| *path != "-")
                .map(|path| markdown::is_markdown_path(Path::new(path)))
                .unwrap_or(false)
    }
}

impl<'a> RenderRequest<'a> {
    pub(super) fn render_markdown(&self, text: &str) -> Result<(), CliError> {
        if self.plan.format.is_text() {
            return Err(CliError::InvalidOutput(
                "Markdown input does not support ASCII/Unicode output".to_string(),
            ));
        }

        let output_path = match self.plan.output.as_ref() {
            Some(OutputTarget::File(path)) => path.as_path(),
            None | Some(OutputTarget::Stdout) => {
                return Err(CliError::InvalidOutput(
                    "Cannot use `stdout` with markdown input".to_string(),
                ));
            }
        };

        let charts = markdown::extract_charts(text);

        if charts.is_empty() {
            self.info("No mermaid charts found in Markdown input");
        } else {
            self.info(&format!(
                "Found {} mermaid charts in Markdown input",
                charts.len()
            ));
        }

        let images = self.render_markdown_charts(output_path, &charts)?;

        if markdown::is_markdown_path(output_path) {
            let rewritten = markdown::replace_charts_with_images(text, &images);
            write_file(output_path, rewritten.as_bytes())?;
            self.info(&format!("{}", output_path.display()));
        }

        Ok(())
    }

    fn render_markdown_charts(
        &self,
        output_path: &Path,
        charts: &[markdown::MarkdownChart],
    ) -> Result<Vec<MarkdownImage>, CliError> {
        if charts.len() <= 1 || self.plan.jobs == 1 {
            return charts
                .iter()
                .enumerate()
                .map(|(index, chart)| self.render_markdown_chart(output_path, index, chart))
                .collect();
        }

        let pool = rayon::ThreadPoolBuilder::new()
            .num_threads(self.plan.jobs)
            .build()
            .map_err(|err| {
                CliError::InvalidInput(format!("failed to configure Markdown render jobs: {err}"))
            })?;

        pool.install(|| {
            charts
                .par_iter()
                .enumerate()
                .map(|(index, chart)| self.render_markdown_chart(output_path, index, chart))
                .collect()
        })
    }

    fn render_markdown_chart(
        &self,
        output_path: &Path,
        index: usize,
        chart: &markdown::MarkdownChart,
    ) -> Result<MarkdownImage, CliError> {
        let output_file = markdown::numbered_output_path(
            output_path,
            index + 1,
            self.plan.format,
            self.plan.artefacts.as_deref(),
        );
        let artifact = self.render_artifact(&chart.definition)?;
        write_file(&output_file, &artifact.bytes)?;

        let url = markdown::relative_markdown_url(output_path, &output_file)?;
        self.info(&format!("{url}"));
        Ok(MarkdownImage {
            url,
            title: artifact.title,
            alt: artifact.desc.unwrap_or_else(|| "diagram".to_string()),
        })
    }
}