//! 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
\nb
"
);
}
#[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());
}
}