Skip to main content

usage/docs/markdown/
renderer.rs

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/// One of the templates used to generate Markdown documentation.
9///
10/// A renderer starts with a complete built-in template set. Replacing one member keeps the
11/// others available, including through Tera's `{% include %}` directive. This lets an adopter
12/// change the document shell or the presentation of one kind of item without copying the whole
13/// theme.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum MarkdownTemplate {
16    /// A single-file document containing the root and every subcommand.
17    Spec,
18    /// The landing page generated in multi-file mode.
19    Index,
20    /// A command and its arguments, flags, outputs, exits, and examples.
21    Command,
22    /// The details beneath one positional argument.
23    Argument,
24    /// The details beneath one flag.
25    Flag,
26    /// The configuration reference appended to a spec or written in multi-file mode.
27    Config,
28}
29
30/// The built-in presentation used for generated Markdown.
31#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
32pub enum MarkdownTheme {
33    /// Dense grouped lists intended for scanning a large command reference.
34    #[default]
35    Compact,
36    /// Give every argument and flag its own addressable heading and detail block.
37    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
53/// A backtick span, or a bare `<` outside one.
54static 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    // Help text is allowed to contain terminal styling. clap-era applications commonly build
59    // their examples with `color_print::cstr!`, which embeds SGR sequences even when color is
60    // disabled at runtime. Terminal styling has no meaning in generated Markdown, and leaving
61    // it here publishes literal escape bytes in docs and downstream static sites.
62    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                // Preserve indented code, including code nested within list items.
72                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                // Converted blocks may require longer fences to contain literal backticks.
81                } 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                    // replace '<' with '&lt;' but not inside code blocks
90                    CODE_SPAN_OR_LT
91                        .replace_all(line, |caps: &regex::Captures| {
92                            if caps.get(1).is_some() {
93                                caps.get(1).unwrap().as_str().to_string()
94                            } else {
95                                "&lt;".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    /// The spec as its author wrote it, before any rendering.
117    ///
118    /// Kept because rendering depends on the builder options, and the builders run *after*
119    /// [`Self::new`]. Rendering eagerly in `new` read options nobody had set yet, and because
120    /// rendering marks each item done, the later pass with the real options no-opped — which
121    /// is how `--indented-blocks-to-code-fences` came to do nothing at all on single-file
122    /// output. The rendered model is derived from this on first use instead, so it always sees
123    /// the options the caller actually asked for.
124    raw: crate::Spec,
125    /// The rendered docs model, derived from [`Self::raw`] on first use by [`Self::spec`].
126    ///
127    /// Every builder clears this, so an option set after a render still takes effect.
128    ///
129    /// A `OnceLock` rather than a `OnceCell` so the renderer stays `Sync` and `RefUnwindSafe`
130    /// for callers that hold one across threads — a `OnceCell` here would take those away.
131    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    /// The rendered docs model, built on first use from the options set by then.
159    pub(crate) fn spec(&self) -> &Spec {
160        self.spec.get_or_init(|| {
161            // By reference: `From<&Spec>` already clones internally, so handing it an owned
162            // spec clones the whole tree twice.
163            let mut spec = Spec::from(&self.raw);
164            spec.render_md(self);
165            spec
166        })
167    }
168
169    /// Apply a builder option and drop any model rendered under the old options.
170    ///
171    /// Every `with_*` goes through this. A builder that set its field directly would leave a
172    /// model rendered without its option behind, which is the bug this type already had once.
173    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    /// The file name the settings page gets in `--multi` mode.
180    ///
181    /// One name, always. `settings.md` was the obvious choice and the wrong one: mise has a
182    /// `settings` command, whose own page lands there, and config is written second — so for
183    /// the very CLI this feature is aimed at, the command's page was silently replaced and the
184    /// index linked both entries to the same file.
185    ///
186    /// Choosing between two names by whether a command is in the way would fix that and
187    /// introduce something worse: the name would move when a command is added or removed
188    /// between runs, leaving the abandoned one behind as a stale page nothing links but the
189    /// docs site still serves. A fixed name cannot do that, and `configuration` is a name CLIs
190    /// give to a *file*, not to a command — the command is called `config`.
191    pub fn config_page(&self) -> &'static str {
192        "configuration.md"
193    }
194
195    /// A visible top-level command whose own page would be written to [`Self::config_page`].
196    ///
197    /// Vanishingly unlikely, and silence is what made the `settings.md` collision hard to see,
198    /// so the one case left is reported rather than guessed at.
199    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    /// Extension for generated page links, including the dot (default `.md`).
226    /// Use `.html` for rendered websites or an empty string for extensionless URLs.
227    /// This does not change the names of generated Markdown files.
228    pub fn with_link_extension(self, extension: impl Into<String>) -> Self {
229        self.with(|r| r.link_extension = extension.into())
230    }
231
232    /// Turn four-space indented blocks in help text into fenced code blocks.
233    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    /// The former name of [`Self::with_indented_blocks_to_code_fences`]. Prefer that one.
238    ///
239    /// A misnomer from the start: no `<pre>` tag was ever involved. Kept, rather than renamed
240    /// out from under callers, so an existing docs build kept compiling — and left in the
241    /// public API rather than `#[doc(hidden)]`, because hiding it is itself the kind of
242    /// removal that costs downstreams a major version.
243    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    /// Select a built-in Markdown presentation.
248    pub fn with_theme(self, theme: MarkdownTheme) -> Self {
249        self.with(|r| r.theme = theme)
250    }
251
252    /// Replace one built-in Markdown template.
253    ///
254    /// Templates use [Tera](https://keats.github.io/tera/). A replacement may include any of the
255    /// templates it did not replace; for example, a custom [`MarkdownTemplate::Spec`] can still
256    /// contain `{% include "cmd_template.md.tera" %}`. Replacing the same member more than once
257    /// uses the last value. Syntax and include errors are returned by the render method.
258    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    /// Render with values that belong only to this page.
289    ///
290    /// A page used to clone the whole renderer — including the complete command tree — merely
291    /// to insert one local into its stored context. Multi-page output paid that deep clone once
292    /// per command. The context already has to be materialized for Tera, so enrich that directly.
293    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    /// Rewrite four-space indented blocks as fenced ones, when the caller asked for it.
336    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                        // A same-line list marker or blockquote prefix belongs to its
357                        // container. Keep these blocks indented rather than replacing
358                        // the container along with the code block.
359                        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 &lt;\n```\ninside <\n```  \nafter &lt;";
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 &lt;";
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 &lt;\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}