Skip to main content

max / docengine

9.6 KB · 285 lines History Blame Raw
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 }
285