use std::fmt::Write as _;
use crate::rendering::svg::render_mermaid_to_svg_with_id;
pub const AUTODOC_CSS: &str = include_str!("autodoc.css");
const DEFAULT_TITLE: &str = "TestStand Documentation Report";
#[must_use]
pub fn markdown_to_html(markdown_text: &str, title: Option<&str>) -> String {
let doc_title = title.unwrap_or(DEFAULT_TITLE);
let body_html = add_heading_anchors(&compile_body(markdown_text));
format!(
"<!doctype html>\n\
<html lang=\"en\">\n\
<head>\n\
<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\
<title>{title}</title>\n\
<style>{css}</style>\n\
</head>\n\
<body>\n<main>\n{body}\n</main>\n</body>\n</html>\n",
title = escape_html(doc_title),
css = AUTODOC_CSS,
body = body_html,
)
}
fn compile_body(markdown_text: &str) -> String {
let Ok(tree) = markdown::to_mdast(markdown_text, &markdown::ParseOptions::gfm()) else {
return format!("<pre>{}</pre>", escape_html(markdown_text));
};
let mut replacements = Vec::new();
collect_diagrams(&tree, &mut replacements, &mut 0);
replacements.sort_by_key(|(start, _, _)| *start);
let mut figures = Vec::with_capacity(replacements.len());
let placeheld: Vec<(usize, usize, String)> = replacements
.into_iter()
.enumerate()
.map(|(index, (start, end, html))| {
figures.push(html);
(start, end, format!("<!--rs-teststand-figure-{index}-->"))
})
.collect();
let substituted = splice(markdown_text, &placeheld);
let mut compiled = markdown::to_html_with_options(
&substituted,
&markdown::Options {
compile: markdown::CompileOptions {
allow_dangerous_html: true,
..markdown::CompileOptions::gfm()
},
parse: markdown::ParseOptions::gfm(),
},
)
.unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(markdown_text)));
for (index, figure) in figures.into_iter().enumerate() {
compiled = compiled.replace(&format!("<!--rs-teststand-figure-{index}-->"), &figure);
}
compiled
}
fn collect_diagrams(
node: &markdown::mdast::Node,
replacements: &mut Vec<(usize, usize, String)>,
diagram_count: &mut usize,
) {
use markdown::mdast::Node;
if let Node::Code(code) = node
&& code.lang.as_deref() == Some("mermaid")
{
if let Some(position) = &code.position {
*diagram_count += 1;
let rendered = render_diagram(&code.value, *diagram_count);
replacements.push((position.start.offset, position.end.offset, rendered));
}
return;
}
if let Some(children) = node.children() {
for child in children {
collect_diagrams(child, replacements, diagram_count);
}
}
}
fn render_diagram(source: &str, ordinal: usize) -> String {
let diagram_id = format!("mermaid_diagram_{ordinal}");
let picture = match render_mermaid_to_svg_with_id(source, &diagram_id) {
Ok(svg) if !svg.is_empty() => svg,
_ => String::new(),
};
if picture.is_empty() {
return format!(
"<div class=\"mermaid-diagram\">
<pre class=\"mermaid\"><code>{source}</code></pre>
</div>",
source = escape_html(source),
);
}
format!(
"<div class=\"mermaid-diagram\">
{picture}
</div>"
)
}
fn splice(text: &str, replacements: &[(usize, usize, String)]) -> String {
let mut out = String::with_capacity(text.len());
let mut last_end = 0;
for (start, end, replacement) in replacements {
if *start < last_end || *end > text.len() || start > end {
continue;
}
if let Some(gap) = text.get(last_end..*start) {
out.push_str(gap);
}
out.push_str(replacement);
last_end = *end;
}
if let Some(tail) = text.get(last_end..) {
out.push_str(tail);
}
out
}
fn add_heading_anchors(html: &str) -> String {
let mut out = String::with_capacity(html.len() + 256);
let mut rest = html;
while let Some(at) = rest.find("<h") {
let (before, tail) = rest.split_at(at);
out.push_str(before);
let level = tail.get(2..3).and_then(|c| c.parse::<u8>().ok());
let Some(level) = level.filter(|n| (1..=6).contains(n)) else {
out.push_str(tail.get(..2).unwrap_or_default());
rest = tail.get(2..).unwrap_or_default();
continue;
};
let open = format!("<h{level}>");
let close = format!("</h{level}>");
let Some(text_start) = tail.strip_prefix(open.as_str()) else {
out.push_str(tail.get(..2).unwrap_or_default());
rest = tail.get(2..).unwrap_or_default();
continue;
};
let Some(end) = text_start.find(close.as_str()) else {
out.push_str(tail);
return out;
};
let inner = text_start.get(..end).unwrap_or_default();
let plain: String = strip_tags(inner);
let anchor = crate::rendering::markdown::slug(&plain);
let _ = write!(out, "<h{level} id=\"{anchor}\">{inner}</h{level}>");
rest = text_start.get(end + close.len()..).unwrap_or_default();
}
out.push_str(rest);
out
}
fn strip_tags(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut inside = false;
for ch in input.chars() {
match ch {
'<' => inside = true,
'>' => inside = false,
other if !inside => out.push(other),
_ => {}
}
}
out
}
fn escape_html(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::markdown_to_html;
const CHART: &str = "# Sequence File\n\nIntro.\n\n```mermaid\nflowchart TD\n A[Step 1] --> B[Step 2]\n```\n\n## Next\n";
#[test]
fn renders_a_diagram_as_inline_svg() {
let html = markdown_to_html(CHART, Some("Test Report"));
assert!(html.contains("<!doctype html>"));
assert!(html.contains("<title>Test Report</title>"));
assert!(html.contains(">Sequence File</h1>"));
assert!(html.contains("<div class=\"mermaid-diagram\">"));
assert!(html.contains("<svg"));
}
#[test]
fn shows_the_drawing_without_the_script_behind_it() {
let html = markdown_to_html(CHART, None);
assert!(html.contains("<svg"), "the drawing should be there");
assert!(
!html.contains("mermaid-source"),
"the source disclosure should be gone",
);
assert!(
!html.contains("flowchart TD"),
"the script should not be shown beside its own drawing",
);
}
#[test]
fn shows_the_source_when_there_is_no_drawing() {
let unrenderable = "```mermaid
not a diagram at all
```
";
let html = markdown_to_html(unrenderable, None);
assert!(
html.contains("mermaid-diagram"),
"the block should still be present",
);
assert!(
html.contains("not a diagram at all"),
"the source should stand in when nothing was drawn",
);
}
#[test]
fn embeds_everything_and_fetches_nothing() {
let html = markdown_to_html(CHART, None);
for remote in [
"cdn.",
"unpkg",
"jsdelivr",
"<script src=",
"<link href=",
"@import",
] {
assert!(
!html.contains(remote),
"document must not reference {remote}"
);
}
for (index, _) in html.match_indices("://") {
let tail = html.get(index..index + 24).unwrap_or_default();
assert!(
tail.starts_with("://www.w3.org/"),
"unexpected absolute URL in output: {tail}"
);
}
}
#[test]
fn the_diagram_stylesheet_survives_compilation() {
let html = markdown_to_html(CHART, None);
assert!(
!html.contains("<style"),
"the diagram stylesheet was escaped into text"
);
assert!(
html.contains("<style"),
"the diagram should carry its own stylesheet"
);
assert!(
html.contains("fill:none") || html.contains("fill: none"),
"the edge rules should reach the document"
);
}
#[test]
fn converts_tables() {
let html = markdown_to_html(
"| Step | Type |\n| --- | --- |\n| Action | PassFail |\n",
None,
);
assert!(html.contains("<table>"));
assert!(html.contains("<th>Step</th>"));
assert!(html.contains("<td>Action</td>"));
}
}