|
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-->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 |
+ |
}
|