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