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 raw: crate::Spec,
125 spec: std::sync::OnceLock<Spec>,
132 pub(crate) header_level: usize,
133 pub(crate) multi: bool,
134 url_prefix: Option<String>,
135 html_encode: bool,
136 indented_blocks_to_code_fences: bool,
137 theme: MarkdownTheme,
138 templates: Vec<(MarkdownTemplate, String)>,
139}
140
141impl MarkdownRenderer {
142 pub fn new(spec: crate::Spec) -> Self {
143 Self {
144 raw: spec,
145 spec: std::sync::OnceLock::new(),
146 header_level: 1,
147 multi: false,
148 url_prefix: None,
149 html_encode: true,
150 indented_blocks_to_code_fences: false,
151 theme: MarkdownTheme::default(),
152 templates: Vec::new(),
153 }
154 }
155
156 pub(crate) fn spec(&self) -> &Spec {
158 self.spec.get_or_init(|| {
159 let mut spec = Spec::from(&self.raw);
162 spec.render_md(self);
163 spec
164 })
165 }
166
167 fn with(mut self, set: impl FnOnce(&mut Self)) -> Self {
172 set(&mut self);
173 self.spec = std::sync::OnceLock::new();
174 self
175 }
176
177 pub fn config_page(&self) -> &'static str {
190 "configuration.md"
191 }
192
193 pub fn config_page_collision(&self) -> Option<&str> {
198 let stem = self.config_page().trim_end_matches(".md");
199 self.spec()
200 .cmd
201 .subcommands
202 .values()
203 .find(|cmd| !cmd.hide && cmd.full_cmd == [stem])
204 .map(|cmd| cmd.name.as_str())
205 }
206
207 pub fn with_header_level(self, header_level: usize) -> Self {
208 self.with(|r| r.header_level = header_level)
209 }
210
211 pub fn with_multi(self, index: bool) -> Self {
212 self.with(|r| r.multi = index)
213 }
214
215 pub fn with_url_prefix<S: Into<String>>(self, url_prefix: S) -> Self {
216 self.with(|r| r.url_prefix = Some(url_prefix.into()))
217 }
218
219 pub fn with_html_encode(self, html_encode: bool) -> Self {
220 self.with(|r| r.html_encode = html_encode)
221 }
222
223 pub fn with_indented_blocks_to_code_fences(self, indented_blocks_to_code_fences: bool) -> Self {
225 self.with(|r| r.indented_blocks_to_code_fences = indented_blocks_to_code_fences)
226 }
227
228 pub fn with_replace_pre_with_code_fences(self, indented_blocks_to_code_fences: bool) -> Self {
235 self.with_indented_blocks_to_code_fences(indented_blocks_to_code_fences)
236 }
237
238 pub fn with_theme(self, theme: MarkdownTheme) -> Self {
240 self.with(|r| r.theme = theme)
241 }
242
243 pub fn with_template(self, template: MarkdownTemplate, source: impl Into<String>) -> Self {
250 let source = source.into();
251 self.with(|r| {
252 if let Some((_, current)) = r
253 .templates
254 .iter_mut()
255 .find(|(current, _)| *current == template)
256 {
257 *current = source;
258 } else {
259 r.templates.push((template, source));
260 }
261 })
262 }
263
264 fn tera_ctx(&self) -> tera::Context {
265 let mut ctx = tera::Context::new();
266 ctx.insert("spec", self.spec());
267 ctx.insert("header_level", &self.header_level);
268 ctx.insert("multi", &self.multi);
269 ctx.insert("url_prefix", &self.url_prefix);
270 ctx.insert("html_encode", &self.html_encode);
271 ctx
272 }
273
274 pub(crate) fn render_with(
280 &self,
281 template_name: &str,
282 enrich: impl FnOnce(&mut tera::Context),
283 ) -> Result<String, UsageErr> {
284 let mut tera = match self.theme {
285 MarkdownTheme::Compact => TERA.clone(),
286 MarkdownTheme::Detailed => crate::docs::markdown::tera::DETAILED_TERA.clone(),
287 };
288
289 for (template, source) in &self.templates {
290 tera.add_raw_template(template.name(), source)?;
291 }
292
293 let html_encode = self.html_encode;
294 tera.register_filter(
295 "escape_md",
296 move |value: &tera::Value,
297 _: tera::Kwargs,
298 _: &tera::State|
299 -> tera::TeraResult<String> {
300 let value = value.as_str().unwrap();
301 let value = escape_md(value, html_encode);
302 Ok(value)
303 },
304 );
305 tera.register_filter(
306 "escape_md_indented",
307 move |value: &tera::Value,
308 _: tera::Kwargs,
309 _: &tera::State|
310 -> tera::TeraResult<String> {
311 let value = value.as_str().unwrap();
312 Ok(escape_md_with_indent(value, html_encode, true))
313 },
314 );
315
316 let mut ctx = self.tera_ctx();
317 enrich(&mut ctx);
318 Ok(tera.render(template_name, &ctx)?)
319 }
320
321 pub(crate) fn fence_indented_blocks(&self, md: String) -> String {
323 if !self.indented_blocks_to_code_fences {
324 return md;
325 }
326 let mut in_code_block = false;
328 let mut new_md = String::new();
329 for line in md.lines() {
330 if let Some(line) = line.strip_prefix(" ") {
331 if in_code_block {
332 new_md.push_str(&format!("{line}\n"));
333 } else {
334 new_md.push_str(&format!("```\n{line}\n"));
335 in_code_block = true;
336 }
337 } else {
338 if in_code_block {
339 new_md.push_str("```\n");
340 in_code_block = false;
341 }
342 new_md.push_str(&format!("{line}\n"));
343 }
344 }
345 if in_code_block {
346 new_md.push_str("```\n");
347 }
348 new_md.replace("```\n\n```\n", "\n")
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::{escape_md, MarkdownRenderer, MarkdownTemplate};
355 use pretty_assertions::assert_eq;
356
357 #[test]
358 fn escapes_html_around_fenced_code_blocks() {
359 let input = "before <\n```\ninside <\n``` \nafter <";
360 let expected = "before <\n```\ninside <\n``` \nafter <";
361
362 assert_eq!(escape_md(input, true), expected);
363 }
364
365 #[test]
366 fn supports_fence_info_strings() {
367 let input = "```bash\necho <value>\n```\nafter <";
368 let expected = "```bash\necho <value>\n```\nafter <";
369
370 assert_eq!(escape_md(input, true), expected);
371 }
372
373 #[test]
374 fn leaves_unclosed_fences_unescaped() {
375 let input = "```\necho <value>";
376
377 assert_eq!(escape_md(input, true), input);
378 }
379
380 #[test]
381 fn ignores_indented_and_longer_fences() {
382 let input = " ```\nindented <\n````\nlonger <";
383 let expected = " ```\nindented <\n````\nlonger <";
384
385 assert_eq!(escape_md(input, true), expected);
386 }
387
388 #[test]
389 fn leaves_markdown_unchanged_when_html_encoding_is_disabled() {
390 let input = "before <\n```\ninside <\n```\nafter <";
391
392 assert_eq!(escape_md(input, false), input);
393 }
394
395 #[test]
396 fn strips_terminal_styling_from_generated_markdown() {
397 let input =
398 "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n \u{1b}[1mmise run\u{1b}[22m";
399 let expected = "Examples:\n\n mise run";
400
401 assert_eq!(escape_md(input, true), expected);
402 assert_eq!(escape_md(input, false), expected);
403 }
404
405 #[test]
406 fn one_template_can_be_replaced_without_copying_its_includes() {
407 let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
408 .parse()
409 .unwrap();
410 let page = MarkdownRenderer::new(spec)
411 .with_template(
412 MarkdownTemplate::Spec,
413 "# Custom {{ spec.bin }}\n{% set cmd = spec.cmd %}\n{% include \"cmd_template.md.tera\" %}",
414 )
415 .render_spec()
416 .unwrap();
417
418 assert!(page.starts_with("# Custom ex\n"), "{page}");
419 assert!(page.contains("- **`--force`**"), "{page}");
420 }
421
422 #[test]
423 fn the_last_replacement_of_a_template_wins() {
424 let spec = "bin \"ex\"\n".parse().unwrap();
425 let page = MarkdownRenderer::new(spec)
426 .with_template(MarkdownTemplate::Spec, "{{")
427 .with_template(MarkdownTemplate::Spec, "second")
428 .render_spec()
429 .unwrap();
430
431 assert_eq!(page, "second");
432 }
433
434 #[test]
435 fn a_bad_custom_template_is_a_render_error() {
436 let spec = "bin \"ex\"\n".parse().unwrap();
437 let err = MarkdownRenderer::new(spec)
438 .with_template(MarkdownTemplate::Spec, "{{")
439 .render_spec()
440 .unwrap_err();
441
442 assert!(err.to_string().contains("template"), "{err}");
443 }
444
445 #[test]
446 fn the_detailed_theme_keeps_addressable_entry_headings() {
447 let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
448 .parse()
449 .unwrap();
450 let page = MarkdownRenderer::new(spec)
451 .with_theme(super::MarkdownTheme::Detailed)
452 .render_spec()
453 .unwrap();
454
455 assert!(page.contains("### `--force`"), "{page}");
456 }
457
458 #[test]
459 fn entry_template_overrides_apply_to_the_compact_theme() {
460 let spec = "bin \"ex\"\narg \"<file>\"\nflag \"--force\"\n"
461 .parse()
462 .unwrap();
463 let page = MarkdownRenderer::new(spec)
464 .with_template(MarkdownTemplate::Argument, "argument: {{ arg.usage }}")
465 .with_template(MarkdownTemplate::Flag, "flag: {{ flag.usage }}")
466 .render_spec()
467 .unwrap();
468
469 assert!(page.contains("argument: <file>"), "{page}");
470 assert!(page.contains("flag: --force"), "{page}");
471 }
472}