Skip to main content

max / docengine

Add docengine-render, a command-line front for the permissive renderer A bin target, not a new crate. _private/diagrams/infra needed markdown rendered to HTML by a script, and docengine is already this tree's markdown-to-HTML library; putting a compiled artifact and a patched git dep into the secrets repo to get it would be the worse trade. Adds no dependency. It uses std plus pulldown-cmark, which the library already takes, so the git-dep consumers building only the lib target are unaffected and need no version bump. Two flags beyond rendering, both generic rather than diagram-specific. --id-prefix qualifies heading ids so several documents can share a page without colliding. --mermaid-wrapper rewrites fenced mermaid blocks into the div-and-pre shape mermaid.js looks for; it identifies them by reading fence languages from the markdown and matching to code blocks by document position, because ammonia strips the class attribute off code elements and a sanitized mermaid fence is otherwise indistinguishable from any other. A count mismatch between the parse and the HTML is a hard error rather than a silent mislabel.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-04 15:22 UTC
Signed with PGP, not checked
Commit: d7618306697bf1e526aa2278daf65cc92c404201
Parent: e853ac4
2 files changed, +291 insertions, -0 deletions
M Cargo.toml +7
@@ -71,3 +71,10 @@
71 71 match_same_arms = "allow"
72 72 unnecessary_wraps = "allow"
73 73 type_complexity = "allow"
74 +
75 + # A command-line front for the permissive renderer, for callers that cannot link
76 + # the library. Adds no dependency, so the git-dep consumers building only the lib
77 + # target are unaffected.
78 + [[bin]]
79 + name = "docengine-render"
80 + path = "src/bin/docengine-render.rs"
@@ -1,0 +1,284 @@
1 + //! Render one markdown file to an HTML fragment on stdout.
2 + //!
3 + //! A thin command-line front for the permissive renderer, for the places that
4 + //! want docengine's output without linking docengine: a shell build script, a
5 + //! Makefile, a one-off conversion. It writes a fragment, not a document, so the
6 + //! caller owns the surrounding page.
7 + //!
8 + //! ```text
9 + //! docengine-render [--mermaid-wrapper] [--id-prefix SLUG] FILE.md
10 + //! ```
11 + //!
12 + //! `--mermaid-wrapper` rewrites every ```` ```mermaid ```` fence into the
13 + //! `<div class="mermaid-wrapper"><pre class="mermaid">` shape mermaid.js reads.
14 + //! The fence body is left exactly as the renderer escaped it.
15 + //!
16 + //! `--id-prefix SLUG` prefixes every heading `id` with `SLUG--`, so several
17 + //! rendered files can be concatenated into one page without their anchors
18 + //! colliding.
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 + // `render_permissive` is the same preset with heading ids off, and an
85 + // anchorless fragment is no use to a page that wants to link into it.
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 + /// Prefix every heading `id` with `{prefix}--`.
107 + ///
108 + /// Sanitization keeps `id` on `h1`-`h6` and strips it everywhere else, so
109 + /// scanning for heading tags is enough: no other element in the fragment can
110 + /// carry an id to be rewritten.
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 + /// Whether each code block in `markdown` is a mermaid fence, in document order.
152 + ///
153 + /// The language has to come from the markdown because sanitization strips
154 + /// `class` from `code`, so by the time the HTML exists every block looks alike.
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 + /// Rewrite mermaid code fences into the block mermaid.js initializes.
168 + ///
169 + /// The body is copied across untouched: the renderer has already escaped it,
170 + /// and that is the escaping mermaid.js expects to read back out of the DOM.
171 + ///
172 + /// Blocks are matched to fences by position, so a mismatch between the two
173 + /// counts is an error rather than a guess: it would mean the HTML holds a
174 + /// `<pre><code>` the markdown parse did not account for, and every block after
175 + /// it would be mislabelled.
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--&gt;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--&gt;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 + }