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/// An ANSI escape sequence: what `color_print::cstr!` leaves in help text.
54static SGR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
55/// A backtick span, or a bare `<` outside one.
56static CODE_SPAN_OR_LT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(`[^`]*`)|(<)").unwrap());
57
58fn escape_md_with_indent(value: &str, html_encode: bool, indent: bool) -> String {
59    let mut in_fenced_code_block = false;
60    // Help text is allowed to contain terminal styling. clap-era applications commonly build
61    // their examples with `color_print::cstr!`, which embeds SGR sequences even when color is
62    // disabled at runtime. Terminal styling has no meaning in generated Markdown, and leaving
63    // it here publishes literal escape bytes in docs and downstream static sites.
64    let value = SGR.replace_all(value, "");
65
66    value
67        .lines()
68        .enumerate()
69        .map(|(index, line)| {
70            let line = if !html_encode {
71                line.to_string()
72            } else {
73                // Indented code is handled before fence state. This is safe because
74                // `replace_code_fences` always emits closing fences at column zero.
75                if line.starts_with("    ") {
76                    line.to_string()
77                } else if in_fenced_code_block {
78                    if line.trim_end() == "```" {
79                        in_fenced_code_block = false;
80                    }
81                    line.to_string()
82                // Support the conventional fence shape emitted by `replace_code_fences`
83                // without attempting to parse the full Markdown specification.
84                } else if line
85                    .strip_prefix("```")
86                    .is_some_and(|suffix| !suffix.starts_with('`'))
87                {
88                    in_fenced_code_block = true;
89                    line.to_string()
90                } else {
91                    // replace '<' with '&lt;' but not inside code blocks
92                    CODE_SPAN_OR_LT
93                        .replace_all(line, |caps: &regex::Captures| {
94                            if caps.get(1).is_some() {
95                                caps.get(1).unwrap().as_str().to_string()
96                            } else {
97                                "&lt;".to_string()
98                            }
99                        })
100                        .to_string()
101                }
102            };
103            if indent && index > 0 && !line.is_empty() {
104                format!("  {line}")
105            } else {
106                line
107            }
108        })
109        .join("\n")
110}
111
112fn escape_md(value: &str, html_encode: bool) -> String {
113    escape_md_with_indent(value, html_encode, false)
114}
115
116#[derive(Debug, Clone)]
117pub struct MarkdownRenderer {
118    pub(crate) spec: Spec,
119    /// The config block as the spec wrote it, before any rendering.
120    ///
121    /// `new` renders the whole docs model eagerly, which happens *before* the builder methods
122    /// that set `replace_pre_with_code_fences` and friends — and rendering marks each item
123    /// done, so a later pass no-ops. `render_cmd` avoids this by re-deriving from the raw
124    /// command its caller hands it; config has no such argument, so the raw form is kept here
125    /// instead. Without it, `--replace-pre-with-code-fences` silently did nothing to a
126    /// setting's long help.
127    pub(crate) raw_config: crate::spec::config::SpecConfig,
128    pub(crate) header_level: usize,
129    pub(crate) multi: bool,
130    url_prefix: Option<String>,
131    html_encode: bool,
132    replace_pre_with_code_fences: bool,
133    theme: MarkdownTheme,
134    templates: Vec<(MarkdownTemplate, String)>,
135}
136
137impl MarkdownRenderer {
138    pub fn new(spec: crate::Spec) -> Self {
139        let mut renderer = Self {
140            raw_config: spec.config.clone(),
141            spec: spec.into(),
142            header_level: 1,
143            multi: false,
144            url_prefix: None,
145            html_encode: true,
146            replace_pre_with_code_fences: false,
147            theme: MarkdownTheme::default(),
148            templates: Vec::new(),
149        };
150        let mut spec = renderer.spec.clone();
151        spec.render_md(&renderer);
152        renderer.spec = spec;
153        renderer
154    }
155
156    /// The file name the settings page gets in `--multi` mode.
157    ///
158    /// One name, always. `settings.md` was the obvious choice and the wrong one: mise has a
159    /// `settings` command, whose own page lands there, and config is written second — so for
160    /// the very CLI this feature is aimed at, the command's page was silently replaced and the
161    /// index linked both entries to the same file.
162    ///
163    /// Choosing between two names by whether a command is in the way would fix that and
164    /// introduce something worse: the name would move when a command is added or removed
165    /// between runs, leaving the abandoned one behind as a stale page nothing links but the
166    /// docs site still serves. A fixed name cannot do that, and `configuration` is a name CLIs
167    /// give to a *file*, not to a command — the command is called `config`.
168    pub fn config_page(&self) -> &'static str {
169        "configuration.md"
170    }
171
172    /// A visible top-level command whose own page would be written to [`Self::config_page`].
173    ///
174    /// Vanishingly unlikely, and silence is what made the `settings.md` collision hard to see,
175    /// so the one case left is reported rather than guessed at.
176    pub fn config_page_collision(&self) -> Option<&str> {
177        let stem = self.config_page().trim_end_matches(".md");
178        self.spec
179            .cmd
180            .subcommands
181            .values()
182            .find(|cmd| !cmd.hide && cmd.full_cmd == [stem])
183            .map(|cmd| cmd.name.as_str())
184    }
185
186    pub fn with_header_level(mut self, header_level: usize) -> Self {
187        self.header_level = header_level;
188        self
189    }
190
191    pub fn with_multi(mut self, index: bool) -> Self {
192        self.multi = index;
193        self
194    }
195
196    pub fn with_url_prefix<S: Into<String>>(mut self, url_prefix: S) -> Self {
197        self.url_prefix = Some(url_prefix.into());
198        self
199    }
200
201    pub fn with_html_encode(mut self, html_encode: bool) -> Self {
202        self.html_encode = html_encode;
203        self
204    }
205
206    pub fn with_replace_pre_with_code_fences(mut self, replace_pre_with_code_fences: bool) -> Self {
207        self.replace_pre_with_code_fences = replace_pre_with_code_fences;
208        self
209    }
210
211    /// Select a built-in Markdown presentation.
212    pub fn with_theme(mut self, theme: MarkdownTheme) -> Self {
213        self.theme = theme;
214        self
215    }
216
217    /// Replace one built-in Markdown template.
218    ///
219    /// Templates use [Tera](https://keats.github.io/tera/). A replacement may include any of the
220    /// templates it did not replace; for example, a custom [`MarkdownTemplate::Spec`] can still
221    /// contain `{% include "cmd_template.md.tera" %}`. Replacing the same member more than once
222    /// uses the last value. Syntax and include errors are returned by the render method.
223    pub fn with_template(mut self, template: MarkdownTemplate, source: impl Into<String>) -> Self {
224        let source = source.into();
225        if let Some((_, current)) = self
226            .templates
227            .iter_mut()
228            .find(|(current, _)| *current == template)
229        {
230            *current = source;
231        } else {
232            self.templates.push((template, source));
233        }
234        self
235    }
236
237    fn tera_ctx(&self) -> tera::Context {
238        let mut ctx = tera::Context::new();
239        ctx.insert("spec", &self.spec);
240        ctx.insert("header_level", &self.header_level);
241        ctx.insert("multi", &self.multi);
242        ctx.insert("url_prefix", &self.url_prefix);
243        ctx.insert("html_encode", &self.html_encode);
244        ctx
245    }
246
247    /// Render with values that belong only to this page.
248    ///
249    /// A page used to clone the whole renderer — including the complete command tree — merely
250    /// to insert one local into its stored context. Multi-page output paid that deep clone once
251    /// per command. The context already has to be materialized for Tera, so enrich that directly.
252    pub(crate) fn render_with(
253        &self,
254        template_name: &str,
255        enrich: impl FnOnce(&mut tera::Context),
256    ) -> Result<String, UsageErr> {
257        let mut tera = match self.theme {
258            MarkdownTheme::Compact => TERA.clone(),
259            MarkdownTheme::Detailed => crate::docs::markdown::tera::DETAILED_TERA.clone(),
260        };
261
262        for (template, source) in &self.templates {
263            tera.add_raw_template(template.name(), source)?;
264        }
265
266        let html_encode = self.html_encode;
267        tera.register_filter(
268            "escape_md",
269            move |value: &tera::Value,
270                  _: tera::Kwargs,
271                  _: &tera::State|
272                  -> tera::TeraResult<String> {
273                let value = value.as_str().unwrap();
274                let value = escape_md(value, html_encode);
275                Ok(value)
276            },
277        );
278        tera.register_filter(
279            "escape_md_indented",
280            move |value: &tera::Value,
281                  _: tera::Kwargs,
282                  _: &tera::State|
283                  -> tera::TeraResult<String> {
284                let value = value.as_str().unwrap();
285                Ok(escape_md_with_indent(value, html_encode, true))
286            },
287        );
288
289        let mut ctx = self.tera_ctx();
290        enrich(&mut ctx);
291        Ok(tera.render(template_name, &ctx)?)
292    }
293
294    pub(crate) fn replace_code_fences(&self, md: String) -> String {
295        if !self.replace_pre_with_code_fences {
296            return md;
297        }
298        // TODO: handle fences inside of <pre> or <code>
299        let mut in_code_block = false;
300        let mut new_md = String::new();
301        for line in md.lines() {
302            if let Some(line) = line.strip_prefix("    ") {
303                if in_code_block {
304                    new_md.push_str(&format!("{line}\n"));
305                } else {
306                    new_md.push_str(&format!("```\n{line}\n"));
307                    in_code_block = true;
308                }
309            } else {
310                if in_code_block {
311                    new_md.push_str("```\n");
312                    in_code_block = false;
313                }
314                new_md.push_str(&format!("{line}\n"));
315            }
316        }
317        if in_code_block {
318            new_md.push_str("```\n");
319        }
320        new_md.replace("```\n\n```\n", "\n")
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::{escape_md, MarkdownRenderer, MarkdownTemplate};
327    use pretty_assertions::assert_eq;
328
329    #[test]
330    fn escapes_html_around_fenced_code_blocks() {
331        let input = "before <\n```\ninside <\n```  \nafter <";
332        let expected = "before &lt;\n```\ninside <\n```  \nafter &lt;";
333
334        assert_eq!(escape_md(input, true), expected);
335    }
336
337    #[test]
338    fn supports_fence_info_strings() {
339        let input = "```bash\necho <value>\n```\nafter <";
340        let expected = "```bash\necho <value>\n```\nafter &lt;";
341
342        assert_eq!(escape_md(input, true), expected);
343    }
344
345    #[test]
346    fn leaves_unclosed_fences_unescaped() {
347        let input = "```\necho <value>";
348
349        assert_eq!(escape_md(input, true), input);
350    }
351
352    #[test]
353    fn ignores_indented_and_longer_fences() {
354        let input = "    ```\nindented <\n````\nlonger <";
355        let expected = "    ```\nindented &lt;\n````\nlonger &lt;";
356
357        assert_eq!(escape_md(input, true), expected);
358    }
359
360    #[test]
361    fn leaves_markdown_unchanged_when_html_encoding_is_disabled() {
362        let input = "before <\n```\ninside <\n```\nafter <";
363
364        assert_eq!(escape_md(input, false), input);
365    }
366
367    #[test]
368    fn strips_terminal_styling_from_generated_markdown() {
369        let input =
370            "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n    \u{1b}[1mmise run\u{1b}[22m";
371        let expected = "Examples:\n\n    mise run";
372
373        assert_eq!(escape_md(input, true), expected);
374        assert_eq!(escape_md(input, false), expected);
375    }
376
377    #[test]
378    fn one_template_can_be_replaced_without_copying_its_includes() {
379        let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
380            .parse()
381            .unwrap();
382        let page = MarkdownRenderer::new(spec)
383            .with_template(
384                MarkdownTemplate::Spec,
385                "# Custom {{ spec.bin }}\n{% set cmd = spec.cmd %}\n{% include \"cmd_template.md.tera\" %}",
386            )
387            .render_spec()
388            .unwrap();
389
390        assert!(page.starts_with("# Custom ex\n"), "{page}");
391        assert!(page.contains("- **`--force`**"), "{page}");
392    }
393
394    #[test]
395    fn the_last_replacement_of_a_template_wins() {
396        let spec = "bin \"ex\"\n".parse().unwrap();
397        let page = MarkdownRenderer::new(spec)
398            .with_template(MarkdownTemplate::Spec, "{{")
399            .with_template(MarkdownTemplate::Spec, "second")
400            .render_spec()
401            .unwrap();
402
403        assert_eq!(page, "second");
404    }
405
406    #[test]
407    fn a_bad_custom_template_is_a_render_error() {
408        let spec = "bin \"ex\"\n".parse().unwrap();
409        let err = MarkdownRenderer::new(spec)
410            .with_template(MarkdownTemplate::Spec, "{{")
411            .render_spec()
412            .unwrap_err();
413
414        assert!(err.to_string().contains("template"), "{err}");
415    }
416
417    #[test]
418    fn the_detailed_theme_keeps_addressable_entry_headings() {
419        let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
420            .parse()
421            .unwrap();
422        let page = MarkdownRenderer::new(spec)
423            .with_theme(super::MarkdownTheme::Detailed)
424            .render_spec()
425            .unwrap();
426
427        assert!(page.contains("### `--force`"), "{page}");
428    }
429
430    #[test]
431    fn entry_template_overrides_apply_to_the_compact_theme() {
432        let spec = "bin \"ex\"\narg \"<file>\"\nflag \"--force\"\n"
433            .parse()
434            .unwrap();
435        let page = MarkdownRenderer::new(spec)
436            .with_template(MarkdownTemplate::Argument, "argument: {{ arg.usage }}")
437            .with_template(MarkdownTemplate::Flag, "flag: {{ flag.usage }}")
438            .render_spec()
439            .unwrap();
440
441        assert!(page.contains("argument: <file>"), "{page}");
442        assert!(page.contains("flag: --force"), "{page}");
443    }
444}