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 fence: Option<(char, usize)> = None;
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(" ") {
73 line.to_string()
74 } else if let Some((marker, length)) = fence {
75 let trimmed = line.trim();
76 if trimmed.len() >= length && trimmed.chars().all(|c| c == marker) {
77 fence = None;
78 }
79 line.to_string()
80 } else if line.trim_start().starts_with("```")
82 || line.trim_start().starts_with("~~~")
83 {
84 let trimmed = line.trim_start();
85 let marker = trimmed.chars().next().unwrap();
86 fence = Some((marker, trimmed.chars().take_while(|c| *c == marker).count()));
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 link_extension: String,
136 html_encode: bool,
137 indented_blocks_to_code_fences: bool,
138 theme: MarkdownTheme,
139 templates: Vec<(MarkdownTemplate, String)>,
140}
141
142impl MarkdownRenderer {
143 pub fn new(spec: crate::Spec) -> Self {
144 Self {
145 raw: spec,
146 spec: std::sync::OnceLock::new(),
147 header_level: 1,
148 multi: false,
149 url_prefix: None,
150 link_extension: ".md".into(),
151 html_encode: true,
152 indented_blocks_to_code_fences: false,
153 theme: MarkdownTheme::default(),
154 templates: Vec::new(),
155 }
156 }
157
158 pub(crate) fn spec(&self) -> &Spec {
160 self.spec.get_or_init(|| {
161 let mut spec = Spec::from(&self.raw);
164 spec.render_md(self);
165 spec
166 })
167 }
168
169 fn with(mut self, set: impl FnOnce(&mut Self)) -> Self {
174 set(&mut self);
175 self.spec = std::sync::OnceLock::new();
176 self
177 }
178
179 pub fn config_page(&self) -> &'static str {
192 "configuration.md"
193 }
194
195 pub fn config_page_collision(&self) -> Option<&str> {
200 let stem = self.config_page().trim_end_matches(".md");
201 self.spec()
202 .cmd
203 .subcommands
204 .values()
205 .find(|cmd| !cmd.hide && cmd.full_cmd == [stem])
206 .map(|cmd| cmd.name.as_str())
207 }
208
209 pub fn with_header_level(self, header_level: usize) -> Self {
210 self.with(|r| r.header_level = header_level)
211 }
212
213 pub fn with_multi(self, index: bool) -> Self {
214 self.with(|r| r.multi = index)
215 }
216
217 pub fn with_url_prefix<S: Into<String>>(self, url_prefix: S) -> Self {
218 self.with(|r| r.url_prefix = Some(url_prefix.into()))
219 }
220
221 pub fn with_html_encode(self, html_encode: bool) -> Self {
222 self.with(|r| r.html_encode = html_encode)
223 }
224
225 pub fn with_link_extension(self, extension: impl Into<String>) -> Self {
229 self.with(|r| r.link_extension = extension.into())
230 }
231
232 pub fn with_indented_blocks_to_code_fences(self, indented_blocks_to_code_fences: bool) -> Self {
234 self.with(|r| r.indented_blocks_to_code_fences = indented_blocks_to_code_fences)
235 }
236
237 pub fn with_replace_pre_with_code_fences(self, indented_blocks_to_code_fences: bool) -> Self {
244 self.with_indented_blocks_to_code_fences(indented_blocks_to_code_fences)
245 }
246
247 pub fn with_theme(self, theme: MarkdownTheme) -> Self {
249 self.with(|r| r.theme = theme)
250 }
251
252 pub fn with_template(self, template: MarkdownTemplate, source: impl Into<String>) -> Self {
259 let source = source.into();
260 self.with(|r| {
261 if let Some((_, current)) = r
262 .templates
263 .iter_mut()
264 .find(|(current, _)| *current == template)
265 {
266 *current = source;
267 } else {
268 r.templates.push((template, source));
269 }
270 })
271 }
272
273 fn tera_ctx(&self) -> tera::Context {
274 let mut ctx = tera::Context::new();
275 ctx.insert("spec", self.spec());
276 ctx.insert("header_level", &self.header_level);
277 ctx.insert("multi", &self.multi);
278 ctx.insert("url_prefix", &self.url_prefix);
279 ctx.insert("link_extension", &self.link_extension);
280 ctx.insert(
281 "config_link",
282 &format!("configuration{}", self.link_extension),
283 );
284 ctx.insert("html_encode", &self.html_encode);
285 ctx
286 }
287
288 pub(crate) fn render_with(
294 &self,
295 template_name: &str,
296 enrich: impl FnOnce(&mut tera::Context),
297 ) -> Result<String, UsageErr> {
298 let mut tera = match self.theme {
299 MarkdownTheme::Compact => TERA.clone(),
300 MarkdownTheme::Detailed => crate::docs::markdown::tera::DETAILED_TERA.clone(),
301 };
302
303 for (template, source) in &self.templates {
304 tera.add_raw_template(template.name(), source)?;
305 }
306
307 let html_encode = self.html_encode;
308 tera.register_filter(
309 "escape_md",
310 move |value: &tera::Value,
311 _: tera::Kwargs,
312 _: &tera::State|
313 -> tera::TeraResult<String> {
314 let value = value.as_str().unwrap();
315 let value = escape_md(value, html_encode);
316 Ok(value)
317 },
318 );
319 tera.register_filter(
320 "escape_md_indented",
321 move |value: &tera::Value,
322 _: tera::Kwargs,
323 _: &tera::State|
324 -> tera::TeraResult<String> {
325 let value = value.as_str().unwrap();
326 Ok(escape_md_with_indent(value, html_encode, true))
327 },
328 );
329
330 let mut ctx = self.tera_ctx();
331 enrich(&mut ctx);
332 Ok(tera.render(template_name, &ctx)?)
333 }
334
335 pub(crate) fn fence_indented_blocks(&self, md: String) -> String {
337 if !self.indented_blocks_to_code_fences {
338 return md;
339 }
340 use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
341 let mut edits = Vec::new();
342 let mut block = None;
343 for (event, range) in Parser::new(&md).into_offset_iter() {
344 match event {
345 Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
346 block = Some((range, String::new()));
347 }
348 Event::Text(text) => {
349 if let Some((_, content)) = &mut block {
350 content.push_str(&text);
351 }
352 }
353 Event::End(TagEnd::CodeBlock) => {
354 if let Some((range, content)) = block.take() {
355 let start = md[..range.start].rfind('\n').map_or(0, |i| i + 1);
356 if md[start..range.start].chars().any(|c| !c.is_whitespace()) {
360 continue;
361 }
362 let source_line = md[start..].lines().next().unwrap_or_default();
363 let source_indent =
364 source_line.len() - source_line.trim_start_matches(' ').len();
365 let content_line = content
366 .lines()
367 .find(|line| !line.trim().is_empty())
368 .unwrap_or_default();
369 let content_indent =
370 content_line.len() - content_line.trim_start_matches(' ').len();
371 let prefix = " ".repeat(source_indent.saturating_sub(4 + content_indent));
372 let fence = "`".repeat(
373 content
374 .split(|c| c != '`')
375 .map(str::len)
376 .max()
377 .unwrap_or(0)
378 .max(2)
379 + 1,
380 );
381 let mut replacement = format!("{prefix}{fence}\n");
382 for line in content.lines() {
383 replacement.push_str(&format!("{prefix}{line}\n"));
384 }
385 replacement.push_str(&format!("{prefix}{fence}\n"));
386 edits.push((start..range.end, replacement));
387 }
388 }
389 _ => {}
390 }
391 }
392 let mut result = md;
393 for (range, replacement) in edits.into_iter().rev() {
394 result.replace_range(range, &replacement);
395 }
396 result
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::{escape_md, MarkdownRenderer, MarkdownTemplate};
403 use pretty_assertions::assert_eq;
404
405 #[test]
406 fn conversion_preserves_markdown_structure_and_code_contents() {
407 use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag};
408 let renderer = MarkdownRenderer::new("bin ex".parse().unwrap())
409 .with_indented_blocks_to_code_fences(true);
410 for source in [
411 "- outer\n - inner\n\n first\n\n indented\n\n prose\n",
412 "```json\n{\n \"key\": 1\n}\n```\n",
413 " [tools]\n node = \"20\"\n\n ```literal```\n",
414 " first\n\n second\n",
415 "> quoted code\n>\n> indented\n",
416 "- same-line list code\n",
417 "Examples:\n paragraph continuation\n\n actual code\n",
418 ] {
419 let normalized = |text: &str| {
420 pulldown_cmark::TextMergeStream::new(Parser::new(text))
421 .map(|event| match event {
422 Event::Start(Tag::CodeBlock(_)) => {
423 Event::Start(Tag::CodeBlock(CodeBlockKind::Indented))
424 }
425 event => event,
426 })
427 .map(Event::into_static)
428 .collect::<Vec<_>>()
429 };
430 let output = renderer.fence_indented_blocks(source.into());
431 assert_eq!(normalized(source), normalized(&output), "{output}");
432 assert_eq!(renderer.fence_indented_blocks(output.clone()), output);
433 }
434 }
435
436 #[test]
437 fn legacy_examples_with_markdown_separation_form_one_code_block() {
438 let renderer = MarkdownRenderer::new("bin ex".parse().unwrap())
439 .with_indented_blocks_to_code_fences(true);
440 let source = "Examples:\n\n # first\n ex first\n\n # second\n ex second\n";
441 assert_eq!(
442 renderer.fence_indented_blocks(source.into()),
443 "Examples:\n\n```\n# first\nex first\n\n# second\nex second\n```\n"
444 );
445 let spec =
446 crate::Spec::parse_file(std::path::Path::new("../benches/mise.usage.kdl")).unwrap();
447 let page = MarkdownRenderer::new(spec.clone())
448 .with_indented_blocks_to_code_fences(true)
449 .render_cmd(&spec.cmd.subcommands["settings"])
450 .unwrap();
451 assert!(
452 page.contains("Examples:\n\n```\n# list all settings"),
453 "{page}"
454 );
455 }
456
457 #[test]
458 fn visible_children_get_a_heading_and_index_has_one_synopsis() {
459 let spec: crate::Spec = "bin ex\ncmd a hide=#true\ncmd b\n".parse().unwrap();
460 for theme in [
461 super::MarkdownTheme::Compact,
462 super::MarkdownTheme::Detailed,
463 ] {
464 let renderer = MarkdownRenderer::new(spec.clone())
465 .with_theme(theme)
466 .with_multi(true);
467 assert!(renderer
468 .render_cmd(&spec.cmd)
469 .unwrap()
470 .contains("## Subcommands"));
471 assert_eq!(
472 renderer
473 .render_index()
474 .unwrap()
475 .matches("**Usage:**")
476 .count(),
477 1
478 );
479 let hidden: crate::Spec = "bin ex\ncmd a hide=#true\n".parse().unwrap();
480 assert!(!MarkdownRenderer::new(hidden.clone())
481 .with_theme(theme)
482 .with_multi(true)
483 .render_cmd(&hidden.cmd)
484 .unwrap()
485 .contains("## Subcommands"));
486 }
487 }
488
489 #[test]
490 fn page_links_follow_extension_without_renaming_files() {
491 let spec: crate::Spec = "bin ex\ncmd go\nconfig {\n prop jobs type=\"uint\"\n}\n"
492 .parse()
493 .unwrap();
494 for extension in [".md", ".html", ""] {
495 let renderer = MarkdownRenderer::new(spec.clone())
496 .with_url_prefix("/cli")
497 .with_link_extension(extension);
498 let index = renderer.render_index().unwrap();
499 assert!(index.contains(&format!("(/cli/go{extension})")), "{index}");
500 assert!(
501 index.contains(&format!("(/cli/configuration{extension})")),
502 "{index}"
503 );
504 assert_eq!(renderer.config_page(), "configuration.md");
505 let custom = renderer.with_template(MarkdownTemplate::Index, "{{ link_extension }}");
506 assert_eq!(custom.render_index().unwrap(), extension);
507 }
508 }
509
510 #[test]
511 fn escapes_html_around_fenced_code_blocks() {
512 let input = "before <\n```\ninside <\n``` \nafter <";
513 let expected = "before <\n```\ninside <\n``` \nafter <";
514
515 assert_eq!(escape_md(input, true), expected);
516 }
517
518 #[test]
519 fn supports_fence_info_strings() {
520 let input = "```bash\necho <value>\n```\nafter <";
521 let expected = "```bash\necho <value>\n```\nafter <";
522
523 assert_eq!(escape_md(input, true), expected);
524 }
525
526 #[test]
527 fn leaves_unclosed_fences_unescaped() {
528 let input = "```\necho <value>";
529
530 assert_eq!(escape_md(input, true), input);
531 }
532
533 #[test]
534 fn handles_longer_fences_and_indented_code() {
535 let input = " ```\nindented <\n````\nlonger <";
536 let expected = " ```\nindented <\n````\nlonger <";
537
538 assert_eq!(escape_md(input, true), expected);
539 }
540
541 #[test]
542 fn leaves_markdown_unchanged_when_html_encoding_is_disabled() {
543 let input = "before <\n```\ninside <\n```\nafter <";
544
545 assert_eq!(escape_md(input, false), input);
546 }
547
548 #[test]
549 fn strips_terminal_styling_from_generated_markdown() {
550 let input =
551 "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n \u{1b}[1mmise run\u{1b}[22m";
552 let expected = "Examples:\n\n mise run";
553
554 assert_eq!(escape_md(input, true), expected);
555 assert_eq!(escape_md(input, false), expected);
556 }
557
558 #[test]
559 fn one_template_can_be_replaced_without_copying_its_includes() {
560 let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
561 .parse()
562 .unwrap();
563 let page = MarkdownRenderer::new(spec)
564 .with_template(
565 MarkdownTemplate::Spec,
566 "# Custom {{ spec.bin }}\n{% set cmd = spec.cmd %}\n{% include \"cmd_template.md.tera\" %}",
567 )
568 .render_spec()
569 .unwrap();
570
571 assert!(page.starts_with("# Custom ex\n"), "{page}");
572 assert!(page.contains("- **`--force`**"), "{page}");
573 }
574
575 #[test]
576 fn the_last_replacement_of_a_template_wins() {
577 let spec = "bin \"ex\"\n".parse().unwrap();
578 let page = MarkdownRenderer::new(spec)
579 .with_template(MarkdownTemplate::Spec, "{{")
580 .with_template(MarkdownTemplate::Spec, "second")
581 .render_spec()
582 .unwrap();
583
584 assert_eq!(page, "second");
585 }
586
587 #[test]
588 fn a_bad_custom_template_is_a_render_error() {
589 let spec = "bin \"ex\"\n".parse().unwrap();
590 let err = MarkdownRenderer::new(spec)
591 .with_template(MarkdownTemplate::Spec, "{{")
592 .render_spec()
593 .unwrap_err();
594
595 assert!(err.to_string().contains("template"), "{err}");
596 }
597
598 #[test]
599 fn the_detailed_theme_keeps_addressable_entry_headings() {
600 let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
601 .parse()
602 .unwrap();
603 let page = MarkdownRenderer::new(spec)
604 .with_theme(super::MarkdownTheme::Detailed)
605 .render_spec()
606 .unwrap();
607
608 assert!(page.contains("### `--force`"), "{page}");
609 }
610
611 #[test]
612 fn entry_template_overrides_apply_to_the_compact_theme() {
613 let spec = "bin \"ex\"\narg \"<file>\"\nflag \"--force\"\n"
614 .parse()
615 .unwrap();
616 let page = MarkdownRenderer::new(spec)
617 .with_template(MarkdownTemplate::Argument, "argument: {{ arg.usage }}")
618 .with_template(MarkdownTemplate::Flag, "flag: {{ flag.usage }}")
619 .render_spec()
620 .unwrap();
621
622 assert!(page.contains("argument: <file>"), "{page}");
623 assert!(page.contains("flag: --force"), "{page}");
624 }
625}