| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
|
| 20 |
use std::process::ExitCode; |
| 21 |
|
| 22 |
use docengine::Renderer; |
| 23 |
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag}; |
| 24 |
|
| 25 |
const USAGE: &str = "usage: docengine-render [--mermaid-wrapper] [--id-prefix SLUG] FILE.md"; |
| 26 |
|
| 27 |
struct Args { |
| 28 |
path: String, |
| 29 |
mermaid_wrapper: bool, |
| 30 |
id_prefix: Option<String>, |
| 31 |
} |
| 32 |
|
| 33 |
fn parse_args() -> Result<Args, String> { |
| 34 |
let mut path: Option<String> = None; |
| 35 |
let mut mermaid_wrapper = false; |
| 36 |
let mut id_prefix: Option<String> = None; |
| 37 |
|
| 38 |
let mut args = std::env::args().skip(1); |
| 39 |
while let Some(arg) = args.next() { |
| 40 |
match arg.as_str() { |
| 41 |
"--mermaid-wrapper" => mermaid_wrapper = true, |
| 42 |
"--id-prefix" => { |
| 43 |
let value = args.next().ok_or("--id-prefix needs a value")?; |
| 44 |
id_prefix = Some(value); |
| 45 |
} |
| 46 |
"-h" | "--help" => return Err(USAGE.to_string()), |
| 47 |
other if other.starts_with('-') => { |
| 48 |
return Err(format!("unknown option: {other}")); |
| 49 |
} |
| 50 |
other => { |
| 51 |
if path.is_some() { |
| 52 |
return Err("only one input file is accepted".to_string()); |
| 53 |
} |
| 54 |
path = Some(other.to_string()); |
| 55 |
} |
| 56 |
} |
| 57 |
} |
| 58 |
|
| 59 |
Ok(Args { |
| 60 |
path: path.ok_or("no input file given")?, |
| 61 |
mermaid_wrapper, |
| 62 |
id_prefix, |
| 63 |
}) |
| 64 |
} |
| 65 |
|
| 66 |
fn main() -> ExitCode { |
| 67 |
let args = match parse_args() { |
| 68 |
Ok(args) => args, |
| 69 |
Err(message) => { |
| 70 |
eprintln!("{message}"); |
| 71 |
eprintln!("{USAGE}"); |
| 72 |
return ExitCode::from(2); |
| 73 |
} |
| 74 |
}; |
| 75 |
|
| 76 |
let markdown = match std::fs::read_to_string(&args.path) { |
| 77 |
Ok(text) => text, |
| 78 |
Err(err) => { |
| 79 |
eprintln!("cannot read {}: {err}", args.path); |
| 80 |
return ExitCode::from(2); |
| 81 |
} |
| 82 |
}; |
| 83 |
|
| 84 |
|
| 85 |
|
| 86 |
let mut html = Renderer::permissive() |
| 87 |
.with_heading_ids(true) |
| 88 |
.render(&markdown); |
| 89 |
if let Some(prefix) = &args.id_prefix { |
| 90 |
html = prefix_heading_ids(&html, prefix); |
| 91 |
} |
| 92 |
if args.mermaid_wrapper { |
| 93 |
html = match wrap_mermaid(&html, &markdown) { |
| 94 |
Ok(wrapped) => wrapped, |
| 95 |
Err(message) => { |
| 96 |
eprintln!("{}: {message}", args.path); |
| 97 |
return ExitCode::from(2); |
| 98 |
} |
| 99 |
}; |
| 100 |
} |
| 101 |
|
| 102 |
println!("{html}"); |
| 103 |
ExitCode::SUCCESS |
| 104 |
} |
| 105 |
|
| 106 |
|
| 107 |
|
| 108 |
|
| 109 |
|
| 110 |
|
| 111 |
fn prefix_heading_ids(html: &str, prefix: &str) -> String { |
| 112 |
const ID_ATTR: &str = " id=\""; |
| 113 |
|
| 114 |
let mut out = String::with_capacity(html.len()); |
| 115 |
let mut cursor = 0usize; |
| 116 |
|
| 117 |
while let Some(offset) = html[cursor..].find("<h") { |
| 118 |
let tag_start = cursor + offset; |
| 119 |
let level_at = tag_start + "<h".len(); |
| 120 |
let is_heading = html[level_at..] |
| 121 |
.chars() |
| 122 |
.next() |
| 123 |
.is_some_and(|c| ('1'..='6').contains(&c)); |
| 124 |
if !is_heading { |
| 125 |
out.push_str(&html[cursor..level_at]); |
| 126 |
cursor = level_at; |
| 127 |
continue; |
| 128 |
} |
| 129 |
let Some(close) = html[level_at..].find('>') else { |
| 130 |
break; |
| 131 |
}; |
| 132 |
let tag_end = level_at + close; |
| 133 |
out.push_str(&html[cursor..tag_start]); |
| 134 |
let tag = &html[tag_start..tag_end]; |
| 135 |
if let Some(id_at) = tag.find(ID_ATTR) { |
| 136 |
let value_at = tag_start + id_at + ID_ATTR.len(); |
| 137 |
out.push_str(&html[tag_start..value_at]); |
| 138 |
out.push_str(prefix); |
| 139 |
out.push_str("--"); |
| 140 |
out.push_str(&html[value_at..tag_end]); |
| 141 |
} else { |
| 142 |
out.push_str(tag); |
| 143 |
} |
| 144 |
cursor = tag_end; |
| 145 |
} |
| 146 |
|
| 147 |
out.push_str(&html[cursor..]); |
| 148 |
out |
| 149 |
} |
| 150 |
|
| 151 |
|
| 152 |
|
| 153 |
|
| 154 |
|
| 155 |
fn mermaid_flags(markdown: &str) -> Vec<bool> { |
| 156 |
Parser::new_ext(markdown, Options::empty()) |
| 157 |
.filter_map(|event| match event { |
| 158 |
Event::Start(Tag::CodeBlock(kind)) => Some(match kind { |
| 159 |
CodeBlockKind::Fenced(info) => info.split_whitespace().next() == Some("mermaid"), |
| 160 |
CodeBlockKind::Indented => false, |
| 161 |
}), |
| 162 |
_ => None, |
| 163 |
}) |
| 164 |
.collect() |
| 165 |
} |
| 166 |
|
| 167 |
|
| 168 |
|
| 169 |
|
| 170 |
|
| 171 |
|
| 172 |
|
| 173 |
|
| 174 |
|
| 175 |
|
| 176 |
fn wrap_mermaid(html: &str, markdown: &str) -> Result<String, String> { |
| 177 |
const OPEN: &str = "<pre><code>"; |
| 178 |
const CLOSE: &str = "</code></pre>"; |
| 179 |
|
| 180 |
let flags = mermaid_flags(markdown); |
| 181 |
let mut out = String::with_capacity(html.len()); |
| 182 |
let mut cursor = 0usize; |
| 183 |
let mut index = 0usize; |
| 184 |
|
| 185 |
while let Some(offset) = html[cursor..].find(OPEN) { |
| 186 |
let block_start = cursor + offset; |
| 187 |
let body_start = block_start + OPEN.len(); |
| 188 |
let close = html[body_start..] |
| 189 |
.find(CLOSE) |
| 190 |
.ok_or_else(|| "unclosed <pre><code> in the rendered HTML".to_string())?; |
| 191 |
let body_end = body_start + close; |
| 192 |
|
| 193 |
let is_mermaid = *flags.get(index).ok_or_else(|| { |
| 194 |
format!( |
| 195 |
"the rendered HTML holds more code blocks than the markdown has ({})", |
| 196 |
flags.len() |
| 197 |
) |
| 198 |
})?; |
| 199 |
index += 1; |
| 200 |
|
| 201 |
out.push_str(&html[cursor..block_start]); |
| 202 |
if is_mermaid { |
| 203 |
out.push_str("<div class=\"mermaid-wrapper\">\n<pre class=\"mermaid\">\n"); |
| 204 |
let body = &html[body_start..body_end]; |
| 205 |
out.push_str(body); |
| 206 |
if !body.ends_with('\n') { |
| 207 |
out.push('\n'); |
| 208 |
} |
| 209 |
out.push_str("</pre>\n</div>"); |
| 210 |
} else { |
| 211 |
out.push_str(&html[block_start..body_end + CLOSE.len()]); |
| 212 |
} |
| 213 |
|
| 214 |
cursor = body_end + CLOSE.len(); |
| 215 |
} |
| 216 |
|
| 217 |
if index != flags.len() { |
| 218 |
return Err(format!( |
| 219 |
"the markdown has {} code blocks but the rendered HTML holds {index}", |
| 220 |
flags.len() |
| 221 |
)); |
| 222 |
} |
| 223 |
|
| 224 |
out.push_str(&html[cursor..]); |
| 225 |
Ok(out) |
| 226 |
} |
| 227 |
|
| 228 |
#[cfg(test)] |
| 229 |
mod tests { |
| 230 |
use super::{prefix_heading_ids, wrap_mermaid}; |
| 231 |
|
| 232 |
#[test] |
| 233 |
fn heading_ids_take_the_prefix() { |
| 234 |
let html = r#"<h2 id="overview">Overview</h2><p id="x">t</p>"#; |
| 235 |
assert_eq!( |
| 236 |
prefix_heading_ids(html, "auth-flows"), |
| 237 |
r#"<h2 id="auth-flows--overview">Overview</h2><p id="x">t</p>"# |
| 238 |
); |
| 239 |
} |
| 240 |
|
| 241 |
#[test] |
| 242 |
fn an_idless_heading_is_left_alone() { |
| 243 |
let html = "<h1>Title</h1><hr><html>"; |
| 244 |
assert_eq!(prefix_heading_ids(html, "slug"), html); |
| 245 |
} |
| 246 |
|
| 247 |
#[test] |
| 248 |
fn a_mermaid_fence_becomes_a_wrapped_pre() { |
| 249 |
let markdown = "a\n\n```mermaid\ngraph TD\n A-->B\n```\n\nb\n"; |
| 250 |
let html = "<p>a</p><pre><code>graph TD\n A-->B\n</code></pre><p>b</p>"; |
| 251 |
assert_eq!( |
| 252 |
wrap_mermaid(html, markdown).unwrap(), |
| 253 |
"<p>a</p><div class=\"mermaid-wrapper\">\n<pre class=\"mermaid\">\ngraph TD\n A-->B\n</pre>\n</div><p>b</p>" |
| 254 |
); |
| 255 |
} |
| 256 |
|
| 257 |
#[test] |
| 258 |
fn other_code_fences_are_untouched() { |
| 259 |
let markdown = "```rust\nfn main() {}\n```\n"; |
| 260 |
let html = "<pre><code>fn main() {}\n</code></pre>"; |
| 261 |
assert_eq!(wrap_mermaid(html, markdown).unwrap(), html); |
| 262 |
} |
| 263 |
|
| 264 |
#[test] |
| 265 |
fn only_the_mermaid_block_of_a_mixed_run_is_wrapped() { |
| 266 |
let markdown = "```rust\nlet x = 1;\n```\n\n```mermaid\ngraph TD\n```\n\n```sh\nls\n```\n"; |
| 267 |
let html = "<pre><code>let x = 1;\n</code></pre><pre><code>graph TD\n</code></pre><pre><code>ls\n</code></pre>"; |
| 268 |
let out = wrap_mermaid(html, markdown).unwrap(); |
| 269 |
assert_eq!(out.matches("class=\"mermaid\"").count(), 1); |
| 270 |
assert!( |
| 271 |
out.contains("<pre class=\"mermaid\">\ngraph TD\n</pre>"), |
| 272 |
"got: {out}" |
| 273 |
); |
| 274 |
assert!(out.contains("<pre><code>let x = 1;"), "got: {out}"); |
| 275 |
assert!(out.contains("<pre><code>ls\n</code></pre>"), "got: {out}"); |
| 276 |
} |
| 277 |
|
| 278 |
#[test] |
| 279 |
fn a_count_mismatch_is_an_error_rather_than_a_guess() { |
| 280 |
let markdown = "```mermaid\ngraph TD\n```\n"; |
| 281 |
let html = "<pre><code>graph TD\n</code></pre><pre><code>stray\n</code></pre>"; |
| 282 |
assert!(wrap_mermaid(html, markdown).is_err()); |
| 283 |
} |
| 284 |
} |
| 285 |
|