1use crate::docs::markdown::tera::TERA;
2use crate::docs::models::Spec;
3use crate::error::UsageErr;
4use itertools::Itertools;
5use regex::Regex;
6use std::sync::LazyLock;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum MarkdownTemplate {
16 Spec,
18 Index,
20 Command,
22 Argument,
24 Flag,
26 Config,
28}
29
30#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
32pub enum MarkdownTheme {
33 #[default]
35 Compact,
36 Detailed,
38}
39
40impl MarkdownTemplate {
41 fn name(self) -> &'static str {
42 match self {
43 Self::Spec => "spec_template.md.tera",
44 Self::Index => "index_template.md.tera",
45 Self::Command => "cmd_template.md.tera",
46 Self::Argument => "arg_template.md.tera",
47 Self::Flag => "flag_template.md.tera",
48 Self::Config => "config_template.md.tera",
49 }
50 }
51}
52
53static CODE_SPAN_OR_LT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(`[^`]*`)|(<)").unwrap());
55
56fn escape_md_with_indent(value: &str, html_encode: bool, indent: bool) -> String {
57 let mut in_fenced_code_block = false;
58 let value = crate::docs::strip_ansi(value);
63
64 value
65 .lines()
66 .enumerate()
67 .map(|(index, line)| {
68 let line = if !html_encode {
69 line.to_string()
70 } else {
71 if line.starts_with(" ") {
74 line.to_string()
75 } else if in_fenced_code_block {
76 if line.trim_end() == "```" {
77 in_fenced_code_block = false;
78 }
79 line.to_string()
80 } else if line
83 .strip_prefix("```")
84 .is_some_and(|suffix| !suffix.starts_with('`'))
85 {
86 in_fenced_code_block = true;
87 line.to_string()
88 } else {
89 CODE_SPAN_OR_LT
91 .replace_all(line, |caps: ®ex::Captures| {
92 if caps.get(1).is_some() {
93 caps.get(1).unwrap().as_str().to_string()
94 } else {
95 "<".to_string()
96 }
97 })
98 .to_string()
99 }
100 };
101 if indent && index > 0 && !line.is_empty() {
102 format!(" {line}")
103 } else {
104 line
105 }
106 })
107 .join("\n")
108}
109
110fn escape_md(value: &str, html_encode: bool) -> String {
111 escape_md_with_indent(value, html_encode, false)
112}
113
114#[derive(Debug, Clone)]
115pub struct MarkdownRenderer {
116 pub(crate) spec: Spec,
117 pub(crate) raw_config: crate::spec::config::SpecConfig,
126 pub(crate) header_level: usize,
127 pub(crate) multi: bool,
128 url_prefix: Option<String>,
129 html_encode: bool,
130 replace_pre_with_code_fences: bool,
131 theme: MarkdownTheme,
132 templates: Vec<(MarkdownTemplate, String)>,
133}
134
135impl MarkdownRenderer {
136 pub fn new(spec: crate::Spec) -> Self {
137 let mut renderer = Self {
138 raw_config: spec.config.clone(),
139 spec: spec.into(),
140 header_level: 1,
141 multi: false,
142 url_prefix: None,
143 html_encode: true,
144 replace_pre_with_code_fences: false,
145 theme: MarkdownTheme::default(),
146 templates: Vec::new(),
147 };
148 let mut spec = renderer.spec.clone();
149 spec.render_md(&renderer);
150 renderer.spec = spec;
151 renderer
152 }
153
154 pub fn config_page(&self) -> &'static str {
167 "configuration.md"
168 }
169
170 pub fn config_page_collision(&self) -> Option<&str> {
175 let stem = self.config_page().trim_end_matches(".md");
176 self.spec
177 .cmd
178 .subcommands
179 .values()
180 .find(|cmd| !cmd.hide && cmd.full_cmd == [stem])
181 .map(|cmd| cmd.name.as_str())
182 }
183
184 pub fn with_header_level(mut self, header_level: usize) -> Self {
185 self.header_level = header_level;
186 self
187 }
188
189 pub fn with_multi(mut self, index: bool) -> Self {
190 self.multi = index;
191 self
192 }
193
194 pub fn with_url_prefix<S: Into<String>>(mut self, url_prefix: S) -> Self {
195 self.url_prefix = Some(url_prefix.into());
196 self
197 }
198
199 pub fn with_html_encode(mut self, html_encode: bool) -> Self {
200 self.html_encode = html_encode;
201 self
202 }
203
204 pub fn with_replace_pre_with_code_fences(mut self, replace_pre_with_code_fences: bool) -> Self {
205 self.replace_pre_with_code_fences = replace_pre_with_code_fences;
206 self
207 }
208
209 pub fn with_theme(mut self, theme: MarkdownTheme) -> Self {
211 self.theme = theme;
212 self
213 }
214
215 pub fn with_template(mut self, template: MarkdownTemplate, source: impl Into<String>) -> Self {
222 let source = source.into();
223 if let Some((_, current)) = self
224 .templates
225 .iter_mut()
226 .find(|(current, _)| *current == template)
227 {
228 *current = source;
229 } else {
230 self.templates.push((template, source));
231 }
232 self
233 }
234
235 fn tera_ctx(&self) -> tera::Context {
236 let mut ctx = tera::Context::new();
237 ctx.insert("spec", &self.spec);
238 ctx.insert("header_level", &self.header_level);
239 ctx.insert("multi", &self.multi);
240 ctx.insert("url_prefix", &self.url_prefix);
241 ctx.insert("html_encode", &self.html_encode);
242 ctx
243 }
244
245 pub(crate) fn render_with(
251 &self,
252 template_name: &str,
253 enrich: impl FnOnce(&mut tera::Context),
254 ) -> Result<String, UsageErr> {
255 let mut tera = match self.theme {
256 MarkdownTheme::Compact => TERA.clone(),
257 MarkdownTheme::Detailed => crate::docs::markdown::tera::DETAILED_TERA.clone(),
258 };
259
260 for (template, source) in &self.templates {
261 tera.add_raw_template(template.name(), source)?;
262 }
263
264 let html_encode = self.html_encode;
265 tera.register_filter(
266 "escape_md",
267 move |value: &tera::Value,
268 _: tera::Kwargs,
269 _: &tera::State|
270 -> tera::TeraResult<String> {
271 let value = value.as_str().unwrap();
272 let value = escape_md(value, html_encode);
273 Ok(value)
274 },
275 );
276 tera.register_filter(
277 "escape_md_indented",
278 move |value: &tera::Value,
279 _: tera::Kwargs,
280 _: &tera::State|
281 -> tera::TeraResult<String> {
282 let value = value.as_str().unwrap();
283 Ok(escape_md_with_indent(value, html_encode, true))
284 },
285 );
286
287 let mut ctx = self.tera_ctx();
288 enrich(&mut ctx);
289 Ok(tera.render(template_name, &ctx)?)
290 }
291
292 pub(crate) fn replace_code_fences(&self, md: String) -> String {
293 if !self.replace_pre_with_code_fences {
294 return md;
295 }
296 let mut in_code_block = false;
298 let mut new_md = String::new();
299 for line in md.lines() {
300 if let Some(line) = line.strip_prefix(" ") {
301 if in_code_block {
302 new_md.push_str(&format!("{line}\n"));
303 } else {
304 new_md.push_str(&format!("```\n{line}\n"));
305 in_code_block = true;
306 }
307 } else {
308 if in_code_block {
309 new_md.push_str("```\n");
310 in_code_block = false;
311 }
312 new_md.push_str(&format!("{line}\n"));
313 }
314 }
315 if in_code_block {
316 new_md.push_str("```\n");
317 }
318 new_md.replace("```\n\n```\n", "\n")
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::{escape_md, MarkdownRenderer, MarkdownTemplate};
325 use pretty_assertions::assert_eq;
326
327 #[test]
328 fn escapes_html_around_fenced_code_blocks() {
329 let input = "before <\n```\ninside <\n``` \nafter <";
330 let expected = "before <\n```\ninside <\n``` \nafter <";
331
332 assert_eq!(escape_md(input, true), expected);
333 }
334
335 #[test]
336 fn supports_fence_info_strings() {
337 let input = "```bash\necho <value>\n```\nafter <";
338 let expected = "```bash\necho <value>\n```\nafter <";
339
340 assert_eq!(escape_md(input, true), expected);
341 }
342
343 #[test]
344 fn leaves_unclosed_fences_unescaped() {
345 let input = "```\necho <value>";
346
347 assert_eq!(escape_md(input, true), input);
348 }
349
350 #[test]
351 fn ignores_indented_and_longer_fences() {
352 let input = " ```\nindented <\n````\nlonger <";
353 let expected = " ```\nindented <\n````\nlonger <";
354
355 assert_eq!(escape_md(input, true), expected);
356 }
357
358 #[test]
359 fn leaves_markdown_unchanged_when_html_encoding_is_disabled() {
360 let input = "before <\n```\ninside <\n```\nafter <";
361
362 assert_eq!(escape_md(input, false), input);
363 }
364
365 #[test]
366 fn strips_terminal_styling_from_generated_markdown() {
367 let input =
368 "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n \u{1b}[1mmise run\u{1b}[22m";
369 let expected = "Examples:\n\n mise run";
370
371 assert_eq!(escape_md(input, true), expected);
372 assert_eq!(escape_md(input, false), expected);
373 }
374
375 #[test]
376 fn one_template_can_be_replaced_without_copying_its_includes() {
377 let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
378 .parse()
379 .unwrap();
380 let page = MarkdownRenderer::new(spec)
381 .with_template(
382 MarkdownTemplate::Spec,
383 "# Custom {{ spec.bin }}\n{% set cmd = spec.cmd %}\n{% include \"cmd_template.md.tera\" %}",
384 )
385 .render_spec()
386 .unwrap();
387
388 assert!(page.starts_with("# Custom ex\n"), "{page}");
389 assert!(page.contains("- **`--force`**"), "{page}");
390 }
391
392 #[test]
393 fn the_last_replacement_of_a_template_wins() {
394 let spec = "bin \"ex\"\n".parse().unwrap();
395 let page = MarkdownRenderer::new(spec)
396 .with_template(MarkdownTemplate::Spec, "{{")
397 .with_template(MarkdownTemplate::Spec, "second")
398 .render_spec()
399 .unwrap();
400
401 assert_eq!(page, "second");
402 }
403
404 #[test]
405 fn a_bad_custom_template_is_a_render_error() {
406 let spec = "bin \"ex\"\n".parse().unwrap();
407 let err = MarkdownRenderer::new(spec)
408 .with_template(MarkdownTemplate::Spec, "{{")
409 .render_spec()
410 .unwrap_err();
411
412 assert!(err.to_string().contains("template"), "{err}");
413 }
414
415 #[test]
416 fn the_detailed_theme_keeps_addressable_entry_headings() {
417 let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
418 .parse()
419 .unwrap();
420 let page = MarkdownRenderer::new(spec)
421 .with_theme(super::MarkdownTheme::Detailed)
422 .render_spec()
423 .unwrap();
424
425 assert!(page.contains("### `--force`"), "{page}");
426 }
427
428 #[test]
429 fn entry_template_overrides_apply_to_the_compact_theme() {
430 let spec = "bin \"ex\"\narg \"<file>\"\nflag \"--force\"\n"
431 .parse()
432 .unwrap();
433 let page = MarkdownRenderer::new(spec)
434 .with_template(MarkdownTemplate::Argument, "argument: {{ arg.usage }}")
435 .with_template(MarkdownTemplate::Flag, "flag: {{ flag.usage }}")
436 .render_spec()
437 .unwrap();
438
439 assert!(page.contains("argument: <file>"), "{page}");
440 assert!(page.contains("flag: --force"), "{page}");
441 }
442}