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