//! Render one markdown file to an HTML fragment on stdout. //! //! A thin command-line front for the permissive renderer, for the places that //! want docengine's output without linking docengine: a shell build script, a //! Makefile, a one-off conversion. It writes a fragment, not a document, so the //! caller owns the surrounding page. //! //! ```text //! docengine-render [--mermaid-wrapper] [--id-prefix SLUG] FILE.md //! ``` //! //! `--mermaid-wrapper` rewrites every ```` ```mermaid ```` fence into the //! `
` shape mermaid.js reads.
//! The fence body is left exactly as the renderer escaped it.
//!
//! `--id-prefix SLUG` prefixes every heading `id` with `SLUG--`, so several
//! rendered files can be concatenated into one page without their anchors
//! colliding.

use std::process::ExitCode;

use docengine::Renderer;
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};

const USAGE: &str = "usage: docengine-render [--mermaid-wrapper] [--id-prefix SLUG] FILE.md";

struct Args {
    path: String,
    mermaid_wrapper: bool,
    id_prefix: Option,
}

fn parse_args() -> Result {
    let mut path: Option = None;
    let mut mermaid_wrapper = false;
    let mut id_prefix: Option = None;

    let mut args = std::env::args().skip(1);
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--mermaid-wrapper" => mermaid_wrapper = true,
            "--id-prefix" => {
                let value = args.next().ok_or("--id-prefix needs a value")?;
                id_prefix = Some(value);
            }
            "-h" | "--help" => return Err(USAGE.to_string()),
            other if other.starts_with('-') => {
                return Err(format!("unknown option: {other}"));
            }
            other => {
                if path.is_some() {
                    return Err("only one input file is accepted".to_string());
                }
                path = Some(other.to_string());
            }
        }
    }

    Ok(Args {
        path: path.ok_or("no input file given")?,
        mermaid_wrapper,
        id_prefix,
    })
}

fn main() -> ExitCode {
    let args = match parse_args() {
        Ok(args) => args,
        Err(message) => {
            eprintln!("{message}");
            eprintln!("{USAGE}");
            return ExitCode::from(2);
        }
    };

    let markdown = match std::fs::read_to_string(&args.path) {
        Ok(text) => text,
        Err(err) => {
            eprintln!("cannot read {}: {err}", args.path);
            return ExitCode::from(2);
        }
    };

    // `render_permissive` is the same preset with heading ids off, and an
    // anchorless fragment is no use to a page that wants to link into it.
    let mut html = Renderer::permissive()
        .with_heading_ids(true)
        .render(&markdown);
    if let Some(prefix) = &args.id_prefix {
        html = prefix_heading_ids(&html, prefix);
    }
    if args.mermaid_wrapper {
        html = match wrap_mermaid(&html, &markdown) {
            Ok(wrapped) => wrapped,
            Err(message) => {
                eprintln!("{}: {message}", args.path);
                return ExitCode::from(2);
            }
        };
    }

    println!("{html}");
    ExitCode::SUCCESS
}

/// Prefix every heading `id` with `{prefix}--`.
///
/// Sanitization keeps `id` on `h1`-`h6` and strips it everywhere else, so
/// scanning for heading tags is enough: no other element in the fragment can
/// carry an id to be rewritten.
fn prefix_heading_ids(html: &str, prefix: &str) -> String {
    const ID_ATTR: &str = " id=\"";

    let mut out = String::with_capacity(html.len());
    let mut cursor = 0usize;

    while let Some(offset) = html[cursor..].find("') else {
            break;
        };
        let tag_end = level_at + close;
        out.push_str(&html[cursor..tag_start]);
        let tag = &html[tag_start..tag_end];
        if let Some(id_at) = tag.find(ID_ATTR) {
            let value_at = tag_start + id_at + ID_ATTR.len();
            out.push_str(&html[tag_start..value_at]);
            out.push_str(prefix);
            out.push_str("--");
            out.push_str(&html[value_at..tag_end]);
        } else {
            out.push_str(tag);
        }
        cursor = tag_end;
    }

    out.push_str(&html[cursor..]);
    out
}

/// Whether each code block in `markdown` is a mermaid fence, in document order.
///
/// The language has to come from the markdown because sanitization strips
/// `class` from `code`, so by the time the HTML exists every block looks alike.
fn mermaid_flags(markdown: &str) -> Vec {
    Parser::new_ext(markdown, Options::empty())
        .filter_map(|event| match event {
            Event::Start(Tag::CodeBlock(kind)) => Some(match kind {
                CodeBlockKind::Fenced(info) => info.split_whitespace().next() == Some("mermaid"),
                CodeBlockKind::Indented => false,
            }),
            _ => None,
        })
        .collect()
}

/// Rewrite mermaid code fences into the block mermaid.js initializes.
///
/// The body is copied across untouched: the renderer has already escaped it,
/// and that is the escaping mermaid.js expects to read back out of the DOM.
///
/// Blocks are matched to fences by position, so a mismatch between the two
/// counts is an error rather than a guess: it would mean the HTML holds a
/// `
` the markdown parse did not account for, and every block after
/// it would be mislabelled.
fn wrap_mermaid(html: &str, markdown: &str) -> Result {
    const OPEN: &str = "
";
    const CLOSE: &str = "
"; let flags = mermaid_flags(markdown); let mut out = String::with_capacity(html.len()); let mut cursor = 0usize; let mut index = 0usize; while let Some(offset) = html[cursor..].find(OPEN) { let block_start = cursor + offset; let body_start = block_start + OPEN.len(); let close = html[body_start..] .find(CLOSE) .ok_or_else(|| "unclosed
 in the rendered HTML".to_string())?;
        let body_end = body_start + close;

        let is_mermaid = *flags.get(index).ok_or_else(|| {
            format!(
                "the rendered HTML holds more code blocks than the markdown has ({})",
                flags.len()
            )
        })?;
        index += 1;

        out.push_str(&html[cursor..block_start]);
        if is_mermaid {
            out.push_str("
\n
\n");
            let body = &html[body_start..body_end];
            out.push_str(body);
            if !body.ends_with('\n') {
                out.push('\n');
            }
            out.push_str("
\n
"); } else { out.push_str(&html[block_start..body_end + CLOSE.len()]); } cursor = body_end + CLOSE.len(); } if index != flags.len() { return Err(format!( "the markdown has {} code blocks but the rendered HTML holds {index}", flags.len() )); } out.push_str(&html[cursor..]); Ok(out) } #[cfg(test)] mod tests { use super::{prefix_heading_ids, wrap_mermaid}; #[test] fn heading_ids_take_the_prefix() { let html = r#"

Overview

t

"#; assert_eq!( prefix_heading_ids(html, "auth-flows"), r#"

Overview

t

"# ); } #[test] fn an_idless_heading_is_left_alone() { let html = "

Title


"; assert_eq!(prefix_heading_ids(html, "slug"), html); } #[test] fn a_mermaid_fence_becomes_a_wrapped_pre() { let markdown = "a\n\n```mermaid\ngraph TD\n A-->B\n```\n\nb\n"; let html = "

a

graph TD\n  A-->B\n

b

"; assert_eq!( wrap_mermaid(html, markdown).unwrap(), "

a

\n
\ngraph TD\n  A-->B\n
\n

b

" ); } #[test] fn other_code_fences_are_untouched() { let markdown = "```rust\nfn main() {}\n```\n"; let html = "
fn main() {}\n
"; assert_eq!(wrap_mermaid(html, markdown).unwrap(), html); } #[test] fn only_the_mermaid_block_of_a_mixed_run_is_wrapped() { let markdown = "```rust\nlet x = 1;\n```\n\n```mermaid\ngraph TD\n```\n\n```sh\nls\n```\n"; let html = "
let x = 1;\n
graph TD\n
ls\n
"; let out = wrap_mermaid(html, markdown).unwrap(); assert_eq!(out.matches("class=\"mermaid\"").count(), 1); assert!( out.contains("
\ngraph TD\n
"), "got: {out}" ); assert!(out.contains("
let x = 1;"), "got: {out}");
        assert!(out.contains("
ls\n
"), "got: {out}"); } #[test] fn a_count_mismatch_is_an_error_rather_than_a_guess() { let markdown = "```mermaid\ngraph TD\n```\n"; let html = "
graph TD\n
stray\n
"; assert!(wrap_mermaid(html, markdown).is_err()); } }