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                // `fence_indented_blocks` 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 `fence_indented_blocks`
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    /// 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    html_encode: bool,
136    indented_blocks_to_code_fences: bool,
137    theme: MarkdownTheme,
138    templates: Vec<(MarkdownTemplate, String)>,
139}
140
141impl MarkdownRenderer {
142    pub fn new(spec: crate::Spec) -> Self {
143        Self {
144            raw: spec,
145            spec: std::sync::OnceLock::new(),
146            header_level: 1,
147            multi: false,
148            url_prefix: None,
149            html_encode: true,
150            indented_blocks_to_code_fences: false,
151            theme: MarkdownTheme::default(),
152            templates: Vec::new(),
153        }
154    }
155
156    /// The rendered docs model, built on first use from the options set by then.
157    pub(crate) fn spec(&self) -> &Spec {
158        self.spec.get_or_init(|| {
159            // By reference: `From<&Spec>` already clones internally, so handing it an owned
160            // spec clones the whole tree twice.
161            let mut spec = Spec::from(&self.raw);
162            spec.render_md(self);
163            spec
164        })
165    }
166
167    /// Apply a builder option and drop any model rendered under the old options.
168    ///
169    /// Every `with_*` goes through this. A builder that set its field directly would leave a
170    /// model rendered without its option behind, which is the bug this type already had once.
171    fn with(mut self, set: impl FnOnce(&mut Self)) -> Self {
172        set(&mut self);
173        self.spec = std::sync::OnceLock::new();
174        self
175    }
176
177    /// The file name the settings page gets in `--multi` mode.
178    ///
179    /// One name, always. `settings.md` was the obvious choice and the wrong one: mise has a
180    /// `settings` command, whose own page lands there, and config is written second — so for
181    /// the very CLI this feature is aimed at, the command's page was silently replaced and the
182    /// index linked both entries to the same file.
183    ///
184    /// Choosing between two names by whether a command is in the way would fix that and
185    /// introduce something worse: the name would move when a command is added or removed
186    /// between runs, leaving the abandoned one behind as a stale page nothing links but the
187    /// docs site still serves. A fixed name cannot do that, and `configuration` is a name CLIs
188    /// give to a *file*, not to a command — the command is called `config`.
189    pub fn config_page(&self) -> &'static str {
190        "configuration.md"
191    }
192
193    /// A visible top-level command whose own page would be written to [`Self::config_page`].
194    ///
195    /// Vanishingly unlikely, and silence is what made the `settings.md` collision hard to see,
196    /// so the one case left is reported rather than guessed at.
197    pub fn config_page_collision(&self) -> Option<&str> {
198        let stem = self.config_page().trim_end_matches(".md");
199        self.spec()
200            .cmd
201            .subcommands
202            .values()
203            .find(|cmd| !cmd.hide && cmd.full_cmd == [stem])
204            .map(|cmd| cmd.name.as_str())
205    }
206
207    pub fn with_header_level(self, header_level: usize) -> Self {
208        self.with(|r| r.header_level = header_level)
209    }
210
211    pub fn with_multi(self, index: bool) -> Self {
212        self.with(|r| r.multi = index)
213    }
214
215    pub fn with_url_prefix<S: Into<String>>(self, url_prefix: S) -> Self {
216        self.with(|r| r.url_prefix = Some(url_prefix.into()))
217    }
218
219    pub fn with_html_encode(self, html_encode: bool) -> Self {
220        self.with(|r| r.html_encode = html_encode)
221    }
222
223    /// Turn four-space indented blocks in help text into fenced code blocks.
224    pub fn with_indented_blocks_to_code_fences(self, indented_blocks_to_code_fences: bool) -> Self {
225        self.with(|r| r.indented_blocks_to_code_fences = indented_blocks_to_code_fences)
226    }
227
228    /// The former name of [`Self::with_indented_blocks_to_code_fences`]. Prefer that one.
229    ///
230    /// A misnomer from the start: no `<pre>` tag was ever involved. Kept, rather than renamed
231    /// out from under callers, so an existing docs build kept compiling — and left in the
232    /// public API rather than `#[doc(hidden)]`, because hiding it is itself the kind of
233    /// removal that costs downstreams a major version.
234    pub fn with_replace_pre_with_code_fences(self, indented_blocks_to_code_fences: bool) -> Self {
235        self.with_indented_blocks_to_code_fences(indented_blocks_to_code_fences)
236    }
237
238    /// Select a built-in Markdown presentation.
239    pub fn with_theme(self, theme: MarkdownTheme) -> Self {
240        self.with(|r| r.theme = theme)
241    }
242
243    /// Replace one built-in Markdown template.
244    ///
245    /// Templates use [Tera](https://keats.github.io/tera/). A replacement may include any of the
246    /// templates it did not replace; for example, a custom [`MarkdownTemplate::Spec`] can still
247    /// contain `{% include "cmd_template.md.tera" %}`. Replacing the same member more than once
248    /// uses the last value. Syntax and include errors are returned by the render method.
249    pub fn with_template(self, template: MarkdownTemplate, source: impl Into<String>) -> Self {
250        let source = source.into();
251        self.with(|r| {
252            if let Some((_, current)) = r
253                .templates
254                .iter_mut()
255                .find(|(current, _)| *current == template)
256            {
257                *current = source;
258            } else {
259                r.templates.push((template, source));
260            }
261        })
262    }
263
264    fn tera_ctx(&self) -> tera::Context {
265        let mut ctx = tera::Context::new();
266        ctx.insert("spec", self.spec());
267        ctx.insert("header_level", &self.header_level);
268        ctx.insert("multi", &self.multi);
269        ctx.insert("url_prefix", &self.url_prefix);
270        ctx.insert("html_encode", &self.html_encode);
271        ctx
272    }
273
274    /// Render with values that belong only to this page.
275    ///
276    /// A page used to clone the whole renderer — including the complete command tree — merely
277    /// to insert one local into its stored context. Multi-page output paid that deep clone once
278    /// per command. The context already has to be materialized for Tera, so enrich that directly.
279    pub(crate) fn render_with(
280        &self,
281        template_name: &str,
282        enrich: impl FnOnce(&mut tera::Context),
283    ) -> Result<String, UsageErr> {
284        let mut tera = match self.theme {
285            MarkdownTheme::Compact => TERA.clone(),
286            MarkdownTheme::Detailed => crate::docs::markdown::tera::DETAILED_TERA.clone(),
287        };
288
289        for (template, source) in &self.templates {
290            tera.add_raw_template(template.name(), source)?;
291        }
292
293        let html_encode = self.html_encode;
294        tera.register_filter(
295            "escape_md",
296            move |value: &tera::Value,
297                  _: tera::Kwargs,
298                  _: &tera::State|
299                  -> tera::TeraResult<String> {
300                let value = value.as_str().unwrap();
301                let value = escape_md(value, html_encode);
302                Ok(value)
303            },
304        );
305        tera.register_filter(
306            "escape_md_indented",
307            move |value: &tera::Value,
308                  _: tera::Kwargs,
309                  _: &tera::State|
310                  -> tera::TeraResult<String> {
311                let value = value.as_str().unwrap();
312                Ok(escape_md_with_indent(value, html_encode, true))
313            },
314        );
315
316        let mut ctx = self.tera_ctx();
317        enrich(&mut ctx);
318        Ok(tera.render(template_name, &ctx)?)
319    }
320
321    /// Rewrite four-space indented blocks as fenced ones, when the caller asked for it.
322    pub(crate) fn fence_indented_blocks(&self, md: String) -> String {
323        if !self.indented_blocks_to_code_fences {
324            return md;
325        }
326        // TODO: handle fences inside of <pre> or <code>
327        let mut in_code_block = false;
328        let mut new_md = String::new();
329        for line in md.lines() {
330            if let Some(line) = line.strip_prefix("    ") {
331                if in_code_block {
332                    new_md.push_str(&format!("{line}\n"));
333                } else {
334                    new_md.push_str(&format!("```\n{line}\n"));
335                    in_code_block = true;
336                }
337            } else {
338                if in_code_block {
339                    new_md.push_str("```\n");
340                    in_code_block = false;
341                }
342                new_md.push_str(&format!("{line}\n"));
343            }
344        }
345        if in_code_block {
346            new_md.push_str("```\n");
347        }
348        new_md.replace("```\n\n```\n", "\n")
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::{escape_md, MarkdownRenderer, MarkdownTemplate};
355    use pretty_assertions::assert_eq;
356
357    #[test]
358    fn escapes_html_around_fenced_code_blocks() {
359        let input = "before <\n```\ninside <\n```  \nafter <";
360        let expected = "before &lt;\n```\ninside <\n```  \nafter &lt;";
361
362        assert_eq!(escape_md(input, true), expected);
363    }
364
365    #[test]
366    fn supports_fence_info_strings() {
367        let input = "```bash\necho <value>\n```\nafter <";
368        let expected = "```bash\necho <value>\n```\nafter &lt;";
369
370        assert_eq!(escape_md(input, true), expected);
371    }
372
373    #[test]
374    fn leaves_unclosed_fences_unescaped() {
375        let input = "```\necho <value>";
376
377        assert_eq!(escape_md(input, true), input);
378    }
379
380    #[test]
381    fn ignores_indented_and_longer_fences() {
382        let input = "    ```\nindented <\n````\nlonger <";
383        let expected = "    ```\nindented &lt;\n````\nlonger &lt;";
384
385        assert_eq!(escape_md(input, true), expected);
386    }
387
388    #[test]
389    fn leaves_markdown_unchanged_when_html_encoding_is_disabled() {
390        let input = "before <\n```\ninside <\n```\nafter <";
391
392        assert_eq!(escape_md(input, false), input);
393    }
394
395    #[test]
396    fn strips_terminal_styling_from_generated_markdown() {
397        let input =
398            "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n    \u{1b}[1mmise run\u{1b}[22m";
399        let expected = "Examples:\n\n    mise run";
400
401        assert_eq!(escape_md(input, true), expected);
402        assert_eq!(escape_md(input, false), expected);
403    }
404
405    #[test]
406    fn one_template_can_be_replaced_without_copying_its_includes() {
407        let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
408            .parse()
409            .unwrap();
410        let page = MarkdownRenderer::new(spec)
411            .with_template(
412                MarkdownTemplate::Spec,
413                "# Custom {{ spec.bin }}\n{% set cmd = spec.cmd %}\n{% include \"cmd_template.md.tera\" %}",
414            )
415            .render_spec()
416            .unwrap();
417
418        assert!(page.starts_with("# Custom ex\n"), "{page}");
419        assert!(page.contains("- **`--force`**"), "{page}");
420    }
421
422    #[test]
423    fn the_last_replacement_of_a_template_wins() {
424        let spec = "bin \"ex\"\n".parse().unwrap();
425        let page = MarkdownRenderer::new(spec)
426            .with_template(MarkdownTemplate::Spec, "{{")
427            .with_template(MarkdownTemplate::Spec, "second")
428            .render_spec()
429            .unwrap();
430
431        assert_eq!(page, "second");
432    }
433
434    #[test]
435    fn a_bad_custom_template_is_a_render_error() {
436        let spec = "bin \"ex\"\n".parse().unwrap();
437        let err = MarkdownRenderer::new(spec)
438            .with_template(MarkdownTemplate::Spec, "{{")
439            .render_spec()
440            .unwrap_err();
441
442        assert!(err.to_string().contains("template"), "{err}");
443    }
444
445    #[test]
446    fn the_detailed_theme_keeps_addressable_entry_headings() {
447        let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
448            .parse()
449            .unwrap();
450        let page = MarkdownRenderer::new(spec)
451            .with_theme(super::MarkdownTheme::Detailed)
452            .render_spec()
453            .unwrap();
454
455        assert!(page.contains("### `--force`"), "{page}");
456    }
457
458    #[test]
459    fn entry_template_overrides_apply_to_the_compact_theme() {
460        let spec = "bin \"ex\"\narg \"<file>\"\nflag \"--force\"\n"
461            .parse()
462            .unwrap();
463        let page = MarkdownRenderer::new(spec)
464            .with_template(MarkdownTemplate::Argument, "argument: {{ arg.usage }}")
465            .with_template(MarkdownTemplate::Flag, "flag: {{ flag.usage }}")
466            .render_spec()
467            .unwrap();
468
469        assert!(page.contains("argument: <file>"), "{page}");
470        assert!(page.contains("flag: --force"), "{page}");
471    }
472}